Hi all. This is a brief description of who I am.
9 | 10 | 11 | -------------------------------------------------------------------------------- /testdata/blog/.test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |Here goes list of posts
8 |This is my first post
9 | 10 | 11 | -------------------------------------------------------------------------------- /testdata/blog/.test/posts/update.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |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 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], "