diff --git a/src/generator/markdown.test.ts b/src/generator/markdown.test.ts index 12fcf76..d9af481 100644 --- a/src/generator/markdown.test.ts +++ b/src/generator/markdown.test.ts @@ -148,6 +148,62 @@ describe('renderMarkdown', () => { expect(md).not.toContain('Hidden paragraph.'); expect(md).not.toContain('A.'); }); + // Regression (#122): notes/continuations/vanish siblings must NOT consume a CSI + // number. A "Related Sections" pr1 whose first children are specifier-note banners + // rendered its 1..n list starting at the note count (e.g. "5." instead of "1."). + it('numbers pr2 siblings from 1, skipping leading note siblings', () => { + const tree: SpecTree = { + id: '00000000-0000-0000-0000-000000000001', + section: '09 05 00', + title: 'Numbering', + parts: [ + { + id: '00000000-0000-0000-0000-000000000002', + type: 'part', + text: 'GENERAL', + meta: {}, + children: [ + { + id: '00000000-0000-0000-0000-000000000003', + type: 'article', + text: 'SUMMARY', + meta: {}, + children: [ + { + id: '00000000-0000-0000-0000-000000000004', + type: 'pr1', + text: 'Related Sections:', + meta: {}, + children: [ + { + id: 'n1', + type: 'note', + text: 'banner one', + children: [], + meta: { vanish: true }, + }, + { + id: 'n2', + type: 'note', + text: 'banner two', + children: [], + meta: { vanish: true }, + }, + { id: 'r1', type: 'pr2', text: 'Section 01 30 00', children: [], meta: {} }, + { id: 'r2', type: 'pr2', text: 'Section 01 33 00', children: [], meta: {} }, + ], + }, + ], + }, + ], + }, + ], + }; + const md = renderMarkdown(tree); + expect(md).toContain(' 1. Section 01 30 00'); + expect(md).toContain(' 2. Section 01 33 00'); + expect(md).not.toContain('3. Section 01 30 00'); + }); it('renders empty tree without error', () => { const empty: SpecTree = { id: '00000000-0000-0000-0000-000000000001', diff --git a/src/generator/markdown.ts b/src/generator/markdown.ts index 4a5107a..588cfac 100644 --- a/src/generator/markdown.ts +++ b/src/generator/markdown.ts @@ -35,6 +35,29 @@ export function getLabel(type: NodeType, index: number, partNumber = 1): string const INDENT = ' '; +// notes render as [NOTE] blockquotes, continuations as plain text, and vanish +// nodes not at all — none carry a CSI number, so none may consume an ordinal. +// Counting them shifted numbered siblings (#122): specifier-note banners pushed +// a 1..15 "Related Sections" list to 5..20. +function consumesNumber(node: SpecNode): boolean { + return node.type !== 'note' && node.type !== 'continuation' && !node.meta.vanish; +} + +// Render a node's children, advancing the CSI ordinal only past numbered siblings +// so notes/continuations/vanish nodes interleave without disturbing the sequence. +function renderChildren( + children: readonly SpecNode[], + render: (child: SpecNode, ordinal: number) => string +): string { + let ordinal = 0; + const out: string[] = []; + for (const child of children) { + out.push(render(child, ordinal)); + if (consumesNumber(child)) ordinal += 1; + } + return out.join(''); +} + function renderPrNode(node: SpecNode, index: number, depth: number): string { // note nodes always render as [NOTE] blockquotes regardless of meta.vanish — editorial // notes are structural metadata visible to spec writers, not owner-facing content. @@ -49,26 +72,26 @@ function renderPrNode(node: SpecNode, index: number, depth: number): string { } const pad = INDENT.repeat(depth); const label = getLabel(node.type, index); - return [ - `\n${pad}${label} ${node.text}`, - ...node.children.map((child, i) => renderPrNode(child, i, depth + 1)), - ].join(''); + return ( + `\n${pad}${label} ${node.text}` + + renderChildren(node.children, (child, ordinal) => renderPrNode(child, ordinal, depth + 1)) + ); } function renderArticle(node: SpecNode, index: number, partNumber: number): string { const label = getLabel('article', index, partNumber); - return [ - `\n### ${label} ${node.text}\n`, - ...node.children.map((child, i) => renderPrNode(child, i, 0)), - ].join(''); + return ( + `\n### ${label} ${node.text}\n` + + renderChildren(node.children, (child, ordinal) => renderPrNode(child, ordinal, 0)) + ); } function renderPart(node: SpecNode, index: number): string { const label = getLabel('part', index); - return [ - `\n## ${label} ${node.text}\n`, - ...node.children.map((child, i) => renderArticle(child, i, index + 1)), - ].join(''); + return ( + `\n## ${label} ${node.text}\n` + + renderChildren(node.children, (child, ordinal) => renderArticle(child, ordinal, index + 1)) + ); } export function renderMarkdown(tree: SpecTree): string {