Skip to content
velella-shPublic

About

A deterministic docs engine with exact source positions.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

Mizzen

Mizzen

Same input. Same site.
A deterministic docs engine with exact source positions.

CI npm coverage skills.sh

Quick start · Documentation · Contributing · Lateen


A deterministic docs engine with exact source positions: config, the content model, Markdown, navigation, the render host, outputs, diagnostics, and the CLI. No source file imports the theme's code, and no theme color, layout, or stylesheet ships here. A Lateen checkout beside this repository is only the CLI's fallback when no theme is configured. test/boundary.test.ts enforces that boundary.

Why Mizzen

  • Deterministic. The same input gives byte-identical output, in the same order, with LF line endings.
  • Traceable. Every page keeps the exact source position of its text, so a tool or an agent can edit the right line.
  • Agent-ready. A build also writes llms.txt, Markdown twins, a source map, and per-page JSON beside the HTML.
  • Theme-free. The engine ships no colors or layouts; Lateen draws the pages.

A build runs eight stages, each a function from typed input to typed output: config, discover, front matter, Markdown, HTML, site model, render, and outputs. Stages talk only through the frozen types in src/contracts/. The same input gives byte-identical output, in the same order, with LF line endings.

No build step

The source is TypeScript that Node runs directly through type stripping (Node 24 or later). Use only erasable syntax (no enum, namespace, or parameter properties), import local files with their .ts extension, and mark type-only imports with import type. tsconfig.json enforces this and emits nothing; never commit compiled output.

Commands

  • npm test runs node --test on test/**/*.test.ts.
  • npm run typecheck runs tsc --noEmit.

Many tests build the fixtures in fixtures/ with the theme, so check out Lateen next to this repository (../lateen) and run npm ci in both.

CLI

The package installs one command, mizzen, from bin/mizzen.js, which runs the built dist/cli/index.js. Its help is the source of truth for the flags:

Usage: mizzen <command> [dir] [options]

Commands:
  build [dir]               Build the site into --out (default: dist)
  check [dir]               Build to a temp folder and print diagnostics only
  data fetch [dir]          Fetch and pin HTTPS collection sources (--check verifies only)
  dev [dir]                 Serve the site, rebuild on change, and reload the browser
  doctor [dir]              Check the config, content, output paths, runtime, and toolchain
  edit apply <patch.json> [dir]
                            Apply a span patch to its source file and record a proof
  edit verify <proof.json>  Check an edit proof against the site and rerun the doctor
  pack [dir]                Print one context pack for a topic or an ad-hoc request, to stdout or --out
  verify [dir]              Check a built folder against the hash in its manifest
  watch [dir]               Rebuild the site whenever a source or theme file changes

