#!/usr/bin/env node // Copies the repo's own docs/*.md into src/content/docs/ so Astro's content // collection can read them, and rewrites the relative links inside them so // they resolve on the published site instead of on a GitHub-style tree. // // The docs live at the repo root, not inside site/, because they are real, // human-written documentation shared with `cargo doc` (see // crates/didbot/src/lib.rs, which include_str!s the same files into the // crate's rustdoc). This script does not fork them: it reads them where they // live and writes a generated copy, which is why src/content/docs/ is // gitignored — regenerate it, never hand-edit it. // // Link rewriting: // - A link to another page under docs/ (e.g. "trust-model.md" or // "../docs/trust-model.md") becomes "/docs//", the route the docs // collection renders that page at. // - An in-page anchor ("#section") is left alone. // - Anything absolute (http/https/mailto) is left alone. // - Any other relative link (e.g. "../README.md", // "../crates/didbot-stack/README.md") points at a file this site does not // publish, so it is rewritten to that file's location in the source // repository on Tangled, where it always resolves. import { createHash } from "node:crypto"; import { execFileSync } from "node:child_process"; import { mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const here = dirname(fileURLToPath(import.meta.url)); const siteRoot = resolve(here, ".."); const repoRoot = resolve(siteRoot, ".."); const docsSrc = join(repoRoot, "docs"); const docsDest = join(siteRoot, "src", "content", "docs"); // The canonical place to browse a file that this site does not render. const SOURCE_REPO = "https://tangled.org/permadeath.com/didbot/blob/main"; const LINK_RE = /\]\(([^)]+)\)/g; // Reference-style link definitions: "[label]: target" on its own line. Used // in docs/conformance.md for rustdoc intra-doc links like // "[`AgentDid`]: crate::identity::AgentDid" — a shorthand markdown-it expands // with no parentheses in sight, so the inline-link regex above never sees it. const REF_LINK_RE = /^(\[[^\]]+\]:\s*)(\S+)/gm; // A rustdoc intra-doc link: a bare Rust item path, not a URL and not a // filesystem path. It only resolves inside rustdoc itself, so on this site it // becomes a link into the /api/ mount's search rather than a dead link. const RUST_PATH_RE = /^crate::[\w:]+$/; function titleFor(markdown, fallback) { const match = markdown.match(/^#\s+(.+)$/m); return match ? match[1].trim() : fallback; } function rewriteLink(target, docFile) { if (/^(https?:)?\/\//.test(target) || target.startsWith("mailto:") || target.startsWith("#")) { return target; } if (RUST_PATH_RE.test(target)) { const lastSegment = target.split("::").pop(); return `/api/didbot/?search=${encodeURIComponent(lastSegment)}`; } const [pathPart, anchor] = target.split("#"); const anchorSuffix = anchor ? `#${anchor}` : ""; if (pathPart === "") { return target; } // Resolve relative to the *source* file's real location, not the copy. const resolved = resolve(dirname(join(docsSrc, docFile)), pathPart); if (resolved.startsWith(docsSrc) && resolved.endsWith(".md")) { const slug = resolved.slice(docsSrc.length + 1).replace(/\.md$/, ""); return `/docs/${slug}/${anchorSuffix}`; } if (resolved.startsWith(repoRoot)) { const repoRelative = resolved.slice(repoRoot.length + 1); return `${SOURCE_REPO}/${repoRelative}${anchorSuffix}`; } // Outside the repo entirely — nothing sensible to rewrite it to. return target; } rmSync(docsDest, { recursive: true, force: true }); mkdirSync(docsDest, { recursive: true }); const files = readdirSync(docsSrc).filter((name) => name.endsWith(".md")); if (files.length === 0) { throw new Error(`no markdown files found in ${docsSrc}`); } // Real provenance, not decoration: a content hash of the actual file this // page renders, and the actual commit that last touched it — the site's // "a record is a signed commit" argument, made touchable rather than stated. // Falls back to "unknown" rather than a fake hash if git is unavailable // (a tarball checkout with no `.git`, say); never invents one. function lastCommit(file) { try { return execFileSync( "git", ["log", "-1", "--format=%h", "--", `docs/${file}`], { cwd: repoRoot, encoding: "utf8" }, ).trim() || "unknown"; } catch { return "unknown"; } } for (const file of files) { const raw = readFileSync(join(docsSrc, file), "utf8"); const rewritten = raw .replace(LINK_RE, (whole, target) => `](${rewriteLink(target, file)})`) .replace(REF_LINK_RE, (whole, prefix, target) => `${prefix}${rewriteLink(target, file)}`); const contentHash = createHash("sha256").update(raw).digest("hex").slice(0, 12); const commit = lastCommit(file); const slug = file.replace(/\.md$/, ""); const title = titleFor(raw, slug); const frontmatter = `---\ntitle: ${JSON.stringify(title)}\nslug: ${JSON.stringify(slug)}\nsourcePath: ${JSON.stringify(`docs/${file}`)}\ncontentHash: ${JSON.stringify(contentHash)}\nlastCommit: ${JSON.stringify(commit)}\n---\n\n`; writeFileSync(join(docsDest, file), frontmatter + rewritten); } console.log(`prepare-docs: wrote ${files.length} page(s) to ${docsDest}`);