Skip to content

Commit bc292b9

Browse files
committed
feat: add per-entrypoint API docs pages for multi-export packages
Packages with only subpath exports (no root export) previously got no docs because esm.sh returns 404 for their root URL. Fix by falling back to the npm registry field to discover typed subpath entries. Additionally, multi-entrypoint packages now get separate docs pages per subpath with an EntrypointSelector dropdown, instead of dumping all symbols into one flat page. The base URL redirects to the first entrypoint. URL structure: /package-docs/{pkg}/v/{version}/{entrypoint} Closes #1479
1 parent f88bbcb commit bc292b9

File tree

8 files changed

+668
-28
lines changed

8 files changed

+668
-28
lines changed
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<script setup lang="ts">
2+
const props = defineProps<{
3+
packageName: string
4+
version: string
5+
currentEntrypoint: string
6+
entrypoints: string[]
7+
}>()
8+
9+
function getEntrypointUrl(entrypoint: string): string {
10+
return `/package-docs/${props.packageName}/v/${props.version}/${entrypoint}`
11+
}
12+
13+
function onSelect(event: Event) {
14+
const target = event.target as HTMLSelectElement
15+
navigateTo(getEntrypointUrl(target.value))
16+
}
17+
</script>
18+
19+
<template>
20+
<select
21+
:value="currentEntrypoint"
22+
aria-label="Select entrypoint"
23+
class="text-fg-subtle font-mono text-sm bg-transparent border border-border rounded px-2 py-1 hover:text-fg hover:border-border-subtle transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring shrink-0"
24+
@change="onSelect"
25+
>
26+
<option v-for="ep in entrypoints" :key="ep" :value="ep">./{{ ep }}</option>
27+
</select>
28+
</template>

app/pages/package-docs/[...path].vue

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,17 +20,26 @@ const parsedRoute = computed(() => {
2020
return {
2121
packageName: segments.join('/'),
2222
version: null as string | null,
23+
entrypoint: null as string | null,
2324
}
2425
}
2526
27+
// Version is the segment right after "v"
28+
const version = segments[vIndex + 1]!
29+
// Everything after the version is the entrypoint path (e.g., "router.js")
30+
const entrypointSegments = segments.slice(vIndex + 2)
31+
const entrypoint = entrypointSegments.length > 0 ? entrypointSegments.join('/') : null
32+
2633
return {
2734
packageName: segments.slice(0, vIndex).join('/'),
28-
version: segments.slice(vIndex + 1).join('/'),
35+
version,
36+
entrypoint,
2937
}
3038
})
3139
3240
const packageName = computed(() => parsedRoute.value.packageName)
3341
const requestedVersion = computed(() => parsedRoute.value.version)
42+
const entrypoint = computed(() => parsedRoute.value.entrypoint)
3443
3544
// Validate package name on server-side for early error detection
3645
if (import.meta.server && packageName.value) {
@@ -90,7 +99,8 @@ useCommandPalettePackageCommands(commandPalettePackageContext)
9099
91100
const docsUrl = computed(() => {
92101
if (!packageName.value || !resolvedVersion.value) return null
93-
return `/api/registry/docs/${packageName.value}/v/${resolvedVersion.value}`
102+
const base = `/api/registry/docs/${packageName.value}/v/${resolvedVersion.value}`
103+
return entrypoint.value ? `${base}/${entrypoint.value}` : base
94104
})
95105
96106
const shouldFetch = computed(() => !!docsUrl.value)
@@ -119,9 +129,10 @@ const latestVersionDetailed = computed(() => {
119129
return pkg.value.versions[latestTag] ?? null
120130
})
121131
122-
const versionUrlPattern = computed(
123-
() => `/package-docs/${pkg.value?.name || packageName.value}/v/{version}`,
124-
)
132+
const versionUrlPattern = computed(() => {
133+
const base = `/package-docs/${pkg.value?.name || packageName.value}/v/{version}`
134+
return entrypoint.value ? `${base}/${entrypoint.value}` : base
135+
})
125136
126137
useCommandPaletteVersionCommands(commandPalettePackageContext, versionUrlPattern)
127138
@@ -159,6 +170,27 @@ const stickyStyle = computed(() => {
159170
'--combined-header-height': `${56 + (packageHeaderHeight.value || 44)}px`,
160171
}
161172
})
173+
174+
// Multi-entrypoint support
175+
const entrypoints = computed(() => docsData.value?.entrypoints ?? null)
176+
const currentEntrypoint = computed(() => docsData.value?.entrypoint ?? entrypoint.value ?? '')
177+
178+
// Redirect to first entrypoint for multi-entrypoint packages
179+
watch(docsData, data => {
180+
if (data?.entrypoints?.length && !entrypoint.value && resolvedVersion.value) {
181+
const firstEntrypoint = data.entrypoints[0]!
182+
const pathSegments = [
183+
...packageName.value.split('/'),
184+
'v',
185+
resolvedVersion.value,
186+
...firstEntrypoint.split('/'),
187+
]
188+
router.replace({
189+
name: 'docs',
190+
params: { path: pathSegments as [string, ...string[]] },
191+
})
192+
}
193+
})
162194
</script>
163195

