${escapeHtml(item.title)}
${escapeHtml(item.summary)}
diff --git a/.gitignore b/.gitignore index 5a86d2a..89ba67e 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,6 @@ coverage/ *.log .env* !.env.example + +# Project-local SSH credentials (never commit private keys) +.ssh/ diff --git a/README.md b/README.md index 605c3df..e53f886 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,56 @@ -
-Generated Projection from Repository Artifacts
-Text <img src=x>
"); +}); diff --git a/src/backend/buildEngine.ts b/src/backend/buildEngine.ts new file mode 100644 index 0000000..b630eda --- /dev/null +++ b/src/backend/buildEngine.ts @@ -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${escapeHtml(code)}`);
+ return token;
+ });
+
+ rendered = escapeHtml(rendered)
+ .replace(
+ /\[([^\]]+)\]\((https?:\/\/[^\s)]+|\/[^\s)]*)\)/g,
+ '$1',
+ )
+ .replace(/\*\*([^*]+)\*\*/g, "$1")
+ .replace(/\*([^*]+)\*/g, "$1");
+
+ 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(`${renderInlineMarkdown(paragraph.join(" "))}
`); + paragraph = []; + } + }; + const flushList = () => { + if (list.length > 0) { + blocks.push(`${escapeHtml(code.join("\n"))}`);
+ 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(`${renderInlineMarkdown(line.slice(2))}`); + continue; + } + + paragraph.push(line.trim()); + } + + if (code !== null) { + blocks.push(`
${escapeHtml(code.join("\n"))}`);
+ }
+ 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${escapeHtml(item.summary)}
No published content.
"}`)); + + for (const entry of input.siteConfig.navigation.filter((candidate) => candidate.route !== "/")) { + const items = publishedItems + .filter((item) => item.artifactType === entry.contentModel) + .map((item) => `${escapeHtml(item.summary)}
No published content.
"}`)); + } + + for (const item of publishedItems) { + const embeds = item.youtubeDirectives + .filter((directive) => /^[A-Za-z0-9_-]{6,20}$/.test(directive.videoId)) + .map((directive) => `${escapeHtml(item.summary)}
${renderMarkdown(item.contentMarkdown)}${embeds}The requested page was not found.
"); + 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; + } +} diff --git a/src/backend/store.ts b/src/backend/store.ts index 214e2d6..adea041 100644 --- a/src/backend/store.ts +++ b/src/backend/store.ts @@ -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${item.summary}
`; - 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, diff --git a/src/components/BuildEngineTab.tsx b/src/components/BuildEngineTab.tsx index f6002c7..a8e0801 100644 --- a/src/components/BuildEngineTab.tsx +++ b/src/components/BuildEngineTab.tsx @@ -320,19 +320,43 @@ export const BuildEngineTab: React.FC- All required metadata, Markdown dialect constraints, - YouTube embed IDs, and media namespace routes passed - verification. +
+ {selectedBuild.validationReport.routeCollisions.length} route collisions, {selectedBuild.validationReport.missingMedia.length} missing media references, and {selectedBuild.validationReport.htmlPolicyViolations.length} HTML policy violations.