diff --git a/src/Renderer.ts b/src/Renderer.ts index 890ac2c4ec..6410b5da12 100644 --- a/src/Renderer.ts +++ b/src/Renderer.ts @@ -163,7 +163,7 @@ export class _Renderer { if (cleanHref === null) { return text as RendererOutput; } - href = cleanHref; + href = cleanHref.replace(/&/g, '&'); let out = ' { if (cleanHref === null) { return escapeHtmlEntities(text) as RendererOutput; } - href = cleanHref; + href = cleanHref.replace(/&/g, '&'); let out = `${escapeHtmlEntities(text)} { href = href.slice(1, -1); } } + // Decode HTML entities in inline link destinations per CommonMark spec. + // Entity references (e.g. &) are recognized in link destinations + // but not in autolinks. The renderer will re-escape on output. + const decodedHref = href ? decodeHtmlEntities(href.replace(this.rules.inline.anyPunctuation, '$1')) : href; return outputLink(cap, { - href: href ? href.replace(this.rules.inline.anyPunctuation, '$1') : href, + href: decodedHref, title: title ? title.replace(this.rules.inline.anyPunctuation, '$1') : title, }, cap[0], this.lexer, this.rules); } diff --git a/src/helpers.ts b/src/helpers.ts index ae8c380b26..e6eeb6c416 100644 --- a/src/helpers.ts +++ b/src/helpers.ts @@ -26,6 +26,28 @@ export function escapeHtmlEntities(html: string, encode?: boolean) { return html; } +/** + * Decode HTML character entities (e.g. & -> &, < -> <). + * Used for inline link destinations where CommonMark requires + * entity decoding before output escaping. + */ +export function decodeHtmlEntities(html: string): string { + return html + .replace(/&(?:#x([0-9a-fA-F]+)|#(\d+)|(\w+));/g, (_, hex, dec, named) => { + if (hex) return String.fromCodePoint(parseInt(hex, 16)); + if (dec) return String.fromCodePoint(parseInt(dec, 10)); + switch (named) { + case 'amp': return '&'; + case 'lt': return '<'; + case 'gt': return '>'; + case 'quot': return '"'; + case 'apos': return "'"; + case '#39': return "'"; + default: return _; + } + }); +} + export function cleanUrl(href: string) { try { href = encodeURI(href).replace(other.percentDecode, '%'); diff --git a/test/unit/marked.test.js b/test/unit/marked.test.js index 5d1449b4aa..ea43c9299d 100644 --- a/test/unit/marked.test.js +++ b/test/unit/marked.test.js @@ -1122,4 +1122,34 @@ br assert.strictEqual(html.trim(), '

text

'); }); }); + + describe('HTML entity escaping in link hrefs', () => { + it('should escape & in autolink hrefs (CommonMark example 595)', () => { + const result = marked.parse(''); + assert.strictEqual( + result.trim(), + '

https://foo.bar.baz/test?q=hello&id=22&boolean

', + ); + }); + + it('should decode & in inline link hrefs and re-escape on output', () => { + const result = marked.parse('[t](https://example.com/?a=1&b=2)'); + assert.ok(result.includes('href="https://example.com/?a=1&b=2"')); + }); + + it('should escape & in plain autolink URLs', () => { + const result = marked.parse(''); + assert.ok(result.includes('href="https://example.com/?a=1&b=2"')); + }); + + it('should escape < in autolink hrefs', () => { + const result = marked.parse(''); + assert.ok(result.includes('href="https://example.com/?x=1&lt;2"')); + }); + + it('should escape & in image src attributes', () => { + const result = marked.parse('![img](https://example.com/?a=1&b=2)'); + assert.ok(result.includes('src="https://example.com/?a=1&b=2"')); + }); + }); });