diff --git a/app/pages/package-docs/[...path].vue b/app/pages/package-docs/[...path].vue index bc0315a6dd..27b7b9fdb7 100644 --- a/app/pages/package-docs/[...path].vue +++ b/app/pages/package-docs/[...path].vue @@ -271,6 +271,15 @@ const stickyStyle = computed(() => { @apply text-xs text-fg-subtle hover:text-fg block py-0.5 truncate; } +.toc-content li.docs-toc-group:not(:first-child) { + @apply mt-6; +} + +.toc-content .docs-toc-group > a { + direction: rtl; + text-align: left; +} + /* Main docs content container - no max-width to use full space */ .docs-content { @apply max-w-none; @@ -286,6 +295,18 @@ const stickyStyle = computed(() => { top: var(--combined-header-height); } +.docs-content .docs-group { + scroll-margin-top: var(--combined-header-height); +} + +.docs-content .docs-group-title { + @apply static mt-20; +} + +.docs-content .docs-group:first-child .docs-group-title { + @apply mt-0; +} + /* Individual symbol articles */ .docs-content .docs-symbol { @apply mb-10 pb-10 border-b border-border/30 last:border-0; diff --git a/server/utils/docs/client.ts b/server/utils/docs/client.ts index dc3f78d1b1..8023dd0ca5 100644 --- a/server/utils/docs/client.ts +++ b/server/utils/docs/client.ts @@ -8,7 +8,8 @@ */ import { doc, type DocNode } from '@deno/doc' -import type { DenoDocNode, DenoDocResult } from '#shared/types/deno-doc' +import type { DenoDocNode, DenoDocResult, DocEntry } from '#shared/types/deno-doc' +import { mapWithConcurrency } from '#shared/utils/async' import { isBuiltin } from 'node:module' // ============================================================================= @@ -26,31 +27,123 @@ 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) + const entryPoints = await resolveEntryPoints(packageName, version) - if (!typesUrl) { - return { version: 1, nodes: [] } + if (entryPoints.length === 0) { + return { version: 1, entries: [] } } - // Generate docs using @deno/doc WASM - let result: Record + const entries: (DocEntry | null)[] = await mapWithConcurrency( + entryPoints, + async ({ entryPoint, typesUrl }): Promise => { + let result: Record + try { + result = await doc([typesUrl], { + load: createLoader(), + resolve: createResolver(), + }) + } catch { + return null + } + + const nodes: DenoDocNode[] = [] + for (const docNodes of Object.values(result)) { + nodes.push(...(docNodes as DenoDocNode[])) + } + + if (nodes.length === 0) { + return null + } + + return { entryPoint, nodes } + }, + 10, + ) + + return { + version: 1, + entries: entries.filter((entry): entry is DocEntry => entry !== null), + } +} + +// ============================================================================= +// Entry Point Resolution +// ============================================================================= + +interface ResolvedEntryPoint { + entryPoint: string + typesUrl: string +} + +/** + * Resolve the documentable entry points for a package. + */ +async function resolveEntryPoints( + packageName: string, + version: string, +): Promise { + const modules = await getModules(packageName, version) + + const resolved = await mapWithConcurrency( + modules, + async (entryPoint): Promise => { + const submodule = entryPoint === '.' ? '' : entryPoint.replace(/^\./, '') + const typesUrl = await getTypesUrl(packageName, version, submodule) + return typesUrl ? { entryPoint, typesUrl } : null + }, + 10, + ) + + return resolved.filter((entry): entry is ResolvedEntryPoint => entry !== null) +} + +/** Minimal package manifest shape needed to resolve entry points. */ +interface PackageManifest { + name: string + exports?: unknown +} + +/** + * Resolve importable module specifiers for a package. + * + * @internal + */ +export async function getModules(packageName: string, version: string): Promise { + let pkg: PackageManifest | undefined try { - result = await doc([typesUrl], { - load: createLoader(), - resolve: createResolver(), - }) - } catch { - return { version: 1, nodes: [] } + pkg = await $fetch( + `https://esm.sh/${encodePackageName(packageName)}@${version}/package.json`, + { timeout: FETCH_TIMEOUT_MS }, + ) + } catch (e) { + // eslint-disable-next-line no-console + console.error(e) + return ['.'] + } + + const exportsField = pkg?.exports + if (!exportsField || typeof exportsField !== 'object' || Array.isArray(exportsField)) { + return ['.'] } - // Collect all nodes from all specifiers - const allNodes: DenoDocNode[] = [] - for (const nodes of Object.values(result)) { - allNodes.push(...(nodes as DenoDocNode[])) + // A submodule map keys entries by `.`/`./*`; a bare conditions map (e.g. only + // `import`/`require`) describes the root entry, so treat it as root-only. + const subpathKeys = Object.keys(exportsField).filter(key => key === '.' || key.startsWith('./')) + if (subpathKeys.length === 0) { + return ['.'] } - return { version: 1, nodes: allNodes } + return ( + subpathKeys + .filter(key => key !== './package.json' && !key.includes('*')) + // Order module specifiers with the root `.` first, then alphabetically. + .sort((a, b) => { + if (a === b) return 0 + if (a === '.') return -1 + if (b === '.') return 1 + return a.localeCompare(b) + }) + ) } // ============================================================================= @@ -160,8 +253,12 @@ 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}` +async function getTypesUrl( + packageName: string, + version: string, + submodule = '', +): Promise { + const url = `https://esm.sh/${encodePackageName(packageName)}@${version}${submodule}` try { const response = await $fetch.raw(url, { diff --git a/server/utils/docs/index.ts b/server/utils/docs/index.ts index 66a7394489..5901acb5e1 100644 --- a/server/utils/docs/index.ts +++ b/server/utils/docs/index.ts @@ -11,7 +11,9 @@ import type { DocsGenerationResult } from '#shared/types/deno-doc' import { getDocNodes } from './client' import { buildSymbolLookup, flattenNamespaces, mergeOverloads } from './processing' -import { renderDocNodes, renderToc } from './render' +import { renderDocNodes, renderGroupedDocNodes, renderGroupedToc, renderToc } from './render' +import { computeEntryPrefixes } from './text' +import type { ProcessedEntry } from './types' /** * Generate API documentation for an npm package. @@ -35,21 +37,61 @@ export async function generateDocsWithDeno( packageName: string, version: string, ): Promise { - // Get doc nodes using @deno/doc WASM + // Get doc nodes (grouped by entry point) using @deno/doc WASM const result = await getDocNodes(packageName, version) - if (!result.nodes || result.nodes.length === 0) { + if (result.entries.length === 0) { return null } - // Process nodes: flatten namespaces, merge overloads, and build lookup - const flattenedNodes = flattenNamespaces(result.nodes) - const mergedSymbols = mergeOverloads(flattenedNodes) - const symbolLookup = buildSymbolLookup(flattenedNodes) + const entries = result.entries + .map(entry => { + const flattenedNodes = flattenNamespaces(entry.nodes) + return { + entryPoint: entry.entryPoint, + nodes: flattenedNodes, + symbols: mergeOverloads(flattenedNodes), + } + }) + .filter(entry => entry.symbols.length > 0) + + if (entries.length === 0) { + return null + } + + const isMultiEntry = entries.length > 1 + + // Anchor IDs are only prefixed when multiple entry points share a page. Prefixes + // are computed as a set so lossy slugs can't collide (see computeEntryPrefixes); + // the root entry is never prefixed, so a package that also ships a root export + // keeps clean root IDs while namespacing submodules. + const prefixes = isMultiEntry + ? computeEntryPrefixes(entries.map(entry => entry.entryPoint)) + : null + + const processed: ProcessedEntry[] = entries.map(entry => { + const prefix = prefixes?.get(entry.entryPoint) ?? '' + return { + entryPoint: entry.entryPoint, + prefix, + nodes: entry.nodes, + symbols: entry.symbols, + lookup: buildSymbolLookup(entry.nodes, prefix), + } + }) + + const allNodes = processed.flatMap(entry => entry.nodes) + + if (!isMultiEntry) { + const entry = processed[0]! + const html = await renderDocNodes(entry.symbols, entry.lookup) + const toc = renderToc(entry.symbols) + return { html, toc, nodes: allNodes } + } // Render HTML and TOC from pre-computed merged symbols - const html = await renderDocNodes(mergedSymbols, symbolLookup) - const toc = renderToc(mergedSymbols) + const html = await renderGroupedDocNodes(processed) + const toc = renderGroupedToc(processed) - return { html, toc, nodes: flattenedNodes } + return { html, toc, nodes: allNodes } } diff --git a/server/utils/docs/processing.ts b/server/utils/docs/processing.ts index 9a0a1354c0..e3f0125530 100644 --- a/server/utils/docs/processing.ts +++ b/server/utils/docs/processing.ts @@ -44,12 +44,12 @@ export function flattenNamespaces(nodes: DenoDocNode[]): DenoDocNode[] { * Build a lookup table mapping symbol names to their HTML anchor IDs. * Used for {@link} cross-references. */ -export function buildSymbolLookup(nodes: DenoDocNode[]): SymbolLookup { +export function buildSymbolLookup(nodes: DenoDocNode[], prefix = ''): SymbolLookup { const lookup = new Map() for (const node of nodes) { const cleanName = cleanSymbolName(node.name) - const id = createSymbolId(node.kind, cleanName) + const id = createSymbolId(node.kind, cleanName, prefix) lookup.set(cleanName, id) } diff --git a/server/utils/docs/render.ts b/server/utils/docs/render.ts index a70f856551..538d46122c 100644 --- a/server/utils/docs/render.ts +++ b/server/utils/docs/render.ts @@ -11,7 +11,7 @@ import { highlightCodeBlock } from '../shiki' import { formatParam, formatType, getNodeSignature } from './format' import { groupMergedByKind } from './processing' import { escapeHtml, createSymbolId, parseJsDocLinks, renderMarkdown } from './text' -import type { MergedSymbol, SymbolLookup } from './types' +import type { MergedSymbol, ProcessedEntry, SymbolLookup } from './types' // ============================================================================= // Configuration @@ -55,18 +55,56 @@ const KIND_TITLES: Record = { export async function renderDocNodes( symbols: MergedSymbol[], symbolLookup: SymbolLookup, + prefix = '', ): Promise { const grouped = groupMergedByKind(symbols) const sectionPromises = KIND_DISPLAY_ORDER.map(async kind => { const kindSymbols = grouped[kind] if (!kindSymbols || kindSymbols.length === 0) return '' - return renderKindSection(kind, kindSymbols, symbolLookup) + return renderKindSection(kind, kindSymbols, symbolLookup, prefix) }) const sections = await Promise.all(sectionPromises) return sections.filter(Boolean).join('\n') } +/** + * Render multiple package entry points as grouped sections. + */ +export async function renderGroupedDocNodes(entries: ProcessedEntry[]): Promise { + const groups = await Promise.all( + entries.map(async entry => { + const isRoot = entry.entryPoint === '.' + const slug = entry.prefix + const body = await renderDocNodes(entry.symbols, entry.lookup, slug) + // Render nothing at all for an entry that produced no content, rather + // than an empty group wrapper + heading. + if (!body) return '' + + // The root entry renders flat + if (isRoot) return body + + const lines: string[] = [] + lines.push(`
`) + lines.push( + `

${escapeHtml(formatEntryPoint(entry.entryPoint))}

`, + ) + lines.push(body) + lines.push(`
`) + return lines.join('\n') + }), + ) + + return groups.filter(Boolean).join('\n') +} + +/** + * Format an entry point for display. + */ +function formatEntryPoint(entryPoint: string): string { + return entryPoint.replace(/^\.\//, '') +} + /** * Render a section for a specific symbol kind. */ @@ -74,14 +112,16 @@ async function renderKindSection( kind: string, symbols: MergedSymbol[], symbolLookup: SymbolLookup, + prefix = '', ): Promise { const title = KIND_TITLES[kind] || kind const lines: string[] = [] const renderedSymbols = await Promise.all( - symbols.map(symbol => renderMergedSymbol(symbol, symbolLookup)), + symbols.map(symbol => renderMergedSymbol(symbol, symbolLookup, prefix)), ) - lines.push(`
`) + const sectionId = prefix ? `section-${prefix}-${kind}` : `section-${kind}` + lines.push(`
`) lines.push(`

${title}

`) lines.push(...renderedSymbols) @@ -96,12 +136,13 @@ async function renderKindSection( async function renderMergedSymbol( symbol: MergedSymbol, symbolLookup: SymbolLookup, + prefix = '', ): Promise { const primaryNode = symbol.nodes[0] if (!primaryNode) return '' // Safety check - should never happen const lines: string[] = [] - const id = createSymbolId(symbol.kind, symbol.name) + const id = createSymbolId(symbol.kind, symbol.name, prefix) const hasOverloads = symbol.nodes.length > 1 lines.push(`
`) @@ -441,27 +482,43 @@ function renderEnumMembers(def: NonNullable): string { /** * Render table of contents. */ -export function renderToc(symbols: MergedSymbol[]): string { +export function renderToc(symbols: MergedSymbol[], prefix = ''): string { + return [ + ``, + ].join('\n') +} + +/** + * Render the inner TOC list (no `