/*
RFC-0320: portable provenance frontmatter for generated Markdown twins.
Builds YAML frontmatter with canonical URL, language, lastModified, contentHash,
license, generator, and sourceKind. The contentHash is a deterministic sha256
over the normalized Markdown body (excluding frontmatter).
- Do not hash the frontmatter — only the body is hashed.
- Do not use build date or mtime for lastModified.
- RFC-0320: initial implementation.
- RFC-0377: extended provenance with MarkdownTwinSemanticMeta, derivation maps, and bumped schema to gogol.markdown-twin@2.
- RFC-0602: allow null lastModified for person-slug twins without source-backed stamps.
*/
import { createHash } from "node:crypto";
import type { SemanticPageType } from "../../semantic/models.ts";
export interface MarkdownTwinSemanticMeta {
id: string;
route: string;
title: string;
type: SemanticPageType;
domain: string;
audience: string;
lang: string;
metaDescription: string;
priority: number;
tags: string[];
agentRoles?: string[];
visibility?: "public" | "internal" | "experimental";
}
export interface MarkdownTwinProvenance {
canonical: string;
language: string;
lastModified: string | null;
license: string;
generator: string;
sourceKind: string;
pageId?: string;
sourceInputs?: string[];
schema?: string;
semantic?: MarkdownTwinSemanticMeta;
}
export const AUDIENCE_BY_PAGE_TYPE: Record = {
home: "general",
about: "general",
projects: "developer",
donationContact: "general",
openSource: "developer",
content: "general",
article: "general",
person: "general",
participant: "general",
legal: "business_owner",
collection: "general",
};
export const PRIORITY_BY_PAGE_TYPE: Record = {
home: 1.0,
about: 0.6,
projects: 0.5,
donationContact: 0.3,
openSource: 0.4,
content: 0.7,
article: 0.8,
person: 0.3,
participant: 0.3,
legal: 0.5,
collection: 0.8,
};
export const DOMAIN_BY_PAGE_TYPE: Record = {
home: "site",
about: "site",
projects: "projects",
donationContact: "contact",
openSource: "projects",
content: "content",
article: "content",
person: "team",
participant: "team",
legal: "legal",
collection: "content",
};
const DEFAULT_SCHEMA = "gogol.markdown-twin@2";
/**
* RFC-0320: Normalize body for hashing.
* - Line endings to LF
* - Trim exactly one trailing newline
*/
function normalizeBody(body: string): string {
const lf = body.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
return `${lf.trimEnd()}\n`;
}
/**
* RFC-0320: Compute deterministic sha256 hash over normalized body bytes.
* Returns `sha256:`.
*/
export function computeContentHash(body: string): string {
const normalized = normalizeBody(body);
const hash = createHash("sha256").update(normalized, "utf-8").digest("hex");
return `sha256:${hash}`;
}
/**
* RFC-0320: Build YAML frontmatter for a Markdown twin.
*/
function buildYamlListField(name: string, items: string[]): string {
if (items.length === 0) {
return `${name}: []`;
}
const lines = items.map((item) => ` - "${item}"`).join("\n");
return `${name}:\n${lines}`;
}
export function buildMarkdownTwinFrontmatter(
provenance: MarkdownTwinProvenance,
contentHash: string,
): string {
const fields: string[] = [
`canonical: "${provenance.canonical}"`,
`language: "${provenance.language}"`,
`lastModified: ${provenance.lastModified === null ? "null" : `"${provenance.lastModified}"`}`,
`contentHash: "${contentHash}"`,
`license: "${provenance.license}"`,
`generator: "${provenance.generator}"`,
`sourceKind: "${provenance.sourceKind}"`,
];
if (provenance.pageId) {
fields.push(`pageId: "${provenance.pageId}"`);
}
if (provenance.sourceInputs && provenance.sourceInputs.length > 0) {
fields.push(buildYamlListField("sourceInputs", provenance.sourceInputs));
}
if (provenance.semantic) {
const s = provenance.semantic;
fields.push(`id: "${s.id}"`);
fields.push(`route: "${s.route}"`);
fields.push(`title: "${s.title}"`);
fields.push(`type: "${s.type}"`);
fields.push(`domain: "${s.domain}"`);
fields.push(`audience: "${s.audience}"`);
fields.push(`lang: "${s.lang}"`);
fields.push(`metaDescription: "${s.metaDescription}"`);
fields.push(`priority: ${s.priority}`);
fields.push(buildYamlListField("tags", s.tags));
if (s.agentRoles && s.agentRoles.length > 0) {
fields.push(buildYamlListField("agentRoles", s.agentRoles));
}
if (s.visibility) {
fields.push(`visibility: "${s.visibility}"`);
}
}
fields.push(`schema: "${provenance.schema ?? DEFAULT_SCHEMA}"`);
return `---\n${fields.join("\n")}\n---\n\n`;
}
/**
* RFC-0320: Build a complete Markdown twin with frontmatter + body.
* The contentHash is computed over the normalized body.
*/
export function buildMarkdownTwin(body: string, provenance: MarkdownTwinProvenance): string {
const normalizedBody = normalizeBody(body);
const contentHash = computeContentHash(normalizedBody);
const frontmatter = buildMarkdownTwinFrontmatter(provenance, contentHash);
return `${frontmatter}${normalizedBody}`;
}
/**
* RFC-0320: Parse frontmatter from a Markdown twin document.
* Returns { frontmatter, body } or null if no frontmatter found.
*/
export function parseMarkdownTwinFrontmatter(
content: string,
): { frontmatter: Record; body: string } | null {
if (!content.startsWith("---\n")) return null;
const endIdx = content.indexOf("\n---\n", 4);
if (endIdx === -1) return null;
const yamlBlock = content.slice(4, endIdx);
// buildMarkdownTwinFrontmatter() closes with "---\n\n" (a blank separator line before the
// body). Skipping only "\n---\n" leaves that blank line's newline as a stray leading
// character in `body`, which then hashes differently than the body that was actually
// written — every twin's contentHash mismatched on validate. Consume the separator too.
const body = content.slice(endIdx + 5).replace(/^\n/, "");
const frontmatter: Record = {};
let _currentKey = "";
let currentList: string[] | null = null;
for (const line of yamlBlock.split("\n")) {
if (line.startsWith(" - ")) {
if (currentList) {
currentList.push(line.slice(4).replace(/^["']|["']$/g, ""));
}
continue;
}
const match = line.match(/^(\w+):\s*(.*)$/);
if (match) {
const [, key, value] = match;
if (value === "") {
_currentKey = key;
currentList = [];
frontmatter[key] = currentList;
} else {
_currentKey = "";
currentList = null;
const stripped = value.replace(/^["']|["']$/g, "");
frontmatter[key] = stripped === "null" ? null : stripped;
}
}
}
return { frontmatter, body };
}
/**
* RFC-0320: Verify that the contentHash in frontmatter matches the body.
*/
export function verifyMarkdownTwinHash(content: string): boolean {
const parsed = parseMarkdownTwinFrontmatter(content);
if (!parsed) return false;
const declared = parsed.frontmatter.contentHash;
if (typeof declared !== "string") return false;
const actual = computeContentHash(parsed.body);
return declared === actual;
}