diff main.go @ 47:0adadd497d23 draft

Forked project
author prologic
date Fri, 17 Sep 2021 23:32:14 +0000
parents
children e054275d0938
line wrap: on
line diff
--- /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)
+		}
+	}
+}