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 #id> to a link with cloned target text', () => {
const html = carveToHtml('# Getting Started\n\nSee #getting-started>.')
- 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 #id> 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('')
- expect(html).toContain('API
')
+ // ids live on , headings carry no id (PART 9 §13).
+ expect(html).toContain('')
+ expect(html).toContain('')
+ 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(
+ '',
+ )
+ })
+
+ it('nests a deeper heading inside the shallower section', () => {
+ expect(h('# A\n\n## B')).toBe(
+ '',
+ )
+ })
+
+ it('produces sibling sections for same-level headings', () => {
+ expect(h('# A\n\n# B')).toBe(
+ '\n',
+ )
+ })
+
+ it('closes a deeper section when a shallower heading follows', () => {
+ const html = h('# A\n\n## B\n\n# C')
+ expect(html).toBe(
+ [
+ '',
+ '',
+ ].join('\n'),
+ )
+ })
+
+ it('nests by level number across a skipped level', () => {
+ expect(h('# H1\n\n### H3')).toBe(
+ '',
+ )
+ })
+
+ it('puts an explicit {#id} on the section, other attrs on the heading', () => {
+ expect(h('# Title {.large #intro}\n\nP.')).toBe(
+ '',
+ )
+ })
+
+ 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(
+ [
+ '',
+ ].join('\n'),
+ )
+ })
+
+ it('keeps the fragment target resolvable via crossref', () => {
+ const html = h('# Getting Started\n\nSee #getting-started>.')
+ 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('
')
+ })
+})