Implement repository-driven theme rendering

This commit is contained in:
root
2026-07-23 17:49:10 -07:00
parent 68d76a59df
commit 884d182c86
32 changed files with 3040 additions and 1417 deletions
+17 -14
View File
@@ -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/<artifact-build-id>/`;
- 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.
+34
View File
@@ -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
<build-root>/
├── releases/sha256-<input-digest>/
├── staging -> releases/sha256-<input-digest>/
└── current -> releases/sha256-<input-digest>/
```
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.
File diff suppressed because it is too large Load Diff
+84
View File
@@ -11,8 +11,10 @@
"@google/genai": "^2.4.0",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"ajv": "8.20.0",
"dotenv": "^17.2.3",
"express": "^4.21.2",
"liquidjs": "10.27.2",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"react": "^19.0.1",
@@ -1747,6 +1749,22 @@
"node": ">= 14"
}
},
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -1967,6 +1985,15 @@
],
"license": "CC-BY-4.0"
},
"node_modules/commander": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
"integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -2294,6 +2321,28 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.4",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -2699,6 +2748,12 @@
"bignumber.js": "^9.0.0"
}
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -2993,6 +3048,26 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/liquidjs": {
"version": "10.27.2",
"resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.27.2.tgz",
"integrity": "sha512-kvknfAEtOHjHkAAv7GxLEJh8ghpMQm3Fc4uWVyF7hERSTsSRsdC7saWs0p5aDG7GDcWsu5o+T4232O+8KZO55w==",
"license": "MIT",
"dependencies": {
"commander": "^10.0.0"
},
"bin": {
"liquid": "bin/liquid.js",
"liquidjs": "bin/liquid.js"
},
"engines": {
"node": ">=16"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/liquidjs"
}
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
@@ -3427,6 +3502,15 @@
"node": ">=0.10.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/retry": {
"version": "0.13.1",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
+4 -2
View File
@@ -16,8 +16,10 @@
"@google/genai": "^2.4.0",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"ajv": "8.20.0",
"dotenv": "^17.2.3",
"express": "^4.21.2",
"liquidjs": "10.27.2",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"react": "^19.0.1",
@@ -26,13 +28,13 @@
"yaml": "^2.9.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"esbuild": "^0.25.0",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.3",
"@types/express": "^4.17.21"
"vite": "^6.2.3"
}
}
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Labyricorn mark">
<path fill="#c5a059" d="M8 8h12v36h36v12H8z"/>
<path fill="#f5f0e7" d="M28 8h28v12H40v16H28z"/>
</svg>

After

Width:  |  Height:  |  Size: 204 B

