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

feat: generate package docs using `@deno/doc` (#135)

authored by

Devon Wells and committed by
GitHub
8aae4f32 aa5c33a4

+2869 -23
+1
.npmrc
··· 1 + @jsr:registry=https://npm.jsr.io
+4 -1
app/components/AppHeader.vue
··· 14 14 </script> 15 15 16 16 <template> 17 - <header class="sticky top-0 z-50 bg-bg/80 backdrop-blur-md border-b border-border"> 17 + <header 18 + aria-label="Site header" 19 + class="sticky top-0 z-50 bg-bg/80 backdrop-blur-md border-b border-border" 20 + > 18 21 <nav aria-label="Main navigation" class="container h-14 flex items-center"> 19 22 <!-- Left: Logo --> 20 23 <div class="flex-shrink-0">
+221
app/components/DocsVersionSelector.vue
··· 1 + <script setup lang="ts"> 2 + import { onClickOutside } from '@vueuse/core' 3 + import { compare } from 'semver' 4 + 5 + const props = defineProps<{ 6 + packageName: string 7 + currentVersion: string 8 + versions: Record<string, unknown> 9 + distTags: Record<string, string> 10 + }>() 11 + 12 + const isOpen = ref(false) 13 + const dropdownRef = useTemplateRef('dropdownRef') 14 + const listboxRef = useTemplateRef('listboxRef') 15 + const focusedIndex = ref(-1) 16 + 17 + onClickOutside(dropdownRef, () => { 18 + isOpen.value = false 19 + }) 20 + 21 + /** Maximum number of versions to show in dropdown */ 22 + const MAX_VERSIONS = 10 23 + 24 + /** Safe version comparison that falls back to string comparison on error */ 25 + function safeCompareVersions(a: string, b: string): number { 26 + try { 27 + return compare(a, b) 28 + } catch { 29 + return a.localeCompare(b) 30 + } 31 + } 32 + 33 + /** Get sorted list of recent versions with their tags */ 34 + const recentVersions = computed(() => { 35 + const versionList = Object.keys(props.versions) 36 + .sort((a, b) => safeCompareVersions(b, a)) 37 + .slice(0, MAX_VERSIONS) 38 + 39 + // Create a map of version -> tags 40 + const versionTags = new Map<string, string[]>() 41 + for (const [tag, version] of Object.entries(props.distTags)) { 42 + const existing = versionTags.get(version) 43 + if (existing) { 44 + existing.push(tag) 45 + } else { 46 + versionTags.set(version, [tag]) 47 + } 48 + } 49 + 50 + return versionList.map(version => ({ 51 + version, 52 + tags: versionTags.get(version) ?? [], 53 + isCurrent: version === props.currentVersion, 54 + })) 55 + }) 56 + 57 + const latestVersion = computed(() => props.distTags.latest) 58 + 59 + function getDocsUrl(version: string): string { 60 + return `/docs/${props.packageName}/v/${version}` 61 + } 62 + 63 + function handleButtonKeydown(event: KeyboardEvent) { 64 + if (event.key === 'Escape') { 65 + isOpen.value = false 66 + } else if (event.key === 'ArrowDown' && !isOpen.value) { 67 + event.preventDefault() 68 + isOpen.value = true 69 + focusedIndex.value = 0 70 + } 71 + } 72 + 73 + function handleListboxKeydown(event: KeyboardEvent) { 74 + const items = recentVersions.value 75 + 76 + switch (event.key) { 77 + case 'Escape': 78 + isOpen.value = false 79 + break 80 + case 'ArrowDown': 81 + event.preventDefault() 82 + focusedIndex.value = Math.min(focusedIndex.value + 1, items.length - 1) 83 + scrollToFocused() 84 + break 85 + case 'ArrowUp': 86 + event.preventDefault() 87 + focusedIndex.value = Math.max(focusedIndex.value - 1, 0) 88 + scrollToFocused() 89 + break 90 + case 'Home': 91 + event.preventDefault() 92 + focusedIndex.value = 0 93 + scrollToFocused() 94 + break 95 + case 'End': 96 + event.preventDefault() 97 + focusedIndex.value = items.length - 1 98 + scrollToFocused() 99 + break 100 + case 'Enter': 101 + case ' ': 102 + event.preventDefault() 103 + if (focusedIndex.value >= 0 && focusedIndex.value < items.length) { 104 + navigateToVersion(items[focusedIndex.value]!.version) 105 + } 106 + break 107 + } 108 + } 109 + 110 + function scrollToFocused() { 111 + nextTick(() => { 112 + const focused = listboxRef.value?.querySelector('[data-focused="true"]') 113 + focused?.scrollIntoView({ block: 'nearest' }) 114 + }) 115 + } 116 + 117 + function navigateToVersion(version: string) { 118 + isOpen.value = false 119 + navigateTo(getDocsUrl(version)) 120 + } 121 + 122 + // Reset focused index when dropdown opens 123 + watch(isOpen, open => { 124 + if (open) { 125 + const currentIdx = recentVersions.value.findIndex(v => v.isCurrent) 126 + focusedIndex.value = currentIdx >= 0 ? currentIdx : 0 127 + } 128 + }) 129 + </script> 130 + 131 + <template> 132 + <div ref="dropdownRef" class="relative"> 133 + <button 134 + type="button" 135 + aria-haspopup="listbox" 136 + :aria-expanded="isOpen" 137 + class="flex items-center gap-1.5 text-fg-subtle font-mono text-sm hover:text-fg transition-[color] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-bg rounded" 138 + @click="isOpen = !isOpen" 139 + @keydown="handleButtonKeydown" 140 + > 141 + <span>{{ currentVersion }}</span> 142 + <span 143 + v-if="currentVersion === latestVersion" 144 + class="text-[10px] px-1.5 py-0.5 rounded bg-emerald-500/10 text-emerald-400 font-sans font-medium" 145 + > 146 + latest 147 + </span> 148 + <span 149 + class="i-carbon-chevron-down w-3.5 h-3.5 transition-[transform] duration-200 motion-reduce:transition-none" 150 + :class="{ 'rotate-180': isOpen }" 151 + aria-hidden="true" 152 + /> 153 + </button> 154 + 155 + <Transition 156 + enter-active-class="transition-[opacity,transform] duration-150 ease-out motion-reduce:transition-none" 157 + enter-from-class="opacity-0 scale-95" 158 + enter-to-class="opacity-100 scale-100" 159 + leave-active-class="transition-[opacity,transform] duration-100 ease-in motion-reduce:transition-none" 160 + leave-from-class="opacity-100 scale-100" 161 + leave-to-class="opacity-0 scale-95" 162 + > 163 + <div 164 + v-if="isOpen" 165 + ref="listboxRef" 166 + role="listbox" 167 + tabindex="0" 168 + :aria-activedescendant=" 169 + focusedIndex >= 0 ? `version-${recentVersions[focusedIndex]?.version}` : undefined 170 + " 171 + class="absolute top-full left-0 mt-2 min-w-[180px] bg-bg-elevated border border-border rounded-lg shadow-lg z-50 py-1 max-h-[300px] overflow-y-auto overscroll-contain focus-visible:outline-none" 172 + @keydown="handleListboxKeydown" 173 + > 174 + <NuxtLink 175 + v-for="({ version, tags, isCurrent }, index) in recentVersions" 176 + :id="`version-${version}`" 177 + :key="version" 178 + :to="getDocsUrl(version)" 179 + role="option" 180 + :aria-selected="isCurrent" 181 + :data-focused="index === focusedIndex" 182 + class="flex items-center justify-between gap-3 px-3 py-2 text-sm font-mono hover:bg-bg-muted transition-[color,background-color] focus-visible:outline-none focus-visible:bg-bg-muted" 183 + :class="[ 184 + isCurrent ? 'text-fg bg-bg-muted' : 'text-fg-muted', 185 + index === focusedIndex ? 'bg-bg-muted' : '', 186 + ]" 187 + @click="isOpen = false" 188 + > 189 + <span class="truncate">{{ version }}</span> 190 + <span v-if="tags.length > 0" class="flex items-center gap-1 shrink-0"> 191 + <span 192 + v-for="tag in tags" 193 + :key="tag" 194 + class="text-[10px] px-1.5 py-0.5 rounded font-sans font-medium" 195 + :class=" 196 + tag === 'latest' 197 + ? 'bg-emerald-500/10 text-emerald-400' 198 + : 'bg-bg-muted text-fg-subtle' 199 + " 200 + > 201 + {{ tag }} 202 + </span> 203 + </span> 204 + </NuxtLink> 205 + 206 + <div 207 + v-if="Object.keys(versions).length > MAX_VERSIONS" 208 + class="border-t border-border mt-1 pt-1 px-3 py-2" 209 + > 210 + <NuxtLink 211 + :to="`/${packageName}`" 212 + class="text-xs text-fg-subtle hover:text-fg transition-[color] focus-visible:outline-none focus-visible:text-fg" 213 + @click="isOpen = false" 214 + > 215 + View all {{ Object.keys(versions).length }} versions 216 + </NuxtLink> 217 + </div> 218 + </div> 219 + </Transition> 220 + </div> 221 + </template>
+19
app/pages/[...package].vue
··· 216 216 return homepage 217 217 }) 218 218 219 + // Docs URL: use our generated API docs 220 + const docsLink = computed(() => { 221 + if (!displayVersion.value) return null 222 + 223 + return { 224 + name: 'docs' as const, 225 + params: { path: [...pkg.value!.name.split('/'), 'v', displayVersion.value.version] }, 226 + } 227 + }) 228 + 219 229 function normalizeGitUrl(url: string): string { 220 230 return url 221 231 .replace(/^git\+/, '') ··· 723 733 <span class="i-simple-icons-jsr w-4 h-4" aria-hidden="true" /> 724 734 {{ $t('package.links.jsr') }} 725 735 </a> 736 + </li> 737 + <li v-if="docsLink"> 738 + <NuxtLink 739 + :to="docsLink" 740 + class="link-subtle font-mono text-sm inline-flex items-center gap-1.5" 741 + > 742 + <span class="i-carbon-document w-4 h-4" aria-hidden="true" /> 743 + {{ $t('package.links.docs') }} 744 + </NuxtLink> 726 745 </li> 727 746 <li v-if="displayVersion" class="sm:ml-auto"> 728 747 <NuxtLink
+448
app/pages/docs/[...path].vue
··· 1 + <script setup lang="ts"> 2 + import type { DocsResponse } from '#shared/types' 3 + import { assertValidPackageName } from '#shared/utils/npm' 4 + 5 + definePageMeta({ 6 + name: 'docs', 7 + }) 8 + 9 + const route = useRoute('docs') 10 + const router = useRouter() 11 + 12 + const parsedRoute = computed(() => { 13 + const segments = route.params.path || [] 14 + const vIndex = segments.indexOf('v') 15 + 16 + if (vIndex === -1 || vIndex >= segments.length - 1) { 17 + return { 18 + packageName: segments.join('/'), 19 + version: null as string | null, 20 + } 21 + } 22 + 23 + return { 24 + packageName: segments.slice(0, vIndex).join('/'), 25 + version: segments.slice(vIndex + 1).join('/'), 26 + } 27 + }) 28 + 29 + const packageName = computed(() => parsedRoute.value.packageName) 30 + const requestedVersion = computed(() => parsedRoute.value.version) 31 + 32 + // Validate package name on server-side for early error detection 33 + if (import.meta.server && packageName.value) { 34 + assertValidPackageName(packageName.value) 35 + } 36 + 37 + const { data: pkg } = usePackage(packageName) 38 + 39 + const latestVersion = computed(() => pkg.value?.['dist-tags']?.latest ?? null) 40 + 41 + watch( 42 + [requestedVersion, latestVersion, packageName], 43 + ([version, latest, name]) => { 44 + if (!version && latest && name) { 45 + router.replace(`/docs/${name}/v/${latest}`) 46 + } 47 + }, 48 + { immediate: true }, 49 + ) 50 + 51 + const resolvedVersion = computed(() => requestedVersion.value ?? latestVersion.value) 52 + 53 + const docsUrl = computed(() => { 54 + if (!packageName.value || !resolvedVersion.value) return null 55 + return `/api/registry/docs/${packageName.value}/v/${resolvedVersion.value}` 56 + }) 57 + 58 + const shouldFetch = computed(() => !!docsUrl.value) 59 + 60 + const { data: docsData, status: docsStatus } = useLazyFetch<DocsResponse>( 61 + () => docsUrl.value ?? '', 62 + { 63 + watch: [docsUrl], 64 + immediate: shouldFetch.value, 65 + default: () => ({ 66 + package: packageName.value, 67 + version: resolvedVersion.value ?? '', 68 + html: '', 69 + toc: null, 70 + status: 'missing' as const, 71 + message: 'Docs are not available for this version.', 72 + }), 73 + }, 74 + ) 75 + 76 + const pageTitle = computed(() => { 77 + if (!packageName.value) return 'API Docs - npmx' 78 + if (!resolvedVersion.value) return `${packageName.value} docs - npmx` 79 + return `${packageName.value}@${resolvedVersion.value} docs - npmx` 80 + }) 81 + 82 + useSeoMeta({ 83 + title: () => pageTitle.value, 84 + }) 85 + 86 + const showLoading = computed(() => docsStatus.value === 'pending') 87 + const showEmptyState = computed(() => docsData.value?.status !== 'ok') 88 + </script> 89 + 90 + <template> 91 + <div class="docs-page min-h-screen"> 92 + <!-- Visually hidden h1 for accessibility --> 93 + <h1 class="sr-only">{{ packageName }} API Documentation</h1> 94 + 95 + <!-- Sticky header - positioned below AppHeader --> 96 + <header 97 + aria-label="Package documentation header" 98 + class="docs-header sticky z-10 bg-bg/95 backdrop-blur border-b border-border" 99 + > 100 + <div class="px-4 sm:px-6 lg:px-8 py-4"> 101 + <div class="flex items-center justify-between gap-4"> 102 + <div class="flex items-center gap-3 min-w-0"> 103 + <NuxtLink 104 + v-if="packageName" 105 + :to="`/${packageName}`" 106 + class="font-mono text-lg sm:text-xl font-semibold text-fg hover:text-fg-muted transition-colors truncate" 107 + > 108 + {{ packageName }} 109 + </NuxtLink> 110 + <DocsVersionSelector 111 + v-if="resolvedVersion && pkg?.versions && pkg?.['dist-tags']" 112 + :package-name="packageName" 113 + :current-version="resolvedVersion" 114 + :versions="pkg.versions" 115 + :dist-tags="pkg['dist-tags']" 116 + /> 117 + <span v-else-if="resolvedVersion" class="text-fg-subtle font-mono text-sm shrink-0"> 118 + {{ resolvedVersion }} 119 + </span> 120 + </div> 121 + <div class="flex items-center gap-3 shrink-0"> 122 + <span 123 + class="text-xs px-2 py-1 rounded bg-emerald-500/10 text-emerald-400 border border-emerald-500/20" 124 + > 125 + API Docs 126 + </span> 127 + </div> 128 + </div> 129 + </div> 130 + </header> 131 + 132 + <div class="flex"> 133 + <!-- Sidebar TOC --> 134 + <aside 135 + v-if="docsData?.toc && !showEmptyState" 136 + class="hidden lg:block w-64 xl:w-72 shrink-0 border-r border-border" 137 + > 138 + <div class="docs-sidebar sticky overflow-y-auto p-4"> 139 + <h2 class="text-xs font-semibold text-fg-subtle uppercase tracking-wider mb-4"> 140 + Contents 141 + </h2> 142 + <!-- eslint-disable vue/no-v-html --> 143 + <div class="toc-content" v-html="docsData.toc" /> 144 + </div> 145 + </aside> 146 + 147 + <!-- Main content --> 148 + <main class="flex-1 min-w-0"> 149 + <div v-if="showLoading" class="p-6 sm:p-8 lg:p-12 space-y-4"> 150 + <div class="skeleton h-8 w-64 rounded" /> 151 + <div class="skeleton h-4 w-full max-w-2xl rounded" /> 152 + <div class="skeleton h-4 w-5/6 max-w-2xl rounded" /> 153 + <div class="skeleton h-4 w-3/4 max-w-2xl rounded" /> 154 + </div> 155 + 156 + <div v-else-if="showEmptyState" class="p-6 sm:p-8 lg:p-12"> 157 + <div class="max-w-xl rounded-lg border border-border bg-bg-muted p-6"> 158 + <h2 class="font-mono text-lg mb-2">Docs not available</h2> 159 + <p class="text-fg-subtle text-sm"> 160 + {{ docsData?.message ?? 'We could not generate docs for this version.' }} 161 + </p> 162 + <div class="flex gap-4 mt-4"> 163 + <NuxtLink 164 + v-if="packageName" 165 + :to="`/${packageName}`" 166 + class="link-subtle font-mono text-sm" 167 + > 168 + View package 169 + </NuxtLink> 170 + </div> 171 + </div> 172 + </div> 173 + 174 + <!-- eslint-disable vue/no-v-html --> 175 + <div v-else class="docs-content p-6 sm:p-8 lg:p-12" v-html="docsData?.html" /> 176 + </main> 177 + </div> 178 + </div> 179 + </template> 180 + 181 + <style> 182 + /* Layout constants - must match AppHeader height */ 183 + .docs-page { 184 + --app-header-height: 57px; 185 + --docs-header-height: 57px; 186 + --combined-header-height: calc(var(--app-header-height) + var(--docs-header-height)); 187 + } 188 + 189 + .docs-header { 190 + top: var(--app-header-height); 191 + } 192 + 193 + .docs-sidebar { 194 + top: var(--combined-header-height); 195 + height: calc(100vh - var(--combined-header-height)); 196 + } 197 + 198 + /* Table of contents styles */ 199 + .toc-content ul { 200 + @apply space-y-1; 201 + } 202 + 203 + .toc-content > ul > li { 204 + @apply mb-4; 205 + } 206 + 207 + .toc-content > ul > li > a { 208 + @apply text-sm font-medium text-fg-muted hover:text-fg; 209 + } 210 + 211 + .toc-content > ul > li > ul { 212 + @apply mt-2 pl-3 border-l border-border/50; 213 + } 214 + 215 + .toc-content > ul > li > ul a { 216 + @apply text-xs text-fg-subtle hover:text-fg block py-0.5 truncate; 217 + } 218 + 219 + /* Main docs content container - no max-width to use full space */ 220 + .docs-content { 221 + @apply max-w-none; 222 + } 223 + 224 + /* Section headings */ 225 + .docs-content .docs-section { 226 + @apply mb-16; 227 + } 228 + 229 + .docs-content .docs-section-title { 230 + @apply text-lg font-semibold text-fg mb-8 pb-3 pt-4 border-b border-border sticky bg-bg z-[2]; 231 + top: var(--combined-header-height); 232 + } 233 + 234 + /* Individual symbol articles */ 235 + .docs-content .docs-symbol { 236 + @apply mb-10 pb-10 border-b border-border/30 last:border-0; 237 + } 238 + 239 + .docs-content .docs-symbol:target { 240 + @apply scroll-mt-32; 241 + } 242 + 243 + .docs-content .docs-symbol:target .docs-symbol-header { 244 + @apply bg-amber-500/10 -mx-3 px-3 py-1 rounded-md; 245 + } 246 + 247 + /* Symbol header (name + badges) */ 248 + .docs-content .docs-symbol-header { 249 + @apply flex items-center gap-3 mb-4 flex-wrap; 250 + } 251 + 252 + .docs-content .docs-anchor { 253 + @apply text-fg-subtle/50 hover:text-fg-subtle transition-colors text-lg no-underline; 254 + } 255 + 256 + .docs-content .docs-symbol-name { 257 + @apply font-mono text-lg font-semibold text-fg m-0; 258 + } 259 + 260 + /* Badges */ 261 + .docs-content .docs-badge { 262 + @apply text-xs px-2 py-0.5 rounded-full font-medium; 263 + } 264 + 265 + .docs-content .docs-badge--function { 266 + @apply bg-blue-500/15 text-blue-400; 267 + } 268 + .docs-content .docs-badge--class { 269 + @apply bg-amber-500/15 text-amber-400; 270 + } 271 + .docs-content .docs-badge--interface { 272 + @apply bg-emerald-500/15 text-emerald-400; 273 + } 274 + .docs-content .docs-badge--typeAlias { 275 + @apply bg-violet-500/15 text-violet-400; 276 + } 277 + .docs-content .docs-badge--variable { 278 + @apply bg-orange-500/15 text-orange-400; 279 + } 280 + .docs-content .docs-badge--enum { 281 + @apply bg-pink-500/15 text-pink-400; 282 + } 283 + .docs-content .docs-badge--namespace { 284 + @apply bg-cyan-500/15 text-cyan-400; 285 + } 286 + .docs-content .docs-badge--async { 287 + @apply bg-purple-500/15 text-purple-400; 288 + } 289 + 290 + /* Signature code block - now uses Shiki */ 291 + .docs-content .docs-signature { 292 + @apply mb-5; 293 + } 294 + 295 + .docs-content .docs-signature .shiki { 296 + @apply text-sm bg-bg-muted/50 border border-border/50 p-4 rounded-lg; 297 + white-space: pre-wrap; 298 + word-break: break-word; 299 + } 300 + 301 + .docs-content .docs-signature .shiki code { 302 + @apply text-sm; 303 + white-space: pre-wrap; 304 + } 305 + 306 + /* Overload count badge */ 307 + .docs-content .docs-overload-count { 308 + @apply text-xs text-fg-subtle; 309 + } 310 + 311 + /* More overloads indicator */ 312 + .docs-content .docs-more-overloads { 313 + @apply text-xs text-fg-subtle italic mt-2 mb-0; 314 + } 315 + 316 + /* Description text */ 317 + .docs-content .docs-description { 318 + @apply text-sm text-fg-muted leading-relaxed mb-5; 319 + } 320 + 321 + /* Inline code in descriptions */ 322 + .docs-content .docs-description code { 323 + @apply bg-bg-muted px-1.5 py-0.5 rounded text-xs font-mono; 324 + } 325 + 326 + /* 327 + * Fenced code blocks in descriptions use a subtle left-border style. 328 + * 329 + * Design rationale: We use two visual styles for code examples: 330 + * 1. Boxed style (bg + border + padding) - for formal @example JSDoc tags 331 + * and function signatures. These are intentional, structured sections. 332 + * 2. Left-border style (blockquote-like) - for inline code in descriptions. 333 + * These are illustrative/casual and shouldn't compete with the signature. 334 + */ 335 + .docs-content .docs-description .shiki { 336 + @apply text-sm pl-4 py-3 my-4 border-l-2 border-border; 337 + white-space: pre-wrap; 338 + word-break: break-word; 339 + } 340 + 341 + .docs-content .docs-description .shiki code { 342 + @apply text-sm bg-transparent p-0; 343 + white-space: pre-wrap; 344 + } 345 + 346 + /* Deprecation warning */ 347 + .docs-content .docs-deprecated { 348 + @apply bg-amber-500/10 border border-amber-500/20 rounded-lg p-4 mb-5; 349 + } 350 + 351 + .docs-content .docs-deprecated strong { 352 + @apply text-amber-400 text-sm; 353 + } 354 + 355 + .docs-content .docs-deprecated p { 356 + @apply text-amber-300/80 text-sm mt-2 mb-0; 357 + } 358 + 359 + /* Parameters, Returns, Examples, See Also sections */ 360 + .docs-content .docs-params, 361 + .docs-content .docs-returns, 362 + .docs-content .docs-examples, 363 + .docs-content .docs-see, 364 + .docs-content .docs-members { 365 + @apply mb-5; 366 + } 367 + 368 + .docs-content .docs-params h4, 369 + .docs-content .docs-returns h4, 370 + .docs-content .docs-examples h4, 371 + .docs-content .docs-see h4, 372 + .docs-content .docs-members h4 { 373 + @apply text-xs font-semibold text-fg-subtle uppercase tracking-wider mb-3; 374 + } 375 + 376 + /* Definition lists for params/members */ 377 + .docs-content dl { 378 + @apply space-y-2; 379 + } 380 + 381 + .docs-content dt { 382 + @apply font-mono text-sm text-fg-muted; 383 + } 384 + 385 + .docs-content dd { 386 + @apply text-sm text-fg-subtle ml-4 mb-3; 387 + } 388 + 389 + /* Returns paragraph */ 390 + .docs-content .docs-returns p { 391 + @apply text-sm text-fg-muted m-0; 392 + } 393 + 394 + /* Example code blocks from @example JSDoc tags - boxed style (see design rationale above) */ 395 + .docs-content .docs-examples .shiki { 396 + @apply text-sm bg-bg-muted border border-border/50 p-4 rounded-lg overflow-x-auto mb-3; 397 + } 398 + 399 + .docs-content .docs-examples .shiki code { 400 + @apply text-sm; 401 + } 402 + 403 + /* See also list */ 404 + .docs-content .docs-see ul { 405 + @apply list-disc list-inside text-sm text-fg-muted space-y-1; 406 + } 407 + 408 + .docs-content .docs-link { 409 + @apply text-blue-400 hover:text-blue-300 underline underline-offset-2; 410 + } 411 + 412 + /* Symbol cross-reference links */ 413 + .docs-content .docs-symbol-link { 414 + @apply text-emerald-400 hover:text-emerald-300 underline underline-offset-2; 415 + } 416 + 417 + /* Unknown symbol references shown as code */ 418 + .docs-content .docs-symbol-ref { 419 + @apply bg-bg-muted px-1.5 py-0.5 rounded text-xs font-mono; 420 + } 421 + 422 + /* Inline code in descriptions */ 423 + .docs-content .docs-inline-code { 424 + @apply bg-bg-muted px-1.5 py-0.5 rounded text-xs font-mono; 425 + } 426 + 427 + /* Enum members */ 428 + .docs-content .docs-enum-members { 429 + @apply flex flex-wrap gap-2 list-none p-0; 430 + } 431 + 432 + .docs-content .docs-enum-members li { 433 + @apply m-0; 434 + } 435 + 436 + .docs-content .docs-enum-members code { 437 + @apply text-sm font-mono text-fg-muted bg-bg-muted px-2 py-1 rounded; 438 + } 439 + 440 + /* Members section (constructors, properties, methods) */ 441 + .docs-content .docs-members pre { 442 + @apply text-sm bg-bg-muted/50 border border-border/50 p-3 rounded-lg overflow-x-auto font-mono; 443 + } 444 + 445 + .docs-content .docs-members pre code { 446 + @apply text-fg-muted; 447 + } 448 + </style>
+2 -1
i18n/locales/en.json
··· 101 101 "issues": "issues", 102 102 "forks": "fork | forks", 103 103 "jsr": "jsr", 104 - "code": "code" 104 + "code": "code", 105 + "docs": "docs" 105 106 }, 106 107 "install": { 107 108 "title": "Install",
+11
nuxt.config.ts
··· 93 93 compatibilityDate: '2024-04-03', 94 94 95 95 nitro: { 96 + experimental: { 97 + wasm: true, 98 + }, 96 99 externals: { 97 100 inline: [ 98 101 'shiki', ··· 102 105 '@shikijs/engine-javascript', 103 106 '@shikijs/core', 104 107 ], 108 + external: ['@deno/doc'], 109 + }, 110 + rollupConfig: { 111 + output: { 112 + paths: { 113 + '@deno/doc': '@jsr/deno__doc', 114 + }, 115 + }, 105 116 }, 106 117 // Storage configuration for local development 107 118 // In production (Vercel), this is overridden by modules/cache.ts
+6
package.json
··· 27 27 "test:unit": "vite test --project unit" 28 28 }, 29 29 "dependencies": { 30 + "@deno/doc": "jsr:^0.189.1", 30 31 "@iconify-json/simple-icons": "^1.2.67", 31 32 "@iconify-json/vscode-icons": "^1.2.40", 32 33 "@nuxt/a11y": "1.0.0-alpha.1", ··· 84 85 "vite-plus": "latest", 85 86 "vitest": "npm:@voidzero-dev/vite-plus-test@latest", 86 87 "vue-tsc": "3.2.2" 88 + }, 89 + "pnpm": { 90 + "patchedDependencies": { 91 + "@jsr/deno__doc@0.189.1": "patches/@jsr__deno__doc@0.189.1.patch" 92 + } 87 93 }, 88 94 "simple-git-hooks": { 89 95 "pre-commit": "npx lint-staged"
+43
patches/@jsr__deno__doc@0.189.1.patch
··· 1 + diff --git a/deno_doc_wasm.generated.js b/deno_doc_wasm.generated.js 2 + index 12add85709ae8a235058c751c397893693f52a6a..da235f2e0ce35b9fc012a51495a15c84d024e434 100755 3 + --- a/deno_doc_wasm.generated.js 4 + +++ b/deno_doc_wasm.generated.js 5 + @@ -1012,13 +1012,18 @@ class WasmBuildLoader { 6 + 7 + // make file urls work in Node via dnt 8 + const isNode = globalThis.process?.versions?.node != null; 9 + - if (isFile && typeof Deno !== "object") { 10 + - throw new Error( 11 + - "Loading local files are not supported in this environment", 12 + + if (isNode && isFile) { 13 + + // Use Node.js fs module to read the file (patched for Node.js support) 14 + + const { readFileSync } = await import("node:fs"); 15 + + const { fileURLToPath } = await import("node:url"); 16 + + const wasmCode = readFileSync(fileURLToPath(url)); 17 + + return WebAssembly.instantiate( 18 + + decompress ? decompress(wasmCode) : wasmCode, 19 + + imports, 20 + ); 21 + } 22 + - if (isNode && isFile) { 23 + - // the deno global will be shimmed by dnt 24 + + if (isFile && typeof Deno === "object") { 25 + + // Deno environment 26 + const wasmCode = await Deno.readFile(url); 27 + return WebAssembly.instantiate( 28 + decompress ? decompress(wasmCode) : wasmCode, 29 + diff --git a/mod.js b/mod.js 30 + index f9d9de1349c2bb575b29138a5fc54654e92c4a47..025ecdb580b52160f709b32bb5db9ecf481e70ce 100755 31 + --- a/mod.js 32 + +++ b/mod.js 33 + @@ -1,8 +1,8 @@ 34 + // Copyright 2018-2024 the Deno authors. All rights reserved. MIT license. 35 + import { instantiate } from "./deno_doc_wasm.generated.js"; 36 + import { createCache } from "@jsr/deno__cache-dir"; 37 + -export * from "./types.d.ts"; 38 + -export * from "./html_types.d.ts"; 39 + +// Removed: export * from "./types.d.ts"; (incompatible with Node.js/Rollup) 40 + +// Removed: export * from "./html_types.d.ts"; (incompatible with Node.js/Rollup) 41 + const encoder = new TextEncoder(); 42 + /** 43 + * Generate asynchronously an array of documentation nodes for the supplied
+74
pnpm-lock.yaml
··· 11 11 12 12 packageExtensionsChecksum: sha256-MLpDvxkp40Q0pRGkcNzUeHJyjDQFfGfxm/iGkXRnXJg= 13 13 14 + patchedDependencies: 15 + '@jsr/deno__doc@0.189.1': 16 + hash: 24f326e123c822a07976329a5afe91a8713e82d53134b5586625b72431c87832 17 + path: patches/@jsr__deno__doc@0.189.1.patch 18 + 14 19 importers: 15 20 16 21 .: 17 22 dependencies: 23 + '@deno/doc': 24 + specifier: jsr:^0.189.1 25 + version: '@jsr/deno__doc@0.189.1(patch_hash=24f326e123c822a07976329a5afe91a8713e82d53134b5586625b72431c87832)' 18 26 '@iconify-json/simple-icons': 19 27 specifier: ^1.2.67 20 28 version: 1.2.68 ··· 1530 1538 1531 1539 '@jsdevtools/ono@7.1.3': 1532 1540 resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} 1541 + 1542 + '@jsr/deno__cache-dir@0.25.0': 1543 + resolution: {integrity: sha512-J5/kPR8sc+dsRyE8rnLPhqay7B4XgBmkv7QaeIV41sGibszQjIb8b599LssN1YWBbqPLakPRMVobg5u6LheWfg==, tarball: https://npm.jsr.io/~/11/@jsr/deno__cache-dir/0.25.0.tgz} 1544 + 1545 + '@jsr/deno__doc@0.189.1': 1546 + resolution: {integrity: sha512-GtlJz3nGX1zF+XBG5pEPYdRbo5feyP3eM1+zsEqFqjfvT/nfRdjyGNogEs4yd+DW8cD7tQceRd2vypDhG1X7kA==, tarball: https://npm.jsr.io/~/11/@jsr/deno__doc/0.189.1.tgz} 1547 + 1548 + '@jsr/deno__graph@0.100.1': 1549 + resolution: {integrity: sha512-mPemftpdwtz8fo+RNKujjXKpDQRcj5E0nOcxeNiDGLRzqQk/Q69IcGM4ruZGX+0xhTNNSLc8Uu3W4ynIDfTR6g==, tarball: https://npm.jsr.io/~/11/@jsr/deno__graph/0.100.1.tgz} 1550 + 1551 + '@jsr/deno__graph@0.86.9': 1552 + resolution: {integrity: sha512-+qrrma5/bL+hcG20mfaEeC8SLopqoyd1RjcKFMRu++3SAXyrTKuvuIjBJCn/NyN7X+kV+QrJG67BCHX38Rzw+g==, tarball: https://npm.jsr.io/~/11/@jsr/deno__graph/0.86.9.tgz} 1553 + 1554 + '@jsr/std__bytes@1.0.6': 1555 + resolution: {integrity: sha512-St6yKggjFGhxS52IFLJWvkchRFbAKg2Xh8UxA4S1EGz7GJ2Ui+ssDDldj/w2c8vCxvl6qgR0HaYbKeFJNqujmA==, tarball: https://npm.jsr.io/~/11/@jsr/std__bytes/1.0.6.tgz} 1556 + 1557 + '@jsr/std__fmt@1.0.9': 1558 + resolution: {integrity: sha512-YFJJMozmORj2K91c5J9opWeh0VUwrd+Mwb7Pr0FkVCAKVLu2UhT4LyvJqWiyUT+eF+MdfqQ9F7RtQj4bXn9Smw==, tarball: https://npm.jsr.io/~/11/@jsr/std__fmt/1.0.9.tgz} 1559 + 1560 + '@jsr/std__fs@1.0.22': 1561 + resolution: {integrity: sha512-PvDtgT25IqhFEX2LjQI0aTz/Wg61jCtJ8l19fE9MUSvSmtw57Kzr6sM7GcCsSrsZEdQ7wjLfXvvhy8irta4Zww==, tarball: https://npm.jsr.io/~/11/@jsr/std__fs/1.0.22.tgz} 1562 + 1563 + '@jsr/std__internal@1.0.12': 1564 + resolution: {integrity: sha512-6xReMW9p+paJgqoFRpOE2nogJFvzPfaLHLIlyADYjKMUcwDyjKZxryIbgcU+gxiTygn8yCjld1HoI0ET4/iZeA==, tarball: https://npm.jsr.io/~/11/@jsr/std__internal/1.0.12.tgz} 1565 + 1566 + '@jsr/std__io@0.225.2': 1567 + resolution: {integrity: sha512-QNImMbao6pKXvV4xpFkY2zviZi1r+1KpYzgMqaa2gHDPZhXQqlia/Og+VqMxxfAr8Pw6BF3tw/hSw3LrWWTRmA==, tarball: https://npm.jsr.io/~/11/@jsr/std__io/0.225.2.tgz} 1568 + 1569 + '@jsr/std__path@1.1.4': 1570 + resolution: {integrity: sha512-SK4u9H6NVTfolhPdlvdYXfNFefy1W04AEHWJydryYbk+xqzNiVmr5o7TLJLJFqwHXuwMRhwrn+mcYeUfS0YFaA==, tarball: https://npm.jsr.io/~/11/@jsr/std__path/1.1.4.tgz} 1533 1571 1534 1572 '@kwsites/file-exists@1.1.1': 1535 1573 resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} ··· 10176 10214 '@jridgewell/sourcemap-codec': 1.5.5 10177 10215 10178 10216 '@jsdevtools/ono@7.1.3': {} 10217 + 10218 + '@jsr/deno__cache-dir@0.25.0': 10219 + dependencies: 10220 + '@jsr/deno__graph': 0.86.9 10221 + '@jsr/std__fmt': 1.0.9 10222 + '@jsr/std__fs': 1.0.22 10223 + '@jsr/std__io': 0.225.2 10224 + '@jsr/std__path': 1.1.4 10225 + 10226 + '@jsr/deno__doc@0.189.1(patch_hash=24f326e123c822a07976329a5afe91a8713e82d53134b5586625b72431c87832)': 10227 + dependencies: 10228 + '@jsr/deno__cache-dir': 0.25.0 10229 + '@jsr/deno__graph': 0.100.1 10230 + 10231 + '@jsr/deno__graph@0.100.1': {} 10232 + 10233 + '@jsr/deno__graph@0.86.9': {} 10234 + 10235 + '@jsr/std__bytes@1.0.6': {} 10236 + 10237 + '@jsr/std__fmt@1.0.9': {} 10238 + 10239 + '@jsr/std__fs@1.0.22': 10240 + dependencies: 10241 + '@jsr/std__internal': 1.0.12 10242 + '@jsr/std__path': 1.1.4 10243 + 10244 + '@jsr/std__internal@1.0.12': {} 10245 + 10246 + '@jsr/std__io@0.225.2': 10247 + dependencies: 10248 + '@jsr/std__bytes': 1.0.6 10249 + 10250 + '@jsr/std__path@1.1.4': 10251 + dependencies: 10252 + '@jsr/std__internal': 1.0.12 10179 10253 10180 10254 '@kwsites/file-exists@1.1.1': 10181 10255 dependencies:
+70
server/api/registry/docs/[...pkg].get.ts
··· 1 + import type { DocsResponse } from '#shared/types' 2 + import { fetchNpmPackage } from '#server/utils/npm' 3 + import { assertValidPackageName } from '#shared/utils/npm' 4 + import { parsePackageParam } from '#shared/utils/parse-package-param' 5 + import { generateDocsWithDeno } from '#server/utils/docs' 6 + 7 + export default defineCachedEventHandler( 8 + async event => { 9 + const pkgParam = getRouterParam(event, 'pkg') 10 + if (!pkgParam) { 11 + throw createError({ statusCode: 400, message: 'Package name is required' }) 12 + } 13 + 14 + const { packageName, version: requestedVersion } = parsePackageParam(pkgParam) 15 + 16 + if (!packageName) { 17 + throw createError({ statusCode: 400, message: 'Package name is required' }) 18 + } 19 + assertValidPackageName(packageName) 20 + 21 + const packument = await fetchNpmPackage(packageName) 22 + const version = requestedVersion ?? packument['dist-tags']?.latest 23 + 24 + if (!version) { 25 + throw createError({ statusCode: 404, message: 'No latest version found' }) 26 + } 27 + 28 + let generated 29 + try { 30 + generated = await generateDocsWithDeno(packageName, version) 31 + } catch (error) { 32 + console.error(`Doc generation failed for ${packageName}@${version}:`, error) 33 + return { 34 + package: packageName, 35 + version, 36 + html: '', 37 + toc: null, 38 + status: 'error', 39 + message: 'Failed to generate documentation. Please try again later.', 40 + } satisfies DocsResponse 41 + } 42 + 43 + if (!generated) { 44 + return { 45 + package: packageName, 46 + version, 47 + html: '', 48 + toc: null, 49 + status: 'missing', 50 + message: 'Docs are not available for this package. It may not have TypeScript types.', 51 + } satisfies DocsResponse 52 + } 53 + 54 + return { 55 + package: packageName, 56 + version, 57 + html: generated.html, 58 + toc: generated.toc, 59 + status: 'ok', 60 + } satisfies DocsResponse 61 + }, 62 + { 63 + maxAge: 60 * 60, // 1 hour cache 64 + swr: true, 65 + getKey: event => { 66 + const pkg = getRouterParam(event, 'pkg') ?? '' 67 + return `docs:v1:${pkg}` 68 + }, 69 + }, 70 + )
+3
server/utils/code-highlight.ts
··· 268 268 theme: 'github-dark', 269 269 }) 270 270 271 + // Shiki doesn't encode > in text content (e.g., arrow functions) 272 + html = escapeRawGt(html) 273 + 271 274 // Make import statements clickable for JS/TS languages 272 275 if (IMPORT_LANGUAGES.has(language)) { 273 276 html = linkifyImports(html, {
+173
server/utils/docs/client.ts
··· 1 + /** 2 + * Deno Integration (WASM) 3 + * 4 + * Uses @deno/doc (WASM build of deno_doc) for documentation generation. 5 + * This runs entirely in Node.js without requiring a Deno subprocess. 6 + * 7 + * @module server/utils/docs/client 8 + */ 9 + 10 + import { doc } from '@deno/doc' 11 + import type { DenoDocNode, DenoDocResult } from '#shared/types/deno-doc' 12 + 13 + // ============================================================================= 14 + // Configuration 15 + // ============================================================================= 16 + 17 + /** Timeout for fetching modules in milliseconds */ 18 + const FETCH_TIMEOUT_MS = 30 * 1000 19 + 20 + // ============================================================================= 21 + // Main Export 22 + // ============================================================================= 23 + 24 + /** 25 + * Get documentation nodes for a package using @deno/doc WASM. 26 + */ 27 + export async function getDocNodes(packageName: string, version: string): Promise<DenoDocResult> { 28 + // Get types URL from esm.sh header 29 + const typesUrl = await getTypesUrl(packageName, version) 30 + 31 + if (!typesUrl) { 32 + return { version: 1, nodes: [] } 33 + } 34 + 35 + // Generate docs using @deno/doc WASM 36 + const result = await doc([typesUrl], { 37 + load: createLoader(), 38 + resolve: createResolver(), 39 + }) 40 + 41 + // Collect all nodes from all specifiers 42 + const allNodes: DenoDocNode[] = [] 43 + for (const nodes of Object.values(result)) { 44 + allNodes.push(...(nodes as DenoDocNode[])) 45 + } 46 + 47 + return { version: 1, nodes: allNodes } 48 + } 49 + 50 + // ============================================================================= 51 + // Module Loading 52 + // ============================================================================= 53 + 54 + /** Load response for the doc() function */ 55 + interface LoadResponse { 56 + kind: 'module' 57 + specifier: string 58 + headers?: Record<string, string> 59 + content: string 60 + } 61 + 62 + /** 63 + * Create a custom module loader for @deno/doc. 64 + * 65 + * Fetches modules from URLs using fetch(), with proper timeout handling. 66 + */ 67 + function createLoader(): ( 68 + specifier: string, 69 + isDynamic?: boolean, 70 + cacheSetting?: string, 71 + checksum?: string, 72 + ) => Promise<LoadResponse | undefined> { 73 + return async ( 74 + specifier: string, 75 + _isDynamic?: boolean, 76 + _cacheSetting?: string, 77 + _checksum?: string, 78 + ) => { 79 + let url: URL 80 + try { 81 + url = new URL(specifier) 82 + } catch { 83 + return undefined 84 + } 85 + 86 + // Only handle http/https URLs 87 + if (url.protocol !== 'http:' && url.protocol !== 'https:') { 88 + return undefined 89 + } 90 + 91 + const controller = new AbortController() 92 + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) 93 + 94 + try { 95 + const response = await fetch(url.toString(), { 96 + redirect: 'follow', 97 + signal: controller.signal, 98 + }) 99 + clearTimeout(timeoutId) 100 + 101 + if (response.status !== 200) { 102 + return undefined 103 + } 104 + 105 + const content = await response.text() 106 + const headers: Record<string, string> = {} 107 + for (const [key, value] of response.headers) { 108 + headers[key.toLowerCase()] = value 109 + } 110 + 111 + return { 112 + kind: 'module', 113 + specifier: response.url, 114 + headers, 115 + content, 116 + } 117 + } catch { 118 + clearTimeout(timeoutId) 119 + return undefined 120 + } 121 + } 122 + } 123 + 124 + /** 125 + * Create a module resolver for @deno/doc. 126 + * 127 + * Handles resolving relative imports and esm.sh redirects. 128 + */ 129 + function createResolver(): (specifier: string, referrer: string) => string { 130 + return (specifier: string, referrer: string) => { 131 + // Handle relative imports 132 + if (specifier.startsWith('.') || specifier.startsWith('/')) { 133 + return new URL(specifier, referrer).toString() 134 + } 135 + 136 + // Handle bare specifiers - resolve through esm.sh 137 + if (!specifier.startsWith('http://') && !specifier.startsWith('https://')) { 138 + // Try to resolve bare specifier relative to esm.sh base 139 + const baseUrl = new URL(referrer) 140 + if (baseUrl.hostname === 'esm.sh') { 141 + return `https://esm.sh/${specifier}` 142 + } 143 + } 144 + 145 + return specifier 146 + } 147 + } 148 + 149 + /** 150 + * Get the TypeScript types URL from esm.sh's x-typescript-types header. 151 + * 152 + * esm.sh serves types URL in the `x-typescript-types` header, not at the main URL. 153 + * Example: curl -sI 'https://esm.sh/ufo@1.5.0' returns header: 154 + * x-typescript-types: https://esm.sh/ufo@1.5.0/dist/index.d.ts 155 + */ 156 + async function getTypesUrl(packageName: string, version: string): Promise<string | null> { 157 + const url = `https://esm.sh/${packageName}@${version}` 158 + 159 + const controller = new AbortController() 160 + const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS) 161 + 162 + try { 163 + const response = await fetch(url, { 164 + method: 'HEAD', 165 + signal: controller.signal, 166 + }) 167 + clearTimeout(timeoutId) 168 + return response.headers.get('x-typescript-types') 169 + } catch { 170 + clearTimeout(timeoutId) 171 + return null 172 + } 173 + }
+96
server/utils/docs/format.ts
··· 1 + /** 2 + * Signature Formatting 3 + * 4 + * Functions for formatting TypeScript signatures, parameters, and types. 5 + * 6 + * @module server/utils/docs/format 7 + */ 8 + 9 + import type { DenoDocNode, FunctionParam, TsType } from '#shared/types/deno-doc' 10 + import { cleanSymbolName, stripAnsi } from './text' 11 + 12 + /** 13 + * Generate a TypeScript signature string for a node. 14 + */ 15 + export function getNodeSignature(node: DenoDocNode): string | null { 16 + const name = cleanSymbolName(node.name) 17 + 18 + switch (node.kind) { 19 + case 'function': { 20 + const typeParams = node.functionDef?.typeParams?.map(t => t.name).join(', ') 21 + const typeParamsStr = typeParams ? `<${typeParams}>` : '' 22 + const params = node.functionDef?.params?.map(p => formatParam(p)).join(', ') || '' 23 + const ret = formatType(node.functionDef?.returnType) || 'void' 24 + const asyncStr = node.functionDef?.isAsync ? 'async ' : '' 25 + return `${asyncStr}function ${name}${typeParamsStr}(${params}): ${ret}` 26 + } 27 + case 'class': { 28 + const ext = node.classDef?.extends ? ` extends ${formatType(node.classDef.extends)}` : '' 29 + const impl = node.classDef?.implements?.map(t => formatType(t)).join(', ') 30 + const implStr = impl ? ` implements ${impl}` : '' 31 + const abstractStr = node.classDef?.isAbstract ? 'abstract ' : '' 32 + return `${abstractStr}class ${name}${ext}${implStr}` 33 + } 34 + case 'interface': { 35 + const typeParams = node.interfaceDef?.typeParams?.map(t => t.name).join(', ') 36 + const typeParamsStr = typeParams ? `<${typeParams}>` : '' 37 + const ext = node.interfaceDef?.extends?.map(t => formatType(t)).join(', ') 38 + const extStr = ext ? ` extends ${ext}` : '' 39 + return `interface ${name}${typeParamsStr}${extStr}` 40 + } 41 + case 'typeAlias': { 42 + const typeParams = node.typeAliasDef?.typeParams?.map(t => t.name).join(', ') 43 + const typeParamsStr = typeParams ? `<${typeParams}>` : '' 44 + const type = formatType(node.typeAliasDef?.tsType) || 'unknown' 45 + return `type ${name}${typeParamsStr} = ${type}` 46 + } 47 + case 'variable': { 48 + const keyword = node.variableDef?.kind === 'const' ? 'const' : 'let' 49 + const type = formatType(node.variableDef?.tsType) || 'unknown' 50 + return `${keyword} ${name}: ${type}` 51 + } 52 + case 'enum': { 53 + return `enum ${name}` 54 + } 55 + default: 56 + return null 57 + } 58 + } 59 + 60 + /** 61 + * Format a function parameter. 62 + */ 63 + export function formatParam(param: FunctionParam): string { 64 + const optional = param.optional ? '?' : '' 65 + const type = formatType(param.tsType) 66 + return type ? `${param.name}${optional}: ${type}` : `${param.name}${optional}` 67 + } 68 + 69 + /** 70 + * Format a TypeScript type. 71 + */ 72 + export function formatType(type?: TsType): string { 73 + if (!type) return '' 74 + 75 + // Strip ANSI codes from repr (deno doc may include terminal colors since it's built for that) 76 + if (type.repr) return stripAnsi(type.repr) 77 + 78 + if (type.kind === 'keyword' && type.keyword) { 79 + return type.keyword 80 + } 81 + 82 + if (type.kind === 'typeRef' && type.typeRef) { 83 + const params = type.typeRef.typeParams?.map(t => formatType(t)).join(', ') 84 + return params ? `${type.typeRef.typeName}<${params}>` : type.typeRef.typeName 85 + } 86 + 87 + if (type.kind === 'array' && type.array) { 88 + return `${formatType(type.array)}[]` 89 + } 90 + 91 + if (type.kind === 'union' && type.union) { 92 + return type.union.map(t => formatType(t)).join(' | ') 93 + } 94 + 95 + return type.repr ? stripAnsi(type.repr) : 'unknown' 96 + }
+55
server/utils/docs/index.ts
··· 1 + /** 2 + * API Documentation Generator 3 + * 4 + * Generates TypeScript API documentation for npm packages. 5 + * Uses esm.sh to resolve package types, which handles @types/* packages automatically. 6 + * Uses @deno/doc (WASM build of deno_doc) for documentation generation. 7 + * 8 + * @module server/utils/docs 9 + */ 10 + 11 + import type { DocsGenerationResult } from '#shared/types/deno-doc' 12 + import { getDocNodes } from './client' 13 + import { buildSymbolLookup, flattenNamespaces, mergeOverloads } from './processing' 14 + import { renderDocNodes, renderToc } from './render' 15 + 16 + /** 17 + * Generate API documentation for an npm package. 18 + * 19 + * Uses @deno/doc (WASM build of deno_doc) with esm.sh URLs to extract 20 + * TypeScript type information and JSDoc comments, then renders them as HTML. 21 + * 22 + * @param packageName - The npm package name (e.g., "react", "@types/lodash") 23 + * @param version - The package version (e.g., "19.2.3") 24 + * @returns Generated documentation or null if no types are available 25 + * 26 + * @example 27 + * ```ts 28 + * const docs = await generateDocsWithDeno('ufo', '1.5.0') 29 + * if (docs) { 30 + * console.log(docs.html) 31 + * } 32 + * ``` 33 + */ 34 + export async function generateDocsWithDeno( 35 + packageName: string, 36 + version: string, 37 + ): Promise<DocsGenerationResult | null> { 38 + // Get doc nodes using @deno/doc WASM 39 + const result = await getDocNodes(packageName, version) 40 + 41 + if (!result.nodes || result.nodes.length === 0) { 42 + return null 43 + } 44 + 45 + // Process nodes: flatten namespaces, merge overloads, and build lookup 46 + const flattenedNodes = flattenNamespaces(result.nodes) 47 + const mergedSymbols = mergeOverloads(flattenedNodes) 48 + const symbolLookup = buildSymbolLookup(flattenedNodes) 49 + 50 + // Render HTML and TOC from pre-computed merged symbols 51 + const html = await renderDocNodes(mergedSymbols, symbolLookup) 52 + const toc = renderToc(mergedSymbols) 53 + 54 + return { html, toc, nodes: flattenedNodes } 55 + }
+114
server/utils/docs/processing.ts
··· 1 + /** 2 + * Node Processing 3 + * 4 + * Functions for processing deno doc output: flattening namespaces, 5 + * merging overloads, and building symbol lookups. 6 + * 7 + * @module server/utils/docs/processing 8 + */ 9 + 10 + import type { DenoDocNode } from '#shared/types/deno-doc' 11 + import type { MergedSymbol, SymbolLookup } from './types' 12 + import { cleanSymbolName, createSymbolId } from './text' 13 + 14 + /** 15 + * Flatten namespace elements into top-level nodes for easier display. 16 + * Also filters out import/reference nodes that aren't useful for docs. 17 + */ 18 + export function flattenNamespaces(nodes: DenoDocNode[]): DenoDocNode[] { 19 + const result: DenoDocNode[] = [] 20 + 21 + for (const node of nodes) { 22 + // Skip internal nodes 23 + if (node.kind === 'import' || node.kind === 'reference') { 24 + continue 25 + } 26 + 27 + result.push(node) 28 + 29 + // Inline namespace members with qualified names 30 + if (node.kind === 'namespace' && node.namespaceDef?.elements) { 31 + for (const element of node.namespaceDef.elements) { 32 + result.push({ 33 + ...element, 34 + name: `${node.name}.${element.name}`, 35 + }) 36 + } 37 + } 38 + } 39 + 40 + return result 41 + } 42 + 43 + /** 44 + * Build a lookup table mapping symbol names to their HTML anchor IDs. 45 + * Used for {@link} cross-references. 46 + */ 47 + export function buildSymbolLookup(nodes: DenoDocNode[]): SymbolLookup { 48 + const lookup = new Map<string, string>() 49 + 50 + for (const node of nodes) { 51 + const cleanName = cleanSymbolName(node.name) 52 + const id = createSymbolId(node.kind, cleanName) 53 + lookup.set(cleanName, id) 54 + } 55 + 56 + return lookup 57 + } 58 + 59 + /** 60 + * Merge function/method overloads into single entries. 61 + * 62 + * TypeScript packages often export many overloads for the same function 63 + * (e.g., React's `h` has 23 overloads). This groups them together. 64 + */ 65 + export function mergeOverloads(nodes: DenoDocNode[]): MergedSymbol[] { 66 + const byKey = new Map<string, DenoDocNode[]>() 67 + 68 + for (const node of nodes) { 69 + const cleanName = cleanSymbolName(node.name) 70 + const key = `${node.kind}:${cleanName}` 71 + const existing = byKey.get(key) 72 + if (existing) { 73 + existing.push(node) 74 + } else { 75 + byKey.set(key, [node]) 76 + } 77 + } 78 + 79 + const result: MergedSymbol[] = [] 80 + 81 + for (const [, groupedNodes] of byKey) { 82 + const first = groupedNodes[0] 83 + if (!first) continue // Should never happen, but defensive programming etc 84 + 85 + // Use JSDoc from the best-documented overload 86 + const withDoc = groupedNodes.find(n => n.jsDoc?.doc) ?? first 87 + 88 + result.push({ 89 + name: cleanSymbolName(first.name), 90 + kind: first.kind, 91 + nodes: groupedNodes, 92 + jsDoc: withDoc.jsDoc, 93 + }) 94 + } 95 + 96 + // Sort alphabetically 97 + result.sort((a, b) => a.name.localeCompare(b.name)) 98 + 99 + return result 100 + } 101 + 102 + /** 103 + * Group merged symbols by their kind (function, class, etc.) 104 + */ 105 + export function groupMergedByKind(symbols: MergedSymbol[]): Record<string, MergedSymbol[]> { 106 + const grouped: Record<string, MergedSymbol[]> = {} 107 + 108 + for (const sym of symbols) { 109 + const kindGroup = (grouped[sym.kind] ??= []) 110 + kindGroup.push(sym) 111 + } 112 + 113 + return grouped 114 + }
+428
server/utils/docs/render.ts
··· 1 + /** 2 + * HTML Rendering 3 + * 4 + * Functions for rendering documentation nodes as HTML. 5 + * 6 + * @module server/utils/docs/render 7 + */ 8 + 9 + import type { DenoDocNode, JsDocTag } from '#shared/types/deno-doc' 10 + import { highlightCodeBlock } from '../shiki' 11 + import { formatParam, formatType, getNodeSignature } from './format' 12 + import { groupMergedByKind } from './processing' 13 + import { createSymbolId, escapeHtml, parseJsDocLinks, renderMarkdown } from './text' 14 + import type { MergedSymbol, SymbolLookup } from './types' 15 + 16 + // ============================================================================= 17 + // Configuration 18 + // ============================================================================= 19 + 20 + /** Maximum number of overload signatures to display per symbol */ 21 + const MAX_OVERLOAD_SIGNATURES = 5 22 + 23 + /** Maximum number of items to show in TOC per category before truncating */ 24 + const MAX_TOC_ITEMS_PER_KIND = 50 25 + 26 + /** Order in which symbol kinds are displayed */ 27 + const KIND_DISPLAY_ORDER = [ 28 + 'function', 29 + 'class', 30 + 'interface', 31 + 'typeAlias', 32 + 'variable', 33 + 'enum', 34 + 'namespace', 35 + ] as const 36 + 37 + /** Human-readable titles for symbol kinds */ 38 + const KIND_TITLES: Record<string, string> = { 39 + function: 'Functions', 40 + class: 'Classes', 41 + interface: 'Interfaces', 42 + typeAlias: 'Type Aliases', 43 + variable: 'Variables', 44 + enum: 'Enums', 45 + namespace: 'Namespaces', 46 + } 47 + 48 + // ============================================================================= 49 + // Main Rendering Functions 50 + // ============================================================================= 51 + 52 + /** 53 + * Render all documentation nodes as HTML. 54 + */ 55 + export async function renderDocNodes( 56 + symbols: MergedSymbol[], 57 + symbolLookup: SymbolLookup, 58 + ): Promise<string> { 59 + const grouped = groupMergedByKind(symbols) 60 + const sections: string[] = [] 61 + 62 + for (const kind of KIND_DISPLAY_ORDER) { 63 + const kindSymbols = grouped[kind] 64 + if (!kindSymbols || kindSymbols.length === 0) continue 65 + 66 + sections.push(await renderKindSection(kind, kindSymbols, symbolLookup)) 67 + } 68 + 69 + return sections.join('\n') 70 + } 71 + 72 + /** 73 + * Render a section for a specific symbol kind. 74 + */ 75 + async function renderKindSection( 76 + kind: string, 77 + symbols: MergedSymbol[], 78 + symbolLookup: SymbolLookup, 79 + ): Promise<string> { 80 + const title = KIND_TITLES[kind] || kind 81 + const lines: string[] = [] 82 + 83 + lines.push(`<section class="docs-section" id="section-${kind}">`) 84 + lines.push(`<h2 class="docs-section-title">${title}</h2>`) 85 + 86 + for (const symbol of symbols) { 87 + lines.push(await renderMergedSymbol(symbol, symbolLookup)) 88 + } 89 + 90 + lines.push(`</section>`) 91 + 92 + return lines.join('\n') 93 + } 94 + 95 + /** 96 + * Render a merged symbol (with all its overloads). 97 + */ 98 + async function renderMergedSymbol( 99 + symbol: MergedSymbol, 100 + symbolLookup: SymbolLookup, 101 + ): Promise<string> { 102 + const primaryNode = symbol.nodes[0] 103 + if (!primaryNode) return '' // Safety check - should never happen 104 + 105 + const lines: string[] = [] 106 + const id = createSymbolId(symbol.kind, symbol.name) 107 + const hasOverloads = symbol.nodes.length > 1 108 + 109 + lines.push(`<article class="docs-symbol" id="${id}">`) 110 + 111 + // Header 112 + lines.push(`<header class="docs-symbol-header">`) 113 + lines.push( 114 + `<a href="#${id}" class="docs-anchor" aria-label="Link to ${escapeHtml(symbol.name)}">#</a>`, 115 + ) 116 + lines.push(`<h3 class="docs-symbol-name">${escapeHtml(symbol.name)}</h3>`) 117 + lines.push(`<span class="docs-badge docs-badge--${symbol.kind}">${symbol.kind}</span>`) 118 + if (primaryNode.functionDef?.isAsync) { 119 + lines.push(`<span class="docs-badge docs-badge--async">async</span>`) 120 + } 121 + if (hasOverloads) { 122 + lines.push(`<span class="docs-overload-count">${symbol.nodes.length} overloads</span>`) 123 + } 124 + lines.push(`</header>`) 125 + 126 + // Signatures 127 + const signatures = symbol.nodes 128 + .slice(0, hasOverloads ? MAX_OVERLOAD_SIGNATURES : 1) 129 + .map(n => getNodeSignature(n)) 130 + .filter(Boolean) as string[] 131 + 132 + if (signatures.length > 0) { 133 + const signatureCode = signatures.join('\n') 134 + const highlightedSignature = await highlightCodeBlock(signatureCode, 'typescript') 135 + lines.push(`<div class="docs-signature">${highlightedSignature}</div>`) 136 + 137 + if (symbol.nodes.length > MAX_OVERLOAD_SIGNATURES) { 138 + const remaining = symbol.nodes.length - MAX_OVERLOAD_SIGNATURES 139 + lines.push(`<p class="docs-more-overloads">+ ${remaining} more overloads</p>`) 140 + } 141 + } 142 + 143 + // Description 144 + if (symbol.jsDoc?.doc) { 145 + const description = symbol.jsDoc.doc.trim() 146 + lines.push( 147 + `<div class="docs-description">${await renderMarkdown(description, symbolLookup)}</div>`, 148 + ) 149 + } 150 + 151 + // JSDoc tags 152 + if (symbol.jsDoc?.tags && symbol.jsDoc.tags.length > 0) { 153 + lines.push(await renderJsDocTags(symbol.jsDoc.tags, symbolLookup)) 154 + } 155 + 156 + // Type-specific members 157 + if (symbol.kind === 'class' && primaryNode.classDef) { 158 + lines.push(renderClassMembers(primaryNode.classDef)) 159 + } else if (symbol.kind === 'interface' && primaryNode.interfaceDef) { 160 + lines.push(renderInterfaceMembers(primaryNode.interfaceDef)) 161 + } else if (symbol.kind === 'enum' && primaryNode.enumDef) { 162 + lines.push(renderEnumMembers(primaryNode.enumDef)) 163 + } 164 + 165 + lines.push(`</article>`) 166 + 167 + return lines.join('\n') 168 + } 169 + 170 + /** 171 + * Render JSDoc tags (params, returns, examples, etc.) 172 + */ 173 + async function renderJsDocTags(tags: JsDocTag[], symbolLookup: SymbolLookup): Promise<string> { 174 + const lines: string[] = [] 175 + 176 + const params = tags.filter(t => t.kind === 'param') 177 + const returns = tags.find(t => t.kind === 'return') 178 + const examples = tags.filter(t => t.kind === 'example') 179 + const deprecated = tags.find(t => t.kind === 'deprecated') 180 + const see = tags.filter(t => t.kind === 'see') 181 + 182 + // Deprecated warning 183 + if (deprecated) { 184 + lines.push(`<div class="docs-deprecated">`) 185 + lines.push(`<strong>Deprecated</strong>`) 186 + if (deprecated.doc) { 187 + lines.push(`<p>${parseJsDocLinks(deprecated.doc, symbolLookup)}</p>`) 188 + } 189 + lines.push(`</div>`) 190 + } 191 + 192 + // Parameters 193 + if (params.length > 0) { 194 + lines.push(`<div class="docs-params">`) 195 + lines.push(`<h4>Parameters</h4>`) 196 + lines.push(`<dl>`) 197 + for (const param of params) { 198 + lines.push( 199 + `<dt><code>${escapeHtml(param.name || '')}${param.optional ? '?' : ''}</code></dt>`, 200 + ) 201 + if (param.doc) { 202 + lines.push(`<dd>${parseJsDocLinks(param.doc, symbolLookup)}</dd>`) 203 + } 204 + } 205 + lines.push(`</dl>`) 206 + lines.push(`</div>`) 207 + } 208 + 209 + // Returns 210 + if (returns?.doc) { 211 + lines.push(`<div class="docs-returns">`) 212 + lines.push(`<h4>Returns</h4>`) 213 + lines.push(`<p>${parseJsDocLinks(returns.doc, symbolLookup)}</p>`) 214 + lines.push(`</div>`) 215 + } 216 + 217 + // Examples (with syntax highlighting) 218 + if (examples.length > 0) { 219 + lines.push(`<div class="docs-examples">`) 220 + lines.push(`<h4>Example${examples.length > 1 ? 's' : ''}</h4>`) 221 + for (const example of examples) { 222 + if (example.doc) { 223 + const langMatch = example.doc.match(/```(\w+)?/) 224 + const lang = langMatch?.[1] || 'typescript' 225 + const code = example.doc.replace(/```\w*\n?/g, '').trim() 226 + const highlighted = await highlightCodeBlock(code, lang) 227 + lines.push(highlighted) 228 + } 229 + } 230 + lines.push(`</div>`) 231 + } 232 + 233 + // See also 234 + if (see.length > 0) { 235 + lines.push(`<div class="docs-see">`) 236 + lines.push(`<h4>See Also</h4>`) 237 + lines.push(`<ul>`) 238 + for (const s of see) { 239 + if (s.doc) { 240 + lines.push(`<li>${parseJsDocLinks(s.doc, symbolLookup)}</li>`) 241 + } 242 + } 243 + lines.push(`</ul>`) 244 + lines.push(`</div>`) 245 + } 246 + 247 + return lines.join('\n') 248 + } 249 + 250 + // ============================================================================= 251 + // Member Rendering 252 + // ============================================================================= 253 + 254 + /** 255 + * Render class members (constructor, properties, methods). 256 + */ 257 + function renderClassMembers(def: NonNullable<DenoDocNode['classDef']>): string { 258 + const lines: string[] = [] 259 + const { constructors, properties, methods } = def 260 + 261 + if (constructors && constructors.length > 0) { 262 + lines.push(`<div class="docs-members">`) 263 + lines.push(`<h4>Constructor</h4>`) 264 + for (const ctor of constructors) { 265 + const params = ctor.params?.map(p => formatParam(p)).join(', ') || '' 266 + lines.push(`<pre><code>constructor(${escapeHtml(params)})</code></pre>`) 267 + } 268 + lines.push(`</div>`) 269 + } 270 + 271 + if (properties && properties.length > 0) { 272 + lines.push(`<div class="docs-members">`) 273 + lines.push(`<h4>Properties</h4>`) 274 + lines.push(`<dl>`) 275 + for (const prop of properties) { 276 + const modifiers: string[] = [] 277 + if (prop.isStatic) modifiers.push('static') 278 + if (prop.readonly) modifiers.push('readonly') 279 + const modStr = modifiers.length > 0 ? `${modifiers.join(' ')} ` : '' 280 + const type = formatType(prop.tsType) 281 + const opt = prop.optional ? '?' : '' 282 + lines.push( 283 + `<dt><code>${escapeHtml(modStr)}${escapeHtml(prop.name)}${opt}: ${escapeHtml(type)}</code></dt>`, 284 + ) 285 + if (prop.jsDoc?.doc) { 286 + lines.push(`<dd>${escapeHtml(prop.jsDoc.doc.split('\n')[0] ?? '')}</dd>`) 287 + } 288 + } 289 + lines.push(`</dl>`) 290 + lines.push(`</div>`) 291 + } 292 + 293 + if (methods && methods.length > 0) { 294 + lines.push(`<div class="docs-members">`) 295 + lines.push(`<h4>Methods</h4>`) 296 + lines.push(`<dl>`) 297 + for (const method of methods) { 298 + const params = method.functionDef?.params?.map(p => formatParam(p)).join(', ') || '' 299 + const ret = formatType(method.functionDef?.returnType) || 'void' 300 + const staticStr = method.isStatic ? 'static ' : '' 301 + lines.push( 302 + `<dt><code>${escapeHtml(staticStr)}${escapeHtml(method.name)}(${escapeHtml(params)}): ${escapeHtml(ret)}</code></dt>`, 303 + ) 304 + if (method.jsDoc?.doc) { 305 + lines.push(`<dd>${escapeHtml(method.jsDoc.doc.split('\n')[0] ?? '')}</dd>`) 306 + } 307 + } 308 + lines.push(`</dl>`) 309 + lines.push(`</div>`) 310 + } 311 + 312 + return lines.join('\n') 313 + } 314 + 315 + /** 316 + * Render interface members (properties, methods). 317 + */ 318 + function renderInterfaceMembers(def: NonNullable<DenoDocNode['interfaceDef']>): string { 319 + const lines: string[] = [] 320 + const { properties, methods } = def 321 + 322 + if (properties && properties.length > 0) { 323 + lines.push(`<div class="docs-members">`) 324 + lines.push(`<h4>Properties</h4>`) 325 + lines.push(`<dl>`) 326 + for (const prop of properties) { 327 + const type = formatType(prop.tsType) 328 + const opt = prop.optional ? '?' : '' 329 + const ro = prop.readonly ? 'readonly ' : '' 330 + lines.push( 331 + `<dt><code>${escapeHtml(ro)}${escapeHtml(prop.name)}${opt}: ${escapeHtml(type)}</code></dt>`, 332 + ) 333 + if (prop.jsDoc?.doc) { 334 + lines.push(`<dd>${escapeHtml(prop.jsDoc.doc.split('\n')[0] ?? '')}</dd>`) 335 + } 336 + } 337 + lines.push(`</dl>`) 338 + lines.push(`</div>`) 339 + } 340 + 341 + if (methods && methods.length > 0) { 342 + lines.push(`<div class="docs-members">`) 343 + lines.push(`<h4>Methods</h4>`) 344 + lines.push(`<dl>`) 345 + for (const method of methods) { 346 + const params = method.params?.map(p => formatParam(p)).join(', ') || '' 347 + const ret = formatType(method.returnType) || 'void' 348 + lines.push( 349 + `<dt><code>${escapeHtml(method.name)}(${escapeHtml(params)}): ${escapeHtml(ret)}</code></dt>`, 350 + ) 351 + if (method.jsDoc?.doc) { 352 + lines.push(`<dd>${escapeHtml(method.jsDoc.doc.split('\n')[0] ?? '')}</dd>`) 353 + } 354 + } 355 + lines.push(`</dl>`) 356 + lines.push(`</div>`) 357 + } 358 + 359 + return lines.join('\n') 360 + } 361 + 362 + /** 363 + * Render enum members. 364 + */ 365 + function renderEnumMembers(def: NonNullable<DenoDocNode['enumDef']>): string { 366 + const lines: string[] = [] 367 + const { members } = def 368 + 369 + if (members && members.length > 0) { 370 + lines.push(`<div class="docs-members">`) 371 + lines.push(`<h4>Members</h4>`) 372 + lines.push(`<ul class="docs-enum-members">`) 373 + for (const member of members) { 374 + lines.push(`<li><code>${escapeHtml(member.name)}</code></li>`) 375 + } 376 + lines.push(`</ul>`) 377 + lines.push(`</div>`) 378 + } 379 + 380 + return lines.join('\n') 381 + } 382 + 383 + // ============================================================================= 384 + // Table of Contents 385 + // ============================================================================= 386 + 387 + /** 388 + * Render table of contents. 389 + */ 390 + export function renderToc(symbols: MergedSymbol[]): string { 391 + const grouped = groupMergedByKind(symbols) 392 + const lines: string[] = [] 393 + 394 + lines.push(`<nav class="toc text-sm" aria-label="Table of contents">`) 395 + lines.push(`<ul class="space-y-3">`) 396 + 397 + for (const kind of KIND_DISPLAY_ORDER) { 398 + const kindSymbols = grouped[kind] 399 + if (!kindSymbols || kindSymbols.length === 0) continue 400 + 401 + const title = KIND_TITLES[kind] || kind 402 + lines.push(`<li>`) 403 + lines.push( 404 + `<a href="#section-${kind}" class="font-semibold text-fg-muted hover:text-fg block mb-1">${title} <span class="text-fg-subtle font-normal">(${kindSymbols.length})</span></a>`, 405 + ) 406 + 407 + const showSymbols = kindSymbols.slice(0, MAX_TOC_ITEMS_PER_KIND) 408 + lines.push(`<ul class="pl-3 space-y-0.5 border-l border-border/50">`) 409 + for (const symbol of showSymbols) { 410 + const id = createSymbolId(symbol.kind, symbol.name) 411 + lines.push( 412 + `<li><a href="#${id}" class="text-fg-subtle hover:text-fg font-mono text-xs block py-0.5 truncate">${escapeHtml(symbol.name)}</a></li>`, 413 + ) 414 + } 415 + if (kindSymbols.length > MAX_TOC_ITEMS_PER_KIND) { 416 + const remaining = kindSymbols.length - MAX_TOC_ITEMS_PER_KIND 417 + lines.push(`<li class="text-fg-subtle text-xs py-0.5">... and ${remaining} more</li>`) 418 + } 419 + lines.push(`</ul>`) 420 + 421 + lines.push(`</li>`) 422 + } 423 + 424 + lines.push(`</ul>`) 425 + lines.push(`</nav>`) 426 + 427 + return lines.join('\n') 428 + }
+134
server/utils/docs/text.ts
··· 1 + /** 2 + * Text Processing Utilities 3 + * 4 + * Functions for escaping HTML, parsing JSDoc links, and rendering markdown. 5 + * 6 + * @module server/utils/docs/text 7 + */ 8 + 9 + import { highlightCodeBlock } from '../shiki' 10 + import type { SymbolLookup } from './types' 11 + 12 + /** 13 + * Strip ANSI escape codes from text. 14 + * Deno doc output may contain terminal color codes that need to be removed. 15 + */ 16 + const ESC = String.fromCharCode(27) 17 + const ANSI_PATTERN = new RegExp(`${ESC}\\[[0-9;]*m`, 'g') 18 + 19 + export function stripAnsi(text: string): string { 20 + return text.replace(ANSI_PATTERN, '') 21 + } 22 + 23 + /** 24 + * Escape HTML special characters. 25 + * 26 + * @internal Exported for testing 27 + */ 28 + export function escapeHtml(text: string): string { 29 + return text 30 + .replace(/&/g, '&amp;') 31 + .replace(/</g, '&lt;') 32 + .replace(/>/g, '&gt;') 33 + .replace(/"/g, '&quot;') 34 + .replace(/'/g, '&#39;') 35 + } 36 + 37 + /** 38 + * Clean up symbol names by stripping esm.sh prefixes. 39 + * 40 + * Packages using @types/* definitions get "default." or "default_" prefixes 41 + * from esm.sh that we need to remove for clean display. 42 + */ 43 + export function cleanSymbolName(name: string): string { 44 + if (name.startsWith('default.')) { 45 + return name.slice(8) 46 + } 47 + if (name.startsWith('default_')) { 48 + return name.slice(8) 49 + } 50 + return name 51 + } 52 + 53 + /** 54 + * Create a URL-safe HTML anchor ID for a symbol. 55 + */ 56 + export function createSymbolId(kind: string, name: string): string { 57 + return `${kind}-${name}`.replace(/[^a-zA-Z0-9-]/g, '_') 58 + } 59 + 60 + /** 61 + * Parse JSDoc {@link} tags into HTML links. 62 + * 63 + * Handles: 64 + * - {@link https://example.com} - external URL 65 + * - {@link https://example.com Link Text} - external URL with label 66 + * - {@link SomeSymbol} - internal cross-reference 67 + * 68 + * @internal Exported for testing 69 + */ 70 + export function parseJsDocLinks(text: string, symbolLookup: SymbolLookup): string { 71 + let result = escapeHtml(text) 72 + 73 + result = result.replace(/\{@link\s+([^\s}]+)(?:\s+([^}]+))?\}/g, (_, target, label) => { 74 + const displayText = label || target 75 + 76 + // External URL 77 + if (target.startsWith('http://') || target.startsWith('https://')) { 78 + return `<a href="${target}" target="_blank" rel="noopener" class="docs-link">${displayText}</a>` 79 + } 80 + 81 + // Internal symbol reference 82 + const symbolId = symbolLookup.get(target) 83 + if (symbolId) { 84 + return `<a href="#${symbolId}" class="docs-symbol-link">${displayText}</a>` 85 + } 86 + 87 + // Unknown symbol 88 + return `<code class="docs-symbol-ref">${displayText}</code>` 89 + }) 90 + 91 + return result 92 + } 93 + 94 + /** 95 + * Render simple markdown-like formatting. 96 + * Uses <br> for line breaks to avoid nesting issues with inline elements. 97 + * Fenced code blocks (```) are syntax-highlighted with Shiki. 98 + * 99 + * @internal Exported for testing 100 + */ 101 + export async function renderMarkdown(text: string, symbolLookup: SymbolLookup): Promise<string> { 102 + // Extract fenced code blocks FIRST (before any HTML escaping) 103 + // Pattern handles: 104 + // - Optional whitespace before/after language identifier 105 + // - \r\n, \n, or \r line endings 106 + const codeBlockData: Array<{ lang: string; code: string }> = [] 107 + let result = text.replace( 108 + /```[ \t]*(\w*)[ \t]*(?:\r\n|\r|\n)([\s\S]*?)(?:\r\n|\r|\n)?```/g, 109 + (_, lang, code) => { 110 + const index = codeBlockData.length 111 + codeBlockData.push({ lang: lang || 'text', code: code.trim() }) 112 + return `__CODE_BLOCK_${index}__` 113 + }, 114 + ) 115 + 116 + // Now process the rest (JSDoc links, HTML escaping, etc.) 117 + result = parseJsDocLinks(result, symbolLookup) 118 + 119 + // Handle inline code (single backticks) - won't interfere with fenced blocks 120 + result = result 121 + .replace(/`([^`]+)`/g, '<code class="docs-inline-code">$1</code>') 122 + .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>') 123 + .replace(/\n\n+/g, '<br><br>') 124 + .replace(/\n/g, '<br>') 125 + 126 + // Highlight and restore code blocks 127 + for (let i = 0; i < codeBlockData.length; i++) { 128 + const { lang, code } = codeBlockData[i]! 129 + const highlighted = await highlightCodeBlock(code, lang) 130 + result = result.replace(`__CODE_BLOCK_${i}__`, highlighted) 131 + } 132 + 133 + return result 134 + }
+24
server/utils/docs/types.ts
··· 1 + /** 2 + * Internal Types for Docs Module 3 + * These are highly coupled to `deno doc`, hence they live here instead of shared types. 4 + * 5 + * @module server/utils/docs/types 6 + */ 7 + 8 + import type { DenoDocNode } from '#shared/types/deno-doc' 9 + 10 + /** 11 + * Map of symbol names to anchor IDs for cross-referencing. 12 + * @internal Exported for testing 13 + */ 14 + export type SymbolLookup = Map<string, string> 15 + 16 + /** 17 + * Symbol with merged overloads 18 + */ 19 + export interface MergedSymbol { 20 + name: string 21 + kind: string 22 + nodes: DenoDocNode[] 23 + jsDoc?: DenoDocNode['jsDoc'] 24 + }
+2 -18
server/utils/readme.ts
··· 3 3 import { hasProtocol } from 'ufo' 4 4 import type { ReadmeResponse } from '#shared/types/readme' 5 5 import { convertBlobToRawUrl, type RepositoryInfo } from '#shared/utils/git-providers' 6 + import { highlightCodeSync } from './shiki' 6 7 7 8 /** 8 9 * Playground provider configuration ··· 266 267 267 268 // Syntax highlighting for code blocks (uses shared highlighter) 268 269 renderer.code = ({ text, lang }: Tokens.Code) => { 269 - const language = lang || 'text' 270 - const loadedLangs = shiki.getLoadedLanguages() 271 - 272 - // Use Shiki if language is loaded, otherwise fall back to plain 273 - if (loadedLangs.includes(language as never)) { 274 - try { 275 - return shiki.codeToHtml(text, { 276 - lang: language, 277 - theme: 'github-dark', 278 - }) 279 - } catch { 280 - // Fall back to plain code block 281 - } 282 - } 283 - 284 - // Plain code block for unknown languages 285 - const escaped = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') 286 - return `<pre><code class="language-${language}">${escaped}</code></pre>\n` 270 + return highlightCodeSync(shiki, text, lang || 'text') 287 271 } 288 272 289 273 // Resolve image URLs (with GitHub blob → raw conversion)
+47 -3
server/utils/shiki.ts
··· 49 49 return highlighter 50 50 } 51 51 52 - export async function highlightCodeBlock(code: string, language: string): Promise<string> { 53 - const shiki = await getShikiHighlighter() 52 + /** 53 + * Synchronously highlight a code block using an already-initialized highlighter. 54 + * Use this when you have already awaited getShikiHighlighter() and need to 55 + * highlight multiple blocks without async overhead (e.g., in marked renderers). 56 + * 57 + * @param shiki - The initialized Shiki highlighter instance 58 + * @param code - The code to highlight 59 + * @param language - The language identifier (e.g., 'typescript', 'bash') 60 + * @returns HTML string with syntax highlighting 61 + */ 62 + export function highlightCodeSync(shiki: HighlighterCore, code: string, language: string): string { 54 63 const loadedLangs = shiki.getLoadedLanguages() 55 64 56 65 if (loadedLangs.includes(language as never)) { 57 66 try { 58 - return shiki.codeToHtml(code, { 67 + let html = shiki.codeToHtml(code, { 59 68 lang: language, 60 69 theme: 'github-dark', 61 70 }) 71 + // Remove inline style from <pre> tag so CSS can control appearance 72 + html = html.replace(/<pre([^>]*)\s+style="[^"]*"/, '<pre$1') 73 + // Shiki doesn't encode > in text content (e.g., arrow functions =>) 74 + // We need to encode them for HTML validation 75 + return escapeRawGt(html) 62 76 } catch { 63 77 // Fall back to plain 64 78 } ··· 68 82 const escaped = code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') 69 83 return `<pre><code class="language-${language}">${escaped}</code></pre>\n` 70 84 } 85 + 86 + /** 87 + * Highlight a code block with syntax highlighting (async convenience wrapper). 88 + * Initializes the highlighter if needed, then delegates to highlightCodeSync. 89 + * 90 + * @param code - The code to highlight 91 + * @param language - The language identifier (e.g., 'typescript', 'bash') 92 + * @returns HTML string with syntax highlighting 93 + */ 94 + export async function highlightCodeBlock(code: string, language: string): Promise<string> { 95 + const shiki = await getShikiHighlighter() 96 + return highlightCodeSync(shiki, code, language) 97 + } 98 + 99 + /** 100 + * Escape raw > characters in HTML text content. 101 + * Shiki outputs > without encoding in constructs like arrow functions (=>). 102 + * This replaces > that appear in text content (after >) but not inside tags. 103 + * 104 + * @internal Exported for testing 105 + */ 106 + export function escapeRawGt(html: string): string { 107 + // Match > that appears after a closing tag or other > (i.e., in text content) 108 + // Pattern: after </...> or after >, match any > that isn't starting a tag 109 + return html.replace(/>([^<]*)/g, (match, textContent) => { 110 + // Encode any > in the text content portion 111 + const escapedText = textContent.replace(/>/g, '&gt;') 112 + return `>${escapedText}` 113 + }) 114 + }
+139
shared/types/deno-doc.ts
··· 1 + /** 2 + * Types for deno doc JSON output. 3 + * 4 + * These types represent the structure of the JSON output from `deno doc --json`. 5 + * In an ideal world, we'd generate these or implement our own AST / parser. 6 + * That might be an endgame, but there's a lot of value in using deno's implementation, too. 7 + * Well trodden ground and all that. 8 + * 9 + * @see: https://deno.land/x/deno_doc 10 + */ 11 + 12 + /** JSDoc tag from deno doc output */ 13 + export interface JsDocTag { 14 + kind: string 15 + name?: string 16 + doc?: string 17 + optional?: boolean 18 + type?: string 19 + } 20 + 21 + /** TypeScript type representation from deno doc */ 22 + export interface TsType { 23 + repr: string 24 + kind: string 25 + keyword?: string 26 + typeRef?: { 27 + typeName: string 28 + typeParams?: TsType[] | null 29 + } 30 + array?: TsType 31 + union?: TsType[] 32 + literal?: { 33 + kind: string 34 + string?: string 35 + number?: number 36 + boolean?: boolean 37 + } 38 + } 39 + 40 + /** Function parameter from deno doc */ 41 + export interface FunctionParam { 42 + kind: string 43 + name: string 44 + optional?: boolean 45 + tsType?: TsType 46 + } 47 + 48 + /** A documentation node from deno doc output */ 49 + export interface DenoDocNode { 50 + name: string 51 + kind: string 52 + isDefault?: boolean 53 + location?: { 54 + filename: string 55 + line: number 56 + col: number 57 + } 58 + declarationKind?: string 59 + jsDoc?: { 60 + doc?: string 61 + tags?: JsDocTag[] 62 + } 63 + functionDef?: { 64 + params?: FunctionParam[] 65 + returnType?: TsType 66 + isAsync?: boolean 67 + isGenerator?: boolean 68 + typeParams?: Array<{ name: string }> 69 + } 70 + classDef?: { 71 + isAbstract?: boolean 72 + properties?: Array<{ 73 + name: string 74 + tsType?: TsType 75 + readonly?: boolean 76 + optional?: boolean 77 + isStatic?: boolean 78 + jsDoc?: { doc?: string } 79 + }> 80 + methods?: Array<{ 81 + name: string 82 + isStatic?: boolean 83 + functionDef?: { 84 + params?: FunctionParam[] 85 + returnType?: TsType 86 + } 87 + jsDoc?: { doc?: string } 88 + }> 89 + constructors?: Array<{ 90 + params?: FunctionParam[] 91 + }> 92 + extends?: TsType 93 + implements?: TsType[] 94 + } 95 + interfaceDef?: { 96 + properties?: Array<{ 97 + name: string 98 + tsType?: TsType 99 + readonly?: boolean 100 + optional?: boolean 101 + jsDoc?: { doc?: string } 102 + }> 103 + methods?: Array<{ 104 + name: string 105 + params?: FunctionParam[] 106 + returnType?: TsType 107 + jsDoc?: { doc?: string } 108 + }> 109 + extends?: TsType[] 110 + typeParams?: Array<{ name: string }> 111 + } 112 + typeAliasDef?: { 113 + tsType?: TsType 114 + typeParams?: Array<{ name: string }> 115 + } 116 + variableDef?: { 117 + tsType?: TsType 118 + kind?: string 119 + } 120 + enumDef?: { 121 + members?: Array<{ name: string; init?: TsType }> 122 + } 123 + namespaceDef?: { 124 + elements?: DenoDocNode[] 125 + } 126 + } 127 + 128 + /** Raw output from deno doc --json */ 129 + export interface DenoDocResult { 130 + version: number 131 + nodes: DenoDocNode[] 132 + } 133 + 134 + /** Result of documentation generation */ 135 + export interface DocsGenerationResult { 136 + html: string 137 + toc: string | null 138 + nodes: DenoDocNode[] 139 + }
+17
shared/types/docs.ts
··· 1 + export type DocsStatus = 'ok' | 'missing' | 'error' 2 + 3 + export interface DocsResponse { 4 + package: string 5 + version: string 6 + html: string 7 + toc: string | null 8 + breadcrumbs?: string | null 9 + status: DocsStatus 10 + message?: string 11 + } 12 + 13 + export interface DocsSearchResponse { 14 + package: string 15 + version: string 16 + index: Record<string, unknown> 17 + }
+2
shared/types/index.ts
··· 2 2 export * from './jsr' 3 3 export * from './osv' 4 4 export * from './readme' 5 + export * from './docs' 6 + export * from './deno-doc'
+55
shared/utils/parse-package-param.ts
··· 1 + /** 2 + * Parsed package parameters from URL path segments. 3 + */ 4 + export interface ParsedPackageParams { 5 + /** The npm package name (e.g., "vue", "@nuxt/kit") */ 6 + packageName: string 7 + /** The version if specified (e.g., "3.4.0"), undefined otherwise */ 8 + version: string | undefined 9 + /** Remaining path segments after the version (e.g., for file paths) */ 10 + rest: string[] 11 + } 12 + 13 + /** 14 + * Parse package name, optional version, and remaining path from URL segments. 15 + * 16 + * Supports these URL patterns: 17 + * - `/pkg` → { packageName: "pkg", version: undefined, rest: [] } 18 + * - `/pkg/v/1.2.3` → { packageName: "pkg", version: "1.2.3", rest: [] } 19 + * - `/pkg/v/1.2.3/src/index.ts` → { packageName: "pkg", version: "1.2.3", rest: ["src", "index.ts"] } 20 + * - `/@scope/pkg` → { packageName: "@scope/pkg", version: undefined, rest: [] } 21 + * - `/@scope/pkg/v/1.2.3` → { packageName: "@scope/pkg", version: "1.2.3", rest: [] } 22 + * 23 + * @param pkgParam - The raw package parameter from the URL (e.g., "vue/v/3.4.0/src/index.ts") 24 + * @returns Parsed package name, optional version, and remaining path segments 25 + * 26 + * @example 27 + * ```ts 28 + * parsePackageParam('vue') 29 + * // { packageName: 'vue', version: undefined, rest: [] } 30 + * 31 + * parsePackageParam('vue/v/3.4.0') 32 + * // { packageName: 'vue', version: '3.4.0', rest: [] } 33 + * 34 + * parsePackageParam('@nuxt/kit/v/1.0.0/src/index.ts') 35 + * // { packageName: '@nuxt/kit', version: '1.0.0', rest: ['src', 'index.ts'] } 36 + * ``` 37 + */ 38 + export function parsePackageParam(pkgParam: string): ParsedPackageParams { 39 + const segments = pkgParam.split('/') 40 + const vIndex = segments.indexOf('v') 41 + 42 + if (vIndex !== -1 && vIndex < segments.length - 1) { 43 + return { 44 + packageName: segments.slice(0, vIndex).join('/'), 45 + version: segments[vIndex + 1], 46 + rest: segments.slice(vIndex + 2), 47 + } 48 + } 49 + 50 + return { 51 + packageName: segments.join('/'), 52 + version: undefined, 53 + rest: [], 54 + } 55 + }
+255
test/unit/docs-text.spec.ts
··· 1 + import { describe, expect, it } from 'vitest' 2 + import { 3 + escapeHtml, 4 + parseJsDocLinks, 5 + renderMarkdown, 6 + stripAnsi, 7 + } from '../../server/utils/docs/text' 8 + import type { SymbolLookup } from '../../server/utils/docs/types' 9 + 10 + describe('stripAnsi', () => { 11 + it('should strip basic color codes', () => { 12 + const ESC = String.fromCharCode(27) 13 + const input = `${ESC}[0m${ESC}[38;5;12mhello${ESC}[0m` 14 + expect(stripAnsi(input)).toBe('hello') 15 + }) 16 + 17 + it('should strip multiple ANSI codes', () => { 18 + const ESC = String.fromCharCode(27) 19 + const input = `${ESC}[31mred${ESC}[0m and ${ESC}[32mgreen${ESC}[0m` 20 + expect(stripAnsi(input)).toBe('red and green') 21 + }) 22 + 23 + it('should handle text without ANSI codes', () => { 24 + expect(stripAnsi('plain text')).toBe('plain text') 25 + }) 26 + 27 + it('should handle empty string', () => { 28 + expect(stripAnsi('')).toBe('') 29 + }) 30 + 31 + it('should strip 256-color codes', () => { 32 + const ESC = String.fromCharCode(27) 33 + const input = `${ESC}[38;5;196mtext${ESC}[0m` 34 + expect(stripAnsi(input)).toBe('text') 35 + }) 36 + 37 + it('should handle type predicates from deno_doc', () => { 38 + // Real example: "object is ReactElement<P>" with ANSI codes 39 + const ESC = String.fromCharCode(27) 40 + const input = `object is ReactElement${ESC}[0m${ESC}[38;5;12m<${ESC}[0mP${ESC}[38;5;12m>${ESC}[0m` 41 + expect(stripAnsi(input)).toBe('object is ReactElement<P>') 42 + }) 43 + }) 44 + 45 + describe('escapeHtml', () => { 46 + it('should escape < and >', () => { 47 + expect(escapeHtml('<script>')).toBe('&lt;script&gt;') 48 + }) 49 + 50 + it('should escape &', () => { 51 + expect(escapeHtml('foo & bar')).toBe('foo &amp; bar') 52 + }) 53 + 54 + it('should escape quotes', () => { 55 + expect(escapeHtml('"hello"')).toBe('&quot;hello&quot;') 56 + expect(escapeHtml("'hello'")).toBe('&#39;hello&#39;') 57 + }) 58 + 59 + it('should handle multiple special characters', () => { 60 + expect(escapeHtml('<a href="test?a=1&b=2">')).toBe( 61 + '&lt;a href=&quot;test?a=1&amp;b=2&quot;&gt;', 62 + ) 63 + }) 64 + 65 + it('should return empty string for empty input', () => { 66 + expect(escapeHtml('')).toBe('') 67 + }) 68 + 69 + it('should not modify text without special characters', () => { 70 + expect(escapeHtml('hello world')).toBe('hello world') 71 + }) 72 + }) 73 + 74 + describe('parseJsDocLinks', () => { 75 + const emptyLookup: SymbolLookup = new Map() 76 + 77 + it('should convert external URLs to links', () => { 78 + const result = parseJsDocLinks('{@link https://example.com}', emptyLookup) 79 + expect(result).toContain('href="https://example.com"') 80 + expect(result).toContain('target="_blank"') 81 + expect(result).toContain('rel="noopener"') 82 + }) 83 + 84 + it('should handle external URLs with labels', () => { 85 + const result = parseJsDocLinks('{@link https://example.com Example Site}', emptyLookup) 86 + expect(result).toContain('href="https://example.com"') 87 + expect(result).toContain('>Example Site</a>') 88 + }) 89 + 90 + it('should convert internal symbol references to anchor links', () => { 91 + const lookup: SymbolLookup = new Map([['MyFunction', 'function-MyFunction']]) 92 + const result = parseJsDocLinks('{@link MyFunction}', lookup) 93 + expect(result).toContain('href="#function-MyFunction"') 94 + expect(result).toContain('docs-symbol-link') 95 + }) 96 + 97 + it('should render unknown symbols as code', () => { 98 + const result = parseJsDocLinks('{@link UnknownSymbol}', emptyLookup) 99 + expect(result).toContain('<code class="docs-symbol-ref">UnknownSymbol</code>') 100 + }) 101 + 102 + it('should escape HTML in surrounding text', () => { 103 + const result = parseJsDocLinks('Use <T> with {@link https://example.com}', emptyLookup) 104 + expect(result).toContain('&lt;T&gt;') 105 + }) 106 + 107 + it('should handle multiple links', () => { 108 + const result = parseJsDocLinks( 109 + 'See {@link https://a.com} and {@link https://b.com}', 110 + emptyLookup, 111 + ) 112 + expect(result).toContain('href="https://a.com"') 113 + expect(result).toContain('href="https://b.com"') 114 + }) 115 + 116 + it('should not convert non-http URLs to links', () => { 117 + const result = parseJsDocLinks('{@link javascript:alert(1)}', emptyLookup) 118 + // Should be treated as unknown symbol, not a link 119 + expect(result).not.toContain('href="javascript:') 120 + expect(result).toContain('<code') 121 + }) 122 + 123 + it('should handle http URLs (not just https)', () => { 124 + const result = parseJsDocLinks('{@link http://example.com}', emptyLookup) 125 + expect(result).toContain('href="http://example.com"') 126 + }) 127 + }) 128 + 129 + describe('renderMarkdown', () => { 130 + const emptyLookup: SymbolLookup = new Map() 131 + 132 + it('should convert inline code', async () => { 133 + const result = await renderMarkdown('Use `foo()` here', emptyLookup) 134 + expect(result).toContain('<code class="docs-inline-code">foo()</code>') 135 + }) 136 + 137 + it('should escape HTML inside inline code', async () => { 138 + const result = await renderMarkdown('Use `Array<T>` here', emptyLookup) 139 + expect(result).toContain('&lt;T&gt;') 140 + expect(result).not.toContain('<T>') 141 + }) 142 + 143 + it('should convert bold text', async () => { 144 + const result = await renderMarkdown('This is **important**', emptyLookup) 145 + expect(result).toContain('<strong>important</strong>') 146 + }) 147 + 148 + it('should convert single newlines to <br>', async () => { 149 + const result = await renderMarkdown('line 1\nline 2', emptyLookup) 150 + expect(result).toBe('line 1<br>line 2') 151 + }) 152 + 153 + it('should convert double newlines to <br><br>', async () => { 154 + const result = await renderMarkdown('paragraph 1\n\nparagraph 2', emptyLookup) 155 + expect(result).toBe('paragraph 1<br><br>paragraph 2') 156 + }) 157 + 158 + it('should handle multiple formatting in same text', async () => { 159 + const result = await renderMarkdown('Use `foo()` for **important** tasks', emptyLookup) 160 + expect(result).toContain('<code class="docs-inline-code">foo()</code>') 161 + expect(result).toContain('<strong>important</strong>') 162 + }) 163 + 164 + it('should process {@link} tags', async () => { 165 + const lookup: SymbolLookup = new Map([['MyFunc', 'function-MyFunc']]) 166 + const result = await renderMarkdown('See {@link MyFunc} for details', lookup) 167 + expect(result).toContain('href="#function-MyFunc"') 168 + }) 169 + 170 + it('should escape HTML in regular text', async () => { 171 + const result = await renderMarkdown('Returns <T> or null', emptyLookup) 172 + expect(result).toContain('&lt;T&gt;') 173 + }) 174 + 175 + it('should handle empty string', async () => { 176 + expect(await renderMarkdown('', emptyLookup)).toBe('') 177 + }) 178 + 179 + it('should handle text with only whitespace', async () => { 180 + const result = await renderMarkdown(' \n ', emptyLookup) 181 + expect(result).toBe(' <br> ') 182 + }) 183 + 184 + it('should syntax highlight fenced code blocks with Shiki', async () => { 185 + const input = '```ts\nconst x = 1;\n```' 186 + const result = await renderMarkdown(input, emptyLookup) 187 + // Shiki outputs use class="shiki" and have syntax highlighting spans 188 + expect(result).toContain('shiki') 189 + expect(result).toContain('const') 190 + expect(result).not.toContain('```') 191 + }) 192 + 193 + it('should handle fenced code blocks with CRLF line endings', async () => { 194 + const input = '```ts\r\nconst x = 1;\r\n```' 195 + const result = await renderMarkdown(input, emptyLookup) 196 + expect(result).toContain('shiki') 197 + expect(result).toContain('const') 198 + expect(result).not.toContain('```') 199 + }) 200 + 201 + it('should handle fenced code blocks with CR line endings', async () => { 202 + const input = '```ts\rconst x = 1;\r```' 203 + const result = await renderMarkdown(input, emptyLookup) 204 + expect(result).toContain('shiki') 205 + expect(result).toContain('const') 206 + expect(result).not.toContain('```') 207 + }) 208 + 209 + it('should handle fenced code blocks without language', async () => { 210 + const input = '```\nconst x = 1;\n```' 211 + const result = await renderMarkdown(input, emptyLookup) 212 + // Falls back to plain code block for unknown language 213 + expect(result).toContain('<pre>') 214 + expect(result).toContain('const x = 1;') 215 + }) 216 + 217 + it('should handle fenced code blocks with trailing whitespace after language', async () => { 218 + const input = '```ts \nconst x = 1;\n```' 219 + const result = await renderMarkdown(input, emptyLookup) 220 + expect(result).toContain('shiki') 221 + expect(result).toContain('const') 222 + }) 223 + 224 + it('should handle fenced code blocks with space before language', async () => { 225 + const input = '``` js\nconst x = 1;\n```' 226 + const result = await renderMarkdown(input, emptyLookup) 227 + expect(result).toContain('shiki') 228 + expect(result).toContain('const') 229 + expect(result).not.toContain('```') 230 + }) 231 + 232 + it('should escape HTML inside fenced code blocks', async () => { 233 + const input = '```ts\nconst arr: Array<string> = [];\n```' 234 + const result = await renderMarkdown(input, emptyLookup) 235 + // Shiki escapes < as &#x3C; (hex entity) 236 + expect(result).toContain('&#x3C;') 237 + // The raw < character shouldn't appear outside of HTML tags 238 + expect(result).not.toMatch(/<string>/) 239 + }) 240 + 241 + it('should handle multiple fenced code blocks', async () => { 242 + const input = '```ts\nconst a = 1\n```\n\nSome text\n\n```js\nconst b = 2\n```' 243 + const result = await renderMarkdown(input, emptyLookup) 244 + expect(result).toContain('Some text') 245 + // Both code blocks should be highlighted 246 + expect((result.match(/shiki/g) || []).length).toBe(2) 247 + }) 248 + 249 + it('should not confuse inline code with fenced code blocks', async () => { 250 + const input = 'Use `code` inline and:\n```ts\nblock code\n```' 251 + const result = await renderMarkdown(input, emptyLookup) 252 + expect(result).toContain('<code class="docs-inline-code">code</code>') 253 + expect(result).toContain('shiki') 254 + }) 255 + })
+126
test/unit/parse-package-param.spec.ts
··· 1 + import { describe, expect, it } from 'vitest' 2 + import { parsePackageParam } from '../../shared/utils/parse-package-param' 3 + 4 + describe('parsePackageParam', () => { 5 + describe('unscoped packages', () => { 6 + it('parses package name without version', () => { 7 + const result = parsePackageParam('vue') 8 + expect(result).toEqual({ 9 + packageName: 'vue', 10 + version: undefined, 11 + rest: [], 12 + }) 13 + }) 14 + 15 + it('parses package name with version', () => { 16 + const result = parsePackageParam('vue/v/3.4.0') 17 + expect(result).toEqual({ 18 + packageName: 'vue', 19 + version: '3.4.0', 20 + rest: [], 21 + }) 22 + }) 23 + 24 + it('parses package name with prerelease version', () => { 25 + const result = parsePackageParam('nuxt/v/4.0.0-rc.1') 26 + expect(result).toEqual({ 27 + packageName: 'nuxt', 28 + version: '4.0.0-rc.1', 29 + rest: [], 30 + }) 31 + }) 32 + 33 + it('parses package name with version and file path', () => { 34 + const result = parsePackageParam('vue/v/3.4.0/src/index.ts') 35 + expect(result).toEqual({ 36 + packageName: 'vue', 37 + version: '3.4.0', 38 + rest: ['src', 'index.ts'], 39 + }) 40 + }) 41 + 42 + it('parses package name with version and nested file path', () => { 43 + const result = parsePackageParam('lodash/v/4.17.21/lib/fp/map.js') 44 + expect(result).toEqual({ 45 + packageName: 'lodash', 46 + version: '4.17.21', 47 + rest: ['lib', 'fp', 'map.js'], 48 + }) 49 + }) 50 + }) 51 + 52 + describe('scoped packages', () => { 53 + it('parses scoped package name without version', () => { 54 + const result = parsePackageParam('@nuxt/kit') 55 + expect(result).toEqual({ 56 + packageName: '@nuxt/kit', 57 + version: undefined, 58 + rest: [], 59 + }) 60 + }) 61 + 62 + it('parses scoped package name with version', () => { 63 + const result = parsePackageParam('@nuxt/kit/v/1.0.0') 64 + expect(result).toEqual({ 65 + packageName: '@nuxt/kit', 66 + version: '1.0.0', 67 + rest: [], 68 + }) 69 + }) 70 + 71 + it('parses scoped package name with version and file path', () => { 72 + const result = parsePackageParam('@vue/compiler-sfc/v/3.5.0/dist/index.d.ts') 73 + expect(result).toEqual({ 74 + packageName: '@vue/compiler-sfc', 75 + version: '3.5.0', 76 + rest: ['dist', 'index.d.ts'], 77 + }) 78 + }) 79 + 80 + it('parses deeply nested scoped packages', () => { 81 + const result = parsePackageParam('@types/node/v/22.0.0') 82 + expect(result).toEqual({ 83 + packageName: '@types/node', 84 + version: '22.0.0', 85 + rest: [], 86 + }) 87 + }) 88 + }) 89 + 90 + describe('edge cases', () => { 91 + it('handles package name that looks like a version marker', () => { 92 + // Package named "v" shouldn't be confused with version separator 93 + const result = parsePackageParam('v') 94 + expect(result).toEqual({ 95 + packageName: 'v', 96 + version: undefined, 97 + rest: [], 98 + }) 99 + }) 100 + 101 + it('handles version segment without actual version', () => { 102 + // "v" at the end without a version after it 103 + const result = parsePackageParam('vue/v') 104 + expect(result).toEqual({ 105 + packageName: 'vue/v', 106 + version: undefined, 107 + rest: [], 108 + }) 109 + }) 110 + 111 + it('handles package with "v" in the name followed by version', () => { 112 + const result = parsePackageParam('vueuse/v/12.0.0') 113 + expect(result).toEqual({ 114 + packageName: 'vueuse', 115 + version: '12.0.0', 116 + rest: [], 117 + }) 118 + }) 119 + 120 + it('handles empty rest when file path is empty', () => { 121 + const result = parsePackageParam('react/v/18.2.0') 122 + expect(result.rest).toEqual([]) 123 + expect(result.rest.length).toBe(0) 124 + }) 125 + }) 126 + })
+114
test/unit/shiki.spec.ts
··· 1 + import { describe, expect, it } from 'vitest' 2 + import { escapeRawGt, highlightCodeBlock } from '../../server/utils/shiki' 3 + 4 + describe('escapeRawGt', () => { 5 + it('should encode > in arrow functions', () => { 6 + const input = '<span style="color:#F97583">=></span>' 7 + const output = escapeRawGt(input) 8 + expect(output).toBe('<span style="color:#F97583">=&gt;</span>') 9 + }) 10 + 11 + it('should encode > in comparison operators', () => { 12 + const input = '<span>x > 5</span>' 13 + const output = escapeRawGt(input) 14 + expect(output).toBe('<span>x &gt; 5</span>') 15 + }) 16 + 17 + it('should encode multiple > in text content', () => { 18 + const input = '<span>a > b > c</span>' 19 + const output = escapeRawGt(input) 20 + expect(output).toBe('<span>a &gt; b &gt; c</span>') 21 + }) 22 + 23 + it('should not affect HTML tag structure', () => { 24 + const input = '<span class="test"><code>text</code></span>' 25 + const output = escapeRawGt(input) 26 + expect(output).toBe('<span class="test"><code>text</code></span>') 27 + }) 28 + 29 + it('should not affect attributes containing >', () => { 30 + // Attributes with > are already encoded by Shiki, but test anyway 31 + const input = '<span title="a &gt; b">text</span>' 32 + const output = escapeRawGt(input) 33 + expect(output).toBe('<span title="a &gt; b">text</span>') 34 + }) 35 + 36 + it('should handle empty text content', () => { 37 + const input = '<span></span><code></code>' 38 + const output = escapeRawGt(input) 39 + expect(output).toBe('<span></span><code></code>') 40 + }) 41 + 42 + it('should handle text without special characters', () => { 43 + const input = '<span>hello world</span>' 44 + const output = escapeRawGt(input) 45 + expect(output).toBe('<span>hello world</span>') 46 + }) 47 + 48 + it('should handle nested spans (Shiki output structure)', () => { 49 + const input = 50 + '<span class="line"><span style="color:#F97583">const</span><span> x = () =></span><span> 5</span></span>' 51 + const output = escapeRawGt(input) 52 + expect(output).toBe( 53 + '<span class="line"><span style="color:#F97583">const</span><span> x = () =&gt;</span><span> 5</span></span>', 54 + ) 55 + }) 56 + 57 + it('should handle >= operator', () => { 58 + const input = '<span>x >= 5</span>' 59 + const output = escapeRawGt(input) 60 + expect(output).toBe('<span>x &gt;= 5</span>') 61 + }) 62 + 63 + it('should handle generic type syntax', () => { 64 + const input = '<span>Array&lt;T></span>' 65 + const output = escapeRawGt(input) 66 + // The < is already encoded, the > should be encoded 67 + expect(output).toBe('<span>Array&lt;T&gt;</span>') 68 + }) 69 + }) 70 + 71 + describe('highlightCodeBlock', () => { 72 + it('should highlight TypeScript code', async () => { 73 + const code = 'const x = 1' 74 + const html = await highlightCodeBlock(code, 'typescript') 75 + 76 + expect(html).toContain('<pre') 77 + expect(html).toContain('const') 78 + expect(html).toContain('shiki') 79 + }) 80 + 81 + it('should encode > in arrow functions', async () => { 82 + const code = 'const fn = () => 5' 83 + const html = await highlightCodeBlock(code, 'typescript') 84 + 85 + // The > in => should be encoded 86 + expect(html).toContain('=&gt;') 87 + expect(html).not.toMatch(/=>(?!&)/) // no raw => (except in &gt;) 88 + }) 89 + 90 + it('should encode > in generic types', async () => { 91 + const code = 'const x: Array<string> = []' 92 + const html = await highlightCodeBlock(code, 'typescript') 93 + 94 + // Should have encoded > 95 + expect(html).toContain('&gt;') 96 + }) 97 + 98 + it('should fall back to plain code for unknown languages', async () => { 99 + const code = 'some random code > with special < chars' 100 + const html = await highlightCodeBlock(code, 'unknownlang123') 101 + 102 + expect(html).toContain('&gt;') 103 + expect(html).toContain('&lt;') 104 + expect(html).toContain('language-unknownlang123') 105 + }) 106 + 107 + it('should escape special characters in fallback', async () => { 108 + const code = '<script>alert("xss")</script>' 109 + const html = await highlightCodeBlock(code, 'unknownlang123') 110 + 111 + expect(html).toContain('&lt;script&gt;') 112 + expect(html).not.toContain('<script>') 113 + }) 114 + })
+186
tests/docs.spec.ts
··· 1 + import { expect, test } from '@nuxt/test-utils/playwright' 2 + 3 + test.describe('API Documentation Pages', () => { 4 + test('docs page loads and shows content for a package', async ({ page, goto }) => { 5 + // Use a small, stable package with TypeScript types 6 + await goto('/docs/ufo/v/1.6.3', { waitUntil: 'networkidle' }) 7 + 8 + // Page title should include package name 9 + await expect(page).toHaveTitle(/ufo.*docs/i) 10 + 11 + // Header should show package name and version 12 + await expect(page.locator('header').getByText('ufo')).toBeVisible() 13 + await expect(page.locator('header').getByText('1.6.3')).toBeVisible() 14 + 15 + // API Docs badge should be visible 16 + await expect(page.locator('text=API Docs')).toBeVisible() 17 + 18 + // Should have documentation content 19 + const docsContent = page.locator('.docs-content') 20 + await expect(docsContent).toBeVisible() 21 + 22 + // Should have at least one function documented 23 + await expect(page.locator('.docs-badge--function').first()).toBeVisible() 24 + }) 25 + 26 + test('docs page shows TOC sidebar on desktop', async ({ page, goto }) => { 27 + await goto('/docs/ufo/v/1.6.3', { waitUntil: 'networkidle' }) 28 + 29 + // TOC sidebar should be visible (on desktop viewport) 30 + const tocSidebar = page.locator('aside') 31 + await expect(tocSidebar).toBeVisible() 32 + 33 + // Should have "Contents" heading 34 + await expect(tocSidebar.getByText('Contents')).toBeVisible() 35 + 36 + // Should have section links (Functions, etc.) 37 + await expect(tocSidebar.locator('a[href="#section-function"]')).toBeVisible() 38 + }) 39 + 40 + test('TOC links navigate to sections', async ({ page, goto }) => { 41 + await goto('/docs/ufo/v/1.6.3', { waitUntil: 'networkidle' }) 42 + 43 + // Click on Functions in TOC 44 + const functionsLink = page.locator('aside a[href="#section-function"]') 45 + await functionsLink.click() 46 + 47 + // URL should have the hash 48 + await expect(page).toHaveURL(/#section-function/) 49 + 50 + // Section should be scrolled into view 51 + const functionSection = page.locator('#section-function') 52 + await expect(functionSection).toBeInViewport() 53 + }) 54 + 55 + test('clicking symbol name scrolls to symbol', async ({ page, goto }) => { 56 + await goto('/docs/ufo/v/1.6.3', { waitUntil: 'networkidle' }) 57 + 58 + // Find a symbol link in the TOC 59 + const symbolLink = page.locator('aside a[href^="#function-"]').first() 60 + const href = await symbolLink.getAttribute('href') 61 + 62 + // Click the symbol link 63 + await symbolLink.click() 64 + 65 + // URL should have the hash 66 + await expect(page).toHaveURL(new RegExp(href!.replace('#', '#'))) 67 + }) 68 + 69 + test('docs page without version redirects to latest', async ({ page, goto }) => { 70 + await goto('/docs/ufo', { waitUntil: 'networkidle' }) 71 + 72 + // Should redirect to include version 73 + await expect(page).toHaveURL(/\/docs\/ufo\/v\//) 74 + }) 75 + 76 + test('package link in header navigates to package page', async ({ page, goto }) => { 77 + await goto('/docs/ufo/v/1.6.3', { waitUntil: 'networkidle' }) 78 + 79 + // Click on package name in header 80 + const packageLink = page.locator('header a').filter({ hasText: 'ufo' }) 81 + await packageLink.click() 82 + 83 + // Should navigate to package page (URL ends with /ufo) 84 + await expect(page).toHaveURL(/\/ufo$/) 85 + }) 86 + 87 + test('docs page handles package gracefully when types unavailable', async ({ page, goto }) => { 88 + // Use a simple JS package - the page should load without crashing 89 + // regardless of whether it has types or shows an error state 90 + await goto('/docs/is-odd/v/3.0.1', { waitUntil: 'networkidle' }) 91 + 92 + // Header should always show the package name 93 + await expect(page.locator('header').getByText('is-odd')).toBeVisible() 94 + 95 + // Page should be in one of two states: 96 + // 1. Shows "not available" / error message 97 + // 2. Shows actual docs content (if types were found) 98 + const errorState = page.locator('text=/not available|could not generate/i') 99 + const docsContent = page.locator('.docs-content') 100 + 101 + // One of these should be visible 102 + await expect(errorState.or(docsContent)).toBeVisible() 103 + }) 104 + }) 105 + 106 + test.describe('Version Selector', () => { 107 + test('version selector dropdown shows versions', async ({ page, goto }) => { 108 + await goto('/docs/ufo/v/1.6.3', { waitUntil: 'networkidle' }) 109 + 110 + // Find and click the version selector button 111 + const versionButton = page.locator('header button').filter({ hasText: '1.6.3' }) 112 + 113 + // Skip if version selector not present (data might not be loaded) 114 + if (!(await versionButton.isVisible())) { 115 + test.skip() 116 + return 117 + } 118 + 119 + await versionButton.click() 120 + 121 + // Dropdown should appear with version options 122 + const dropdown = page.locator('[role="listbox"]') 123 + await expect(dropdown).toBeVisible() 124 + 125 + // Should show multiple versions 126 + const versionOptions = dropdown.locator('[role="option"]') 127 + await expect(versionOptions.first()).toBeVisible() 128 + }) 129 + 130 + test('selecting a version navigates to that version', async ({ page, goto }) => { 131 + await goto('/docs/ufo/v/1.6.3', { waitUntil: 'networkidle' }) 132 + 133 + // Find and click the version selector button 134 + const versionButton = page.locator('header button').filter({ hasText: '1.6.3' }) 135 + 136 + // Skip if version selector not present 137 + if (!(await versionButton.isVisible())) { 138 + test.skip() 139 + return 140 + } 141 + 142 + await versionButton.click() 143 + 144 + // Find a different version and click it 145 + const differentVersion = page.locator('[role="option"]').filter({ hasNotText: '1.6.3' }).first() 146 + 147 + // Skip if no other versions available 148 + if (!(await differentVersion.isVisible())) { 149 + test.skip() 150 + return 151 + } 152 + 153 + const versionText = await differentVersion.textContent() 154 + await differentVersion.click() 155 + 156 + // URL should change to the new version 157 + if (versionText) { 158 + const versionMatch = versionText.match(/\d+\.\d+\.\d+/) 159 + if (versionMatch) { 160 + await expect(page).toHaveURL(new RegExp(`/docs/ufo/v/${versionMatch[0]}`)) 161 + } 162 + } 163 + }) 164 + 165 + test('escape key closes version dropdown', async ({ page, goto }) => { 166 + await goto('/docs/ufo/v/1.6.3', { waitUntil: 'networkidle' }) 167 + 168 + const versionButton = page.locator('header button').filter({ hasText: '1.6.3' }) 169 + 170 + if (!(await versionButton.isVisible())) { 171 + test.skip() 172 + return 173 + } 174 + 175 + await versionButton.click() 176 + 177 + const dropdown = page.locator('[role="listbox"]') 178 + await expect(dropdown).toBeVisible() 179 + 180 + // Press escape 181 + await page.keyboard.press('Escape') 182 + 183 + // Dropdown should close 184 + await expect(dropdown).not.toBeVisible() 185 + }) 186 + })