Skip to content

Commit b9d138f

Browse files
niveshdandyanclaude
andcommitted
fix: lowercase hash link IDs and add tests for Readme component
- Lowercase hash link IDs to match heading slugs (addresses reviewer feedback about case sensitivity) - Add component tests for Readme.vue hash link click handling - Add utility tests for scrollToAnchor function This ensures that links like #Installation will correctly scroll to headings with id="user-content-installation" since the slugify function generates lowercase IDs. Co-Authored-By: Claude (claude-opus-4-5) <noreply@anthropic.com>
1 parent 24613b0 commit b9d138f

3 files changed

Lines changed: 221 additions & 1 deletion

File tree

app/components/Readme.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ function handleClick(event: MouseEvent) {
5353
// Handle hash links for internal README navigation (e.g., Table of Contents)
5454
if (href.startsWith('#')) {
5555
event.preventDefault()
56-
const id = href.slice(1) // Remove the leading '#'
56+
// Lowercase the ID to match heading slugs (generated with toLowerCase in slugify)
57+
const id = href.slice(1).toLowerCase()
5758
scrollToAnchor(id)
5859
return
5960
}
Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { mountSuspended } from '@nuxt/test-utils/runtime'
3+
import Readme from '~/components/Readme.vue'
4+
5+
// Mock scrollToAnchor
6+
vi.mock('~/utils/scrollToAnchor', () => ({
7+
scrollToAnchor: vi.fn(),
8+
}))
9+
10+
// Import the mocked function for assertions
11+
import { scrollToAnchor } from '~/utils/scrollToAnchor'
12+
13+
describe('Readme', () => {
14+
beforeEach(() => {
15+
vi.clearAllMocks()
16+
})
17+
18+
afterEach(() => {
19+
vi.restoreAllMocks()
20+
})
21+
22+
describe('rendering', () => {
23+
it('renders the provided HTML content', async () => {
24+
const component = await mountSuspended(Readme, {
25+
props: { html: '<p>Hello world</p>' },
26+
})
27+
expect(component.html()).toContain('Hello world')
28+
})
29+
30+
it('renders as an article element with readme class', async () => {
31+
const component = await mountSuspended(Readme, {
32+
props: { html: '<p>Content</p>' },
33+
})
34+
const article = component.find('article.readme')
35+
expect(article.exists()).toBe(true)
36+
})
37+
})
38+
39+
describe('hash link click handling', () => {
40+
it('intercepts hash link clicks and calls scrollToAnchor', async () => {
41+
const component = await mountSuspended(Readme, {
42+
props: { html: '<a href="#installation">Installation</a>' },
43+
})
44+
45+
const link = component.find('a')
46+
await link.trigger('click')
47+
48+
expect(scrollToAnchor).toHaveBeenCalledWith('installation')
49+
})
50+
51+
it('lowercases the ID when calling scrollToAnchor', async () => {
52+
const component = await mountSuspended(Readme, {
53+
props: { html: '<a href="#Installation">Installation</a>' },
54+
})
55+
56+
const link = component.find('a')
57+
await link.trigger('click')
58+
59+
expect(scrollToAnchor).toHaveBeenCalledWith('installation')
60+
})
61+
62+
it('handles user-content prefixed hash links', async () => {
63+
const component = await mountSuspended(Readme, {
64+
props: { html: '<a href="#user-content-getting-started">Getting Started</a>' },
65+
})
66+
67+
const link = component.find('a')
68+
await link.trigger('click')
69+
70+
expect(scrollToAnchor).toHaveBeenCalledWith('user-content-getting-started')
71+
})
72+
73+
it('handles mixed case user-content hash links', async () => {
74+
const component = await mountSuspended(Readme, {
75+
props: { html: '<a href="#User-Content-API">API</a>' },
76+
})
77+
78+
const link = component.find('a')
79+
await link.trigger('click')
80+
81+
expect(scrollToAnchor).toHaveBeenCalledWith('user-content-api')
82+
})
83+
})
84+
85+
describe('non-hash link handling', () => {
86+
it('does not intercept external links', async () => {
87+
const component = await mountSuspended(Readme, {
88+
props: { html: '<a href="https://example.com">External</a>' },
89+
})
90+
91+
const link = component.find('a')
92+
await link.trigger('click')
93+
94+
expect(scrollToAnchor).not.toHaveBeenCalled()
95+
})
96+
97+
it('does not intercept relative links', async () => {
98+
const component = await mountSuspended(Readme, {
99+
props: { html: '<a href="./docs/readme.md">Docs</a>' },
100+
})
101+
102+
const link = component.find('a')
103+
await link.trigger('click')
104+
105+
expect(scrollToAnchor).not.toHaveBeenCalled()
106+
})
107+
})
108+
})
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2+
import { scrollToAnchor } from '~/utils/scrollToAnchor'
3+
4+
describe('scrollToAnchor', () => {
5+
let scrollToSpy: ReturnType<typeof vi.fn>
6+
let replaceStateSpy: ReturnType<typeof vi.fn>
7+
let testElement: HTMLElement
8+
9+
beforeEach(() => {
10+
// Spy on window.scrollTo
11+
scrollToSpy = vi.fn()
12+
vi.stubGlobal('scrollTo', scrollToSpy)
13+
14+
// Spy on history.replaceState
15+
replaceStateSpy = vi.spyOn(history, 'replaceState').mockImplementation(() => {})
16+
17+
// Create a test element
18+
testElement = document.createElement('div')
19+
testElement.id = 'test-section'
20+
document.body.appendChild(testElement)
21+
})
22+
23+
afterEach(() => {
24+
vi.restoreAllMocks()
25+
vi.unstubAllGlobals()
26+
// Clean up test element
27+
if (testElement && testElement.parentNode) {
28+
testElement.parentNode.removeChild(testElement)
29+
}
30+
})
31+
32+
describe('with custom scrollFn', () => {
33+
it('calls the provided scroll function with the id', () => {
34+
const scrollFn = vi.fn()
35+
scrollToAnchor('test-section', { scrollFn })
36+
37+
expect(scrollFn).toHaveBeenCalledWith('test-section')
38+
expect(scrollToSpy).not.toHaveBeenCalled()
39+
})
40+
41+
it('does not update URL when using custom scrollFn', () => {
42+
const scrollFn = vi.fn()
43+
scrollToAnchor('test-section', { scrollFn })
44+
45+
expect(replaceStateSpy).not.toHaveBeenCalled()
46+
})
47+
})
48+
49+
describe('with default scroll behavior', () => {
50+
it('does nothing when element is not found', () => {
51+
scrollToAnchor('non-existent-id')
52+
53+
expect(scrollToSpy).not.toHaveBeenCalled()
54+
expect(replaceStateSpy).not.toHaveBeenCalled()
55+
})
56+
57+
it('scrolls to element with smooth behavior', () => {
58+
scrollToAnchor('test-section')
59+
60+
expect(scrollToSpy).toHaveBeenCalledWith(
61+
expect.objectContaining({
62+
behavior: 'smooth',
63+
}),
64+
)
65+
})
66+
67+
it('calculates scroll position with header offset', () => {
68+
scrollToAnchor('test-section')
69+
70+
// Verify scrollTo was called with a top property (exact value depends on element position)
71+
expect(scrollToSpy).toHaveBeenCalledWith(
72+
expect.objectContaining({
73+
top: expect.any(Number),
74+
}),
75+
)
76+
})
77+
78+
it('updates URL hash by default', () => {
79+
scrollToAnchor('test-section')
80+
81+
expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '#test-section')
82+
})
83+
84+
it('does not update URL when updateUrl is false', () => {
85+
scrollToAnchor('test-section', { updateUrl: false })
86+
87+
expect(scrollToSpy).toHaveBeenCalled()
88+
expect(replaceStateSpy).not.toHaveBeenCalled()
89+
})
90+
})
91+
92+
describe('edge cases', () => {
93+
it('handles empty id string without errors', () => {
94+
expect(() => scrollToAnchor('')).not.toThrow()
95+
expect(scrollToSpy).not.toHaveBeenCalled()
96+
})
97+
98+
it('handles id with user-content prefix (GitHub-style anchors)', () => {
99+
const userContentElement = document.createElement('div')
100+
userContentElement.id = 'user-content-installation'
101+
document.body.appendChild(userContentElement)
102+
103+
scrollToAnchor('user-content-installation')
104+
105+
expect(scrollToSpy).toHaveBeenCalled()
106+
expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '#user-content-installation')
107+
108+
document.body.removeChild(userContentElement)
109+
})
110+
})
111+
})

0 commit comments

Comments
 (0)