diff --git a/deploy/labyricorn-control-plane.service b/deploy/labyricorn-control-plane.service index 0a7448d..ebea746 100644 --- a/deploy/labyricorn-control-plane.service +++ b/deploy/labyricorn-control-plane.service @@ -12,6 +12,9 @@ Environment=NODE_ENV=production Environment=HOST=127.0.0.1 Environment=PORT=3001 Environment=LABYRICORN_BUILD_ROOT=/var/lib/website-engine/builds +Environment=LABYRICORN_SITE_DEFINITION_REPOSITORY=/var/lib/website-engine/repositories/labyricorn-site-git-repo +Environment=LABYRICORN_SITE_DEFINITION_REF=refs/heads/main +Environment=LABYRICORN_SITE_DEFINITION_PATH=. ExecStart=/usr/bin/node /opt/website-engine-control-plane/dist/server.cjs Restart=on-failure RestartSec=5s diff --git a/deploy/nginx-website-engine.conf b/deploy/nginx-website-engine.conf index 755ed1c..eca7103 100644 --- a/deploy/nginx-website-engine.conf +++ b/deploy/nginx-website-engine.conf @@ -27,3 +27,17 @@ server { error_page 404 /404.html; } } + +server { + listen 8080 default_server; + listen [::]:8080 default_server; + server_name _; + + root /var/lib/website-engine/builds/staging; + index index.html; + + location / { + try_files $uri $uri/ =404; + error_page 404 /404.html; + } +} diff --git a/packages/site-definition/.labyricorn/site.yml b/packages/site-definition/.labyricorn/site.yml new file mode 100644 index 0000000..238ba1f --- /dev/null +++ b/packages/site-definition/.labyricorn/site.yml @@ -0,0 +1,8 @@ +version: 1 +enabled: true +artifacts: + content_paths: + - pages + - articles + - projects + media_paths: [] diff --git a/packages/site-definition/.theme/templates/content/article.liquid b/packages/site-definition/.theme/templates/content/article.liquid index fc33088..4260ad3 100644 --- a/packages/site-definition/.theme/templates/content/article.liquid +++ b/packages/site-definition/.theme/templates/content/article.liquid @@ -1,9 +1,9 @@
- +
-

{{ item.artifactType | escape }}

-

{{ item.title | escape }}

-

{{ item.summary | escape }}

-
{{ item.content | safe_content }}
+

{{ page.contentModel }}

+

{{ page.title }}

+

{{ page.summary }}

+
{{ page.renderedContent | safe_content }}
diff --git a/packages/site-definition/.theme/templates/content/project.liquid b/packages/site-definition/.theme/templates/content/project.liquid index 18947d2..fbced11 100644 --- a/packages/site-definition/.theme/templates/content/project.liquid +++ b/packages/site-definition/.theme/templates/content/project.liquid @@ -2,27 +2,22 @@
-

{{ project.title | escape }}

-

{{ project.summary | escape }}

-

Open the standalone demo

-

Pinned provenance

-

The project and its publication were read from {{ project.sourceSnapshot.repository | escape }} at one exact commit.

+

{{ page.title }}

+

{{ page.summary }}

+
{{ page.renderedContent | safe_content }}
+

Open the standalone demo

Tags

- -

Devlogs

- {% if project.relatedContent.devlogs.size == 0 %}

No matching devlogs in this snapshot.

{% endif %} - {% for item in project.relatedContent.devlogs %}

{{ item.title | escape }}

{% endfor %} +

Related articles

- {% if project.relatedContent.articles.size == 0 %}

No matching articles in this snapshot.

{% endif %} - {% for item in project.relatedContent.articles %}

{{ item.title | escape }}

{% endfor %} + {% for item in page.relationships.articles %}

{{ item.title }}

{% endfor %}
diff --git a/packages/site-definition/.theme/templates/home.liquid b/packages/site-definition/.theme/templates/home.liquid index 261b62c..05f8b79 100644 --- a/packages/site-definition/.theme/templates/home.liquid +++ b/packages/site-definition/.theme/templates/home.liquid @@ -1,12 +1,13 @@

Engineering · Architecture · Operations

-

{{ site.title | escape }}

-

Repository-owned presentation rendered from an exact Git commit.

+

{{ page.title }}

+

{{ page.summary }}

+
{{ page.renderedContent | safe_content }}

Latest publications

- {% if content.size == 0 %}

No published content in this snapshot.

{% endif %} - {% for item in content %} -

{{ item.artifactType | escape }}

{{ item.title | escape }}

{{ item.summary | escape }}

+ {% if collection.items.size == 0 %}

No published content in this snapshot.

{% endif %} + {% for item in collection.items %} +

{{ item.contentModel }}

{{ item.title }}

{{ item.summary }}

{% endfor %}
diff --git a/packages/site-definition/.theme/templates/layout.liquid b/packages/site-definition/.theme/templates/layout.liquid index bab2ed1..3262421 100644 --- a/packages/site-definition/.theme/templates/layout.liquid +++ b/packages/site-definition/.theme/templates/layout.liquid @@ -1,24 +1,24 @@ - + -{{ page.title | escape }} · {{ site.title | escape }} -{% for stylesheet in theme.stylesheets %} -{% endfor %}{% for script in theme.scripts %} +{{ page.title }} · {{ site.title }} +{% for stylesheet in theme.styles %} +{% endfor %}{% for script in theme.scripts %} {% endfor %}
{{ page.body | safe_page_body }}
diff --git a/packages/site-definition/.theme/templates/sections/listing.liquid b/packages/site-definition/.theme/templates/sections/listing.liquid index 7237824..d78695b 100644 --- a/packages/site-definition/.theme/templates/sections/listing.liquid +++ b/packages/site-definition/.theme/templates/sections/listing.liquid @@ -1,10 +1,10 @@

Section

-

{{ section.label | escape }}

+

{{ page.title }}

- {% if content.size == 0 %}

No published items in this snapshot.

{% endif %} - {% for item in content %} -

{{ item.title | escape }}

{{ item.summary | escape }}

+ {% if collection.items.size == 0 %}

No published items in this snapshot.

