forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1import { describe, expect, it } from 'vitest'
2
3// Utility to use more human-readable strings in tests
4function escapeHtml(text: string): string {
5 return text
6 .replace(/&/g, '&')
7 .replace(/</g, '<')
8 .replace(/>/g, '>')
9 .replace(/"/g, '"')
10 .replace(/'/g, ''')
11}
12
13describe('useMarkdown', () => {
14 describe('plain text', () => {
15 it('renders plain text unchanged', () => {
16 const processed = useMarkdown({ text: 'Hello world' })
17 expect(processed.value).toBe('Hello world')
18 })
19
20 it('returns empty for empty text', () => {
21 const processed = useMarkdown({ text: '' })
22 expect(processed.value).toBe('')
23 })
24 })
25
26 describe('HTML escaping', () => {
27 it('strips HTML tags to prevent XSS', () => {
28 const processed = useMarkdown({ text: '<script>alert("xss")</script>' })
29 // HTML tags should be stripped (not rendered)
30 expect(processed.value).not.toContain('<script>')
31 // Only the text content remains
32 expect(processed.value).toBe(escapeHtml('alert("xss")'))
33 })
34
35 it('escapes special characters', () => {
36 const processed = useMarkdown({ text: 'a < b && c > d' })
37 expect(processed.value).toBe(escapeHtml('a < b && c > d'))
38 })
39 })
40
41 describe('bold formatting', () => {
42 it('renders **text** as bold', () => {
43 const processed = useMarkdown({ text: 'This is **bold** text' })
44 expect(processed.value).toContain('<strong>')
45 expect(processed.value).toContain('bold')
46 })
47
48 it('renders __text__ as bold', () => {
49 const processed = useMarkdown({ text: 'This is __bold__ text' })
50 expect(processed.value).toContain('<strong>')
51 expect(processed.value).toContain('bold')
52 })
53 })
54
55 describe('italic formatting', () => {
56 it('renders *text* as italic', () => {
57 const processed = useMarkdown({ text: 'This is *italic* text' })
58 expect(processed.value).toContain('<em>')
59 expect(processed.value).toContain('italic')
60 })
61
62 it('renders _text_ as italic', () => {
63 const processed = useMarkdown({ text: 'This is _italic_ text' })
64 expect(processed.value).toContain('<em>')
65 expect(processed.value).toContain('italic')
66 })
67 })
68
69 describe('inline code', () => {
70 it('renders `code` in code tags', () => {
71 const processed = useMarkdown({ text: 'Run `npm install` to start' })
72 expect(processed.value).toContain('<code>')
73 expect(processed.value).toContain('npm install')
74 })
75 })
76
77 describe('strikethrough', () => {
78 it('renders ~~text~~ as strikethrough', () => {
79 const processed = useMarkdown({ text: 'This is ~~deleted~~ text' })
80 expect(processed.value).toContain('<del>')
81 expect(processed.value).toContain('deleted')
82 })
83 })
84
85 describe('links', () => {
86 it('renders [text](https://url) as a link', () => {
87 const processed = useMarkdown({ text: 'Visit [our site](https://example.com) for more' })
88 expect(processed.value).toContain(
89 '<a href="https://example.com/" rel="nofollow noreferrer noopener" target="_blank">our site</a>',
90 )
91 })
92
93 it('adds security attributes to links', () => {
94 const processed = useMarkdown({ text: '[link](https://example.com)' })
95 expect(processed.value).toBe(
96 '<a href="https://example.com/" rel="nofollow noreferrer noopener" target="_blank">link</a>',
97 )
98 })
99
100 it('allows mailto: links', () => {
101 const processed = useMarkdown({ text: 'Contact [us](mailto:test@example.com)' })
102 expect(processed.value).toContain(
103 '<a href="mailto:test@example.com" rel="nofollow noreferrer noopener" target="_blank">us</a>',
104 )
105 })
106
107 it('blocks javascript: protocol links', () => {
108 const processed = useMarkdown({ text: '[click me](javascript:alert("xss"))' })
109 expect(processed.value).toBe(`click me ${escapeHtml('(javascript:alert("xss"))')}`)
110 })
111
112 it('blocks http: links (only https allowed)', () => {
113 const processed = useMarkdown({ text: '[site](http://example.com)' })
114 expect(processed.value).toBe('site (http://example.com)')
115 })
116
117 it('handles invalid URLs gracefully', () => {
118 const processed = useMarkdown({ text: '[link](not a valid url)' })
119 expect(processed.value).toBe('link (not a valid url)')
120 })
121
122 it('handles URLs with ampersands', () => {
123 const processed = useMarkdown({ text: '[search](https://example.com?a=1&b=2)' })
124 expect(processed.value).toBe(
125 '<a href="https://example.com/?a=1&b=2" rel="nofollow noreferrer noopener" target="_blank">search</a>',
126 )
127 })
128 })
129
130 describe('plain prop', () => {
131 it('renders link text without anchor tag when plain=true', () => {
132 const processed = useMarkdown({
133 text: 'Visit [our site](https://example.com) for more',
134 plain: true,
135 })
136 expect(processed.value).toBe('Visit our site for more')
137 })
138
139 it('still renders other formatting when plain=true', () => {
140 const processed = useMarkdown({
141 text: '**bold** and [link](https://example.com)',
142 plain: true,
143 })
144 expect(processed.value).toBe('<strong>bold</strong> and link')
145 })
146 })
147
148 describe('combined formatting', () => {
149 it('handles multiple formatting in one string', () => {
150 const processed = useMarkdown({ text: '**bold** and *italic* and `code`' })
151 expect(processed.value).toContain('<strong>')
152 expect(processed.value).toContain('<em>')
153 expect(processed.value).toContain('<code>')
154 })
155 })
156
157 describe('markdown image stripping', () => {
158 it('strips standalone markdown images', () => {
159 const processed = useMarkdown({
160 text: ' A library',
161 })
162 expect(processed.value).toBe('A library')
163 })
164
165 it('strips linked markdown images (badges)', () => {
166 const processed = useMarkdown({
167 text: '[](https://travis-ci.org/user/repo) A library',
168 })
169 expect(processed.value).toBe('A library')
170 })
171
172 it('strips multiple badges', () => {
173 const processed = useMarkdown({
174 text: '[](https://npm.com) [](https://ci.com) A library',
175 })
176 expect(processed.value).toBe('A library')
177 })
178
179 it('preserves malformed image syntax without closing paren', () => {
180 // Incomplete/malformed markdown images are left as-is for safety
181 const processed = useMarkdown({ text: '
182 // The image syntax is not stripped because it's malformed (no closing paren)
183 expect(processed.value).toBe('
184 })
185
186 it('strips empty link syntax', () => {
187 const processed = useMarkdown({ text: '[](https://example.com) A library' })
188 expect(processed.value).toBe('A library')
189 })
190
191 it('preserves regular markdown links', () => {
192 const processed = useMarkdown({ text: '[documentation](https://docs.example.com) is here' })
193 expect(processed.value).toBe(
194 '<a href="https://docs.example.com/" rel="nofollow noreferrer noopener" target="_blank">documentation</a> is here',
195 )
196 })
197 })
198
199 describe('packageName prop', () => {
200 it('strips package name from the beginning of plain text', () => {
201 const processed = useMarkdown({
202 text: 'my-package - A great library',
203 packageName: 'my-package',
204 })
205 expect(processed.value).toBe('A great library')
206 })
207
208 it('strips package name with colon separator', () => {
209 const processed = useMarkdown({
210 text: 'my-package: A great library',
211 packageName: 'my-package',
212 })
213 expect(processed.value).toBe('A great library')
214 })
215
216 it('strips package name with em dash separator', () => {
217 const processed = useMarkdown({
218 text: 'my-package — A great library',
219 packageName: 'my-package',
220 })
221 expect(processed.value).toBe('A great library')
222 })
223
224 it('strips package name without separator', () => {
225 const processed = useMarkdown({
226 text: 'my-package A great library',
227 packageName: 'my-package',
228 })
229 expect(processed.value).toBe('A great library')
230 })
231
232 it('is case-insensitive', () => {
233 const processed = useMarkdown({
234 text: 'MY-PACKAGE - A great library',
235 packageName: 'my-package',
236 })
237 expect(processed.value).toBe('A great library')
238 })
239
240 it('does not strip package name from middle of text', () => {
241 const processed = useMarkdown({
242 text: 'A great my-package library',
243 packageName: 'my-package',
244 })
245 expect(processed.value).toBe('A great my-package library')
246 })
247
248 it('handles scoped package names', () => {
249 const processed = useMarkdown({
250 text: '@org/my-package - A great library',
251 packageName: '@org/my-package',
252 })
253 expect(processed.value).toBe('A great library')
254 })
255
256 it('handles package names with special regex characters', () => {
257 const processed = useMarkdown({
258 text: 'pkg.name+test - A great library',
259 packageName: 'pkg.name+test',
260 })
261 expect(processed.value).toBe('A great library')
262 })
263
264 it('strips package name from HTML-containing descriptions', () => {
265 const processed = useMarkdown({
266 text: '<b>my-package</b> - A great library',
267 packageName: 'my-package',
268 })
269 expect(processed.value).toBe('A great library')
270 })
271
272 it('strips package name from descriptions with markdown images', () => {
273 const processed = useMarkdown({
274 text: ' my-package - A great library',
275 packageName: 'my-package',
276 })
277 expect(processed.value).toBe('A great library')
278 })
279
280 it('does nothing when packageName is not provided', () => {
281 const processed = useMarkdown({
282 text: 'my-package - A great library',
283 })
284 expect(processed.value).toBe('my-package - A great library')
285 })
286 })
287
288 describe('HTML tag stripping', () => {
289 it('strips simple HTML tags but keeps content', () => {
290 const processed = useMarkdown({ text: '<b>bold text</b> here' })
291 expect(processed.value).toBe('bold text here')
292 expect(processed.value).not.toContain('<b>')
293 })
294
295 it('strips nested HTML tags', () => {
296 const processed = useMarkdown({ text: '<div><span>nested</span> content</div>' })
297 expect(processed.value).toBe('nested content')
298 })
299
300 it('strips self-closing tags', () => {
301 const processed = useMarkdown({ text: 'before<br/>after' })
302 expect(processed.value).toBe('beforeafter')
303 })
304
305 it('strips tags with attributes', () => {
306 const processed = useMarkdown({ text: '<a href="https://evil.com">click me</a>' })
307 expect(processed.value).toBe('click me')
308 expect(processed.value).not.toContain('<a href="https://evil.com">')
309 })
310
311 it('preserves text that looks like comparison operators', () => {
312 const processed = useMarkdown({ text: 'x < y > z and a < b && c > d' })
313 expect(processed.value).toBe(escapeHtml('x < y > z and a < b && c > d'))
314 })
315
316 it('handles mixed HTML and markdown', () => {
317 const processed = useMarkdown({ text: '<b>bold</b> and **also bold**' })
318 expect(processed.value).toBe('bold and <strong>also bold</strong>')
319 })
320 })
321
322 describe('HTML comment stripping', () => {
323 it('strips HTML comments', () => {
324 const processed = useMarkdown({ text: '<!-- automd:badges color=yellow -->A library' })
325 expect(processed.value).toBe('A library')
326 })
327
328 it('strips HTML comments from the middle of text', () => {
329 const processed = useMarkdown({ text: 'Before <!-- comment --> after' })
330 expect(processed.value).toBe('Before after')
331 })
332
333 it('strips multiple HTML comments', () => {
334 const processed = useMarkdown({ text: '<!-- first -->Text <!-- second -->here' })
335 expect(processed.value).toBe('Text here')
336 })
337
338 it('strips multiline HTML comments', () => {
339 const processed = useMarkdown({ text: '<!-- multi\nline\ncomment -->Text' })
340 expect(processed.value).toBe('Text')
341 })
342
343 it('returns empty string when description is only a comment', () => {
344 const processed = useMarkdown({ text: '<!-- automd:badges color=yellow -->' })
345 expect(processed.value).toBe('')
346 })
347
348 it('strips unclosed HTML comments (truncated)', () => {
349 const processed = useMarkdown({ text: 'A library <!-- automd:badges color=yel' })
350 expect(processed.value).toBe('A library ')
351 })
352 })
353})