Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions app/components/EntrypointSelector.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<script setup lang="ts">
const props = defineProps<{
packageName: string
version: string
currentEntrypoint: string
entrypoints: string[]
}>()

function getEntrypointUrl(entrypoint: string): string {
return `/package-docs/${props.packageName}/v/${props.version}/${entrypoint}`
}

function onSelect(event: Event) {
const target = event.target as HTMLSelectElement
navigateTo(getEntrypointUrl(target.value))
}
</script>

<template>
<select
:value="currentEntrypoint"
aria-label="Select entrypoint"
class="text-fg-subtle font-mono text-sm bg-transparent border border-border rounded px-2 py-1 hover:text-fg hover:border-border-subtle transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring shrink-0"
@change="onSelect"
>
<option v-for="ep in entrypoints" :key="ep" :value="ep">./{{ ep }}</option>
</select>
</template>
50 changes: 47 additions & 3 deletions app/pages/package-docs/[...path].vue
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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[]] },
})
}
})
</script>

<template>
Expand Down Expand Up @@ -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"
/>
<span v-else-if="resolvedVersion" class="text-fg-subtle font-mono text-sm shrink-0">
{{ resolvedVersion }}
</span>
<EntrypointSelector
v-if="entrypoints && currentEntrypoint && resolvedVersion"
:package-name="packageName"
:version="resolvedVersion"
:current-entrypoint="currentEntrypoint"
:entrypoints="entrypoints"
/>
</div>
<div class="flex items-center gap-3 shrink-0">
<span class="text-xs px-2 py-1 rounded badge-green border border-badge-green/50">
Expand Down
31 changes: 27 additions & 4 deletions server/api/registry/docs/[...pkg].get.ts
Original file line number Diff line number Diff line change
@@ -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 => {
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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
}

Expand All @@ -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
}

Expand All @@ -57,14 +79,15 @@ export default defineCachedEventHandler(
html: generated.html,
toc: generated.toc,
status: 'ok',
...(entrypoints && { entrypoints, entrypoint }),
} satisfies DocsResponse
},
{
maxAge: 60 * 60, // 1 hour cache
swr: true,
getKey: event => {
const pkg = getRouterParam(event, 'pkg') ?? ''
return `docs:v2:${pkg}`
return `docs:v3:${pkg}`
},
},
)
128 changes: 119 additions & 9 deletions server/utils/docs/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
// =============================================================================
Expand All @@ -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<DenoDocResult> {
// 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<string, DocNode[]>
try {
result = await doc([typesUrl], {
result = await doc(typesUrls, {
load: createLoader(),
resolve: createResolver(),
})
Expand Down Expand Up @@ -153,25 +157,131 @@ 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<string[]> {
// 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<DenoDocResult> {
const typesUrl = await getTypesUrlForSubpath(packageName, version, entrypoint)

if (!typesUrl) {
return { version: 1, nodes: [] }
}

let result: Record<string, DocNode[]>
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.
*
* esm.sh serves types URL in the `x-typescript-types` header, not at the main URL.
* 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<string | null> {
const url = `https://esm.sh/${packageName}@${version}`
export async function getTypesUrlForSubpath(
packageName: string,
version: string,
subpath?: string,
): Promise<string | null> {
const url = subpath
? `https://esm.sh/${packageName}@${version}/${subpath}`
: `https://esm.sh/${packageName}@${version}`

try {
const response = await $fetch.raw(url, {
method: 'HEAD',
timeout: FETCH_TIMEOUT_MS,
})
return response.headers.get('x-typescript-types')
} catch (e) {
// eslint-disable-next-line no-console
console.error(e)
} catch {
return null
}
}

/**
* Get subpath export paths from the npm registry's package.json `exports` field.
* Only returns subpaths that declare types (have a `types` condition).
*
* Skips the root export (".") since that's handled by the main getTypesUrl call.
* Skips wildcard patterns ("./foo/*") since they can't be resolved to specific files.
*/
export async function getSubpathExports(packageName: string, version: string): Promise<string[]> {
try {
const encodedName = encodePackageName(packageName)
const pkgJson = await $fetch<Record<string, unknown>>(
`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<string, unknown>)) {
// 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 []
}
}
Loading
Loading