Skip to content

Commit 265e942

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 ebcfc01 commit 265e942

14 files changed

Lines changed: 950 additions & 48 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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 getEntrypointLabel(entrypoint: string): string {
10+
return entrypoint === '.' ? '.' : `./${entrypoint}`
11+
}
12+
13+
function onSelect(event: Event) {
14+
const target = event.target as HTMLSelectElement
15+
navigateTo(docsRoute(props.packageName, props.version, 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">
27+
{{ getEntrypointLabel(ep) }}
28+
</option>
29+
</select>
30+
</template>

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

Lines changed: 47 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script setup lang="ts">
22
import { setResponseHeader } from 'h3'
3+
import { parsePackageParam } from '#shared/utils/parse-package-param'
34
45
definePageMeta({
56
name: 'docs',
@@ -13,24 +14,19 @@ const router = useRouter()
1314
const { t } = useI18n()
1415
1516
const parsedRoute = computed(() => {
16-
const segments = route.params.path?.filter(Boolean)
17-
const vIndex = segments.indexOf('v')
18-
19-
if (vIndex === -1 || vIndex >= segments.length - 1) {
20-
return {
21-
packageName: segments.join('/'),
22-
version: null as string | null,
23-
}
24-
}
17+
const rawPath = route.params.path?.filter(Boolean).join('/') ?? ''
18+
const { packageName, version, rest } = parsePackageParam(rawPath)
2519
2620
return {
27-
packageName: segments.slice(0, vIndex).join('/'),
28-
version: segments.slice(vIndex + 1).join('/'),
21+
packageName,
22+
version: version ?? null,
23+
entrypoint: rest.length > 0 ? rest.join('/') : null,
2924
}
3025
})
3126
3227
const packageName = computed(() => parsedRoute.value.packageName)
3328
const requestedVersion = computed(() => parsedRoute.value.version)
29+
const entrypoint = computed(() => parsedRoute.value.entrypoint)
3430
3531
// Validate package name on server-side for early error detection
3632
if (import.meta.server && packageName.value) {
@@ -48,12 +44,8 @@ if (import.meta.server && !requestedVersion.value && packageName.value) {
4844
const version = await fetchLatestVersion(packageName.value)
4945
if (version) {
5046
setResponseHeader(useRequestEvent()!, 'Cache-Control', 'no-cache')
51-
const pathSegments = [...packageName.value.split('/'), 'v', version]
5247
app.runWithContext(() =>
53-
navigateTo(
54-
{ name: 'docs', params: { path: pathSegments as [string, ...string[]] } },
55-
{ redirectCode: 302 },
56-
),
48+
navigateTo(docsRoute(packageName.value, version), { redirectCode: 302 }),
5749
)
5850
}
5951
}
@@ -62,8 +54,7 @@ watch(
6254
[requestedVersion, latestVersion, packageName],
6355
([version, latest, name]) => {
6456
if (!version && latest && name) {
65-
const pathSegments = [...name.split('/'), 'v', latest]
66-
router.replace({ name: 'docs', params: { path: pathSegments as [string, ...string[]] } })
57+
router.replace(docsRoute(name, latest))
6758
}
6859
},
6960
{ immediate: true },
@@ -90,7 +81,8 @@ useCommandPalettePackageCommands(commandPalettePackageContext)
9081
9182
const docsUrl = computed(() => {
9283
if (!packageName.value || !resolvedVersion.value) return null
93-
return `/api/registry/docs/${packageName.value}/v/${resolvedVersion.value}`
84+
const base = `/api/registry/docs/${packageName.value}/v/${resolvedVersion.value}`
85+
return entrypoint.value ? `${base}/${entrypoint.value}` : base
9486
})
9587
9688
const shouldFetch = computed(() => !!docsUrl.value)
@@ -119,9 +111,10 @@ const latestVersionDetailed = computed(() => {
119111
return pkg.value.versions[latestTag] ?? null
120112
})
121113
122-
const versionUrlPattern = computed(
123-
() => `/package-docs/${pkg.value?.name || packageName.value}/v/{version}`,
124-
)
114+
const versionUrlPattern = computed(() => {
115+
const base = `/package-docs/${pkg.value?.name || packageName.value}/v/{version}`
116+
return entrypoint.value && entrypoint.value !== '.' ? `${base}/${entrypoint.value}` : base
117+
})
125118
126119
useCommandPaletteVersionCommands(commandPalettePackageContext, versionUrlPattern)
127120
@@ -171,6 +164,26 @@ const stickyStyle = computed(() => {
171164
'--combined-header-height': `${56 + (packageHeaderHeight.value || 44)}px`,
172165
}
173166
})
167+
168+
// Multi-entrypoint support
169+
const entrypoints = computed(() => docsData.value?.entrypoints ?? null)
170+
const hasRootEntrypoint = computed(() => entrypoints.value?.includes('.') ?? false)
171+
const currentEntrypoint = computed(
172+
() => docsData.value?.entrypoint ?? entrypoint.value ?? (hasRootEntrypoint.value ? '.' : ''),
173+
)
174+
175+
// Redirect to first entrypoint for multi-entrypoint packages
176+
watch(docsData, data => {
177+
if (
178+
data?.entrypoints?.length &&
179+
!data.entrypoints.includes('.') &&
180+
!entrypoint.value &&
181+
resolvedVersion.value
182+
) {
183+
const firstEntrypoint = data.entrypoints[0]!
184+
router.replace(docsRoute(packageName.value, resolvedVersion.value, firstEntrypoint))
185+
}
186+
})
174187
</script>
175188

176189
<template>
@@ -184,6 +197,18 @@ const stickyStyle = computed(() => {
184197
page="docs"
185198
/>
186199

200+
<div
201+
v-if="entrypoints && currentEntrypoint && resolvedVersion"
202+
class="container py-2 border-b border-border"
203+
>
204+
<EntrypointSelector
205+
:package-name="packageName"
206+
:version="resolvedVersion"
207+
:current-entrypoint="currentEntrypoint"
208+
:entrypoints="entrypoints"
209+
/>
210+
</div>
211+
187212
<div class="flex" dir="ltr">
188213
<!-- Sidebar TOC -->
189214
<aside

app/utils/router.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,3 +52,22 @@ export function diffRoute(
5252
},
5353
}
5454
}
55+
56+
export function docsRoute(
57+
packageName: string,
58+
version: string,
59+
entrypoint?: string | null,
60+
): RouteLocationRaw {
61+
const pathSegments = [...packageName.split('/'), 'v', version]
62+
63+
if (entrypoint && entrypoint !== '.') {
64+
pathSegments.push(...entrypoint.split('/'))
65+
}
66+
67+
return {
68+
name: 'docs',
69+
params: {
70+
path: pathSegments as [string, ...string[]],
71+
},
72+
}
73+
}

i18n/locales/en.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -462,7 +462,8 @@
462462
"page_title_name": "{name} docs - npmx",
463463
"page_title_version": "{name} docs - npmx",
464464
"og_title": "{name} - Docs",
465-
"view_package": "View package"
465+
"view_package": "View package",
466+
"select_entrypoint": "Select entrypoint"
466467
},
467468
"get_started": {
468469
"title": "Get started",

i18n/locales/fr-FR.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -450,7 +450,8 @@
450450
"page_title_name": "Documentation {name} - npmx",
451451
"page_title_version": "Documentation {name} - npmx",
452452
"og_title": "{name} - Documentation",
453-
"view_package": "Voir le paquet"
453+
"view_package": "Voir le paquet",
454+
"select_entrypoint": "Sélectionner le point d'entrée"
454455
},
455456
"get_started": {
456457
"title": "Commencer",

i18n/schema.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1392,6 +1392,9 @@
13921392
},
13931393
"view_package": {
13941394
"type": "string"
1395+
},
1396+
"select_entrypoint": {
1397+
"type": "string"
13951398
}
13961399
},
13971400
"additionalProperties": false

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

Lines changed: 36 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,34 @@ 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 hasRootEntrypoint = entrypoints?.includes('.') ?? false
33+
const requestedEntrypoint = entrypoint === '.' ? undefined : entrypoint
34+
const currentEntrypoint =
35+
requestedEntrypoint ?? (entrypoints && hasRootEntrypoint ? '.' : undefined)
36+
const entrypointFields = entrypoints ? { entrypoints, entrypoint: currentEntrypoint } : {}
37+
38+
// If the package only has subpath entrypoints, return the list so the
39+
// client can redirect to the first concrete entrypoint page.
40+
if (entrypoints && !hasRootEntrypoint && !requestedEntrypoint) {
41+
return {
42+
package: packageName,
43+
version,
44+
html: '',
45+
toc: null,
46+
status: 'ok',
47+
entrypoints,
48+
entrypoint: entrypoints[0],
49+
} satisfies DocsResponse
50+
}
51+
2252
let generated
2353
try {
24-
generated = await generateDocsWithDeno(packageName, version)
54+
generated = await generateDocsWithDeno(packageName, version, requestedEntrypoint)
2555
} catch (error) {
2656
// eslint-disable-next-line no-console
2757
console.error(`Doc generation failed for ${packageName}@${version}:`, error)
@@ -32,6 +62,7 @@ export default defineCachedEventHandler(
3262
toc: null,
3363
status: 'error',
3464
message: 'Failed to generate documentation. Please try again later.',
65+
...entrypointFields,
3566
} satisfies DocsResponse
3667
}
3768

@@ -43,6 +74,7 @@ export default defineCachedEventHandler(
4374
toc: null,
4475
status: 'missing',
4576
message: 'Docs are not available for this package. It may not have TypeScript types.',
77+
...entrypointFields,
4678
} satisfies DocsResponse
4779
}
4880

@@ -52,14 +84,15 @@ export default defineCachedEventHandler(
5284
html: generated.html,
5385
toc: generated.toc,
5486
status: 'ok',
87+
...entrypointFields,
5588
} satisfies DocsResponse
5689
},
5790
{
5891
maxAge: 60 * 60, // 1 hour cache
5992
swr: true,
6093
getKey: event => {
6194
const pkg = getRouterParam(event, 'pkg') ?? ''
62-
return `docs:v2:${pkg}`
95+
return `docs:v3:${pkg}`
6396
},
6497
},
6598
)

0 commit comments

Comments
 (0)