Skip to content

Commit dd90132

Browse files
committed
fix: preserve inline heading content and stable slug ids
1 parent c9ac901 commit dd90132

File tree

2 files changed

+60
-8
lines changed

2 files changed

+60
-8
lines changed

server/utils/readme.ts

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,14 @@ function slugify(text: string): string {
219219
.replace(/^-|-$/g, '') // Trim leading/trailing hyphens
220220
}
221221

222+
function getHeadingPlainText(text: string): string {
223+
return decodeHtmlEntities(stripHtmlTags(text).trim())
224+
}
225+
226+
function getHeadingSlugSource(text: string): string {
227+
return stripHtmlTags(text).trim()
228+
}
229+
222230
/**
223231
* Lazy ATX heading extension for marked: allows headings without a space after `#`.
224232
*
@@ -457,11 +465,17 @@ export async function renderReadmeHtml(
457465
let lastSemanticLevel = 2 // Start after h2 (the "Readme" section heading)
458466

459467
// Shared heading processing for both markdown and HTML headings
460-
function processHeading(depth: number, plainText: string, preservedAttrs = '') {
468+
function processHeading(
469+
depth: number,
470+
displayHtml: string,
471+
plainText: string,
472+
slugSource: string,
473+
preservedAttrs = '',
474+
) {
461475
const semanticLevel = calculateSemanticDepth(depth, lastSemanticLevel)
462476
lastSemanticLevel = semanticLevel
463477

464-
let slug = slugify(plainText)
478+
let slug = slugify(slugSource)
465479
if (!slug) slug = 'heading'
466480

467481
const count = usedSlugs.get(slug) ?? 0
@@ -473,13 +487,14 @@ export async function renderReadmeHtml(
473487
toc.push({ text: plainText, id, depth })
474488
}
475489

476-
return `<h${semanticLevel} id="${id}" data-level="${depth}"${preservedAttrs}><a href="#${id}">${plainText}</a></h${semanticLevel}>\n`
490+
return `<h${semanticLevel} id="${id}" data-level="${depth}"${preservedAttrs}><a href="#${id}">${displayHtml}</a></h${semanticLevel}>\n`
477491
}
478492

479493
renderer.heading = function ({ tokens, depth }: Tokens.Heading) {
480-
const text = this.parser.parseInline(tokens)
481-
const plainText = decodeHtmlEntities(stripHtmlTags(text).trim())
482-
return processHeading(depth, plainText)
494+
const displayHtml = this.parser.parseInline(tokens)
495+
const plainText = getHeadingPlainText(displayHtml)
496+
const slugSource = getHeadingSlugSource(displayHtml)
497+
return processHeading(depth, displayHtml, plainText, slugSource)
483498
}
484499

485500
// Intercept HTML headings so they get id, TOC entry, and correct semantic level.
@@ -489,10 +504,11 @@ export async function renderReadmeHtml(
489504
renderer.html = function ({ text }: Tokens.HTML) {
490505
let result = text.replace(htmlHeadingRe, (_, level, attrs = '', inner) => {
491506
const depth = parseInt(level)
492-
const plainText = decodeHtmlEntities(stripHtmlTags(inner).trim())
507+
const plainText = getHeadingPlainText(inner)
508+
const slugSource = getHeadingSlugSource(inner)
493509
const align = /\balign=(["'])(.*?)\1/i.exec(attrs)?.[2]
494510
const preservedAttrs = align ? ` align="${align}"` : ''
495-
return processHeading(depth, plainText, preservedAttrs).trimEnd()
511+
return processHeading(depth, inner, plainText, slugSource, preservedAttrs).trimEnd()
496512
})
497513
// Process raw HTML <a> tags for playground link collection and URL resolution
498514
result = result.replace(htmlAnchorRe, (_full, beforeHref, _quote, href, afterHref, inner) => {

test/unit/server/utils/readme.spec.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,20 @@ describe('HTML output', () => {
592592
expect(result.html).toContain('align="center"')
593593
})
594594

595+
it('preserves inline code heading content and generates encoded slugs', async () => {
596+
const markdown = ['### `<Text>`', '', '### `<Box>`'].join('\n')
597+
const result = await renderReadmeHtml(markdown, 'test-pkg')
598+
599+
expect(result.toc).toHaveLength(2)
600+
expect(result.toc[0]).toMatchObject({ text: '<Text>', id: 'user-content-lttextgt', depth: 3 })
601+
expect(result.toc[1]).toMatchObject({ text: '<Box>', id: 'user-content-ltboxgt', depth: 3 })
602+
expect(result.html).toContain('<code>&lt;Text&gt;</code>')
603+
expect(result.html).toContain('<code>&lt;Box&gt;</code>')
604+
expect(result.html).toContain('id="user-content-lttextgt"')
605+
expect(result.html).toContain('id="user-content-ltboxgt"')
606+
expect(result.html).not.toContain('user-content-heading')
607+
})
608+
595609
it('preserves supported attributes on rewritten raw HTML anchors (renderer.html path)', async () => {
596610
const md = [
597611
'<div>',
@@ -803,5 +817,27 @@ describe('Issue #1323 — single-pass rendering correctness', () => {
803817
const ids = result.toc.map(t => t.id)
804818
expect(new Set(ids).size).toBe(ids.length)
805819
})
820+
821+
it('keeps paragraphs and fenced code blocks when mixed with HTML headings', async () => {
822+
const md = [
823+
'<h2><code>&lt;Text&gt;</code></h2>',
824+
'',
825+
'Paragraph before code.',
826+
'',
827+
'```ts',
828+
'const component = "Text"',
829+
'```',
830+
'',
831+
'Paragraph after code.',
832+
].join('\n')
833+
834+
const result = await renderReadmeHtml(md, 'test-pkg')
835+
836+
expect(result.html).toContain('<code>&lt;Text&gt;</code>')
837+
expect(result.html).toContain('<p>Paragraph before code.</p>')
838+
expect(result.html).toContain('const component = "Text"')
839+
expect(result.html).toContain('<p>Paragraph after code.</p>')
840+
expect(result.html).toContain('id="user-content-lttextgt"')
841+
})
806842
})
807843
})

0 commit comments

Comments
 (0)