Skip to content
15 changes: 14 additions & 1 deletion app/components/Readme.vue
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
<script setup lang="ts">
import { scrollToAnchor } from '~/utils/scrollToAnchor'

defineProps<{
html: string
}>()
Expand All @@ -9,6 +11,7 @@ const { copy } = useClipboard()
// Combined click handler for:
// 1. Intercepting npmjs.com links to route internally
// 2. Copy button functionality for code blocks
// 3. Hash link scrolling for internal README navigation (Table of Contents)
function handleClick(event: MouseEvent) {
const target = event.target as HTMLElement | undefined
if (!target) return
Expand Down Expand Up @@ -40,13 +43,23 @@ function handleClick(event: MouseEvent) {
return
}

// Handle npmjs.com link clicks - route internally
// Handle anchor link clicks
const anchor = target.closest('a')
if (!anchor) return

const href = anchor.getAttribute('href')
if (!href) return

// Handle hash links for internal README navigation (e.g., Table of Contents)
if (href.startsWith('#')) {
event.preventDefault()
// Lowercase the ID to match heading slugs (generated with toLowerCase in slugify)
const id = href.slice(1).toLowerCase()
scrollToAnchor(id)
return
}
Comment on lines +54 to +61
Copy link
Copy Markdown
Member

@alexdln alexdln Feb 5, 2026

Choose a reason for hiding this comment

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

The problem is that anchor link can have different case values, but the heading ID only uses lowercase. Both the anchor and the heading id (already so) need to be converted to lowercase

The current logic will not work after a reload or with link sharing and is generally redundant - it is always better to trust native capabilities as much as possible

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks for the feedback @alexdln! You're absolutely right about the case sensitivity issue.

I've addressed this in commit b9d138f by adding .toLowerCase() when extracting the ID from the hash link. This ensures that links like #Installation will correctly match heading IDs like user-content-installation (which are generated using the slugify function that lowercases the text).

The fix now follows the same pattern as the server-side slugify function which converts heading text to lowercase before creating the ID.

I also added tests for the Readme component to verify the hash link handling and case sensitivity fix.

Comment on lines +54 to +61
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.

would it work if we just returned, deferring to the browser?

Suggested change
if (href.startsWith('#')) {
event.preventDefault()
// Lowercase the ID to match heading slugs (generated with toLowerCase in slugify)
const id = href.slice(1).toLowerCase()
scrollToAnchor(id)
return
}
if (href.startsWith('#')) {
return
}

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.

nope, different case-registry, we need to modify href (toLowerCase())

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.

but why? we're generating the ids and the links. if case matters, surely we should do toLowerCase here:

const id = `user-content-${uniqueSlug}`

if (url.startsWith('#')) {
// Prefix anchor links to match heading IDs (avoids collision with page IDs)
return `#user-content-${url.slice(1)}`
}

Copy link
Copy Markdown
Member

@alexdln alexdln Feb 5, 2026

Choose a reason for hiding this comment

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

Regarding the previous comment - sorry, I meant that this alone is not enough - if we modify the link, it will work

image
  • The default scrolling behavior during page navigation requires a consistent case;
  • The default scrolling behavior will be abrupt, not smooth;
  • But if there was a hash when loading the page, the browser will recognize it and start from that place.

Better

  • Use consistent case to ensure stable and predictable browser behavior;
  • Enable scroll-behavior: smooth. But I'm not sure how Nuxt handles this - will it disable this property when switching between pages (like with setting in next)


// Handle npmjs.com link clicks - route internally
const match = href.match(/^(?:https?:\/\/)?(?:www\.)?npmjs\.(?:com|org)(\/.+)$/)
if (!match || !match[1]) return

Expand Down
46 changes: 46 additions & 0 deletions test/nuxt/components/Readme.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it, vi } from 'vitest'
import { mountSuspended } from '@nuxt/test-utils/runtime'
import Readme from '~/components/Readme.vue'

// Mock scrollToAnchor
vi.mock('~/utils/scrollToAnchor', () => ({
scrollToAnchor: vi.fn(),
}))

import { scrollToAnchor } from '~/utils/scrollToAnchor'

