From 57c6bd98d130c4f9cfac94685fd1d8cfe85e6338 Mon Sep 17 00:00:00 2001 From: Philippe Serhal Date: Fri, 27 Feb 2026 19:51:25 -0500 Subject: [PATCH] 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 --- app/components/EntrypointSelector.vue | 28 ++ app/pages/package-docs/[...path].vue | 54 ++- i18n/locales/en.json | 3 +- i18n/locales/fr-FR.json | 3 +- server/api/registry/docs/[...pkg].get.ts | 35 +- server/utils/docs/client.ts | 114 +++++- server/utils/docs/index.ts | 43 ++- shared/types/docs.ts | 4 + test/unit/a11y-component-coverage.spec.ts | 1 + test/unit/server/utils/docs/client.spec.ts | 405 +++++++++++++++++++++ test/unit/server/utils/docs/index.spec.ts | 123 +++++++ 11 files changed, 782 insertions(+), 31 deletions(-) create mode 100644 app/components/EntrypointSelector.vue create mode 100644 test/unit/server/utils/docs/client.spec.ts create mode 100644 test/unit/server/utils/docs/index.spec.ts diff --git a/app/components/EntrypointSelector.vue b/app/components/EntrypointSelector.vue new file mode 100644 index 0000000000..243b71613a --- /dev/null +++ b/app/components/EntrypointSelector.vue @@ -0,0 +1,28 @@ + + + diff --git a/app/pages/package-docs/[...path].vue b/app/pages/package-docs/[...path].vue index 31aba3bd52..7558661aed 100644 --- a/app/pages/package-docs/[...path].vue +++ b/app/pages/package-docs/[...path].vue @@ -20,17 +20,26 @@ const parsedRoute = computed(() => { return { packageName: segments.join('/'), version: null as string | null, + entrypoint: null as string | null, } } + // Version is the segment right after "v" + const version = segments[vIndex + 1]! + // Everything after the version is the entrypoint path (e.g., "router.js") + const entrypointSegments = segments.slice(vIndex + 2) + const entrypoint = entrypointSegments.length > 0 ? entrypointSegments.join('/') : null + return { packageName: segments.slice(0, vIndex).join('/'), - version: segments.slice(vIndex + 1).join('/'), + version, + entrypoint, } }) const packageName = computed(() => parsedRoute.value.packageName) const requestedVersion = computed(() => parsedRoute.value.version) +const entrypoint = computed(() => parsedRoute.value.entrypoint) // Validate package name on server-side for early error detection if (import.meta.server && packageName.value) { @@ -90,7 +99,8 @@ useCommandPalettePackageCommands(commandPalettePackageContext) const docsUrl = computed(() => { if (!packageName.value || !resolvedVersion.value) return null - return `/api/registry/docs/${packageName.value}/v/${resolvedVersion.value}` + const base = `/api/registry/docs/${packageName.value}/v/${resolvedVersion.value}` + return entrypoint.value ? `${base}/${entrypoint.value}` : base }) const shouldFetch = computed(() => !!docsUrl.value) @@ -119,9 +129,10 @@ const latestVersionDetailed = computed(() => { return pkg.value.versions[latestTag] ?? null }) -const versionUrlPattern = computed( - () => `/package-docs/${pkg.value?.name || packageName.value}/v/{version}`, -) +const versionUrlPattern = computed(() => { + const base = `/package-docs/${pkg.value?.name || packageName.value}/v/{version}` + return entrypoint.value ? `${base}/${entrypoint.value}` : base +}) useCommandPaletteVersionCommands(commandPalettePackageContext, versionUrlPattern) @@ -159,6 +170,27 @@ const stickyStyle = computed(() => { '--combined-header-height': `${56 + (packageHeaderHeight.value || 44)}px`, } }) + +// Multi-entrypoint support +const entrypoints = computed(() => docsData.value?.entrypoints ?? null) +const currentEntrypoint = computed(() => docsData.value?.entrypoint ?? entrypoint.value ?? '') + +// Redirect to first entrypoint for multi-entrypoint packages +watch(docsData, data => { + if (data?.entrypoints?.length && !entrypoint.value && resolvedVersion.value) { + const firstEntrypoint = data.entrypoints[0]! + const pathSegments = [ + ...packageName.value.split('/'), + 'v', + resolvedVersion.value, + ...firstEntrypoint.split('/'), + ] + router.replace({ + name: 'docs', + params: { path: pathSegments as [string, ...string[]] }, + }) + } +})