Skip to content

Commit 759dbfc

Browse files
committed
fix: fence code block regex handles ansi, edge cases, shiki highlighting fixed, tests updated
1 parent ff78f69 commit 759dbfc

8 files changed

Lines changed: 195 additions & 34 deletions

File tree

app/pages/docs/[...path].vue

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -318,10 +318,31 @@ const showEmptyState = computed(() => docsData.value?.status !== 'ok')
318318
@apply text-sm text-fg-muted leading-relaxed mb-5;
319319
}
320320
321+
/* Inline code in descriptions */
321322
.docs-content .docs-description code {
322323
@apply bg-bg-muted px-1.5 py-0.5 rounded text-xs font-mono;
323324
}
324325
326+
/*
327+
* Fenced code blocks in descriptions use a subtle left-border style.
328+
*
329+
* Design rationale: We use two visual styles for code examples:
330+
* 1. Boxed style (bg + border + padding) - for formal @example JSDoc tags
331+
* and function signatures. These are intentional, structured sections.
332+
* 2. Left-border style (blockquote-like) - for inline code in descriptions.
333+
* These are illustrative/casual and shouldn't compete with the signature.
334+
*/
335+
.docs-content .docs-description .shiki {
336+
@apply text-sm pl-4 py-3 my-4 border-l-2 border-border;
337+
white-space: pre-wrap;
338+
word-break: break-word;
339+
}
340+
341+
.docs-content .docs-description .shiki code {
342+
@apply text-sm bg-transparent p-0;
343+
white-space: pre-wrap;
344+
}
345+
325346
/* Deprecation warning */
326347
.docs-content .docs-deprecated {
327348
@apply bg-amber-500/10 border border-amber-500/20 rounded-lg p-4 mb-5;
@@ -370,7 +391,7 @@ const showEmptyState = computed(() => docsData.value?.status !== 'ok')
370391
@apply text-sm text-fg-muted m-0;
371392
}
372393
373-
/* Example code blocks - now uses Shiki */
394+
/* Example code blocks from @example JSDoc tags - boxed style (see design rationale above) */
374395
.docs-content .docs-examples .shiki {
375396
@apply text-sm bg-bg-muted border border-border/50 p-4 rounded-lg overflow-x-auto mb-3;
376397
}

server/api/registry/docs/[...pkg].get.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { DocsResponse } from '#shared/types'
22
import { fetchNpmPackage } from '#server/utils/npm'
3-
import { assertValidPackageName, parsePackageParam } from '#shared/utils/npm'
3+
import { assertValidPackageName } from '#shared/utils/npm'
4+
import { parsePackageParam } from '#shared/utils/parse-package-param'
45
import { generateDocsWithDeno } from '#server/utils/docs'
56

