forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
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}