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=PORT=3001
|
||||
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
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
|
||||
@@ -27,3 +27,17 @@ server {
|
||||
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">
|
||||
<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>
|
||||
<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>
|
||||
<p class="eyebrow">{{ page.contentModel }}</p>
|
||||
<h1>{{ page.title }}</h1>
|
||||
<p class="lede">{{ page.summary }}</p>
|
||||
<div class="prose">{{ page.renderedContent | safe_content }}</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -2,27 +2,22 @@
|
||||
<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>
|
||||
<dt>Version</dt><dd>{{ page.metadata.version }}</dd>
|
||||
<dt>License</dt><dd>{{ page.metadata.license }}</dd>
|
||||
<dt>Source commit</dt><dd><code>{{ page.provenance.commit }}</code></dd>
|
||||
<dt>Updated</dt><dd>{{ page.updated }}</dd>
|
||||
</dl>
|
||||
<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>
|
||||
<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>
|
||||
<h1>{{ page.title }}</h1>
|
||||
<p class="lede">{{ page.summary }}</p>
|
||||
<div class="prose">{{ page.renderedContent | safe_content }}</div>
|
||||
<p><a href="{{ page.metadata.homepage }}">Open the standalone demo</a></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 %}
|
||||
<ul class="tags">{% for tag in page.tags %}<li>{{ tag }}</li>{% endfor %}</ul>
|
||||
<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 %}
|
||||
{% for item in page.relationships.articles %}<article><h3><a href="{{ item.route }}">{{ item.title }}</a></h3></article>{% endfor %}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<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>
|
||||
<h1>{{ page.title }}</h1>
|
||||
<p>{{ page.summary }}</p>
|
||||
<div class="prose">{{ page.renderedContent | safe_content }}</div>
|
||||
</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>
|
||||
{% if collection.items.size == 0 %}<p>No published content in this snapshot.</p>{% endif %}
|
||||
{% for item in collection.items %}
|
||||
<article><p class="eyebrow">{{ item.contentModel }}</p><h3><a href="{{ item.route }}">{{ item.title }}</a></h3><p>{{ item.summary }}</p></article>
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
<!doctype html>
|
||||
<html lang="{{ site.language | escape }}">
|
||||
<html lang="{{ site.language }}">
|
||||
<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>
|
||||
<title>{{ page.title }} · {{ site.title }}</title>
|
||||
{% for stylesheet in theme.styles %}<link rel="stylesheet" href="{{ stylesheet }}">
|
||||
{% endfor %}{% for script in theme.scripts %}<script src="{{ script }}" 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>
|
||||
<a class="brand" href="/"><img src="/assets/images/logo.svg" width="32" height="32" alt=""><span>{{ site.title }}</span></a>
|
||||
<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>
|
||||
</header>
|
||||
<main>{{ page.body | safe_page_body }}</main>
|
||||
<footer class="site-footer">
|
||||
<span>Built from immutable Git snapshots.</span>
|
||||
<code>{{ build.id | escape }}</code>
|
||||
<code>{{ build.id }}</code>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<header class="section-heading">
|
||||
<p class="eyebrow">Section</p>
|
||||
<h1>{{ section.label | escape }}</h1>
|
||||
<h1>{{ page.title }}</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>
|
||||
{% if collection.items.size == 0 %}<p>No published items in this snapshot.</p>{% endif %}
|
||||
{% for item in collection.items %}
|
||||
<article><h2><a href="{{ item.route }}">{{ item.title }}</a></h2><p>{{ item.summary }}</p></article>
|
||||
{% endfor %}
|
||||
</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);
|
||||
assert.equal(result.success, true);
|
||||
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, "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, "project/website-engine-control-plane/index.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.theme.commit, input.theme.snapshot.commit);
|
||||
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/);
|
||||
} finally { manager.dispose(); }
|
||||
}));
|
||||
@@ -152,6 +158,21 @@ test("project declaration controls the project route and standalone publication"
|
||||
} 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) => {
|
||||
const { manager, input } = load(root, "publication");
|
||||
try {
|
||||
|
||||
@@ -5,14 +5,13 @@ import path from "node:path";
|
||||
import YAML from "yaml";
|
||||
import {
|
||||
ContentItem,
|
||||
ContentModel,
|
||||
MediaAsset,
|
||||
ProjectDeclaration,
|
||||
ResolvedBuildInput,
|
||||
ResolvedProject,
|
||||
ResolvedPublishedFile,
|
||||
SiteConfig,
|
||||
} from "../types";
|
||||
import { loadRepositoryContent } from "./contentLoader";
|
||||
import { RepositorySnapshotManager } from "./repositories/repositorySnapshot";
|
||||
import { canonicalPath, parseProjects, resolveRegularFile, ThemeLoader } from "./theme/themeLoader";
|
||||
|
||||
@@ -32,6 +31,11 @@ export interface BuildInputLoaderOptions {
|
||||
builderVersion: string;
|
||||
}
|
||||
|
||||
const normalizedRoute = (route: string): string => {
|
||||
const parts = route.split("/").filter(Boolean);
|
||||
return parts.length === 0 ? "/" : `/${parts.join("/")}/`;
|
||||
};
|
||||
|
||||
export class BuildInputLoader {
|
||||
private readonly snapshots: RepositorySnapshotManager;
|
||||
private readonly repositoryRoot: string;
|
||||
@@ -53,9 +57,10 @@ export class BuildInputLoader {
|
||||
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 canonical = canonicalPath(relative, "configuration path");
|
||||
const file = resolveRegularFile(configRoot, canonical, `configuration '${relative}'`, 2 * 1024 * 1024);
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -64,26 +69,16 @@ export class BuildInputLoader {
|
||||
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 loadedContent = loadRepositoryContent(siteDefinition, configRoot, siteDocument, inputChecksums);
|
||||
|
||||
const siteConfig: SiteConfig = {
|
||||
protocol: siteDocument.protocol,
|
||||
site: siteDocument.site,
|
||||
navigation: navigationDocument.navigation ?? [],
|
||||
contentModels,
|
||||
contentModels: loadedContent.contentModels,
|
||||
contentModelsPath: loadedContent.contentModelsPath,
|
||||
sourcesFile: siteDocument.sourcesFile ?? "sources.yml",
|
||||
styleInstancesPath: siteDocument.styleInstancesPath ?? "../style-configs",
|
||||
styleInstancesPath: siteDocument.styleInstancesPath ?? "style-configs",
|
||||
pagesPath: siteDocument.pagesPath ?? "pages",
|
||||
navigationFile: navigationRelative,
|
||||
projectsFile: siteDocument.projectsFile,
|
||||
@@ -100,20 +95,32 @@ export class BuildInputLoader {
|
||||
};
|
||||
|
||||
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);
|
||||
if (siteConfig.projectsFile) declarations = parseProjects(readConfig(siteConfig.projectsFile));
|
||||
const contentItems = loadedContent.contentItems;
|
||||
const mediaAssets = loadedContent.mediaAssets;
|
||||
const declaredProjectRoutes = new Set(declarations.map((project) => normalizedRoute(project.route)));
|
||||
const projectContentByRoute = new Map(
|
||||
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);
|
||||
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;
|
||||
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 publishedFiles: ResolvedPublishedFile[] = [];
|
||||
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}'.`);
|
||||
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));
|
||||
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.`);
|
||||
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 ?? []) {
|
||||
const sourcePath = canonicalPath(publication.source, `published file for '${project.id}'`);
|
||||
const absolute = resolveRegularFile(sourceSnapshot.checkoutRoot, sourcePath, `published file '${sourcePath}'`);
|
||||
@@ -145,10 +162,23 @@ export class BuildInputLoader {
|
||||
};
|
||||
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) });
|
||||
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,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 {
|
||||
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) {
|
||||
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 => {
|
||||
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);
|
||||
@@ -202,7 +212,8 @@ export class BuildEngine {
|
||||
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 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, "");
|
||||
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 {
|
||||
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[] = [];
|
||||
for (const font of input.theme.manifest.assets.fonts) {
|
||||
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}'`)));
|
||||
|
||||
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 styles = input.theme.manifest.assets.styles.map((asset) => `/${asset}`);
|
||||
if (fontCss.length) styles.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 navigation = input.siteConfig.navigation.map(({ id, label, route, contentModel, presentation }) => ({
|
||||
id, label, route: normalizeDirectoryRoute(route), contentModel: contentModel ?? null,
|
||||
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];
|
||||
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");
|
||||
const bodyContext = escapeContext({ ...base, page: pageContext, collection }) as Record<string, unknown>;
|
||||
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);
|
||||
};
|
||||
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 !== "/")) {
|
||||
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));
|
||||
const contentCollection = publishedItems.filter((item) => item.artifactType === entry.contentModel).map(collectionItem);
|
||||
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")) {
|
||||
const rendered = renderMarkdownDocument(item.contentMarkdown);
|
||||
|
||||
for (const project of input.projects) {
|
||||
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;
|
||||
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 releaseFiles = new Set(files);
|
||||
|
||||
@@ -72,6 +72,8 @@ export interface ContentItem {
|
||||
status: "published" | "draft" | "archived";
|
||||
artifactType: string; // Matches ContentModel ID
|
||||
summary: string;
|
||||
updated?: string;
|
||||
author?: string;
|
||||
featuredImage?: string;
|
||||
tags: string[];
|
||||
presentation?: {
|
||||
@@ -95,6 +97,7 @@ export interface ContentItem {
|
||||
projectId?: string;
|
||||
projectName?: string;
|
||||
projectUrl?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -107,6 +110,8 @@ export interface MediaAsset {
|
||||
sizeBytes: number;
|
||||
mimeType: string;
|
||||
isValidated: boolean;
|
||||
sourcePath?: string;
|
||||
sourceChecksum?: string;
|
||||
}
|
||||
|
||||
export interface ThemeConfig {
|
||||
@@ -298,6 +303,7 @@ export interface SiteConfig {
|
||||
};
|
||||
navigation: NavigationEntry[];
|
||||
contentModels: ContentModel[];
|
||||
contentModelsPath?: string;
|
||||
sourcesFile: string;
|
||||
styleInstancesPath: string;
|
||||
pagesPath: string;
|
||||
@@ -455,6 +461,7 @@ export interface ProjectDeclaration {
|
||||
export interface ResolvedProject extends ProjectDeclaration {
|
||||
sourceSnapshot: RepositorySnapshot;
|
||||
relatedContent: Readonly<Record<string, readonly ContentItem[]>>;
|
||||
contentItem?: ContentItem;
|
||||
}
|
||||
|
||||
export interface ResolvedPublishedFile extends PublishedFile {
|
||||
|
||||
Reference in New Issue
Block a user