forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1/* oxlint-disable regexp/no-super-linear-backtracking */
2/**
3 * Text Processing Utilities
4 *
5 * Functions for escaping HTML, parsing JSDoc links, and rendering markdown.
6 *
7 * @module server/utils/docs/text
8 */
9
10import { highlightCodeBlock } from '../shiki'
11import type { SymbolLookup } from './types'
12
13/**
14 * Strip ANSI escape codes from text.
15 * Deno doc output may contain terminal color codes that need to be removed.
16 */
17const ESC = String.fromCharCode(27)
18const ANSI_PATTERN = new RegExp(`${ESC}\\[[0-9;]*m`, 'g')
19
20export function stripAnsi(text: string): string {
21 return text.replace(ANSI_PATTERN, '')
22}
23
24/**
25 * Escape HTML special characters.
26 *
27 * @internal Exported for testing
28 */
29export function escapeHtml(text: string): string {
30 return text
31 .replace(/&/g, '&')
32 .replace(/</g, '<')
33 .replace(/>/g, '>')
34 .replace(/"/g, '"')
35 .replace(/'/g, ''')
36}
37
38/**
39 * Clean up symbol names by stripping esm.sh prefixes.
40 *
41 * Packages using @types/* definitions get "default." or "default_" prefixes
42 * from esm.sh that we need to remove for clean display.
43 */
44export function cleanSymbolName(name: string): string {
45 if (name.startsWith('default.')) {
46 return name.slice(8)
47 }
48 if (name.startsWith('default_')) {
49 return name.slice(8)
50 }
51 return name
52}
53
54/**
55 * Create a URL-safe HTML anchor ID for a symbol.
56 */
57export function createSymbolId(kind: string, name: string): string {
58 return `${kind}-${name}`.replace(/[^a-z0-9-]/gi, '_')
59}
60
61/**
62 * Parse JSDoc {@link} tags into HTML links.
63 *
64 * Handles:
65 * - {@link https://example.com} - external URL
66 * - {@link https://example.com Link Text} - external URL with label
67 * - {@link SomeSymbol} - internal cross-reference
68 *
69 * @internal Exported for testing
70 */
71export function parseJsDocLinks(text: string, symbolLookup: SymbolLookup): string {
72 let result = escapeHtml(text)
73
74 result = result.replace(/\{@link\s+([^\s}]+)(?:\s+([^}]+))?\}/g, (_, target, label) => {
75 const displayText = label || target
76
77 // External URL
78 if (target.startsWith('http://') || target.startsWith('https://')) {
79 return `<a href="${target}" target="_blank" rel="noreferrer" class="docs-link">${displayText}</a>`
80 }
81
82 // Internal symbol reference
83 const symbolId = symbolLookup.get(target)
84 if (symbolId) {
85 return `<a href="#${symbolId}" class="docs-symbol-link">${displayText}</a>`
86 }
87
88 // Unknown symbol
89 return `<code class="docs-symbol-ref">${displayText}</code>`
90 })
91
92 return result
93}
94
95/**
96 * Render simple markdown-like formatting.
97 * Uses <br> for line breaks to avoid nesting issues with inline elements.
98 * Fenced code blocks (```) are syntax-highlighted with Shiki.
99 *
100 * @internal Exported for testing
101 */
102export async function renderMarkdown(text: string, symbolLookup: SymbolLookup): Promise<string> {
103 // Extract fenced code blocks FIRST (before any HTML escaping)
104 // Pattern handles:
105 // - Optional whitespace before/after language identifier
106 // - \r\n, \n, or \r line endings
107 const codeBlockData: Array<{ lang: string; code: string }> = []
108 let result = text.replace(
109 /```[ \t]*(\w*)[ \t]*(?:\r\n|\r|\n)([\s\S]*?)(?:\r\n|\r|\n)?```/g,
110 (_, lang, code) => {
111 const index = codeBlockData.length
112 codeBlockData.push({ lang: lang || 'text', code: code.trim() })
113 return `__CODE_BLOCK_${index}__`
114 },
115 )
116
117 // Now process the rest (JSDoc links, HTML escaping, etc.)
118 result = parseJsDocLinks(result, symbolLookup)
119
120 // Markdown links - i.e. [text](url)
121 result = result.replace(
122 /\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g,
123 '<a href="$2" target="_blank" rel="noreferrer" class="docs-link">$1</a>',
124 )
125
126 // Handle inline code (single backticks) - won't interfere with fenced blocks
127 result = result
128 .replace(/`([^`]+)`/g, '<code class="docs-inline-code">$1</code>')
129 .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
130 .replace(/\n{2,}/g, '<br><br>')
131 .replace(/\n/g, '<br>')
132
133 // Highlight and restore code blocks
134 for (let i = 0; i < codeBlockData.length; i++) {
135 const { lang, code } = codeBlockData[i]!
136 const highlighted = await highlightCodeBlock(code, lang)
137 result = result.replace(`__CODE_BLOCK_${i}__`, highlighted)
138 }
139
140 return result
141}