Markdown lets you write HTML, which is how a styled page quietly becomes an
HTML document with a YAML header: every time the Markdown does not quite
stretch a <div> goes in, and nothing ever objects. Fenced containers and
attribute lists now cover the structural cases, so a site can hold the line:
html:
policy: allowlist
allow: [sup, sub, br, kbd, abbr]
`allow` stays the default, so upgrading changes no existing site.
`allowlist` passes the named elements and refuses the rest; `escape`
refuses all of it.
Refused markup is shown as the text it is and reported against the file it
came from. goldmark's own answer to unsafe HTML is to replace it with an
"<!-- raw HTML omitted -->" comment and carry on, so the page builds and
the writing is simply gone — the failure that is invisible until someone
reads the published page. Deleting somebody's content without telling them
is worse than either keeping it or refusing it, so neither policy does it,
and there is a test that says so.
contrib/hooks/prebuild-no-raw-html is the stricter sibling for sites that
would rather the build stop than publish a page with escaped markup in it.
It names the Markdown spelling for whatever it rejected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LArUFzbPFiuwMn7ZdADfyi
zs - ⚡️ Zen Static site generator
zs is an extremely minimal static site generator written in Go.
Table of Contents:
- zs - ⚡️ Zen Static site generator
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 thezsexecutable$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
zsitself, seezs --helpfor configuration options. - Using environment variables such as
ZS_PRODUCTION=1. These match the command-line flags above, are uppercase and prefixed withZS_. - Using
zs -c/--config ...to pass an explicit configuration file. - Placing a
.zs/config.ymlconfiguration file in your.zsdirectory.
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
- attributes
- 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.
- attributes -- Applies a
{.class}line to the block that follows it. - d2 -- Adds support for D2 diagrams.
- embed -- Adds support for rendering embeds from YouTube links.
- fences -- Pandoc-style fenced divs, rendering as any element you name.
- 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.
Fenced containers
A run of three or more colons opens a container, which closes at the next run of the same length with nothing after it:
:::section{.hero #top}
# Your family's cloud, in your home.
:::figure{.appliances}

:::figcaption
Design renders; the production casing may differ.
:::
:::
:::
The word immediately after the colons names the element; without one a
container is a <div>. Containers nest, and the closing ::: never carries a
name or attributes, so there is nothing to match up by eye.
| Elements | article, aside, blockquote, details, div, figcaption, figure, footer, form, header, hgroup, main, nav, section, summary |
| Attributes | {#id .class name="value"}, using the same syntax as headings. Every data-* and aria-* attribute is kept |
A name outside that set is reported in the build log and the container renders
as a <div>, so a typo shows up without failing the build.
Block attributes
Goldmark attaches {.class} to headings and code fences only, which leaves
tables, lists, blockquotes and images with no way to carry a class short of
writing them as raw HTML. A line holding nothing but an attribute list applies
those attributes to the block that follows it:
{.pricing}
| Setup option | Best for | Price |
| ------------ | ---------- | -------- |
| Yourself | Most homes | Included |
{.steps}
1. Plug it in
2. Power on
{width=1400 height=583 loading=lazy}

An attribute list directly after a link or an image applies to that instead:
[Register your interest](/register.html){.btn .btn--primary}
{width=1400 loading=lazy}
It has to butt straight up against the link; [a](/b) {.c} is a link followed
by a brace and stays that way. Only links and images take inline attributes --
Pandoc's bracketed spans, [text]{.class}, are not supported.
An attribute line binds forwards, and has to be a block of its own -- braces
part-way through a paragraph stay text. An image on its own line takes the
attributes itself rather than handing them to the paragraph around it, which is
how width, height and loading reach the <img>. A class adds to one the
block already has; every other attribute replaces it.
Raw HTML
Markdown lets you write HTML, which is how a styled page quietly becomes an HTML
document with a YAML header: every time the Markdown does not quite stretch, a
<div> goes in, and nothing objects. Now that fenced containers and attribute
lists cover the structural cases, a site can ask zs to hold the line:
html:
policy: allowlist
allow: [sup, sub, br, kbd, abbr]
| Policy | Effect |
|---|---|
allow |
Raw HTML is passed through. The default; upgrading changes nothing |
allowlist |
Only the listed elements pass. allow defaults to inline typography with no Markdown spelling |
escape |
No raw HTML passes |
Anything held back is shown as the text it is, and reported against the file
it came from. That is deliberate: goldmark's own answer to unsafe HTML is to
replace it with an <!-- raw HTML omitted --> comment and carry on, so the page
builds and the writing is simply gone. Deleting someone's content without
telling them is worse than either keeping it or refusing it.
For a site that would rather the build stop outright than publish a page with
escaped markup in it, contrib/hooks/prebuild-no-raw-html is a pre-hook that
fails the build instead, and names the Markdown spelling for whatever it
rejected.
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 processedprehook-- before any file is processed, afterprebuildposthook-- after all files are processedpostbuild-- after all files are processed, afterposthook
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.
A pre-hook that exits non-zero stops the build, and nothing is written to
the output directory. That makes prebuild the place to refuse a build on your
own terms — a lint, a schema check, a generator that couldn't produce its
inputs — since building on from inputs a hook has just rejected is worse than
not having the hook. A failing post-hook is reported but does not fail the
build: by then the output exists and is correct, and a minifier falling over
shouldn't throw it away. Under zs serve a failed pre-hook leaves the previous
output in place and the next save gets a clean attempt.
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 to302(temporary redirect).
Examples
Redirects
/old-about /about 301
/blog/* /posts/$1 302
- Requests to
/old-aboutbecome/aboutwith a permanent 301. - Requests to
/blog/somethingbecome/posts/somethingwith 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
/secretalways serves your custom 404 page./old-pageresponds 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 (default2,3; empty for all).-m, --min-- emit nothing unless at least this many headings match (default2), 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 buildre-builds your site.zs build <file>re-builds one file and prints resulting content to stdout.zs watchrebuilds your site every time you modify any file.zs serverebuilds your site and serves it on the network.zs initcreates a new site in the current directory.zs generaterenders 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 indexbuilds the document index, andzs queryreads it (see Index).zs <name>runs thezs-<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,attributes,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:
- https://yarn.social -- Landing page of the decentralized microBlogging ecosystem.
- https://salty.im -- Landing page of the e2e encrypted IndieWeb inspired messaging protocol.
- https://zs.mills.io -- zs starter template demo.
- https://prologic.shortcircuit.net.au -- Home page of James Mills / prologic (author of zs)
Want to add your site here? File an issue or submit a pull-request!
Frequently Asked Questions
How do I link to other pages?
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.