/** * HTML Rendering * * Functions for rendering documentation nodes as HTML. * * @module server/utils/docs/render */ import type { DenoDocNode, JsDocTag } from '#shared/types/deno-doc' import { highlightCodeBlock } from '../shiki' import { formatParam, formatType, getNodeSignature } from './format' import { groupMergedByKind } from './processing' import { createSymbolId, escapeHtml, parseJsDocLinks, renderMarkdown } from './text' import type { MergedSymbol, SymbolLookup } from './types' // ============================================================================= // Configuration // ============================================================================= /** Maximum number of overload signatures to display per symbol */ const MAX_OVERLOAD_SIGNATURES = 5 /** Maximum number of items to show in TOC per category before truncating */ const MAX_TOC_ITEMS_PER_KIND = 50 /** Order in which symbol kinds are displayed */ const KIND_DISPLAY_ORDER = [ 'function', 'class', 'interface', 'typeAlias', 'variable', 'enum', 'namespace', ] as const /** Human-readable titles for symbol kinds */ const KIND_TITLES: Record = { function: 'Functions', class: 'Classes', interface: 'Interfaces', typeAlias: 'Type Aliases', variable: 'Variables', enum: 'Enums', namespace: 'Namespaces', } // ============================================================================= // Main Rendering Functions // ============================================================================= /** * Render all documentation nodes as HTML. */ export async function renderDocNodes( symbols: MergedSymbol[], symbolLookup: SymbolLookup, ): Promise { const grouped = groupMergedByKind(symbols) const sections: string[] = [] for (const kind of KIND_DISPLAY_ORDER) { const kindSymbols = grouped[kind] if (!kindSymbols || kindSymbols.length === 0) continue sections.push(await renderKindSection(kind, kindSymbols, symbolLookup)) } return sections.join('\n') } /** * Render a section for a specific symbol kind. */ async function renderKindSection( kind: string, symbols: MergedSymbol[], symbolLookup: SymbolLookup, ): Promise { const title = KIND_TITLES[kind] || kind const lines: string[] = [] lines.push(`
`) lines.push(`

${title}

`) for (const symbol of symbols) { lines.push(await renderMergedSymbol(symbol, symbolLookup)) } lines.push(`
`) return lines.join('\n') } /** * Render a merged symbol (with all its overloads). */ async function renderMergedSymbol( symbol: MergedSymbol, symbolLookup: SymbolLookup, ): 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 hasOverloads = symbol.nodes.length > 1 lines.push(`
`) // Header lines.push(`
`) lines.push( `#`, ) lines.push(`

${escapeHtml(symbol.name)}

`) lines.push(`${symbol.kind}`) if (primaryNode.functionDef?.isAsync) { lines.push(`async`) } if (hasOverloads) { lines.push(`${symbol.nodes.length} overloads`) } lines.push(`
`) // Signatures const signatures = symbol.nodes .slice(0, hasOverloads ? MAX_OVERLOAD_SIGNATURES : 1) .map(n => getNodeSignature(n)) .filter(Boolean) as string[] if (signatures.length > 0) { const signatureCode = signatures.join('\n') const highlightedSignature = await highlightCodeBlock(signatureCode, 'typescript') lines.push(`
${highlightedSignature}
`) if (symbol.nodes.length > MAX_OVERLOAD_SIGNATURES) { const remaining = symbol.nodes.length - MAX_OVERLOAD_SIGNATURES lines.push(`

+ ${remaining} more overloads

`) } } // Description if (symbol.jsDoc?.doc) { const description = symbol.jsDoc.doc.trim() lines.push( `
${await renderMarkdown(description, symbolLookup)}
`, ) } // JSDoc tags if (symbol.jsDoc?.tags && symbol.jsDoc.tags.length > 0) { lines.push(await renderJsDocTags(symbol.jsDoc.tags, symbolLookup)) } // Type-specific members if (symbol.kind === 'class' && primaryNode.classDef) { lines.push(renderClassMembers(primaryNode.classDef)) } else if (symbol.kind === 'interface' && primaryNode.interfaceDef) { lines.push(renderInterfaceMembers(primaryNode.interfaceDef)) } else if (symbol.kind === 'enum' && primaryNode.enumDef) { lines.push(renderEnumMembers(primaryNode.enumDef)) } lines.push(`
`) return lines.join('\n') } /** * Render JSDoc tags (params, returns, examples, etc.) */ async function renderJsDocTags(tags: JsDocTag[], symbolLookup: SymbolLookup): Promise { const lines: string[] = [] const params = tags.filter(t => t.kind === 'param') const returns = tags.find(t => t.kind === 'return') const examples = tags.filter(t => t.kind === 'example') const deprecated = tags.find(t => t.kind === 'deprecated') const see = tags.filter(t => t.kind === 'see') // Deprecated warning if (deprecated) { lines.push(`
`) lines.push(`Deprecated`) if (deprecated.doc) { // We remove new lines because they look weird when rendered into the deprecated block // I think markdown is actually supposed to collapse single new lines automatically but this function doesn't do that so if that changes remove this const renderedMessage = await renderMarkdown(deprecated.doc.replace(/\n/g, ' '), symbolLookup) lines.push(`
${renderedMessage}
`) } lines.push(`
`) } // Parameters if (params.length > 0) { lines.push(`
`) lines.push(`

Parameters

