1010import { doc , type DocNode } from '@deno/doc'
1111import type { DenoDocNode , DenoDocResult } from '#shared/types/deno-doc'
1212import { isBuiltin } from 'node:module'
13+ import { encodePackageName } from '#shared/utils/npm'
1314
1415// =============================================================================
1516// Configuration
@@ -18,6 +19,9 @@ import { isBuiltin } from 'node:module'
1819/** Timeout for fetching modules in milliseconds */
1920const FETCH_TIMEOUT_MS = 30 * 1000
2021
22+ /** Maximum number of subpath exports to process */
23+ const MAX_SUBPATH_EXPORTS = 20
24+
2125// =============================================================================
2226// Main Export
2327// =============================================================================
@@ -26,17 +30,23 @@ const FETCH_TIMEOUT_MS = 30 * 1000
2630 * Get documentation nodes for a package using @deno/doc WASM.
2731 */
2832export async function getDocNodes ( packageName : string , version : string ) : Promise < DenoDocResult > {
29- // Get types URL from esm.sh header
30- const typesUrl = await getTypesUrl ( packageName , version )
33+ // Get types URL from esm.sh header for the root entry
34+ const typesUrls = await getTypesUrls ( packageName , version )
35+ return runDoc ( typesUrls )
36+ }
3137
32- if ( ! typesUrl ) {
38+ /**
39+ * Run @deno/doc on a list of types URLs and collect all resulting nodes.
40+ */
41+ async function runDoc ( typesUrls : string [ ] ) : Promise < DenoDocResult > {
42+ if ( typesUrls . length === 0 ) {
3343 return { version : 1 , nodes : [ ] }
3444 }
3545
3646 // Generate docs using @deno /doc WASM
3747 let result : Record < string , DocNode [ ] >
3848 try {
39- result = await doc ( [ typesUrl ] , {
49+ result = await doc ( typesUrls , {
4050 load : createLoader ( ) ,
4151 resolve : createResolver ( ) ,
4252 } )
@@ -153,25 +163,111 @@ function createResolver(): (specifier: string, referrer: string) => string {
153163 }
154164}
155165
166+ /**
167+ * Get TypeScript types URLs for a package, trying the root entry first,
168+ * then falling back to subpath exports if the package has no default export.
169+ */
170+ async function getTypesUrls ( packageName : string , version : string ) : Promise < string [ ] > {
171+ // Try root entry first
172+ const rootTypesUrl = await getTypesUrlForSubpath ( packageName , version )
173+ if ( rootTypesUrl ) {
174+ return [ rootTypesUrl ]
175+ }
176+
177+ // Root has no types — check subpath exports from the npm registry
178+ const subpaths = await getSubpathExports ( packageName , version )
179+ if ( subpaths . length === 0 ) {
180+ return [ ]
181+ }
182+
183+ // Fetch types URLs for each subpath export in parallel
184+ const results = await Promise . all (
185+ subpaths . map ( subpath => getTypesUrlForSubpath ( packageName , version , subpath ) ) ,
186+ )
187+
188+ return results . filter ( ( url ) : url is string => url !== null )
189+ }
190+
191+ /**
192+ * Get documentation nodes for a specific subpath export of a package.
193+ */
194+ export async function getDocNodesForEntrypoint (
195+ packageName : string ,
196+ version : string ,
197+ entrypoint : string ,
198+ ) : Promise < DenoDocResult > {
199+ const typesUrl = await getTypesUrlForSubpath ( packageName , version , entrypoint )
200+ return runDoc ( typesUrl ? [ typesUrl ] : [ ] )
201+ }
202+
156203/**
157204 * Get the TypeScript types URL from esm.sh's x-typescript-types header.
158205 *
159206 * esm.sh serves types URL in the `x-typescript-types` header, not at the main URL.
160207 * Example: curl -sI 'https://esm.sh/ufo@1.5.0' returns header:
161208 * x-typescript-types: https://esm.sh/ufo@1.5.0/dist/index.d.ts
162209 */
163- async function getTypesUrl ( packageName : string , version : string ) : Promise < string | null > {
164- const url = `https://esm.sh/${ packageName } @${ version } `
210+ export async function getTypesUrlForSubpath (
211+ packageName : string ,
212+ version : string ,
213+ subpath ?: string ,
214+ ) : Promise < string | null > {
215+ const url = subpath
216+ ? `https://esm.sh/${ packageName } @${ version } /${ subpath } `
217+ : `https://esm.sh/${ packageName } @${ version } `
165218
166219 try {
167220 const response = await $fetch . raw ( url , {
168221 method : 'HEAD' ,
169222 timeout : FETCH_TIMEOUT_MS ,
170223 } )
171224 return response . headers . get ( 'x-typescript-types' )
172- } catch ( e ) {
173- // eslint-disable-next-line no-console
174- console . error ( e )
225+ } catch {
175226 return null
176227 }
177228}
229+
230+ /**
231+ * Get subpath export paths from the npm registry's package.json `exports` field.
232+ * Only returns subpaths that declare types (have a `types` condition).
233+ *
234+ * Skips the root export (".") since that's handled by the main getTypesUrl call.
235+ * Skips wildcard patterns ("./foo/*") since they can't be resolved to specific files.
236+ */
237+ export async function getSubpathExports ( packageName : string , version : string ) : Promise < string [ ] > {
238+ try {
239+ const encodedName = encodePackageName ( packageName )
240+ const pkgJson = await $fetch < Record < string , unknown > > (
241+ `https://registry.npmjs.org/${ encodedName } /${ version } ` ,
242+ { timeout : FETCH_TIMEOUT_MS } ,
243+ )
244+
245+ const exports = pkgJson . exports
246+ if ( ! exports || typeof exports !== 'object' ) {
247+ return [ ]
248+ }
249+
250+ const subpaths : string [ ] = [ ]
251+
252+ for ( const [ key , value ] of Object . entries ( exports as Record < string , unknown > ) ) {
253+ // Skip root export (already tried), non-subpath entries, and wildcards
254+ if ( key === '.' || ! key . startsWith ( './' ) || key . includes ( '*' ) ) {
255+ continue
256+ }
257+
258+ // Only include exports that declare types
259+ if ( value && typeof value === 'object' && 'types' in value ) {
260+ // Strip leading "./" for the esm.sh URL
261+ subpaths . push ( key . slice ( 2 ) )
262+ }
263+
264+ if ( subpaths . length >= MAX_SUBPATH_EXPORTS ) {
265+ break
266+ }
267+ }
268+
269+ return subpaths
270+ } catch {
271+ return [ ]
272+ }
273+ }
0 commit comments