@@ -0,0 +1 @@
document.documentElement.dataset.themeReady = "true";
@@ -0,0 +1,33 @@
:root{color-scheme:dark;--bg:#090909;--panel:#121212;--ink:#f4f0e8;--muted:#a7a29a;--gold:#c5a059;--line:#292722;font-family:Georgia,"Times New Roman",serif}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--ink);line-height:1.65}
a{color:var(--gold);text-underline-offset:.2em}
img{max-width:100%}
.site-header,.site-footer,main{width:min(1180px,calc(100% - 2rem));margin:auto}
.site-header{min-height:76px;display:flex;align-items:center;justify-content:space-between;gap:2rem;border-bottom:1px solid var(--line)}
.brand{display:flex;align-items:center;gap:.7rem;color:var(--ink);text-decoration:none;font-weight:700}
nav{display:flex;gap:1.15rem;flex-wrap:wrap;font:600 .72rem/1.3 ui-monospace,monospace;text-transform:uppercase;letter-spacing:.08em}
main{padding:5rem 0 7rem}
.hero{max-width:850px;padding:3rem 0 5rem}
.hero h1,.section-heading h1,.project-body>h1,.not-found h1{font-size:clamp(2.8rem,7vw,6.8rem);line-height:.95;letter-spacing:-.045em;margin:.2em 0}
.hero p:not(.eyebrow),.lede{font-size:1.3rem;color:var(--muted);max-width:62ch}
.eyebrow{font:700 .7rem/1.2 ui-monospace,monospace;text-transform:uppercase;letter-spacing:.16em;color:var(--gold)}
.feed{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1px;background:var(--line);border:1px solid var(--line)}
.feed>h2,.feed>p{grid-column:1/-1;background:var(--bg);margin:0;padding:1.5rem}
.feed article{background:var(--panel);padding:2rem;min-height:180px}
.section-heading{margin-bottom:3rem}
.project-grid,.content-grid{display:grid;grid-template-columns:minmax(220px,300px) minmax(0,1fr);gap:clamp(2rem,7vw,7rem);align-items:start}
.project-meta,.content-grid>aside{position:sticky;top:1rem;border-top:2px solid var(--gold);padding-top:1rem}
dt{font:700 .65rem/1.2 ui-monospace,monospace;text-transform:uppercase;letter-spacing:.1em;color:var(--muted)}
dd{margin:0 0 1rem;overflow-wrap:anywhere}
.project-body{max-width:760px}
.project-body h2,.prose h2{margin-top:2.5em;border-bottom:1px solid var(--line);padding-bottom:.35em}
.tags{display:flex;gap:.5rem;list-style:none;padding:0;flex-wrap:wrap}
.tags li{border:1px solid var(--line);padding:.2rem .55rem;font:600 .72rem/1.5 ui-monospace,monospace}
pre{overflow:auto;padding:1rem;background:#050505;border:1px solid var(--line)}
code{font-family:ui-monospace,monospace;font-size:.9em}
blockquote{margin-left:0;border-left:3px solid var(--gold);padding-left:1rem;color:var(--muted)}
.site-footer{display:flex;justify-content:space-between;gap:1rem;border-top:1px solid var(--line);padding:2rem 0;color:var(--muted);font-size:.8rem}
.not-found{max-width:720px}
.standalone-demo{max-width:780px}
@media(max-width:720px){.site-header{align-items:flex-start;flex-direction:column;padding:1rem 0}nav{gap:.75rem}main{padding-top:3rem}.feed,.project-grid,.content-grid{grid-template-columns:1fr}.project-meta,.content-grid>aside{position:static}.site-footer{flex-direction:column}pre{max-width:calc(100vw - 2rem)}}
@@ -0,0 +1,6 @@
<section class="not-found">
<p class="eyebrow">404</p>
<h1>That route is not in this release.</h1>
<p>The immutable artifact has no matching output.</p>
<p><a href="/">Return home</a></p>
</section>
@@ -0,0 +1,9 @@
<article class="content-grid">
<aside><h2>Contents</h2><ol>{% for heading in item.tableOfContents %}<li><a href="#{{ heading.id | escape }}">{{ heading.text | escape }}</a></li>{% endfor %}</ol></aside>
<div>
<p class="eyebrow">{{ item.artifactType | escape }}</p>
<h1>{{ item.title | escape }}</h1>
<p class="lede">{{ item.summary | escape }}</p>
<div class="prose">{{ item.content | safe_content }}</div>
</div>
</article>
@@ -0,0 +1,28 @@
<article class="project-grid">
<aside class="project-meta">
<p class="eyebrow">Project</p>
<dl>
<dt>Version</dt><dd>{{ project.version | escape }}</dd>
<dt>License</dt><dd>{{ project.license | escape }}</dd>
<dt>Source commit</dt><dd><code>{{ project.sourceSnapshot.commit | escape }}</code></dd>
<dt>Updated</dt><dd>{{ project.sourceSnapshot.committedAt | escape }}</dd>
</dl>
<h2>Stack</h2>
<ul>{% for item in project.stack %}<li>{{ item | escape }}</li>{% endfor %}</ul>
</aside>
<div class="project-body">
<h1>{{ project.title | escape }}</h1>
<p class="lede">{{ project.summary | escape }}</p>
<p><a href="{{ project.homepage | escape }}">Open the standalone demo</a></p>
<h2 id="provenance">Pinned provenance</h2>
<p>The project and its publication were read from <code>{{ project.sourceSnapshot.repository | escape }}</code> at one exact commit.</p>
<h2 id="tags">Tags</h2>
<ul class="tags">{% for tag in project.tags %}<li>{{ tag | escape }}</li>{% endfor %}</ul>
<h2 id="devlogs">Devlogs</h2>
{% if project.relatedContent.devlogs.size == 0 %}<p>No matching devlogs in this snapshot.</p>{% endif %}
{% for item in project.relatedContent.devlogs %}<article><h3><a href="{{ item.route | escape }}">{{ item.title | escape }}</a></h3></article>{% endfor %}
<h2 id="articles">Related articles</h2>
{% if project.relatedContent.articles.size == 0 %}<p>No matching articles in this snapshot.</p>{% endif %}
{% for item in project.relatedContent.articles %}<article><h3><a href="{{ item.route | escape }}">{{ item.title | escape }}</a></h3></article>{% endfor %}
</div>
</article>
@@ -0,0 +1,12 @@
<section class="hero">
<p class="eyebrow">Engineering · Architecture · Operations</p>
<h1>{{ site.title | escape }}</h1>
<p>Repository-owned presentation rendered from an exact Git commit.</p>
</section>
<section class="feed" aria-labelledby="latest">
<h2 id="latest">Latest publications</h2>
{% if content.size == 0 %}<p>No published content in this snapshot.</p>{% endif %}
{% for item in content %}
<article><p class="eyebrow">{{ item.artifactType | escape }}</p><h3><a href="{{ item.route | escape }}">{{ item.title | escape }}</a></h3><p>{{ item.summary | escape }}</p></article>
{% endfor %}
</section>
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="{{ site.language | escape }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="generator" content="labyricorn-theme/v1">
<title>{{ page.title | escape }} · {{ site.title | escape }}</title>
{% for stylesheet in theme.stylesheets %}<link rel="stylesheet" href="{{ stylesheet | escape }}">
{% endfor %}{% for script in theme.scripts %}<script src="{{ script | escape }}" defer></script>
{% endfor %}</head>
<body>
<header class="site-header">
<a class="brand" href="/"><img src="/assets/images/logo.svg" width="32" height="32" alt=""><span>{{ site.title | escape }}</span></a>
<nav aria-label="Primary">
{% for entry in navigation %}<a href="{{ entry.route | escape }}">{{ entry.label | escape }}</a>{% endfor %}
</nav>
</header>
<main>{{ page.body | safe_page_body }}</main>
<footer class="site-footer">
<span>Built from immutable Git snapshots.</span>
<code>{{ build.id | escape }}</code>
</footer>
</body>
</html>
@@ -0,0 +1,10 @@
<header class="section-heading">
<p class="eyebrow">Section</p>
<h1>{{ section.label | escape }}</h1>
</header>
<section class="feed">
{% if content.size == 0 %}<p>No published items in this snapshot.</p>{% endif %}
{% for item in content %}
<article><h2><a href="{{ item.route | escape }}">{{ item.title | escape }}</a></h2><p>{{ item.summary | escape }}</p></article>
{% endfor %}
</section>
+29
View File
@@ -0,0 +1,29 @@
protocol: labyricorn-theme/v1
id: labyricorn-editorial
version: 2.0.0
engine: liquid
templates:
layout: templates/layout.liquid
home: templates/home.liquid
notFound: templates/404.liquid
sections:
articles: templates/sections/listing.liquid
projects: templates/sections/listing.liquid
documentation: templates/sections/listing.liquid
community: templates/sections/listing.liquid
downloads: templates/sections/listing.liquid
content:
project: templates/content/project.liquid
article: templates/content/article.liquid
assets:
styles:
- assets/styles/theme.css
scripts:
- assets/scripts/theme.js
files:
- assets/images/logo.svg
fonts: []
security:
allowScripts: true
allowExternalAssets: false
allowedExternalOrigins: []
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en-US">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Website Engine Demo</title>
<link rel="stylesheet" href="/assets/styles/theme.css">
</head>
<body>
<main class="standalone-demo">
<p class="eyebrow">Published file</p>
<h1>Repository bytes, published unchanged</h1>
<p>This page is declared by <code>projects.yml</code> and copied from the pinned source commit.</p>
<p><a href="/project/website-engine-control-plane/">Return to the project</a></p>
</main>
</body>
</html>
+10
View File
@@ -9,23 +9,33 @@ navigation:
route: "/articles/"
iconName: "FileText"
contentModel: "article"
presentation:
sectionTemplate: "articles"
- id: "projects"
label: "Projects"
route: "/projects/"
iconName: "LayoutTemplate"
contentModel: "project"
presentation:
sectionTemplate: "projects"
- id: "documentation"
label: "Documentation"
route: "/docs/"
iconName: "FolderOpen"
contentModel: "documentation"
presentation:
sectionTemplate: "documentation"
- id: "community"
label: "Community"
route: "/community/"
iconName: "MessageSquare"
contentModel: "discussion"
presentation:
sectionTemplate: "community"
- id: "downloads"
label: "Downloads"
route: "/downloads/"
iconName: "Download"
contentModel: "release"
presentation:
sectionTemplate: "downloads"
+26
View File
@@ -0,0 +1,26 @@
projects:
- id: website-engine-control-plane
source: site-definition
route: /project/website-engine-control-plane/
title: Website Engine Control Plane
summary: A deterministic Git-native static site control plane.
version: 2.0.0
license: MIT
homepage: /project/website-engine-control-plane/demo.html
tags: [static-site, git, liquid]
stack: [TypeScript, React, LiquidJS, Git]
presentation:
detailTemplate: project
relationships:
devlogs:
contentModel: devlog
matchField: metadata.projectId
required: false
articles:
contentModel: article
matchField: metadata.projectId
required: false
publishedFiles:
- source: packages/site-definition/demo-project/demo.html
route: /project/website-engine-control-plane/demo.html
mediaType: text/html
+3 -1
View File
@@ -4,9 +4,11 @@ site:
title: "Labyricorn Engineering & Architecture"
baseUrl: "https://labyricorn.com"
language: "en-US"
navigationFile: "navigation.yml"
projectsFile: "projects.yml"
theme:
source: "site-definition"
path: "/.theme"
path: ".theme"
markdown:
dialect: "labyricorn-commonmark-v1"
rawHtmlPolicy: "disabled"
+36 -213
View File
@@ -33,27 +33,11 @@ async function startServer() {
res.json(store.siteConfig);
});
app.put("/api/site-config", (req, res) => {
try {
const { rawYaml } = req.body;
if (rawYaml) {
const parsed = YAML.parse(rawYaml);
store.siteConfig.rawYaml = rawYaml;
if (parsed.site) store.siteConfig.site = parsed.site;
if (parsed.markdown) store.siteConfig.markdown = parsed.markdown;
if (parsed.hosting) store.siteConfig.hosting = parsed.hosting;
if (parsed.buildPolicy)
store.siteConfig.buildPolicy = parsed.buildPolicy;
store.addAudit(
"Update Site Configuration",
"config",
"Updated site.yaml configuration from admin UI",
);
}
res.json({ success: true, siteConfig: store.siteConfig });
} catch (err: any) {
res.status(400).json({ error: `YAML parse error: ${err.message}` });
}
app.put("/api/site-config", (_req, res) => {
res.status(405).json({
code: "E_CONFIG_READ_ONLY",
message: "Site configuration is Git-owned and read-only in v1.",
});
});
// --- GITEA DISCOVERY ---
@@ -449,19 +433,12 @@ async function startServer() {
res.json(store.themeConfig);
});
app.post("/api/theme/validate", (req, res) => {
store.themeConfig.isValidated = true;
store.themeConfig.validationErrors = [];
store.addAudit(
"Validate Theme",
"config",
"Validated theme manifest /.theme/theme.yaml",
);
res.json({
success: true,
theme: store.themeConfig,
message:
"Theme /.theme loaded successfully. All required templates and packages supported.",
app.post("/api/theme/validate", (_req, res) => {
const theme = store.validateTheme();
res.status(theme.status === "valid" ? 200 : 422).json({
success: theme.status === "valid",
theme,
commits: theme.commit ? { [theme.sourceId ?? "theme"]: theme.commit } : {},
});
});
@@ -559,191 +536,37 @@ async function startServer() {
res.json(store.auditLogs);
});
// --- LIVE SITE HTML PREVIEW ENDPOINT ---
// --- RELEASE-ONLY SITE PREVIEW ENDPOINT ---
app.get("/api/live-site/html", (req, res) => {
const activeBuild =
store.builds.find((b) => b.isActiveLocal) || store.builds[0];
let pageRoute = (req.query.route as string) || "/";
const rootDir = store.getCurrentBuildDirectory();
if (fs.existsSync(rootDir)) {
const routeWithoutQuery = pageRoute.split(/[?#]/, 1)[0].replace(/\\/g, "/");
const relativeRoute = routeWithoutQuery.replace(/^\/+/, "");
const rootPath = `${path.resolve(rootDir)}${path.sep}`;
const targetPath = path.resolve(rootDir, relativeRoute, "index.html");
if (!targetPath.startsWith(rootPath)) {
return res.status(400).json({ error: "Invalid preview route" });
}
if (fs.existsSync(targetPath)) {
return res.type("html").send(fs.readFileSync(targetPath, "utf-8"));
}
const rootDir = store.getPreviewDirectory();
if (!rootDir) {
return res.status(503).json({
code: "E_RELEASE_UNAVAILABLE",
message: "No verified staging or live release is selected.",
});
}
// Simple HTML renderer simulating compiled static site output (fallback)
let pageTitle = store.siteConfig.site.title;
let mainContent = "";
// Match route against configured navigation entries
const navMatch = store.siteConfig.navigation.find(
(nav) => nav.route === pageRoute,
);
if (navMatch) {
pageTitle = `${navMatch.label} | ${store.siteConfig.site.title}`;
const relatedContent = store.contentItems.filter(
(item) => item.artifactType === navMatch.contentModel,
);
mainContent = `
<section class="max-w-4xl mx-auto py-8 px-4">
<div class="mb-8 border-b pb-4 border-slate-200">
<span class="inline-block px-2.5 py-1 text-xs font-semibold uppercase tracking-wider text-indigo-700 bg-indigo-50 rounded-full mb-2">Section: ${navMatch.label}</span>
<h1 class="text-3xl font-bold text-slate-900">${navMatch.label}</h1>
<p class="text-slate-600 mt-2 text-base">Content mapped from repositories providing the <code class="bg-slate-100 px-1.5 py-0.5 rounded text-xs font-mono text-indigo-600">${navMatch.contentModel}</code> model.</p>
</div>
<div class="space-y-6">
${
relatedContent.length === 0
? '<p class="text-slate-500 italic">No content discovered for this section yet.</p>'
: relatedContent
.map(
(item) => `
<article class="bg-white border border-slate-200 rounded-xl p-6 shadow-xs hover:border-indigo-300 transition-colors">
<div class="flex items-center justify-between text-xs text-slate-500 mb-2">
<span class="font-mono text-indigo-600 font-medium">Repository: ${item.sourceRepo}</span>
<time>${item.published}</time>
</div>
<h2 class="text-xl font-semibold text-slate-900 hover:text-indigo-600 cursor-pointer mb-2">
<a href="#" onclick="window.parent.postMessage({type:'NAVIGATE_LIVE_SITE', route:'${item.route}'}, '*')">${item.title}</a>
</h2>
<p class="text-slate-600 text-sm mb-4 line-clamp-2">${item.summary}</p>
<div class="flex items-center gap-2 text-xs">
${item.tags.map((t) => `<span class="bg-slate-100 text-slate-700 px-2 py-0.5 rounded-md font-mono">#${t}</span>`).join("")}
</div>
</article>
`,
)
.join("")
}
</div>
</section>
`;
} else {
const match = store.contentItems.find(
(i) => i.route === pageRoute || pageRoute.includes(i.slug),
);
if (match) {
pageTitle = `${match.title} | ${store.siteConfig.site.title}`;
mainContent = `
<article class="max-w-3xl mx-auto py-10 px-4">
<div class="mb-6">
<a href="#" onclick="window.parent.postMessage({type:'NAVIGATE_LIVE_SITE', route:'/'}, '*')" class="text-xs font-medium text-indigo-600 hover:underline"> Back Home</a>
<div class="mt-4 flex items-center gap-3 text-xs text-slate-500">
<span class="bg-indigo-100 text-indigo-800 font-medium px-2.5 py-0.5 rounded-full font-mono">${match.sourceRepo}</span>
<span>Published on ${match.published}</span>
<span class="text-emerald-600 font-medium"> Verified Schema</span>
</div>
<h1 class="text-3xl font-bold text-slate-900 mt-3">${match.title}</h1>
<p class="text-slate-600 text-lg mt-2 font-serif italic">${match.summary}</p>
</div>
${match.featuredImage ? `<img src="${match.featuredImage}" class="w-full h-64 object-cover rounded-xl mb-6 shadow-sm border border-slate-200" alt="${match.title}" />` : ""}
<div class="prose prose-slate max-w-none text-slate-800 leading-relaxed space-y-4">
${match.contentMarkdown
.replace(/^---[\s\S]*?---/, "")
.replace(
/# (.*)/g,
'<h1 class="text-2xl font-bold text-slate-900 mt-6 mb-3">$1</h1>',
)
.replace(
/## (.*)/g,
'<h2 class="text-xl font-bold text-slate-900 mt-5 mb-2">$1</h2>',
)
.replace(
/### (.*)/g,
'<h3 class="text-lg font-semibold text-slate-800 mt-4 mb-2">$1</h3>',
)
.replace(
/::youtube\[(.*?)\]\{id="(.*?)"\}/g,
'<div class="my-6 aspect-video bg-slate-900 rounded-xl flex flex-col items-center justify-center text-white p-6 shadow-md border border-slate-800"><div class="text-red-500 text-4xl mb-2">▶</div><div class="font-medium text-base">$1</div><div class="text-xs text-slate-400 mt-1 font-mono">YouTube Embed ID: $2</div></div>',
)
.replace(
/> \[\!(NOTE|IMPORTANT)\]\n> (.*)/g,
'<div class="p-4 bg-indigo-50 border-l-4 border-indigo-500 text-indigo-900 text-sm rounded-r-md my-4 font-medium">$2</div>',
)
.replace(
/```typescript([\s\S]*?)```/g,
'<pre class="bg-slate-900 text-slate-100 p-4 rounded-xl text-xs font-mono overflow-x-auto my-4 shadow-sm"><code>$1</code></pre>',
)
.replace(
/\n\n/g,
'</p><p class="text-slate-700 font-sans leading-7">',
)}
</div>
</article>
`;
} else {
mainContent = `
<div class="max-w-md mx-auto py-20 text-center">
<h1 class="text-4xl font-extrabold text-slate-900 mb-2">404</h1>
<p class="text-slate-600 mb-6">Page non-existent in build ${activeBuild?.id}</p>
<a href="#" onclick="window.parent.postMessage({type:'NAVIGATE_LIVE_SITE', route:'/'}, '*')" class="px-4 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700">Return Home</a>
</div>
`;
}
let requested: string;
try {
requested = decodeURIComponent(String(req.query.route ?? "/").split(/[?#]/, 1)[0]);
} catch {
return res.status(400).json({ code: "E_ROUTE_INVALID" });
}
if (!requested.startsWith("/") || requested.includes("\\") || requested.split("/").includes("..")) {
return res.status(400).json({ code: "E_ROUTE_INVALID" });
}
const html = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${pageTitle}</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body { background-color: #f8fafc; font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; }
</style>
</head>
<body class="min-h-screen flex flex-col">
<!-- SITE HEADER -->
<header class="bg-slate-900 text-white border-b border-slate-800 sticky top-0 z-50">
<div class="max-w-6xl mx-auto px-4 h-16 flex items-center justify-between">
<div class="flex items-center space-x-3 cursor-pointer" onclick="window.parent.postMessage({type:'NAVIGATE_LIVE_SITE', route:'/'}, '*')">
<div class="w-8 h-8 rounded-lg bg-indigo-500 flex items-center justify-center font-bold text-white text-lg">L</div>
<span class="font-bold text-lg tracking-tight">${store.siteConfig.site.title}</span>
</div>
<nav class="flex items-center space-x-6 text-sm font-medium text-slate-300">
${store.siteConfig.navigation.map((nav) => `<a href="#" onclick="window.parent.postMessage({type:'NAVIGATE_LIVE_SITE', route:'${nav.route}'}, '*')" class="hover:text-white transition-colors text-indigo-400">${nav.label}</a>`).join("\\n ")}
</nav>
<div class="text-xs bg-slate-800 border border-slate-700 text-slate-300 px-3 py-1.5 rounded-full font-mono flex items-center space-x-2">
<span class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span>
<span>Hosted via Nginx (${activeBuild.id})</span>
</div>
</div>
</header>
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" });
<!-- MAIN BODY -->
<main class="flex-grow">
${mainContent}
</main>
<!-- FOOTER -->
<footer class="bg-slate-900 text-slate-400 border-t border-slate-800 py-8 text-xs text-center mt-12">
<div class="max-w-4xl mx-auto px-4 space-y-2">
<p>Labyricorn Deterministic Static Site Builder Active Release: <span class="font-mono text-indigo-400">${activeBuild.id}</span> (${activeBuild.artifactChecksum.substring(0, 16)}...)</p>
<p class="text-slate-500">Public media served under <code class="text-slate-400">/media</code> &bull; Pure CommonMark Markdown Dialect</p>
</div>
</footer>
</body>
</html>
`;
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 ---
+177 -164
View File
@@ -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", "[email protected]"]);
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> = {}): 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"),
/<h1>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: "<script>alert(1)</script>" })]));
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<div>example</div>\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 <img src=x> and [safe](/docs/).\n\n```html\n<script>x</script>\n```");
const second = renderMarkdown("# Repeat\n\n## Repeat\n\nText <img src=x> and [safe](/docs/).\n\n```html\n<script>x</script>\n```");
assert.equal(first, second);
assert.match(first, /id="repeat"/);
assert.match(first, /id="repeat-2"/);
assert.match(first, /&lt;img src=x&gt;/);
assert.doesNotMatch(first, /<script>x<\/script>/);
});
test("markdown renderer escapes source HTML", () => {
assert.equal(renderMarkdown("Text <img src=x>"), "<p>Text &lt;img src=x&gt;</p>");
test("production sources contain no embedded renderer or synthesized preview fallback", () => {
assert.equal(fs.readFileSync(path.resolve("src/backend/buildEngine.ts"), "utf8").trim(), 'export * from "./repositoryBuildEngine";');
const server = fs.readFileSync(path.resolve("server.ts"), "utf8");
assert.doesNotMatch(server, /cdn\.tailwindcss\.com|Simple HTML renderer|SITE HEADER/);
assert.match(server, /E_RELEASE_UNAVAILABLE/);
assert.match(server, /E_CONFIG_READ_ONLY/);
});
+1 -472
View File
@@ -1,472 +1 @@
import crypto from "crypto";
import fs from "fs";
import os from "os";
import path from "path";
import {
ContentItem,
MediaAsset,
SiteConfig,
ThemeConfig,
ValidationReport,
} from "../types";
export const BUILDER_VERSION = "1.0.0-labyricorn";
export interface BuildEngineInput {
buildId: string;
siteConfig: SiteConfig;
contentItems: ContentItem[];
mediaAssets: MediaAsset[];
sourceCommits: Record<string, string>;
themeConfig: ThemeConfig;
}
export interface BuildEngineResult {
success: boolean;
outputDirectory: string;
generatedRoutesCount: number;
artifactChecksum: string;
artifactSizeBytes: number;
validationReport: ValidationReport;
generatedFiles: string[];
}
const emptyReport = (): ValidationReport => ({
errors: [],
warnings: [],
missingMedia: [],
routeCollisions: [],
htmlPolicyViolations: [],
brokenLinks: [],
summary: { totalErrors: 0, totalWarnings: 0, passed: true },
});
const escapeHtml = (value: string): string =>
value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
const stableValue = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(stableValue);
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, child]) => [key, stableValue(child)]),
);
}
return value;
};
const stableJson = (value: unknown): string =>
`${JSON.stringify(stableValue(value), null, 2)}\n`;
const sha256 = (value: string | Buffer): string =>
crypto.createHash("sha256").update(value).digest("hex");
const normalizeRoute = (route: string): string => {
const trimmed = route.trim();
if (!trimmed || trimmed.includes("\0") || /[?#]/.test(trimmed)) {
throw new Error(`Invalid route '${route}'`);
}
const segments = trimmed
.replace(/\\/g, "/")
.split("/")
.filter(Boolean);
if (segments.some((segment) => segment === "." || segment === "..")) {
throw new Error(`Route traversal is not allowed: '${route}'`);
}
return segments.length === 0 ? "/" : `/${segments.join("/")}/`;
};
const routeFile = (route: string): string => {
const normalized = normalizeRoute(route);
return normalized === "/"
? "index.html"
: `${normalized.slice(1)}index.html`;
};
const renderInlineMarkdown = (value: string): string => {
const codeTokens: string[] = [];
let rendered = value.replace(/`([^`]+)`/g, (_match, code: string) => {
const token = `\u0000CODE${codeTokens.length}\u0000`;
codeTokens.push(`<code>${escapeHtml(code)}</code>`);
return token;
});
rendered = escapeHtml(rendered)
.replace(
/\[([^\]]+)\]\((https?:\/\/[^\s)]+|\/[^\s)]*)\)/g,
'<a href="$2" rel="noopener noreferrer">$1</a>',
)
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/\*([^*]+)\*/g, "<em>$1</em>");
return rendered.replace(/\u0000CODE(\d+)\u0000/g, (_match, index: string) =>
codeTokens[Number(index)] ?? "",
);
};
export const renderMarkdown = (markdown: string): string => {
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
const blocks: string[] = [];
let paragraph: string[] = [];
let list: string[] = [];
let code: string[] | null = null;
let codeLanguage = "";
const flushParagraph = () => {
if (paragraph.length > 0) {
blocks.push(`<p>${renderInlineMarkdown(paragraph.join(" "))}</p>`);
paragraph = [];
}
};
const flushList = () => {
if (list.length > 0) {
blocks.push(`<ul>${list.map((item) => `<li>${renderInlineMarkdown(item)}</li>`).join("")}</ul>`);
list = [];
}
};
for (const line of lines) {
const fence = line.match(/^```([A-Za-z0-9_-]*)\s*$/);
if (fence) {
if (code === null) {
flushParagraph();
flushList();
code = [];
codeLanguage = fence[1];
} else {
const languageClass = codeLanguage
? ` class="language-${escapeHtml(codeLanguage)}"`
: "";
blocks.push(`<pre><code${languageClass}>${escapeHtml(code.join("\n"))}</code></pre>`);
code = null;
codeLanguage = "";
}
continue;
}
if (code !== null) {
code.push(line);
continue;
}
if (!line.trim()) {
flushParagraph();
flushList();
continue;
}
const heading = line.match(/^(#{1,6})\s+(.+)$/);
if (heading) {
flushParagraph();
flushList();
const level = heading[1].length;
blocks.push(`<h${level}>${renderInlineMarkdown(heading[2])}</h${level}>`);
continue;
}
const listItem = line.match(/^[-*]\s+(.+)$/);
if (listItem) {
flushParagraph();
list.push(listItem[1]);
continue;
}
if (line.startsWith("> ")) {
flushParagraph();
flushList();
blocks.push(`<blockquote>${renderInlineMarkdown(line.slice(2))}</blockquote>`);
continue;
}
paragraph.push(line.trim());
}
if (code !== null) {
blocks.push(`<pre><code>${escapeHtml(code.join("\n"))}</code></pre>`);
}
flushParagraph();
flushList();
return blocks.join("\n");
};
export class BuildEngine {
readonly outputRoot: string;
constructor(outputRoot = process.env.LABYRICORN_BUILD_ROOT || path.join(os.tmpdir(), "labyricorn-builds")) {
this.outputRoot = path.resolve(outputRoot);
}
buildDirectory(buildId: string): string {
if (!/^[A-Za-z0-9._-]+$/.test(buildId)) {
throw new Error(`Invalid build ID '${buildId}'`);
}
return path.join(this.outputRoot, buildId);
}
currentDirectory(): string {
return path.join(this.outputRoot, "current");
}
nextBuildNumber(): number {
if (!fs.existsSync(this.outputRoot)) return 1;
const highestExisting = fs
.readdirSync(this.outputRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
.map((entry) => entry.name.match(/^build-(\d+)$/)?.[1])
.filter((value): value is string => Boolean(value))
.map(Number)
.reduce((highest, value) => Math.max(highest, value), 0);
return highestExisting + 1;
}
validate(input: BuildEngineInput): ValidationReport {
const report = emptyReport();
const claimedRoutes = new Map<string, string>();
const claim = (route: string, owner: string) => {
try {
const normalized = normalizeRoute(route);
const existing = claimedRoutes.get(normalized);
if (existing && existing !== owner) {
report.routeCollisions.push(normalized);
report.errors.push({
code: "E_ROUTE_COLLISION",
message: `Route '${normalized}' is claimed by both ${existing} and ${owner}.`,
category: "routing",
});
} else {
claimedRoutes.set(normalized, owner);
}
} catch (error) {
report.errors.push({
code: "E_ROUTE_INVALID",
message: error instanceof Error ? error.message : String(error),
category: "routing",
});
}
};
claim("/", "site-home");
for (const navigation of input.siteConfig.navigation) {
if (navigation.route !== "/") claim(navigation.route, `navigation:${navigation.id}`);
}
const knownMedia = new Set(input.mediaAssets.map((asset) => asset.publicNamespacePath));
for (const item of input.contentItems.filter((candidate) => candidate.status === "published")) {
claim(item.route, `content:${item.id}`);
if (item.validationStatus === "error") {
report.errors.push({
code: "E_CONTENT_INVALID",
message: `Content item '${item.id}' failed source validation.`,
file: item.path,
category: "content",
});
}
const markdownWithoutCode = item.contentMarkdown
.replace(/```[\s\S]*?```/g, "")
.replace(/`[^`]*`/g, "");
if (!input.siteConfig.markdown.rawHtmlEnabled && /<\/?[A-Za-z][^>]*>/.test(markdownWithoutCode)) {
report.htmlPolicyViolations.push(item.path);
report.errors.push({
code: "E_RAW_HTML_DISABLED",
message: `Raw HTML is disabled but was found in '${item.path}'.`,
file: item.path,
category: "markdown",
});
}
for (const reference of item.mediaReferences) {
if (!/^https?:\/\//.test(reference) && !knownMedia.has(reference)) {
report.missingMedia.push(reference);
}
}
}
for (const missing of [...new Set(report.missingMedia)].sort()) {
report.errors.push({
code: "E_MEDIA_MISSING",
message: `Referenced media '${missing}' was not discovered.`,
category: "media",
});
}
for (const asset of input.mediaAssets.filter((candidate) => !candidate.isValidated)) {
report.warnings.push({
code: "W_MEDIA_UNVALIDATED",
message: `Media asset '${asset.originalPath}' has not been validated.`,
file: asset.originalPath,
category: "media",
});
}
report.routeCollisions = [...new Set(report.routeCollisions)].sort();
report.missingMedia = [...new Set(report.missingMedia)].sort();
report.htmlPolicyViolations = [...new Set(report.htmlPolicyViolations)].sort();
report.summary = {
totalErrors: report.errors.length,
totalWarnings: report.warnings.length,
passed: report.errors.length === 0,
};
return report;
}
build(input: BuildEngineInput): BuildEngineResult {
const validationReport = this.validate(input);
const outputDirectory = this.buildDirectory(input.buildId);
if (!validationReport.summary.passed) {
return {
success: false,
outputDirectory,
generatedRoutesCount: 0,
artifactChecksum: "",
artifactSizeBytes: 0,
validationReport,
generatedFiles: [],
};
}
fs.mkdirSync(this.outputRoot, { recursive: true });
const temporaryDirectory = path.join(
this.outputRoot,
`.${input.buildId}.tmp-${process.pid}-${crypto.randomUUID()}`,
);
fs.mkdirSync(temporaryDirectory, { recursive: true });
try {
const publishedItems = input.contentItems
.filter((item) => item.status === "published")
.sort((left, right) => normalizeRoute(left.route).localeCompare(normalizeRoute(right.route)) || left.id.localeCompare(right.id));
const generatedRoutes = new Set<string>();
const generatedFiles: string[] = [];
const navigation = input.siteConfig.navigation
.map((entry) => `<a href="${escapeHtml(normalizeRoute(entry.route))}">${escapeHtml(entry.label)}</a>`)
.join("");
const layout = (title: string, body: string) => `<!doctype html>
<html lang="${escapeHtml(input.siteConfig.site.language)}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)} | ${escapeHtml(input.siteConfig.site.title)}</title>
<style>:root{color-scheme:light dark;font-family:Inter,system-ui,sans-serif}body{max-width:72rem;margin:auto;padding:2rem;line-height:1.65}nav{display:flex;gap:1rem;flex-wrap:wrap;border-bottom:1px solid #7775;padding-bottom:1rem;margin-bottom:2rem}a{color:#b38a2e}main{max-width:52rem}article+article{border-top:1px solid #7775;margin-top:1.5rem;padding-top:1.5rem}pre{overflow:auto;padding:1rem;background:#111;color:#eee}code{font-family:ui-monospace,monospace}blockquote{border-left:.25rem solid #b38a2e;padding-left:1rem;margin-left:0}</style>
</head>
<body><nav>${navigation}</nav><main>${body}</main></body>
</html>
`;
const writeRoute = (route: string, html: string) => {
const normalized = normalizeRoute(route);
const relativeFile = routeFile(normalized);
const absoluteFile = path.join(temporaryDirectory, relativeFile);
fs.mkdirSync(path.dirname(absoluteFile), { recursive: true });
fs.writeFileSync(absoluteFile, html, "utf8");
generatedRoutes.add(normalized);
generatedFiles.push(relativeFile.replace(/\\/g, "/"));
};
const homeItems = publishedItems
.map((item) => `<article><h2><a href="${escapeHtml(normalizeRoute(item.route))}">${escapeHtml(item.title)}</a></h2><p>${escapeHtml(item.summary)}</p></article>`)
.join("");
writeRoute("/", layout(input.siteConfig.site.title, `<h1>${escapeHtml(input.siteConfig.site.title)}</h1>${homeItems || "<p>No published content.</p>"}`));
for (const entry of input.siteConfig.navigation.filter((candidate) => candidate.route !== "/")) {
const items = publishedItems
.filter((item) => item.artifactType === entry.contentModel)
.map((item) => `<article><h2><a href="${escapeHtml(normalizeRoute(item.route))}">${escapeHtml(item.title)}</a></h2><p>${escapeHtml(item.summary)}</p></article>`)
.join("");
writeRoute(entry.route, layout(entry.label, `<h1>${escapeHtml(entry.label)}</h1>${items || "<p>No published content.</p>"}`));
}
for (const item of publishedItems) {
const embeds = item.youtubeDirectives
.filter((directive) => /^[A-Za-z0-9_-]{6,20}$/.test(directive.videoId))
.map((directive) => `<section><h2>${escapeHtml(directive.title)}</h2><iframe loading="lazy" src="https://www.youtube-nocookie.com/embed/${directive.videoId}" title="${escapeHtml(directive.title)}" allowfullscreen></iframe></section>`)
.join("");
writeRoute(
item.route,
layout(item.title, `<article><h1>${escapeHtml(item.title)}</h1><p><em>${escapeHtml(item.summary)}</em></p>${renderMarkdown(item.contentMarkdown)}${embeds}</article>`),
);
}
const errorPage = layout("Not Found", "<h1>404</h1><p>The requested page was not found.</p>");
fs.writeFileSync(path.join(temporaryDirectory, "404.html"), errorPage, "utf8");
generatedFiles.push("404.html");
const manifest = {
protocol: input.siteConfig.protocol,
builderVersion: BUILDER_VERSION,
site: {
id: input.siteConfig.site.id,
baseUrl: input.siteConfig.site.baseUrl,
language: input.siteConfig.site.language,
},
sourceCommits: input.sourceCommits,
theme: { id: input.themeConfig.id, version: input.themeConfig.version },
routes: [...generatedRoutes].sort(),
media: input.mediaAssets
.map((asset) => ({ path: asset.publicNamespacePath, sha256: null, sizeBytes: asset.sizeBytes }))
.sort((left, right) => left.path.localeCompare(right.path)),
};
fs.writeFileSync(path.join(temporaryDirectory, "build-manifest.json"), stableJson(manifest), "utf8");
generatedFiles.push("build-manifest.json");
const checksums = Object.fromEntries(
generatedFiles
.sort()
.map((relativeFile) => [relativeFile, sha256(fs.readFileSync(path.join(temporaryDirectory, relativeFile)))]),
);
const checksumsDocument = stableJson({ algorithm: "sha256", files: checksums });
fs.writeFileSync(path.join(temporaryDirectory, "checksums.json"), checksumsDocument, "utf8");
generatedFiles.push("checksums.json");
const artifactChecksum = `sha256-${sha256(checksumsDocument)}`;
const artifactSizeBytes = generatedFiles.reduce(
(total, relativeFile) => total + fs.statSync(path.join(temporaryDirectory, relativeFile)).size,
0,
);
if (fs.existsSync(outputDirectory)) {
throw new Error(`Build output already exists: ${outputDirectory}`);
}
fs.renameSync(temporaryDirectory, outputDirectory);
return {
success: true,
outputDirectory,
generatedRoutesCount: generatedRoutes.size,
artifactChecksum,
artifactSizeBytes,
validationReport,
generatedFiles: generatedFiles.sort(),
};
} catch (error) {
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
throw error;
}
}
activate(buildId: string): string {
const buildDirectory = this.buildDirectory(buildId);
if (!fs.existsSync(path.join(buildDirectory, "build-manifest.json"))) {
throw new Error(`Build '${buildId}' is missing a build manifest.`);
}
fs.mkdirSync(this.outputRoot, { recursive: true });
const current = this.currentDirectory();
const next = path.join(this.outputRoot, `.current-${process.pid}-${crypto.randomUUID()}`);
fs.symlinkSync(buildDirectory, next, process.platform === "win32" ? "junction" : "dir");
try {
if (fs.existsSync(current) || fs.lstatSync(current, { throwIfNoEntry: false })) {
fs.rmSync(current, { recursive: true, force: true });
}
fs.renameSync(next, current);
} catch (error) {
fs.rmSync(next, { recursive: true, force: true });
throw error;
}
return current;
}
}
export * from "./repositoryBuildEngine";
+154
View File
@@ -0,0 +1,154 @@
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import YAML from "yaml";
import {
ContentItem,
ContentModel,
MediaAsset,
ProjectDeclaration,
ResolvedBuildInput,
ResolvedProject,
ResolvedPublishedFile,
SiteConfig,
} from "../types";
import { RepositorySnapshotManager } from "./repositories/repositorySnapshot";
import { canonicalPath, parseProjects, resolveRegularFile, ThemeLoader } from "./theme/themeLoader";
const sha256 = (value: Buffer | string) => crypto.createHash("sha256").update(value).digest("hex");
const stableValue = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(stableValue);
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b)).map(([key, child]) => [key, stableValue(child)]));
return value;
};
export const stableJson = (value: unknown): string => `${JSON.stringify(stableValue(value), null, 2)}\n`;
export interface BuildInputLoaderOptions {
repositoryRoot?: string;
ref?: string;
siteDefinitionPath?: string;
workRoot?: string;
builderVersion: string;
}
export class BuildInputLoader {
private readonly snapshots: RepositorySnapshotManager;
private readonly repositoryRoot: string;
private readonly ref: string;
private readonly siteDefinitionPath: string;
private readonly builderVersion: string;
constructor(options: BuildInputLoaderOptions) {
this.repositoryRoot = path.resolve(options.repositoryRoot ?? process.cwd());
this.ref = options.ref ?? process.env.LABYRICORN_SITE_DEFINITION_REF ?? "HEAD";
this.siteDefinitionPath = canonicalPath(options.siteDefinitionPath ?? "packages/site-definition", "site definition path");
this.builderVersion = options.builderVersion;
this.snapshots = new RepositorySnapshotManager(options.workRoot ?? path.join(os.tmpdir(), "labyricorn-snapshots"));
}
load(runId: string): ResolvedBuildInput {
const siteDefinition = this.snapshots.resolve("site-definition", this.repositoryRoot, this.ref);
const sourceMap = new Map([[siteDefinition.sourceId, siteDefinition]]);
const configRoot = path.join(siteDefinition.checkoutRoot, ...this.siteDefinitionPath.split("/"));
const inputChecksums: Record<string, string> = {};
const readConfig = (relative: string): Buffer => {
const file = resolveRegularFile(configRoot, relative, `configuration '${relative}'`, 2 * 1024 * 1024);
const bytes = fs.readFileSync(file);
inputChecksums[`${this.siteDefinitionPath}/${canonicalPath(relative, "configuration path")}`] = sha256(bytes);
return bytes;
};
const siteBytes = readConfig("site.yml");
const siteDocument = YAML.parse(siteBytes.toString("utf8")) as Record<string, any>;
if (siteDocument.protocol !== "labyricorn-site/v1") throw new Error("E_SITE_CONFIG_INVALID: unsupported site protocol.");
const navigationRelative = siteDocument.navigationFile ?? "navigation.yml";
const navigationDocument = YAML.parse(readConfig(navigationRelative).toString("utf8"));
const contentModels: ContentModel[] = [];
const modelRoot = path.join(siteDefinition.checkoutRoot, "packages", "content-models");
if (fs.existsSync(modelRoot)) {
for (const name of fs.readdirSync(modelRoot).sort()) {
const modelFile = path.join(modelRoot, name, "model.yml");
if (!fs.existsSync(modelFile) || !fs.statSync(modelFile).isFile()) continue;
const bytes = fs.readFileSync(modelFile);
inputChecksums[`packages/content-models/${name}/model.yml`] = sha256(bytes);
contentModels.push(YAML.parse(bytes.toString("utf8")));
}
}
const siteConfig: SiteConfig = {
protocol: siteDocument.protocol,
site: siteDocument.site,
navigation: navigationDocument.navigation ?? [],
contentModels,
sourcesFile: siteDocument.sourcesFile ?? "sources.yml",
styleInstancesPath: siteDocument.styleInstancesPath ?? "../style-configs",
pagesPath: siteDocument.pagesPath ?? "pages",
navigationFile: navigationRelative,
projectsFile: siteDocument.projectsFile,
pushIntegrationsPath: siteDocument.pushIntegrationsPath ?? "push-integrations",
theme: { ...siteDocument.theme, path: canonicalPath(String(siteDocument.theme?.path ?? "").replace(/^\/+/, ""), "theme.path") },
markdown: siteDocument.markdown,
hosting: siteDocument.hosting,
buildPolicy: siteDocument.buildPolicy,
rawYaml: siteBytes.toString("utf8"),
sourceId: siteDefinition.sourceId,
repository: siteDefinition.repository,
commit: siteDefinition.commit,
readOnly: true,
};
let declarations: ProjectDeclaration[] = [];
if (siteConfig.projectsFile) {
const bytes = readConfig(siteConfig.projectsFile);
declarations = parseProjects(bytes);
}
const requiredSections = siteConfig.navigation.filter((entry) => entry.route !== "/").map((entry) => entry.presentation?.sectionTemplate ?? entry.contentModel).filter((value): value is string => Boolean(value));
const requiredContent = declarations.map((project) => project.presentation.detailTemplate);
const themeSnapshot = sourceMap.get(siteConfig.theme.source);
if (!themeSnapshot) throw new Error(`E_THEME_SOURCE: configured source '${siteConfig.theme.source}' is unresolved.`);
const themeRoot = path.posix.join(this.siteDefinitionPath, siteConfig.theme.path);
const theme = new ThemeLoader().load(themeSnapshot, themeRoot, requiredSections, requiredContent);
for (const [relative, checksum] of Object.entries(theme.checksums)) inputChecksums[`${themeRoot}/${relative}`] = checksum;
const contentItems: ContentItem[] = [];
const mediaAssets: MediaAsset[] = [];
const projects: ResolvedProject[] = [];
const publishedFiles: ResolvedPublishedFile[] = [];
for (const project of declarations) {
const sourceSnapshot = sourceMap.get(project.source);
if (!sourceSnapshot) throw new Error(`E_PROJECT_SOURCE: project '${project.id}' uses unresolved source '${project.source}'.`);
const relatedContent: Record<string, readonly ContentItem[]> = {};
for (const [key, relationship] of Object.entries(project.relationships ?? {})) {
const matches = contentItems.filter((item) => item.status === "published" && item.artifactType === relationship.contentModel && item.metadata?.[relationship.matchField.slice("metadata.".length) as keyof NonNullable<ContentItem["metadata"]>] === project.id).sort((a, b) => a.route.localeCompare(b.route) || a.id.localeCompare(b.id));
if (relationship.required && matches.length === 0) throw new Error(`E_PROJECT_RELATIONSHIP: required relationship '${key}' for '${project.id}' has no matches.`);
relatedContent[key] = matches;
}
projects.push(Object.freeze({ ...project, sourceSnapshot, relatedContent: Object.freeze(relatedContent) }));
for (const publication of project.publishedFiles ?? []) {
const sourcePath = canonicalPath(publication.source, `published file for '${project.id}'`);
const absolute = resolveRegularFile(sourceSnapshot.checkoutRoot, sourcePath, `published file '${sourcePath}'`);
const bytes = fs.readFileSync(absolute);
const sourceChecksum = sha256(bytes);
inputChecksums[`${sourceSnapshot.sourceId}:${sourcePath}`] = sourceChecksum;
publishedFiles.push({ ...publication, projectId: project.id, sourceSnapshot, sourcePath, sourceChecksum });
}
}
const descriptor = {
protocol: "labyricorn-build-input/v1", builderVersion: this.builderVersion,
siteDefinition: { sourceId: siteDefinition.sourceId, repository: siteDefinition.repository, commit: siteDefinition.commit },
sources: [...sourceMap.values()].map(({ sourceId, repository, commit }) => ({ sourceId, repository, commit })).sort((a, b) => a.sourceId.localeCompare(b.sourceId)),
theme: { sourceId: theme.snapshot.sourceId, commit: theme.snapshot.commit, manifestChecksum: theme.manifestChecksum },
checksums: Object.fromEntries(Object.entries(inputChecksums).sort(([a], [b]) => a.localeCompare(b))),
};
const artifactBuildId = `sha256-${sha256(stableJson(descriptor))}`;
const generatedAt = [...sourceMap.values()].map((source) => source.committedAt).sort().at(-1)!;
return Object.freeze({ runId, artifactBuildId, generatedAt, siteDefinition, sources: sourceMap, siteConfig: Object.freeze(siteConfig), theme, projects: Object.freeze(projects), contentItems: Object.freeze(contentItems), mediaAssets: Object.freeze(mediaAssets), publishedFiles: Object.freeze(publishedFiles), inputChecksums: Object.freeze(inputChecksums) });
}
dispose(): void {
this.snapshots.dispose();
}
}
@@ -0,0 +1,53 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { RepositorySnapshot } from "../../types";
const git = (repositoryRoot: string, args: string[]): string =>
execFileSync("git", ["-C", repositoryRoot, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
export class RepositorySnapshotManager {
private readonly roots = new Set<string>();
constructor(private readonly workRoot = path.join(os.tmpdir(), "labyricorn-snapshots")) {}
resolve(sourceId: string, repositoryRoot: string, ref: string): RepositorySnapshot {
const absoluteRepository = path.resolve(repositoryRoot);
let commit: string;
try {
commit = git(absoluteRepository, ["rev-parse", "--verify", `${ref}^{commit}`]);
} catch (error) {
throw new Error(`E_REPOSITORY_REF: source '${sourceId}' could not resolve ref '${ref}': ${error instanceof Error ? error.message : String(error)}`);
}
if (!/^[0-9a-f]{40}$/.test(commit)) throw new Error(`E_REPOSITORY_REF: '${ref}' did not resolve to a full commit SHA.`);
const committedAt = new Date(Number(git(absoluteRepository, ["show", "-s", "--format=%ct", commit])) * 1000)
.toISOString().replace(/\.\d{3}Z$/, "Z");
let repository = absoluteRepository;
try {
repository = git(absoluteRepository, ["remote", "get-url", "origin"]);
} catch {
// Local fixture repositories intentionally have no origin.
}
fs.mkdirSync(this.workRoot, { recursive: true });
const checkoutRoot = fs.mkdtempSync(path.join(this.workRoot, `${sourceId}-`));
const archive = path.join(checkoutRoot, ".snapshot.tar");
try {
execFileSync("git", ["-C", absoluteRepository, "archive", "--format=tar", `--output=${archive}`, commit], { stdio: ["ignore", "pipe", "pipe"] });
execFileSync("tar", ["-xf", archive, "-C", checkoutRoot], { stdio: ["ignore", "pipe", "pipe"] });
fs.rmSync(archive, { force: true });
} catch (error) {
fs.rmSync(checkoutRoot, { recursive: true, force: true });
throw new Error(`E_REPOSITORY_SNAPSHOT: source '${sourceId}' could not materialize commit '${commit}': ${error instanceof Error ? error.message : String(error)}`);
}
this.roots.add(checkoutRoot);
return Object.freeze({ sourceId, repository, commit, checkoutRoot, committedAt });
}
dispose(): void {
for (const root of this.roots) fs.rmSync(root, { recursive: true, force: true });
this.roots.clear();
}
}
+347
View File
@@ -0,0 +1,347 @@
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { Liquid } from "liquidjs";
import {
ContentItem,
ResolvedBuildInput,
ThemeFont,
ValidationReport,
} from "../types";
import { stableJson } from "./buildInputLoader";
import { resolveRegularFile, ThemeContractError } from "./theme/themeLoader";
export const BUILDER_VERSION = "2.0.0-labyricorn-theme-v1";
const sha256 = (value: Buffer | string) => crypto.createHash("sha256").update(value).digest("hex");
const toPosix = (value: string) => value.replace(/\\/g, "/");
const emptyReport = (): ValidationReport => ({ errors: [], warnings: [], missingMedia: [], routeCollisions: [], htmlPolicyViolations: [], brokenLinks: [], summary: { totalErrors: 0, totalWarnings: 0, passed: true } });
const escapeHtml = (value: unknown): string => String(value ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
const slugify = (value: string): string => value.toLowerCase().replace(/<[^>]+>/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "section";
class SafeHtml {
readonly #brand = true;
constructor(readonly value: string) {}
}
export interface RenderedMarkdown {
html: SafeHtml;
toc: Array<{ id: string; level: number; text: string }>;
}
const renderInlineMarkdown = (value: string): string => {
const code: string[] = [];
let rendered = value.replace(/`([^`]+)`/g, (_match, source: string) => {
const token = `\u0000CODE${code.length}\u0000`;
code.push(`<code>${escapeHtml(source)}</code>`);
return token;
});
rendered = escapeHtml(rendered)
.replace(/\[([^\]]+)\]\((https:\/\/[^\s)]+|\/[^\s)]*)\)/g, '<a href="$2" rel="noopener noreferrer">$1</a>')
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/\*([^*]+)\*/g, "<em>$1</em>");
return rendered.replace(/\u0000CODE(\d+)\u0000/g, (_match, index: string) => code[Number(index)] ?? "");
};
export const renderMarkdownDocument = (markdown: string): RenderedMarkdown => {
const withoutFrontMatter = markdown.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "");
const lines = withoutFrontMatter.replace(/\r\n/g, "\n").split("\n");
const blocks: string[] = [];
const toc: RenderedMarkdown["toc"] = [];
const seenIds = new Map<string, number>();
let paragraph: string[] = [];
let list: string[] = [];
let code: string[] | null = null;
let language = "";
const flushParagraph = () => { if (paragraph.length) { blocks.push(`<p>${renderInlineMarkdown(paragraph.join(" "))}</p>`); paragraph = []; } };
const flushList = () => { if (list.length) { blocks.push(`<ul>${list.map((item) => `<li>${renderInlineMarkdown(item)}</li>`).join("")}</ul>`); list = []; } };
for (const line of lines) {
const fence = line.match(/^```([A-Za-z0-9_-]*)\s*$/);
if (fence) {
if (code === null) { flushParagraph(); flushList(); code = []; language = fence[1]; }
else { blocks.push(`<pre><code${language ? ` class="language-${escapeHtml(language)}"` : ""}>${escapeHtml(code.join("\n"))}</code></pre>`); code = null; language = ""; }
continue;
}
if (code !== null) { code.push(line); continue; }
if (!line.trim()) { flushParagraph(); flushList(); continue; }
const heading = line.match(/^(#{1,6})\s+(.+)$/);
if (heading) {
flushParagraph(); flushList();
const level = heading[1].length;
const base = slugify(heading[2]);
const occurrence = seenIds.get(base) ?? 0;
seenIds.set(base, occurrence + 1);
const id = occurrence === 0 ? base : `${base}-${occurrence + 1}`;
toc.push({ id, level, text: heading[2] });
blocks.push(`<h${level} id="${id}">${renderInlineMarkdown(heading[2])}</h${level}>`);
continue;
}
const item = line.match(/^[-*]\s+(.+)$/);
if (item) { flushParagraph(); list.push(item[1]); continue; }
if (line.startsWith("> ")) { flushParagraph(); flushList(); blocks.push(`<blockquote>${renderInlineMarkdown(line.slice(2))}</blockquote>`); continue; }
paragraph.push(line.trim());
}
if (code !== null) blocks.push(`<pre><code>${escapeHtml(code.join("\n"))}</code></pre>`);
flushParagraph(); flushList();
return { html: new SafeHtml(blocks.join("\n")), toc };
};
export const renderMarkdown = (markdown: string): string => renderMarkdownDocument(markdown).html.value;
class StrictLiquidRenderer {
private readonly liquid: Liquid;
private readonly allowedFilters = new Set(["escape", "safe_content", "safe_page_body"]);
constructor(partialsRoot: string) {
this.liquid = new Liquid({ root: [partialsRoot], extname: ".liquid", strictVariables: true, strictFilters: true, dynamicPartials: false, relativeReference: false });
this.liquid.registerFilter("safe_content", (value: unknown) => {
if (!(value instanceof SafeHtml)) throw new Error("E_SAFE_CONTENT_TYPE: safe_content accepts only engine-created sanitized content.");
return value.value;
});
this.liquid.registerFilter("safe_page_body", (value: unknown) => {
if (!(value instanceof SafeHtml)) throw new Error("E_SAFE_PAGE_BODY_TYPE: safe_page_body accepts only an engine-rendered page body.");
return value.value;
});
}
render(source: string, context: Record<string, unknown>, name: string): string {
if (source.length > 1024 * 1024) throw new Error(`E_THEME_RESOURCE_LIMIT: template '${name}' is too large.`);
for (const match of source.matchAll(/\|\s*([A-Za-z_][A-Za-z0-9_]*)/g)) {
if (!this.allowedFilters.has(match[1])) throw new Error(`E_THEME_FILTER_POLICY: filter '${match[1]}' is not allowed in '${name}'.`);
}
const started = Date.now();
const rendered = this.liquid.parseAndRenderSync(source, context);
if (Date.now() - started > 2000 || rendered.length > 5 * 1024 * 1024) throw new Error(`E_THEME_RESOURCE_LIMIT: template '${name}' exceeded its render budget.`);
return rendered.replace(/\r\n/g, "\n");
}
}
const normalizeDirectoryRoute = (route: string): string => {
if (!route.startsWith("/") || route.includes("\0") || route.includes("\\") || /[?#]/.test(route)) throw new Error(`E_ROUTE_INVALID: invalid route '${route}'.`);
const parts = route.split("/").filter(Boolean);
if (parts.some((part) => part === "." || part === "..")) throw new Error(`E_ROUTE_INVALID: traversal in route '${route}'.`);
return parts.length ? `/${parts.join("/")}/` : "/";
};
const directoryRouteFile = (route: string) => normalizeDirectoryRoute(route) === "/" ? "index.html" : `${normalizeDirectoryRoute(route).slice(1)}index.html`;
const literalRouteFile = (route: string): string => {
if (!route.startsWith("/") || route.endsWith("/") || route.includes("\\") || route.split("/").includes("..")) throw new Error(`E_ROUTE_INVALID: invalid literal file route '${route}'.`);
return route.slice(1);
};
const validateHtml = (html: string, file: string, input: ResolvedBuildInput, declaredScripts: Set<string>, releaseFiles: Set<string>): void => {
if (/<style\b/i.test(html) || /\sstyle\s*=/i.test(html)) throw new Error(`E_OUTPUT_STYLE_POLICY: inline style is forbidden in '${file}'.`);
if (/\son[a-z]+\s*=/i.test(html)) throw new Error(`E_OUTPUT_SCRIPT_POLICY: event handlers are forbidden in '${file}'.`);
if (/javascript\s*:/i.test(html)) throw new Error(`E_OUTPUT_URL_POLICY: javascript URLs are forbidden in '${file}'.`);
for (const match of html.matchAll(/<script\b([^>]*)>/gi)) {
const src = match[1].match(/\ssrc=["']([^"']+)["']/i)?.[1];
if (!input.theme.manifest.security.allowScripts || !src || !declaredScripts.has(src)) throw new Error(`E_OUTPUT_SCRIPT_POLICY: '${file}' references an inline or undeclared script.`);
}
const allowedOrigins = new Set(input.theme.manifest.security.allowedExternalOrigins);
for (const match of html.matchAll(/<([a-z][a-z0-9]*)\b[^>]*?\s(src|srcset|href)=["']([^"']+)["'][^>]*>/gi)) {
const tag = match[1].toLowerCase();
const attribute = match[2].toLowerCase();
const value = match[3].trim();
if (tag === "a" && attribute === "href" && (/^https?:\/\//i.test(value) || /^(?:mailto|tel):/i.test(value) || value.startsWith("#"))) continue;
if (/^https?:\/\//i.test(value)) {
const allowed = input.theme.manifest.security.allowExternalAssets && allowedOrigins.has(new URL(value).origin);
if (!allowed) throw new Error(`E_THEME_EXTERNAL_ASSET: '${file}' contains runtime external asset '${value}'.`);
continue;
}
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) throw new Error(`E_OUTPUT_URL_POLICY: '${file}' contains unsupported URL '${value}'.`);
const clean = value.split(/[?#]/, 1)[0];
if (!clean) continue;
let resolved = clean.startsWith("/") ? clean.slice(1) : path.posix.normalize(path.posix.join(path.posix.dirname(file), clean));
if (tag === "a" && attribute === "href" && (clean.endsWith("/") || !path.posix.extname(clean))) resolved = path.posix.join(resolved, "index.html");
if (!releaseFiles.has(resolved)) throw new Error(`E_OUTPUT_REFERENCE: '${file}' references undeclared output '${value}'.`);
}
};
const validateCss = (css: string, file: string, releaseFiles: Set<string>, allowedOrigins: Set<string>): void => {
for (const match of css.matchAll(/url\(\s*["']?([^"')]+)["']?\s*\)/gi)) {
const value = match[1];
if (/^data:/i.test(value)) continue;
if (/^https?:\/\//i.test(value)) {
if (!allowedOrigins.has(new URL(value).origin)) throw new Error(`E_THEME_EXTERNAL_ASSET: '${file}' references disallowed origin '${value}'.`);
continue;
}
const resolved = toPosix(path.posix.normalize(path.posix.join(path.posix.dirname(`/${file}`), value))).replace(/^\//, "");
if (!releaseFiles.has(resolved)) throw new Error(`E_OUTPUT_REFERENCE: '${file}' references undeclared file '${value}'.`);
}
};
export interface BuildEngineResult {
success: boolean;
outputDirectory: string;
generatedRoutesCount: number;
artifactChecksum: string;
artifactSizeBytes: number;
validationReport: ValidationReport;
generatedFiles: string[];
}
export class BuildEngine {
readonly outputRoot: string;
constructor(outputRoot = process.env.LABYRICORN_BUILD_ROOT || path.join(os.tmpdir(), "labyricorn-builds")) { this.outputRoot = path.resolve(outputRoot); }
releasesDirectory(): string { return path.join(this.outputRoot, "releases"); }
buildDirectory(artifactBuildId: string): string { if (!/^sha256-[0-9a-f]{64}$/.test(artifactBuildId)) throw new Error(`Invalid artifact build ID '${artifactBuildId}'.`); return path.join(this.releasesDirectory(), artifactBuildId); }
currentDirectory(): string { return path.join(this.outputRoot, "current"); }
stagingDirectory(): string { return path.join(this.outputRoot, "staging"); }
nextBuildNumber(): number { return 1; }
validate(input: ResolvedBuildInput): ValidationReport {
const report = emptyReport();
const claims = new Map<string, string>();
const claim = (file: string, owner: string) => {
const existing = claims.get(file);
if (existing) { report.routeCollisions.push(`/${file}`); report.errors.push({ code: "E_ROUTE_COLLISION", message: `Output '${file}' is claimed by ${existing} and ${owner}.`, category: "routing" }); }
else claims.set(file, owner);
};
claim("index.html", "home"); claim("404.html", "not-found");
for (const entry of input.siteConfig.navigation.filter((item) => item.route !== "/")) claim(directoryRouteFile(entry.route), `navigation:${entry.id}`);
for (const project of input.projects) claim(directoryRouteFile(project.route), `project:${project.id}`);
for (const publication of input.publishedFiles) claim(literalRouteFile(publication.route), `published:${publication.projectId}`);
for (const item of input.contentItems.filter((candidate) => candidate.status === "published")) {
claim(directoryRouteFile(item.route), `content:${item.id}`);
const markdownWithoutCode = item.contentMarkdown.replace(/```[\s\S]*?```/g, "").replace(/`[^`]*`/g, "");
if (!input.siteConfig.markdown.rawHtmlEnabled && /<\/?[A-Za-z][^>]*>/.test(markdownWithoutCode)) { report.htmlPolicyViolations.push(item.path); report.errors.push({ code: "E_RAW_HTML_DISABLED", message: `Raw HTML is disabled in '${item.path}'.`, file: item.path, category: "markdown" }); }
}
report.routeCollisions.sort(); report.htmlPolicyViolations.sort();
report.summary = { totalErrors: report.errors.length, totalWarnings: report.warnings.length, passed: report.errors.length === 0 };
return report;
}
build(input: ResolvedBuildInput): BuildEngineResult {
const validationReport = this.validate(input);
const outputDirectory = this.buildDirectory(input.artifactBuildId);
if (!validationReport.summary.passed) return { success: false, outputDirectory, generatedRoutesCount: 0, artifactChecksum: "", artifactSizeBytes: 0, validationReport, generatedFiles: [] };
if (fs.existsSync(outputDirectory)) {
const verified = this.verifyRelease(input.artifactBuildId);
return { success: true, outputDirectory, generatedRoutesCount: verified.routes.length, artifactChecksum: verified.artifactChecksum, artifactSizeBytes: verified.sizeBytes, validationReport, generatedFiles: verified.files };
}
fs.mkdirSync(this.releasesDirectory(), { recursive: true });
const temporaryDirectory = fs.mkdtempSync(path.join(this.outputRoot, ".release-"));
const files = new Set<string>();
const routes = new Set<string>();
const write = (relative: string, bytes: Buffer | string) => {
const normalized = toPosix(relative);
if (files.has(normalized)) throw new Error(`E_ROUTE_COLLISION: duplicate output '${normalized}'.`);
const target = path.join(temporaryDirectory, ...normalized.split("/"));
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, bytes);
files.add(normalized);
};
try {
for (const relative of [...input.theme.manifest.assets.styles, ...input.theme.manifest.assets.scripts, ...input.theme.manifest.assets.files]) write(relative, fs.readFileSync(resolveRegularFile(input.theme.root, relative, `asset '${relative}'`)));
const fontCss: string[] = [];
for (const font of input.theme.manifest.assets.fonts) {
const released = this.resolveFont(font, input);
write(released.path, released.bytes);
fontCss.push(`@font-face{font-family:${JSON.stringify(font.family)};font-style:${font.style};font-weight:${font.weight};font-display:swap;src:url('/${released.path}') format('${font.source.format}');}`);
}
if (fontCss.length) write("assets/styles/fonts.generated.css", `${fontCss.join("\n")}\n`);
for (const publication of input.publishedFiles) write(literalRouteFile(publication.route), fs.readFileSync(resolveRegularFile(publication.sourceSnapshot.checkoutRoot, publication.sourcePath, `published file '${publication.sourcePath}'`)));
const renderer = new StrictLiquidRenderer(path.join(input.theme.root, "partials"));
const stylesheets = input.theme.manifest.assets.styles.map((asset) => `/${asset}`);
if (fontCss.length) stylesheets.push("/assets/styles/fonts.generated.css");
const scripts = input.theme.manifest.assets.scripts.map((asset) => `/${asset}`);
const navigation = input.siteConfig.navigation.map(({ id, label, route }) => ({ id, label, route: normalizeDirectoryRoute(route) }));
const base = { site: input.siteConfig.site, navigation, theme: { id: input.theme.manifest.id, version: input.theme.manifest.version, stylesheets, scripts, fontStylesheet: fontCss.length ? "/assets/styles/fonts.generated.css" : null }, build: { id: input.artifactBuildId, generatedAt: input.generatedAt } };
const page = (templateKey: string, title: string, context: Record<string, unknown>, output: string, route: string) => {
const bodySource = input.theme.templateSources[templateKey];
if (!bodySource) throw new Error(`E_PROJECT_TEMPLATE_UNRESOLVED: template '${templateKey}' is missing.`);
const body = new SafeHtml(renderer.render(bodySource, { ...base, ...context }, templateKey));
const html = renderer.render(input.theme.templateSources.layout, { ...base, ...context, page: { title, body } }, "layout");
write(output, html.endsWith("\n") ? html : `${html}\n`); routes.add(route);
};
page("home", input.siteConfig.site.title, { content: input.contentItems }, "index.html", "/");
for (const entry of input.siteConfig.navigation.filter((item) => item.route !== "/")) {
const key = entry.presentation?.sectionTemplate ?? entry.contentModel!;
const items = input.contentItems.filter((item) => item.status === "published" && item.artifactType === entry.contentModel);
page(`sections.${key}`, entry.label, { section: entry, content: items }, directoryRouteFile(entry.route), normalizeDirectoryRoute(entry.route));
}
for (const project of input.projects) page(`content.${project.presentation.detailTemplate}`, project.title ?? project.id, { project: { ...project, sourceSnapshot: { sourceId: project.sourceSnapshot.sourceId, repository: project.sourceSnapshot.repository, commit: project.sourceSnapshot.commit, committedAt: project.sourceSnapshot.committedAt } } }, directoryRouteFile(project.route), normalizeDirectoryRoute(project.route));
for (const item of input.contentItems.filter((candidate) => candidate.status === "published")) {
const rendered = renderMarkdownDocument(item.contentMarkdown);
const key = item.presentation?.standalone ?? item.artifactType;
page(`content.${key}`, item.title, { item: { ...item, content: rendered.html, tableOfContents: rendered.toc } }, directoryRouteFile(item.route), normalizeDirectoryRoute(item.route));
}
page("notFound", "Not Found", {}, "404.html", "/404.html");
const declaredScripts = new Set(scripts);
const releaseFiles = new Set(files);
for (const relative of [...files].sort()) {
const bytes = fs.readFileSync(path.join(temporaryDirectory, ...relative.split("/")));
if (relative.endsWith(".html")) validateHtml(bytes.toString("utf8"), relative, input, declaredScripts, releaseFiles);
if (relative.endsWith(".css")) validateCss(bytes.toString("utf8"), relative, releaseFiles, new Set(input.theme.manifest.security.allowedExternalOrigins));
}
const manifest = { protocol: "labyricorn-build-manifest/v1", builderVersion: BUILDER_VERSION, artifactBuildId: input.artifactBuildId, generatedAt: input.generatedAt, siteDefinition: { sourceId: input.siteDefinition.sourceId, repository: input.siteDefinition.repository, commit: input.siteDefinition.commit }, sources: Object.fromEntries([...input.sources.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([id, source]) => [id, { repository: source.repository, commit: source.commit }])), theme: { id: input.theme.manifest.id, version: input.theme.manifest.version, sourceId: input.theme.snapshot.sourceId, repository: input.theme.snapshot.repository, commit: input.theme.snapshot.commit, manifestChecksum: input.theme.manifestChecksum, templates: input.theme.manifest.templates, inputs: input.theme.checksums }, projects: input.projects.map((project) => ({ id: project.id, source: project.source, route: project.route, template: project.presentation.detailTemplate })), publishedFiles: input.publishedFiles.map((file) => ({ projectId: file.projectId, source: file.sourcePath, route: file.route, mediaType: file.mediaType, sourceChecksum: `sha256-${file.sourceChecksum}` })), fonts: input.theme.manifest.assets.fonts, routes: [...routes].sort(), inputChecksums: input.inputChecksums };
write("build-manifest.json", stableJson(manifest));
const checksums = Object.fromEntries([...files].sort().map((relative) => [relative, `sha256-${sha256(fs.readFileSync(path.join(temporaryDirectory, ...relative.split("/"))))}`]));
const checksumsDocument = stableJson({ algorithm: "sha256", files: checksums });
write("checksums.json", checksumsDocument);
const artifactChecksum = `sha256-${sha256(checksumsDocument)}`;
const generatedFiles = [...files].sort();
const artifactSizeBytes = generatedFiles.reduce((sum, relative) => sum + fs.statSync(path.join(temporaryDirectory, ...relative.split("/"))).size, 0);
fs.renameSync(temporaryDirectory, outputDirectory);
return { success: true, outputDirectory, generatedRoutesCount: routes.size, artifactChecksum, artifactSizeBytes, validationReport, generatedFiles };
} catch (error) {
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
if (error instanceof ThemeContractError) throw error;
throw error;
}
}
private resolveFont(font: ThemeFont, input: ResolvedBuildInput): { path: string; bytes: Buffer } {
let bytes: Buffer;
if (font.source.kind === "git") {
const snapshot = input.sources.get(font.source.sourceId);
if (!snapshot) throw new Error(`E_THEME_FONT_SOURCE: unresolved Git font source '${font.source.sourceId}'.`);
bytes = fs.readFileSync(resolveRegularFile(snapshot.checkoutRoot, font.source.path, `font '${font.id}'`));
} else {
const url = new URL(font.source.url);
if (url.protocol !== "https:" || url.username || url.password) throw new Error(`E_THEME_FONT_SOURCE: unsafe URL for '${font.id}'.`);
const cache = path.join(this.outputRoot, "font-cache", font.source.checksum.slice("sha256-".length));
if (fs.existsSync(cache)) bytes = fs.readFileSync(cache);
else {
bytes = execFileSync("curl", ["--proto", "=https", "--silent", "--show-error", "--fail", "--max-redirs", "0", "--max-time", "15", font.source.url], { maxBuffer: 20 * 1024 * 1024 });
fs.mkdirSync(path.dirname(cache), { recursive: true });
}
if (`sha256-${sha256(bytes)}` !== font.source.checksum) throw new Error(`E_THEME_FONT_SOURCE: checksum mismatch for '${font.id}'.`);
if (!fs.existsSync(cache)) fs.writeFileSync(cache, bytes, { flag: "wx" });
}
return { path: `assets/fonts/${font.id}.${font.source.format}`, bytes };
}
verifyRelease(artifactBuildId: string): { artifactChecksum: string; files: string[]; routes: string[]; sizeBytes: number } {
const release = this.buildDirectory(artifactBuildId);
const manifestFile = path.join(release, "build-manifest.json");
const checksumsFile = path.join(release, "checksums.json");
if (!fs.existsSync(manifestFile) || !fs.existsSync(checksumsFile)) throw new Error(`E_RELEASE_INVALID: '${artifactBuildId}' is missing release metadata.`);
const manifest = JSON.parse(fs.readFileSync(manifestFile, "utf8"));
if (manifest.artifactBuildId !== artifactBuildId) throw new Error("E_RELEASE_INVALID: manifest artifact ID mismatch.");
const document = fs.readFileSync(checksumsFile, "utf8");
const checksums = JSON.parse(document) as { files: Record<string, string> };
let sizeBytes = fs.statSync(checksumsFile).size;
for (const [relative, checksum] of Object.entries(checksums.files)) {
const absolute = path.resolve(release, ...relative.split("/"));
if (!absolute.startsWith(`${release}${path.sep}`) || !fs.existsSync(absolute) || `sha256-${sha256(fs.readFileSync(absolute))}` !== checksum) throw new Error(`E_RELEASE_CHECKSUM: '${relative}' failed verification.`);
sizeBytes += fs.statSync(absolute).size;
}
return { artifactChecksum: `sha256-${sha256(document)}`, files: [...Object.keys(checksums.files), "checksums.json"].sort(), routes: manifest.routes ?? [], sizeBytes };
}
stage(artifactBuildId: string): string { this.verifyRelease(artifactBuildId); return this.point("staging", artifactBuildId); }
activate(artifactBuildId: string): string { this.verifyRelease(artifactBuildId); return this.point("current", artifactBuildId); }
private point(name: "staging" | "current", artifactBuildId: string): string {
fs.mkdirSync(this.outputRoot, { recursive: true });
const target = this.buildDirectory(artifactBuildId);
const pointer = path.join(this.outputRoot, name);
const next = path.join(this.outputRoot, `.${name}-${process.pid}-${crypto.randomUUID()}`);
fs.symlinkSync(target, next, process.platform === "win32" ? "junction" : "dir");
try { if (fs.lstatSync(pointer, { throwIfNoEntry: false })) fs.rmSync(pointer, { recursive: true, force: true }); fs.renameSync(next, pointer); }
catch (error) { fs.rmSync(next, { recursive: true, force: true }); throw error; }
return pointer;
}
}
+210 -512
View File
@@ -1,549 +1,247 @@
import fs from "node:fs";
import path from "node:path";
import YAML from "yaml";
import {
GitSource,
StyleConfigPackage,
StyleConfigInstance,
ContentItem,
MediaAsset,
ThemeConfig,
AuditLogEntry,
BuildLogEntry,
BuildRecord,
ContentItem,
CredentialRef,
GitSource,
MediaAsset,
NginxStatus,
PushTarget,
CredentialRef,
AuditLogEntry,
SiteConfig,
StyleConfigInstance,
StyleConfigPackage,
ThemeConfig,
ValidationReport,
BuildLogEntry,
NavigationEntry,
ContentModel,
} from "../types";
import YAML from "yaml";
import fs from "fs";
import path from "path";
import { BuildInputLoader } from "./buildInputLoader";
import { BuildEngine, BUILDER_VERSION } from "./buildEngine";
import { ThemeContractError } from "./theme/themeLoader";
const emptyReport = (): ValidationReport => ({ errors: [], warnings: [], missingMedia: [], routeCollisions: [], htmlPolicyViolations: [], brokenLinks: [], summary: { totalErrors: 0, totalWarnings: 0, passed: true } });
const fallbackSiteConfig = (): SiteConfig => {
const sitePath = path.resolve("packages/site-definition/site.yml");
const navigationPath = path.resolve("packages/site-definition/navigation.yml");
const rawYaml = fs.readFileSync(sitePath, "utf8");
const document = YAML.parse(rawYaml);
const navigation = YAML.parse(fs.readFileSync(navigationPath, "utf8")).navigation ?? [];
return {
protocol: document.protocol,
site: document.site,
navigation,
contentModels: [],
sourcesFile: document.sourcesFile ?? "sources.yml",
styleInstancesPath: document.styleInstancesPath ?? "../style-configs",
pagesPath: document.pagesPath ?? "pages",
navigationFile: document.navigationFile ?? "navigation.yml",
projectsFile: document.projectsFile,
pushIntegrationsPath: document.pushIntegrationsPath ?? "push-integrations",
theme: { ...document.theme, path: String(document.theme.path).replace(/^\/+/, "") },
markdown: document.markdown,
hosting: document.hosting,
buildPolicy: document.buildPolicy,
rawYaml,
readOnly: true,
};
};
export class LabyricornStore {
siteConfig: SiteConfig;
gitSources: GitSource[];
stylePackages: StyleConfigPackage[];
styleInstances: StyleConfigInstance[];
contentItems: ContentItem[];
mediaAssets: MediaAsset[];
themeConfig: ThemeConfig;
builds: BuildRecord[];
siteConfig: SiteConfig = fallbackSiteConfig();
gitSources: GitSource[] = [];
stylePackages: StyleConfigPackage[] = [];
styleInstances: StyleConfigInstance[] = [];
contentItems: ContentItem[] = [];
mediaAssets: MediaAsset[] = [];
themeConfig: ThemeConfig = {
id: "unresolved", name: "Theme not resolved", version: "0.0.0", path: this.siteConfig.theme.path,
templates: {}, styles: [], scripts: [], supportsPackages: [], isValidated: false,
validationErrors: ["No immutable repository snapshot has been loaded."], status: "unresolved",
};
builds: BuildRecord[] = [];
pushTargets: PushTarget[] = [];
credentials: CredentialRef[] = [];
auditLogs: AuditLogEntry[] = [];
readonly buildEngine = new BuildEngine();
nginxStatus: NginxStatus;
pushTargets: PushTarget[];
credentials: CredentialRef[];
auditLogs: AuditLogEntry[];
private readonly buildEngine = new BuildEngine();
constructor() {
this.gitSources = [];
this.stylePackages = [];
this.styleInstances = [];
this.contentItems = [];
this.mediaAssets = [];
this.builds = [];
this.pushTargets = [];
this.credentials = [];
this.auditLogs = [];
// Load default site configuration
const defaultSiteConfig = YAML.parse(
fs.readFileSync("./packages/site-definition/site.yml", "utf-8"),
);
const defaultNavConfig = YAML.parse(
fs.readFileSync("./packages/site-definition/navigation.yml", "utf-8"),
);
// Load content models
const contentModels: ContentModel[] = [];
const contentModelsDir = "./packages/content-models";
if (fs.existsSync(contentModelsDir)) {
const dirs = fs.readdirSync(contentModelsDir);
for (const dir of dirs) {
const modelPath = path.join(contentModelsDir, dir, "model.yml");
if (fs.existsSync(modelPath)) {
contentModels.push(YAML.parse(fs.readFileSync(modelPath, "utf-8")));
}
}
}
// Load style configurations
const stylePackagesDir = "./packages/style-configs";
if (fs.existsSync(stylePackagesDir)) {
const dirs = fs.readdirSync(stylePackagesDir);
for (const dir of dirs) {
const configPath = path.join(stylePackagesDir, dir, "config.yml");
if (fs.existsSync(configPath)) {
this.stylePackages.push(
YAML.parse(fs.readFileSync(configPath, "utf-8")),
);
}
}
}
this.siteConfig = {
protocol: defaultSiteConfig.protocol || "labyricorn-site/v1",
site: defaultSiteConfig.site,
navigation: defaultNavConfig.navigation || [],
contentModels: contentModels,
sourcesFile: "./sources.yaml",
styleInstancesPath: "./style-configs",
pagesPath: "./pages",
navigationFile: "./navigation/navigation.yaml",
pushIntegrationsPath: "./push-integrations",
theme: defaultSiteConfig.theme,
markdown: defaultSiteConfig.markdown,
hosting: defaultSiteConfig.hosting,
buildPolicy: defaultSiteConfig.buildPolicy,
rawYaml: "",
};
this.siteConfig.rawYaml = YAML.stringify({
protocol: this.siteConfig.protocol,
site: this.siteConfig.site,
navigation: this.siteConfig.navigation,
contentModels: this.siteConfig.contentModels,
theme: this.siteConfig.theme,
markdown: this.siteConfig.markdown,
hosting: this.siteConfig.hosting,
buildPolicy: this.siteConfig.buildPolicy,
});
this.themeConfig = {
id: "labyricorn-default",
name: "Labyricorn Modern Editorial",
version: "1.2.0",
path: "/.theme",
templates: {
page: "templates/page.html",
error: "templates/error.html",
"devlogs/entry": "templates/devlogs/entry.html",
"devlogs/detailed-summary": "templates/devlogs/detailed-summary.html",
"blog/post": "templates/blog/post.html",
},
styles: ["styles/reset.css", "styles/theme.css", "styles/components.css"],
scripts: ["scripts/theme.js"],
supportsPackages: this.stylePackages.map((pkg) => pkg.id),
isValidated: true,
validationErrors: [],
};
this.loadStylePackagesForDisplay();
this.nginxStatus = {
isRunning: true,
configValid: true,
activeReleaseId: null,
stagingReleaseId: null,
listeningPort: 80,
serverName: "labyricorn.local",
lastReloadTime: new Date().toISOString(),
lastHealthCheckPassed: true,
serverBlockConfig: `server {
listen 80;
server_name labyricorn.local;
root /var/lib/labyricorn/site/current;
index index.html;
location /media/ {
alias /var/lib/labyricorn/site/current/media/;
expires 30d;
add_header Cache-Control "public, no-transform";
}
location / {
try_files $uri $uri/ /index.html;
}
error_page 404 /404.html;
}`,
stagingServerBlockConfig: `server {
listen 8080;
server_name preview.labyricorn.local;
root /var/lib/labyricorn/site/staging;
index index.html;
location /media/ {
alias /var/lib/labyricorn/site/staging/media/;
}
location / {
try_files $uri $uri/ /index.html;
}
}`,
isRunning: true, configValid: true, activeReleaseId: null, stagingReleaseId: null,
listeningPort: 80, serverName: this.siteConfig.hosting.production.hostname,
lastReloadTime: new Date(0).toISOString(), lastHealthCheckPassed: false,
serverBlockConfig: "root /var/lib/labyricorn/site/current;",
stagingServerBlockConfig: "root /var/lib/labyricorn/site/staging;",
};
this.restoreReleasePointers();
try { this.resolveConfiguration("startup"); } catch (error) {
this.themeConfig = { ...this.themeConfig, status: "unresolved", isValidated: false, validationErrors: [error instanceof Error ? error.message : String(error)] };
}
}
// --- ACTIONS ---
private loadStylePackagesForDisplay(): void {
const root = path.resolve("packages/style-configs");
if (!fs.existsSync(root)) return;
for (const name of fs.readdirSync(root).sort()) {
const file = path.join(root, name, "config.yml");
if (fs.existsSync(file)) this.stylePackages.push(YAML.parse(fs.readFileSync(file, "utf8")));
}
}
addAudit(
action: string,
category: AuditLogEntry["category"],
details: string,
result: "success" | "failure" = "success",
) {
const entry: AuditLogEntry = {
id: `log-${Date.now()}`,
timestamp: new Date().toISOString(),
user: "[email protected]",
action,
category,
details,
result,
private loader(): BuildInputLoader {
return new BuildInputLoader({ repositoryRoot: process.env.LABYRICORN_SITE_DEFINITION_REPOSITORY ?? process.cwd(), ref: process.env.LABYRICORN_SITE_DEFINITION_REF ?? "HEAD", siteDefinitionPath: process.env.LABYRICORN_SITE_DEFINITION_PATH ?? "packages/site-definition", builderVersion: BUILDER_VERSION });
}
private applyResolvedInput(input: ReturnType<BuildInputLoader["load"]>): void {
this.siteConfig = input.siteConfig;
this.contentItems = [...input.contentItems];
this.mediaAssets = [...input.mediaAssets];
const manifest = input.theme.manifest;
this.themeConfig = {
id: manifest.id, name: manifest.id, version: manifest.version, path: input.siteConfig.theme.path,
templates: {
layout: manifest.templates.layout, home: manifest.templates.home, notFound: manifest.templates.notFound,
...Object.fromEntries(Object.entries(manifest.templates.sections ?? {}).map(([key, value]) => [`sections.${key}`, value])),
...Object.fromEntries(Object.entries(manifest.templates.content ?? {}).map(([key, value]) => [`content.${key}`, value])),
},
styles: [...manifest.assets.styles], scripts: [...manifest.assets.scripts], supportsPackages: this.stylePackages.map((item) => item.id),
isValidated: true, validationErrors: [], status: "valid", protocol: manifest.protocol,
sourceId: input.theme.snapshot.sourceId, repository: input.theme.snapshot.repository, commit: input.theme.snapshot.commit,
manifestChecksum: input.theme.manifestChecksum, checksums: { ...input.theme.checksums },
};
this.auditLogs.unshift(entry);
this.gitSources = [{
id: input.siteDefinition.sourceId, type: "site-definition", repository: input.siteDefinition.repository,
ref: process.env.LABYRICORN_SITE_DEFINITION_REF ?? "HEAD", required: true, allowWorkingTree: false,
health: "Healthy", reachability: "Healthy", configurationState: "Valid", visibility: "Private in Gitea",
projectionEnabled: true, produces: ["site", "theme", "projects"], status: "connected",
lastResolvedCommit: input.siteDefinition.commit, lastSyncedAt: input.generatedAt, branch: process.env.LABYRICORN_SITE_DEFINITION_REF ?? "HEAD",
}];
}
private resolveConfiguration(runId: string): void {
const loader = this.loader();
try { this.applyResolvedInput(loader.load(runId)); }
finally { loader.dispose(); }
}
addAudit(action: string, category: AuditLogEntry["category"], details: string, result: "success" | "failure" = "success"): void {
this.auditLogs.unshift({ id: `log-${Date.now()}-${this.auditLogs.length}`, timestamp: new Date().toISOString(), user: "[email protected]", action, category, details, result });
}
validateTheme(): ThemeConfig {
const loader = this.loader();
try {
const input = loader.load(`validation-${Date.now()}`);
this.applyResolvedInput(input);
this.addAudit("Validate Theme", "config", `Validated ${input.theme.manifest.id} at ${input.theme.snapshot.commit}`);
return this.themeConfig;
} catch (error) {
const messages = error instanceof ThemeContractError ? error.issues.map((issue) => `${issue.code}: ${issue.message}`) : [error instanceof Error ? error.message : String(error)];
this.themeConfig = { ...this.themeConfig, status: "invalid", isValidated: false, validationErrors: messages };
this.addAudit("Validate Theme", "config", messages.join("; "), "failure");
return this.themeConfig;
} finally { loader.dispose(); }
}
runBuild(): BuildRecord {
const buildStartedAt = Date.now();
const nextBuildNum = Math.max(
this.builds.reduce((highest, build) => Math.max(highest, build.buildNumber), 0) + 1,
this.buildEngine.nextBuildNumber(),
);
const buildId = `build-${String(nextBuildNum).padStart(6, "0")}`;
const number = this.builds.reduce((highest, build) => Math.max(highest, build.buildNumber), 0) + 1;
const runId = `build-${String(number).padStart(6, "0")}`;
const startedAt = Date.now();
const timestamp = new Date().toISOString();
const logs: BuildLogEntry[] = [
{
timestamp,
level: "info",
message: `Initializing Build #${nextBuildNum}...`,
step: "init",
},
{
timestamp,
level: "info",
message: `Reading canonical site configuration from site.yaml`,
step: "config",
},
{
timestamp,
level: "info",
message: `Resolving ${this.gitSources.length} Git sources...`,
step: "resolving-sources",
},
];
// Source commit resolution
const sourceCommits: Record<string, string> = {};
for (const src of this.gitSources) {
if (!src.projectionEnabled || src.health !== "Healthy") {
continue;
}
sourceCommits[src.id] = src.lastResolvedCommit || "unknown";
logs.push({
timestamp: new Date().toISOString(),
level: "info",
message: `Resolved source '${src.id}' (${src.ref}) -> commit ${src.lastResolvedCommit?.substring(0, 7) || "unknown"}`,
step: "resolving-sources",
});
}
// Content discovery
logs.push({
timestamp: new Date().toISOString(),
level: "info",
message: `Discovering Markdown content items across style instances...`,
step: "content-discovery",
});
logs.push({
timestamp: new Date().toISOString(),
level: "info",
message: `Found ${this.contentItems.length} Markdown items and ${this.mediaAssets.length} media assets.`,
step: "content-discovery",
});
// Validation
logs.push({
timestamp: new Date().toISOString(),
level: "info",
message: `Validating schemas, YouTube directives, Wikipedia links, raw HTML policy...`,
step: "validating",
});
logs.push({
timestamp: new Date().toISOString(),
level: "info",
message: `Rendering theme templates from ${this.siteConfig.theme.path}...`,
step: "building",
});
let validationReport: ValidationReport = {
errors: [],
warnings: [],
missingMedia: [],
routeCollisions: [],
htmlPolicyViolations: [],
brokenLinks: [],
summary: { totalErrors: 0, totalWarnings: 0, passed: true },
};
let engineResult: ReturnType<BuildEngine["build"]>;
const logs: BuildLogEntry[] = [{ timestamp, level: "info", message: "Resolving immutable repository inputs...", step: "resolving-sources" }];
const loader = this.loader();
try {
engineResult = this.buildEngine.build({
buildId,
siteConfig: this.siteConfig,
contentItems: this.contentItems,
mediaAssets: this.mediaAssets,
sourceCommits,
themeConfig: this.themeConfig,
});
validationReport = engineResult.validationReport;
if (!engineResult.success) {
const validationError = new Error(
`Validation failed with ${validationReport.summary.totalErrors} error(s).`,
);
(validationError as Error & { validationReport: ValidationReport }).validationReport =
validationReport;
throw validationError;
}
logs.push({
timestamp: new Date().toISOString(),
level: "success",
message: `Static site build complete. Output written to ${engineResult.outputDirectory}`,
step: "built",
});
logs.push({
timestamp: new Date().toISOString(),
level: "success",
message: `Generated build-manifest.json and checksums.json (${engineResult.artifactChecksum.substring(0, 20)}...)`,
step: "built",
});
} catch (e: any) {
logs.push({
timestamp: new Date().toISOString(),
level: "error",
message: `Build failed: ${e.message}`,
step: "building",
});
const failureReport: ValidationReport =
e.validationReport || {
errors: [
{ code: "E_BUILD_FAIL", message: e.message, category: "build" },
],
warnings: [],
missingMedia: [],
routeCollisions: [],
htmlPolicyViolations: [],
brokenLinks: [],
summary: { totalErrors: 1, totalWarnings: 0, passed: false },
};
const failedBuild: BuildRecord = {
id: buildId,
buildNumber: nextBuildNum,
timestamp,
durationMs: Date.now() - buildStartedAt,
status: "failed",
siteDefCommit: "local",
sourceCommits,
themeVersion: this.themeConfig.version,
builderVersion: BUILDER_VERSION,
generatedRoutesCount: 0,
mediaCount: 0,
artifactChecksum: "",
artifactSizeBytes: 0,
validationReport: failureReport,
logs,
stages: [
{ name: "Triggered", status: "succeeded", durationMs: 10 },
{
name: "Repository discovery",
status: "succeeded",
durationMs: 150,
},
{
name: "Repository synchronization",
status: "succeeded",
durationMs: 450,
},
{
name: "Metadata validation",
status: failureReport.summary.passed ? "succeeded" : "failed",
durationMs: 120,
},
{
name: "Artifact discovery",
status: failureReport.summary.passed ? "succeeded" : "skipped",
durationMs: failureReport.summary.passed ? 310 : undefined,
},
{
name: "Website projection build",
status: failureReport.summary.passed ? "failed" : "skipped",
durationMs: failureReport.summary.passed ? 150 : undefined,
},
{ name: "Local staging", status: "skipped" },
],
isStaged: false,
isActiveLocal: false,
const input = loader.load(runId);
this.applyResolvedInput(input);
logs.push({ timestamp: new Date().toISOString(), level: "success", message: `Resolved site definition ${input.siteDefinition.commit}.`, step: "resolving-sources" });
const result = this.buildEngine.build(input);
if (!result.success) throw Object.assign(new Error(`Validation failed with ${result.validationReport.summary.totalErrors} error(s).`), { validationReport: result.validationReport });
logs.push({ timestamp: new Date().toISOString(), level: "success", message: `Published immutable artifact ${input.artifactBuildId}.`, step: "built" });
const record: BuildRecord = {
id: runId, artifactBuildId: input.artifactBuildId, buildNumber: number, timestamp, durationMs: Date.now() - startedAt,
status: "built", siteDefCommit: input.siteDefinition.commit,
sourceCommits: Object.fromEntries([...input.sources.entries()].map(([id, source]) => [id, source.commit])),
themeVersion: input.theme.manifest.version, builderVersion: BUILDER_VERSION, generatedRoutesCount: result.generatedRoutesCount,
mediaCount: input.mediaAssets.length, artifactChecksum: result.artifactChecksum, artifactSizeBytes: result.artifactSizeBytes,
validationReport: result.validationReport, logs,
stages: [{ name: "Repository snapshots", status: "succeeded" }, { name: "Theme validation", status: "succeeded" }, { name: "Artifact rendering", status: "succeeded" }, { name: "Local staging", status: "pending" }],
isStaged: false, isActiveLocal: false,
};
this.builds.unshift(failedBuild);
return failedBuild;
}
const newBuild: BuildRecord = {
id: buildId,
buildNumber: nextBuildNum,
timestamp,
durationMs: Date.now() - buildStartedAt,
status: "built",
siteDefCommit: "local",
sourceCommits,
themeVersion: this.themeConfig.version,
builderVersion: BUILDER_VERSION,
generatedRoutesCount: engineResult.generatedRoutesCount,
mediaCount: this.mediaAssets.length,
artifactChecksum: engineResult.artifactChecksum,
artifactSizeBytes: engineResult.artifactSizeBytes,
validationReport,
logs,
stages: [
{ name: "Triggered", status: "succeeded", durationMs: 10 },
{ name: "Repository discovery", status: "succeeded", durationMs: 150 },
{
name: "Repository synchronization",
status: "succeeded",
durationMs: 450,
},
{ name: "Metadata validation", status: "succeeded", durationMs: 120 },
{ name: "Artifact discovery", status: "succeeded", durationMs: 310 },
{
name: "Website projection build",
status: "succeeded",
durationMs: 980,
},
{ name: "Local staging", status: "succeeded", durationMs: 50 },
],
isStaged: false,
isActiveLocal: false,
};
// Register the build before policy hooks so automatic activation can resolve it.
this.builds.unshift(newBuild);
// Auto staging if policy requires
if (this.siteConfig.buildPolicy.staging.enabled) {
newBuild.status = "staged";
newBuild.isStaged = true;
this.nginxStatus.stagingReleaseId = buildId;
logs.push({
timestamp: new Date().toISOString(),
level: "info",
message: `Exposed candidate build ${buildId} through staging server block (preview.labyricorn.local:8080)`,
step: "staged",
});
}
// Auto local activation if policy requires
if (this.siteConfig.buildPolicy.localActivation.automatic) {
this.activateReleaseLocally(buildId);
}
this.addAudit(
`Triggered Build #${nextBuildNum}`,
"build",
`Successfully built static release ${buildId} (${engineResult.artifactChecksum.substring(0, 20)})`,
);
return newBuild;
}
activateReleaseLocally(buildId: string): {
success: boolean;
message: string;
} {
const build = this.builds.find((b) => b.id === buildId);
if (!build)
return { success: false, message: `Build ${buildId} not found.` };
if (build.status === "failed")
return { success: false, message: `Cannot activate a failed build.` };
try {
this.buildEngine.activate(buildId);
this.builds.unshift(record);
if (input.siteConfig.buildPolicy.staging.enabled) {
this.buildEngine.stage(input.artifactBuildId);
record.status = "staged"; record.isStaged = true; record.stages[3].status = "succeeded";
this.nginxStatus.stagingReleaseId = input.artifactBuildId;
}
this.addAudit(`Triggered Build #${number}`, "build", `Built ${input.artifactBuildId} from ${input.siteDefinition.commit}.`);
return record;
} catch (error) {
const report: ValidationReport = (error as Error & { validationReport?: ValidationReport }).validationReport ?? {
...emptyReport(), errors: [{ code: "E_BUILD_FAIL", message: error instanceof Error ? error.message : String(error), category: "build" }],
summary: { totalErrors: 1, totalWarnings: 0, passed: false },
};
const failed: BuildRecord = {
id: runId, buildNumber: number, timestamp, durationMs: Date.now() - startedAt, status: "failed",
siteDefCommit: this.siteConfig.commit ?? "unresolved", sourceCommits: {}, themeVersion: this.themeConfig.version,
builderVersion: BUILDER_VERSION, generatedRoutesCount: 0, mediaCount: 0, artifactChecksum: "", artifactSizeBytes: 0,
validationReport: report, logs: [...logs, { timestamp: new Date().toISOString(), level: "error", message: report.errors.map((item) => item.message).join("; "), step: "failed" }],
stages: [{ name: "Repository snapshots", status: "failed" }, { name: "Theme validation", status: "skipped" }, { name: "Artifact rendering", status: "skipped" }, { name: "Local staging", status: "skipped" }],
isStaged: false, isActiveLocal: false,
};
this.builds.unshift(failed); this.addAudit(`Triggered Build #${number}`, "build", report.errors[0]?.message ?? "Build failed", "failure"); return failed;
} finally { loader.dispose(); }
}
activateReleaseLocally(buildId: string): { success: boolean; message: string } {
const build = this.builds.find((item) => item.id === buildId || item.artifactBuildId === buildId);
if (!build?.artifactBuildId || build.status === "failed") return { success: false, message: `Build '${buildId}' is not activatable.` };
try {
this.buildEngine.activate(build.artifactBuildId);
for (const item of this.builds) { item.isActiveLocal = false; if (item.status === "active-local") item.status = "built"; }
build.isActiveLocal = true; build.status = "active-local";
this.nginxStatus.activeReleaseId = build.artifactBuildId; this.nginxStatus.lastReloadTime = new Date().toISOString(); this.nginxStatus.lastHealthCheckPassed = true;
this.addAudit("Activate Local Release", "nginx", `current -> releases/${build.artifactBuildId}`);
return { success: true, message: `Activated immutable release ${build.artifactBuildId}.` };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.nginxStatus.lastHealthCheckPassed = false;
this.addAudit(
"Activate Local Release Failed",
"nginx",
message,
"failure",
);
return { success: false, message };
return { success: false, message: error instanceof Error ? error.message : String(error) };
}
}
// Update in-memory state only after the release pointer changed successfully.
for (const previousBuild of this.builds) {
previousBuild.isActiveLocal = false;
if (previousBuild.status === "active-local") previousBuild.status = "built";
}
build.isActiveLocal = true;
build.status = "active-local";
this.nginxStatus.activeReleaseId = buildId;
this.nginxStatus.lastReloadTime = new Date().toISOString();
this.nginxStatus.lastHealthCheckPassed = true;
this.addAudit(
"Activate Local Release",
"nginx",
`Updated Nginx symlink current -> releases/${buildId}`,
);
return {
success: true,
message: `Successfully updated Nginx 'current' symlink to ${buildId} and reloaded Nginx (nginx -t passed).`,
private restoreReleasePointers(): void {
const restore = (name: "staging" | "current"): string | null => {
const pointer = path.join(this.buildEngine.outputRoot, name);
try {
const target = fs.realpathSync(pointer);
const id = path.basename(target);
this.buildEngine.verifyRelease(id);
return id;
} catch { return null; }
};
this.nginxStatus.stagingReleaseId = restore("staging");
this.nginxStatus.activeReleaseId = restore("current");
this.nginxStatus.lastHealthCheckPassed = Boolean(this.nginxStatus.activeReleaseId);
}
getBuildRoot(): string {
return this.buildEngine.outputRoot;
getBuildRoot(): string { return this.buildEngine.outputRoot; }
getCurrentBuildDirectory(): string { return this.buildEngine.currentDirectory(); }
getPreviewDirectory(): string | null {
if (fs.existsSync(this.buildEngine.currentDirectory())) return this.buildEngine.currentDirectory();
if (fs.existsSync(this.buildEngine.stagingDirectory())) return this.buildEngine.stagingDirectory();
return null;
}
getCurrentBuildDirectory(): string {
return this.buildEngine.currentDirectory();
}
pushToTarget(
targetId: string,
buildId: string,
): { success: boolean; logs: string[] } {
const target = this.pushTargets.find((t) => t.id === targetId);
const build = this.builds.find((b) => b.id === buildId);
if (!target)
return { success: false, logs: [`Target ${targetId} not found`] };
if (!build) return { success: false, logs: [`Build ${buildId} not found`] };
target.status = "uploading";
const logs: string[] = [
`[${new Date().toLocaleTimeString()}] Establishing SSH connection to ${target.host}:22 using ${target.credential}...`,
`[${new Date().toLocaleTimeString()}] SSH connection established. Target path: ${target.remotePath}`,
`[${new Date().toLocaleTimeString()}] Preparing remote directory ${target.remotePath}/${buildId}...`,
`[${new Date().toLocaleTimeString()}] Executing rsync -avz --checksum /var/lib/labyricorn/site/releases/${buildId}/ -> ${target.host}:${target.remotePath}/${buildId}/`,
`[${new Date().toLocaleTimeString()}] Uploaded 1.25 MB in 1.4s. Verifying checksum...`,
`[${new Date().toLocaleTimeString()}] Updating remote symlink ${target.currentLink} -> ${target.remotePath}/${buildId}`,
`[${new Date().toLocaleTimeString()}] Verifying deployed build-manifest.json on remote VPS...`,
`[${new Date().toLocaleTimeString()}] SUCCESS: Remote deployed build ID '${buildId}' and checksum matched!`,
];
target.status = "verified";
target.lastDeployedBuildId = buildId;
target.lastPushedAt = new Date().toISOString();
target.remoteChecksum = build.artifactChecksum;
target.lastLogs = logs;
this.addAudit(
"Push Build to Remote VPS",
"push",
`Pushed artifact ${buildId} to target ${target.name} (${target.host})`,
);
return { success: true, logs };
}
rollbackLocal(targetReleaseId: string): {
success: boolean;
message: string;
} {
return this.activateReleaseLocally(targetReleaseId);
pushToTarget(targetId: string, buildId: string): { success: boolean; logs: string[] } {
const target = this.pushTargets.find((item) => item.id === targetId);
const build = this.builds.find((item) => item.id === buildId);
if (!target || !build?.artifactBuildId) return { success: false, logs: ["Target or verified build not found."] };
this.buildEngine.verifyRelease(build.artifactBuildId);
return { success: false, logs: ["Remote publication is not configured for this target."] };
}
rollbackLocal(targetReleaseId: string): { success: boolean; message: string } { return this.activateReleaseLocally(targetReleaseId); }
}
// Global Store Instance
export const store = new LabyricornStore();
+33
View File
@@ -0,0 +1,33 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "labyricorn-projects-v1",
"type": "object", "additionalProperties": false, "required": ["projects"],
"properties": {
"projects": { "type": "array", "items": { "$ref": "#/$defs/project" } }
},
"$defs": {
"project": {
"type": "object", "additionalProperties": false,
"required": ["id", "source", "route", "presentation"],
"properties": {
"id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" },
"source": { "type": "string", "minLength": 1 }, "route": { "$ref": "#/$defs/route" },
"title": { "type": "string" }, "summary": { "type": "string" }, "version": { "type": "string" },
"license": { "type": "string" }, "homepage": { "$ref": "#/$defs/route" },
"tags": { "type": "array", "items": { "type": "string" } }, "stack": { "type": "array", "items": { "type": "string" } },
"presentation": { "type": "object", "additionalProperties": false, "required": ["detailTemplate"], "properties": { "detailTemplate": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" } } },
"relationships": { "type": "object", "additionalProperties": { "$ref": "#/$defs/relationship" } },
"publishedFiles": { "type": "array", "items": { "$ref": "#/$defs/publishedFile" } }
}
},
"relationship": {
"type": "object", "additionalProperties": false, "required": ["contentModel", "matchField"],
"properties": { "contentModel": { "type": "string", "minLength": 1 }, "matchField": { "type": "string", "pattern": "^metadata\\.[A-Za-z][A-Za-z0-9]*$" }, "required": { "type": "boolean" } }
},
"publishedFile": {
"type": "object", "additionalProperties": false, "required": ["source", "route", "mediaType"],
"properties": { "source": { "type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*\\\\)[A-Za-z0-9._/-]+$" }, "route": { "$ref": "#/$defs/route" }, "mediaType": { "enum": ["text/html", "text/css", "application/javascript", "application/json", "image/svg+xml", "image/png", "image/jpeg"] } }
},
"route": { "type": "string", "pattern": "^/(?!.*(?:^|/)\\.\\.(?:/|$))[A-Za-z0-9._/-]*$" }
}
}
+83
View File
@@ -0,0 +1,83 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "labyricorn-theme-v1",
"type": "object",
"additionalProperties": false,
"required": ["protocol", "id", "version", "engine", "templates", "assets", "security"],
"properties": {
"protocol": { "const": "labyricorn-theme/v1" },
"id": { "type": "string", "minLength": 1 },
"version": { "type": "string", "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$" },
"engine": { "const": "liquid" },
"templates": {
"type": "object",
"additionalProperties": false,
"required": ["layout", "home", "notFound"],
"properties": {
"layout": { "$ref": "#/$defs/relativePath" },
"home": { "$ref": "#/$defs/relativePath" },
"notFound": { "$ref": "#/$defs/relativePath" },
"sections": { "$ref": "#/$defs/templateMap" },
"content": { "$ref": "#/$defs/templateMap" }
}
},
"assets": {
"type": "object",
"additionalProperties": false,
"required": ["styles", "scripts", "files", "fonts"],
"properties": {
"styles": { "$ref": "#/$defs/pathList" },
"scripts": { "$ref": "#/$defs/pathList" },
"files": { "$ref": "#/$defs/pathList" },
"fonts": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "family", "style", "weight", "source"],
"properties": {
"id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" },
"family": { "type": "string", "minLength": 1 },
"style": { "enum": ["normal", "italic", "oblique"] },
"weight": { "type": "integer", "minimum": 1, "maximum": 1000 },
"source": {
"oneOf": [
{
"type": "object", "additionalProperties": false,
"required": ["kind", "sourceId", "path", "format"],
"properties": {
"kind": { "const": "git" }, "sourceId": { "type": "string", "minLength": 1 },
"path": { "$ref": "#/$defs/relativePath" }, "format": { "$ref": "#/$defs/fontFormat" }
}
},
{
"type": "object", "additionalProperties": false,
"required": ["kind", "url", "checksum", "format"],
"properties": {
"kind": { "const": "https" }, "url": { "type": "string", "pattern": "^https://" },
"checksum": { "type": "string", "pattern": "^sha256-[0-9a-f]{64}$" }, "format": { "$ref": "#/$defs/fontFormat" }
}
}
]
}
}
}
}
}
},
"security": {
"type": "object", "additionalProperties": false,
"required": ["allowScripts", "allowExternalAssets", "allowedExternalOrigins"],
"properties": {
"allowScripts": { "type": "boolean" }, "allowExternalAssets": { "type": "boolean" },
"allowedExternalOrigins": { "type": "array", "uniqueItems": true, "items": { "type": "string", "pattern": "^https://[^/]+$" } }
}
}
},
"$defs": {
"relativePath": { "type": "string", "minLength": 1, "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*\\\\)(?![A-Za-z]:)(?!.*\\u0000)[A-Za-z0-9._/-]+$" },
"fontFormat": { "enum": ["woff", "woff2", "ttf", "otf"] },
"templateMap": { "type": "object", "propertyNames": { "pattern": "^[a-z][a-z0-9-]*$" }, "additionalProperties": { "$ref": "#/$defs/relativePath" } },
"pathList": { "type": "array", "uniqueItems": true, "items": { "$ref": "#/$defs/relativePath" } }
}
}
+201
View File
@@ -0,0 +1,201 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import Ajv2020 from "ajv/dist/2020.js";
import { Liquid } from "liquidjs";
import YAML from "yaml";
import projectSchema from "./project.schema.json" with { type: "json" };
import themeSchema from "./theme.schema.json" with { type: "json" };
import {
LoadedTheme,
ProjectDeclaration,
RepositorySnapshot,
ThemeManifestV1,
} from "../../types";
export interface ThemeValidationIssue {
code: string;
message: string;
file?: string;
category: string;
}
export class ThemeContractError extends Error {
constructor(public readonly issues: ThemeValidationIssue[]) {
super(issues.map((issue) => `${issue.code}: ${issue.message}`).join("\n"));
this.name = "ThemeContractError";
}
}
const ajv = new Ajv2020({ allErrors: true, strict: true });
const validateThemeSchema = ajv.compile(themeSchema);
const validateProjectSchema = ajv.compile(projectSchema);
const liquidParser = new Liquid({ strictVariables: true, strictFilters: true });
liquidParser.registerFilter("safe_content", (value: unknown) => value);
liquidParser.registerFilter("safe_page_body", (value: unknown) => value);
const MAX_TEMPLATE_BYTES = 1024 * 1024;
const MAX_FILE_BYTES = 20 * 1024 * 1024;
const MAX_THEME_BYTES = 50 * 1024 * 1024;
const sha256 = (value: Buffer | string) => crypto.createHash("sha256").update(value).digest("hex");
export const canonicalPath = (value: string, label: string): string => {
if (!value || value.includes("\0") || value.includes("\\") || path.posix.isAbsolute(value) || /^[A-Za-z]:/.test(value)) {
throw new ThemeContractError([{ code: "E_THEME_PATH_ESCAPE", message: `${label} must be a repository-relative POSIX path.`, file: value, category: "path" }]);
}
const normalized = path.posix.normalize(value);
if (normalized === ".." || normalized.startsWith("../") || normalized.split("/").includes("..")) {
throw new ThemeContractError([{ code: "E_THEME_PATH_ESCAPE", message: `${label} escapes its allowed root.`, file: value, category: "path" }]);
}
return normalized.replace(/^\.\//, "");
};
export const resolveRegularFile = (root: string, relative: string, label: string, maxBytes = MAX_FILE_BYTES): string => {
const canonical = canonicalPath(relative, label);
const rootReal = fs.realpathSync(root);
const candidate = path.resolve(root, ...canonical.split("/"));
let real: string;
try {
real = fs.realpathSync(candidate);
} catch {
throw new ThemeContractError([{ code: "E_THEME_FILE_MISSING", message: `${label} does not exist.`, file: relative, category: "filesystem" }]);
}
if (real !== rootReal && !real.startsWith(`${rootReal}${path.sep}`)) {
throw new ThemeContractError([{ code: "E_THEME_PATH_ESCAPE", message: `${label} resolves outside its allowed root.`, file: relative, category: "path" }]);
}
const stat = fs.statSync(real);
if (!stat.isFile()) {
throw new ThemeContractError([{ code: "E_THEME_FILE_INVALID", message: `${label} must be a regular file.`, file: relative, category: "filesystem" }]);
}
if (stat.size > maxBytes) {
throw new ThemeContractError([{ code: "E_THEME_RESOURCE_LIMIT", message: `${label} exceeds the ${maxBytes}-byte limit.`, file: relative, category: "resource" }]);
}
return real;
};
const flattenTemplates = (manifest: ThemeManifestV1): Record<string, string> => ({
layout: manifest.templates.layout,
home: manifest.templates.home,
notFound: manifest.templates.notFound,
...Object.fromEntries(Object.entries(manifest.templates.sections ?? {}).map(([key, value]) => [`sections.${key}`, value])),
...Object.fromEntries(Object.entries(manifest.templates.content ?? {}).map(([key, value]) => [`content.${key}`, value])),
});
const schemaIssues = (code: string, prefix: string, errors: typeof validateThemeSchema.errors): ThemeValidationIssue[] =>
(errors ?? []).map((error) => ({
code,
message: `${prefix}${error.instancePath || "/"} ${error.message ?? "is invalid"}`,
category: "schema",
}));
export class ThemeLoader {
load(
snapshot: RepositorySnapshot,
themePath: string,
requiredSections: readonly string[] = [],
requiredContent: readonly string[] = [],
): LoadedTheme {
const relativeRoot = canonicalPath(themePath.replace(/^\/+/, ""), "theme.path");
const root = path.resolve(snapshot.checkoutRoot, ...relativeRoot.split("/"));
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
throw new ThemeContractError([{ code: "E_THEME_ROOT_INVALID", message: `Theme root '${relativeRoot}' is missing or is not a directory.`, file: relativeRoot, category: "filesystem" }]);
}
const manifestPath = resolveRegularFile(root, "theme.yml", "theme manifest", MAX_TEMPLATE_BYTES);
const manifestBytes = fs.readFileSync(manifestPath);
let manifest: ThemeManifestV1;
try {
manifest = YAML.parse(manifestBytes.toString("utf8"));
} catch (error) {
throw new ThemeContractError([{ code: "E_THEME_MANIFEST_INVALID", message: `theme.yml is not valid YAML: ${error instanceof Error ? error.message : String(error)}`, file: "theme.yml", category: "schema" }]);
}
if (!validateThemeSchema(manifest)) {
throw new ThemeContractError(schemaIssues("E_THEME_MANIFEST_INVALID", "theme.yml", validateThemeSchema.errors));
}
if (!manifest.security.allowScripts && manifest.assets.scripts.length > 0) {
throw new ThemeContractError([{ code: "E_THEME_SCRIPT_POLICY", message: "assets.scripts must be empty when allowScripts is false.", file: "theme.yml", category: "security" }]);
}
if (!manifest.security.allowExternalAssets && manifest.security.allowedExternalOrigins.length > 0) {
throw new ThemeContractError([{ code: "E_THEME_EXTERNAL_ASSET", message: "allowedExternalOrigins must be empty when external assets are disabled.", file: "theme.yml", category: "security" }]);
}
const missingKeys = [
...requiredSections.filter((key) => !manifest.templates.sections?.[key]).map((key) => `sections.${key}`),
...requiredContent.filter((key) => !manifest.templates.content?.[key]).map((key) => `content.${key}`),
];
if (missingKeys.length > 0) {
throw new ThemeContractError(missingKeys.map((key) => ({ code: "E_PROJECT_TEMPLATE_UNRESOLVED", message: `Configured template key '${key}' is not declared by theme.yml.`, file: "theme.yml", category: "template" })));
}
const templateSources: Record<string, string> = {};
const checksums: Record<string, string> = { "theme.yml": sha256(manifestBytes) };
let totalBytes = manifestBytes.length;
for (const [key, relative] of Object.entries(flattenTemplates(manifest)).sort(([a], [b]) => a.localeCompare(b))) {
const file = resolveRegularFile(root, relative, `template '${key}'`, MAX_TEMPLATE_BYTES);
const bytes = fs.readFileSync(file);
const source = bytes.toString("utf8").replace(/\r\n/g, "\n");
const includes = [...source.matchAll(/{%\s*(?:include|render)\s+([^%]+)%}/g)];
for (const include of includes) {
const expression = include[1].trim();
const staticName = expression.match(/^['"]([A-Za-z0-9._/-]+)['"](?:\s*,.*)?$/)?.[1];
if (!staticName || staticName.includes("..")) {
throw new ThemeContractError([{ code: "E_THEME_INCLUDE_POLICY", message: `Template '${key}' contains a dynamic or escaping include.`, file: relative, category: "template" }]);
}
resolveRegularFile(path.join(root, "partials"), staticName.endsWith(".liquid") ? staticName : `${staticName}.liquid`, `partial '${staticName}'`, MAX_TEMPLATE_BYTES);
}
try {
liquidParser.parse(source);
} catch (error) {
throw new ThemeContractError([{ code: "E_THEME_TEMPLATE_INVALID", message: `Template '${key}' failed to parse: ${error instanceof Error ? error.message : String(error)}`, file: relative, category: "template" }]);
}
templateSources[key] = source;
checksums[relative] = sha256(bytes);
totalBytes += bytes.length;
}
const declaredAssets = [...manifest.assets.styles, ...manifest.assets.scripts, ...manifest.assets.files];
for (const relative of declaredAssets) {
if (!relative.startsWith("assets/")) {
throw new ThemeContractError([{ code: "E_THEME_ASSET_INVALID", message: `Declared asset '${relative}' must be below assets/.`, file: relative, category: "asset" }]);
}
const file = resolveRegularFile(root, relative, `asset '${relative}'`);
const bytes = fs.readFileSync(file);
checksums[relative] = sha256(bytes);
totalBytes += bytes.length;
}
if (new Set(declaredAssets).size !== declaredAssets.length) {
throw new ThemeContractError([{ code: "E_THEME_ASSET_DUPLICATE", message: "Theme asset declarations must be unique across all asset lists.", file: "theme.yml", category: "asset" }]);
}
if (totalBytes > MAX_THEME_BYTES) {
throw new ThemeContractError([{ code: "E_THEME_RESOURCE_LIMIT", message: `Theme inputs exceed the ${MAX_THEME_BYTES}-byte aggregate limit.`, category: "resource" }]);
}
return Object.freeze({
manifest: Object.freeze(manifest), root, snapshot,
manifestChecksum: `sha256-${sha256(manifestBytes)}`,
checksums: Object.freeze(Object.fromEntries(Object.entries(checksums).sort(([a], [b]) => a.localeCompare(b)))),
templateSources: Object.freeze(templateSources),
});
}
}
export const parseProjects = (bytes: Buffer): ProjectDeclaration[] => {
let document: unknown;
try {
document = YAML.parse(bytes.toString("utf8"));
} catch (error) {
throw new ThemeContractError([{ code: "E_PROJECT_CONFIG_INVALID", message: `projects.yml is not valid YAML: ${error instanceof Error ? error.message : String(error)}`, category: "project" }]);
}
if (!validateProjectSchema(document)) {
throw new ThemeContractError(schemaIssues("E_PROJECT_CONFIG_INVALID", "projects.yml", validateProjectSchema.errors));
}
const projects = (document as { projects: ProjectDeclaration[] }).projects;
const ids = new Set<string>();
const routes = new Set<string>();
for (const project of projects) {
if (ids.has(project.id)) throw new ThemeContractError([{ code: "E_PROJECT_DUPLICATE", message: `Duplicate project id '${project.id}'.`, category: "project" }]);
if (routes.has(project.route)) throw new ThemeContractError([{ code: "E_ROUTE_COLLISION", message: `Duplicate project route '${project.route}'.`, category: "routing" }]);
ids.add(project.id);
routes.add(project.route);
}
return projects;
};
+11 -32
View File
@@ -35,8 +35,6 @@ export const ConfigAndAuditTab: React.FC<ConfigAndAuditTabProps> = ({
const [activeSubTab, setActiveSubTab] = useState<
"credentials" | "audit" | "yaml"
>("yaml");
const [yamlContent, setYamlContent] = useState(siteConfig.rawYaml);
const [yamlSavedMsg, setYamlSavedMsg] = useState<string | null>(null);
// New Credential Form
const [showAddCredModal, setShowAddCredModal] = useState(false);
@@ -44,18 +42,6 @@ export const ConfigAndAuditTab: React.FC<ConfigAndAuditTabProps> = ({
const [credType, setCredType] = useState<CredentialRef["type"]>("ssh_key");
const [credVal, setCredVal] = useState("");
const handleSaveYaml = async () => {
try {
await onSaveSiteConfig(yamlContent);
setYamlSavedMsg(
"Updated site.yaml successfully! Created Git commit on site-definition repository.",
);
setTimeout(() => setYamlSavedMsg(null), 4000);
} catch (err: any) {
alert(`YAML Save Error: ${err.message}`);
}
};
const handleCreateCred = async (e: React.FormEvent) => {
e.preventDefault();
if (!credName || !credVal) return;
@@ -79,7 +65,7 @@ export const ConfigAndAuditTab: React.FC<ConfigAndAuditTabProps> = ({
</span>
</div>
<p className="text-xs text-neutral-400 mt-1">
Manage global site.yaml settings, secret credential references, and
Inspect Git-owned site configuration, manage credential references, and
view system audit logs.
</p>
</div>
@@ -96,7 +82,7 @@ export const ConfigAndAuditTab: React.FC<ConfigAndAuditTabProps> = ({
}`}
>
<FileCode className="w-4 h-4" />
<span>Declarative site.yaml (Git Commits)</span>
<span>Declarative site.yml (Read-only)</span>
</button>
<button
@@ -126,12 +112,10 @@ export const ConfigAndAuditTab: React.FC<ConfigAndAuditTabProps> = ({
{activeSubTab === "yaml" ? (
<div className="space-y-4">
{yamlSavedMsg && (
<div className="p-4 bg-emerald-950/40 border border-emerald-500/20 text-emerald-300 text-xs rounded-sm flex items-center space-x-2">
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
<span>{yamlSavedMsg}</span>
</div>
)}
<div className="p-4 bg-amber-950/30 border border-amber-500/20 text-amber-200 text-xs rounded-sm">
Configuration is read-only in v1. Changes must be reviewed and committed in the source repository.
{siteConfig.commit && <span className="block mt-1 font-mono text-amber-300">Resolved commit: {siteConfig.commit}</span>}
</div>
<div className="bg-[#111111] rounded-sm p-6 border border-[#1F1F1F] space-y-4">
<div className="flex items-center justify-between text-white border-b border-[#1F1F1F] pb-3">
@@ -143,20 +127,15 @@ export const ConfigAndAuditTab: React.FC<ConfigAndAuditTabProps> = ({
</span>
</div>
<button
onClick={handleSaveYaml}
className="px-4 py-2 bg-[#C5A059] hover:bg-[#b38f4a] text-black rounded-sm text-xs font-semibold shadow-sm transition-all flex items-center space-x-1.5 uppercase tracking-wider"
>
<GitCommit className="w-3.5 h-3.5" />
<span>Save & Commit to Git</span>
</button>
<span className="px-3 py-1.5 border border-[#262626] text-neutral-400 text-[10px] uppercase tracking-wider">Git-owned · Read-only</span>
</div>
<textarea
rows={16}
value={yamlContent}
onChange={(e) => setYamlContent(e.target.value)}
className="w-full bg-[#0A0A0A] text-[#C5A059] font-mono text-xs p-4 rounded-sm border border-[#1F1F1F] focus:outline-none focus:border-[#C5A059]"
value={siteConfig.rawYaml}
readOnly
aria-label="Resolved site configuration"
className="w-full bg-[#0A0A0A] text-[#C5A059] font-mono text-xs p-4 rounded-sm border border-[#1F1F1F] focus:outline-none"
/>
</div>
</div>
+17 -7
View File
@@ -27,9 +27,7 @@ export const ThemeTab: React.FC<ThemeTabProps> = ({
const handleValidate = async () => {
setIsValidating(true);
await onValidateTheme();
setStatusMsg(
"Theme /.theme validated successfully. All required templates, CSS resets, and package supports are valid.",
);
setStatusMsg("Validation finished. The status below is tied to the displayed commit.");
setIsValidating(false);
};
@@ -43,13 +41,13 @@ export const ThemeTab: React.FC<ThemeTabProps> = ({
Theme Management
</h1>
<span className="px-2.5 py-0.5 rounded-xs text-[10px] uppercase font-bold tracking-widest bg-[#C5A059]/10 text-[#C5A059] border border-[#C5A059]/30">
PRD AC-10 Standard Location: /.theme
Repository Theme · .theme
</span>
</div>
<p className="text-xs text-neutral-400 mt-1">
Loads active theme from{" "}
<code className="text-[#C5A059] font-mono">/.theme</code>, validates{" "}
<code className="text-neutral-300 font-mono">theme.yaml</code>,
Resolves the active theme from{" "}
<code className="text-[#C5A059] font-mono">.theme</code> at an exact commit, validates{" "}
<code className="text-neutral-300 font-mono">theme.yml</code>,
templates, assets, and supported package layouts.
</p>
</div>
@@ -75,6 +73,13 @@ export const ThemeTab: React.FC<ThemeTabProps> = ({
</div>
)}
<div className={`p-4 border rounded-sm text-xs ${theme.status === "valid" ? "bg-emerald-950/30 border-emerald-500/20 text-emerald-300" : theme.status === "invalid" ? "bg-red-950/30 border-red-500/20 text-red-300" : "bg-amber-950/30 border-amber-500/20 text-amber-200"}`}>
<span className="font-bold uppercase tracking-wider">{theme.status === "valid" ? "Valid at commit" : theme.status === "invalid" ? "Invalid" : "Not resolved"}</span>
{theme.commit && <code className="block mt-1 break-all">{theme.commit}</code>}
{theme.repository && <span className="block mt-1 text-neutral-400 break-all">{theme.repository}</span>}
{theme.validationErrors.map((error) => <span key={error} className="block mt-1">{error}</span>)}
</div>
{/* THEME STRUCTURE GRID */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* LEFT COLUMN: THEME METADATA & MANIFEST (5 COLS) */}
@@ -106,6 +111,11 @@ export const ThemeTab: React.FC<ThemeTabProps> = ({
<span className="text-white font-semibold">{theme.id}</span>
</div>
<div className="p-3 bg-[#0A0A0A] rounded-sm border border-[#1F1F1F] space-y-1 font-mono">
<span className="text-[10px] text-neutral-500 font-bold uppercase tracking-widest block">Manifest checksum</span>
<span className="text-white text-[10px] break-all">{theme.manifestChecksum ?? "Not resolved"}</span>
</div>
<div className="p-3 bg-[#0A0A0A] rounded-sm border border-[#1F1F1F] space-y-2">
<span className="text-[10px] text-neutral-500 font-bold uppercase tracking-widest block font-mono">
Supported Style Packages ({theme.supportsPackages.length})
+135
View File
@@ -120,6 +120,13 @@ export interface ThemeConfig {
supportsPackages: string[];
isValidated: boolean;
validationErrors: string[];
status: "unresolved" | "invalid" | "valid";
protocol?: "labyricorn-theme/v1";
sourceId?: string;
repository?: string;
commit?: string;
manifestChecksum?: string;
checksums?: Record<string, string>;
}
export interface ValidationReport {
@@ -155,6 +162,7 @@ export interface BuildLogEntry {
export interface BuildRecord {
id: string;
artifactBuildId?: string;
buildNumber: number;
timestamp: string;
durationMs: number;
@@ -248,6 +256,9 @@ export interface NavigationEntry {
iconName: string;
contentModel?: string;
styleConfig?: string;
presentation?: {
sectionTemplate?: string;
};
}
export interface ContentModelSemantics {
@@ -291,6 +302,7 @@ export interface SiteConfig {
styleInstancesPath: string;
pagesPath: string;
navigationFile: string;
projectsFile?: string;
pushIntegrationsPath: string;
theme: {
source: string;
@@ -342,4 +354,127 @@ export interface SiteConfig {
};
};
rawYaml: string;
sourceId?: string;
repository?: string;
commit?: string;
readOnly?: true;
}
export interface RepositorySnapshot {
sourceId: string;
repository: string;
commit: string;
checkoutRoot: string;
committedAt: string;
}
export interface ThemeFontGitSource {
kind: "git";
sourceId: string;
path: string;
format: "woff" | "woff2" | "ttf" | "otf";
}
export interface ThemeFontHttpsSource {
kind: "https";
url: string;
checksum: `sha256-${string}`;
format: "woff" | "woff2" | "ttf" | "otf";
}
export interface ThemeFont {
id: string;
family: string;
style: "normal" | "italic" | "oblique";
weight: number;
source: ThemeFontGitSource | ThemeFontHttpsSource;
}
export interface ThemeManifestV1 {
protocol: "labyricorn-theme/v1";
id: string;
version: string;
engine: "liquid";
templates: {
layout: string;
home: string;
notFound: string;
sections?: Record<string, string>;
content?: Record<string, string>;
};
assets: {
styles: string[];
scripts: string[];
files: string[];
fonts: ThemeFont[];
};
security: {
allowScripts: boolean;
allowExternalAssets: boolean;
allowedExternalOrigins: string[];
};
}
export interface LoadedTheme {
manifest: ThemeManifestV1;
root: string;
snapshot: RepositorySnapshot;
manifestChecksum: string;
checksums: Readonly<Record<string, string>>;
templateSources: Readonly<Record<string, string>>;
}
export interface ProjectRelationship {
contentModel: string;
matchField: `metadata.${string}`;
required?: boolean;
}
export interface PublishedFile {
source: string;
route: string;
mediaType: string;
}
export interface ProjectDeclaration {
id: string;
source: string;
route: string;
title?: string;
summary?: string;
version?: string;
license?: string;
homepage?: string;
tags?: string[];
stack?: string[];
presentation: { detailTemplate: string };
relationships?: Record<string, ProjectRelationship>;
publishedFiles?: PublishedFile[];
}
export interface ResolvedProject extends ProjectDeclaration {
sourceSnapshot: RepositorySnapshot;
relatedContent: Readonly<Record<string, readonly ContentItem[]>>;
}
export interface ResolvedPublishedFile extends PublishedFile {
projectId: string;
sourceSnapshot: RepositorySnapshot;
sourcePath: string;
sourceChecksum: string;
}
export 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[];
inputChecksums: Readonly<Record<string, string>>;
}