From 3c5d0bcb4f8fd0729627e5f723ac3a36a85bfda3 Mon Sep 17 00:00:00 2001 From: benshi <807629978@qq.com> Date: Tue, 1 Sep 2026 08:28:34 +0000 Subject: [PATCH 1/2] fix(markdown): render inline code entities correctly --- .../commit/__tests__/CommitDetails.test.ts | 10 ++++++++++ .../src/components/common/MarkdownNode.svelte | 18 +++++++++++++++++- .../common/__tests__/Markdown.test.ts | 18 ++++++++++++++++++ 3 files changed, 45 insertions(+), 1 deletion(-) diff --git a/webview-ui/src/components/commit/__tests__/CommitDetails.test.ts b/webview-ui/src/components/commit/__tests__/CommitDetails.test.ts index 9b04ec1..84a2c38 100644 --- a/webview-ui/src/components/commit/__tests__/CommitDetails.test.ts +++ b/webview-ui/src/components/commit/__tests__/CommitDetails.test.ts @@ -1336,6 +1336,16 @@ describe('CommitDetails — markdown toggle', () => { expect(container.querySelector('.message-section strong')?.textContent).toBe('bold'); }); + it('renders inline code with markup characters without exposing HTML entities', () => { + const snippet = ''; + const { container } = render(CommitDetails, { + commit: commit({ body: `- \`${snippet}\``, parents: [] }), + }); + + expect(container.querySelector('.message-section .md-codespan')?.textContent).toBe(snippet); + expect(container.querySelector('.message-section if')).toBeNull(); + }); + it('switches to plain text when the Plain toggle is clicked', async () => { const { container, getByText } = render(CommitDetails, { commit: commit({ subject: '**bold** subject', body: '- one\n- two', parents: [] }), diff --git a/webview-ui/src/components/common/MarkdownNode.svelte b/webview-ui/src/components/common/MarkdownNode.svelte index b24f3e9..e55e9a6 100644 --- a/webview-ui/src/components/common/MarkdownNode.svelte +++ b/webview-ui/src/components/common/MarkdownNode.svelte @@ -24,6 +24,22 @@ function safeHref(href: string): string | undefined { return /^https?:\/\//i.test(href) ? href : undefined; } + + function decodeMarkedCodeSpan(text: string): string { + // Marked escapes code spans for insertion into an HTML string. This + // renderer uses Svelte text nodes instead, so reverse exactly that one + // escaping pass; Svelte still keeps the resulting text inert in the DOM. + return text.replace(/&(?:amp|lt|gt|quot|#39);/g, (entity) => { + switch (entity) { + case '&': return '&'; + case '<': return '<'; + case '>': return '>'; + case '"': return '"'; + case ''': return "'"; + default: return entity; + } + }); + } {#each tokens as tok} @@ -89,7 +105,7 @@ {:else if tok.type === 'codespan'} {@const cs = tok as Tokens.Codespan} - {cs.text} + {decodeMarkedCodeSpan(cs.text)} {:else if tok.type === 'br'}
{:else if tok.type === 'link'} diff --git a/webview-ui/src/components/common/__tests__/Markdown.test.ts b/webview-ui/src/components/common/__tests__/Markdown.test.ts index 5b5468f..19228b4 100644 --- a/webview-ui/src/components/common/__tests__/Markdown.test.ts +++ b/webview-ui/src/components/common/__tests__/Markdown.test.ts @@ -30,6 +30,24 @@ describe('Markdown', () => { expect(container.querySelector('pre code')?.textContent).toContain('code'); }); + it('renders markup characters in inline code instead of visible HTML entities', () => { + const snippet = ''; + const { container } = render(Markdown, { props: { text: `- \`${snippet}\`` } }); + + expect(container.querySelector('.md-codespan')?.textContent).toBe(snippet); + expect(container.querySelector('if')).toBeNull(); + }); + + it('decodes marked inline-code entities exactly once', () => { + const { container } = render(Markdown, { + props: { text: '`<script>alert(1)</script>`' }, + }); + + expect(container.querySelector('.md-codespan')?.textContent) + .toBe('<script>alert(1)</script>'); + expect(container.querySelector('script')).toBeNull(); + }); + it('renders nested unordered lists', () => { const { container } = render(Markdown, { props: { text: '- a\n - b' } }); const outer = container.querySelector('ul'); From 5b359c3161bae015267e9a92ae5bc011283b85fd Mon Sep 17 00:00:00 2001 From: benshi <807629978@qq.com> Date: Wed, 2 Sep 2026 06:24:53 +0000 Subject: [PATCH 2/2] fix(markdown): decode escaped display text --- .../commit/__tests__/CommitDetails.test.ts | 9 +++++ .../src/components/common/MarkdownNode.svelte | 12 +++--- .../common/__tests__/Markdown.test.ts | 37 ++++++++++++++++++- 3 files changed, 51 insertions(+), 7 deletions(-) diff --git a/webview-ui/src/components/commit/__tests__/CommitDetails.test.ts b/webview-ui/src/components/commit/__tests__/CommitDetails.test.ts index 84a2c38..833d97a 100644 --- a/webview-ui/src/components/commit/__tests__/CommitDetails.test.ts +++ b/webview-ui/src/components/commit/__tests__/CommitDetails.test.ts @@ -1346,6 +1346,15 @@ describe('CommitDetails — markdown toggle', () => { expect(container.querySelector('.message-section if')).toBeNull(); }); + it('renders quotes in plain Markdown text without exposing HTML entities', () => { + const { container } = render(CommitDetails, { + commit: commit({ body: '- getEnumByCode("250")', parents: [] }), + }); + + expect(container.querySelector('.message-section .md-li')?.textContent?.trim()) + .toBe('getEnumByCode("250")'); + }); + it('switches to plain text when the Plain toggle is clicked', async () => { const { container, getByText } = render(CommitDetails, { commit: commit({ subject: '**bold** subject', body: '- one\n- two', parents: [] }), diff --git a/webview-ui/src/components/common/MarkdownNode.svelte b/webview-ui/src/components/common/MarkdownNode.svelte index e55e9a6..fcb8825 100644 --- a/webview-ui/src/components/common/MarkdownNode.svelte +++ b/webview-ui/src/components/common/MarkdownNode.svelte @@ -25,8 +25,8 @@ return /^https?:\/\//i.test(href) ? href : undefined; } - function decodeMarkedCodeSpan(text: string): string { - // Marked escapes code spans for insertion into an HTML string. This + function decodeMarkedText(text: string): string { + // Marked escapes display text for insertion into an HTML string. This // renderer uses Svelte text nodes instead, so reverse exactly that one // escaping pass; Svelte still keeps the resulting text inert in the DOM. return text.replace(/&(?:amp|lt|gt|quot|#39);/g, (entity) => { @@ -105,7 +105,7 @@ {:else if tok.type === 'codespan'} {@const cs = tok as Tokens.Codespan} - {decodeMarkedCodeSpan(cs.text)} + {decodeMarkedText(cs.text)} {:else if tok.type === 'br'}
{:else if tok.type === 'link'} @@ -113,16 +113,16 @@ open(e, l.href)}> {:else if tok.type === 'image'} {@const img = tok as Tokens.Image} - open(e, img.href)}>{img.text || img.href} + open(e, img.href)}>{img.text ? decodeMarkedText(img.text) : img.href} {:else if tok.type === 'text'} {@const txt = tok as Tokens.Text} {#if txt.tokens && txt.tokens.length > 0} {:else} - + {/if} {:else if tok.type === 'escape'} - {(tok as Tokens.Escape).text} + {decodeMarkedText((tok as Tokens.Escape).text)} {:else} {/if} diff --git a/webview-ui/src/components/common/__tests__/Markdown.test.ts b/webview-ui/src/components/common/__tests__/Markdown.test.ts index 19228b4..9e5523c 100644 --- a/webview-ui/src/components/common/__tests__/Markdown.test.ts +++ b/webview-ui/src/components/common/__tests__/Markdown.test.ts @@ -48,6 +48,30 @@ describe('Markdown', () => { expect(container.querySelector('script')).toBeNull(); }); + it('renders quotes in Markdown text instead of visible HTML entities', () => { + const { container } = render(Markdown, { + props: { text: '- getEnumByCode("250")' }, + }); + + expect(container.querySelector('.md-li')?.textContent?.trim()).toBe('getEnumByCode("250")'); + }); + + it('decodes marked text entities exactly once', () => { + const { container } = render(Markdown, { + props: { text: '- &lt;script&gt;' }, + }); + + expect(container.querySelector('.md-li')?.textContent?.trim()).toBe('<script>'); + expect(container.querySelector('script')).toBeNull(); + }); + + it('renders escaped markup characters as inert text', () => { + const { container } = render(Markdown, { props: { text: '- \\' } }); + + expect(container.querySelector('.md-li')?.textContent?.trim()).toBe(''); + expect(container.querySelector('tag')).toBeNull(); + }); + it('renders nested unordered lists', () => { const { container } = render(Markdown, { props: { text: '- a\n - b' } }); const outer = container.querySelector('ul'); @@ -78,10 +102,21 @@ describe('Markdown', () => { }); }); + it('renders quotes in markdown link labels without changing the href', () => { + const { container } = render(Markdown, { + props: { text: '[getEnumByCode("250")](https://example.com)' }, + }); + const link = container.querySelector('a') as HTMLAnchorElement; + + expect(link.textContent).toBe('getEnumByCode("250")'); + expect(link.getAttribute('href')).toBe('https://example.com'); + }); + it('renders an image as a link (CSP blocks external images)', () => { - const { container } = render(Markdown, { props: { text: '![alt](https://example.com/x.png)' } }); + const { container } = render(Markdown, { props: { text: '![alt "x"](https://example.com/x.png)' } }); const link = container.querySelector('a') as HTMLAnchorElement; expect(link.getAttribute('href')).toBe('https://example.com/x.png'); + expect(link.textContent).toBe('alt "x"'); expect(container.querySelector('img')).toBeNull(); });