Skip to content

Commit 7bacc0a

Browse files
open markdown links in new window/tab
1 parent c436552 commit 7bacc0a

File tree

4 files changed

+126
-11
lines changed

4 files changed

+126
-11
lines changed

src/platform/updates/components/WhatsNewPopup.vue

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,11 +64,11 @@
6464
</template>
6565

6666
<script setup lang="ts">
67-
import { marked } from 'marked'
6867
import { computed, onMounted, ref } from 'vue'
6968
import { useI18n } from 'vue-i18n'
7069
7170
import { formatVersionAnchor } from '@/utils/formatUtil'
71+
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
7272
7373
import type { ReleaseNote } from '../common/releaseService'
7474
import { useReleaseStore } from '../common/releaseStore'
@@ -108,17 +108,13 @@ const changelogUrl = computed(() => {
108108
return baseUrl
109109
})
110110
111-
// Format release content for display using marked
112111
const formattedContent = computed(() => {
113112
if (!latestRelease.value?.content) {
114113
return `<p>${t('whatsNewPopup.noReleaseNotes')}</p>`
115114
}
116115
117116
try {
118-
// Use marked to parse markdown to HTML
119-
return marked(latestRelease.value.content, {
120-
gfm: true // Enable GitHub Flavored Markdown
121-
})
117+
return renderMarkdownToHtml(latestRelease.value.content)
122118
} catch (error) {
123119
console.error('Error parsing markdown:', error)
124120
// Fallback to plain text with line breaks

src/utils/markdownRendererUtil.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,11 @@ export function createMarkdownRenderer(baseUrl?: string): Renderer {
2929
const titleAttr = title ? ` title="${title}"` : ''
3030
return `<img src="${src}" alt="${text}"${titleAttr} />`
3131
}
32+
renderer.link = ({ href, title, tokens }) => {
33+
const text = renderer.parser.parseInline(tokens)
34+
const titleAttr = title ? ` title="${title}"` : ''
35+
return `<a href="${href}"${titleAttr} target="_blank" rel="noopener noreferrer">${text}</a>`
36+
}
3237
return renderer
3338
}
3439

@@ -48,6 +53,6 @@ export function renderMarkdownToHtml(
4853

4954
return DOMPurify.sanitize(html, {
5055
ADD_TAGS: ALLOWED_TAGS,
51-
ADD_ATTR: ALLOWED_ATTRS
56+
ADD_ATTR: [...ALLOWED_ATTRS, 'target', 'rel']
5257
})
5358
}

tests-ui/tests/components/helpcenter/WhatsNewPopup.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ vi.mock('vue-i18n', () => ({
1515
}))
1616
}))
1717