Options:
  --out <path>         Output folder: build (default: dist), dev (default: .mizzen/dev), watch (default: .mizzen/watch); pack writes a file here instead of stdout
  --theme <dir>        Theme root folder (layouts/, chrome/, components/)
  --format <format>    text, json, or sarif (check and doctor; default: text); text or json for edit apply and edit verify; markdown or json for pack
  --hash <hex>         Expected build hash for verify (default: the manifest's)
  --site <dir>         Site root for edit verify (default: .)
  --doctor             Rerun the doctor for edit verify
  --port <number>      Port for dev (default: 3000; 0 picks a free one)
  --host <host>        Host for dev (default: 127.0.0.1)
  --workers <n|auto>   Render pages in n worker threads; auto is the CPU count minus one (default: 1)
  --check              For data fetch: compare with mizzen.lock.json without writing
  --request <file|->   Versioned JSON context request for pack; - reads stdin
  --topic <name>       Topic for pack (default: the site's only topic); not with --request
  --budget <tokens>    Token budget for pack, overriding the topic's; not with --request
  --dry-run            Print the diff for edit apply without writing
  --allow-unchecked    Apply an edit apply patch that carries neither "expected" nor "beforeHash"
  --proof <path>       Proof record for edit apply (default: <patch>.proof.json)
  -h, --help           Show this help

Exit codes: 0 ok, 1 errors, 2 usage.

From this repository, run it as node bin/mizzen.ts; from a site that installed the package, as mizzen.

Command What it does
mizzen build [dir] --out <dir> --theme <dir> Builds dir (default .) into --out (default dist). Prints diagnostics as text. A build with an error still writes the site and exits 1.
mizzen check [dir] --format text|json|sarif --theme <dir> Builds into a temporary folder, prints only the diagnostics in the chosen format, and removes the folder. Exits 1 on an error.
mizzen data fetch [dir] --check Fetches every https: collection source, stores it under .mizzen/data/, and writes mizzen.lock.json; --check compares without writing.
mizzen dev [dir] --port <number> --host <host> --theme <dir> Builds dir, serves the output with pretty URLs, watches the source and theme, rebuilds on change, and reloads the browser over Server-Sent Events. The reload script is added to HTML responses only; the files on disk stay as build wrote them. Ctrl-C stops the server.
mizzen doctor [dir] --format text|json|sarif --theme <dir> Checks the runtime, the config, the content, the output paths, and the toolchain without writing to the site, and prints the findings in the chosen format. Exits 1 on an error.
mizzen edit apply <patch.json> [dir] --dry-run --proof <path> --allow-unchecked Applies one span patch (the JSON shape of src/edits/patch.ts) to the source file its span.file names, resolved against dir and refused outside it. The patch is applied in memory and the file is replaced through a rename only when it succeeds, so a refused patch leaves it byte-identical. A patch that carries neither expected nor beforeHash is refused (exit 2) unless --allow-unchecked is given; one that carries only one is still refused. Writes an edit proof (src/edits/proof.ts) with the SHA-256 before and after, at --proof or <patch>.proof.json, and prints its path. --dry-run prints a unified diff and writes neither. Exits 1 on a refused patch or path, 2 on a missing or invalid patch. --format json prints the same outcome as one report: status, exitCode, the file, the proof, and any diagnostics with their path and span.
mizzen edit verify <proof.json> --site <dir> --doctor Re-hashes every file the edit's proof record names, under --site (default .), and compares each with its after-edit hash. The first mismatch is named, and the command exits 1; a proof that cannot be read exits 2. With --doctor it also runs mizzen doctor --format json and compares the level with the recorded one; without it, the hashes are checked and the command says the doctor check did not run. --format json prints the same outcome as one report, with the failed check, its path, and the doctor levels it compared.
mizzen pack [dir] --topic <name> --budget <tokens> --out <file> Prints the context pack for one topic in _data/context-packs.yml to stdout, or writes it to --out. --budget overrides the topic's budget; without --topic the site's only topic is used, and a site with several lists them and exits 2. Diagnostics go to stderr, so stdout stays clean Markdown.
mizzen verify [dir] --hash <hex> Hashes a built folder by the same rule the build used and compares it with .mizzen/manifest.json (or --hash). Exits 1 on a mismatch.
mizzen watch [dir] --out <dir> --theme <dir> Builds dir once, then rebuilds whenever a source or theme file changes. Changes are debounced and builds run one at a time, in order; an unchanged build is restored from .mizzen/cache. Ctrl-C stops the watcher.

--out is resolved from the current working directory, not from the site root, for build, dev, pack, and watch; dev's default is <site>/.mizzen/dev and watch's is <site>/.mizzen/watch. check is the command to use in CI: it never touches dist. pack reads the site model and never the theme, so it needs no theme root.

--workers <n|auto> (for build and check) renders pages in n worker threads, each one loading the theme; auto is the CPU count minus one. The site written is byte for byte the one --workers 1 writes, diagnostics included. The default is 1: a small site would pay for threads it cannot use, so a large one opts in with --workers auto. A worker that fails renders its pages in the main thread and adds a render.worker-failed warning. There is no config key, because how many cores a machine has is a machine setting, not a property of the site.

The theme root is picked in this order:

  1. --theme <dir>, resolved from the current working directory.
  2. theme.name in mizzen.yml: a folder relative to the site root, an absolute path, or a package name resolved from the site and then from the current directory.
  3. A lateen checkout beside this repository (../lateen), when it exists.

A page with no layout in its front matter or in defaults gets the layout doc from the CLI. A programmatic build has no default unless it passes defaultLayout.

Config

A site's settings live in mizzen.yml at its root; every path in the file is relative to that folder. Without the file the engine builds with the defaults below and the folder name as the title. The top level is closed: an unknown key is an error with its line and column. The schema is schema/config.schema.json, and SiteConfig in src/contracts/config.ts is the same shape.

Key Type Default What it does
title string the folder name Site name in the header, page titles, llms.txt, and JSON-LD.
description string One-sentence summary for the home page's meta description and llms.txt.
url string Scheme and host of the published site, without a trailing slash, such as https://example.com. Needed for canonical links, social images, and an absolute sitemap; without it they are relative or left out, with a warning.
base string "" Path the site lives under, starting with / and without a trailing slash; empty at a domain root. Every URL the engine writes carries it.
lang string en Default language of every page, as a BCP 47 tag. A page's lang wins.
dir ltr or rtl from lang Default text direction: rtl for fa, ar, he, ur, and the other right-to-left languages. A page's dir wins.
languages list of {code, name, prefix} [] The site's languages, in switcher order. Leave it out for a one-language site.
source string . Folder that holds the content, relative to the site root. Files outside it are not pages.
include list of strings [] Paths under source that discovery would skip (names starting with . or _) but should copy or build, such as .well-known.
exclude list of strings [] Paths under source that are neither pages nor copied. node_modules, the output folder, and mizzen.yml are always excluded.
navigation string _data/navigation.yml Navigation file, relative to the site root. Without it, navigation is built from folders and each page's order.
defaults list of {path, values} [] Front matter applied to every page under a path unless the page sets the key itself. Later entries win over earlier ones; a page always wins.
repository {url, branch, dir} Where the source lives, for "Edit this page" links and the "Edit with your agent" prompt. branch defaults to main; dir is the site root's folder in the repository, default "".
theme {name, options} The theme package or folder, and the options handed to it unread. The theme validates its own options.
search false or {text} {} false builds no search index. text is the most characters of a page's body the index keeps (default 300); titles and headings are always indexed.
redirects map of path to path or URL {} Redirect pages: each key is an old path under base, each value a path on the site or an https URL.
outputs map of name to boolean every output on Turns one output off by its name, such as twins: false. The names are in Outputs.
title: Beacon
description: Documentation for the Beacon deploy CLI.
url: https://example.com
base: /beacon
lang: en
source: docs
navigation: _data/navigation.yml
repository:
  url: https://github.com/example/beacon
  branch: main
  dir: docs
defaults:
  - path: ""
    values:
      layout: doc
search:
  text: 300
redirects:
  /old/: /new/
outputs:
  twins: false

mizzen.config.ts is not a second config file. It holds code only: the site's plugins, default-exported as { plugins: [...] }. It cannot set a key of mizzen.yml, so every setting stays readable without running the site's code.

Programmatic API

The package exposes two entry points today:

Entry Exports
@velella/mizzen version.
@velella/mizzen/contracts The frozen contract types, type-only.

test/contracts-import.test.ts builds the package and imports each entry point the way a consumer does, so the table above is checked against the artifact.

Inside the repository, each stage is a function with typed input and output:

Function File Returns
build(options) src/pipeline/build.ts BuildResult: the output folder, every file written (sorted), and every diagnostic.
buildModel(root) src/model/build.ts ModelResult: the complete site model plus diagnostics. It parses, renders Markdown, and builds navigation, but writes nothing.
applyFacts({ site, root, sourceDir, dir? }) src/model/facts.ts ApplyFactsResult: the site with each derived fact's value at data[fact.name], plus diagnostics. buildModel calls it.
loadConfig(root, options?) src/config/load.ts LoadConfigResult: a complete SiteConfig after defaults, plus diagnostics.
loadRegistry(themeRoot) src/render/registry.ts ThemeRegistry: the theme manifest, layouts, chrome, and components.
loadCodeConfig(root, options?) src/plugins/hooks.ts LoadedCode: the site's plugins and diagnostics. See Plugins.

build() takes:

  • root: the site root, the folder that holds mizzen.yml.
  • out: the output folder, replaced as a whole. It must be outside source, or its first segment must start with _ or ., as in _site.
  • themeRoot: the theme's root folder, which holds src/index.ts and the layouts/, chrome/, and components/ folders.
  • themeFiles: theme files to copy, as paths relative to themeRoot; each lands under assets/theme/ with its hash in the name.
  • defaultLayout: the layout for a page that names none; without it such a page is an error.
  • outputsDir: the folder outputs are read from; default src/outputs/.
  • cache: reuse an unchanged build's output from the cache; off by default, and mizzen build turns it on.
  • cacheDir: the cache folder; default <root>/.mizzen/cache, and only used when cache is true.
  • workers: pages to render at once in worker threads; default 1, which renders in this process. 2 or more starts that many workers, each loading the theme.

Every output runs first, in memory, and its files are checked: a path outside out is an error, and so is a path two outputs both write (the first one wins). Then the site is written to a staging folder beside out and swapped in only when complete. A site's own file wins over an output's file at the same path, with no diagnostic. Problems in content and theme come back as diagnostics; a throw means a bug, such as an output or layout that threw, and leaves out as it was.

import { build, type BuildResult } from "../src/pipeline/build.ts";

/** Builds a site and returns what was written. */
export function buildSite(root: string, out: string, themeRoot: string): Promise<BuildResult> {
  return build({ root, out, themeRoot, defaultLayout: "doc" });
}

buildModel() is the API for a tool that reads the site without writing it, such as a linter or an MCP server. Its diagnostics are the same ones a build reports.

Outputs

Each *.ts file in an outputs folder is one unit. The pipeline finds them by listing the folder in file-name order; adding one needs no edit to a shared index. A file exports exactly one Output, under any export name, and its name must equal the file name without .ts. A file that exports no output is a helper and is not run.

Name Writes
agents-md AGENTS.md, a guide to the built site for agents.
changelog-feed changelog.xml, an Atom feed of the changelog page's dated release headings.
code-meta-check No files: reports fence meta that names lines the fence does not have.
context-pack pack/<topic>.md for each topic in _data/context-packs.yml: the best pages for the topic, trimmed to its token budget, with each page's URL and source path.
drift-report drift.json, every drift finding grouped by page URL.
examples-report examples.json, per page: the runnable examples and how many passed, failed, or were skipped.
feed changes.xml, an Atom feed of the 30 most recently changed doc pages, when the config has url.
footnote-check No files: reports footnote references with no definition.
html One HTML file per document page, at the page's pretty URL.
link-check No files: reports internal links and heading anchors that do not resolve.
llms llms.txt, following llmstxt.org.
llms-full llms-full.txt, every page's Markdown in one file.
manifest .mizzen/manifest.json, the page list, per-page source and HTML hashes, and the build hash.
not-found 404.html, the page at /404/ when the site has one, else a minimal page.
opensearch opensearch.xml, the site's search as an OpenSearch provider, when the config has url.
page-agent One JSON file per document page with an agent: block (agent.json for a pretty URL): URL, source, and the page's instructions.
page-json One JSON file per document page (index.json for a pretty URL): URL, title, description, headings, source, last change, and estimated tokens.
pages-files .nojekyll, and CNAME when the config's url is a custom domain on GitHub Pages.
reading-time reading-time.json, the word count and reading minutes of each doc page.
redirects One HTML redirect page per old path, and _redirects for hosts that read it.
related related.json, the few pages each doc page is most related to.
review-coverage review-coverage.json, each doc page's review policy, reviewers, last review, and coverage state.
robots robots.txt, with the sitemap address when the config has url.
search pagefind/, a static search index.
search-synonyms pagefind.yml, the search synonyms from _data/search-synonyms.yml.
site-files No files: names the root files a site may own (llms.txt, llms-full.txt, robots.txt, sitemap.xml) so its own copy wins.
sitemap sitemap.xml.
sourcemap sourcemap.json, the line-exact source map (format).
translations translations.json, each page's status in every configured language.
trust trust.json, each doc page's trust level, reasons, and last reviewer.
twins One Markdown twin per document page: the same URL with .md in place of the HTML file.
variables variables.json, every variable placeholder a page uses and its declaration.
version-diff diff/<version>/<path>.json for each adjacent pair of versions whose page sections differ.
versions versions.json, the versions each page path exists in and how it differs from the newest.

outputs in mizzen.yml turns one off by name:

outputs:
  twins: false
  search: false

The contract is in src/contracts/output.ts:

interface Output {
  readonly name: string;
  run(site: Site, ctx: OutputContext): Promise<readonly OutputFile[]>;
}

ctx.renderPage(page) renders a page to a complete HTML document through the theme. ctx.report(diagnostic) records a finding; an output reports a content problem instead of throwing. run() returns files sorted by path, each { path, contents }, where path is relative to the output folder and uses /, and contents is a string (UTF-8, LF) or a Uint8Array.

A custom outputs folder is passed to build() as outputsDir. It replaces the built-in folder for that build, so a custom output can either live beside the built-ins or be the whole set.

import type { Output } from "@velella/mizzen/contracts";

/** Writes feed.txt; the file name must be feeds.ts, so the output's name is feeds. */
export const feeds: Output = {
  name: "feeds",
  async run() {
    return [{ path: "feed.txt", contents: "hello\n" }];
  },
};

Plugins

A site adds behavior from mizzen.config.ts at its root. The file default-exports its plugins and nothing else:

import type { Plugin } from "@velella/mizzen/contracts";

export default {
  plugins: [
    /* Plugin objects, in the order they run. */
  ],
};

A site without the file is an ordinary site: no plugins, no diagnostics. A file Node cannot import, or a default export without a plugins list, is one error naming the file. An entry that is not a plugin is dropped with an error and the sound entries still run.

A plugin is a name for diagnostics and any of three hooks. Each hook receives a value and returns the value the next plugin receives; none mutates its input. A hook may be async, and it is awaited before the next plugin runs.

Hook Runs Receives Returns
content Once per page, after its Markdown is parsed and before the site model is assembled. Page Page
model Once, after the site model is assembled and before anything is rendered. Site Site
outputs Once, with the files the outputs produced and the site they came from, before the files are written. readonly OutputFile[], Site readonly OutputFile[]

A hook that throws, or returns something the stage cannot use, is an error naming the plugin. The value the stage carried before that plugin is kept and the plugins after it still run, so one bad plugin costs its own work and nothing more. The diagnostic id is plugin.hook-failed.

import type { Plugin } from "@velella/mizzen/contracts";

const banner: Plugin = {
  name: "banner",
  content(page) {
    return { ...page, frontMatter: { ...page.frontMatter, banner: true } };
  },
};

export default { plugins: [banner] };

The loader and the hook runners are loadCodeConfig(), runContentHooks(), runModelHooks(), and runOutputsHooks() in src/plugins/hooks.ts. Each runner returns { value, diagnostics } and never fails the stage. build() loads mizzen.config.ts and runs every hook in the order above, once per stage, before anything is written. A site with a code file always builds cold, since what its imports read cannot be part of the cache key, and the file itself is never copied into the output.

Layout

  • src/index.ts: the public entry. It exports only version for now.
  • src/contracts/: the frozen types every stage shares; types only, nothing runs.
  • src/config/, src/content/, src/markdown/, src/model/, src/nav/, src/pipeline/, src/plugins/, src/render/, src/outputs/, src/source/: the pipeline's stages and units.
  • src/model/facts/: one file per derived fact, merged into Site.data at the end of the model stage; see its README.
  • src/outputs/: one file per output; see its README.
  • bin/mizzen.js: the installed mizzen command, which runs the built dist/cli/index.js.
  • bin/mizzen.ts: the same command run from a checkout.
  • schema/config.schema.json: the site config schema.
  • test/: *.test.ts files for node --test.

Contributing

Bug reports, fixes, and docs improvements are welcome. See CONTRIBUTING.md to get started.

License

Apache-2.0.


Mizzen is part of Velella. The mast behind the sail.

About

A deterministic docs engine with exact source positions.

Topics

Resources

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages