From a89e54cb4d3e4ef8f3f3f8af0fd2d0f150255b11 Mon Sep 17 00:00:00 2001 From: Vordgi Date: Sun, 12 Apr 2026 12:34:48 +0100 Subject: [PATCH 1/3] fix: improve anchors parsing in readmes --- app/pages/package/[[org]]/[name].vue | 1 - server/utils/readme.ts | 16 +++++++++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/app/pages/package/[[org]]/[name].vue b/app/pages/package/[[org]]/[name].vue index bc10db6210..b9740f799f 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 a97e620ebd..e3939fb4af 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` } renderer.heading = function ({ tokens, depth }: Tokens.Heading) { - const displayHtml = this.parser.parseInline(tokens) + const anchorTokenRegex = /^$/ + 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}` } From 9fdbec4d0fa8b073c5d898c5d8213179a42b61a1 Mon Sep 17 00:00:00 2001 From: Vordgi Date: Sun, 12 Apr 2026 22:05:35 +0100 Subject: [PATCH 2/3] chore: reuse regex in renderer heading --- server/utils/readme.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/utils/readme.ts b/server/utils/readme.ts index e3939fb4af..81dddfbb28 100644 --- a/server/utils/readme.ts +++ b/server/utils/readme.ts @@ -558,8 +558,8 @@ export async function renderReadmeHtml( return `${displayHtml}\n` } + const anchorTokenRegex = /^$/ renderer.heading = function ({ tokens, depth }: Tokens.Heading) { - const anchorTokenRegex = /^$/ const isAnchorHeading = anchorTokenRegex.test(tokens[0]?.raw ?? '') && tokens[tokens.length - 1]?.raw === '' From 859e9a49d311424f1b2075de72a0c973fbfbbcea Mon Sep 17 00:00:00 2001 From: Vordgi Date: Sun, 12 Apr 2026 22:06:10 +0100 Subject: [PATCH 3/3] test: cover links in renderer heading --- test/unit/server/utils/readme.spec.ts | 34 +++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/test/unit/server/utils/readme.spec.ts b/test/unit/server/utils/readme.spec.ts index 5d85902158..dacc2653b7 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( + `

My Section

\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 = '

Guide: page

' + 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( + '

Guide: page

', + ) + }) + }) + it('preserves supported attributes on raw HTML headings', async () => { const md = '

My Package

' const result = await renderReadmeHtml(md, 'test-pkg')