[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: replace MarkdownText component with useMarkdown composable (#590)

Co-authored-by: Daniel Roe <daniel@roe.dev>

authored by

Denys
Daniel Roe
and committed by
GitHub
592982d0 da2d3f23

+358 -464
+1 -1
CONTRIBUTING.md
··· 187 187 188 188 | Type | Convention | Example | 189 189 | ---------------- | ------------------------ | ------------------------------ | 190 - | Vue components | PascalCase | `MarkdownText.vue` | 190 + | Vue components | PascalCase | `DateTime.vue` | 191 191 | Pages | kebab-case | `search.vue`, `[...name].vue` | 192 192 | Composables | camelCase + `use` prefix | `useNpmRegistry.ts` | 193 193 | Server routes | kebab-case + method | `search.get.ts` |
+13 -17
app/components/MarkdownText.vue app/composables/useMarkdown.ts
··· 1 - <script setup lang="ts"> 2 1 import { decodeHtmlEntities } from '~/utils/formatters' 3 2 4 - const props = defineProps<{ 3 + interface UseMarkdownOptions { 5 4 text: string 6 5 /** When true, renders link text without the anchor tag (useful when inside another link) */ 7 6 plain?: boolean 8 7 /** Package name to strip from the beginning of the description (if present) */ 9 8 packageName?: string 10 - }>() 9 + } 10 + 11 + /** @public */ 12 + export function useMarkdown(options: MaybeRefOrGetter<UseMarkdownOptions>) { 13 + return computed(() => parseMarkdown(toValue(options))) 14 + } 11 15 12 16 // Strip markdown image badges from text 13 17 function stripMarkdownImages(text: string): string { ··· 22 26 } 23 27 24 28 // Strip HTML tags and escape remaining HTML to prevent XSS 25 - function stripAndEscapeHtml(text: string): string { 29 + function stripAndEscapeHtml(text: string, packageName?: string): string { 26 30 // First decode any HTML entities in the input 27 31 let stripped = decodeHtmlEntities(text) 28 32 ··· 33 37 // Only match tags that start with a letter or / (to avoid matching things like "a < b > c") 34 38 stripped = stripped.replace(/<\/?[a-z][^>]*>/gi, '') 35 39 36 - if (props.packageName) { 40 + if (packageName) { 37 41 // Trim first to handle leading/trailing whitespace from stripped HTML 38 42 stripped = stripped.trim() 39 43 // Collapse multiple whitespace into single space 40 44 stripped = stripped.replace(/\s+/g, ' ') 41 45 // Escape special regex characters in package name 42 - const escapedName = props.packageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') 46 + const escapedName = packageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') 43 47 // Match package name at the start, optionally followed by: space, dash, colon, hyphen, or just space 44 48 const namePattern = new RegExp(`^${escapedName}\\s*[-:—]?\\s*`, 'i') 45 49 stripped = stripped.replace(namePattern, '').trim() ··· 55 59 } 56 60 57 61 // Parse simple inline markdown to HTML 58 - function parseMarkdown(text: string): string { 62 + function parseMarkdown({ text, packageName, plain }: UseMarkdownOptions): string { 59 63 if (!text) return '' 60 64 61 65 // First strip HTML tags and escape remaining HTML 62 - let html = stripAndEscapeHtml(text) 66 + let html = stripAndEscapeHtml(text, packageName) 63 67 64 68 // Bold: **text** or __text__ 65 69 html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') ··· 78 82 // Links: [text](url) - only allow https, mailto 79 83 html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, text, url) => { 80 84 // In plain mode, just render the link text without the anchor 81 - if (props.plain) { 85 + if (plain) { 82 86 return text 83 87 } 84 88 const decodedUrl = url.replace(/&amp;/g, '&') ··· 94 98 95 99 return html 96 100 } 97 - 98 - const html = computed(() => parseMarkdown(props.text)) 99 - </script> 100 - 101 - <template> 102 - <!-- eslint-disable-next-line vue/no-v-html --> 103 - <span v-html="html" /> 104 - </template>
+9 -5
app/components/Package/Card.vue
··· 19 19 const name = props.result.package.name.toLowerCase() 20 20 return query === name 21 21 }) 22 + 23 + // Process package description 24 + const pkgDescription = useMarkdown(() => ({ 25 + text: props.result.package.description ?? '', 26 + plain: true, 27 + packageName: props.result.package.name, 28 + })) 22 29 </script> 23 30 24 31 <template> ··· 74 81 </div> 75 82 <div class="flex justify-start items-start gap-4 sm:gap-8"> 76 83 <div class="min-w-0"> 77 - <p 78 - v-if="result.package.description" 79 - class="text-fg-muted text-xs sm:text-sm line-clamp-2 mb-2 sm:mb-3" 80 - > 81 - <MarkdownText :text="result.package.description" plain /> 84 + <p v-if="pkgDescription" class="text-fg-muted text-xs sm:text-sm line-clamp-2 mb-2 sm:mb-3"> 85 + <span v-html="pkgDescription" /> 82 86 </p> 83 87 <div class="flex flex-wrap items-center gap-x-3 sm:gap-x-4 gap-y-2 text-xs text-fg-subtle"> 84 88 <dl v-if="showPublisher || result.package.date" class="flex items-center gap-4 m-0">
+14 -4
app/pages/[...package].vue
··· 122 122 return pkg.value.versions[latestTag] ?? null 123 123 }) 124 124 125 + // Process package description 126 + const pkgDescription = useMarkdown(() => ({ 127 + text: pkg.value?.description ?? '', 128 + packageName: pkg.value?.name, 129 + })) 130 + 125 131 //copy package name 126 132 const { copied: copiedPkgName, copy: copyPkgName } = useClipboard({ 127 133 source: packageName, ··· 159 165 // Otherwise show "version deprecated" 160 166 return { type: 'version' as const, message: displayVersion.value.deprecated } 161 167 }) 168 + 169 + const deprecationNoticeMessage = useMarkdown(() => ({ 170 + text: deprecationNotice.value?.message ?? '', 171 + })) 162 172 163 173 const sizeTooltip = computed(() => { 164 174 const chunks = [ ··· 563 573 <div class="mb-4"> 564 574 <!-- Description container with min-height to prevent CLS --> 565 575 <div class="max-w-2xl min-h-[4.5rem]"> 566 - <p v-if="pkg.description" class="text-fg-muted text-base m-0"> 567 - <MarkdownText :text="pkg.description" :package-name="pkg.name" /> 576 + <p v-if="pkgDescription" class="text-fg-muted text-base m-0"> 577 + <span v-html="pkgDescription" /> 568 578 </p> 569 579 <p v-else class="text-fg-subtle text-base m-0 italic"> 570 580 {{ $t('package.no_description') }} ··· 713 723 : $t('package.deprecation.version') 714 724 }} 715 725 </h2> 716 - <p v-if="deprecationNotice.message" class="text-base m-0"> 717 - <MarkdownText :text="deprecationNotice.message" /> 726 + <p v-if="deprecationNoticeMessage" class="text-base m-0"> 727 + <span v-html="deprecationNoticeMessage" /> 718 728 </p> 719 729 <p v-else class="text-base m-0 italic"> 720 730 {{ $t('package.deprecation.no_reason') }}
-19
test/nuxt/a11y.spec.ts
··· 76 76 HeaderAccountMenu, 77 77 LicenseDisplay, 78 78 LoadingSpinner, 79 - MarkdownText, 80 79 PackageChartModal, 81 80 PackageClaimPackageModal, 82 81 HeaderConnectorModal, ··· 275 274 version: '3.0.0', 276 275 compact: true, 277 276 }, 278 - }) 279 - const results = await runAxe(component) 280 - expect(results.violations).toEqual([]) 281 - }) 282 - }) 283 - 284 - describe('MarkdownText', () => { 285 - it('should have no accessibility violations with plain text', async () => { 286 - const component = await mountSuspended(MarkdownText, { 287 - props: { text: 'Simple text' }, 288 - }) 289 - const results = await runAxe(component) 290 - expect(results.violations).toEqual([]) 291 - }) 292 - 293 - it('should have no accessibility violations with formatted text', async () => { 294 - const component = await mountSuspended(MarkdownText, { 295 - props: { text: '**Bold** and *italic* and `code`' }, 296 277 }) 297 278 const results = await runAxe(component) 298 279 expect(results.violations).toEqual([])
-418
test/nuxt/components/MarkdownText.spec.ts
··· 1 - import { describe, expect, it } from 'vitest' 2 - import { mountSuspended } from '@nuxt/test-utils/runtime' 3 - import MarkdownText from '~/components/MarkdownText.vue' 4 - 5 - describe('MarkdownText', () => { 6 - describe('plain text', () => { 7 - it('renders plain text unchanged', async () => { 8 - const component = await mountSuspended(MarkdownText, { 9 - props: { text: 'Hello world' }, 10 - }) 11 - expect(component.text()).toBe('Hello world') 12 - }) 13 - 14 - it('returns empty for empty text', async () => { 15 - const component = await mountSuspended(MarkdownText, { 16 - props: { text: '' }, 17 - }) 18 - expect(component.text()).toBe('') 19 - }) 20 - }) 21 - 22 - describe('HTML escaping', () => { 23 - it('strips HTML tags to prevent XSS', async () => { 24 - const component = await mountSuspended(MarkdownText, { 25 - props: { text: '<script>alert("xss")</script>' }, 26 - }) 27 - // HTML tags should be stripped (not rendered) 28 - expect(component.html()).not.toContain('<script>') 29 - // Only the text content remains 30 - expect(component.text()).toBe('alert("xss")') 31 - }) 32 - 33 - it('escapes special characters', async () => { 34 - const component = await mountSuspended(MarkdownText, { 35 - props: { text: 'a < b && c > d' }, 36 - }) 37 - expect(component.text()).toBe('a < b && c > d') 38 - }) 39 - }) 40 - 41 - describe('bold formatting', () => { 42 - it('renders **text** as bold', async () => { 43 - const component = await mountSuspended(MarkdownText, { 44 - props: { text: 'This is **bold** text' }, 45 - }) 46 - const strong = component.find('strong') 47 - expect(strong.exists()).toBe(true) 48 - expect(strong.text()).toBe('bold') 49 - }) 50 - 51 - it('renders __text__ as bold', async () => { 52 - const component = await mountSuspended(MarkdownText, { 53 - props: { text: 'This is __bold__ text' }, 54 - }) 55 - const strong = component.find('strong') 56 - expect(strong.exists()).toBe(true) 57 - expect(strong.text()).toBe('bold') 58 - }) 59 - }) 60 - 61 - describe('italic formatting', () => { 62 - it('renders *text* as italic', async () => { 63 - const component = await mountSuspended(MarkdownText, { 64 - props: { text: 'This is *italic* text' }, 65 - }) 66 - const em = component.find('em') 67 - expect(em.exists()).toBe(true) 68 - expect(em.text()).toBe('italic') 69 - }) 70 - 71 - it('renders _text_ as italic', async () => { 72 - const component = await mountSuspended(MarkdownText, { 73 - props: { text: 'This is _italic_ text' }, 74 - }) 75 - const em = component.find('em') 76 - expect(em.exists()).toBe(true) 77 - expect(em.text()).toBe('italic') 78 - }) 79 - }) 80 - 81 - describe('inline code', () => { 82 - it('renders `code` in code tags', async () => { 83 - const component = await mountSuspended(MarkdownText, { 84 - props: { text: 'Run `npm install` to start' }, 85 - }) 86 - const code = component.find('code') 87 - expect(code.exists()).toBe(true) 88 - expect(code.text()).toBe('npm install') 89 - }) 90 - }) 91 - 92 - describe('strikethrough', () => { 93 - it('renders ~~text~~ as strikethrough', async () => { 94 - const component = await mountSuspended(MarkdownText, { 95 - props: { text: 'This is ~~deleted~~ text' }, 96 - }) 97 - const del = component.find('del') 98 - expect(del.exists()).toBe(true) 99 - expect(del.text()).toBe('deleted') 100 - }) 101 - }) 102 - 103 - describe('links', () => { 104 - it('renders [text](https://url) as a link', async () => { 105 - const component = await mountSuspended(MarkdownText, { 106 - props: { text: 'Visit [our site](https://example.com) for more' }, 107 - }) 108 - const link = component.find('a') 109 - expect(link.exists()).toBe(true) 110 - expect(link.attributes('href')).toBe('https://example.com/') 111 - expect(link.text()).toBe('our site') 112 - }) 113 - 114 - it('adds security attributes to links', async () => { 115 - const component = await mountSuspended(MarkdownText, { 116 - props: { text: '[link](https://example.com)' }, 117 - }) 118 - const link = component.find('a') 119 - expect(link.attributes('rel')).toBe('nofollow noreferrer noopener') 120 - expect(link.attributes('target')).toBe('_blank') 121 - }) 122 - 123 - it('allows mailto: links', async () => { 124 - const component = await mountSuspended(MarkdownText, { 125 - props: { text: 'Contact [us](mailto:test@example.com)' }, 126 - }) 127 - const link = component.find('a') 128 - expect(link.exists()).toBe(true) 129 - expect(link.attributes('href')).toBe('mailto:test@example.com') 130 - }) 131 - 132 - it('blocks javascript: protocol links', async () => { 133 - const component = await mountSuspended(MarkdownText, { 134 - props: { text: '[click me](javascript:alert("xss"))' }, 135 - }) 136 - const link = component.find('a') 137 - expect(link.exists()).toBe(false) 138 - expect(component.text()).toContain('click me') 139 - }) 140 - 141 - it('blocks http: links (only https allowed)', async () => { 142 - const component = await mountSuspended(MarkdownText, { 143 - props: { text: '[site](http://example.com)' }, 144 - }) 145 - const link = component.find('a') 146 - expect(link.exists()).toBe(false) 147 - expect(component.text()).toContain('site') 148 - }) 149 - 150 - it('handles invalid URLs gracefully', async () => { 151 - const component = await mountSuspended(MarkdownText, { 152 - props: { text: '[link](not a valid url)' }, 153 - }) 154 - const link = component.find('a') 155 - expect(link.exists()).toBe(false) 156 - expect(component.text()).toContain('link') 157 - }) 158 - 159 - it('handles URLs with ampersands', async () => { 160 - const component = await mountSuspended(MarkdownText, { 161 - props: { text: '[search](https://example.com?a=1&b=2)' }, 162 - }) 163 - const link = component.find('a') 164 - expect(link.exists()).toBe(true) 165 - expect(link.attributes('href')).toBe('https://example.com/?a=1&b=2') 166 - }) 167 - }) 168 - 169 - describe('plain prop', () => { 170 - it('renders link text without anchor tag when plain=true', async () => { 171 - const component = await mountSuspended(MarkdownText, { 172 - props: { 173 - text: 'Visit [our site](https://example.com) for more', 174 - plain: true, 175 - }, 176 - }) 177 - const link = component.find('a') 178 - expect(link.exists()).toBe(false) 179 - expect(component.text()).toBe('Visit our site for more') 180 - }) 181 - 182 - it('still renders other formatting when plain=true', async () => { 183 - const component = await mountSuspended(MarkdownText, { 184 - props: { 185 - text: '**bold** and [link](https://example.com)', 186 - plain: true, 187 - }, 188 - }) 189 - const strong = component.find('strong') 190 - const link = component.find('a') 191 - expect(strong.exists()).toBe(true) 192 - expect(link.exists()).toBe(false) 193 - expect(component.text()).toBe('bold and link') 194 - }) 195 - }) 196 - 197 - describe('combined formatting', () => { 198 - it('handles multiple formatting in one string', async () => { 199 - const component = await mountSuspended(MarkdownText, { 200 - props: { text: '**bold** and *italic* and `code`' }, 201 - }) 202 - expect(component.find('strong').exists()).toBe(true) 203 - expect(component.find('em').exists()).toBe(true) 204 - expect(component.find('code').exists()).toBe(true) 205 - }) 206 - }) 207 - 208 - describe('markdown image stripping', () => { 209 - it('strips standalone markdown images', async () => { 210 - const component = await mountSuspended(MarkdownText, { 211 - props: { text: '![badge](https://img.shields.io/badge.svg) A library' }, 212 - }) 213 - expect(component.text()).toBe('A library') 214 - }) 215 - 216 - it('strips linked markdown images (badges)', async () => { 217 - const component = await mountSuspended(MarkdownText, { 218 - props: { 219 - text: '[![Build Status](https://travis-ci.org/user/repo.svg)](https://travis-ci.org/user/repo) A library', 220 - }, 221 - }) 222 - expect(component.text()).toBe('A library') 223 - }) 224 - 225 - it('strips multiple badges', async () => { 226 - const component = await mountSuspended(MarkdownText, { 227 - props: { 228 - text: '[![npm](https://badge.svg)](https://npm.com) [![build](https://ci.svg)](https://ci.com) A library', 229 - }, 230 - }) 231 - expect(component.text()).toBe('A library') 232 - }) 233 - 234 - it('preserves malformed image syntax without closing paren', async () => { 235 - // Incomplete/malformed markdown images are left as-is for safety 236 - const component = await mountSuspended(MarkdownText, { 237 - props: { text: '![badge](https://example.svg A library' }, 238 - }) 239 - // The image syntax is not stripped because it's malformed (no closing paren) 240 - expect(component.text()).toBe('![badge](https://example.svg A library') 241 - }) 242 - 243 - it('strips empty link syntax', async () => { 244 - const component = await mountSuspended(MarkdownText, { 245 - props: { text: '[](https://example.com) A library' }, 246 - }) 247 - expect(component.text()).toBe('A library') 248 - }) 249 - 250 - it('preserves regular markdown links', async () => { 251 - const component = await mountSuspended(MarkdownText, { 252 - props: { text: '[documentation](https://docs.example.com) is here' }, 253 - }) 254 - const link = component.find('a') 255 - expect(link.exists()).toBe(true) 256 - expect(link.text()).toBe('documentation') 257 - expect(component.text()).toBe('documentation is here') 258 - }) 259 - }) 260 - 261 - describe('packageName prop', () => { 262 - it('strips package name from the beginning of plain text', async () => { 263 - const component = await mountSuspended(MarkdownText, { 264 - props: { 265 - text: 'my-package - A great library', 266 - packageName: 'my-package', 267 - }, 268 - }) 269 - expect(component.text()).toBe('A great library') 270 - }) 271 - 272 - it('strips package name with colon separator', async () => { 273 - const component = await mountSuspended(MarkdownText, { 274 - props: { 275 - text: 'my-package: A great library', 276 - packageName: 'my-package', 277 - }, 278 - }) 279 - expect(component.text()).toBe('A great library') 280 - }) 281 - 282 - it('strips package name with em dash separator', async () => { 283 - const component = await mountSuspended(MarkdownText, { 284 - props: { 285 - text: 'my-package — A great library', 286 - packageName: 'my-package', 287 - }, 288 - }) 289 - expect(component.text()).toBe('A great library') 290 - }) 291 - 292 - it('strips package name without separator', async () => { 293 - const component = await mountSuspended(MarkdownText, { 294 - props: { 295 - text: 'my-package A great library', 296 - packageName: 'my-package', 297 - }, 298 - }) 299 - expect(component.text()).toBe('A great library') 300 - }) 301 - 302 - it('is case-insensitive', async () => { 303 - const component = await mountSuspended(MarkdownText, { 304 - props: { 305 - text: 'MY-PACKAGE - A great library', 306 - packageName: 'my-package', 307 - }, 308 - }) 309 - expect(component.text()).toBe('A great library') 310 - }) 311 - 312 - it('does not strip package name from middle of text', async () => { 313 - const component = await mountSuspended(MarkdownText, { 314 - props: { 315 - text: 'A great my-package library', 316 - packageName: 'my-package', 317 - }, 318 - }) 319 - expect(component.text()).toBe('A great my-package library') 320 - }) 321 - 322 - it('handles scoped package names', async () => { 323 - const component = await mountSuspended(MarkdownText, { 324 - props: { 325 - text: '@org/my-package - A great library', 326 - packageName: '@org/my-package', 327 - }, 328 - }) 329 - expect(component.text()).toBe('A great library') 330 - }) 331 - 332 - it('handles package names with special regex characters', async () => { 333 - const component = await mountSuspended(MarkdownText, { 334 - props: { 335 - text: 'pkg.name+test - A great library', 336 - packageName: 'pkg.name+test', 337 - }, 338 - }) 339 - expect(component.text()).toBe('A great library') 340 - }) 341 - 342 - it('strips package name from HTML-containing descriptions', async () => { 343 - const component = await mountSuspended(MarkdownText, { 344 - props: { 345 - text: '<b>my-package</b> - A great library', 346 - packageName: 'my-package', 347 - }, 348 - }) 349 - expect(component.text()).toBe('A great library') 350 - }) 351 - 352 - it('strips package name from descriptions with markdown images', async () => { 353 - const component = await mountSuspended(MarkdownText, { 354 - props: { 355 - text: '![badge](https://badge.svg) my-package - A great library', 356 - packageName: 'my-package', 357 - }, 358 - }) 359 - expect(component.text()).toBe('A great library') 360 - }) 361 - 362 - it('does nothing when packageName is not provided', async () => { 363 - const component = await mountSuspended(MarkdownText, { 364 - props: { 365 - text: 'my-package - A great library', 366 - }, 367 - }) 368 - expect(component.text()).toBe('my-package - A great library') 369 - }) 370 - }) 371 - 372 - describe('HTML tag stripping', () => { 373 - it('strips simple HTML tags but keeps content', async () => { 374 - const component = await mountSuspended(MarkdownText, { 375 - props: { text: '<b>bold text</b> here' }, 376 - }) 377 - expect(component.text()).toBe('bold text here') 378 - expect(component.html()).not.toContain('<b>') 379 - }) 380 - 381 - it('strips nested HTML tags', async () => { 382 - const component = await mountSuspended(MarkdownText, { 383 - props: { text: '<div><span>nested</span> content</div>' }, 384 - }) 385 - expect(component.text()).toBe('nested content') 386 - }) 387 - 388 - it('strips self-closing tags', async () => { 389 - const component = await mountSuspended(MarkdownText, { 390 - props: { text: 'before<br/>after' }, 391 - }) 392 - expect(component.text()).toBe('beforeafter') 393 - }) 394 - 395 - it('strips tags with attributes', async () => { 396 - const component = await mountSuspended(MarkdownText, { 397 - props: { text: '<a href="https://evil.com">click me</a>' }, 398 - }) 399 - expect(component.text()).toBe('click me') 400 - expect(component.find('a').exists()).toBe(false) 401 - }) 402 - 403 - it('preserves text that looks like comparison operators', async () => { 404 - const component = await mountSuspended(MarkdownText, { 405 - props: { text: 'x < y > z and a < b && c > d' }, 406 - }) 407 - expect(component.text()).toBe('x < y > z and a < b && c > d') 408 - }) 409 - 410 - it('handles mixed HTML and markdown', async () => { 411 - const component = await mountSuspended(MarkdownText, { 412 - props: { text: '<b>bold</b> and **also bold**' }, 413 - }) 414 - expect(component.text()).toBe('bold and also bold') 415 - expect(component.find('strong').exists()).toBe(true) 416 - }) 417 - }) 418 - })
+321
test/nuxt/composables/use-markdown.spec.ts
··· 1 + import { describe, expect, it } from 'vitest' 2 + 3 + // Utility to use more human-readable strings in tests 4 + function escapeHtml(text: string): string { 5 + return text 6 + .replace(/&/g, '&amp;') 7 + .replace(/</g, '&lt;') 8 + .replace(/>/g, '&gt;') 9 + .replace(/"/g, '&quot;') 10 + .replace(/'/g, '&#039;') 11 + } 12 + 13 + describe('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: '![badge](https://img.shields.io/badge.svg) 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: '[![Build Status](https://travis-ci.org/user/repo.svg)](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: '[![npm](https://badge.svg)](https://npm.com) [![build](https://ci.svg)](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: '![badge](https://example.svg A library' }) 182 + // The image syntax is not stripped because it's malformed (no closing paren) 183 + expect(processed.value).toBe('![badge](https://example.svg A library') 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: '![badge](https://badge.svg) 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 + })