Files
zs/main.go
Bob 0bbbf7974b Add prebuild/postbuild hooks and fix stdin/stdout passthrough for all hooks (#25)
Fixes #17

### What changed

**New `runHook()` function**
Hooks now run with `stdin`, `stdout`, and `stderr` wired directly to the terminal. This means hook scripts can be interactive — print progress, prompt the user, run `rsync` interactively, etc. Previously all hook output was swallowed (hooks used the same `run()` path as template plugins, which captures stdout into a buffer).

**`prebuild` hook**
Fires once per build cycle, before `prehook` and before any file is processed. Useful for generating source files (markdown from git log, JSON data, updating `layout.html`) that zs should then process.

**`postbuild` hook**
Fires once per build cycle, after `posthook` and after all files are processed. Useful for publication steps (rsync prompt, test server launch, etc.).

**Bug fix: `posthook` was silently skipped**
`modified = true` was set only inside the `prehook` branch, so `posthook` never fired unless `prehook` was also on `PATH`. Fixed by setting `modified = true` unconditionally before running any pre-hooks.

### Hook order per build cycle (when at least one file changed)

```
prebuild  →  prehook  →  [build each changed file]  →  posthook  →  postbuild
```

All four hooks receive the same `ZS_*` environment variables. None of them are required — each is skipped silently if not found on `PATH`.

Co-authored-by: James Mills <james@mills.io>
Reviewed-on: https://git.mills.io/prologic/zs/pulls/25
Reviewed-by: James Mills <james@mills.io>
Co-authored-by: Bob <bob@noreply@mills.io>
Co-committed-by: Bob <bob@noreply@mills.io>
2026-05-24 10:35:17 +00:00

1376 lines
37 KiB
Go

// Package main is a command-lint tool `zs` called Zen Static for generating static websites
package main
import (
"bytes"
"context"
"embed"
"errors"
"fmt"
"io"
"io/fs"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strconv"
"strings"
"syscall"
"text/template"
"time"
"unicode"
embedExt "github.com/13rac1/goldmark-embed"
d2Ext "github.com/FurqanSoftware/goldmark-d2"
chromaHTMLExt "github.com/alecthomas/chroma/v2/formatters/html"
"github.com/gabriel-vasile/mimetype"
gitIgnore "github.com/sabhiram/go-gitignore"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/spf13/viper"
fencesExt "github.com/stefanfritsch/goldmark-fences"
"github.com/yuin/goldmark"
highlightingExt "github.com/yuin/goldmark-highlighting/v2"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/extension"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer/html"
"github.com/yuin/goldmark/text"
"go.abhg.dev/goldmark/anchor"
"go.abhg.dev/goldmark/wikilink"
"go.mills.io/static"
"golang.org/x/sync/errgroup"
"golang.org/x/text/unicode/norm"
"gopkg.in/yaml.v2"
)
const (
// ZSDIR is the default directory for storing layouts and extensions
ZSDIR = ".zs"
// ZSCONFIG is the default configuration name (without extension)
ZSCONFIG = "config"
// ZSIGNORE is the default ignore file
ZSIGNORE = ".zsignore"
// PUBDIR is the default directory for publishing final built content
PUBDIR = ".pub"
// DefaultIgnore is the default set of ignore patterns if no .zsignore
DefaultIgnore = `*~
*.bak
COPYING
Dockerfile
LICENSE
Makefile
README.md`
)
// Ignore holds a set of patterns to ignore from parsing a .zsignore file
var Ignore *gitIgnore.GitIgnore
// Parser holds a configured global instance of the goldmark markdown parser
var Parser goldmark.Markdown
var (
configFile string
//go:embed default default/.gitignore default/.zs default/.zsignore
defaultFS embed.FS
)
// Extensions is a mapping of name to extension and the default set of extensions enabled
// which can be overridden with -e/--extension or the extensions key
// in ia config file such as .zs/config.yml
var Extensions = map[string]goldmark.Extender{
"table": extension.Table,
"strikethrough": extension.Strikethrough,
"linkify": extension.Linkify,
"tasklist": extension.TaskList,
"definitionlist": extension.DefinitionList,
"footnote": extension.Footnote,
"typography": extension.Typographer,
"cjk": extension.CJK,
"highlighting": highlightingExt.NewHighlighting(
highlightingExt.WithStyle("github"),
highlightingExt.WithFormatOptions(
chromaHTMLExt.WithLineNumbers(true),
chromaHTMLExt.WithClasses(true),
),
),
"anchor": &anchor.Extender{Texter: anchor.Text(" ")},
"d2": &d2Ext.Extender{},
"embed": embedExt.New(),
"fences": &fencesExt.Extender{},
"wikilink": &wikilink.Extender{},
}
// Vars holds a map of global variables
type Vars map[string]string
// MapKeys returns a slice of keys from a map
func MapKeys[K comparable, V any](m map[K]V) []K {
r := make([]K, 0, len(m))
for k := range m {
r = append(r, k)
}
return r
}
// NewTicker is a function that wraps a time.Ticker and ticks immediately instead of waiting for the first interval
func NewTicker(d time.Duration) *time.Ticker {
ticker := time.NewTicker(d)
oc := ticker.C
nc := make(chan time.Time, 1)
go func() {
nc <- time.Now()
for tm := range oc {
nc <- tm
}
}()
ticker.C = nc
return ticker
}
// slugify converts a string to a URL-friendly slug following these rules:
// 1) lowercase; 2) NFKD normalize; 3) strip diacritics; 4) non-alnum -> '-'
// 5) collapse '-'; 6) trim '-'; 7) restrict to [a-z0-9-];
// 8) optionally remove stop-words; 9) optionally truncate to <= maxlen;
// 10) deterministic output.
func slugify(s string) string {
s = strings.ToLower(s)
// Normalize to NFKD then remove diacritics (unicode.Mn)
s = norm.NFKD.String(s)
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
if unicode.Is(unicode.Mn, r) {
continue
}
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
b.WriteRune(r)
} else {
b.WriteByte('-')
}
}
// Collapse multiple '-'
inter := b.String()
var c strings.Builder
prevDash := false
for i := 0; i < len(inter); i++ {
ch := inter[i]
if ch == '-' {
if !prevDash {
c.WriteByte('-')
prevDash = true
}
} else {
c.WriteByte(ch)
prevDash = false
}
}
sl := strings.Trim(c.String(), "-")
// Optionally remove stop-words
if viper.GetBool("slug.stopwords") {
stop := map[string]struct{}{
"a": {}, "an": {}, "the": {},
"and": {}, "or": {}, "but": {},
"of": {}, "in": {}, "on": {}, "at": {}, "to": {}, "for": {}, "with": {}, "without": {},
"is": {}, "are": {}, "was": {}, "were": {}, "be": {}, "been": {}, "being": {},
"by": {}, "from": {}, "as": {}, "into": {}, "over": {}, "after": {}, "before": {}, "up": {}, "down": {}, "out": {}, "off": {}, "than": {},
}
parts := strings.Split(sl, "-")
filt := parts[:0]
for _, p := range parts {
if p == "" {
continue
}
if _, ok := stop[p]; ok {
continue
}
filt = append(filt, p)
}
sl = strings.Join(filt, "-")
sl = strings.Trim(sl, "-")
}
// Optionally truncate
if maxlen := viper.GetInt("slug.maxlen"); maxlen > 0 && len(sl) > maxlen {
sl = sl[:maxlen]
sl = strings.Trim(sl, "-")
var cb strings.Builder
pd := false
for i := 0; i < len(sl); i++ {
ch := sl[i]
if ch == '-' {
if !pd {
cb.WriteByte('-')
pd = true
}
} else {
cb.WriteByte(ch)
pd = false
}
}
sl = cb.String()
}
return sl
}
// RootCmd is the base command when called without any subcommands
var RootCmd = &cobra.Command{
Use: "zs",
Version: FullVersion(),
Short: "zs the zen static site generator",
Long: `zs is an extremely minimal static site generator written in Go.
- 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) in the .zs subdirectory.
- Define variables in the header of the content files using YAML front matter:
- Use placeholders for variables and plugins in your markdown or html files, e.g. {{ title }} or {{ command arg1 arg2 }}.
- Write extensions in any language you like and put them into the .zs sub-directory.
- Everything the extensions prints to stdout becomes the value of the placeholder.
Quick Start: zs init
`,
PersistentPreRunE: func(cmd *cobra.Command, _ []string) error {
debug, err := cmd.Flags().GetBool("debug")
if err != nil {
return fmt.Errorf("error getting debug flag: %w", err)
}
if debug {
log.SetLevel(log.DebugLevel)
} else {
log.SetLevel(log.InfoLevel)
}
var extensions []goldmark.Extender
for _, name := range viper.GetStringSlice("extensions") {
if extender, valid := Extensions[name]; valid {
extensions = append(extensions, extender)
} else {
log.Warnf("invalid extension: %s", name)
}
}
Parser = goldmark.New(
goldmark.WithExtensions(extensions...),
goldmark.WithParserOptions(
parser.WithAttribute(),
parser.WithAutoHeadingID(),
),
goldmark.WithRendererOptions(
html.WithXHTML(),
html.WithUnsafe(),
),
)
return nil
},
}
// BuildCmd is the build sub-command that performs whole builds or single builds
var BuildCmd = &cobra.Command{
Use: "build [<file>]",
Short: "Builds the whole site or a single file",
Long: `The build command builds the entire site or a single file if specified.`,
Args: cobra.RangeArgs(0, 1),
RunE: func(_ *cobra.Command, args []string) error {
if len(args) == 0 {
ctx := context.Background()
if err := buildAll(ctx, false); err != nil {
return fmt.Errorf("error building site: %w", err)
}
return nil
}
if err := build(args[0], os.Stdout, globals()); err != nil {
return fmt.Errorf("error building file %q", args[0])
}
return nil
},
}
// GenerateCmd is the generate sub-command that builds partial fragments
var GenerateCmd = &cobra.Command{
Use: "generate",
Aliases: []string{"gen"},
Short: "Generates partial fragments",
Long: `The generate command parses and renders partial fragments from stdin and writes to stdout`,
Args: cobra.RangeArgs(0, 1),
RunE: func(_ *cobra.Command, _ []string) error {
if err := generate(os.Stdin, os.Stdout, globals()); err != nil {
return fmt.Errorf("error generating fragment: %w", err)
}
return nil
},
}
// HeadingsCmd is the headings sub-command that extracts Markdown headings (level, id, text)
var HeadingsCmd = &cobra.Command{
Use: "headings <file>",
Short: "Extract Markdown headings (level, id, text)",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
levels, err := cmd.Flags().GetString("levels")
if err != nil {
return fmt.Errorf("error getting levels flag: %w", err)
}
min, err := cmd.Flags().GetInt("min")
if err != nil {
return fmt.Errorf("error getting min flag: %w", err)
}
if err := headings(args[0], os.Stdout, globals(), levels, min); err != nil {
return fmt.Errorf("error generating fragment: %w", err)
}
return nil
},
}
// InitCmd is the init sub-command that creates a new zs site
var InitCmd = &cobra.Command{
Use: "init",
Aliases: []string{"new"},
Short: "Initializes a new Zen Static site",
Long: `The init command creates a new Zen Static site in the current directory`,
Args: cobra.RangeArgs(0, 1),
RunE: func(_ *cobra.Command, args []string) error {
var (
dir string
err error
)
if len(args) > 0 || (len(args) == 1 && args[0] == ".") {
dir = args[0]
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("error creating directory %q: %w", dir, err)
}
} else {
dir, err = os.Getwd()
if err != nil {
return fmt.Errorf("error getting current directory: %w", err)
}
}
if err := initSite(dir); err != nil {
return fmt.Errorf("error initializing site: %w", err)
}
return nil
},
}
// ServeCmd is the serve sub-command that performs whole builds or single builds
var ServeCmd = &cobra.Command{
Use: "serve [flags]",
Short: "Serves the site and rebuilds automatically",
Long: `The serve command serves the site and watches for rebuilds automatically`,
Args: cobra.RangeArgs(0, 1),
RunE: func(cmd *cobra.Command, _ []string) error {
var wg errgroup.Group
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
bind, err := cmd.Flags().GetString("bind")
if err != nil {
return fmt.Errorf("error getting bind flag: %w", err)
}
root, err := cmd.Flags().GetString("root")
if err != nil {
return fmt.Errorf("error getting root flag: %w", err)
}
wg.Go(func() error {
if err := serve(ctx, bind, root); err != nil {
return fmt.Errorf("error serving site: %w", err)
}
return nil
})
if err := wg.Wait(); err != nil {
return fmt.Errorf("error running serve: %w", err)
}
return nil
},
}
// VarCmd is the var sub-command that performs whole builds or single builds
var VarCmd = &cobra.Command{
Use: "var <file> [<var>...]",
Aliases: []string{"vars"},
Short: "Display variables for the specified file",
Long: `The var command extracts and display sll teh variables defined in a file.
If the name of variables (optional) are passed as additional arguments, only those variables
are display instead of all variables (the default behaviors).`,
Args: cobra.MinimumNArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
s := ""
vars, _, err := getVars(args[0], globals())
if err != nil {
return fmt.Errorf("error getting variables from %s: %w", args[0], err)
}
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))
return nil
},
}
// WatchCmd is the watch sub-command that performs whole builds or single builds
var WatchCmd = &cobra.Command{
Use: "watch",
Short: "Watches for file changes and rebuilds modified files",
Long: `The watch command watches for any changes to files and rebuilds them automatically`,
Args: cobra.RangeArgs(0, 1),
RunE: func(_ *cobra.Command, _ []string) error {
var wg errgroup.Group
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
wg.Go(func() error {
if err := buildAll(ctx, true); err != nil {
return fmt.Errorf("error watching for changes: %w", err)
}
return nil
})
if err := wg.Wait(); err != nil {
return fmt.Errorf("error running watch: %w", err)
}
return nil
},
}
// 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
}
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{
"title": viper.GetString("title"),
"description": viper.GetString("description"),
"keywords": viper.GetString("keywords"),
}
if viper.GetBool("production") {
vars["production"] = "1"
}
// Variables from the environment in the form of ZS_<name>=<value>
for _, e := range os.Environ() {
pair := strings.Split(e, "=")
if strings.HasPrefix(pair[0], "ZS_") {
vars[strings.ToLower(pair[0][3:])] = pair[1]
}
}
// Variables from the command-line -v/--vars (or env var as $ZS_VARS) or configuration
// Note: These will override the previous variables if names clash.
for _, e := range viper.GetStringSlice("vars") {
pair := strings.Split(e, "=")
vars[pair[0]] = pair[1]
}
return vars
}
// runHook executes a lifecycle hook (e.g. prebuild, prehook, posthook, postbuild)
// with stdin, stdout, and stderr connected to the terminal so hooks can be
// interactive. Returns nil when the hook binary is not found on PATH (not an error).
// Vars are passed as ZS_* environment variables, identical to run().
func runHook(vars Vars, name string) error {
if _, err := exec.LookPath(name); err != nil {
return nil
}
c := exec.Command(name)
env := []string{"ZS=" + os.Args[0], "ZS_OUTDIR=" + PUBDIR}
env = append(env, os.Environ()...)
for k, v := range vars {
if k != "content" {
env = append(env, "ZS_"+strings.ToUpper(k)+"="+v)
}
}
c.Env = env
c.Stdin = os.Stdin
c.Stdout = os.Stdout
c.Stderr = os.Stderr
log.Debugf("hook: %s", name)
now := time.Now()
if err := c.Run(); err != nil {
return fmt.Errorf("error running hook %s: %w", name, err)
}
log.Debugf("hook duration: %s %s", name, time.Since(now))
return nil
}
// run executes a command or a script. Vars define the command environment,
// each zs var is converted into OS environment 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 := os.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 {
if k != "content" {
env = append(env, "ZS_"+strings.ToUpper(k)+"="+v)
}
}
c.Env = env
c.Stdout = &outbuf
c.Stderr = &errbuf
log.Debugf("command: %s %s", cmd, args)
now := time.Now()
if err := c.Run(); err != nil {
log.Errorf("error running command: %s", cmd)
log.Error(errbuf.String())
return "", err
}
log.Debugf("duration: %s %s", cmd, time.Since(now))
out := outbuf.String()
log.Debugf("output: %s %s", cmd, out)
return out, 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) {
if Ignore.MatchesPath(path) {
return nil, "", nil
}
b, err := os.ReadFile(path)
if err != nil {
return nil, "", fmt.Errorf("error getting vars from %q: %w", path, 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"
sep := strings.Index(s, delim)
if sep == -1 {
return v, s, nil
}
header := s[:sep]
body := s[sep+len(delim):]
vars := Vars{}
log.Debugf("header: %s", header)
if err := yaml.Unmarshal([]byte(header), &vars); err != nil {
log.WithError(err).Warnf("failed to parse header for %s", path)
return v, s, nil
}
// Override default values + globals with the ones defines in the file
for key, value := range vars {
v[key] = value
}
// If a slug is provided in front matter, infer url/output from it
if s, ok := v["slug"]; ok && s != "" {
dir := filepath.Dir(path)
ext := filepath.Ext(path)
if ext == ".md" {
ext = ".html"
}
var u string
if dir == "." {
u = s + ext
} else {
u = filepath.ToSlash(filepath.Join(dir, s+ext))
}
u = strings.TrimPrefix(u, "./")
v["url"] = u
v["slug"] = s
} else {
v["url"] = strings.TrimPrefix(v["url"], "/")
}
v["output"] = filepath.Join(PUBDIR, v["url"])
return v, body, nil
}
// Render expanding zs plugins and variables
func render(s string, vars Vars) (string, error) {
openingDelimiter := viper.GetString("opening-delim")
closingDelimiter := viper.GetString("closing-delim")
out := &bytes.Buffer{}
for {
from := strings.Index(s, openingDelimiter)
if from == -1 {
out.WriteString(s)
return out.String(), nil
}
to := strings.Index(s, closingDelimiter)
if to == -1 {
return "", fmt.Errorf("closing delimiter not found")
}
out.WriteString(s[:from])
cmd := s[from+len(openingDelimiter) : to]
s = s[to+len(closingDelimiter):]
m := strings.Fields(strings.TrimSpace(cmd))
if len(m) == 1 {
if v, ok := vars[m[0]]; ok {
out.WriteString(v)
continue
}
}
if _, err := exec.LookPath(m[0]); err == nil {
if res, err := run(vars, m[0], m[1:]...); err == nil {
out.WriteString(res)
} else {
log.WithError(err).Warnf("error running command: %s", m[0])
}
} else {
if !viper.GetBool("production") {
fmt.Fprintf(out, "%s: plugin or variable not found", m[0])
}
}
}
}
// 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
}
source, err := render(body, v)
if err != nil {
return err
}
v["source"] = source
buf := &bytes.Buffer{}
if err := Parser.Convert([]byte(source), buf); err != nil {
return err
}
v["content"] = buf.String()
if w == nil {
outPath := v["output"]
if outPath == "" {
outPath = filepath.Join(PUBDIR, renameExt(path, "", ".html"))
}
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
return err
}
out, err := os.Create(outPath)
if err != nil {
return err
}
defer out.Close()
w = out
}
return buildText(filepath.Join(ZSDIR, v["layout"]), w, v)
}
// Renders text file expanding all variable macros inside it
func buildText(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(path).Delims("<%", "%>").Parse(body)
if err != nil {
return err
}
if w == nil {
// Default output path preserves existing behavior for text files
outPath := filepath.Join(PUBDIR, path)
// Only override when a slug is explicitly set in front matter (and not a layout under .zs/)
if !strings.HasPrefix(path, ZSDIR+string(os.PathSeparator)) && !strings.HasPrefix(path, ZSDIR+"/") {
if v["_slugset"] == "1" && v["output"] != "" {
outPath = v["output"]
}
}
if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil {
return err
}
f, err := os.Create(outPath)
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 {
r, err := os.Open(path)
if err != nil {
return err
}
defer r.Close()
if w == nil {
stat, err := os.Stat(path)
if err != nil {
return err
}
fn := filepath.Join(PUBDIR, path)
out, err := os.Create(fn)
if err != nil {
return err
}
defer out.Close()
if err := os.Chmod(fn, stat.Mode()); err != nil {
return err
}
w = out
}
if _, err := io.Copy(w, r); err != nil {
return err
}
return nil
}
func build(path string, w io.Writer, vars Vars) error {
if Ignore.MatchesPath(path) {
return nil
}
ext := filepath.Ext(path)
switch ext {
case ".md":
return buildMarkdown(path, w, vars)
case ".txt", ".html", ".css", ".xml", "json", "yml":
return buildText(path, w, vars)
}
return buildRaw(path, w)
}
func buildAll(ctx context.Context, watch bool) error {
ticker := NewTicker(time.Second)
defer ticker.Stop()
lastModified := time.Unix(0, 0)
modified := false
vars := globals()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
os.Mkdir(PUBDIR, 0755)
err := filepath.Walk(".", func(path string, info os.FileInfo, err error) error {
// rebuild if changes to .zs/ or .zsignore
if (filepath.Base(path) == ZSIGNORE || filepath.Dir(path) == ZSDIR) && info.ModTime().After(lastModified) {
if filepath.Base(path) == ZSIGNORE {
Ignore = ParseIgnoreFile(path)
}
// reset lastModified to 0 so everything rebuilds
lastModified = time.Unix(0, 0)
return nil
}
// ignore hidden files and directories and ignored patterns
// except for the default .routes file (redirects, rewrites, etc)
if filepath.Base(path) != ".routes" && (filepath.Base(path)[0] == '.' || strings.HasPrefix(path, ".") || Ignore.MatchesPath(path)) {
return nil
}
// inform user about fs walk errors, but continue iteration
if err != nil {
log.WithError(err).Warn("error walking directory")
return nil
}
if info.IsDir() {
os.Mkdir(filepath.Join(PUBDIR, path), 0755)
return nil
} else if info.ModTime().After(lastModified) {
if !modified {
// First modified file in this build cycle: fire pre-hooks.
modified = true
if err := runHook(vars, "prebuild"); err != nil {
log.WithError(err).Warn("error running prebuild hook")
}
if err := runHook(vars, "prehook"); err != nil {
log.WithError(err).Warn("error running prehook")
}
}
log.Debugf("build: %s", path)
return build(path, nil, vars)
}
return nil
})
if modified {
// At least one file in this build cycle was modified: fire post-hooks.
if err := runHook(vars, "posthook"); err != nil {
log.WithError(err).Warn("error running posthook")
}
if err := runHook(vars, "postbuild"); err != nil {
log.WithError(err).Warn("error running postbuild hook")
}
modified = false
}
if !watch {
return err
}
lastModified = time.Now()
}
}
}
// gen generates partial fragments
func generate(r io.Reader, w io.Writer, v Vars) error {
data, err := io.ReadAll(r)
if err != nil {
return err
}
body := string(data)
source, err := render(body, v)
if err != nil {
return err
}
if err := Parser.Convert([]byte(source), w); err != nil {
return err
}
return nil
}
// headings generates headings from Markdown, filtered by levels and 'min'.
// Output: TSV lines "level<TAB>id<TAB>text"
func headings(path string, w io.Writer, vars Vars, levels string, min int) error {
v, body, err := getVars(path, vars)
if err != nil {
return err
}
source, err := render(body, v)
if err != nil {
return err
}
// Use the same Goldmark parser configuration the site uses.
p := Parser.Parser()
src := []byte(source)
ctx := parser.NewContext()
doc := p.Parse(text.NewReader(src), parser.WithContext(ctx))
// Helper: extract visible text from a node subtree.
extractText := func(n ast.Node) string {
var b strings.Builder
ast.Walk(n, func(nn ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
switch t := nn.(type) {
case *ast.Text:
b.Write(t.Segment.Value(src))
// Goldmark represents soft/hard breaks via separate nodes,
// but older versions may encode hard breaks on Text as well.
if t.HardLineBreak() || t.SoftLineBreak() {
b.WriteByte(' ')
}
case *ast.ThematicBreak:
b.WriteByte(' ')
}
return ast.WalkContinue, nil
})
// Normalize whitespace: collapse runs and trim.
s := strings.Join(strings.Fields(b.String()), " ")
return s
}
// Fallback slugify if no ID was assigned by extensions.
slugify := func(s string) string {
// strip HTML entities and punctuation except space, _ and -
s = strings.ToLower(s)
var b strings.Builder
for _, r := range s {
switch {
case r >= 'a' && r <= 'z',
r >= '0' && r <= '9':
b.WriteRune(r)
case r == ' ' || r == '_' || r == '-':
b.WriteRune(r)
// skip everything else
}
}
// spaces -> hyphens, collapse, trim
sl := strings.Trim(b.String(), " -_")
sl = strings.Join(strings.Fields(strings.ReplaceAll(sl, "_", " ")), "-")
return sl
}
// parse requested levels into a set
parseLevelSet := func(spec string) map[int]bool {
m := map[int]bool{}
for _, p := range strings.Split(spec, ",") {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if n, err := strconv.Atoi(p); err == nil && n >= 1 && n <= 6 {
m[n] = true
}
}
return m
}
want := parseLevelSet(levels)
type row struct {
lvl int
id, txt string
}
var rows []row
// Collect headings, then filter & emit
err = ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
h, ok := n.(*ast.Heading)
if !ok {
return ast.WalkContinue, nil
}
level := h.Level
if level != 2 && level != 3 {
return ast.WalkContinue, nil
}
// Heading text
text := extractText(h)
if text == "" {
return ast.WalkContinue, nil
}
// Prefer ID assigned by attribute/auto-ID extensions.
var id string
if attr, ok := h.AttributeString("id"); ok {
id = string(attr.([]byte))
} else {
id = slugify(text)
}
rows = append(rows, row{lvl: h.Level, id: id, txt: text})
return ast.WalkContinue, nil
})
// Filter by requested levels
filt := make([]row, 0, len(rows))
if len(want) == 0 {
filt = rows
} else {
for _, r := range rows {
if want[r.lvl] {
filt = append(filt, r)
}
}
}
if len(filt) < min {
return nil
}
for _, r := range filt {
if _, err := fmt.Fprintf(w, "%d\t%s\t%s\n", r.lvl, r.id, r.txt); err != nil {
return err
}
}
return nil
}
// copyFile copies a file from the src fs.FS to the dst directory.
//
// This function assumes that the dst directory already exists.
func copyFile(src fs.FS, dst string, path string) error {
r, err := src.Open(path)
if err != nil {
return err
}
f, err := os.Create(filepath.Join(dst, path))
if err != nil {
return err
}
defer f.Close()
if _, err := io.Copy(f, r); err != nil {
return err
}
return nil
}
// initSite initializes the site
func initSite(dir string) error {
defaultFS, err := fs.Sub(defaultFS, "default")
if err != nil {
return fmt.Errorf("failed to get default fs: %w", err)
}
// Copy files and directories from the default structure from defaultFS
if err := fs.WalkDir(defaultFS, ".", func(path string, d fs.DirEntry, err error) error {
if path == "." || path == ".." {
return nil
}
if d.IsDir() {
os.Mkdir(filepath.Join(dir, path), 0755)
return nil
}
if err := copyFile(defaultFS, dir, path); err != nil {
return fmt.Errorf("failed to copy file: %w", err)
}
fileType, err := mimetype.DetectFile(filepath.Join(dir, path))
if err != nil {
return fmt.Errorf("failed to detect file type: %w", err)
}
// If the fileType is an executable application or script, make it executable
if fileType.Is("application/x-executable") {
if err := os.Chmod(filepath.Join(dir, path), 0755); err != nil {
return fmt.Errorf("failed to set executable permissions: %w", err)
}
}
return nil
}); err != nil {
return fmt.Errorf("failed to walk default fs: %w", err)
}
return nil
}
// serve runs a static web server and builds and continuously watches for changes to rebuild
func serve(ctx context.Context, bind, root string) error {
os.Mkdir(root, 0755)
svr, err := static.NewServer(
static.WithBind(bind),
static.WithRoot(root),
static.WithCGI(true),
static.WithDir(true),
)
if err != nil {
return err
}
url := bind
if strings.HasPrefix(bind, ":") {
url = "http://localhost" + bind
} else {
url = "http://" + bind
}
log.Infof("zs %s server listening on %s", ShortVersion(), url)
go svr.Run(ctx)
go buildAll(ctx, true)
<-ctx.Done()
return nil
}
func ensureFirstPath(p string) {
paths := strings.Split(os.Getenv("PATH"), string(os.PathListSeparator))
if len(paths) > 0 && paths[0] != p {
paths = append([]string{p}, paths...)
os.Setenv("PATH", strings.Join(paths, string(os.PathListSeparator)))
}
}
func init() {
cobra.OnInitialize(initConfig)
RootCmd.PersistentFlags().BoolP("debug", "D", false, "enable debug logging $($ZS_DEBUG)")
RootCmd.PersistentFlags().StringVarP(&configFile, "config", "C", "", "config file (default: .zs/config.yml)")
RootCmd.PersistentFlags().StringSliceP("extensions", "e", MapKeys(Extensions), "override and enable specific extensions")
RootCmd.PersistentFlags().BoolP("production", "p", false, "enable production mode ($ZS_PRODUCTION)")
RootCmd.PersistentFlags().StringP("title", "t", "", "site title ($ZS_TITLE)")
RootCmd.PersistentFlags().StringP("description", "d", "", "site description ($ZS_DESCRIPTION)")
RootCmd.PersistentFlags().StringP("keywords", "k", "", "site keywords ($ZS_KEYWORDS)")
RootCmd.PersistentFlags().StringSliceP("vars", "v", nil, "additional variables")
RootCmd.PersistentFlags().StringP("opening-delim", "o", "{{", "opening delimiter for plugins")
RootCmd.PersistentFlags().StringP("closing-delim", "c", "{{", "closing delimiter for plugins")
viper.BindPFlag("debug", RootCmd.PersistentFlags().Lookup("debug"))
viper.SetDefault("debug", false)
viper.BindPFlag("extensions", RootCmd.PersistentFlags().Lookup("extensions"))
viper.SetDefault("extensions", MapKeys(Extensions))
viper.BindPFlag("production", RootCmd.PersistentFlags().Lookup("production"))
viper.SetDefault("production", false)
viper.BindPFlag("title", RootCmd.PersistentFlags().Lookup("title"))
viper.SetDefault("title", "")
viper.BindPFlag("description", RootCmd.PersistentFlags().Lookup("description"))
viper.SetDefault("description", "")
viper.BindPFlag("keywords", RootCmd.PersistentFlags().Lookup("keywords"))
viper.SetDefault("keywords", "")
viper.BindPFlag("vars", RootCmd.PersistentFlags().Lookup("vars"))
viper.SetDefault("vars", "")
viper.BindPFlag("opening-delim", RootCmd.PersistentFlags().Lookup("opening-delim"))
viper.SetDefault("opening-delim", "{{")
viper.BindPFlag("closing-delim", RootCmd.PersistentFlags().Lookup("closing-delim"))
viper.SetDefault("closing-delim", "}}")
// Slug configuration (optional behaviors)
viper.SetDefault("slug.stopwords", false)
viper.SetDefault("slug.maxlen", 0)
ServeCmd.Flags().StringP("bind", "b", ":8000", "set the [<address>]:<port> to listen on")
ServeCmd.Flags().StringP("root", "r", PUBDIR, "set the root directory to serve")
RootCmd.AddCommand(BuildCmd)
RootCmd.AddCommand(GenerateCmd)
RootCmd.AddCommand(HeadingsCmd)
RootCmd.AddCommand(InitCmd)
RootCmd.AddCommand(ServeCmd)
RootCmd.AddCommand(VarCmd)
RootCmd.AddCommand(WatchCmd)
HeadingsCmd.Flags().StringP("levels", "l", "2,3", "comma-separated heading levels to include (e.g. 2 or 2,3,4)")
HeadingsCmd.Flags().IntP("min", "m", 2, "minimum number of headings required to emit anything")
// prepend .zs to $PATH, so plugins will be found before OS commands
w, _ := os.Getwd()
ensureFirstPath(filepath.Join(w, ZSDIR))
// Extend mimetype to support application/x-shellscript and text/x-shellscript
mimetype.Extend(func(raw []byte, _ uint32) bool {
return len(raw) >= 2 && raw[0] == '#' && raw[1] == '!'
}, "application/x-executable", "")
}
// initConfig reads in config file and ENV variables if set.
func initConfig() {
if configFile == "" {
// Use config file from .zs/config.yml
viper.AddConfigPath(ZSDIR)
viper.SetConfigName(ZSCONFIG)
} else {
// Use config file from the flag.
viper.SetConfigFile(configFile)
}
// from the environment
viper.SetEnvPrefix("ZS")
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
viper.AutomaticEnv() // read in environment variables that match
// If a config file is found, read it in.
if err := viper.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
log.WithError(err).Warnf("error reading config %s (using defaults)", viper.ConfigFileUsed())
}
}
}
// ParseIgnoreFile parsers a .zsignore file or uses the default if an error occurred
func ParseIgnoreFile(fn string) *gitIgnore.GitIgnore {
obj, err := gitIgnore.CompileIgnoreFile(ZSIGNORE)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
log.WithError(err).Warnf("error parsing .zsignore: %s (using defaults)s", fn)
}
return gitIgnore.CompileIgnoreLines(DefaultIgnore)
}
return obj
}
// isBuiltinCommand reports whether name is a built-in cobra subcommand or alias.
func isBuiltinCommand(name string) bool {
if name == "" {
return false
}
for _, c := range RootCmd.Commands() {
if c.Name() == name {
return true
}
for _, a := range c.Aliases {
if a == name {
return true
}
}
}
return false
}
// firstNonFlag returns the first argv token that is not a flag (does not start with "-").
// It returns the token and its index in argv (relative to argv slice provided).
func firstNonFlag(argv []string) (string, int) {
for i, a := range argv {
if a == "--" { // explicit end of flags
if i+1 < len(argv) {
return argv[i+1], i + 1
}
return "", -1
}
if len(a) > 0 && a[0] != '-' {
return a, i
}
// skip value of known global flags that take a value (-C/--config)
if a == "-C" || a == "--config" {
// consume the value if present
if i+1 < len(argv) {
i++
}
}
}
return "", -1
}
// execExternal tries to execute a plugin subcommand "zs-<name>" found in PATH (with .zs/ prefixed earlier).
// It mirrors the environment used by template-time extensions (see run()).
func execExternal(name string, args []string) error {
plugin := "zs-" + name
// best-effort: load config/env so globals() can populate ZS_* vars consistently
// honour -C/--config if it was provided before the subcommand
// Cobra normally runs initConfig via OnInitialize during Execute(), but we're pre-empting that.
initConfig()
vars := globals()
var env []string
env = append(env, "ZS="+os.Args[0], "ZS_OUTDIR="+PUBDIR)
env = append(env, os.Environ()...)
for k, v := range vars {
if k != "content" {
env = append(env, "ZS_"+strings.ToUpper(k)+"="+v)
}
}
cmd := exec.Command(plugin, args...)
cmd.Env = env
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Stdin = os.Stdin
// If not found, LookPath will fail when starting; expose a clearer error.
if _, err := exec.LookPath(plugin); err != nil {
return fmt.Errorf("external subcommand not found: %s (searched in PATH and %s)", plugin, ZSDIR)
}
return cmd.Run()
}
func main() {
// prepend .zs to $PATH, so plugins will be found before OS commands
cwd, err := os.Getwd()
if err != nil {
log.WithError(err).Fatal("error getting current working directory")
}
ensureFirstPath(filepath.Join(cwd, ZSDIR))
// initializes Ignore (.zsignore) patterns
Ignore = ParseIgnoreFile(ZSIGNORE)
// External subcommand dispatch (e.g., "zs newpost" -> runs "zs-newpost").
// If the first non-flag arg is not a built-in command, try to run a plugin.
if len(os.Args) > 1 {
if name, idx := firstNonFlag(os.Args[1:]); name != "" && !isBuiltinCommand(name) {
if err := execExternal(name, os.Args[1+idx+1:]); err != nil {
log.WithError(err).Error("external subcommand failed")
os.Exit(1)
}
return
}
}
if err := RootCmd.Execute(); err != nil {
log.WithError(err).Error("error executing command")
os.Exit(1)
}
}