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

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 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:

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:

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:

  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:

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:

  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:

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:

  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:

<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-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:

<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

  1. A missing theme.yml fails with E_THEME_ROOT_INVALID or E_THEME_FILE_MISSING.
  2. Each missing required template fails with E_THEME_FILE_MISSING; a missing configured template key fails with E_PROJECT_TEMPLATE_UNRESOLVED.
  3. Invalid YAML, an unknown manifest key, unsupported protocol, unsupported engine, and invalid SemVer each fail with E_THEME_MANIFEST_INVALID.
  4. Absolute paths, Windows drive paths, UNC paths, backslashes, NUL bytes, and .. traversal fail.
  5. A symlink escaping .theme/ or the repository snapshot fails with E_THEME_PATH_ESCAPE; an internal symlink resolves only if its target remains allowed.
  6. A template include outside partials/, dynamic include, include cycle, or excessive include depth fails.
  7. Missing declared assets, fonts, published files, and duplicate declarations fail.
  8. 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

  1. An unknown Liquid variable and unknown filter fail in strict mode.
  2. Templates cannot read environment variables, credentials, process state, or files outside the partial resolver.
  3. Theme-provided plug-ins, custom filters, and executable build files are ignored and never executed.
  4. Titles, summaries, tags, navigation labels, and front matter containing HTML or Liquid syntax render as escaped text.
  5. safe_content rejects any value other than the engine-created sanitized content value.
  6. Markdown content containing script elements, event handlers, javascript: URLs, unsafe SVG, or CSS cannot inject script into output.
  7. 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.
  8. External assets are rejected when allowExternalAssets is false.
  9. When external assets are enabled, an origin absent from allowedExternalOrigins is rejected.

Provenance, UI, and promotion

  1. A build reads configuration and theme files from the resolved commit even if the branch advances before rendering.
  2. 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.
  3. PUT /api/site-config returns 405 and does not change subsequent builds.
  4. 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.
  5. Staging and live point to the identical release directory and artifact checksum after promotion.
  6. Promotion detects any post-build byte modification and refuses activation.
  7. Restarting the service reconstructs site, theme, staged, and live provenance without relying on prior in-memory state.
  8. A missing theme never invokes a production fallback renderer.
  9. 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:

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.