feat(docs): add docs index generator + initial INDEX.md
scripts/generate-docs-index.mjs walks docs/**/*.md, parses YAML front-matter (or first H1 fallback) and a Nygard-style ## Status section, then writes docs/INDEX.md with grouped tables for ADRs, Specs, Plans (with archive subsection), Patterns, and Other. Pure Node 20 (no external deps); idempotent — running it twice produces byte-identical output. Excludes adr/templates/, the ADR README, INDEX.md itself, and any *_*.md sidecar file. Wire-up: - package.json: docs:index → node scripts/generate-docs-index.mjs Initial run indexed 35 docs across the existing structure; the generated INDEX.md is committed so the table is reviewable in the PR before hooking generation into a pre-commit step.
This commit is contained in:
parent
8a7d419972
commit
eea5c86886
3 changed files with 344 additions and 1 deletions
65
docs/INDEX.md
Normal file
65
docs/INDEX.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
<!-- Generated by scripts/generate-docs-index.mjs. Do not edit by hand. Run `npm run docs:index`. -->
|
||||
|
||||
# Documentation Index
|
||||
|
||||
Auto-generated on 2026-05-02 from front-matter and headings.
|
||||
|
||||
## Architecture Decision Records
|
||||
|
||||
| # | Title | Status |
|
||||
|---|---|---|
|
||||
| 0000 | [ADR-0000: Record architecture decisions](./adr/0000-record-architecture-decisions.md) | accepted |
|
||||
|
||||
## Specifications
|
||||
|
||||
| Title | Status | Updated |
|
||||
|---|---|---|
|
||||
| [Scrum4Me — Technische Architectuur](./scrum4me-architecture.md) | — | — |
|
||||
| [Scrum4Me — Implementatie Backlog](./scrum4me-backlog.md) | — | — |
|
||||
| [Scrum4Me — Functionele Specificatie](./scrum4me-functional-spec.md) | — | — |
|
||||
| [PbiDialog Profiel](./scrum4me-pbi-dialog.md) | — | — |
|
||||
| [DevPlanner — User Personas](./scrum4me-personas.md) | — | — |
|
||||
| [DevPlanner — Product Backlog](./scrum4me-product-backlog.md) | — | — |
|
||||
| [StoryDialog Profiel](./scrum4me-story-dialog.md) | — | — |
|
||||
| [Scrum4Me — Styling & Design System](./scrum4me-styling.md) | — | — |
|
||||
| [TaskDialog Profiel](./scrum4me-task-dialog.md) | — | — |
|
||||
| [Scrum4Me — API Test Plan](./scrum4me-test-plan.md) | — | — |
|
||||
|
||||
## Plans
|
||||
|
||||
| Title | Status | Updated |
|
||||
|---|---|---|
|
||||
| [Docs-restructuur — geoptimaliseerd voor AI-lookup](./plans/docs-restructure-ai-lookup.md) | proposal | 2026-05-02 |
|
||||
| [M10 — Password-loze inlog via QR-pairing](./plans/M10-qr-pairing-login.md) | — | — |
|
||||
| [M11 — Claude vraagt, gebruiker antwoordt](./plans/M11-claude-questions.md) | — | — |
|
||||
| [M9 — Actief Product Backlog](./plans/M9-active-product-backlog.md) | — | — |
|
||||
| [Plan — ST-1109 · PBI krijgt een status (Ready / Blocked / Done)](./plans/ST-1109-pbi-status.md) | — | — |
|
||||
| [Plan: ST-1110 — Demo gebruiker read-only](./plans/ST-1110-demo-readonly.md) | — | — |
|
||||
| [ST-1111 — 'Voer uit'-knop met Claude Code job queue](./plans/ST-1111-claude-job-trigger.md) | — | — |
|
||||
| [Plan — ST-1114 · Copilot reviews op dashboard](./plans/ST-1114-copilot-reviews.md) | — | — |
|
||||
| [Plan: Tweede Claude Agent — Planning Agent (PBI/Story → children)](./plans/Tweede Claude Agent — Planning Agent.md) | — | — |
|
||||
|
||||
## Patterns
|
||||
|
||||
| Title | Status | Updated |
|
||||
|---|---|---|
|
||||
| [Patroon: Bidirectionele async-comms tussen MCP-agent en interactieve user](./patterns/claude-question-channel.md) | — | — |
|
||||
| [Pattern — Entity Dialog](./patterns/dialog.md) | — | — |
|
||||
| [Patroon: iron-session](./patterns/iron-session.md) | — | — |
|
||||
| [Patroon: Proxy (route protection)](./patterns/middleware.md) | — | — |
|
||||
| [Patroon: Prisma Client singleton](./patterns/prisma-client.md) | — | — |
|
||||
| [Patroon: QR-pairing via unauth-SSE + pre-auth cookie](./patterns/qr-login.md) | — | — |
|
||||
| [Patroon: Route Handler (REST API)](./patterns/route-handler.md) | — | — |
|
||||
| [Patroon: Server Action](./patterns/server-action.md) | — | — |
|
||||
| [Patroon: Float sort_order (drag-and-drop volgorde)](./patterns/sort-order.md) | — | — |
|
||||
| [test](./patterns/test.md) | — | — |
|
||||
| [Patroon: Zustand optimistische update + rollback](./patterns/zustand-optimistic.md) | — | — |
|
||||
|
||||
## Other Docs
|
||||
|
||||
| Title | Path | Status | Updated |
|
||||
|---|---|---|---|
|
||||
| [Agent Instruction Audit](./agent-instruction-audit.md) | `agent-instruction-audit.md` | — | — |
|
||||
| [Scrum4Me REST API](./API.md) | `API.md` | — | — |
|
||||
| [Material Design 3 Color Scheme Documentation](./MD3_Color_Scheme_Documentation.md) | `MD3_Color_Scheme_Documentation.md` | — | — |
|
||||
| [Solo Paneel — Implementatie-specificatie (v2)](./solo-paneel-spec.md) | `solo-paneel-spec.md` | — | — |
|
||||
|
|
@ -15,7 +15,8 @@
|
|||
"db:erd": "prisma generate",
|
||||
"db:erd:watch": "chokidar \"prisma/schema.prisma\" -c \"npm run db:erd\"",
|
||||
"db:insert-milestone": "tsx scripts/insert-milestone.ts",
|
||||
"seed": "prisma db seed"
|
||||
"seed": "prisma db seed",
|
||||
"docs:index": "node scripts/generate-docs-index.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.4.1",
|
||||
|
|
|
|||
277
scripts/generate-docs-index.mjs
Normal file
277
scripts/generate-docs-index.mjs
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
#!/usr/bin/env node
|
||||
// Generate docs/INDEX.md from the front-matter and headings of every
|
||||
// .md file under docs/. Pure Node 20 — no external dependencies.
|
||||
//
|
||||
// Usage: `npm run docs:index` (or `node scripts/generate-docs-index.mjs`).
|
||||
//
|
||||
// Idempotent: rewriting INDEX.md from the same inputs produces identical
|
||||
// output (apart from the generation date in the header), so the script
|
||||
// is safe to run repeatedly and in pre-commit hooks.
|
||||
|
||||
import { readdir, readFile, writeFile } from 'node:fs/promises';
|
||||
import { join, relative, basename, sep } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
|
||||
const REPO_ROOT = join(SCRIPT_DIR, '..');
|
||||
const DOCS_DIR = join(REPO_ROOT, 'docs');
|
||||
const INDEX_PATH = join(DOCS_DIR, 'INDEX.md');
|
||||
|
||||
// Paths (relative to repo root, forward-slashed) that the index should
|
||||
// skip entirely. Templates and archived plans aren't useful in the live
|
||||
// roster; sidecar files prefixed with `_` are personal Obsidian scratch.
|
||||
const EXCLUDE_PATTERNS = [
|
||||
/^docs\/adr\/templates\//,
|
||||
/^docs\/adr\/README\.md$/,
|
||||
/\/_[^/]+\.md$/,
|
||||
/^docs\/INDEX\.md$/,
|
||||
];
|
||||
|
||||
async function walk(dir) {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const files = [];
|
||||
for (const e of entries) {
|
||||
const full = join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
files.push(...(await walk(full)));
|
||||
} else if (e.isFile() && e.name.endsWith('.md')) {
|
||||
files.push(full);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
// Minimal YAML front-matter parser. Front-matter in this repo is restricted
|
||||
// to flat `key: value` pairs, so a hand-rolled parser is enough — and
|
||||
// keeps the script dependency-free.
|
||||
function parseFrontMatter(content) {
|
||||
if (!content.startsWith('---\n')) return { data: {}, body: content };
|
||||
const end = content.indexOf('\n---\n', 4);
|
||||
if (end === -1) return { data: {}, body: content };
|
||||
const block = content.slice(4, end);
|
||||
const data = {};
|
||||
for (const raw of block.split('\n')) {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
const m = line.match(/^([A-Za-z][\w-]*)\s*:\s*(.*?)\s*$/);
|
||||
if (!m) continue;
|
||||
let val = m[2];
|
||||
if (
|
||||
(val.startsWith('"') && val.endsWith('"')) ||
|
||||
(val.startsWith("'") && val.endsWith("'"))
|
||||
) {
|
||||
val = val.slice(1, -1);
|
||||
}
|
||||
data[m[1]] = val;
|
||||
}
|
||||
return { data, body: content.slice(end + 5) };
|
||||
}
|
||||
|
||||
function extractFirstH1(text) {
|
||||
const m = text.match(/^#\s+(.+?)\s*$/m);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
// For Nygard-style ADRs the status lives under a `## Status` heading
|
||||
// instead of YAML front-matter. Pull the first non-empty line after the
|
||||
// heading so the index can still show it.
|
||||
function extractStatusSection(text) {
|
||||
const m = text.match(/^##\s+Status\s*\n+([^\n#].*?)(?:\n|$)/m);
|
||||
return m ? m[1].trim() : null;
|
||||
}
|
||||
|
||||
function isExcluded(relPath) {
|
||||
return EXCLUDE_PATTERNS.some((rx) => rx.test(relPath));
|
||||
}
|
||||
|
||||
// Map a path under docs/ to one of the four named sections, or "Other".
|
||||
// Folder-based first; root-level docs fall back to a name-prefix rule
|
||||
// so legacy `scrum4me-*.md` files still surface under Specs until the
|
||||
// docs-restructure migrates them into `docs/specs/`.
|
||||
function categorize(relPath) {
|
||||
const parts = relPath.split('/');
|
||||
if (parts[0] !== 'docs') return 'Other';
|
||||
if (parts.length === 2) {
|
||||
return /^scrum4me-/.test(parts[1]) ? 'Specs' : 'Other';
|
||||
}
|
||||
const sub = parts[1];
|
||||
if (sub === 'adr') return 'ADRs';
|
||||
if (sub === 'specs') return 'Specs';
|
||||
if (sub === 'plans') return 'Plans';
|
||||
if (sub === 'patterns') return 'Patterns';
|
||||
return 'Other';
|
||||
}
|
||||
|
||||
function adrNumber(filename) {
|
||||
const m = filename.match(/^(\d{4})-/);
|
||||
return m ? parseInt(m[1], 10) : null;
|
||||
}
|
||||
|
||||
function escapePipe(s) {
|
||||
return String(s).replace(/\|/g, '\\|');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const files = await walk(DOCS_DIR);
|
||||
const docs = [];
|
||||
|
||||
for (const full of files) {
|
||||
const rel = relative(REPO_ROOT, full).split(sep).join('/');
|
||||
if (isExcluded(rel)) continue;
|
||||
|
||||
const content = await readFile(full, 'utf8');
|
||||
const { data, body } = parseFrontMatter(content);
|
||||
|
||||
const title =
|
||||
data.title || extractFirstH1(body) || basename(full, '.md');
|
||||
const status = data.status || extractStatusSection(body) || '';
|
||||
const date = data.date || data.last_updated || '';
|
||||
const linkPath = './' + rel.replace(/^docs\//, '');
|
||||
const category = categorize(rel);
|
||||
|
||||
docs.push({
|
||||
rel,
|
||||
title,
|
||||
status,
|
||||
date,
|
||||
linkPath,
|
||||
category,
|
||||
basename: basename(full),
|
||||
});
|
||||
}
|
||||
|
||||
const groups = { ADRs: [], Specs: [], Plans: [], Patterns: [], Other: [] };
|
||||
for (const d of docs) {
|
||||
if (groups[d.category]) groups[d.category].push(d);
|
||||
}
|
||||
|
||||
groups.ADRs.sort((a, b) => {
|
||||
const na = adrNumber(a.basename) ?? 9999;
|
||||
const nb = adrNumber(b.basename) ?? 9999;
|
||||
if (na !== nb) return na - nb;
|
||||
return a.basename.localeCompare(b.basename);
|
||||
});
|
||||
for (const k of ['Specs', 'Plans', 'Patterns', 'Other']) {
|
||||
groups[k].sort((a, b) => a.rel.localeCompare(b.rel));
|
||||
}
|
||||
|
||||
const lines = [];
|
||||
lines.push(
|
||||
'<!-- Generated by scripts/generate-docs-index.mjs. Do not edit by hand. Run `npm run docs:index`. -->'
|
||||
);
|
||||
lines.push('');
|
||||
lines.push('# Documentation Index');
|
||||
lines.push('');
|
||||
lines.push(
|
||||
`Auto-generated on ${new Date().toISOString().slice(0, 10)} from front-matter and headings.`
|
||||
);
|
||||
lines.push('');
|
||||
|
||||
// --- ADRs ---
|
||||
lines.push('## Architecture Decision Records');
|
||||
lines.push('');
|
||||
if (groups.ADRs.length === 0) {
|
||||
lines.push('_No ADRs yet._');
|
||||
lines.push('');
|
||||
} else {
|
||||
lines.push('| # | Title | Status |');
|
||||
lines.push('|---|---|---|');
|
||||
for (const d of groups.ADRs) {
|
||||
const n = adrNumber(d.basename);
|
||||
const num = n !== null ? String(n).padStart(4, '0') : '—';
|
||||
lines.push(
|
||||
`| ${num} | [${escapePipe(d.title)}](${d.linkPath}) | ${escapePipe(d.status || '—')} |`
|
||||
);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// --- Specs ---
|
||||
lines.push('## Specifications');
|
||||
lines.push('');
|
||||
if (groups.Specs.length === 0) {
|
||||
lines.push('_No specs yet._');
|
||||
lines.push('');
|
||||
} else {
|
||||
lines.push('| Title | Status | Updated |');
|
||||
lines.push('|---|---|---|');
|
||||
for (const d of groups.Specs) {
|
||||
lines.push(
|
||||
`| [${escapePipe(d.title)}](${d.linkPath}) | ${escapePipe(d.status || '—')} | ${escapePipe(d.date || '—')} |`
|
||||
);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// --- Plans (with archive subsection) ---
|
||||
lines.push('## Plans');
|
||||
lines.push('');
|
||||
const plansActive = groups.Plans.filter((d) => !d.rel.includes('/archive/'));
|
||||
const plansArchive = groups.Plans.filter((d) => d.rel.includes('/archive/'));
|
||||
if (plansActive.length === 0) {
|
||||
lines.push('_No active plans._');
|
||||
lines.push('');
|
||||
} else {
|
||||
lines.push('| Title | Status | Updated |');
|
||||
lines.push('|---|---|---|');
|
||||
for (const d of plansActive) {
|
||||
lines.push(
|
||||
`| [${escapePipe(d.title)}](${d.linkPath}) | ${escapePipe(d.status || '—')} | ${escapePipe(d.date || '—')} |`
|
||||
);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
if (plansArchive.length > 0) {
|
||||
lines.push('### Archive');
|
||||
lines.push('');
|
||||
lines.push('| Title | Updated |');
|
||||
lines.push('|---|---|');
|
||||
for (const d of plansArchive) {
|
||||
lines.push(
|
||||
`| [${escapePipe(d.title)}](${d.linkPath}) | ${escapePipe(d.date || '—')} |`
|
||||
);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// --- Patterns ---
|
||||
lines.push('## Patterns');
|
||||
lines.push('');
|
||||
if (groups.Patterns.length === 0) {
|
||||
lines.push('_No patterns yet._');
|
||||
lines.push('');
|
||||
} else {
|
||||
lines.push('| Title | Status | Updated |');
|
||||
lines.push('|---|---|---|');
|
||||
for (const d of groups.Patterns) {
|
||||
lines.push(
|
||||
`| [${escapePipe(d.title)}](${d.linkPath}) | ${escapePipe(d.status || '—')} | ${escapePipe(d.date || '—')} |`
|
||||
);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// --- Other (catches design/, api/, runbooks/, etc. until they get
|
||||
// dedicated sections after the docs-restructure) ---
|
||||
if (groups.Other.length > 0) {
|
||||
lines.push('## Other Docs');
|
||||
lines.push('');
|
||||
lines.push('| Title | Path | Status | Updated |');
|
||||
lines.push('|---|---|---|---|');
|
||||
for (const d of groups.Other) {
|
||||
lines.push(
|
||||
`| [${escapePipe(d.title)}](${d.linkPath}) | \`${d.rel.replace(/^docs\//, '')}\` | ${escapePipe(d.status || '—')} | ${escapePipe(d.date || '—')} |`
|
||||
);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
const out = lines.join('\n');
|
||||
await writeFile(INDEX_PATH, out, 'utf8');
|
||||
console.log(`Wrote ${relative(REPO_ROOT, INDEX_PATH)} (${docs.length} docs indexed)`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue