diff --git a/src/render-html.ts b/src/render-html.ts index 067f6e97..e2f7bcfb 100644 --- a/src/render-html.ts +++ b/src/render-html.ts @@ -30,11 +30,51 @@ export interface RenderOptions { } export function renderHtml(ast: Document, opts: RenderOptions = {}): string { - const blocks = ast.children - .filter((n) => n.type !== 'abbreviation-def') - .map((n) => renderBlock(n, opts, 0)) - .filter((s) => s !== '') - return blocks.join('\n') + const out: string[] = [] + // Section-wrapping pass (grammar PART 9 §13): every top-level heading + // opens a
that holds the heading and the content + // up to the next same-or-shallower heading. The id lives on the + //
, not on the . Sections nest by heading level. + const sectionStack: number[] = [] // open section heading-levels, outer→inner + + const closeTo = (level: number): void => { + while (sectionStack.length && sectionStack[sectionStack.length - 1]! >= level) { + sectionStack.pop() + out.push(`${indent(sectionStack.length)}
`) + } + } + + for (const node of ast.children) { + if (node.type === 'abbreviation-def') continue + if (node.type === 'heading') { + closeTo(node.level) + const depth = sectionStack.length + // The id moves to
; any other heading attrs (classes, + // key-values) stay on the . + const id = node.attrs?.id + const sectionId = id ? ` id="${escapeAttr(id)}"` : '' + out.push(`${indent(depth)}`) + sectionStack.push(node.level) + const headingAttrs = stripId(node.attrs) + const inner = renderInlines(node.children, opts) + out.push( + `${indent(depth + 1)}${inner}`, + ) + continue + } + const rendered = renderBlock(node, opts, sectionStack.length) + if (rendered !== '') out.push(rendered) + } + closeTo(1) // close any sections still open at end of document + return out.join('\n') +} + +/** Copy attrs without the `id` (the id moves to the enclosing
). */ +function stripId(attrs?: Attrs): Attrs | undefined { + if (!attrs) return undefined + if (attrs.id === undefined) return attrs + const { id: _omit, ...rest } = attrs + return rest } function indent(level: number): string { diff --git a/test/corpus.test.ts b/test/corpus.test.ts index 34885bbd..008ce78d 100644 --- a/test/corpus.test.ts +++ b/test/corpus.test.ts @@ -77,11 +77,23 @@ const IMPLEMENTED = new Set([ ]) /** - * Sub-examples in IMPLEMENTED categories that are known to fail because - * a specific construct is not yet supported. Move out of this set as - * implementation lands. + * Sub-examples in IMPLEMENTED categories that are temporarily skipped. + * + * These heading fixtures still show the pre-§13 bare `` shape. + * The renderer now emits the `
` + * wrapping (grammar PART 9 §13), so the impl is AHEAD of the vendored + * spec corpus. Re-add each entry once the carve repo re-vendors carve-lib + * and rewrites these fixtures to the section-wrapped shape, then bumps the + * spec submodule here. Same coordination pattern as the ASCII-slug change. */ -const KNOWN_GAPS = new Set([]) +const KNOWN_GAPS = new Set([ + '02-headings', + '02-headings-2', + '02-headings-3', + '02-headings-4', + '17-attributes', + '19-heading-ids', +]) const baseSlug = (name: string) => name.replace(/-\d+$/, '') diff --git a/test/heading-ids.test.ts b/test/heading-ids.test.ts index c2d554e2..6e8c3d8d 100644 --- a/test/heading-ids.test.ts +++ b/test/heading-ids.test.ts @@ -107,7 +107,9 @@ describe('resolveHeadingIds', () => { }) it('resolves to a link with cloned target text', () => { const html = carveToHtml('# Getting Started\n\nSee .') - expect(html).toContain('

Getting Started

') + // The id lives on the
, not the

(PART 9 §13). + expect(html).toContain('
') + expect(html).toContain('

Getting Started

') expect(html).toContain('Getting Started') }) it('renders an unresolved as literal text', () => { diff --git a/test/implicit-heading-refs.test.ts b/test/implicit-heading-refs.test.ts index 30acd69c..8be3fb4a 100644 --- a/test/implicit-heading-refs.test.ts +++ b/test/implicit-heading-refs.test.ts @@ -179,8 +179,11 @@ describe('implicit heading references ([Heading][])', () => { // resolves to first-occurrence -> heading 1 (`#api`). The link inside // heading 1 self-resolves to "#api". Matches carve-php. const html = h('# [API][]\n\n# API\n\n[API][]') - expect(html).toContain('

API

') - expect(html).toContain('

API

') + // ids live on
, headings carry no id (PART 9 §13). + expect(html).toContain('
') + expect(html).toContain('

API

') + expect(html).toContain('
') + expect(html).toContain('

API

') expect(html).toContain('

API

') }) }) diff --git a/test/section-wrapper.test.ts b/test/section-wrapper.test.ts new file mode 100644 index 00000000..22d77e78 --- /dev/null +++ b/test/section-wrapper.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest' +import { carveToHtml } from '../src/index.js' + +const h = (s: string) => carveToHtml(s) + +/** + * Heading section wrapping (grammar PART 9 §13): every top-level heading + * emits
around itself and the content up to the + * next same-or-shallower heading. The id lives on the
, not the + * . Sections nest by heading level. Matches djot. + */ +describe('heading
wrapping', () => { + it('wraps a single heading and its body', () => { + expect(h('# Intro\n\nText.')).toBe( + '
\n

Intro

\n

Text.

\n
', + ) + }) + + it('nests a deeper heading inside the shallower section', () => { + expect(h('# A\n\n## B')).toBe( + '
\n

A

\n
\n

B

\n
\n
', + ) + }) + + it('produces sibling sections for same-level headings', () => { + expect(h('# A\n\n# B')).toBe( + '
\n

A

\n
\n
\n

B

\n
', + ) + }) + + it('closes a deeper section when a shallower heading follows', () => { + const html = h('# A\n\n## B\n\n# C') + expect(html).toBe( + [ + '
', + '

A

', + '
', + '

B

', + '
', + '
', + '
', + '

C

', + '
', + ].join('\n'), + ) + }) + + it('nests by level number across a skipped level', () => { + expect(h('# H1\n\n### H3')).toBe( + '
\n

H1

\n
\n

H3

\n
\n
', + ) + }) + + it('puts an explicit {#id} on the section, other attrs on the heading', () => { + expect(h('# Title {.large #intro}\n\nP.')).toBe( + '
\n

Title

\n

P.

\n
', + ) + }) + + it('emits no
for a document without headings', () => { + expect(h('Just a paragraph.')).toBe('

Just a paragraph.

') + }) + + it('emits no
for an empty document', () => { + expect(h('')).toBe('') + }) + + it('closes all open sections at end of document', () => { + const html = h('# A\n\n## B\n\n### C') + // Three nested opens; closes are innermost-first and indented to + // each section's own depth. + expect(html).toBe( + [ + '
', + '

A

', + '
', + '

B

', + '
', + '

C

', + '
', + '
', + '
', + ].join('\n'), + ) + }) + + it('keeps the fragment target resolvable via crossref', () => { + const html = h('# Getting Started\n\nSee .') + expect(html).toContain('
') + expect(html).toContain('

Getting Started

') + expect(html).toContain('Getting Started') + }) + + it('does not wrap a heading nested inside a blockquote', () => { + // resolveHeadingIds only assigns ids to top-level headings, so nested + // headings carry no id and stay bare with no
. + const html = h('> # Sub\n') + expect(html).not.toContain('Sub

') + }) +})