{% endif %} + {% for item in collection.items %} +

{{ item.title }}

{{ item.summary }}

{% endfor %}
diff --git a/packages/site-definition/articles/welcome.md b/packages/site-definition/articles/welcome.md new file mode 100644 index 0000000..97f6a13 --- /dev/null +++ b/packages/site-definition/articles/welcome.md @@ -0,0 +1,22 @@ +--- +model: article +title: Repository Rendering Is Live +slug: repository-rendering +route: /articles/repository-rendering/ +date: 2026-07-23 +status: published +summary: The reference article proves Markdown discovery and strict Liquid rendering. +tags: + - git + - liquid +metadata: + projectId: website-engine-control-plane +--- + +## Immutable inputs + +Every build resolves one exact Git commit before rendering. + +## Verified output + +The generated release records source checksums and can be promoted atomically. diff --git a/packages/site-definition/pages/index.md b/packages/site-definition/pages/index.md new file mode 100644 index 0000000..f5e5932 --- /dev/null +++ b/packages/site-definition/pages/index.md @@ -0,0 +1,10 @@ +--- +model: page +title: Labyricorn Engineering & Architecture +slug: home +route: / +status: published +summary: Deterministic publishing from immutable Git snapshots. +--- + +The control plane renders this homepage from committed Markdown and repository-owned Liquid templates. diff --git a/packages/site-definition/projects/control-plane.md b/packages/site-definition/projects/control-plane.md new file mode 100644 index 0000000..7a94acb --- /dev/null +++ b/packages/site-definition/projects/control-plane.md @@ -0,0 +1,16 @@ +--- +model: project +title: Website Engine Control Plane +slug: website-engine-control-plane +route: /project/website-engine-control-plane/ +status: published +summary: A deterministic Git-native static site control plane. +tags: + - static-site + - git + - liquid +--- + +## Configuration first + +The project page joins its allowlisted declaration with this committed Markdown body. diff --git a/src/backend/buildEngine.test.ts b/src/backend/buildEngine.test.ts index 876f2ab..abb8161 100644 --- a/src/backend/buildEngine.test.ts +++ b/src/backend/buildEngine.test.ts @@ -49,7 +49,10 @@ test("reference repository theme renders declared routes and only declared asset const result = new BuildEngine(output).build(input); assert.equal(result.success, true); assert.match(input.siteDefinition.commit, /^[0-9a-f]{40}$/); + assert.equal(input.contentItems.length, 3); assert.ok(fs.existsSync(path.join(result.outputDirectory, "index.html"))); + assert.ok(fs.existsSync(path.join(result.outputDirectory, "articles/index.html"))); + assert.ok(fs.existsSync(path.join(result.outputDirectory, "articles/repository-rendering/index.html"))); assert.ok(fs.existsSync(path.join(result.outputDirectory, "projects/index.html"))); assert.ok(fs.existsSync(path.join(result.outputDirectory, "project/website-engine-control-plane/index.html"))); assert.ok(fs.existsSync(path.join(result.outputDirectory, "project/website-engine-control-plane/demo.html"))); @@ -62,6 +65,9 @@ test("reference repository theme renders declared routes and only declared asset assert.equal(manifest.siteDefinition.commit, input.siteDefinition.commit); assert.equal(manifest.theme.commit, input.theme.snapshot.commit); assert.equal(manifest.artifactBuildId, input.artifactBuildId); + assert.match(fs.readFileSync(path.join(result.outputDirectory, "index.html"), "utf8"), /renders this homepage from committed Markdown/); + assert.match(fs.readFileSync(path.join(result.outputDirectory, "articles/repository-rendering/index.html"), "utf8"), /Every build resolves one exact Git commit/); + assert.match(fs.readFileSync(path.join(result.outputDirectory, "project/website-engine-control-plane/index.html"), "utf8"), /joins its allowlisted declaration/); assert.doesNotMatch(fs.readFileSync(path.join(result.outputDirectory, "index.html"), "utf8"), /fonts\.googleapis|tailwindcss\.com/); } finally { manager.dispose(); } })); @@ -152,6 +158,21 @@ test("project declaration controls the project route and standalone publication" } finally { manager.dispose(); } })); + +test("project declarations cannot silently shadow non-project content", () => withRepository((root, output) => { + fs.rmSync(path.join(root, "packages/site-definition/projects/control-plane.md")); + fs.writeFileSync(path.join(root, "packages/site-definition/projects/.gitkeep"), ""); + const article = path.join(root, "packages/site-definition/articles/welcome.md"); + fs.writeFileSync(article, fs.readFileSync(article, "utf8").replace("route: /articles/repository-rendering/", "route: /project/website-engine-control-plane/")); + commit(root, "create route collision"); + const { manager, input } = load(root, "route-collision"); + try { + const result = new BuildEngine(output).build(input); + assert.equal(result.success, false); + assert.ok(result.validationReport.errors.some((error) => error.code === "E_ROUTE_COLLISION")); + } finally { manager.dispose(); } +})); + test("declared standalone files are copied byte-for-byte", () => withRepository((root, output) => { const { manager, input } = load(root, "publication"); try { diff --git a/src/backend/buildInputLoader.ts b/src/backend/buildInputLoader.ts index 30c54a4..1b4781f 100644 --- a/src/backend/buildInputLoader.ts +++ b/src/backend/buildInputLoader.ts @@ -5,14 +5,13 @@ import path from "node:path"; import YAML from "yaml"; import { ContentItem, - ContentModel, - MediaAsset, ProjectDeclaration, ResolvedBuildInput, ResolvedProject, ResolvedPublishedFile, SiteConfig, } from "../types"; +import { loadRepositoryContent } from "./contentLoader"; import { RepositorySnapshotManager } from "./repositories/repositorySnapshot"; import { canonicalPath, parseProjects, resolveRegularFile, ThemeLoader } from "./theme/themeLoader"; @@ -32,6 +31,11 @@ export interface BuildInputLoaderOptions { builderVersion: string; } +const normalizedRoute = (route: string): string => { + const parts = route.split("/").filter(Boolean); + return parts.length === 0 ? "/" : `/${parts.join("/")}/`; +}; + export class BuildInputLoader { private readonly snapshots: RepositorySnapshotManager; private readonly repositoryRoot: string; @@ -53,9 +57,10 @@ export class BuildInputLoader { const configRoot = path.join(siteDefinition.checkoutRoot, ...this.siteDefinitionPath.split("/")); const inputChecksums: Record = {}; const readConfig = (relative: string): Buffer => { - const file = resolveRegularFile(configRoot, relative, `configuration '${relative}'`, 2 * 1024 * 1024); + const canonical = canonicalPath(relative, "configuration path"); + const file = resolveRegularFile(configRoot, canonical, `configuration '${relative}'`, 2 * 1024 * 1024); const bytes = fs.readFileSync(file); - inputChecksums[`${this.siteDefinitionPath}/${canonicalPath(relative, "configuration path")}`] = sha256(bytes); + inputChecksums[path.posix.join(this.siteDefinitionPath, canonical)] = sha256(bytes); return bytes; }; @@ -64,26 +69,16 @@ export class BuildInputLoader { if (siteDocument.protocol !== "labyricorn-site/v1") throw new Error("E_SITE_CONFIG_INVALID: unsupported site protocol."); const navigationRelative = siteDocument.navigationFile ?? "navigation.yml"; const navigationDocument = YAML.parse(readConfig(navigationRelative).toString("utf8")); - - const contentModels: ContentModel[] = []; - const modelRoot = path.join(siteDefinition.checkoutRoot, "packages", "content-models"); - if (fs.existsSync(modelRoot)) { - for (const name of fs.readdirSync(modelRoot).sort()) { - const modelFile = path.join(modelRoot, name, "model.yml"); - if (!fs.existsSync(modelFile) || !fs.statSync(modelFile).isFile()) continue; - const bytes = fs.readFileSync(modelFile); - inputChecksums[`packages/content-models/${name}/model.yml`] = sha256(bytes); - contentModels.push(YAML.parse(bytes.toString("utf8"))); - } - } + const loadedContent = loadRepositoryContent(siteDefinition, configRoot, siteDocument, inputChecksums); const siteConfig: SiteConfig = { protocol: siteDocument.protocol, site: siteDocument.site, navigation: navigationDocument.navigation ?? [], - contentModels, + contentModels: loadedContent.contentModels, + contentModelsPath: loadedContent.contentModelsPath, sourcesFile: siteDocument.sourcesFile ?? "sources.yml", - styleInstancesPath: siteDocument.styleInstancesPath ?? "../style-configs", + styleInstancesPath: siteDocument.styleInstancesPath ?? "style-configs", pagesPath: siteDocument.pagesPath ?? "pages", navigationFile: navigationRelative, projectsFile: siteDocument.projectsFile, @@ -100,20 +95,32 @@ export class BuildInputLoader { }; let declarations: ProjectDeclaration[] = []; - if (siteConfig.projectsFile) { - const bytes = readConfig(siteConfig.projectsFile); - declarations = parseProjects(bytes); - } - const requiredSections = siteConfig.navigation.filter((entry) => entry.route !== "/").map((entry) => entry.presentation?.sectionTemplate ?? entry.contentModel).filter((value): value is string => Boolean(value)); - const requiredContent = declarations.map((project) => project.presentation.detailTemplate); + if (siteConfig.projectsFile) declarations = parseProjects(readConfig(siteConfig.projectsFile)); + const contentItems = loadedContent.contentItems; + const mediaAssets = loadedContent.mediaAssets; + const declaredProjectRoutes = new Set(declarations.map((project) => normalizedRoute(project.route))); + const projectContentByRoute = new Map( + contentItems + .filter((item) => item.artifactType === "project" && item.status === "published") + .map((item) => [normalizedRoute(item.route), item]), + ); + + const requiredSections = siteConfig.navigation + .filter((entry) => entry.route !== "/") + .map((entry) => entry.presentation?.sectionTemplate ?? entry.contentModel) + .filter((value): value is string => Boolean(value)); + const requiredContent = [...new Set([ + ...declarations.map((project) => project.presentation.detailTemplate), + ...contentItems + .filter((item) => item.status === "published" && item.route !== "/" && item.artifactType !== "project") + .map((item) => item.presentation?.standalone ?? item.artifactType), + ])]; const themeSnapshot = sourceMap.get(siteConfig.theme.source); if (!themeSnapshot) throw new Error(`E_THEME_SOURCE: configured source '${siteConfig.theme.source}' is unresolved.`); const themeRoot = path.posix.join(this.siteDefinitionPath, siteConfig.theme.path); const theme = new ThemeLoader().load(themeSnapshot, themeRoot, requiredSections, requiredContent); - for (const [relative, checksum] of Object.entries(theme.checksums)) inputChecksums[`${themeRoot}/${relative}`] = checksum; + for (const [relative, checksum] of Object.entries(theme.checksums)) inputChecksums[path.posix.join(themeRoot, relative)] = checksum; - const contentItems: ContentItem[] = []; - const mediaAssets: MediaAsset[] = []; const projects: ResolvedProject[] = []; const publishedFiles: ResolvedPublishedFile[] = []; for (const project of declarations) { @@ -121,11 +128,21 @@ export class BuildInputLoader { if (!sourceSnapshot) throw new Error(`E_PROJECT_SOURCE: project '${project.id}' uses unresolved source '${project.source}'.`); const relatedContent: Record = {}; 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] === project.id).sort((a, b) => a.route.localeCompare(b.route) || a.id.localeCompare(b.id)); + const metadataKey = relationship.matchField.slice("metadata.".length); + const matches = contentItems + .filter((item) => item.status === "published" && item.artifactType === relationship.contentModel && item.metadata?.[metadataKey] === project.id) + .sort((a, b) => a.route.localeCompare(b.route) || a.id.localeCompare(b.id)); if (relationship.required && matches.length === 0) throw new Error(`E_PROJECT_RELATIONSHIP: required relationship '${key}' for '${project.id}' has no matches.`); relatedContent[key] = matches; } - projects.push(Object.freeze({ ...project, sourceSnapshot, relatedContent: Object.freeze(relatedContent) })); + projects.push(Object.freeze({ + ...project, + sourceSnapshot, + relatedContent: Object.freeze(relatedContent), + contentItem: declaredProjectRoutes.has(normalizedRoute(project.route)) + ? projectContentByRoute.get(normalizedRoute(project.route)) + : undefined, + })); for (const publication of project.publishedFiles ?? []) { const sourcePath = canonicalPath(publication.source, `published file for '${project.id}'`); const absolute = resolveRegularFile(sourceSnapshot.checkoutRoot, sourcePath, `published file '${sourcePath}'`); @@ -145,10 +162,23 @@ export class BuildInputLoader { }; const artifactBuildId = `sha256-${sha256(stableJson(descriptor))}`; const generatedAt = [...sourceMap.values()].map((source) => source.committedAt).sort().at(-1)!; - return Object.freeze({ runId, artifactBuildId, generatedAt, siteDefinition, sources: sourceMap, siteConfig: Object.freeze(siteConfig), theme, projects: Object.freeze(projects), contentItems: Object.freeze(contentItems), mediaAssets: Object.freeze(mediaAssets), publishedFiles: Object.freeze(publishedFiles), inputChecksums: Object.freeze(inputChecksums) }); + return Object.freeze({ + runId, + artifactBuildId, + generatedAt, + siteDefinition, + sources: sourceMap, + siteConfig: Object.freeze(siteConfig), + theme, + projects: Object.freeze(projects), + contentItems: Object.freeze(contentItems), + mediaAssets: Object.freeze(mediaAssets), + publishedFiles: Object.freeze(publishedFiles), + inputChecksums: Object.freeze(inputChecksums), + }); } dispose(): void { this.snapshots.dispose(); } -} +} \ No newline at end of file diff --git a/src/backend/contentLoader.ts b/src/backend/contentLoader.ts new file mode 100644 index 0000000..447e652 --- /dev/null +++ b/src/backend/contentLoader.ts @@ -0,0 +1,277 @@ +import crypto from "node:crypto"; +import fs from "node:fs"; +import path from "node:path"; +import YAML from "yaml"; +import { + ContentItem, + ContentModel, + MediaAsset, + RepositorySnapshot, +} from "../types"; +import { + canonicalPath, + resolveRegularFile, + ThemeContractError, +} from "./theme/themeLoader"; + +const MAX_CONTENT_BYTES = 2 * 1024 * 1024; +const sha256 = (value: Buffer | string) => + crypto.createHash("sha256").update(value).digest("hex"); + +const issue = (code: string, message: string, file?: string): never => { + throw new ThemeContractError([ + { code, message, file, category: "content" }, + ]); +}; + +const repositoryRelative = (snapshot: RepositorySnapshot, absolute: string): string => + path.relative(snapshot.checkoutRoot, absolute).replace(/\\/g, "/"); + +const resolveDirectory = (root: string, relative: string, label: string): string => { + const canonical = canonicalPath(relative, label); + const rootReal = fs.realpathSync(root); + const candidate = path.resolve(root, ...canonical.split("/")); + let real: string; + try { + real = fs.realpathSync(candidate); + } catch { + return issue("E_CONTENT_PATH_INVALID", `${label} does not exist.`, relative); + } + if (real !== rootReal && !real.startsWith(`${rootReal}${path.sep}`)) { + return issue("E_THEME_PATH_ESCAPE", `${label} resolves outside its allowed root.`, relative); + } + if (!fs.statSync(real).isDirectory()) { + return issue("E_CONTENT_PATH_INVALID", `${label} must be a directory.`, relative); + } + return real; +}; + +const walkFiles = (root: string): string[] => { + const files: string[] = []; + const visit = (directory: string): void => { + for (const entry of fs.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) { + const absolute = path.join(directory, entry.name); + const stat = fs.lstatSync(absolute); + if (stat.isSymbolicLink()) { + issue("E_THEME_PATH_ESCAPE", `Configured content path contains symlink '${entry.name}'.`, entry.name); + } + if (stat.isDirectory()) visit(absolute); + else if (stat.isFile()) files.push(absolute); + else issue("E_CONTENT_PATH_INVALID", `Configured content path contains a non-regular entry '${entry.name}'.`, entry.name); + } + }; + visit(root); + return files; +}; + +const parseFrontMatter = (bytes: Buffer, relative: string): { attributes: Record; body: string } => { + if (bytes.length > MAX_CONTENT_BYTES) { + return issue("E_CONTENT_TOO_LARGE", `Content '${relative}' exceeds ${MAX_CONTENT_BYTES} bytes.`, relative); + } + const source = bytes.toString("utf8").replace(/\r\n/g, "\n"); + const match = source.match(/^---\n([\s\S]*?)\n---(?:\n|$)([\s\S]*)$/); + if (!match) return issue("E_CONTENT_FRONT_MATTER", `Content '${relative}' is missing YAML front matter.`, relative); + let attributes: unknown; + try { + attributes = YAML.parse(match[1]); + } catch (error) { + return issue("E_CONTENT_FRONT_MATTER", `Content '${relative}' has invalid YAML: ${error instanceof Error ? error.message : String(error)}`, relative); + } + if (!attributes || typeof attributes !== "object" || Array.isArray(attributes)) { + return issue("E_CONTENT_FRONT_MATTER", `Content '${relative}' front matter must be a mapping.`, relative); + } + return { attributes: attributes as Record, body: match[2] }; +}; + +const stringValue = (value: unknown, field: string, relative: string, required = false): string => { + if (value === undefined || value === null || value === "") { + if (required) issue("E_CONTENT_FRONT_MATTER", `Content '${relative}' requires '${field}'.`, relative); + return ""; + } + if (typeof value === "string" || typeof value === "number") return String(value); + if (value instanceof Date) return value.toISOString().replace(/T00:00:00\.000Z$/, ""); + return issue("E_CONTENT_FRONT_MATTER", `Content '${relative}' field '${field}' must be scalar.`, relative); +}; + +const stringArray = (value: unknown, field: string, relative: string): string[] => { + if (value === undefined || value === null) return []; + if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) { + return issue("E_CONTENT_FRONT_MATTER", `Content '${relative}' field '${field}' must be a string array.`, relative); + } + return [...value]; +}; + +const normalizeRoute = (value: string, relative: string): string => { + if (!value.startsWith("/") || value.includes("\\") || value.includes("\0") || /[?#]/.test(value)) { + return issue("E_ROUTE_INVALID", `Content '${relative}' has invalid route '${value}'.`, relative); + } + const parts = value.split("/").filter(Boolean); + if (parts.some((part) => part === "." || part === "..")) { + return issue("E_ROUTE_INVALID", `Content '${relative}' route escapes the site root.`, relative); + } + return parts.length === 0 ? "/" : `/${parts.join("/")}/`; +}; + +const mimeType = (filename: string): string => { + switch (path.extname(filename).toLowerCase()) { + case ".svg": return "image/svg+xml"; + case ".png": return "image/png"; + case ".jpg": + case ".jpeg": return "image/jpeg"; + case ".webp": return "image/webp"; + case ".gif": return "image/gif"; + default: return "application/octet-stream"; + } +}; + +export interface LoadedRepositoryContent { + contentModels: ContentModel[]; + contentModelsPath?: string; + contentItems: ContentItem[]; + mediaAssets: MediaAsset[]; +} + +export const loadRepositoryContent = ( + snapshot: RepositorySnapshot, + configRoot: string, + siteDocument: Record, + inputChecksums: Record, +): LoadedRepositoryContent => { + const configuredModelsPath = siteDocument.contentModelsPath + ? canonicalPath(String(siteDocument.contentModelsPath), "contentModelsPath") + : undefined; + const legacyModelRoot = path.join(snapshot.checkoutRoot, "packages", "content-models"); + const modelRoot = configuredModelsPath + ? resolveDirectory(configRoot, configuredModelsPath, "contentModelsPath") + : fs.existsSync(path.join(configRoot, "content-models")) + ? resolveDirectory(configRoot, "content-models", "contentModelsPath") + : fs.existsSync(legacyModelRoot) + ? legacyModelRoot + : undefined; + const contentModels: ContentModel[] = []; + if (modelRoot) { + for (const name of fs.readdirSync(modelRoot).sort()) { + const modelFile = path.join(modelRoot, name, "model.yml"); + if (!fs.existsSync(modelFile)) continue; + const resolved = resolveRegularFile(modelRoot, `${name}/model.yml`, `content model '${name}'`, MAX_CONTENT_BYTES); + const bytes = fs.readFileSync(resolved); + const parsed = YAML.parse(bytes.toString("utf8")) as ContentModel; + if (!parsed?.id || parsed.kind !== "content-model") { + issue("E_CONTENT_MODEL_INVALID", `Content model '${name}' is invalid.`, repositoryRelative(snapshot, resolved)); + } + contentModels.push(parsed); + inputChecksums[repositoryRelative(snapshot, resolved)] = sha256(bytes); + } + } + + let participation: Record = {}; + const participationCandidate = path.join(configRoot, ".labyricorn", "site.yml"); + if (fs.existsSync(participationCandidate)) { + const participationFile = resolveRegularFile(configRoot, ".labyricorn/site.yml", "repository participation policy", MAX_CONTENT_BYTES); + const bytes = fs.readFileSync(participationFile); + participation = YAML.parse(bytes.toString("utf8")) ?? {}; + inputChecksums[repositoryRelative(snapshot, participationFile)] = sha256(bytes); + } + const contentPaths = participation.artifacts?.content_paths ?? [siteDocument.pagesPath ?? "pages"]; + const mediaPaths = participation.artifacts?.media_paths ?? []; + if (!Array.isArray(contentPaths) || contentPaths.some((entry: unknown) => typeof entry !== "string")) { + issue("E_CONTENT_PATH_INVALID", "artifacts.content_paths must be a string array.", ".labyricorn/site.yml"); + } + if (!Array.isArray(mediaPaths) || mediaPaths.some((entry: unknown) => typeof entry !== "string")) { + issue("E_CONTENT_PATH_INVALID", "artifacts.media_paths must be a string array.", ".labyricorn/site.yml"); + } + + const modelById = new Map(contentModels.map((model) => [model.id, model])); + const contentItems: ContentItem[] = []; + const routes = new Set(); + for (const configuredPath of contentPaths as string[]) { + const contentRoot = resolveDirectory(configRoot, configuredPath, `content path '${configuredPath}'`); + for (const absolute of walkFiles(contentRoot).filter((file) => file.toLowerCase().endsWith(".md"))) { + const relative = repositoryRelative(snapshot, absolute); + const bytes = fs.readFileSync(absolute); + const { attributes, body } = parseFrontMatter(bytes, relative); + const artifactType = stringValue(attributes.model, "model", relative, true); + const model = modelById.get(artifactType); + if (!model) issue("E_CONTENT_MODEL_INVALID", `Content '${relative}' references unknown model '${artifactType}'.`, relative); + const title = stringValue(attributes.title, "title", relative, true); + const slug = stringValue(attributes.slug, "slug", relative) || path.basename(relative, path.extname(relative)); + const configuredRoute = stringValue(attributes.route, "route", relative); + const derivedRoute = model.routing?.detail?.replace("{slug}", slug); + const route = normalizeRoute(configuredRoute || derivedRoute || `/${slug}/`, relative); + if (routes.has(route)) issue("E_ROUTE_COLLISION", `Multiple content documents declare '${route}'.`, relative); + routes.add(route); + const status = stringValue(attributes.status, "status", relative, true); + if (!(["published", "draft", "archived"] as string[]).includes(status)) { + issue("E_CONTENT_FRONT_MATTER", `Content '${relative}' has invalid status '${status}'.`, relative); + } + const metadata = attributes.metadata && typeof attributes.metadata === "object" && !Array.isArray(attributes.metadata) + ? { ...(attributes.metadata as Record) } + : {}; + for (const key of ["projectId", "projectName", "projectUrl"]) { + if (attributes[key] !== undefined) metadata[key] = attributes[key]; + } + const presentation = attributes.presentation && typeof attributes.presentation === "object" && !Array.isArray(attributes.presentation) + ? attributes.presentation as ContentItem["presentation"] + : undefined; + contentItems.push({ + id: `${snapshot.sourceId}:${relative}`, + title, + slug, + published: stringValue(attributes.published ?? attributes.date, "published", relative) || snapshot.committedAt, + updated: stringValue(attributes.updated, "updated", relative) || snapshot.committedAt, + author: stringValue(attributes.author, "author", relative) || undefined, + status: status as ContentItem["status"], + artifactType, + summary: stringValue(attributes.summary, "summary", relative), + featuredImage: stringValue(attributes.featuredImage, "featuredImage", relative) || undefined, + tags: stringArray(attributes.tags, "tags", relative), + presentation, + aliases: stringArray(attributes.aliases, "aliases", relative), + sourceRepo: snapshot.sourceId, + path: relative, + contentMarkdown: body, + mediaReferences: [], + youtubeDirectives: [], + wikipediaLinks: [], + validationStatus: "valid", + validationMessages: [], + route, + styleInstanceId: model.presentation?.default_style_config ?? artifactType, + metadata, + }); + inputChecksums[relative] = sha256(bytes); + } + } + + const mediaAssets: MediaAsset[] = []; + for (const configuredPath of mediaPaths as string[]) { + const mediaRoot = resolveDirectory(configRoot, configuredPath, `media path '${configuredPath}'`); + for (const absolute of walkFiles(mediaRoot).filter((file) => !path.basename(file).startsWith("."))) { + const bytes = fs.readFileSync(absolute); + const sourcePath = repositoryRelative(snapshot, absolute); + const localRelative = path.relative(mediaRoot, absolute).replace(/\\/g, "/"); + const publicPath = `/media/${localRelative}`; + const checksum = sha256(bytes); + mediaAssets.push({ + id: `${snapshot.sourceId}:${sourcePath}`, + sourceRepo: snapshot.sourceId, + originalPath: sourcePath, + sourcePath, + sourceChecksum: checksum, + publicNamespacePath: publicPath, + filename: path.basename(absolute), + sizeBytes: bytes.length, + mimeType: mimeType(absolute), + isValidated: true, + }); + inputChecksums[sourcePath] = checksum; + } + } + + return { + contentModels: contentModels.sort((a, b) => a.id.localeCompare(b.id)), + contentModelsPath: configuredModelsPath, + contentItems: contentItems.sort((a, b) => a.route.localeCompare(b.route) || a.id.localeCompare(b.id)), + mediaAssets: mediaAssets.sort((a, b) => a.publicNamespacePath.localeCompare(b.publicNamespacePath)), + }; +}; \ No newline at end of file diff --git a/src/backend/repositoryBuildEngine.ts b/src/backend/repositoryBuildEngine.ts index 9332826..ba64329 100644 --- a/src/backend/repositoryBuildEngine.ts +++ b/src/backend/repositoryBuildEngine.ts @@ -91,7 +91,7 @@ export const renderMarkdown = (markdown: string): string => renderMarkdownDocume class StrictLiquidRenderer { private readonly liquid: Liquid; - private readonly allowedFilters = new Set(["escape", "safe_content", "safe_page_body"]); + private readonly allowedFilters = new Set(["escape", "join", "safe_content", "safe_page_body"]); constructor(partialsRoot: string) { this.liquid = new Liquid({ root: [partialsRoot], extname: ".liquid", strictVariables: true, strictFilters: true, dynamicPartials: false, relativeReference: false }); @@ -117,6 +117,16 @@ class StrictLiquidRenderer { } } +const escapeContext = (value: unknown): unknown => { + if (value instanceof SafeHtml || value === null || value === undefined) return value; + if (typeof value === "string") return escapeHtml(value); + if (Array.isArray(value)) return value.map(escapeContext); + if (typeof value === "object") { + return Object.fromEntries(Object.entries(value as Record).map(([key, child]) => [key, escapeContext(child)])); + } + return value; +}; + const normalizeDirectoryRoute = (route: string): string => { if (!route.startsWith("/") || route.includes("\0") || route.includes("\\") || /[?#]/.test(route)) throw new Error(`E_ROUTE_INVALID: invalid route '${route}'.`); const parts = route.split("/").filter(Boolean); @@ -202,7 +212,8 @@ export class BuildEngine { for (const project of input.projects) claim(directoryRouteFile(project.route), `project:${project.id}`); for (const publication of input.publishedFiles) claim(literalRouteFile(publication.route), `published:${publication.projectId}`); for (const item of input.contentItems.filter((candidate) => candidate.status === "published")) { - claim(directoryRouteFile(item.route), `content:${item.id}`); + const route = normalizeDirectoryRoute(item.route); + if (item.artifactType !== "project" && route !== "/") claim(directoryRouteFile(route), `content:${item.id}`); const markdownWithoutCode = item.contentMarkdown.replace(/```[\s\S]*?```/g, "").replace(/`[^`]*`/g, ""); if (!input.siteConfig.markdown.rawHtmlEnabled && /<\/?[A-Za-z][^>]*>/.test(markdownWithoutCode)) { report.htmlPolicyViolations.push(item.path); report.errors.push({ code: "E_RAW_HTML_DISABLED", message: `Raw HTML is disabled in '${item.path}'.`, file: item.path, category: "markdown" }); } } @@ -233,6 +244,11 @@ export class BuildEngine { }; try { for (const relative of [...input.theme.manifest.assets.styles, ...input.theme.manifest.assets.scripts, ...input.theme.manifest.assets.files]) write(relative, fs.readFileSync(resolveRegularFile(input.theme.root, relative, `asset '${relative}'`))); + for (const media of input.mediaAssets) { + const snapshot = input.sources.get(media.sourceRepo); + if (!snapshot || !media.sourcePath) throw new Error(`E_MEDIA_SOURCE: unresolved media '${media.id}'.`); + write(media.publicNamespacePath.replace(/^\/+/, ""), fs.readFileSync(resolveRegularFile(snapshot.checkoutRoot, media.sourcePath, `media '${media.sourcePath}'`))); + } const fontCss: string[] = []; for (const font of input.theme.manifest.assets.fonts) { const released = this.resolveFont(font, input); @@ -243,31 +259,105 @@ export class BuildEngine { for (const publication of input.publishedFiles) write(literalRouteFile(publication.route), fs.readFileSync(resolveRegularFile(publication.sourceSnapshot.checkoutRoot, publication.sourcePath, `published file '${publication.sourcePath}'`))); const renderer = new StrictLiquidRenderer(path.join(input.theme.root, "partials")); - const stylesheets = input.theme.manifest.assets.styles.map((asset) => `/${asset}`); - if (fontCss.length) stylesheets.push("/assets/styles/fonts.generated.css"); + const styles = input.theme.manifest.assets.styles.map((asset) => `/${asset}`); + if (fontCss.length) styles.push("/assets/styles/fonts.generated.css"); const scripts = input.theme.manifest.assets.scripts.map((asset) => `/${asset}`); - const navigation = input.siteConfig.navigation.map(({ id, label, route }) => ({ id, label, route: normalizeDirectoryRoute(route) })); - const base = { site: input.siteConfig.site, navigation, theme: { id: input.theme.manifest.id, version: input.theme.manifest.version, stylesheets, scripts, fontStylesheet: fontCss.length ? "/assets/styles/fonts.generated.css" : null }, build: { id: input.artifactBuildId, generatedAt: input.generatedAt } }; - const page = (templateKey: string, title: string, context: Record, output: string, route: string) => { + const navigation = input.siteConfig.navigation.map(({ id, label, route, contentModel, presentation }) => ({ + id, label, route: normalizeDirectoryRoute(route), contentModel: contentModel ?? null, + templateKey: presentation?.sectionTemplate ?? contentModel ?? null, + })); + const sourceCommits = Object.fromEntries([...input.sources.entries()].map(([id, snapshot]) => [id, snapshot.commit]).sort(([a], [b]) => a.localeCompare(b))); + const base = { + site: input.siteConfig.site, + navigation, + theme: { id: input.theme.manifest.id, version: input.theme.manifest.version, assetBaseUrl: "/assets/", styles, scripts, fontStylesheet: fontCss.length ? "/assets/styles/fonts.generated.css" : null }, + build: { id: input.artifactBuildId, generatedAt: input.generatedAt, sourceCommits }, + }; + const collectionItem = (item: ContentItem) => ({ + id: item.id, contentModel: item.artifactType, templateKey: item.presentation?.standalone ?? item.artifactType, + title: item.title, route: normalizeDirectoryRoute(item.route), summary: item.summary || null, + published: item.published || null, tags: item.tags, metadata: item.metadata ?? {}, + }); + const renderedItemPage = (item: ContentItem, type: "home" | "content" = "content") => { + const rendered = renderMarkdownDocument(item.contentMarkdown); + const snapshot = input.sources.get(item.sourceRepo)!; + return { + type, contentModel: item.artifactType, templateKey: type === "home" ? "home" : item.presentation?.standalone ?? item.artifactType, + id: item.id, title: item.title, route: normalizeDirectoryRoute(item.route), summary: item.summary || null, + published: item.published || null, updated: item.updated ?? snapshot.committedAt, author: item.author ?? null, tags: item.tags, + metadata: { version: null, license: null, homepage: null, stack: [], ...(item.metadata ?? {}) }, provenance: { sourceId: snapshot.sourceId, repository: snapshot.repository, commit: snapshot.commit }, + renderedContent: rendered.html, body: null, tableOfContents: rendered.toc, relationships: {}, publications: [], + }; + }; + const renderPage = (templateKey: string, pageContext: Record, collection: { items: unknown[] }, output: string, route: string) => { const bodySource = input.theme.templateSources[templateKey]; if (!bodySource) throw new Error(`E_PROJECT_TEMPLATE_UNRESOLVED: template '${templateKey}' is missing.`); - const body = new SafeHtml(renderer.render(bodySource, { ...base, ...context }, templateKey)); - const html = renderer.render(input.theme.templateSources.layout, { ...base, ...context, page: { title, body } }, "layout"); + const bodyContext = escapeContext({ ...base, page: pageContext, collection }) as Record; + const body = new SafeHtml(renderer.render(bodySource, bodyContext, templateKey)); + const layoutContext = escapeContext({ ...base, page: { ...pageContext, body }, collection }) as Record; + const html = renderer.render(input.theme.templateSources.layout, layoutContext, "layout"); write(output, html.endsWith("\n") ? html : `${html}\n`); routes.add(route); }; - page("home", input.siteConfig.site.title, { content: input.contentItems }, "index.html", "/"); + + const projectRoutes = new Set(input.projects.map((project) => normalizeDirectoryRoute(project.route))); + const publishedItems = input.contentItems.filter((item) => + item.status === "published" && (item.artifactType !== "project" || projectRoutes.has(normalizeDirectoryRoute(item.route))), + ); + const homeItem = publishedItems.find((item) => normalizeDirectoryRoute(item.route) === "/"); + const homePage = homeItem ? renderedItemPage(homeItem, "home") : { + type: "home", contentModel: "page", templateKey: "home", id: input.siteConfig.site.id, title: input.siteConfig.site.title, + route: "/", summary: null, published: null, updated: input.generatedAt, author: null, tags: [], metadata: {}, + provenance: { sourceId: input.siteDefinition.sourceId, repository: input.siteDefinition.repository, commit: input.siteDefinition.commit }, + renderedContent: new SafeHtml(""), body: null, tableOfContents: [], relationships: {}, publications: [], + }; + const homeCollection = publishedItems.filter((item) => normalizeDirectoryRoute(item.route) !== "/").map(collectionItem); + renderPage("home", homePage, { items: homeCollection }, "index.html", "/"); + for (const entry of input.siteConfig.navigation.filter((item) => item.route !== "/")) { const key = entry.presentation?.sectionTemplate ?? entry.contentModel!; - const items = input.contentItems.filter((item) => item.status === "published" && item.artifactType === entry.contentModel); - page(`sections.${key}`, entry.label, { section: entry, content: items }, directoryRouteFile(entry.route), normalizeDirectoryRoute(entry.route)); + const contentCollection = publishedItems.filter((item) => item.artifactType === entry.contentModel).map(collectionItem); + const projectCollection = input.projects.map((project) => project.contentItem ? collectionItem(project.contentItem) : ({ + id: project.id, contentModel: "project", templateKey: project.presentation.detailTemplate, title: project.title ?? project.id, + route: normalizeDirectoryRoute(project.route), summary: project.summary ?? null, published: project.sourceSnapshot.committedAt, + tags: project.tags ?? [], metadata: { version: project.version ?? null, license: project.license ?? null, homepage: project.homepage ?? null, stack: project.stack ?? [] }, + })); + const pageContext = { + type: "section", contentModel: entry.contentModel ?? null, templateKey: key, id: entry.id, title: entry.label, + route: normalizeDirectoryRoute(entry.route), summary: null, published: null, updated: input.generatedAt, author: null, tags: [], metadata: {}, + provenance: { sourceId: input.siteDefinition.sourceId, repository: input.siteDefinition.repository, commit: input.siteDefinition.commit }, + renderedContent: new SafeHtml(""), body: null, tableOfContents: [], relationships: {}, publications: [], + }; + renderPage(`sections.${key}`, pageContext, { items: entry.contentModel === "project" ? projectCollection : contentCollection }, directoryRouteFile(entry.route), normalizeDirectoryRoute(entry.route)); } - for (const project of input.projects) page(`content.${project.presentation.detailTemplate}`, project.title ?? project.id, { project: { ...project, sourceSnapshot: { sourceId: project.sourceSnapshot.sourceId, repository: project.sourceSnapshot.repository, commit: project.sourceSnapshot.commit, committedAt: project.sourceSnapshot.committedAt } } }, directoryRouteFile(project.route), normalizeDirectoryRoute(project.route)); - for (const item of input.contentItems.filter((candidate) => candidate.status === "published")) { - const rendered = renderMarkdownDocument(item.contentMarkdown); + + for (const project of input.projects) { + const route = normalizeDirectoryRoute(project.route); + const item = project.contentItem; + const rendered = item ? renderMarkdownDocument(item.contentMarkdown) : { html: new SafeHtml(""), toc: [] }; + const relationships = Object.fromEntries(Object.entries(project.relatedContent).map(([key, items]) => [key, items.map((related) => ({ ...collectionItem(related), renderedContent: renderMarkdownDocument(related.contentMarkdown).html }))])); + const pageContext = { + type: "project", contentModel: "project", templateKey: project.presentation.detailTemplate, id: project.id, + title: project.title ?? item?.title ?? project.id, route, summary: project.summary ?? item?.summary ?? null, + published: item?.published ?? project.sourceSnapshot.committedAt, updated: item?.updated ?? project.sourceSnapshot.committedAt, author: item?.author ?? null, + tags: project.tags ?? item?.tags ?? [], metadata: { ...(item?.metadata ?? {}), version: project.version ?? null, license: project.license ?? null, homepage: project.homepage ?? null, stack: project.stack ?? [] }, + provenance: { sourceId: project.sourceSnapshot.sourceId, repository: project.sourceSnapshot.repository, commit: project.sourceSnapshot.commit }, + renderedContent: rendered.html, body: null, tableOfContents: rendered.toc, relationships, publications: project.publishedFiles ?? [], + }; + renderPage(`content.${project.presentation.detailTemplate}`, pageContext, { items: [] }, directoryRouteFile(route), route); + } + + for (const item of publishedItems) { + const route = normalizeDirectoryRoute(item.route); + if (route === "/" || item.artifactType === "project" || projectRoutes.has(route)) continue; const key = item.presentation?.standalone ?? item.artifactType; - page(`content.${key}`, item.title, { item: { ...item, content: rendered.html, tableOfContents: rendered.toc } }, directoryRouteFile(item.route), normalizeDirectoryRoute(item.route)); + renderPage(`content.${key}`, renderedItemPage(item), { items: [] }, directoryRouteFile(route), route); } - page("notFound", "Not Found", {}, "404.html", "/404.html"); + const notFoundPage = { + type: "notFound", contentModel: null, templateKey: "notFound", id: null, title: "Not Found", route: "/404.html", summary: null, + published: null, updated: input.generatedAt, author: null, tags: [], metadata: {}, provenance: { sourceId: input.siteDefinition.sourceId, repository: input.siteDefinition.repository, commit: input.siteDefinition.commit }, + renderedContent: new SafeHtml(""), body: null, tableOfContents: [], relationships: {}, publications: [], + }; + renderPage("notFound", notFoundPage, { items: [] }, "404.html", "/404.html"); const declaredScripts = new Set(scripts); const releaseFiles = new Set(files); diff --git a/src/types.ts b/src/types.ts index 049e608..caa5b50 100644 --- a/src/types.ts +++ b/src/types.ts @@ -72,6 +72,8 @@ export interface ContentItem { status: "published" | "draft" | "archived"; artifactType: string; // Matches ContentModel ID summary: string; + updated?: string; + author?: string; featuredImage?: string; tags: string[]; presentation?: { @@ -95,6 +97,7 @@ export interface ContentItem { projectId?: string; projectName?: string; projectUrl?: string; + [key: string]: unknown; }; } @@ -107,6 +110,8 @@ export interface MediaAsset { sizeBytes: number; mimeType: string; isValidated: boolean; + sourcePath?: string; + sourceChecksum?: string; } export interface ThemeConfig { @@ -298,6 +303,7 @@ export interface SiteConfig { }; navigation: NavigationEntry[]; contentModels: ContentModel[]; + contentModelsPath?: string; sourcesFile: string; styleInstancesPath: string; pagesPath: string; @@ -455,6 +461,7 @@ export interface ProjectDeclaration { export interface ResolvedProject extends ProjectDeclaration { sourceSnapshot: RepositorySnapshot; relatedContent: Readonly>; + contentItem?: ContentItem; } export interface ResolvedPublishedFile extends PublishedFile {