164196
<template>
@@ -172,6 +204,18 @@ const stickyStyle = computed(() => {
172204
page="docs"
173205
/>
174206

207+
<div
208+
v-if="entrypoints && currentEntrypoint && resolvedVersion"
209+
class="container py-2 border-b border-border"
210+
>
211+
<EntrypointSelector
212+
:package-name="packageName"
213+
:version="resolvedVersion"
214+
:current-entrypoint="currentEntrypoint"
215+
:entrypoints="entrypoints"
216+
/>
217+
</div>
218+
175219
<div class="flex" dir="ltr">
176220
<!-- Sidebar TOC -->
177221
<aside

server/api/registry/docs/[...pkg].get.ts

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
import type { DocsResponse } from '#shared/types'
2+
import { assertValidPackageName } from '#shared/utils/npm'
3+
import { parsePackageParam } from '#shared/utils/parse-package-param'
4+
import { generateDocsWithDeno, getEntrypoints } from '#server/utils/docs'
5+
16
export default defineCachedEventHandler(
27
async event => {
38
const pkgParam = getRouterParam(event, 'pkg')
@@ -6,7 +11,7 @@ export default defineCachedEventHandler(
611
throw createError({ statusCode: 404, message: 'Package name is required' })
712
}
813

9-
const { packageName, version } = parsePackageParam(pkgParam)
14+
const { packageName, version, rest } = parsePackageParam(pkgParam)
1015

1116
if (!packageName) {
1217
// TODO: throwing 404 rather than 400 as it's cacheable
@@ -19,9 +24,29 @@ export default defineCachedEventHandler(
1924
throw createError({ statusCode: 404, message: 'Package version is required' })
2025
}
2126

27+
// Extract entrypoint from remaining path segments (e.g., ["router.js"] -> "router.js")
28+
const entrypoint = rest.length > 0 ? rest.join('/') : undefined
29+
30+
// Discover available entrypoints (null for single-entrypoint packages)
31+
const entrypoints = await getEntrypoints(packageName, version)
32+
33+
// If multi-entrypoint but no specific entrypoint requested, return early
34+
// with the entrypoints list so the client can redirect to the first one
35+
if (entrypoints && !entrypoint) {
36+
return {
37+
package: packageName,
38+
version,
39+
html: '',
40+
toc: null,
41+
status: 'ok',
42+
entrypoints,
43+
entrypoint: entrypoints[0],
44+
} satisfies DocsResponse
45+
}
46+
2247
let generated
2348
try {
24-
generated = await generateDocsWithDeno(packageName, version)
49+
generated = await generateDocsWithDeno(packageName, version, entrypoint)
2550
} catch (error) {
2651
// eslint-disable-next-line no-console
2752
console.error(`Doc generation failed for ${packageName}@${version}:`, error)
@@ -32,6 +57,7 @@ export default defineCachedEventHandler(
3257
toc: null,
3358
status: 'error',
3459
message: 'Failed to generate documentation. Please try again later.',
60+
...(entrypoints && { entrypoints, entrypoint }),
3561
} satisfies DocsResponse
3662
}
3763

@@ -43,6 +69,7 @@ export default defineCachedEventHandler(
4369
toc: null,
4470
status: 'missing',
4571
message: 'Docs are not available for this package. It may not have TypeScript types.',
72+
...(entrypoints && { entrypoints, entrypoint }),
4673
} satisfies DocsResponse
4774
}
4875

@@ -52,14 +79,15 @@ export default defineCachedEventHandler(
5279
html: generated.html,
5380
toc: generated.toc,
5481
status: 'ok',
82+
...(entrypoints && { entrypoints, entrypoint }),
5583
} satisfies DocsResponse
5684
},
5785
{
5886
maxAge: 60 * 60, // 1 hour cache
5987
swr: true,
6088
getKey: event => {
6189
const pkg = getRouterParam(event, 'pkg') ?? ''
62-
return `docs:v2:${pkg}`
90+
return `docs:v3:${pkg}`
6391
},
6492
},
6593
)

server/utils/docs/client.ts

Lines changed: 119 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import { doc, type DocNode } from '@deno/doc'
1111
import type { DenoDocNode, DenoDocResult } from '#shared/types/deno-doc'
1212
import { isBuiltin } from 'node:module'
13+
import { encodePackageName } from '#shared/utils/npm'
1314

1415
// =============================================================================
1516
// Configuration
@@ -18,6 +19,9 @@ import { isBuiltin } from 'node:module'
1819
/** Timeout for fetching modules in milliseconds */
1920
const FETCH_TIMEOUT_MS = 30 * 1000
2021

22+
/** Maximum number of subpath exports to process */
23+
const MAX_SUBPATH_EXPORTS = 20
24+
2125
// =============================================================================
2226
// Main Export
2327
// =============================================================================
@@ -26,17 +30,17 @@ const FETCH_TIMEOUT_MS = 30 * 1000
2630
* Get documentation nodes for a package using @deno/doc WASM.
2731
*/
2832
export async function getDocNodes(packageName: string, version: string): Promise<DenoDocResult> {
29-
// Get types URL from esm.sh header
30-
const typesUrl = await getTypesUrl(packageName, version)
33+
// Get types URL from esm.sh header for the root entry
34+
const typesUrls = await getTypesUrls(packageName, version)
3135

32-
if (!typesUrl) {
36+
if (typesUrls.length === 0) {
3337
return { version: 1, nodes: [] }
3438
}
3539

3640
// Generate docs using @deno/doc WASM
3741
let result: Record<string, DocNode[]>
3842
try {
39-
result = await doc([typesUrl], {
43+
result = await doc(typesUrls, {
4044
load: createLoader(),
4145
resolve: createResolver(),
4246
})
@@ -153,25 +157,131 @@ function createResolver(): (specifier: string, referrer: string) => string {
153157
}
154158
}
155159

160+
/**
161+
* Get TypeScript types URLs for a package, trying the root entry first,
162+
* then falling back to subpath exports if the package has no default export.
163+
*/
164+
async function getTypesUrls(packageName: string, version: string): Promise<string[]> {
165+
// Try root entry first
166+
const rootTypesUrl = await getTypesUrlForSubpath(packageName, version)
167+
if (rootTypesUrl) {
168+
return [rootTypesUrl]
169+
}
170+
171+
// Root has no types — check subpath exports from the npm registry
172+
const subpaths = await getSubpathExports(packageName, version)
173+
if (subpaths.length === 0) {
174+
return []
175+
}
176+
177+
// Fetch types URLs for each subpath export in parallel
178+
const results = await Promise.all(
179+
subpaths.map(subpath => getTypesUrlForSubpath(packageName, version, subpath)),
180+
)
181+
182+
return results.filter((url): url is string => url !== null)
183+
}
184+
185+
/**
186+
* Get documentation nodes for a specific subpath export of a package.
187+
*/
188+
export async function getDocNodesForEntrypoint(
189+
packageName: string,
190+
version: string,
191+
entrypoint: string,
192+
): Promise<DenoDocResult> {
193+
const typesUrl = await getTypesUrlForSubpath(packageName, version, entrypoint)
194+
195+
if (!typesUrl) {
196+
return { version: 1, nodes: [] }
197+
}
198+
199+
let result: Record<string, DocNode[]>
200+
try {
201+
result = await doc([typesUrl], {
202+
load: createLoader(),
203+
resolve: createResolver(),
204+
})
205+
} catch {
206+
return { version: 1, nodes: [] }
207+
}
208+
209+
const allNodes: DenoDocNode[] = []
210+
for (const nodes of Object.values(result)) {
211+
allNodes.push(...(nodes as DenoDocNode[]))
212+
}
213+
214+
return { version: 1, nodes: allNodes }
215+
}
216+
156217
/**
157218
* Get the TypeScript types URL from esm.sh's x-typescript-types header.
158219
*
159220
* esm.sh serves types URL in the `x-typescript-types` header, not at the main URL.
160221
* Example: curl -sI 'https://esm.sh/ufo@1.5.0' returns header:
161222
* x-typescript-types: https://esm.sh/ufo@1.5.0/dist/index.d.ts
162223
*/
163-
async function getTypesUrl(packageName: string, version: string): Promise<string | null> {
164-
const url = `https://esm.sh/${packageName}@${version}`
224+
export async function getTypesUrlForSubpath(
225+
packageName: string,
226+
version: string,
227+
subpath?: string,
228+
): Promise<string | null> {
229+
const url = subpath
230+
? `https://esm.sh/${packageName}@${version}/${subpath}`
231+
: `https://esm.sh/${packageName}@${version}`
165232

166233
try {
167234
const response = await $fetch.raw(url, {
168235
method: 'HEAD',
169236
timeout: FETCH_TIMEOUT_MS,
170237
})
171238
return response.headers.get('x-typescript-types')
172-
} catch (e) {
173-
// eslint-disable-next-line no-console
174-
console.error(e)
239+
} catch {
175240
return null
176241
}
177242
}
243+
244+
/**
245+
* Get subpath export paths from the npm registry's package.json `exports` field.
246+
* Only returns subpaths that declare types (have a `types` condition).
247+
*
248+
* Skips the root export (".") since that's handled by the main getTypesUrl call.
249+
* Skips wildcard patterns ("./foo/*") since they can't be resolved to specific files.
250+
*/
251+
export async function getSubpathExports(packageName: string, version: string): Promise<string[]> {
252+
try {
253+
const encodedName = encodePackageName(packageName)
254+
const pkgJson = await $fetch<Record<string, unknown>>(
255+
`https://registry.npmjs.org/${encodedName}/${version}`,
256+
{ timeout: FETCH_TIMEOUT_MS },
257+
)
258+
259+
const exports = pkgJson.exports
260+
if (!exports || typeof exports !== 'object') {
261+
return []
262+
}
263+
264+
const subpaths: string[] = []
265+
266+
for (const [key, value] of Object.entries(exports as Record<string, unknown>)) {
267+
// Skip root export (already tried), non-subpath entries, and wildcards
268+
if (key === '.' || !key.startsWith('./') || key.includes('*')) {
269+
continue
270+
}
271+
272+
// Only include exports that declare types
273+
if (value && typeof value === 'object' && 'types' in value) {
274+
// Strip leading "./" for the esm.sh URL
275+
subpaths.push(key.slice(2))
276+
}
277+
278+
if (subpaths.length >= MAX_SUBPATH_EXPORTS) {
279+
break
280+
}
281+
}
282+
283+
return subpaths
284+
} catch {
285+
return []
286+
}
287+
}

0 commit comments

Comments
 (0)