67
export default defineCachedEventHandler(

server/utils/docs/format.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
*/
88

99
import type { DenoDocNode, FunctionParam, TsType } from '#shared/types/deno-doc'
10-
import { cleanSymbolName } from './text'
10+
import { cleanSymbolName, stripAnsi } from './text'
1111

1212
/**
1313
* Generate a TypeScript signature string for a node.
@@ -72,7 +72,8 @@ export function formatParam(param: FunctionParam): string {
7272
export function formatType(type?: TsType): string {
7373
if (!type) return ''
7474

75-
if (type.repr) return type.repr
75+
// Strip ANSI codes from repr (deno doc may include terminal colors since it's built for that)
76+
if (type.repr) return stripAnsi(type.repr)
7677

7778
if (type.kind === 'keyword' && type.keyword) {
7879
return type.keyword
@@ -91,5 +92,5 @@ export function formatType(type?: TsType): string {
9192
return type.union.map(t => formatType(t)).join(' | ')
9293
}
9394

94-
return type.repr || 'unknown'
95+
return type.repr ? stripAnsi(type.repr) : 'unknown'
9596
}

server/utils/docs/render.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ async function renderMergedSymbol(
143143
// Description
144144
if (symbol.jsDoc?.doc) {
145145
const description = symbol.jsDoc.doc.trim()
146-
lines.push(`<div class="docs-description">${renderMarkdown(description, symbolLookup)}</div>`)
146+
lines.push(`<div class="docs-description">${await renderMarkdown(description, symbolLookup)}</div>`)
147147
}
148148

149149
// JSDoc tags

server/utils/docs/text.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,20 @@
66
* @module server/utils/docs/text
77
*/
88

9+
import { highlightCodeBlock } from '../shiki'
910
import type { SymbolLookup } from './types'
1011

12+
/**
13+
* Strip ANSI escape codes from text.
14+
* Deno doc output may contain terminal color codes that need to be removed.
15+
*/
16+
const ESC = String.fromCharCode(27)
17+
const ANSI_PATTERN = new RegExp(`${ESC}\\[[0-9;]*m`, 'g')
18+
19+
export function stripAnsi(text: string): string {
20+
return text.replace(ANSI_PATTERN, '')
21+
}
22+
1123
/**
1224
* Escape HTML special characters.
1325
*
@@ -82,17 +94,38 @@ export function parseJsDocLinks(text: string, symbolLookup: SymbolLookup): strin
8294
/**
8395
* Render simple markdown-like formatting.
8496
* Uses <br> for line breaks to avoid nesting issues with inline elements.
97+
* Fenced code blocks (```) are syntax-highlighted with Shiki.
8598
*
8699
* @internal Exported for testing
87100
*/
88-
export function renderMarkdown(text: string, symbolLookup: SymbolLookup): string {
89-
let result = parseJsDocLinks(text, symbolLookup)
101+
export async function renderMarkdown(text: string, symbolLookup: SymbolLookup): Promise<string> {
102+
// Extract fenced code blocks FIRST (before any HTML escaping)
103+
// Pattern handles:
104+
// - Optional whitespace before/after language identifier
105+
// - \r\n, \n, or \r line endings
106+
const codeBlockData: Array<{ lang: string, code: string }> = []
107+
let result = text.replace(/```[ \t]*(\w*)[ \t]*(?:\r\n|\r|\n)([\s\S]*?)(?:\r\n|\r|\n)?```/g, (_, lang, code) => {
108+
const index = codeBlockData.length
109+
codeBlockData.push({ lang: lang || 'text', code: code.trim() })
110+
return `__CODE_BLOCK_${index}__`
111+
})
112+
113+
// Now process the rest (JSDoc links, HTML escaping, etc.)
114+
result = parseJsDocLinks(result, symbolLookup)
90115

116+
// Handle inline code (single backticks) - won't interfere with fenced blocks
91117
result = result
92118
.replace(/`([^`]+)`/g, '<code class="docs-inline-code">$1</code>')
93119
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
94120
.replace(/\n\n+/g, '<br><br>')
95121
.replace(/\n/g, '<br>')
96122

123+
// Highlight and restore code blocks
124+
for (let i = 0; i < codeBlockData.length; i++) {
125+
const { lang, code } = codeBlockData[i]!
126+
const highlighted = await highlightCodeBlock(code, lang)
127+
result = result.replace(`__CODE_BLOCK_${i}__`, highlighted)
128+
}
129+
97130
return result
98131
}

server/utils/shiki.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,12 @@ export async function highlightCodeBlock(code: string, language: string): Promis
5555

5656
if (loadedLangs.includes(language as never)) {
5757
try {
58-
const html = shiki.codeToHtml(code, {
58+
let html = shiki.codeToHtml(code, {
5959
lang: language,
6060
theme: 'github-dark',
6161
})
62+
// Remove inline style from <pre> tag so CSS can control appearance
63+
html = html.replace(/<pre([^>]*)\s+style="[^"]*"/, '<pre$1')
6264
// Shiki doesn't encode > in text content (e.g., arrow functions =>)
6365
// We need to encode them for HTML validation
6466
return escapeRawGt(html)

shared/utils/npm.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
import { createError } from 'h3'
22
import validatePackageName from 'validate-npm-package-name'
33

4-
// Re-export from separate file (avoids h3 dependency for unit tests)
5-
export { parsePackageParam } from './parse-package-param'
6-
export type { ParsedPackageParams } from './parse-package-param'
7-
84
/**
95
* Validate an npm package name and throw an HTTP error if invalid.
106
* Uses validate-npm-package-name to check against npm naming rules.

test/unit/docs-text.spec.ts

Lines changed: 128 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,42 @@
11
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'
33
import type { SymbolLookup } from '../../server/utils/docs/types'
44

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+
540
describe('escapeHtml', () => {
641
it('should escape < and >', () => {
742
expect(escapeHtml('<script>')).toBe('&lt;script&gt;')
@@ -89,55 +124,127 @@ describe('parseJsDocLinks', () => {
89124
describe('renderMarkdown', () => {
90125
const emptyLookup: SymbolLookup = new Map()
91126

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)
94129
expect(result).toContain('<code class="docs-inline-code">foo()</code>')
95130
})
96131

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)
99134
expect(result).toContain('&lt;T&gt;')
100135
expect(result).not.toContain('<T>')
101136
})
102137

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)
105140
expect(result).toContain('<strong>important</strong>')
106141
})
107142

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)
110145
expect(result).toBe('line 1<br>line 2')
111146
})
112147

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)
115150
expect(result).toBe('paragraph 1<br><br>paragraph 2')
116151
})
117152

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)
120155
expect(result).toContain('<code class="docs-inline-code">foo()</code>')
121156
expect(result).toContain('<strong>important</strong>')
122157
})
123158

124-
it('should process {@link} tags', () => {
159+
it('should process {@link} tags', async () => {
125160
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)
127162
expect(result).toContain('href="#function-MyFunc"')
128163
})
129164

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)
132167
expect(result).toContain('&lt;T&gt;')
133168
})
134169

135-
it('should handle empty string', () => {
136-
expect(renderMarkdown('', emptyLookup)).toBe('')
170+
it('should handle empty string', async () => {
171+
expect(await renderMarkdown('', emptyLookup)).toBe('')
137172
})
138173

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)
141176
expect(result).toBe(' <br> ')
142177
})
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 &#x3C; (hex entity)
231+
expect(result).toContain('&#x3C;')
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+
})
143250
})

0 commit comments

Comments
 (0)