|
1 | 1 | import { describe, expect, it } from 'vitest' |
2 | | -import { escapeHtml, parseJsDocLinks, renderMarkdown } from '../../server/utils/docs/text' |
| 2 | +import { escapeHtml, parseJsDocLinks, renderMarkdown, stripAnsi } from '../../server/utils/docs/text' |
3 | 3 | import type { SymbolLookup } from '../../server/utils/docs/types' |
4 | 4 |
|
| 5 | +describe('stripAnsi', () => { |
| 6 | + it('should strip basic color codes', () => { |
| 7 | + const ESC = String.fromCharCode(27) |
| 8 | + const input = `${ESC}[0m${ESC}[38;5;12mhello${ESC}[0m` |
| 9 | + expect(stripAnsi(input)).toBe('hello') |
| 10 | + }) |
| 11 | + |
| 12 | + it('should strip multiple ANSI codes', () => { |
| 13 | + const ESC = String.fromCharCode(27) |
| 14 | + const input = `${ESC}[31mred${ESC}[0m and ${ESC}[32mgreen${ESC}[0m` |
| 15 | + expect(stripAnsi(input)).toBe('red and green') |
| 16 | + }) |
| 17 | + |
| 18 | + it('should handle text without ANSI codes', () => { |
| 19 | + expect(stripAnsi('plain text')).toBe('plain text') |
| 20 | + }) |
| 21 | + |
| 22 | + it('should handle empty string', () => { |
| 23 | + expect(stripAnsi('')).toBe('') |
| 24 | + }) |
| 25 | + |
| 26 | + it('should strip 256-color codes', () => { |
| 27 | + const ESC = String.fromCharCode(27) |
| 28 | + const input = `${ESC}[38;5;196mtext${ESC}[0m` |
| 29 | + expect(stripAnsi(input)).toBe('text') |
| 30 | + }) |
| 31 | + |
| 32 | + it('should handle type predicates from deno_doc', () => { |
| 33 | + // Real example: "object is ReactElement<P>" with ANSI codes |
| 34 | + const ESC = String.fromCharCode(27) |
| 35 | + const input = `object is ReactElement${ESC}[0m${ESC}[38;5;12m<${ESC}[0mP${ESC}[38;5;12m>${ESC}[0m` |
| 36 | + expect(stripAnsi(input)).toBe('object is ReactElement<P>') |
| 37 | + }) |
| 38 | +}) |
| 39 | + |
5 | 40 | describe('escapeHtml', () => { |
6 | 41 | it('should escape < and >', () => { |
7 | 42 | expect(escapeHtml('<script>')).toBe('<script>') |
@@ -89,55 +124,127 @@ describe('parseJsDocLinks', () => { |
89 | 124 | describe('renderMarkdown', () => { |
90 | 125 | const emptyLookup: SymbolLookup = new Map() |
91 | 126 |
|
92 | | - it('should convert inline code', () => { |
93 | | - const result = renderMarkdown('Use `foo()` here', emptyLookup) |
| 127 | + it('should convert inline code', async () => { |
| 128 | + const result = await renderMarkdown('Use `foo()` here', emptyLookup) |
94 | 129 | expect(result).toContain('<code class="docs-inline-code">foo()</code>') |
95 | 130 | }) |
96 | 131 |
|
97 | | - it('should escape HTML inside inline code', () => { |
98 | | - const result = renderMarkdown('Use `Array<T>` here', emptyLookup) |
| 132 | + it('should escape HTML inside inline code', async () => { |
| 133 | + const result = await renderMarkdown('Use `Array<T>` here', emptyLookup) |
99 | 134 | expect(result).toContain('<T>') |
100 | 135 | expect(result).not.toContain('<T>') |
101 | 136 | }) |
102 | 137 |
|
103 | | - it('should convert bold text', () => { |
104 | | - const result = renderMarkdown('This is **important**', emptyLookup) |
| 138 | + it('should convert bold text', async () => { |
| 139 | + const result = await renderMarkdown('This is **important**', emptyLookup) |
105 | 140 | expect(result).toContain('<strong>important</strong>') |
106 | 141 | }) |
107 | 142 |
|
108 | | - it('should convert single newlines to <br>', () => { |
109 | | - const result = renderMarkdown('line 1\nline 2', emptyLookup) |
| 143 | + it('should convert single newlines to <br>', async () => { |
| 144 | + const result = await renderMarkdown('line 1\nline 2', emptyLookup) |
110 | 145 | expect(result).toBe('line 1<br>line 2') |
111 | 146 | }) |
112 | 147 |
|
113 | | - it('should convert double newlines to <br><br>', () => { |
114 | | - const result = renderMarkdown('paragraph 1\n\nparagraph 2', emptyLookup) |
| 148 | + it('should convert double newlines to <br><br>', async () => { |
| 149 | + const result = await renderMarkdown('paragraph 1\n\nparagraph 2', emptyLookup) |
115 | 150 | expect(result).toBe('paragraph 1<br><br>paragraph 2') |
116 | 151 | }) |
117 | 152 |
|
118 | | - it('should handle multiple formatting in same text', () => { |
119 | | - const result = renderMarkdown('Use `foo()` for **important** tasks', emptyLookup) |
| 153 | + it('should handle multiple formatting in same text', async () => { |
| 154 | + const result = await renderMarkdown('Use `foo()` for **important** tasks', emptyLookup) |
120 | 155 | expect(result).toContain('<code class="docs-inline-code">foo()</code>') |
121 | 156 | expect(result).toContain('<strong>important</strong>') |
122 | 157 | }) |
123 | 158 |
|
124 | | - it('should process {@link} tags', () => { |
| 159 | + it('should process {@link} tags', async () => { |
125 | 160 | const lookup: SymbolLookup = new Map([['MyFunc', 'function-MyFunc']]) |
126 | | - const result = renderMarkdown('See {@link MyFunc} for details', lookup) |
| 161 | + const result = await renderMarkdown('See {@link MyFunc} for details', lookup) |
127 | 162 | expect(result).toContain('href="#function-MyFunc"') |
128 | 163 | }) |
129 | 164 |
|
130 | | - it('should escape HTML in regular text', () => { |
131 | | - const result = renderMarkdown('Returns <T> or null', emptyLookup) |
| 165 | + it('should escape HTML in regular text', async () => { |
| 166 | + const result = await renderMarkdown('Returns <T> or null', emptyLookup) |
132 | 167 | expect(result).toContain('<T>') |
133 | 168 | }) |
134 | 169 |
|
135 | | - it('should handle empty string', () => { |
136 | | - expect(renderMarkdown('', emptyLookup)).toBe('') |
| 170 | + it('should handle empty string', async () => { |
| 171 | + expect(await renderMarkdown('', emptyLookup)).toBe('') |
137 | 172 | }) |
138 | 173 |
|
139 | | - it('should handle text with only whitespace', () => { |
140 | | - const result = renderMarkdown(' \n ', emptyLookup) |
| 174 | + it('should handle text with only whitespace', async () => { |
| 175 | + const result = await renderMarkdown(' \n ', emptyLookup) |
141 | 176 | expect(result).toBe(' <br> ') |
142 | 177 | }) |
| 178 | + |
| 179 | + it('should syntax highlight fenced code blocks with Shiki', async () => { |
| 180 | + const input = '```ts\nconst x = 1;\n```' |
| 181 | + const result = await renderMarkdown(input, emptyLookup) |
| 182 | + // Shiki outputs use class="shiki" and have syntax highlighting spans |
| 183 | + expect(result).toContain('shiki') |
| 184 | + expect(result).toContain('const') |
| 185 | + expect(result).not.toContain('```') |
| 186 | + }) |
| 187 | + |
| 188 | + it('should handle fenced code blocks with CRLF line endings', async () => { |
| 189 | + const input = '```ts\r\nconst x = 1;\r\n```' |
| 190 | + const result = await renderMarkdown(input, emptyLookup) |
| 191 | + expect(result).toContain('shiki') |
| 192 | + expect(result).toContain('const') |
| 193 | + expect(result).not.toContain('```') |
| 194 | + }) |
| 195 | + |
| 196 | + it('should handle fenced code blocks with CR line endings', async () => { |
| 197 | + const input = '```ts\rconst x = 1;\r```' |
| 198 | + const result = await renderMarkdown(input, emptyLookup) |
| 199 | + expect(result).toContain('shiki') |
| 200 | + expect(result).toContain('const') |
| 201 | + expect(result).not.toContain('```') |
| 202 | + }) |
| 203 | + |
| 204 | + it('should handle fenced code blocks without language', async () => { |
| 205 | + const input = '```\nconst x = 1;\n```' |
| 206 | + const result = await renderMarkdown(input, emptyLookup) |
| 207 | + // Falls back to plain code block for unknown language |
| 208 | + expect(result).toContain('<pre>') |
| 209 | + expect(result).toContain('const x = 1;') |
| 210 | + }) |
| 211 | + |
| 212 | + it('should handle fenced code blocks with trailing whitespace after language', async () => { |
| 213 | + const input = '```ts \nconst x = 1;\n```' |
| 214 | + const result = await renderMarkdown(input, emptyLookup) |
| 215 | + expect(result).toContain('shiki') |
| 216 | + expect(result).toContain('const') |
| 217 | + }) |
| 218 | + |
| 219 | + it('should handle fenced code blocks with space before language', async () => { |
| 220 | + const input = '``` js\nconst x = 1;\n```' |
| 221 | + const result = await renderMarkdown(input, emptyLookup) |
| 222 | + expect(result).toContain('shiki') |
| 223 | + expect(result).toContain('const') |
| 224 | + expect(result).not.toContain('```') |
| 225 | + }) |
| 226 | + |
| 227 | + it('should escape HTML inside fenced code blocks', async () => { |
| 228 | + const input = '```ts\nconst arr: Array<string> = [];\n```' |
| 229 | + const result = await renderMarkdown(input, emptyLookup) |
| 230 | + // Shiki escapes < as < (hex entity) |
| 231 | + expect(result).toContain('<') |
| 232 | + // The raw < character shouldn't appear outside of HTML tags |
| 233 | + expect(result).not.toMatch(/<string>/) |
| 234 | + }) |
| 235 | + |
| 236 | + it('should handle multiple fenced code blocks', async () => { |
| 237 | + const input = '```ts\nconst a = 1\n```\n\nSome text\n\n```js\nconst b = 2\n```' |
| 238 | + const result = await renderMarkdown(input, emptyLookup) |
| 239 | + expect(result).toContain('Some text') |
| 240 | + // Both code blocks should be highlighted |
| 241 | + expect((result.match(/shiki/g) || []).length).toBe(2) |
| 242 | + }) |
| 243 | + |
| 244 | + it('should not confuse inline code with fenced code blocks', async () => { |
| 245 | + const input = 'Use `code` inline and:\n```ts\nblock code\n```' |
| 246 | + const result = await renderMarkdown(input, emptyLookup) |
| 247 | + expect(result).toContain('<code class="docs-inline-code">code</code>') |
| 248 | + expect(result).toContain('shiki') |
| 249 | + }) |
143 | 250 | }) |
0 commit comments