[READ-ONLY] a fast, modern browser for the npm registry
at main 141 lines 4.3 kB view raw
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, '&amp;') 32 .replace(/</g, '&lt;') 33 .replace(/>/g, '&gt;') 34 .replace(/"/g, '&quot;') 35 .replace(/'/g, '&#39;') 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}