From 884d182c861cdabc5fa4ffc0767dabd769aca3bf Mon Sep 17 00:00:00 2001 From: root Date: Thu, 23 Jul 2026 17:24:20 -0700 Subject: [PATCH] Implement repository-driven theme rendering --- README.md | 31 +- docs/OPERATIONS.md | 34 + .../0001-repository-driven-theme-rendering.md | 1231 +++++++++++++++++ package-lock.json | 84 ++ package.json | 6 +- .../.theme/assets/images/logo.svg | 4 + .../.theme/assets/scripts/theme.js | 1 + .../.theme/assets/styles/theme.css | 33 + .../.theme/templates/404.liquid | 6 + .../.theme/templates/content/article.liquid | 9 + .../.theme/templates/content/project.liquid | 28 + .../.theme/templates/home.liquid | 12 + .../.theme/templates/layout.liquid | 24 + .../.theme/templates/sections/listing.liquid | 10 + packages/site-definition/.theme/theme.yml | 29 + .../site-definition/demo-project/demo.html | 17 + packages/site-definition/navigation.yml | 10 + packages/site-definition/projects.yml | 26 + packages/site-definition/site.yml | 4 +- server.ts | 249 +--- src/backend/buildEngine.test.ts | 341 ++--- src/backend/buildEngine.ts | 473 +------ src/backend/buildInputLoader.ts | 154 +++ .../repositories/repositorySnapshot.ts | 53 + src/backend/repositoryBuildEngine.ts | 347 +++++ src/backend/store.ts | 722 +++------- src/backend/theme/project.schema.json | 33 + src/backend/theme/theme.schema.json | 83 ++ src/backend/theme/themeLoader.ts | 201 +++ src/components/ConfigAndAuditTab.tsx | 43 +- src/components/ThemeTab.tsx | 24 +- src/types.ts | 135 ++ 32 files changed, 3040 insertions(+), 1417 deletions(-) create mode 100644 docs/changes/0001-repository-driven-theme-rendering.md create mode 100644 packages/site-definition/.theme/assets/images/logo.svg create mode 100644 packages/site-definition/.theme/assets/scripts/theme.js create mode 100644 packages/site-definition/.theme/assets/styles/theme.css create mode 100644 packages/site-definition/.theme/templates/404.liquid create mode 100644 packages/site-definition/.theme/templates/content/article.liquid create mode 100644 packages/site-definition/.theme/templates/content/project.liquid create mode 100644 packages/site-definition/.theme/templates/home.liquid create mode 100644 packages/site-definition/.theme/templates/layout.liquid create mode 100644 packages/site-definition/.theme/templates/sections/listing.liquid create mode 100644 packages/site-definition/.theme/theme.yml create mode 100644 packages/site-definition/demo-project/demo.html create mode 100644 packages/site-definition/projects.yml create mode 100644 src/backend/buildInputLoader.ts create mode 100644 src/backend/repositories/repositorySnapshot.ts create mode 100644 src/backend/repositoryBuildEngine.ts create mode 100644 src/backend/theme/project.schema.json create mode 100644 src/backend/theme/theme.schema.json create mode 100644 src/backend/theme/themeLoader.ts diff --git a/README.md b/README.md index c0c6b29..31df6bd 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,20 @@ # Labyricorn Website Engine Control Plane -A React control plane and Express backend for discovering Git-hosted Markdown, validating canonical site configuration, and producing deterministic static website releases. +A React control plane and Express backend for resolving Git-owned site inputs and producing deterministic, immutable static website releases. ## Engine capabilities The build engine now: -- validates routes, route collisions, content state, raw-HTML policy, and media references; -- renders published content and navigation indexes into static HTML; -- escapes source HTML and renders a safe CommonMark-style subset; -- emits deterministic `build-manifest.json` and `checksums.json` files; -- calculates an artifact checksum from generated file checksums; -- writes releases atomically beneath the configured build root; -- activates releases through an atomic `current` symlink/junction; -- survives process restarts without reusing existing build numbers; and -- serves the active generated release through the live-preview endpoint. +- resolves the site definition and theme to exact 40-character Git commits; +- materializes detached snapshots before reading configuration, projects, templates, or assets; +- validates the closed `labyricorn-theme/v1` and project schemas without executing repository code; +- renders explicit template keys through LiquidJS in strict mode with engine-owned safe-content filters; +- copies only declared assets, verified fonts, and configuration-authorized standalone files; +- emits deterministic provenance-rich `build-manifest.json` and `checksums.json` files; +- writes immutable releases atomically beneath `releases//`; +- stages and promotes the same checksum-verified release through atomic pointers; and +- serves only a selected release through the preview endpoint, with no synthesized fallback. ## Development @@ -46,15 +46,18 @@ By default, releases are written to the platform temporary directory under `laby Each successful release contains: -- generated route directories with `index.html`; +- theme-rendered route directories with `index.html`; +- declared static assets and standalone publications; - `404.html`; - `build-manifest.json`; and - `checksums.json`. -Validation failures produce a failed build record but never publish a partial release directory. +Validation failures produce a failed build record but never publish a partial release directory. Configuration and theme state are Git-owned and read-only in control-plane v1. `PUT /api/site-config` returns `405 E_CONFIG_READ_ONLY`. ## Current boundaries -- The built-in renderer is used until repository-provided theme templates are implemented. -- Git source synchronization supports HTTP(S), SSH-style Git URLs, and local paths; other source types remain unavailable. +- Theme packages are presentation data only; Node, shell, WASM, package-manager scripts, custom Liquid tags, and custom filters are not executed. +- The reference loader resolves the configured site-definition repository and supports separately pinned source snapshots through the typed boundary; source-management UX remains follow-up work. - Remote rsync deployment is still represented by the existing control-plane simulation and must not be treated as a completed production deploy path. + +See [docs/OPERATIONS.md](docs/OPERATIONS.md) for snapshot, release, promotion, failure, and rollback procedures. diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index c27d68e..10c29b2 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -148,3 +148,37 @@ An unknown rendered-site route should return HTTP 404 and the generated `404.htm 7. Remove the old private key only after the new path has been verified end to end. Do not overwrite the active private key in place during rotation; retaining the old key until verification provides a safe rollback path. + +## Repository theme build contract + +Production presentation is owned by `.theme/` in the repository selected by `packages/site-definition/site.yml`. There is no built-in production renderer or preview fallback. Configuration is Git-owned and read-only in control-plane v1; `PUT /api/site-config` returns `405 E_CONFIG_READ_ONLY`. + +Each build resolves `LABYRICORN_SITE_DEFINITION_REF` (default `HEAD`) once to a full commit and materializes an archive of that commit. Set these variables when the site definition is not the application checkout: + +```text +LABYRICORN_SITE_DEFINITION_REPOSITORY=/srv/site-definition +LABYRICORN_SITE_DEFINITION_REF=refs/heads/main +LABYRICORN_SITE_DEFINITION_PATH=packages/site-definition +LABYRICORN_BUILD_ROOT=/var/lib/website-engine/builds +``` + +The artifact ID is derived from the canonical input descriptor. Operational run IDs and execution time do not enter artifact bytes. The release layout is: + +```text +/ +├── releases/sha256-/ +├── staging -> releases/sha256-/ +└── current -> releases/sha256-/ +``` + +Staging and promotion verify every entry in `checksums.json`. A modified artifact is rejected. Promotion updates `current`; it does not render or copy a second release. On startup, valid `staging` and `current` pointers are rediscovered and verified. + +### Theme failure behavior + +- Missing or invalid `.theme/theme.yml`: the build fails before a release directory is created. +- Unknown manifest keys, unsafe paths, missing templates/assets, or strict-Liquid errors: the build fails closed. +- No selected staging/live release: `/api/live-site/html` returns `503 E_RELEASE_UNAVAILABLE`. +- Unknown route: the selected artifact's `404.html` is returned with HTTP 404. +- Rollback activates a previously verified immutable release; it never recreates or mutates release bytes. + +Repository themes may contain Liquid templates and manifest-declared browser assets. They may not execute build programs, declare custom filters/tags, use dynamic includes, emit inline scripts/event handlers/styles, or reference undeclared local dependencies. HTTPS fonts require a pinned SHA-256 checksum and are cached only after verification. diff --git a/docs/changes/0001-repository-driven-theme-rendering.md b/docs/changes/0001-repository-driven-theme-rendering.md new file mode 100644 index 0000000..89c94f1 --- /dev/null +++ b/docs/changes/0001-repository-driven-theme-rendering.md @@ -0,0 +1,1231 @@ +# 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 ` - - - -
-
-
-
L
- ${store.siteConfig.site.title} -
- -
- - Hosted via Nginx (${activeBuild.id}) -
-
-
+ const relative = requested.replace(/^\/+/, ""); + const outputFile = relative && path.posix.extname(relative) ? relative : path.posix.join(relative, "index.html"); + const rootPath = `${path.resolve(rootDir)}${path.sep}`; + const target = path.resolve(rootDir, ...outputFile.split("/")); + if (!target.startsWith(rootPath)) return res.status(400).json({ code: "E_ROUTE_INVALID" }); - -
- ${mainContent} -
- - -
-
-

