Skip to content

Commit 3003b60

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 f70a11b commit 3003b60

File tree

12 files changed

+785
-31
lines changed

12 files changed

+785
-31
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="$t('package.docs.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

i18n/locales/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -448,7 +448,8 @@
448448
"page_title_name": "{name} docs - npmx",
449449
"page_title_version": "{name} docs - npmx",
450450
"og_title": "{name} - Docs",
451-
"view_package": "View package"
451+
"view_package": "View package",
452+
"select_entrypoint": "Select entrypoint"
452453
},
453454
"get_started": {
454455
"title": "Get started",

i18n/locales/fr-FR.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -446,7 +446,8 @@
446446
"page_title_name": "Documentation {name} - npmx",
447447
"page_title_version": "Documentation {name} - npmx",
448448
"og_title": "{name} - Documentation",
449-
"view_package": "Voir le paquet"
449+
"view_package": "Voir le paquet",
450+
"select_entrypoint": "Sélectionner le point d'entrée"
450451
},
451452
"get_started": {
452453
"title": "Commencer",

i18n/schema.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1350,6 +1350,9 @@
13501350
},
13511351
"view_package": {
13521352
"type": "string"
1353+
},
1354+
"select_entrypoint": {
1355+
"type": "string"
13531356
}
13541357
},
13551358
"additionalProperties": false

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
)

0 commit comments

Comments
 (0)