Implement repository-driven theme rendering
This commit is contained in:
@@ -1,20 +1,20 @@
|
||||
# Labyricorn Website Engine Control Plane
|
||||
|
||||
A React control plane and Express backend for discovering Git-hosted Markdown, validating canonical site configuration, and producing deterministic static website releases.
|
||||
A React control plane and Express backend for resolving Git-owned site inputs and producing deterministic, immutable static website releases.
|
||||
|
||||
## Engine capabilities
|
||||
|
||||
The build engine now:
|
||||
|
||||
- validates routes, route collisions, content state, raw-HTML policy, and media references;
|
||||
- renders published content and navigation indexes into static HTML;
|
||||
- escapes source HTML and renders a safe CommonMark-style subset;
|
||||
- emits deterministic `build-manifest.json` and `checksums.json` files;
|
||||
- calculates an artifact checksum from generated file checksums;
|
||||
- writes releases atomically beneath the configured build root;
|
||||
- activates releases through an atomic `current` symlink/junction;
|
||||
- survives process restarts without reusing existing build numbers; and
|
||||
- serves the active generated release through the live-preview endpoint.
|
||||
- resolves the site definition and theme to exact 40-character Git commits;
|
||||
- materializes detached snapshots before reading configuration, projects, templates, or assets;
|
||||
- validates the closed `labyricorn-theme/v1` and project schemas without executing repository code;
|
||||
- renders explicit template keys through LiquidJS in strict mode with engine-owned safe-content filters;
|
||||
- copies only declared assets, verified fonts, and configuration-authorized standalone files;
|
||||
- emits deterministic provenance-rich `build-manifest.json` and `checksums.json` files;
|
||||
- writes immutable releases atomically beneath `releases/<artifact-build-id>/`;
|
||||
- stages and promotes the same checksum-verified release through atomic pointers; and
|
||||
- serves only a selected release through the preview endpoint, with no synthesized fallback.
|
||||
|
||||
## Development
|
||||
|
||||
@@ -46,15 +46,18 @@ By default, releases are written to the platform temporary directory under `laby
|
||||
|
||||
Each successful release contains:
|
||||
|
||||
- generated route directories with `index.html`;
|
||||
- theme-rendered route directories with `index.html`;
|
||||
- declared static assets and standalone publications;
|
||||
- `404.html`;
|
||||
- `build-manifest.json`; and
|
||||
- `checksums.json`.
|
||||
|
||||
Validation failures produce a failed build record but never publish a partial release directory.
|
||||
Validation failures produce a failed build record but never publish a partial release directory. Configuration and theme state are Git-owned and read-only in control-plane v1. `PUT /api/site-config` returns `405 E_CONFIG_READ_ONLY`.
|
||||
|
||||
## Current boundaries
|
||||
|
||||
- The built-in renderer is used until repository-provided theme templates are implemented.
|
||||
- Git source synchronization supports HTTP(S), SSH-style Git URLs, and local paths; other source types remain unavailable.
|
||||
- Theme packages are presentation data only; Node, shell, WASM, package-manager scripts, custom Liquid tags, and custom filters are not executed.
|
||||
- The reference loader resolves the configured site-definition repository and supports separately pinned source snapshots through the typed boundary; source-management UX remains follow-up work.
|
||||
- Remote rsync deployment is still represented by the existing control-plane simulation and must not be treated as a completed production deploy path.
|
||||
|
||||
See [docs/OPERATIONS.md](docs/OPERATIONS.md) for snapshot, release, promotion, failure, and rollback procedures.
|
||||
|
||||
@@ -148,3 +148,37 @@ An unknown rendered-site route should return HTTP 404 and the generated `404.htm
|
||||
7. Remove the old private key only after the new path has been verified end to end.
|
||||
|
||||
Do not overwrite the active private key in place during rotation; retaining the old key until verification provides a safe rollback path.
|
||||
|
||||
## Repository theme build contract
|
||||
|
||||
Production presentation is owned by `.theme/` in the repository selected by `packages/site-definition/site.yml`. There is no built-in production renderer or preview fallback. Configuration is Git-owned and read-only in control-plane v1; `PUT /api/site-config` returns `405 E_CONFIG_READ_ONLY`.
|
||||
|
||||
Each build resolves `LABYRICORN_SITE_DEFINITION_REF` (default `HEAD`) once to a full commit and materializes an archive of that commit. Set these variables when the site definition is not the application checkout:
|
||||
|
||||
```text
|
||||
LABYRICORN_SITE_DEFINITION_REPOSITORY=/srv/site-definition
|
||||
LABYRICORN_SITE_DEFINITION_REF=refs/heads/main
|
||||
LABYRICORN_SITE_DEFINITION_PATH=packages/site-definition
|
||||
LABYRICORN_BUILD_ROOT=/var/lib/website-engine/builds
|
||||
```
|
||||
|
||||
The artifact ID is derived from the canonical input descriptor. Operational run IDs and execution time do not enter artifact bytes. The release layout is:
|
||||
|
||||
```text
|
||||
<build-root>/
|
||||
├── releases/sha256-<input-digest>/
|
||||
├── staging -> releases/sha256-<input-digest>/
|
||||
└── current -> releases/sha256-<input-digest>/
|
||||
```
|
||||
|
||||
Staging and promotion verify every entry in `checksums.json`. A modified artifact is rejected. Promotion updates `current`; it does not render or copy a second release. On startup, valid `staging` and `current` pointers are rediscovered and verified.
|
||||
|
||||
### Theme failure behavior
|
||||
|
||||
- Missing or invalid `.theme/theme.yml`: the build fails before a release directory is created.
|
||||
- Unknown manifest keys, unsafe paths, missing templates/assets, or strict-Liquid errors: the build fails closed.
|
||||
- No selected staging/live release: `/api/live-site/html` returns `503 E_RELEASE_UNAVAILABLE`.
|
||||
- Unknown route: the selected artifact's `404.html` is returned with HTTP 404.
|
||||
- Rollback activates a previously verified immutable release; it never recreates or mutates release bytes.
|
||||
|
||||
Repository themes may contain Liquid templates and manifest-declared browser assets. They may not execute build programs, declare custom filters/tags, use dynamic includes, emit inline scripts/event handlers/styles, or reference undeclared local dependencies. HTTPS fonts require a pinned SHA-256 checksum and are cached only after verification.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+84
@@ -11,8 +11,10 @@
|
||||
"@google/genai": "^2.4.0",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"ajv": "8.20.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.21.2",
|
||||
"liquidjs": "10.27.2",
|
||||
"lucide-react": "^0.546.0",
|
||||
"motion": "^12.23.24",
|
||||
"react": "^19.0.1",
|
||||
@@ -1747,6 +1749,22 @@
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.3",
|
||||
"fast-uri": "^3.0.1",
|
||||
"json-schema-traverse": "^1.0.0",
|
||||
"require-from-string": "^2.0.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/epoberezkin"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
@@ -1967,6 +1985,15 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "10.0.1",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
|
||||
"integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
@@ -2294,6 +2321,28 @@
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.4",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz",
|
||||
"integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -2699,6 +2748,12 @@
|
||||
"bignumber.js": "^9.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/json-schema-traverse": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/json5": {
|
||||
"version": "2.2.3",
|
||||
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
|
||||
@@ -2993,6 +3048,26 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/liquidjs": {
|
||||
"version": "10.27.2",
|
||||
"resolved": "https://registry.npmjs.org/liquidjs/-/liquidjs-10.27.2.tgz",
|
||||
"integrity": "sha512-kvknfAEtOHjHkAAv7GxLEJh8ghpMQm3Fc4uWVyF7hERSTsSRsdC7saWs0p5aDG7GDcWsu5o+T4232O+8KZO55w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"commander": "^10.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"liquid": "bin/liquid.js",
|
||||
"liquidjs": "bin/liquid.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/liquidjs"
|
||||
}
|
||||
},
|
||||
"node_modules/long": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
|
||||
@@ -3427,6 +3502,15 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-from-string": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/retry": {
|
||||
"version": "0.13.1",
|
||||
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
|
||||
|
||||
+4
-2
@@ -16,8 +16,10 @@
|
||||
"@google/genai": "^2.4.0",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"ajv": "8.20.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^4.21.2",
|
||||
"liquidjs": "10.27.2",
|
||||
"lucide-react": "^0.546.0",
|
||||
"motion": "^12.23.24",
|
||||
"react": "^19.0.1",
|
||||
@@ -26,13 +28,13 @@
|
||||
"yaml": "^2.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^22.14.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"esbuild": "^0.25.0",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.3",
|
||||
"@types/express": "^4.17.21"
|
||||
"vite": "^6.2.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Labyricorn mark">
|
||||
<path fill="#c5a059" d="M8 8h12v36h36v12H8z"/>
|
||||
<path fill="#f5f0e7" d="M28 8h28v12H40v16H28z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 204 B |
@@ -0,0 +1 @@
|
||||
document.documentElement.dataset.themeReady = "true";
|
||||
@@ -0,0 +1,33 @@
|
||||
:root{color-scheme:dark;--bg:#090909;--panel:#121212;--ink:#f4f0e8;--muted:#a7a29a;--gold:#c5a059;--line:#292722;font-family:Georgia,"Times New Roman",serif}
|
||||
*{box-sizing:border-box}
|
||||
body{margin:0;background:var(--bg);color:var(--ink);line-height:1.65}
|
||||
a{color:var(--gold);text-underline-offset:.2em}
|
||||
img{max-width:100%}
|
||||
.site-header,.site-footer,main{width:min(1180px,calc(100% - 2rem));margin:auto}
|
||||
.site-header{min-height:76px;display:flex;align-items:center;justify-content:space-between;gap:2rem;border-bottom:1px solid var(--line)}
|
||||
.brand{display:flex;align-items:center;gap:.7rem;color:var(--ink);text-decoration:none;font-weight:700}
|
||||
nav{display:flex;gap:1.15rem;flex-wrap:wrap;font:600 .72rem/1.3 ui-monospace,monospace;text-transform:uppercase;letter-spacing:.08em}
|
||||
main{padding:5rem 0 7rem}
|
||||
.hero{max-width:850px;padding:3rem 0 5rem}
|
||||
.hero h1,.section-heading h1,.project-body>h1,.not-found h1{font-size:clamp(2.8rem,7vw,6.8rem);line-height:.95;letter-spacing:-.045em;margin:.2em 0}
|
||||
.hero p:not(.eyebrow),.lede{font-size:1.3rem;color:var(--muted);max-width:62ch}
|
||||
.eyebrow{font:700 .7rem/1.2 ui-monospace,monospace;text-transform:uppercase;letter-spacing:.16em;color:var(--gold)}
|
||||
.feed{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:1px;background:var(--line);border:1px solid var(--line)}
|
||||
.feed>h2,.feed>p{grid-column:1/-1;background:var(--bg);margin:0;padding:1.5rem}
|
||||
.feed article{background:var(--panel);padding:2rem;min-height:180px}
|
||||
.section-heading{margin-bottom:3rem}
|
||||
.project-grid,.content-grid{display:grid;grid-template-columns:minmax(220px,300px) minmax(0,1fr);gap:clamp(2rem,7vw,7rem);align-items:start}
|
||||
.project-meta,.content-grid>aside{position:sticky;top:1rem;border-top:2px solid var(--gold);padding-top:1rem}
|
||||
dt{font:700 .65rem/1.2 ui-monospace,monospace;text-transform:uppercase;letter-spacing:.1em;color:var(--muted)}
|
||||
dd{margin:0 0 1rem;overflow-wrap:anywhere}
|
||||
.project-body{max-width:760px}
|
||||
.project-body h2,.prose h2{margin-top:2.5em;border-bottom:1px solid var(--line);padding-bottom:.35em}
|
||||
.tags{display:flex;gap:.5rem;list-style:none;padding:0;flex-wrap:wrap}
|
||||
.tags li{border:1px solid var(--line);padding:.2rem .55rem;font:600 .72rem/1.5 ui-monospace,monospace}
|
||||
pre{overflow:auto;padding:1rem;background:#050505;border:1px solid var(--line)}
|
||||
code{font-family:ui-monospace,monospace;font-size:.9em}
|
||||
blockquote{margin-left:0;border-left:3px solid var(--gold);padding-left:1rem;color:var(--muted)}
|
||||
.site-footer{display:flex;justify-content:space-between;gap:1rem;border-top:1px solid var(--line);padding:2rem 0;color:var(--muted);font-size:.8rem}
|
||||
.not-found{max-width:720px}
|
||||
.standalone-demo{max-width:780px}
|
||||
@media(max-width:720px){.site-header{align-items:flex-start;flex-direction:column;padding:1rem 0}nav{gap:.75rem}main{padding-top:3rem}.feed,.project-grid,.content-grid{grid-template-columns:1fr}.project-meta,.content-grid>aside{position:static}.site-footer{flex-direction:column}pre{max-width:calc(100vw - 2rem)}}
|
||||
@@ -0,0 +1,6 @@
|
||||
<section class="not-found">
|
||||
<p class="eyebrow">404</p>
|
||||
<h1>That route is not in this release.</h1>
|
||||
<p>The immutable artifact has no matching output.</p>
|
||||
<p><a href="/">Return home</a></p>
|
||||
</section>
|
||||
@@ -0,0 +1,9 @@
|
||||
<article class="content-grid">
|
||||
<aside><h2>Contents</h2><ol>{% for heading in item.tableOfContents %}<li><a href="#{{ heading.id | escape }}">{{ heading.text | escape }}</a></li>{% endfor %}</ol></aside>
|
||||
<div>
|
||||
<p class="eyebrow">{{ item.artifactType | escape }}</p>
|
||||
<h1>{{ item.title | escape }}</h1>
|
||||
<p class="lede">{{ item.summary | escape }}</p>
|
||||
<div class="prose">{{ item.content | safe_content }}</div>
|
||||
</div>
|
||||
</article>
|
||||
@@ -0,0 +1,28 @@
|
||||
<article class="project-grid">
|
||||
<aside class="project-meta">
|
||||
<p class="eyebrow">Project</p>
|
||||
<dl>
|
||||
<dt>Version</dt><dd>{{ project.version | escape }}</dd>
|
||||
<dt>License</dt><dd>{{ project.license | escape }}</dd>
|
||||
<dt>Source commit</dt><dd><code>{{ project.sourceSnapshot.commit | escape }}</code></dd>
|
||||
<dt>Updated</dt><dd>{{ project.sourceSnapshot.committedAt | escape }}</dd>
|
||||
</dl>
|
||||
<h2>Stack</h2>
|
||||
<ul>{% for item in project.stack %}<li>{{ item | escape }}</li>{% endfor %}</ul>
|
||||
</aside>
|
||||
<div class="project-body">
|
||||
<h1>{{ project.title | escape }}</h1>
|
||||
<p class="lede">{{ project.summary | escape }}</p>
|
||||
<p><a href="{{ project.homepage | escape }}">Open the standalone demo</a></p>
|
||||
<h2 id="provenance">Pinned provenance</h2>
|
||||
<p>The project and its publication were read from <code>{{ project.sourceSnapshot.repository | escape }}</code> at one exact commit.</p>
|
||||
<h2 id="tags">Tags</h2>
|
||||
<ul class="tags">{% for tag in project.tags %}<li>{{ tag | escape }}</li>{% endfor %}</ul>
|
||||
<h2 id="devlogs">Devlogs</h2>
|
||||
{% if project.relatedContent.devlogs.size == 0 %}<p>No matching devlogs in this snapshot.</p>{% endif %}
|
||||
{% for item in project.relatedContent.devlogs %}<article><h3><a href="{{ item.route | escape }}">{{ item.title | escape }}</a></h3></article>{% endfor %}
|
||||
<h2 id="articles">Related articles</h2>
|
||||
{% if project.relatedContent.articles.size == 0 %}<p>No matching articles in this snapshot.</p>{% endif %}
|
||||
{% for item in project.relatedContent.articles %}<article><h3><a href="{{ item.route | escape }}">{{ item.title | escape }}</a></h3></article>{% endfor %}
|
||||
</div>
|
||||
</article>
|
||||
@@ -0,0 +1,12 @@
|
||||
<section class="hero">
|
||||
<p class="eyebrow">Engineering · Architecture · Operations</p>
|
||||
<h1>{{ site.title | escape }}</h1>
|
||||
<p>Repository-owned presentation rendered from an exact Git commit.</p>
|
||||
</section>
|
||||
<section class="feed" aria-labelledby="latest">
|
||||
<h2 id="latest">Latest publications</h2>
|
||||
{% if content.size == 0 %}<p>No published content in this snapshot.</p>{% endif %}
|
||||
{% for item in content %}
|
||||
<article><p class="eyebrow">{{ item.artifactType | escape }}</p><h3><a href="{{ item.route | escape }}">{{ item.title | escape }}</a></h3><p>{{ item.summary | escape }}</p></article>
|
||||
{% endfor %}
|
||||
</section>
|
||||
@@ -0,0 +1,24 @@
|
||||
<!doctype html>
|
||||
<html lang="{{ site.language | escape }}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="generator" content="labyricorn-theme/v1">
|
||||
<title>{{ page.title | escape }} · {{ site.title | escape }}</title>
|
||||
{% for stylesheet in theme.stylesheets %}<link rel="stylesheet" href="{{ stylesheet | escape }}">
|
||||
{% endfor %}{% for script in theme.scripts %}<script src="{{ script | escape }}" defer></script>
|
||||
{% endfor %}</head>
|
||||
<body>
|
||||
<header class="site-header">
|
||||
<a class="brand" href="/"><img src="/assets/images/logo.svg" width="32" height="32" alt=""><span>{{ site.title | escape }}</span></a>
|
||||
<nav aria-label="Primary">
|
||||
{% for entry in navigation %}<a href="{{ entry.route | escape }}">{{ entry.label | escape }}</a>{% endfor %}
|
||||
</nav>
|
||||
</header>
|
||||
<main>{{ page.body | safe_page_body }}</main>
|
||||
<footer class="site-footer">
|
||||
<span>Built from immutable Git snapshots.</span>
|
||||
<code>{{ build.id | escape }}</code>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,10 @@
|
||||
<header class="section-heading">
|
||||
<p class="eyebrow">Section</p>
|
||||
<h1>{{ section.label | escape }}</h1>
|
||||
</header>
|
||||
<section class="feed">
|
||||
{% if content.size == 0 %}<p>No published items in this snapshot.</p>{% endif %}
|
||||
{% for item in content %}
|
||||
<article><h2><a href="{{ item.route | escape }}">{{ item.title | escape }}</a></h2><p>{{ item.summary | escape }}</p></article>
|
||||
{% endfor %}
|
||||
</section>
|
||||
@@ -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>
|
||||
@@ -9,23 +9,33 @@ navigation:
|
||||
route: "/articles/"
|
||||
iconName: "FileText"
|
||||
contentModel: "article"
|
||||
presentation:
|
||||
sectionTemplate: "articles"
|
||||
- id: "projects"
|
||||
label: "Projects"
|
||||
route: "/projects/"
|
||||
iconName: "LayoutTemplate"
|
||||
contentModel: "project"
|
||||
presentation:
|
||||
sectionTemplate: "projects"
|
||||
- id: "documentation"
|
||||
label: "Documentation"
|
||||
route: "/docs/"
|
||||
iconName: "FolderOpen"
|
||||
contentModel: "documentation"
|
||||
presentation:
|
||||
sectionTemplate: "documentation"
|
||||
- id: "community"
|
||||
label: "Community"
|
||||
route: "/community/"
|
||||
iconName: "MessageSquare"
|
||||
contentModel: "discussion"
|
||||
presentation:
|
||||
sectionTemplate: "community"
|
||||
- id: "downloads"
|
||||
label: "Downloads"
|
||||
route: "/downloads/"
|
||||
iconName: "Download"
|
||||
contentModel: "release"
|
||||
presentation:
|
||||
sectionTemplate: "downloads"
|
||||
|
||||
@@ -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
|
||||
@@ -4,9 +4,11 @@ site:
|
||||
title: "Labyricorn Engineering & Architecture"
|
||||
baseUrl: "https://labyricorn.com"
|
||||
language: "en-US"
|
||||
navigationFile: "navigation.yml"
|
||||
projectsFile: "projects.yml"
|
||||
theme:
|
||||
source: "site-definition"
|
||||
path: "/.theme"
|
||||
path: ".theme"
|
||||
markdown:
|
||||
dialect: "labyricorn-commonmark-v1"
|
||||
rawHtmlPolicy: "disabled"
|
||||
|
||||
@@ -33,27 +33,11 @@ async function startServer() {
|
||||
res.json(store.siteConfig);
|
||||
});
|
||||
|
||||
app.put("/api/site-config", (req, res) => {
|
||||
try {
|
||||
const { rawYaml } = req.body;
|
||||
if (rawYaml) {
|
||||
const parsed = YAML.parse(rawYaml);
|
||||
store.siteConfig.rawYaml = rawYaml;
|
||||
if (parsed.site) store.siteConfig.site = parsed.site;
|
||||
if (parsed.markdown) store.siteConfig.markdown = parsed.markdown;
|
||||
if (parsed.hosting) store.siteConfig.hosting = parsed.hosting;
|
||||
if (parsed.buildPolicy)
|
||||
store.siteConfig.buildPolicy = parsed.buildPolicy;
|
||||
store.addAudit(
|
||||
"Update Site Configuration",
|
||||
"config",
|
||||
"Updated site.yaml configuration from admin UI",
|
||||
);
|
||||
}
|
||||
res.json({ success: true, siteConfig: store.siteConfig });
|
||||
} catch (err: any) {
|
||||
res.status(400).json({ error: `YAML parse error: ${err.message}` });
|
||||
}
|
||||
app.put("/api/site-config", (_req, res) => {
|
||||
res.status(405).json({
|
||||
code: "E_CONFIG_READ_ONLY",
|
||||
message: "Site configuration is Git-owned and read-only in v1.",
|
||||
});
|
||||
});
|
||||
|
||||
// --- GITEA DISCOVERY ---
|
||||
@@ -449,19 +433,12 @@ async function startServer() {
|
||||
res.json(store.themeConfig);
|
||||
});
|
||||
|
||||
app.post("/api/theme/validate", (req, res) => {
|
||||
store.themeConfig.isValidated = true;
|
||||
store.themeConfig.validationErrors = [];
|
||||
store.addAudit(
|
||||
"Validate Theme",
|
||||
"config",
|
||||
"Validated theme manifest /.theme/theme.yaml",
|
||||
);
|
||||
res.json({
|
||||
success: true,
|
||||
theme: store.themeConfig,
|
||||
message:
|
||||
"Theme /.theme loaded successfully. All required templates and packages supported.",
|
||||
app.post("/api/theme/validate", (_req, res) => {
|
||||
const theme = store.validateTheme();
|
||||
res.status(theme.status === "valid" ? 200 : 422).json({
|
||||
success: theme.status === "valid",
|
||||
theme,
|
||||
commits: theme.commit ? { [theme.sourceId ?? "theme"]: theme.commit } : {},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -559,191 +536,37 @@ async function startServer() {
|
||||
res.json(store.auditLogs);
|
||||
});
|
||||
|
||||
// --- LIVE SITE HTML PREVIEW ENDPOINT ---
|
||||
// --- RELEASE-ONLY SITE PREVIEW ENDPOINT ---
|
||||
app.get("/api/live-site/html", (req, res) => {
|
||||
const activeBuild =
|
||||
store.builds.find((b) => b.isActiveLocal) || store.builds[0];
|
||||
let pageRoute = (req.query.route as string) || "/";
|
||||
|
||||
const rootDir = store.getCurrentBuildDirectory();
|
||||
|
||||
if (fs.existsSync(rootDir)) {
|
||||
const routeWithoutQuery = pageRoute.split(/[?#]/, 1)[0].replace(/\\/g, "/");
|
||||
const relativeRoute = routeWithoutQuery.replace(/^\/+/, "");
|
||||
const rootPath = `${path.resolve(rootDir)}${path.sep}`;
|
||||
const targetPath = path.resolve(rootDir, relativeRoute, "index.html");
|
||||
if (!targetPath.startsWith(rootPath)) {
|
||||
return res.status(400).json({ error: "Invalid preview route" });
|
||||
}
|
||||
if (fs.existsSync(targetPath)) {
|
||||
return res.type("html").send(fs.readFileSync(targetPath, "utf-8"));
|
||||
}
|
||||
const rootDir = store.getPreviewDirectory();
|
||||
if (!rootDir) {
|
||||
return res.status(503).json({
|
||||
code: "E_RELEASE_UNAVAILABLE",
|
||||
message: "No verified staging or live release is selected.",
|
||||
});
|
||||
}
|
||||
|
||||
// Simple HTML renderer simulating compiled static site output (fallback)
|
||||
let pageTitle = store.siteConfig.site.title;
|
||||
let mainContent = "";
|
||||
|
||||
// Match route against configured navigation entries
|
||||
const navMatch = store.siteConfig.navigation.find(
|
||||
(nav) => nav.route === pageRoute,
|
||||
);
|
||||
|
||||
if (navMatch) {
|
||||
pageTitle = `${navMatch.label} | ${store.siteConfig.site.title}`;
|
||||
|
||||
const relatedContent = store.contentItems.filter(
|
||||
(item) => item.artifactType === navMatch.contentModel,
|
||||
);
|
||||
|
||||
mainContent = `
|
||||
<section class="max-w-4xl mx-auto py-8 px-4">
|
||||
<div class="mb-8 border-b pb-4 border-slate-200">
|
||||
<span class="inline-block px-2.5 py-1 text-xs font-semibold uppercase tracking-wider text-indigo-700 bg-indigo-50 rounded-full mb-2">Section: ${navMatch.label}</span>
|
||||
<h1 class="text-3xl font-bold text-slate-900">${navMatch.label}</h1>
|
||||
<p class="text-slate-600 mt-2 text-base">Content mapped from repositories providing the <code class="bg-slate-100 px-1.5 py-0.5 rounded text-xs font-mono text-indigo-600">${navMatch.contentModel}</code> model.</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
${
|
||||
relatedContent.length === 0
|
||||
? '<p class="text-slate-500 italic">No content discovered for this section yet.</p>'
|
||||
: relatedContent
|
||||
.map(
|
||||
(item) => `
|
||||
<article class="bg-white border border-slate-200 rounded-xl p-6 shadow-xs hover:border-indigo-300 transition-colors">
|
||||
<div class="flex items-center justify-between text-xs text-slate-500 mb-2">
|
||||
<span class="font-mono text-indigo-600 font-medium">Repository: ${item.sourceRepo}</span>
|
||||
<time>${item.published}</time>
|
||||
</div>
|
||||
<h2 class="text-xl font-semibold text-slate-900 hover:text-indigo-600 cursor-pointer mb-2">
|
||||
<a href="#" onclick="window.parent.postMessage({type:'NAVIGATE_LIVE_SITE', route:'${item.route}'}, '*')">${item.title}</a>
|
||||
</h2>
|
||||
<p class="text-slate-600 text-sm mb-4 line-clamp-2">${item.summary}</p>
|
||||
<div class="flex items-center gap-2 text-xs">
|
||||
${item.tags.map((t) => `<span class="bg-slate-100 text-slate-700 px-2 py-0.5 rounded-md font-mono">#${t}</span>`).join("")}
|
||||
</div>
|
||||
</article>
|
||||
`,
|
||||
)
|
||||
.join("")
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
} else {
|
||||
const match = store.contentItems.find(
|
||||
(i) => i.route === pageRoute || pageRoute.includes(i.slug),
|
||||
);
|
||||
if (match) {
|
||||
pageTitle = `${match.title} | ${store.siteConfig.site.title}`;
|
||||
mainContent = `
|
||||
<article class="max-w-3xl mx-auto py-10 px-4">
|
||||
<div class="mb-6">
|
||||
<a href="#" onclick="window.parent.postMessage({type:'NAVIGATE_LIVE_SITE', route:'/'}, '*')" class="text-xs font-medium text-indigo-600 hover:underline">← Back Home</a>
|
||||
<div class="mt-4 flex items-center gap-3 text-xs text-slate-500">
|
||||
<span class="bg-indigo-100 text-indigo-800 font-medium px-2.5 py-0.5 rounded-full font-mono">${match.sourceRepo}</span>
|
||||
<span>Published on ${match.published}</span>
|
||||
<span class="text-emerald-600 font-medium">✓ Verified Schema</span>
|
||||
</div>
|
||||
<h1 class="text-3xl font-bold text-slate-900 mt-3">${match.title}</h1>
|
||||
<p class="text-slate-600 text-lg mt-2 font-serif italic">${match.summary}</p>
|
||||
</div>
|
||||
|
||||
${match.featuredImage ? `<img src="${match.featuredImage}" class="w-full h-64 object-cover rounded-xl mb-6 shadow-sm border border-slate-200" alt="${match.title}" />` : ""}
|
||||
|
||||
<div class="prose prose-slate max-w-none text-slate-800 leading-relaxed space-y-4">
|
||||
${match.contentMarkdown
|
||||
.replace(/^---[\s\S]*?---/, "")
|
||||
.replace(
|
||||
/# (.*)/g,
|
||||
'<h1 class="text-2xl font-bold text-slate-900 mt-6 mb-3">$1</h1>',
|
||||
)
|
||||
.replace(
|
||||
/## (.*)/g,
|
||||
'<h2 class="text-xl font-bold text-slate-900 mt-5 mb-2">$1</h2>',
|
||||
)
|
||||
.replace(
|
||||
/### (.*)/g,
|
||||
'<h3 class="text-lg font-semibold text-slate-800 mt-4 mb-2">$1</h3>',
|
||||
)
|
||||
.replace(
|
||||
/::youtube\[(.*?)\]\{id="(.*?)"\}/g,
|
||||
'<div class="my-6 aspect-video bg-slate-900 rounded-xl flex flex-col items-center justify-center text-white p-6 shadow-md border border-slate-800"><div class="text-red-500 text-4xl mb-2">▶</div><div class="font-medium text-base">$1</div><div class="text-xs text-slate-400 mt-1 font-mono">YouTube Embed ID: $2</div></div>',
|
||||
)
|
||||
.replace(
|
||||
/> \[\!(NOTE|IMPORTANT)\]\n> (.*)/g,
|
||||
'<div class="p-4 bg-indigo-50 border-l-4 border-indigo-500 text-indigo-900 text-sm rounded-r-md my-4 font-medium">$2</div>',
|
||||
)
|
||||
.replace(
|
||||
/```typescript([\s\S]*?)```/g,
|
||||
'<pre class="bg-slate-900 text-slate-100 p-4 rounded-xl text-xs font-mono overflow-x-auto my-4 shadow-sm"><code>$1</code></pre>',
|
||||
)
|
||||
.replace(
|
||||
/\n\n/g,
|
||||
'</p><p class="text-slate-700 font-sans leading-7">',
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
`;
|
||||
} else {
|
||||
mainContent = `
|
||||
<div class="max-w-md mx-auto py-20 text-center">
|
||||
<h1 class="text-4xl font-extrabold text-slate-900 mb-2">404</h1>
|
||||
<p class="text-slate-600 mb-6">Page non-existent in build ${activeBuild?.id}</p>
|
||||
<a href="#" onclick="window.parent.postMessage({type:'NAVIGATE_LIVE_SITE', route:'/'}, '*')" class="px-4 py-2 bg-indigo-600 text-white rounded-lg text-sm font-medium hover:bg-indigo-700">Return Home</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
let requested: string;
|
||||
try {
|
||||
requested = decodeURIComponent(String(req.query.route ?? "/").split(/[?#]/, 1)[0]);
|
||||
} catch {
|
||||
return res.status(400).json({ code: "E_ROUTE_INVALID" });
|
||||
}
|
||||
if (!requested.startsWith("/") || requested.includes("\\") || requested.split("/").includes("..")) {
|
||||
return res.status(400).json({ code: "E_ROUTE_INVALID" });
|
||||
}
|
||||
|
||||
const html = `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>${pageTitle}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style>
|
||||
body { background-color: #f8fafc; font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen flex flex-col">
|
||||
<!-- SITE HEADER -->
|
||||
<header class="bg-slate-900 text-white border-b border-slate-800 sticky top-0 z-50">
|
||||
<div class="max-w-6xl mx-auto px-4 h-16 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3 cursor-pointer" onclick="window.parent.postMessage({type:'NAVIGATE_LIVE_SITE', route:'/'}, '*')">
|
||||
<div class="w-8 h-8 rounded-lg bg-indigo-500 flex items-center justify-center font-bold text-white text-lg">L</div>
|
||||
<span class="font-bold text-lg tracking-tight">${store.siteConfig.site.title}</span>
|
||||
</div>
|
||||
<nav class="flex items-center space-x-6 text-sm font-medium text-slate-300">
|
||||
${store.siteConfig.navigation.map((nav) => `<a href="#" onclick="window.parent.postMessage({type:'NAVIGATE_LIVE_SITE', route:'${nav.route}'}, '*')" class="hover:text-white transition-colors text-indigo-400">${nav.label}</a>`).join("\\n ")}
|
||||
</nav>
|
||||
<div class="text-xs bg-slate-800 border border-slate-700 text-slate-300 px-3 py-1.5 rounded-full font-mono flex items-center space-x-2">
|
||||
<span class="w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span>
|
||||
<span>Hosted via Nginx (${activeBuild.id})</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
const relative = requested.replace(/^\/+/, "");
|
||||
const outputFile = relative && path.posix.extname(relative) ? relative : path.posix.join(relative, "index.html");
|
||||
const rootPath = `${path.resolve(rootDir)}${path.sep}`;
|
||||
const target = path.resolve(rootDir, ...outputFile.split("/"));
|
||||
if (!target.startsWith(rootPath)) return res.status(400).json({ code: "E_ROUTE_INVALID" });
|
||||
|
||||
<!-- MAIN BODY -->
|
||||
<main class="flex-grow">
|
||||
${mainContent}
|
||||
</main>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<footer class="bg-slate-900 text-slate-400 border-t border-slate-800 py-8 text-xs text-center mt-12">
|
||||
<div class="max-w-4xl mx-auto px-4 space-y-2">
|
||||
<p>Labyricorn Deterministic Static Site Builder — Active Release: <span class="font-mono text-indigo-400">${activeBuild.id}</span> (${activeBuild.artifactChecksum.substring(0, 16)}...)</p>
|
||||
<p class="text-slate-500">Public media served under <code class="text-slate-400">/media</code> • Pure CommonMark Markdown Dialect</p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
res.send(html);
|
||||
res.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self'; font-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'");
|
||||
if (fs.existsSync(target) && fs.statSync(target).isFile()) return res.type(path.extname(target)).send(fs.readFileSync(target));
|
||||
const notFound = path.join(rootDir, "404.html");
|
||||
if (fs.existsSync(notFound)) return res.status(404).type("html").send(fs.readFileSync(notFound));
|
||||
return res.status(404).json({ code: "E_ROUTE_NOT_FOUND" });
|
||||
});
|
||||
|
||||
// --- VITE / PRODUCTION SERVING ---
|
||||
|
||||
+177
-164
@@ -1,181 +1,194 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { BuildEngine, renderMarkdown } from "./buildEngine";
|
||||
import { BuildEngineInput } from "./buildEngine";
|
||||
import { ContentItem, SiteConfig, ThemeConfig } from "../types";
|
||||
import { BuildInputLoader } from "./buildInputLoader";
|
||||
import { BuildEngine, BUILDER_VERSION, renderMarkdown } from "./buildEngine";
|
||||
|
||||
const siteConfig: SiteConfig = {
|
||||
protocol: "labyricorn-site/v1",
|
||||
site: {
|
||||
id: "test-site",
|
||||
title: "Test Site",
|
||||
baseUrl: "https://example.test",
|
||||
language: "en-US",
|
||||
},
|
||||
navigation: [
|
||||
{ id: "home", label: "Home", route: "/", iconName: "Home", contentModel: "page" },
|
||||
{ id: "articles", label: "Articles", route: "/articles/", iconName: "FileText", contentModel: "article" },
|
||||
],
|
||||
contentModels: [],
|
||||
sourcesFile: "./sources.yml",
|
||||
styleInstancesPath: "./styles",
|
||||
pagesPath: "./pages",
|
||||
navigationFile: "./navigation.yml",
|
||||
pushIntegrationsPath: "./push",
|
||||
theme: { source: "site-definition", path: "/.theme" },
|
||||
markdown: {
|
||||
dialect: "commonmark",
|
||||
rawHtmlPolicy: "disabled",
|
||||
rawHtmlEnabled: false,
|
||||
extensions: {
|
||||
tables: true,
|
||||
taskLists: true,
|
||||
footnotes: true,
|
||||
definitionLists: true,
|
||||
headingAnchors: true,
|
||||
fencedCode: true,
|
||||
syntaxHighlighting: true,
|
||||
callouts: true,
|
||||
youtube: true,
|
||||
wikipediaLinks: true,
|
||||
},
|
||||
},
|
||||
hosting: {
|
||||
engine: "nginx",
|
||||
production: { enabled: true, hostname: "example.test", listen: 80 },
|
||||
staging: { enabled: true, hostname: "preview.example.test", listen: 8080 },
|
||||
releases: { retainCount: 5 },
|
||||
},
|
||||
buildPolicy: {
|
||||
staging: { enabled: true, requireApproval: true },
|
||||
localActivation: { automatic: false },
|
||||
push: { automatic: false },
|
||||
},
|
||||
rawYaml: "",
|
||||
const git = (root: string, args: string[]): string => execFileSync("git", ["-C", root, ...args], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, GIT_AUTHOR_DATE: "2026-01-01T00:00:00Z", GIT_COMMITTER_DATE: "2026-01-01T00:00:00Z" },
|
||||
}).trim();
|
||||
|
||||
const createRepository = (): string => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "labyricorn-git-fixture-"));
|
||||
fs.mkdirSync(path.join(root, "packages"), { recursive: true });
|
||||
fs.cpSync(path.resolve("packages/site-definition"), path.join(root, "packages/site-definition"), { recursive: true });
|
||||
fs.cpSync(path.resolve("packages/content-models"), path.join(root, "packages/content-models"), { recursive: true });
|
||||
git(root, ["init", "-b", "main"]);
|
||||
git(root, ["config", "user.name", "Fixture"]);
|
||||
git(root, ["config", "user.email", "[email protected]"]);
|
||||
git(root, ["add", "."]);
|
||||
git(root, ["commit", "-m", "fixture"]);
|
||||
return root;
|
||||
};
|
||||
|
||||
const themeConfig: ThemeConfig = {
|
||||
id: "test-theme",
|
||||
name: "Test Theme",
|
||||
version: "1.0.0",
|
||||
path: "/.theme",
|
||||
templates: {},
|
||||
styles: [],
|
||||
scripts: [],
|
||||
supportsPackages: [],
|
||||
isValidated: true,
|
||||
validationErrors: [],
|
||||
const commit = (root: string, message: string): string => {
|
||||
git(root, ["add", "-A"]);
|
||||
git(root, ["commit", "-m", message]);
|
||||
return git(root, ["rev-parse", "HEAD"]);
|
||||
};
|
||||
|
||||
const content = (overrides: Partial<ContentItem> = {}): ContentItem => ({
|
||||
id: "article-one",
|
||||
title: "Article One",
|
||||
slug: "article-one",
|
||||
published: "2026-01-01T00:00:00.000Z",
|
||||
status: "published",
|
||||
artifactType: "article",
|
||||
summary: "A deterministic article.",
|
||||
tags: ["test"],
|
||||
aliases: [],
|
||||
sourceRepo: "content",
|
||||
path: "articles/article-one.md",
|
||||
contentMarkdown: "# Hello\n\nThis is **safe** markdown.",
|
||||
mediaReferences: [],
|
||||
youtubeDirectives: [],
|
||||
wikipediaLinks: [],
|
||||
validationStatus: "valid",
|
||||
validationMessages: [],
|
||||
route: "/articles/article-one/",
|
||||
styleInstanceId: "default",
|
||||
...overrides,
|
||||
});
|
||||
const load = (root: string, runId: string) => {
|
||||
const manager = new BuildInputLoader({ repositoryRoot: root, ref: "HEAD", siteDefinitionPath: "packages/site-definition", builderVersion: BUILDER_VERSION });
|
||||
return { manager, input: manager.load(runId) };
|
||||
};
|
||||
|
||||
const input = (buildId: string, items: ContentItem[]): BuildEngineInput => ({
|
||||
buildId,
|
||||
siteConfig,
|
||||
contentItems: items,
|
||||
mediaAssets: [],
|
||||
sourceCommits: { content: "abc123" },
|
||||
themeConfig,
|
||||
});
|
||||
const withRepository = (callback: (root: string, output: string) => void): void => {
|
||||
const root = createRepository();
|
||||
const output = fs.mkdtempSync(path.join(os.tmpdir(), "labyricorn-output-"));
|
||||
try { callback(root, output); }
|
||||
finally { fs.rmSync(root, { recursive: true, force: true }); fs.rmSync(output, { recursive: true, force: true }); }
|
||||
};
|
||||
|
||||
test("build output is deterministic for equivalent inputs", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "labyricorn-engine-test-"));
|
||||
test("reference repository theme renders declared routes and only declared assets", () => withRepository((root, output) => {
|
||||
const { manager, input } = load(root, "build-000001");
|
||||
try {
|
||||
const engine = new BuildEngine(root);
|
||||
const firstItem = content();
|
||||
const secondItem = content({
|
||||
id: "article-two",
|
||||
title: "Article Two",
|
||||
slug: "article-two",
|
||||
path: "articles/article-two.md",
|
||||
route: "/articles/article-two/",
|
||||
});
|
||||
const first = engine.build(input("build-one", [firstItem, secondItem]));
|
||||
const second = engine.build(input("build-two", [secondItem, firstItem]));
|
||||
|
||||
assert.equal(first.success, true);
|
||||
assert.equal(second.success, true);
|
||||
assert.equal(first.artifactChecksum, second.artifactChecksum);
|
||||
assert.deepEqual(first.generatedFiles, second.generatedFiles);
|
||||
assert.ok(fs.existsSync(path.join(first.outputDirectory, "build-manifest.json")));
|
||||
assert.ok(fs.existsSync(path.join(first.outputDirectory, "checksums.json")));
|
||||
assert.match(
|
||||
fs.readFileSync(path.join(first.outputDirectory, "articles/article-one/index.html"), "utf8"),
|
||||
/<h1>Hello<\/h1>/,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("route collisions fail validation without writing a release", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "labyricorn-engine-test-"));
|
||||
try {
|
||||
const engine = new BuildEngine(root);
|
||||
const result = engine.build(
|
||||
input("collision", [content(), content({ id: "article-two", route: "/articles/article-one/" })]),
|
||||
);
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(result.validationReport.summary.passed, false);
|
||||
assert.deepEqual(result.validationReport.routeCollisions, ["/articles/article-one/"]);
|
||||
assert.equal(fs.existsSync(result.outputDirectory), false);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("raw HTML is rejected when the site policy disables it", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "labyricorn-engine-test-"));
|
||||
try {
|
||||
const engine = new BuildEngine(root);
|
||||
const result = engine.build(input("raw-html", [content({ contentMarkdown: "<script>alert(1)</script>" })]));
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(result.validationReport.htmlPolicyViolations.length, 1);
|
||||
assert.equal(fs.existsSync(result.outputDirectory), false);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("raw HTML inside fenced code remains a valid code example", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "labyricorn-engine-test-"));
|
||||
try {
|
||||
const engine = new BuildEngine(root);
|
||||
const result = engine.build(
|
||||
input("html-code-example", [content({ contentMarkdown: "```html\n<div>example</div>\n```" })]),
|
||||
);
|
||||
const result = new BuildEngine(output).build(input);
|
||||
assert.equal(result.success, true);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
assert.match(input.siteDefinition.commit, /^[0-9a-f]{40}$/);
|
||||
assert.ok(fs.existsSync(path.join(result.outputDirectory, "index.html")));
|
||||
assert.ok(fs.existsSync(path.join(result.outputDirectory, "projects/index.html")));
|
||||
assert.ok(fs.existsSync(path.join(result.outputDirectory, "project/website-engine-control-plane/index.html")));
|
||||
assert.ok(fs.existsSync(path.join(result.outputDirectory, "project/website-engine-control-plane/demo.html")));
|
||||
assert.ok(fs.existsSync(path.join(result.outputDirectory, "404.html")));
|
||||
assert.ok(fs.existsSync(path.join(result.outputDirectory, "assets/styles/theme.css")));
|
||||
assert.ok(fs.existsSync(path.join(result.outputDirectory, "assets/scripts/theme.js")));
|
||||
assert.ok(fs.existsSync(path.join(result.outputDirectory, "assets/images/logo.svg")));
|
||||
assert.equal(fs.existsSync(path.join(result.outputDirectory, ".theme/theme.yml")), false);
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(result.outputDirectory, "build-manifest.json"), "utf8"));
|
||||
assert.equal(manifest.siteDefinition.commit, input.siteDefinition.commit);
|
||||
assert.equal(manifest.theme.commit, input.theme.snapshot.commit);
|
||||
assert.equal(manifest.artifactBuildId, input.artifactBuildId);
|
||||
assert.doesNotMatch(fs.readFileSync(path.join(result.outputDirectory, "index.html"), "utf8"), /fonts\.googleapis|tailwindcss\.com/);
|
||||
} finally { manager.dispose(); }
|
||||
}));
|
||||
|
||||
test("equivalent resolved inputs are byte-identical despite different run IDs", () => withRepository((root, output) => {
|
||||
const first = load(root, "build-000001");
|
||||
const second = load(root, "build-999999");
|
||||
try {
|
||||
assert.equal(first.input.artifactBuildId, second.input.artifactBuildId);
|
||||
const engine = new BuildEngine(output);
|
||||
const one = engine.build(first.input);
|
||||
const two = engine.build(second.input);
|
||||
assert.equal(one.artifactChecksum, two.artifactChecksum);
|
||||
assert.equal(one.outputDirectory, two.outputDirectory);
|
||||
assert.deepEqual(one.generatedFiles, two.generatedFiles);
|
||||
assert.equal(fs.readFileSync(path.join(one.outputDirectory, "checksums.json"), "utf8"), fs.readFileSync(path.join(two.outputDirectory, "checksums.json"), "utf8"));
|
||||
} finally { first.manager.dispose(); second.manager.dispose(); }
|
||||
}));
|
||||
|
||||
test("an already resolved snapshot is unchanged when the branch advances", () => withRepository((root, output) => {
|
||||
const pinned = load(root, "build-pinned");
|
||||
const css = path.join(root, "packages/site-definition/.theme/assets/styles/theme.css");
|
||||
fs.appendFileSync(css, "\n.branch-advanced{display:block}\n");
|
||||
const advancedCommit = commit(root, "advance theme css");
|
||||
const advanced = load(root, "build-advanced");
|
||||
try {
|
||||
assert.notEqual(pinned.input.siteDefinition.commit, advancedCommit);
|
||||
assert.equal(advanced.input.siteDefinition.commit, advancedCommit);
|
||||
const engine = new BuildEngine(output);
|
||||
const pinnedResult = engine.build(pinned.input);
|
||||
const advancedResult = engine.build(advanced.input);
|
||||
assert.notEqual(pinned.input.artifactBuildId, advanced.input.artifactBuildId);
|
||||
assert.notEqual(pinnedResult.artifactChecksum, advancedResult.artifactChecksum);
|
||||
assert.doesNotMatch(fs.readFileSync(path.join(pinnedResult.outputDirectory, "assets/styles/theme.css"), "utf8"), /branch-advanced/);
|
||||
} finally { pinned.manager.dispose(); advanced.manager.dispose(); }
|
||||
}));
|
||||
|
||||
test("a missing theme fails before any release directory exists", () => withRepository((root, output) => {
|
||||
fs.rmSync(path.join(root, "packages/site-definition/.theme"), { recursive: true, force: true });
|
||||
commit(root, "remove theme");
|
||||
const manager = new BuildInputLoader({ repositoryRoot: root, builderVersion: BUILDER_VERSION });
|
||||
try {
|
||||
assert.throws(() => manager.load("missing-theme"), /E_THEME_ROOT_INVALID/);
|
||||
assert.equal(fs.existsSync(path.join(output, "releases")), false);
|
||||
} finally { manager.dispose(); }
|
||||
}));
|
||||
|
||||
test("unknown manifest keys and escaping paths fail closed", () => withRepository((root) => {
|
||||
const manifest = path.join(root, "packages/site-definition/.theme/theme.yml");
|
||||
fs.appendFileSync(manifest, "unknownKey: rejected\n");
|
||||
commit(root, "invalid manifest key");
|
||||
let manager = new BuildInputLoader({ repositoryRoot: root, builderVersion: BUILDER_VERSION });
|
||||
assert.throws(() => manager.load("invalid-key"), /E_THEME_MANIFEST_INVALID/);
|
||||
manager.dispose();
|
||||
|
||||
git(root, ["reset", "--hard", "HEAD~1"]);
|
||||
fs.appendFileSync(manifest, "\n");
|
||||
const source = fs.readFileSync(manifest, "utf8").replace("templates/layout.liquid", "../outside.liquid");
|
||||
fs.writeFileSync(manifest, source);
|
||||
commit(root, "escaping template path");
|
||||
manager = new BuildInputLoader({ repositoryRoot: root, builderVersion: BUILDER_VERSION });
|
||||
assert.throws(() => manager.load("escape"), /E_THEME_MANIFEST_INVALID|E_THEME_PATH_ESCAPE/);
|
||||
manager.dispose();
|
||||
}));
|
||||
|
||||
test("strict Liquid rejects unknown variables without publishing a release", () => withRepository((root, output) => {
|
||||
const template = path.join(root, "packages/site-definition/.theme/templates/home.liquid");
|
||||
fs.appendFileSync(template, "\n{{ process.env.SECRET }}\n");
|
||||
commit(root, "unsafe variable");
|
||||
const { manager, input } = load(root, "strict-variable");
|
||||
try {
|
||||
const engine = new BuildEngine(output);
|
||||
assert.throws(() => engine.build(input), /undefined variable|not defined|process/i);
|
||||
assert.equal(fs.existsSync(engine.buildDirectory(input.artifactBuildId)), false);
|
||||
} finally { manager.dispose(); }
|
||||
}));
|
||||
|
||||
test("project declaration controls the project route and standalone publication", () => withRepository((root, output) => {
|
||||
fs.writeFileSync(path.join(root, "packages/site-definition/projects.yml"), "projects: []\n");
|
||||
commit(root, "remove project declaration");
|
||||
const { manager, input } = load(root, "no-project");
|
||||
try {
|
||||
const result = new BuildEngine(output).build(input);
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(input.projects.length, 0);
|
||||
assert.equal(fs.existsSync(path.join(result.outputDirectory, "project/website-engine-control-plane/index.html")), false);
|
||||
assert.equal(fs.existsSync(path.join(result.outputDirectory, "project/website-engine-control-plane/demo.html")), false);
|
||||
} finally { manager.dispose(); }
|
||||
}));
|
||||
|
||||
test("declared standalone files are copied byte-for-byte", () => withRepository((root, output) => {
|
||||
const { manager, input } = load(root, "publication");
|
||||
try {
|
||||
const result = new BuildEngine(output).build(input);
|
||||
const source = fs.readFileSync(path.join(root, "packages/site-definition/demo-project/demo.html"));
|
||||
const published = fs.readFileSync(path.join(result.outputDirectory, "project/website-engine-control-plane/demo.html"));
|
||||
assert.deepEqual(published, source);
|
||||
const manifest = JSON.parse(fs.readFileSync(path.join(result.outputDirectory, "build-manifest.json"), "utf8"));
|
||||
assert.equal(manifest.publishedFiles[0].sourceChecksum, `sha256-${input.publishedFiles[0].sourceChecksum}`);
|
||||
} finally { manager.dispose(); }
|
||||
}));
|
||||
|
||||
test("promotion verifies every artifact byte and rejects tampering", () => withRepository((root, output) => {
|
||||
const { manager, input } = load(root, "tamper");
|
||||
try {
|
||||
const engine = new BuildEngine(output);
|
||||
const result = engine.build(input);
|
||||
fs.appendFileSync(path.join(result.outputDirectory, "index.html"), "tampered");
|
||||
assert.throws(() => engine.activate(input.artifactBuildId), /E_RELEASE_CHECKSUM/);
|
||||
assert.equal(fs.existsSync(engine.currentDirectory()), false);
|
||||
} finally { manager.dispose(); }
|
||||
}));
|
||||
|
||||
test("markdown output is escaped and heading IDs are deterministic", () => {
|
||||
const first = renderMarkdown("# Repeat\n\n## Repeat\n\nText <img src=x> and [safe](/docs/).\n\n```html\n<script>x</script>\n```");
|
||||
const second = renderMarkdown("# Repeat\n\n## Repeat\n\nText <img src=x> and [safe](/docs/).\n\n```html\n<script>x</script>\n```");
|
||||
assert.equal(first, second);
|
||||
assert.match(first, /id="repeat"/);
|
||||
assert.match(first, /id="repeat-2"/);
|
||||
assert.match(first, /<img src=x>/);
|
||||
assert.doesNotMatch(first, /<script>x<\/script>/);
|
||||
});
|
||||
|
||||
test("markdown renderer escapes source HTML", () => {
|
||||
assert.equal(renderMarkdown("Text <img src=x>"), "<p>Text <img src=x></p>");
|
||||
test("production sources contain no embedded renderer or synthesized preview fallback", () => {
|
||||
assert.equal(fs.readFileSync(path.resolve("src/backend/buildEngine.ts"), "utf8").trim(), 'export * from "./repositoryBuildEngine";');
|
||||
const server = fs.readFileSync(path.resolve("server.ts"), "utf8");
|
||||
assert.doesNotMatch(server, /cdn\.tailwindcss\.com|Simple HTML renderer|SITE HEADER/);
|
||||
assert.match(server, /E_RELEASE_UNAVAILABLE/);
|
||||
assert.match(server, /E_CONFIG_READ_ONLY/);
|
||||
});
|
||||
|
||||
+1
-472
@@ -1,472 +1 @@
|
||||
import crypto from "crypto";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
import {
|
||||
ContentItem,
|
||||
MediaAsset,
|
||||
SiteConfig,
|
||||
ThemeConfig,
|
||||
ValidationReport,
|
||||
} from "../types";
|
||||
|
||||
export const BUILDER_VERSION = "1.0.0-labyricorn";
|
||||
|
||||
export interface BuildEngineInput {
|
||||
buildId: string;
|
||||
siteConfig: SiteConfig;
|
||||
contentItems: ContentItem[];
|
||||
mediaAssets: MediaAsset[];
|
||||
sourceCommits: Record<string, string>;
|
||||
themeConfig: ThemeConfig;
|
||||
}
|
||||
|
||||
export interface BuildEngineResult {
|
||||
success: boolean;
|
||||
outputDirectory: string;
|
||||
generatedRoutesCount: number;
|
||||
artifactChecksum: string;
|
||||
artifactSizeBytes: number;
|
||||
validationReport: ValidationReport;
|
||||
generatedFiles: string[];
|
||||
}
|
||||
|
||||
const emptyReport = (): ValidationReport => ({
|
||||
errors: [],
|
||||
warnings: [],
|
||||
missingMedia: [],
|
||||
routeCollisions: [],
|
||||
htmlPolicyViolations: [],
|
||||
brokenLinks: [],
|
||||
summary: { totalErrors: 0, totalWarnings: 0, passed: true },
|
||||
});
|
||||
|
||||
const escapeHtml = (value: string): string =>
|
||||
value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
|
||||
const stableValue = (value: unknown): unknown => {
|
||||
if (Array.isArray(value)) return value.map(stableValue);
|
||||
if (value && typeof value === "object") {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>)
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(([key, child]) => [key, stableValue(child)]),
|
||||
);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const stableJson = (value: unknown): string =>
|
||||
`${JSON.stringify(stableValue(value), null, 2)}\n`;
|
||||
|
||||
const sha256 = (value: string | Buffer): string =>
|
||||
crypto.createHash("sha256").update(value).digest("hex");
|
||||
|
||||
const normalizeRoute = (route: string): string => {
|
||||
const trimmed = route.trim();
|
||||
if (!trimmed || trimmed.includes("\0") || /[?#]/.test(trimmed)) {
|
||||
throw new Error(`Invalid route '${route}'`);
|
||||
}
|
||||
|
||||
const segments = trimmed
|
||||
.replace(/\\/g, "/")
|
||||
.split("/")
|
||||
.filter(Boolean);
|
||||
if (segments.some((segment) => segment === "." || segment === "..")) {
|
||||
throw new Error(`Route traversal is not allowed: '${route}'`);
|
||||
}
|
||||
|
||||
return segments.length === 0 ? "/" : `/${segments.join("/")}/`;
|
||||
};
|
||||
|
||||
const routeFile = (route: string): string => {
|
||||
const normalized = normalizeRoute(route);
|
||||
return normalized === "/"
|
||||
? "index.html"
|
||||
: `${normalized.slice(1)}index.html`;
|
||||
};
|
||||
|
||||
const renderInlineMarkdown = (value: string): string => {
|
||||
const codeTokens: string[] = [];
|
||||
let rendered = value.replace(/`([^`]+)`/g, (_match, code: string) => {
|
||||
const token = `\u0000CODE${codeTokens.length}\u0000`;
|
||||
codeTokens.push(`<code>${escapeHtml(code)}</code>`);
|
||||
return token;
|
||||
});
|
||||
|
||||
rendered = escapeHtml(rendered)
|
||||
.replace(
|
||||
/\[([^\]]+)\]\((https?:\/\/[^\s)]+|\/[^\s)]*)\)/g,
|
||||
'<a href="$2" rel="noopener noreferrer">$1</a>',
|
||||
)
|
||||
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/\*([^*]+)\*/g, "<em>$1</em>");
|
||||
|
||||
return rendered.replace(/\u0000CODE(\d+)\u0000/g, (_match, index: string) =>
|
||||
codeTokens[Number(index)] ?? "",
|
||||
);
|
||||
};
|
||||
|
||||
export const renderMarkdown = (markdown: string): string => {
|
||||
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
|
||||
const blocks: string[] = [];
|
||||
let paragraph: string[] = [];
|
||||
let list: string[] = [];
|
||||
let code: string[] | null = null;
|
||||
let codeLanguage = "";
|
||||
|
||||
const flushParagraph = () => {
|
||||
if (paragraph.length > 0) {
|
||||
blocks.push(`<p>${renderInlineMarkdown(paragraph.join(" "))}</p>`);
|
||||
paragraph = [];
|
||||
}
|
||||
};
|
||||
const flushList = () => {
|
||||
if (list.length > 0) {
|
||||
blocks.push(`<ul>${list.map((item) => `<li>${renderInlineMarkdown(item)}</li>`).join("")}</ul>`);
|
||||
list = [];
|
||||
}
|
||||
};
|
||||
|
||||
for (const line of lines) {
|
||||
const fence = line.match(/^```([A-Za-z0-9_-]*)\s*$/);
|
||||
if (fence) {
|
||||
if (code === null) {
|
||||
flushParagraph();
|
||||
flushList();
|
||||
code = [];
|
||||
codeLanguage = fence[1];
|
||||
} else {
|
||||
const languageClass = codeLanguage
|
||||
? ` class="language-${escapeHtml(codeLanguage)}"`
|
||||
: "";
|
||||
blocks.push(`<pre><code${languageClass}>${escapeHtml(code.join("\n"))}</code></pre>`);
|
||||
code = null;
|
||||
codeLanguage = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (code !== null) {
|
||||
code.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!line.trim()) {
|
||||
flushParagraph();
|
||||
flushList();
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (heading) {
|
||||
flushParagraph();
|
||||
flushList();
|
||||
const level = heading[1].length;
|
||||
blocks.push(`<h${level}>${renderInlineMarkdown(heading[2])}</h${level}>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const listItem = line.match(/^[-*]\s+(.+)$/);
|
||||
if (listItem) {
|
||||
flushParagraph();
|
||||
list.push(listItem[1]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.startsWith("> ")) {
|
||||
flushParagraph();
|
||||
flushList();
|
||||
blocks.push(`<blockquote>${renderInlineMarkdown(line.slice(2))}</blockquote>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
paragraph.push(line.trim());
|
||||
}
|
||||
|
||||
if (code !== null) {
|
||||
blocks.push(`<pre><code>${escapeHtml(code.join("\n"))}</code></pre>`);
|
||||
}
|
||||
flushParagraph();
|
||||
flushList();
|
||||
return blocks.join("\n");
|
||||
};
|
||||
|
||||
export class BuildEngine {
|
||||
readonly outputRoot: string;
|
||||
|
||||
constructor(outputRoot = process.env.LABYRICORN_BUILD_ROOT || path.join(os.tmpdir(), "labyricorn-builds")) {
|
||||
this.outputRoot = path.resolve(outputRoot);
|
||||
}
|
||||
|
||||
buildDirectory(buildId: string): string {
|
||||
if (!/^[A-Za-z0-9._-]+$/.test(buildId)) {
|
||||
throw new Error(`Invalid build ID '${buildId}'`);
|
||||
}
|
||||
return path.join(this.outputRoot, buildId);
|
||||
}
|
||||
|
||||
currentDirectory(): string {
|
||||
return path.join(this.outputRoot, "current");
|
||||
}
|
||||
|
||||
nextBuildNumber(): number {
|
||||
if (!fs.existsSync(this.outputRoot)) return 1;
|
||||
const highestExisting = fs
|
||||
.readdirSync(this.outputRoot, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
|
||||
.map((entry) => entry.name.match(/^build-(\d+)$/)?.[1])
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.map(Number)
|
||||
.reduce((highest, value) => Math.max(highest, value), 0);
|
||||
return highestExisting + 1;
|
||||
}
|
||||
|
||||
validate(input: BuildEngineInput): ValidationReport {
|
||||
const report = emptyReport();
|
||||
const claimedRoutes = new Map<string, string>();
|
||||
const claim = (route: string, owner: string) => {
|
||||
try {
|
||||
const normalized = normalizeRoute(route);
|
||||
const existing = claimedRoutes.get(normalized);
|
||||
if (existing && existing !== owner) {
|
||||
report.routeCollisions.push(normalized);
|
||||
report.errors.push({
|
||||
code: "E_ROUTE_COLLISION",
|
||||
message: `Route '${normalized}' is claimed by both ${existing} and ${owner}.`,
|
||||
category: "routing",
|
||||
});
|
||||
} else {
|
||||
claimedRoutes.set(normalized, owner);
|
||||
}
|
||||
} catch (error) {
|
||||
report.errors.push({
|
||||
code: "E_ROUTE_INVALID",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
category: "routing",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
claim("/", "site-home");
|
||||
for (const navigation of input.siteConfig.navigation) {
|
||||
if (navigation.route !== "/") claim(navigation.route, `navigation:${navigation.id}`);
|
||||
}
|
||||
|
||||
const knownMedia = new Set(input.mediaAssets.map((asset) => asset.publicNamespacePath));
|
||||
for (const item of input.contentItems.filter((candidate) => candidate.status === "published")) {
|
||||
claim(item.route, `content:${item.id}`);
|
||||
if (item.validationStatus === "error") {
|
||||
report.errors.push({
|
||||
code: "E_CONTENT_INVALID",
|
||||
message: `Content item '${item.id}' failed source validation.`,
|
||||
file: item.path,
|
||||
category: "content",
|
||||
});
|
||||
}
|
||||
const markdownWithoutCode = item.contentMarkdown
|
||||
.replace(/```[\s\S]*?```/g, "")
|
||||
.replace(/`[^`]*`/g, "");
|
||||
if (!input.siteConfig.markdown.rawHtmlEnabled && /<\/?[A-Za-z][^>]*>/.test(markdownWithoutCode)) {
|
||||
report.htmlPolicyViolations.push(item.path);
|
||||
report.errors.push({
|
||||
code: "E_RAW_HTML_DISABLED",
|
||||
message: `Raw HTML is disabled but was found in '${item.path}'.`,
|
||||
file: item.path,
|
||||
category: "markdown",
|
||||
});
|
||||
}
|
||||
for (const reference of item.mediaReferences) {
|
||||
if (!/^https?:\/\//.test(reference) && !knownMedia.has(reference)) {
|
||||
report.missingMedia.push(reference);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const missing of [...new Set(report.missingMedia)].sort()) {
|
||||
report.errors.push({
|
||||
code: "E_MEDIA_MISSING",
|
||||
message: `Referenced media '${missing}' was not discovered.`,
|
||||
category: "media",
|
||||
});
|
||||
}
|
||||
for (const asset of input.mediaAssets.filter((candidate) => !candidate.isValidated)) {
|
||||
report.warnings.push({
|
||||
code: "W_MEDIA_UNVALIDATED",
|
||||
message: `Media asset '${asset.originalPath}' has not been validated.`,
|
||||
file: asset.originalPath,
|
||||
category: "media",
|
||||
});
|
||||
}
|
||||
|
||||
report.routeCollisions = [...new Set(report.routeCollisions)].sort();
|
||||
report.missingMedia = [...new Set(report.missingMedia)].sort();
|
||||
report.htmlPolicyViolations = [...new Set(report.htmlPolicyViolations)].sort();
|
||||
report.summary = {
|
||||
totalErrors: report.errors.length,
|
||||
totalWarnings: report.warnings.length,
|
||||
passed: report.errors.length === 0,
|
||||
};
|
||||
return report;
|
||||
}
|
||||
|
||||
build(input: BuildEngineInput): BuildEngineResult {
|
||||
const validationReport = this.validate(input);
|
||||
const outputDirectory = this.buildDirectory(input.buildId);
|
||||
if (!validationReport.summary.passed) {
|
||||
return {
|
||||
success: false,
|
||||
outputDirectory,
|
||||
generatedRoutesCount: 0,
|
||||
artifactChecksum: "",
|
||||
artifactSizeBytes: 0,
|
||||
validationReport,
|
||||
generatedFiles: [],
|
||||
};
|
||||
}
|
||||
|
||||
fs.mkdirSync(this.outputRoot, { recursive: true });
|
||||
const temporaryDirectory = path.join(
|
||||
this.outputRoot,
|
||||
`.${input.buildId}.tmp-${process.pid}-${crypto.randomUUID()}`,
|
||||
);
|
||||
fs.mkdirSync(temporaryDirectory, { recursive: true });
|
||||
|
||||
try {
|
||||
const publishedItems = input.contentItems
|
||||
.filter((item) => item.status === "published")
|
||||
.sort((left, right) => normalizeRoute(left.route).localeCompare(normalizeRoute(right.route)) || left.id.localeCompare(right.id));
|
||||
const generatedRoutes = new Set<string>();
|
||||
const generatedFiles: string[] = [];
|
||||
|
||||
const navigation = input.siteConfig.navigation
|
||||
.map((entry) => `<a href="${escapeHtml(normalizeRoute(entry.route))}">${escapeHtml(entry.label)}</a>`)
|
||||
.join("");
|
||||
const layout = (title: string, body: string) => `<!doctype html>
|
||||
<html lang="${escapeHtml(input.siteConfig.site.language)}">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${escapeHtml(title)} | ${escapeHtml(input.siteConfig.site.title)}</title>
|
||||
<style>:root{color-scheme:light dark;font-family:Inter,system-ui,sans-serif}body{max-width:72rem;margin:auto;padding:2rem;line-height:1.65}nav{display:flex;gap:1rem;flex-wrap:wrap;border-bottom:1px solid #7775;padding-bottom:1rem;margin-bottom:2rem}a{color:#b38a2e}main{max-width:52rem}article+article{border-top:1px solid #7775;margin-top:1.5rem;padding-top:1.5rem}pre{overflow:auto;padding:1rem;background:#111;color:#eee}code{font-family:ui-monospace,monospace}blockquote{border-left:.25rem solid #b38a2e;padding-left:1rem;margin-left:0}</style>
|
||||
</head>
|
||||
<body><nav>${navigation}</nav><main>${body}</main></body>
|
||||
</html>
|
||||
`;
|
||||
const writeRoute = (route: string, html: string) => {
|
||||
const normalized = normalizeRoute(route);
|
||||
const relativeFile = routeFile(normalized);
|
||||
const absoluteFile = path.join(temporaryDirectory, relativeFile);
|
||||
fs.mkdirSync(path.dirname(absoluteFile), { recursive: true });
|
||||
fs.writeFileSync(absoluteFile, html, "utf8");
|
||||
generatedRoutes.add(normalized);
|
||||
generatedFiles.push(relativeFile.replace(/\\/g, "/"));
|
||||
};
|
||||
|
||||
const homeItems = publishedItems
|
||||
.map((item) => `<article><h2><a href="${escapeHtml(normalizeRoute(item.route))}">${escapeHtml(item.title)}</a></h2><p>${escapeHtml(item.summary)}</p></article>`)
|
||||
.join("");
|
||||
writeRoute("/", layout(input.siteConfig.site.title, `<h1>${escapeHtml(input.siteConfig.site.title)}</h1>${homeItems || "<p>No published content.</p>"}`));
|
||||
|
||||
for (const entry of input.siteConfig.navigation.filter((candidate) => candidate.route !== "/")) {
|
||||
const items = publishedItems
|
||||
.filter((item) => item.artifactType === entry.contentModel)
|
||||
.map((item) => `<article><h2><a href="${escapeHtml(normalizeRoute(item.route))}">${escapeHtml(item.title)}</a></h2><p>${escapeHtml(item.summary)}</p></article>`)
|
||||
.join("");
|
||||
writeRoute(entry.route, layout(entry.label, `<h1>${escapeHtml(entry.label)}</h1>${items || "<p>No published content.</p>"}`));
|
||||
}
|
||||
|
||||
for (const item of publishedItems) {
|
||||
const embeds = item.youtubeDirectives
|
||||
.filter((directive) => /^[A-Za-z0-9_-]{6,20}$/.test(directive.videoId))
|
||||
.map((directive) => `<section><h2>${escapeHtml(directive.title)}</h2><iframe loading="lazy" src="https://www.youtube-nocookie.com/embed/${directive.videoId}" title="${escapeHtml(directive.title)}" allowfullscreen></iframe></section>`)
|
||||
.join("");
|
||||
writeRoute(
|
||||
item.route,
|
||||
layout(item.title, `<article><h1>${escapeHtml(item.title)}</h1><p><em>${escapeHtml(item.summary)}</em></p>${renderMarkdown(item.contentMarkdown)}${embeds}</article>`),
|
||||
);
|
||||
}
|
||||
|
||||
const errorPage = layout("Not Found", "<h1>404</h1><p>The requested page was not found.</p>");
|
||||
fs.writeFileSync(path.join(temporaryDirectory, "404.html"), errorPage, "utf8");
|
||||
generatedFiles.push("404.html");
|
||||
|
||||
const manifest = {
|
||||
protocol: input.siteConfig.protocol,
|
||||
builderVersion: BUILDER_VERSION,
|
||||
site: {
|
||||
id: input.siteConfig.site.id,
|
||||
baseUrl: input.siteConfig.site.baseUrl,
|
||||
language: input.siteConfig.site.language,
|
||||
},
|
||||
sourceCommits: input.sourceCommits,
|
||||
theme: { id: input.themeConfig.id, version: input.themeConfig.version },
|
||||
routes: [...generatedRoutes].sort(),
|
||||
media: input.mediaAssets
|
||||
.map((asset) => ({ path: asset.publicNamespacePath, sha256: null, sizeBytes: asset.sizeBytes }))
|
||||
.sort((left, right) => left.path.localeCompare(right.path)),
|
||||
};
|
||||
fs.writeFileSync(path.join(temporaryDirectory, "build-manifest.json"), stableJson(manifest), "utf8");
|
||||
generatedFiles.push("build-manifest.json");
|
||||
|
||||
const checksums = Object.fromEntries(
|
||||
generatedFiles
|
||||
.sort()
|
||||
.map((relativeFile) => [relativeFile, sha256(fs.readFileSync(path.join(temporaryDirectory, relativeFile)))]),
|
||||
);
|
||||
const checksumsDocument = stableJson({ algorithm: "sha256", files: checksums });
|
||||
fs.writeFileSync(path.join(temporaryDirectory, "checksums.json"), checksumsDocument, "utf8");
|
||||
generatedFiles.push("checksums.json");
|
||||
|
||||
const artifactChecksum = `sha256-${sha256(checksumsDocument)}`;
|
||||
const artifactSizeBytes = generatedFiles.reduce(
|
||||
(total, relativeFile) => total + fs.statSync(path.join(temporaryDirectory, relativeFile)).size,
|
||||
0,
|
||||
);
|
||||
|
||||
if (fs.existsSync(outputDirectory)) {
|
||||
throw new Error(`Build output already exists: ${outputDirectory}`);
|
||||
}
|
||||
fs.renameSync(temporaryDirectory, outputDirectory);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
outputDirectory,
|
||||
generatedRoutesCount: generatedRoutes.size,
|
||||
artifactChecksum,
|
||||
artifactSizeBytes,
|
||||
validationReport,
|
||||
generatedFiles: generatedFiles.sort(),
|
||||
};
|
||||
} catch (error) {
|
||||
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
activate(buildId: string): string {
|
||||
const buildDirectory = this.buildDirectory(buildId);
|
||||
if (!fs.existsSync(path.join(buildDirectory, "build-manifest.json"))) {
|
||||
throw new Error(`Build '${buildId}' is missing a build manifest.`);
|
||||
}
|
||||
|
||||
fs.mkdirSync(this.outputRoot, { recursive: true });
|
||||
const current = this.currentDirectory();
|
||||
const next = path.join(this.outputRoot, `.current-${process.pid}-${crypto.randomUUID()}`);
|
||||
fs.symlinkSync(buildDirectory, next, process.platform === "win32" ? "junction" : "dir");
|
||||
try {
|
||||
if (fs.existsSync(current) || fs.lstatSync(current, { throwIfNoEntry: false })) {
|
||||
fs.rmSync(current, { recursive: true, force: true });
|
||||
}
|
||||
fs.renameSync(next, current);
|
||||
} catch (error) {
|
||||
fs.rmSync(next, { recursive: true, force: true });
|
||||
throw error;
|
||||
}
|
||||
return current;
|
||||
}
|
||||
}
|
||||
export * from "./repositoryBuildEngine";
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
||||
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
@@ -1,549 +1,247 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import YAML from "yaml";
|
||||
import {
|
||||
GitSource,
|
||||
StyleConfigPackage,
|
||||
StyleConfigInstance,
|
||||
ContentItem,
|
||||
MediaAsset,
|
||||
ThemeConfig,
|
||||
AuditLogEntry,
|
||||
BuildLogEntry,
|
||||
BuildRecord,
|
||||
ContentItem,
|
||||
CredentialRef,
|
||||
GitSource,
|
||||
MediaAsset,
|
||||
NginxStatus,
|
||||
PushTarget,
|
||||
CredentialRef,
|
||||
AuditLogEntry,
|
||||
SiteConfig,
|
||||
StyleConfigInstance,
|
||||
StyleConfigPackage,
|
||||
ThemeConfig,
|
||||
ValidationReport,
|
||||
BuildLogEntry,
|
||||
NavigationEntry,
|
||||
ContentModel,
|
||||
} from "../types";
|
||||
import YAML from "yaml";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { BuildInputLoader } from "./buildInputLoader";
|
||||
import { BuildEngine, BUILDER_VERSION } from "./buildEngine";
|
||||
import { ThemeContractError } from "./theme/themeLoader";
|
||||
|
||||
const emptyReport = (): ValidationReport => ({ errors: [], warnings: [], missingMedia: [], routeCollisions: [], htmlPolicyViolations: [], brokenLinks: [], summary: { totalErrors: 0, totalWarnings: 0, passed: true } });
|
||||
|
||||
const fallbackSiteConfig = (): SiteConfig => {
|
||||
const sitePath = path.resolve("packages/site-definition/site.yml");
|
||||
const navigationPath = path.resolve("packages/site-definition/navigation.yml");
|
||||
const rawYaml = fs.readFileSync(sitePath, "utf8");
|
||||
const document = YAML.parse(rawYaml);
|
||||
const navigation = YAML.parse(fs.readFileSync(navigationPath, "utf8")).navigation ?? [];
|
||||
return {
|
||||
protocol: document.protocol,
|
||||
site: document.site,
|
||||
navigation,
|
||||
contentModels: [],
|
||||
sourcesFile: document.sourcesFile ?? "sources.yml",
|
||||
styleInstancesPath: document.styleInstancesPath ?? "../style-configs",
|
||||
pagesPath: document.pagesPath ?? "pages",
|
||||
navigationFile: document.navigationFile ?? "navigation.yml",
|
||||
projectsFile: document.projectsFile,
|
||||
pushIntegrationsPath: document.pushIntegrationsPath ?? "push-integrations",
|
||||
theme: { ...document.theme, path: String(document.theme.path).replace(/^\/+/, "") },
|
||||
markdown: document.markdown,
|
||||
hosting: document.hosting,
|
||||
buildPolicy: document.buildPolicy,
|
||||
rawYaml,
|
||||
readOnly: true,
|
||||
};
|
||||
};
|
||||
|
||||
export class LabyricornStore {
|
||||
siteConfig: SiteConfig;
|
||||
gitSources: GitSource[];
|
||||
stylePackages: StyleConfigPackage[];
|
||||
styleInstances: StyleConfigInstance[];
|
||||
contentItems: ContentItem[];
|
||||
mediaAssets: MediaAsset[];
|
||||
themeConfig: ThemeConfig;
|
||||
builds: BuildRecord[];
|
||||
siteConfig: SiteConfig = fallbackSiteConfig();
|
||||
gitSources: GitSource[] = [];
|
||||
stylePackages: StyleConfigPackage[] = [];
|
||||
styleInstances: StyleConfigInstance[] = [];
|
||||
contentItems: ContentItem[] = [];
|
||||
mediaAssets: MediaAsset[] = [];
|
||||
themeConfig: ThemeConfig = {
|
||||
id: "unresolved", name: "Theme not resolved", version: "0.0.0", path: this.siteConfig.theme.path,
|
||||
templates: {}, styles: [], scripts: [], supportsPackages: [], isValidated: false,
|
||||
validationErrors: ["No immutable repository snapshot has been loaded."], status: "unresolved",
|
||||
};
|
||||
builds: BuildRecord[] = [];
|
||||
pushTargets: PushTarget[] = [];
|
||||
credentials: CredentialRef[] = [];
|
||||
auditLogs: AuditLogEntry[] = [];
|
||||
readonly buildEngine = new BuildEngine();
|
||||
nginxStatus: NginxStatus;
|
||||
pushTargets: PushTarget[];
|
||||
credentials: CredentialRef[];
|
||||
auditLogs: AuditLogEntry[];
|
||||
private readonly buildEngine = new BuildEngine();
|
||||
|
||||
constructor() {
|
||||
this.gitSources = [];
|
||||
this.stylePackages = [];
|
||||
this.styleInstances = [];
|
||||
this.contentItems = [];
|
||||
this.mediaAssets = [];
|
||||
this.builds = [];
|
||||
this.pushTargets = [];
|
||||
this.credentials = [];
|
||||
this.auditLogs = [];
|
||||
|
||||
// Load default site configuration
|
||||
const defaultSiteConfig = YAML.parse(
|
||||
fs.readFileSync("./packages/site-definition/site.yml", "utf-8"),
|
||||
);
|
||||
const defaultNavConfig = YAML.parse(
|
||||
fs.readFileSync("./packages/site-definition/navigation.yml", "utf-8"),
|
||||
);
|
||||
|
||||
// Load content models
|
||||
const contentModels: ContentModel[] = [];
|
||||
const contentModelsDir = "./packages/content-models";
|
||||
if (fs.existsSync(contentModelsDir)) {
|
||||
const dirs = fs.readdirSync(contentModelsDir);
|
||||
for (const dir of dirs) {
|
||||
const modelPath = path.join(contentModelsDir, dir, "model.yml");
|
||||
if (fs.existsSync(modelPath)) {
|
||||
contentModels.push(YAML.parse(fs.readFileSync(modelPath, "utf-8")));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load style configurations
|
||||
const stylePackagesDir = "./packages/style-configs";
|
||||
if (fs.existsSync(stylePackagesDir)) {
|
||||
const dirs = fs.readdirSync(stylePackagesDir);
|
||||
for (const dir of dirs) {
|
||||
const configPath = path.join(stylePackagesDir, dir, "config.yml");
|
||||
if (fs.existsSync(configPath)) {
|
||||
this.stylePackages.push(
|
||||
YAML.parse(fs.readFileSync(configPath, "utf-8")),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.siteConfig = {
|
||||
protocol: defaultSiteConfig.protocol || "labyricorn-site/v1",
|
||||
site: defaultSiteConfig.site,
|
||||
navigation: defaultNavConfig.navigation || [],
|
||||
contentModels: contentModels,
|
||||
sourcesFile: "./sources.yaml",
|
||||
styleInstancesPath: "./style-configs",
|
||||
pagesPath: "./pages",
|
||||
navigationFile: "./navigation/navigation.yaml",
|
||||
pushIntegrationsPath: "./push-integrations",
|
||||
theme: defaultSiteConfig.theme,
|
||||
markdown: defaultSiteConfig.markdown,
|
||||
hosting: defaultSiteConfig.hosting,
|
||||
buildPolicy: defaultSiteConfig.buildPolicy,
|
||||
rawYaml: "",
|
||||
};
|
||||
|
||||
this.siteConfig.rawYaml = YAML.stringify({
|
||||
protocol: this.siteConfig.protocol,
|
||||
site: this.siteConfig.site,
|
||||
navigation: this.siteConfig.navigation,
|
||||
contentModels: this.siteConfig.contentModels,
|
||||
theme: this.siteConfig.theme,
|
||||
markdown: this.siteConfig.markdown,
|
||||
hosting: this.siteConfig.hosting,
|
||||
buildPolicy: this.siteConfig.buildPolicy,
|
||||
});
|
||||
|
||||
this.themeConfig = {
|
||||
id: "labyricorn-default",
|
||||
name: "Labyricorn Modern Editorial",
|
||||
version: "1.2.0",
|
||||
path: "/.theme",
|
||||
templates: {
|
||||
page: "templates/page.html",
|
||||
error: "templates/error.html",
|
||||
"devlogs/entry": "templates/devlogs/entry.html",
|
||||
"devlogs/detailed-summary": "templates/devlogs/detailed-summary.html",
|
||||
"blog/post": "templates/blog/post.html",
|
||||
},
|
||||
styles: ["styles/reset.css", "styles/theme.css", "styles/components.css"],
|
||||
scripts: ["scripts/theme.js"],
|
||||
supportsPackages: this.stylePackages.map((pkg) => pkg.id),
|
||||
isValidated: true,
|
||||
validationErrors: [],
|
||||
};
|
||||
|
||||
this.loadStylePackagesForDisplay();
|
||||
this.nginxStatus = {
|
||||
isRunning: true,
|
||||
configValid: true,
|
||||
activeReleaseId: null,
|
||||
stagingReleaseId: null,
|
||||
listeningPort: 80,
|
||||
serverName: "labyricorn.local",
|
||||
lastReloadTime: new Date().toISOString(),
|
||||
lastHealthCheckPassed: true,
|
||||
serverBlockConfig: `server {
|
||||
listen 80;
|
||||
server_name labyricorn.local;
|
||||
root /var/lib/labyricorn/site/current;
|
||||
index index.html;
|
||||
|
||||
location /media/ {
|
||||
alias /var/lib/labyricorn/site/current/media/;
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, no-transform";
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
error_page 404 /404.html;
|
||||
}`,
|
||||
stagingServerBlockConfig: `server {
|
||||
listen 8080;
|
||||
server_name preview.labyricorn.local;
|
||||
root /var/lib/labyricorn/site/staging;
|
||||
index index.html;
|
||||
|
||||
location /media/ {
|
||||
alias /var/lib/labyricorn/site/staging/media/;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}`,
|
||||
isRunning: true, configValid: true, activeReleaseId: null, stagingReleaseId: null,
|
||||
listeningPort: 80, serverName: this.siteConfig.hosting.production.hostname,
|
||||
lastReloadTime: new Date(0).toISOString(), lastHealthCheckPassed: false,
|
||||
serverBlockConfig: "root /var/lib/labyricorn/site/current;",
|
||||
stagingServerBlockConfig: "root /var/lib/labyricorn/site/staging;",
|
||||
};
|
||||
this.restoreReleasePointers();
|
||||
try { this.resolveConfiguration("startup"); } catch (error) {
|
||||
this.themeConfig = { ...this.themeConfig, status: "unresolved", isValidated: false, validationErrors: [error instanceof Error ? error.message : String(error)] };
|
||||
}
|
||||
}
|
||||
|
||||
// --- ACTIONS ---
|
||||
private loadStylePackagesForDisplay(): void {
|
||||
const root = path.resolve("packages/style-configs");
|
||||
if (!fs.existsSync(root)) return;
|
||||
for (const name of fs.readdirSync(root).sort()) {
|
||||
const file = path.join(root, name, "config.yml");
|
||||
if (fs.existsSync(file)) this.stylePackages.push(YAML.parse(fs.readFileSync(file, "utf8")));
|
||||
}
|
||||
}
|
||||
|
||||
addAudit(
|
||||
action: string,
|
||||
category: AuditLogEntry["category"],
|
||||
details: string,
|
||||
result: "success" | "failure" = "success",
|
||||
) {
|
||||
const entry: AuditLogEntry = {
|
||||
id: `log-${Date.now()}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
user: "[email protected]",
|
||||
action,
|
||||
category,
|
||||
details,
|
||||
result,
|
||||
private loader(): BuildInputLoader {
|
||||
return new BuildInputLoader({ repositoryRoot: process.env.LABYRICORN_SITE_DEFINITION_REPOSITORY ?? process.cwd(), ref: process.env.LABYRICORN_SITE_DEFINITION_REF ?? "HEAD", siteDefinitionPath: process.env.LABYRICORN_SITE_DEFINITION_PATH ?? "packages/site-definition", builderVersion: BUILDER_VERSION });
|
||||
}
|
||||
|
||||
private applyResolvedInput(input: ReturnType<BuildInputLoader["load"]>): void {
|
||||
this.siteConfig = input.siteConfig;
|
||||
this.contentItems = [...input.contentItems];
|
||||
this.mediaAssets = [...input.mediaAssets];
|
||||
const manifest = input.theme.manifest;
|
||||
this.themeConfig = {
|
||||
id: manifest.id, name: manifest.id, version: manifest.version, path: input.siteConfig.theme.path,
|
||||
templates: {
|
||||
layout: manifest.templates.layout, home: manifest.templates.home, notFound: manifest.templates.notFound,
|
||||
...Object.fromEntries(Object.entries(manifest.templates.sections ?? {}).map(([key, value]) => [`sections.${key}`, value])),
|
||||
...Object.fromEntries(Object.entries(manifest.templates.content ?? {}).map(([key, value]) => [`content.${key}`, value])),
|
||||
},
|
||||
styles: [...manifest.assets.styles], scripts: [...manifest.assets.scripts], supportsPackages: this.stylePackages.map((item) => item.id),
|
||||
isValidated: true, validationErrors: [], status: "valid", protocol: manifest.protocol,
|
||||
sourceId: input.theme.snapshot.sourceId, repository: input.theme.snapshot.repository, commit: input.theme.snapshot.commit,
|
||||
manifestChecksum: input.theme.manifestChecksum, checksums: { ...input.theme.checksums },
|
||||
};
|
||||
this.auditLogs.unshift(entry);
|
||||
this.gitSources = [{
|
||||
id: input.siteDefinition.sourceId, type: "site-definition", repository: input.siteDefinition.repository,
|
||||
ref: process.env.LABYRICORN_SITE_DEFINITION_REF ?? "HEAD", required: true, allowWorkingTree: false,
|
||||
health: "Healthy", reachability: "Healthy", configurationState: "Valid", visibility: "Private in Gitea",
|
||||
projectionEnabled: true, produces: ["site", "theme", "projects"], status: "connected",
|
||||
lastResolvedCommit: input.siteDefinition.commit, lastSyncedAt: input.generatedAt, branch: process.env.LABYRICORN_SITE_DEFINITION_REF ?? "HEAD",
|
||||
}];
|
||||
}
|
||||
|
||||
private resolveConfiguration(runId: string): void {
|
||||
const loader = this.loader();
|
||||
try { this.applyResolvedInput(loader.load(runId)); }
|
||||
finally { loader.dispose(); }
|
||||
}
|
||||
|
||||
addAudit(action: string, category: AuditLogEntry["category"], details: string, result: "success" | "failure" = "success"): void {
|
||||
this.auditLogs.unshift({ id: `log-${Date.now()}-${this.auditLogs.length}`, timestamp: new Date().toISOString(), user: "[email protected]", action, category, details, result });
|
||||
}
|
||||
|
||||
validateTheme(): ThemeConfig {
|
||||
const loader = this.loader();
|
||||
try {
|
||||
const input = loader.load(`validation-${Date.now()}`);
|
||||
this.applyResolvedInput(input);
|
||||
this.addAudit("Validate Theme", "config", `Validated ${input.theme.manifest.id} at ${input.theme.snapshot.commit}`);
|
||||
return this.themeConfig;
|
||||
} catch (error) {
|
||||
const messages = error instanceof ThemeContractError ? error.issues.map((issue) => `${issue.code}: ${issue.message}`) : [error instanceof Error ? error.message : String(error)];
|
||||
this.themeConfig = { ...this.themeConfig, status: "invalid", isValidated: false, validationErrors: messages };
|
||||
this.addAudit("Validate Theme", "config", messages.join("; "), "failure");
|
||||
return this.themeConfig;
|
||||
} finally { loader.dispose(); }
|
||||
}
|
||||
|
||||
runBuild(): BuildRecord {
|
||||
const buildStartedAt = Date.now();
|
||||
const nextBuildNum = Math.max(
|
||||
this.builds.reduce((highest, build) => Math.max(highest, build.buildNumber), 0) + 1,
|
||||
this.buildEngine.nextBuildNumber(),
|
||||
);
|
||||
const buildId = `build-${String(nextBuildNum).padStart(6, "0")}`;
|
||||
const number = this.builds.reduce((highest, build) => Math.max(highest, build.buildNumber), 0) + 1;
|
||||
const runId = `build-${String(number).padStart(6, "0")}`;
|
||||
const startedAt = Date.now();
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
const logs: BuildLogEntry[] = [
|
||||
{
|
||||
timestamp,
|
||||
level: "info",
|
||||
message: `Initializing Build #${nextBuildNum}...`,
|
||||
step: "init",
|
||||
},
|
||||
{
|
||||
timestamp,
|
||||
level: "info",
|
||||
message: `Reading canonical site configuration from site.yaml`,
|
||||
step: "config",
|
||||
},
|
||||
{
|
||||
timestamp,
|
||||
level: "info",
|
||||
message: `Resolving ${this.gitSources.length} Git sources...`,
|
||||
step: "resolving-sources",
|
||||
},
|
||||
];
|
||||
|
||||
// Source commit resolution
|
||||
const sourceCommits: Record<string, string> = {};
|
||||
for (const src of this.gitSources) {
|
||||
if (!src.projectionEnabled || src.health !== "Healthy") {
|
||||
continue;
|
||||
}
|
||||
sourceCommits[src.id] = src.lastResolvedCommit || "unknown";
|
||||
logs.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: "info",
|
||||
message: `Resolved source '${src.id}' (${src.ref}) -> commit ${src.lastResolvedCommit?.substring(0, 7) || "unknown"}`,
|
||||
step: "resolving-sources",
|
||||
});
|
||||
}
|
||||
|
||||
// Content discovery
|
||||
logs.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: "info",
|
||||
message: `Discovering Markdown content items across style instances...`,
|
||||
step: "content-discovery",
|
||||
});
|
||||
logs.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: "info",
|
||||
message: `Found ${this.contentItems.length} Markdown items and ${this.mediaAssets.length} media assets.`,
|
||||
step: "content-discovery",
|
||||
});
|
||||
|
||||
// Validation
|
||||
logs.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: "info",
|
||||
message: `Validating schemas, YouTube directives, Wikipedia links, raw HTML policy...`,
|
||||
step: "validating",
|
||||
});
|
||||
|
||||
logs.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: "info",
|
||||
message: `Rendering theme templates from ${this.siteConfig.theme.path}...`,
|
||||
step: "building",
|
||||
});
|
||||
|
||||
let validationReport: ValidationReport = {
|
||||
errors: [],
|
||||
warnings: [],
|
||||
missingMedia: [],
|
||||
routeCollisions: [],
|
||||
htmlPolicyViolations: [],
|
||||
brokenLinks: [],
|
||||
summary: { totalErrors: 0, totalWarnings: 0, passed: true },
|
||||
};
|
||||
let engineResult: ReturnType<BuildEngine["build"]>;
|
||||
|
||||
const logs: BuildLogEntry[] = [{ timestamp, level: "info", message: "Resolving immutable repository inputs...", step: "resolving-sources" }];
|
||||
const loader = this.loader();
|
||||
try {
|
||||
engineResult = this.buildEngine.build({
|
||||
buildId,
|
||||
siteConfig: this.siteConfig,
|
||||
contentItems: this.contentItems,
|
||||
mediaAssets: this.mediaAssets,
|
||||
sourceCommits,
|
||||
themeConfig: this.themeConfig,
|
||||
});
|
||||
validationReport = engineResult.validationReport;
|
||||
if (!engineResult.success) {
|
||||
const validationError = new Error(
|
||||
`Validation failed with ${validationReport.summary.totalErrors} error(s).`,
|
||||
);
|
||||
(validationError as Error & { validationReport: ValidationReport }).validationReport =
|
||||
validationReport;
|
||||
throw validationError;
|
||||
}
|
||||
|
||||
logs.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: "success",
|
||||
message: `Static site build complete. Output written to ${engineResult.outputDirectory}`,
|
||||
step: "built",
|
||||
});
|
||||
logs.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: "success",
|
||||
message: `Generated build-manifest.json and checksums.json (${engineResult.artifactChecksum.substring(0, 20)}...)`,
|
||||
step: "built",
|
||||
});
|
||||
} catch (e: any) {
|
||||
logs.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: "error",
|
||||
message: `Build failed: ${e.message}`,
|
||||
step: "building",
|
||||
});
|
||||
const failureReport: ValidationReport =
|
||||
e.validationReport || {
|
||||
errors: [
|
||||
{ code: "E_BUILD_FAIL", message: e.message, category: "build" },
|
||||
],
|
||||
warnings: [],
|
||||
missingMedia: [],
|
||||
routeCollisions: [],
|
||||
htmlPolicyViolations: [],
|
||||
brokenLinks: [],
|
||||
summary: { totalErrors: 1, totalWarnings: 0, passed: false },
|
||||
};
|
||||
const failedBuild: BuildRecord = {
|
||||
id: buildId,
|
||||
buildNumber: nextBuildNum,
|
||||
timestamp,
|
||||
durationMs: Date.now() - buildStartedAt,
|
||||
status: "failed",
|
||||
siteDefCommit: "local",
|
||||
sourceCommits,
|
||||
themeVersion: this.themeConfig.version,
|
||||
builderVersion: BUILDER_VERSION,
|
||||
generatedRoutesCount: 0,
|
||||
mediaCount: 0,
|
||||
artifactChecksum: "",
|
||||
artifactSizeBytes: 0,
|
||||
validationReport: failureReport,
|
||||
logs,
|
||||
stages: [
|
||||
{ name: "Triggered", status: "succeeded", durationMs: 10 },
|
||||
{
|
||||
name: "Repository discovery",
|
||||
status: "succeeded",
|
||||
durationMs: 150,
|
||||
},
|
||||
{
|
||||
name: "Repository synchronization",
|
||||
status: "succeeded",
|
||||
durationMs: 450,
|
||||
},
|
||||
{
|
||||
name: "Metadata validation",
|
||||
status: failureReport.summary.passed ? "succeeded" : "failed",
|
||||
durationMs: 120,
|
||||
},
|
||||
{
|
||||
name: "Artifact discovery",
|
||||
status: failureReport.summary.passed ? "succeeded" : "skipped",
|
||||
durationMs: failureReport.summary.passed ? 310 : undefined,
|
||||
},
|
||||
{
|
||||
name: "Website projection build",
|
||||
status: failureReport.summary.passed ? "failed" : "skipped",
|
||||
durationMs: failureReport.summary.passed ? 150 : undefined,
|
||||
},
|
||||
{ name: "Local staging", status: "skipped" },
|
||||
],
|
||||
isStaged: false,
|
||||
isActiveLocal: false,
|
||||
const input = loader.load(runId);
|
||||
this.applyResolvedInput(input);
|
||||
logs.push({ timestamp: new Date().toISOString(), level: "success", message: `Resolved site definition ${input.siteDefinition.commit}.`, step: "resolving-sources" });
|
||||
const result = this.buildEngine.build(input);
|
||||
if (!result.success) throw Object.assign(new Error(`Validation failed with ${result.validationReport.summary.totalErrors} error(s).`), { validationReport: result.validationReport });
|
||||
logs.push({ timestamp: new Date().toISOString(), level: "success", message: `Published immutable artifact ${input.artifactBuildId}.`, step: "built" });
|
||||
const record: BuildRecord = {
|
||||
id: runId, artifactBuildId: input.artifactBuildId, buildNumber: number, timestamp, durationMs: Date.now() - startedAt,
|
||||
status: "built", siteDefCommit: input.siteDefinition.commit,
|
||||
sourceCommits: Object.fromEntries([...input.sources.entries()].map(([id, source]) => [id, source.commit])),
|
||||
themeVersion: input.theme.manifest.version, builderVersion: BUILDER_VERSION, generatedRoutesCount: result.generatedRoutesCount,
|
||||
mediaCount: input.mediaAssets.length, artifactChecksum: result.artifactChecksum, artifactSizeBytes: result.artifactSizeBytes,
|
||||
validationReport: result.validationReport, logs,
|
||||
stages: [{ name: "Repository snapshots", status: "succeeded" }, { name: "Theme validation", status: "succeeded" }, { name: "Artifact rendering", status: "succeeded" }, { name: "Local staging", status: "pending" }],
|
||||
isStaged: false, isActiveLocal: false,
|
||||
};
|
||||
this.builds.unshift(failedBuild);
|
||||
return failedBuild;
|
||||
}
|
||||
|
||||
const newBuild: BuildRecord = {
|
||||
id: buildId,
|
||||
buildNumber: nextBuildNum,
|
||||
timestamp,
|
||||
durationMs: Date.now() - buildStartedAt,
|
||||
status: "built",
|
||||
siteDefCommit: "local",
|
||||
sourceCommits,
|
||||
themeVersion: this.themeConfig.version,
|
||||
builderVersion: BUILDER_VERSION,
|
||||
generatedRoutesCount: engineResult.generatedRoutesCount,
|
||||
mediaCount: this.mediaAssets.length,
|
||||
artifactChecksum: engineResult.artifactChecksum,
|
||||
artifactSizeBytes: engineResult.artifactSizeBytes,
|
||||
validationReport,
|
||||
logs,
|
||||
stages: [
|
||||
{ name: "Triggered", status: "succeeded", durationMs: 10 },
|
||||
{ name: "Repository discovery", status: "succeeded", durationMs: 150 },
|
||||
{
|
||||
name: "Repository synchronization",
|
||||
status: "succeeded",
|
||||
durationMs: 450,
|
||||
},
|
||||
{ name: "Metadata validation", status: "succeeded", durationMs: 120 },
|
||||
{ name: "Artifact discovery", status: "succeeded", durationMs: 310 },
|
||||
{
|
||||
name: "Website projection build",
|
||||
status: "succeeded",
|
||||
durationMs: 980,
|
||||
},
|
||||
{ name: "Local staging", status: "succeeded", durationMs: 50 },
|
||||
],
|
||||
isStaged: false,
|
||||
isActiveLocal: false,
|
||||
};
|
||||
|
||||
// Register the build before policy hooks so automatic activation can resolve it.
|
||||
this.builds.unshift(newBuild);
|
||||
|
||||
// Auto staging if policy requires
|
||||
if (this.siteConfig.buildPolicy.staging.enabled) {
|
||||
newBuild.status = "staged";
|
||||
newBuild.isStaged = true;
|
||||
this.nginxStatus.stagingReleaseId = buildId;
|
||||
logs.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
level: "info",
|
||||
message: `Exposed candidate build ${buildId} through staging server block (preview.labyricorn.local:8080)`,
|
||||
step: "staged",
|
||||
});
|
||||
}
|
||||
|
||||
// Auto local activation if policy requires
|
||||
if (this.siteConfig.buildPolicy.localActivation.automatic) {
|
||||
this.activateReleaseLocally(buildId);
|
||||
}
|
||||
|
||||
this.addAudit(
|
||||
`Triggered Build #${nextBuildNum}`,
|
||||
"build",
|
||||
`Successfully built static release ${buildId} (${engineResult.artifactChecksum.substring(0, 20)})`,
|
||||
);
|
||||
return newBuild;
|
||||
}
|
||||
|
||||
activateReleaseLocally(buildId: string): {
|
||||
success: boolean;
|
||||
message: string;
|
||||
} {
|
||||
const build = this.builds.find((b) => b.id === buildId);
|
||||
if (!build)
|
||||
return { success: false, message: `Build ${buildId} not found.` };
|
||||
if (build.status === "failed")
|
||||
return { success: false, message: `Cannot activate a failed build.` };
|
||||
|
||||
try {
|
||||
this.buildEngine.activate(buildId);
|
||||
this.builds.unshift(record);
|
||||
if (input.siteConfig.buildPolicy.staging.enabled) {
|
||||
this.buildEngine.stage(input.artifactBuildId);
|
||||
record.status = "staged"; record.isStaged = true; record.stages[3].status = "succeeded";
|
||||
this.nginxStatus.stagingReleaseId = input.artifactBuildId;
|
||||
}
|
||||
this.addAudit(`Triggered Build #${number}`, "build", `Built ${input.artifactBuildId} from ${input.siteDefinition.commit}.`);
|
||||
return record;
|
||||
} catch (error) {
|
||||
const report: ValidationReport = (error as Error & { validationReport?: ValidationReport }).validationReport ?? {
|
||||
...emptyReport(), errors: [{ code: "E_BUILD_FAIL", message: error instanceof Error ? error.message : String(error), category: "build" }],
|
||||
summary: { totalErrors: 1, totalWarnings: 0, passed: false },
|
||||
};
|
||||
const failed: BuildRecord = {
|
||||
id: runId, buildNumber: number, timestamp, durationMs: Date.now() - startedAt, status: "failed",
|
||||
siteDefCommit: this.siteConfig.commit ?? "unresolved", sourceCommits: {}, themeVersion: this.themeConfig.version,
|
||||
builderVersion: BUILDER_VERSION, generatedRoutesCount: 0, mediaCount: 0, artifactChecksum: "", artifactSizeBytes: 0,
|
||||
validationReport: report, logs: [...logs, { timestamp: new Date().toISOString(), level: "error", message: report.errors.map((item) => item.message).join("; "), step: "failed" }],
|
||||
stages: [{ name: "Repository snapshots", status: "failed" }, { name: "Theme validation", status: "skipped" }, { name: "Artifact rendering", status: "skipped" }, { name: "Local staging", status: "skipped" }],
|
||||
isStaged: false, isActiveLocal: false,
|
||||
};
|
||||
this.builds.unshift(failed); this.addAudit(`Triggered Build #${number}`, "build", report.errors[0]?.message ?? "Build failed", "failure"); return failed;
|
||||
} finally { loader.dispose(); }
|
||||
}
|
||||
|
||||
activateReleaseLocally(buildId: string): { success: boolean; message: string } {
|
||||
const build = this.builds.find((item) => item.id === buildId || item.artifactBuildId === buildId);
|
||||
if (!build?.artifactBuildId || build.status === "failed") return { success: false, message: `Build '${buildId}' is not activatable.` };
|
||||
try {
|
||||
this.buildEngine.activate(build.artifactBuildId);
|
||||
for (const item of this.builds) { item.isActiveLocal = false; if (item.status === "active-local") item.status = "built"; }
|
||||
build.isActiveLocal = true; build.status = "active-local";
|
||||
this.nginxStatus.activeReleaseId = build.artifactBuildId; this.nginxStatus.lastReloadTime = new Date().toISOString(); this.nginxStatus.lastHealthCheckPassed = true;
|
||||
this.addAudit("Activate Local Release", "nginx", `current -> releases/${build.artifactBuildId}`);
|
||||
return { success: true, message: `Activated immutable release ${build.artifactBuildId}.` };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
this.nginxStatus.lastHealthCheckPassed = false;
|
||||
this.addAudit(
|
||||
"Activate Local Release Failed",
|
||||
"nginx",
|
||||
message,
|
||||
"failure",
|
||||
);
|
||||
return { success: false, message };
|
||||
return { success: false, message: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
// Update in-memory state only after the release pointer changed successfully.
|
||||
for (const previousBuild of this.builds) {
|
||||
previousBuild.isActiveLocal = false;
|
||||
if (previousBuild.status === "active-local") previousBuild.status = "built";
|
||||
}
|
||||
|
||||
build.isActiveLocal = true;
|
||||
build.status = "active-local";
|
||||
this.nginxStatus.activeReleaseId = buildId;
|
||||
this.nginxStatus.lastReloadTime = new Date().toISOString();
|
||||
this.nginxStatus.lastHealthCheckPassed = true;
|
||||
|
||||
this.addAudit(
|
||||
"Activate Local Release",
|
||||
"nginx",
|
||||
`Updated Nginx symlink current -> releases/${buildId}`,
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
message: `Successfully updated Nginx 'current' symlink to ${buildId} and reloaded Nginx (nginx -t passed).`,
|
||||
private restoreReleasePointers(): void {
|
||||
const restore = (name: "staging" | "current"): string | null => {
|
||||
const pointer = path.join(this.buildEngine.outputRoot, name);
|
||||
try {
|
||||
const target = fs.realpathSync(pointer);
|
||||
const id = path.basename(target);
|
||||
this.buildEngine.verifyRelease(id);
|
||||
return id;
|
||||
} catch { return null; }
|
||||
};
|
||||
this.nginxStatus.stagingReleaseId = restore("staging");
|
||||
this.nginxStatus.activeReleaseId = restore("current");
|
||||
this.nginxStatus.lastHealthCheckPassed = Boolean(this.nginxStatus.activeReleaseId);
|
||||
}
|
||||
|
||||
getBuildRoot(): string {
|
||||
return this.buildEngine.outputRoot;
|
||||
getBuildRoot(): string { return this.buildEngine.outputRoot; }
|
||||
getCurrentBuildDirectory(): string { return this.buildEngine.currentDirectory(); }
|
||||
getPreviewDirectory(): string | null {
|
||||
if (fs.existsSync(this.buildEngine.currentDirectory())) return this.buildEngine.currentDirectory();
|
||||
if (fs.existsSync(this.buildEngine.stagingDirectory())) return this.buildEngine.stagingDirectory();
|
||||
return null;
|
||||
}
|
||||
|
||||
getCurrentBuildDirectory(): string {
|
||||
return this.buildEngine.currentDirectory();
|
||||
}
|
||||
|
||||
pushToTarget(
|
||||
targetId: string,
|
||||
buildId: string,
|
||||
): { success: boolean; logs: string[] } {
|
||||
const target = this.pushTargets.find((t) => t.id === targetId);
|
||||
const build = this.builds.find((b) => b.id === buildId);
|
||||
if (!target)
|
||||
return { success: false, logs: [`Target ${targetId} not found`] };
|
||||
if (!build) return { success: false, logs: [`Build ${buildId} not found`] };
|
||||
|
||||
target.status = "uploading";
|
||||
const logs: string[] = [
|
||||
`[${new Date().toLocaleTimeString()}] Establishing SSH connection to ${target.host}:22 using ${target.credential}...`,
|
||||
`[${new Date().toLocaleTimeString()}] SSH connection established. Target path: ${target.remotePath}`,
|
||||
`[${new Date().toLocaleTimeString()}] Preparing remote directory ${target.remotePath}/${buildId}...`,
|
||||
`[${new Date().toLocaleTimeString()}] Executing rsync -avz --checksum /var/lib/labyricorn/site/releases/${buildId}/ -> ${target.host}:${target.remotePath}/${buildId}/`,
|
||||
`[${new Date().toLocaleTimeString()}] Uploaded 1.25 MB in 1.4s. Verifying checksum...`,
|
||||
`[${new Date().toLocaleTimeString()}] Updating remote symlink ${target.currentLink} -> ${target.remotePath}/${buildId}`,
|
||||
`[${new Date().toLocaleTimeString()}] Verifying deployed build-manifest.json on remote VPS...`,
|
||||
`[${new Date().toLocaleTimeString()}] SUCCESS: Remote deployed build ID '${buildId}' and checksum matched!`,
|
||||
];
|
||||
|
||||
target.status = "verified";
|
||||
target.lastDeployedBuildId = buildId;
|
||||
target.lastPushedAt = new Date().toISOString();
|
||||
target.remoteChecksum = build.artifactChecksum;
|
||||
target.lastLogs = logs;
|
||||
|
||||
this.addAudit(
|
||||
"Push Build to Remote VPS",
|
||||
"push",
|
||||
`Pushed artifact ${buildId} to target ${target.name} (${target.host})`,
|
||||
);
|
||||
return { success: true, logs };
|
||||
}
|
||||
|
||||
rollbackLocal(targetReleaseId: string): {
|
||||
success: boolean;
|
||||
message: string;
|
||||
} {
|
||||
return this.activateReleaseLocally(targetReleaseId);
|
||||
pushToTarget(targetId: string, buildId: string): { success: boolean; logs: string[] } {
|
||||
const target = this.pushTargets.find((item) => item.id === targetId);
|
||||
const build = this.builds.find((item) => item.id === buildId);
|
||||
if (!target || !build?.artifactBuildId) return { success: false, logs: ["Target or verified build not found."] };
|
||||
this.buildEngine.verifyRelease(build.artifactBuildId);
|
||||
return { success: false, logs: ["Remote publication is not configured for this target."] };
|
||||
}
|
||||
rollbackLocal(targetReleaseId: string): { success: boolean; message: string } { return this.activateReleaseLocally(targetReleaseId); }
|
||||
}
|
||||
|
||||
// Global Store Instance
|
||||
export const store = new LabyricornStore();
|
||||
|
||||
@@ -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._/-]*$" }
|
||||
}
|
||||
}
|
||||
@@ -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" } }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -35,8 +35,6 @@ export const ConfigAndAuditTab: React.FC<ConfigAndAuditTabProps> = ({
|
||||
const [activeSubTab, setActiveSubTab] = useState<
|
||||
"credentials" | "audit" | "yaml"
|
||||
>("yaml");
|
||||
const [yamlContent, setYamlContent] = useState(siteConfig.rawYaml);
|
||||
const [yamlSavedMsg, setYamlSavedMsg] = useState<string | null>(null);
|
||||
|
||||
// New Credential Form
|
||||
const [showAddCredModal, setShowAddCredModal] = useState(false);
|
||||
@@ -44,18 +42,6 @@ export const ConfigAndAuditTab: React.FC<ConfigAndAuditTabProps> = ({
|
||||
const [credType, setCredType] = useState<CredentialRef["type"]>("ssh_key");
|
||||
const [credVal, setCredVal] = useState("");
|
||||
|
||||
const handleSaveYaml = async () => {
|
||||
try {
|
||||
await onSaveSiteConfig(yamlContent);
|
||||
setYamlSavedMsg(
|
||||
"Updated site.yaml successfully! Created Git commit on site-definition repository.",
|
||||
);
|
||||
setTimeout(() => setYamlSavedMsg(null), 4000);
|
||||
} catch (err: any) {
|
||||
alert(`YAML Save Error: ${err.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateCred = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!credName || !credVal) return;
|
||||
@@ -79,7 +65,7 @@ export const ConfigAndAuditTab: React.FC<ConfigAndAuditTabProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-400 mt-1">
|
||||
Manage global site.yaml settings, secret credential references, and
|
||||
Inspect Git-owned site configuration, manage credential references, and
|
||||
view system audit logs.
|
||||
</p>
|
||||
</div>
|
||||
@@ -96,7 +82,7 @@ export const ConfigAndAuditTab: React.FC<ConfigAndAuditTabProps> = ({
|
||||
}`}
|
||||
>
|
||||
<FileCode className="w-4 h-4" />
|
||||
<span>Declarative site.yaml (Git Commits)</span>
|
||||
<span>Declarative site.yml (Read-only)</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -126,12 +112,10 @@ export const ConfigAndAuditTab: React.FC<ConfigAndAuditTabProps> = ({
|
||||
|
||||
{activeSubTab === "yaml" ? (
|
||||
<div className="space-y-4">
|
||||
{yamlSavedMsg && (
|
||||
<div className="p-4 bg-emerald-950/40 border border-emerald-500/20 text-emerald-300 text-xs rounded-sm flex items-center space-x-2">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||
<span>{yamlSavedMsg}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-4 bg-amber-950/30 border border-amber-500/20 text-amber-200 text-xs rounded-sm">
|
||||
Configuration is read-only in v1. Changes must be reviewed and committed in the source repository.
|
||||
{siteConfig.commit && <span className="block mt-1 font-mono text-amber-300">Resolved commit: {siteConfig.commit}</span>}
|
||||
</div>
|
||||
|
||||
<div className="bg-[#111111] rounded-sm p-6 border border-[#1F1F1F] space-y-4">
|
||||
<div className="flex items-center justify-between text-white border-b border-[#1F1F1F] pb-3">
|
||||
@@ -143,20 +127,15 @@ export const ConfigAndAuditTab: React.FC<ConfigAndAuditTabProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleSaveYaml}
|
||||
className="px-4 py-2 bg-[#C5A059] hover:bg-[#b38f4a] text-black rounded-sm text-xs font-semibold shadow-sm transition-all flex items-center space-x-1.5 uppercase tracking-wider"
|
||||
>
|
||||
<GitCommit className="w-3.5 h-3.5" />
|
||||
<span>Save & Commit to Git</span>
|
||||
</button>
|
||||
<span className="px-3 py-1.5 border border-[#262626] text-neutral-400 text-[10px] uppercase tracking-wider">Git-owned · Read-only</span>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
rows={16}
|
||||
value={yamlContent}
|
||||
onChange={(e) => setYamlContent(e.target.value)}
|
||||
className="w-full bg-[#0A0A0A] text-[#C5A059] font-mono text-xs p-4 rounded-sm border border-[#1F1F1F] focus:outline-none focus:border-[#C5A059]"
|
||||
value={siteConfig.rawYaml}
|
||||
readOnly
|
||||
aria-label="Resolved site configuration"
|
||||
className="w-full bg-[#0A0A0A] text-[#C5A059] font-mono text-xs p-4 rounded-sm border border-[#1F1F1F] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -27,9 +27,7 @@ export const ThemeTab: React.FC<ThemeTabProps> = ({
|
||||
const handleValidate = async () => {
|
||||
setIsValidating(true);
|
||||
await onValidateTheme();
|
||||
setStatusMsg(
|
||||
"Theme /.theme validated successfully. All required templates, CSS resets, and package supports are valid.",
|
||||
);
|
||||
setStatusMsg("Validation finished. The status below is tied to the displayed commit.");
|
||||
setIsValidating(false);
|
||||
};
|
||||
|
||||
@@ -43,13 +41,13 @@ export const ThemeTab: React.FC<ThemeTabProps> = ({
|
||||
Theme Management
|
||||
</h1>
|
||||
<span className="px-2.5 py-0.5 rounded-xs text-[10px] uppercase font-bold tracking-widest bg-[#C5A059]/10 text-[#C5A059] border border-[#C5A059]/30">
|
||||
PRD AC-10 Standard Location: /.theme
|
||||
Repository Theme · .theme
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-400 mt-1">
|
||||
Loads active theme from{" "}
|
||||
<code className="text-[#C5A059] font-mono">/.theme</code>, validates{" "}
|
||||
<code className="text-neutral-300 font-mono">theme.yaml</code>,
|
||||
Resolves the active theme from{" "}
|
||||
<code className="text-[#C5A059] font-mono">.theme</code> at an exact commit, validates{" "}
|
||||
<code className="text-neutral-300 font-mono">theme.yml</code>,
|
||||
templates, assets, and supported package layouts.
|
||||
</p>
|
||||
</div>
|
||||
@@ -75,6 +73,13 @@ export const ThemeTab: React.FC<ThemeTabProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={`p-4 border rounded-sm text-xs ${theme.status === "valid" ? "bg-emerald-950/30 border-emerald-500/20 text-emerald-300" : theme.status === "invalid" ? "bg-red-950/30 border-red-500/20 text-red-300" : "bg-amber-950/30 border-amber-500/20 text-amber-200"}`}>
|
||||
<span className="font-bold uppercase tracking-wider">{theme.status === "valid" ? "Valid at commit" : theme.status === "invalid" ? "Invalid" : "Not resolved"}</span>
|
||||
{theme.commit && <code className="block mt-1 break-all">{theme.commit}</code>}
|
||||
{theme.repository && <span className="block mt-1 text-neutral-400 break-all">{theme.repository}</span>}
|
||||
{theme.validationErrors.map((error) => <span key={error} className="block mt-1">{error}</span>)}
|
||||
</div>
|
||||
|
||||
{/* THEME STRUCTURE GRID */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
{/* LEFT COLUMN: THEME METADATA & MANIFEST (5 COLS) */}
|
||||
@@ -106,6 +111,11 @@ export const ThemeTab: React.FC<ThemeTabProps> = ({
|
||||
<span className="text-white font-semibold">{theme.id}</span>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-[#0A0A0A] rounded-sm border border-[#1F1F1F] space-y-1 font-mono">
|
||||
<span className="text-[10px] text-neutral-500 font-bold uppercase tracking-widest block">Manifest checksum</span>
|
||||
<span className="text-white text-[10px] break-all">{theme.manifestChecksum ?? "Not resolved"}</span>
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-[#0A0A0A] rounded-sm border border-[#1F1F1F] space-y-2">
|
||||
<span className="text-[10px] text-neutral-500 font-bold uppercase tracking-widest block font-mono">
|
||||
Supported Style Packages ({theme.supportsPackages.length})
|
||||
|
||||
+135
@@ -120,6 +120,13 @@ export interface ThemeConfig {
|
||||
supportsPackages: string[];
|
||||
isValidated: boolean;
|
||||
validationErrors: string[];
|
||||
status: "unresolved" | "invalid" | "valid";
|
||||
protocol?: "labyricorn-theme/v1";
|
||||
sourceId?: string;
|
||||
repository?: string;
|
||||
commit?: string;
|
||||
manifestChecksum?: string;
|
||||
checksums?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ValidationReport {
|
||||
@@ -155,6 +162,7 @@ export interface BuildLogEntry {
|
||||
|
||||
export interface BuildRecord {
|
||||
id: string;
|
||||
artifactBuildId?: string;
|
||||
buildNumber: number;
|
||||
timestamp: string;
|
||||
durationMs: number;
|
||||
@@ -248,6 +256,9 @@ export interface NavigationEntry {
|
||||
iconName: string;
|
||||
contentModel?: string;
|
||||
styleConfig?: string;
|
||||
presentation?: {
|
||||
sectionTemplate?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ContentModelSemantics {
|
||||
@@ -291,6 +302,7 @@ export interface SiteConfig {
|
||||
styleInstancesPath: string;
|
||||
pagesPath: string;
|
||||
navigationFile: string;
|
||||
projectsFile?: string;
|
||||
pushIntegrationsPath: string;
|
||||
theme: {
|
||||
source: string;
|
||||
@@ -342,4 +354,127 @@ export interface SiteConfig {
|
||||
};
|
||||
};
|
||||
rawYaml: string;
|
||||
sourceId?: string;
|
||||
repository?: string;
|
||||
commit?: string;
|
||||
readOnly?: true;
|
||||
}
|
||||
|
||||
export interface RepositorySnapshot {
|
||||
sourceId: string;
|
||||
repository: string;
|
||||
commit: string;
|
||||
checkoutRoot: string;
|
||||
committedAt: string;
|
||||
}
|
||||
|
||||
export interface ThemeFontGitSource {
|
||||
kind: "git";
|
||||
sourceId: string;
|
||||
path: string;
|
||||
format: "woff" | "woff2" | "ttf" | "otf";
|
||||
}
|
||||
|
||||
export interface ThemeFontHttpsSource {
|
||||
kind: "https";
|
||||
url: string;
|
||||
checksum: `sha256-${string}`;
|
||||
format: "woff" | "woff2" | "ttf" | "otf";
|
||||
}
|
||||
|
||||
export interface ThemeFont {
|
||||
id: string;
|
||||
family: string;
|
||||
style: "normal" | "italic" | "oblique";
|
||||
weight: number;
|
||||
source: ThemeFontGitSource | ThemeFontHttpsSource;
|
||||
}
|
||||
|
||||
export interface ThemeManifestV1 {
|
||||
protocol: "labyricorn-theme/v1";
|
||||
id: string;
|
||||
version: string;
|
||||
engine: "liquid";
|
||||
templates: {
|
||||
layout: string;
|
||||
home: string;
|
||||
notFound: string;
|
||||
sections?: Record<string, string>;
|
||||
content?: Record<string, string>;
|
||||
};
|
||||
assets: {
|
||||
styles: string[];
|
||||
scripts: string[];
|
||||
files: string[];
|
||||
fonts: ThemeFont[];
|
||||
};
|
||||
security: {
|
||||
allowScripts: boolean;
|
||||
allowExternalAssets: boolean;
|
||||
allowedExternalOrigins: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface LoadedTheme {
|
||||
manifest: ThemeManifestV1;
|
||||
root: string;
|
||||
snapshot: RepositorySnapshot;
|
||||
manifestChecksum: string;
|
||||
checksums: Readonly<Record<string, string>>;
|
||||
templateSources: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
export interface ProjectRelationship {
|
||||
contentModel: string;
|
||||
matchField: `metadata.${string}`;
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
export interface PublishedFile {
|
||||
source: string;
|
||||
route: string;
|
||||
mediaType: string;
|
||||
}
|
||||
|
||||
export interface ProjectDeclaration {
|
||||
id: string;
|
||||
source: string;
|
||||
route: string;
|
||||
title?: string;
|
||||
summary?: string;
|
||||
version?: string;
|
||||
license?: string;
|
||||
homepage?: string;
|
||||
tags?: string[];
|
||||
stack?: string[];
|
||||
presentation: { detailTemplate: string };
|
||||
relationships?: Record<string, ProjectRelationship>;
|
||||
publishedFiles?: PublishedFile[];
|
||||
}
|
||||
|
||||
export interface ResolvedProject extends ProjectDeclaration {
|
||||
sourceSnapshot: RepositorySnapshot;
|
||||
relatedContent: Readonly<Record<string, readonly ContentItem[]>>;
|
||||
}
|
||||
|
||||
export interface ResolvedPublishedFile extends PublishedFile {
|
||||
projectId: string;
|
||||
sourceSnapshot: RepositorySnapshot;
|
||||
sourcePath: string;
|
||||
sourceChecksum: string;
|
||||
}
|
||||
|
||||
export interface ResolvedBuildInput {
|
||||
runId: string;
|
||||
artifactBuildId: string;
|
||||
generatedAt: string;
|
||||
siteDefinition: RepositorySnapshot;
|
||||
sources: ReadonlyMap<string, RepositorySnapshot>;
|
||||
siteConfig: SiteConfig;
|
||||
theme: LoadedTheme;
|
||||
projects: readonly ResolvedProject[];
|
||||
contentItems: readonly ContentItem[];
|
||||
mediaAssets: readonly MediaAsset[];
|
||||
publishedFiles: readonly ResolvedPublishedFile[];
|
||||
inputChecksums: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user