describe('Readme', () => {
describe('rendering', () => {
it('renders the provided HTML content', async () => {
const component = await mountSuspended(Readme, {
props: { html: '<p>Hello world</p>' },
})
expect(component.html()).toContain('Hello world')
})
})

describe('hash link click handling', () => {
it('intercepts hash link clicks and calls scrollToAnchor with lowercase ID', async () => {
const component = await mountSuspended(Readme, {
props: { html: '<a href="#Installation">Installation</a>' },
})

const link = component.find('a')
await link.trigger('click')

expect(scrollToAnchor).toHaveBeenCalledWith('installation')
})

it('handles user-content prefixed hash links', async () => {
vi.mocked(scrollToAnchor).mockClear()
const component = await mountSuspended(Readme, {
props: { html: '<a href="#user-content-getting-started">Getting Started</a>' },
})

const link = component.find('a')
await link.trigger('click')

expect(scrollToAnchor).toHaveBeenCalledWith('user-content-getting-started')
})
})
})
111 changes: 111 additions & 0 deletions test/nuxt/utils/scrollToAnchor.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { scrollToAnchor } from '~/utils/scrollToAnchor'

describe('scrollToAnchor', () => {
let scrollToSpy: ReturnType<typeof vi.fn>
let replaceStateSpy: ReturnType<typeof vi.fn>
let testElement: HTMLElement

beforeEach(() => {
// Spy on window.scrollTo
scrollToSpy = vi.fn()
vi.stubGlobal('scrollTo', scrollToSpy)

// Spy on history.replaceState
replaceStateSpy = vi.spyOn(history, 'replaceState').mockImplementation(() => {})

// Create a test element
testElement = document.createElement('div')
testElement.id = 'test-section'
document.body.appendChild(testElement)
})

afterEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
// Clean up test element
if (testElement && testElement.parentNode) {
testElement.parentNode.removeChild(testElement)
}
})

describe('with custom scrollFn', () => {
it('calls the provided scroll function with the id', () => {
const scrollFn = vi.fn()
scrollToAnchor('test-section', { scrollFn })

expect(scrollFn).toHaveBeenCalledWith('test-section')
expect(scrollToSpy).not.toHaveBeenCalled()
})

it('does not update URL when using custom scrollFn', () => {
const scrollFn = vi.fn()
scrollToAnchor('test-section', { scrollFn })

expect(replaceStateSpy).not.toHaveBeenCalled()
})
})

describe('with default scroll behavior', () => {
it('does nothing when element is not found', () => {
scrollToAnchor('non-existent-id')

expect(scrollToSpy).not.toHaveBeenCalled()
expect(replaceStateSpy).not.toHaveBeenCalled()
})

it('scrolls to element with smooth behavior', () => {
scrollToAnchor('test-section')

expect(scrollToSpy).toHaveBeenCalledWith(
expect.objectContaining({
behavior: 'smooth',
}),
)
})

it('calculates scroll position with header offset', () => {
scrollToAnchor('test-section')

// Verify scrollTo was called with a top property (exact value depends on element position)
expect(scrollToSpy).toHaveBeenCalledWith(
expect.objectContaining({
top: expect.any(Number),
}),
)
})

it('updates URL hash by default', () => {
scrollToAnchor('test-section')

expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '#test-section')
})

it('does not update URL when updateUrl is false', () => {
scrollToAnchor('test-section', { updateUrl: false })

expect(scrollToSpy).toHaveBeenCalled()
expect(replaceStateSpy).not.toHaveBeenCalled()
})
})

describe('edge cases', () => {
it('handles empty id string without errors', () => {
expect(() => scrollToAnchor('')).not.toThrow()
expect(scrollToSpy).not.toHaveBeenCalled()
})

it('handles id with user-content prefix (GitHub-style anchors)', () => {
const userContentElement = document.createElement('div')
userContentElement.id = 'user-content-installation'
document.body.appendChild(userContentElement)

scrollToAnchor('user-content-installation')

expect(scrollToSpy).toHaveBeenCalled()
expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '#user-content-installation')

document.body.removeChild(userContentElement)
})
})
})
Loading