From 50a69ad3f9a29e9ff469181e194dc5477769e40f Mon Sep 17 00:00:00 2001 From: Marais Rossouw Date: Fri, 26 Jun 2026 10:28:46 +1000 Subject: [PATCH 01/12] feat: resolve package entry points from esm.sh exports --- server/utils/docs/client.ts | 122 ++++++++++++++++++++++++++++++------ shared/types/deno-doc.ts | 7 ++- 2 files changed, 108 insertions(+), 21 deletions(-) diff --git a/server/utils/docs/client.ts b/server/utils/docs/client.ts index dc3f78d1b1..a1e859a0de 100644 --- a/server/utils/docs/client.ts +++ b/server/utils/docs/client.ts @@ -8,7 +8,7 @@ */ 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 { isBuiltin } from 'node:module' // ============================================================================= @@ -24,33 +24,111 @@ const FETCH_TIMEOUT_MS = 30 * 1000 /** * Get documentation nodes for a package using @deno/doc WASM. + * + * Resolves the package's entry points: + * - root `.` export, or + * - for submodule only exports, each documented and returns doc nodes grouped by entrypoint. */ 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 (entryPoints.length === 0) { + return { version: 1, entries: [] } + } + + const entries: (DocEntry | null)[] = await Promise.all( + entryPoints.map(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 } + }), + ) - if (!typesUrl) { - return { version: 1, nodes: [] } + return { + version: 1, + entries: entries.filter((entry): entry is DocEntry => entry !== null), } +} + +// ============================================================================= +// Entry Point Resolution +// ============================================================================= + +interface ResolvedEntryPoint { + entryPoint: string + typesUrl: string +} - // Generate docs using @deno/doc WASM - let result: Record +/** + * Resolve the documentable entry points for a package. + */ +async function resolveEntryPoints( + packageName: string, + version: string, +): Promise { + const rootTypes = await getTypesUrl(packageName, version, '') + if (rootTypes) { + return [{ entryPoint: '.', typesUrl: rootTypes }] + } + + const submodules = await getSubmodules(packageName, version) + if (submodules.length === 0) { + return [] + } + + const resolved = await Promise.all( + submodules.map(async (submodule): Promise => { + const typesUrl = await getTypesUrl(packageName, version, submodule.replace(/^\./, '')) + return typesUrl ? { entryPoint: submodule, typesUrl } : null + }), + ) + + return resolved + .filter((entry: any): entry is ResolvedEntryPoint => entry !== null) + .sort((a, b) => a.entryPoint.localeCompare(b.entryPoint)) +} + +/** + * Read the importable submodule exports from a package's `package.json`. + */ +async function getSubmodules(packageName: string, version: string): Promise { + let pkg: { exports?: unknown } try { - result = await doc([typesUrl], { - load: createLoader(), - resolve: createResolver(), - }) - } catch { - return { version: 1, nodes: [] } + pkg = await $fetch<{ exports?: unknown }>( + `https://esm.sh/${packageName}@${version}/package.json`, + { timeout: FETCH_TIMEOUT_MS }, + ) + } catch (e) { + // eslint-disable-next-line no-console + console.error(e) + return [] } - // Collect all nodes from all specifiers - const allNodes: DenoDocNode[] = [] - for (const nodes of Object.values(result)) { - allNodes.push(...(nodes as DenoDocNode[])) + const exportsField = pkg.exports + if (!exportsField || typeof exportsField !== 'object' || Array.isArray(exportsField)) { + return [] } - return { version: 1, nodes: allNodes } + return Object.keys(exportsField).filter( + key => key.startsWith('./') && key !== './package.json' && !key.includes('*'), + ) } // ============================================================================= @@ -160,8 +238,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/${packageName}@${version}${submodule}` try { const response = await $fetch.raw(url, { diff --git a/shared/types/deno-doc.ts b/shared/types/deno-doc.ts index 80a30b2b97..fdd1e76b52 100644 --- a/shared/types/deno-doc.ts +++ b/shared/types/deno-doc.ts @@ -148,9 +148,14 @@ export interface DenoDocNode { } /** Raw output from deno doc --json */ +export interface DocEntry { + entryPoint: string + nodes: DenoDocNode[] +} + export interface DenoDocResult { version: number - nodes: DenoDocNode[] + entries: DocEntry[] } /** Result of documentation generation */ From f63c1ba9946da482aedfeca60a176394d1e1c42d Mon Sep 17 00:00:00 2001 From: Marais Rossouw Date: Fri, 26 Jun 2026 10:30:55 +1000 Subject: [PATCH 02/12] feat: namespace symbol anchors for multi-entry packages --- server/utils/docs/processing.ts | 4 +- server/utils/docs/text.ts | 17 +++++++- test/unit/server/utils/docs/text.spec.ts | 51 +++++++++++++++++++++++- 3 files changed, 67 insertions(+), 5 deletions(-) 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/text.ts b/server/utils/docs/text.ts index 706fbede38..5785210aec 100644 --- a/server/utils/docs/text.ts +++ b/server/utils/docs/text.ts @@ -54,8 +54,21 @@ export function cleanSymbolName(name: string): string { /** * Create a URL-safe HTML anchor ID for a symbol. */ -export function createSymbolId(kind: string, name: string): string { - return `${kind}-${name}`.replace(/[^a-z0-9-]/gi, '_') +export function createSymbolId(kind: string, name: string, prefix = ''): string { + const base = prefix ? `${prefix}-${kind}-${name}` : `${kind}-${name}` + return base.replace(/[^a-z0-9-]/gi, '_') +} + +/** + * Derive a stable, URL-safe slug from a package entry point. + */ +export function entrySlug(entryPoint: string): string { + const cleaned = entryPoint + .replace(/^\.\/?/, '') // ./mod -> mod + .replace(/[^a-z0-9]+/gi, '-') // non-alphanumeric -> dash + .replace(/^-+|-+$/g, '') // trim dashes from start/end + .toLowerCase() + return cleaned || 'root' } /** diff --git a/test/unit/server/utils/docs/text.spec.ts b/test/unit/server/utils/docs/text.spec.ts index 5353b233df..ccf04e3506 100644 --- a/test/unit/server/utils/docs/text.spec.ts +++ b/test/unit/server/utils/docs/text.spec.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest' import * as fc from 'fast-check' -import { escapeHtml, parseJsDocLinks, renderMarkdown, stripAnsi } from '#server/utils/docs/text' +import { + createSymbolId, + entrySlug, + escapeHtml, + parseJsDocLinks, + renderMarkdown, + stripAnsi, +} from '#server/utils/docs/text' import type { SymbolLookup } from '#server/utils/docs/types' describe('stripAnsi', () => { @@ -327,3 +334,45 @@ describe('renderMarkdown', () => { ) }) }) + +describe('createSymbolId', () => { + it('builds an ID without a prefix', () => { + expect(createSymbolId('function', 'make')).toBe('function-make') + }) + + it('namespaces the ID when a prefix is given', () => { + expect(createSymbolId('function', 'make', 'traceparent')).toBe('traceparent-function-make') + }) + + it('sanitises unsafe characters', () => { + expect(createSymbolId('typeAlias', 'Foo.Bar', 'a/b')).toBe('a_b-typeAlias-Foo_Bar') + }) + + it('produces distinct IDs for same-named symbols across prefixes', () => { + expect(createSymbolId('function', 'make', 'traceparent')).not.toBe( + createSymbolId('function', 'make', 'tracestate'), + ) + }) +}) + +describe('entrySlug', () => { + it('strips the leading "./" from subpath exports', () => { + expect(entrySlug('./traceparent')).toBe('traceparent') + }) + + it('maps the root export to "root"', () => { + expect(entrySlug('.')).toBe('root') + }) + + it('slugifies nested subpaths', () => { + expect(entrySlug('./sub/path')).toBe('sub-path') + }) + + it('always yields a URL-safe slug', () => { + fc.assert( + fc.property(fc.string(), input => { + expect(entrySlug(input)).toMatch(/^[a-z0-9-]+$/) + }), + ) + }) +}) From 2ab54880321d5b951f08090213724269c4baeabc Mon Sep 17 00:00:00 2001 From: Marais Rossouw Date: Fri, 26 Jun 2026 10:55:08 +1000 Subject: [PATCH 03/12] feat: render grouped docs and toc for multi-entry modules --- server/utils/docs/index.ts | 55 +++++++++++--- server/utils/docs/render.ts | 82 ++++++++++++++++++--- server/utils/docs/types.ts | 10 +++ test/unit/server/utils/docs/render.spec.ts | 85 +++++++++++++++++++++- 4 files changed, 211 insertions(+), 21 deletions(-) diff --git a/server/utils/docs/index.ts b/server/utils/docs/index.ts index 66a7394489..fff428c218 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 { entrySlug } from './text' +import type { ProcessedEntry } from './types' /** * Generate API documentation for an npm package. @@ -35,21 +37,54 @@ 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, so a + // single-entry package keeps the exact same IDs it had before. + const processed: ProcessedEntry[] = entries.map(entry => { + const prefix = isMultiEntry ? entrySlug(entry.entryPoint) : '' + return { + entryPoint: entry.entryPoint, + 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/render.ts b/server/utils/docs/render.ts index a70f856551..243dedbb4c 100644 --- a/server/utils/docs/render.ts +++ b/server/utils/docs/render.ts @@ -10,8 +10,8 @@ import type { DenoDocNode, JsDocTag } from '#shared/types/deno-doc' 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 { escapeHtml, createSymbolId, entrySlug, parseJsDocLinks, renderMarkdown } from './text' +import type { MergedSymbol, ProcessedEntry, SymbolLookup } from './types' // ============================================================================= // Configuration @@ -55,18 +55,52 @@ 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 slug = entrySlug(entry.entryPoint) + 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 '' + + 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 === '.' ? '.' : entryPoint.replace(/^\.\//, '') +} + /** * Render a section for a specific symbol kind. */ @@ -74,14 +108,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 +132,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,7 +478,7 @@ function renderEnumMembers(def: NonNullable): string { /** * Render table of contents. */ -export function renderToc(symbols: MergedSymbol[]): string { +export function renderToc(symbols: MergedSymbol[], prefix = ''): string { const grouped = groupMergedByKind(symbols) const lines: string[] = [] @@ -453,15 +490,16 @@ export function renderToc(symbols: MergedSymbol[]): string { if (!kindSymbols || kindSymbols.length === 0) continue const title = KIND_TITLES[kind] || kind + const sectionId = prefix ? `section-${prefix}-${kind}` : `section-${kind}` lines.push(`
  • `) lines.push( - `${title} (${kindSymbols.length})`, + `${title} (${kindSymbols.length})`, ) const showSymbols = kindSymbols.slice(0, MAX_TOC_ITEMS_PER_KIND) lines.push(`
      `) for (const symbol of showSymbols) { - const id = createSymbolId(symbol.kind, symbol.name) + const id = createSymbolId(symbol.kind, symbol.name, prefix) lines.push( `
    • ${escapeHtml(symbol.name)}
    • `, ) @@ -480,3 +518,29 @@ export function renderToc(symbols: MergedSymbol[]): string { return lines.join('\n') } + +/** + * Render a table of contents grouped by package entry point. + */ +export function renderGroupedToc(entries: ProcessedEntry[]): string { + const lines: string[] = [] + + lines.push(``) + + return lines.join('\n') +} diff --git a/server/utils/docs/types.ts b/server/utils/docs/types.ts index c4e373b49a..964d7aec6d 100644 --- a/server/utils/docs/types.ts +++ b/server/utils/docs/types.ts @@ -22,3 +22,13 @@ export interface MergedSymbol { nodes: DenoDocNode[] jsDoc?: DenoDocNode['jsDoc'] } + +/** + * Processed entry point with merged symbols and lookup ready for rendering. + */ +export interface ProcessedEntry { + entryPoint: string + nodes: DenoDocNode[] + symbols: MergedSymbol[] + lookup: SymbolLookup +} diff --git a/test/unit/server/utils/docs/render.spec.ts b/test/unit/server/utils/docs/render.spec.ts index 2d595425a7..625cfe2c5c 100644 --- a/test/unit/server/utils/docs/render.spec.ts +++ b/test/unit/server/utils/docs/render.spec.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from 'vitest' -import { renderDocNodes } from '#server/utils/docs/render' +import { renderDocNodes, renderGroupedDocNodes, renderGroupedToc } from '#server/utils/docs/render' +import { buildSymbolLookup, mergeOverloads } from '#server/utils/docs/processing' +import { entrySlug } from '#server/utils/docs/text' import type { DenoDocNode } from '#shared/types/deno-doc' -import type { MergedSymbol } from '#server/utils/docs/types' +import type { MergedSymbol, ProcessedEntry } from '#server/utils/docs/types' // ============================================================================= // Issue #1943: class getters shown as methods @@ -216,3 +218,82 @@ describe('renderDocNodes examples', () => { expect(html).not.toContain('```') }) }) + +// ============================================================================= +// Multi-entry packages +// ============================================================================= + +function createEntry(entryPoint: string, fnNames: string[]): ProcessedEntry { + const nodes: DenoDocNode[] = fnNames.map(name => ({ + name, + kind: 'function', + functionDef: { + params: [], + returnType: { repr: 'void', kind: 'keyword', keyword: 'void' }, + }, + })) + const prefix = entrySlug(entryPoint) + return { + entryPoint, + nodes, + symbols: mergeOverloads(nodes), + lookup: buildSymbolLookup(nodes, prefix), + } +} + +describe('renderGroupedDocNodes - multi-entry packages', () => { + it('keeps same-named symbols from different entries separate', async () => { + const entries = [ + createEntry('./traceparent', ['make', 'parse']), + createEntry('./tracestate', ['make', 'parse']), + ] + + const html = await renderGroupedDocNodes(entries) + + // Both entries get their own group with a heading showing the subpath + // (with the leading `./` pruned). + expect(html).toContain('id="group-traceparent"') + expect(html).toContain('id="group-tracestate"') + expect(html).toContain('>traceparent') + expect(html).toContain('>tracestate') + expect(html).not.toContain('./traceparent') + expect(html).not.toContain('./tracestate') + + // Same-named exports must produce distinct, namespaced anchor IDs. + expect(html).toContain('id="traceparent-function-make"') + expect(html).toContain('id="tracestate-function-make"') + expect(html).not.toContain('id="function-make"') + + // Each "make" appears once per entry (not merged into one "2 overloads"). + expect(html).not.toContain('2 overloads') + }) + + it('namespaces section IDs per entry', async () => { + const entries = [createEntry('./traceparent', ['make']), createEntry('./tracestate', ['make'])] + + const html = await renderGroupedDocNodes(entries) + + expect(html).toContain('id="section-traceparent-function"') + expect(html).toContain('id="section-tracestate-function"') + }) +}) + +describe('renderGroupedToc - multi-entry packages', () => { + it('renders one TOC block per entry with matching anchors', () => { + const entries = [ + createEntry('./traceparent', ['make', 'parse']), + createEntry('./tracestate', ['make']), + ] + + const toc = renderGroupedToc(entries) + + expect(toc).toContain('href="#group-traceparent"') + expect(toc).toContain('href="#group-tracestate"') + expect(toc).toContain('href="#section-traceparent-function"') + expect(toc).toContain('href="#traceparent-function-make"') + expect(toc).toContain('href="#tracestate-function-make"') + // Entry labels prune the leading `./` and aren't mono. + expect(toc).toContain('>traceparent') + expect(toc).not.toContain('./traceparent') + }) +}) From 039dc2086ffdafe721363374455ee961a58e97d5 Mon Sep 17 00:00:00 2001 From: Marais Rossouw Date: Fri, 26 Jun 2026 11:27:47 +1000 Subject: [PATCH 04/12] chore: resolve root and submodule exports together --- package.json | 1 + pnpm-lock.yaml | 9 +++ server/utils/docs/client.ts | 75 +++++++++++++--------- server/utils/docs/index.ts | 7 +- server/utils/docs/render.ts | 18 +++++- test/unit/server/utils/docs/render.spec.ts | 42 +++++++++++- 6 files changed, 116 insertions(+), 36 deletions(-) diff --git a/package.json b/package.json index c94585abc5..d1beb4f657 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,7 @@ "ohash": "2.0.11", "packumeta": "0.4.1", "perfect-debounce": "2.1.0", + "resolve.exports": "2.0.3", "sanitize-html": "2.17.2", "semver": "7.7.4", "shiki": "4.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a9284c19ca..ece6adbc5d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -213,6 +213,9 @@ importers: perfect-debounce: specifier: 2.1.0 version: 2.1.0 + resolve.exports: + specifier: 2.0.3 + version: 2.0.3 sanitize-html: specifier: 2.17.2 version: 2.17.2 @@ -9945,6 +9948,10 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + resolve@1.22.11: resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} engines: {node: '>= 0.4'} @@ -22748,6 +22755,8 @@ snapshots: resolve-pkg-maps@1.0.0: {} + resolve.exports@2.0.3: {} + resolve@1.22.11: dependencies: is-core-module: 2.16.1 diff --git a/server/utils/docs/client.ts b/server/utils/docs/client.ts index a1e859a0de..d494ed1496 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, DocEntry } from '#shared/types/deno-doc' import { isBuiltin } from 'node:module' +import { exports as resolveExports } from 'resolve.exports' // ============================================================================= // Configuration @@ -24,10 +25,6 @@ const FETCH_TIMEOUT_MS = 30 * 1000 /** * Get documentation nodes for a package using @deno/doc WASM. - * - * Resolves the package's entry points: - * - root `.` export, or - * - for submodule only exports, each documented and returns doc nodes grouped by entrypoint. */ export async function getDocNodes(packageName: string, version: string): Promise { const entryPoints = await resolveEntryPoints(packageName, version) @@ -83,52 +80,72 @@ async function resolveEntryPoints( packageName: string, version: string, ): Promise { - const rootTypes = await getTypesUrl(packageName, version, '') - if (rootTypes) { - return [{ entryPoint: '.', typesUrl: rootTypes }] - } - - const submodules = await getSubmodules(packageName, version) - if (submodules.length === 0) { - return [] - } + const modules = await getModules(packageName, version) const resolved = await Promise.all( - submodules.map(async (submodule): Promise => { - const typesUrl = await getTypesUrl(packageName, version, submodule.replace(/^\./, '')) - return typesUrl ? { entryPoint: submodule, typesUrl } : null + modules.map(async (entryPoint): Promise => { + const submodule = entryPoint === '.' ? '' : entryPoint.replace(/^\./, '') + const typesUrl = await getTypesUrl(packageName, version, submodule) + return typesUrl ? { entryPoint, typesUrl } : null }), ) - return resolved - .filter((entry: any): entry is ResolvedEntryPoint => entry !== null) - .sort((a, b) => a.entryPoint.localeCompare(b.entryPoint)) + return resolved.filter((entry): entry is ResolvedEntryPoint => entry !== null) +} + +/** Minimal package manifest shape needed to resolve entry points. */ +interface PackageManifest { + name: string + exports?: unknown } /** - * Read the importable submodule exports from a package's `package.json`. + * Resolve importable module specifiers for a package. */ -async function getSubmodules(packageName: string, version: string): Promise { - let pkg: { exports?: unknown } +async function getModules(packageName: string, version: string): Promise { + let pkg: PackageManifest try { - pkg = await $fetch<{ exports?: unknown }>( - `https://esm.sh/${packageName}@${version}/package.json`, + 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 [] + return ['.'] } const exportsField = pkg.exports if (!exportsField || typeof exportsField !== 'object' || Array.isArray(exportsField)) { - return [] + return ['.'] } - return Object.keys(exportsField).filter( - key => key.startsWith('./') && key !== './package.json' && !key.includes('*'), - ) + // 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 ['.'] + } + + const candidates = subpathKeys.filter(key => key !== './package.json' && !key.includes('*')) + + // Keep only specifiers that actually resolve to a target + const modules = candidates.filter(key => { + try { + const target = resolveExports(pkg, key) + return Boolean(target && target.length > 0) + } catch { + return false + } + }) + + // Order module specifiers with the root `.` first, then alphabetically. + return [...modules].sort((a, b) => { + if (a === b) return 0 + if (a === '.') return -1 + if (b === '.') return 1 + return a.localeCompare(b) + }) } // ============================================================================= diff --git a/server/utils/docs/index.ts b/server/utils/docs/index.ts index fff428c218..979584a37e 100644 --- a/server/utils/docs/index.ts +++ b/server/utils/docs/index.ts @@ -61,10 +61,11 @@ export async function generateDocsWithDeno( const isMultiEntry = entries.length > 1 - // Anchor IDs are only prefixed when multiple entry points share a page, so a - // single-entry package keeps the exact same IDs it had before. + // Anchor IDs are only prefixed when multiple entry points share a page. The root entry + // is never prefixed, so a package that also ships a root export keeps clean + // root IDs while namespacing submodules. const processed: ProcessedEntry[] = entries.map(entry => { - const prefix = isMultiEntry ? entrySlug(entry.entryPoint) : '' + const prefix = isMultiEntry && entry.entryPoint !== '.' ? entrySlug(entry.entryPoint) : '' return { entryPoint: entry.entryPoint, nodes: entry.nodes, diff --git a/server/utils/docs/render.ts b/server/utils/docs/render.ts index 243dedbb4c..f92265502c 100644 --- a/server/utils/docs/render.ts +++ b/server/utils/docs/render.ts @@ -74,12 +74,16 @@ export async function renderDocNodes( export async function renderGroupedDocNodes(entries: ProcessedEntry[]): Promise { const groups = await Promise.all( entries.map(async entry => { - const slug = entrySlug(entry.entryPoint) + const isRoot = entry.entryPoint === '.' + const slug = isRoot ? '' : entrySlug(entry.entryPoint) 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( @@ -98,7 +102,7 @@ export async function renderGroupedDocNodes(entries: ProcessedEntry[]): Promise< * Format an entry point for display. */ function formatEntryPoint(entryPoint: string): string { - return entryPoint === '.' ? '.' : entryPoint.replace(/^\.\//, '') + return entryPoint.replace(/^\.\//, '') } /** @@ -530,7 +534,15 @@ export function renderGroupedToc(entries: ProcessedEntry[]): string { for (const entry of entries) { if (entry.symbols.length === 0) continue - const slug = entrySlug(entry.entryPoint) + const isRoot = entry.entryPoint === '.' + const slug = isRoot ? '' : entrySlug(entry.entryPoint) + + // The root entry's contents sit flat at the top with no group label. + if (isRoot) { + lines.push(renderToc(entry.symbols, slug)) + continue + } + lines.push(`
      `) lines.push( `${escapeHtml(formatEntryPoint(entry.entryPoint))}`, diff --git a/test/unit/server/utils/docs/render.spec.ts b/test/unit/server/utils/docs/render.spec.ts index 625cfe2c5c..17d8e094e4 100644 --- a/test/unit/server/utils/docs/render.spec.ts +++ b/test/unit/server/utils/docs/render.spec.ts @@ -232,7 +232,7 @@ function createEntry(entryPoint: string, fnNames: string[]): ProcessedEntry { returnType: { repr: 'void', kind: 'keyword', keyword: 'void' }, }, })) - const prefix = entrySlug(entryPoint) + const prefix = entryPoint === '.' ? '' : entrySlug(entryPoint) return { entryPoint, nodes, @@ -276,6 +276,32 @@ describe('renderGroupedDocNodes - multi-entry packages', () => { expect(html).toContain('id="section-traceparent-function"') expect(html).toContain('id="section-tracestate-function"') }) + + it('renders the root entry flat while grouping subpaths', async () => { + const entries = [createEntry('.', ['create']), createEntry('./feature', ['make'])] + + const html = await renderGroupedDocNodes(entries) + + // Root content is flat: no group wrapper or heading for `.`. + expect(html).not.toContain('id="group-"') + expect(html).not.toContain('>.') + // Root symbols keep clean, unprefixed IDs. + expect(html).toContain('id="function-create"') + expect(html).not.toContain('id="root-function-create"') + // Subpaths still render as their own prefixed group. + expect(html).toContain('id="group-feature"') + expect(html).toContain('>feature') + expect(html).toContain('id="feature-function-make"') + }) + + it('does not collide root and subpath IDs when names match', async () => { + const entries = [createEntry('.', ['make']), createEntry('./feature', ['make'])] + + const html = await renderGroupedDocNodes(entries) + + expect(html).toContain('id="function-make"') + expect(html).toContain('id="feature-function-make"') + }) }) describe('renderGroupedToc - multi-entry packages', () => { @@ -296,4 +322,18 @@ describe('renderGroupedToc - multi-entry packages', () => { expect(toc).toContain('>traceparent') expect(toc).not.toContain('./traceparent') }) + + it('renders the root entry flat with no group label', () => { + const entries = [createEntry('.', ['create']), createEntry('./feature', ['make'])] + + const toc = renderGroupedToc(entries) + + // Root has no group label and keeps clean anchors. + expect(toc).not.toContain('href="#group-"') + expect(toc).toContain('href="#section-function"') + expect(toc).toContain('href="#function-create"') + // Subpath keeps its group label + namespaced anchors. + expect(toc).toContain('href="#group-feature"') + expect(toc).toContain('href="#feature-function-make"') + }) }) From 4fc0ebf211e429ee93f1f88a8bb00fc4a108b357 Mon Sep 17 00:00:00 2001 From: Marais Rossouw Date: Fri, 26 Jun 2026 13:29:16 +1000 Subject: [PATCH 05/12] chore: style the submodule headers a little --- app/pages/package-docs/[...path].vue | 8 ++++++++ server/utils/docs/render.ts | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/app/pages/package-docs/[...path].vue b/app/pages/package-docs/[...path].vue index bc0315a6dd..67f2b28a4a 100644 --- a/app/pages/package-docs/[...path].vue +++ b/app/pages/package-docs/[...path].vue @@ -286,6 +286,14 @@ const stickyStyle = computed(() => { 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/render.ts b/server/utils/docs/render.ts index f92265502c..d0a8581e61 100644 --- a/server/utils/docs/render.ts +++ b/server/utils/docs/render.ts @@ -87,7 +87,7 @@ export async function renderGroupedDocNodes(entries: ProcessedEntry[]): Promise< const lines: string[] = [] lines.push(`
      `) lines.push( - `

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

      `, + `

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

      `, ) lines.push(body) lines.push(`
      `) From 9cccfb88138a58fd81d085b6457362bc952c5490 Mon Sep 17 00:00:00 2001 From: Marais Rossouw Date: Fri, 26 Jun 2026 13:01:38 +1000 Subject: [PATCH 06/12] chore: actually we dont need resolve.exports --- package.json | 1 - pnpm-lock.yaml | 9 --------- server/utils/docs/client.ts | 33 ++++++++++++--------------------- 3 files changed, 12 insertions(+), 31 deletions(-) diff --git a/package.json b/package.json index d1beb4f657..c94585abc5 100644 --- a/package.json +++ b/package.json @@ -98,7 +98,6 @@ "ohash": "2.0.11", "packumeta": "0.4.1", "perfect-debounce": "2.1.0", - "resolve.exports": "2.0.3", "sanitize-html": "2.17.2", "semver": "7.7.4", "shiki": "4.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ece6adbc5d..a9284c19ca 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -213,9 +213,6 @@ importers: perfect-debounce: specifier: 2.1.0 version: 2.1.0 - resolve.exports: - specifier: 2.0.3 - version: 2.0.3 sanitize-html: specifier: 2.17.2 version: 2.17.2 @@ -9948,10 +9945,6 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - resolve.exports@2.0.3: - resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} - engines: {node: '>=10'} - resolve@1.22.11: resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} engines: {node: '>= 0.4'} @@ -22755,8 +22748,6 @@ snapshots: resolve-pkg-maps@1.0.0: {} - resolve.exports@2.0.3: {} - resolve@1.22.11: dependencies: is-core-module: 2.16.1 diff --git a/server/utils/docs/client.ts b/server/utils/docs/client.ts index d494ed1496..05b8cb6cfa 100644 --- a/server/utils/docs/client.ts +++ b/server/utils/docs/client.ts @@ -10,7 +10,6 @@ import { doc, type DocNode } from '@deno/doc' import type { DenoDocNode, DenoDocResult, DocEntry } from '#shared/types/deno-doc' import { isBuiltin } from 'node:module' -import { exports as resolveExports } from 'resolve.exports' // ============================================================================= // Configuration @@ -106,7 +105,7 @@ async function getModules(packageName: string, version: string): Promise( - `https://esm.sh/${encodePackageName(packageName)}/${version}/package.json`, + `https://esm.sh/${encodePackageName(packageName)}@${version}/package.json`, { timeout: FETCH_TIMEOUT_MS }, ) } catch (e) { @@ -127,25 +126,17 @@ async function getModules(packageName: string, version: string): Promise key !== './package.json' && !key.includes('*')) - - // Keep only specifiers that actually resolve to a target - const modules = candidates.filter(key => { - try { - const target = resolveExports(pkg, key) - return Boolean(target && target.length > 0) - } catch { - return false - } - }) - - // Order module specifiers with the root `.` first, then alphabetically. - return [...modules].sort((a, b) => { - if (a === b) return 0 - if (a === '.') return -1 - if (b === '.') return 1 - return a.localeCompare(b) - }) + 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) + }) + ) } // ============================================================================= From 4a65fac277dd0ca4149c273066e02809319a4155 Mon Sep 17 00:00:00 2001 From: Marais Rossouw Date: Fri, 26 Jun 2026 15:12:26 +1000 Subject: [PATCH 07/12] chore: add some tests around getModules --- server/utils/docs/client.ts | 8 +- test/unit/server/utils/docs/client.spec.ts | 85 ++++++++++++++++++++++ 2 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 test/unit/server/utils/docs/client.spec.ts diff --git a/server/utils/docs/client.ts b/server/utils/docs/client.ts index 05b8cb6cfa..f080175308 100644 --- a/server/utils/docs/client.ts +++ b/server/utils/docs/client.ts @@ -100,9 +100,11 @@ interface PackageManifest { /** * Resolve importable module specifiers for a package. + * + * @internal */ -async function getModules(packageName: string, version: string): Promise { - let pkg: PackageManifest +export async function getModules(packageName: string, version: string): Promise { + let pkg: PackageManifest | undefined try { pkg = await $fetch( `https://esm.sh/${encodePackageName(packageName)}@${version}/package.json`, @@ -114,7 +116,7 @@ async function getModules(packageName: string, version: string): Promise { + beforeEach(() => { + $fetchMock.mockReset() + }) + + it('falls back to root when esm.sh resolves an empty body', async () => { + $fetchMock.mockResolvedValue(undefined) + + await expect(getModules('ufo', '1.6.3')).resolves.toEqual(['.']) + }) + + it('falls back to root when the manifest fetch throws', async () => { + $fetchMock.mockRejectedValue(new Error('network')) + + await expect(getModules('ufo', '1.6.3')).resolves.toEqual(['.']) + }) + + it('falls back to root when there is no exports field', async () => { + $fetchMock.mockResolvedValue({ name: 'is-odd' }) + + await expect(getModules('is-odd', '3.0.1')).resolves.toEqual(['.']) + }) + + it('treats a bare conditions map (no submodules) as root-only', async () => { + $fetchMock.mockResolvedValue({ + name: 'ufo', + exports: { import: './dist/index.mjs', require: './dist/index.cjs' }, + }) + + await expect(getModules('ufo', '1.6.3')).resolves.toEqual(['.']) + }) + + it('resolves a root entry alongside submodules, root first', async () => { + $fetchMock.mockResolvedValue({ + name: 'unstorage', + exports: { + '.': './dist/index.mjs', + './server': './dist/server.mjs', + './drivers/fs': './dist/drivers/fs.mjs', + }, + }) + + await expect(getModules('unstorage', '1.10.2')).resolves.toEqual([ + '.', + './drivers/fs', + './server', + ]) + }) + + it('resolves submodule-only packages without a root entry', async () => { + $fetchMock.mockResolvedValue({ + name: 'tctx', + exports: { + './traceparent': { types: './traceparent.d.mts', default: './traceparent.mjs' }, + './tracestate': { types: './tracestate.d.mts', default: './tracestate.mjs' }, + './package.json': './package.json', + }, + }) + + await expect(getModules('tctx', '0.2.5')).resolves.toEqual(['./traceparent', './tracestate']) + }) + + it('ignores ./package.json and wildcard submodules', async () => { + $fetchMock.mockResolvedValue({ + name: 'pkg', + exports: { + '.': './index.mjs', + './package.json': './package.json', + './features/*': './features/*.mjs', + }, + }) + + await expect(getModules('pkg', '1.0.0')).resolves.toEqual(['.']) + }) +}) From 72d60d617b22ff7f11871b74ad21cec161bed450 Mon Sep 17 00:00:00 2001 From: Marais Rossouw Date: Fri, 26 Jun 2026 15:28:49 +1000 Subject: [PATCH 08/12] fix: address all AI review comments --- app/pages/package-docs/[...path].vue | 4 +++ server/utils/docs/client.ts | 17 ++++++---- server/utils/docs/index.ts | 16 ++++++--- server/utils/docs/render.ts | 23 +++++++++---- server/utils/docs/text.ts | 27 +++++++++++++++ server/utils/docs/types.ts | 1 + test/unit/server/utils/docs/render.spec.ts | 12 +++++++ test/unit/server/utils/docs/text.spec.ts | 39 ++++++++++++++++++++++ 8 files changed, 121 insertions(+), 18 deletions(-) diff --git a/app/pages/package-docs/[...path].vue b/app/pages/package-docs/[...path].vue index 67f2b28a4a..757e052b92 100644 --- a/app/pages/package-docs/[...path].vue +++ b/app/pages/package-docs/[...path].vue @@ -286,6 +286,10 @@ 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; } diff --git a/server/utils/docs/client.ts b/server/utils/docs/client.ts index f080175308..cbebb3c11b 100644 --- a/server/utils/docs/client.ts +++ b/server/utils/docs/client.ts @@ -9,6 +9,7 @@ import { doc, type DocNode } from '@deno/doc' import type { DenoDocNode, DenoDocResult, DocEntry } from '#shared/types/deno-doc' +import { mapWithConcurrency } from '#shared/utils/async' import { isBuiltin } from 'node:module' // ============================================================================= @@ -32,8 +33,9 @@ export async function getDocNodes(packageName: string, version: string): Promise return { version: 1, entries: [] } } - const entries: (DocEntry | null)[] = await Promise.all( - entryPoints.map(async ({ entryPoint, typesUrl }): Promise => { + const entries: (DocEntry | null)[] = await mapWithConcurrency( + entryPoints, + async ({ entryPoint, typesUrl }): Promise => { let result: Record try { result = await doc([typesUrl], { @@ -54,7 +56,8 @@ export async function getDocNodes(packageName: string, version: string): Promise } return { entryPoint, nodes } - }), + }, + 10, ) return { @@ -81,12 +84,14 @@ async function resolveEntryPoints( ): Promise { const modules = await getModules(packageName, version) - const resolved = await Promise.all( - modules.map(async (entryPoint): Promise => { + 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) diff --git a/server/utils/docs/index.ts b/server/utils/docs/index.ts index 979584a37e..5901acb5e1 100644 --- a/server/utils/docs/index.ts +++ b/server/utils/docs/index.ts @@ -12,7 +12,7 @@ import type { DocsGenerationResult } from '#shared/types/deno-doc' import { getDocNodes } from './client' import { buildSymbolLookup, flattenNamespaces, mergeOverloads } from './processing' import { renderDocNodes, renderGroupedDocNodes, renderGroupedToc, renderToc } from './render' -import { entrySlug } from './text' +import { computeEntryPrefixes } from './text' import type { ProcessedEntry } from './types' /** @@ -61,13 +61,19 @@ export async function generateDocsWithDeno( const isMultiEntry = entries.length > 1 - // Anchor IDs are only prefixed when multiple entry points share a page. The root entry - // is never prefixed, so a package that also ships a root export keeps clean - // root IDs while namespacing submodules. + // 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 = isMultiEntry && entry.entryPoint !== '.' ? entrySlug(entry.entryPoint) : '' + const prefix = prefixes?.get(entry.entryPoint) ?? '' return { entryPoint: entry.entryPoint, + prefix, nodes: entry.nodes, symbols: entry.symbols, lookup: buildSymbolLookup(entry.nodes, prefix), diff --git a/server/utils/docs/render.ts b/server/utils/docs/render.ts index d0a8581e61..6a469ee7b7 100644 --- a/server/utils/docs/render.ts +++ b/server/utils/docs/render.ts @@ -10,7 +10,7 @@ import type { DenoDocNode, JsDocTag } from '#shared/types/deno-doc' import { highlightCodeBlock } from '../shiki' import { formatParam, formatType, getNodeSignature } from './format' import { groupMergedByKind } from './processing' -import { escapeHtml, createSymbolId, entrySlug, parseJsDocLinks, renderMarkdown } from './text' +import { escapeHtml, createSymbolId, parseJsDocLinks, renderMarkdown } from './text' import type { MergedSymbol, ProcessedEntry, SymbolLookup } from './types' // ============================================================================= @@ -75,7 +75,7 @@ export async function renderGroupedDocNodes(entries: ProcessedEntry[]): Promise< const groups = await Promise.all( entries.map(async entry => { const isRoot = entry.entryPoint === '.' - const slug = isRoot ? '' : entrySlug(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. @@ -483,10 +483,20 @@ function renderEnumMembers(def: NonNullable): string { * Render table of contents. */ export function renderToc(symbols: MergedSymbol[], prefix = ''): string { + return [ + ``, + ].join('\n') +} + +/** + * Render the inner TOC list (no `
      `) } diff --git a/server/utils/docs/text.ts b/server/utils/docs/text.ts index 5785210aec..a0dd138297 100644 --- a/server/utils/docs/text.ts +++ b/server/utils/docs/text.ts @@ -71,6 +71,33 @@ export function entrySlug(entryPoint: string): string { return cleaned || 'root' } +/** + * Compute collision-free anchor prefixes for a set of entry points. + */ +export function computeEntryPrefixes(entryPoints: string[]): Map { + const prefixes = new Map() + const used = new Set() + + for (const entryPoint of entryPoints) { + if (entryPoint === '.') { + prefixes.set(entryPoint, '') + continue + } + + const base = entrySlug(entryPoint) + let slug = base + let counter = 2 + while (used.has(slug)) { + slug = `${base}-${counter++}` + } + + used.add(slug) + prefixes.set(entryPoint, slug) + } + + return prefixes +} + /** * Parse JSDoc {@link} tags into HTML links. * diff --git a/server/utils/docs/types.ts b/server/utils/docs/types.ts index 964d7aec6d..16f1e152ac 100644 --- a/server/utils/docs/types.ts +++ b/server/utils/docs/types.ts @@ -28,6 +28,7 @@ export interface MergedSymbol { */ export interface ProcessedEntry { entryPoint: string + prefix: string nodes: DenoDocNode[] symbols: MergedSymbol[] lookup: SymbolLookup diff --git a/test/unit/server/utils/docs/render.spec.ts b/test/unit/server/utils/docs/render.spec.ts index 17d8e094e4..205f2f1ed5 100644 --- a/test/unit/server/utils/docs/render.spec.ts +++ b/test/unit/server/utils/docs/render.spec.ts @@ -235,6 +235,7 @@ function createEntry(entryPoint: string, fnNames: string[]): ProcessedEntry { const prefix = entryPoint === '.' ? '' : entrySlug(entryPoint) return { entryPoint, + prefix, nodes, symbols: mergeOverloads(nodes), lookup: buildSymbolLookup(nodes, prefix), @@ -336,4 +337,15 @@ describe('renderGroupedToc - multi-entry packages', () => { expect(toc).toContain('href="#group-feature"') expect(toc).toContain('href="#feature-function-make"') }) + + it('exposes a single table-of-contents navigation landmark', () => { + const entries = [createEntry('./traceparent', ['make']), createEntry('./tracestate', ['make'])] + + const toc = renderGroupedToc(entries) + + // Nested