Skip to content
Open
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
21 changes: 21 additions & 0 deletions app/pages/package-docs/[...path].vue
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
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
137 changes: 117 additions & 20 deletions server/utils/docs/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

// =============================================================================
Expand All @@ -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<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 mapWithConcurrency(
entryPoints,
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 }
},
10,
)

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 mapWithConcurrency(
modules,
async (entryPoint): Promise<ResolvedEntryPoint | null> => {
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<string[]> {
let pkg: PackageManifest | undefined
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 +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<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/${encodePackageName(packageName)}@${version}${submodule}`

try {
const response = await $fetch.raw(url, {
Expand Down
62 changes: 52 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 { computeEntryPrefixes } from './text'
import type { ProcessedEntry } from './types'

/**
* Generate API documentation for an npm package.
Expand All @@ -35,21 +37,61 @@ 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. 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 }
}
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