Skip to content

Commit 7099e4b

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 69351cf commit 7099e4b

20 files changed

Lines changed: 1270 additions & 56 deletions

File tree

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import type { Meta, StoryObj } from '@storybook-vue/nuxt'
2+
import EntrypointSelector from './EntrypointSelector.vue'
3+
4+
const meta = {
5+
component: EntrypointSelector,
6+
} satisfies Meta<typeof EntrypointSelector>
7+
8+
export default meta
9+
type Story = StoryObj<typeof meta>
10+
11+
export const SingleEntrypoint: Story = {
12+
args: {
13+
packageName: 'vue',
14+
version: '3.5.0',
15+
currentEntrypoint: '.',
16+
entrypoints: ['.'],
17+
},
18+
}
19+
20+
export const MultipleEntrypoints: Story = {
21+
args: {
22+
packageName: '@nuxt/kit',
23+
version: '4.3.1',
24+
currentEntrypoint: '.',
25+
entrypoints: ['.', 'compatibility', 'loader'],
26+
},
27+
}
28+
29+
export const ManyEntrypoints: Story = {
30+
args: {
31+
packageName: '@radix-ui/themes',
32+
version: '3.0.0',
33+
currentEntrypoint: 'button',
34+
entrypoints: [
35+
'accordion',
36+
'alert-dialog',
37+
'avatar',
38+
'badge',
39+
'box',
40+
'button',
41+
'callout',
42+
'card',
43+
'checkbox',
44+
'container',
45+
'dialog',
46+
'flex',
47+
'grid',
48+
'heading',
49+
'icon-button',
50+
'inset',
51+
'link',
52+
'popover',
53+
'progress',
54+
'radio-group',
55+
'scroll-area',
56+
'select',
57+
'separator',
58+
'skeleton',
59+
'slider',
60+
'spinner',
61+
'switch',
62+
'table',
63+
'tabs',
64+
'text',
65+
'text-area',
66+
'text-field',
67+
'theme',
68+
'tooltip',
69+
],
70+
},
71+
}
72+
73+
export const NestedEntrypoint: Story = {
74+
args: {
75+
packageName: '@nuxt/kit',
76+
version: '4.3.1',
77+
currentEntrypoint: 'compat/utils',
78+
entrypoints: ['.', 'compat/utils', 'compat/legacy'],
79+
},
80+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<script setup lang="ts">
2+
const props = defineProps<{
3+
packageName: string
4+
version: string
5+
currentEntrypoint: string
6+
entrypoints: string[]
7+
}>()
8+
9+
const hasMultiple = computed(() => props.entrypoints.length > 1)
10+
const selectId = useId()
11+
12+
const selectedEntrypoint = shallowRef(props.currentEntrypoint)
13+
14+
watch(
15+
() => props.currentEntrypoint,
16+
entrypoint => {
17+
selectedEntrypoint.value = entrypoint
18+
},
19+
)
20+
21+
const entrypointItems = computed(() =>
22+
props.entrypoints.map(entrypoint => ({
23+
value: entrypoint,
24+
label: formatEntrypointLabel(entrypoint),
25+
})),
26+
)
27+
28+
function formatEntrypointLabel(entrypoint: string): string {
29+
return entrypoint === '.' ? '.' : `./${entrypoint}`
30+
}
31+
32+
function handleEntrypointChange(entrypoint: string | undefined) {
33+
if (!entrypoint || entrypoint === props.currentEntrypoint) {
34+
return
35+
}
36+
37+
selectedEntrypoint.value = entrypoint
38+
navigateTo(docsRoute(props.packageName, props.version, entrypoint))
39+
}
40+
</script>
41+
42+
<template>
43+
<div
44+
v-if="!hasMultiple"
45+
class="text-fg-subtle font-mono text-sm inline-flex items-center gap-1.5"
46+
>
47+
<span class="i-lucide:package w-3.5 h-3.5" aria-hidden="true" />
48+
<span dir="ltr">{{ formatEntrypointLabel(currentEntrypoint) }}</span>
49+
</div>
50+
51+
<div v-else class="inline-flex items-center gap-1.5">
52+
<span class="i-lucide:package w-3.5 h-3.5 text-fg-subtle shrink-0" aria-hidden="true" />
53+
<SelectField
54+
:id="selectId"
55+
v-model="selectedEntrypoint"
56+
:items="entrypointItems"
57+
:label="$t('package.docs.select_entrypoint')"
58+
hidden-label
59+
size="sm"
60+
:select-attrs="{ dir: 'ltr' }"
61+
@update:model-value="handleEntrypointChange"
62+
/>
63+
</div>
64+
</template>

app/composables/useCommandPaletteCommands.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const GROUP_ORDER: CommandPaletteGroup[] = [
1717
'settings',
1818
'help',
1919
'npmx',
20+
'entrypoints',
2021
'versions',
2122
]
2223

@@ -68,6 +69,10 @@ export function useCommandPaletteCommands() {
6869
return packageName
6970
? t('command_palette.groups.versions_with_name', { name: packageName })
7071
: t('command_palette.groups.versions')
72+
case 'entrypoints':
73+
return packageName
74+
? t('command_palette.groups.entrypoints_with_name', { name: packageName })
75+
: t('command_palette.groups.entrypoints')
7176
}
7277
}
7378

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// @unocss-include
2+
import type { MaybeRefOrGetter } from 'vue'
3+
import type {
4+
CommandPaletteContextCommandInput,
5+
CommandPalettePackageContext,
6+
} from '~/types/command-palette'
7+
8+
interface EntrypointContext {
9+
packageContext: CommandPalettePackageContext
10+
entrypoints: string[]
11+
currentEntrypoint: string | null
12+
}
13+
14+
function getEntrypointLabel(entrypoint: string): string {
15+
return entrypoint === '.' ? '.' : `./${entrypoint}`
16+
}
17+
18+
export function useCommandPaletteEntrypointCommands(
19+
context: MaybeRefOrGetter<EntrypointContext | null>,
20+
) {
21+
const { t } = useI18n()
22+
23+
useCommandPaletteContextCommands(
24+
computed((): CommandPaletteContextCommandInput[] => {
25+
const ctx = toValue(context)
26+
if (!ctx?.packageContext.resolvedVersion) return []
27+
if (ctx.entrypoints.length === 0) return []
28+
29+
return ctx.entrypoints.map(entrypoint => ({
30+
id: `entrypoint:${entrypoint}`,
31+
group: 'entrypoints' as const,
32+
label: t('command_palette.entrypoint.label', {
33+
entrypoint: getEntrypointLabel(entrypoint),
34+
}),
35+
keywords: [
36+
ctx.packageContext.packageName,
37+
entrypoint,
38+
getEntrypointLabel(entrypoint),
39+
t('command_palette.groups.entrypoints'),
40+
],
41+
iconClass: 'i-lucide:package',
42+
active: entrypoint === ctx.currentEntrypoint,
43+
to: docsRoute(
44+
ctx.packageContext.packageName,
45+
ctx.packageContext.resolvedVersion!,
46+
entrypoint,
47+
),
48+
}))
49+
}),
50+
)
51+
}

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

