Skip to content

Commit ab87991

Browse files
committed
fix: some validation issues with markdown rendering and parsing
1 parent 550f30d commit ab87991

8 files changed

Lines changed: 310 additions & 13 deletions

File tree

app/components/AppHeader.vue

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,10 @@ const { isConnected, npmUser } = useConnector()
1414
</script>
1515

1616
<template>
17-
<header class="sticky top-0 z-50 bg-bg/80 backdrop-blur-md border-b border-border">
17+
<header
18+
aria-label="Site header"
19+
class="sticky top-0 z-50 bg-bg/80 backdrop-blur-md border-b border-border"
20+
>
1821
<nav aria-label="Main navigation" class="container h-14 flex items-center">
1922
<!-- Left: Logo -->
2023
<div class="flex-shrink-0">

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,11 @@ const showEmptyState = computed(() => docsData.value?.status !== 'ok')
8989

9090
<template>
9191
<div class="docs-page min-h-screen">
92+
<!-- Visually hidden h1 for accessibility -->
93+
<h1 class="sr-only">{{ packageName }} API Documentation</h1>
94+
9295
<!-- Sticky header - positioned below AppHeader -->
93-
<header class="docs-header sticky z-10 bg-bg/95 backdrop-blur border-b border-border">
96+
<header aria-label="Package documentation header" class="docs-header sticky z-10 bg-bg/95 backdrop-blur border-b border-border">
9497
<div class="px-4 sm:px-6 lg:px-8 py-4">
9598
<div class="flex items-center justify-between gap-4">
9699
<div class="flex items-center gap-3 min-w-0">
@@ -219,8 +222,7 @@ const showEmptyState = computed(() => docsData.value?.status !== 'ok')
219222
}
220223
221224
.docs-content .docs-section-title {
222-
@apply text-lg font-semibold text-fg mb-8 pb-3 pt-4 border-b border-border;
223-
@apply sticky bg-bg z-[2];
225+
@apply text-lg font-semibold text-fg mb-8 pb-3 pt-4 border-b border-border sticky bg-bg z-[2];
224226
top: var(--combined-header-height);
225227
}
226228

server/utils/code-highlight.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,9 @@ export async function highlightCode(
268268
theme: 'github-dark',
269269
})
270270

