├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── go.mod ├── go.sum ├── testdata ├── blog │ ├── .test │ │ ├── about.html │ │ ├── index.html │ │ ├── posts │ │ │ ├── hello.html │ │ │ └── update.html │ │ └── styles.css │ ├── .zs │ │ └── layout.amber │ ├── about.md │ ├── index.amber │ ├── posts │ │ ├── hello.md │ │ └── update.md │ └── styles.gcss ├── empty │ └── .empty ├── page │ ├── .test │ │ └── index.html │ └── index.html └── sugar │ ├── .test │ ├── index.html │ └── styles.css │ ├── index.amber │ └── styles.gcss ├── zs.go ├── zs_build_test.go └── zs_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | zs 2 | .pub 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - 1.2 5 | - 1.3 6 | - 1.4 7 | - release 8 | - tip 9 | 10 | before_install: 11 | - pip install --user codecov 12 | after_success: 13 | - codecov 14 | 15 | script: 16 | - go test -coverprofile=coverage.txt -covermode=atomic 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 zserge 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | zs 2 | == 3 | 4 | [![Build Status](https://travis-ci.org/zserge/zs.svg?branch=master)](https://travis-ci.org/zserge/zs) 5 | 6 | zs is an extremely minimal static site generator written in Go. 7 | 8 | It's inspired by `zas` generator, but is even more minimal. 9 | 10 | The name stands for 'zen static' as well as it's my initials. 11 | 12 | ## Features 13 | 14 | * Zero configuration (no configuration file needed) 15 | * Cross-platform 16 | * Highly extensible 17 | * Works well for blogs and generic static websites (landing pages etc) 18 | * Easy to learn 19 | * Fast 20 | 21 | ## Installation 22 | 23 | Download the binaries from Github or build it manually: 24 | 25 | $ go get github.com/zserge/zs 26 | 27 | ## Ideology 28 | 29 | Keep your texts in markdown, [amber] or HTML format right in the main directory 30 | of your blog/site. 31 | 32 | Keep all service files (extensions, layout pages, deployment scripts etc) 33 | in the `.zs` subdirectory. 34 | 35 | Define variables in the header of the content files using [YAML]: 36 | 37 | title: My web site 38 | keywords: best website, hello, world 39 | --- 40 | 41 | Markdown text goes after a header *separator* 42 | 43 | Use placeholders for variables and plugins in your markdown or html 44 | files, e.g. `{{ title }}` or `{{ command arg1 arg2 }}. 45 | 46 | Write extensions in any language you like and put them into the `.zs` 47 | subdiretory. 48 | 49 | Everything the extensions prints to stdout becomes the value of the 50 | placeholder. 51 | 52 | Every variable from the content header will be passed via environment variables like `title` becomes `$ZS_TITLE` and so on. There are some special variables: 53 | 54 | * `$ZS` - a path to the `zs` executable 55 | * `$ZS_OUTDIR` - a path to the directory with generated files 56 | * `$ZS_FILE` - a path to the currently processed markdown file 57 | * `$ZS_URL` - a URL for the currently generated page 58 | 59 | ## Example of RSS generation 60 | 61 | Extensions can be written in any language you know (Bash, Python, Lua, JavaScript, Go, even Assembler). Here's an example of how to scan all markdown blog posts and create RSS items: 62 | 63 | ``` bash 64 | for f in ./blog/*.md ; do 65 | d=$($ZS var $f date) 66 | if [ ! -z $d ] ; then 67 | timestamp=`date --date "$d" +%s` 68 | url=`$ZS var $f url` 69 | title=`$ZS var $f title | tr A-Z a-z` 70 | descr=`$ZS var $f description` 71 | echo $timestamp \ 72 | "" \ 73 | "$title" \ 74 | "http://zserge.com/$url" \ 75 | "$descr" \ 76 | "$(date --date @$timestamp -R)" \ 77 | "http://zserge.com/$url" \ 78 | "" 79 | fi 80 | done | sort -r -n | cut -d' ' -f2- 81 | ``` 82 | 83 | ## Hooks 84 | 85 | There are two special plugin names that are executed every time the build 86 | happens - `prehook` and `posthook`. You can define some global actions here like 87 | content generation, or additional commands, like LESS to CSS conversion: 88 | 89 | # .zs/post 90 | 91 | #!/bin/sh 92 | lessc < $ZS_OUTDIR/styles.less > $ZS_OUTDIR/styles.css 93 | rm -f $ZS_OUTDIR/styles.css 94 | 95 | ## Syntax sugar 96 | 97 | By default, `zs` converts each `.amber` file into `.html`, so you can use lightweight Jade-like syntax instead of bloated HTML. 98 | 99 | Also, `zs` converts `.gcss` into `.css`, so you don't really need LESS or SASS. More about GCSS can be found [here][gcss]. 100 | 101 | ## Command line usage 102 | 103 | `zs build` re-builds your site. 104 | 105 | `zs build ` re-builds one file and prints resulting content to stdout. 106 | 107 | `zs watch` rebuilds your site every time you modify any file. 108 | 109 | `zs var [var1 var2...]` prints a list of variables defined in the 110 | header of a given markdown file, or the values of certain variables (even if 111 | it's an empty string). 112 | 113 | ## License 114 | 115 | The software is distributed under the MIT license. 116 | 117 | [amber]: https://github.com/eknkc/amber/ 118 | [YAML]: https://github.com/go-yaml/yaml 119 | [gcss]: https://github.com/yosssi/gcss 120 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/zserge/zs 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385 7 | gopkg.in/russross/blackfriday.v2 v2.0.0+incompatible 8 | github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect 9 | github.com/yosssi/gcss v0.1.0 10 | gopkg.in/yaml.v2 v2.2.2 11 | ) 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385 h1:clC1lXBpe2kTj2VHdaIu9ajZQe4kcEY9j0NsnDDBZ3o= 2 | github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM= 3 | github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= 4 | github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= 5 | github.com/yosssi/gcss v0.1.0 h1:jRuino7qq7kqntBIhT+0xSUI5/sBgCA/zCQ1Tuzd6Gg= 6 | github.com/yosssi/gcss v0.1.0/go.mod h1:M3mTPOWZWjVROkXKZ2AiDzOBOXu2MqQeDXF/nKO44sI= 7 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 8 | gopkg.in/russross/blackfriday.v2 v2.0.0+incompatible h1:l1Mna0cVh8WlpyB8uFtc2c+5cdvrI5CDyuwTgIChojI= 9 | gopkg.in/russross/blackfriday.v2 v2.0.0+incompatible/go.mod h1:6sSBNz/GtOm/pJTuh5UmBK2ZHfmnxGbl2NZg1UliSOI= 10 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 11 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 12 | -------------------------------------------------------------------------------- /testdata/blog/.test/about.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | About myself 4 | 5 | 6 |

About myself

7 | 8 |

Hi all. This is a brief description of who I am.

9 | 10 | 11 | -------------------------------------------------------------------------------- /testdata/blog/.test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | My blog 4 | 5 | 6 | 7 |

Here goes list of posts

8 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /testdata/blog/.test/posts/hello.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | First post 4 | 5 | 6 |

First post

7 | 8 |

This is my first post

9 | 10 | 11 | -------------------------------------------------------------------------------- /testdata/blog/.test/posts/update.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Second post 4 | 5 | 6 |

Second post

7 | 8 |

This is my second post

9 | 10 | 11 | -------------------------------------------------------------------------------- /testdata/blog/.test/styles.css: -------------------------------------------------------------------------------- 1 | html{margin:0;padding:0;box-sizing:border-box;}body{font-size:16pt;} -------------------------------------------------------------------------------- /testdata/blog/.zs/layout.amber: -------------------------------------------------------------------------------- 1 | html 2 | head 3 | title #{title} 4 | link[href="styles.css"][rel="stylesheet"][type="text/css"] 5 | body 6 | #{unescaped(content)} 7 | -------------------------------------------------------------------------------- /testdata/blog/about.md: -------------------------------------------------------------------------------- 1 | title: About myself 2 | date: 28-08-2015 3 | --- 4 | 5 | # {{title}} 6 | 7 | Hi all. This is a brief description of who I am. 8 | -------------------------------------------------------------------------------- /testdata/blog/index.amber: -------------------------------------------------------------------------------- 1 | html 2 | head 3 | title My blog 4 | link[href="styles.css"][rel="stylesheet"][type="text/css"] 5 | body 6 | p Here goes list of posts 7 | ul 8 | li 9 | a[href="/posts/hello.html"] First post 10 | li 11 | a[href="/posts/update.html"] Second post 12 | 13 | -------------------------------------------------------------------------------- /testdata/blog/posts/hello.md: -------------------------------------------------------------------------------- 1 | title: First post 2 | date: 28-08-2015 3 | --- 4 | 5 | # {{title}} 6 | 7 | This is my first post 8 | 9 | -------------------------------------------------------------------------------- /testdata/blog/posts/update.md: -------------------------------------------------------------------------------- 1 | title: Second post 2 | date: 29-08-2015 3 | --- 4 | 5 | # {{title}} 6 | 7 | This is my second post 8 | 9 | -------------------------------------------------------------------------------- /testdata/blog/styles.gcss: -------------------------------------------------------------------------------- 1 | html 2 | margin: 0 3 | padding: 0 4 | box-sizing: border-box 5 | 6 | body 7 | font-size: 16pt 8 | -------------------------------------------------------------------------------- /testdata/empty/.empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zserge/zs/4900afa45db4d9254110f2eabcac6cfd606423b6/testdata/empty/.empty -------------------------------------------------------------------------------- /testdata/page/.test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Hello

4 | 5 | 6 | -------------------------------------------------------------------------------- /testdata/page/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

{{ printf Hello }}

4 | 5 | 6 | -------------------------------------------------------------------------------- /testdata/sugar/.test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Hello world

4 | 5 | 6 | -------------------------------------------------------------------------------- /testdata/sugar/.test/styles.css: -------------------------------------------------------------------------------- 1 | body{font:100% Helvetica, sans-serif;color:blue;} -------------------------------------------------------------------------------- /testdata/sugar/index.amber: -------------------------------------------------------------------------------- 1 | html 2 | body 3 | p Hello world 4 | -------------------------------------------------------------------------------- /testdata/sugar/styles.gcss: -------------------------------------------------------------------------------- 1 | $base-font: Helvetica, sans-serif 2 | $main-color: blue 3 | 4 | body 5 | font: 100% $base-font 6 | color: $main-color 7 | -------------------------------------------------------------------------------- /zs.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "log" 9 | "os" 10 | "os/exec" 11 | "path/filepath" 12 | "strings" 13 | "text/template" 14 | "time" 15 | 16 | "github.com/eknkc/amber" 17 | "github.com/yosssi/gcss" 18 | "gopkg.in/russross/blackfriday.v2" 19 | "gopkg.in/yaml.v2" 20 | ) 21 | 22 | const ( 23 | ZSDIR = ".zs" 24 | PUBDIR = ".pub" 25 | ) 26 | 27 | type Vars map[string]string 28 | 29 | // renameExt renames extension (if any) from oldext to newext 30 | // If oldext is an empty string - extension is extracted automatically. 31 | // If path has no extension - new extension is appended 32 | func renameExt(path, oldext, newext string) string { 33 | if oldext == "" { 34 | oldext = filepath.Ext(path) 35 | } 36 | if oldext == "" || strings.HasSuffix(path, oldext) { 37 | return strings.TrimSuffix(path, oldext) + newext 38 | } else { 39 | return path 40 | } 41 | } 42 | 43 | // globals returns list of global OS environment variables that start 44 | // with ZS_ prefix as Vars, so the values can be used inside templates 45 | func globals() Vars { 46 | vars := Vars{} 47 | for _, e := range os.Environ() { 48 | pair := strings.Split(e, "=") 49 | if strings.HasPrefix(pair[0], "ZS_") { 50 | vars[strings.ToLower(pair[0][3:])] = pair[1] 51 | } 52 | } 53 | return vars 54 | } 55 | 56 | // run executes a command or a script. Vars define the command environment, 57 | // each zs var is converted into OS environemnt variable with ZS_ prefix 58 | // prepended. Additional variable $ZS contains path to the zs binary. Command 59 | // stderr is printed to zs stderr, command output is returned as a string. 60 | func run(vars Vars, cmd string, args ...string) (string, error) { 61 | // First check if partial exists (.amber or .html) 62 | if b, err := ioutil.ReadFile(filepath.Join(ZSDIR, cmd+".amber")); err == nil { 63 | return string(b), nil 64 | } 65 | if b, err := ioutil.ReadFile(filepath.Join(ZSDIR, cmd+".html")); err == nil { 66 | return string(b), nil 67 | } 68 | 69 | var errbuf, outbuf bytes.Buffer 70 | c := exec.Command(cmd, args...) 71 | env := []string{"ZS=" + os.Args[0], "ZS_OUTDIR=" + PUBDIR} 72 | env = append(env, os.Environ()...) 73 | for k, v := range vars { 74 | env = append(env, "ZS_"+strings.ToUpper(k)+"="+v) 75 | } 76 | c.Env = env 77 | c.Stdout = &outbuf 78 | c.Stderr = &errbuf 79 | 80 | err := c.Run() 81 | 82 | if errbuf.Len() > 0 { 83 | log.Println("ERROR:", errbuf.String()) 84 | } 85 | if err != nil { 86 | return "", err 87 | } 88 | return string(outbuf.Bytes()), nil 89 | } 90 | 91 | // getVars returns list of variables defined in a text file and actual file 92 | // content following the variables declaration. Header is separated from 93 | // content by an empty line. Header can be either YAML or JSON. 94 | // If no empty newline is found - file is treated as content-only. 95 | func getVars(path string, globals Vars) (Vars, string, error) { 96 | b, err := ioutil.ReadFile(path) 97 | if err != nil { 98 | return nil, "", err 99 | } 100 | s := string(b) 101 | 102 | // Pick some default values for content-dependent variables 103 | v := Vars{} 104 | title := strings.Replace(strings.Replace(path, "_", " ", -1), "-", " ", -1) 105 | v["title"] = strings.ToTitle(title) 106 | v["description"] = "" 107 | v["file"] = path 108 | v["url"] = path[:len(path)-len(filepath.Ext(path))] + ".html" 109 | v["output"] = filepath.Join(PUBDIR, v["url"]) 110 | 111 | // Override default values with globals 112 | for name, value := range globals { 113 | v[name] = value 114 | } 115 | 116 | // Add layout if none is specified 117 | if _, ok := v["layout"]; !ok { 118 | if _, err := os.Stat(filepath.Join(ZSDIR, "layout.amber")); err == nil { 119 | v["layout"] = "layout.amber" 120 | } else { 121 | v["layout"] = "layout.html" 122 | } 123 | } 124 | 125 | delim := "\n---\n" 126 | if sep := strings.Index(s, delim); sep == -1 { 127 | return v, s, nil 128 | } else { 129 | header := s[:sep] 130 | body := s[sep+len(delim):] 131 | 132 | vars := Vars{} 133 | if err := yaml.Unmarshal([]byte(header), &vars); err != nil { 134 | fmt.Println("ERROR: failed to parse header", err) 135 | return nil, "", err 136 | } else { 137 | // Override default values + globals with the ones defines in the file 138 | for key, value := range vars { 139 | v[key] = value 140 | } 141 | } 142 | if strings.HasPrefix(v["url"], "./") { 143 | v["url"] = v["url"][2:] 144 | } 145 | return v, body, nil 146 | } 147 | } 148 | 149 | // Render expanding zs plugins and variables 150 | func render(s string, vars Vars) (string, error) { 151 | delim_open := "{{" 152 | delim_close := "}}" 153 | 154 | out := &bytes.Buffer{} 155 | for { 156 | if from := strings.Index(s, delim_open); from == -1 { 157 | out.WriteString(s) 158 | return out.String(), nil 159 | } else { 160 | if to := strings.Index(s, delim_close); to == -1 { 161 | return "", fmt.Errorf("Close delim not found") 162 | } else { 163 | out.WriteString(s[:from]) 164 | cmd := s[from+len(delim_open) : to] 165 | s = s[to+len(delim_close):] 166 | m := strings.Fields(cmd) 167 | if len(m) == 1 { 168 | if v, ok := vars[m[0]]; ok { 169 | out.WriteString(v) 170 | continue 171 | } 172 | } 173 | if res, err := run(vars, m[0], m[1:]...); err == nil { 174 | out.WriteString(res) 175 | } else { 176 | fmt.Println(err) 177 | } 178 | } 179 | } 180 | } 181 | return s, nil 182 | } 183 | 184 | // Renders markdown with the given layout into html expanding all the macros 185 | func buildMarkdown(path string, w io.Writer, vars Vars) error { 186 | v, body, err := getVars(path, vars) 187 | if err != nil { 188 | return err 189 | } 190 | content, err := render(body, v) 191 | if err != nil { 192 | return err 193 | } 194 | v["content"] = string(blackfriday.Run([]byte(content))) 195 | if w == nil { 196 | out, err := os.Create(filepath.Join(PUBDIR, renameExt(path, "", ".html"))) 197 | if err != nil { 198 | return err 199 | } 200 | defer out.Close() 201 | w = out 202 | } 203 | if strings.HasSuffix(v["layout"], ".amber") { 204 | return buildAmber(filepath.Join(ZSDIR, v["layout"]), w, v) 205 | } else { 206 | return buildHTML(filepath.Join(ZSDIR, v["layout"]), w, v) 207 | } 208 | } 209 | 210 | // Renders text file expanding all variable macros inside it 211 | func buildHTML(path string, w io.Writer, vars Vars) error { 212 | v, body, err := getVars(path, vars) 213 | if err != nil { 214 | return err 215 | } 216 | if body, err = render(body, v); err != nil { 217 | return err 218 | } 219 | tmpl, err := template.New("").Delims("<%", "%>").Parse(body) 220 | if err != nil { 221 | return err 222 | } 223 | if w == nil { 224 | f, err := os.Create(filepath.Join(PUBDIR, path)) 225 | if err != nil { 226 | return err 227 | } 228 | defer f.Close() 229 | w = f 230 | } 231 | return tmpl.Execute(w, vars) 232 | } 233 | 234 | // Renders .amber file into .html 235 | func buildAmber(path string, w io.Writer, vars Vars) error { 236 | v, body, err := getVars(path, vars) 237 | if err != nil { 238 | return err 239 | } 240 | a := amber.New() 241 | if err := a.Parse(body); err != nil { 242 | fmt.Println(body) 243 | return err 244 | } 245 | 246 | t, err := a.Compile() 247 | if err != nil { 248 | return err 249 | } 250 | 251 | htmlBuf := &bytes.Buffer{} 252 | if err := t.Execute(htmlBuf, v); err != nil { 253 | return err 254 | } 255 | 256 | if body, err = render(string(htmlBuf.Bytes()), v); err != nil { 257 | return err 258 | } 259 | 260 | if w == nil { 261 | f, err := os.Create(filepath.Join(PUBDIR, renameExt(path, ".amber", ".html"))) 262 | if err != nil { 263 | return err 264 | } 265 | defer f.Close() 266 | w = f 267 | } 268 | _, err = io.WriteString(w, body) 269 | return err 270 | } 271 | 272 | // Compiles .gcss into .css 273 | func buildGCSS(path string, w io.Writer) error { 274 | f, err := os.Open(path) 275 | if err != nil { 276 | return err 277 | } 278 | defer f.Close() 279 | 280 | if w == nil { 281 | s := strings.TrimSuffix(path, ".gcss") + ".css" 282 | css, err := os.Create(filepath.Join(PUBDIR, s)) 283 | if err != nil { 284 | return err 285 | } 286 | defer css.Close() 287 | w = css 288 | } 289 | _, err = gcss.Compile(w, f) 290 | return err 291 | } 292 | 293 | // Copies file as is from path to writer 294 | func buildRaw(path string, w io.Writer) error { 295 | in, err := os.Open(path) 296 | if err != nil { 297 | return err 298 | } 299 | defer in.Close() 300 | if w == nil { 301 | if out, err := os.Create(filepath.Join(PUBDIR, path)); err != nil { 302 | return err 303 | } else { 304 | defer out.Close() 305 | w = out 306 | } 307 | } 308 | _, err = io.Copy(w, in) 309 | return err 310 | } 311 | 312 | func build(path string, w io.Writer, vars Vars) error { 313 | ext := filepath.Ext(path) 314 | if ext == ".md" || ext == ".mkd" { 315 | return buildMarkdown(path, w, vars) 316 | } else if ext == ".html" || ext == ".xml" { 317 | return buildHTML(path, w, vars) 318 | } else if ext == ".amber" { 319 | return buildAmber(path, w, vars) 320 | } else if ext == ".gcss" { 321 | return buildGCSS(path, w) 322 | } else { 323 | return buildRaw(path, w) 324 | } 325 | } 326 | 327 | func buildAll(watch bool) { 328 | lastModified := time.Unix(0, 0) 329 | modified := false 330 | 331 | vars := globals() 332 | for { 333 | os.Mkdir(PUBDIR, 0755) 334 | filepath.Walk(".", func(path string, info os.FileInfo, err error) error { 335 | // ignore hidden files and directories 336 | if filepath.Base(path)[0] == '.' || strings.HasPrefix(path, ".") { 337 | return nil 338 | } 339 | // inform user about fs walk errors, but continue iteration 340 | if err != nil { 341 | fmt.Println("error:", err) 342 | return nil 343 | } 344 | 345 | if info.IsDir() { 346 | os.Mkdir(filepath.Join(PUBDIR, path), 0755) 347 | return nil 348 | } else if info.ModTime().After(lastModified) { 349 | if !modified { 350 | // First file in this build cycle is about to be modified 351 | run(vars, "prehook") 352 | modified = true 353 | } 354 | log.Println("build:", path) 355 | return build(path, nil, vars) 356 | } 357 | return nil 358 | }) 359 | if modified { 360 | // At least one file in this build cycle has been modified 361 | run(vars, "posthook") 362 | modified = false 363 | } 364 | if !watch { 365 | break 366 | } 367 | lastModified = time.Now() 368 | time.Sleep(1 * time.Second) 369 | } 370 | } 371 | 372 | func init() { 373 | // prepend .zs to $PATH, so plugins will be found before OS commands 374 | p := os.Getenv("PATH") 375 | p = ZSDIR + ":" + p 376 | os.Setenv("PATH", p) 377 | } 378 | 379 | func main() { 380 | if len(os.Args) == 1 { 381 | fmt.Println(os.Args[0], " [args]") 382 | return 383 | } 384 | cmd := os.Args[1] 385 | args := os.Args[2:] 386 | switch cmd { 387 | case "build": 388 | if len(args) == 0 { 389 | buildAll(false) 390 | } else if len(args) == 1 { 391 | if err := build(args[0], os.Stdout, globals()); err != nil { 392 | fmt.Println("ERROR: " + err.Error()) 393 | } 394 | } else { 395 | fmt.Println("ERROR: too many arguments") 396 | } 397 | case "watch": 398 | buildAll(true) 399 | case "var": 400 | if len(args) == 0 { 401 | fmt.Println("var: filename expected") 402 | } else { 403 | s := "" 404 | if vars, _, err := getVars(args[0], Vars{}); err != nil { 405 | fmt.Println("var: " + err.Error()) 406 | } else { 407 | if len(args) > 1 { 408 | for _, a := range args[1:] { 409 | s = s + vars[a] + "\n" 410 | } 411 | } else { 412 | for k, v := range vars { 413 | s = s + k + ":" + v + "\n" 414 | } 415 | } 416 | } 417 | fmt.Println(strings.TrimSpace(s)) 418 | } 419 | default: 420 | if s, err := run(globals(), cmd, args...); err != nil { 421 | fmt.Println(err) 422 | } else { 423 | fmt.Println(s) 424 | } 425 | } 426 | } 427 | -------------------------------------------------------------------------------- /zs_build_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/md5" 5 | "encoding/hex" 6 | "io" 7 | "io/ioutil" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | "testing" 12 | ) 13 | 14 | const TESTDIR = ".test" 15 | 16 | func TestBuild(t *testing.T) { 17 | files, _ := ioutil.ReadDir("testdata") 18 | for _, f := range files { 19 | if f.IsDir() { 20 | testBuild(filepath.Join("testdata", f.Name()), t) 21 | } 22 | } 23 | } 24 | 25 | func testBuild(path string, t *testing.T) { 26 | wd, _ := os.Getwd() 27 | os.Chdir(path) 28 | args := os.Args[:] 29 | os.Args = []string{"zs", "build"} 30 | t.Log("--- BUILD", path) 31 | main() 32 | 33 | compare(PUBDIR, TESTDIR, t) 34 | 35 | os.Chdir(wd) 36 | os.Args = args 37 | } 38 | 39 | func compare(pub, test string, t *testing.T) { 40 | a := md5dir(pub) 41 | b := md5dir(test) 42 | for k, v := range a { 43 | if s, ok := b[k]; !ok { 44 | t.Error("Unexpected file:", k, v) 45 | } else if s != v { 46 | t.Error("Different file:", k, v, s) 47 | } else { 48 | t.Log("Matching file", k, v) 49 | } 50 | } 51 | for k, v := range b { 52 | if _, ok := a[k]; !ok { 53 | t.Error("Missing file:", k, v) 54 | } 55 | } 56 | } 57 | 58 | func md5dir(path string) map[string]string { 59 | files := map[string]string{} 60 | filepath.Walk(path, func(s string, info os.FileInfo, err error) error { 61 | if err == nil && !info.IsDir() { 62 | if f, err := os.Open(s); err == nil { 63 | defer f.Close() 64 | hash := md5.New() 65 | io.Copy(hash, f) 66 | files[strings.TrimPrefix(s, path)] = hex.EncodeToString(hash.Sum(nil)) 67 | } 68 | } 69 | return nil 70 | }) 71 | return files 72 | } 73 | -------------------------------------------------------------------------------- /zs_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | ) 9 | 10 | func TestRenameExt(t *testing.T) { 11 | if s := renameExt("foo.amber", ".amber", ".html"); s != "foo.html" { 12 | t.Error(s) 13 | } 14 | if s := renameExt("foo.amber", "", ".html"); s != "foo.html" { 15 | t.Error(s) 16 | } 17 | if s := renameExt("foo.amber", ".md", ".html"); s != "foo.amber" { 18 | t.Error(s) 19 | } 20 | if s := renameExt("foo", ".amber", ".html"); s != "foo" { 21 | t.Error(s) 22 | } 23 | if s := renameExt("foo", "", ".html"); s != "foo.html" { 24 | t.Error(s) 25 | } 26 | } 27 | 28 | func TestRun(t *testing.T) { 29 | // external command 30 | if s, err := run(Vars{}, "echo", "hello"); err != nil || s != "hello\n" { 31 | t.Error(s, err) 32 | } 33 | // passing variables to plugins 34 | if s, err := run(Vars{"foo": "bar"}, "sh", "-c", "echo $ZS_FOO"); err != nil || s != "bar\n" { 35 | t.Error(s, err) 36 | } 37 | 38 | // custom plugin overriding external command 39 | os.Mkdir(ZSDIR, 0755) 40 | script := `#!/bin/sh 41 | echo foo 42 | ` 43 | ioutil.WriteFile(filepath.Join(ZSDIR, "echo"), []byte(script), 0755) 44 | if s, err := run(Vars{}, "echo", "hello"); err != nil || s != "foo\n" { 45 | t.Error(s, err) 46 | } 47 | os.Remove(filepath.Join(ZSDIR, "echo")) 48 | os.Remove(ZSDIR) 49 | } 50 | 51 | func TestVars(t *testing.T) { 52 | tests := map[string]Vars{ 53 | ` 54 | foo: bar 55 | title: Hello, world! 56 | --- 57 | Some content in markdown 58 | `: Vars{ 59 | "foo": "bar", 60 | "title": "Hello, world!", 61 | "url": "test.html", 62 | "file": "test.md", 63 | "output": filepath.Join(PUBDIR, "test.html"), 64 | "__content": "Some content in markdown\n", 65 | }, 66 | ` 67 | url: "example.com/foo.html" 68 | --- 69 | Hello 70 | `: Vars{ 71 | "url": "example.com/foo.html", 72 | "__content": "Hello\n", 73 | }, 74 | } 75 | 76 | for script, vars := range tests { 77 | ioutil.WriteFile("test.md", []byte(script), 0644) 78 | if v, s, err := getVars("test.md", Vars{"baz": "123"}); err != nil { 79 | t.Error(err) 80 | } else if s != vars["__content"] { 81 | t.Error(s, vars["__content"]) 82 | } else { 83 | for key, value := range vars { 84 | if key != "__content" && v[key] != value { 85 | t.Error(key, v[key], value) 86 | } 87 | } 88 | } 89 | } 90 | } 91 | 92 | func TestRender(t *testing.T) { 93 | vars := map[string]string{"foo": "bar"} 94 | 95 | if s, _ := render("foo bar", vars); s != "foo bar" { 96 | t.Error(s) 97 | } 98 | if s, _ := render("a {{printf short}} text", vars); s != "a short text" { 99 | t.Error(s) 100 | } 101 | if s, _ := render("{{printf Hello}} x{{foo}}z", vars); s != "Hello xbarz" { 102 | t.Error(s) 103 | } 104 | // Test error case 105 | if _, err := render("a {{greet text ", vars); err == nil { 106 | t.Error("error expected") 107 | } 108 | } 109 | --------------------------------------------------------------------------------