#!/usr/bin/env node
/**
* Sync the wiki corpus (+ the root DESIGN.md legacy doorway) into Starlight,
* emit a sidebar module from wiki/SUMMARY.md.
*
* Generated paths are gitignored; never hand-edit them.
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const SITE_ROOT = path.resolve(__dirname, '..');
const REPO_ROOT = path.resolve(SITE_ROOT, '..');
const WIKI_ROOT = path.join(REPO_ROOT, 'wiki');
const REPO_ASSETS_ROOT = path.join(REPO_ROOT, 'assets');
const DOCS_OUT = path.join(SITE_ROOT, 'src', 'content', 'docs');
const ASSETS_OUT = path.join(SITE_ROOT, 'src', 'content', 'assets');
const GENERATED_OUT = path.join(SITE_ROOT, 'src', 'generated');
const SIDEBAR_OUT = path.join(GENERATED_OUT, 'sidebar.ts');
function rmrf(dir) {
fs.rmSync(dir, { recursive: true, force: true });
}
function ensureDir(dir) {
fs.mkdirSync(dir, { recursive: true });
}
const ASSET_EXT = new Set([
'.png',
'.jpg',
'.jpeg',
'.gif',
'.webp',
'.svg',
'.avif',
]);
function walkFiles(dir, predicate, out = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walkFiles(full, predicate, out);
} else if (
(entry.isFile() || entry.isSymbolicLink()) &&
predicate(entry.name, full)
) {
out.push(full);
}
}
return out;
}
function extractTitle(body, fallback) {
for (const line of body.split(/\r?\n/)) {
const m = line.match(/^#\s+(.+?)\s*$/);
if (m) {
return m[1]
.replace(/[*_`]/g, '')
.replace(/\s+/g, ' ')
.trim();
}
}
return fallback;
}
const CORPUS_ROLES = {
law: { name: 'LAW', meaning: 'Binding invariant' },
spec: { name: 'SPEC', meaning: 'Binding system contract' },
knowledge: { name: 'KNOWLEDGE', meaning: 'Current-state fact' },
log: { name: 'LOG', meaning: 'Historical record — not current design' },
navigation: { name: 'NAVIGATION', meaning: 'Doorway only — no design authority' },
};
function extractCorpusRole(body, wikiRel) {
if (wikiRel === 'DESIGN.md') {
return { role: 'navigation', body };
}
const matches = [...body.matchAll(/^Type: (law|spec|knowledge|log)\s*$/gm)];
if (matches.length !== 1) {
throw new Error(
`${wikiRel}: expected exactly one supported Type line, found ${matches.length}`,
);
}
const match = matches[0];
body = `${body.slice(0, match.index)}${body.slice(match.index + match[0].length)}`;
// Law, knowledge, and log pages commonly put Type in its own fenced block.
// Once the line becomes a visible role banner, remove that now-empty fence.
body = body.replace(/^```[ \t]*\r?\n[ \t]*\r?\n```[ \t]*\r?\n?/m, '');
return { role: match[1], body };
}
function renderCorpusRole(role) {
const copy = CORPUS_ROLES[role];
return [
`
`,
` ${copy.name}`,
` ${copy.meaning}`,
'
',
].join('\n');
}
/** Strip the first ATX H1 — Starlight already renders `title` as the page H1. */
function stripLeadingH1(body) {
const lines = body.split(/\r?\n/);
let i = 0;
while (i < lines.length && lines[i].trim() === '') i += 1;
if (i < lines.length && /^#\s+/.test(lines[i])) {
i += 1;
while (i < lines.length && lines[i].trim() === '') i += 1;
return lines.slice(i).join('\n');
}
return body;
}
function yamlEscape(s) {
if (/[:#{}[\],&*!|>'"%@`]|^[-?]|\n/.test(s) || s !== s.trim()) {
return JSON.stringify(s);
}
return s;
}
/**
* Site base prefix without trailing slash. Empty string when the site is
* served at domain root (local `SITE_BASE=/`). Production Tangled sub-path
* hosting defaults to `/misaligned` — same default as astro.config.mjs.
*/
function siteBasePrefix() {
const raw = process.env.SITE_BASE ?? '/misaligned';
if (!raw || raw === '/') return '';
return raw.endsWith('/') ? raw.slice(0, -1) : raw;
}
/** wiki-relative path → Starlight slug (no leading/trailing slash). */
function wikiRelToSlug(relPosix) {
const normalized = path.posix.normalize(relPosix);
let slug = normalized.replace(/\.md$/i, '');
// Keep the old constitution slug for root-doorway compatibility.
if (slug === 'DESIGN' || slug === '../DESIGN') return 'constitution';
if (slug.endsWith('/README')) {
slug = slug.slice(0, -'/README'.length);
} else if (slug === 'README') {
slug = '';
}
// Starlight / Astro content collection IDs are lowercased.
return slug.toLowerCase();
}
/** Starlight slug → public URL under SITE_BASE (trailing slash). */
function slugToPublicUrl(slug) {
const prefix = siteBasePrefix();
if (!slug) return `${prefix}/`;
return `${prefix}/${slug}/`;
}
function destFileForWikiRel(relPosix) {
if (relPosix === 'DESIGN.md') return path.join(DOCS_OUT, 'constitution.md');
if (relPosix === 'SUMMARY.md') return null;
if (/(^|\/)README\.md$/i.test(relPosix)) {
const dir = relPosix.replace(/README\.md$/i, '');
return path.join(DOCS_OUT, dir, 'index.md');
}
// Keep original filename casing on disk. Starlight content IDs are
// lowercased regardless; sidebar slugs go through wikiRelToSlug().
return path.join(DOCS_OUT, relPosix);
}
function rewriteMarkdownLinks(body, fromWikiRel) {
const fromDir = path.posix.dirname(fromWikiRel);
return body.replace(/\[([^\]]*)\]\(([^)]+)\)/g, (full, text, href) => {
const trimmed = href.trim();
if (
!trimmed ||
trimmed.startsWith('#') ||
trimmed.startsWith('http://') ||
trimmed.startsWith('https://') ||
trimmed.startsWith('mailto:') ||
trimmed.startsWith('tel:')
) {
return full;
}
const [pathPartRaw, hash = ''] = trimmed.split('#');
let pathPart = pathPartRaw;
if (!pathPart) return full;
pathPart = pathPart.replace(/^<|>$/g, '').split(/\s+/)[0];
let targetWikiRel;
if (pathPart.startsWith('/')) {
const abs = pathPart.replace(/^\//, '');
if (abs === 'DESIGN.md' || abs === 'DESIGN') {
targetWikiRel = 'DESIGN.md';
} else if (abs.startsWith('wiki/')) {
targetWikiRel = abs.slice('wiki/'.length);
} else {
return full;
}
} else {
const joined = path.posix.normalize(path.posix.join(fromDir, pathPart));
if (joined === '../DESIGN.md' || joined === 'DESIGN.md') {
targetWikiRel = 'DESIGN.md';
} else if (fromWikiRel === 'DESIGN.md' && joined.startsWith('wiki/')) {
targetWikiRel = joined.slice('wiki/'.length);
} else if (fromWikiRel === 'DESIGN.md') {
// Other root repository files are not part of the public docs tree.
return text;
} else if (joined.startsWith('../')) {
return full;
} else {
targetWikiRel = joined;
}
}
if (!/\.md$/i.test(targetWikiRel)) return full;
// SUMMARY drives the sidebar but is not itself a rendered page.
if (targetWikiRel === 'SUMMARY.md') return text;
const slug = wikiRelToSlug(targetWikiRel);
// Must include SITE_BASE. Root-absolute `/slug/` links 404 on Tangled
// sub-path hosting (cameron.tngl.io/misaligned/...), where the public
// route is `/misaligned/slug/`. Starlight prefixes its own chrome; body
// markdown is emitted as written, so the sync owns the base.
const url = slugToPublicUrl(slug);
const withHash = hash ? `${url}#${hash}` : url;
return `[${text}](${withHash})`;
});
}
function writeDoc(wikiRel, sourceAbs) {
const dest = destFileForWikiRel(wikiRel);
if (!dest) return;
let body = fs.readFileSync(sourceAbs, 'utf8');
if (body.startsWith('---\n')) {
const end = body.indexOf('\n---\n', 4);
if (end !== -1) body = body.slice(end + 5);
}
const fallbackTitle = path.basename(wikiRel, '.md');
const title = extractTitle(body, fallbackTitle);
const corpus = extractCorpusRole(body, wikiRel);
body = corpus.body;
body = stripLeadingH1(body);
body = rewriteMarkdownLinks(body, wikiRel);
body = `${renderCorpusRole(corpus.role)}\n\n${body.trimStart()}`;
const out = `---\ntitle: ${yamlEscape(title)}\n---\n\n${body}`;
ensureDir(path.dirname(dest));
fs.writeFileSync(dest, out, 'utf8');
}
function nestInto(items, item, depth) {
if (depth <= 0 || items.length === 0) {
items.push(item);
return;
}
const prev = items[items.length - 1];
if (!prev.items) {
items[items.length - 1] = {
label: prev.label,
items: [{ label: prev.label, slug: prev.slug }],
};
}
const parent = items[items.length - 1];
if (depth === 1) {
parent.items.push(item);
} else {
nestInto(parent.items, item, depth - 1);
}
}
function mapItem(item) {
if (item.items) {
return {
label: item.label,
collapsed: true,
items: item.items.map(mapItem),
};
}
const slug = item.slug === '' ? 'index' : item.slug;
return { label: item.label, slug };
}
/** Parse the repo's CommonMark SUMMARY.md into Starlight sidebar items. */
function parseSummary(summaryText) {
const lines = summaryText.split(/\r?\n/);
const top = [];
let currentSection = null;
const linkRe = /^(\s*)-\s+\[([^\]]+)\]\(([^)]+)\)\s*$/;
const bareLinkRe = /^\[([^\]]+)\]\(([^)]+)\)\s*$/;
const sectionRe = /^#\s+(.+?)\s*$/;
function pushLink(label, href, indent) {
const cleanHref = href.split('#')[0];
if (!cleanHref.endsWith('.md')) return;
const slug = wikiRelToSlug(cleanHref);
const item = { label, slug };
if (!currentSection) {
top.push({ type: 'link', ...item });
return;
}
const depth = Math.floor(indent / 2);
if (depth <= 1) {
currentSection.items.push(item);
} else {
nestInto(currentSection.items, item, depth - 1);
}
}
for (const raw of lines) {
const line = raw.replace(/\t/g, ' ');
if (!line.trim()) continue;
if (line.trim().startsWith('Dated session')) break;
const sec = line.match(sectionRe);
if (sec) {
const label = sec[1].trim();
if (label.toLowerCase() === 'summary') continue;
currentSection = { label, items: [] };
top.push({ type: 'section', label, items: currentSection.items });
continue;
}
const bare = line.match(bareLinkRe);
if (bare && !line.trimStart().startsWith('-')) {
pushLink(bare[1], bare[2], 0);
continue;
}
const m = line.match(linkRe);
if (m) pushLink(m[2], m[3], m[1].length);
}
const mapped = top.map((entry) => {
if (entry.type === 'link') {
return {
label: entry.label,
slug: entry.slug === '' ? 'index' : entry.slug,
};
}
return {
label: entry.label,
collapsed: false,
items: entry.items.map(mapItem),
};
});
// Splash homepage is a custom Astro route, not a docs slug.
return [{ label: 'Home', link: '/' }, ...mapped];
}
function writeSidebar(sidebar) {
ensureDir(GENERATED_OUT);
const json = JSON.stringify(sidebar, null, 2);
const src = `/** Auto-generated by scripts/sync-wiki.mjs — do not edit. */\nexport const sidebar = ${json} as const;\n`;
fs.writeFileSync(SIDEBAR_OUT, src, 'utf8');
}
function main() {
if (!fs.existsSync(WIKI_ROOT)) {
console.error(`wiki root not found: ${WIKI_ROOT}`);
process.exit(1);
}
rmrf(DOCS_OUT);
rmrf(ASSETS_OUT);
ensureDir(DOCS_OUT);
ensureDir(ASSETS_OUT);
ensureDir(GENERATED_OUT);
// DESIGN.md is a root doorway, not design authority. Sync it explicitly for
// old /constitution/ links instead of keeping a renderer-specific duplicate.
writeDoc('DESIGN.md', path.join(REPO_ROOT, 'DESIGN.md'));
let pageCount = 1;
const mdFiles = walkFiles(WIKI_ROOT, (name) => name.endsWith('.md'));
for (const abs of mdFiles) {
const rel = path.relative(WIKI_ROOT, abs).split(path.sep).join('/');
const real = fs.realpathSync(abs);
writeDoc(rel, real);
pageCount += 1;
}
const wikiAssets = walkFiles(WIKI_ROOT, (name) =>
ASSET_EXT.has(path.extname(name).toLowerCase()),
);
let assetCount = 0;
for (const abs of wikiAssets) {
const rel = path.relative(WIKI_ROOT, abs).split(path.sep).join('/');
const dest = path.join(DOCS_OUT, rel);
ensureDir(path.dirname(dest));
fs.copyFileSync(fs.realpathSync(abs), dest);
assetCount += 1;
}
// Wiki pages keep repo-relative links to design references under assets/.
// Mirror those files beside the generated docs so Astro can resolve the
// same ../../assets/... paths without rewriting source documentation.
if (fs.existsSync(REPO_ASSETS_ROOT)) {
const repoAssets = walkFiles(REPO_ASSETS_ROOT, (name) =>
ASSET_EXT.has(path.extname(name).toLowerCase()),
);
for (const abs of repoAssets) {
const rel = path
.relative(REPO_ASSETS_ROOT, abs)
.split(path.sep)
.join('/');
const dest = path.join(ASSETS_OUT, rel);
ensureDir(path.dirname(dest));
fs.copyFileSync(fs.realpathSync(abs), dest);
assetCount += 1;
}
}
const summary = fs.readFileSync(path.join(WIKI_ROOT, 'SUMMARY.md'), 'utf8');
const sidebar = parseSummary(summary);
writeSidebar(sidebar);
console.log(
`sync-wiki: wrote ${pageCount} pages + ${assetCount} assets → ${path.relative(REPO_ROOT, DOCS_OUT)}`,
);
console.log(
`sync-wiki: sidebar → ${path.relative(REPO_ROOT, SIDEBAR_OUT)} (${sidebar.length} top entries)`,
);
}
main();