Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 45 additions & 5 deletions src/render-html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <section id="{slug}"> that holds the heading and the content
// up to the next same-or-shallower heading. The id lives on the
// <section>, not on the <h*>. 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)}</section>`)
}
}

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 <section>; any other heading attrs (classes,
// key-values) stay on the <h*>.
const id = node.attrs?.id
const sectionId = id ? ` id="${escapeAttr(id)}"` : ''
out.push(`${indent(depth)}<section${sectionId}>`)
sectionStack.push(node.level)
const headingAttrs = stripId(node.attrs)
const inner = renderInlines(node.children, opts)
out.push(
`${indent(depth + 1)}<h${node.level}${renderAttrs(headingAttrs)}>${inner}</h${node.level}>`,
)
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 <section>). */
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 {
Expand Down
20 changes: 16 additions & 4 deletions test/corpus.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<h* id="…">` shape.
* The renderer now emits the `<section id="…"><h*>…</h*></section>`
* 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<string>([])
const KNOWN_GAPS = new Set<string>([
'02-headings',
'02-headings-2',
'02-headings-3',
'02-headings-4',
'17-attributes',
'19-heading-ids',
])

const baseSlug = (name: string) => name.replace(/-\d+$/, '')

Expand Down
4 changes: 3 additions & 1 deletion test/heading-ids.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<h1 id="getting-started">Getting Started</h1>')
// The id lives on the <section>, not the <h1> (PART 9 §13).
expect(html).toContain('<section id="getting-started">')
expect(html).toContain('<h1>Getting Started</h1>')
expect(html).toContain('<a href="#getting-started">Getting Started</a>')
})
it('renders an unresolved </#id> as literal text', () => {
Expand Down
7 changes: 5 additions & 2 deletions test/implicit-heading-refs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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('<h1 id="api"><a href="#api">API</a></h1>')
expect(html).toContain('<h1 id="api-2">API</h1>')
// ids live on <section>, headings carry no id (PART 9 §13).
expect(html).toContain('<section id="api">')
expect(html).toContain('<h1><a href="#api">API</a></h1>')
expect(html).toContain('<section id="api-2">')
expect(html).toContain('<h1>API</h1>')
expect(html).toContain('<p><a href="#api">API</a></p>')
})
})
101 changes: 101 additions & 0 deletions test/section-wrapper.test.ts
Original file line number Diff line number Diff line change
@@ -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 <section id="{slug}"> around itself and the content up to the
* next same-or-shallower heading. The id lives on the <section>, not the
* <h*>. Sections nest by heading level. Matches djot.
*/
describe('heading <section> wrapping', () => {
it('wraps a single heading and its body', () => {
expect(h('# Intro\n\nText.')).toBe(
'<section id="intro">\n <h1>Intro</h1>\n <p>Text.</p>\n</section>',
)
})

it('nests a deeper heading inside the shallower section', () => {
expect(h('# A\n\n## B')).toBe(
'<section id="a">\n <h1>A</h1>\n <section id="b">\n <h2>B</h2>\n </section>\n</section>',
)
})

it('produces sibling sections for same-level headings', () => {
expect(h('# A\n\n# B')).toBe(
'<section id="a">\n <h1>A</h1>\n</section>\n<section id="b">\n <h1>B</h1>\n</section>',
)
})

it('closes a deeper section when a shallower heading follows', () => {
const html = h('# A\n\n## B\n\n# C')
expect(html).toBe(
[
'<section id="a">',
' <h1>A</h1>',
' <section id="b">',
' <h2>B</h2>',
' </section>',
'</section>',
'<section id="c">',
' <h1>C</h1>',
'</section>',
].join('\n'),
)
})

it('nests by level number across a skipped level', () => {
expect(h('# H1\n\n### H3')).toBe(
'<section id="h1">\n <h1>H1</h1>\n <section id="h3">\n <h3>H3</h3>\n </section>\n</section>',
)
})

it('puts an explicit {#id} on the section, other attrs on the heading', () => {
expect(h('# Title {.large #intro}\n\nP.')).toBe(
'<section id="intro">\n <h1 class="large">Title</h1>\n <p>P.</p>\n</section>',
)
})

it('emits no <section> for a document without headings', () => {
expect(h('Just a paragraph.')).toBe('<p>Just a paragraph.</p>')
})

it('emits no <section> 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(
[
'<section id="a">',
' <h1>A</h1>',
' <section id="b">',
' <h2>B</h2>',
' <section id="c">',
' <h3>C</h3>',
' </section>',
' </section>',
'</section>',
].join('\n'),
)
})

it('keeps the fragment target resolvable via crossref', () => {
const html = h('# Getting Started\n\nSee </#getting-started>.')
expect(html).toContain('<section id="getting-started">')
expect(html).toContain('<h1>Getting Started</h1>')
expect(html).toContain('<a href="#getting-started">Getting Started</a>')
})

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 <h*> with no <section>.
const html = h('> # Sub\n')
expect(html).not.toContain('<section')
expect(html).toContain('<h1>Sub</h1>')
})
})
Loading