Labyricorn Deterministic Static Site Builder — Active Release: ${activeBuild.id} (${activeBuild.artifactChecksum.substring(0, 16)}...)

-

Public media served under /media • Pure CommonMark Markdown Dialect

-
-
- - - `; - - res.send(html); + res.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'"); + if (fs.existsSync(target) && fs.statSync(target).isFile()) return res.type(path.extname(target)).send(fs.readFileSync(target)); + const notFound = path.join(rootDir, "404.html"); + if (fs.existsSync(notFound)) return res.status(404).type("html").send(fs.readFileSync(notFound)); + return res.status(404).json({ code: "E_ROUTE_NOT_FOUND" }); }); // --- VITE / PRODUCTION SERVING --- diff --git a/src/backend/buildEngine.test.ts b/src/backend/buildEngine.test.ts index 8372462..876f2ab 100644 --- a/src/backend/buildEngine.test.ts +++ b/src/backend/buildEngine.test.ts @@ -1,181 +1,194 @@ import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import test from "node:test"; -import { BuildEngine, renderMarkdown } from "./buildEngine"; -import { BuildEngineInput } from "./buildEngine"; -import { ContentItem, SiteConfig, ThemeConfig } from "../types"; +import { BuildInputLoader } from "./buildInputLoader"; +import { BuildEngine, BUILDER_VERSION, renderMarkdown } from "./buildEngine"; -const siteConfig: SiteConfig = { - protocol: "labyricorn-site/v1", - site: { - id: "test-site", - title: "Test Site", - baseUrl: "https://example.test", - language: "en-US", - }, - navigation: [ - { id: "home", label: "Home", route: "/", iconName: "Home", contentModel: "page" }, - { id: "articles", label: "Articles", route: "/articles/", iconName: "FileText", contentModel: "article" }, - ], - contentModels: [], - sourcesFile: "./sources.yml", - styleInstancesPath: "./styles", - pagesPath: "./pages", - navigationFile: "./navigation.yml", - pushIntegrationsPath: "./push", - theme: { source: "site-definition", path: "/.theme" }, - markdown: { - dialect: "commonmark", - rawHtmlPolicy: "disabled", - rawHtmlEnabled: false, - extensions: { - tables: true, - taskLists: true, - footnotes: true, - definitionLists: true, - headingAnchors: true, - fencedCode: true, - syntaxHighlighting: true, - callouts: true, - youtube: true, - wikipediaLinks: true, - }, - }, - hosting: { - engine: "nginx", - production: { enabled: true, hostname: "example.test", listen: 80 }, - staging: { enabled: true, hostname: "preview.example.test", listen: 8080 }, - releases: { retainCount: 5 }, - }, - buildPolicy: { - staging: { enabled: true, requireApproval: true }, - localActivation: { automatic: false }, - push: { automatic: false }, - }, - rawYaml: "", +const git = (root: string, args: string[]): string => execFileSync("git", ["-C", root, ...args], { + encoding: "utf8", + env: { ...process.env, GIT_AUTHOR_DATE: "2026-01-01T00:00:00Z", GIT_COMMITTER_DATE: "2026-01-01T00:00:00Z" }, +}).trim(); + +const createRepository = (): string => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "labyricorn-git-fixture-")); + fs.mkdirSync(path.join(root, "packages"), { recursive: true }); + fs.cpSync(path.resolve("packages/site-definition"), path.join(root, "packages/site-definition"), { recursive: true }); + fs.cpSync(path.resolve("packages/content-models"), path.join(root, "packages/content-models"), { recursive: true }); + git(root, ["init", "-b", "main"]); + git(root, ["config", "user.name", "Fixture"]); + git(root, ["config", "user.email", "fixture@example.test"]); + git(root, ["add", "."]); + git(root, ["commit", "-m", "fixture"]); + return root; }; -const themeConfig: ThemeConfig = { - id: "test-theme", - name: "Test Theme", - version: "1.0.0", - path: "/.theme", - templates: {}, - styles: [], - scripts: [], - supportsPackages: [], - isValidated: true, - validationErrors: [], +const commit = (root: string, message: string): string => { + git(root, ["add", "-A"]); + git(root, ["commit", "-m", message]); + return git(root, ["rev-parse", "HEAD"]); }; -const content = (overrides: Partial = {}): ContentItem => ({ - id: "article-one", - title: "Article One", - slug: "article-one", - published: "2026-01-01T00:00:00.000Z", - status: "published", - artifactType: "article", - summary: "A deterministic article.", - tags: ["test"], - aliases: [], - sourceRepo: "content", - path: "articles/article-one.md", - contentMarkdown: "# Hello\n\nThis is **safe** markdown.", - mediaReferences: [], - youtubeDirectives: [], - wikipediaLinks: [], - validationStatus: "valid", - validationMessages: [], - route: "/articles/article-one/", - styleInstanceId: "default", - ...overrides, -}); +const load = (root: string, runId: string) => { + const manager = new BuildInputLoader({ repositoryRoot: root, ref: "HEAD", siteDefinitionPath: "packages/site-definition", builderVersion: BUILDER_VERSION }); + return { manager, input: manager.load(runId) }; +}; -const input = (buildId: string, items: ContentItem[]): BuildEngineInput => ({ - buildId, - siteConfig, - contentItems: items, - mediaAssets: [], - sourceCommits: { content: "abc123" }, - themeConfig, -}); +const withRepository = (callback: (root: string, output: string) => void): void => { + const root = createRepository(); + const output = fs.mkdtempSync(path.join(os.tmpdir(), "labyricorn-output-")); + try { callback(root, output); } + finally { fs.rmSync(root, { recursive: true, force: true }); fs.rmSync(output, { recursive: true, force: true }); } +}; -test("build output is deterministic for equivalent inputs", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "labyricorn-engine-test-")); +test("reference repository theme renders declared routes and only declared assets", () => withRepository((root, output) => { + const { manager, input } = load(root, "build-000001"); try { - const engine = new BuildEngine(root); - const firstItem = content(); - const secondItem = content({ - id: "article-two", - title: "Article Two", - slug: "article-two", - path: "articles/article-two.md", - route: "/articles/article-two/", - }); - const first = engine.build(input("build-one", [firstItem, secondItem])); - const second = engine.build(input("build-two", [secondItem, firstItem])); - - assert.equal(first.success, true); - assert.equal(second.success, true); - assert.equal(first.artifactChecksum, second.artifactChecksum); - assert.deepEqual(first.generatedFiles, second.generatedFiles); - assert.ok(fs.existsSync(path.join(first.outputDirectory, "build-manifest.json"))); - assert.ok(fs.existsSync(path.join(first.outputDirectory, "checksums.json"))); - assert.match( - fs.readFileSync(path.join(first.outputDirectory, "articles/article-one/index.html"), "utf8"), - /

Hello<\/h1>/, - ); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } -}); - -test("route collisions fail validation without writing a release", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "labyricorn-engine-test-")); - try { - const engine = new BuildEngine(root); - const result = engine.build( - input("collision", [content(), content({ id: "article-two", route: "/articles/article-one/" })]), - ); - - assert.equal(result.success, false); - assert.equal(result.validationReport.summary.passed, false); - assert.deepEqual(result.validationReport.routeCollisions, ["/articles/article-one/"]); - assert.equal(fs.existsSync(result.outputDirectory), false); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } -}); - -test("raw HTML is rejected when the site policy disables it", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "labyricorn-engine-test-")); - try { - const engine = new BuildEngine(root); - const result = engine.build(input("raw-html", [content({ contentMarkdown: "" })])); - - assert.equal(result.success, false); - assert.equal(result.validationReport.htmlPolicyViolations.length, 1); - assert.equal(fs.existsSync(result.outputDirectory), false); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } -}); - -test("raw HTML inside fenced code remains a valid code example", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "labyricorn-engine-test-")); - try { - const engine = new BuildEngine(root); - const result = engine.build( - input("html-code-example", [content({ contentMarkdown: "```html\n
example
\n```" })]), - ); + const result = new BuildEngine(output).build(input); assert.equal(result.success, true); - } finally { - fs.rmSync(root, { recursive: true, force: true }); - } + assert.match(input.siteDefinition.commit, /^[0-9a-f]{40}$/); + assert.ok(fs.existsSync(path.join(result.outputDirectory, "index.html"))); + assert.ok(fs.existsSync(path.join(result.outputDirectory, "projects/index.html"))); + assert.ok(fs.existsSync(path.join(result.outputDirectory, "project/website-engine-control-plane/index.html"))); + assert.ok(fs.existsSync(path.join(result.outputDirectory, "project/website-engine-control-plane/demo.html"))); + assert.ok(fs.existsSync(path.join(result.outputDirectory, "404.html"))); + assert.ok(fs.existsSync(path.join(result.outputDirectory, "assets/styles/theme.css"))); + assert.ok(fs.existsSync(path.join(result.outputDirectory, "assets/scripts/theme.js"))); + assert.ok(fs.existsSync(path.join(result.outputDirectory, "assets/images/logo.svg"))); + assert.equal(fs.existsSync(path.join(result.outputDirectory, ".theme/theme.yml")), false); + const manifest = JSON.parse(fs.readFileSync(path.join(result.outputDirectory, "build-manifest.json"), "utf8")); + assert.equal(manifest.siteDefinition.commit, input.siteDefinition.commit); + assert.equal(manifest.theme.commit, input.theme.snapshot.commit); + assert.equal(manifest.artifactBuildId, input.artifactBuildId); + assert.doesNotMatch(fs.readFileSync(path.join(result.outputDirectory, "index.html"), "utf8"), /fonts\.googleapis|tailwindcss\.com/); + } finally { manager.dispose(); } +})); + +test("equivalent resolved inputs are byte-identical despite different run IDs", () => withRepository((root, output) => { + const first = load(root, "build-000001"); + const second = load(root, "build-999999"); + try { + assert.equal(first.input.artifactBuildId, second.input.artifactBuildId); + const engine = new BuildEngine(output); + const one = engine.build(first.input); + const two = engine.build(second.input); + assert.equal(one.artifactChecksum, two.artifactChecksum); + assert.equal(one.outputDirectory, two.outputDirectory); + assert.deepEqual(one.generatedFiles, two.generatedFiles); + assert.equal(fs.readFileSync(path.join(one.outputDirectory, "checksums.json"), "utf8"), fs.readFileSync(path.join(two.outputDirectory, "checksums.json"), "utf8")); + } finally { first.manager.dispose(); second.manager.dispose(); } +})); + +test("an already resolved snapshot is unchanged when the branch advances", () => withRepository((root, output) => { + const pinned = load(root, "build-pinned"); + const css = path.join(root, "packages/site-definition/.theme/assets/styles/theme.css"); + fs.appendFileSync(css, "\n.branch-advanced{display:block}\n"); + const advancedCommit = commit(root, "advance theme css"); + const advanced = load(root, "build-advanced"); + try { + assert.notEqual(pinned.input.siteDefinition.commit, advancedCommit); + assert.equal(advanced.input.siteDefinition.commit, advancedCommit); + const engine = new BuildEngine(output); + const pinnedResult = engine.build(pinned.input); + const advancedResult = engine.build(advanced.input); + assert.notEqual(pinned.input.artifactBuildId, advanced.input.artifactBuildId); + assert.notEqual(pinnedResult.artifactChecksum, advancedResult.artifactChecksum); + assert.doesNotMatch(fs.readFileSync(path.join(pinnedResult.outputDirectory, "assets/styles/theme.css"), "utf8"), /branch-advanced/); + } finally { pinned.manager.dispose(); advanced.manager.dispose(); } +})); + +test("a missing theme fails before any release directory exists", () => withRepository((root, output) => { + fs.rmSync(path.join(root, "packages/site-definition/.theme"), { recursive: true, force: true }); + commit(root, "remove theme"); + const manager = new BuildInputLoader({ repositoryRoot: root, builderVersion: BUILDER_VERSION }); + try { + assert.throws(() => manager.load("missing-theme"), /E_THEME_ROOT_INVALID/); + assert.equal(fs.existsSync(path.join(output, "releases")), false); + } finally { manager.dispose(); } +})); + +test("unknown manifest keys and escaping paths fail closed", () => withRepository((root) => { + const manifest = path.join(root, "packages/site-definition/.theme/theme.yml"); + fs.appendFileSync(manifest, "unknownKey: rejected\n"); + commit(root, "invalid manifest key"); + let manager = new BuildInputLoader({ repositoryRoot: root, builderVersion: BUILDER_VERSION }); + assert.throws(() => manager.load("invalid-key"), /E_THEME_MANIFEST_INVALID/); + manager.dispose(); + + git(root, ["reset", "--hard", "HEAD~1"]); + fs.appendFileSync(manifest, "\n"); + const source = fs.readFileSync(manifest, "utf8").replace("templates/layout.liquid", "../outside.liquid"); + fs.writeFileSync(manifest, source); + commit(root, "escaping template path"); + manager = new BuildInputLoader({ repositoryRoot: root, builderVersion: BUILDER_VERSION }); + assert.throws(() => manager.load("escape"), /E_THEME_MANIFEST_INVALID|E_THEME_PATH_ESCAPE/); + manager.dispose(); +})); + +test("strict Liquid rejects unknown variables without publishing a release", () => withRepository((root, output) => { + const template = path.join(root, "packages/site-definition/.theme/templates/home.liquid"); + fs.appendFileSync(template, "\n{{ process.env.SECRET }}\n"); + commit(root, "unsafe variable"); + const { manager, input } = load(root, "strict-variable"); + try { + const engine = new BuildEngine(output); + assert.throws(() => engine.build(input), /undefined variable|not defined|process/i); + assert.equal(fs.existsSync(engine.buildDirectory(input.artifactBuildId)), false); + } finally { manager.dispose(); } +})); + +test("project declaration controls the project route and standalone publication", () => withRepository((root, output) => { + fs.writeFileSync(path.join(root, "packages/site-definition/projects.yml"), "projects: []\n"); + commit(root, "remove project declaration"); + const { manager, input } = load(root, "no-project"); + try { + const result = new BuildEngine(output).build(input); + assert.equal(result.success, true); + assert.equal(input.projects.length, 0); + assert.equal(fs.existsSync(path.join(result.outputDirectory, "project/website-engine-control-plane/index.html")), false); + assert.equal(fs.existsSync(path.join(result.outputDirectory, "project/website-engine-control-plane/demo.html")), false); + } finally { manager.dispose(); } +})); + +test("declared standalone files are copied byte-for-byte", () => withRepository((root, output) => { + const { manager, input } = load(root, "publication"); + try { + const result = new BuildEngine(output).build(input); + const source = fs.readFileSync(path.join(root, "packages/site-definition/demo-project/demo.html")); + const published = fs.readFileSync(path.join(result.outputDirectory, "project/website-engine-control-plane/demo.html")); + assert.deepEqual(published, source); + const manifest = JSON.parse(fs.readFileSync(path.join(result.outputDirectory, "build-manifest.json"), "utf8")); + assert.equal(manifest.publishedFiles[0].sourceChecksum, `sha256-${input.publishedFiles[0].sourceChecksum}`); + } finally { manager.dispose(); } +})); + +test("promotion verifies every artifact byte and rejects tampering", () => withRepository((root, output) => { + const { manager, input } = load(root, "tamper"); + try { + const engine = new BuildEngine(output); + const result = engine.build(input); + fs.appendFileSync(path.join(result.outputDirectory, "index.html"), "tampered"); + assert.throws(() => engine.activate(input.artifactBuildId), /E_RELEASE_CHECKSUM/); + assert.equal(fs.existsSync(engine.currentDirectory()), false); + } finally { manager.dispose(); } +})); + +test("markdown output is escaped and heading IDs are deterministic", () => { + const first = renderMarkdown("# Repeat\n\n## Repeat\n\nText and [safe](/docs/).\n\n```html\n\n```"); + const second = renderMarkdown("# Repeat\n\n## Repeat\n\nText and [safe](/docs/).\n\n```html\n\n```"); + assert.equal(first, second); + assert.match(first, /id="repeat"/); + assert.match(first, /id="repeat-2"/); + assert.match(first, /<img src=x>/); + assert.doesNotMatch(first, /