Skip to content

Commit c79a304

Browse files
authored
feat: render links in package descriptions (#147)
1 parent 065ad9b commit c79a304

3 files changed

Lines changed: 225 additions & 1 deletion

File tree

app/components/MarkdownText.vue

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
<script setup lang="ts">
22
const props = defineProps<{
33
text: string
4+
/** When true, renders link text without the anchor tag (useful when inside another link) */
5+
plain?: boolean
46
}>()
57
68
// Escape HTML to prevent XSS
@@ -34,6 +36,23 @@ function parseMarkdown(text: string): string {
3436
// Strikethrough: ~~text~~
3537
html = html.replace(/~~(.+?)~~/g, '<del>$1</del>')
3638
39+
// Links: [text](url) - only allow https, mailto
40+
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_match, text, url) => {
41+
// In plain mode, just render the link text without the anchor
42+
if (props.plain) {
43+
return text
44+
}
45+
const decodedUrl = url.replace(/&amp;/g, '&')
46+
try {
47+
const { protocol, href } = new URL(decodedUrl)
48+
if (['https:', 'mailto:'].includes(protocol)) {
49+
const safeUrl = href.replace(/"/g, '&quot;')
50+
return `<a href="${safeUrl}" rel="nofollow noreferrer noopener" target="_blank">${text}</a>`
51+
}
52+
} catch {}
53+
return `${text} (${url})`
54+
})
55+
3756
return html
3857
}
3958

