51 KiB
Change 0001: Repository-driven theme rendering
- Status: Implemented
- Owners: Website engine maintainers
- Target:
labyricorn-theme/v1 - Last updated: 2026-07-23
Summary
Replace the built-in production HTML and CSS renderer with a repository-controlled
theme package rooted at .theme/. A build must resolve every input to an exact Git
commit, validate the theme without executing repository code, render with a
strict Liquid-compatible engine, copy only declared static assets, and publish a
single immutable release artifact.
Themes are trusted presentation data, not executable build programs. They may contain templates and manifest-declared browser assets, but may not run Node, shell commands, package-manager scripts, Liquid extensions, or arbitrary build-time callbacks.
The first implementation is deliberately read-only. The control plane displays configuration and theme data from the resolved Git snapshot, but does not claim to persist edits until a separate reviewed commit or pull-request workflow is implemented.
Decision record
| Area | Decision |
|---|---|
| Template engine | Liquid-compatible, implemented with LiquidJS in strict mode |
| Theme location | .theme/ in the repository named by site.yml |
| Theme execution | Templates only; no repository-provided build-time code |
| Template selection | Explicit manifest keys selected by content and project configuration |
| Project publication | Configuration-first; repository content cannot create a project page by itself |
| Standalone files | Explicit source-to-route mappings in project configuration |
| CSS and browser JavaScript | Static files explicitly declared by theme.yml |
| Fonts | Git or checksum-pinned HTTPS sources, copied into the release |
| Missing or invalid theme | Fail validation and create no release |
| Configuration source | Fresh immutable snapshots at exact Git commits for every build |
| Control-plane editing | Read-only in v1 |
| Content safety | Escaped values plus explicitly typed, sanitized content fields |
| Promotion | Staging and live point to the same immutable release directory |
| Determinism | Input commits and source-file checksums are recorded in the manifest |
| Production fallback | None after migration |
Current state and problem statement
The current implementation has a real deterministic artifact writer, but its presentation and provenance do not yet match what the control plane reports.
| Current behavior | Evidence | Required change |
|---|---|---|
| Production layout and CSS are string literals. | src/backend/buildEngine.ts defines layout() and embeds a <style> block. |
Render every route from .theme/ templates and copy declared assets. |
| Theme metadata is synthetic process state. | src/backend/store.ts constructs themeConfig during startup. |
Load the manifest and files from a pinned repository snapshot. |
| Theme validation always succeeds without reading files. | POST /api/theme/validate in server.ts sets isValidated = true. |
Run the same loader, schema, path, parse, and policy checks used by builds. |
| Site configuration is loaded from the application checkout at startup. | src/backend/store.ts reads packages/site-definition/*.yml in its constructor. |
Load configuration from a pinned site-definition commit for each build. |
| The configuration editor only mutates memory. | PUT /api/site-config in server.ts changes store.siteConfig. |
Make the API and UI explicitly read-only in v1. |
| The UI says that saving created a Git commit. | src/components/ConfigAndAuditTab.tsx displays “Created Git commit”. |
Remove the editor and false persistence message. |
| Build provenance uses a placeholder. | src/backend/store.ts writes siteDefCommit: "local". |
Record the resolved repository and full commit SHA. |
| Live preview has a second hard-coded renderer. | /api/live-site/html in server.ts synthesizes HTML if no release file exists. |
Serve a release file or return an explicit unavailable/404 response. |
| Staging is represented by mutable status only. | stagingReleaseId is updated, while the configured staging root is separate. |
Point a staging link to the built release; promote that same directory. |
These mismatches are operationally significant. An operator cannot prove which repository state produced a release, theme validation does not establish that a theme exists, a restart discards edited configuration, and preview output can differ from the generated artifact.
Goals
- All production presentation comes from a validated
.theme/package. - Builds use immutable Git snapshots and remain restart-safe.
- A missing, malformed, unsafe, or incomplete theme fails before a release directory is created.
- The manifest identifies the exact site-definition, content, and theme inputs.
- Builds from identical input snapshots are byte-identical.
- Staging and live serve the identical immutable artifact.
- The control plane reports only capabilities the backend actually implements.
- Content cannot acquire script execution by crossing the Markdown-to-template boundary.
- Composite project pages are generated only from declared project configuration and may include configured metadata, relationships, and standalone file publications.
- Content models select explicit theme templates instead of relying on conditional logic in one generic content template.
Non-goals
- Arbitrary Node, shell, WASM, package-manager, or generator execution from a theme repository.
- A visual page builder.
- A runtime database-backed CMS.
- Automatic mutation of Git repositories in v1.
- Installing dependencies declared by a theme.
- General-purpose or undeclared repository file hosting.
- Server-side theme plug-ins, custom Liquid tags, or custom Liquid filters.
- Silent production fallback to a built-in renderer.
- Backward-compatible rendering of the synthetic
ThemeConfig. - Guaranteeing identical artifacts across different builder versions.
Terminology and invariants
- Build run ID is the control-plane identifier such as
build-000123. It is operational state and must not enter rendered bytes. - Input snapshot is the canonical descriptor of resolved repositories, commits, builder version, and source-file checksums.
- Artifact build ID is
sha256-<hex>over the canonical input snapshot. It is deterministic and is the value exposed to templates asbuild.id. - Release directory is the immutable directory created after a successful build.
- Theme root is the validated
.theme/directory inside the checked-out theme repository. - Declared project is a project entry in the Git-owned project configuration. Repository discovery alone never creates one.
- Published file is a regular repository file copied unchanged to one exact configured artifact route.
- Promotion changes a staging or live pointer; it never rerenders or copies a release.
The following invariants are mandatory:
- A ref is resolved once per build and all reads use the resulting full commit SHA.
- No build reads configuration, content, templates, or assets from mutable process state after snapshots are created.
- No release directory is visible until all validation and rendering steps succeed.
- A published release is never modified.
- The build run ID, wall-clock start time, temporary paths, process ID, and repository checkout paths never affect artifact bytes.
- Staging and live reference a release by directory identity and verified artifact checksum.
Repository and snapshot contract
site.yml continues to select the theme:
theme:
source: site-definition
path: .theme
source must equal a configured Git source ID. A leading slash in the existing
/.theme spelling is accepted during migration and canonicalized to .theme;
new configuration must use the repository-relative spelling.
For each build, a new BuildInputLoader performs these steps:
- Resolve the site-definition ref with Git to a full 40-character commit SHA.
- Create a detached, read-only checkout for that SHA beneath the build work root.
- Parse and validate
site.ymlfrom that checkout. - Resolve every required source ref to a full SHA and create detached checkouts. A required source that is unreachable or unresolved fails the build.
- Resolve
site.theme.sourceto one of those snapshots. In v1 this will normally besite-definition, but the contract permits a separate configured theme source. - Load navigation, content models, source definitions, style configuration,
content, and
.theme/only from these snapshots. - Create a canonical input descriptor and derive the artifact build ID.
- Pass an immutable
ResolvedBuildInputinto validation and rendering.
Resolving a ref and checking out files are part of one build operation. Cached Git object databases may be reused, but working trees and previously parsed objects may not be reused as authoritative input. A cache hit must still verify the requested commit and materialize or address that exact tree.
Production builds reject dirty working trees and symbolic refs after resolution. An explicitly configured local working-tree mode may remain for development, but it must be labeled non-reproducible, must not be promotable, and is outside the v1 production path.
The new backend boundary is:
interface RepositorySnapshot {
sourceId: string;
repository: string;
commit: string; // full SHA
checkoutRoot: string; // internal only; never serialized into an artifact
}
interface ResolvedBuildInput {
runId: string;
artifactBuildId: string;
generatedAt: string;
siteDefinition: RepositorySnapshot;
sources: ReadonlyMap<string, RepositorySnapshot>;
siteConfig: SiteConfig;
theme: LoadedTheme;
projects: readonly ResolvedProject[];
contentItems: readonly ContentItem[];
mediaAssets: readonly MediaAsset[];
publishedFiles: readonly ResolvedPublishedFile[];
}
generatedAt is deterministic: it is the latest Git committer timestamp among
the pinned input commits, normalized to UTC with whole-second precision. It is
not the build execution time. The execution time remains control-plane metadata
outside the artifact.
Configuration-first project publication
Project pages are allowlisted by Git-owned configuration. Discovery of a repository, README, devlog, or project-shaped front matter never creates a public project or route by itself.
site.yml references a project declaration file:
projectsFile: ./projects.yml
projects.yml declares each public project and all material that may be joined
into its composite page:
projects:
- id: demo-project
source: demo-project
route: /project/demo-project.html
title: Demo Project
summary: A configured Git-native project.
version: 1.0.0
license: MIT
homepage: /project/demo-project/demo.html
tags: [demo, test]
stack: [Python, HTML, CSS, Git]
presentation:
detailTemplate: project
relationships:
devlogs:
contentModel: devlog
matchField: metadata.projectId
required: false
articles:
contentModel: article
matchField: metadata.projectId
required: false
publishedFiles:
- source: demo.html
route: /project/demo-project/demo.html
mediaType: text/html
source must resolve to a configured pinned repository snapshot. id,
source, route, and presentation.detailTemplate are required. Project
metadata is schema-validated and remains inert escaped data in the render
context.
The project projection obeys these rules:
- No declaration means no project page, even when matching repository content exists.
- A duplicate project ID or route is a configuration error.
- A declared project whose source or detail template cannot be resolved fails
the build. A relationship with no matches fails only when its declaration
sets
required: true. matchFieldis a schema-defined dotted field name, not an expression. Its value must equal the declaring project'sid. Arbitrary queries, Liquid, regular expressions, and executable predicates are forbidden.- Relationship items are filtered to published, valid content and sorted by a configured stable order or the content model's canonical order.
- Git provenance shown on the project page comes from the pinned source snapshot and discovered commit metadata, never from theme-supplied values.
- The engine generates deterministic heading IDs and a table of contents from sanitized Markdown headings when that capability is enabled by the content model.
Navigation and content-model configuration select section templates by key:
navigation:
- id: projects
route: /projects
contentModel: project
presentation:
sectionTemplate: projects
The keys project and projects resolve through theme.yml; configuration
never supplies a template filesystem path.
Declared standalone files
publishedFiles is the configuration term for exact source-to-route
publication. Each entry maps one regular file in the project's pinned source
repository to one exact artifact path. The source path is repository-relative;
the route is an absolute public path and may be file-style, such as
/project/demo-project/demo.html.
Published files are copied byte-for-byte. They are not Liquid-rendered or Markdown-rendered. They are nevertheless part of the release security boundary:
- source paths receive the same traversal, symlink, special-file, and size checks as theme assets;
- every dependent local stylesheet, script, image, font, or other file must have its own declaration or resolve to an already declared release asset;
- HTML and CSS are parsed after copying and must satisfy the same script, external-origin, URL-scheme, and local-reference policy as rendered pages;
- executable server files and build scripts are never run;
- route and output-file collisions fail the build; and
- each source checksum, output route, media type, and output checksum is recorded in the build manifest.
The route writer must support both directory routes that emit index.html and
literal configured file routes. These are distinct route kinds in the internal
model and share one collision map. A published file is unreachable unless its
declaration and owning project both validate.
Theme package contract
The required layout is:
.theme/
├── theme.yml
├── templates/
│ ├── layout.liquid
│ ├── home.liquid
│ ├── 404.liquid
│ ├── sections/
│ │ ├── projects.liquid
│ │ ├── articles.liquid
│ │ └── blog.liquid
│ └── content/
│ ├── project.liquid
│ ├── article.liquid
│ └── blog.liquid
├── partials/
└── assets/
├── styles/
├── scripts/
├── images/
└── fonts/
The tree is illustrative. layout, home, and notFound are required. Every
section or content template key referenced by site, navigation, content-model,
or project configuration is also required. Unreferenced templates are allowed
but do not create routes.
theme.yml
protocol: labyricorn-theme/v1
id: labyricorn-default
version: 2.0.0
engine: liquid
templates:
layout: templates/layout.liquid
home: templates/home.liquid
notFound: templates/404.liquid
sections:
projects: templates/sections/projects.liquid
articles: templates/sections/articles.liquid
blog: templates/sections/blog.liquid
content:
project: templates/content/project.liquid
article: templates/content/article.liquid
blog: templates/content/blog.liquid
assets:
styles:
- assets/styles/theme.css
scripts:
- assets/scripts/theme.js
files:
- assets/images/logo.svg
fonts:
- id: playfair-display-500-italic
family: Playfair Display
style: italic
weight: 500
source:
kind: https
url: https://fonts.gstatic.com/example/playfair-display.woff2
checksum: sha256-0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef
format: woff2
- id: ibm-plex-mono-400
family: IBM Plex Mono
style: normal
weight: 400
source:
kind: git
sourceId: site-definition
path: .theme/assets/fonts/ibm-plex-mono-400.woff2
format: woff2
security:
allowScripts: true
allowExternalAssets: false
allowedExternalOrigins: []
The schema is closed: unknown keys fail validation. protocol and engine are
exact enums. id and version are required non-empty strings; version must be
SemVer. Template keys must match the configured presentation keys exactly. A
missing key is a build error; the renderer does not fall back to another content
template.
Content-model and project configuration selects keys, not paths. For example,
detailTemplate: project selects templates.content.project; it cannot name
templates/content/project.liquid directly. This keeps filesystem ownership in
the theme manifest while making presentation selection explicit.
Asset lists are ordered because their order controls emitted <link> and
<script> tags. Duplicate paths are invalid. Only regular files below
.theme/assets/ may be declared. Directories, globs, absolute paths, URL values,
and undeclared files are not copied, except for the explicit font-source union
described below.
Font sources
assets.fonts may contain as many font-face declarations as the theme needs,
subject to the aggregate theme size and build resource limits. Each declaration
describes one exact face, including family, style, numeric weight, format, and a
source.
A git font source identifies a configured source snapshot and a
repository-relative regular file. Its bytes come from that source's pinned
commit. An https font source identifies one exact HTTPS font-file URL and must
include its expected SHA-256 checksum. The builder:
- resolves Git font files from the pinned snapshot or downloads HTTPS font files with a versioned fixed request profile;
- rejects redirects to a different origin, credentials in URLs, non-HTTPS remote URLs, private/link-local/loopback destinations, MIME or format mismatches, and checksum mismatches;
- caches remote bytes only by verified checksum;
- copies every verified face to a deterministic local path under
/assets/fonts/; - emits deterministic
/assets/styles/fonts.generated.csscontaining the corresponding@font-facedeclarations; and - exposes that local stylesheet through
theme.fontStylesheet.
The generated site never depends on a live Google or other font host at runtime. A Google-hosted face is therefore represented by its direct HTTPS font-file URL and pinned checksum, not by an unpinned runtime stylesheet. All font source URLs, source commits, input checksums, and release paths are recorded in the manifest.
If allowScripts is false, assets.scripts must be empty and generated HTML
must not contain script elements or inline event-handler attributes. If true,
only the declared local script files may be referenced; inline scripts and
inline event handlers remain forbidden.
All author-supplied CSS must be a declared static stylesheet. Inline <style>
elements and style attributes are rejected. The engine-generated font
stylesheet is the only generated CSS exception. CSS files are parsed so every
local url() resolves to a declared release file and every external URL obeys
the external-asset policy.
If allowExternalAssets is false, allowedExternalOrigins must be empty and
runtime network-bearing src, srcset, stylesheet, object, embed, and iframe
reference is rejected. Build-time HTTPS font sources are copied locally and do
not count as runtime external assets. If external assets are enabled, each
external origin must be an exact
lower-case HTTPS origin in allowedExternalOrigins; wildcards, credentials,
paths, queries, fragments, HTTP, protocol-relative URLs, and non-default port
ambiguity are forbidden. Ordinary navigation links are link-checked but are not
assets.
External navigation links are checked syntactically without making a network
request; HTTPS and configured mailto: links are permitted.
Content-derived embeds, including YouTube embeds, are subject to the same
external-origin allowlist. For example, a theme that renders the engine's
YouTube directive must explicitly allow
https://www.youtube-nocookie.com.
Liquid profile
The renderer uses LiquidJS with:
strictVariables: true;strictFilters: true;- file-system access disabled except through an engine-owned partial resolver;
- dynamic partial names disabled;
- include/render roots fixed to
.theme/partials/; - an allowlist of standard non-executing tags and filters;
- the
rawtag and any unescaped-output aliases disabled; - maximum template size, render-output size, include depth, loop iterations, and render duration enforced; and
- no theme-provided extensions.
The exact allowlists and limits live in versioned engine code and are covered by contract tests. The initial limits are:
| Limit | Value |
|---|---|
| Individual manifest, template, or partial | 1 MiB |
| Total theme source plus declared assets | 50 MiB |
| Include depth | 20 |
| Loop iterations per rendered page | 10,000 |
| Generated HTML per route | 5 MiB |
| Render time per route | 2 seconds |
Limit failures are validation/build errors, not warnings.
layout.liquid is the outer document template. Page templates render their page
body first; the engine then invokes layout.liquid with that result in
page.body. The engine chooses the page template from the validated
configuration key and manifest mapping before rendering. Liquid cannot compute,
override, or select a template path.
Stable render context
Templates receive the following documented, JSON-serializable shape:
site:
id:
title:
language:
baseUrl:
navigation:
- id:
label:
route:
contentModel:
templateKey:
page:
type: home | section | content | project | notFound
contentModel:
templateKey:
id:
title:
route:
summary:
published:
updated:
author:
tags: []
metadata:
version:
license:
homepage:
stack: []
provenance:
sourceId:
repository:
commit:
renderedContent:
body:
tableOfContents:
- id:
text:
level:
relationships:
devlogs:
- id:
title:
route:
summary:
published:
metadata: {}
renderedContent:
articles:
- id:
title:
route:
summary:
published:
metadata: {}
renderedContent:
publications:
- source:
route:
mediaType:
collection:
items:
- id:
contentModel:
templateKey:
title:
route:
summary:
published:
tags: []
metadata: {}
theme:
id:
version:
assetBaseUrl: /assets/
styles: []
scripts: []
fontStylesheet: /assets/styles/fonts.generated.css
build:
id:
generatedAt:
sourceCommits: {}
Keys are always present. Inapplicable scalar values are null, and collections
are empty arrays or objects. Items and object keys are sorted in engine-defined
canonical order before rendering. New optional keys may be added within the
same protocol, but existing keys may not change meaning or type.
page.relationships contains only relationship names declared by the owning
project configuration and only the fields allowed by each content model's
projection schema. A project template can therefore render a composite page
with repository metadata, a generated contents panel, embedded devlogs, related
articles, and links to declared standalone publications without accessing raw
repository objects.
page.metadata and collection-item metadata use closed schemas selected by the
content model. They are not unrestricted front-matter bags. Project version,
license, homepage, and stack come from the project declaration; source commit,
updated time, and author come from verified discovery provenance.
The context contains no filesystem paths, environment variables, credentials, repository transport credentials, mutable store objects, functions, prototypes, or executable callbacks.
Content-safety boundary
All ordinary scalar values are HTML-escaped by default at Liquid output time.
Markdown is converted by the engine and sanitized against a versioned HTML
allowlist before it becomes page.renderedContent. The engine represents that
field internally as a SafeHtml value; a theme can emit it only with the
engine-owned safe_content filter:
<article class="content">
{{ page.renderedContent | safe_content }}
</article>
safe_content rejects every value except the exact engine-created safe value.
It cannot be applied to titles, summaries, front matter, repository values, or
theme variables. page.body is likewise created only by rendering a validated
page template and is emitted by the layout with safe_page_body.
Relationship renderedContent fields pass through the same sanitizer and
receive the same internal safe-value type. A composite project template may
therefore render configured devlogs without granting raw front matter access:
{% for entry in page.relationships.devlogs %}
{{ entry.renderedContent | safe_content }}
{% endfor %}
The versioned Markdown profile required for the reference theme includes headings with deterministic IDs, paragraphs, emphasis, links, lists, fenced and inline code, blockquotes, and configured alert/callout syntax. The table of contents is derived from the same sanitized heading model used to render HTML, so its IDs cannot diverge from the document.
Raw Markdown and unsanitized HTML are not included in the template context.
When rawHtmlPolicy is disabled, source HTML is escaped as today. When it is
sanitized, source HTML passes through the same allowlist and all scriptable
elements, attributes, URL schemes, and CSS are removed. Sanitization happens
before the safe value is constructed.
Theme loading and validation pipeline
ThemeLoader.load(snapshot, themePath) is a pure input-loading operation with
structured errors. BuildEngine.build() must not accept a caller-supplied
ThemeConfig claiming prior validation.
Validation runs in this order:
- Parse and schema-validate the site, navigation, content-model, and project configuration from the pinned site-definition snapshot.
- Resolve each declared project's configured source snapshot. Reject content that attempts to create a project without a declaration.
- Validate project IDs, routes, metadata, relationship selectors, presentation keys, and published-file mappings.
- Canonicalize the configured theme path as a repository-relative POSIX path.
- Reject empty paths, NUL bytes, drive prefixes, UNC paths, absolute paths,
backslashes,
.segments, and..segments. - Resolve the theme root and verify its real path remains inside the repository checkout.
- Read
.theme/theme.ymlwith size and UTF-8 checks. - Parse YAML with aliases disabled and reject duplicate mapping keys.
- Validate against the closed
labyricorn-theme/v1JSON Schema. - Resolve every configured section and detail template key through the manifest. Missing mappings fail; no fallback key is selected.
- Canonicalize every template, partial, local asset, Git font, and published source path.
- Walk the referenced trees with
lstat. For symlinks, resolve the final real path and reject links escaping the theme root or repository snapshot. Reject devices, sockets, named pipes, and other non-regular files. - Confirm every required template, declared asset, Git font, and published file exists and has the expected file type.
- Resolve every HTTPS font declaration, verify its format, size, MIME type, and pinned checksum, then assign its deterministic local release path.
- Parse all templates and partials before rendering. Reject syntax errors,
unknown tags or filters, forbidden constructs, dynamic includes, and
includes outside
.theme/partials/. - Validate the script, CSS, and external-asset policy against manifest declarations.
- Calculate SHA-256 checksums for configuration, the manifest, every template and partial, every declared asset and font, and every published source file.
- Copy assets, resolved fonts, and declared standalone files into a temporary release directory. Generate the local font stylesheet.
- Build config-authorized project, content, section, home, and 404 contexts and render their explicitly selected templates.
- Parse all rendered and copied HTML and CSS. Reject policy violations, duplicate output paths, broken internal links, missing local assets, and routes that escape the release root.
- Calculate output checksums and the artifact checksum.
- Rename the completed temporary directory to its immutable release path.
The Theme Management validation endpoint uses the same pinned site configuration
and runs steps 1 through 17 so it can verify configured template keys, fonts,
projects, and publications rather than validating an isolated manifest.
Validation never mutates ThemeConfig to manufacture success.
Error model
Build-input and theme errors use stable codes in ValidationReport.errors:
| Code | Meaning |
|---|---|
E_PROJECT_CONFIG_INVALID |
Project declaration or metadata is missing or invalid |
E_PROJECT_SOURCE_UNRESOLVED |
A declared project source cannot be resolved |
E_PROJECT_TEMPLATE_UNRESOLVED |
A configured presentation key is absent from the theme manifest |
E_PROJECT_RELATION_INVALID |
A relationship selector or required relationship is invalid |
E_PUBLISHED_FILE_INVALID |
A published source file or mapping is missing, unsafe, or invalid |
E_PUBLISHED_ROUTE_COLLISION |
A published file collides with another output route |
E_THEME_SOURCE_UNRESOLVED |
Theme source or commit cannot be resolved |
E_THEME_ROOT_INVALID |
Configured theme root is unsafe or missing |
E_THEME_MANIFEST_INVALID |
YAML or schema validation failed |
E_THEME_PATH_ESCAPE |
A path or symlink escapes an allowed root |
E_THEME_FILE_MISSING |
A required template, partial, or asset is missing |
E_THEME_FONT_SOURCE |
A font source is unsafe, unavailable, malformed, or fails checksum validation |
E_THEME_TEMPLATE_INVALID |
Liquid parsing or strict-profile validation failed |
E_THEME_RENDER_FAILED |
Strict rendering or a resource limit failed |
E_THEME_SCRIPT_POLICY |
Script declaration or generated HTML violates policy |
E_THEME_STYLE_POLICY |
CSS declaration, inline style, or CSS URL violates policy |
E_THEME_EXTERNAL_ASSET |
An external asset is undeclared or disallowed |
E_THEME_OUTPUT_INVALID |
Generated routes, links, or local asset references are invalid |
Errors include the repository-relative file path when one is safe to disclose. They never include checkout roots, credentials, environment data, or template context values.
Artifact layout and manifest
Declared local assets are copied without transformation to /assets/,
preserving their path relative to .theme/assets/. Resolved fonts use
deterministic generated paths, and project publications use their exact
configured routes:
<release>/
├── index.html
├── articles/.../index.html
├── 404.html
├── assets/
│ └── ...
├── build-manifest.json
└── checksums.json
The builder does not minify, transpile, bundle, fingerprint, or normalize theme assets or published-file contents in v1. The generated font stylesheet and template output are deterministic builder products. This keeps the artifact a transparent function of configured source bytes and builder version.
The illustrative tree also permits literal configured paths such as
project/demo-project.html and project/demo-project/demo.html, plus generated
assets/styles/fonts.generated.css and copied files below assets/fonts/.
build-manifest.json adds complete provenance:
{
"protocol": "labyricorn-build/v1",
"builderVersion": "2.0.0-labyricorn",
"artifactBuildId": "sha256-...",
"generatedAt": "2026-07-23T12:00:00Z",
"site": {
"id": "labyricorn",
"repository": "ssh://git.example/site-definition.git",
"commit": "0123456789abcdef0123456789abcdef01234567",
"configChecksum": "sha256-..."
},
"sourceCommits": {
"content": "89abcdef0123456789abcdef0123456789abcdef"
},
"theme": {
"sourceId": "site-definition",
"repository": "ssh://git.example/site-definition.git",
"commit": "0123456789abcdef0123456789abcdef01234567",
"id": "labyricorn-default",
"version": "2.0.0",
"manifestChecksum": "sha256-...",
"templateChecksums": {
"templates/layout.liquid": "sha256-..."
},
"assetChecksums": {
"assets/styles/theme.css": "sha256-..."
},
"fonts": {
"playfair-display-500-italic": {
"sourceKind": "https",
"source": "https://fonts.gstatic.com/example/playfair-display.woff2",
"inputChecksum": "sha256-...",
"output": "assets/fonts/playfair-display-500-italic.woff2",
"outputChecksum": "sha256-..."
}
}
},
"projects": {
"demo-project": {
"sourceId": "demo-project",
"commit": "fedcba9876543210fedcba9876543210fedcba98",
"templateKey": "project",
"route": "/project/demo-project.html"
}
},
"publishedFiles": [
{
"projectId": "demo-project",
"source": "demo.html",
"route": "/project/demo-project/demo.html",
"mediaType": "text/html",
"inputChecksum": "sha256-...",
"outputChecksum": "sha256-..."
}
],
"routes": ["/", "/project/demo-project.html", "/project/demo-project/demo.html"],
"media": []
}
Repository URLs are normalized identifiers with user information and
credentials removed. Maps and route lists are serialized in lexical order with
the existing stable JSON writer. Checksums use lowercase SHA-256 hex prefixed
with sha256-.
checksums.json contains the checksum of every generated HTML file, copied
asset, and build-manifest.json. It does not checksum itself. The artifact
checksum remains SHA-256 over the canonical bytes of checksums.json, avoiding
a checksum cycle.
Template and asset checksums in the manifest are checksums of repository source
bytes. Output checksums in checksums.json are checksums of artifact bytes.
Determinism requirements
Two builds are byte-identical when all of these values match:
- builder version;
- site-definition repository and commit;
- all required source repositories and commits;
- theme repository and commit;
- every configured Git and HTTPS font input checksum;
- rendering protocol versions; and
- explicitly versioned sanitizer and Liquid profiles.
To preserve that property:
- templates use the deterministic artifact build ID and
generatedAt; - files are read and written as bytes with explicit UTF-8 for text;
- emitted text uses LF line endings;
- directory enumeration, maps, routes, navigation, content, and checksums are canonically sorted where semantic order is not configured;
- configured list order is preserved where order is semantic;
- locale-sensitive, host-sensitive, and timezone-sensitive formatting is forbidden;
- generated files receive no timestamps or random identifiers in their content; and
- the build run ID is never exposed to templates or written into artifacts.
A builder-version change is allowed to change output and must be visible in the manifest.
Configuration ownership and control-plane behavior
site.yml, navigation, project declarations, content models, source
definitions, style configuration, font declarations, and .theme/ are
Git-owned. The backend loads them for each build from resolved pinned snapshots.
The v1 API behavior is:
GET /api/site-configreturns parsed configuration plus{ sourceId, repository, commit, readOnly: true }.PUT /api/site-configreturns HTTP 405 with{ code: "E_CONFIG_READ_ONLY" }.GET /api/themereturns the latest resolved manifest, source repository, commit, checksums, validation state, and validation errors. It must return an explicitunresolvedstate when no snapshot has been loaded.POST /api/theme/validateresolves or accepts an already resolved commit and runs validation steps 1 through 17, including configured template, font, project, and publication references. Its response identifies every exact commit validated.
The control plane:
- displays the site-definition source and full commit;
- displays the theme source, full commit, manifest checksum, and validation result;
- removes the editable configuration textarea and “Save & Commit to Git” control, or disables them with a clear read-only explanation;
- never says a Git commit was created unless a future backend workflow actually creates and verifies one; and
- distinguishes “not resolved”, “invalid”, and “valid at commit”.
An actual commit or pull-request editing workflow requires a later change proposal with authorization, conflict handling, review, audit, and credential design.
Staging, promotion, and recovery
A successful build creates:
<build-root>/releases/<artifact-build-id>/
The directory is made read-only after its manifest and checksums are finalized. The control plane may associate multiple build run IDs with the same artifact.
Staging atomically points <build-root>/staging to the release directory.
Promotion verifies checksums.json, then atomically points
<build-root>/current to that exact directory. It does not call the renderer,
recopy assets, change bytes, or create a second release.
Before either pointer update, the backend verifies:
- the target resolves below
<build-root>/releases/; build-manifest.jsonandchecksums.jsonexist;- all recorded output checksums match; and
- the computed artifact checksum equals the build record.
On restart, the backend reconstructs staged and live state from the two pointers and manifests rather than relying on in-memory booleans. A missing or invalid target is reported as unhealthy and is never silently replaced.
The preview endpoint serves only files from the staging or live release. If no
valid release is selected, it returns HTTP 503 with
E_RELEASE_UNAVAILABLE. An unknown route returns the artifact's 404.html with
HTTP 404. There is no synthesized HTML fallback.
Implementation plan and migration gates
Each phase is independently mergeable. The production fallback is removed only after the reference theme passes the full contract.
Phase 1: Schemas and reference fixture
- Add
src/backend/theme/theme.schema.json. - Add a closed project-declaration schema and reference
projects.yml. - Add TypeScript types for
ThemeManifestV1,LoadedTheme,RepositorySnapshot,ProjectDeclaration,PublishedFile,FontSource, andResolvedBuildInput. - Add
.theme/beneathpackages/site-definition/as the reference theme. - Model the inspected project, article, blog, composite project, and standalone demo routes in the reference fixtures.
- Add fixture themes under
src/backend/fixtures/themes/for valid and invalid contracts. - Add LiquidJS and the chosen JSON Schema validator as pinned dependencies.
Gate: schema and fixture tests pass; no production rendering changes.
Phase 2: Snapshot-based input loading
- Add
src/backend/repositories/repositorySnapshot.ts. - Add
src/backend/buildInputLoader.ts. - Resolve refs once, checkout exact commits, and load all Git-owned configuration, including project declarations, relationships, publications, and Git font sources, from snapshots.
- Separate build run state from immutable resolved input.
- Replace
"local"provenance with the full site-definition commit.
Gate: tests prove a ref moving after resolution cannot change an in-progress build, required source failures stop the build, and a restart can rediscover release provenance.
Phase 3: Theme loading and validation
- Add
src/backend/theme/themeLoader.ts. - Add
src/backend/theme/fontResolver.tswith pinned Git and HTTPS resolution. - Implement schema, explicit-template, project, relationship, published-file, path, symlink, file, policy, font, and checksum validation.
- Return structured
ValidationReporterrors. - Make
/api/theme/validatecall the real loader.
Gate: all configuration, path, manifest, and font security tests pass and validation identifies every exact source commit and remote checksum.
Phase 4: Strict Liquid rendering
- Add
src/backend/theme/liquidRenderer.ts. - Define and freeze the Liquid tag/filter allowlists and resource limits.
- Add
SafeHtml,safe_content, andsafe_page_body. - Build the stable render context for home, explicit section and content views, composite projects, relationships, table-of-contents data, and 404 routes.
- Resolve every page through its configured manifest key without a template fallback.
- Use a whole-build migration feature flag; production keeps its current default until the reference theme has parity.
Gate: reference-theme output passes route, safety, determinism, and snapshot tests.
Phase 5: Assets, output validation, and manifest v1
- Copy only declared assets, resolved fonts, and config-authorized standalone files.
- Generate the local font stylesheet and expose it to the layout context.
- Add literal file-route output alongside directory-index routes.
- Parse rendered and copied HTML plus CSS and enforce script, style, external-origin, local-asset, link, and route policies.
- Add theme and configuration provenance to
build-manifest.json. - Include copied assets, fonts, generated font CSS, and published files in
checksums.json.
Gate: changing any asset, font, or published source changes the artifact checksum; undeclared or unsafe references fail before publication.
Phase 6: Truthful control plane
- Replace synthetic
themeConfigwith loader results. - Update Theme Management to show actual manifest files, source, commit, checksums, and errors.
- Make site configuration read-only in the backend and UI.
- Distinguish operational build run IDs from artifact build IDs.
Gate: every status shown in Theme Management and Configuration is backed by the resolved snapshot or an explicit unavailable state.
Phase 7: Immutable staging and promotion
- Store releases by artifact build ID.
- Implement verified atomic staging and current pointers.
- Reconstruct active state on restart.
- Make live preview serve only the selected artifact.
Gate: staging and live resolve to the same release directory and checksum before and after restart.
Phase 8: Remove production fallbacks
- Remove the
layout()HTML/CSS strings fromsrc/backend/buildEngine.ts. - Remove synthesized preview HTML from
server.ts. - Remove the synthetic startup theme from
src/backend/store.ts. - Remove the production feature flag for the old renderer.
- Update
README.mdanddocs/OPERATIONS.md.
Gate: a repository without a valid .theme/ fails; no production code path can
emit a built-in layout.
Acceptance tests
The implementation is complete only when the following automated tests pass. Unless otherwise stated, a failed build must leave no release directory and must not change staging or live pointers.
Reference-site parity
The reference fixture must demonstrate that a theme with the inspected site's information architecture and presentation is possible without special renderer code:
RP-01: the fixture renders a dark editorial home feed, explicitly templated project/article/blog sections, article and blog details, a composite project detail, a declared standalone demo page, and a themed 404 page;RP-02: the project page contains configured version, license, tags, homepage, stack, source commit, updated time, author, generated table of contents, embedded devlogs, and related articles;RP-03: removing the project declaration removes the project page, relationships, and standalone publication even when the repository still contains matching files;RP-04: adding repository content without a matching declaration creates no public project route;RP-05: each project, article, blog, and section route uses its exact configuration-selected manifest template key;RP-06: the reference Markdown renders headings, code, lists, links, and alert/callout blocks while producing matching deterministic contents links;RP-07: declared Git and HTTPS font faces are copied into the release, the generated local font stylesheet contains the requested families, weights, and styles, and rendered HTML contains no runtime Google Fonts dependency;RP-08: any number of declared font faces works within aggregate resource limits and each face appears in manifest provenance;RP-09: the publisheddemo.htmlbytes equal the pinned source bytes and are served at/project/demo-project/demo.html;RP-10: desktop verification at 1440 CSS pixels shows the project metadata and contents beside the main project content;RP-11: mobile verification at 390 CSS pixels shows usable navigation, moves project metadata into a single-column flow, prevents horizontal overflow, and preserves readable code blocks and relationship cards; andRP-12: the reference appearance is produced by theme templates, declared CSS, local release fonts, and configured data only—no application renderer contains reference-theme markup, colors, typography, or breakpoints.
Contract and rendering
- A valid reference theme renders home, every configured section and content template, composite projects, declared files, and the 404 template.
- Changing only declared theme CSS changes the copied CSS bytes, artifact checksum, and browser-rendered appearance.
- Changing one template changes the output routes that use that template and leaves unrelated routes byte-identical.
- Two builds from identical repository commits and builder version have
identical file lists, file bytes,
checksums.json, and artifact checksum, even when their build run IDs and execution times differ. - The manifest identifies the exact site-definition, content, and theme repository commits, project declarations, published files, fonts, template mappings, and all source checksums.
- Stylesheet, generated font stylesheet, and script order in generated HTML matches manifest order.
- Undeclared files under
.theme/assets/are not copied.
Failure and filesystem safety
- A missing
theme.ymlfails withE_THEME_ROOT_INVALIDorE_THEME_FILE_MISSING. - Each missing required template fails with
E_THEME_FILE_MISSING; a missing configured template key fails withE_PROJECT_TEMPLATE_UNRESOLVED. - Invalid YAML, an unknown manifest key, unsupported protocol, unsupported
engine, and invalid SemVer each fail with
E_THEME_MANIFEST_INVALID. - Absolute paths, Windows drive paths, UNC paths, backslashes, NUL bytes, and
..traversal fail. - A symlink escaping
.theme/or the repository snapshot fails withE_THEME_PATH_ESCAPE; an internal symlink resolves only if its target remains allowed. - A template include outside
partials/, dynamic include, include cycle, or excessive include depth fails. - Missing declared assets, fonts, published files, and duplicate declarations fail.
- A template exceeding resource limits fails without leaving a partial
release.
15a. An HTTPS font with a missing or wrong checksum, unsafe destination,
cross-origin redirect, incorrect MIME type, or format mismatch fails with
E_THEME_FONT_SOURCE. 15b. Published source traversal, symlink escape, special files, unsafe media types, undeclared dependencies, and output collisions fail before copying a release.
Template and content security
- An unknown Liquid variable and unknown filter fail in strict mode.
- Templates cannot read environment variables, credentials, process state, or files outside the partial resolver.
- Theme-provided plug-ins, custom filters, and executable build files are ignored and never executed.
- Titles, summaries, tags, navigation labels, and front matter containing HTML or Liquid syntax render as escaped text.
safe_contentrejects any value other than the engine-created sanitized content value.- Markdown content containing script elements, event handlers,
javascript:URLs, unsafe SVG, or CSS cannot inject script into output. - Scripts are rejected when
allowScriptsis false. When true, undeclared local scripts, inline scripts, and event handlers are still rejected. 22a. Inline<style>elements andstyleattributes are rejected; CSS local URLs must resolve to declared output files. 22b. Copied HTML and CSS receive the same script, style, URL, and external-asset validation as rendered output. - External assets are rejected when
allowExternalAssetsis false. - When external assets are enabled, an origin absent from
allowedExternalOriginsis rejected.
Provenance, UI, and promotion
- A build reads configuration and theme files from the resolved commit even if the branch advances before rendering.
- A required repository that cannot resolve to a commit fails the build. 26a. Project pages and relationships use only the pinned project declaration and source commits, and remain unchanged if refs advance during rendering. 26b. A verified remote font cache entry is addressed by checksum and produces the same release bytes when its origin is unavailable.
PUT /api/site-configreturns 405 and does not change subsequent builds.- Theme Management reports validation of actual files at the displayed commits, configured template keys, fonts, projects, and publications; it cannot turn invalid state valid by button click alone.
- Staging and live point to the identical release directory and artifact checksum after promotion.
- Promotion detects any post-build byte modification and refuses activation.
- Restarting the service reconstructs site, theme, staged, and live provenance without relying on prior in-memory state.
- A missing theme never invokes a production fallback renderer.
- No production layout HTML or CSS remains embedded in
src/backend/buildEngine.tsor the live preview route inserver.ts.
Required verification commands
The final phase must expose these checks through the normal project scripts:
npm test
npm run lint
npm run build
npm test must include contract, security, determinism, snapshot, and promotion
tests. Integration fixtures must use local temporary Git repositories and must
not require network access or credentials.
Rollout and rollback
Rollout uses a builder feature flag only during phases 4 through 7:
LABYRICORN_THEME_RENDERER=repository
The flag selects the renderer for a whole build; mixed built-in/theme rendering within one artifact is forbidden. Repository rendering is exercised in CI and staging against the reference theme before it becomes the default.
After Phase 8, the flag and built-in renderer are deleted. Operational rollback then means activating a previously verified immutable release or deploying the previous application version. It never means silently rendering a missing theme with embedded production HTML.
Open follow-up work
The following require separate proposals and do not block this change:
- reviewed Git commit or pull-request editing from the control plane;
- additional theme protocol versions;
- asset compilation or image transformation;
- a richer component/slot contract;
- runtime content APIs; and
- cryptographic signing or attestations for build manifests.