diff --git a/app/pages/package/[[org]]/[name].vue b/app/pages/package/[[org]]/[name].vue
index bc10db621..b9740f799 100644
--- a/app/pages/package/[[org]]/[name].vue
+++ b/app/pages/package/[[org]]/[name].vue
@@ -548,7 +548,6 @@ const showSkeleton = shallowRef(false)
:latest-version="latestVersion"
:provenance-data="provenanceData"
:provenance-status="provenanceStatus"
- :class="$style.areaHeader"
page="main"
:version-url-pattern="versionUrlPattern"
/>
diff --git a/server/utils/readme.ts b/server/utils/readme.ts
index a97e620eb..81dddfbb2 100644
--- a/server/utils/readme.ts
+++ b/server/utils/readme.ts
@@ -549,11 +549,23 @@ export async function renderReadmeHtml(
toc.push({ text: plainText, id, depth })
}
+ // The browser doesn't support anchors within anchors and automatically extracts them from each other,
+ // causing a hydration error. To prevent this from happening in such cases, we use the anchor separately
+ if (htmlAnchorRe.test(displayHtml)) {
+ return `${displayHtml}\n`
+ }
+
return `${displayHtml}\n`
}
+ const anchorTokenRegex = /^$/
renderer.heading = function ({ tokens, depth }: Tokens.Heading) {
- const displayHtml = this.parser.parseInline(tokens)
+ const isAnchorHeading =
+ anchorTokenRegex.test(tokens[0]?.raw ?? '') && tokens[tokens.length - 1]?.raw === ''
+
+ // for anchor headings, we will ignore user-added id and add our own
+ const tokensWithoutAnchor = isAnchorHeading ? tokens.slice(1, -1) : tokens
+ const displayHtml = this.parser.parseInline(tokensWithoutAnchor)
const plainText = getHeadingPlainText(displayHtml)
const slugSource = getHeadingSlugSource(displayHtml)
return processHeading(depth, displayHtml, plainText, slugSource)
@@ -643,6 +655,8 @@ ${html}
const { resolvedHref, extraAttrs } = processLink(href, plainText || title || '')
+ if (!resolvedHref) return text
+
return `${text}`
}
diff --git a/test/unit/server/utils/readme.spec.ts b/test/unit/server/utils/readme.spec.ts
index 5d8590215..dacc2653b 100644
--- a/test/unit/server/utils/readme.spec.ts
+++ b/test/unit/server/utils/readme.spec.ts
@@ -591,6 +591,40 @@ describe('HTML output', () => {
expect(result.html).toContain('id="user-content-api-1"')
})
+ describe('heading anchors (renderer.heading)', () => {
+ it('strips a full-line anchor wrapper and uses inner text for slug, toc, and permalink', async () => {
+ const markdown = '## My Section'
+ const result = await renderReadmeHtml(markdown, 'test-pkg')
+
+ expect(result.toc).toEqual([{ text: 'My Section', depth: 2, id: 'user-content-my-section' }])
+ expect(result.html).toBe(
+ `
\n`,
+ )
+ })
+
+ it('uses a trailing empty permalink when heading content already includes a link (no nested anchors)', async () => {
+ const markdown = '### See docs for more'
+ const result = await renderReadmeHtml(markdown, 'test-pkg')
+
+ expect(result.toc).toEqual([
+ { text: 'See docs for more', depth: 3, id: 'user-content-see-docs-for-more' },
+ ])
+ expect(result.html).toBe(
+ `See docs for more
\n`,
+ )
+ })
+
+ it('applies the same permalink pattern to raw HTML headings that contain links', async () => {
+ const md = ''
+ const result = await renderReadmeHtml(md, 'test-pkg')
+
+ expect(result.toc).toEqual([{ text: 'Guide: page', depth: 2, id: 'user-content-guide-page' }])
+ expect(result.html).toBe(
+ '',
+ )
+ })
+ })
+
it('preserves supported attributes on raw HTML headings', async () => {
const md = 'My Package
'
const result = await renderReadmeHtml(md, 'test-pkg')