Skip to content

Commit f58ffc8

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 f58ffc8

File tree

8 files changed

+655
-29
lines changed

8 files changed

+655
-29
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: 32 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,30 @@ 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+
const entrypointFields = entrypoints ? { entrypoints, entrypoint } : {}
33+
34+
// If multi-entrypoint but no specific entrypoint requested, return early
35+
// with the entrypoints list so the client can redirect to the first one
36+
if (entrypoints && !entrypoint) {
37+
return {
38+
package: packageName,
39+
version,
40+
html: '',
41+
toc: null,
42+
status: 'ok',
43+
entrypoints,
44+
entrypoint: entrypoints[0],
45+
} satisfies DocsResponse
46+
}
47+
2248
let generated
2349
try {
24-
generated = await generateDocsWithDeno(packageName, version)
50+
generated = await generateDocsWithDeno(packageName, version, entrypoint)
2551
} catch (error) {
2652
// eslint-disable-next-line no-console
2753
console.error(`Doc generation failed for ${packageName}@${version}:`, error)
@@ -32,6 +58,7 @@ export default defineCachedEventHandler(
3258
toc: null,
3359
status: 'error',
3460
message: 'Failed to generate documentation. Please try again later.',
61+
...entrypointFields,
3562
} satisfies DocsResponse
3663
}
3764

@@ -43,6 +70,7 @@ export default defineCachedEventHandler(
4370
toc: null,
4471
status: 'missing',
4572
message: 'Docs are not available for this package. It may not have TypeScript types.',
73+
...entrypointFields,
4674
} satisfies DocsResponse
4775
}
4876

@@ -52,14 +80,15 @@ export default defineCachedEventHandler(
5280
html: generated.html,
5381
toc: generated.toc,
5482
status: 'ok',
83+
...entrypointFields,
5584
} satisfies DocsResponse
5685
},
5786
{
5887
maxAge: 60 * 60, // 1 hour cache
5988
swr: true,
6089
getKey: event => {
6190
const pkg = getRouterParam(event, 'pkg') ?? ''
62-
return `docs:v2:${pkg}`
91+
return `docs:v3:${pkg}`
6392
},
6493
},
6594
)

server/utils/docs/client.ts

Lines changed: 105 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,23 @@ 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)
35+
return runDoc(typesUrls)
36+
}
3137

32-
if (!typesUrl) {
38+
/**
39+
* Run @deno/doc on a list of types URLs and collect all resulting nodes.
40+
*/
41+
async function runDoc(typesUrls: string[]): Promise<DenoDocResult> {
42+
if (typesUrls.length === 0) {
3343
return { version: 1, nodes: [] }
3444
}
3545

3646
// Generate docs using @deno/doc WASM
3747
let result: Record<string, DocNode[]>
3848
try {
39-
result = await doc([typesUrl], {
49+
result = await doc(typesUrls, {
4050
load: createLoader(),
4151
resolve: createResolver(),
4252
})
@@ -153,25 +163,111 @@ function createResolver(): (specifier: string, referrer: string) => string {
153163
}
154164
}
155165

166+
/**
167+
* Get TypeScript types URLs for a package, trying the root entry first,
168+
* then falling back to subpath exports if the package has no default export.
169+
*/
170+
async function getTypesUrls(packageName: string, version: string): Promise<string[]> {
171+
// Try root entry first
172+
const rootTypesUrl = await getTypesUrlForSubpath(packageName, version)
173+
if (rootTypesUrl) {
174+
return [rootTypesUrl]
175+
}
176+
177+
// Root has no types — check subpath exports from the npm registry
178+
const subpaths = await getSubpathExports(packageName, version)
179+
if (subpaths.length === 0) {
180+
return []
181+
}
182+
183+
// Fetch types URLs for each subpath export in parallel
184+
const results = await Promise.all(
185+
subpaths.map(subpath => getTypesUrlForSubpath(packageName, version, subpath)),
186+
)
187+
188+
return results.filter((url): url is string => url !== null)
189+
}
190+
191+
/**
192+
* Get documentation nodes for a specific subpath export of a package.
193+
*/
194+
export async function getDocNodesForEntrypoint(
195+
packageName: string,
196+
version: string,
197+
entrypoint: string,
198+
): Promise<DenoDocResult> {
199+
const typesUrl = await getTypesUrlForSubpath(packageName, version, entrypoint)
200+
return runDoc(typesUrl ? [typesUrl] : [])
201+
}
202+
156203
/**
157204
* Get the TypeScript types URL from esm.sh's x-typescript-types header.
158205
*
159206
* esm.sh serves types URL in the `x-typescript-types` header, not at the main URL.
160207
* Example: curl -sI 'https://esm.sh/ufo@1.5.0' returns header:
161208
* x-typescript-types: https://esm.sh/ufo@1.5.0/dist/index.d.ts
162209
*/
163-
async function getTypesUrl(packageName: string, version: string): Promise<string | null> {
164-
const url = `https://esm.sh/${packageName}@${version}`
210+
export async function getTypesUrlForSubpath(
211+
packageName: string,
212+
version: string,
213+
subpath?: string,
214+
): Promise<string | null> {
215+
const url = subpath
216+
? `https://esm.sh/${packageName}@${version}/${subpath}`
217+
: `https://esm.sh/${packageName}@${version}`
165218

166219
try {
167220
const response = await $fetch.raw(url, {
168221
method: 'HEAD',
169222
timeout: FETCH_TIMEOUT_MS,
170223
})
171224
return response.headers.get('x-typescript-types')
172-
} catch (e) {
173-
// eslint-disable-next-line no-console
174-
console.error(e)
225+
} catch {
175226
return null
176227
}
177228
}
229+
230+
/**
231+
* Get subpath export paths from the npm registry's package.json `exports` field.
232+
* Only returns subpaths that declare types (have a `types` condition).
233+
*
234+
* Skips the root export (".") since that's handled by the main getTypesUrl call.
235+
* Skips wildcard patterns ("./foo/*") since they can't be resolved to specific files.
236+
*/
237+
export async function getSubpathExports(packageName: string, version: string): Promise<string[]> {
238+
try {
239+
const encodedName = encodePackageName(packageName)
240+
const pkgJson = await $fetch<Record<string, unknown>>(
241+
`https://registry.npmjs.org/${encodedName}/${version}`,
242+
{ timeout: FETCH_TIMEOUT_MS },
243+
)
244+
245+
const exports = pkgJson.exports
246+
if (!exports || typeof exports !== 'object') {
247+
return []
248+
}
249+
250+
const subpaths: string[] = []
251+
252+
for (const [key, value] of Object.entries(exports as Record<string, unknown>)) {
253+
// Skip root export (already tried), non-subpath entries, and wildcards
254+
if (key === '.' || !key.startsWith('./') || key.includes('*')) {
255+
continue
256+
}
257+
258+
// Only include exports that declare types
259+
if (value && typeof value === 'object' && 'types' in value) {
260+
// Strip leading "./" for the esm.sh URL
261+
subpaths.push(key.slice(2))
262+
}
263+
264+
if (subpaths.length >= MAX_SUBPATH_EXPORTS) {
265+
break
266+
}
267+
}
268+
269+
return subpaths
270+
} catch {
271+
return []
272+
}
273+
}

0 commit comments

Comments
 (0)