Files
git-website-engine-control-…/docs/changes/0001-repository-driven-theme-rendering.md
T

1232 lines
51 KiB
Markdown

# 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 as `build.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:
1. A ref is resolved once per build and all reads use the resulting full commit
SHA.
2. No build reads configuration, content, templates, or assets from mutable
process state after snapshots are created.
3. No release directory is visible until all validation and rendering steps
succeed.
4. A published release is never modified.
5. The build run ID, wall-clock start time, temporary paths, process ID, and
repository checkout paths never affect artifact bytes.
6. Staging and live reference a release by directory identity and verified
artifact checksum.
## Repository and snapshot contract
`site.yml` continues to select the theme:
```yaml
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:
1. Resolve the site-definition ref with Git to a full 40-character commit SHA.
2. Create a detached, read-only checkout for that SHA beneath the build work
root.
3. Parse and validate `site.yml` from that checkout.
4. Resolve every required source ref to a full SHA and create detached
checkouts. A required source that is unreachable or unresolved fails the
build.
5. Resolve `site.theme.source` to one of those snapshots. In v1 this will
normally be `site-definition`, but the contract permits a separate configured
theme source.
6. Load navigation, content models, source definitions, style configuration,
content, and `.theme/` only from these snapshots.
7. Create a canonical input descriptor and derive the artifact build ID.
8. Pass an immutable `ResolvedBuildInput` into 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:
```ts
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:
```yaml
projectsFile: ./projects.yml
```
`projects.yml` declares each public project and all material that may be joined
into its composite page:
```yaml
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:
1. No declaration means no project page, even when matching repository content
exists.
2. A duplicate project ID or route is a configuration error.
3. 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`.
4. `matchField` is a schema-defined dotted field name, not an expression. Its
value must equal the declaring project's `id`. Arbitrary queries, Liquid,
regular expressions, and executable predicates are forbidden.
5. Relationship items are filtered to published, valid content and sorted by a
configured stable order or the content model's canonical order.
6. Git provenance shown on the project page comes from the pinned source
snapshot and discovered commit metadata, never from theme-supplied values.
7. 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:
```yaml
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:
```text
.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`
```yaml
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:
1. resolves Git font files from the pinned snapshot or downloads HTTPS font
files with a versioned fixed request profile;
2. 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;
3. caches remote bytes only by verified checksum;
4. copies every verified face to a deterministic local path under
`/assets/fonts/`;
5. emits deterministic `/assets/styles/fonts.generated.css` containing the
corresponding `@font-face` declarations; and
6. 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 `raw` tag 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:
```yaml
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:
```liquid
<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:
```liquid
{% 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:
1. Parse and schema-validate the site, navigation, content-model, and project
configuration from the pinned site-definition snapshot.
2. Resolve each declared project's configured source snapshot. Reject content
that attempts to create a project without a declaration.
3. Validate project IDs, routes, metadata, relationship selectors, presentation
keys, and published-file mappings.
4. Canonicalize the configured theme path as a repository-relative POSIX path.
5. Reject empty paths, NUL bytes, drive prefixes, UNC paths, absolute paths,
backslashes, `.` segments, and `..` segments.
6. Resolve the theme root and verify its real path remains inside the repository
checkout.
7. Read `.theme/theme.yml` with size and UTF-8 checks.
8. Parse YAML with aliases disabled and reject duplicate mapping keys.
9. Validate against the closed `labyricorn-theme/v1` JSON Schema.
10. Resolve every configured section and detail template key through the
manifest. Missing mappings fail; no fallback key is selected.
11. Canonicalize every template, partial, local asset, Git font, and published
source path.
12. 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.
13. Confirm every required template, declared asset, Git font, and published
file exists and has the expected file type.
14. Resolve every HTTPS font declaration, verify its format, size, MIME type,
and pinned checksum, then assign its deterministic local release path.
15. Parse all templates and partials before rendering. Reject syntax errors,
unknown tags or filters, forbidden constructs, dynamic includes, and
includes outside `.theme/partials/`.
16. Validate the script, CSS, and external-asset policy against manifest
declarations.
17. Calculate SHA-256 checksums for configuration, the manifest, every template
and partial, every declared asset and font, and every published source file.
18. Copy assets, resolved fonts, and declared standalone files into a temporary
release directory. Generate the local font stylesheet.
19. Build config-authorized project, content, section, home, and 404 contexts and
render their explicitly selected templates.
20. 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.
21. Calculate output checksums and the artifact checksum.
22. 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:
```text
<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:
```json
{
"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-config` returns parsed configuration plus
`{ sourceId, repository, commit, readOnly: true }`.
- `PUT /api/site-config` returns HTTP 405 with
`{ code: "E_CONFIG_READ_ONLY" }`.
- `GET /api/theme` returns the latest resolved manifest, source repository,
commit, checksums, validation state, and validation errors. It must return an
explicit `unresolved` state when no snapshot has been loaded.
- `POST /api/theme/validate` resolves 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:
```text
<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:
1. the target resolves below `<build-root>/releases/`;
2. `build-manifest.json` and `checksums.json` exist;
3. all recorded output checksums match; and
4. 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`, and
`ResolvedBuildInput`.
- Add `.theme/` beneath `packages/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.ts` with pinned Git and HTTPS resolution.
- Implement schema, explicit-template, project, relationship, published-file,
path, symlink, file, policy, font, and checksum validation.
- Return structured `ValidationReport` errors.
- Make `/api/theme/validate` call 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`, and `safe_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 `themeConfig` with 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 from `src/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.md` and `docs/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 published `demo.html` bytes 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; and
- `RP-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
1. A valid reference theme renders home, every configured section and content
template, composite projects, declared files, and the 404 template.
2. Changing only declared theme CSS changes the copied CSS bytes, artifact
checksum, and browser-rendered appearance.
3. Changing one template changes the output routes that use that template and
leaves unrelated routes byte-identical.
4. 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.
5. The manifest identifies the exact site-definition, content, and theme
repository commits, project declarations, published files, fonts, template
mappings, and all source checksums.
6. Stylesheet, generated font stylesheet, and script order in generated HTML
matches manifest order.
7. Undeclared files under `.theme/assets/` are not copied.
### Failure and filesystem safety
8. A missing `theme.yml` fails with `E_THEME_ROOT_INVALID` or
`E_THEME_FILE_MISSING`.
9. Each missing required template fails with `E_THEME_FILE_MISSING`; a missing
configured template key fails with `E_PROJECT_TEMPLATE_UNRESOLVED`.
10. Invalid YAML, an unknown manifest key, unsupported protocol, unsupported
engine, and invalid SemVer each fail with `E_THEME_MANIFEST_INVALID`.
11. Absolute paths, Windows drive paths, UNC paths, backslashes, NUL bytes, and
`..` traversal fail.
12. A symlink escaping `.theme/` or the repository snapshot fails with
`E_THEME_PATH_ESCAPE`; an internal symlink resolves only if its target
remains allowed.
13. A template include outside `partials/`, dynamic include, include cycle, or
excessive include depth fails.
14. Missing declared assets, fonts, published files, and duplicate declarations
fail.
15. 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
16. An unknown Liquid variable and unknown filter fail in strict mode.
17. Templates cannot read environment variables, credentials, process state,
or files outside the partial resolver.
18. Theme-provided plug-ins, custom filters, and executable build files are
ignored and never executed.
19. Titles, summaries, tags, navigation labels, and front matter containing HTML
or Liquid syntax render as escaped text.
20. `safe_content` rejects any value other than the engine-created sanitized
content value.
21. Markdown content containing script elements, event handlers, `javascript:`
URLs, unsafe SVG, or CSS cannot inject script into output.
22. Scripts are rejected when `allowScripts` is false. When true, undeclared
local scripts, inline scripts, and event handlers are still rejected.
22a. Inline `<style>` elements and `style` attributes 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.
23. External assets are rejected when `allowExternalAssets` is false.
24. When external assets are enabled, an origin absent from
`allowedExternalOrigins` is rejected.
### Provenance, UI, and promotion
25. A build reads configuration and theme files from the resolved commit even if
the branch advances before rendering.
26. 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.
27. `PUT /api/site-config` returns 405 and does not change subsequent builds.
28. 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.
29. Staging and live point to the identical release directory and artifact
checksum after promotion.
30. Promotion detects any post-build byte modification and refuses activation.
31. Restarting the service reconstructs site, theme, staged, and live provenance
without relying on prior in-memory state.
32. A missing theme never invokes a production fallback renderer.
33. No production layout HTML or CSS remains embedded in
`src/backend/buildEngine.ts` or the live preview route in `server.ts`.
## Required verification commands
The final phase must expose these checks through the normal project scripts:
```bash
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:
```text
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.