271+
// Shiki doesn't encode > in text content (e.g., arrow functions)
272+
html = escapeRawGt(html)
273+
271274
// Make import statements clickable for JS/TS languages
272275
if (IMPORT_LANGUAGES.has(language)) {
273276
html = linkifyImports(html, {

server/utils/docs.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,11 @@ interface MergedSymbol {
7171
jsDoc?: DenoDocNode['jsDoc']
7272
}
7373

74-
/** Map of symbol names to anchor IDs for cross-referencing */
75-
type SymbolLookup = Map<string, string>
74+
/**
75+
* Map of symbol names to anchor IDs for cross-referencing.
76+
* @internal Exported for testing
77+
*/
78+
export type SymbolLookup = Map<string, string>
7679

7780
// =============================================================================
7881
// Main API
@@ -614,7 +617,7 @@ function renderToc(symbols: MergedSymbol[]): string {
614617
const grouped = groupMergedByKind(symbols)
615618
const lines: string[] = []
616619

617-
lines.push(`<nav class="toc text-sm">`)
620+
lines.push(`<nav class="toc text-sm" aria-label="Table of contents">`)
618621
lines.push(`<ul class="space-y-3">`)
619622

620623
for (const kind of KIND_DISPLAY_ORDER) {
@@ -769,8 +772,10 @@ function createSymbolId(kind: string, name: string): string {
769772
* - {@link https://example.com} - external URL
770773
* - {@link https://example.com Link Text} - external URL with label
771774
* - {@link SomeSymbol} - internal cross-reference
775+
*
776+
* @internal Exported for testing
772777
*/
773-
function parseJsDocLinks(text: string, symbolLookup: SymbolLookup): string {
778+
export function parseJsDocLinks(text: string, symbolLookup: SymbolLookup): string {
774779
let result = escapeHtml(text)
775780

776781
result = result.replace(/\{@link\s+([^\s}]+)(?:\s+([^}]+))?\}/g, (_, target, label) => {
@@ -796,23 +801,28 @@ function parseJsDocLinks(text: string, symbolLookup: SymbolLookup): string {
796801

797802
/**
798803
* Render simple markdown-like formatting.
804+
* Uses <br> for line breaks to avoid nesting issues with inline elements.
805+
*
806+
* @internal Exported for testing
799807
*/
800-
function renderMarkdown(text: string, symbolLookup: SymbolLookup): string {
808+
export function renderMarkdown(text: string, symbolLookup: SymbolLookup): string {
801809
let result = parseJsDocLinks(text, symbolLookup)
802810

803811
result = result
804812
.replace(/`([^`]+)`/g, '<code class="docs-inline-code">$1</code>')
805813
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
806-
.replace(/\n\n/g, '</p><p class="mt-2">')
814+
.replace(/\n\n+/g, '<br><br>')
807815
.replace(/\n/g, '<br>')
808816

809817
return result
810818
}
811819

812820
/**
813821
* Escape HTML special characters.
822+
*
823+
* @internal Exported for testing
814824
*/
815-
function escapeHtml(text: string): string {
825+
export function escapeHtml(text: string): string {
816826
return text
817827
.replace(/&/g, '&amp;')
818828
.replace(/</g, '&lt;')

server/utils/readme.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,10 +272,12 @@ export async function renderReadmeHtml(
272272
// Use Shiki if language is loaded, otherwise fall back to plain
273273
if (loadedLangs.includes(language as never)) {
274274
try {
275-
return shiki.codeToHtml(text, {
275+
const html = shiki.codeToHtml(text, {
276276
lang: language,
277277
theme: 'github-dark',
278278
})
279+
// Shiki doesn't encode > in text content (e.g., arrow functions)
280+
return escapeRawGt(html)
279281
} catch {
280282
// Fall back to plain code block
281283
}

server/utils/shiki.ts

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

5656
if (loadedLangs.includes(language as never)) {
5757
try {
58-
return shiki.codeToHtml(code, {
58+
const html = shiki.codeToHtml(code, {
5959
lang: language,
6060
theme: 'github-dark',
6161
})
62+
// Shiki doesn't encode > in text content (e.g., arrow functions =>)
63+
// We need to encode them for HTML validation
64+
return escapeRawGt(html)
6265
} catch {
6366
// Fall back to plain
6467
}
@@ -68,3 +71,20 @@ export async function highlightCodeBlock(code: string, language: string): Promis
6871
const escaped = code.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
6972
return `<pre><code class="language-${language}">${escaped}</code></pre>\n`
7073
}
74+
75+
/**
76+
* Escape raw > characters in HTML text content.
77+
* Shiki outputs > without encoding in constructs like arrow functions (=>).
78+
* This replaces > that appear in text content (after >) but not inside tags.
79+
*
80+
* @internal Exported for testing
81+
*/
82+
export function escapeRawGt(html: string): string {
83+
// Match > that appears after a closing tag or other > (i.e., in text content)
84+
// Pattern: after </...> or after >, match any > that isn't starting a tag
85+
return html.replace(/>([^<]*)/g, (match, textContent) => {
86+
// Encode any > in the text content portion
87+
const escapedText = textContent.replace(/>/g, '&gt;')
88+
return `>${escapedText}`
89+
})
90+
}

test/unit/docs-text.spec.ts

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import { describe, expect, it } from 'vitest'
2+
import {
3+
escapeHtml,
4+
parseJsDocLinks,
5+
renderMarkdown,
6+
type SymbolLookup,
7+
} from '../../server/utils/docs'
8+
9+
describe('escapeHtml', () => {
10+
it('should escape < and >', () => {
11+
expect(escapeHtml('<script>')).toBe('&lt;script&gt;')
12+
})
13+
14+
it('should escape &', () => {
15+
expect(escapeHtml('foo & bar')).toBe('foo &amp; bar')
16+
})
17+
18+
it('should escape quotes', () => {
19+
expect(escapeHtml('"hello"')).toBe('&quot;hello&quot;')
20+
expect(escapeHtml("'hello'")).toBe('&#39;hello&#39;')
21+
})
22+
23+
it('should handle multiple special characters', () => {
24+
expect(escapeHtml('<a href="test?a=1&b=2">'))
25+
.toBe('&lt;a href=&quot;test?a=1&amp;b=2&quot;&gt;')
26+
})
27+
28+
it('should return empty string for empty input', () => {
29+
expect(escapeHtml('')).toBe('')
30+
})
31+
32+
it('should not modify text without special characters', () => {
33+
expect(escapeHtml('hello world')).toBe('hello world')
34+
})
35+
})
36+
37+
describe('parseJsDocLinks', () => {
38+
const emptyLookup: SymbolLookup = new Map()
39+
40+
it('should convert external URLs to links', () => {
41+
const result = parseJsDocLinks('{@link https://example.com}', emptyLookup)
42+
expect(result).toContain('href="https://example.com"')
43+
expect(result).toContain('target="_blank"')
44+
expect(result).toContain('rel="noopener"')
45+
})
46+
47+
it('should handle external URLs with labels', () => {
48+
const result = parseJsDocLinks('{@link https://example.com Example Site}', emptyLookup)
49+
expect(result).toContain('href="https://example.com"')
50+
expect(result).toContain('>Example Site</a>')
51+
})
52+
53+
it('should convert internal symbol references to anchor links', () => {
54+
const lookup: SymbolLookup = new Map([['MyFunction', 'function-MyFunction']])
55+
const result = parseJsDocLinks('{@link MyFunction}', lookup)
56+
expect(result).toContain('href="#function-MyFunction"')
57+
expect(result).toContain('docs-symbol-link')
58+
})
59+
60+
it('should render unknown symbols as code', () => {
61+
const result = parseJsDocLinks('{@link UnknownSymbol}', emptyLookup)
62+
expect(result).toContain('<code class="docs-symbol-ref">UnknownSymbol</code>')
63+
})
64+
65+
it('should escape HTML in surrounding text', () => {
66+
const result = parseJsDocLinks('Use <T> with {@link https://example.com}', emptyLookup)
67+
expect(result).toContain('&lt;T&gt;')
68+
})
69+
70+
it('should handle multiple links', () => {
71+
const result = parseJsDocLinks(
72+
'See {@link https://a.com} and {@link https://b.com}',
73+
emptyLookup,
74+
)
75+
expect(result).toContain('href="https://a.com"')
76+
expect(result).toContain('href="https://b.com"')
77+
})
78+
79+
it('should not convert non-http URLs to links', () => {
80+
const result = parseJsDocLinks('{@link javascript:alert(1)}', emptyLookup)
81+
// Should be treated as unknown symbol, not a link
82+
expect(result).not.toContain('href="javascript:')
83+
expect(result).toContain('<code')
84+
})
85+
86+
it('should handle http URLs (not just https)', () => {
87+
const result = parseJsDocLinks('{@link http://example.com}', emptyLookup)
88+
expect(result).toContain('href="http://example.com"')
89+
})
90+
})
91+
92+
describe('renderMarkdown', () => {
93+
const emptyLookup: SymbolLookup = new Map()
94+
95+
it('should convert inline code', () => {
96+
const result = renderMarkdown('Use `foo()` here', emptyLookup)
97+
expect(result).toContain('<code class="docs-inline-code">foo()</code>')
98+
})
99+
100+
it('should escape HTML inside inline code', () => {
101+
const result = renderMarkdown('Use `Array<T>` here', emptyLookup)
102+
expect(result).toContain('&lt;T&gt;')
103+
expect(result).not.toContain('<T>')
104+
})
105+
106+
it('should convert bold text', () => {
107+
const result = renderMarkdown('This is **important**', emptyLookup)
108+
expect(result).toContain('<strong>important</strong>')
109+
})
110+
111+
it('should convert single newlines to <br>', () => {
112+
const result = renderMarkdown('line 1\nline 2', emptyLookup)
113+
expect(result).toBe('line 1<br>line 2')
114+
})
115+
116+
it('should convert double newlines to <br><br>', () => {
117+
const result = renderMarkdown('paragraph 1\n\nparagraph 2', emptyLookup)
118+
expect(result).toBe('paragraph 1<br><br>paragraph 2')
119+
})
120+
121+
it('should handle multiple formatting in same text', () => {
122+
const result = renderMarkdown('Use `foo()` for **important** tasks', emptyLookup)
123+
expect(result).toContain('<code class="docs-inline-code">foo()</code>')
124+
expect(result).toContain('<strong>important</strong>')
125+
})
126+
127+
it('should process {@link} tags', () => {
128+
const lookup: SymbolLookup = new Map([['MyFunc', 'function-MyFunc']])
129+
const result = renderMarkdown('See {@link MyFunc} for details', lookup)
130+
expect(result).toContain('href="#function-MyFunc"')
131+
})
132+
133+
it('should escape HTML in regular text', () => {
134+
const result = renderMarkdown('Returns <T> or null', emptyLookup)
135+
expect(result).toContain('&lt;T&gt;')
136+
})
137+
138+
it('should handle empty string', () => {
139+
expect(renderMarkdown('', emptyLookup)).toBe('')
140+
})
141+
142+
it('should handle text with only whitespace', () => {
143+
const result = renderMarkdown(' \n ', emptyLookup)
144+
expect(result).toBe(' <br> ')
145+
})
146+
})

0 commit comments

Comments
 (0)