@@ -148,11 +185,18 @@ const showEmptyState = computed(() => docsData.value?.status !== 'ok')
:current-version="resolvedVersion"
:versions="pkg.versions"
:dist-tags="pkg['dist-tags']"
- :url-pattern="`/package-docs/${packageName}/v/{version}`"
+ :url-pattern="versionUrlPattern"
/>
{{ resolvedVersion }}
+
diff --git a/server/api/registry/docs/[...pkg].get.ts b/server/api/registry/docs/[...pkg].get.ts
index 95402cf86..3165d4ccf 100644
--- a/server/api/registry/docs/[...pkg].get.ts
+++ b/server/api/registry/docs/[...pkg].get.ts
@@ -1,7 +1,7 @@
import type { DocsResponse } from '#shared/types'
import { assertValidPackageName } from '#shared/utils/npm'
import { parsePackageParam } from '#shared/utils/parse-package-param'
-import { generateDocsWithDeno } from '#server/utils/docs'
+import { generateDocsWithDeno, getEntrypoints } from '#server/utils/docs'
export default defineCachedEventHandler(
async event => {
@@ -11,7 +11,7 @@ export default defineCachedEventHandler(
throw createError({ statusCode: 404, message: 'Package name is required' })
}
- const { packageName, version } = parsePackageParam(pkgParam)
+ const { packageName, version, rest } = parsePackageParam(pkgParam)
if (!packageName) {
// TODO: throwing 404 rather than 400 as it's cacheable
@@ -24,9 +24,29 @@ export default defineCachedEventHandler(
throw createError({ statusCode: 404, message: 'Package version is required' })
}
+ // Extract entrypoint from remaining path segments (e.g., ["router.js"] -> "router.js")
+ const entrypoint = rest.length > 0 ? rest.join('/') : undefined
+
+ // Discover available entrypoints (null for single-entrypoint packages)
+ const entrypoints = await getEntrypoints(packageName, version)
+
+ // If multi-entrypoint but no specific entrypoint requested, return early
+ // with the entrypoints list so the client can redirect to the first one
+ if (entrypoints && !entrypoint) {
+ return {
+ package: packageName,
+ version,
+ html: '',
+ toc: null,
+ status: 'ok',
+ entrypoints,
+ entrypoint: entrypoints[0],
+ } satisfies DocsResponse
+ }
+
let generated
try {
- generated = await generateDocsWithDeno(packageName, version)
+ generated = await generateDocsWithDeno(packageName, version, entrypoint)
} catch (error) {
// eslint-disable-next-line no-console
console.error(`Doc generation failed for ${packageName}@${version}:`, error)
@@ -37,6 +57,7 @@ export default defineCachedEventHandler(
toc: null,
status: 'error',
message: 'Failed to generate documentation. Please try again later.',
+ ...(entrypoints && { entrypoints, entrypoint }),
} satisfies DocsResponse
}
@@ -48,6 +69,7 @@ export default defineCachedEventHandler(
toc: null,
status: 'missing',
message: 'Docs are not available for this package. It may not have TypeScript types.',
+ ...(entrypoints && { entrypoints, entrypoint }),
} satisfies DocsResponse
}
@@ -57,6 +79,7 @@ export default defineCachedEventHandler(
html: generated.html,
toc: generated.toc,
status: 'ok',
+ ...(entrypoints && { entrypoints, entrypoint }),
} satisfies DocsResponse
},
{
@@ -64,7 +87,7 @@ export default defineCachedEventHandler(
swr: true,
getKey: event => {
const pkg = getRouterParam(event, 'pkg') ?? ''
- return `docs:v2:${pkg}`
+ return `docs:v3:${pkg}`
},
},
)
diff --git a/server/utils/docs/client.ts b/server/utils/docs/client.ts
index dc3f78d1b..2fa30caf7 100644
--- a/server/utils/docs/client.ts
+++ b/server/utils/docs/client.ts
@@ -10,6 +10,7 @@
import { doc, type DocNode } from '@deno/doc'
import type { DenoDocNode, DenoDocResult } from '#shared/types/deno-doc'
import { isBuiltin } from 'node:module'
+import { encodePackageName } from '#shared/utils/npm'
// =============================================================================
// Configuration
@@ -18,6 +19,9 @@ import { isBuiltin } from 'node:module'
/** Timeout for fetching modules in milliseconds */
const FETCH_TIMEOUT_MS = 30 * 1000
+/** Maximum number of subpath exports to process */
+const MAX_SUBPATH_EXPORTS = 20
+
// =============================================================================
// Main Export
// =============================================================================
@@ -26,17 +30,17 @@ const FETCH_TIMEOUT_MS = 30 * 1000
* Get documentation nodes for a package using @deno/doc WASM.
*/
export async function getDocNodes(packageName: string, version: string): Promise {
- // Get types URL from esm.sh header
- const typesUrl = await getTypesUrl(packageName, version)
+ // Get types URL from esm.sh header for the root entry
+ const typesUrls = await getTypesUrls(packageName, version)
- if (!typesUrl) {
+ if (typesUrls.length === 0) {
return { version: 1, nodes: [] }
}
// Generate docs using @deno/doc WASM
let result: Record
try {
- result = await doc([typesUrl], {
+ result = await doc(typesUrls, {
load: createLoader(),
resolve: createResolver(),
})
@@ -153,6 +157,63 @@ function createResolver(): (specifier: string, referrer: string) => string {
}
}
+/**
+ * Get TypeScript types URLs for a package, trying the root entry first,
+ * then falling back to subpath exports if the package has no default export.
+ */
+async function getTypesUrls(packageName: string, version: string): Promise {
+ // Try root entry first
+ const rootTypesUrl = await getTypesUrlForSubpath(packageName, version)
+ if (rootTypesUrl) {
+ return [rootTypesUrl]
+ }
+
+ // Root has no types — check subpath exports from the npm registry
+ const subpaths = await getSubpathExports(packageName, version)
+ if (subpaths.length === 0) {
+ return []
+ }
+
+ // Fetch types URLs for each subpath export in parallel
+ const results = await Promise.all(
+ subpaths.map(subpath => getTypesUrlForSubpath(packageName, version, subpath)),
+ )
+
+ return results.filter((url): url is string => url !== null)
+}
+
+/**
+ * Get documentation nodes for a specific subpath export of a package.
+ */
+export async function getDocNodesForEntrypoint(
+ packageName: string,
+ version: string,
+ entrypoint: string,
+): Promise {
+ const typesUrl = await getTypesUrlForSubpath(packageName, version, entrypoint)
+
+ if (!typesUrl) {
+ return { version: 1, nodes: [] }
+ }
+
+ let result: Record
+ try {
+ result = await doc([typesUrl], {
+ load: createLoader(),
+ resolve: createResolver(),
+ })
+ } catch {
+ return { version: 1, nodes: [] }
+ }
+
+ const allNodes: DenoDocNode[] = []
+ for (const nodes of Object.values(result)) {
+ allNodes.push(...(nodes as DenoDocNode[]))
+ }
+
+ return { version: 1, nodes: allNodes }
+}
+
/**
* Get the TypeScript types URL from esm.sh's x-typescript-types header.
*
@@ -160,8 +221,14 @@ function createResolver(): (specifier: string, referrer: string) => string {
* Example: curl -sI 'https://esm.sh/ufo@1.5.0' returns header:
* x-typescript-types: https://esm.sh/ufo@1.5.0/dist/index.d.ts
*/
-async function getTypesUrl(packageName: string, version: string): Promise {
- const url = `https://esm.sh/${packageName}@${version}`
+export async function getTypesUrlForSubpath(
+ packageName: string,
+ version: string,
+ subpath?: string,
+): Promise {
+ const url = subpath
+ ? `https://esm.sh/${packageName}@${version}/${subpath}`
+ : `https://esm.sh/${packageName}@${version}`
try {
const response = await $fetch.raw(url, {
@@ -169,9 +236,52 @@ async function getTypesUrl(packageName: string, version: string): Promise {
+ try {
+ const encodedName = encodePackageName(packageName)
+ const pkgJson = await $fetch>(
+ `https://registry.npmjs.org/${encodedName}/${version}`,
+ { timeout: FETCH_TIMEOUT_MS },
+ )
+
+ const exports = pkgJson.exports
+ if (!exports || typeof exports !== 'object') {
+ return []
+ }
+
+ const subpaths: string[] = []
+
+ for (const [key, value] of Object.entries(exports as Record)) {
+ // Skip root export (already tried), non-subpath entries, and wildcards
+ if (key === '.' || !key.startsWith('./') || key.includes('*')) {
+ continue
+ }
+
+ // Only include exports that declare types
+ if (value && typeof value === 'object' && 'types' in value) {
+ // Strip leading "./" for the esm.sh URL
+ subpaths.push(key.slice(2))
+ }
+
+ if (subpaths.length >= MAX_SUBPATH_EXPORTS) {
+ break
+ }
+ }
+
+ return subpaths
+ } catch {
+ return []
+ }
+}
diff --git a/server/utils/docs/index.ts b/server/utils/docs/index.ts
index 66a739448..de25a4d50 100644
--- a/server/utils/docs/index.ts
+++ b/server/utils/docs/index.ts
@@ -9,34 +9,35 @@
*/
import type { DocsGenerationResult } from '#shared/types/deno-doc'
-import { getDocNodes } from './client'
+import {
+ getDocNodes,
+ getDocNodesForEntrypoint,
+ getSubpathExports,
+ getTypesUrlForSubpath,
+} from './client'
import { buildSymbolLookup, flattenNamespaces, mergeOverloads } from './processing'
import { renderDocNodes, renderToc } from './render'
/**
- * Generate API documentation for an npm package.
+ * Generate API documentation for an npm package (or a specific entrypoint).
*
* Uses @deno/doc (WASM build of deno_doc) with esm.sh URLs to extract
* TypeScript type information and JSDoc comments, then renders them as HTML.
*
* @param packageName - The npm package name (e.g., "react", "@types/lodash")
* @param version - The package version (e.g., "19.2.3")
+ * @param entrypoint - Optional subpath export (e.g., "router.js") for multi-entrypoint packages
* @returns Generated documentation or null if no types are available
- *
- * @example
- * ```ts
- * const docs = await generateDocsWithDeno('ufo', '1.5.0')
- * if (docs) {
- * console.log(docs.html)
- * }
- * ```
*/
export async function generateDocsWithDeno(
packageName: string,
version: string,
+ entrypoint?: string,
): Promise {
// Get doc nodes using @deno/doc WASM
- const result = await getDocNodes(packageName, version)
+ const result = entrypoint
+ ? await getDocNodesForEntrypoint(packageName, version, entrypoint)
+ : await getDocNodes(packageName, version)
if (!result.nodes || result.nodes.length === 0) {
return null
@@ -53,3 +54,22 @@ export async function generateDocsWithDeno(
return { html, toc, nodes: flattenedNodes }
}
+
+/**
+ * Get the list of subpath exports for a package, or null if it's a
+ * single-entrypoint package (has a root export with types).
+ */
+export async function getEntrypoints(
+ packageName: string,
+ version: string,
+): Promise {
+ // Check if root entry has types
+ const rootTypesUrl = await getTypesUrlForSubpath(packageName, version)
+ if (rootTypesUrl) {
+ return null
+ }
+
+ // Multi-entrypoint: return subpath exports
+ const subpaths = await getSubpathExports(packageName, version)
+ return subpaths.length > 0 ? subpaths : null
+}
diff --git a/shared/types/docs.ts b/shared/types/docs.ts
index a59164f81..db884abdf 100644
--- a/shared/types/docs.ts
+++ b/shared/types/docs.ts
@@ -8,6 +8,10 @@ export interface DocsResponse {
breadcrumbs?: string | null
status: DocsStatus
message?: string
+ /** Available entrypoints for multi-entrypoint packages. Absent for single-entrypoint packages. */
+ entrypoints?: string[]
+ /** The current entrypoint being viewed. Absent for single-entrypoint packages. */
+ entrypoint?: string
}
export interface DocsSearchResponse {
diff --git a/test/unit/a11y-component-coverage.spec.ts b/test/unit/a11y-component-coverage.spec.ts
index 0f18a000e..a9b713f53 100644
--- a/test/unit/a11y-component-coverage.spec.ts
+++ b/test/unit/a11y-component-coverage.spec.ts
@@ -46,6 +46,7 @@ const SKIPPED_COMPONENTS: Record = {
'SkeletonBlock.vue': 'Already covered indirectly via other component tests',
'SkeletonInline.vue': 'Already covered indirectly via other component tests',
'Button/Group.vue': "Wrapper component, tests wouldn't make much sense here",
+ 'EntrypointSelector.vue': 'Simple native