Vitrine prepares a structured-Markdown entity-manager repository for publishing as a static website with vanilla MarkPub, by transforming a copy of the content — never the source.
The name is the boundary: a vitrine is a museum display case; it presents the objects unaltered. The source repository is read-only to this tool. Every change happens on a staged copy under a throwaway work directory, and the publisher runs against that copy.
To see what that produces, there is a complete worked example: markpub/probes-wiki, an entity-managed wiki of space probes, published live at probes-wiki.pages.dev — typed records, cross-reference shadows, .vitrine.yaml metadata display, and a .vitrineignore'd directory you can see in the repo but not on the site.
Entity-manager repos keep records as Markdown-with-YAML-frontmatter, with three conventions that plain MarkPub publishes badly:
- Filenames are bare IDs (
DEC-001.md); the human title lives in frontmattertitle:. MarkPub derives the page name from the filename, so titles,<h1>s, nav, and all-pages listings all showDEC-001. (The filenames are load-bearing for the upstream validator, so renaming is not an option.) - References carry a validator-only shadow:
[label](../x/ID.md)^[ID](../x/ID.md). MarkPub renders the trailing^[ID](path)as a literal caret plus a duplicate link. - Relative
.mdlinks stay.mdin the rendered HTML, so cross-references open raw Markdown source instead of the rendered page.
build.sh <source-repo> <output-site> orchestrates:
- copy —
copy_content.pycopies the source into$WORK/content, excluding.git,.obsidian,.claude,.markpub,node_modules(and the work/output dirs themselves). The source is never written to. A.vitrineignoreat the source root (gitignore syntax, as documented incopy_content.py) excludes further paths from the published site while leaving them fully git-tracked — for metadata that belongs in the repo but not on the website (e.g. correspondence between collaborators). It is a publish-time filter only; it never touches git. File modes, mtimes, symlinks, and empty directories all survive the copy.- 1b. scaffold — non-interactive
markpub initon the copy supplies.markpub/(config + dolce theme). Answers are piped fromSITE_TITLE/SITE_AUTHOR/SITE_REPO; init'snetlify.tomland.github/side-effects are removed. Scaffolding is regenerated each build, so nothing needs committing.
- 1b. scaffold — non-interactive
- metadata —
gen_metadata.pyrenders each record's relevant frontmatter as a bullet list under the title, per the content repo's optional.vitrine.yaml(metadata:section: field list per entity type, in display order;_default: none|all|[fields]for the rest — same travel-with-the-content pattern as.vitrineignore, and equally unpublished). Values that reference another record become links labeled with that record's title; URLs autolink. No config, no change. - rename (only with
VITRINE_PAGE_NAMES=slug) —rename_pages.pyrenames record pages on the copy to<id>-<slug>(frontmatterslug:if present, else the slugifiedtitle:), so records publish under readable URLs (/entities/decision/dec-001-build-a-shared-password-protected-website...) while the source repo keeps its bare-ID filenames — publishing names are a view concern. A rename map is written for the later stages:vitrine.pyrewrites link targets through it, andmarkpub_post.pypoints Edit buttons back at the real source files. - navigate —
gen_navigation.pygenerates the navigation MarkPub does not provide: a## Contentssection (the room's tree — subfolders nested, Markdown files linked by frontmattertitle:, non-Markdown files like transcripts linked by filename) appended to each room's folder note; a site-widesitemap.md; and a404.md. A room with no note gets one created in the copy (README.md, or<room>/<room>.mdwithVITRINE_FOLDER_NOTE=1). Emitted targets are site-absolute and mirror MarkPub's path scrub (spaces →_), so links tozoom transcript.txtand kin actually resolve. The entity room keeps its own upstream-generated index. - sidebar —
gen_sidebar.pywritesSidebar.mdinto the copy from the repo's actual room structure (entity index, library, meetingroom, project, tools, logging — only rooms that exist; door: folder note<room>/<room>.mdfirst, elseREADME.md; plus a SITEMAP entry). Links are universal Markdown[LABEL](/path), never[[wikilinks]](forbidden by the repo's convention); includes the MarkPub RANDOM PAGE button idiom, an "About this space" block, and an AI-generated disclosure. - vitrine —
vitrine.pyapplies the three transforms in place on the copy (publisher-agnostic; see below). - build — vanilla, unmodified
markpub buildrenders the copy into<output-site>. If node/npm are present, the lunr search index is built (powers SEARCH and RANDOM PAGE); if not, the build still succeeds without it. - post —
markpub_post.py(the one deliberately MarkPub-specific piece) rewrites the places MarkPub keys off the filename stem: page<title>tags, all-pages/recent-pages listings, and the lunr posts list that drives search-result display. - gate — when
VITRINE_GATE=basic-auth,deploy/_worker.jsis installed into the output as a Pages Function gating every request behind HTTP basic-auth. The gate is part of the build rather than a step someone adds to a deploy command, and the build log states the access posture either way — an ungated site used to be the silent result of a forgottencp.
- T1 — human titles. If frontmatter has
title:, insert# {title}as the first body line (unless an H1 already leads). Records withouttitle:fall back to their ID. Filenames are never touched. - T2 — strip validator shadows. Remove
^[ID](path)immediately following a link, leaving the human[label](path)intact. - T3 —
.md→.html, absolutized. Rewrite relative/local Markdown link targets (including#anchorsand reference-style definitions) so cross-references resolve to rendered pages on any static host — and resolve them to site-absolute paths (/entities/decision/DEC-001.html), computed from each file's location. Relative links are only correct when a page is served at its true URL; hosts that fall back to serving something for unknown routes (Cloudflare Pages without a404.htmlserves the home page with a 200) let one bad URL compound into ever-deeper phantom paths. Absolute links end that class of failure; the generated 404 page closes the other half. Externalhttp(s):///mailto:targets are never touched. Runs after T2.
Guardrails: fenced code blocks and inline code spans are never modified — a ^[ or .md in an example survives byte-for-byte. Frontmatter is preserved verbatim. Everything outside the three transforms is byte-identical.
Requirements: Python 3.12+ with markpub 2.1.2+ and pyyaml; optionally node/npm for the search index. Nothing outside the Python standard library is used by Vitrine's own stages, so there are no system tools to install. markpub 0.x is unsupported and build.sh refuses to run against it — pip resolves markpub to the ancient 0.4.5 on Python ≤3.11 (2.x requires ≥3.12), which is exactly the trap the version guard exists to catch. One 2.x behavior worth knowing: markpub emits a .json sidecar per page containing that page's full frontmatter, independent of what .vitrine.yaml selects for display — on an ungated site, treat all frontmatter as published.
SITE_TITLE=comroom SITE_AUTHOR="WSD group" SITE_REPO=github.com/WSD-Talks/comroom \
bash vitrine/build.sh /path/to/comroom /path/to/output-site
Tests: python3 -m unittest discover -s tests from the repository root. Standard library only, nothing to install — see tests/README.md.
Environment knobs (all optional): PYTHON (default python3; must be ≥3.12 with markpub ≥2.1.2 — an isolated venv, e.g. python3.12 -m venv ~/.venvs/markpub && ~/.venvs/markpub/bin/pip install markpub pyyaml, then PYTHON=~/.venvs/markpub/bin/python, sidesteps PEP 668 restrictions on system Pythons), VITRINE_WORK (work dir, default $PWD/_work, wiped each run), SITE_TITLE (default: source basename), SITE_AUTHOR, SITE_REPO (enables per-page "Edit on GitHub" buttons pointing at the source .md files), VITRINE_GATE (none — default — publishes an ungated, publicly readable site; basic-auth installs deploy/_worker.js, an HTTP basic-auth Pages Function reading SITE_USER/SITE_PASSWORD from the host; the build log states which you got either way), VITRINE_FOLDER_NOTE (set to 1 so rooms lacking a note get <room>/<room>.md created instead of README.md), VITRINE_PAGE_NAMES (filename — default — publishes under source names; slug renames record pages on the copy to <id>-<slug>).
Vendor vitrine/ into the content repo (or fetch it in the build command), then in the Pages project settings:
- Build command:
pip install markpub && bash vitrine/build.sh . _site - Build output directory:
_site - Environment variables:
SITE_TITLE,SITE_AUTHOR,SITE_REPOas desired. The image'spython3must be ≥3.12 sopip install markpubresolves to 2.x — the build's version guard fails loudly if it resolved to 0.x instead.
The Pages build image provides node/npm, so search and RANDOM PAGE work out of the box.
Vitrine never mutates the source repository. It stages a copy, transforms the copy, and publishes the copy. If you ever find a diff in the source after a build, that is a bug in Vitrine, not a feature.
MIT — see LICENSE. Copyright 2026 Peter Kaminski; contributions are made under the same license.