Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
8 changes: 8 additions & 0 deletions app/pages/package-docs/[...path].vue
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment thread
maraisr marked this conversation as resolved.

/* Individual symbol articles */
.docs-content .docs-symbol {
@apply mb-10 pb-10 border-b border-border/30 last:border-0;
Expand Down
130 changes: 110 additions & 20 deletions server/utils/docs/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

// =============================================================================
Expand All @@ -26,31 +26,117 @@ 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)
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<string, DocNode[]>
const entries: (DocEntry | null)[] = await Promise.all(
entryPoints.map(async ({ entryPoint, typesUrl }): Promise<DocEntry | null> => {
let result: Record<string, DocNode[]>
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 }
}),
)

return {
version: 1,
entries: entries.filter((entry): entry is DocEntry => entry !== null),
}
}
Comment thread
maraisr marked this conversation as resolved.

// =============================================================================
// 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<ResolvedEntryPoint[]> {
const modules = await getModules(packageName, version)

const resolved = await Promise.all(
modules.map(async (entryPoint): Promise<ResolvedEntryPoint | null> => {
const submodule = entryPoint === '.' ? '' : entryPoint.replace(/^\./, '')
const typesUrl = await getTypesUrl(packageName, version, submodule)
return typesUrl ? { entryPoint, typesUrl } : null
}),
)

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.
*/
async function getModules(packageName: string, version: string): Promise<string[]> {
let pkg: PackageManifest
try {
result = await doc([typesUrl], {
load: createLoader(),
resolve: createResolver(),
})
} catch {
return { version: 1, nodes: [] }
pkg = await $fetch<PackageManifest>(
`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)
})
)
}

// =============================================================================
Expand Down Expand Up @@ -160,8 +246,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<string | null> {
const url = `https://esm.sh/${packageName}@${version}`
async function getTypesUrl(
packageName: string,
version: string,
submodule = '',
): Promise<string | null> {
const url = `https://esm.sh/${packageName}@${version}${submodule}`

try {
const response = await $fetch.raw(url, {
Expand Down
56 changes: 46 additions & 10 deletions server/utils/docs/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -35,21 +37,55 @@ export async function generateDocsWithDeno(
packageName: string,
version: string,
): Promise<DocsGenerationResult | null> {
// 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. 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 && entry.entryPoint !== '.' ? 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 }
}
4 changes: 2 additions & 2 deletions server/utils/docs/processing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>()

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)
}

Expand Down
Loading
Loading