mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
open markdown links in new window/tab (#6229)
## Summary Changes links in markdown snippets (What's New popup, node info sidebar) to open the link in a new tab/window rather than directly navigating and potentially losing unsaved work. https://github.com/user-attachments/assets/24331bba-e31a-484c-bc11-12cf61805c98 Fixes https://github.com/Comfy-Org/ComfyUI_frontend/issues/6223. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6229-open-markdown-links-in-new-window-tab-2956d73d365081edbb1efb21cd0e2ab2) by [Unito](https://www.unito.io) --------- Co-authored-by: Alexander Brown <drjkl@comfy.org> Co-authored-by: GitHub Action <action@github.com>
This commit is contained in:
@@ -64,11 +64,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { marked } from 'marked'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { formatVersionAnchor } from '@/utils/formatUtil'
|
||||
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
|
||||
|
||||
import type { ReleaseNote } from '../common/releaseService'
|
||||
import { useReleaseStore } from '../common/releaseStore'
|
||||
@@ -108,17 +108,13 @@ const changelogUrl = computed(() => {
|
||||
return baseUrl
|
||||
})
|
||||
|
||||
// Format release content for display using marked
|
||||
const formattedContent = computed(() => {
|
||||
if (!latestRelease.value?.content) {
|
||||
return `<p>${t('whatsNewPopup.noReleaseNotes')}</p>`
|
||||
}
|
||||
|
||||
try {
|
||||
// Use marked to parse markdown to HTML
|
||||
return marked(latestRelease.value.content, {
|
||||
gfm: true // Enable GitHub Flavored Markdown
|
||||
})
|
||||
return renderMarkdownToHtml(latestRelease.value.content)
|
||||
} catch (error) {
|
||||
console.error('Error parsing markdown:', error)
|
||||
// Fallback to plain text with line breaks
|
||||
|
||||
@@ -29,6 +29,12 @@ export function createMarkdownRenderer(baseUrl?: string): Renderer {
|
||||
const titleAttr = title ? ` title="${title}"` : ''
|
||||
return `<img src="${src}" alt="${text}"${titleAttr} />`
|
||||
}
|
||||
renderer.link = ({ href, title, tokens, text }) => {
|
||||
// For autolinks (bare URLs), tokens may be undefined, so fall back to text
|
||||
const linkText = tokens ? renderer.parser.parseInline(tokens) : text
|
||||
const titleAttr = title ? ` title="${title}"` : ''
|
||||
return `<a href="${href}" ${titleAttr} target="_blank" rel="noopener noreferrer">${linkText}</a>`
|
||||
}
|
||||
return renderer
|
||||
}
|
||||
|
||||
@@ -39,7 +45,8 @@ export function renderMarkdownToHtml(
|
||||
if (!markdown) return ''
|
||||
|
||||
let html = marked.parse(markdown, {
|
||||
renderer: createMarkdownRenderer(baseUrl)
|
||||
renderer: createMarkdownRenderer(baseUrl),
|
||||
gfm: true // Enable GitHub Flavored Markdown (including autolinks)
|
||||
}) as string
|
||||
|
||||
if (baseUrl) {
|
||||
@@ -48,6 +55,6 @@ export function renderMarkdownToHtml(
|
||||
|
||||
return DOMPurify.sanitize(html, {
|
||||
ADD_TAGS: ALLOWED_TAGS,
|
||||
ADD_ATTR: ALLOWED_ATTRS
|
||||
ADD_ATTR: [...ALLOWED_ATTRS, 'target', 'rel']
|
||||
})
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ vi.mock('vue-i18n', () => ({
|
||||
}))
|
||||
}))
|
||||
|
||||
vi.mock('marked', () => ({
|
||||
marked: vi.fn((content) => `<p>${content}</p>`)
|
||||
vi.mock('@/utils/markdownRendererUtil', () => ({
|
||||
renderMarkdownToHtml: vi.fn((content) => `<p>${content}</p>`)
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/updates/common/releaseStore', () => ({
|
||||
@@ -119,7 +119,7 @@ describe('WhatsNewPopup', () => {
|
||||
})
|
||||
|
||||
describe('content rendering', () => {
|
||||
it('should render release content using marked', async () => {
|
||||
it('should render release content using renderMarkdownToHtml', async () => {
|
||||
mockReleaseStore.shouldShowPopup = true
|
||||
mockReleaseStore.recentRelease = {
|
||||
id: 1,
|
||||
@@ -132,7 +132,7 @@ describe('WhatsNewPopup', () => {
|
||||
|
||||
const wrapper = createWrapper()
|
||||
|
||||
// Check that the content is rendered (marked is mocked to return processed content)
|
||||
// Check that the content is rendered (renderMarkdownToHtml is mocked to return processed content)
|
||||
expect(wrapper.find('.content-text').exists()).toBe(true)
|
||||
const contentHtml = wrapper.find('.content-text').html()
|
||||
expect(contentHtml).toContain('<p># Release Notes')
|
||||
|
||||
123
tests-ui/tests/utils/markdownRendererUtil.test.ts
Normal file
123
tests-ui/tests/utils/markdownRendererUtil.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
|
||||
|
||||
describe('markdownRendererUtil', () => {
|
||||
describe('renderMarkdownToHtml', () => {
|
||||
it('should render basic markdown to HTML', () => {
|
||||
const markdown = '# Hello\n\nThis is a test.'
|
||||
const html = renderMarkdownToHtml(markdown)
|
||||
|
||||
expect(html).toContain('<h1')
|
||||
expect(html).toContain('Hello')
|
||||
expect(html).toContain('<p>')
|
||||
expect(html).toContain('This is a test.')
|
||||
})
|
||||
|
||||
it('should render links with target="_blank" and rel="noopener noreferrer"', () => {
|
||||
const markdown = '[Click here](https://example.com)'
|
||||
const html = renderMarkdownToHtml(markdown)
|
||||
|
||||
expect(html).toContain('target="_blank"')
|
||||
expect(html).toContain('rel="noopener noreferrer"')
|
||||
expect(html).toContain('href="https://example.com"')
|
||||
expect(html).toContain('Click here')
|
||||
})
|
||||
|
||||
it('should render multiple links with target="_blank"', () => {
|
||||
const markdown =
|
||||
'[Link 1](https://example.com) and [Link 2](https://test.com)'
|
||||
const html = renderMarkdownToHtml(markdown)
|
||||
|
||||
const targetBlankMatches = html.match(/target="_blank"/g)
|
||||
expect(targetBlankMatches).toHaveLength(2)
|
||||
|
||||
const relMatches = html.match(/rel="noopener noreferrer"/g)
|
||||
expect(relMatches).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('should handle relative image paths with baseUrl', () => {
|
||||
const markdown = ''
|
||||
const baseUrl = 'https://cdn.example.com'
|
||||
const html = renderMarkdownToHtml(markdown, baseUrl)
|
||||
|
||||
expect(html).toContain(`src="${baseUrl}/image.png"`)
|
||||
expect(html).toContain('alt="Alt text"')
|
||||
})
|
||||
|
||||
it('should not modify absolute image URLs', () => {
|
||||
const markdown = ''
|
||||
const baseUrl = 'https://cdn.example.com'
|
||||
const html = renderMarkdownToHtml(markdown, baseUrl)
|
||||
|
||||
expect(html).toContain('src="https://example.com/image.png"')
|
||||
expect(html).not.toContain(baseUrl)
|
||||
})
|
||||
|
||||
it('should handle empty markdown', () => {
|
||||
const html = renderMarkdownToHtml('')
|
||||
|
||||
expect(html).toBe('')
|
||||
})
|
||||
|
||||
it('should sanitize potentially dangerous HTML', () => {
|
||||
const markdown = '<script>alert("xss")</script>'
|
||||
const html = renderMarkdownToHtml(markdown)
|
||||
|
||||
expect(html).not.toContain('<script>')
|
||||
expect(html).not.toContain('alert')
|
||||
})
|
||||
|
||||
it('should allow video tags with proper attributes', () => {
|
||||
const markdown =
|
||||
'<video src="video.mp4" controls autoplay loop muted></video>'
|
||||
const html = renderMarkdownToHtml(markdown)
|
||||
|
||||
expect(html).toContain('<video')
|
||||
expect(html).toContain('src="video.mp4"')
|
||||
expect(html).toContain('controls')
|
||||
})
|
||||
|
||||
it('should render links with title attribute', () => {
|
||||
const markdown = '[Link](https://example.com "This is a title")'
|
||||
const html = renderMarkdownToHtml(markdown)
|
||||
|
||||
expect(html).toContain('title="This is a title"')
|
||||
expect(html).toContain('target="_blank"')
|
||||
expect(html).toContain('rel="noopener noreferrer"')
|
||||
})
|
||||
|
||||
it('should handle bare URLs (autolinks)', () => {
|
||||
const markdown = 'Visit https://example.com for more info.'
|
||||
const html = renderMarkdownToHtml(markdown)
|
||||
|
||||
expect(html).toContain('href="https://example.com"')
|
||||
expect(html).toContain('target="_blank"')
|
||||
expect(html).toContain('rel="noopener noreferrer"')
|
||||
})
|
||||
|
||||
it('should render complex markdown with links, images, and text', () => {
|
||||
const markdown = `
|
||||
# Release Notes
|
||||
|
||||
Check out our [documentation](https://docs.example.com) for more info.
|
||||
|
||||

|
||||
|
||||
Visit our [homepage](https://example.com) to learn more.
|
||||
`
|
||||
const baseUrl = 'https://cdn.example.com'
|
||||
const html = renderMarkdownToHtml(markdown, baseUrl)
|
||||
|
||||
// Check links have target="_blank"
|
||||
const targetBlankMatches = html.match(/target="_blank"/g)
|
||||
expect(targetBlankMatches).toHaveLength(2)
|
||||
|
||||
// Check image has baseUrl prepended
|
||||
expect(html).toContain(`${baseUrl}/screenshot.png`)
|
||||
|
||||
// Check heading
|
||||
expect(html).toContain('Release Notes')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user