changeset 47:0adadd497d23 draft

Forked project
author prologic
date Fri, 17 Sep 2021 23:32:14 +0000
parents afb3280aaf28
children d004c7c4ddf4
files README.md build_test.go go.mod go.sum main.go main_test.go zs.go zs_build_test.go zs_test.go
diffstat 9 files changed, 534 insertions(+), 629 deletions(-) [+]
line wrap: on
line diff
--- 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
--- /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
+}
--- 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
 )
--- 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=
--- /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], "<command> [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)
+		}
+	}
+}
--- /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")
+	}
+}
--- 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], "<command> [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)
-		}
-	}
-}
--- 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
-}
--- 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")
-	}
-}