Render repository content through theme contracts
This commit is contained in:
@@ -12,6 +12,9 @@ Environment=NODE_ENV=production
|
|||||||
Environment=HOST=127.0.0.1
|
Environment=HOST=127.0.0.1
|
||||||
Environment=PORT=3001
|
Environment=PORT=3001
|
||||||
Environment=LABYRICORN_BUILD_ROOT=/var/lib/website-engine/builds
|
Environment=LABYRICORN_BUILD_ROOT=/var/lib/website-engine/builds
|
||||||
|
Environment=LABYRICORN_SITE_DEFINITION_REPOSITORY=/var/lib/website-engine/repositories/labyricorn-site-git-repo
|
||||||
|
Environment=LABYRICORN_SITE_DEFINITION_REF=refs/heads/main
|
||||||
|
Environment=LABYRICORN_SITE_DEFINITION_PATH=.
|
||||||
ExecStart=/usr/bin/node /opt/website-engine-control-plane/dist/server.cjs
|
ExecStart=/usr/bin/node /opt/website-engine-control-plane/dist/server.cjs
|
||||||
Restart=on-failure
|
Restart=on-failure
|
||||||
RestartSec=5s
|
RestartSec=5s
|
||||||
|
|||||||
@@ -27,3 +27,17 @@ server {
|
|||||||
error_page 404 /404.html;
|
error_page 404 /404.html;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 8080 default_server;
|
||||||
|
listen [::]:8080 default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /var/lib/website-engine/builds/staging;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ =404;
|
||||||
|
error_page 404 /404.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
version: 1
|
||||||
|
enabled: true
|
||||||
|
artifacts:
|
||||||
|
content_paths:
|
||||||
|
- pages
|
||||||
|
- articles
|
||||||
|
- projects
|
||||||
|
media_paths: []
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<article class="content-grid">
|
<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>
|
<aside><h2>Contents</h2><ol>{% for heading in page.tableOfContents %}<li><a href="#{{ heading.id }}">{{ heading.text }}</a></li>{% endfor %}</ol></aside>
|
||||||
<div>
|
<div>
|
||||||
<p class="eyebrow">{{ item.artifactType | escape }}</p>
|
<p class="eyebrow">{{ page.contentModel }}</p>
|
||||||
<h1>{{ item.title | escape }}</h1>
|
<h1>{{ page.title }}</h1>
|
||||||
<p class="lede">{{ item.summary | escape }}</p>
|
<p class="lede">{{ page.summary }}</p>
|
||||||
<div class="prose">{{ item.content | safe_content }}</div>
|
<div class="prose">{{ page.renderedContent | safe_content }}</div>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -2,27 +2,22 @@
|
|||||||
<aside class="project-meta">
|
<aside class="project-meta">
|
||||||
<p class="eyebrow">Project</p>
|
<p class="eyebrow">Project</p>
|
||||||
<dl>
|
<dl>
|
||||||
<dt>Version</dt><dd>{{ project.version | escape }}</dd>
|
<dt>Version</dt><dd>{{ page.metadata.version }}</dd>
|
||||||
<dt>License</dt><dd>{{ project.license | escape }}</dd>
|
<dt>License</dt><dd>{{ page.metadata.license }}</dd>
|
||||||
<dt>Source commit</dt><dd><code>{{ project.sourceSnapshot.commit | escape }}</code></dd>
|
<dt>Source commit</dt><dd><code>{{ page.provenance.commit }}</code></dd>
|
||||||
<dt>Updated</dt><dd>{{ project.sourceSnapshot.committedAt | escape }}</dd>
|
<dt>Updated</dt><dd>{{ page.updated }}</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<h2>Stack</h2>
|
<h2>Stack</h2>
|
||||||
<ul>{% for item in project.stack %}<li>{{ item | escape }}</li>{% endfor %}</ul>
|
<ul>{% for item in page.metadata.stack %}<li>{{ item }}</li>{% endfor %}</ul>
|
||||||
</aside>
|
</aside>
|
||||||
<div class="project-body">
|
<div class="project-body">
|
||||||
<h1>{{ project.title | escape }}</h1>
|
<h1>{{ page.title }}</h1>
|
||||||
<p class="lede">{{ project.summary | escape }}</p>
|
<p class="lede">{{ page.summary }}</p>
|
||||||
<p><a href="{{ project.homepage | escape }}">Open the standalone demo</a></p>
|
<div class="prose">{{ page.renderedContent | safe_content }}</div>
|
||||||
<h2 id="provenance">Pinned provenance</h2>
|
<p><a href="{{ page.metadata.homepage }}">Open the standalone demo</a></p>
|
||||||
<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>
|
<h2 id="tags">Tags</h2>
|
||||||
<ul class="tags">{% for tag in project.tags %}<li>{{ tag | escape }}</li>{% endfor %}</ul>
|
<ul class="tags">{% for tag in page.tags %}<li>{{ tag }}</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>
|
<h2 id="articles">Related articles</h2>
|
||||||
{% if project.relatedContent.articles.size == 0 %}<p>No matching articles in this snapshot.</p>{% endif %}
|
{% for item in page.relationships.articles %}<article><h3><a href="{{ item.route }}">{{ item.title }}</a></h3></article>{% endfor %}
|
||||||
{% for item in project.relatedContent.articles %}<article><h3><a href="{{ item.route | escape }}">{{ item.title | escape }}</a></h3></article>{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<section class="hero">
|
<section class="hero">
|
||||||
<p class="eyebrow">Engineering · Architecture · Operations</p>
|
<p class="eyebrow">Engineering · Architecture · Operations</p>
|
||||||
<h1>{{ site.title | escape }}</h1>
|
<h1>{{ page.title }}</h1>
|
||||||
<p>Repository-owned presentation rendered from an exact Git commit.</p>
|
<p>{{ page.summary }}</p>
|
||||||
|
<div class="prose">{{ page.renderedContent | safe_content }}</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="feed" aria-labelledby="latest">
|
<section class="feed" aria-labelledby="latest">
|
||||||
<h2 id="latest">Latest publications</h2>
|
<h2 id="latest">Latest publications</h2>
|
||||||
{% if content.size == 0 %}<p>No published content in this snapshot.</p>{% endif %}
|
{% if collection.items.size == 0 %}<p>No published content in this snapshot.</p>{% endif %}
|
||||||
{% for item in content %}
|
{% for item in collection.items %}
|
||||||
<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>
|
<article><p class="eyebrow">{{ item.contentModel }}</p><h3><a href="{{ item.route }}">{{ item.title }}</a></h3><p>{{ item.summary }}</p></article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="{{ site.language | escape }}">
|
<html lang="{{ site.language }}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<meta name="generator" content="labyricorn-theme/v1">
|
<meta name="generator" content="labyricorn-theme/v1">
|
||||||
<title>{{ page.title | escape }} · {{ site.title | escape }}</title>
|
<title>{{ page.title }} · {{ site.title }}</title>
|
||||||
{% for stylesheet in theme.stylesheets %}<link rel="stylesheet" href="{{ stylesheet | escape }}">
|
{% for stylesheet in theme.styles %}<link rel="stylesheet" href="{{ stylesheet }}">
|
||||||
{% endfor %}{% for script in theme.scripts %}<script src="{{ script | escape }}" defer></script>
|
{% endfor %}{% for script in theme.scripts %}<script src="{{ script }}" defer></script>
|
||||||
{% endfor %}</head>
|
{% endfor %}</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="site-header">
|
<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>
|
<a class="brand" href="/"><img src="/assets/images/logo.svg" width="32" height="32" alt=""><span>{{ site.title }}</span></a>
|
||||||
<nav aria-label="Primary">
|
<nav aria-label="Primary">
|
||||||
{% for entry in navigation %}<a href="{{ entry.route | escape }}">{{ entry.label | escape }}</a>{% endfor %}
|
{% for entry in navigation %}<a href="{{ entry.route }}">{{ entry.label }}</a>{% endfor %}
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main>{{ page.body | safe_page_body }}</main>
|
<main>{{ page.body | safe_page_body }}</main>
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
<span>Built from immutable Git snapshots.</span>
|
<span>Built from immutable Git snapshots.</span>
|
||||||
<code>{{ build.id | escape }}</code>
|
<code>{{ build.id }}</code>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<header class="section-heading">
|
<header class="section-heading">
|
||||||
<p class="eyebrow">Section</p>
|
<p class="eyebrow">Section</p>
|
||||||
<h1>{{ section.label | escape }}</h1>
|
<h1>{{ page.title }}</h1>
|
||||||
</header>
|
</header>
|
||||||
<section class="feed">
|
<section class="feed">
|
||||||
{% if content.size == 0 %}<p>No published items in this snapshot.</p>{% endif %}
|
{% if collection.items.size == 0 %}<p>No published items in this snapshot.</p>{% endif %}
|
||||||
{% for item in content %}
|
{% for item in collection.items %}
|
||||||
<article><h2><a href="{{ item.route | escape }}">{{ item.title | escape }}</a></h2><p>{{ item.summary | escape }}</p></article>
|
<article><h2><a href="{{ item.route }}">{{ item.title }}</a></h2><p>{{ item.summary }}</p></article>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
model: article
|
||||||
|
title: Repository Rendering Is Live
|
||||||
|
slug: repository-rendering
|
||||||
|
route: /articles/repository-rendering/
|
||||||
|
date: 2026-07-23
|
||||||
|
status: published
|
||||||
|
summary: The reference article proves Markdown discovery and strict Liquid rendering.
|
||||||
|
tags:
|
||||||
|
- git
|
||||||
|
- liquid
|
||||||
|
metadata:
|
||||||
|
projectId: website-engine-control-plane
|
||||||
|
---
|
||||||
|
|
||||||
|
## Immutable inputs
|
||||||
|
|
||||||
|
Every build resolves one exact Git commit before rendering.
|
||||||
|
|
||||||
|
## Verified output
|
||||||
|
|
||||||
|
The generated release records source checksums and can be promoted atomically.
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
model: page
|
||||||
|
title: Labyricorn Engineering & Architecture
|
||||||
|
slug: home
|
||||||
|
route: /
|
||||||
|
status: published
|
||||||
|
summary: Deterministic publishing from immutable Git snapshots.
|
||||||
|
---
|
||||||
|
|
||||||
|
The control plane renders this homepage from committed Markdown and repository-owned Liquid templates.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
model: project
|
||||||
|
title: Website Engine Control Plane
|
||||||
|
slug: website-engine-control-plane
|
||||||
|
route: /project/website-engine-control-plane/
|
||||||
|
status: published
|
||||||
|
summary: A deterministic Git-native static site control plane.
|
||||||
|
tags:
|
||||||
|
- static-site
|
||||||
|
- git
|
||||||
|
- liquid
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration first
|
||||||
|
|
||||||
|
The project page joins its allowlisted declaration with this committed Markdown body.
|
||||||
@@ -49,7 +49,10 @@ test("reference repository theme renders declared routes and only declared asset
|
|||||||
const result = new BuildEngine(output).build(input);
|
const result = new BuildEngine(output).build(input);
|
||||||
assert.equal(result.success, true);
|
assert.equal(result.success, true);
|
||||||
assert.match(input.siteDefinition.commit, /^[0-9a-f]{40}$/);
|
assert.match(input.siteDefinition.commit, /^[0-9a-f]{40}$/);
|
||||||
|
assert.equal(input.contentItems.length, 3);
|
||||||
assert.ok(fs.existsSync(path.join(result.outputDirectory, "index.html")));
|
assert.ok(fs.existsSync(path.join(result.outputDirectory, "index.html")));
|
||||||
|
assert.ok(fs.existsSync(path.join(result.outputDirectory, "articles/index.html")));
|
||||||
|
assert.ok(fs.existsSync(path.join(result.outputDirectory, "articles/repository-rendering/index.html")));
|
||||||
assert.ok(fs.existsSync(path.join(result.outputDirectory, "projects/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/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, "project/website-engine-control-plane/demo.html")));
|
||||||
@@ -62,6 +65,9 @@ test("reference repository theme renders declared routes and only declared asset
|
|||||||
assert.equal(manifest.siteDefinition.commit, input.siteDefinition.commit);
|
assert.equal(manifest.siteDefinition.commit, input.siteDefinition.commit);
|
||||||
assert.equal(manifest.theme.commit, input.theme.snapshot.commit);
|
assert.equal(manifest.theme.commit, input.theme.snapshot.commit);
|
||||||
assert.equal(manifest.artifactBuildId, input.artifactBuildId);
|
assert.equal(manifest.artifactBuildId, input.artifactBuildId);
|
||||||
|
assert.match(fs.readFileSync(path.join(result.outputDirectory, "index.html"), "utf8"), /renders this homepage from committed Markdown/);
|
||||||
|
assert.match(fs.readFileSync(path.join(result.outputDirectory, "articles/repository-rendering/index.html"), "utf8"), /Every build resolves one exact Git commit/);
|
||||||
|
assert.match(fs.readFileSync(path.join(result.outputDirectory, "project/website-engine-control-plane/index.html"), "utf8"), /joins its allowlisted declaration/);
|
||||||
assert.doesNotMatch(fs.readFileSync(path.join(result.outputDirectory, "index.html"), "utf8"), /fonts\.googleapis|tailwindcss\.com/);
|
assert.doesNotMatch(fs.readFileSync(path.join(result.outputDirectory, "index.html"), "utf8"), /fonts\.googleapis|tailwindcss\.com/);
|
||||||
} finally { manager.dispose(); }
|
} finally { manager.dispose(); }
|
||||||
}));
|
}));
|
||||||
@@ -152,6 +158,21 @@ test("project declaration controls the project route and standalone publication"
|
|||||||
} finally { manager.dispose(); }
|
} finally { manager.dispose(); }
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
||||||
|
test("project declarations cannot silently shadow non-project content", () => withRepository((root, output) => {
|
||||||
|
fs.rmSync(path.join(root, "packages/site-definition/projects/control-plane.md"));
|
||||||
|
fs.writeFileSync(path.join(root, "packages/site-definition/projects/.gitkeep"), "");
|
||||||
|
const article = path.join(root, "packages/site-definition/articles/welcome.md");
|
||||||
|
fs.writeFileSync(article, fs.readFileSync(article, "utf8").replace("route: /articles/repository-rendering/", "route: /project/website-engine-control-plane/"));
|
||||||
|
commit(root, "create route collision");
|
||||||
|
const { manager, input } = load(root, "route-collision");
|
||||||
|
try {
|
||||||
|
const result = new BuildEngine(output).build(input);
|
||||||
|
assert.equal(result.success, false);
|
||||||
|
assert.ok(result.validationReport.errors.some((error) => error.code === "E_ROUTE_COLLISION"));
|
||||||
|
} finally { manager.dispose(); }
|
||||||
|
}));
|
||||||
|
|
||||||
test("declared standalone files are copied byte-for-byte", () => withRepository((root, output) => {
|
test("declared standalone files are copied byte-for-byte", () => withRepository((root, output) => {
|
||||||
const { manager, input } = load(root, "publication");
|
const { manager, input } = load(root, "publication");
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -5,14 +5,13 @@ import path from "node:path";
|
|||||||
import YAML from "yaml";
|
import YAML from "yaml";
|
||||||
import {
|
import {
|
||||||
ContentItem,
|
ContentItem,
|
||||||
ContentModel,
|
|
||||||
MediaAsset,
|
|
||||||
ProjectDeclaration,
|
ProjectDeclaration,
|
||||||
ResolvedBuildInput,
|
ResolvedBuildInput,
|
||||||
ResolvedProject,
|
ResolvedProject,
|
||||||
ResolvedPublishedFile,
|
ResolvedPublishedFile,
|
||||||
SiteConfig,
|
SiteConfig,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
import { loadRepositoryContent } from "./contentLoader";
|
||||||
import { RepositorySnapshotManager } from "./repositories/repositorySnapshot";
|
import { RepositorySnapshotManager } from "./repositories/repositorySnapshot";
|
||||||
import { canonicalPath, parseProjects, resolveRegularFile, ThemeLoader } from "./theme/themeLoader";
|
import { canonicalPath, parseProjects, resolveRegularFile, ThemeLoader } from "./theme/themeLoader";
|
||||||
|
|
||||||
@@ -32,6 +31,11 @@ export interface BuildInputLoaderOptions {
|
|||||||
builderVersion: string;
|
builderVersion: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizedRoute = (route: string): string => {
|
||||||
|
const parts = route.split("/").filter(Boolean);
|
||||||
|
return parts.length === 0 ? "/" : `/${parts.join("/")}/`;
|
||||||
|
};
|
||||||
|
|
||||||
export class BuildInputLoader {
|
export class BuildInputLoader {
|
||||||
private readonly snapshots: RepositorySnapshotManager;
|
private readonly snapshots: RepositorySnapshotManager;
|
||||||
private readonly repositoryRoot: string;
|
private readonly repositoryRoot: string;
|
||||||
@@ -53,9 +57,10 @@ export class BuildInputLoader {
|
|||||||
const configRoot = path.join(siteDefinition.checkoutRoot, ...this.siteDefinitionPath.split("/"));
|
const configRoot = path.join(siteDefinition.checkoutRoot, ...this.siteDefinitionPath.split("/"));
|
||||||
const inputChecksums: Record<string, string> = {};
|
const inputChecksums: Record<string, string> = {};
|
||||||
const readConfig = (relative: string): Buffer => {
|
const readConfig = (relative: string): Buffer => {
|
||||||
const file = resolveRegularFile(configRoot, relative, `configuration '${relative}'`, 2 * 1024 * 1024);
|
const canonical = canonicalPath(relative, "configuration path");
|
||||||
|
const file = resolveRegularFile(configRoot, canonical, `configuration '${relative}'`, 2 * 1024 * 1024);
|
||||||
const bytes = fs.readFileSync(file);
|
const bytes = fs.readFileSync(file);
|
||||||
inputChecksums[`${this.siteDefinitionPath}/${canonicalPath(relative, "configuration path")}`] = sha256(bytes);
|
inputChecksums[path.posix.join(this.siteDefinitionPath, canonical)] = sha256(bytes);
|
||||||
return bytes;
|
return bytes;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -64,26 +69,16 @@ export class BuildInputLoader {
|
|||||||
if (siteDocument.protocol !== "labyricorn-site/v1") throw new Error("E_SITE_CONFIG_INVALID: unsupported site protocol.");
|
if (siteDocument.protocol !== "labyricorn-site/v1") throw new Error("E_SITE_CONFIG_INVALID: unsupported site protocol.");
|
||||||
const navigationRelative = siteDocument.navigationFile ?? "navigation.yml";
|
const navigationRelative = siteDocument.navigationFile ?? "navigation.yml";
|
||||||
const navigationDocument = YAML.parse(readConfig(navigationRelative).toString("utf8"));
|
const navigationDocument = YAML.parse(readConfig(navigationRelative).toString("utf8"));
|
||||||
|
const loadedContent = loadRepositoryContent(siteDefinition, configRoot, siteDocument, inputChecksums);
|
||||||
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 = {
|
const siteConfig: SiteConfig = {
|
||||||
protocol: siteDocument.protocol,
|
protocol: siteDocument.protocol,
|
||||||
site: siteDocument.site,
|
site: siteDocument.site,
|
||||||
navigation: navigationDocument.navigation ?? [],
|
navigation: navigationDocument.navigation ?? [],
|
||||||
contentModels,
|
contentModels: loadedContent.contentModels,
|
||||||
|
contentModelsPath: loadedContent.contentModelsPath,
|
||||||
sourcesFile: siteDocument.sourcesFile ?? "sources.yml",
|
sourcesFile: siteDocument.sourcesFile ?? "sources.yml",
|
||||||
styleInstancesPath: siteDocument.styleInstancesPath ?? "../style-configs",
|
styleInstancesPath: siteDocument.styleInstancesPath ?? "style-configs",
|
||||||
pagesPath: siteDocument.pagesPath ?? "pages",
|
pagesPath: siteDocument.pagesPath ?? "pages",
|
||||||
navigationFile: navigationRelative,
|
navigationFile: navigationRelative,
|
||||||
projectsFile: siteDocument.projectsFile,
|
projectsFile: siteDocument.projectsFile,
|
||||||
@@ -100,20 +95,32 @@ export class BuildInputLoader {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let declarations: ProjectDeclaration[] = [];
|
let declarations: ProjectDeclaration[] = [];
|
||||||
if (siteConfig.projectsFile) {
|
if (siteConfig.projectsFile) declarations = parseProjects(readConfig(siteConfig.projectsFile));
|
||||||
const bytes = readConfig(siteConfig.projectsFile);
|
const contentItems = loadedContent.contentItems;
|
||||||
declarations = parseProjects(bytes);
|
const mediaAssets = loadedContent.mediaAssets;
|
||||||
}
|
const declaredProjectRoutes = new Set(declarations.map((project) => normalizedRoute(project.route)));
|
||||||
const requiredSections = siteConfig.navigation.filter((entry) => entry.route !== "/").map((entry) => entry.presentation?.sectionTemplate ?? entry.contentModel).filter((value): value is string => Boolean(value));
|
const projectContentByRoute = new Map(
|
||||||
const requiredContent = declarations.map((project) => project.presentation.detailTemplate);
|
contentItems
|
||||||
|
.filter((item) => item.artifactType === "project" && item.status === "published")
|
||||||
|
.map((item) => [normalizedRoute(item.route), item]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const requiredSections = siteConfig.navigation
|
||||||
|
.filter((entry) => entry.route !== "/")
|
||||||
|
.map((entry) => entry.presentation?.sectionTemplate ?? entry.contentModel)
|
||||||
|
.filter((value): value is string => Boolean(value));
|
||||||
|
const requiredContent = [...new Set([
|
||||||
|
...declarations.map((project) => project.presentation.detailTemplate),
|
||||||
|
...contentItems
|
||||||
|
.filter((item) => item.status === "published" && item.route !== "/" && item.artifactType !== "project")
|
||||||
|
.map((item) => item.presentation?.standalone ?? item.artifactType),
|
||||||
|
])];
|
||||||
const themeSnapshot = sourceMap.get(siteConfig.theme.source);
|
const themeSnapshot = sourceMap.get(siteConfig.theme.source);
|
||||||
if (!themeSnapshot) throw new Error(`E_THEME_SOURCE: configured source '${siteConfig.theme.source}' is unresolved.`);
|
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 themeRoot = path.posix.join(this.siteDefinitionPath, siteConfig.theme.path);
|
||||||
const theme = new ThemeLoader().load(themeSnapshot, themeRoot, requiredSections, requiredContent);
|
const theme = new ThemeLoader().load(themeSnapshot, themeRoot, requiredSections, requiredContent);
|
||||||
for (const [relative, checksum] of Object.entries(theme.checksums)) inputChecksums[`${themeRoot}/${relative}`] = checksum;
|
for (const [relative, checksum] of Object.entries(theme.checksums)) inputChecksums[path.posix.join(themeRoot, relative)] = checksum;
|
||||||
|
|
||||||
const contentItems: ContentItem[] = [];
|
|
||||||
const mediaAssets: MediaAsset[] = [];
|
|
||||||
const projects: ResolvedProject[] = [];
|
const projects: ResolvedProject[] = [];
|
||||||
const publishedFiles: ResolvedPublishedFile[] = [];
|
const publishedFiles: ResolvedPublishedFile[] = [];
|
||||||
for (const project of declarations) {
|
for (const project of declarations) {
|
||||||
@@ -121,11 +128,21 @@ export class BuildInputLoader {
|
|||||||
if (!sourceSnapshot) throw new Error(`E_PROJECT_SOURCE: project '${project.id}' uses unresolved source '${project.source}'.`);
|
if (!sourceSnapshot) throw new Error(`E_PROJECT_SOURCE: project '${project.id}' uses unresolved source '${project.source}'.`);
|
||||||
const relatedContent: Record<string, readonly ContentItem[]> = {};
|
const relatedContent: Record<string, readonly ContentItem[]> = {};
|
||||||
for (const [key, relationship] of Object.entries(project.relationships ?? {})) {
|
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));
|
const metadataKey = relationship.matchField.slice("metadata.".length);
|
||||||
|
const matches = contentItems
|
||||||
|
.filter((item) => item.status === "published" && item.artifactType === relationship.contentModel && item.metadata?.[metadataKey] === 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.`);
|
if (relationship.required && matches.length === 0) throw new Error(`E_PROJECT_RELATIONSHIP: required relationship '${key}' for '${project.id}' has no matches.`);
|
||||||
relatedContent[key] = matches;
|
relatedContent[key] = matches;
|
||||||
}
|
}
|
||||||
projects.push(Object.freeze({ ...project, sourceSnapshot, relatedContent: Object.freeze(relatedContent) }));
|
projects.push(Object.freeze({
|
||||||
|
...project,
|
||||||
|
sourceSnapshot,
|
||||||
|
relatedContent: Object.freeze(relatedContent),
|
||||||
|
contentItem: declaredProjectRoutes.has(normalizedRoute(project.route))
|
||||||
|
? projectContentByRoute.get(normalizedRoute(project.route))
|
||||||
|
: undefined,
|
||||||
|
}));
|
||||||
for (const publication of project.publishedFiles ?? []) {
|
for (const publication of project.publishedFiles ?? []) {
|
||||||
const sourcePath = canonicalPath(publication.source, `published file for '${project.id}'`);
|
const sourcePath = canonicalPath(publication.source, `published file for '${project.id}'`);
|
||||||
const absolute = resolveRegularFile(sourceSnapshot.checkoutRoot, sourcePath, `published file '${sourcePath}'`);
|
const absolute = resolveRegularFile(sourceSnapshot.checkoutRoot, sourcePath, `published file '${sourcePath}'`);
|
||||||
@@ -145,10 +162,23 @@ export class BuildInputLoader {
|
|||||||
};
|
};
|
||||||
const artifactBuildId = `sha256-${sha256(stableJson(descriptor))}`;
|
const artifactBuildId = `sha256-${sha256(stableJson(descriptor))}`;
|
||||||
const generatedAt = [...sourceMap.values()].map((source) => source.committedAt).sort().at(-1)!;
|
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) });
|
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 {
|
dispose(): void {
|
||||||
this.snapshots.dispose();
|
this.snapshots.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import crypto from "node:crypto";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import YAML from "yaml";
|
||||||
|
import {
|
||||||
|
ContentItem,
|
||||||
|
ContentModel,
|
||||||
|
MediaAsset,
|
||||||
|
RepositorySnapshot,
|
||||||
|
} from "../types";
|
||||||
|
import {
|
||||||
|
canonicalPath,
|
||||||
|
resolveRegularFile,
|
||||||
|
ThemeContractError,
|
||||||
|
} from "./theme/themeLoader";
|
||||||
|
|
||||||
|
const MAX_CONTENT_BYTES = 2 * 1024 * 1024;
|
||||||
|
const sha256 = (value: Buffer | string) =>
|
||||||
|
crypto.createHash("sha256").update(value).digest("hex");
|
||||||
|
|
||||||
|
const issue = (code: string, message: string, file?: string): never => {
|
||||||
|
throw new ThemeContractError([
|
||||||
|
{ code, message, file, category: "content" },
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const repositoryRelative = (snapshot: RepositorySnapshot, absolute: string): string =>
|
||||||
|
path.relative(snapshot.checkoutRoot, absolute).replace(/\\/g, "/");
|
||||||
|
|
||||||
|
const resolveDirectory = (root: string, relative: string, label: string): 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 {
|
||||||
|
return issue("E_CONTENT_PATH_INVALID", `${label} does not exist.`, relative);
|
||||||
|
}
|
||||||
|
if (real !== rootReal && !real.startsWith(`${rootReal}${path.sep}`)) {
|
||||||
|
return issue("E_THEME_PATH_ESCAPE", `${label} resolves outside its allowed root.`, relative);
|
||||||
|
}
|
||||||
|
if (!fs.statSync(real).isDirectory()) {
|
||||||
|
return issue("E_CONTENT_PATH_INVALID", `${label} must be a directory.`, relative);
|
||||||
|
}
|
||||||
|
return real;
|
||||||
|
};
|
||||||
|
|
||||||
|
const walkFiles = (root: string): string[] => {
|
||||||
|
const files: string[] = [];
|
||||||
|
const visit = (directory: string): void => {
|
||||||
|
for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||||
|
const absolute = path.join(directory, entry.name);
|
||||||
|
const stat = fs.lstatSync(absolute);
|
||||||
|
if (stat.isSymbolicLink()) {
|
||||||
|
issue("E_THEME_PATH_ESCAPE", `Configured content path contains symlink '${entry.name}'.`, entry.name);
|
||||||
|
}
|
||||||
|
if (stat.isDirectory()) visit(absolute);
|
||||||
|
else if (stat.isFile()) files.push(absolute);
|
||||||
|
else issue("E_CONTENT_PATH_INVALID", `Configured content path contains a non-regular entry '${entry.name}'.`, entry.name);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
visit(root);
|
||||||
|
return files;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseFrontMatter = (bytes: Buffer, relative: string): { attributes: Record<string, unknown>; body: string } => {
|
||||||
|
if (bytes.length > MAX_CONTENT_BYTES) {
|
||||||
|
return issue("E_CONTENT_TOO_LARGE", `Content '${relative}' exceeds ${MAX_CONTENT_BYTES} bytes.`, relative);
|
||||||
|
}
|
||||||
|
const source = bytes.toString("utf8").replace(/\r\n/g, "\n");
|
||||||
|
const match = source.match(/^---\n([\s\S]*?)\n---(?:\n|$)([\s\S]*)$/);
|
||||||
|
if (!match) return issue("E_CONTENT_FRONT_MATTER", `Content '${relative}' is missing YAML front matter.`, relative);
|
||||||
|
let attributes: unknown;
|
||||||
|
try {
|
||||||
|
attributes = YAML.parse(match[1]);
|
||||||
|
} catch (error) {
|
||||||
|
return issue("E_CONTENT_FRONT_MATTER", `Content '${relative}' has invalid YAML: ${error instanceof Error ? error.message : String(error)}`, relative);
|
||||||
|
}
|
||||||
|
if (!attributes || typeof attributes !== "object" || Array.isArray(attributes)) {
|
||||||
|
return issue("E_CONTENT_FRONT_MATTER", `Content '${relative}' front matter must be a mapping.`, relative);
|
||||||
|
}
|
||||||
|
return { attributes: attributes as Record<string, unknown>, body: match[2] };
|
||||||
|
};
|
||||||
|
|
||||||
|
const stringValue = (value: unknown, field: string, relative: string, required = false): string => {
|
||||||
|
if (value === undefined || value === null || value === "") {
|
||||||
|
if (required) issue("E_CONTENT_FRONT_MATTER", `Content '${relative}' requires '${field}'.`, relative);
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (typeof value === "string" || typeof value === "number") return String(value);
|
||||||
|
if (value instanceof Date) return value.toISOString().replace(/T00:00:00\.000Z$/, "");
|
||||||
|
return issue("E_CONTENT_FRONT_MATTER", `Content '${relative}' field '${field}' must be scalar.`, relative);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stringArray = (value: unknown, field: string, relative: string): string[] => {
|
||||||
|
if (value === undefined || value === null) return [];
|
||||||
|
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
|
||||||
|
return issue("E_CONTENT_FRONT_MATTER", `Content '${relative}' field '${field}' must be a string array.`, relative);
|
||||||
|
}
|
||||||
|
return [...value];
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeRoute = (value: string, relative: string): string => {
|
||||||
|
if (!value.startsWith("/") || value.includes("\\") || value.includes("\0") || /[?#]/.test(value)) {
|
||||||
|
return issue("E_ROUTE_INVALID", `Content '${relative}' has invalid route '${value}'.`, relative);
|
||||||
|
}
|
||||||
|
const parts = value.split("/").filter(Boolean);
|
||||||
|
if (parts.some((part) => part === "." || part === "..")) {
|
||||||
|
return issue("E_ROUTE_INVALID", `Content '${relative}' route escapes the site root.`, relative);
|
||||||
|
}
|
||||||
|
return parts.length === 0 ? "/" : `/${parts.join("/")}/`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mimeType = (filename: string): string => {
|
||||||
|
switch (path.extname(filename).toLowerCase()) {
|
||||||
|
case ".svg": return "image/svg+xml";
|
||||||
|
case ".png": return "image/png";
|
||||||
|
case ".jpg":
|
||||||
|
case ".jpeg": return "image/jpeg";
|
||||||
|
case ".webp": return "image/webp";
|
||||||
|
case ".gif": return "image/gif";
|
||||||
|
default: return "application/octet-stream";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface LoadedRepositoryContent {
|
||||||
|
contentModels: ContentModel[];
|
||||||
|
contentModelsPath?: string;
|
||||||
|
contentItems: ContentItem[];
|
||||||
|
mediaAssets: MediaAsset[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadRepositoryContent = (
|
||||||
|
snapshot: RepositorySnapshot,
|
||||||
|
configRoot: string,
|
||||||
|
siteDocument: Record<string, any>,
|
||||||
|
inputChecksums: Record<string, string>,
|
||||||
|
): LoadedRepositoryContent => {
|
||||||
|
const configuredModelsPath = siteDocument.contentModelsPath
|
||||||
|
? canonicalPath(String(siteDocument.contentModelsPath), "contentModelsPath")
|
||||||
|
: undefined;
|
||||||
|
const legacyModelRoot = path.join(snapshot.checkoutRoot, "packages", "content-models");
|
||||||
|
const modelRoot = configuredModelsPath
|
||||||
|
? resolveDirectory(configRoot, configuredModelsPath, "contentModelsPath")
|
||||||
|
: fs.existsSync(path.join(configRoot, "content-models"))
|
||||||
|
? resolveDirectory(configRoot, "content-models", "contentModelsPath")
|
||||||
|
: fs.existsSync(legacyModelRoot)
|
||||||
|
? legacyModelRoot
|
||||||
|
: undefined;
|
||||||
|
const contentModels: ContentModel[] = [];
|
||||||
|
if (modelRoot) {
|
||||||
|
for (const name of fs.readdirSync(modelRoot).sort()) {
|
||||||
|
const modelFile = path.join(modelRoot, name, "model.yml");
|
||||||
|
if (!fs.existsSync(modelFile)) continue;
|
||||||
|
const resolved = resolveRegularFile(modelRoot, `${name}/model.yml`, `content model '${name}'`, MAX_CONTENT_BYTES);
|
||||||
|
const bytes = fs.readFileSync(resolved);
|
||||||
|
const parsed = YAML.parse(bytes.toString("utf8")) as ContentModel;
|
||||||
|
if (!parsed?.id || parsed.kind !== "content-model") {
|
||||||
|
issue("E_CONTENT_MODEL_INVALID", `Content model '${name}' is invalid.`, repositoryRelative(snapshot, resolved));
|
||||||
|
}
|
||||||
|
contentModels.push(parsed);
|
||||||
|
inputChecksums[repositoryRelative(snapshot, resolved)] = sha256(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let participation: Record<string, any> = {};
|
||||||
|
const participationCandidate = path.join(configRoot, ".labyricorn", "site.yml");
|
||||||
|
if (fs.existsSync(participationCandidate)) {
|
||||||
|
const participationFile = resolveRegularFile(configRoot, ".labyricorn/site.yml", "repository participation policy", MAX_CONTENT_BYTES);
|
||||||
|
const bytes = fs.readFileSync(participationFile);
|
||||||
|
participation = YAML.parse(bytes.toString("utf8")) ?? {};
|
||||||
|
inputChecksums[repositoryRelative(snapshot, participationFile)] = sha256(bytes);
|
||||||
|
}
|
||||||
|
const contentPaths = participation.artifacts?.content_paths ?? [siteDocument.pagesPath ?? "pages"];
|
||||||
|
const mediaPaths = participation.artifacts?.media_paths ?? [];
|
||||||
|
if (!Array.isArray(contentPaths) || contentPaths.some((entry: unknown) => typeof entry !== "string")) {
|
||||||
|
issue("E_CONTENT_PATH_INVALID", "artifacts.content_paths must be a string array.", ".labyricorn/site.yml");
|
||||||
|
}
|
||||||
|
if (!Array.isArray(mediaPaths) || mediaPaths.some((entry: unknown) => typeof entry !== "string")) {
|
||||||
|
issue("E_CONTENT_PATH_INVALID", "artifacts.media_paths must be a string array.", ".labyricorn/site.yml");
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelById = new Map(contentModels.map((model) => [model.id, model]));
|
||||||
|
const contentItems: ContentItem[] = [];
|
||||||
|
const routes = new Set<string>();
|
||||||
|
for (const configuredPath of contentPaths as string[]) {
|
||||||
|
const contentRoot = resolveDirectory(configRoot, configuredPath, `content path '${configuredPath}'`);
|
||||||
|
for (const absolute of walkFiles(contentRoot).filter((file) => file.toLowerCase().endsWith(".md"))) {
|
||||||
|
const relative = repositoryRelative(snapshot, absolute);
|
||||||
|
const bytes = fs.readFileSync(absolute);
|
||||||
|
const { attributes, body } = parseFrontMatter(bytes, relative);
|
||||||
|
const artifactType = stringValue(attributes.model, "model", relative, true);
|
||||||
|
const model = modelById.get(artifactType);
|
||||||
|
if (!model) issue("E_CONTENT_MODEL_INVALID", `Content '${relative}' references unknown model '${artifactType}'.`, relative);
|
||||||
|
const title = stringValue(attributes.title, "title", relative, true);
|
||||||
|
const slug = stringValue(attributes.slug, "slug", relative) || path.basename(relative, path.extname(relative));
|
||||||
|
const configuredRoute = stringValue(attributes.route, "route", relative);
|
||||||
|
const derivedRoute = model.routing?.detail?.replace("{slug}", slug);
|
||||||
|
const route = normalizeRoute(configuredRoute || derivedRoute || `/${slug}/`, relative);
|
||||||
|
if (routes.has(route)) issue("E_ROUTE_COLLISION", `Multiple content documents declare '${route}'.`, relative);
|
||||||
|
routes.add(route);
|
||||||
|
const status = stringValue(attributes.status, "status", relative, true);
|
||||||
|
if (!(["published", "draft", "archived"] as string[]).includes(status)) {
|
||||||
|
issue("E_CONTENT_FRONT_MATTER", `Content '${relative}' has invalid status '${status}'.`, relative);
|
||||||
|
}
|
||||||
|
const metadata = attributes.metadata && typeof attributes.metadata === "object" && !Array.isArray(attributes.metadata)
|
||||||
|
? { ...(attributes.metadata as Record<string, unknown>) }
|
||||||
|
: {};
|
||||||
|
for (const key of ["projectId", "projectName", "projectUrl"]) {
|
||||||
|
if (attributes[key] !== undefined) metadata[key] = attributes[key];
|
||||||
|
}
|
||||||
|
const presentation = attributes.presentation && typeof attributes.presentation === "object" && !Array.isArray(attributes.presentation)
|
||||||
|
? attributes.presentation as ContentItem["presentation"]
|
||||||
|
: undefined;
|
||||||
|
contentItems.push({
|
||||||
|
id: `${snapshot.sourceId}:${relative}`,
|
||||||
|
title,
|
||||||
|
slug,
|
||||||
|
published: stringValue(attributes.published ?? attributes.date, "published", relative) || snapshot.committedAt,
|
||||||
|
updated: stringValue(attributes.updated, "updated", relative) || snapshot.committedAt,
|
||||||
|
author: stringValue(attributes.author, "author", relative) || undefined,
|
||||||
|
status: status as ContentItem["status"],
|
||||||
|
artifactType,
|
||||||
|
summary: stringValue(attributes.summary, "summary", relative),
|
||||||
|
featuredImage: stringValue(attributes.featuredImage, "featuredImage", relative) || undefined,
|
||||||
|
tags: stringArray(attributes.tags, "tags", relative),
|
||||||
|
presentation,
|
||||||
|
aliases: stringArray(attributes.aliases, "aliases", relative),
|
||||||
|
sourceRepo: snapshot.sourceId,
|
||||||
|
path: relative,
|
||||||
|
contentMarkdown: body,
|
||||||
|
mediaReferences: [],
|
||||||
|
youtubeDirectives: [],
|
||||||
|
wikipediaLinks: [],
|
||||||
|
validationStatus: "valid",
|
||||||
|
validationMessages: [],
|
||||||
|
route,
|
||||||
|
styleInstanceId: model.presentation?.default_style_config ?? artifactType,
|
||||||
|
metadata,
|
||||||
|
});
|
||||||
|
inputChecksums[relative] = sha256(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mediaAssets: MediaAsset[] = [];
|
||||||
|
for (const configuredPath of mediaPaths as string[]) {
|
||||||
|
const mediaRoot = resolveDirectory(configRoot, configuredPath, `media path '${configuredPath}'`);
|
||||||
|
for (const absolute of walkFiles(mediaRoot).filter((file) => !path.basename(file).startsWith("."))) {
|
||||||
|
const bytes = fs.readFileSync(absolute);
|
||||||
|
const sourcePath = repositoryRelative(snapshot, absolute);
|
||||||
|
const localRelative = path.relative(mediaRoot, absolute).replace(/\\/g, "/");
|
||||||
|
const publicPath = `/media/${localRelative}`;
|
||||||
|
const checksum = sha256(bytes);
|
||||||
|
mediaAssets.push({
|
||||||
|
id: `${snapshot.sourceId}:${sourcePath}`,
|
||||||
|
sourceRepo: snapshot.sourceId,
|
||||||
|
originalPath: sourcePath,
|
||||||
|
sourcePath,
|
||||||
|
sourceChecksum: checksum,
|
||||||
|
publicNamespacePath: publicPath,
|
||||||
|
filename: path.basename(absolute),
|
||||||
|
sizeBytes: bytes.length,
|
||||||
|
mimeType: mimeType(absolute),
|
||||||
|
isValidated: true,
|
||||||
|
});
|
||||||
|
inputChecksums[sourcePath] = checksum;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
contentModels: contentModels.sort((a, b) => a.id.localeCompare(b.id)),
|
||||||
|
contentModelsPath: configuredModelsPath,
|
||||||
|
contentItems: contentItems.sort((a, b) => a.route.localeCompare(b.route) || a.id.localeCompare(b.id)),
|
||||||
|
mediaAssets: mediaAssets.sort((a, b) => a.publicNamespacePath.localeCompare(b.publicNamespacePath)),
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -91,7 +91,7 @@ export const renderMarkdown = (markdown: string): string => renderMarkdownDocume
|
|||||||
|
|
||||||
class StrictLiquidRenderer {
|
class StrictLiquidRenderer {
|
||||||
private readonly liquid: Liquid;
|
private readonly liquid: Liquid;
|
||||||
private readonly allowedFilters = new Set(["escape", "safe_content", "safe_page_body"]);
|
private readonly allowedFilters = new Set(["escape", "join", "safe_content", "safe_page_body"]);
|
||||||
|
|
||||||
constructor(partialsRoot: string) {
|
constructor(partialsRoot: string) {
|
||||||
this.liquid = new Liquid({ root: [partialsRoot], extname: ".liquid", strictVariables: true, strictFilters: true, dynamicPartials: false, relativeReference: false });
|
this.liquid = new Liquid({ root: [partialsRoot], extname: ".liquid", strictVariables: true, strictFilters: true, dynamicPartials: false, relativeReference: false });
|
||||||
@@ -117,6 +117,16 @@ class StrictLiquidRenderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const escapeContext = (value: unknown): unknown => {
|
||||||
|
if (value instanceof SafeHtml || value === null || value === undefined) return value;
|
||||||
|
if (typeof value === "string") return escapeHtml(value);
|
||||||
|
if (Array.isArray(value)) return value.map(escapeContext);
|
||||||
|
if (typeof value === "object") {
|
||||||
|
return Object.fromEntries(Object.entries(value as Record<string, unknown>).map(([key, child]) => [key, escapeContext(child)]));
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeDirectoryRoute = (route: string): string => {
|
const normalizeDirectoryRoute = (route: string): string => {
|
||||||
if (!route.startsWith("/") || route.includes("\0") || route.includes("\\") || /[?#]/.test(route)) throw new Error(`E_ROUTE_INVALID: invalid route '${route}'.`);
|
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);
|
const parts = route.split("/").filter(Boolean);
|
||||||
@@ -202,7 +212,8 @@ export class BuildEngine {
|
|||||||
for (const project of input.projects) claim(directoryRouteFile(project.route), `project:${project.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 publication of input.publishedFiles) claim(literalRouteFile(publication.route), `published:${publication.projectId}`);
|
||||||
for (const item of input.contentItems.filter((candidate) => candidate.status === "published")) {
|
for (const item of input.contentItems.filter((candidate) => candidate.status === "published")) {
|
||||||
claim(directoryRouteFile(item.route), `content:${item.id}`);
|
const route = normalizeDirectoryRoute(item.route);
|
||||||
|
if (item.artifactType !== "project" && route !== "/") claim(directoryRouteFile(route), `content:${item.id}`);
|
||||||
const markdownWithoutCode = item.contentMarkdown.replace(/```[\s\S]*?```/g, "").replace(/`[^`]*`/g, "");
|
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" }); }
|
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" }); }
|
||||||
}
|
}
|
||||||
@@ -233,6 +244,11 @@ export class BuildEngine {
|
|||||||
};
|
};
|
||||||
try {
|
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}'`)));
|
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}'`)));
|
||||||
|
for (const media of input.mediaAssets) {
|
||||||
|
const snapshot = input.sources.get(media.sourceRepo);
|
||||||
|
if (!snapshot || !media.sourcePath) throw new Error(`E_MEDIA_SOURCE: unresolved media '${media.id}'.`);
|
||||||
|
write(media.publicNamespacePath.replace(/^\/+/, ""), fs.readFileSync(resolveRegularFile(snapshot.checkoutRoot, media.sourcePath, `media '${media.sourcePath}'`)));
|
||||||
|
}
|
||||||
const fontCss: string[] = [];
|
const fontCss: string[] = [];
|
||||||
for (const font of input.theme.manifest.assets.fonts) {
|
for (const font of input.theme.manifest.assets.fonts) {
|
||||||
const released = this.resolveFont(font, input);
|
const released = this.resolveFont(font, input);
|
||||||
@@ -243,31 +259,105 @@ export class BuildEngine {
|
|||||||
for (const publication of input.publishedFiles) write(literalRouteFile(publication.route), fs.readFileSync(resolveRegularFile(publication.sourceSnapshot.checkoutRoot, publication.sourcePath, `published file '${publication.sourcePath}'`)));
|
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 renderer = new StrictLiquidRenderer(path.join(input.theme.root, "partials"));
|
||||||
const stylesheets = input.theme.manifest.assets.styles.map((asset) => `/${asset}`);
|
const styles = input.theme.manifest.assets.styles.map((asset) => `/${asset}`);
|
||||||
if (fontCss.length) stylesheets.push("/assets/styles/fonts.generated.css");
|
if (fontCss.length) styles.push("/assets/styles/fonts.generated.css");
|
||||||
const scripts = input.theme.manifest.assets.scripts.map((asset) => `/${asset}`);
|
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 navigation = input.siteConfig.navigation.map(({ id, label, route, contentModel, presentation }) => ({
|
||||||
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 } };
|
id, label, route: normalizeDirectoryRoute(route), contentModel: contentModel ?? null,
|
||||||
const page = (templateKey: string, title: string, context: Record<string, unknown>, output: string, route: string) => {
|
templateKey: presentation?.sectionTemplate ?? contentModel ?? null,
|
||||||
|
}));
|
||||||
|
const sourceCommits = Object.fromEntries([...input.sources.entries()].map(([id, snapshot]) => [id, snapshot.commit]).sort(([a], [b]) => a.localeCompare(b)));
|
||||||
|
const base = {
|
||||||
|
site: input.siteConfig.site,
|
||||||
|
navigation,
|
||||||
|
theme: { id: input.theme.manifest.id, version: input.theme.manifest.version, assetBaseUrl: "/assets/", styles, scripts, fontStylesheet: fontCss.length ? "/assets/styles/fonts.generated.css" : null },
|
||||||
|
build: { id: input.artifactBuildId, generatedAt: input.generatedAt, sourceCommits },
|
||||||
|
};
|
||||||
|
const collectionItem = (item: ContentItem) => ({
|
||||||
|
id: item.id, contentModel: item.artifactType, templateKey: item.presentation?.standalone ?? item.artifactType,
|
||||||
|
title: item.title, route: normalizeDirectoryRoute(item.route), summary: item.summary || null,
|
||||||
|
published: item.published || null, tags: item.tags, metadata: item.metadata ?? {},
|
||||||
|
});
|
||||||
|
const renderedItemPage = (item: ContentItem, type: "home" | "content" = "content") => {
|
||||||
|
const rendered = renderMarkdownDocument(item.contentMarkdown);
|
||||||
|
const snapshot = input.sources.get(item.sourceRepo)!;
|
||||||
|
return {
|
||||||
|
type, contentModel: item.artifactType, templateKey: type === "home" ? "home" : item.presentation?.standalone ?? item.artifactType,
|
||||||
|
id: item.id, title: item.title, route: normalizeDirectoryRoute(item.route), summary: item.summary || null,
|
||||||
|
published: item.published || null, updated: item.updated ?? snapshot.committedAt, author: item.author ?? null, tags: item.tags,
|
||||||
|
metadata: { version: null, license: null, homepage: null, stack: [], ...(item.metadata ?? {}) }, provenance: { sourceId: snapshot.sourceId, repository: snapshot.repository, commit: snapshot.commit },
|
||||||
|
renderedContent: rendered.html, body: null, tableOfContents: rendered.toc, relationships: {}, publications: [],
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const renderPage = (templateKey: string, pageContext: Record<string, unknown>, collection: { items: unknown[] }, output: string, route: string) => {
|
||||||
const bodySource = input.theme.templateSources[templateKey];
|
const bodySource = input.theme.templateSources[templateKey];
|
||||||
if (!bodySource) throw new Error(`E_PROJECT_TEMPLATE_UNRESOLVED: template '${templateKey}' is missing.`);
|
if (!bodySource) throw new Error(`E_PROJECT_TEMPLATE_UNRESOLVED: template '${templateKey}' is missing.`);
|
||||||
const body = new SafeHtml(renderer.render(bodySource, { ...base, ...context }, templateKey));
|
const bodyContext = escapeContext({ ...base, page: pageContext, collection }) as Record<string, unknown>;
|
||||||
const html = renderer.render(input.theme.templateSources.layout, { ...base, ...context, page: { title, body } }, "layout");
|
const body = new SafeHtml(renderer.render(bodySource, bodyContext, templateKey));
|
||||||
|
const layoutContext = escapeContext({ ...base, page: { ...pageContext, body }, collection }) as Record<string, unknown>;
|
||||||
|
const html = renderer.render(input.theme.templateSources.layout, layoutContext, "layout");
|
||||||
write(output, html.endsWith("\n") ? html : `${html}\n`); routes.add(route);
|
write(output, html.endsWith("\n") ? html : `${html}\n`); routes.add(route);
|
||||||
};
|
};
|
||||||
page("home", input.siteConfig.site.title, { content: input.contentItems }, "index.html", "/");
|
|
||||||
|
const projectRoutes = new Set(input.projects.map((project) => normalizeDirectoryRoute(project.route)));
|
||||||
|
const publishedItems = input.contentItems.filter((item) =>
|
||||||
|
item.status === "published" && (item.artifactType !== "project" || projectRoutes.has(normalizeDirectoryRoute(item.route))),
|
||||||
|
);
|
||||||
|
const homeItem = publishedItems.find((item) => normalizeDirectoryRoute(item.route) === "/");
|
||||||
|
const homePage = homeItem ? renderedItemPage(homeItem, "home") : {
|
||||||
|
type: "home", contentModel: "page", templateKey: "home", id: input.siteConfig.site.id, title: input.siteConfig.site.title,
|
||||||
|
route: "/", summary: null, published: null, updated: input.generatedAt, author: null, tags: [], metadata: {},
|
||||||
|
provenance: { sourceId: input.siteDefinition.sourceId, repository: input.siteDefinition.repository, commit: input.siteDefinition.commit },
|
||||||
|
renderedContent: new SafeHtml(""), body: null, tableOfContents: [], relationships: {}, publications: [],
|
||||||
|
};
|
||||||
|
const homeCollection = publishedItems.filter((item) => normalizeDirectoryRoute(item.route) !== "/").map(collectionItem);
|
||||||
|
renderPage("home", homePage, { items: homeCollection }, "index.html", "/");
|
||||||
|
|
||||||
for (const entry of input.siteConfig.navigation.filter((item) => item.route !== "/")) {
|
for (const entry of input.siteConfig.navigation.filter((item) => item.route !== "/")) {
|
||||||
const key = entry.presentation?.sectionTemplate ?? entry.contentModel!;
|
const key = entry.presentation?.sectionTemplate ?? entry.contentModel!;
|
||||||
const items = input.contentItems.filter((item) => item.status === "published" && item.artifactType === entry.contentModel);
|
const contentCollection = publishedItems.filter((item) => item.artifactType === entry.contentModel).map(collectionItem);
|
||||||
page(`sections.${key}`, entry.label, { section: entry, content: items }, directoryRouteFile(entry.route), normalizeDirectoryRoute(entry.route));
|
const projectCollection = input.projects.map((project) => project.contentItem ? collectionItem(project.contentItem) : ({
|
||||||
|
id: project.id, contentModel: "project", templateKey: project.presentation.detailTemplate, title: project.title ?? project.id,
|
||||||
|
route: normalizeDirectoryRoute(project.route), summary: project.summary ?? null, published: project.sourceSnapshot.committedAt,
|
||||||
|
tags: project.tags ?? [], metadata: { version: project.version ?? null, license: project.license ?? null, homepage: project.homepage ?? null, stack: project.stack ?? [] },
|
||||||
|
}));
|
||||||
|
const pageContext = {
|
||||||
|
type: "section", contentModel: entry.contentModel ?? null, templateKey: key, id: entry.id, title: entry.label,
|
||||||
|
route: normalizeDirectoryRoute(entry.route), summary: null, published: null, updated: input.generatedAt, author: null, tags: [], metadata: {},
|
||||||
|
provenance: { sourceId: input.siteDefinition.sourceId, repository: input.siteDefinition.repository, commit: input.siteDefinition.commit },
|
||||||
|
renderedContent: new SafeHtml(""), body: null, tableOfContents: [], relationships: {}, publications: [],
|
||||||
|
};
|
||||||
|
renderPage(`sections.${key}`, pageContext, { items: entry.contentModel === "project" ? projectCollection : contentCollection }, 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")) {
|
for (const project of input.projects) {
|
||||||
const rendered = renderMarkdownDocument(item.contentMarkdown);
|
const route = normalizeDirectoryRoute(project.route);
|
||||||
|
const item = project.contentItem;
|
||||||
|
const rendered = item ? renderMarkdownDocument(item.contentMarkdown) : { html: new SafeHtml(""), toc: [] };
|
||||||
|
const relationships = Object.fromEntries(Object.entries(project.relatedContent).map(([key, items]) => [key, items.map((related) => ({ ...collectionItem(related), renderedContent: renderMarkdownDocument(related.contentMarkdown).html }))]));
|
||||||
|
const pageContext = {
|
||||||
|
type: "project", contentModel: "project", templateKey: project.presentation.detailTemplate, id: project.id,
|
||||||
|
title: project.title ?? item?.title ?? project.id, route, summary: project.summary ?? item?.summary ?? null,
|
||||||
|
published: item?.published ?? project.sourceSnapshot.committedAt, updated: item?.updated ?? project.sourceSnapshot.committedAt, author: item?.author ?? null,
|
||||||
|
tags: project.tags ?? item?.tags ?? [], metadata: { ...(item?.metadata ?? {}), version: project.version ?? null, license: project.license ?? null, homepage: project.homepage ?? null, stack: project.stack ?? [] },
|
||||||
|
provenance: { sourceId: project.sourceSnapshot.sourceId, repository: project.sourceSnapshot.repository, commit: project.sourceSnapshot.commit },
|
||||||
|
renderedContent: rendered.html, body: null, tableOfContents: rendered.toc, relationships, publications: project.publishedFiles ?? [],
|
||||||
|
};
|
||||||
|
renderPage(`content.${project.presentation.detailTemplate}`, pageContext, { items: [] }, directoryRouteFile(route), route);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of publishedItems) {
|
||||||
|
const route = normalizeDirectoryRoute(item.route);
|
||||||
|
if (route === "/" || item.artifactType === "project" || projectRoutes.has(route)) continue;
|
||||||
const key = item.presentation?.standalone ?? item.artifactType;
|
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));
|
renderPage(`content.${key}`, renderedItemPage(item), { items: [] }, directoryRouteFile(route), route);
|
||||||
}
|
}
|
||||||
page("notFound", "Not Found", {}, "404.html", "/404.html");
|
const notFoundPage = {
|
||||||
|
type: "notFound", contentModel: null, templateKey: "notFound", id: null, title: "Not Found", route: "/404.html", summary: null,
|
||||||
|
published: null, updated: input.generatedAt, author: null, tags: [], metadata: {}, provenance: { sourceId: input.siteDefinition.sourceId, repository: input.siteDefinition.repository, commit: input.siteDefinition.commit },
|
||||||
|
renderedContent: new SafeHtml(""), body: null, tableOfContents: [], relationships: {}, publications: [],
|
||||||
|
};
|
||||||
|
renderPage("notFound", notFoundPage, { items: [] }, "404.html", "/404.html");
|
||||||
|
|
||||||
const declaredScripts = new Set(scripts);
|
const declaredScripts = new Set(scripts);
|
||||||
const releaseFiles = new Set(files);
|
const releaseFiles = new Set(files);
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ export interface ContentItem {
|
|||||||
status: "published" | "draft" | "archived";
|
status: "published" | "draft" | "archived";
|
||||||
artifactType: string; // Matches ContentModel ID
|
artifactType: string; // Matches ContentModel ID
|
||||||
summary: string;
|
summary: string;
|
||||||
|
updated?: string;
|
||||||
|
author?: string;
|
||||||
featuredImage?: string;
|
featuredImage?: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
presentation?: {
|
presentation?: {
|
||||||
@@ -95,6 +97,7 @@ export interface ContentItem {
|
|||||||
projectId?: string;
|
projectId?: string;
|
||||||
projectName?: string;
|
projectName?: string;
|
||||||
projectUrl?: string;
|
projectUrl?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +110,8 @@ export interface MediaAsset {
|
|||||||
sizeBytes: number;
|
sizeBytes: number;
|
||||||
mimeType: string;
|
mimeType: string;
|
||||||
isValidated: boolean;
|
isValidated: boolean;
|
||||||
|
sourcePath?: string;
|
||||||
|
sourceChecksum?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ThemeConfig {
|
export interface ThemeConfig {
|
||||||
@@ -298,6 +303,7 @@ export interface SiteConfig {
|
|||||||
};
|
};
|
||||||
navigation: NavigationEntry[];
|
navigation: NavigationEntry[];
|
||||||
contentModels: ContentModel[];
|
contentModels: ContentModel[];
|
||||||
|
contentModelsPath?: string;
|
||||||
sourcesFile: string;
|
sourcesFile: string;
|
||||||
styleInstancesPath: string;
|
styleInstancesPath: string;
|
||||||
pagesPath: string;
|
pagesPath: string;
|
||||||
@@ -455,6 +461,7 @@ export interface ProjectDeclaration {
|
|||||||
export interface ResolvedProject extends ProjectDeclaration {
|
export interface ResolvedProject extends ProjectDeclaration {
|
||||||
sourceSnapshot: RepositorySnapshot;
|
sourceSnapshot: RepositorySnapshot;
|
||||||
relatedContent: Readonly<Record<string, readonly ContentItem[]>>;
|
relatedContent: Readonly<Record<string, readonly ContentItem[]>>;
|
||||||
|
contentItem?: ContentItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ResolvedPublishedFile extends PublishedFile {
|
export interface ResolvedPublishedFile extends PublishedFile {
|
||||||
|
|||||||
Reference in New Issue
Block a user