PBI-58: Developer manual + in-app /manual page (#148)
* docs(PBI-58): add developer manual chapters under docs/manual/ Adds a 7-file English-language manual targeted at new human contributors: index, overview, statuses & transitions (with mermaid state diagrams), git workflow, MCP integration, docker, and troubleshooting. The manual is the *map* — it cross-references existing runbooks/ADRs/architecture docs rather than duplicating their content. Regenerates docs/INDEX.md and validates with check-doc-links.mjs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(PBI-58): add markdown rendering deps + manual:build script Adds mermaid, rehype-slug, rehype-autolink-headings for the in-app /manual page. Wires manual:build into prebuild so production builds always regenerate the chapter TOC. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(PBI-58): codegen script for in-app manual TOC scripts/build-manual.mjs walks docs/manual/, parses YAML front-matter, strips it from the body, and emits lib/manual.generated.ts with a typed ManualEntry[] containing slug, title, description, filePath, and the embedded markdown body. Pure Node 20, mirrors generate-docs-index.mjs. Inlining the markdown at build time keeps runtime serverless functions free of filesystem reads, which avoids whole-project NFT tracing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(PBI-58): /manual route renders developer manual chapters in-app Catch-all route at app/(app)/manual/[[...slug]]/page.tsx with generateStaticParams covering every TOC entry. Server-side MarkdownView uses react-markdown with remark-gfm, rehype-slug, and rehype-autolink-headings; mermaid code blocks are routed to a client-only MermaidBlock that dynamic-imports mermaid on mount. ManualSidebar (client) reads the typed TOC and highlights the active chapter via usePathname. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(PBI-58): add Manual link to main nav bar Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d750676f5e
commit
bd7478861b
19 changed files with 2239 additions and 105 deletions
159
scripts/build-manual.mjs
Normal file
159
scripts/build-manual.mjs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
#!/usr/bin/env node
|
||||
// Generate lib/manual.generated.ts — a typed TOC of the docs/manual/ chapters.
|
||||
// Walks docs/manual/, parses front-matter, extracts title and description, and
|
||||
// emits a single TS file consumed by the in-app /manual route.
|
||||
//
|
||||
// Usage: `npm run manual:build` (also chained into `prebuild`).
|
||||
//
|
||||
// Pure Node 20 — no external deps. Mirrors scripts/generate-docs-index.mjs.
|
||||
|
||||
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 MANUAL_DIR = join(REPO_ROOT, 'docs', 'manual');
|
||||
const OUT_PATH = join(REPO_ROOT, 'lib', 'manual.generated.ts');
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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(body) {
|
||||
const m = body.match(/^#\s+(.+?)\s*$/m);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function extractFirstParagraph(body) {
|
||||
// Skip leading H1, then take the first non-heading, non-blank block.
|
||||
const lines = body.split('\n');
|
||||
let i = 0;
|
||||
while (i < lines.length && (lines[i].trim() === '' || lines[i].startsWith('#'))) i++;
|
||||
const para = [];
|
||||
while (i < lines.length && lines[i].trim() !== '') {
|
||||
if (lines[i].startsWith('>') || lines[i].startsWith('|') || lines[i].startsWith('```')) break;
|
||||
para.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
return para.join(' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
// docs/manual/01-overview.md → ['01-overview']
|
||||
// docs/manual/index.md → []
|
||||
function fileToSlug(rel) {
|
||||
const stripped = rel.replace(/^docs\/manual\//, '').replace(/\.md$/, '');
|
||||
if (stripped === 'index') return [];
|
||||
return stripped.split('/');
|
||||
}
|
||||
|
||||
function escapeTs(s) {
|
||||
return String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
||||
}
|
||||
|
||||
function escapeBacktick(s) {
|
||||
return String(s).replace(/\\/g, '\\\\').replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
|
||||
}
|
||||
|
||||
function stripFrontMatter(content) {
|
||||
if (!content.startsWith('---\n')) return content;
|
||||
const end = content.indexOf('\n---\n', 4);
|
||||
if (end === -1) return content;
|
||||
return content.slice(end + 5).replace(/^\s*\n/, '');
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const files = (await walk(MANUAL_DIR)).sort();
|
||||
const entries = [];
|
||||
|
||||
for (const full of files) {
|
||||
const rel = relative(REPO_ROOT, full).split(sep).join('/');
|
||||
const content = await readFile(full, 'utf8');
|
||||
const { data, body } = parseFrontMatter(content);
|
||||
const slug = fileToSlug(rel);
|
||||
const title = data.title || extractFirstH1(body) || basename(full, '.md');
|
||||
const description = extractFirstParagraph(body) || '';
|
||||
const markdown = stripFrontMatter(content);
|
||||
entries.push({
|
||||
slug,
|
||||
title,
|
||||
description,
|
||||
filePath: rel,
|
||||
markdown,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort: index first, then by filename so numeric prefixes drive order.
|
||||
entries.sort((a, b) => {
|
||||
if (a.slug.length === 0) return -1;
|
||||
if (b.slug.length === 0) return 1;
|
||||
return a.filePath.localeCompare(b.filePath);
|
||||
});
|
||||
|
||||
const lines = [];
|
||||
lines.push('// AUTO-GENERATED by scripts/build-manual.mjs. Do not edit by hand.');
|
||||
lines.push('// Run `npm run manual:build` to regenerate.');
|
||||
lines.push('');
|
||||
lines.push('export type ManualEntry = {');
|
||||
lines.push(' slug: readonly string[]');
|
||||
lines.push(' title: string');
|
||||
lines.push(' description: string');
|
||||
lines.push(' filePath: string');
|
||||
lines.push(' markdown: string');
|
||||
lines.push('}');
|
||||
lines.push('');
|
||||
lines.push('export const MANUAL_TOC: readonly ManualEntry[] = [');
|
||||
for (const e of entries) {
|
||||
const slugLit = '[' + e.slug.map((s) => `'${escapeTs(s)}'`).join(', ') + '] as const';
|
||||
lines.push(' {');
|
||||
lines.push(` slug: ${slugLit},`);
|
||||
lines.push(` title: '${escapeTs(e.title)}',`);
|
||||
lines.push(` description: '${escapeTs(e.description)}',`);
|
||||
lines.push(` filePath: '${escapeTs(e.filePath)}',`);
|
||||
lines.push(` markdown: \`${escapeBacktick(e.markdown)}\`,`);
|
||||
lines.push(' },');
|
||||
}
|
||||
lines.push('] as const;');
|
||||
lines.push('');
|
||||
|
||||
await writeFile(OUT_PATH, lines.join('\n'), 'utf8');
|
||||
console.log(`Wrote ${relative(REPO_ROOT, OUT_PATH)} (${entries.length} chapters)`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue