[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add toc to readme (#573)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Daniel Roe <daniel@roe.dev>

authored by

Vincent Taverna
autofix-ci[bot]
Daniel Roe
and committed by
GitHub
ad97df01 b27efc33

+595 -18
+246
app/components/ReadmeTocDropdown.vue
··· 1 + <script setup lang="ts"> 2 + import type { TocItem } from '#shared/types/readme' 3 + import { onClickOutside, useEventListener } from '@vueuse/core' 4 + import { scrollToAnchor } from '~/utils/scrollToAnchor' 5 + 6 + const props = defineProps<{ 7 + toc: TocItem[] 8 + activeId?: string | null 9 + scrollToHeading?: (id: string) => void 10 + }>() 11 + 12 + interface TocNode extends TocItem { 13 + children: TocNode[] 14 + } 15 + 16 + function buildTocTree(items: TocItem[]): TocNode[] { 17 + const result: TocNode[] = [] 18 + const stack: TocNode[] = [] 19 + 20 + for (const item of items) { 21 + const node: TocNode = { ...item, children: [] } 22 + 23 + // Find parent: look for the last item with smaller depth 24 + while (stack.length > 0 && stack[stack.length - 1]!.depth >= item.depth) { 25 + stack.pop() 26 + } 27 + 28 + if (stack.length === 0) { 29 + result.push(node) 30 + } else { 31 + stack[stack.length - 1]!.children.push(node) 32 + } 33 + 34 + stack.push(node) 35 + } 36 + 37 + return result 38 + } 39 + 40 + const tocTree = computed(() => buildTocTree(props.toc)) 41 + 42 + // Create a map from id to index for efficient lookup 43 + const idToIndex = computed(() => { 44 + const map = new Map<string, number>() 45 + props.toc.forEach((item, index) => map.set(item.id, index)) 46 + return map 47 + }) 48 + 49 + const listRef = useTemplateRef('listRef') 50 + const triggerRef = useTemplateRef('triggerRef') 51 + const isOpen = shallowRef(false) 52 + const highlightedIndex = shallowRef(-1) 53 + 54 + const dropdownPosition = shallowRef<{ top: number; right: number } | null>(null) 55 + 56 + function getDropdownStyle(): Record<string, string> { 57 + if (!dropdownPosition.value) return {} 58 + return { 59 + top: `${dropdownPosition.value.top}px`, 60 + right: `${document.documentElement.clientWidth - dropdownPosition.value.right}px`, 61 + } 62 + } 63 + 64 + // Close on scroll (but not when scrolling inside the dropdown) 65 + function handleScroll(event: Event) { 66 + if (!isOpen.value) return 67 + if (listRef.value && event.target instanceof Node && listRef.value.contains(event.target)) { 68 + return 69 + } 70 + close() 71 + } 72 + useEventListener('scroll', handleScroll, true) 73 + 74 + // Generate unique ID for accessibility 75 + const inputId = useId() 76 + const listboxId = `${inputId}-toc-listbox` 77 + 78 + function toggle() { 79 + if (isOpen.value) { 80 + close() 81 + } else { 82 + if (triggerRef.value) { 83 + const rect = triggerRef.value.getBoundingClientRect() 84 + dropdownPosition.value = { 85 + top: rect.bottom + 4, 86 + right: rect.right, 87 + } 88 + } 89 + isOpen.value = true 90 + // Highlight active item if any 91 + const activeIndex = idToIndex.value.get(props.activeId ?? '') 92 + highlightedIndex.value = activeIndex ?? 0 93 + } 94 + } 95 + 96 + function close() { 97 + isOpen.value = false 98 + highlightedIndex.value = -1 99 + } 100 + 101 + function select(id: string) { 102 + scrollToAnchor(id, { scrollFn: props.scrollToHeading }) 103 + close() 104 + triggerRef.value?.focus() 105 + } 106 + 107 + function getIndex(id: string): number { 108 + return idToIndex.value.get(id) ?? -1 109 + } 110 + 111 + // Check for reduced motion preference 112 + const prefersReducedMotion = useMediaQuery('(prefers-reduced-motion: reduce)') 113 + 114 + onClickOutside(listRef, close, { ignore: [triggerRef] }) 115 + 116 + function handleKeydown(event: KeyboardEvent) { 117 + if (!isOpen.value) return 118 + 119 + const itemCount = props.toc.length 120 + 121 + switch (event.key) { 122 + case 'ArrowDown': 123 + event.preventDefault() 124 + highlightedIndex.value = (highlightedIndex.value + 1) % itemCount 125 + break 126 + case 'ArrowUp': 127 + event.preventDefault() 128 + highlightedIndex.value = 129 + highlightedIndex.value <= 0 ? itemCount - 1 : highlightedIndex.value - 1 130 + break 131 + case 'Enter': { 132 + event.preventDefault() 133 + const item = props.toc[highlightedIndex.value] 134 + if (item) { 135 + select(item.id) 136 + } 137 + break 138 + } 139 + case 'Escape': 140 + close() 141 + triggerRef.value?.focus() 142 + break 143 + } 144 + } 145 + </script> 146 + 147 + <template> 148 + <button 149 + ref="triggerRef" 150 + type="button" 151 + class="flex items-center gap-1.5 px-2 py-2 font-mono text-xs text-fg-muted bg-bg-subtle border border-border-subtle border-solid rounded-md transition-colors duration-150 hover:(text-fg border-border-hover) active:scale-95 focus:border-border-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-fg/50 hover:text-fg" 152 + :aria-expanded="isOpen" 153 + aria-haspopup="listbox" 154 + :aria-label="$t('package.readme.toc_title')" 155 + :aria-controls="listboxId" 156 + @click="toggle" 157 + @keydown="handleKeydown" 158 + > 159 + <span class="i-carbon:list w-3.5 h-3.5" aria-hidden="true" /> 160 + <span 161 + class="i-carbon:chevron-down w-3 h-3" 162 + :class="[ 163 + { 'rotate-180': isOpen }, 164 + prefersReducedMotion ? '' : 'transition-transform duration-200', 165 + ]" 166 + aria-hidden="true" 167 + /> 168 + </button> 169 + 170 + <Teleport to="body"> 171 + <Transition 172 + :enter-active-class="prefersReducedMotion ? '' : 'transition-opacity duration-150'" 173 + :enter-from-class="prefersReducedMotion ? '' : 'opacity-0'" 174 + enter-to-class="opacity-100" 175 + :leave-active-class="prefersReducedMotion ? '' : 'transition-opacity duration-100'" 176 + leave-from-class="opacity-100" 177 + :leave-to-class="prefersReducedMotion ? '' : 'opacity-0'" 178 + > 179 + <div 180 + v-if="isOpen" 181 + :id="listboxId" 182 + ref="listRef" 183 + role="listbox" 184 + :aria-activedescendant=" 185 + highlightedIndex >= 0 ? `${listboxId}-${toc[highlightedIndex]?.id}` : undefined 186 + " 187 + :aria-label="$t('package.readme.toc_title')" 188 + :style="getDropdownStyle()" 189 + class="fixed bg-bg-subtle border border-border rounded-md shadow-lg z-50 max-h-80 overflow-y-auto w-56 overscroll-contain" 190 + > 191 + <template v-for="node in tocTree" :key="node.id"> 192 + <div 193 + :id="`${listboxId}-${node.id}`" 194 + role="option" 195 + :aria-selected="activeId === node.id" 196 + class="flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer transition-colors duration-150" 197 + :class="[ 198 + activeId === node.id ? 'text-fg font-medium' : 'text-fg-muted', 199 + highlightedIndex === getIndex(node.id) ? 'bg-bg-elevated' : 'hover:bg-bg-elevated', 200 + ]" 201 + @click="select(node.id)" 202 + @mouseenter="highlightedIndex = getIndex(node.id)" 203 + > 204 + <span class="truncate">{{ node.text }}</span> 205 + </div> 206 + 207 + <template v-for="child in node.children" :key="child.id"> 208 + <div 209 + :id="`${listboxId}-${child.id}`" 210 + role="option" 211 + :aria-selected="activeId === child.id" 212 + class="flex items-center gap-2 px-3 py-1.5 ps-6 text-sm cursor-pointer transition-colors duration-150" 213 + :class="[ 214 + activeId === child.id ? 'text-fg font-medium' : 'text-fg-subtle', 215 + highlightedIndex === getIndex(child.id) ? 'bg-bg-elevated' : 'hover:bg-bg-elevated', 216 + ]" 217 + @click="select(child.id)" 218 + @mouseenter="highlightedIndex = getIndex(child.id)" 219 + > 220 + <span class="truncate">{{ child.text }}</span> 221 + </div> 222 + 223 + <div 224 + v-for="grandchild in child.children" 225 + :id="`${listboxId}-${grandchild.id}`" 226 + :key="grandchild.id" 227 + role="option" 228 + :aria-selected="activeId === grandchild.id" 229 + class="flex items-center gap-2 px-3 py-1.5 ps-9 text-sm cursor-pointer transition-colors duration-150" 230 + :class="[ 231 + activeId === grandchild.id ? 'text-fg font-medium' : 'text-fg-subtle', 232 + highlightedIndex === getIndex(grandchild.id) 233 + ? 'bg-bg-elevated' 234 + : 'hover:bg-bg-elevated', 235 + ]" 236 + @click="select(grandchild.id)" 237 + @mouseenter="highlightedIndex = getIndex(grandchild.id)" 238 + > 239 + <span class="truncate">{{ grandchild.text }}</span> 240 + </div> 241 + </template> 242 + </template> 243 + </div> 244 + </Transition> 245 + </Teleport> 246 + </template>
+196
app/composables/useActiveTocItem.ts
··· 1 + import type { TocItem } from '#shared/types/readme' 2 + import type { Ref } from 'vue' 3 + import { scrollToAnchor } from '~/utils/scrollToAnchor' 4 + 5 + /** 6 + * Composable for tracking the currently visible heading in a TOC. 7 + * Uses IntersectionObserver to detect which heading is at the top of the viewport. 8 + * 9 + * @param toc - Reactive array of TOC items 10 + * @returns Object containing activeId and scrollToHeading function 11 + * @public 12 + */ 13 + export function useActiveTocItem(toc: Ref<TocItem[]>) { 14 + const activeId = ref<string | null>(null) 15 + 16 + // Only run observer logic on client 17 + if (import.meta.server) { 18 + // eslint-disable-next-line @typescript-eslint/no-empty-function 19 + return { activeId, scrollToHeading: (_id: string) => {} } 20 + } 21 + 22 + let observer: IntersectionObserver | null = null 23 + const headingElements = new Map<string, Element>() 24 + let scrollCleanup: (() => void) | null = null 25 + 26 + const setupObserver = () => { 27 + // Clean up previous observer 28 + if (observer) { 29 + observer.disconnect() 30 + } 31 + headingElements.clear() 32 + 33 + // Find all heading elements that match TOC IDs 34 + const ids = toc.value.map(item => item.id) 35 + if (ids.length === 0) return 36 + 37 + for (const id of ids) { 38 + const el = document.getElementById(id) 39 + if (el) { 40 + headingElements.set(id, el) 41 + } 42 + } 43 + 44 + if (headingElements.size === 0) return 45 + 46 + // Create observer that triggers when headings cross the top 20% of viewport 47 + observer = new IntersectionObserver( 48 + entries => { 49 + // Get all visible headings sorted by their position 50 + const visibleHeadings: { id: string; top: number }[] = [] 51 + 52 + for (const entry of entries) { 53 + if (entry.isIntersecting) { 54 + visibleHeadings.push({ 55 + id: entry.target.id, 56 + top: entry.boundingClientRect.top, 57 + }) 58 + } 59 + } 60 + 61 + // If there are visible headings, pick the one closest to the top 62 + if (visibleHeadings.length > 0) { 63 + visibleHeadings.sort((a, b) => a.top - b.top) 64 + activeId.value = visibleHeadings[0]?.id ?? null 65 + } else { 66 + // No headings visible in intersection zone - find the one just above viewport 67 + const headingsWithPosition: { id: string; top: number }[] = [] 68 + for (const [id, el] of headingElements) { 69 + const rect = el.getBoundingClientRect() 70 + headingsWithPosition.push({ id, top: rect.top }) 71 + } 72 + 73 + // Find the heading that's closest to (but above) the viewport top 74 + const aboveViewport = headingsWithPosition 75 + .filter(h => h.top < 100) // Allow some buffer 76 + .sort((a, b) => b.top - a.top) // Sort descending (closest to top first) 77 + 78 + if (aboveViewport.length > 0) { 79 + activeId.value = aboveViewport[0]?.id ?? null 80 + } 81 + } 82 + }, 83 + { 84 + rootMargin: '-80px 0px -70% 0px', // Trigger in top ~30% of viewport (accounting for header) 85 + threshold: 0, 86 + }, 87 + ) 88 + 89 + // Observe all heading elements 90 + for (const el of headingElements.values()) { 91 + observer.observe(el) 92 + } 93 + } 94 + 95 + // Scroll to a heading with observer disconnection during scroll 96 + const scrollToHeading = (id: string) => { 97 + if (!document.getElementById(id)) return 98 + 99 + // Clean up any previous scroll monitoring 100 + if (scrollCleanup) { 101 + scrollCleanup() 102 + scrollCleanup = null 103 + } 104 + 105 + // Immediately set activeId 106 + activeId.value = id 107 + 108 + // Disconnect observer to prevent interference during scroll 109 + if (observer) { 110 + observer.disconnect() 111 + } 112 + 113 + // Scroll, but do not update url until scroll ends 114 + scrollToAnchor(id, { updateUrl: false }) 115 + 116 + const handleScrollEnd = () => { 117 + history.replaceState(null, '', `#${id}`) 118 + setupObserver() 119 + scrollCleanup = null 120 + } 121 + 122 + // Check for scrollend support (Chrome 114+, Firefox 109+, Safari 18+) 123 + const supportsScrollEnd = 'onscrollend' in window 124 + 125 + if (supportsScrollEnd) { 126 + window.addEventListener('scrollend', handleScrollEnd, { once: true }) 127 + scrollCleanup = () => window.removeEventListener('scrollend', handleScrollEnd) 128 + } else { 129 + // Fallback: use RAF polling for older browsers 130 + let lastScrollY = window.scrollY 131 + let stableFrames = 0 132 + let rafId: number | null = null 133 + const STABLE_THRESHOLD = 5 // Number of frames with no movement to consider settled 134 + 135 + const checkScrollSettled = () => { 136 + const currentScrollY = window.scrollY 137 + 138 + if (Math.abs(currentScrollY - lastScrollY) < 1) { 139 + stableFrames++ 140 + if (stableFrames >= STABLE_THRESHOLD) { 141 + handleScrollEnd() 142 + return 143 + } 144 + } else { 145 + stableFrames = 0 146 + } 147 + 148 + lastScrollY = currentScrollY 149 + rafId = requestAnimationFrame(checkScrollSettled) 150 + } 151 + 152 + rafId = requestAnimationFrame(checkScrollSettled) 153 + 154 + scrollCleanup = () => { 155 + if (rafId !== null) { 156 + cancelAnimationFrame(rafId) 157 + rafId = null 158 + } 159 + } 160 + } 161 + 162 + // Safety timeout - reconnect observer after max scroll time 163 + setTimeout(() => { 164 + if (scrollCleanup) { 165 + scrollCleanup() 166 + scrollCleanup = null 167 + history.replaceState(null, '', `#${id}`) 168 + setupObserver() 169 + } 170 + }, 1000) 171 + } 172 + 173 + // Set up observer when TOC changes 174 + watch( 175 + toc, 176 + () => { 177 + // Use nextTick to ensure DOM is updated 178 + nextTick(setupObserver) 179 + }, 180 + { immediate: true }, 181 + ) 182 + 183 + // Clean up on unmount 184 + onUnmounted(() => { 185 + if (scrollCleanup) { 186 + scrollCleanup() 187 + scrollCleanup = null 188 + } 189 + if (observer) { 190 + observer.disconnect() 191 + observer = null 192 + } 193 + }) 194 + 195 + return { activeId, scrollToHeading } 196 + }
+27 -12
app/pages/package/[...package].vue
··· 58 58 const version = requestedVersion.value 59 59 return version ? `${base}/v/${version}` : base 60 60 }, 61 - { default: () => ({ html: '', playgroundLinks: [] }) }, 61 + { default: () => ({ html: '', playgroundLinks: [], toc: [] }) }, 62 62 ) 63 + 64 + // Track active TOC item based on scroll position 65 + const tocItems = computed(() => readmeData.value?.toc ?? []) 66 + const { activeId: activeTocId, scrollToHeading } = useActiveTocItem(tocItems) 63 67 64 68 // Check if package exists on JSR (only for scoped packages) 65 69 const { data: jsrInfo } = useLazyFetch<JsrPackageInfo>(() => `/api/jsr/${packageName.value}`, { ··· 966 970 967 971 <!-- README --> 968 972 <section id="readme" class="area-readme min-w-0 scroll-mt-20"> 969 - <h2 id="readme-heading" class="group text-xs text-fg-subtle uppercase tracking-wider mb-4"> 970 - <a 971 - href="#readme" 972 - class="inline-flex py-4 px-2 items-center gap-1.5 text-fg-subtle hover:text-fg-muted transition-colors duration-200 no-underline" 973 - > 974 - {{ $t('package.readme.title') }} 975 - <span 976 - class="i-carbon:link w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity duration-200" 977 - aria-hidden="true" 973 + <div class="flex flex-wrap items-center justify-between mb-4"> 974 + <h2 id="readme-heading" class="group text-xs text-fg-subtle uppercase tracking-wider"> 975 + <a 976 + href="#readme" 977 + class="inline-flex py-4 px-2 items-center gap-1.5 text-fg-subtle hover:text-fg-muted transition-colors duration-200 no-underline" 978 + > 979 + {{ $t('package.readme.title') }} 980 + <span 981 + class="i-carbon:link w-3 h-3 opacity-0 group-hover:opacity-100 transition-opacity duration-200" 982 + aria-hidden="true" 983 + /> 984 + </a> 985 + </h2> 986 + <ClientOnly> 987 + <ReadmeTocDropdown 988 + v-if="readmeData?.toc && readmeData.toc.length > 1" 989 + :toc="readmeData.toc" 990 + :active-id="activeTocId" 991 + :scroll-to-heading="scrollToHeading" 978 992 /> 979 - </a> 980 - </h2> 993 + </ClientOnly> 994 + </div> 995 + 981 996 <!-- eslint-disable vue/no-v-html -- HTML is sanitized server-side --> 982 997 <Readme v-if="readmeData?.html" :html="readmeData.html" /> 983 998 <p v-else class="text-fg-subtle italic">
+45
app/utils/scrollToAnchor.ts
··· 1 + export interface ScrollToAnchorOptions { 2 + /** Custom scroll function (e.g., from useActiveTocItem) */ 3 + scrollFn?: (id: string) => void 4 + /** Whether to update the URL hash (default: true) */ 5 + updateUrl?: boolean 6 + } 7 + 8 + /** 9 + * Scroll to an element by ID, using a custom scroll function if provided, 10 + * otherwise falling back to default scroll behavior with header offset. 11 + * 12 + * @param id - The element ID to scroll to 13 + * @param options - Optional configuration for scroll behavior 14 + */ 15 + export function scrollToAnchor(id: string, options?: ScrollToAnchorOptions): void { 16 + const { scrollFn, updateUrl = true } = options ?? {} 17 + 18 + // Use custom scroll function if provided 19 + if (scrollFn) { 20 + scrollFn(id) 21 + return 22 + } 23 + 24 + // Fallback: scroll with header offset 25 + const element = document.getElementById(id) 26 + if (!element) return 27 + 28 + // Calculate scroll position with header offset (matches scroll-padding-top in main.css) 29 + const HEADER_OFFSET = 80 30 + const PKG_STICKY_HEADER_OFFSET = 52 31 + const elementTop = element.getBoundingClientRect().top + window.scrollY 32 + const targetScrollY = elementTop - (HEADER_OFFSET + PKG_STICKY_HEADER_OFFSET) 33 + 34 + // Use scrollTo for precise control 35 + window.scrollTo({ 36 + top: targetScrollY, 37 + behavior: 'smooth', 38 + }) 39 + 40 + // Update URL hash after initiating scroll 41 + // Use replaceState to avoid triggering native scroll-to-anchor behavior 42 + if (updateUrl) { 43 + history.replaceState(null, '', `#${id}`) 44 + } 45 + }
+2 -1
i18n/locales/en.json
··· 202 202 "readme": { 203 203 "title": "Readme", 204 204 "no_readme": "No README available.", 205 - "view_on_github": "View on GitHub" 205 + "view_on_github": "View on GitHub", 206 + "toc_title": "Outline" 206 207 }, 207 208 "keywords_title": "Keywords", 208 209 "compatibility": "Compatibility",
+2 -1
lunaria/files/en-US.json
··· 202 202 "readme": { 203 203 "title": "Readme", 204 204 "no_readme": "No README available.", 205 - "view_on_github": "View on GitHub" 205 + "view_on_github": "View on GitHub", 206 + "toc_title": "Outline" 206 207 }, 207 208 "keywords_title": "Keywords", 208 209 "compatibility": "Compatibility",
+2 -2
server/api/registry/readme/[...pkg].get.ts
··· 107 107 } 108 108 109 109 if (!readmeContent || readmeContent === NPM_MISSING_README_SENTINEL) { 110 - return { html: '', playgroundLinks: [] } 110 + return { html: '', playgroundLinks: [], toc: [] } 111 111 } 112 112 113 113 // Parse repository info for resolving relative URLs to GitHub ··· 126 126 swr: true, 127 127 getKey: event => { 128 128 const pkg = getRouterParam(event, 'pkg') ?? '' 129 - return `readme:v7:${pkg.replace(/\/+$/, '').trim()}` 129 + return `readme:v8:${pkg.replace(/\/+$/, '').trim()}` 130 130 }, 131 131 }, 132 132 )
+12 -2
server/utils/readme.ts
··· 1 1 import { marked, type Tokens } from 'marked' 2 2 import sanitizeHtml from 'sanitize-html' 3 3 import { hasProtocol } from 'ufo' 4 - import type { ReadmeResponse } from '#shared/types/readme' 4 + import type { ReadmeResponse, TocItem } from '#shared/types/readme' 5 5 import { convertBlobToRawUrl, type RepositoryInfo } from '#shared/utils/git-providers' 6 6 import { highlightCodeSync } from './shiki' 7 7 import { convertToEmoji } from '#shared/utils/emoji' ··· 268 268 packageName: string, 269 269 repoInfo?: RepositoryInfo, 270 270 ): Promise<ReadmeResponse> { 271 - if (!content) return { html: '', playgroundLinks: [] } 271 + if (!content) return { html: '', playgroundLinks: [], toc: [] } 272 272 273 273 const shiki = await getShikiHighlighter() 274 274 const renderer = new marked.Renderer() ··· 276 276 // Collect playground links during parsing 277 277 const collectedLinks: PlaygroundLink[] = [] 278 278 const seenUrls = new Set<string>() 279 + 280 + // Collect table of contents items during parsing 281 + const toc: TocItem[] = [] 279 282 280 283 // Track used heading slugs to handle duplicates (GitHub-style: foo, foo-1, foo-2) 281 284 const usedSlugs = new Map<string, number>() ··· 315 318 // Prefix with 'user-content-' to avoid collisions with page IDs 316 319 // (e.g., #install, #dependencies, #versions are used by the package page) 317 320 const id = `user-content-${uniqueSlug}` 321 + 322 + // Collect TOC item with plain text (HTML stripped) 323 + const plainText = text.replace(/<[^>]*>/g, '').trim() 324 + if (plainText) { 325 + toc.push({ text: plainText, id, depth }) 326 + } 318 327 319 328 return `<h${semanticLevel} id="${id}" data-level="${depth}">${text}</h${semanticLevel}>\n` 320 329 } ··· 433 442 return { 434 443 html: convertToEmoji(sanitized), 435 444 playgroundLinks: collectedLinks, 445 + toc, 436 446 } 437 447 }
+14
shared/types/readme.ts
··· 13 13 } 14 14 15 15 /** 16 + * Table of contents item extracted from README headings 17 + */ 18 + export interface TocItem { 19 + /** Plain text heading (HTML stripped) */ 20 + text: string 21 + /** Anchor ID (e.g., "user-content-installation") */ 22 + id: string 23 + /** Original heading depth (1-6) */ 24 + depth: number 25 + } 26 + 27 + /** 16 28 * Response from README API endpoint 17 29 */ 18 30 export interface ReadmeResponse { ··· 20 32 html: string 21 33 /** Extracted playground/demo links */ 22 34 playgroundLinks: PlaygroundLink[] 35 + /** Table of contents extracted from headings */ 36 + toc: TocItem[] 23 37 }
+49
test/nuxt/a11y.spec.ts
··· 110 110 PaginationControls, 111 111 ProvenanceBadge, 112 112 Readme, 113 + ReadmeTocDropdown, 113 114 SearchSuggestionCard, 114 115 SettingsAccentColorPicker, 115 116 SettingsBgThemePicker, ··· 1788 1789 props: { 1789 1790 html: '<h3>README</h3><p>Some content</p>', 1790 1791 }, 1792 + }) 1793 + const results = await runAxe(component) 1794 + expect(results.violations).toEqual([]) 1795 + }) 1796 + }) 1797 + 1798 + describe('ReadmeTocDropdown', () => { 1799 + const mockToc = [ 1800 + { text: 'Installation', id: 'installation', depth: 2 }, 1801 + { text: 'Usage', id: 'usage', depth: 2 }, 1802 + { text: 'Basic Usage', id: 'basic-usage', depth: 3 }, 1803 + { text: 'Advanced Usage', id: 'advanced-usage', depth: 3 }, 1804 + { text: 'API', id: 'api', depth: 2 }, 1805 + ] 1806 + 1807 + it('should have no accessibility violations', async () => { 1808 + const component = await mountSuspended(ReadmeTocDropdown, { 1809 + props: { toc: mockToc }, 1810 + }) 1811 + const results = await runAxe(component) 1812 + expect(results.violations).toEqual([]) 1813 + }) 1814 + 1815 + it('should have no accessibility violations with active item', async () => { 1816 + const component = await mountSuspended(ReadmeTocDropdown, { 1817 + props: { 1818 + toc: mockToc, 1819 + activeId: 'usage', 1820 + }, 1821 + }) 1822 + const results = await runAxe(component) 1823 + expect(results.violations).toEqual([]) 1824 + }) 1825 + 1826 + it('should have no accessibility violations with nested active item', async () => { 1827 + const component = await mountSuspended(ReadmeTocDropdown, { 1828 + props: { 1829 + toc: mockToc, 1830 + activeId: 'basic-usage', 1831 + }, 1832 + }) 1833 + const results = await runAxe(component) 1834 + expect(results.violations).toEqual([]) 1835 + }) 1836 + 1837 + it('should have no accessibility violations with empty toc', async () => { 1838 + const component = await mountSuspended(ReadmeTocDropdown, { 1839 + props: { toc: [] }, 1791 1840 }) 1792 1841 const results = await runAxe(component) 1793 1842 expect(results.violations).toEqual([])