Skip to content

Commit b732bee

Browse files
niveshdandyanclaude
andcommitted
fix: normalize anchor case at link generation time
Per @danielroe's feedback, move toLowerCase() from the client-side click handler to server-side link generation in resolveUrl(). This ensures consistent case between anchor hrefs and heading IDs at the source, allowing native browser hash navigation to work correctly. - Add .toLowerCase() in server/utils/readme.ts when generating anchor links - Simplify client-side handler to just return for hash links - Update tests to reflect the new behavior Co-Authored-By: Claude (claude-opus-4-5) <noreply@anthropic.com>
1 parent 53d504b commit b732bee

4 files changed

Lines changed: 26 additions & 33 deletions

File tree

app/components/Readme.vue

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
<script setup lang="ts">
2-
import { scrollToAnchor } from '~/utils/scrollToAnchor'
3-
42
defineProps<{
53
html: string
64
}>()
@@ -11,7 +9,6 @@ const { copy } = useClipboard()
119
// Combined click handler for:
1210
// 1. Intercepting npmjs.com links to route internally
1311
// 2. Copy button functionality for code blocks
14-
// 3. Hash link scrolling for internal README navigation (Table of Contents)
1512
function handleClick(event: MouseEvent) {
1613
const target = event.target as HTMLElement | undefined
1714
if (!target) return
@@ -50,12 +47,8 @@ function handleClick(event: MouseEvent) {
5047
const href = anchor.getAttribute('href')
5148
if (!href) return
5249
53-
// Handle hash links for internal README navigation (e.g., Table of Contents)
50+
// Let browser handle hash links natively (case is normalized server-side)
5451
if (href.startsWith('#')) {
55-
event.preventDefault()
56-
// Lowercase the ID to match heading slugs (generated with toLowerCase in slugify)
57-
const id = href.slice(1).toLowerCase()
58-
scrollToAnchor(id)
5952
return
6053
}
6154

server/utils/readme.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,8 +190,9 @@ function slugify(text: string): string {
190190
function resolveUrl(url: string, packageName: string, repoInfo?: RepositoryInfo): string {
191191
if (!url) return url
192192
if (url.startsWith('#')) {
193-
// Prefix anchor links to match heading IDs (avoids collision with page IDs)
194-
return `#user-content-${url.slice(1)}`
193+
// Prefix anchor links and lowercase to match heading IDs
194+
// (slugify uses toLowerCase, and prefix avoids collision with page IDs)
195+
return `#user-content-${url.slice(1).toLowerCase()}`
195196
}
196197
if (hasProtocol(url, { acceptRelative: true })) {
197198
try {
Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,7 @@
1-
import { describe, expect, it, vi } from 'vitest'
1+
import { describe, expect, it } from 'vitest'
22
import { mountSuspended } from '@nuxt/test-utils/runtime'
33
import Readme from '~/components/Readme.vue'
44

5-
// Mock scrollToAnchor
6-
vi.mock('~/utils/scrollToAnchor', () => ({
7-
scrollToAnchor: vi.fn(),
8-
}))
9-
10-
import { scrollToAnchor } from '~/utils/scrollToAnchor'
11-
125
describe('Readme', () => {
136
describe('rendering', () => {
147
it('renders the provided HTML content', async () => {
@@ -20,27 +13,18 @@ describe('Readme', () => {
2013
})
2114

2215
describe('hash link click handling', () => {
23-
it('intercepts hash link clicks and calls scrollToAnchor with lowercase ID', async () => {
16+
it('allows native browser handling for hash links (does not prevent default)', async () => {
2417
const component = await mountSuspended(Readme, {
2518
props: { html: '<a href="#Installation">Installation</a>' },
2619
})
2720

2821
const link = component.find('a')
29-
await link.trigger('click')
30-
31-
expect(scrollToAnchor).toHaveBeenCalledWith('installation')
32-
})
33-
34-
it('handles user-content prefixed hash links', async () => {
35-
vi.mocked(scrollToAnchor).mockClear()
36-
const component = await mountSuspended(Readme, {
37-
props: { html: '<a href="#user-content-getting-started">Getting Started</a>' },
38-
})
39-
40-
const link = component.find('a')
41-
await link.trigger('click')
22+
const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true })
23+
link.element.dispatchEvent(clickEvent)
4224

43-
expect(scrollToAnchor).toHaveBeenCalledWith('user-content-getting-started')
25+
// Hash links should NOT have default prevented - browser handles them natively
26+
// (Case normalization happens server-side when generating the href)
27+
expect(clickEvent.defaultPrevented).toBe(false)
4428
})
4529
})
4630
})

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,21 @@ describe('Markdown File URL Resolution', () => {
274274

275275
expect(result.html).toContain('href="#user-content-installation"')
276276
})
277+
278+
it('lowercases anchor links to match heading slugs', async () => {
279+
const markdown = `[Jump to Installation](#Installation)`
280+
const result = await renderReadmeHtml(markdown, 'test-pkg')
281+
282+
// Anchor links should be lowercased to match heading IDs (slugify uses toLowerCase)
283+
expect(result.html).toContain('href="#user-content-installation"')
284+
})
285+
286+
it('lowercases mixed-case anchor links', async () => {
287+
const markdown = `[Jump to Getting Started](#Getting-Started)`
288+
const result = await renderReadmeHtml(markdown, 'test-pkg')
289+
290+
expect(result.html).toContain('href="#user-content-getting-started"')
291+
})
277292
})
278293

279294
describe('different git providers', () => {

0 commit comments

Comments
 (0)