Skip to content

Commit 5e8498c

Browse files
authored
fix: improve anchors parsing in readmes (#2486)
1 parent 9b5a9c0 commit 5e8498c

File tree

3 files changed

+49
-2
lines changed

3 files changed

+49
-2
lines changed

app/pages/package/[[org]]/[name].vue

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -548,7 +548,6 @@ const showSkeleton = shallowRef(false)
548548
:latest-version="latestVersion"
549549
:provenance-data="provenanceData"
550550
:provenance-status="provenanceStatus"
551-
:class="$style.areaHeader"
552551
page="main"
553552
:version-url-pattern="versionUrlPattern"
554553
/>

server/utils/readme.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -549,11 +549,23 @@ export async function renderReadmeHtml(
549549
toc.push({ text: plainText, id, depth })
550550
}
551551

552+
// The browser doesn't support anchors within anchors and automatically extracts them from each other,
553+
// causing a hydration error. To prevent this from happening in such cases, we use the anchor separately
554+
if (htmlAnchorRe.test(displayHtml)) {
555+
return `<h${semanticLevel} id="${id}" data-level="${depth}"${preservedAttrs}>${displayHtml}<a href="#${id}"></a></h${semanticLevel}>\n`
556+
}
557+
552558
return `<h${semanticLevel} id="${id}" data-level="${depth}"${preservedAttrs}><a href="#${id}">${displayHtml}</a></h${semanticLevel}>\n`
553559
}
554560

561+
const anchorTokenRegex = /^<a(\s.+)?\/?>$/
555562
renderer.heading = function ({ tokens, depth }: Tokens.Heading) {
556-
const displayHtml = this.parser.parseInline(tokens)
563+
const isAnchorHeading =
564+
anchorTokenRegex.test(tokens[0]?.raw ?? '') && tokens[tokens.length - 1]?.raw === '</a>'
565+
566+
// for anchor headings, we will ignore user-added id and add our own
567+
const tokensWithoutAnchor = isAnchorHeading ? tokens.slice(1, -1) : tokens
568+
const displayHtml = this.parser.parseInline(tokensWithoutAnchor)
557569
const plainText = getHeadingPlainText(displayHtml)
558570
const slugSource = getHeadingSlugSource(displayHtml)
559571
return processHeading(depth, displayHtml, plainText, slugSource)
@@ -643,6 +655,8 @@ ${html}
643655

644656
const { resolvedHref, extraAttrs } = processLink(href, plainText || title || '')
645657

658+
if (!resolvedHref) return text
659+
646660
return `<a href="${resolvedHref}"${titleAttr}${extraAttrs}>${text}</a>`
647661
}
648662

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

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -591,6 +591,40 @@ describe('HTML output', () => {
591591
expect(result.html).toContain('id="user-content-api-1"')
592592
})
593593

594+
describe('heading anchors (renderer.heading)', () => {
595+
it('strips a full-line anchor wrapper and uses inner text for slug, toc, and permalink', async () => {
596+
const markdown = '## <a href="https://example.com">My Section</a>'
597+
const result = await renderReadmeHtml(markdown, 'test-pkg')
598+
599+
expect(result.toc).toEqual([{ text: 'My Section', depth: 2, id: 'user-content-my-section' }])
600+
expect(result.html).toBe(
601+
`<h3 id="user-content-my-section" data-level="2"><a href="#user-content-my-section">My Section</a></h3>\n`,
602+
)
603+
})
604+
605+
it('uses a trailing empty permalink when heading content already includes a link (no nested anchors)', async () => {
606+
const markdown = '### See <a href="https://example.com">docs</a> for more'
607+
const result = await renderReadmeHtml(markdown, 'test-pkg')
608+
609+
expect(result.toc).toEqual([
610+
{ text: 'See docs for more', depth: 3, id: 'user-content-see-docs-for-more' },
611+
])
612+
expect(result.html).toBe(
613+
`<h3 id="user-content-see-docs-for-more" data-level="3">See <a href="https://example.com" rel="nofollow noreferrer noopener" target="_blank">docs</a> for more<a href="#user-content-see-docs-for-more"></a></h3>\n`,
614+
)
615+
})
616+
617+
it('applies the same permalink pattern to raw HTML headings that contain links', async () => {
618+
const md = '<h2>Guide: <a href="https://example.com/page">page</a></h2>'
619+
const result = await renderReadmeHtml(md, 'test-pkg')
620+
621+
expect(result.toc).toEqual([{ text: 'Guide: page', depth: 2, id: 'user-content-guide-page' }])
622+
expect(result.html).toBe(
623+
'<h3 id="user-content-guide-page" data-level="2">Guide: <a href="https://example.com/page" rel="nofollow noreferrer noopener" target="_blank">page</a><a href="#user-content-guide-page"></a></h3>',
624+
)
625+
})
626+
})
627+
594628
it('preserves supported attributes on raw HTML headings', async () => {
595629
const md = '<h1 align="center">My Package</h1>'
596630
const result = await renderReadmeHtml(md, 'test-pkg')

0 commit comments

Comments
 (0)