Implement deterministic website build engine
This commit is contained in:
@@ -6,3 +6,6 @@ coverage/
|
||||
*.log
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# Project-local SSH credentials (never commit private keys)
|
||||
.ssh/
|
||||
|
||||
@@ -1,20 +1,56 @@
|
||||
<div align="center">
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://ai.google.dev/static/site-assets/images/share-ais-513315318.png" />
|
||||
</div>
|
||||
# Labyricorn Website Engine Control Plane
|
||||
|
||||
# Run and deploy your AI Studio app
|
||||
A React control plane and Express backend for discovering Git-hosted Markdown, validating canonical site configuration, and producing deterministic static website releases.
|
||||
|
||||
This contains everything you need to run your app locally.
|
||||
## Engine capabilities
|
||||
|
||||
View your app in AI Studio: https://ai.studio/apps/941c018f-56b5-4bf6-afbf-444c3f0a351f
|
||||
The build engine now:
|
||||
|
||||
## Run Locally
|
||||
- validates routes, route collisions, content state, raw-HTML policy, and media references;
|
||||
- renders published content and navigation indexes into static HTML;
|
||||
- escapes source HTML and renders a safe CommonMark-style subset;
|
||||
- emits deterministic `build-manifest.json` and `checksums.json` files;
|
||||
- calculates an artifact checksum from generated file checksums;
|
||||
- writes releases atomically beneath the configured build root;
|
||||
- activates releases through an atomic `current` symlink/junction;
|
||||
- survives process restarts without reusing existing build numbers; and
|
||||
- serves the active generated release through the live-preview endpoint.
|
||||
|
||||
**Prerequisites:** Node.js
|
||||
## Development
|
||||
|
||||
Requires Node.js 22 or later.
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
```bash
|
||||
npm install
|
||||
npm test
|
||||
npm run lint
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Create a production bundle with:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
npm start
|
||||
```
|
||||
|
||||
The control plane listens on port `3000`.
|
||||
|
||||
## Build artifacts
|
||||
|
||||
By default, releases are written to the platform temporary directory under `labyricorn-builds/`. Set `LABYRICORN_BUILD_ROOT` to use a persistent release directory in production.
|
||||
|
||||
Each successful release contains:
|
||||
|
||||
- generated route directories with `index.html`;
|
||||
- `404.html`;
|
||||
- `build-manifest.json`; and
|
||||
- `checksums.json`.
|
||||
|
||||
Validation failures produce a failed build record but never publish a partial release directory.
|
||||
|
||||
## Current boundaries
|
||||
|
||||
- The built-in renderer is used until repository-provided theme templates are implemented.
|
||||
- Git source synchronization supports HTTP(S), SSH-style Git URLs, and local paths; other source types remain unavailable.
|
||||
- Remote rsync deployment is still represented by the existing control-plane simulation and must not be treated as a completed production deploy path.
|
||||
|
||||
+2
-1
@@ -9,7 +9,8 @@
|
||||
"start": "node dist/server.cjs",
|
||||
"preview": "vite preview",
|
||||
"clean": "rm -rf dist server.js",
|
||||
"lint": "tsc --noEmit"
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "node --import tsx --test src/backend/buildEngine.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "^2.4.0",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import express from "express";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import crypto from "crypto";
|
||||
import { createServer as createViteServer } from "vite";
|
||||
import { store } from "./src/backend/store";
|
||||
import { CredentialRef } from "./src/types";
|
||||
@@ -561,41 +560,18 @@ async function startServer() {
|
||||
store.builds.find((b) => b.isActiveLocal) || store.builds[0];
|
||||
let pageRoute = (req.query.route as string) || "/";
|
||||
|
||||
const rootDir = "/tmp/labyricorn-builds/current";
|
||||
const rootDir = store.getCurrentBuildDirectory();
|
||||
|
||||
if (fs.existsSync(rootDir)) {
|
||||
if (pageRoute === "/") {
|
||||
// Try to list all generated pages to simulate an index
|
||||
let links = "";
|
||||
try {
|
||||
const items = store.contentItems;
|
||||
links = items
|
||||
.map(
|
||||
(item) =>
|
||||
`<li><a href="#" onclick="window.parent.postMessage({type:'NAVIGATE_LIVE_SITE', route:'${item.route}'}, '*')">${item.title}</a></li>`,
|
||||
)
|
||||
.join("");
|
||||
} catch (e) {}
|
||||
|
||||
return res.send(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Labyricorn Projection</title>
|
||||
<style>body { font-family: system-ui; padding: 2rem; background: #fff; color: #111; }</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Local Canonical Site</h1>
|
||||
<p>Generated Projection from Repository Artifacts</p>
|
||||
<ul>${links}</ul>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
const routeWithoutQuery = pageRoute.split(/[?#]/, 1)[0].replace(/\\/g, "/");
|
||||
const relativeRoute = routeWithoutQuery.replace(/^\/+/, "");
|
||||
const rootPath = `${path.resolve(rootDir)}${path.sep}`;
|
||||
const targetPath = path.resolve(rootDir, relativeRoute, "index.html");
|
||||
if (!targetPath.startsWith(rootPath)) {
|
||||
return res.status(400).json({ error: "Invalid preview route" });
|
||||
}
|
||||
|
||||
const targetPath = path.join(rootDir, pageRoute, "index.html");
|
||||
if (fs.existsSync(targetPath)) {
|
||||
return res.send(fs.readFileSync(targetPath, "utf-8"));
|
||||
return res.type("html").send(fs.readFileSync(targetPath, "utf-8"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import assert from "node:assert/strict";
|
||||
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";
|
||||
|
||||
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 themeConfig: ThemeConfig = {
|
||||
id: "test-theme",
|
||||
name: "Test Theme",
|
||||
version: "1.0.0",
|
||||
path: "/.theme",
|
||||
templates: {},
|
||||
styles: [],
|
||||
scripts: [],
|
||||
supportsPackages: [],
|
||||
isValidated: true,
|
||||
validationErrors: [],
|
||||
};
|
||||
|
||||
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 input = (buildId: string, items: ContentItem[]): BuildEngineInput => ({
|
||||
buildId,
|
||||
siteConfig,
|
||||
contentItems: items,
|
||||
mediaAssets: [],
|
||||
sourceCommits: { content: "abc123" },
|
||||
themeConfig,
|
||||
});
|
||||
|
||||
test("build output is deterministic for equivalent inputs", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "labyricorn-engine-test-"));
|
||||
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```" })]),
|
||||
);
|
||||
assert.equal(result.success, true);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("markdown renderer escapes source HTML", () => {
|
||||
assert.equal(renderMarkdown("Text <img src=x>"), "<p>Text <img src=x></p>");
|
||||
});
|
||||
@@ -0,0 +1,472 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
+101
-75
@@ -19,7 +19,7 @@ import {
|
||||
import YAML from "yaml";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import crypto from "crypto";
|
||||
import { BuildEngine, BUILDER_VERSION } from "./buildEngine";
|
||||
|
||||
export class LabyricornStore {
|
||||
siteConfig: SiteConfig;
|
||||
@@ -34,6 +34,7 @@ export class LabyricornStore {
|
||||
pushTargets: PushTarget[];
|
||||
credentials: CredentialRef[];
|
||||
auditLogs: AuditLogEntry[];
|
||||
private readonly buildEngine = new BuildEngine();
|
||||
|
||||
constructor() {
|
||||
this.gitSources = [];
|
||||
@@ -193,7 +194,11 @@ export class LabyricornStore {
|
||||
}
|
||||
|
||||
runBuild(): BuildRecord {
|
||||
const nextBuildNum = this.builds.length + 1;
|
||||
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 timestamp = new Date().toISOString();
|
||||
|
||||
@@ -262,38 +267,46 @@ export class LabyricornStore {
|
||||
step: "building",
|
||||
});
|
||||
|
||||
// Actual build simulation logic into /tmp
|
||||
const buildDir = `/tmp/labyricorn-builds/${buildId}`;
|
||||
let artifactSizeBytes = 0;
|
||||
let checksum = "sha256-";
|
||||
let validationReport: ValidationReport = {
|
||||
errors: [],
|
||||
warnings: [],
|
||||
missingMedia: [],
|
||||
routeCollisions: [],
|
||||
htmlPolicyViolations: [],
|
||||
brokenLinks: [],
|
||||
summary: { totalErrors: 0, totalWarnings: 0, passed: true },
|
||||
};
|
||||
let engineResult: ReturnType<BuildEngine["build"]>;
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(buildDir)) fs.mkdirSync(buildDir, { recursive: true });
|
||||
|
||||
// Generate some actual files based on content items
|
||||
this.contentItems.forEach((item) => {
|
||||
const routePath = path.join(buildDir, item.route);
|
||||
if (!fs.existsSync(routePath))
|
||||
fs.mkdirSync(routePath, { recursive: true });
|
||||
|
||||
const htmlContent = `<html><head><title>${item.title}</title></head><body><h1>${item.title}</h1><p>${item.summary}</p></body></html>`;
|
||||
fs.writeFileSync(path.join(routePath, "index.html"), htmlContent);
|
||||
artifactSizeBytes += htmlContent.length;
|
||||
engineResult = this.buildEngine.build({
|
||||
buildId,
|
||||
siteConfig: this.siteConfig,
|
||||
contentItems: this.contentItems,
|
||||
mediaAssets: this.mediaAssets,
|
||||
sourceCommits,
|
||||
themeConfig: this.themeConfig,
|
||||
});
|
||||
|
||||
const checksumContent = this.contentItems.map((i) => i.id).join("-");
|
||||
checksum = `sha256-${crypto.createHash("sha256").update(checksumContent).digest("hex")}`;
|
||||
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 ${buildDir}`,
|
||||
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 & checksums.json (${checksum.substring(0, 16)}...)`,
|
||||
message: `Generated build-manifest.json and checksums.json (${engineResult.artifactChecksum.substring(0, 20)}...)`,
|
||||
step: "built",
|
||||
});
|
||||
} catch (e: any) {
|
||||
@@ -303,21 +316,8 @@ export class LabyricornStore {
|
||||
message: `Build failed: ${e.message}`,
|
||||
step: "building",
|
||||
});
|
||||
const failedBuild: BuildRecord = {
|
||||
id: buildId,
|
||||
buildNumber: nextBuildNum,
|
||||
timestamp,
|
||||
durationMs: 450,
|
||||
status: "failed",
|
||||
siteDefCommit: "local",
|
||||
sourceCommits,
|
||||
themeVersion: this.themeConfig.version,
|
||||
builderVersion: "1.0.0-labyricorn",
|
||||
generatedRoutesCount: 0,
|
||||
mediaCount: 0,
|
||||
artifactChecksum: "",
|
||||
artifactSizeBytes: 0,
|
||||
validationReport: {
|
||||
const failureReport: ValidationReport =
|
||||
e.validationReport || {
|
||||
errors: [
|
||||
{ code: "E_BUILD_FAIL", message: e.message, category: "build" },
|
||||
],
|
||||
@@ -327,7 +327,22 @@ export class LabyricornStore {
|
||||
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 },
|
||||
@@ -341,12 +356,20 @@ export class LabyricornStore {
|
||||
status: "succeeded",
|
||||
durationMs: 450,
|
||||
},
|
||||
{ name: "Metadata validation", status: "succeeded", durationMs: 120 },
|
||||
{ name: "Artifact discovery", status: "succeeded", durationMs: 310 },
|
||||
{
|
||||
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: "failed",
|
||||
durationMs: 150,
|
||||
status: failureReport.summary.passed ? "failed" : "skipped",
|
||||
durationMs: failureReport.summary.passed ? 150 : undefined,
|
||||
},
|
||||
{ name: "Local staging", status: "skipped" },
|
||||
],
|
||||
@@ -361,25 +384,17 @@ export class LabyricornStore {
|
||||
id: buildId,
|
||||
buildNumber: nextBuildNum,
|
||||
timestamp,
|
||||
durationMs: 980,
|
||||
durationMs: Date.now() - buildStartedAt,
|
||||
status: "built",
|
||||
siteDefCommit: "local",
|
||||
sourceCommits,
|
||||
themeVersion: this.themeConfig.version,
|
||||
builderVersion: "1.0.0-labyricorn",
|
||||
generatedRoutesCount: this.contentItems.length,
|
||||
builderVersion: BUILDER_VERSION,
|
||||
generatedRoutesCount: engineResult.generatedRoutesCount,
|
||||
mediaCount: this.mediaAssets.length,
|
||||
artifactChecksum: checksum,
|
||||
artifactSizeBytes,
|
||||
validationReport: {
|
||||
errors: [],
|
||||
warnings: [],
|
||||
missingMedia: [],
|
||||
routeCollisions: [],
|
||||
htmlPolicyViolations: [],
|
||||
brokenLinks: [],
|
||||
summary: { totalErrors: 0, totalWarnings: 0, passed: true },
|
||||
},
|
||||
artifactChecksum: engineResult.artifactChecksum,
|
||||
artifactSizeBytes: engineResult.artifactSizeBytes,
|
||||
validationReport,
|
||||
logs,
|
||||
stages: [
|
||||
{ name: "Triggered", status: "succeeded", durationMs: 10 },
|
||||
@@ -402,6 +417,9 @@ export class LabyricornStore {
|
||||
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";
|
||||
@@ -420,11 +438,10 @@ export class LabyricornStore {
|
||||
this.activateReleaseLocally(buildId);
|
||||
}
|
||||
|
||||
this.builds.unshift(newBuild);
|
||||
this.addAudit(
|
||||
`Triggered Build #${nextBuildNum}`,
|
||||
"build",
|
||||
`Successfully built static release ${buildId} (${checksum.substring(0, 12)})`,
|
||||
`Successfully built static release ${buildId} (${engineResult.artifactChecksum.substring(0, 20)})`,
|
||||
);
|
||||
return newBuild;
|
||||
}
|
||||
@@ -439,10 +456,24 @@ export class LabyricornStore {
|
||||
if (build.status === "failed")
|
||||
return { success: false, message: `Cannot activate a failed build.` };
|
||||
|
||||
// Mark previous active as false
|
||||
for (const b of this.builds) {
|
||||
b.isActiveLocal = false;
|
||||
if (b.status === "active-local") b.status = "built";
|
||||
try {
|
||||
this.buildEngine.activate(buildId);
|
||||
} 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 };
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -451,19 +482,6 @@ export class LabyricornStore {
|
||||
this.nginxStatus.lastReloadTime = new Date().toISOString();
|
||||
this.nginxStatus.lastHealthCheckPassed = true;
|
||||
|
||||
try {
|
||||
if (fs.existsSync("/tmp/labyricorn-builds/current")) {
|
||||
fs.unlinkSync("/tmp/labyricorn-builds/current");
|
||||
}
|
||||
fs.symlinkSync(
|
||||
`/tmp/labyricorn-builds/${buildId}`,
|
||||
"/tmp/labyricorn-builds/current",
|
||||
"dir",
|
||||
);
|
||||
} catch (e) {
|
||||
// Ignore symlink errors in test env
|
||||
}
|
||||
|
||||
this.addAudit(
|
||||
"Activate Local Release",
|
||||
"nginx",
|
||||
@@ -475,6 +493,14 @@ export class LabyricornStore {
|
||||
};
|
||||
}
|
||||
|
||||
getBuildRoot(): string {
|
||||
return this.buildEngine.outputRoot;
|
||||
}
|
||||
|
||||
getCurrentBuildDirectory(): string {
|
||||
return this.buildEngine.currentDirectory();
|
||||
}
|
||||
|
||||
pushToTarget(
|
||||
targetId: string,
|
||||
buildId: string,
|
||||
|
||||
@@ -320,19 +320,43 @@ export const BuildEngineTab: React.FC<BuildEngineTabProps> = ({
|
||||
{/* TAB CONTENT: VALIDATION */}
|
||||
{activeTab === "validation" && (
|
||||
<div className="space-y-3 text-xs">
|
||||
<div className="p-4 bg-emerald-950/40 border border-emerald-500/20 rounded-sm text-emerald-300 space-y-1">
|
||||
<div
|
||||
className={`p-4 rounded-sm space-y-1 border ${
|
||||
selectedBuild.validationReport.summary.passed
|
||||
? "bg-emerald-950/40 border-emerald-500/20 text-emerald-300"
|
||||
: "bg-rose-950/40 border-rose-500/20 text-rose-300"
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-2 font-bold">
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||
{selectedBuild.validationReport.summary.passed ? (
|
||||
<CheckCircle2 className="w-4 h-4 text-emerald-400" />
|
||||
) : (
|
||||
<XCircle className="w-4 h-4 text-rose-400" />
|
||||
)}
|
||||
<span>
|
||||
Validation Passed: 0 Fatal Errors, 0 Route Collisions
|
||||
Validation {selectedBuild.validationReport.summary.passed ? "Passed" : "Failed"}: {selectedBuild.validationReport.summary.totalErrors} Errors, {selectedBuild.validationReport.summary.totalWarnings} Warnings
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-emerald-400 text-[11px]">
|
||||
All required metadata, Markdown dialect constraints,
|
||||
YouTube embed IDs, and media namespace routes passed
|
||||
verification.
|
||||
<p className="text-[11px] opacity-80">
|
||||
{selectedBuild.validationReport.routeCollisions.length} route collisions, {selectedBuild.validationReport.missingMedia.length} missing media references, and {selectedBuild.validationReport.htmlPolicyViolations.length} HTML policy violations.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedBuild.validationReport.errors.map((error, index) => (
|
||||
<div key={`error-${index}`} className="p-3 bg-rose-950/20 border border-rose-500/20 rounded-sm">
|
||||
<div className="font-mono font-bold text-rose-400">{error.code}</div>
|
||||
<div className="text-neutral-300 mt-1">{error.message}</div>
|
||||
{error.file && <div className="font-mono text-neutral-500 mt-1">{error.file}</div>}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{selectedBuild.validationReport.warnings.map((warning, index) => (
|
||||
<div key={`warning-${index}`} className="p-3 bg-amber-950/20 border border-amber-500/20 rounded-sm">
|
||||
<div className="font-mono font-bold text-amber-400">{warning.code}</div>
|
||||
<div className="text-neutral-300 mt-1">{warning.message}</div>
|
||||
{warning.file && <div className="font-mono text-neutral-500 mt-1">{warning.file}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user