diff --git a/app/components/ReadmeTocDropdown.vue b/app/components/ReadmeTocDropdown.vue new file mode 100644 index 000000000..36226ee24 --- /dev/null +++ b/app/components/ReadmeTocDropdown.vue @@ -0,0 +1,246 @@ + + + diff --git a/app/composables/useActiveTocItem.ts b/app/composables/useActiveTocItem.ts new file mode 100644 index 000000000..3898e6a23 --- /dev/null +++ b/app/composables/useActiveTocItem.ts @@ -0,0 +1,196 @@ +import type { TocItem } from '#shared/types/readme' +import type { Ref } from 'vue' +import { scrollToAnchor } from '~/utils/scrollToAnchor' + +/** + * Composable for tracking the currently visible heading in a TOC. + * Uses IntersectionObserver to detect which heading is at the top of the viewport. + * + * @param toc - Reactive array of TOC items + * @returns Object containing activeId and scrollToHeading function + * @public + */ +export function useActiveTocItem(toc: Ref) { + const activeId = ref(null) + + // Only run observer logic on client + if (import.meta.server) { + // eslint-disable-next-line @typescript-eslint/no-empty-function + return { activeId, scrollToHeading: (_id: string) => {} } + } + + let observer: IntersectionObserver | null = null + const headingElements = new Map() + let scrollCleanup: (() => void) | null = null + + const setupObserver = () => { + // Clean up previous observer + if (observer) { + observer.disconnect() + } + headingElements.clear() + + // Find all heading elements that match TOC IDs + const ids = toc.value.map(item => item.id) + if (ids.length === 0) return + + for (const id of ids) { + const el = document.getElementById(id) + if (el) { + headingElements.set(id, el) + } + } + + if (headingElements.size === 0) return + + // Create observer that triggers when headings cross the top 20% of viewport + observer = new IntersectionObserver( + entries => { + // Get all visible headings sorted by their position + const visibleHeadings: { id: string; top: number }[] = [] + + for (const entry of entries) { + if (entry.isIntersecting) { + visibleHeadings.push({ + id: entry.target.id, + top: entry.boundingClientRect.top, + }) + } + } + + // If there are visible headings, pick the one closest to the top + if (visibleHeadings.length > 0) { + visibleHeadings.sort((a, b) => a.top - b.top) + activeId.value = visibleHeadings[0]?.id ?? null + } else { + // No headings visible in intersection zone - find the one just above viewport + const headingsWithPosition: { id: string; top: number }[] = [] + for (const [id, el] of headingElements) { + const rect = el.getBoundingClientRect() + headingsWithPosition.push({ id, top: rect.top }) + } + + // Find the heading that's closest to (but above) the viewport top + const aboveViewport = headingsWithPosition + .filter(h => h.top < 100) // Allow some buffer + .sort((a, b) => b.top - a.top) // Sort descending (closest to top first) + + if (aboveViewport.length > 0) { + activeId.value = aboveViewport[0]?.id ?? null + } + } + }, + { + rootMargin: '-80px 0px -70% 0px', // Trigger in top ~30% of viewport (accounting for header) + threshold: 0, + }, + ) + + // Observe all heading elements + for (const el of headingElements.values()) { + observer.observe(el) + } + } + + // Scroll to a heading with observer disconnection during scroll + const scrollToHeading = (id: string) => { + if (!document.getElementById(id)) return + + // Clean up any previous scroll monitoring + if (scrollCleanup) { + scrollCleanup() + scrollCleanup = null + } + + // Immediately set activeId + activeId.value = id + + // Disconnect observer to prevent interference during scroll + if (observer) { + observer.disconnect() + } + + // Scroll, but do not update url until scroll ends + scrollToAnchor(id, { updateUrl: false }) + + const handleScrollEnd = () => { + history.replaceState(null, '', `#${id}`) + setupObserver() + scrollCleanup = null + } + + // Check for scrollend support (Chrome 114+, Firefox 109+, Safari 18+) + const supportsScrollEnd = 'onscrollend' in window + + if (supportsScrollEnd) { + window.addEventListener('scrollend', handleScrollEnd, { once: true }) + scrollCleanup = () => window.removeEventListener('scrollend', handleScrollEnd) + } else { + // Fallback: use RAF polling for older browsers + let lastScrollY = window.scrollY + let stableFrames = 0 + let rafId: number | null = null + const STABLE_THRESHOLD = 5 // Number of frames with no movement to consider settled + + const checkScrollSettled = () => { + const currentScrollY = window.scrollY + + if (Math.abs(currentScrollY - lastScrollY) < 1) { + stableFrames++ + if (stableFrames >= STABLE_THRESHOLD) { + handleScrollEnd() + return + } + } else { + stableFrames = 0 + } + + lastScrollY = currentScrollY + rafId = requestAnimationFrame(checkScrollSettled) + } + + rafId = requestAnimationFrame(checkScrollSettled) + + scrollCleanup = () => { + if (rafId !== null) { + cancelAnimationFrame(rafId) + rafId = null + } + } + } + + // Safety timeout - reconnect observer after max scroll time + setTimeout(() => { + if (scrollCleanup) { + scrollCleanup() + scrollCleanup = null + history.replaceState(null, '', `#${id}`) + setupObserver() + } + }, 1000) + } + + // Set up observer when TOC changes + watch( + toc, + () => { + // Use nextTick to ensure DOM is updated + nextTick(setupObserver) + }, + { immediate: true }, + ) + + // Clean up on unmount + onUnmounted(() => { + if (scrollCleanup) { + scrollCleanup() + scrollCleanup = null + } + if (observer) { + observer.disconnect() + observer = null + } + }) + + return { activeId, scrollToHeading } +} diff --git a/app/pages/[...package].vue b/app/pages/[...package].vue index 40c6b7bd1..f912a5aed 100644 --- a/app/pages/[...package].vue +++ b/app/pages/[...package].vue @@ -57,9 +57,13 @@ const { data: readmeData } = useLazyFetch( const version = requestedVersion.value return version ? `${base}/v/${version}` : base }, - { default: () => ({ html: '', playgroundLinks: [] }) }, + { default: () => ({ html: '', playgroundLinks: [], toc: [] }) }, ) +// Track active TOC item based on scroll position +const tocItems = computed(() => readmeData.value?.toc ?? []) +const { activeId: activeTocId, scrollToHeading } = useActiveTocItem(tocItems) + // Check if package exists on JSR (only for scoped packages) const { data: jsrInfo } = useLazyFetch(() => `/api/jsr/${packageName.value}`, { default: () => ({ exists: false }), @@ -961,18 +965,29 @@ function handleClick(event: MouseEvent) {
-

- - {{ $t('package.readme.title') }} -

+ + {{ $t('package.readme.title') }} + +

+ + - -

+ + +

diff --git a/app/utils/scrollToAnchor.ts b/app/utils/scrollToAnchor.ts new file mode 100644 index 000000000..f0ec64be4 --- /dev/null +++ b/app/utils/scrollToAnchor.ts @@ -0,0 +1,45 @@ +export interface ScrollToAnchorOptions { + /** Custom scroll function (e.g., from useActiveTocItem) */ + scrollFn?: (id: string) => void + /** Whether to update the URL hash (default: true) */ + updateUrl?: boolean +} + +/** + * Scroll to an element by ID, using a custom scroll function if provided, + * otherwise falling back to default scroll behavior with header offset. + * + * @param id - The element ID to scroll to + * @param options - Optional configuration for scroll behavior + */ +export function scrollToAnchor(id: string, options?: ScrollToAnchorOptions): void { + const { scrollFn, updateUrl = true } = options ?? {} + + // Use custom scroll function if provided + if (scrollFn) { + scrollFn(id) + return + } + + // Fallback: scroll with header offset + const element = document.getElementById(id) + if (!element) return + + // Calculate scroll position with header offset (matches scroll-padding-top in main.css) + const HEADER_OFFSET = 80 + const PKG_STICKY_HEADER_OFFSET = 52 + const elementTop = element.getBoundingClientRect().top + window.scrollY + const targetScrollY = elementTop - (HEADER_OFFSET + PKG_STICKY_HEADER_OFFSET) + + // Use scrollTo for precise control + window.scrollTo({ + top: targetScrollY, + behavior: 'smooth', + }) + + // Update URL hash after initiating scroll + // Use replaceState to avoid triggering native scroll-to-anchor behavior + if (updateUrl) { + history.replaceState(null, '', `#${id}`) + } +} diff --git a/i18n/locales/en.json b/i18n/locales/en.json index 57361f72b..9c366162e 100644 --- a/i18n/locales/en.json +++ b/i18n/locales/en.json @@ -200,7 +200,8 @@ "readme": { "title": "Readme", "no_readme": "No README available.", - "view_on_github": "View on GitHub" + "view_on_github": "View on GitHub", + "toc_title": "Outline" }, "keywords_title": "Keywords", "compatibility": "Compatibility", diff --git a/lunaria/files/en-US.json b/lunaria/files/en-US.json index 57361f72b..9c366162e 100644 --- a/lunaria/files/en-US.json +++ b/lunaria/files/en-US.json @@ -200,7 +200,8 @@ "readme": { "title": "Readme", "no_readme": "No README available.", - "view_on_github": "View on GitHub" + "view_on_github": "View on GitHub", + "toc_title": "Outline" }, "keywords_title": "Keywords", "compatibility": "Compatibility", diff --git a/server/api/registry/readme/[...pkg].get.ts b/server/api/registry/readme/[...pkg].get.ts index c523bbd01..d72b76442 100644 --- a/server/api/registry/readme/[...pkg].get.ts +++ b/server/api/registry/readme/[...pkg].get.ts @@ -107,7 +107,7 @@ export default defineCachedEventHandler( } if (!readmeContent || readmeContent === NPM_MISSING_README_SENTINEL) { - return { html: '', playgroundLinks: [] } + return { html: '', playgroundLinks: [], toc: [] } } // Parse repository info for resolving relative URLs to GitHub diff --git a/server/utils/readme.ts b/server/utils/readme.ts index 66373985b..505989ed0 100644 --- a/server/utils/readme.ts +++ b/server/utils/readme.ts @@ -1,7 +1,7 @@ import { marked, type Tokens } from 'marked' import sanitizeHtml from 'sanitize-html' import { hasProtocol } from 'ufo' -import type { ReadmeResponse } from '#shared/types/readme' +import type { ReadmeResponse, TocItem } from '#shared/types/readme' import { convertBlobToRawUrl, type RepositoryInfo } from '#shared/utils/git-providers' import { highlightCodeSync } from './shiki' import { convertToEmoji } from '#shared/utils/emoji' @@ -255,7 +255,7 @@ export async function renderReadmeHtml( packageName: string, repoInfo?: RepositoryInfo, ): Promise { - if (!content) return { html: '', playgroundLinks: [] } + if (!content) return { html: '', playgroundLinks: [], toc: [] } const shiki = await getShikiHighlighter() const renderer = new marked.Renderer() @@ -264,6 +264,9 @@ export async function renderReadmeHtml( const collectedLinks: PlaygroundLink[] = [] const seenUrls = new Set() + // Collect table of contents items during parsing + const toc: TocItem[] = [] + // Track used heading slugs to handle duplicates (GitHub-style: foo, foo-1, foo-2) const usedSlugs = new Map() @@ -303,6 +306,12 @@ export async function renderReadmeHtml( // (e.g., #install, #dependencies, #versions are used by the package page) const id = `user-content-${uniqueSlug}` + // Collect TOC item with plain text (HTML stripped) + const plainText = text.replace(/<[^>]*>/g, '').trim() + if (plainText) { + toc.push({ text: plainText, id, depth }) + } + return `${text}\n` } @@ -420,5 +429,6 @@ ${html} return { html: convertToEmoji(sanitized), playgroundLinks: collectedLinks, + toc, } } diff --git a/shared/types/readme.ts b/shared/types/readme.ts index 58246fd2a..350450c4d 100644 --- a/shared/types/readme.ts +++ b/shared/types/readme.ts @@ -12,6 +12,18 @@ export interface PlaygroundLink { label: string } +/** + * Table of contents item extracted from README headings + */ +export interface TocItem { + /** Plain text heading (HTML stripped) */ + text: string + /** Anchor ID (e.g., "user-content-installation") */ + id: string + /** Original heading depth (1-6) */ + depth: number +} + /** * Response from README API endpoint */ @@ -20,4 +32,6 @@ export interface ReadmeResponse { html: string /** Extracted playground/demo links */ playgroundLinks: PlaygroundLink[] + /** Table of contents extracted from headings */ + toc: TocItem[] } diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts index e2490b110..472b76eb4 100644 --- a/test/nuxt/a11y.spec.ts +++ b/test/nuxt/a11y.spec.ts @@ -106,6 +106,7 @@ import { PaginationControls, ProvenanceBadge, Readme, + ReadmeTocDropdown, SettingsAccentColorPicker, SettingsToggle, TerminalExecute, @@ -1683,6 +1684,54 @@ describe('component accessibility audits', () => { }) }) + describe('ReadmeTocDropdown', () => { + const mockToc = [ + { text: 'Installation', id: 'installation', depth: 2 }, + { text: 'Usage', id: 'usage', depth: 2 }, + { text: 'Basic Usage', id: 'basic-usage', depth: 3 }, + { text: 'Advanced Usage', id: 'advanced-usage', depth: 3 }, + { text: 'API', id: 'api', depth: 2 }, + ] + + it('should have no accessibility violations', async () => { + const component = await mountSuspended(ReadmeTocDropdown, { + props: { toc: mockToc }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations with active item', async () => { + const component = await mountSuspended(ReadmeTocDropdown, { + props: { + toc: mockToc, + activeId: 'usage', + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations with nested active item', async () => { + const component = await mountSuspended(ReadmeTocDropdown, { + props: { + toc: mockToc, + activeId: 'basic-usage', + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + + it('should have no accessibility violations with empty toc', async () => { + const component = await mountSuspended(ReadmeTocDropdown, { + props: { toc: [] }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + describe('HeaderSearchBox', () => { it('should have no accessibility violations', async () => { const component = await mountSuspended(HeaderSearchBox)