James MillsandClaude Opus 5 bd8e6902f6 Take the registry hostname from a secret, not a variable
This repo is public and its Actions logs are readable anonymously: an
unauthenticated fetch of an old publish run's log returns the registry
hostname in plaintext. Gitea masks secret values in those logs; it does
not mask variables, so `vars.REGISTRY` would have kept publishing the
hostname on every run.

The guard moves off the job, because a job-level `if:` cannot read
`secrets` -- which is why it used `vars` in the first place. A first
step checks the secret and sets an output the publishing steps gate on,
so a fork without it still skips rather than fails.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TiPehVTCDGWgEBJ7H4G5VL
2026-09-01 01:00:27 +10:00
2023-08-12 13:56:54 +10:00
2026-09-01 00:31:55 +10:00
2024-12-26 12:19:27 +10:00
2026-09-01 00:36:19 +10:00
2021-09-18 09:43:22 +10:00
2021-09-18 09:43:22 +10:00
2024-12-26 12:37:52 +10:00
2026-09-01 00:30:10 +10:00
2026-09-01 00:31:55 +10:00
2026-09-01 00:31:55 +10:00
2026-09-01 00:31:55 +10:00

zs - Zen Static site generator

zs is an extremely minimal static site generator written in Go.

Build Status

Table of Contents:

Quick Start

go install go.mills.io/zs@latest
mkdir .zs && cat > .zs/layout.html <<EOF
<html>
  <head>
    <title>{{ title }}</title>
  </head>
  <body>{{ content }}</body>
</html>
EOF
cat > index.md <<EOF
---
title: Hello World
---

# Hello World

Hello World!
EOF
zs serve

For a starter template see the zs-starter-template which can also be found running live at zs.mills.io.

Features

  • Zero configuration (optional configuration file)
  • Highly configurable (flags, env vars, configuration file)
  • Cross-platform (macOS, Windows, Linux)
  • Highly extensible via plugins in any language
  • Works well for blogs and generic static websites (landing pages, etc)
  • Easy to learn
  • Fast!
  • Routes file (.routes) for redirects, rewrites, and error handling

Installation

Download the binaries from go.mills.io/prologic/zs:

go install go.mills.io/zs@latest

Or build from source manually:

git clone https://git.mills.io/prologic/zs
cd zs
make install

Ideology

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:

---
title: My web site
keywords: best website, hello, world
---

Markdown text goes after a header *separator*

The block must open with --- on the very first line of the file and close with the next --- line. A file that doesn't start with --- is content from top to bottom, so a --- thematic break partway down a document is just a horizontal rule and never a variable block.

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.

Every variable from the content header will be passed via environment variables like title becomes $ZS_TITLE and so on. There are some special variables:

  • $ZS - a path to the zs executable
  • $ZS_OUTDIR - a path to the directory with generated files
  • $ZS_FILE - a path to the currently processed markdown file
  • $ZS_URL - a URL for the currently generated page

Configuration

By default no configuration is required. Variables can be defined at the top of each content page using YAML front-matter as described in Idealogy. As your site gets more complex, you may want to define a site-level configuration file. There are a couple of ways to do this:

  • Using command-line flags of zs itself, see zs --help for configuration options.
  • Using environment variables such as ZS_PRODUCTION=1. These match the command-line flags above, are uppercase and prefixed with ZS_.
  • Using zs -c/--config ... to pass an explicit configuration file.
  • Placing a .zs/config.yml configuration file in your .zs directory.

Configuration file

The basic structure of a configuration file looks like:

---
title: zs starter template
description: A starter template for the Zen Static (zs) site generator
keywords: zen, static, zs, starter, template, site, website, template, generator, ssg

extensions:
  - typography
  - wikilink
  - fences
  - embed
  - d2

Extensions (Markdown)

zs supports content written in Markdown, index.md for example and uses the Goldmark Markdown parser with a number of extensions enabled by default:

  • anchor -- Adds anchors (permalinks) next to all headers in a document.
  • d2 -- Adds support for D2 diagrams.
  • embed -- Adds support for rendering embeds from YouTube links.
  • fences -- Support for pandoc-style fenced divs.
  • highlighting -- Adds support for syntax highlighting of code.
  • wikilink -- Adds support for wiki-style links to goldmark.

For a full-list of default extensions enabled, see zs --help and the -e/--extensions flag.

D2 diagrams

Fenced blocks tagged d2 are rendered as inline SVG by piping them through the d2 binary:

```d2
a -> b
```