Lines changed: 61 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,39 @@ 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+
})
187+
188+
useCommandPaletteEntrypointCommands(
189+
computed(() => {
190+
const packageContext = commandPalettePackageContext.value
191+
const allEntrypoints = entrypoints.value
192+
if (!packageContext || !allEntrypoints?.length || allEntrypoints.length < 2) return null
193+
return {
194+
packageContext,
195+
entrypoints: allEntrypoints,
196+
currentEntrypoint: currentEntrypoint.value || null,
197+
}
198+
}),
199+
)
174200
</script>
175201

176202
<template>
@@ -184,6 +210,19 @@ const stickyStyle = computed(() => {
184210
page="docs"
185211
/>
186212

213+
<nav
214+
v-if="entrypoints?.length && currentEntrypoint && resolvedVersion"
215+
:aria-label="$t('package.docs.entrypoints')"
216+
class="container py-2 border-b border-border"
217+
>
218+
<EntrypointSelector
219+
:package-name="packageName"
220+
:version="resolvedVersion"
221+
:current-entrypoint="currentEntrypoint"
222+
:entrypoints="entrypoints"
223+
/>
224+
</nav>
225+
187226
<div class="flex" dir="ltr">
188227
<!-- Sidebar TOC -->
189228
<aside

app/types/command-palette.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export type CommandPaletteGroup =
1111
| 'settings'
1212
| 'npmx'
1313
| 'versions'
14+
| 'entrypoints'
1415

1516
export type CommandPaletteView = 'root' | 'languages' | 'accent-colors' | 'background-themes'
1617

0 commit comments

Comments
 (0)