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 # 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 ## Engine capabilities
The build engine now: The build engine now:
- validates routes, route collisions, content state, raw-HTML policy, and media references; - resolves the site definition and theme to exact 40-character Git commits;
- renders published content and navigation indexes into static HTML; - materializes detached snapshots before reading configuration, projects, templates, or assets;
- escapes source HTML and renders a safe CommonMark-style subset; - validates the closed `labyricorn-theme/v1` and project schemas without executing repository code;
- emits deterministic `build-manifest.json` and `checksums.json` files; - renders explicit template keys through LiquidJS in strict mode with engine-owned safe-content filters;
- calculates an artifact checksum from generated file checksums; - copies only declared assets, verified fonts, and configuration-authorized standalone files;
- writes releases atomically beneath the configured build root; - emits deterministic provenance-rich `build-manifest.json` and `checksums.json` files;
- activates releases through an atomic `current` symlink/junction; - writes immutable releases atomically beneath `releases/<artifact-build-id>/`;
- survives process restarts without reusing existing build numbers; and - stages and promotes the same checksum-verified release through atomic pointers; and
- serves the active generated release through the live-preview endpoint. - serves only a selected release through the preview endpoint, with no synthesized fallback.
## Development ## Development
@@ -46,15 +46,18 @@ By default, releases are written to the platform temporary directory under `laby
Each successful release contains: 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`; - `404.html`;
- `build-manifest.json`; and - `build-manifest.json`; and
- `checksums.json`. - `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 ## Current boundaries
- The built-in renderer is used until repository-provided theme templates are implemented. - Theme packages are presentation data only; Node, shell, WASM, package-manager scripts, custom Liquid tags, and custom filters are not executed.
- Git source synchronization supports HTTP(S), SSH-style Git URLs, and local paths; other source types remain unavailable. - 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. - 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. 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. 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", "@google/genai": "^2.4.0",
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.0.4",
"ajv": "8.20.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^4.21.2", "express": "^4.21.2",
"liquidjs": "10.27.2",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"motion": "^12.23.24", "motion": "^12.23.24",
"react": "^19.0.1", "react": "^19.0.1",
@@ -1747,6 +1749,22 @@
"node": ">= 14" "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": { "node_modules/array-flatten": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
@@ -1967,6 +1985,15 @@
], ],
"license": "CC-BY-4.0" "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": { "node_modules/content-disposition": {
"version": "0.5.4", "version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@ -2294,6 +2321,28 @@
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
"license": "MIT" "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": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -2699,6 +2748,12 @@
"bignumber.js": "^9.0.0" "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": { "node_modules/json5": {
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -2993,6 +3048,26 @@
"url": "https://opencollective.com/parcel" "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": { "node_modules/long": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
@@ -3427,6 +3502,15 @@
"node": ">=0.10.0" "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": { "node_modules/retry": {
"version": "0.13.1", "version": "0.13.1",
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
+4 -2
View File
@@ -16,8 +16,10 @@
"@google/genai": "^2.4.0", "@google/genai": "^2.4.0",
"@tailwindcss/vite": "^4.1.14", "@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4", "@vitejs/plugin-react": "^5.0.4",
"ajv": "8.20.0",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"express": "^4.21.2", "express": "^4.21.2",
"liquidjs": "10.27.2",
"lucide-react": "^0.546.0", "lucide-react": "^0.546.0",
"motion": "^12.23.24", "motion": "^12.23.24",
"react": "^19.0.1", "react": "^19.0.1",
@@ -26,13 +28,13 @@
"yaml": "^2.9.0" "yaml": "^2.9.0"
}, },
"devDependencies": { "devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.14.0", "@types/node": "^22.14.0",
"autoprefixer": "^10.4.21", "autoprefixer": "^10.4.21",
"esbuild": "^0.25.0", "esbuild": "^0.25.0",
"tailwindcss": "^4.1.14", "tailwindcss": "^4.1.14",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "~5.8.2", "typescript": "~5.8.2",
"vite": "^6.2.3", "vite": "^6.2.3"
"@types/express": "^4.17.21"
} }
} }
@@ -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/" route: "/articles/"
iconName: "FileText" iconName: "FileText"
contentModel: "article" contentModel: "article"
presentation:
sectionTemplate: "articles"
- id: "projects" - id: "projects"
label: "Projects" label: "Projects"
route: "/projects/" route: "/projects/"
iconName: "LayoutTemplate" iconName: "LayoutTemplate"
contentModel: "project" contentModel: "project"
presentation:
sectionTemplate: "projects"
- id: "documentation" - id: "documentation"
label: "Documentation" label: "Documentation"
route: "/docs/" route: "/docs/"
iconName: "FolderOpen" iconName: "FolderOpen"
contentModel: "documentation" contentModel: "documentation"
presentation:
sectionTemplate: "documentation"
- id: "community" - id: "community"
label: "Community" label: "Community"
route: "/community/" route: "/community/"
iconName: "MessageSquare" iconName: "MessageSquare"
contentModel: "discussion" contentModel: "discussion"
presentation:
sectionTemplate: "community"
- id: "downloads" - id: "downloads"
label: "Downloads" label: "Downloads"
route: "/downloads/" route: "/downloads/"
iconName: "Download" iconName: "Download"
contentModel: "release" 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" title: "Labyricorn Engineering & Architecture"
baseUrl: "https://labyricorn.com" baseUrl: "https://labyricorn.com"
language: "en-US" language: "en-US"
navigationFile: "navigation.yml"
projectsFile: "projects.yml"
theme: theme:
source: "site-definition" source: "site-definition"
path: "/.theme" path: ".theme"
markdown: markdown:
dialect: "labyricorn-commonmark-v1" dialect: "labyricorn-commonmark-v1"
rawHtmlPolicy: "disabled" rawHtmlPolicy: "disabled"
+36 -213
View File
@@ -33,27 +33,11 @@ async function startServer() {
res.json(store.siteConfig); res.json(store.siteConfig);
}); });
app.put("/api/site-config", (req, res) => { app.put("/api/site-config", (_req, res) => {
try { res.status(405).json({
const { rawYaml } = req.body; code: "E_CONFIG_READ_ONLY",
if (rawYaml) { message: "Site configuration is Git-owned and read-only in v1.",
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}` });
}
}); });
// --- GITEA DISCOVERY --- // --- GITEA DISCOVERY ---
@@ -449,19 +433,12 @@ async function startServer() {
res.json(store.themeConfig); res.json(store.themeConfig);
}); });
app.post("/api/theme/validate", (req, res) => { app.post("/api/theme/validate", (_req, res) => {
store.themeConfig.isValidated = true; const theme = store.validateTheme();
store.themeConfig.validationErrors = []; res.status(theme.status === "valid" ? 200 : 422).json({
store.addAudit( success: theme.status === "valid",
"Validate Theme", theme,
"config", commits: theme.commit ? { [theme.sourceId ?? "theme"]: theme.commit } : {},
"Validated theme manifest /.theme/theme.yaml",
);
res.json({
success: true,
theme: store.themeConfig,
message:
"Theme /.theme loaded successfully. All required templates and packages supported.",
}); });
}); });
@@ -559,191 +536,37 @@ async function startServer() {
res.json(store.auditLogs); res.json(store.auditLogs);
}); });
// --- LIVE SITE HTML PREVIEW ENDPOINT --- // --- RELEASE-ONLY SITE PREVIEW ENDPOINT ---
app.get("/api/live-site/html", (req, res) => { app.get("/api/live-site/html", (req, res) => {
const activeBuild = const rootDir = store.getPreviewDirectory();
store.builds.find((b) => b.isActiveLocal) || store.builds[0]; if (!rootDir) {
let pageRoute = (req.query.route as string) || "/"; return res.status(503).json({
code: "E_RELEASE_UNAVAILABLE",
const rootDir = store.getCurrentBuildDirectory(); message: "No verified staging or live release is selected.",
});
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"));
}
} }
// Simple HTML renderer simulating compiled static site output (fallback) let requested: string;
let pageTitle = store.siteConfig.site.title; try {
let mainContent = ""; requested = decodeURIComponent(String(req.query.route ?? "/").split(/[?#]/, 1)[0]);
} catch {
// Match route against configured navigation entries return res.status(400).json({ code: "E_ROUTE_INVALID" });
const navMatch = store.siteConfig.navigation.find( }
(nav) => nav.route === pageRoute, if (!requested.startsWith("/") || requested.includes("\\") || requested.split("/").includes("..")) {
); return res.status(400).json({ code: "E_ROUTE_INVALID" });
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>
`;
}
} }
const html = ` const relative = requested.replace(/^\/+/, "");
<!DOCTYPE html> const outputFile = relative && path.posix.extname(relative) ? relative : path.posix.join(relative, "index.html");
<html lang="en"> const rootPath = `${path.resolve(rootDir)}${path.sep}`;
<head> const target = path.resolve(rootDir, ...outputFile.split("/"));
<meta charset="UTF-8"> if (!target.startsWith(rootPath)) return res.status(400).json({ code: "E_ROUTE_INVALID" });
<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>
<!-- MAIN BODY --> 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'");
<main class="flex-grow"> if (fs.existsSync(target) && fs.statSync(target).isFile()) return res.type(path.extname(target)).send(fs.readFileSync(target));
${mainContent} const notFound = path.join(rootDir, "404.html");
</main> if (fs.existsSync(notFound)) return res.status(404).type("html").send(fs.readFileSync(notFound));
return res.status(404).json({ code: "E_ROUTE_NOT_FOUND" });
<!-- 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);
}); });
// --- VITE / PRODUCTION SERVING --- // --- VITE / PRODUCTION SERVING ---
+177 -164
View File
@@ -1,181 +1,194 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import fs from "node:fs"; import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import test from "node:test"; import test from "node:test";
import { BuildEngine, renderMarkdown } from "./buildEngine"; import { BuildInputLoader } from "./buildInputLoader";
import { BuildEngineInput } from "./buildEngine"; import { BuildEngine, BUILDER_VERSION, renderMarkdown } from "./buildEngine";
import { ContentItem, SiteConfig, ThemeConfig } from "../types";
const siteConfig: SiteConfig = { const git = (root: string, args: string[]): string => execFileSync("git", ["-C", root, ...args], {
protocol: "labyricorn-site/v1", encoding: "utf8",
site: { env: { ...process.env, GIT_AUTHOR_DATE: "2026-01-01T00:00:00Z", GIT_COMMITTER_DATE: "2026-01-01T00:00:00Z" },
id: "test-site", }).trim();
title: "Test Site",
baseUrl: "https://example.test", const createRepository = (): string => {
language: "en-US", const root = fs.mkdtempSync(path.join(os.tmpdir(), "labyricorn-git-fixture-"));
}, fs.mkdirSync(path.join(root, "packages"), { recursive: true });
navigation: [ fs.cpSync(path.resolve("packages/site-definition"), path.join(root, "packages/site-definition"), { recursive: true });
{ id: "home", label: "Home", route: "/", iconName: "Home", contentModel: "page" }, fs.cpSync(path.resolve("packages/content-models"), path.join(root, "packages/content-models"), { recursive: true });
{ id: "articles", label: "Articles", route: "/articles/", iconName: "FileText", contentModel: "article" }, git(root, ["init", "-b", "main"]);
], git(root, ["config", "user.name", "Fixture"]);
contentModels: [], git(root, ["config", "user.email", "[email protected]"]);
sourcesFile: "./sources.yml", git(root, ["add", "."]);
styleInstancesPath: "./styles", git(root, ["commit", "-m", "fixture"]);
pagesPath: "./pages", return root;
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 themeConfig: ThemeConfig = { const commit = (root: string, message: string): string => {
id: "test-theme", git(root, ["add", "-A"]);
name: "Test Theme", git(root, ["commit", "-m", message]);
version: "1.0.0", return git(root, ["rev-parse", "HEAD"]);
path: "/.theme",
templates: {},
styles: [],
scripts: [],
supportsPackages: [],
isValidated: true,
validationErrors: [],
}; };
const content = (overrides: Partial<ContentItem> = {}): ContentItem => ({ const load = (root: string, runId: string) => {
id: "article-one", const manager = new BuildInputLoader({ repositoryRoot: root, ref: "HEAD", siteDefinitionPath: "packages/site-definition", builderVersion: BUILDER_VERSION });
title: "Article One", return { manager, input: manager.load(runId) };
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 input = (buildId: string, items: ContentItem[]): BuildEngineInput => ({ const withRepository = (callback: (root: string, output: string) => void): void => {
buildId, const root = createRepository();
siteConfig, const output = fs.mkdtempSync(path.join(os.tmpdir(), "labyricorn-output-"));
contentItems: items, try { callback(root, output); }
mediaAssets: [], finally { fs.rmSync(root, { recursive: true, force: true }); fs.rmSync(output, { recursive: true, force: true }); }
sourceCommits: { content: "abc123" }, };
themeConfig,
});
test("build output is deterministic for equivalent inputs", () => { test("reference repository theme renders declared routes and only declared assets", () => withRepository((root, output) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "labyricorn-engine-test-")); const { manager, input } = load(root, "build-000001");
try { try {
const engine = new BuildEngine(root); const result = new BuildEngine(output).build(input);
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```" })]),
);
assert.equal(result.success, true); assert.equal(result.success, true);
} finally { assert.match(input.siteDefinition.commit, /^[0-9a-f]{40}$/);
fs.rmSync(root, { recursive: true, force: true }); 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", () => { test("production sources contain no embedded renderer or synthesized preview fallback", () => {
assert.equal(renderMarkdown("Text <img src=x>"), "<p>Text &lt;img src=x&gt;</p>"); 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"; export * from "./repositoryBuildEngine";
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;
}
}
+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 { import {
GitSource, AuditLogEntry,
StyleConfigPackage, BuildLogEntry,
StyleConfigInstance,
ContentItem,
MediaAsset,
ThemeConfig,
BuildRecord, BuildRecord,
ContentItem,
CredentialRef,
GitSource,
MediaAsset,
NginxStatus, NginxStatus,
PushTarget, PushTarget,
CredentialRef,
AuditLogEntry,
SiteConfig, SiteConfig,
StyleConfigInstance,
StyleConfigPackage,
ThemeConfig,
ValidationReport, ValidationReport,
BuildLogEntry,
NavigationEntry,
ContentModel,
} from "../types"; } from "../types";
import YAML from "yaml"; import { BuildInputLoader } from "./buildInputLoader";
import fs from "fs";
import path from "path";
import { BuildEngine, BUILDER_VERSION } from "./buildEngine"; 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 { export class LabyricornStore {
siteConfig: SiteConfig; siteConfig: SiteConfig = fallbackSiteConfig();
gitSources: GitSource[]; gitSources: GitSource[] = [];
stylePackages: StyleConfigPackage[]; stylePackages: StyleConfigPackage[] = [];
styleInstances: StyleConfigInstance[]; styleInstances: StyleConfigInstance[] = [];
contentItems: ContentItem[]; contentItems: ContentItem[] = [];
mediaAssets: MediaAsset[]; mediaAssets: MediaAsset[] = [];
themeConfig: ThemeConfig; themeConfig: ThemeConfig = {
builds: BuildRecord[]; 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; nginxStatus: NginxStatus;
pushTargets: PushTarget[];
credentials: CredentialRef[];
auditLogs: AuditLogEntry[];
private readonly buildEngine = new BuildEngine();
constructor() { constructor() {
this.gitSources = []; this.loadStylePackagesForDisplay();
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.nginxStatus = { this.nginxStatus = {
isRunning: true, isRunning: true, configValid: true, activeReleaseId: null, stagingReleaseId: null,
configValid: true, listeningPort: 80, serverName: this.siteConfig.hosting.production.hostname,
activeReleaseId: null, lastReloadTime: new Date(0).toISOString(), lastHealthCheckPassed: false,
stagingReleaseId: null, serverBlockConfig: "root /var/lib/labyricorn/site/current;",
listeningPort: 80, stagingServerBlockConfig: "root /var/lib/labyricorn/site/staging;",
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;
}
}`,
}; };
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( private loader(): BuildInputLoader {
action: string, 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 });
category: AuditLogEntry["category"], }
details: string,
result: "success" | "failure" = "success", private applyResolvedInput(input: ReturnType<BuildInputLoader["load"]>): void {
) { this.siteConfig = input.siteConfig;
const entry: AuditLogEntry = { this.contentItems = [...input.contentItems];
id: `log-${Date.now()}`, this.mediaAssets = [...input.mediaAssets];
timestamp: new Date().toISOString(), const manifest = input.theme.manifest;
user: "[email protected]", this.themeConfig = {
action, id: manifest.id, name: manifest.id, version: manifest.version, path: input.siteConfig.theme.path,
category, templates: {
details, layout: manifest.templates.layout, home: manifest.templates.home, notFound: manifest.templates.notFound,
result, ...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 { runBuild(): BuildRecord {
const buildStartedAt = Date.now(); const number = this.builds.reduce((highest, build) => Math.max(highest, build.buildNumber), 0) + 1;
const nextBuildNum = Math.max( const runId = `build-${String(number).padStart(6, "0")}`;
this.builds.reduce((highest, build) => Math.max(highest, build.buildNumber), 0) + 1, const startedAt = Date.now();
this.buildEngine.nextBuildNumber(),
);
const buildId = `build-${String(nextBuildNum).padStart(6, "0")}`;
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
const logs: BuildLogEntry[] = [{ timestamp, level: "info", message: "Resolving immutable repository inputs...", step: "resolving-sources" }];
const logs: BuildLogEntry[] = [ const loader = this.loader();
{
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"]>;
try { try {
engineResult = this.buildEngine.build({ const input = loader.load(runId);
buildId, this.applyResolvedInput(input);
siteConfig: this.siteConfig, logs.push({ timestamp: new Date().toISOString(), level: "success", message: `Resolved site definition ${input.siteDefinition.commit}.`, step: "resolving-sources" });
contentItems: this.contentItems, const result = this.buildEngine.build(input);
mediaAssets: this.mediaAssets, if (!result.success) throw Object.assign(new Error(`Validation failed with ${result.validationReport.summary.totalErrors} error(s).`), { validationReport: result.validationReport });
sourceCommits, logs.push({ timestamp: new Date().toISOString(), level: "success", message: `Published immutable artifact ${input.artifactBuildId}.`, step: "built" });
themeConfig: this.themeConfig, const record: BuildRecord = {
}); id: runId, artifactBuildId: input.artifactBuildId, buildNumber: number, timestamp, durationMs: Date.now() - startedAt,
validationReport = engineResult.validationReport; status: "built", siteDefCommit: input.siteDefinition.commit,
if (!engineResult.success) { sourceCommits: Object.fromEntries([...input.sources.entries()].map(([id, source]) => [id, source.commit])),
const validationError = new Error( themeVersion: input.theme.manifest.version, builderVersion: BUILDER_VERSION, generatedRoutesCount: result.generatedRoutesCount,
`Validation failed with ${validationReport.summary.totalErrors} error(s).`, mediaCount: input.mediaAssets.length, artifactChecksum: result.artifactChecksum, artifactSizeBytes: result.artifactSizeBytes,
); validationReport: result.validationReport, logs,
(validationError as Error & { validationReport: ValidationReport }).validationReport = stages: [{ name: "Repository snapshots", status: "succeeded" }, { name: "Theme validation", status: "succeeded" }, { name: "Artifact rendering", status: "succeeded" }, { name: "Local staging", status: "pending" }],
validationReport; isStaged: false, isActiveLocal: false,
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,
}; };
this.builds.unshift(failedBuild); this.builds.unshift(record);
return failedBuild; if (input.siteConfig.buildPolicy.staging.enabled) {
} this.buildEngine.stage(input.artifactBuildId);
record.status = "staged"; record.isStaged = true; record.stages[3].status = "succeeded";
const newBuild: BuildRecord = { this.nginxStatus.stagingReleaseId = input.artifactBuildId;
id: buildId, }
buildNumber: nextBuildNum, this.addAudit(`Triggered Build #${number}`, "build", `Built ${input.artifactBuildId} from ${input.siteDefinition.commit}.`);
timestamp, return record;
durationMs: Date.now() - buildStartedAt, } catch (error) {
status: "built", const report: ValidationReport = (error as Error & { validationReport?: ValidationReport }).validationReport ?? {
siteDefCommit: "local", ...emptyReport(), errors: [{ code: "E_BUILD_FAIL", message: error instanceof Error ? error.message : String(error), category: "build" }],
sourceCommits, summary: { totalErrors: 1, totalWarnings: 0, passed: false },
themeVersion: this.themeConfig.version, };
builderVersion: BUILDER_VERSION, const failed: BuildRecord = {
generatedRoutesCount: engineResult.generatedRoutesCount, id: runId, buildNumber: number, timestamp, durationMs: Date.now() - startedAt, status: "failed",
mediaCount: this.mediaAssets.length, siteDefCommit: this.siteConfig.commit ?? "unresolved", sourceCommits: {}, themeVersion: this.themeConfig.version,
artifactChecksum: engineResult.artifactChecksum, builderVersion: BUILDER_VERSION, generatedRoutesCount: 0, mediaCount: 0, artifactChecksum: "", artifactSizeBytes: 0,
artifactSizeBytes: engineResult.artifactSizeBytes, validationReport: report, logs: [...logs, { timestamp: new Date().toISOString(), level: "error", message: report.errors.map((item) => item.message).join("; "), step: "failed" }],
validationReport, stages: [{ name: "Repository snapshots", status: "failed" }, { name: "Theme validation", status: "skipped" }, { name: "Artifact rendering", status: "skipped" }, { name: "Local staging", status: "skipped" }],
logs, isStaged: false, isActiveLocal: false,
stages: [ };
{ name: "Triggered", status: "succeeded", durationMs: 10 }, this.builds.unshift(failed); this.addAudit(`Triggered Build #${number}`, "build", report.errors[0]?.message ?? "Build failed", "failure"); return failed;
{ name: "Repository discovery", status: "succeeded", durationMs: 150 }, } finally { loader.dispose(); }
{ }
name: "Repository synchronization",
status: "succeeded", activateReleaseLocally(buildId: string): { success: boolean; message: string } {
durationMs: 450, 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.` };
{ name: "Metadata validation", status: "succeeded", durationMs: 120 }, try {
{ name: "Artifact discovery", status: "succeeded", durationMs: 310 }, this.buildEngine.activate(build.artifactBuildId);
{ for (const item of this.builds) { item.isActiveLocal = false; if (item.status === "active-local") item.status = "built"; }
name: "Website projection build", build.isActiveLocal = true; build.status = "active-local";
status: "succeeded", this.nginxStatus.activeReleaseId = build.artifactBuildId; this.nginxStatus.lastReloadTime = new Date().toISOString(); this.nginxStatus.lastHealthCheckPassed = true;
durationMs: 980, this.addAudit("Activate Local Release", "nginx", `current -> releases/${build.artifactBuildId}`);
}, return { success: true, message: `Activated immutable release ${build.artifactBuildId}.` };
{ 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);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.nginxStatus.lastHealthCheckPassed = false; this.nginxStatus.lastHealthCheckPassed = false;
this.addAudit( return { success: false, message: error instanceof Error ? error.message : String(error) };
"Activate Local Release Failed",
"nginx",
message,
"failure",
);
return { success: false, message };
} }
}
// Update in-memory state only after the release pointer changed successfully. private restoreReleasePointers(): void {
for (const previousBuild of this.builds) { const restore = (name: "staging" | "current"): string | null => {
previousBuild.isActiveLocal = false; const pointer = path.join(this.buildEngine.outputRoot, name);
if (previousBuild.status === "active-local") previousBuild.status = "built"; try {
} const target = fs.realpathSync(pointer);
const id = path.basename(target);
build.isActiveLocal = true; this.buildEngine.verifyRelease(id);
build.status = "active-local"; return id;
this.nginxStatus.activeReleaseId = buildId; } catch { return null; }
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).`,
}; };
this.nginxStatus.stagingReleaseId = restore("staging");
this.nginxStatus.activeReleaseId = restore("current");
this.nginxStatus.lastHealthCheckPassed = Boolean(this.nginxStatus.activeReleaseId);
} }
getBuildRoot(): string { getBuildRoot(): string { return this.buildEngine.outputRoot; }
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;
} }
pushToTarget(targetId: string, buildId: string): { success: boolean; logs: string[] } {
getCurrentBuildDirectory(): string { const target = this.pushTargets.find((item) => item.id === targetId);
return this.buildEngine.currentDirectory(); 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);
pushToTarget( return { success: false, logs: ["Remote publication is not configured for this target."] };
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);
} }
rollbackLocal(targetReleaseId: string): { success: boolean; message: string } { return this.activateReleaseLocally(targetReleaseId); }
} }
// Global Store Instance
export const store = new LabyricornStore(); 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< const [activeSubTab, setActiveSubTab] = useState<
"credentials" | "audit" | "yaml" "credentials" | "audit" | "yaml"
>("yaml"); >("yaml");
const [yamlContent, setYamlContent] = useState(siteConfig.rawYaml);
const [yamlSavedMsg, setYamlSavedMsg] = useState<string | null>(null);
// New Credential Form // New Credential Form
const [showAddCredModal, setShowAddCredModal] = useState(false); const [showAddCredModal, setShowAddCredModal] = useState(false);
@@ -44,18 +42,6 @@ export const ConfigAndAuditTab: React.FC<ConfigAndAuditTabProps> = ({
const [credType, setCredType] = useState<CredentialRef["type"]>("ssh_key"); const [credType, setCredType] = useState<CredentialRef["type"]>("ssh_key");
const [credVal, setCredVal] = useState(""); 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) => { const handleCreateCred = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!credName || !credVal) return; if (!credName || !credVal) return;
@@ -79,7 +65,7 @@ export const ConfigAndAuditTab: React.FC<ConfigAndAuditTabProps> = ({
</span> </span>
</div> </div>
<p className="text-xs text-neutral-400 mt-1"> <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. view system audit logs.
</p> </p>
</div> </div>
@@ -96,7 +82,7 @@ export const ConfigAndAuditTab: React.FC<ConfigAndAuditTabProps> = ({
}`} }`}
> >
<FileCode className="w-4 h-4" /> <FileCode className="w-4 h-4" />
<span>Declarative site.yaml (Git Commits)</span> <span>Declarative site.yml (Read-only)</span>
</button> </button>
<button <button
@@ -126,12 +112,10 @@ export const ConfigAndAuditTab: React.FC<ConfigAndAuditTabProps> = ({
{activeSubTab === "yaml" ? ( {activeSubTab === "yaml" ? (
<div className="space-y-4"> <div className="space-y-4">
{yamlSavedMsg && ( <div className="p-4 bg-amber-950/30 border border-amber-500/20 text-amber-200 text-xs rounded-sm">
<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"> Configuration is read-only in v1. Changes must be reviewed and committed in the source repository.
<CheckCircle2 className="w-4 h-4 text-emerald-400" /> {siteConfig.commit && <span className="block mt-1 font-mono text-amber-300">Resolved commit: {siteConfig.commit}</span>}
<span>{yamlSavedMsg}</span> </div>
</div>
)}
<div className="bg-[#111111] rounded-sm p-6 border border-[#1F1F1F] space-y-4"> <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"> <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> </span>
</div> </div>
<button <span className="px-3 py-1.5 border border-[#262626] text-neutral-400 text-[10px] uppercase tracking-wider">Git-owned · Read-only</span>
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>
</div> </div>
<textarea <textarea
rows={16} rows={16}
value={yamlContent} value={siteConfig.rawYaml}
onChange={(e) => setYamlContent(e.target.value)} readOnly
className="w-full bg-[#0A0A0A] text-[#C5A059] font-mono text-xs p-4 rounded-sm border border-[#1F1F1F] focus:outline-none focus:border-[#C5A059]" 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>
</div> </div>
+17 -7
View File
@@ -27,9 +27,7 @@ export const ThemeTab: React.FC<ThemeTabProps> = ({
const handleValidate = async () => { const handleValidate = async () => {
setIsValidating(true); setIsValidating(true);
await onValidateTheme(); await onValidateTheme();
setStatusMsg( setStatusMsg("Validation finished. The status below is tied to the displayed commit.");
"Theme /.theme validated successfully. All required templates, CSS resets, and package supports are valid.",
);
setIsValidating(false); setIsValidating(false);
}; };
@@ -43,13 +41,13 @@ export const ThemeTab: React.FC<ThemeTabProps> = ({
Theme Management Theme Management
</h1> </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"> <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> </span>
</div> </div>
<p className="text-xs text-neutral-400 mt-1"> <p className="text-xs text-neutral-400 mt-1">
Loads active theme from{" "} Resolves the active theme from{" "}
<code className="text-[#C5A059] font-mono">/.theme</code>, validates{" "} <code className="text-[#C5A059] font-mono">.theme</code> at an exact commit, validates{" "}
<code className="text-neutral-300 font-mono">theme.yaml</code>, <code className="text-neutral-300 font-mono">theme.yml</code>,
templates, assets, and supported package layouts. templates, assets, and supported package layouts.
</p> </p>
</div> </div>
@@ -75,6 +73,13 @@ export const ThemeTab: React.FC<ThemeTabProps> = ({
</div> </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 */} {/* THEME STRUCTURE GRID */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* LEFT COLUMN: THEME METADATA & MANIFEST (5 COLS) */} {/* 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> <span className="text-white font-semibold">{theme.id}</span>
</div> </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"> <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"> <span className="text-[10px] text-neutral-500 font-bold uppercase tracking-widest block font-mono">
Supported Style Packages ({theme.supportsPackages.length}) Supported Style Packages ({theme.supportsPackages.length})
+135
View File
@@ -120,6 +120,13 @@ export interface ThemeConfig {
supportsPackages: string[]; supportsPackages: string[];
isValidated: boolean; isValidated: boolean;
validationErrors: string[]; 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 { export interface ValidationReport {
@@ -155,6 +162,7 @@ export interface BuildLogEntry {
export interface BuildRecord { export interface BuildRecord {
id: string; id: string;
artifactBuildId?: string;
buildNumber: number; buildNumber: number;
timestamp: string; timestamp: string;
durationMs: number; durationMs: number;
@@ -248,6 +256,9 @@ export interface NavigationEntry {
iconName: string; iconName: string;
contentModel?: string; contentModel?: string;
styleConfig?: string; styleConfig?: string;
presentation?: {
sectionTemplate?: string;
};
} }
export interface ContentModelSemantics { export interface ContentModelSemantics {
@@ -291,6 +302,7 @@ export interface SiteConfig {
styleInstancesPath: string; styleInstancesPath: string;
pagesPath: string; pagesPath: string;
navigationFile: string; navigationFile: string;
projectsFile?: string;
pushIntegrationsPath: string; pushIntegrationsPath: string;
theme: { theme: {
source: string; source: string;
@@ -342,4 +354,127 @@ export interface SiteConfig {
}; };
}; };
rawYaml: string; 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>>;
} }