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