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