Skip to content
Closed
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
4 changes: 2 additions & 2 deletions src/Renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ export class _Renderer<ParserOutput = string, RendererOutput = string> {
if (cleanHref === null) {
return text as RendererOutput;
}
href = cleanHref;
href = cleanHref.replace(/&/g, '&amp;');
let out = '<a href="' + href + '"';
if (title) {
out += ' title="' + (escapeHtmlEntities(title)) + '"';
Expand All @@ -180,7 +180,7 @@ export class _Renderer<ParserOutput = string, RendererOutput = string> {
if (cleanHref === null) {
return escapeHtmlEntities(text) as RendererOutput;
}
href = cleanHref;
href = cleanHref.replace(/&/g, '&amp;');

let out = `<img src="${href}" alt="${escapeHtmlEntities(text)}"`;
if (title) {
Expand Down
7 changes: 6 additions & 1 deletion src/Tokenizer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
findClosingBracket,
expandTabs,
trimTrailingBlankLines,
decodeHtmlEntities,
} from './helpers.ts';
import type { Rules } from './rules.ts';
import type { _Lexer } from './Lexer.ts';
Expand Down Expand Up @@ -736,8 +737,12 @@ export class _Tokenizer<ParserOutput = string, RendererOutput = string> {
href = href.slice(1, -1);
}
}
// Decode HTML entities in inline link destinations per CommonMark spec.
// Entity references (e.g. &amp;) 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);
}
Expand Down
22 changes: 22 additions & 0 deletions src/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,28 @@ export function escapeHtmlEntities(html: string, encode?: boolean) {
return html;
}

/**
* Decode HTML character entities (e.g. &amp; -> &, &lt; -> <).
* 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, '%');
Expand Down
30 changes: 30 additions & 0 deletions test/unit/marked.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1122,4 +1122,34 @@ br
assert.strictEqual(html.trim(), '<p><em>text</em></p>');
});
});

describe('HTML entity escaping in link hrefs', () => {
it('should escape & in autolink hrefs (CommonMark example 595)', () => {
const result = marked.parse('<https://foo.bar.baz/test?q=hello&id=22&boolean>');
assert.strictEqual(
result.trim(),
'<p><a href="https://foo.bar.baz/test?q=hello&amp;id=22&amp;boolean">https://foo.bar.baz/test?q=hello&amp;id=22&amp;boolean</a></p>',
);
});

it('should decode &amp; in inline link hrefs and re-escape on output', () => {
const result = marked.parse('[t](https://example.com/?a=1&amp;b=2)');
assert.ok(result.includes('href="https://example.com/?a=1&amp;b=2"'));
});

it('should escape & in plain autolink URLs', () => {
const result = marked.parse('<https://example.com/?a=1&b=2>');
assert.ok(result.includes('href="https://example.com/?a=1&amp;b=2"'));
});

it('should escape &lt; in autolink hrefs', () => {
const result = marked.parse('<https://example.com/?x=1&lt;2>');
assert.ok(result.includes('href="https://example.com/?x=1&amp;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&amp;b=2"'));
});
});
});