18-
vi.mock('marked', () => ({
19-
marked: vi.fn((content) => `<p>${content}</p>`)
18+
vi.mock('@/utils/markdownRendererUtil', () => ({
19+
renderMarkdownToHtml: vi.fn((content) => `<p>${content}</p>`)
2020
}))
2121

2222
vi.mock('@/platform/updates/common/releaseStore', () => ({
@@ -119,7 +119,7 @@ describe('WhatsNewPopup', () => {
119119
})
120120

121121
describe('content rendering', () => {
122-
it('should render release content using marked', async () => {
122+
it('should render release content using renderMarkdownToHtml', async () => {
123123
mockReleaseStore.shouldShowPopup = true
124124
mockReleaseStore.recentRelease = {
125125
id: 1,
@@ -132,7 +132,7 @@ describe('WhatsNewPopup', () => {
132132

133133
const wrapper = createWrapper()
134134

135-
// Check that the content is rendered (marked is mocked to return processed content)
135+
// Check that the content is rendered (renderMarkdownToHtml is mocked to return processed content)
136136
expect(wrapper.find('.content-text').exists()).toBe(true)
137137
const contentHtml = wrapper.find('.content-text').html()
138138
expect(contentHtml).toContain('<p># Release Notes')
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
4+
5+
describe('markdownRendererUtil', () => {
6+
describe('renderMarkdownToHtml', () => {
7+
it('should render basic markdown to HTML', () => {
8+
const markdown = '# Hello\n\nThis is a test.'
9+
const html = renderMarkdownToHtml(markdown)
10+
11+
expect(html).toContain('<h1')
12+
expect(html).toContain('Hello')
13+
expect(html).toContain('<p>')
14+
expect(html).toContain('This is a test.')
15+
})
16+
17+
it('should render links with target="_blank" and rel="noopener noreferrer"', () => {
18+
const markdown = '[Click here](https://example.com)'
19+
const html = renderMarkdownToHtml(markdown)
20+
21+
expect(html).toContain('target="_blank"')
22+
expect(html).toContain('rel="noopener noreferrer"')
23+
expect(html).toContain('href="https://example.com"')
24+
expect(html).toContain('Click here')
25+
})
26+
27+
it('should render multiple links with target="_blank"', () => {
28+
const markdown =
29+
'[Link 1](https://example.com) and [Link 2](https://test.com)'
30+
const html = renderMarkdownToHtml(markdown)
31+
32+
const targetBlankMatches = html.match(/target="_blank"/g)
33+
expect(targetBlankMatches).toHaveLength(2)
34+
35+
const relMatches = html.match(/rel="noopener noreferrer"/g)
36+
expect(relMatches).toHaveLength(2)
37+
})
38+
39+
it('should handle relative image paths with baseUrl', () => {
40+
const markdown = '![Alt text](image.png)'
41+
const baseUrl = 'https://cdn.example.com'
42+
const html = renderMarkdownToHtml(markdown, baseUrl)
43+
44+
expect(html).toContain(`src="${baseUrl}/image.png"`)
45+
expect(html).toContain('alt="Alt text"')
46+
})
47+
48+
it('should not modify absolute image URLs', () => {
49+
const markdown = '![Alt text](https://example.com/image.png)'
50+
const baseUrl = 'https://cdn.example.com'
51+
const html = renderMarkdownToHtml(markdown, baseUrl)
52+
53+
expect(html).toContain('src="https://example.com/image.png"')
54+
expect(html).not.toContain(baseUrl)
55+
})
56+
57+
it('should handle empty markdown', () => {
58+
const html = renderMarkdownToHtml('')
59+
60+
expect(html).toBe('')
61+
})
62+
63+
it('should sanitize potentially dangerous HTML', () => {
64+
const markdown = '<script>alert("xss")</script>'
65+
const html = renderMarkdownToHtml(markdown)
66+
67+
expect(html).not.toContain('<script>')
68+
expect(html).not.toContain('alert')
69+
})
70+
71+
it('should allow video tags with proper attributes', () => {
72+
const markdown =
73+
'<video src="video.mp4" controls autoplay loop muted></video>'
74+
const html = renderMarkdownToHtml(markdown)
75+
76+
expect(html).toContain('<video')
77+
expect(html).toContain('src="video.mp4"')
78+
expect(html).toContain('controls')
79+
})
80+
81+
it('should render links with title attribute', () => {
82+
const markdown = '[Link](https://example.com "This is a title")'
83+
const html = renderMarkdownToHtml(markdown)
84+
85+
expect(html).toContain('title="This is a title"')
86+
expect(html).toContain('target="_blank"')
87+
expect(html).toContain('rel="noopener noreferrer"')
88+
})
89+
90+
it('should render complex markdown with links, images, and text', () => {
91+
const markdown = `
92+
# Release Notes
93+
94+
Check out our [documentation](https://docs.example.com) for more info.
95+
96+
![Screenshot](screenshot.png)
97+
98+
Visit our [homepage](https://example.com) to learn more.
99+
`
100+
const baseUrl = 'https://cdn.example.com'
101+
const html = renderMarkdownToHtml(markdown, baseUrl)
102+
103+
// Check links have target="_blank"
104+
const targetBlankMatches = html.match(/target="_blank"/g)
105+
expect(targetBlankMatches).toHaveLength(2)
106+
107+
// Check image has baseUrl prepended
108+
expect(html).toContain(`${baseUrl}/screenshot.png`)
109+
110+
// Check heading
111+
expect(html).toContain('Release Notes')
112+
})
113+
})
114+
})

0 commit comments

Comments
 (0)