`) lines.push(`
`) for (const param of params) { lines.push( `
${escapeHtml(param.name || '')}${param.optional ? '?' : ''}
`, ) if (param.doc) { lines.push(`
${parseJsDocLinks(param.doc, symbolLookup)}
`) } } lines.push(`
`) lines.push(`
`) } // Returns if (returns?.doc) { lines.push(`
`) lines.push(`

Returns

`) lines.push(`

${parseJsDocLinks(returns.doc, symbolLookup)}

`) lines.push(`
`) } // Examples (with syntax highlighting) if (examples.length > 0) { lines.push(`
`) lines.push(`

Example${examples.length > 1 ? 's' : ''}

`) for (const example of examples) { if (example.doc) { const langMatch = example.doc.match(/```(\w+)?/) const lang = langMatch?.[1] || 'typescript' const code = example.doc.replace(/```\w*\n?/g, '').trim() const highlighted = await highlightCodeBlock(code, lang) lines.push(highlighted) } } lines.push(`
`) } // See also if (see.length > 0) { lines.push(`
`) lines.push(`

See Also

`) lines.push(`
    `) for (const s of see) { if (s.doc) { lines.push(`
  • ${parseJsDocLinks(s.doc, symbolLookup)}
  • `) } } lines.push(`
`) lines.push(`
`) } return lines.join('\n') } // ============================================================================= // Member Rendering // ============================================================================= /** * Render class members (constructor, properties, methods). */ function renderClassMembers(def: NonNullable): string { const lines: string[] = [] const { constructors, properties, methods } = def if (constructors && constructors.length > 0) { lines.push(`
`) lines.push(`

Constructor

`) for (const ctor of constructors) { const params = ctor.params?.map(p => formatParam(p)).join(', ') || '' lines.push(`
constructor(${escapeHtml(params)})
`) } lines.push(`
`) } if (properties && properties.length > 0) { lines.push(`
`) lines.push(`

Properties

`) lines.push(`
`) for (const prop of properties) { const modifiers: string[] = [] if (prop.isStatic) modifiers.push('static') if (prop.readonly) modifiers.push('readonly') const modStr = modifiers.length > 0 ? `${modifiers.join(' ')} ` : '' const type = formatType(prop.tsType) const opt = prop.optional ? '?' : '' lines.push( `
${escapeHtml(modStr)}${escapeHtml(prop.name)}${opt}: ${escapeHtml(type)}
`, ) if (prop.jsDoc?.doc) { lines.push(`
${escapeHtml(prop.jsDoc.doc.split('\n')[0] ?? '')}
`) } } lines.push(`
`) lines.push(`
`) } if (methods && methods.length > 0) { lines.push(`
`) lines.push(`

Methods

`) lines.push(`
`) for (const method of methods) { const params = method.functionDef?.params?.map(p => formatParam(p)).join(', ') || '' const ret = formatType(method.functionDef?.returnType) || 'void' const staticStr = method.isStatic ? 'static ' : '' lines.push( `
${escapeHtml(staticStr)}${escapeHtml(method.name)}(${escapeHtml(params)}): ${escapeHtml(ret)}
`, ) if (method.jsDoc?.doc) { lines.push(`
${escapeHtml(method.jsDoc.doc.split('\n')[0] ?? '')}
`) } } lines.push(`
`) lines.push(`
`) } return lines.join('\n') } /** * Render interface members (properties, methods). */ function renderInterfaceMembers(def: NonNullable): string { const lines: string[] = [] const { properties, methods } = def if (properties && properties.length > 0) { lines.push(`
`) lines.push(`

Properties

`) lines.push(`
`) for (const prop of properties) { const type = formatType(prop.tsType) const opt = prop.optional ? '?' : '' const ro = prop.readonly ? 'readonly ' : '' lines.push( `
${escapeHtml(ro)}${escapeHtml(prop.name)}${opt}: ${escapeHtml(type)}
`, ) if (prop.jsDoc?.doc) { lines.push(`
${escapeHtml(prop.jsDoc.doc.split('\n')[0] ?? '')}
`) } } lines.push(`
`) lines.push(`
`) } if (methods && methods.length > 0) { lines.push(`
`) lines.push(`

Methods

`) lines.push(`
`) for (const method of methods) { const params = method.params?.map(p => formatParam(p)).join(', ') || '' const ret = formatType(method.returnType) || 'void' lines.push( `
${escapeHtml(method.name)}(${escapeHtml(params)}): ${escapeHtml(ret)}
`, ) if (method.jsDoc?.doc) { lines.push(`
${escapeHtml(method.jsDoc.doc.split('\n')[0] ?? '')}
`) } } lines.push(`
`) lines.push(`
`) } return lines.join('\n') } /** * Render enum members. */ function renderEnumMembers(def: NonNullable): string { const lines: string[] = [] const { members } = def if (members && members.length > 0) { lines.push(`
`) lines.push(`

Members

`) lines.push(`
    `) for (const member of members) { lines.push(`
  • ${escapeHtml(member.name)}
  • `) } lines.push(`
`) lines.push(`
`) } return lines.join('\n') } // ============================================================================= // Table of Contents // ============================================================================= /** * Render table of contents. */ export function renderToc(symbols: MergedSymbol[]): string { const grouped = groupMergedByKind(symbols) const lines: string[] = [] lines.push(``) return lines.join('\n') }