From bd34920c9b89c38cd0678871bcef845f59d4794a Mon Sep 17 00:00:00 2001 From: Vincent Taverna Date: Sat, 31 Jan 2026 17:56:52 -0500 Subject: [PATCH 1/5] feat: add toc to readme --- app/components/ReadmeToc.vue | 147 ++++++++++++ app/components/ReadmeTocDropdown.vue | 246 +++++++++++++++++++++ app/composables/useActiveTocItem.ts | 198 +++++++++++++++++ app/pages/[...package].vue | 52 ++++- app/utils/scrollToAnchor.ts | 33 +++ i18n/locales/en.json | 3 +- server/api/registry/readme/[...pkg].get.ts | 4 +- server/utils/readme.ts | 14 +- shared/types/readme.ts | 14 ++ 9 files changed, 694 insertions(+), 17 deletions(-) create mode 100644 app/components/ReadmeToc.vue create mode 100644 app/components/ReadmeTocDropdown.vue create mode 100644 app/composables/useActiveTocItem.ts create mode 100644 app/utils/scrollToAnchor.ts diff --git a/app/components/ReadmeToc.vue b/app/components/ReadmeToc.vue new file mode 100644 index 000000000..cf7e34ba1 --- /dev/null +++ b/app/components/ReadmeToc.vue @@ -0,0 +1,147 @@ + + + + + diff --git a/app/components/ReadmeTocDropdown.vue b/app/components/ReadmeTocDropdown.vue new file mode 100644 index 000000000..7a0feb998 --- /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..6acc77b0c --- /dev/null +++ b/app/composables/useActiveTocItem.ts @@ -0,0 +1,198 @@ +import type { TocItem } from '#shared/types/readme' +import type { Ref } from 'vue' + +/** + * 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) => { + const element = document.getElementById(id) + if (!element) 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() + } + + // Calculate scroll position with header offset (5rem = 80px) + // This matches the scroll-padding-top in main.css + const HEADER_OFFSET = 80 + const elementTop = element.getBoundingClientRect().top + window.scrollY + const targetScrollY = elementTop - HEADER_OFFSET + + // Use scrollTo for precise control (respects CSS scroll-behavior: smooth) + // Don't update URL hash yet - do it after scroll completes to avoid + // browser native scroll-to-anchor behavior interfering + window.scrollTo({ + top: targetScrollY, + behavior: 'smooth', + }) + + // Monitor scroll until it settles, THEN update URL hash + 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) { + // Scroll has settled - NOW update URL hash (won't trigger native scroll) + history.replaceState(null, '', `#${id}`) + setupObserver() + scrollCleanup = null + return + } + } else { + stableFrames = 0 + } + + lastScrollY = currentScrollY + rafId = requestAnimationFrame(checkScrollSettled) + } + + // Start monitoring + rafId = requestAnimationFrame(checkScrollSettled) + + // Store cleanup function + 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() + } + }, 1500) + } + + // 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 d49492afb..d80b6821b 100644 --- a/app/pages/[...package].vue +++ b/app/pages/[...package].vue @@ -29,9 +29,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 }), @@ -923,18 +927,31 @@ function handleClick(event: MouseEvent) {
-

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

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

+ + + - -

+ + +
+ + + +
{ const pkg = getRouterParam(event, 'pkg') ?? '' - return `readme:v5:${pkg.replace(/\/+$/, '').trim()}` + return `readme:v6:${pkg.replace(/\/+$/, '').trim()}` }, }, ) diff --git a/server/utils/readme.ts b/server/utils/readme.ts index 006698551..6f21a3164 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' @@ -253,7 +253,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() @@ -262,6 +262,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() @@ -301,6 +304,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` } @@ -410,5 +419,6 @@ export async function renderReadmeHtml( 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[] } From a6ae7220e9e3400b0ae6055badf9b14ca782e287 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 31 Jan 2026 22:59:41 +0000 Subject: [PATCH 2/5] [autofix.ci] apply automated fixes --- lunaria/files/en-US.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lunaria/files/en-US.json b/lunaria/files/en-US.json index 171309c19..cfaee1e52 100644 --- a/lunaria/files/en-US.json +++ b/lunaria/files/en-US.json @@ -181,7 +181,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", From a811757aaa59b1701377d2cd01f3b6cda5673b6b Mon Sep 17 00:00:00 2001 From: Vincent Taverna Date: Sat, 31 Jan 2026 19:28:15 -0500 Subject: [PATCH 3/5] chore: remove sidebar outline and improve ux --- app/components/ReadmeToc.vue | 147 ---------------------------- app/composables/useActiveTocItem.ts | 69 +++++++------ app/pages/[...package].vue | 15 +-- 3 files changed, 40 insertions(+), 191 deletions(-) delete mode 100644 app/components/ReadmeToc.vue diff --git a/app/components/ReadmeToc.vue b/app/components/ReadmeToc.vue deleted file mode 100644 index cf7e34ba1..000000000 --- a/app/components/ReadmeToc.vue +++ /dev/null @@ -1,147 +0,0 @@ - - - - - diff --git a/app/composables/useActiveTocItem.ts b/app/composables/useActiveTocItem.ts index 6acc77b0c..171a34df4 100644 --- a/app/composables/useActiveTocItem.ts +++ b/app/composables/useActiveTocItem.ts @@ -124,40 +124,49 @@ export function useActiveTocItem(toc: Ref) { behavior: 'smooth', }) - // Monitor scroll until it settles, THEN update URL hash - 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) { - // Scroll has settled - NOW update URL hash (won't trigger native scroll) - history.replaceState(null, '', `#${id}`) - setupObserver() - scrollCleanup = null - return + 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 } - } else { - stableFrames = 0 + + lastScrollY = currentScrollY + rafId = requestAnimationFrame(checkScrollSettled) } - lastScrollY = currentScrollY rafId = requestAnimationFrame(checkScrollSettled) - } - // Start monitoring - rafId = requestAnimationFrame(checkScrollSettled) - - // Store cleanup function - scrollCleanup = () => { - if (rafId !== null) { - cancelAnimationFrame(rafId) - rafId = null + scrollCleanup = () => { + if (rafId !== null) { + cancelAnimationFrame(rafId) + rafId = null + } } } @@ -169,7 +178,7 @@ export function useActiveTocItem(toc: Ref) { history.replaceState(null, '', `#${id}`) setupObserver() } - }, 1500) + }, 1000) } // Set up observer when TOC changes diff --git a/app/pages/[...package].vue b/app/pages/[...package].vue index d80b6821b..8f8bc944e 100644 --- a/app/pages/[...package].vue +++ b/app/pages/[...package].vue @@ -940,14 +940,12 @@ function handleClick(event: MouseEvent) { /> - @@ -1013,17 +1011,6 @@ function handleClick(event: MouseEvent) { :links="readmeData.playgroundLinks" /> - - - -