app/components/PackageCard.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ const emit = defineEmits<{
6262
v-if="result.package.description"
6363
class="text-fg-muted text-xs sm:text-sm line-clamp-2 mb-2 sm:mb-3"
6464
>
65-
<MarkdownText :text="result.package.description" />
65+
<MarkdownText :text="result.package.description" plain />
6666
</p>
6767
<div class="flex flex-wrap items-center gap-x-3 sm:gap-x-4 gap-y-2 text-xs text-fg-subtle">
6868
<dl v-if="showPublisher || result.package.date" class="flex items-center gap-4 m-0">
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
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('escapes HTML tags to prevent XSS', async () => {
24+
const component = await mountSuspended(MarkdownText, {
25+
props: { text: '<script>alert("xss")</script>' },
26+
})
27+
expect(component.html()).not.toContain('<script>')
28+
expect(component.text()).toContain('<script>')
29+
})
30+
31+
it('escapes special characters', async () => {
32+
const component = await mountSuspended(MarkdownText, {
33+
props: { text: 'a < b && c > d' },
34+
})
35+
expect(component.text()).toBe('a < b && c > d')
36+
})
37+
})
38+
39+
describe('bold formatting', () => {
40+
it('renders **text** as bold', async () => {
41+
const component = await mountSuspended(MarkdownText, {
42+
props: { text: 'This is **bold** text' },
43+
})
44+
const strong = component.find('strong')
45+
expect(strong.exists()).toBe(true)
46+
expect(strong.text()).toBe('bold')
47+
})
48+
49+
it('renders __text__ as bold', async () => {
50+
const component = await mountSuspended(MarkdownText, {
51+
props: { text: 'This is __bold__ text' },
52+
})
53+
const strong = component.find('strong')
54+
expect(strong.exists()).toBe(true)
55+
expect(strong.text()).toBe('bold')
56+
})
57+
})
58+
59+
describe('italic formatting', () => {
60+
it('renders *text* as italic', async () => {
61+
const component = await mountSuspended(MarkdownText, {
62+
props: { text: 'This is *italic* text' },
63+
})
64+
const em = component.find('em')
65+
expect(em.exists()).toBe(true)
66+
expect(em.text()).toBe('italic')
67+
})
68+
69+
it('renders _text_ as italic', async () => {
70+
const component = await mountSuspended(MarkdownText, {
71+
props: { text: 'This is _italic_ text' },
72+
})
73+
const em = component.find('em')
74+
expect(em.exists()).toBe(true)
75+
expect(em.text()).toBe('italic')
76+
})
77+
})
78+
79+
describe('inline code', () => {
80+
it('renders `code` in code tags', async () => {
81+
const component = await mountSuspended(MarkdownText, {
82+
props: { text: 'Run `npm install` to start' },
83+
})
84+
const code = component.find('code')
85+
expect(code.exists()).toBe(true)
86+
expect(code.text()).toBe('npm install')
87+
})
88+
})
89+
90+
describe('strikethrough', () => {
91+
it('renders ~~text~~ as strikethrough', async () => {
92+
const component = await mountSuspended(MarkdownText, {
93+
props: { text: 'This is ~~deleted~~ text' },
94+
})
95+
const del = component.find('del')
96+
expect(del.exists()).toBe(true)
97+
expect(del.text()).toBe('deleted')
98+
})
99+
})
100+
101+
describe('links', () => {
102+
it('renders [text](https://url) as a link', async () => {
103+
const component = await mountSuspended(MarkdownText, {
104+
props: { text: 'Visit [our site](https://example.com) for more' },
105+
})
106+
const link = component.find('a')
107+
expect(link.exists()).toBe(true)
108+
expect(link.attributes('href')).toBe('https://example.com/')
109+
expect(link.text()).toBe('our site')
110+
})
111+
112+
it('adds security attributes to links', async () => {
113+
const component = await mountSuspended(MarkdownText, {
114+
props: { text: '[link](https://example.com)' },
115+
})
116+
const link = component.find('a')
117+
expect(link.attributes('rel')).toBe('nofollow noreferrer noopener')
118+
expect(link.attributes('target')).toBe('_blank')
119+
})
120+
121+
it('allows mailto: links', async () => {
122+
const component = await mountSuspended(MarkdownText, {
123+
props: { text: 'Contact [us](mailto:test@example.com)' },
124+
})
125+
const link = component.find('a')
126+
expect(link.exists()).toBe(true)
127+
expect(link.attributes('href')).toBe('mailto:test@example.com')
128+
})
129+
130+
it('blocks javascript: protocol links', async () => {
131+
const component = await mountSuspended(MarkdownText, {
132+
props: { text: '[click me](javascript:alert("xss"))' },
133+
})
134+
const link = component.find('a')
135+
expect(link.exists()).toBe(false)
136+
expect(component.text()).toContain('click me')
137+
})
138+
139+
it('blocks http: links (only https allowed)', async () => {
140+
const component = await mountSuspended(MarkdownText, {
141+
props: { text: '[site](http://example.com)' },
142+
})
143+
const link = component.find('a')
144+
expect(link.exists()).toBe(false)
145+
expect(component.text()).toContain('site')
146+
})
147+
148+
it('handles invalid URLs gracefully', async () => {
149+
const component = await mountSuspended(MarkdownText, {
150+
props: { text: '[link](not a valid url)' },
151+
})
152+
const link = component.find('a')
153+
expect(link.exists()).toBe(false)
154+
expect(component.text()).toContain('link')
155+
})
156+
157+
it('handles URLs with ampersands', async () => {
158+
const component = await mountSuspended(MarkdownText, {
159+
props: { text: '[search](https://example.com?a=1&b=2)' },
160+
})
161+
const link = component.find('a')
162+
expect(link.exists()).toBe(true)
163+
expect(link.attributes('href')).toBe('https://example.com/?a=1&b=2')
164+
})
165+
})
166+
167+
describe('plain prop', () => {
168+
it('renders link text without anchor tag when plain=true', async () => {
169+
const component = await mountSuspended(MarkdownText, {
170+
props: {
171+
text: 'Visit [our site](https://example.com) for more',
172+
plain: true,
173+
},
174+
})
175+
const link = component.find('a')
176+
expect(link.exists()).toBe(false)
177+
expect(component.text()).toBe('Visit our site for more')
178+
})
179+
180+
it('still renders other formatting when plain=true', async () => {
181+
const component = await mountSuspended(MarkdownText, {
182+
props: {
183+
text: '**bold** and [link](https://example.com)',
184+
plain: true,
185+
},
186+
})
187+
const strong = component.find('strong')
188+
const link = component.find('a')
189+
expect(strong.exists()).toBe(true)
190+
expect(link.exists()).toBe(false)
191+
expect(component.text()).toBe('bold and link')
192+
})
193+
})
194+
195+
describe('combined formatting', () => {
196+
it('handles multiple formatting in one string', async () => {
197+
const component = await mountSuspended(MarkdownText, {
198+
props: { text: '**bold** and *italic* and `code`' },
199+
})
200+
expect(component.find('strong').exists()).toBe(true)
201+
expect(component.find('em').exists()).toBe(true)
202+
expect(component.find('code').exists()).toBe(true)
203+
})
204+
})
205+
})

0 commit comments

Comments
 (0)