zs shells out rather than linking d2 in, which keeps the binary about half the size. Install d2 to get diagrams; without it (or if a diagram doesn't compile) the block renders as an ordinary code block, so the site still builds.

Two optional configuration keys tune it:

Key Default Description
d2.command d2 Name or path of the d2 binary
d2.args -- Extra arguments, e.g. ["--theme", "200", "--sketch"]

Plugins

Plugins are just executables in any language that output content. They can be system executables like data or custom scripts or programs that you place in .zs/. To use a plugins simply reference it in your content like so:

Site last updated at {{ date }}

or:

Here's a list of support features:

{{ features }}

Where features is a script defined in .zs/features

Plugins can be written in any language you know (Bash, Python, Lua, JavaScript, Go, even Assembler).

Here are some example plugins you might find useful in your site.

Include

.zs/include:

#!/bin/sh

if [ -f "$1" ]; then
  cat "$1"
else
  echo "error: file not found $1"
fi

RSS

.zs/rss:

#!/bin/sh

for f in ./blog/*.md ; do
	d="$("$ZS" var "$f" date)"
	if [ ! -z $d ] ; then
		timestamp="$(date --date "$d" +%s)"
		url="$("$ZS" var "$f" url)"
		title="$($ZS var "$f" title | tr A-Z a-z)"
		desc="$($ZS var "$f" description)"
		echo $timestamp \
			"<item>" \
			"<title>$title</title>" \
			"<link>http://zserge.com/$url</link>" \
			"<description>$desc</description>" \
			"<pubDate>$(date --date @$timestamp -R)</pubDate>" \
			"<guid>http://zserge.com/$url</guid>" \
		"</item>"
	fi
done | sort -r -n | cut -d' ' -f2-

Looking for more plugins? Check out the contrib/plugins collection!

Hooks

Four special plugin names are executed around each build, in this order:

  • prebuild -- before any file is processed
  • prehook -- before any file is processed, after prebuild
  • posthook -- after all files are processed
  • postbuild -- after all files are processed, after posthook

Each is optional and independent: a hook that isn't found (usually meaning it isn't in .zs/) is simply skipped. Hooks only run in a build cycle where at least one file was modified.

Unlike plugins, hooks run with stdin, stdout and stderr connected to your terminal, so they can report progress or prompt you. Use prebuild to generate source files for zs to then process, and postbuild to publish what it produced.

You can use these to customize the build before and after. For example you can use the posthook to minify CSS or Javascript files.

.zs/posthook:

#!/bin/sh

minify -o "$ZS_OUTDIR/css/fa.min.css" "$ZS_OUTDIR/css/fa.css"
minify -o "$ZS_OUTDIR/css/site.min.css" "$ZS_OUTDIR/css/site.css"

Looking for more hooks? Check out the contrib/hooks collection!

External sub-commands

Any executable named zs-<name> on your $PATH (including .zs/, which zs puts first) becomes a sub-command, the way git does it:

$ cat .zs/zs-newpost
#!/bin/sh
printf -- "---\ntitle: %s\ndate: %s\n---\n" "$1" "$(date +%F)" > "posts/$(date +%F)-$1.md"

$ zs newpost "Hello World"

The plugin is run with the same ZS_* environment as template plugins, gets every remaining argument (including its own flags) untouched, and its exit code becomes zs's. A name that is neither a built-in nor a zs-<name> executable is reported as an unknown command.

Routes

zs supports a .routes file for simple request routing. This lets you configure redirects, rewrites, and error responses directly within your static site.

Syntax

Each line in .routes has the form:

<pattern> <target> [status]
  • <pattern> — A URL path or glob to match (e.g. /old, /blog/*)
  • <target> — Destination path or URL
  • [status] (optional) — HTTP status code. Defaults to 302 (temporary redirect).

Examples

Redirects

/old-about      /about              301
/blog/*         /posts/$1           302
  • Requests to /old-about become /about with a permanent 301.
  • Requests to /blog/something become /posts/something with a temporary 302.

Rewrites

/docs/*         /assets/docs/$1     200

This serves files from /assets/docs/ while keeping the URL under /docs/.

Custom error / gone

/secret         /404.html           404
/old-page       -                   410
  • /secret always serves your custom 404 page.
  • /old-page responds with 410 Gone (no body).

Useful for migrations, vanity URLs, or serving legacy paths without a reverse proxy.

Slugs

A slug in front matter changes where a document is published without renaming the file:

---
title: A Very Long Title From The Archives
slug: long-title
---

posts/a-very-long-title-from-the-archives.md is then published as posts/long-title.html, and the document's url variable matches. This works for any templated file type, not only Markdown.

zs slugify generates slugs the same way, which is what a plugin that creates pages wants:

$ zs slugify "A Very Long Title From 2019"
a-very-long-title-from-2019

$ zs slugify --stopwords "The Best of Times"
best-times

With no arguments it reads stdin a line at a time. Diacritics are folded (Café becomes cafe) rather than dropped. Both flags can be set for the whole site in .zs/config.yml instead:

Key Flag Default Description
slug.stopwords -s, --stopwords false Drop common English stop-words (a, the, of, ...)
slug.maxlen -m, --maxlen 0 Truncate to at most this many characters (0 for no limit)

Headings

zs headings <file> extracts a document's headings as tab-separated level, id, text -- enough to build a table of contents from a plugin without parsing Markdown yourself:

$ zs headings README.md
2	quick-start	Quick Start
2	features	Features

Flags:

  • -l, --levels -- comma-separated heading levels to include, 1-6 (default 2,3; empty for all).
  • -m, --min -- emit nothing unless at least this many headings match (default 2), so short pages don't get a one-item contents list.

Index

zs supports indexing documents and providing a query command for quickly retrieving documents or metadata on documents quickly and easily without having to spend time walking directory trees and parses files over and over again.

Building the index

$ zs index
12 records written to .cache.json

zs index walks the site from the current directory, honouring .zsignore, and indexes .md, .markdown, .html, .htm and .txt files into .cache.json. Being a dotfile, the index is skipped by builds.

Querying the index

  • Extract variables from a document (front matter + defaults):
zs query vars posts/2025-01-02-hello.md

zs query vars reads the index when there is one and falls back to parsing the file, so it works before zs index has ever been run. The other queries need the index.

  • Find neighbors (prev/next in chronological order within the same directory):
$ zs query neighbors posts/2025-01-02-hello.md
  • List posts by tag / year / month:
$ zs query list --tag go --year 2025 --month 9

The on-disk index is a single JSON file containing an array of records with path, url, title, date, vars, tags, and neighbors. A record's date comes from the date front matter variable, else a YYYY-MM-DD filename prefix, else the file's modification time.


Command line usage

  • zs build re-builds your site.
  • zs build <file> re-builds one file and prints resulting content to stdout.
  • zs watch rebuilds your site every time you modify any file.
  • zs serve rebuilds your site and serves it on the network.
  • zs init creates a new site in the current directory.
  • zs generate renders a fragment read from stdin to stdout.
  • zs var <filename> [var1 var2...] prints a list of variables defined in the header of a given markdown file, or the values of certain variables (even if it's an empty string).
  • zs headings <file> prints a document's headings (see Headings).
  • zs slugify <text> converts text into a URL slug (see Slugs).
  • zs index builds the document index, and zs query reads it (see Index).
  • zs <name> runs the zs-<name> executable if there is one (see External sub-commands).

For full usage see zs --help:

$ zs --help
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

Usage:
  zs [flags]
  zs [command]

Available Commands:
  build       Builds the whole site or a single file
  completion  Generate the autocompletion script for the specified shell
  generate    Generates partial fragments
  headings    Extract Markdown headings (level, id, text)
  help        Help about any command
  index       Index documents in a zs site for fast queries
  init        Initializes a new Zen Static site
  query       Query site metadata and the on-disk index
  serve       Serves the site and rebuilds automatically
  slugify     Convert text into a URL-friendly slug
  var         Display variables for the specified file
  watch       Watches for file changes and rebuilds modified files

Flags:
  -c, --closing-delim string   closing delimiter for plugins (default "}}")
  -C, --config string          config file (default: .zs/config.yml)
  -D, --debug                  enable debug logging $($ZS_DEBUG)
  -d, --description string     site description ($ZS_DESCRIPTION)
  -e, --extensions strings     override and enable specific extensions (default [anchor,cjk,d2,definitionlist,embed,fences,footnote,highlighting,linkify,strikethrough,table,tasklist,typography,wikilink])
  -h, --help                   help for zs
  -k, --keywords string        site keywords ($ZS_KEYWORDS)
  -o, --opening-delim string   opening delimiter for plugins (default "{{")
  -p, --production             enable production mode ($ZS_PRODUCTION)
  -t, --title string           site title ($ZS_TITLE)
  -v, --vars strings           additional variables
      --version                version for zs

Use "zs [command] --help" for more information about a command.

zs Users

Here's a few sites that use zs today:

Want to add your site here? File an issue or submit a pull-request!

Frequently Asked Questions

Easy! Just write a normal HTML link using an <a href="/other.html">title</a> tag or a Markdown link using the normal [title](/other.html) syntax.

License

zs is licensed under the terms of the MIT License and was originally forked from zserge/zs also licensed under the terms of the MIT License.


S
Description
️ an extremely minimal static site generator written in Go.
Readme MIT
719 KiB
Languages
JavaScript 54.6%
Go 32.6%
Shell 4.6%
CSS 4.2%
Makefile 1.3%
Other 2.7%