Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions webview-ui/src/components/commit/__tests__/CommitDetails.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1336,6 +1336,25 @@ 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 = '<if test="onlyHasVideo==true">';
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('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: [] }),
Expand Down
24 changes: 20 additions & 4 deletions webview-ui/src/components/common/MarkdownNode.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,22 @@
function safeHref(href: string): string | undefined {
return /^https?:\/\//i.test(href) ? href : undefined;
}

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) => {
switch (entity) {
case '&amp;': return '&';
case '&lt;': return '<';
case '&gt;': return '>';
case '&quot;': return '"';
case '&#39;': return "'";
default: return entity;
}
});
}
</script>

{#each tokens as tok}
Expand Down Expand Up @@ -89,24 +105,24 @@
<del><Self tokens={d.tokens} /></del>
{:else if tok.type === 'codespan'}
{@const cs = tok as Tokens.Codespan}
<code class="md-codespan">{cs.text}</code>
<code class="md-codespan">{decodeMarkedText(cs.text)}</code>
{:else if tok.type === 'br'}
<br />
{:else if tok.type === 'link'}
{@const l = tok as Tokens.Link}
<a class="commit-link" href={safeHref(l.href)} use:tooltip={t('graph.openLink')} onclick={(e) => open(e, l.href)}><Self tokens={l.tokens} /></a>
{:else if tok.type === 'image'}
{@const img = tok as Tokens.Image}
<a class="commit-link" href={safeHref(img.href)} use:tooltip={t('graph.openLink')} onclick={(e) => open(e, img.href)}>{img.text || img.href}</a>
<a class="commit-link" href={safeHref(img.href)} use:tooltip={t('graph.openLink')} onclick={(e) => open(e, img.href)}>{img.text ? decodeMarkedText(img.text) : img.href}</a>
{:else if tok.type === 'text'}
{@const txt = tok as Tokens.Text}
{#if txt.tokens && txt.tokens.length > 0}
<Self tokens={txt.tokens} />
{:else}
<LinkifiedText text={txt.text} />
<LinkifiedText text={decodeMarkedText(txt.text)} />
{/if}
{:else if tok.type === 'escape'}
{(tok as Tokens.Escape).text}
{decodeMarkedText((tok as Tokens.Escape).text)}
{:else}
<LinkifiedText text={(tok as { raw: string }).raw} />
{/if}
Expand Down
55 changes: 54 additions & 1 deletion webview-ui/src/components/common/__tests__/Markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,48 @@ describe('Markdown', () => {
expect(container.querySelector('pre code')?.textContent).toContain('code');
});

it('renders markup characters in inline code instead of visible HTML entities', () => {
const snippet = '<if test="onlyHasVideo==true">';
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: '`&lt;script&gt;alert(1)&lt;/script&gt;`' },
});

expect(container.querySelector('.md-codespan')?.textContent)
.toBe('&lt;script&gt;alert(1)&lt;/script&gt;');
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: '- &amp;lt;script&amp;gt;' },
});

expect(container.querySelector('.md-li')?.textContent?.trim()).toBe('&lt;script&gt;');
expect(container.querySelector('script')).toBeNull();
});

it('renders escaped markup characters as inert text', () => {
const { container } = render(Markdown, { props: { text: '- \\<tag\\>' } });

expect(container.querySelector('.md-li')?.textContent?.trim()).toBe('<tag>');
expect(container.querySelector('tag')).toBeNull();
});

it('renders nested unordered lists', () => {
const { container } = render(Markdown, { props: { text: '- a\n - b' } });
const outer = container.querySelector('ul');
Expand Down Expand Up @@ -60,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();
});

Expand Down
Loading