# HG changeset patch # User prologic # Date 1631921534 0 # Node ID 0adadd497d239a5757b993fa0570f29bcad5ca68 # Parent afb3280aaf2817610c67fa0eeda31ae2ebe938ae Forked project diff -r afb3280aaf28 -r 0adadd497d23 README.md --- a/README.md Fri Sep 17 23:12:48 2021 +0000 +++ b/README.md Fri Sep 17 23:32:14 2021 +0000 @@ -1,7 +1,4 @@ -zs -== - -[![Build Status](https://travis-ci.org/zserge/zs.svg?branch=master)](https://travis-ci.org/zserge/zs) +# zs zs is an extremely minimal static site generator written in Go. @@ -22,11 +19,11 @@ Download the binaries from Github or build it manually: - $ go get github.com/zserge/zs + $ go get git.mills.io/prologic/zs ## Ideology -Keep your texts in markdown, [amber] or HTML format right in the main directory +Keep your texts in markdown, or HTML format right in the main directory of your blog/site. Keep all service files (extensions, layout pages, deployment scripts etc) @@ -92,12 +89,6 @@ lessc < $ZS_OUTDIR/styles.less > $ZS_OUTDIR/styles.css rm -f $ZS_OUTDIR/styles.css -## Syntax sugar - -By default, `zs` converts each `.amber` file into `.html`, so you can use lightweight Jade-like syntax instead of bloated HTML. - -Also, `zs` converts `.gcss` into `.css`, so you don't really need LESS or SASS. More about GCSS can be found [here][gcss]. - ## Command line usage `zs build` re-builds your site. @@ -113,7 +104,3 @@ ## License The software is distributed under the MIT license. - -[amber]: https://github.com/eknkc/amber/ -[YAML]: https://github.com/go-yaml/yaml -[gcss]: https://github.com/yosssi/gcss diff -r afb3280aaf28 -r 0adadd497d23 build_test.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/build_test.go Fri Sep 17 23:32:14 2021 +0000 @@ -0,0 +1,72 @@ +package main + +import ( + "crypto/md5" + "encoding/hex" + "io" + "io/ioutil" + "os" + "path/filepath" + "strings" + "testing" +) + +const TESTDIR = ".test" + +func TestBuild(t *testing.T) { + files, _ := ioutil.ReadDir("testdata") + for _, f := range files { + if f.IsDir() { + testBuild(filepath.Join("testdata", f.Name()), t) + } + } +} + +func testBuild(path string, t *testing.T) { + wd, _ := os.Getwd() + os.Chdir(path) + args := os.Args[:] + os.Args = []string{"zs", "build"} + t.Log("--- BUILD", path) + main() + + compare(PUBDIR, TESTDIR, t) + + os.Chdir(wd) + os.Args = args +} + +func compare(pub, test string, t *testing.T) { + a := md5dir(pub) + b := md5dir(test) + for k, v := range a { + if s, ok := b[k]; !ok { + t.Error("Unexpected file:", k, v) + } else if s != v { + t.Error("Different file:", k, v, s) + } else { + t.Log("Matching file", k, v) + } + } + for k, v := range b { + if _, ok := a[k]; !ok { + t.Error("Missing file:", k, v) + } + } +} + +func md5dir(path string) map[string]string { + files := map[string]string{} + filepath.Walk(path, func(s string, info os.FileInfo, err error) error { + if err == nil && !info.IsDir() { + if f, err := os.Open(s); err == nil { + defer f.Close() + hash := md5.New() + io.Copy(hash, f) + files[strings.TrimPrefix(s, path)] = hex.EncodeToString(hash.Sum(nil)) + } + } + return nil + }) + return files +} diff -r afb3280aaf28 -r 0adadd497d23 go.mod --- a/go.mod Fri Sep 17 23:12:48 2021 +0000 +++ b/go.mod Fri Sep 17 23:32:14 2021 +0000 @@ -1,10 +1,8 @@ -module github.com/zserge/zs +module git.mills.io/prologic/zs go 1.17 require ( - github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385 github.com/russross/blackfriday/v2 v2.1.0 - github.com/yosssi/gcss v0.1.0 gopkg.in/yaml.v2 v2.4.0 ) diff -r afb3280aaf28 -r 0adadd497d23 go.sum --- a/go.sum Fri Sep 17 23:12:48 2021 +0000 +++ b/go.sum Fri Sep 17 23:32:14 2021 +0000 @@ -1,9 +1,5 @@ -github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385 h1:clC1lXBpe2kTj2VHdaIu9ajZQe4kcEY9j0NsnDDBZ3o= -github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= -github.com/yosssi/gcss v0.1.0 h1:jRuino7qq7kqntBIhT+0xSUI5/sBgCA/zCQ1Tuzd6Gg= -github.com/yosssi/gcss v0.1.0/go.mod h1:M3mTPOWZWjVROkXKZ2AiDzOBOXu2MqQeDXF/nKO44sI= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= diff -r afb3280aaf28 -r 0adadd497d23 main.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/main.go Fri Sep 17 23:32:14 2021 +0000 @@ -0,0 +1,350 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "io/ioutil" + "log" + "os" + "os/exec" + "path/filepath" + "strings" + "text/template" + "time" + + "github.com/russross/blackfriday/v2" + "gopkg.in/yaml.v2" +) + +const ( + ZSDIR = ".zs" + PUBDIR = ".pub" +) + +type Vars map[string]string + +// renameExt renames extension (if any) from oldext to newext +// If oldext is an empty string - extension is extracted automatically. +// If path has no extension - new extension is appended +func renameExt(path, oldext, newext string) string { + if oldext == "" { + oldext = filepath.Ext(path) + } + if oldext == "" || strings.HasSuffix(path, oldext) { + return strings.TrimSuffix(path, oldext) + newext + } else { + return path + } +} + +// globals returns list of global OS environment variables that start +// with ZS_ prefix as Vars, so the values can be used inside templates +func globals() Vars { + vars := Vars{} + for _, e := range os.Environ() { + pair := strings.Split(e, "=") + if strings.HasPrefix(pair[0], "ZS_") { + vars[strings.ToLower(pair[0][3:])] = pair[1] + } + } + return vars +} + +// run executes a command or a script. Vars define the command environment, +// each zs var is converted into OS environemnt variable with ZS_ prefix +// prepended. Additional variable $ZS contains path to the zs binary. Command +// stderr is printed to zs stderr, command output is returned as a string. +func run(vars Vars, cmd string, args ...string) (string, error) { + // First check if partial exists (.html) + if b, err := ioutil.ReadFile(filepath.Join(ZSDIR, cmd+".html")); err == nil { + return string(b), nil + } + + var errbuf, outbuf bytes.Buffer + c := exec.Command(cmd, args...) + env := []string{"ZS=" + os.Args[0], "ZS_OUTDIR=" + PUBDIR} + env = append(env, os.Environ()...) + for k, v := range vars { + env = append(env, "ZS_"+strings.ToUpper(k)+"="+v) + } + c.Env = env + c.Stdout = &outbuf + c.Stderr = &errbuf + + err := c.Run() + + if errbuf.Len() > 0 { + log.Println("ERROR:", errbuf.String()) + } + if err != nil { + return "", err + } + return string(outbuf.Bytes()), nil +} + +// getVars returns list of variables defined in a text file and actual file +// content following the variables declaration. Header is separated from +// content by an empty line. Header can be either YAML or JSON. +// If no empty newline is found - file is treated as content-only. +func getVars(path string, globals Vars) (Vars, string, error) { + b, err := ioutil.ReadFile(path) + if err != nil { + return nil, "", err + } + s := string(b) + + // Pick some default values for content-dependent variables + v := Vars{} + title := strings.Replace(strings.Replace(path, "_", " ", -1), "-", " ", -1) + v["title"] = strings.ToTitle(title) + v["description"] = "" + v["file"] = path + v["url"] = path[:len(path)-len(filepath.Ext(path))] + ".html" + v["output"] = filepath.Join(PUBDIR, v["url"]) + + // Override default values with globals + for name, value := range globals { + v[name] = value + } + + // Add layout if none is specified + if _, ok := v["layout"]; !ok { + v["layout"] = "layout.html" + } + + delim := "\n---\n" + if sep := strings.Index(s, delim); sep == -1 { + return v, s, nil + } else { + header := s[:sep] + body := s[sep+len(delim):] + + vars := Vars{} + if err := yaml.Unmarshal([]byte(header), &vars); err != nil { + fmt.Println("ERROR: failed to parse header", err) + return nil, "", err + } else { + // Override default values + globals with the ones defines in the file + for key, value := range vars { + v[key] = value + } + } + if strings.HasPrefix(v["url"], "./") { + v["url"] = v["url"][2:] + } + return v, body, nil + } +} + +// Render expanding zs plugins and variables +func render(s string, vars Vars) (string, error) { + delim_open := "{{" + delim_close := "}}" + + out := &bytes.Buffer{} + for { + if from := strings.Index(s, delim_open); from == -1 { + out.WriteString(s) + return out.String(), nil + } else { + if to := strings.Index(s, delim_close); to == -1 { + return "", fmt.Errorf("Close delim not found") + } else { + out.WriteString(s[:from]) + cmd := s[from+len(delim_open) : to] + s = s[to+len(delim_close):] + m := strings.Fields(cmd) + if len(m) == 1 { + if v, ok := vars[m[0]]; ok { + out.WriteString(v) + continue + } + } + if res, err := run(vars, m[0], m[1:]...); err == nil { + out.WriteString(res) + } else { + fmt.Println(err) + } + } + } + } + return s, nil +} + +// Renders markdown with the given layout into html expanding all the macros +func buildMarkdown(path string, w io.Writer, vars Vars) error { + v, body, err := getVars(path, vars) + if err != nil { + return err + } + content, err := render(body, v) + if err != nil { + return err + } + v["content"] = string(blackfriday.Run([]byte(content))) + if w == nil { + out, err := os.Create(filepath.Join(PUBDIR, renameExt(path, "", ".html"))) + if err != nil { + return err + } + defer out.Close() + w = out + } + return buildHTML(filepath.Join(ZSDIR, v["layout"]), w, v) +} + +// Renders text file expanding all variable macros inside it +func buildHTML(path string, w io.Writer, vars Vars) error { + v, body, err := getVars(path, vars) + if err != nil { + return err + } + if body, err = render(body, v); err != nil { + return err + } + tmpl, err := template.New("").Delims("<%", "%>").Parse(body) + if err != nil { + return err + } + if w == nil { + f, err := os.Create(filepath.Join(PUBDIR, path)) + if err != nil { + return err + } + defer f.Close() + w = f + } + return tmpl.Execute(w, vars) +} + +// Copies file as is from path to writer +func buildRaw(path string, w io.Writer) error { + in, err := os.Open(path) + if err != nil { + return err + } + defer in.Close() + if w == nil { + if out, err := os.Create(filepath.Join(PUBDIR, path)); err != nil { + return err + } else { + defer out.Close() + w = out + } + } + _, err = io.Copy(w, in) + return err +} + +func build(path string, w io.Writer, vars Vars) error { + ext := filepath.Ext(path) + if ext == ".md" || ext == ".mkd" { + return buildMarkdown(path, w, vars) + } else if ext == ".html" || ext == ".xml" { + return buildHTML(path, w, vars) + } else { + return buildRaw(path, w) + } +} + +func buildAll(watch bool) { + lastModified := time.Unix(0, 0) + modified := false + + vars := globals() + for { + os.Mkdir(PUBDIR, 0755) + filepath.Walk(".", func(path string, info os.FileInfo, err error) error { + // ignore hidden files and directories + if filepath.Base(path)[0] == '.' || strings.HasPrefix(path, ".") { + return nil + } + // inform user about fs walk errors, but continue iteration + if err != nil { + fmt.Println("error:", err) + return nil + } + + if info.IsDir() { + os.Mkdir(filepath.Join(PUBDIR, path), 0755) + return nil + } else if info.ModTime().After(lastModified) { + if !modified { + // First file in this build cycle is about to be modified + run(vars, "prehook") + modified = true + } + log.Println("build:", path) + return build(path, nil, vars) + } + return nil + }) + if modified { + // At least one file in this build cycle has been modified + run(vars, "posthook") + modified = false + } + if !watch { + break + } + lastModified = time.Now() + time.Sleep(1 * time.Second) + } +} + +func init() { + // prepend .zs to $PATH, so plugins will be found before OS commands + p := os.Getenv("PATH") + p = ZSDIR + ":" + p + os.Setenv("PATH", p) +} + +func main() { + if len(os.Args) == 1 { + fmt.Println(os.Args[0], " [args]") + return + } + cmd := os.Args[1] + args := os.Args[2:] + switch cmd { + case "build": + if len(args) == 0 { + buildAll(false) + } else if len(args) == 1 { + if err := build(args[0], os.Stdout, globals()); err != nil { + fmt.Println("ERROR: " + err.Error()) + } + } else { + fmt.Println("ERROR: too many arguments") + } + case "watch": + buildAll(true) + case "var": + if len(args) == 0 { + fmt.Println("var: filename expected") + } else { + s := "" + if vars, _, err := getVars(args[0], Vars{}); err != nil { + fmt.Println("var: " + err.Error()) + } else { + if len(args) > 1 { + for _, a := range args[1:] { + s = s + vars[a] + "\n" + } + } else { + for k, v := range vars { + s = s + k + ":" + v + "\n" + } + } + } + fmt.Println(strings.TrimSpace(s)) + } + default: + if s, err := run(globals(), cmd, args...); err != nil { + fmt.Println(err) + } else { + fmt.Println(s) + } + } +} diff -r afb3280aaf28 -r 0adadd497d23 main_test.go --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/main_test.go Fri Sep 17 23:32:14 2021 +0000 @@ -0,0 +1,108 @@ +package main + +import ( + "io/ioutil" + "os" + "path/filepath" + "testing" +) + +func TestRenameExt(t *testing.T) { + if s := renameExt("foo.amber", ".amber", ".html"); s != "foo.html" { + t.Error(s) + } + if s := renameExt("foo.amber", "", ".html"); s != "foo.html" { + t.Error(s) + } + if s := renameExt("foo.amber", ".md", ".html"); s != "foo.amber" { + t.Error(s) + } + if s := renameExt("foo", ".amber", ".html"); s != "foo" { + t.Error(s) + } + if s := renameExt("foo", "", ".html"); s != "foo.html" { + t.Error(s) + } +} + +func TestRun(t *testing.T) { + // external command + if s, err := run(Vars{}, "echo", "hello"); err != nil || s != "hello\n" { + t.Error(s, err) + } + // passing variables to plugins + if s, err := run(Vars{"foo": "bar"}, "sh", "-c", "echo $ZS_FOO"); err != nil || s != "bar\n" { + t.Error(s, err) + } + + // custom plugin overriding external command + os.Mkdir(ZSDIR, 0755) + script := `#!/bin/sh +echo foo +` + ioutil.WriteFile(filepath.Join(ZSDIR, "echo"), []byte(script), 0755) + if s, err := run(Vars{}, "echo", "hello"); err != nil || s != "foo\n" { + t.Error(s, err) + } + os.Remove(filepath.Join(ZSDIR, "echo")) + os.Remove(ZSDIR) +} + +func TestVars(t *testing.T) { + tests := map[string]Vars{ + ` +foo: bar +title: Hello, world! +--- +Some content in markdown +`: Vars{ + "foo": "bar", + "title": "Hello, world!", + "url": "test.html", + "file": "test.md", + "output": filepath.Join(PUBDIR, "test.html"), + "__content": "Some content in markdown\n", + }, + ` +url: "example.com/foo.html" +--- +Hello +`: Vars{ + "url": "example.com/foo.html", + "__content": "Hello\n", + }, + } + + for script, vars := range tests { + ioutil.WriteFile("test.md", []byte(script), 0644) + if v, s, err := getVars("test.md", Vars{"baz": "123"}); err != nil { + t.Error(err) + } else if s != vars["__content"] { + t.Error(s, vars["__content"]) + } else { + for key, value := range vars { + if key != "__content" && v[key] != value { + t.Error(key, v[key], value) + } + } + } + } +} + +func TestRender(t *testing.T) { + vars := map[string]string{"foo": "bar"} + + if s, _ := render("foo bar", vars); s != "foo bar" { + t.Error(s) + } + if s, _ := render("a {{printf short}} text", vars); s != "a short text" { + t.Error(s) + } + if s, _ := render("{{printf Hello}} x{{foo}}z", vars); s != "Hello xbarz" { + t.Error(s) + } + // Test error case + if _, err := render("a {{greet text ", vars); err == nil { + t.Error("error expected") + } +} diff -r afb3280aaf28 -r 0adadd497d23 zs.go --- a/zs.go Fri Sep 17 23:12:48 2021 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,426 +0,0 @@ -package main - -import ( - "bytes" - "fmt" - "io" - "io/ioutil" - "log" - "os" - "os/exec" - "path/filepath" - "strings" - "text/template" - "time" - - "github.com/eknkc/amber" - "github.com/russross/blackfriday/v2" - "github.com/yosssi/gcss" - "gopkg.in/yaml.v2" -) - -const ( - ZSDIR = ".zs" - PUBDIR = ".pub" -) - -type Vars map[string]string - -// renameExt renames extension (if any) from oldext to newext -// If oldext is an empty string - extension is extracted automatically. -// If path has no extension - new extension is appended -func renameExt(path, oldext, newext string) string { - if oldext == "" { - oldext = filepath.Ext(path) - } - if oldext == "" || strings.HasSuffix(path, oldext) { - return strings.TrimSuffix(path, oldext) + newext - } else { - return path - } -} - -// globals returns list of global OS environment variables that start -// with ZS_ prefix as Vars, so the values can be used inside templates -func globals() Vars { - vars := Vars{} - for _, e := range os.Environ() { - pair := strings.Split(e, "=") - if strings.HasPrefix(pair[0], "ZS_") { - vars[strings.ToLower(pair[0][3:])] = pair[1] - } - } - return vars -} - -// run executes a command or a script. Vars define the command environment, -// each zs var is converted into OS environemnt variable with ZS_ prefix -// prepended. Additional variable $ZS contains path to the zs binary. Command -// stderr is printed to zs stderr, command output is returned as a string. -func run(vars Vars, cmd string, args ...string) (string, error) { - // First check if partial exists (.amber or .html) - if b, err := ioutil.ReadFile(filepath.Join(ZSDIR, cmd+".amber")); err == nil { - return string(b), nil - } - if b, err := ioutil.ReadFile(filepath.Join(ZSDIR, cmd+".html")); err == nil { - return string(b), nil - } - - var errbuf, outbuf bytes.Buffer - c := exec.Command(cmd, args...) - env := []string{"ZS=" + os.Args[0], "ZS_OUTDIR=" + PUBDIR} - env = append(env, os.Environ()...) - for k, v := range vars { - env = append(env, "ZS_"+strings.ToUpper(k)+"="+v) - } - c.Env = env - c.Stdout = &outbuf - c.Stderr = &errbuf - - err := c.Run() - - if errbuf.Len() > 0 { - log.Println("ERROR:", errbuf.String()) - } - if err != nil { - return "", err - } - return string(outbuf.Bytes()), nil -} - -// getVars returns list of variables defined in a text file and actual file -// content following the variables declaration. Header is separated from -// content by an empty line. Header can be either YAML or JSON. -// If no empty newline is found - file is treated as content-only. -func getVars(path string, globals Vars) (Vars, string, error) { - b, err := ioutil.ReadFile(path) - if err != nil { - return nil, "", err - } - s := string(b) - - // Pick some default values for content-dependent variables - v := Vars{} - title := strings.Replace(strings.Replace(path, "_", " ", -1), "-", " ", -1) - v["title"] = strings.ToTitle(title) - v["description"] = "" - v["file"] = path - v["url"] = path[:len(path)-len(filepath.Ext(path))] + ".html" - v["output"] = filepath.Join(PUBDIR, v["url"]) - - // Override default values with globals - for name, value := range globals { - v[name] = value - } - - // Add layout if none is specified - if _, ok := v["layout"]; !ok { - if _, err := os.Stat(filepath.Join(ZSDIR, "layout.amber")); err == nil { - v["layout"] = "layout.amber" - } else { - v["layout"] = "layout.html" - } - } - - delim := "\n---\n" - if sep := strings.Index(s, delim); sep == -1 { - return v, s, nil - } else { - header := s[:sep] - body := s[sep+len(delim):] - - vars := Vars{} - if err := yaml.Unmarshal([]byte(header), &vars); err != nil { - fmt.Println("ERROR: failed to parse header", err) - return nil, "", err - } else { - // Override default values + globals with the ones defines in the file - for key, value := range vars { - v[key] = value - } - } - if strings.HasPrefix(v["url"], "./") { - v["url"] = v["url"][2:] - } - return v, body, nil - } -} - -// Render expanding zs plugins and variables -func render(s string, vars Vars) (string, error) { - delim_open := "{{" - delim_close := "}}" - - out := &bytes.Buffer{} - for { - if from := strings.Index(s, delim_open); from == -1 { - out.WriteString(s) - return out.String(), nil - } else { - if to := strings.Index(s, delim_close); to == -1 { - return "", fmt.Errorf("Close delim not found") - } else { - out.WriteString(s[:from]) - cmd := s[from+len(delim_open) : to] - s = s[to+len(delim_close):] - m := strings.Fields(cmd) - if len(m) == 1 { - if v, ok := vars[m[0]]; ok { - out.WriteString(v) - continue - } - } - if res, err := run(vars, m[0], m[1:]...); err == nil { - out.WriteString(res) - } else { - fmt.Println(err) - } - } - } - } - return s, nil -} - -// Renders markdown with the given layout into html expanding all the macros -func buildMarkdown(path string, w io.Writer, vars Vars) error { - v, body, err := getVars(path, vars) - if err != nil { - return err - } - content, err := render(body, v) - if err != nil { - return err - } - v["content"] = string(blackfriday.Run([]byte(content))) - if w == nil { - out, err := os.Create(filepath.Join(PUBDIR, renameExt(path, "", ".html"))) - if err != nil { - return err - } - defer out.Close() - w = out - } - if strings.HasSuffix(v["layout"], ".amber") { - return buildAmber(filepath.Join(ZSDIR, v["layout"]), w, v) - } else { - return buildHTML(filepath.Join(ZSDIR, v["layout"]), w, v) - } -} - -// Renders text file expanding all variable macros inside it -func buildHTML(path string, w io.Writer, vars Vars) error { - v, body, err := getVars(path, vars) - if err != nil { - return err - } - if body, err = render(body, v); err != nil { - return err - } - tmpl, err := template.New("").Delims("<%", "%>").Parse(body) - if err != nil { - return err - } - if w == nil { - f, err := os.Create(filepath.Join(PUBDIR, path)) - if err != nil { - return err - } - defer f.Close() - w = f - } - return tmpl.Execute(w, vars) -} - -// Renders .amber file into .html -func buildAmber(path string, w io.Writer, vars Vars) error { - v, body, err := getVars(path, vars) - if err != nil { - return err - } - a := amber.New() - if err := a.Parse(body); err != nil { - fmt.Println(body) - return err - } - - t, err := a.Compile() - if err != nil { - return err - } - - htmlBuf := &bytes.Buffer{} - if err := t.Execute(htmlBuf, v); err != nil { - return err - } - - if body, err = render(string(htmlBuf.Bytes()), v); err != nil { - return err - } - - if w == nil { - f, err := os.Create(filepath.Join(PUBDIR, renameExt(path, ".amber", ".html"))) - if err != nil { - return err - } - defer f.Close() - w = f - } - _, err = io.WriteString(w, body) - return err -} - -// Compiles .gcss into .css -func buildGCSS(path string, w io.Writer) error { - f, err := os.Open(path) - if err != nil { - return err - } - defer f.Close() - - if w == nil { - s := strings.TrimSuffix(path, ".gcss") + ".css" - css, err := os.Create(filepath.Join(PUBDIR, s)) - if err != nil { - return err - } - defer css.Close() - w = css - } - _, err = gcss.Compile(w, f) - return err -} - -// Copies file as is from path to writer -func buildRaw(path string, w io.Writer) error { - in, err := os.Open(path) - if err != nil { - return err - } - defer in.Close() - if w == nil { - if out, err := os.Create(filepath.Join(PUBDIR, path)); err != nil { - return err - } else { - defer out.Close() - w = out - } - } - _, err = io.Copy(w, in) - return err -} - -func build(path string, w io.Writer, vars Vars) error { - ext := filepath.Ext(path) - if ext == ".md" || ext == ".mkd" { - return buildMarkdown(path, w, vars) - } else if ext == ".html" || ext == ".xml" { - return buildHTML(path, w, vars) - } else if ext == ".amber" { - return buildAmber(path, w, vars) - } else if ext == ".gcss" { - return buildGCSS(path, w) - } else { - return buildRaw(path, w) - } -} - -func buildAll(watch bool) { - lastModified := time.Unix(0, 0) - modified := false - - vars := globals() - for { - os.Mkdir(PUBDIR, 0755) - filepath.Walk(".", func(path string, info os.FileInfo, err error) error { - // ignore hidden files and directories - if filepath.Base(path)[0] == '.' || strings.HasPrefix(path, ".") { - return nil - } - // inform user about fs walk errors, but continue iteration - if err != nil { - fmt.Println("error:", err) - return nil - } - - if info.IsDir() { - os.Mkdir(filepath.Join(PUBDIR, path), 0755) - return nil - } else if info.ModTime().After(lastModified) { - if !modified { - // First file in this build cycle is about to be modified - run(vars, "prehook") - modified = true - } - log.Println("build:", path) - return build(path, nil, vars) - } - return nil - }) - if modified { - // At least one file in this build cycle has been modified - run(vars, "posthook") - modified = false - } - if !watch { - break - } - lastModified = time.Now() - time.Sleep(1 * time.Second) - } -} - -func init() { - // prepend .zs to $PATH, so plugins will be found before OS commands - p := os.Getenv("PATH") - p = ZSDIR + ":" + p - os.Setenv("PATH", p) -} - -func main() { - if len(os.Args) == 1 { - fmt.Println(os.Args[0], " [args]") - return - } - cmd := os.Args[1] - args := os.Args[2:] - switch cmd { - case "build": - if len(args) == 0 { - buildAll(false) - } else if len(args) == 1 { - if err := build(args[0], os.Stdout, globals()); err != nil { - fmt.Println("ERROR: " + err.Error()) - } - } else { - fmt.Println("ERROR: too many arguments") - } - case "watch": - buildAll(true) - case "var": - if len(args) == 0 { - fmt.Println("var: filename expected") - } else { - s := "" - if vars, _, err := getVars(args[0], Vars{}); err != nil { - fmt.Println("var: " + err.Error()) - } else { - if len(args) > 1 { - for _, a := range args[1:] { - s = s + vars[a] + "\n" - } - } else { - for k, v := range vars { - s = s + k + ":" + v + "\n" - } - } - } - fmt.Println(strings.TrimSpace(s)) - } - default: - if s, err := run(globals(), cmd, args...); err != nil { - fmt.Println(err) - } else { - fmt.Println(s) - } - } -} diff -r afb3280aaf28 -r 0adadd497d23 zs_build_test.go --- a/zs_build_test.go Fri Sep 17 23:12:48 2021 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,72 +0,0 @@ -package main - -import ( - "crypto/md5" - "encoding/hex" - "io" - "io/ioutil" - "os" - "path/filepath" - "strings" - "testing" -) - -const TESTDIR = ".test" - -func TestBuild(t *testing.T) { - files, _ := ioutil.ReadDir("testdata") - for _, f := range files { - if f.IsDir() { - testBuild(filepath.Join("testdata", f.Name()), t) - } - } -} - -func testBuild(path string, t *testing.T) { - wd, _ := os.Getwd() - os.Chdir(path) - args := os.Args[:] - os.Args = []string{"zs", "build"} - t.Log("--- BUILD", path) - main() - - compare(PUBDIR, TESTDIR, t) - - os.Chdir(wd) - os.Args = args -} - -func compare(pub, test string, t *testing.T) { - a := md5dir(pub) - b := md5dir(test) - for k, v := range a { - if s, ok := b[k]; !ok { - t.Error("Unexpected file:", k, v) - } else if s != v { - t.Error("Different file:", k, v, s) - } else { - t.Log("Matching file", k, v) - } - } - for k, v := range b { - if _, ok := a[k]; !ok { - t.Error("Missing file:", k, v) - } - } -} - -func md5dir(path string) map[string]string { - files := map[string]string{} - filepath.Walk(path, func(s string, info os.FileInfo, err error) error { - if err == nil && !info.IsDir() { - if f, err := os.Open(s); err == nil { - defer f.Close() - hash := md5.New() - io.Copy(hash, f) - files[strings.TrimPrefix(s, path)] = hex.EncodeToString(hash.Sum(nil)) - } - } - return nil - }) - return files -} diff -r afb3280aaf28 -r 0adadd497d23 zs_test.go --- a/zs_test.go Fri Sep 17 23:12:48 2021 +0000 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,108 +0,0 @@ -package main - -import ( - "io/ioutil" - "os" - "path/filepath" - "testing" -) - -func TestRenameExt(t *testing.T) { - if s := renameExt("foo.amber", ".amber", ".html"); s != "foo.html" { - t.Error(s) - } - if s := renameExt("foo.amber", "", ".html"); s != "foo.html" { - t.Error(s) - } - if s := renameExt("foo.amber", ".md", ".html"); s != "foo.amber" { - t.Error(s) - } - if s := renameExt("foo", ".amber", ".html"); s != "foo" { - t.Error(s) - } - if s := renameExt("foo", "", ".html"); s != "foo.html" { - t.Error(s) - } -} - -func TestRun(t *testing.T) { - // external command - if s, err := run(Vars{}, "echo", "hello"); err != nil || s != "hello\n" { - t.Error(s, err) - } - // passing variables to plugins - if s, err := run(Vars{"foo": "bar"}, "sh", "-c", "echo $ZS_FOO"); err != nil || s != "bar\n" { - t.Error(s, err) - } - - // custom plugin overriding external command - os.Mkdir(ZSDIR, 0755) - script := `#!/bin/sh -echo foo -` - ioutil.WriteFile(filepath.Join(ZSDIR, "echo"), []byte(script), 0755) - if s, err := run(Vars{}, "echo", "hello"); err != nil || s != "foo\n" { - t.Error(s, err) - } - os.Remove(filepath.Join(ZSDIR, "echo")) - os.Remove(ZSDIR) -} - -func TestVars(t *testing.T) { - tests := map[string]Vars{ - ` -foo: bar -title: Hello, world! ---- -Some content in markdown -`: Vars{ - "foo": "bar", - "title": "Hello, world!", - "url": "test.html", - "file": "test.md", - "output": filepath.Join(PUBDIR, "test.html"), - "__content": "Some content in markdown\n", - }, - ` -url: "example.com/foo.html" ---- -Hello -`: Vars{ - "url": "example.com/foo.html", - "__content": "Hello\n", - }, - } - - for script, vars := range tests { - ioutil.WriteFile("test.md", []byte(script), 0644) - if v, s, err := getVars("test.md", Vars{"baz": "123"}); err != nil { - t.Error(err) - } else if s != vars["__content"] { - t.Error(s, vars["__content"]) - } else { - for key, value := range vars { - if key != "__content" && v[key] != value { - t.Error(key, v[key], value) - } - } - } - } -} - -func TestRender(t *testing.T) { - vars := map[string]string{"foo": "bar"} - - if s, _ := render("foo bar", vars); s != "foo bar" { - t.Error(s) - } - if s, _ := render("a {{printf short}} text", vars); s != "a short text" { - t.Error(s) - } - if s, _ := render("{{printf Hello}} x{{foo}}z", vars); s != "Hello xbarz" { - t.Error(s) - } - // Test error case - if _, err := render("a {{greet text ", vars); err == nil { - t.Error("error expected") - } -}