James MillsandClaude Opus 5 f3ec839719 Let a site decide what raw HTML may do
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
2026-09-12 14:31:26 +10:00
2023-08-12 13:56:54 +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
2026-09-12 12:10:32 +10:00
2026-09-12 12:10:32 +10:00
2026-09-12 12:10:32 +10:00
2026-09-12 14:27:45 +10:00
2026-09-01 00:30:10 +10:00
2026-09-12 14:31:26 +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
  - 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}
![The two appliances](/img/appliances.webp)
:::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}
![The two appliances](/img/appliances.webp)

An attribute list directly after a link or an image applies to that instead:

[Register your interest](/register.html){.btn .btn--primary}

![The two appliances](/img/appliances.webp){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 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.

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 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,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:

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
869 KiB
Languages
JavaScript 45.2%
Go 43%
Shell 4.9%
CSS 3.5%
Makefile 1.1%
Other 2.3%