Publisher
The component that renders, validates and atomically publishes completed static websites. It is integrated into the CMS backend and retains a clearly defined internal interface. Publication is independent of public request handling; a publication may take seconds or minutes without affecting the currently published website.
Publication process
- Snapshot the saved project, settings, selected theme, installed packages and asset bytes. The UI saves the current page first; the API itself does not save browser-local work.
- Reserve a durable release sequence and allocate
/www/releases/NNNNNN. - Render source formats, navigation, indexes/repeaters and themes, then sanitize generated HTML/CSS. No general executable website JavaScript is generated.
- Copy theme assets and all uploaded media. Media is mirrored to
api/assets/<id>/content, matching the URL embedded by the editor, with immutable delivery metadata beside it. - Generate
sitemap.xmlandrobots.txt. - Sync the rendered tree, write durable
release.jsoncompletion metadata and validate the release. - Prepare durable activation intent, atomically move
current, record activation history and recover the intent/history relationship on subsequent operations. - Update in-process release metrics, notify asynchronous Website status sync and schedule retention cleanup.
The active release is never modified in place; each release directory is
immutable once published. Abandoned release dirs (no release.json, e.g. after
a crash) are swept on startup and before each publish/rollback.
Sanitisation (published output)
Markdown pages are first rendered from their stored source with Goldmark (CommonMark plus tables, strikethrough and automatic links; raw HTML disabled). Draft previews share this renderer. Visual pages retain their stored HTML/CSS. See Content for the source contract and template roadmap.
Before writing a release, every page's HTML is passed through a conservative
bluemonday allowlist policy and
every page's CSS through a small CSS scrubber:
- HTML: strips
<script>,<iframe>,<object>,<embed>,<style>,<link>,<meta>,<form>/<textarea>and editable inputs (only inert disabled task-list checkboxes survive); strips allon*event attributes; dropsjavascript:/data:/vbscript:inhref/src(http/https/mailto/relative only); keeps structural tags and attributes (class,id,title,lang,dir, sanitisedstyle,href/src/alt/width/height,colspan/rowspan/scope, andsrcsetre-validated to safe URLs). - CSS: strips
</stylebreakouts,@import,expression(...),-moz-bindingandurl()targets that are not http/https/relative.
Titles/descriptions are auto-escaped by html/template. This is the last line
of defence against stored XSS on the unauthenticated public origin.
Atomic activation
The publisher swaps the current symlink using an atomic rename on the same
filesystem:
ln -s releases/000003 current.new
mv -Tf current.new current
The implementation uses the filesystem APIs directly (no shell utilities inside
the runtime container): a stale current.new and a stale non-symlink current
are removed first so a corrupted pointer cannot make every publish fail with
EISDIR. Verify the semantics of the selected RWX storage provider. A pair of
ordinary directory renames must not be treated as a single atomic transaction.
Before writing the durable release.json completion marker, publication syncs
all rendered files and copied assets, then directories from the leaves through
the releases parent. A persistence failure keeps the previous release active.
The current-pointer directory is synced after activation; if that final sync
fails, publishing reports an error and preserves the already active release.
release.json is written before activation, not edited into an already-live
release afterward.
Incomplete CSS functions are discarded safely rather than causing a panic.
Activation history
/www/activations.json records every pointer move — publish and rollback —
as {releaseId, at, type: publish|rollback, previousRelease}, written
atomically (temp file + rename). status.previousRelease reflects the most
recent edge (e.g. after publish A → publish B → rollback A, previousRelease is
B) without ever mutating the immutable release directories.
Before moving current, the publisher syncs activation-intent.json with the
new release, timestamp and previous release. Status uses this intent when it
matches current, including after a restart. A failed history append leaves
the intent intact; the next activation must recover that edge into
activations.json before replacing the intent. Failure to prepare the intent
leaves the existing publication active.
Consistency
- The server resolves the active publication correctly and must avoid caching an obsolete symlink target.
- Kubernetes
subPathmounts must not pin the server to an obsolete release. - Old releases remain available long enough for in-flight requests to finish.
- Versioned or content-hashed asset URLs are preferred to avoid mixed-release page loads. Atomic activation is a pointer guarantee, not a transaction across all HTTP requests in a browser visit. Another request can observe a later pointer; the 30-second release retirement grace allows already-open reads time to complete, but is not unlimited historical URL availability.
Retention
spec.publishing.retainReleases (default 0):
0— no historical releases retained after safe cleanup.1— retain one previous release.N— retain N previous releases.
Retention is distinct from the 30-second retirement grace and asynchronous cleanup required for in-flight reads. After grace, cleanup keeps the active release plus N most recent other releases; removal failures are logged. Rollback history is not a replacement for storage backups.
Rollback
Where retained releases exist the CMS provides a rollback operation that changes the active release pointer without rerendering (unknown or already-active release ids are rejected). Release metadata records release id, publication timestamp, source project revision/snapshot, result, optional description and previous release id.
Failure handling
If rendering fails the current release stays active, the failed release is not published, the CMS reports the failure and temporary files are cleaned up safely. If activation fails the existing publication remains available where filesystem guarantees permit and the operation is safely retryable. Before activation, failed/incomplete output is removed and the old pointer remains. After the pointer moved, late durability/history errors can be reported while the new release is live; cleanup protects whichever release is current. Inspect status and the public site before assuming a failed call changed nothing.
Concurrency
Publish and rollback are serialized within this process by a mutex. The plan (§17) is a single CMS replica initially; before scaling to multiple replicas the publisher must coordinate activations across processes — an RWX advisory lock or leader election — because a concurrent activation from another process would not be serialized here. The same single-replica constraint applies to the in-memory CSRF sessions.
