[READ-ONLY] a fast, modern browser for the npm registry
at main 431 lines 14 kB view raw
1/** 2 * HTML Rendering 3 * 4 * Functions for rendering documentation nodes as HTML. 5 * 6 * @module server/utils/docs/render 7 */ 8 9import type { DenoDocNode, JsDocTag } from '#shared/types/deno-doc' 10import { highlightCodeBlock } from '../shiki' 11import { formatParam, formatType, getNodeSignature } from './format' 12import { groupMergedByKind } from './processing' 13import { createSymbolId, escapeHtml, parseJsDocLinks, renderMarkdown } from './text' 14import type { MergedSymbol, SymbolLookup } from './types' 15 16// ============================================================================= 17// Configuration 18// ============================================================================= 19 20/** Maximum number of overload signatures to display per symbol */ 21const MAX_OVERLOAD_SIGNATURES = 5 22 23/** Maximum number of items to show in TOC per category before truncating */ 24const MAX_TOC_ITEMS_PER_KIND = 50 25 26/** Order in which symbol kinds are displayed */ 27const KIND_DISPLAY_ORDER = [ 28 'function', 29 'class', 30 'interface', 31 'typeAlias', 32 'variable', 33 'enum', 34 'namespace', 35] as const 36 37/** Human-readable titles for symbol kinds */ 38const KIND_TITLES: Record<string, string> = { 39 function: 'Functions', 40 class: 'Classes', 41 interface: 'Interfaces', 42 typeAlias: 'Type Aliases', 43 variable: 'Variables', 44 enum: 'Enums', 45 namespace: 'Namespaces', 46} 47 48// ============================================================================= 49// Main Rendering Functions 50// ============================================================================= 51 52/** 53 * Render all documentation nodes as HTML. 54 */ 55export async function renderDocNodes( 56 symbols: MergedSymbol[], 57 symbolLookup: SymbolLookup, 58): Promise<string> { 59 const grouped = groupMergedByKind(symbols) 60 const sections: string[] = [] 61 62 for (const kind of KIND_DISPLAY_ORDER) { 63 const kindSymbols = grouped[kind] 64 if (!kindSymbols || kindSymbols.length === 0) continue 65 66 sections.push(await renderKindSection(kind, kindSymbols, symbolLookup)) 67 } 68 69 return sections.join('\n') 70} 71 72/** 73 * Render a section for a specific symbol kind. 74 */ 75async function renderKindSection( 76 kind: string, 77 symbols: MergedSymbol[], 78 symbolLookup: SymbolLookup, 79): Promise<string> { 80 const title = KIND_TITLES[kind] || kind 81 const lines: string[] = [] 82 83 lines.push(`<section class="docs-section" id="section-${kind}">`) 84 lines.push(`<h2 class="docs-section-title">${title}</h2>`) 85 86 for (const symbol of symbols) { 87 lines.push(await renderMergedSymbol(symbol, symbolLookup)) 88 } 89 90 lines.push(`</section>`) 91 92 return lines.join('\n') 93} 94 95/** 96 * Render a merged symbol (with all its overloads). 97 */ 98async function renderMergedSymbol( 99 symbol: MergedSymbol, 100 symbolLookup: SymbolLookup, 101): Promise<string> { 102 const primaryNode = symbol.nodes[0] 103 if (!primaryNode) return '' // Safety check - should never happen 104 105 const lines: string[] = [] 106 const id = createSymbolId(symbol.kind, symbol.name) 107 const hasOverloads = symbol.nodes.length > 1 108 109 lines.push(`<article class="docs-symbol" id="${id}">`) 110 111 // Header 112 lines.push(`<header class="docs-symbol-header">`) 113 lines.push( 114 `<a href="#${id}" class="docs-anchor" aria-label="Link to ${escapeHtml(symbol.name)}">#</a>`, 115 ) 116 lines.push(`<h3 class="docs-symbol-name">${escapeHtml(symbol.name)}</h3>`) 117 lines.push(`<span class="docs-badge docs-badge--${symbol.kind}">${symbol.kind}</span>`) 118 if (primaryNode.functionDef?.isAsync) { 119 lines.push(`<span class="docs-badge docs-badge--async">async</span>`) 120 } 121 if (hasOverloads) { 122 lines.push(`<span class="docs-overload-count">${symbol.nodes.length} overloads</span>`) 123 } 124 lines.push(`</header>`) 125 126 // Signatures 127 const signatures = symbol.nodes 128 .slice(0, hasOverloads ? MAX_OVERLOAD_SIGNATURES : 1) 129 .map(n => getNodeSignature(n)) 130 .filter(Boolean) as string[] 131 132 if (signatures.length > 0) { 133 const signatureCode = signatures.join('\n') 134 const highlightedSignature = await highlightCodeBlock(signatureCode, 'typescript') 135 lines.push(`<div class="docs-signature">${highlightedSignature}</div>`) 136 137 if (symbol.nodes.length > MAX_OVERLOAD_SIGNATURES) { 138 const remaining = symbol.nodes.length - MAX_OVERLOAD_SIGNATURES 139 lines.push(`<p class="docs-more-overloads">+ ${remaining} more overloads</p>`) 140 } 141 } 142 143 // Description 144 if (symbol.jsDoc?.doc) { 145 const description = symbol.jsDoc.doc.trim() 146 lines.push( 147 `<div class="docs-description">${await renderMarkdown(description, symbolLookup)}</div>`, 148 ) 149 } 150 151 // JSDoc tags 152 if (symbol.jsDoc?.tags && symbol.jsDoc.tags.length > 0) { 153 lines.push(await renderJsDocTags(symbol.jsDoc.tags, symbolLookup)) 154 } 155 156 // Type-specific members 157 if (symbol.kind === 'class' && primaryNode.classDef) { 158 lines.push(renderClassMembers(primaryNode.classDef)) 159 } else if (symbol.kind === 'interface' && primaryNode.interfaceDef) { 160 lines.push(renderInterfaceMembers(primaryNode.interfaceDef)) 161 } else if (symbol.kind === 'enum' && primaryNode.enumDef) { 162 lines.push(renderEnumMembers(primaryNode.enumDef)) 163 } 164 165 lines.push(`</article>`) 166 167 return lines.join('\n') 168} 169 170/** 171 * Render JSDoc tags (params, returns, examples, etc.) 172 */ 173async function renderJsDocTags(tags: JsDocTag[], symbolLookup: SymbolLookup): Promise<string> { 174 const lines: string[] = [] 175 176 const params = tags.filter(t => t.kind === 'param') 177 const returns = tags.find(t => t.kind === 'return') 178 const examples = tags.filter(t => t.kind === 'example') 179 const deprecated = tags.find(t => t.kind === 'deprecated') 180 const see = tags.filter(t => t.kind === 'see') 181 182 // Deprecated warning 183 if (deprecated) { 184 lines.push(`<div class="docs-deprecated">`) 185 lines.push(`<strong>Deprecated</strong>`) 186 if (deprecated.doc) { 187 // We remove new lines because they look weird when rendered into the deprecated block 188 // 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 189 const renderedMessage = await renderMarkdown(deprecated.doc.replace(/\n/g, ' '), symbolLookup) 190 lines.push(`<div class="docs-deprecated-message">${renderedMessage}</div>`) 191 } 192 lines.push(`</div>`) 193 } 194 195 // Parameters 196 if (params.length > 0) { 197 lines.push(`<div class="docs-params">`) 198 lines.push(`<h4>Parameters</h4>`) 199 lines.push(`<dl>`) 200 for (const param of params) { 201 lines.push( 202 `<dt><code>${escapeHtml(param.name || '')}${param.optional ? '?' : ''}</code></dt>`, 203 ) 204 if (param.doc) { 205 lines.push(`<dd>${parseJsDocLinks(param.doc, symbolLookup)}</dd>`) 206 } 207 } 208 lines.push(`</dl>`) 209 lines.push(`</div>`) 210 } 211 212 // Returns 213 if (returns?.doc) { 214 lines.push(`<div class="docs-returns">`) 215 lines.push(`<h4>Returns</h4>`) 216 lines.push(`<p>${parseJsDocLinks(returns.doc, symbolLookup)}</p>`) 217 lines.push(`</div>`) 218 } 219 220 // Examples (with syntax highlighting) 221 if (examples.length > 0) { 222 lines.push(`<div class="docs-examples">`) 223 lines.push(`<h4>Example${examples.length > 1 ? 's' : ''}</h4>`) 224 for (const example of examples) { 225 if (example.doc) { 226 const langMatch = example.doc.match(/```(\w+)?/) 227 const lang = langMatch?.[1] || 'typescript' 228 const code = example.doc.replace(/```\w*\n?/g, '').trim() 229 const highlighted = await highlightCodeBlock(code, lang) 230 lines.push(highlighted) 231 } 232 } 233 lines.push(`</div>`) 234 } 235 236 // See also 237 if (see.length > 0) { 238 lines.push(`<div class="docs-see">`) 239 lines.push(`<h4>See Also</h4>`) 240 lines.push(`<ul>`) 241 for (const s of see) { 242 if (s.doc) { 243 lines.push(`<li>${parseJsDocLinks(s.doc, symbolLookup)}</li>`) 244 } 245 } 246 lines.push(`</ul>`) 247 lines.push(`</div>`) 248 } 249 250 return lines.join('\n') 251} 252 253// ============================================================================= 254// Member Rendering 255// ============================================================================= 256 257/** 258 * Render class members (constructor, properties, methods). 259 */ 260function renderClassMembers(def: NonNullable<DenoDocNode['classDef']>): string { 261 const lines: string[] = [] 262 const { constructors, properties, methods } = def 263 264 if (constructors && constructors.length > 0) { 265 lines.push(`<div class="docs-members">`) 266 lines.push(`<h4>Constructor</h4>`) 267 for (const ctor of constructors) { 268 const params = ctor.params?.map(p => formatParam(p)).join(', ') || '' 269 lines.push(`<pre><code>constructor(${escapeHtml(params)})</code></pre>`) 270 } 271 lines.push(`</div>`) 272 } 273 274 if (properties && properties.length > 0) { 275 lines.push(`<div class="docs-members">`) 276 lines.push(`<h4>Properties</h4>`) 277 lines.push(`<dl>`) 278 for (const prop of properties) { 279 const modifiers: string[] = [] 280 if (prop.isStatic) modifiers.push('static') 281 if (prop.readonly) modifiers.push('readonly') 282 const modStr = modifiers.length > 0 ? `${modifiers.join(' ')} ` : '' 283 const type = formatType(prop.tsType) 284 const opt = prop.optional ? '?' : '' 285 lines.push( 286 `<dt><code>${escapeHtml(modStr)}${escapeHtml(prop.name)}${opt}: ${escapeHtml(type)}</code></dt>`, 287 ) 288 if (prop.jsDoc?.doc) { 289 lines.push(`<dd>${escapeHtml(prop.jsDoc.doc.split('\n')[0] ?? '')}</dd>`) 290 } 291 } 292 lines.push(`</dl>`) 293 lines.push(`</div>`) 294 } 295 296 if (methods && methods.length > 0) { 297 lines.push(`<div class="docs-members">`) 298 lines.push(`<h4>Methods</h4>`) 299 lines.push(`<dl>`) 300 for (const method of methods) { 301 const params = method.functionDef?.params?.map(p => formatParam(p)).join(', ') || '' 302 const ret = formatType(method.functionDef?.returnType) || 'void' 303 const staticStr = method.isStatic ? 'static ' : '' 304 lines.push( 305 `<dt><code>${escapeHtml(staticStr)}${escapeHtml(method.name)}(${escapeHtml(params)}): ${escapeHtml(ret)}</code></dt>`, 306 ) 307 if (method.jsDoc?.doc) { 308 lines.push(`<dd>${escapeHtml(method.jsDoc.doc.split('\n')[0] ?? '')}</dd>`) 309 } 310 } 311 lines.push(`</dl>`) 312 lines.push(`</div>`) 313 } 314 315 return lines.join('\n') 316} 317 318/** 319 * Render interface members (properties, methods). 320 */ 321function renderInterfaceMembers(def: NonNullable<DenoDocNode['interfaceDef']>): string { 322 const lines: string[] = [] 323 const { properties, methods } = def 324 325 if (properties && properties.length > 0) { 326 lines.push(`<div class="docs-members">`) 327 lines.push(`<h4>Properties</h4>`) 328 lines.push(`<dl>`) 329 for (const prop of properties) { 330 const type = formatType(prop.tsType) 331 const opt = prop.optional ? '?' : '' 332 const ro = prop.readonly ? 'readonly ' : '' 333 lines.push( 334 `<dt><code>${escapeHtml(ro)}${escapeHtml(prop.name)}${opt}: ${escapeHtml(type)}</code></dt>`, 335 ) 336 if (prop.jsDoc?.doc) { 337 lines.push(`<dd>${escapeHtml(prop.jsDoc.doc.split('\n')[0] ?? '')}</dd>`) 338 } 339 } 340 lines.push(`</dl>`) 341 lines.push(`</div>`) 342 } 343 344 if (methods && methods.length > 0) { 345 lines.push(`<div class="docs-members">`) 346 lines.push(`<h4>Methods</h4>`) 347 lines.push(`<dl>`) 348 for (const method of methods) { 349 const params = method.params?.map(p => formatParam(p)).join(', ') || '' 350 const ret = formatType(method.returnType) || 'void' 351 lines.push( 352 `<dt><code>${escapeHtml(method.name)}(${escapeHtml(params)}): ${escapeHtml(ret)}</code></dt>`, 353 ) 354 if (method.jsDoc?.doc) { 355 lines.push(`<dd>${escapeHtml(method.jsDoc.doc.split('\n')[0] ?? '')}</dd>`) 356 } 357 } 358 lines.push(`</dl>`) 359 lines.push(`</div>`) 360 } 361 362 return lines.join('\n') 363} 364 365/** 366 * Render enum members. 367 */ 368function renderEnumMembers(def: NonNullable<DenoDocNode['enumDef']>): string { 369 const lines: string[] = [] 370 const { members } = def 371 372 if (members && members.length > 0) { 373 lines.push(`<div class="docs-members">`) 374 lines.push(`<h4>Members</h4>`) 375 lines.push(`<ul class="docs-enum-members">`) 376 for (const member of members) { 377 lines.push(`<li><code>${escapeHtml(member.name)}</code></li>`) 378 } 379 lines.push(`</ul>`) 380 lines.push(`</div>`) 381 } 382 383 return lines.join('\n') 384} 385 386// ============================================================================= 387// Table of Contents 388// ============================================================================= 389 390/** 391 * Render table of contents. 392 */ 393export function renderToc(symbols: MergedSymbol[]): string { 394 const grouped = groupMergedByKind(symbols) 395 const lines: string[] = [] 396 397 lines.push(`<nav class="toc text-sm" aria-label="Table of contents">`) 398 lines.push(`<ul class="space-y-3">`) 399 400 for (const kind of KIND_DISPLAY_ORDER) { 401 const kindSymbols = grouped[kind] 402 if (!kindSymbols || kindSymbols.length === 0) continue 403 404 const title = KIND_TITLES[kind] || kind 405 lines.push(`<li>`) 406 lines.push( 407 `<a href="#section-${kind}" class="font-semibold text-fg-muted hover:text-fg block mb-1">${title} <span class="text-fg-subtle font-normal">(${kindSymbols.length})</span></a>`, 408 ) 409 410 const showSymbols = kindSymbols.slice(0, MAX_TOC_ITEMS_PER_KIND) 411 lines.push(`<ul class="ps-3 space-y-0.5 border-is border-border/50">`) 412 for (const symbol of showSymbols) { 413 const id = createSymbolId(symbol.kind, symbol.name) 414 lines.push( 415 `<li><a href="#${id}" class="text-fg-subtle hover:text-fg font-mono text-xs block py-0.5 truncate">${escapeHtml(symbol.name)}</a></li>`, 416 ) 417 } 418 if (kindSymbols.length > MAX_TOC_ITEMS_PER_KIND) { 419 const remaining = kindSymbols.length - MAX_TOC_ITEMS_PER_KIND 420 lines.push(`<li class="text-fg-subtle text-xs py-0.5">... and ${remaining} more</li>`) 421 } 422 lines.push(`</ul>`) 423 424 lines.push(`</li>`) 425 } 426 427 lines.push(`</ul>`) 428 lines.push(`</nav>`) 429 430 return lines.join('\n') 431}