Implement repository-driven theme rendering

This commit is contained in:
root
2026-07-23 17:49:10 -07:00
parent 68d76a59df
commit 884d182c86
32 changed files with 3040 additions and 1417 deletions
+177 -164
View File
@@ -1,181 +1,194 @@
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { BuildEngine, renderMarkdown } from "./buildEngine";
import { BuildEngineInput } from "./buildEngine";
import { ContentItem, SiteConfig, ThemeConfig } from "../types";
import { BuildInputLoader } from "./buildInputLoader";
import { BuildEngine, BUILDER_VERSION, renderMarkdown } from "./buildEngine";
const siteConfig: SiteConfig = {
protocol: "labyricorn-site/v1",
site: {
id: "test-site",
title: "Test Site",
baseUrl: "https://example.test",
language: "en-US",
},
navigation: [
{ id: "home", label: "Home", route: "/", iconName: "Home", contentModel: "page" },
{ id: "articles", label: "Articles", route: "/articles/", iconName: "FileText", contentModel: "article" },
],
contentModels: [],
sourcesFile: "./sources.yml",
styleInstancesPath: "./styles",
pagesPath: "./pages",
navigationFile: "./navigation.yml",
pushIntegrationsPath: "./push",
theme: { source: "site-definition", path: "/.theme" },
markdown: {
dialect: "commonmark",
rawHtmlPolicy: "disabled",
rawHtmlEnabled: false,
extensions: {
tables: true,
taskLists: true,
footnotes: true,
definitionLists: true,
headingAnchors: true,
fencedCode: true,
syntaxHighlighting: true,
callouts: true,
youtube: true,
wikipediaLinks: true,
},
},
hosting: {
engine: "nginx",
production: { enabled: true, hostname: "example.test", listen: 80 },
staging: { enabled: true, hostname: "preview.example.test", listen: 8080 },
releases: { retainCount: 5 },
},
buildPolicy: {
staging: { enabled: true, requireApproval: true },
localActivation: { automatic: false },
push: { automatic: false },
},
rawYaml: "",
const git = (root: string, args: string[]): string => execFileSync("git", ["-C", root, ...args], {
encoding: "utf8",
env: { ...process.env, GIT_AUTHOR_DATE: "2026-01-01T00:00:00Z", GIT_COMMITTER_DATE: "2026-01-01T00:00:00Z" },
}).trim();
const createRepository = (): string => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "labyricorn-git-fixture-"));
fs.mkdirSync(path.join(root, "packages"), { recursive: true });
fs.cpSync(path.resolve("packages/site-definition"), path.join(root, "packages/site-definition"), { recursive: true });
fs.cpSync(path.resolve("packages/content-models"), path.join(root, "packages/content-models"), { recursive: true });
git(root, ["init", "-b", "main"]);
git(root, ["config", "user.name", "Fixture"]);
git(root, ["config", "user.email", "[email protected]"]);
git(root, ["add", "."]);
git(root, ["commit", "-m", "fixture"]);
return root;
};
const themeConfig: ThemeConfig = {
id: "test-theme",
name: "Test Theme",
version: "1.0.0",
path: "/.theme",
templates: {},
styles: [],
scripts: [],
supportsPackages: [],
isValidated: true,
validationErrors: [],
const commit = (root: string, message: string): string => {
git(root, ["add", "-A"]);
git(root, ["commit", "-m", message]);
return git(root, ["rev-parse", "HEAD"]);
};
const content = (overrides: Partial<ContentItem> = {}): ContentItem => ({
id: "article-one",
title: "Article One",
slug: "article-one",
published: "2026-01-01T00:00:00.000Z",
status: "published",
artifactType: "article",
summary: "A deterministic article.",
tags: ["test"],
aliases: [],
sourceRepo: "content",
path: "articles/article-one.md",
contentMarkdown: "# Hello\n\nThis is **safe** markdown.",
mediaReferences: [],
youtubeDirectives: [],
wikipediaLinks: [],
validationStatus: "valid",
validationMessages: [],
route: "/articles/article-one/",
styleInstanceId: "default",
...overrides,
});
const load = (root: string, runId: string) => {
const manager = new BuildInputLoader({ repositoryRoot: root, ref: "HEAD", siteDefinitionPath: "packages/site-definition", builderVersion: BUILDER_VERSION });
return { manager, input: manager.load(runId) };
};
const input = (buildId: string, items: ContentItem[]): BuildEngineInput => ({
buildId,
siteConfig,
contentItems: items,
mediaAssets: [],
sourceCommits: { content: "abc123" },
themeConfig,
});
const withRepository = (callback: (root: string, output: string) => void): void => {
const root = createRepository();
const output = fs.mkdtempSync(path.join(os.tmpdir(), "labyricorn-output-"));
try { callback(root, output); }
finally { fs.rmSync(root, { recursive: true, force: true }); fs.rmSync(output, { recursive: true, force: true }); }
};
test("build output is deterministic for equivalent inputs", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "labyricorn-engine-test-"));
test("reference repository theme renders declared routes and only declared assets", () => withRepository((root, output) => {
const { manager, input } = load(root, "build-000001");
try {
const engine = new BuildEngine(root);
const firstItem = content();
const secondItem = content({
id: "article-two",
title: "Article Two",
slug: "article-two",
path: "articles/article-two.md",
route: "/articles/article-two/",
});
const first = engine.build(input("build-one", [firstItem, secondItem]));
const second = engine.build(input("build-two", [secondItem, firstItem]));
assert.equal(first.success, true);
assert.equal(second.success, true);
assert.equal(first.artifactChecksum, second.artifactChecksum);
assert.deepEqual(first.generatedFiles, second.generatedFiles);
assert.ok(fs.existsSync(path.join(first.outputDirectory, "build-manifest.json")));
assert.ok(fs.existsSync(path.join(first.outputDirectory, "checksums.json")));
assert.match(
fs.readFileSync(path.join(first.outputDirectory, "articles/article-one/index.html"), "utf8"),
/<h1>Hello<\/h1>/,
);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test("route collisions fail validation without writing a release", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "labyricorn-engine-test-"));
try {
const engine = new BuildEngine(root);
const result = engine.build(
input("collision", [content(), content({ id: "article-two", route: "/articles/article-one/" })]),
);
assert.equal(result.success, false);
assert.equal(result.validationReport.summary.passed, false);
assert.deepEqual(result.validationReport.routeCollisions, ["/articles/article-one/"]);
assert.equal(fs.existsSync(result.outputDirectory), false);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test("raw HTML is rejected when the site policy disables it", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "labyricorn-engine-test-"));
try {
const engine = new BuildEngine(root);
const result = engine.build(input("raw-html", [content({ contentMarkdown: "<script>alert(1)</script>" })]));
assert.equal(result.success, false);
assert.equal(result.validationReport.htmlPolicyViolations.length, 1);
assert.equal(fs.existsSync(result.outputDirectory), false);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
});
test("raw HTML inside fenced code remains a valid code example", () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "labyricorn-engine-test-"));
try {
const engine = new BuildEngine(root);
const result = engine.build(
input("html-code-example", [content({ contentMarkdown: "```html\n<div>example</div>\n```" })]),
);
const result = new BuildEngine(output).build(input);
assert.equal(result.success, true);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
assert.match(input.siteDefinition.commit, /^[0-9a-f]{40}$/);
assert.ok(fs.existsSync(path.join(result.outputDirectory, "index.html")));
assert.ok(fs.existsSync(path.join(result.outputDirectory, "projects/index.html")));
assert.ok(fs.existsSync(path.join(result.outputDirectory, "project/website-engine-control-plane/index.html")));
assert.ok(fs.existsSync(path.join(result.outputDirectory, "project/website-engine-control-plane/demo.html")));
assert.ok(fs.existsSync(path.join(result.outputDirectory, "404.html")));
assert.ok(fs.existsSync(path.join(result.outputDirectory, "assets/styles/theme.css")));
assert.ok(fs.existsSync(path.join(result.outputDirectory, "assets/scripts/theme.js")));
assert.ok(fs.existsSync(path.join(result.outputDirectory, "assets/images/logo.svg")));
assert.equal(fs.existsSync(path.join(result.outputDirectory, ".theme/theme.yml")), false);
const manifest = JSON.parse(fs.readFileSync(path.join(result.outputDirectory, "build-manifest.json"), "utf8"));
assert.equal(manifest.siteDefinition.commit, input.siteDefinition.commit);
assert.equal(manifest.theme.commit, input.theme.snapshot.commit);
assert.equal(manifest.artifactBuildId, input.artifactBuildId);
assert.doesNotMatch(fs.readFileSync(path.join(result.outputDirectory, "index.html"), "utf8"), /fonts\.googleapis|tailwindcss\.com/);
} finally { manager.dispose(); }
}));
test("equivalent resolved inputs are byte-identical despite different run IDs", () => withRepository((root, output) => {
const first = load(root, "build-000001");
const second = load(root, "build-999999");
try {
assert.equal(first.input.artifactBuildId, second.input.artifactBuildId);
const engine = new BuildEngine(output);
const one = engine.build(first.input);
const two = engine.build(second.input);
assert.equal(one.artifactChecksum, two.artifactChecksum);
assert.equal(one.outputDirectory, two.outputDirectory);
assert.deepEqual(one.generatedFiles, two.generatedFiles);
assert.equal(fs.readFileSync(path.join(one.outputDirectory, "checksums.json"), "utf8"), fs.readFileSync(path.join(two.outputDirectory, "checksums.json"), "utf8"));
} finally { first.manager.dispose(); second.manager.dispose(); }
}));
test("an already resolved snapshot is unchanged when the branch advances", () => withRepository((root, output) => {
const pinned = load(root, "build-pinned");
const css = path.join(root, "packages/site-definition/.theme/assets/styles/theme.css");
fs.appendFileSync(css, "\n.branch-advanced{display:block}\n");
const advancedCommit = commit(root, "advance theme css");
const advanced = load(root, "build-advanced");
try {
assert.notEqual(pinned.input.siteDefinition.commit, advancedCommit);
assert.equal(advanced.input.siteDefinition.commit, advancedCommit);
const engine = new BuildEngine(output);
const pinnedResult = engine.build(pinned.input);
const advancedResult = engine.build(advanced.input);
assert.notEqual(pinned.input.artifactBuildId, advanced.input.artifactBuildId);
assert.notEqual(pinnedResult.artifactChecksum, advancedResult.artifactChecksum);
assert.doesNotMatch(fs.readFileSync(path.join(pinnedResult.outputDirectory, "assets/styles/theme.css"), "utf8"), /branch-advanced/);
} finally { pinned.manager.dispose(); advanced.manager.dispose(); }
}));
test("a missing theme fails before any release directory exists", () => withRepository((root, output) => {
fs.rmSync(path.join(root, "packages/site-definition/.theme"), { recursive: true, force: true });
commit(root, "remove theme");
const manager = new BuildInputLoader({ repositoryRoot: root, builderVersion: BUILDER_VERSION });
try {
assert.throws(() => manager.load("missing-theme"), /E_THEME_ROOT_INVALID/);
assert.equal(fs.existsSync(path.join(output, "releases")), false);
} finally { manager.dispose(); }
}));
test("unknown manifest keys and escaping paths fail closed", () => withRepository((root) => {
const manifest = path.join(root, "packages/site-definition/.theme/theme.yml");
fs.appendFileSync(manifest, "unknownKey: rejected\n");
commit(root, "invalid manifest key");
let manager = new BuildInputLoader({ repositoryRoot: root, builderVersion: BUILDER_VERSION });
assert.throws(() => manager.load("invalid-key"), /E_THEME_MANIFEST_INVALID/);
manager.dispose();
git(root, ["reset", "--hard", "HEAD~1"]);
fs.appendFileSync(manifest, "\n");
const source = fs.readFileSync(manifest, "utf8").replace("templates/layout.liquid", "../outside.liquid");
fs.writeFileSync(manifest, source);
commit(root, "escaping template path");
manager = new BuildInputLoader({ repositoryRoot: root, builderVersion: BUILDER_VERSION });
assert.throws(() => manager.load("escape"), /E_THEME_MANIFEST_INVALID|E_THEME_PATH_ESCAPE/);
manager.dispose();
}));
test("strict Liquid rejects unknown variables without publishing a release", () => withRepository((root, output) => {
const template = path.join(root, "packages/site-definition/.theme/templates/home.liquid");
fs.appendFileSync(template, "\n{{ process.env.SECRET }}\n");
commit(root, "unsafe variable");
const { manager, input } = load(root, "strict-variable");
try {
const engine = new BuildEngine(output);
assert.throws(() => engine.build(input), /undefined variable|not defined|process/i);
assert.equal(fs.existsSync(engine.buildDirectory(input.artifactBuildId)), false);
} finally { manager.dispose(); }
}));
test("project declaration controls the project route and standalone publication", () => withRepository((root, output) => {
fs.writeFileSync(path.join(root, "packages/site-definition/projects.yml"), "projects: []\n");
commit(root, "remove project declaration");
const { manager, input } = load(root, "no-project");
try {
const result = new BuildEngine(output).build(input);
assert.equal(result.success, true);
assert.equal(input.projects.length, 0);
assert.equal(fs.existsSync(path.join(result.outputDirectory, "project/website-engine-control-plane/index.html")), false);
assert.equal(fs.existsSync(path.join(result.outputDirectory, "project/website-engine-control-plane/demo.html")), false);
} finally { manager.dispose(); }
}));
test("declared standalone files are copied byte-for-byte", () => withRepository((root, output) => {
const { manager, input } = load(root, "publication");
try {
const result = new BuildEngine(output).build(input);
const source = fs.readFileSync(path.join(root, "packages/site-definition/demo-project/demo.html"));
const published = fs.readFileSync(path.join(result.outputDirectory, "project/website-engine-control-plane/demo.html"));
assert.deepEqual(published, source);
const manifest = JSON.parse(fs.readFileSync(path.join(result.outputDirectory, "build-manifest.json"), "utf8"));
assert.equal(manifest.publishedFiles[0].sourceChecksum, `sha256-${input.publishedFiles[0].sourceChecksum}`);
} finally { manager.dispose(); }
}));
test("promotion verifies every artifact byte and rejects tampering", () => withRepository((root, output) => {
const { manager, input } = load(root, "tamper");
try {
const engine = new BuildEngine(output);
const result = engine.build(input);
fs.appendFileSync(path.join(result.outputDirectory, "index.html"), "tampered");
assert.throws(() => engine.activate(input.artifactBuildId), /E_RELEASE_CHECKSUM/);
assert.equal(fs.existsSync(engine.currentDirectory()), false);
} finally { manager.dispose(); }
}));
test("markdown output is escaped and heading IDs are deterministic", () => {
const first = renderMarkdown("# Repeat\n\n## Repeat\n\nText <img src=x> and [safe](/docs/).\n\n```html\n<script>x</script>\n```");
const second = renderMarkdown("# Repeat\n\n## Repeat\n\nText <img src=x> and [safe](/docs/).\n\n```html\n<script>x</script>\n```");
assert.equal(first, second);
assert.match(first, /id="repeat"/);
assert.match(first, /id="repeat-2"/);
assert.match(first, /&lt;img src=x&gt;/);
assert.doesNotMatch(first, /<script>x<\/script>/);
});
test("markdown renderer escapes source HTML", () => {
assert.equal(renderMarkdown("Text <img src=x>"), "<p>Text &lt;img src=x&gt;</p>");
test("production sources contain no embedded renderer or synthesized preview fallback", () => {
assert.equal(fs.readFileSync(path.resolve("src/backend/buildEngine.ts"), "utf8").trim(), 'export * from "./repositoryBuildEngine";');
const server = fs.readFileSync(path.resolve("server.ts"), "utf8");
assert.doesNotMatch(server, /cdn\.tailwindcss\.com|Simple HTML renderer|SITE HEADER/);
assert.match(server, /E_RELEASE_UNAVAILABLE/);
assert.match(server, /E_CONFIG_READ_ONLY/);
});
+1 -472
View File
@@ -1,472 +1 @@
import crypto from "crypto";
import fs from "fs";
import os from "os";
import path from "path";
import {
ContentItem,
MediaAsset,
SiteConfig,
ThemeConfig,
ValidationReport,
} from "../types";
export const BUILDER_VERSION = "1.0.0-labyricorn";
export interface BuildEngineInput {
buildId: string;
siteConfig: SiteConfig;
contentItems: ContentItem[];
mediaAssets: MediaAsset[];
sourceCommits: Record<string, string>;
themeConfig: ThemeConfig;
}
export interface BuildEngineResult {
success: boolean;
outputDirectory: string;
generatedRoutesCount: number;
artifactChecksum: string;
artifactSizeBytes: number;
validationReport: ValidationReport;
generatedFiles: string[];
}
const emptyReport = (): ValidationReport => ({
errors: [],
warnings: [],
missingMedia: [],
routeCollisions: [],
htmlPolicyViolations: [],
brokenLinks: [],
summary: { totalErrors: 0, totalWarnings: 0, passed: true },
});
const escapeHtml = (value: string): string =>
value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
const stableValue = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(stableValue);
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, child]) => [key, stableValue(child)]),
);
}
return value;
};
const stableJson = (value: unknown): string =>
`${JSON.stringify(stableValue(value), null, 2)}\n`;
const sha256 = (value: string | Buffer): string =>
crypto.createHash("sha256").update(value).digest("hex");
const normalizeRoute = (route: string): string => {
const trimmed = route.trim();
if (!trimmed || trimmed.includes("\0") || /[?#]/.test(trimmed)) {
throw new Error(`Invalid route '${route}'`);
}
const segments = trimmed
.replace(/\\/g, "/")
.split("/")
.filter(Boolean);
if (segments.some((segment) => segment === "." || segment === "..")) {
throw new Error(`Route traversal is not allowed: '${route}'`);
}
return segments.length === 0 ? "/" : `/${segments.join("/")}/`;
};
const routeFile = (route: string): string => {
const normalized = normalizeRoute(route);
return normalized === "/"
? "index.html"
: `${normalized.slice(1)}index.html`;
};
const renderInlineMarkdown = (value: string): string => {
const codeTokens: string[] = [];
let rendered = value.replace(/`([^`]+)`/g, (_match, code: string) => {
const token = `\u0000CODE${codeTokens.length}\u0000`;
codeTokens.push(`<code>${escapeHtml(code)}</code>`);
return token;
});
rendered = escapeHtml(rendered)
.replace(
/\[([^\]]+)\]\((https?:\/\/[^\s)]+|\/[^\s)]*)\)/g,
'<a href="$2" rel="noopener noreferrer">$1</a>',
)
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/\*([^*]+)\*/g, "<em>$1</em>");
return rendered.replace(/\u0000CODE(\d+)\u0000/g, (_match, index: string) =>
codeTokens[Number(index)] ?? "",
);
};
export const renderMarkdown = (markdown: string): string => {
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
const blocks: string[] = [];
let paragraph: string[] = [];
let list: string[] = [];
let code: string[] | null = null;
let codeLanguage = "";
const flushParagraph = () => {
if (paragraph.length > 0) {
blocks.push(`<p>${renderInlineMarkdown(paragraph.join(" "))}</p>`);
paragraph = [];
}
};
const flushList = () => {
if (list.length > 0) {
blocks.push(`<ul>${list.map((item) => `<li>${renderInlineMarkdown(item)}</li>`).join("")}</ul>`);
list = [];
}
};
for (const line of lines) {
const fence = line.match(/^```([A-Za-z0-9_-]*)\s*$/);
if (fence) {
if (code === null) {
flushParagraph();
flushList();
code = [];
codeLanguage = fence[1];
} else {
const languageClass = codeLanguage
? ` class="language-${escapeHtml(codeLanguage)}"`
: "";
blocks.push(`<pre><code${languageClass}>${escapeHtml(code.join("\n"))}</code></pre>`);
code = null;
codeLanguage = "";
}
continue;
}
if (code !== null) {
code.push(line);
continue;
}
if (!line.trim()) {
flushParagraph();
flushList();
continue;
}
const heading = line.match(/^(#{1,6})\s+(.+)$/);
if (heading) {
flushParagraph();
flushList();
const level = heading[1].length;
blocks.push(`<h${level}>${renderInlineMarkdown(heading[2])}</h${level}>`);
continue;
}
const listItem = line.match(/^[-*]\s+(.+)$/);
if (listItem) {
flushParagraph();
list.push(listItem[1]);
continue;
}
if (line.startsWith("> ")) {
flushParagraph();
flushList();
blocks.push(`<blockquote>${renderInlineMarkdown(line.slice(2))}</blockquote>`);
continue;
}
paragraph.push(line.trim());
}
if (code !== null) {
blocks.push(`<pre><code>${escapeHtml(code.join("\n"))}</code></pre>`);
}
flushParagraph();
flushList();
return blocks.join("\n");
};
export class BuildEngine {
readonly outputRoot: string;
constructor(outputRoot = process.env.LABYRICORN_BUILD_ROOT || path.join(os.tmpdir(), "labyricorn-builds")) {
this.outputRoot = path.resolve(outputRoot);
}
buildDirectory(buildId: string): string {
if (!/^[A-Za-z0-9._-]+$/.test(buildId)) {
throw new Error(`Invalid build ID '${buildId}'`);
}
return path.join(this.outputRoot, buildId);
}
currentDirectory(): string {
return path.join(this.outputRoot, "current");
}
nextBuildNumber(): number {
if (!fs.existsSync(this.outputRoot)) return 1;
const highestExisting = fs
.readdirSync(this.outputRoot, { withFileTypes: true })
.filter((entry) => entry.isDirectory() || entry.isSymbolicLink())
.map((entry) => entry.name.match(/^build-(\d+)$/)?.[1])
.filter((value): value is string => Boolean(value))
.map(Number)
.reduce((highest, value) => Math.max(highest, value), 0);
return highestExisting + 1;
}
validate(input: BuildEngineInput): ValidationReport {
const report = emptyReport();
const claimedRoutes = new Map<string, string>();
const claim = (route: string, owner: string) => {
try {
const normalized = normalizeRoute(route);
const existing = claimedRoutes.get(normalized);
if (existing && existing !== owner) {
report.routeCollisions.push(normalized);
report.errors.push({
code: "E_ROUTE_COLLISION",
message: `Route '${normalized}' is claimed by both ${existing} and ${owner}.`,
category: "routing",
});
} else {
claimedRoutes.set(normalized, owner);
}
} catch (error) {
report.errors.push({
code: "E_ROUTE_INVALID",
message: error instanceof Error ? error.message : String(error),
category: "routing",
});
}
};
claim("/", "site-home");
for (const navigation of input.siteConfig.navigation) {
if (navigation.route !== "/") claim(navigation.route, `navigation:${navigation.id}`);
}
const knownMedia = new Set(input.mediaAssets.map((asset) => asset.publicNamespacePath));
for (const item of input.contentItems.filter((candidate) => candidate.status === "published")) {
claim(item.route, `content:${item.id}`);
if (item.validationStatus === "error") {
report.errors.push({
code: "E_CONTENT_INVALID",
message: `Content item '${item.id}' failed source validation.`,
file: item.path,
category: "content",
});
}
const markdownWithoutCode = item.contentMarkdown
.replace(/```[\s\S]*?```/g, "")
.replace(/`[^`]*`/g, "");
if (!input.siteConfig.markdown.rawHtmlEnabled && /<\/?[A-Za-z][^>]*>/.test(markdownWithoutCode)) {
report.htmlPolicyViolations.push(item.path);
report.errors.push({
code: "E_RAW_HTML_DISABLED",
message: `Raw HTML is disabled but was found in '${item.path}'.`,
file: item.path,
category: "markdown",
});
}
for (const reference of item.mediaReferences) {
if (!/^https?:\/\//.test(reference) && !knownMedia.has(reference)) {
report.missingMedia.push(reference);
}
}
}
for (const missing of [...new Set(report.missingMedia)].sort()) {
report.errors.push({
code: "E_MEDIA_MISSING",
message: `Referenced media '${missing}' was not discovered.`,
category: "media",
});
}
for (const asset of input.mediaAssets.filter((candidate) => !candidate.isValidated)) {
report.warnings.push({
code: "W_MEDIA_UNVALIDATED",
message: `Media asset '${asset.originalPath}' has not been validated.`,
file: asset.originalPath,
category: "media",
});
}
report.routeCollisions = [...new Set(report.routeCollisions)].sort();
report.missingMedia = [...new Set(report.missingMedia)].sort();
report.htmlPolicyViolations = [...new Set(report.htmlPolicyViolations)].sort();
report.summary = {
totalErrors: report.errors.length,
totalWarnings: report.warnings.length,
passed: report.errors.length === 0,
};
return report;
}
build(input: BuildEngineInput): BuildEngineResult {
const validationReport = this.validate(input);
const outputDirectory = this.buildDirectory(input.buildId);
if (!validationReport.summary.passed) {
return {
success: false,
outputDirectory,
generatedRoutesCount: 0,
artifactChecksum: "",
artifactSizeBytes: 0,
validationReport,
generatedFiles: [],
};
}
fs.mkdirSync(this.outputRoot, { recursive: true });
const temporaryDirectory = path.join(
this.outputRoot,
`.${input.buildId}.tmp-${process.pid}-${crypto.randomUUID()}`,
);
fs.mkdirSync(temporaryDirectory, { recursive: true });
try {
const publishedItems = input.contentItems
.filter((item) => item.status === "published")
.sort((left, right) => normalizeRoute(left.route).localeCompare(normalizeRoute(right.route)) || left.id.localeCompare(right.id));
const generatedRoutes = new Set<string>();
const generatedFiles: string[] = [];
const navigation = input.siteConfig.navigation
.map((entry) => `<a href="${escapeHtml(normalizeRoute(entry.route))}">${escapeHtml(entry.label)}</a>`)
.join("");
const layout = (title: string, body: string) => `<!doctype html>
<html lang="${escapeHtml(input.siteConfig.site.language)}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(title)} | ${escapeHtml(input.siteConfig.site.title)}</title>
<style>:root{color-scheme:light dark;font-family:Inter,system-ui,sans-serif}body{max-width:72rem;margin:auto;padding:2rem;line-height:1.65}nav{display:flex;gap:1rem;flex-wrap:wrap;border-bottom:1px solid #7775;padding-bottom:1rem;margin-bottom:2rem}a{color:#b38a2e}main{max-width:52rem}article+article{border-top:1px solid #7775;margin-top:1.5rem;padding-top:1.5rem}pre{overflow:auto;padding:1rem;background:#111;color:#eee}code{font-family:ui-monospace,monospace}blockquote{border-left:.25rem solid #b38a2e;padding-left:1rem;margin-left:0}</style>
</head>
<body><nav>${navigation}</nav><main>${body}</main></body>
</html>
`;
const writeRoute = (route: string, html: string) => {
const normalized = normalizeRoute(route);
const relativeFile = routeFile(normalized);
const absoluteFile = path.join(temporaryDirectory, relativeFile);
fs.mkdirSync(path.dirname(absoluteFile), { recursive: true });
fs.writeFileSync(absoluteFile, html, "utf8");
generatedRoutes.add(normalized);
generatedFiles.push(relativeFile.replace(/\\/g, "/"));
};
const homeItems = publishedItems
.map((item) => `<article><h2><a href="${escapeHtml(normalizeRoute(item.route))}">${escapeHtml(item.title)}</a></h2><p>${escapeHtml(item.summary)}</p></article>`)
.join("");
writeRoute("/", layout(input.siteConfig.site.title, `<h1>${escapeHtml(input.siteConfig.site.title)}</h1>${homeItems || "<p>No published content.</p>"}`));
for (const entry of input.siteConfig.navigation.filter((candidate) => candidate.route !== "/")) {
const items = publishedItems
.filter((item) => item.artifactType === entry.contentModel)
.map((item) => `<article><h2><a href="${escapeHtml(normalizeRoute(item.route))}">${escapeHtml(item.title)}</a></h2><p>${escapeHtml(item.summary)}</p></article>`)
.join("");
writeRoute(entry.route, layout(entry.label, `<h1>${escapeHtml(entry.label)}</h1>${items || "<p>No published content.</p>"}`));
}
for (const item of publishedItems) {
const embeds = item.youtubeDirectives
.filter((directive) => /^[A-Za-z0-9_-]{6,20}$/.test(directive.videoId))
.map((directive) => `<section><h2>${escapeHtml(directive.title)}</h2><iframe loading="lazy" src="https://www.youtube-nocookie.com/embed/${directive.videoId}" title="${escapeHtml(directive.title)}" allowfullscreen></iframe></section>`)
.join("");
writeRoute(
item.route,
layout(item.title, `<article><h1>${escapeHtml(item.title)}</h1><p><em>${escapeHtml(item.summary)}</em></p>${renderMarkdown(item.contentMarkdown)}${embeds}</article>`),
);
}
const errorPage = layout("Not Found", "<h1>404</h1><p>The requested page was not found.</p>");
fs.writeFileSync(path.join(temporaryDirectory, "404.html"), errorPage, "utf8");
generatedFiles.push("404.html");
const manifest = {
protocol: input.siteConfig.protocol,
builderVersion: BUILDER_VERSION,
site: {
id: input.siteConfig.site.id,
baseUrl: input.siteConfig.site.baseUrl,
language: input.siteConfig.site.language,
},
sourceCommits: input.sourceCommits,
theme: { id: input.themeConfig.id, version: input.themeConfig.version },
routes: [...generatedRoutes].sort(),
media: input.mediaAssets
.map((asset) => ({ path: asset.publicNamespacePath, sha256: null, sizeBytes: asset.sizeBytes }))
.sort((left, right) => left.path.localeCompare(right.path)),
};
fs.writeFileSync(path.join(temporaryDirectory, "build-manifest.json"), stableJson(manifest), "utf8");
generatedFiles.push("build-manifest.json");
const checksums = Object.fromEntries(
generatedFiles
.sort()
.map((relativeFile) => [relativeFile, sha256(fs.readFileSync(path.join(temporaryDirectory, relativeFile)))]),
);
const checksumsDocument = stableJson({ algorithm: "sha256", files: checksums });
fs.writeFileSync(path.join(temporaryDirectory, "checksums.json"), checksumsDocument, "utf8");
generatedFiles.push("checksums.json");
const artifactChecksum = `sha256-${sha256(checksumsDocument)}`;
const artifactSizeBytes = generatedFiles.reduce(
(total, relativeFile) => total + fs.statSync(path.join(temporaryDirectory, relativeFile)).size,
0,
);
if (fs.existsSync(outputDirectory)) {
throw new Error(`Build output already exists: ${outputDirectory}`);
}
fs.renameSync(temporaryDirectory, outputDirectory);
return {
success: true,
outputDirectory,
generatedRoutesCount: generatedRoutes.size,
artifactChecksum,
artifactSizeBytes,
validationReport,
generatedFiles: generatedFiles.sort(),
};
} catch (error) {
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
throw error;
}
}
activate(buildId: string): string {
const buildDirectory = this.buildDirectory(buildId);
if (!fs.existsSync(path.join(buildDirectory, "build-manifest.json"))) {
throw new Error(`Build '${buildId}' is missing a build manifest.`);
}
fs.mkdirSync(this.outputRoot, { recursive: true });
const current = this.currentDirectory();
const next = path.join(this.outputRoot, `.current-${process.pid}-${crypto.randomUUID()}`);
fs.symlinkSync(buildDirectory, next, process.platform === "win32" ? "junction" : "dir");
try {
if (fs.existsSync(current) || fs.lstatSync(current, { throwIfNoEntry: false })) {
fs.rmSync(current, { recursive: true, force: true });
}
fs.renameSync(next, current);
} catch (error) {
fs.rmSync(next, { recursive: true, force: true });
throw error;
}
return current;
}
}
export * from "./repositoryBuildEngine";
+154
View File
@@ -0,0 +1,154 @@
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import YAML from "yaml";
import {
ContentItem,
ContentModel,
MediaAsset,
ProjectDeclaration,
ResolvedBuildInput,
ResolvedProject,
ResolvedPublishedFile,
SiteConfig,
} from "../types";
import { RepositorySnapshotManager } from "./repositories/repositorySnapshot";
import { canonicalPath, parseProjects, resolveRegularFile, ThemeLoader } from "./theme/themeLoader";
const sha256 = (value: Buffer | string) => crypto.createHash("sha256").update(value).digest("hex");
const stableValue = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(stableValue);
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value as Record<string, unknown>).sort(([a], [b]) => a.localeCompare(b)).map(([key, child]) => [key, stableValue(child)]));
return value;
};
export const stableJson = (value: unknown): string => `${JSON.stringify(stableValue(value), null, 2)}\n`;
export interface BuildInputLoaderOptions {
repositoryRoot?: string;
ref?: string;
siteDefinitionPath?: string;
workRoot?: string;
builderVersion: string;
}
export class BuildInputLoader {
private readonly snapshots: RepositorySnapshotManager;
private readonly repositoryRoot: string;
private readonly ref: string;
private readonly siteDefinitionPath: string;
private readonly builderVersion: string;
constructor(options: BuildInputLoaderOptions) {
this.repositoryRoot = path.resolve(options.repositoryRoot ?? process.cwd());
this.ref = options.ref ?? process.env.LABYRICORN_SITE_DEFINITION_REF ?? "HEAD";
this.siteDefinitionPath = canonicalPath(options.siteDefinitionPath ?? "packages/site-definition", "site definition path");
this.builderVersion = options.builderVersion;
this.snapshots = new RepositorySnapshotManager(options.workRoot ?? path.join(os.tmpdir(), "labyricorn-snapshots"));
}
load(runId: string): ResolvedBuildInput {
const siteDefinition = this.snapshots.resolve("site-definition", this.repositoryRoot, this.ref);
const sourceMap = new Map([[siteDefinition.sourceId, siteDefinition]]);
const configRoot = path.join(siteDefinition.checkoutRoot, ...this.siteDefinitionPath.split("/"));
const inputChecksums: Record<string, string> = {};
const readConfig = (relative: string): Buffer => {
const file = resolveRegularFile(configRoot, relative, `configuration '${relative}'`, 2 * 1024 * 1024);
const bytes = fs.readFileSync(file);
inputChecksums[`${this.siteDefinitionPath}/${canonicalPath(relative, "configuration path")}`] = sha256(bytes);
return bytes;
};
const siteBytes = readConfig("site.yml");
const siteDocument = YAML.parse(siteBytes.toString("utf8")) as Record<string, any>;
if (siteDocument.protocol !== "labyricorn-site/v1") throw new Error("E_SITE_CONFIG_INVALID: unsupported site protocol.");
const navigationRelative = siteDocument.navigationFile ?? "navigation.yml";
const navigationDocument = YAML.parse(readConfig(navigationRelative).toString("utf8"));
const contentModels: ContentModel[] = [];
const modelRoot = path.join(siteDefinition.checkoutRoot, "packages", "content-models");
if (fs.existsSync(modelRoot)) {
for (const name of fs.readdirSync(modelRoot).sort()) {
const modelFile = path.join(modelRoot, name, "model.yml");
if (!fs.existsSync(modelFile) || !fs.statSync(modelFile).isFile()) continue;
const bytes = fs.readFileSync(modelFile);
inputChecksums[`packages/content-models/${name}/model.yml`] = sha256(bytes);
contentModels.push(YAML.parse(bytes.toString("utf8")));
}
}
const siteConfig: SiteConfig = {
protocol: siteDocument.protocol,
site: siteDocument.site,
navigation: navigationDocument.navigation ?? [],
contentModels,
sourcesFile: siteDocument.sourcesFile ?? "sources.yml",
styleInstancesPath: siteDocument.styleInstancesPath ?? "../style-configs",
pagesPath: siteDocument.pagesPath ?? "pages",
navigationFile: navigationRelative,
projectsFile: siteDocument.projectsFile,
pushIntegrationsPath: siteDocument.pushIntegrationsPath ?? "push-integrations",
theme: { ...siteDocument.theme, path: canonicalPath(String(siteDocument.theme?.path ?? "").replace(/^\/+/, ""), "theme.path") },
markdown: siteDocument.markdown,
hosting: siteDocument.hosting,
buildPolicy: siteDocument.buildPolicy,
rawYaml: siteBytes.toString("utf8"),
sourceId: siteDefinition.sourceId,
repository: siteDefinition.repository,
commit: siteDefinition.commit,
readOnly: true,
};
let declarations: ProjectDeclaration[] = [];
if (siteConfig.projectsFile) {
const bytes = readConfig(siteConfig.projectsFile);
declarations = parseProjects(bytes);
}
const requiredSections = siteConfig.navigation.filter((entry) => entry.route !== "/").map((entry) => entry.presentation?.sectionTemplate ?? entry.contentModel).filter((value): value is string => Boolean(value));
const requiredContent = declarations.map((project) => project.presentation.detailTemplate);
const themeSnapshot = sourceMap.get(siteConfig.theme.source);
if (!themeSnapshot) throw new Error(`E_THEME_SOURCE: configured source '${siteConfig.theme.source}' is unresolved.`);
const themeRoot = path.posix.join(this.siteDefinitionPath, siteConfig.theme.path);
const theme = new ThemeLoader().load(themeSnapshot, themeRoot, requiredSections, requiredContent);
for (const [relative, checksum] of Object.entries(theme.checksums)) inputChecksums[`${themeRoot}/${relative}`] = checksum;
const contentItems: ContentItem[] = [];
const mediaAssets: MediaAsset[] = [];
const projects: ResolvedProject[] = [];
const publishedFiles: ResolvedPublishedFile[] = [];
for (const project of declarations) {
const sourceSnapshot = sourceMap.get(project.source);
if (!sourceSnapshot) throw new Error(`E_PROJECT_SOURCE: project '${project.id}' uses unresolved source '${project.source}'.`);
const relatedContent: Record<string, readonly ContentItem[]> = {};
for (const [key, relationship] of Object.entries(project.relationships ?? {})) {
const matches = contentItems.filter((item) => item.status === "published" && item.artifactType === relationship.contentModel && item.metadata?.[relationship.matchField.slice("metadata.".length) as keyof NonNullable<ContentItem["metadata"]>] === project.id).sort((a, b) => a.route.localeCompare(b.route) || a.id.localeCompare(b.id));
if (relationship.required && matches.length === 0) throw new Error(`E_PROJECT_RELATIONSHIP: required relationship '${key}' for '${project.id}' has no matches.`);
relatedContent[key] = matches;
}
projects.push(Object.freeze({ ...project, sourceSnapshot, relatedContent: Object.freeze(relatedContent) }));
for (const publication of project.publishedFiles ?? []) {
const sourcePath = canonicalPath(publication.source, `published file for '${project.id}'`);
const absolute = resolveRegularFile(sourceSnapshot.checkoutRoot, sourcePath, `published file '${sourcePath}'`);
const bytes = fs.readFileSync(absolute);
const sourceChecksum = sha256(bytes);
inputChecksums[`${sourceSnapshot.sourceId}:${sourcePath}`] = sourceChecksum;
publishedFiles.push({ ...publication, projectId: project.id, sourceSnapshot, sourcePath, sourceChecksum });
}
}
const descriptor = {
protocol: "labyricorn-build-input/v1", builderVersion: this.builderVersion,
siteDefinition: { sourceId: siteDefinition.sourceId, repository: siteDefinition.repository, commit: siteDefinition.commit },
sources: [...sourceMap.values()].map(({ sourceId, repository, commit }) => ({ sourceId, repository, commit })).sort((a, b) => a.sourceId.localeCompare(b.sourceId)),
theme: { sourceId: theme.snapshot.sourceId, commit: theme.snapshot.commit, manifestChecksum: theme.manifestChecksum },
checksums: Object.fromEntries(Object.entries(inputChecksums).sort(([a], [b]) => a.localeCompare(b))),
};
const artifactBuildId = `sha256-${sha256(stableJson(descriptor))}`;
const generatedAt = [...sourceMap.values()].map((source) => source.committedAt).sort().at(-1)!;
return Object.freeze({ runId, artifactBuildId, generatedAt, siteDefinition, sources: sourceMap, siteConfig: Object.freeze(siteConfig), theme, projects: Object.freeze(projects), contentItems: Object.freeze(contentItems), mediaAssets: Object.freeze(mediaAssets), publishedFiles: Object.freeze(publishedFiles), inputChecksums: Object.freeze(inputChecksums) });
}
dispose(): void {
this.snapshots.dispose();
}
}
@@ -0,0 +1,53 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { RepositorySnapshot } from "../../types";
const git = (repositoryRoot: string, args: string[]): string =>
execFileSync("git", ["-C", repositoryRoot, ...args], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
export class RepositorySnapshotManager {
private readonly roots = new Set<string>();
constructor(private readonly workRoot = path.join(os.tmpdir(), "labyricorn-snapshots")) {}
resolve(sourceId: string, repositoryRoot: string, ref: string): RepositorySnapshot {
const absoluteRepository = path.resolve(repositoryRoot);
let commit: string;
try {
commit = git(absoluteRepository, ["rev-parse", "--verify", `${ref}^{commit}`]);
} catch (error) {
throw new Error(`E_REPOSITORY_REF: source '${sourceId}' could not resolve ref '${ref}': ${error instanceof Error ? error.message : String(error)}`);
}
if (!/^[0-9a-f]{40}$/.test(commit)) throw new Error(`E_REPOSITORY_REF: '${ref}' did not resolve to a full commit SHA.`);
const committedAt = new Date(Number(git(absoluteRepository, ["show", "-s", "--format=%ct", commit])) * 1000)
.toISOString().replace(/\.\d{3}Z$/, "Z");
let repository = absoluteRepository;
try {
repository = git(absoluteRepository, ["remote", "get-url", "origin"]);
} catch {
// Local fixture repositories intentionally have no origin.
}
fs.mkdirSync(this.workRoot, { recursive: true });
const checkoutRoot = fs.mkdtempSync(path.join(this.workRoot, `${sourceId}-`));
const archive = path.join(checkoutRoot, ".snapshot.tar");
try {
execFileSync("git", ["-C", absoluteRepository, "archive", "--format=tar", `--output=${archive}`, commit], { stdio: ["ignore", "pipe", "pipe"] });
execFileSync("tar", ["-xf", archive, "-C", checkoutRoot], { stdio: ["ignore", "pipe", "pipe"] });
fs.rmSync(archive, { force: true });
} catch (error) {
fs.rmSync(checkoutRoot, { recursive: true, force: true });
throw new Error(`E_REPOSITORY_SNAPSHOT: source '${sourceId}' could not materialize commit '${commit}': ${error instanceof Error ? error.message : String(error)}`);
}
this.roots.add(checkoutRoot);
return Object.freeze({ sourceId, repository, commit, checkoutRoot, committedAt });
}
dispose(): void {
for (const root of this.roots) fs.rmSync(root, { recursive: true, force: true });
this.roots.clear();
}
}
+347
View File
@@ -0,0 +1,347 @@
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { Liquid } from "liquidjs";
import {
ContentItem,
ResolvedBuildInput,
ThemeFont,
ValidationReport,
} from "../types";
import { stableJson } from "./buildInputLoader";
import { resolveRegularFile, ThemeContractError } from "./theme/themeLoader";
export const BUILDER_VERSION = "2.0.0-labyricorn-theme-v1";
const sha256 = (value: Buffer | string) => crypto.createHash("sha256").update(value).digest("hex");
const toPosix = (value: string) => value.replace(/\\/g, "/");
const emptyReport = (): ValidationReport => ({ errors: [], warnings: [], missingMedia: [], routeCollisions: [], htmlPolicyViolations: [], brokenLinks: [], summary: { totalErrors: 0, totalWarnings: 0, passed: true } });
const escapeHtml = (value: unknown): string => String(value ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
const slugify = (value: string): string => value.toLowerCase().replace(/<[^>]+>/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "section";
class SafeHtml {
readonly #brand = true;
constructor(readonly value: string) {}
}
export interface RenderedMarkdown {
html: SafeHtml;
toc: Array<{ id: string; level: number; text: string }>;
}
const renderInlineMarkdown = (value: string): string => {
const code: string[] = [];
let rendered = value.replace(/`([^`]+)`/g, (_match, source: string) => {
const token = `\u0000CODE${code.length}\u0000`;
code.push(`<code>${escapeHtml(source)}</code>`);
return token;
});
rendered = escapeHtml(rendered)
.replace(/\[([^\]]+)\]\((https:\/\/[^\s)]+|\/[^\s)]*)\)/g, '<a href="$2" rel="noopener noreferrer">$1</a>')
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
.replace(/\*([^*]+)\*/g, "<em>$1</em>");
return rendered.replace(/\u0000CODE(\d+)\u0000/g, (_match, index: string) => code[Number(index)] ?? "");
};
export const renderMarkdownDocument = (markdown: string): RenderedMarkdown => {
const withoutFrontMatter = markdown.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, "");
const lines = withoutFrontMatter.replace(/\r\n/g, "\n").split("\n");
const blocks: string[] = [];
const toc: RenderedMarkdown["toc"] = [];
const seenIds = new Map<string, number>();
let paragraph: string[] = [];
let list: string[] = [];
let code: string[] | null = null;
let language = "";
const flushParagraph = () => { if (paragraph.length) { blocks.push(`<p>${renderInlineMarkdown(paragraph.join(" "))}</p>`); paragraph = []; } };
const flushList = () => { if (list.length) { blocks.push(`<ul>${list.map((item) => `<li>${renderInlineMarkdown(item)}</li>`).join("")}</ul>`); list = []; } };
for (const line of lines) {
const fence = line.match(/^```([A-Za-z0-9_-]*)\s*$/);
if (fence) {
if (code === null) { flushParagraph(); flushList(); code = []; language = fence[1]; }
else { blocks.push(`<pre><code${language ? ` class="language-${escapeHtml(language)}"` : ""}>${escapeHtml(code.join("\n"))}</code></pre>`); code = null; language = ""; }
continue;
}
if (code !== null) { code.push(line); continue; }
if (!line.trim()) { flushParagraph(); flushList(); continue; }
const heading = line.match(/^(#{1,6})\s+(.+)$/);
if (heading) {
flushParagraph(); flushList();
const level = heading[1].length;
const base = slugify(heading[2]);
const occurrence = seenIds.get(base) ?? 0;
seenIds.set(base, occurrence + 1);
const id = occurrence === 0 ? base : `${base}-${occurrence + 1}`;
toc.push({ id, level, text: heading[2] });
blocks.push(`<h${level} id="${id}">${renderInlineMarkdown(heading[2])}</h${level}>`);
continue;
}
const item = line.match(/^[-*]\s+(.+)$/);
if (item) { flushParagraph(); list.push(item[1]); continue; }
if (line.startsWith("> ")) { flushParagraph(); flushList(); blocks.push(`<blockquote>${renderInlineMarkdown(line.slice(2))}</blockquote>`); continue; }
paragraph.push(line.trim());
}
if (code !== null) blocks.push(`<pre><code>${escapeHtml(code.join("\n"))}</code></pre>`);
flushParagraph(); flushList();
return { html: new SafeHtml(blocks.join("\n")), toc };
};
export const renderMarkdown = (markdown: string): string => renderMarkdownDocument(markdown).html.value;
class StrictLiquidRenderer {
private readonly liquid: Liquid;
private readonly allowedFilters = new Set(["escape", "safe_content", "safe_page_body"]);
constructor(partialsRoot: string) {
this.liquid = new Liquid({ root: [partialsRoot], extname: ".liquid", strictVariables: true, strictFilters: true, dynamicPartials: false, relativeReference: false });
this.liquid.registerFilter("safe_content", (value: unknown) => {
if (!(value instanceof SafeHtml)) throw new Error("E_SAFE_CONTENT_TYPE: safe_content accepts only engine-created sanitized content.");
return value.value;
});
this.liquid.registerFilter("safe_page_body", (value: unknown) => {
if (!(value instanceof SafeHtml)) throw new Error("E_SAFE_PAGE_BODY_TYPE: safe_page_body accepts only an engine-rendered page body.");
return value.value;
});
}
render(source: string, context: Record<string, unknown>, name: string): string {
if (source.length > 1024 * 1024) throw new Error(`E_THEME_RESOURCE_LIMIT: template '${name}' is too large.`);
for (const match of source.matchAll(/\|\s*([A-Za-z_][A-Za-z0-9_]*)/g)) {
if (!this.allowedFilters.has(match[1])) throw new Error(`E_THEME_FILTER_POLICY: filter '${match[1]}' is not allowed in '${name}'.`);
}
const started = Date.now();
const rendered = this.liquid.parseAndRenderSync(source, context);
if (Date.now() - started > 2000 || rendered.length > 5 * 1024 * 1024) throw new Error(`E_THEME_RESOURCE_LIMIT: template '${name}' exceeded its render budget.`);
return rendered.replace(/\r\n/g, "\n");
}
}
const normalizeDirectoryRoute = (route: string): string => {
if (!route.startsWith("/") || route.includes("\0") || route.includes("\\") || /[?#]/.test(route)) throw new Error(`E_ROUTE_INVALID: invalid route '${route}'.`);
const parts = route.split("/").filter(Boolean);
if (parts.some((part) => part === "." || part === "..")) throw new Error(`E_ROUTE_INVALID: traversal in route '${route}'.`);
return parts.length ? `/${parts.join("/")}/` : "/";
};
const directoryRouteFile = (route: string) => normalizeDirectoryRoute(route) === "/" ? "index.html" : `${normalizeDirectoryRoute(route).slice(1)}index.html`;
const literalRouteFile = (route: string): string => {
if (!route.startsWith("/") || route.endsWith("/") || route.includes("\\") || route.split("/").includes("..")) throw new Error(`E_ROUTE_INVALID: invalid literal file route '${route}'.`);
return route.slice(1);
};
const validateHtml = (html: string, file: string, input: ResolvedBuildInput, declaredScripts: Set<string>, releaseFiles: Set<string>): void => {
if (/<style\b/i.test(html) || /\sstyle\s*=/i.test(html)) throw new Error(`E_OUTPUT_STYLE_POLICY: inline style is forbidden in '${file}'.`);
if (/\son[a-z]+\s*=/i.test(html)) throw new Error(`E_OUTPUT_SCRIPT_POLICY: event handlers are forbidden in '${file}'.`);
if (/javascript\s*:/i.test(html)) throw new Error(`E_OUTPUT_URL_POLICY: javascript URLs are forbidden in '${file}'.`);
for (const match of html.matchAll(/<script\b([^>]*)>/gi)) {
const src = match[1].match(/\ssrc=["']([^"']+)["']/i)?.[1];
if (!input.theme.manifest.security.allowScripts || !src || !declaredScripts.has(src)) throw new Error(`E_OUTPUT_SCRIPT_POLICY: '${file}' references an inline or undeclared script.`);
}
const allowedOrigins = new Set(input.theme.manifest.security.allowedExternalOrigins);
for (const match of html.matchAll(/<([a-z][a-z0-9]*)\b[^>]*?\s(src|srcset|href)=["']([^"']+)["'][^>]*>/gi)) {
const tag = match[1].toLowerCase();
const attribute = match[2].toLowerCase();
const value = match[3].trim();
if (tag === "a" && attribute === "href" && (/^https?:\/\//i.test(value) || /^(?:mailto|tel):/i.test(value) || value.startsWith("#"))) continue;
if (/^https?:\/\//i.test(value)) {
const allowed = input.theme.manifest.security.allowExternalAssets && allowedOrigins.has(new URL(value).origin);
if (!allowed) throw new Error(`E_THEME_EXTERNAL_ASSET: '${file}' contains runtime external asset '${value}'.`);
continue;
}
if (/^[a-z][a-z0-9+.-]*:/i.test(value)) throw new Error(`E_OUTPUT_URL_POLICY: '${file}' contains unsupported URL '${value}'.`);
const clean = value.split(/[?#]/, 1)[0];
if (!clean) continue;
let resolved = clean.startsWith("/") ? clean.slice(1) : path.posix.normalize(path.posix.join(path.posix.dirname(file), clean));
if (tag === "a" && attribute === "href" && (clean.endsWith("/") || !path.posix.extname(clean))) resolved = path.posix.join(resolved, "index.html");
if (!releaseFiles.has(resolved)) throw new Error(`E_OUTPUT_REFERENCE: '${file}' references undeclared output '${value}'.`);
}
};
const validateCss = (css: string, file: string, releaseFiles: Set<string>, allowedOrigins: Set<string>): void => {
for (const match of css.matchAll(/url\(\s*["']?([^"')]+)["']?\s*\)/gi)) {
const value = match[1];
if (/^data:/i.test(value)) continue;
if (/^https?:\/\//i.test(value)) {
if (!allowedOrigins.has(new URL(value).origin)) throw new Error(`E_THEME_EXTERNAL_ASSET: '${file}' references disallowed origin '${value}'.`);
continue;
}
const resolved = toPosix(path.posix.normalize(path.posix.join(path.posix.dirname(`/${file}`), value))).replace(/^\//, "");
if (!releaseFiles.has(resolved)) throw new Error(`E_OUTPUT_REFERENCE: '${file}' references undeclared file '${value}'.`);
}
};
export interface BuildEngineResult {
success: boolean;
outputDirectory: string;
generatedRoutesCount: number;
artifactChecksum: string;
artifactSizeBytes: number;
validationReport: ValidationReport;
generatedFiles: string[];
}
export class BuildEngine {
readonly outputRoot: string;
constructor(outputRoot = process.env.LABYRICORN_BUILD_ROOT || path.join(os.tmpdir(), "labyricorn-builds")) { this.outputRoot = path.resolve(outputRoot); }
releasesDirectory(): string { return path.join(this.outputRoot, "releases"); }
buildDirectory(artifactBuildId: string): string { if (!/^sha256-[0-9a-f]{64}$/.test(artifactBuildId)) throw new Error(`Invalid artifact build ID '${artifactBuildId}'.`); return path.join(this.releasesDirectory(), artifactBuildId); }
currentDirectory(): string { return path.join(this.outputRoot, "current"); }
stagingDirectory(): string { return path.join(this.outputRoot, "staging"); }
nextBuildNumber(): number { return 1; }
validate(input: ResolvedBuildInput): ValidationReport {
const report = emptyReport();
const claims = new Map<string, string>();
const claim = (file: string, owner: string) => {
const existing = claims.get(file);
if (existing) { report.routeCollisions.push(`/${file}`); report.errors.push({ code: "E_ROUTE_COLLISION", message: `Output '${file}' is claimed by ${existing} and ${owner}.`, category: "routing" }); }
else claims.set(file, owner);
};
claim("index.html", "home"); claim("404.html", "not-found");
for (const entry of input.siteConfig.navigation.filter((item) => item.route !== "/")) claim(directoryRouteFile(entry.route), `navigation:${entry.id}`);
for (const project of input.projects) claim(directoryRouteFile(project.route), `project:${project.id}`);
for (const publication of input.publishedFiles) claim(literalRouteFile(publication.route), `published:${publication.projectId}`);
for (const item of input.contentItems.filter((candidate) => candidate.status === "published")) {
claim(directoryRouteFile(item.route), `content:${item.id}`);
const markdownWithoutCode = item.contentMarkdown.replace(/```[\s\S]*?```/g, "").replace(/`[^`]*`/g, "");
if (!input.siteConfig.markdown.rawHtmlEnabled && /<\/?[A-Za-z][^>]*>/.test(markdownWithoutCode)) { report.htmlPolicyViolations.push(item.path); report.errors.push({ code: "E_RAW_HTML_DISABLED", message: `Raw HTML is disabled in '${item.path}'.`, file: item.path, category: "markdown" }); }
}
report.routeCollisions.sort(); report.htmlPolicyViolations.sort();
report.summary = { totalErrors: report.errors.length, totalWarnings: report.warnings.length, passed: report.errors.length === 0 };
return report;
}
build(input: ResolvedBuildInput): BuildEngineResult {
const validationReport = this.validate(input);
const outputDirectory = this.buildDirectory(input.artifactBuildId);
if (!validationReport.summary.passed) return { success: false, outputDirectory, generatedRoutesCount: 0, artifactChecksum: "", artifactSizeBytes: 0, validationReport, generatedFiles: [] };
if (fs.existsSync(outputDirectory)) {
const verified = this.verifyRelease(input.artifactBuildId);
return { success: true, outputDirectory, generatedRoutesCount: verified.routes.length, artifactChecksum: verified.artifactChecksum, artifactSizeBytes: verified.sizeBytes, validationReport, generatedFiles: verified.files };
}
fs.mkdirSync(this.releasesDirectory(), { recursive: true });
const temporaryDirectory = fs.mkdtempSync(path.join(this.outputRoot, ".release-"));
const files = new Set<string>();
const routes = new Set<string>();
const write = (relative: string, bytes: Buffer | string) => {
const normalized = toPosix(relative);
if (files.has(normalized)) throw new Error(`E_ROUTE_COLLISION: duplicate output '${normalized}'.`);
const target = path.join(temporaryDirectory, ...normalized.split("/"));
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, bytes);
files.add(normalized);
};
try {
for (const relative of [...input.theme.manifest.assets.styles, ...input.theme.manifest.assets.scripts, ...input.theme.manifest.assets.files]) write(relative, fs.readFileSync(resolveRegularFile(input.theme.root, relative, `asset '${relative}'`)));
const fontCss: string[] = [];
for (const font of input.theme.manifest.assets.fonts) {
const released = this.resolveFont(font, input);
write(released.path, released.bytes);
fontCss.push(`@font-face{font-family:${JSON.stringify(font.family)};font-style:${font.style};font-weight:${font.weight};font-display:swap;src:url('/${released.path}') format('${font.source.format}');}`);
}
if (fontCss.length) write("assets/styles/fonts.generated.css", `${fontCss.join("\n")}\n`);
for (const publication of input.publishedFiles) write(literalRouteFile(publication.route), fs.readFileSync(resolveRegularFile(publication.sourceSnapshot.checkoutRoot, publication.sourcePath, `published file '${publication.sourcePath}'`)));
const renderer = new StrictLiquidRenderer(path.join(input.theme.root, "partials"));
const stylesheets = input.theme.manifest.assets.styles.map((asset) => `/${asset}`);
if (fontCss.length) stylesheets.push("/assets/styles/fonts.generated.css");
const scripts = input.theme.manifest.assets.scripts.map((asset) => `/${asset}`);
const navigation = input.siteConfig.navigation.map(({ id, label, route }) => ({ id, label, route: normalizeDirectoryRoute(route) }));
const base = { site: input.siteConfig.site, navigation, theme: { id: input.theme.manifest.id, version: input.theme.manifest.version, stylesheets, scripts, fontStylesheet: fontCss.length ? "/assets/styles/fonts.generated.css" : null }, build: { id: input.artifactBuildId, generatedAt: input.generatedAt } };
const page = (templateKey: string, title: string, context: Record<string, unknown>, output: string, route: string) => {
const bodySource = input.theme.templateSources[templateKey];
if (!bodySource) throw new Error(`E_PROJECT_TEMPLATE_UNRESOLVED: template '${templateKey}' is missing.`);
const body = new SafeHtml(renderer.render(bodySource, { ...base, ...context }, templateKey));
const html = renderer.render(input.theme.templateSources.layout, { ...base, ...context, page: { title, body } }, "layout");
write(output, html.endsWith("\n") ? html : `${html}\n`); routes.add(route);
};
page("home", input.siteConfig.site.title, { content: input.contentItems }, "index.html", "/");
for (const entry of input.siteConfig.navigation.filter((item) => item.route !== "/")) {
const key = entry.presentation?.sectionTemplate ?? entry.contentModel!;
const items = input.contentItems.filter((item) => item.status === "published" && item.artifactType === entry.contentModel);
page(`sections.${key}`, entry.label, { section: entry, content: items }, directoryRouteFile(entry.route), normalizeDirectoryRoute(entry.route));
}
for (const project of input.projects) page(`content.${project.presentation.detailTemplate}`, project.title ?? project.id, { project: { ...project, sourceSnapshot: { sourceId: project.sourceSnapshot.sourceId, repository: project.sourceSnapshot.repository, commit: project.sourceSnapshot.commit, committedAt: project.sourceSnapshot.committedAt } } }, directoryRouteFile(project.route), normalizeDirectoryRoute(project.route));
for (const item of input.contentItems.filter((candidate) => candidate.status === "published")) {
const rendered = renderMarkdownDocument(item.contentMarkdown);
const key = item.presentation?.standalone ?? item.artifactType;
page(`content.${key}`, item.title, { item: { ...item, content: rendered.html, tableOfContents: rendered.toc } }, directoryRouteFile(item.route), normalizeDirectoryRoute(item.route));
}
page("notFound", "Not Found", {}, "404.html", "/404.html");
const declaredScripts = new Set(scripts);
const releaseFiles = new Set(files);
for (const relative of [...files].sort()) {
const bytes = fs.readFileSync(path.join(temporaryDirectory, ...relative.split("/")));
if (relative.endsWith(".html")) validateHtml(bytes.toString("utf8"), relative, input, declaredScripts, releaseFiles);
if (relative.endsWith(".css")) validateCss(bytes.toString("utf8"), relative, releaseFiles, new Set(input.theme.manifest.security.allowedExternalOrigins));
}
const manifest = { protocol: "labyricorn-build-manifest/v1", builderVersion: BUILDER_VERSION, artifactBuildId: input.artifactBuildId, generatedAt: input.generatedAt, siteDefinition: { sourceId: input.siteDefinition.sourceId, repository: input.siteDefinition.repository, commit: input.siteDefinition.commit }, sources: Object.fromEntries([...input.sources.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([id, source]) => [id, { repository: source.repository, commit: source.commit }])), theme: { id: input.theme.manifest.id, version: input.theme.manifest.version, sourceId: input.theme.snapshot.sourceId, repository: input.theme.snapshot.repository, commit: input.theme.snapshot.commit, manifestChecksum: input.theme.manifestChecksum, templates: input.theme.manifest.templates, inputs: input.theme.checksums }, projects: input.projects.map((project) => ({ id: project.id, source: project.source, route: project.route, template: project.presentation.detailTemplate })), publishedFiles: input.publishedFiles.map((file) => ({ projectId: file.projectId, source: file.sourcePath, route: file.route, mediaType: file.mediaType, sourceChecksum: `sha256-${file.sourceChecksum}` })), fonts: input.theme.manifest.assets.fonts, routes: [...routes].sort(), inputChecksums: input.inputChecksums };
write("build-manifest.json", stableJson(manifest));
const checksums = Object.fromEntries([...files].sort().map((relative) => [relative, `sha256-${sha256(fs.readFileSync(path.join(temporaryDirectory, ...relative.split("/"))))}`]));
const checksumsDocument = stableJson({ algorithm: "sha256", files: checksums });
write("checksums.json", checksumsDocument);
const artifactChecksum = `sha256-${sha256(checksumsDocument)}`;
const generatedFiles = [...files].sort();
const artifactSizeBytes = generatedFiles.reduce((sum, relative) => sum + fs.statSync(path.join(temporaryDirectory, ...relative.split("/"))).size, 0);
fs.renameSync(temporaryDirectory, outputDirectory);
return { success: true, outputDirectory, generatedRoutesCount: routes.size, artifactChecksum, artifactSizeBytes, validationReport, generatedFiles };
} catch (error) {
fs.rmSync(temporaryDirectory, { recursive: true, force: true });
if (error instanceof ThemeContractError) throw error;
throw error;
}
}
private resolveFont(font: ThemeFont, input: ResolvedBuildInput): { path: string; bytes: Buffer } {
let bytes: Buffer;
if (font.source.kind === "git") {
const snapshot = input.sources.get(font.source.sourceId);
if (!snapshot) throw new Error(`E_THEME_FONT_SOURCE: unresolved Git font source '${font.source.sourceId}'.`);
bytes = fs.readFileSync(resolveRegularFile(snapshot.checkoutRoot, font.source.path, `font '${font.id}'`));
} else {
const url = new URL(font.source.url);
if (url.protocol !== "https:" || url.username || url.password) throw new Error(`E_THEME_FONT_SOURCE: unsafe URL for '${font.id}'.`);
const cache = path.join(this.outputRoot, "font-cache", font.source.checksum.slice("sha256-".length));
if (fs.existsSync(cache)) bytes = fs.readFileSync(cache);
else {
bytes = execFileSync("curl", ["--proto", "=https", "--silent", "--show-error", "--fail", "--max-redirs", "0", "--max-time", "15", font.source.url], { maxBuffer: 20 * 1024 * 1024 });
fs.mkdirSync(path.dirname(cache), { recursive: true });
}
if (`sha256-${sha256(bytes)}` !== font.source.checksum) throw new Error(`E_THEME_FONT_SOURCE: checksum mismatch for '${font.id}'.`);
if (!fs.existsSync(cache)) fs.writeFileSync(cache, bytes, { flag: "wx" });
}
return { path: `assets/fonts/${font.id}.${font.source.format}`, bytes };
}
verifyRelease(artifactBuildId: string): { artifactChecksum: string; files: string[]; routes: string[]; sizeBytes: number } {
const release = this.buildDirectory(artifactBuildId);
const manifestFile = path.join(release, "build-manifest.json");
const checksumsFile = path.join(release, "checksums.json");
if (!fs.existsSync(manifestFile) || !fs.existsSync(checksumsFile)) throw new Error(`E_RELEASE_INVALID: '${artifactBuildId}' is missing release metadata.`);
const manifest = JSON.parse(fs.readFileSync(manifestFile, "utf8"));
if (manifest.artifactBuildId !== artifactBuildId) throw new Error("E_RELEASE_INVALID: manifest artifact ID mismatch.");
const document = fs.readFileSync(checksumsFile, "utf8");
const checksums = JSON.parse(document) as { files: Record<string, string> };
let sizeBytes = fs.statSync(checksumsFile).size;
for (const [relative, checksum] of Object.entries(checksums.files)) {
const absolute = path.resolve(release, ...relative.split("/"));
if (!absolute.startsWith(`${release}${path.sep}`) || !fs.existsSync(absolute) || `sha256-${sha256(fs.readFileSync(absolute))}` !== checksum) throw new Error(`E_RELEASE_CHECKSUM: '${relative}' failed verification.`);
sizeBytes += fs.statSync(absolute).size;
}
return { artifactChecksum: `sha256-${sha256(document)}`, files: [...Object.keys(checksums.files), "checksums.json"].sort(), routes: manifest.routes ?? [], sizeBytes };
}
stage(artifactBuildId: string): string { this.verifyRelease(artifactBuildId); return this.point("staging", artifactBuildId); }
activate(artifactBuildId: string): string { this.verifyRelease(artifactBuildId); return this.point("current", artifactBuildId); }
private point(name: "staging" | "current", artifactBuildId: string): string {
fs.mkdirSync(this.outputRoot, { recursive: true });
const target = this.buildDirectory(artifactBuildId);
const pointer = path.join(this.outputRoot, name);
const next = path.join(this.outputRoot, `.${name}-${process.pid}-${crypto.randomUUID()}`);
fs.symlinkSync(target, next, process.platform === "win32" ? "junction" : "dir");
try { if (fs.lstatSync(pointer, { throwIfNoEntry: false })) fs.rmSync(pointer, { recursive: true, force: true }); fs.renameSync(next, pointer); }
catch (error) { fs.rmSync(next, { recursive: true, force: true }); throw error; }
return pointer;
}
}
+210 -512
View File
@@ -1,549 +1,247 @@
import fs from "node:fs";
import path from "node:path";
import YAML from "yaml";
import {
GitSource,
StyleConfigPackage,
StyleConfigInstance,
ContentItem,
MediaAsset,
ThemeConfig,
AuditLogEntry,
BuildLogEntry,
BuildRecord,
ContentItem,
CredentialRef,
GitSource,
MediaAsset,
NginxStatus,
PushTarget,
CredentialRef,
AuditLogEntry,
SiteConfig,
StyleConfigInstance,
StyleConfigPackage,
ThemeConfig,
ValidationReport,
BuildLogEntry,
NavigationEntry,
ContentModel,
} from "../types";
import YAML from "yaml";
import fs from "fs";
import path from "path";
import { BuildInputLoader } from "./buildInputLoader";
import { BuildEngine, BUILDER_VERSION } from "./buildEngine";
import { ThemeContractError } from "./theme/themeLoader";
const emptyReport = (): ValidationReport => ({ errors: [], warnings: [], missingMedia: [], routeCollisions: [], htmlPolicyViolations: [], brokenLinks: [], summary: { totalErrors: 0, totalWarnings: 0, passed: true } });
const fallbackSiteConfig = (): SiteConfig => {
const sitePath = path.resolve("packages/site-definition/site.yml");
const navigationPath = path.resolve("packages/site-definition/navigation.yml");
const rawYaml = fs.readFileSync(sitePath, "utf8");
const document = YAML.parse(rawYaml);
const navigation = YAML.parse(fs.readFileSync(navigationPath, "utf8")).navigation ?? [];
return {
protocol: document.protocol,
site: document.site,
navigation,
contentModels: [],
sourcesFile: document.sourcesFile ?? "sources.yml",
styleInstancesPath: document.styleInstancesPath ?? "../style-configs",
pagesPath: document.pagesPath ?? "pages",
navigationFile: document.navigationFile ?? "navigation.yml",
projectsFile: document.projectsFile,
pushIntegrationsPath: document.pushIntegrationsPath ?? "push-integrations",
theme: { ...document.theme, path: String(document.theme.path).replace(/^\/+/, "") },
markdown: document.markdown,
hosting: document.hosting,
buildPolicy: document.buildPolicy,
rawYaml,
readOnly: true,
};
};
export class LabyricornStore {
siteConfig: SiteConfig;
gitSources: GitSource[];
stylePackages: StyleConfigPackage[];
styleInstances: StyleConfigInstance[];
contentItems: ContentItem[];
mediaAssets: MediaAsset[];
themeConfig: ThemeConfig;
builds: BuildRecord[];
siteConfig: SiteConfig = fallbackSiteConfig();
gitSources: GitSource[] = [];
stylePackages: StyleConfigPackage[] = [];
styleInstances: StyleConfigInstance[] = [];
contentItems: ContentItem[] = [];
mediaAssets: MediaAsset[] = [];
themeConfig: ThemeConfig = {
id: "unresolved", name: "Theme not resolved", version: "0.0.0", path: this.siteConfig.theme.path,
templates: {}, styles: [], scripts: [], supportsPackages: [], isValidated: false,
validationErrors: ["No immutable repository snapshot has been loaded."], status: "unresolved",
};
builds: BuildRecord[] = [];
pushTargets: PushTarget[] = [];
credentials: CredentialRef[] = [];
auditLogs: AuditLogEntry[] = [];
readonly buildEngine = new BuildEngine();
nginxStatus: NginxStatus;
pushTargets: PushTarget[];
credentials: CredentialRef[];
auditLogs: AuditLogEntry[];
private readonly buildEngine = new BuildEngine();
constructor() {
this.gitSources = [];
this.stylePackages = [];
this.styleInstances = [];
this.contentItems = [];
this.mediaAssets = [];
this.builds = [];
this.pushTargets = [];
this.credentials = [];
this.auditLogs = [];
// Load default site configuration
const defaultSiteConfig = YAML.parse(
fs.readFileSync("./packages/site-definition/site.yml", "utf-8"),
);
const defaultNavConfig = YAML.parse(
fs.readFileSync("./packages/site-definition/navigation.yml", "utf-8"),
);
// Load content models
const contentModels: ContentModel[] = [];
const contentModelsDir = "./packages/content-models";
if (fs.existsSync(contentModelsDir)) {
const dirs = fs.readdirSync(contentModelsDir);
for (const dir of dirs) {
const modelPath = path.join(contentModelsDir, dir, "model.yml");
if (fs.existsSync(modelPath)) {
contentModels.push(YAML.parse(fs.readFileSync(modelPath, "utf-8")));
}
}
}
// Load style configurations
const stylePackagesDir = "./packages/style-configs";
if (fs.existsSync(stylePackagesDir)) {
const dirs = fs.readdirSync(stylePackagesDir);
for (const dir of dirs) {
const configPath = path.join(stylePackagesDir, dir, "config.yml");
if (fs.existsSync(configPath)) {
this.stylePackages.push(
YAML.parse(fs.readFileSync(configPath, "utf-8")),
);
}
}
}
this.siteConfig = {
protocol: defaultSiteConfig.protocol || "labyricorn-site/v1",
site: defaultSiteConfig.site,
navigation: defaultNavConfig.navigation || [],
contentModels: contentModels,
sourcesFile: "./sources.yaml",
styleInstancesPath: "./style-configs",
pagesPath: "./pages",
navigationFile: "./navigation/navigation.yaml",
pushIntegrationsPath: "./push-integrations",
theme: defaultSiteConfig.theme,
markdown: defaultSiteConfig.markdown,
hosting: defaultSiteConfig.hosting,
buildPolicy: defaultSiteConfig.buildPolicy,
rawYaml: "",
};
this.siteConfig.rawYaml = YAML.stringify({
protocol: this.siteConfig.protocol,
site: this.siteConfig.site,
navigation: this.siteConfig.navigation,
contentModels: this.siteConfig.contentModels,
theme: this.siteConfig.theme,
markdown: this.siteConfig.markdown,
hosting: this.siteConfig.hosting,
buildPolicy: this.siteConfig.buildPolicy,
});
this.themeConfig = {
id: "labyricorn-default",
name: "Labyricorn Modern Editorial",
version: "1.2.0",
path: "/.theme",
templates: {
page: "templates/page.html",
error: "templates/error.html",
"devlogs/entry": "templates/devlogs/entry.html",
"devlogs/detailed-summary": "templates/devlogs/detailed-summary.html",
"blog/post": "templates/blog/post.html",
},
styles: ["styles/reset.css", "styles/theme.css", "styles/components.css"],
scripts: ["scripts/theme.js"],
supportsPackages: this.stylePackages.map((pkg) => pkg.id),
isValidated: true,
validationErrors: [],
};
this.loadStylePackagesForDisplay();
this.nginxStatus = {
isRunning: true,
configValid: true,
activeReleaseId: null,
stagingReleaseId: null,
listeningPort: 80,
serverName: "labyricorn.local",
lastReloadTime: new Date().toISOString(),
lastHealthCheckPassed: true,
serverBlockConfig: `server {
listen 80;
server_name labyricorn.local;
root /var/lib/labyricorn/site/current;
index index.html;
location /media/ {
alias /var/lib/labyricorn/site/current/media/;
expires 30d;
add_header Cache-Control "public, no-transform";
}
location / {
try_files $uri $uri/ /index.html;
}
error_page 404 /404.html;
}`,
stagingServerBlockConfig: `server {
listen 8080;
server_name preview.labyricorn.local;
root /var/lib/labyricorn/site/staging;
index index.html;
location /media/ {
alias /var/lib/labyricorn/site/staging/media/;
}
location / {
try_files $uri $uri/ /index.html;
}
}`,
isRunning: true, configValid: true, activeReleaseId: null, stagingReleaseId: null,
listeningPort: 80, serverName: this.siteConfig.hosting.production.hostname,
lastReloadTime: new Date(0).toISOString(), lastHealthCheckPassed: false,
serverBlockConfig: "root /var/lib/labyricorn/site/current;",
stagingServerBlockConfig: "root /var/lib/labyricorn/site/staging;",
};
this.restoreReleasePointers();
try { this.resolveConfiguration("startup"); } catch (error) {
this.themeConfig = { ...this.themeConfig, status: "unresolved", isValidated: false, validationErrors: [error instanceof Error ? error.message : String(error)] };
}
}
// --- ACTIONS ---
private loadStylePackagesForDisplay(): void {
const root = path.resolve("packages/style-configs");
if (!fs.existsSync(root)) return;
for (const name of fs.readdirSync(root).sort()) {
const file = path.join(root, name, "config.yml");
if (fs.existsSync(file)) this.stylePackages.push(YAML.parse(fs.readFileSync(file, "utf8")));
}
}
addAudit(
action: string,
category: AuditLogEntry["category"],
details: string,
result: "success" | "failure" = "success",
) {
const entry: AuditLogEntry = {
id: `log-${Date.now()}`,
timestamp: new Date().toISOString(),
user: "[email protected]",
action,
category,
details,
result,
private loader(): BuildInputLoader {
return new BuildInputLoader({ repositoryRoot: process.env.LABYRICORN_SITE_DEFINITION_REPOSITORY ?? process.cwd(), ref: process.env.LABYRICORN_SITE_DEFINITION_REF ?? "HEAD", siteDefinitionPath: process.env.LABYRICORN_SITE_DEFINITION_PATH ?? "packages/site-definition", builderVersion: BUILDER_VERSION });
}
private applyResolvedInput(input: ReturnType<BuildInputLoader["load"]>): void {
this.siteConfig = input.siteConfig;
this.contentItems = [...input.contentItems];
this.mediaAssets = [...input.mediaAssets];
const manifest = input.theme.manifest;
this.themeConfig = {
id: manifest.id, name: manifest.id, version: manifest.version, path: input.siteConfig.theme.path,
templates: {
layout: manifest.templates.layout, home: manifest.templates.home, notFound: manifest.templates.notFound,
...Object.fromEntries(Object.entries(manifest.templates.sections ?? {}).map(([key, value]) => [`sections.${key}`, value])),
...Object.fromEntries(Object.entries(manifest.templates.content ?? {}).map(([key, value]) => [`content.${key}`, value])),
},
styles: [...manifest.assets.styles], scripts: [...manifest.assets.scripts], supportsPackages: this.stylePackages.map((item) => item.id),
isValidated: true, validationErrors: [], status: "valid", protocol: manifest.protocol,
sourceId: input.theme.snapshot.sourceId, repository: input.theme.snapshot.repository, commit: input.theme.snapshot.commit,
manifestChecksum: input.theme.manifestChecksum, checksums: { ...input.theme.checksums },
};
this.auditLogs.unshift(entry);
this.gitSources = [{
id: input.siteDefinition.sourceId, type: "site-definition", repository: input.siteDefinition.repository,
ref: process.env.LABYRICORN_SITE_DEFINITION_REF ?? "HEAD", required: true, allowWorkingTree: false,
health: "Healthy", reachability: "Healthy", configurationState: "Valid", visibility: "Private in Gitea",
projectionEnabled: true, produces: ["site", "theme", "projects"], status: "connected",
lastResolvedCommit: input.siteDefinition.commit, lastSyncedAt: input.generatedAt, branch: process.env.LABYRICORN_SITE_DEFINITION_REF ?? "HEAD",
}];
}
private resolveConfiguration(runId: string): void {
const loader = this.loader();
try { this.applyResolvedInput(loader.load(runId)); }
finally { loader.dispose(); }
}
addAudit(action: string, category: AuditLogEntry["category"], details: string, result: "success" | "failure" = "success"): void {
this.auditLogs.unshift({ id: `log-${Date.now()}-${this.auditLogs.length}`, timestamp: new Date().toISOString(), user: "[email protected]", action, category, details, result });
}
validateTheme(): ThemeConfig {
const loader = this.loader();
try {
const input = loader.load(`validation-${Date.now()}`);
this.applyResolvedInput(input);
this.addAudit("Validate Theme", "config", `Validated ${input.theme.manifest.id} at ${input.theme.snapshot.commit}`);
return this.themeConfig;
} catch (error) {
const messages = error instanceof ThemeContractError ? error.issues.map((issue) => `${issue.code}: ${issue.message}`) : [error instanceof Error ? error.message : String(error)];
this.themeConfig = { ...this.themeConfig, status: "invalid", isValidated: false, validationErrors: messages };
this.addAudit("Validate Theme", "config", messages.join("; "), "failure");
return this.themeConfig;
} finally { loader.dispose(); }
}
runBuild(): BuildRecord {
const buildStartedAt = Date.now();
const nextBuildNum = Math.max(
this.builds.reduce((highest, build) => Math.max(highest, build.buildNumber), 0) + 1,
this.buildEngine.nextBuildNumber(),
);
const buildId = `build-${String(nextBuildNum).padStart(6, "0")}`;
const number = this.builds.reduce((highest, build) => Math.max(highest, build.buildNumber), 0) + 1;
const runId = `build-${String(number).padStart(6, "0")}`;
const startedAt = Date.now();
const timestamp = new Date().toISOString();
const logs: BuildLogEntry[] = [
{
timestamp,
level: "info",
message: `Initializing Build #${nextBuildNum}...`,
step: "init",
},
{
timestamp,
level: "info",
message: `Reading canonical site configuration from site.yaml`,
step: "config",
},
{
timestamp,
level: "info",
message: `Resolving ${this.gitSources.length} Git sources...`,
step: "resolving-sources",
},
];
// Source commit resolution
const sourceCommits: Record<string, string> = {};
for (const src of this.gitSources) {
if (!src.projectionEnabled || src.health !== "Healthy") {
continue;
}
sourceCommits[src.id] = src.lastResolvedCommit || "unknown";
logs.push({
timestamp: new Date().toISOString(),
level: "info",
message: `Resolved source '${src.id}' (${src.ref}) -> commit ${src.lastResolvedCommit?.substring(0, 7) || "unknown"}`,
step: "resolving-sources",
});
}
// Content discovery
logs.push({
timestamp: new Date().toISOString(),
level: "info",
message: `Discovering Markdown content items across style instances...`,
step: "content-discovery",
});
logs.push({
timestamp: new Date().toISOString(),
level: "info",
message: `Found ${this.contentItems.length} Markdown items and ${this.mediaAssets.length} media assets.`,
step: "content-discovery",
});
// Validation
logs.push({
timestamp: new Date().toISOString(),
level: "info",
message: `Validating schemas, YouTube directives, Wikipedia links, raw HTML policy...`,
step: "validating",
});
logs.push({
timestamp: new Date().toISOString(),
level: "info",
message: `Rendering theme templates from ${this.siteConfig.theme.path}...`,
step: "building",
});
let validationReport: ValidationReport = {
errors: [],
warnings: [],
missingMedia: [],
routeCollisions: [],
htmlPolicyViolations: [],
brokenLinks: [],
summary: { totalErrors: 0, totalWarnings: 0, passed: true },
};
let engineResult: ReturnType<BuildEngine["build"]>;
const logs: BuildLogEntry[] = [{ timestamp, level: "info", message: "Resolving immutable repository inputs...", step: "resolving-sources" }];
const loader = this.loader();
try {
engineResult = this.buildEngine.build({
buildId,
siteConfig: this.siteConfig,
contentItems: this.contentItems,
mediaAssets: this.mediaAssets,
sourceCommits,
themeConfig: this.themeConfig,
});
validationReport = engineResult.validationReport;
if (!engineResult.success) {
const validationError = new Error(
`Validation failed with ${validationReport.summary.totalErrors} error(s).`,
);
(validationError as Error & { validationReport: ValidationReport }).validationReport =
validationReport;
throw validationError;
}
logs.push({
timestamp: new Date().toISOString(),
level: "success",
message: `Static site build complete. Output written to ${engineResult.outputDirectory}`,
step: "built",
});
logs.push({
timestamp: new Date().toISOString(),
level: "success",
message: `Generated build-manifest.json and checksums.json (${engineResult.artifactChecksum.substring(0, 20)}...)`,
step: "built",
});
} catch (e: any) {
logs.push({
timestamp: new Date().toISOString(),
level: "error",
message: `Build failed: ${e.message}`,
step: "building",
});
const failureReport: ValidationReport =
e.validationReport || {
errors: [
{ code: "E_BUILD_FAIL", message: e.message, category: "build" },
],
warnings: [],
missingMedia: [],
routeCollisions: [],
htmlPolicyViolations: [],
brokenLinks: [],
summary: { totalErrors: 1, totalWarnings: 0, passed: false },
};
const failedBuild: BuildRecord = {
id: buildId,
buildNumber: nextBuildNum,
timestamp,
durationMs: Date.now() - buildStartedAt,
status: "failed",
siteDefCommit: "local",
sourceCommits,
themeVersion: this.themeConfig.version,
builderVersion: BUILDER_VERSION,
generatedRoutesCount: 0,
mediaCount: 0,
artifactChecksum: "",
artifactSizeBytes: 0,
validationReport: failureReport,
logs,
stages: [
{ name: "Triggered", status: "succeeded", durationMs: 10 },
{
name: "Repository discovery",
status: "succeeded",
durationMs: 150,
},
{
name: "Repository synchronization",
status: "succeeded",
durationMs: 450,
},
{
name: "Metadata validation",
status: failureReport.summary.passed ? "succeeded" : "failed",
durationMs: 120,
},
{
name: "Artifact discovery",
status: failureReport.summary.passed ? "succeeded" : "skipped",
durationMs: failureReport.summary.passed ? 310 : undefined,
},
{
name: "Website projection build",
status: failureReport.summary.passed ? "failed" : "skipped",
durationMs: failureReport.summary.passed ? 150 : undefined,
},
{ name: "Local staging", status: "skipped" },
],
isStaged: false,
isActiveLocal: false,
const input = loader.load(runId);
this.applyResolvedInput(input);
logs.push({ timestamp: new Date().toISOString(), level: "success", message: `Resolved site definition ${input.siteDefinition.commit}.`, step: "resolving-sources" });
const result = this.buildEngine.build(input);
if (!result.success) throw Object.assign(new Error(`Validation failed with ${result.validationReport.summary.totalErrors} error(s).`), { validationReport: result.validationReport });
logs.push({ timestamp: new Date().toISOString(), level: "success", message: `Published immutable artifact ${input.artifactBuildId}.`, step: "built" });
const record: BuildRecord = {
id: runId, artifactBuildId: input.artifactBuildId, buildNumber: number, timestamp, durationMs: Date.now() - startedAt,
status: "built", siteDefCommit: input.siteDefinition.commit,
sourceCommits: Object.fromEntries([...input.sources.entries()].map(([id, source]) => [id, source.commit])),
themeVersion: input.theme.manifest.version, builderVersion: BUILDER_VERSION, generatedRoutesCount: result.generatedRoutesCount,
mediaCount: input.mediaAssets.length, artifactChecksum: result.artifactChecksum, artifactSizeBytes: result.artifactSizeBytes,
validationReport: result.validationReport, logs,
stages: [{ name: "Repository snapshots", status: "succeeded" }, { name: "Theme validation", status: "succeeded" }, { name: "Artifact rendering", status: "succeeded" }, { name: "Local staging", status: "pending" }],
isStaged: false, isActiveLocal: false,
};
this.builds.unshift(failedBuild);
return failedBuild;
}
const newBuild: BuildRecord = {
id: buildId,
buildNumber: nextBuildNum,
timestamp,
durationMs: Date.now() - buildStartedAt,
status: "built",
siteDefCommit: "local",
sourceCommits,
themeVersion: this.themeConfig.version,
builderVersion: BUILDER_VERSION,
generatedRoutesCount: engineResult.generatedRoutesCount,
mediaCount: this.mediaAssets.length,
artifactChecksum: engineResult.artifactChecksum,
artifactSizeBytes: engineResult.artifactSizeBytes,
validationReport,
logs,
stages: [
{ name: "Triggered", status: "succeeded", durationMs: 10 },
{ name: "Repository discovery", status: "succeeded", durationMs: 150 },
{
name: "Repository synchronization",
status: "succeeded",
durationMs: 450,
},
{ name: "Metadata validation", status: "succeeded", durationMs: 120 },
{ name: "Artifact discovery", status: "succeeded", durationMs: 310 },
{
name: "Website projection build",
status: "succeeded",
durationMs: 980,
},
{ name: "Local staging", status: "succeeded", durationMs: 50 },
],
isStaged: false,
isActiveLocal: false,
};
// Register the build before policy hooks so automatic activation can resolve it.
this.builds.unshift(newBuild);
// Auto staging if policy requires
if (this.siteConfig.buildPolicy.staging.enabled) {
newBuild.status = "staged";
newBuild.isStaged = true;
this.nginxStatus.stagingReleaseId = buildId;
logs.push({
timestamp: new Date().toISOString(),
level: "info",
message: `Exposed candidate build ${buildId} through staging server block (preview.labyricorn.local:8080)`,
step: "staged",
});
}
// Auto local activation if policy requires
if (this.siteConfig.buildPolicy.localActivation.automatic) {
this.activateReleaseLocally(buildId);
}
this.addAudit(
`Triggered Build #${nextBuildNum}`,
"build",
`Successfully built static release ${buildId} (${engineResult.artifactChecksum.substring(0, 20)})`,
);
return newBuild;
}
activateReleaseLocally(buildId: string): {
success: boolean;
message: string;
} {
const build = this.builds.find((b) => b.id === buildId);
if (!build)
return { success: false, message: `Build ${buildId} not found.` };
if (build.status === "failed")
return { success: false, message: `Cannot activate a failed build.` };
try {
this.buildEngine.activate(buildId);
this.builds.unshift(record);
if (input.siteConfig.buildPolicy.staging.enabled) {
this.buildEngine.stage(input.artifactBuildId);
record.status = "staged"; record.isStaged = true; record.stages[3].status = "succeeded";
this.nginxStatus.stagingReleaseId = input.artifactBuildId;
}
this.addAudit(`Triggered Build #${number}`, "build", `Built ${input.artifactBuildId} from ${input.siteDefinition.commit}.`);
return record;
} catch (error) {
const report: ValidationReport = (error as Error & { validationReport?: ValidationReport }).validationReport ?? {
...emptyReport(), errors: [{ code: "E_BUILD_FAIL", message: error instanceof Error ? error.message : String(error), category: "build" }],
summary: { totalErrors: 1, totalWarnings: 0, passed: false },
};
const failed: BuildRecord = {
id: runId, buildNumber: number, timestamp, durationMs: Date.now() - startedAt, status: "failed",
siteDefCommit: this.siteConfig.commit ?? "unresolved", sourceCommits: {}, themeVersion: this.themeConfig.version,
builderVersion: BUILDER_VERSION, generatedRoutesCount: 0, mediaCount: 0, artifactChecksum: "", artifactSizeBytes: 0,
validationReport: report, logs: [...logs, { timestamp: new Date().toISOString(), level: "error", message: report.errors.map((item) => item.message).join("; "), step: "failed" }],
stages: [{ name: "Repository snapshots", status: "failed" }, { name: "Theme validation", status: "skipped" }, { name: "Artifact rendering", status: "skipped" }, { name: "Local staging", status: "skipped" }],
isStaged: false, isActiveLocal: false,
};
this.builds.unshift(failed); this.addAudit(`Triggered Build #${number}`, "build", report.errors[0]?.message ?? "Build failed", "failure"); return failed;
} finally { loader.dispose(); }
}
activateReleaseLocally(buildId: string): { success: boolean; message: string } {
const build = this.builds.find((item) => item.id === buildId || item.artifactBuildId === buildId);
if (!build?.artifactBuildId || build.status === "failed") return { success: false, message: `Build '${buildId}' is not activatable.` };
try {
this.buildEngine.activate(build.artifactBuildId);
for (const item of this.builds) { item.isActiveLocal = false; if (item.status === "active-local") item.status = "built"; }
build.isActiveLocal = true; build.status = "active-local";
this.nginxStatus.activeReleaseId = build.artifactBuildId; this.nginxStatus.lastReloadTime = new Date().toISOString(); this.nginxStatus.lastHealthCheckPassed = true;
this.addAudit("Activate Local Release", "nginx", `current -> releases/${build.artifactBuildId}`);
return { success: true, message: `Activated immutable release ${build.artifactBuildId}.` };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
this.nginxStatus.lastHealthCheckPassed = false;
this.addAudit(
"Activate Local Release Failed",
"nginx",
message,
"failure",
);
return { success: false, message };
return { success: false, message: error instanceof Error ? error.message : String(error) };
}
}
// Update in-memory state only after the release pointer changed successfully.
for (const previousBuild of this.builds) {
previousBuild.isActiveLocal = false;
if (previousBuild.status === "active-local") previousBuild.status = "built";
}
build.isActiveLocal = true;
build.status = "active-local";
this.nginxStatus.activeReleaseId = buildId;
this.nginxStatus.lastReloadTime = new Date().toISOString();
this.nginxStatus.lastHealthCheckPassed = true;
this.addAudit(
"Activate Local Release",
"nginx",
`Updated Nginx symlink current -> releases/${buildId}`,
);
return {
success: true,
message: `Successfully updated Nginx 'current' symlink to ${buildId} and reloaded Nginx (nginx -t passed).`,
private restoreReleasePointers(): void {
const restore = (name: "staging" | "current"): string | null => {
const pointer = path.join(this.buildEngine.outputRoot, name);
try {
const target = fs.realpathSync(pointer);
const id = path.basename(target);
this.buildEngine.verifyRelease(id);
return id;
} catch { return null; }
};
this.nginxStatus.stagingReleaseId = restore("staging");
this.nginxStatus.activeReleaseId = restore("current");
this.nginxStatus.lastHealthCheckPassed = Boolean(this.nginxStatus.activeReleaseId);
}
getBuildRoot(): string {
return this.buildEngine.outputRoot;
getBuildRoot(): string { return this.buildEngine.outputRoot; }
getCurrentBuildDirectory(): string { return this.buildEngine.currentDirectory(); }
getPreviewDirectory(): string | null {
if (fs.existsSync(this.buildEngine.currentDirectory())) return this.buildEngine.currentDirectory();
if (fs.existsSync(this.buildEngine.stagingDirectory())) return this.buildEngine.stagingDirectory();
return null;
}
getCurrentBuildDirectory(): string {
return this.buildEngine.currentDirectory();
}
pushToTarget(
targetId: string,
buildId: string,
): { success: boolean; logs: string[] } {
const target = this.pushTargets.find((t) => t.id === targetId);
const build = this.builds.find((b) => b.id === buildId);
if (!target)
return { success: false, logs: [`Target ${targetId} not found`] };
if (!build) return { success: false, logs: [`Build ${buildId} not found`] };
target.status = "uploading";
const logs: string[] = [
`[${new Date().toLocaleTimeString()}] Establishing SSH connection to ${target.host}:22 using ${target.credential}...`,
`[${new Date().toLocaleTimeString()}] SSH connection established. Target path: ${target.remotePath}`,
`[${new Date().toLocaleTimeString()}] Preparing remote directory ${target.remotePath}/${buildId}...`,
`[${new Date().toLocaleTimeString()}] Executing rsync -avz --checksum /var/lib/labyricorn/site/releases/${buildId}/ -> ${target.host}:${target.remotePath}/${buildId}/`,
`[${new Date().toLocaleTimeString()}] Uploaded 1.25 MB in 1.4s. Verifying checksum...`,
`[${new Date().toLocaleTimeString()}] Updating remote symlink ${target.currentLink} -> ${target.remotePath}/${buildId}`,
`[${new Date().toLocaleTimeString()}] Verifying deployed build-manifest.json on remote VPS...`,
`[${new Date().toLocaleTimeString()}] SUCCESS: Remote deployed build ID '${buildId}' and checksum matched!`,
];
target.status = "verified";
target.lastDeployedBuildId = buildId;
target.lastPushedAt = new Date().toISOString();
target.remoteChecksum = build.artifactChecksum;
target.lastLogs = logs;
this.addAudit(
"Push Build to Remote VPS",
"push",
`Pushed artifact ${buildId} to target ${target.name} (${target.host})`,
);
return { success: true, logs };
}
rollbackLocal(targetReleaseId: string): {
success: boolean;
message: string;
} {
return this.activateReleaseLocally(targetReleaseId);
pushToTarget(targetId: string, buildId: string): { success: boolean; logs: string[] } {
const target = this.pushTargets.find((item) => item.id === targetId);
const build = this.builds.find((item) => item.id === buildId);
if (!target || !build?.artifactBuildId) return { success: false, logs: ["Target or verified build not found."] };
this.buildEngine.verifyRelease(build.artifactBuildId);
return { success: false, logs: ["Remote publication is not configured for this target."] };
}
rollbackLocal(targetReleaseId: string): { success: boolean; message: string } { return this.activateReleaseLocally(targetReleaseId); }
}
// Global Store Instance
export const store = new LabyricornStore();
+33
View File
@@ -0,0 +1,33 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "labyricorn-projects-v1",
"type": "object", "additionalProperties": false, "required": ["projects"],
"properties": {
"projects": { "type": "array", "items": { "$ref": "#/$defs/project" } }
},
"$defs": {
"project": {
"type": "object", "additionalProperties": false,
"required": ["id", "source", "route", "presentation"],
"properties": {
"id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" },
"source": { "type": "string", "minLength": 1 }, "route": { "$ref": "#/$defs/route" },
"title": { "type": "string" }, "summary": { "type": "string" }, "version": { "type": "string" },
"license": { "type": "string" }, "homepage": { "$ref": "#/$defs/route" },
"tags": { "type": "array", "items": { "type": "string" } }, "stack": { "type": "array", "items": { "type": "string" } },
"presentation": { "type": "object", "additionalProperties": false, "required": ["detailTemplate"], "properties": { "detailTemplate": { "type": "string", "pattern": "^[a-z][a-z0-9-]*$" } } },
"relationships": { "type": "object", "additionalProperties": { "$ref": "#/$defs/relationship" } },
"publishedFiles": { "type": "array", "items": { "$ref": "#/$defs/publishedFile" } }
}
},
"relationship": {
"type": "object", "additionalProperties": false, "required": ["contentModel", "matchField"],
"properties": { "contentModel": { "type": "string", "minLength": 1 }, "matchField": { "type": "string", "pattern": "^metadata\\.[A-Za-z][A-Za-z0-9]*$" }, "required": { "type": "boolean" } }
},
"publishedFile": {
"type": "object", "additionalProperties": false, "required": ["source", "route", "mediaType"],
"properties": { "source": { "type": "string", "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*\\\\)[A-Za-z0-9._/-]+$" }, "route": { "$ref": "#/$defs/route" }, "mediaType": { "enum": ["text/html", "text/css", "application/javascript", "application/json", "image/svg+xml", "image/png", "image/jpeg"] } }
},
"route": { "type": "string", "pattern": "^/(?!.*(?:^|/)\\.\\.(?:/|$))[A-Za-z0-9._/-]*$" }
}
}
+83
View File
@@ -0,0 +1,83 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "labyricorn-theme-v1",
"type": "object",
"additionalProperties": false,
"required": ["protocol", "id", "version", "engine", "templates", "assets", "security"],
"properties": {
"protocol": { "const": "labyricorn-theme/v1" },
"id": { "type": "string", "minLength": 1 },
"version": { "type": "string", "pattern": "^(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)(?:-[0-9A-Za-z.-]+)?(?:\\+[0-9A-Za-z.-]+)?$" },
"engine": { "const": "liquid" },
"templates": {
"type": "object",
"additionalProperties": false,
"required": ["layout", "home", "notFound"],
"properties": {
"layout": { "$ref": "#/$defs/relativePath" },
"home": { "$ref": "#/$defs/relativePath" },
"notFound": { "$ref": "#/$defs/relativePath" },
"sections": { "$ref": "#/$defs/templateMap" },
"content": { "$ref": "#/$defs/templateMap" }
}
},
"assets": {
"type": "object",
"additionalProperties": false,
"required": ["styles", "scripts", "files", "fonts"],
"properties": {
"styles": { "$ref": "#/$defs/pathList" },
"scripts": { "$ref": "#/$defs/pathList" },
"files": { "$ref": "#/$defs/pathList" },
"fonts": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["id", "family", "style", "weight", "source"],
"properties": {
"id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" },
"family": { "type": "string", "minLength": 1 },
"style": { "enum": ["normal", "italic", "oblique"] },
"weight": { "type": "integer", "minimum": 1, "maximum": 1000 },
"source": {
"oneOf": [
{
"type": "object", "additionalProperties": false,
"required": ["kind", "sourceId", "path", "format"],
"properties": {
"kind": { "const": "git" }, "sourceId": { "type": "string", "minLength": 1 },
"path": { "$ref": "#/$defs/relativePath" }, "format": { "$ref": "#/$defs/fontFormat" }
}
},
{
"type": "object", "additionalProperties": false,
"required": ["kind", "url", "checksum", "format"],
"properties": {
"kind": { "const": "https" }, "url": { "type": "string", "pattern": "^https://" },
"checksum": { "type": "string", "pattern": "^sha256-[0-9a-f]{64}$" }, "format": { "$ref": "#/$defs/fontFormat" }
}
}
]
}
}
}
}
}
},
"security": {
"type": "object", "additionalProperties": false,
"required": ["allowScripts", "allowExternalAssets", "allowedExternalOrigins"],
"properties": {
"allowScripts": { "type": "boolean" }, "allowExternalAssets": { "type": "boolean" },
"allowedExternalOrigins": { "type": "array", "uniqueItems": true, "items": { "type": "string", "pattern": "^https://[^/]+$" } }
}
}
},
"$defs": {
"relativePath": { "type": "string", "minLength": 1, "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))(?!.*\\\\)(?![A-Za-z]:)(?!.*\\u0000)[A-Za-z0-9._/-]+$" },
"fontFormat": { "enum": ["woff", "woff2", "ttf", "otf"] },
"templateMap": { "type": "object", "propertyNames": { "pattern": "^[a-z][a-z0-9-]*$" }, "additionalProperties": { "$ref": "#/$defs/relativePath" } },
"pathList": { "type": "array", "uniqueItems": true, "items": { "$ref": "#/$defs/relativePath" } }
}
}
+201
View File
@@ -0,0 +1,201 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import Ajv2020 from "ajv/dist/2020.js";
import { Liquid } from "liquidjs";
import YAML from "yaml";
import projectSchema from "./project.schema.json" with { type: "json" };
import themeSchema from "./theme.schema.json" with { type: "json" };
import {
LoadedTheme,
ProjectDeclaration,
RepositorySnapshot,
ThemeManifestV1,
} from "../../types";
export interface ThemeValidationIssue {
code: string;
message: string;
file?: string;
category: string;
}
export class ThemeContractError extends Error {
constructor(public readonly issues: ThemeValidationIssue[]) {
super(issues.map((issue) => `${issue.code}: ${issue.message}`).join("\n"));
this.name = "ThemeContractError";
}
}
const ajv = new Ajv2020({ allErrors: true, strict: true });
const validateThemeSchema = ajv.compile(themeSchema);
const validateProjectSchema = ajv.compile(projectSchema);
const liquidParser = new Liquid({ strictVariables: true, strictFilters: true });
liquidParser.registerFilter("safe_content", (value: unknown) => value);
liquidParser.registerFilter("safe_page_body", (value: unknown) => value);
const MAX_TEMPLATE_BYTES = 1024 * 1024;
const MAX_FILE_BYTES = 20 * 1024 * 1024;
const MAX_THEME_BYTES = 50 * 1024 * 1024;
const sha256 = (value: Buffer | string) => crypto.createHash("sha256").update(value).digest("hex");
export const canonicalPath = (value: string, label: string): string => {
if (!value || value.includes("\0") || value.includes("\\") || path.posix.isAbsolute(value) || /^[A-Za-z]:/.test(value)) {
throw new ThemeContractError([{ code: "E_THEME_PATH_ESCAPE", message: `${label} must be a repository-relative POSIX path.`, file: value, category: "path" }]);
}
const normalized = path.posix.normalize(value);
if (normalized === ".." || normalized.startsWith("../") || normalized.split("/").includes("..")) {
throw new ThemeContractError([{ code: "E_THEME_PATH_ESCAPE", message: `${label} escapes its allowed root.`, file: value, category: "path" }]);
}
return normalized.replace(/^\.\//, "");
};
export const resolveRegularFile = (root: string, relative: string, label: string, maxBytes = MAX_FILE_BYTES): string => {
const canonical = canonicalPath(relative, label);
const rootReal = fs.realpathSync(root);
const candidate = path.resolve(root, ...canonical.split("/"));
let real: string;
try {
real = fs.realpathSync(candidate);
} catch {
throw new ThemeContractError([{ code: "E_THEME_FILE_MISSING", message: `${label} does not exist.`, file: relative, category: "filesystem" }]);
}
if (real !== rootReal && !real.startsWith(`${rootReal}${path.sep}`)) {
throw new ThemeContractError([{ code: "E_THEME_PATH_ESCAPE", message: `${label} resolves outside its allowed root.`, file: relative, category: "path" }]);
}
const stat = fs.statSync(real);
if (!stat.isFile()) {
throw new ThemeContractError([{ code: "E_THEME_FILE_INVALID", message: `${label} must be a regular file.`, file: relative, category: "filesystem" }]);
}
if (stat.size > maxBytes) {
throw new ThemeContractError([{ code: "E_THEME_RESOURCE_LIMIT", message: `${label} exceeds the ${maxBytes}-byte limit.`, file: relative, category: "resource" }]);
}
return real;
};
const flattenTemplates = (manifest: ThemeManifestV1): Record<string, string> => ({
layout: manifest.templates.layout,
home: manifest.templates.home,
notFound: manifest.templates.notFound,
...Object.fromEntries(Object.entries(manifest.templates.sections ?? {}).map(([key, value]) => [`sections.${key}`, value])),
...Object.fromEntries(Object.entries(manifest.templates.content ?? {}).map(([key, value]) => [`content.${key}`, value])),
});
const schemaIssues = (code: string, prefix: string, errors: typeof validateThemeSchema.errors): ThemeValidationIssue[] =>
(errors ?? []).map((error) => ({
code,
message: `${prefix}${error.instancePath || "/"} ${error.message ?? "is invalid"}`,
category: "schema",
}));
export class ThemeLoader {
load(
snapshot: RepositorySnapshot,
themePath: string,
requiredSections: readonly string[] = [],
requiredContent: readonly string[] = [],
): LoadedTheme {
const relativeRoot = canonicalPath(themePath.replace(/^\/+/, ""), "theme.path");
const root = path.resolve(snapshot.checkoutRoot, ...relativeRoot.split("/"));
if (!fs.existsSync(root) || !fs.statSync(root).isDirectory()) {
throw new ThemeContractError([{ code: "E_THEME_ROOT_INVALID", message: `Theme root '${relativeRoot}' is missing or is not a directory.`, file: relativeRoot, category: "filesystem" }]);
}
const manifestPath = resolveRegularFile(root, "theme.yml", "theme manifest", MAX_TEMPLATE_BYTES);
const manifestBytes = fs.readFileSync(manifestPath);
let manifest: ThemeManifestV1;
try {
manifest = YAML.parse(manifestBytes.toString("utf8"));
} catch (error) {
throw new ThemeContractError([{ code: "E_THEME_MANIFEST_INVALID", message: `theme.yml is not valid YAML: ${error instanceof Error ? error.message : String(error)}`, file: "theme.yml", category: "schema" }]);
}
if (!validateThemeSchema(manifest)) {
throw new ThemeContractError(schemaIssues("E_THEME_MANIFEST_INVALID", "theme.yml", validateThemeSchema.errors));
}
if (!manifest.security.allowScripts && manifest.assets.scripts.length > 0) {
throw new ThemeContractError([{ code: "E_THEME_SCRIPT_POLICY", message: "assets.scripts must be empty when allowScripts is false.", file: "theme.yml", category: "security" }]);
}
if (!manifest.security.allowExternalAssets && manifest.security.allowedExternalOrigins.length > 0) {
throw new ThemeContractError([{ code: "E_THEME_EXTERNAL_ASSET", message: "allowedExternalOrigins must be empty when external assets are disabled.", file: "theme.yml", category: "security" }]);
}
const missingKeys = [
...requiredSections.filter((key) => !manifest.templates.sections?.[key]).map((key) => `sections.${key}`),
...requiredContent.filter((key) => !manifest.templates.content?.[key]).map((key) => `content.${key}`),
];
if (missingKeys.length > 0) {
throw new ThemeContractError(missingKeys.map((key) => ({ code: "E_PROJECT_TEMPLATE_UNRESOLVED", message: `Configured template key '${key}' is not declared by theme.yml.`, file: "theme.yml", category: "template" })));
}
const templateSources: Record<string, string> = {};
const checksums: Record<string, string> = { "theme.yml": sha256(manifestBytes) };
let totalBytes = manifestBytes.length;
for (const [key, relative] of Object.entries(flattenTemplates(manifest)).sort(([a], [b]) => a.localeCompare(b))) {
const file = resolveRegularFile(root, relative, `template '${key}'`, MAX_TEMPLATE_BYTES);
const bytes = fs.readFileSync(file);
const source = bytes.toString("utf8").replace(/\r\n/g, "\n");
const includes = [...source.matchAll(/{%\s*(?:include|render)\s+([^%]+)%}/g)];
for (const include of includes) {
const expression = include[1].trim();
const staticName = expression.match(/^['"]([A-Za-z0-9._/-]+)['"](?:\s*,.*)?$/)?.[1];
if (!staticName || staticName.includes("..")) {
throw new ThemeContractError([{ code: "E_THEME_INCLUDE_POLICY", message: `Template '${key}' contains a dynamic or escaping include.`, file: relative, category: "template" }]);
}
resolveRegularFile(path.join(root, "partials"), staticName.endsWith(".liquid") ? staticName : `${staticName}.liquid`, `partial '${staticName}'`, MAX_TEMPLATE_BYTES);
}
try {
liquidParser.parse(source);
} catch (error) {
throw new ThemeContractError([{ code: "E_THEME_TEMPLATE_INVALID", message: `Template '${key}' failed to parse: ${error instanceof Error ? error.message : String(error)}`, file: relative, category: "template" }]);
}
templateSources[key] = source;
checksums[relative] = sha256(bytes);
totalBytes += bytes.length;
}
const declaredAssets = [...manifest.assets.styles, ...manifest.assets.scripts, ...manifest.assets.files];
for (const relative of declaredAssets) {
if (!relative.startsWith("assets/")) {
throw new ThemeContractError([{ code: "E_THEME_ASSET_INVALID", message: `Declared asset '${relative}' must be below assets/.`, file: relative, category: "asset" }]);
}
const file = resolveRegularFile(root, relative, `asset '${relative}'`);
const bytes = fs.readFileSync(file);
checksums[relative] = sha256(bytes);
totalBytes += bytes.length;
}
if (new Set(declaredAssets).size !== declaredAssets.length) {
throw new ThemeContractError([{ code: "E_THEME_ASSET_DUPLICATE", message: "Theme asset declarations must be unique across all asset lists.", file: "theme.yml", category: "asset" }]);
}
if (totalBytes > MAX_THEME_BYTES) {
throw new ThemeContractError([{ code: "E_THEME_RESOURCE_LIMIT", message: `Theme inputs exceed the ${MAX_THEME_BYTES}-byte aggregate limit.`, category: "resource" }]);
}
return Object.freeze({
manifest: Object.freeze(manifest), root, snapshot,
manifestChecksum: `sha256-${sha256(manifestBytes)}`,
checksums: Object.freeze(Object.fromEntries(Object.entries(checksums).sort(([a], [b]) => a.localeCompare(b)))),
templateSources: Object.freeze(templateSources),
});
}
}
export const parseProjects = (bytes: Buffer): ProjectDeclaration[] => {
let document: unknown;
try {
document = YAML.parse(bytes.toString("utf8"));
} catch (error) {
throw new ThemeContractError([{ code: "E_PROJECT_CONFIG_INVALID", message: `projects.yml is not valid YAML: ${error instanceof Error ? error.message : String(error)}`, category: "project" }]);
}
if (!validateProjectSchema(document)) {
throw new ThemeContractError(schemaIssues("E_PROJECT_CONFIG_INVALID", "projects.yml", validateProjectSchema.errors));
}
const projects = (document as { projects: ProjectDeclaration[] }).projects;
const ids = new Set<string>();
const routes = new Set<string>();
for (const project of projects) {
if (ids.has(project.id)) throw new ThemeContractError([{ code: "E_PROJECT_DUPLICATE", message: `Duplicate project id '${project.id}'.`, category: "project" }]);
if (routes.has(project.route)) throw new ThemeContractError([{ code: "E_ROUTE_COLLISION", message: `Duplicate project route '${project.route}'.`, category: "routing" }]);
ids.add(project.id);
routes.add(project.route);
}
return projects;
};