Skip to content
Open
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
1 change: 0 additions & 1 deletion app/pages/package/[[org]]/[name].vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
/>
Expand Down
16 changes: 15 additions & 1 deletion server/utils/readme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -549,11 +549,23 @@
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 `<h${semanticLevel} id="${id}" data-level="${depth}"${preservedAttrs}>${displayHtml}<a href="#${id}"></a></h${semanticLevel}>\n`
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we just always do it this way rather than conditionally? 🤔

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably more UX question - is it appropriate for us to only be able to click the anchor by hovering over that side area, not the entire header? I didn't want to break the current UX, so I just added a condition for this rare, specific case with anchor inside heading

}

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

const anchorTokenRegex = /^<a(\s.+)?\/?>$/

Check warning on line 561 in server/utils/readme.ts

View workflow job for this annotation

GitHub Actions / 🤖 Autofix code

regexp(no-unused-capturing-group)

Capturing group number 1 is defined but never used.

Check warning on line 561 in server/utils/readme.ts

View workflow job for this annotation

GitHub Actions / 🔠 Lint project

regexp(no-unused-capturing-group)

Capturing group number 1 is defined but never used.
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 === '</a>'

// 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)
Expand Down Expand Up @@ -643,6 +655,8 @@

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

if (!resolvedHref) return text

return `<a href="${resolvedHref}"${titleAttr}${extraAttrs}>${text}</a>`
}

Expand Down
34 changes: 34 additions & 0 deletions test/unit/server/utils/readme.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '## <a href="https://example.com">My Section</a>'
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(
`<h3 id="user-content-my-section" data-level="2"><a href="#user-content-my-section">My Section</a></h3>\n`,
)
})

it('uses a trailing empty permalink when heading content already includes a link (no nested anchors)', async () => {
const markdown = '### See <a href="https://example.com">docs</a> 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(
`<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`,
)
})

it('applies the same permalink pattern to raw HTML headings that contain links', async () => {
const md = '<h2>Guide: <a href="https://example.com/page">page</a></h2>'
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(
'<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>',
)
})
})

Comment on lines +594 to +627
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add coverage for the empty-heading-link case mentioned in the PR behaviour.

The new suite covers wrapper anchors and inline-link headings well, but it misses the empty-link-content scenario described in the PR objective. Please add a regression test for that path to lock in the intended rendering behaviour.

✅ Suggested test addition
   describe('heading anchors (renderer.heading)', () => {
+    it('renders only text when heading link content is empty', async () => {
+      const markdown = '## [](https://example.com) Section'
+      const result = await renderReadmeHtml(markdown, 'test-pkg')
+
+      expect(result.toc).toEqual([{ text: 'Section', depth: 2, id: 'user-content-section' }])
+      expect(result.html).toBe(
+        `<h3 id="user-content-section" data-level="2"><a href="#user-content-section">Section</a></h3>\n`,
+      )
+    })
+
     it('strips a full-line anchor wrapper and uses inner text for slug, toc, and permalink', async () => {

As per coding guidelines, "Write unit tests for core functionality using vitest."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
describe('heading anchors (renderer.heading)', () => {
it('strips a full-line anchor wrapper and uses inner text for slug, toc, and permalink', async () => {
const markdown = '## <a href="https://example.com">My Section</a>'
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(
`<h3 id="user-content-my-section" data-level="2"><a href="#user-content-my-section">My Section</a></h3>\n`,
)
})
it('uses a trailing empty permalink when heading content already includes a link (no nested anchors)', async () => {
const markdown = '### See <a href="https://example.com">docs</a> 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(
`<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`,
)
})
it('applies the same permalink pattern to raw HTML headings that contain links', async () => {
const md = '<h2>Guide: <a href="https://example.com/page">page</a></h2>'
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(
'<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>',
)
})
})
describe('heading anchors (renderer.heading)', () => {
it('renders only text when heading link content is empty', async () => {
const markdown = '## [](https://example.com) Section'
const result = await renderReadmeHtml(markdown, 'test-pkg')
expect(result.toc).toEqual([{ text: 'Section', depth: 2, id: 'user-content-section' }])
expect(result.html).toBe(
`<h3 id="user-content-section" data-level="2"><a href="#user-content-section">Section</a></h3>\n`,
)
})
it('strips a full-line anchor wrapper and uses inner text for slug, toc, and permalink', async () => {
const markdown = '## <a href="https://example.com">My Section</a>'
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(
`<h3 id="user-content-my-section" data-level="2"><a href="#user-content-my-section">My Section</a></h3>\n`,
)
})
it('uses a trailing empty permalink when heading content already includes a link (no nested anchors)', async () => {
const markdown = '### See <a href="https://example.com">docs</a> 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(
`<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`,
)
})
it('applies the same permalink pattern to raw HTML headings that contain links', async () => {
const md = '<h2>Guide: <a href="https://example.com/page">page</a></h2>'
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(
'<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>',
)
})
})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/unit/server/utils/readme.spec.ts` around lines 594 - 627, Add a
regression test in the existing "heading anchors (renderer.heading)" suite that
covers a heading containing an anchor with empty content (e.g., a heading with
<a href="..."></a> inside), calling renderReadmeHtml and asserting the TOC entry
uses the surrounding text for text and id (slug) and the rendered HTML includes
the trailing empty permalink anchor pattern (same format used in the other
tests); place the test alongside the existing cases so it validates the
empty-link-content rendering path.

it('preserves supported attributes on raw HTML headings', async () => {
const md = '<h1 align="center">My Package</h1>'
const result = await renderReadmeHtml(md, 'test-pkg')
Expand Down
Loading