From 71528cca6e20cdb26fa5aeb5fc7a6eabc208ea7f 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 | 50 ++- server/api/registry/docs/[...pkg].get.ts | 31 +- server/utils/docs/client.ts | 128 ++++++- server/utils/docs/index.ts | 42 ++- shared/types/docs.ts | 4 + test/unit/a11y-component-coverage.spec.ts | 1 + test/unit/server/utils/docs/client.spec.ts | 405 +++++++++++++++++++++ 8 files changed, 662 insertions(+), 27 deletions(-) create mode 100644 app/components/EntrypointSelector.vue create mode 100644 test/unit/server/utils/docs/client.spec.ts diff --git a/app/components/EntrypointSelector.vue b/app/components/EntrypointSelector.vue new file mode 100644 index 000000000..edbd1b385 --- /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 07e6dbe92..76c28c46b 100644 --- a/app/pages/package-docs/[...path].vue +++ b/app/pages/package-docs/[...path].vue @@ -21,17 +21,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) { @@ -72,7 +81,8 @@ const resolvedVersion = computed(() => requestedVersion.value ?? latestVersion.v 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,6 +129,33 @@ const showLoading = computed( () => docsStatus.value === 'pending' || (docsStatus.value === 'idle' && docsUrl.value !== null), ) const showEmptyState = computed(() => docsData.value?.status !== 'ok') + +// Multi-entrypoint support +const entrypoints = computed(() => docsData.value?.entrypoints ?? null) +const currentEntrypoint = computed(() => docsData.value?.entrypoint ?? entrypoint.value ?? '') + +// Preserve entrypoint when switching versions +const versionUrlPattern = computed(() => { + const base = `/package-docs/${packageName.value}/v/{version}` + return entrypoint.value ? `${base}/${entrypoint.value}` : base +}) + +// 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[]] }, + }) + } +})