Content Management that can withstand the plundering hoards

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

  1. 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.
  2. Reserve a durable release sequence and allocate /www/releases/NNNNNN.
  3. Render source formats, navigation, indexes/repeaters and themes, then sanitize generated HTML/CSS. No general executable website JavaScript is generated.
  4. 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.
  5. Generate sitemap.xml and robots.txt.
  6. Sync the rendered tree, write durable release.json completion metadata and validate the release.
  7. Prepare durable activation intent, atomically move current, record activation history and recover the intent/history relationship on subsequent operations.
  8. 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:

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

Retention

spec.publishing.retainReleases (default 0):

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.