Generate a topic-map graph from Markdown frontmatter
A Node script that reads topic:/relates: frontmatter across a Markdown vault and generates MOC index pages, region tags, and backlink blocks — with a --check gate that blocks stale commits.
bash# ─────────────────────────────────────────────────────────────
# Prerequisites: Node.js 18+ and a Markdown vault whose topic
# pages carry `topic:` and `relates:` YAML frontmatter.
# ─────────────────────────────────────────────────────────────
# 1. In each TOPIC page, declare the neuron id and its synapses:
# ---
# title: "Proxmox Cluster"
# topic: proxmox-cluster
# relates: [storage-layout, cluster-networking]
# tags: [infrastructure]
# ---
# 2. Dry run — see what WOULD be generated, write nothing
node scripts/build-topic-map.mjs .
# 3. Generate the maps for real (deterministic: stamp the date)
node scripts/build-topic-map.mjs . --write --date 2026-07-15
# 4. Verify the generated glia
ls topics/ # one MOC page per region
git status # region/* tags + brain-region blocks updated
# 5. Drift gate — run before every commit. Non-zero exit = stale.
node scripts/build-topic-map.mjs . --check \
|| { echo "Maps stale — run --write, then stage."; exit 1; }
# 6. Optional: wire the gate into a pre-commit hook
cat > .git/hooks/pre-commit <<'EOF'
#!/bin/bash
node scripts/build-topic-map.mjs . --check || {
echo "Topic maps are stale. Run: node scripts/build-topic-map.mjs . --write --date $(date +%F)"
exit 1
}
EOF
chmod +x .git/hooks/pre-commit
# ─────────────────────────────────────────────────────────────
# Reference: a minimal build-topic-map.mjs skeleton.
# Save as scripts/build-topic-map.mjs. Hardened CLI: validates the
# root positional, rejects unknown flags, honors -h/--help.
# ─────────────────────────────────────────────────────────────
cat > scripts/build-topic-map.mjs <<'EOF'
#!/usr/bin/env node
import { readdir, readFile, writeFile } from 'node:fs/promises';
import { join, extname } from 'node:path';
const USAGE = `Usage: build-topic-map.mjs <vault-root> [--write] [--check] [--date YYYY-MM-DD]
<vault-root> directory to scan (required)
--write write generated MOC/tag/backlink files
--check exit non-zero if committed maps differ from generated
--date DATE stamp regeneration date (deterministic output)
-h, --help show this help`;
const args = process.argv.slice(2);
if (args.includes('-h') || args.includes('--help')) { console.log(USAGE); process.exit(0); }
const flags = new Set(['--write', '--check', '--date']);
const root = args.find(a => !a.startsWith('-') && args[args.indexOf(a) - 1] !== '--date');
if (!root) { console.error('error: <vault-root> is required\n\n' + USAGE); process.exit(2); }
for (const a of args) {
if (a.startsWith('--') && !flags.has(a)) { console.error(`error: unknown flag ${a}\n\n` + USAGE); process.exit(2); }
}
const write = args.includes('--write');
const check = args.includes('--check');
const date = args[args.indexOf('--date') + 1] || '';
// Walk .md files, parse frontmatter topic:/relates:, build regions.
async function walk(dir, out = []) {
for (const e of await readdir(dir, { withFileTypes: true })) {
if (e.name.startsWith('.')) continue;
const p = join(dir, e.name);
if (e.isDirectory()) await walk(p, out);
else if (extname(e.name) === '.md') out.push(p);
}
return out;
}
const files = await walk(root);
const neurons = [];
for (const f of files) {
const txt = await readFile(f, 'utf8');
const m = txt.match(/^---\n([\s\S]*?)\n---/);
if (!m) continue;
const topic = (m[1].match(/^topic:\s*(.+)$/m) || [])[1]?.trim();
if (!topic) continue;
const rel = (m[1].match(/^relates:\s*\[(.*)\]/m) || [])[1] || '';
const tags = (m[1].match(/^tags:\s*\[(.*)\]/m) || [])[1] || '';
const region = (tags.match(/region\/([\w-]+)/) || [])[1] || 'unsorted';
neurons.push({ file: f, topic, region, relates: rel.split(',').map(s => s.trim()).filter(Boolean) });
}
// Group into regions → one MOC per region.
const byRegion = {};
for (const n of neurons) (byRegion[n.region] ||= []).push(n);
let stale = false;
for (const [region, members] of Object.entries(byRegion)) {
const moc = `---\ntitle: "${region}"\ntype: moc\nupdated: ${date}\n---\n# ${region}\n\n`
+ members.map(n => `- [[${n.topic}]]`).join('\n') + '\n';
const dest = join(root, 'topics', `${region}.md`);
if (check) {
const cur = await readFile(dest, 'utf8').catch(() => '');
if (cur !== moc) { stale = true; console.error(`stale: ${dest}`); }
} else if (write) {
await writeFile(dest, moc);
console.log(`wrote ${dest}`);
} else {
console.log(`would write ${dest} (${members.length} neurons)`);
}
}
if (check && stale) process.exit(1);
EOF
What this does
Reads topic: and relates: frontmatter across every Markdown page in a vault and generates the graph scaffolding: one MOC (Map of Content) index page per region, plus region grouping. It runs in three modes — dry run (default, writes nothing), --write (generate the files), and --check (exit non-zero if the committed maps are stale) — so you can gate commits on an up-to-date graph. The script block includes a minimal, hardened build-topic-map.mjs skeleton you can extend.
Why this approach
A knowledge base grows faster than any manual link maintenance strategy can keep up with. Early on, I maintained a hand-written list of related pages at the bottom of each note. When a page was renamed, I’d update its link in a few places and forget the rest. When I added a new page, I had to remember to add it to every relevant index. The graph was always partially stale, and the staleness was silent — nothing told me which links were wrong.
The topic graph generator in this playbook flips the model. Instead of maintaining links by hand, you declare one piece of metadata in each page’s frontmatter — topic: second-brain or relates: [proxmox-cluster, wiki-js] — and the script derives the entire link graph from those declarations. The MOC pages (one per topic region) are generated output, not authored content. You never edit them directly. When a page moves or a new one appears, you update the topic: field in that one file and regenerate. The graph is always as current as your last regeneration.
The --check mode is what makes this scalable beyond a toy vault. A generated file that you forget to regenerate is worse than a hand-maintained file, because it looks authoritative and isn’t. The check mode runs the generator in memory, diffs the output against what’s committed, and exits non-zero if they don’t match. Wire that into your commit workflow (a pre-commit hook, or the preflight step of the canonical write), and you literally cannot commit a stale graph. “I forgot to regenerate” stops being a class of bug that reaches production.
The hardening requirement in the Notes section — validating arguments, printing usage on bad input, honoring -h/--help — came from a very concrete incident. An early version of the generator threw an ENOENT when invoked with a stray --write flag but no vault root (the flag was being interpreted as the root positional). It took several minutes to figure out why. A tool that tells you plainly what it wants, rather than crashing cryptically or hanging, makes runbooks you wrote six months ago actually followable at midnight.
Prerequisites
- Node.js 18+ (
node --version). - A Markdown vault whose topic pages carry
topic:and (optionally)relates:andregion/*-tagged frontmatter. - The vault under Git, so
--checkin a pre-commit hook can block stale commits.
Notes
- Substitute your own values before running any line — this playbook uses example stand-ins. Replace the
scripts/…paths (wherever your vault keeps its tooling) and thetopic:/relates:values (your own subjects, not the examples). - Declare relationships once, generate the rest. You never hand-edit the
topics/*.mdMOC pages — they are regenerated glia. Edit a page’srelates:instead and rebuild. - Always run
--checkbefore committing. The whole value of a generated graph is that it stays in sync with the pages; the check turns “forgot to regenerate” into a hard, visible error. - The CLI validates its arguments. It requires the
<vault-root>positional, rejects unknown flags, and honors-h/--help— an earlier version that hung on missing input or threw a crypticENOENTwas the mistake this hardening fixes. A tool you run at midnight must state plainly what it wants. --datemakes output deterministic so regeneration doesn’t produce spurious diffs from an embedded timestamp — pass the same date you commit under.- The skeleton is intentionally minimal — it generates region MOCs to illustrate the pattern. Extend it to also write
region/*tags andbrain-regionbacklink blocks onto each neuron as your vault grows. - Mark
tested: trueonly after running all three modes against your own vault.