diff --git a/lib/html-to-markdown.js b/lib/html-to-markdown.js
index 11fec4d..ef0f8ff 100644
--- a/lib/html-to-markdown.js
+++ b/lib/html-to-markdown.js
@@ -3,6 +3,7 @@ const {
escapeSentinels,
finalizeSentinels,
fenceLength,
+ escapeFenceLikeText,
splitOnFences,
cleanupWithFences,
} = require('./markdown-cleanup');
@@ -196,8 +197,36 @@ function convertLists(src) {
return out + src.slice(copied);
}
+// Code regions and tags (attribute values may hold backticks) are left alone;
+// only the prose between them is escaped. Backtick entities in prose are
+// decoded first, since the entity pass runs after the fence-aware cleanup and
+// would otherwise reintroduce a literal fence line, and NBSP entities count as
+// indentation for the same reason. A tag must start with a letter, `/` or `!`
+// so prose like `a < b` is not mistaken for one. The code-region patterns are
+// only used when a closing tag exists, which keeps unclosed `
` / ``
+// from rescanning the rest of the input on every attempt.
+const BACKTICK_ENTITY_RE = /*96;|*60;/gi;
+const NBSP_ENTITY = ' |*160;|*a0;';
+function fenceProtectedRe(html) {
+ const parts = [];
+ if (/<\/pre/i.test(html)) parts.push('');
+ if (/<\/code/i.test(html)) parts.push(']*>[\\s\\S]*?<\\/code>');
+ parts.push('<[A-Za-z/!][^>]*>');
+ return new RegExp(parts.join('|'), 'gi');
+}
+function escapeLiteralFences(html) {
+ const escapeProse = (text) => escapeFenceLikeText(text.replace(BACKTICK_ENTITY_RE, '`'), NBSP_ENTITY);
+ let out = '';
+ let last = 0;
+ for (const m of html.matchAll(fenceProtectedRe(html))) {
+ out += escapeProse(html.slice(last, m.index)) + m[0];
+ last = m.index + m[0].length;
+ }
+ return out + escapeProse(html.slice(last));
+}
+
function htmlToMarkdown(html) {
- let markdown = escapeSentinels(html);
+ let markdown = escapeLiteralFences(escapeSentinels(html));
markdown = markdown.replace(/]*(?:\/>|>\s*<\/time>)/g, '$1');
diff --git a/lib/markdown-cleanup.js b/lib/markdown-cleanup.js
index ed2283b..5e06f7a 100644
--- a/lib/markdown-cleanup.js
+++ b/lib/markdown-cleanup.js
@@ -30,6 +30,33 @@ function fenceLength(decodedBody) {
return Math.max(3, max + 1);
}
+// A prose line that opens with a run of 3+ backticks looks like a code fence.
+// splitOnFences would pair it with the next real fence, so the real code body
+// would leave the fence and go through whitespace cleanup — silently changing
+// the code. Backslash-escaping every backtick of the run keeps the line
+// literal text, which is also what the author meant: converter-emitted fences
+// are the only lines that may open one. (Escaping only the first would leave
+// the rest free to pair with later backticks into a code span on write-back.)
+// Apply to prose only, never to code bodies or code spans. A run at the start
+// of the text or of a line is escaped; text that begins at an inline-element
+// boundary counts as a line start, where escaping is harmless. Mid-line runs
+// cannot open a fence and stay as written. Plain spaces, tabs and NBSP may
+// indent the run (`extraIndent` adds more alternatives, e.g. HTML entities).
+const FENCE_LIKE_RES = new Map();
+function fenceLikeRe(extraIndent) {
+ if (!FENCE_LIKE_RES.has(extraIndent)) {
+ const indent = extraIndent ? `[ \\t\\u00a0]|${extraIndent}` : '[ \\t\\u00a0]';
+ FENCE_LIKE_RES.set(extraIndent, new RegExp(`(^|\\n)((?:${indent})*)(\`{3,})`, 'gi'));
+ }
+ return FENCE_LIKE_RES.get(extraIndent);
+}
+function escapeFenceLikeText(text, extraIndent = '') {
+ return text.replace(
+ fenceLikeRe(extraIndent),
+ (_, lead, indent, ticks) => `${lead}${indent}${'\\`'.repeat(ticks.length)}`,
+ );
+}
+
// List-item continuation indent emitted by StorageWalker and
// html-to-markdown. A private-use codepoint (not matched by \s or [ \t]) so
// the whitespace cleanup chain below cannot strip or collapse it; each
@@ -267,6 +294,7 @@ module.exports = {
stripListIndent,
finalizeSentinels,
fenceLength,
+ escapeFenceLikeText,
splitOnFences,
isFenceOpenLine,
collapseBlankLines,
diff --git a/lib/storage-walker.js b/lib/storage-walker.js
index 50eecc9..3390bd2 100644
--- a/lib/storage-walker.js
+++ b/lib/storage-walker.js
@@ -8,6 +8,7 @@ const {
stripListIndent,
finalizeSentinels,
fenceLength,
+ escapeFenceLikeText,
isFenceOpenLine,
splitOnFences,
collapseBlankLines,
@@ -857,9 +858,10 @@ class StorageWalker {
renderText(text) {
const decodedText = decodeEntities(text);
- return this._markdownLinkLabelDepth > 0 && this._markdownCodeSpanDepth === 0
+ if (this._markdownCodeSpanDepth > 0) return decodedText;
+ return this._markdownLinkLabelDepth > 0
? this.escapeMarkdownText(decodedText)
- : decodedText;
+ : escapeFenceLikeText(decodedText);
}
renderCodeSpan(content) {
diff --git a/tests/html-to-markdown.test.js b/tests/html-to-markdown.test.js
index 0ada337..3a0ff49 100644
--- a/tests/html-to-markdown.test.js
+++ b/tests/html-to-markdown.test.js
@@ -178,10 +178,10 @@ describe('htmlToMarkdown', () => {
});
test('literal backticks leading an item keep a plain-space marker', () => {
- // Joining the marker with LIST_INDENT would make splitOnFences pair
- // this literal with the following real fence.
+ // The literal is escaped, and joining the marker with LIST_INDENT
+ // would make splitOnFences pair it with the following real fence.
const html = 'x y
c ';
- expect(htmlToMarkdown(html)).toBe('- ```\n\nx y\n\n```\n c\n```');
+ expect(htmlToMarkdown(html)).toBe('- \\`\\`\\`\n\nx y\n\n```\n c\n```');
});
test('pretty-printed HTML with whitespace between tags', () => {
@@ -476,7 +476,7 @@ describe('htmlToMarkdown', () => {
test('a line whose info string contains a backtick does not open a fence (#244)', () => {
const out = htmlToMarkdown('```js `x`
a b
c d ');
- expect(out).toBe('```js `x`\n\na b\n\n```\nc d\n```');
+ expect(out).toBe('\\`\\`\\`js `x`\n\na b\n\n```\nc d\n```');
});
test('a fence in a list item does not disturb a later top-level fence (#238, #243)', () => {
@@ -484,4 +484,58 @@ describe('htmlToMarkdown', () => {
expect(out).toContain('```\nq r\n```');
expect(out).toBe('- ```\n a b\n ```\n\nx\n\n```\nq r\n```');
});
+ describe('literal fence-like text (#254)', () => {
+ const PRE = ' indented\n code here ';
+ const FENCED = '```\n indented\n code here\n```';
+
+ test('a literal ``` after a nested list does not capture the next code block', () => {
+ const html = `${PRE}`;
+ expect(htmlToMarkdown(html)).toBe(`- a\n - b\n\n \\\`\\\`\\\`\n\n${FENCED}`);
+ });
+
+ test('a top-level ```
does not capture the next code block', () => {
+ expect(htmlToMarkdown(`\`\`\`
${PRE}`)).toBe(`\\\`\\\`\\\`\n\n${FENCED}`);
+ });
+
+ test('backticks written as entities are treated the same way', () => {
+ expect(htmlToMarkdown(````
${PRE}`)).toBe(`\\\`\\\`\\\`\n\n${FENCED}`);
+ expect(htmlToMarkdown(````
${PRE}`)).toBe(`\\\`\\\`\\\`\n\n${FENCED}`);
+ });
+
+ test('a literal fence line after a is escaped', () => {
+ expect(htmlToMarkdown(`x \`\`\`
${PRE}`)).toBe(`x\n\\\`\\\`\\\`\n\n${FENCED}`);
+ });
+
+ test.each([
+ [' ', 'x ```
', 'x\n'],
+ [' ', ' ```
', ''],
+ [' ', ' ```
', ''],
+ ])('a literal fence line indented by %s is escaped', (_label, prose, lead) => {
+ const out = htmlToMarkdown(`${prose}${PRE}`);
+ expect(out).toBe(`${lead}\\\`\\\`\\\`\n\n${FENCED}`);
+ });
+
+ test('a bare < in prose is not mistaken for a tag that hides a literal fence', () => {
+ expect(htmlToMarkdown(`a < b\n\`\`\`\nc
${PRE}`)).toBe(`a < b\n\\\`\\\`\\\`\nc\n\n${FENCED}`);
+ });
+
+ test('unclosed tags do not make the escape pass quadratic', () => {
+ const start = Date.now();
+ htmlToMarkdown('x'.repeat(40000));
+ expect(Date.now() - start).toBeLessThan(1000);
+ });
+
+ test('backticks inside a code block body are left untouched', () => {
+ const html = '```\n inner\n``` ';
+ expect(htmlToMarkdown(html)).toBe('````\n```\n inner\n```\n````');
+ });
+
+ test('backticks in prose that cannot open a fence are left as written', () => {
+ expect(htmlToMarkdown('use ```js fences
')).toBe('use ```js fences');
+ });
+
+ test('backticks inside an attribute value are not touched', () => {
+ expect(htmlToMarkdown('x
')).toBe('[x](https://example.com/a```b)');
+ });
+ });
});
diff --git a/tests/macro-converter.test.js b/tests/macro-converter.test.js
index 1a5aafe..459e780 100644
--- a/tests/macro-converter.test.js
+++ b/tests/macro-converter.test.js
@@ -2184,21 +2184,23 @@ describe('MacroConverter storageToMarkdown code inside quotes and callouts (#244
test('a bare ``` paragraph in a callout does not swallow the code blocks after it', () => {
const storage = macro('info', '```
') + codeMacro('', 'one 1') + 'x y
' + codeMacro('', 'two 2');
expect(converter.storageToMarkdown(storage))
- .toBe('> **INFO**\n> ```\n\n```\none 1\n```\n\nx y\n\n```\ntwo 2\n```');
+ .toBe('> **INFO**\n> \\`\\`\\`\n\n```\none 1\n```\n\nx y\n\n```\ntwo 2\n```');
});
test('a bare ``` paragraph in a callout does not pair with a fence in a later callout', () => {
const storage = macro('info', '```
') + 'mid text
' + macro('note', codeMacro('js', 'keep 1'));
expect(converter.storageToMarkdown(storage))
- .toBe('> **INFO**\n> ```\n\nmid text\n\n> **NOTE**\n> ```js\n> keep 1\n> ```');
+ .toBe('> **INFO**\n> \\`\\`\\`\n\nmid text\n\n> **NOTE**\n> ```js\n> keep 1\n> ```');
});
test('indented loose text between callouts is cleaned, not turned into code on write-back', () => {
const storage = macro('info', '```
') + 'loose\n indented text\n' + macro('note', codeMacro('js', 'keep 1'));
const md = converter.storageToMarkdown(storage);
- expect(md).toBe('> **INFO**\n> ```\nloose\nindented text\n\n> **NOTE**\n> ```js\n> keep 1\n> ```');
+ expect(md).toBe('> **INFO**\n> \\`\\`\\`\nloose\nindented text\n\n> **NOTE**\n> ```js\n> keep 1\n> ```');
const storage1 = converter.markdownToStorage(md);
- expect(storage1).toContain('loose\nindented text
');
+ // The escaped literal stays prose; the loose text continues that paragraph
+ // (no blank line separates them) and is not turned into code.
+ expect(storage1).toContain('```\nloose\nindented text
');
expect(storage1).toContain('');
});
@@ -2524,7 +2526,7 @@ describe('MacroConverter storageToMarkdown in flattened contexts (#242)',
['-', '\\-'],
['===', '\\==='],
['* * *', '\\* * *'],
- ['```js', '\\```js'],
+ ['```js', '\\`\\`\\`js'],
['~~~', '\\~~~'],
['--- | ---', '\\--- | ---'],
['|:---|---:|', '\\|:---|---:|'],
@@ -2633,3 +2635,70 @@ describe('MacroConverter storageToMarkdown in flattened contexts (#242)',
});
});
});
+
+describe('MacroConverter storageToMarkdown literal fence-like text (#254)', () => {
+ const converter = new MacroConverter({ isCloud: true });
+ const codeMacro = (lang, body) =>
+ `${lang} `;
+ const macro = (name, body) =>
+ `${body} `;
+ const code = codeMacro('', ' indented\n code here');
+ const FENCED = '```\n indented\n code here\n```';
+
+ test('a ```
does not capture the next code macro', () => {
+ expect(converter.storageToMarkdown(`\`\`\`
${code}`)).toBe(`\\\`\\\`\\\`\n\n${FENCED}`);
+ });
+
+ test('a literal ``` after a nested list does not capture the next code macro', () => {
+ const storage = ` ${code}`;
+ expect(converter.storageToMarkdown(storage)).toBe(`- a\n - b\n\n \\\`\\\`\\\`\n\n${FENCED}`);
+ });
+
+ test('a literal fence line after a is escaped', () => {
+ expect(converter.storageToMarkdown(`x \`\`\`
${code}`)).toBe(`x\n\\\`\\\`\\\`\n\n${FENCED}`);
+ });
+
+ test('backticks written as entities or CDATA are treated the same way', () => {
+ expect(converter.storageToMarkdown(````
${code}`)).toBe(`\\\`\\\`\\\`\n\n${FENCED}`);
+ expect(converter.storageToMarkdown(`
${code}`)).toBe(`\\\`\\\`\\\`\n\n${FENCED}`);
+ });
+
+ test('a literal NBSP before the fence-like run does not hide it', () => {
+ expect(converter.storageToMarkdown(`\u00a0\`\`\`
${code}`)).toBe(`\\\`\\\`\\\`\n\n${FENCED}`);
+ expect(converter.storageToMarkdown(` \`\`\`
${code}`)).toBe(`\\\`\\\`\\\`\n\n${FENCED}`);
+ });
+
+ test.each([
+ ['four backticks then a matching run', '```` b ``` c
', '````\nb ``` c
'],
+ ['a run followed by a shorter one', '``` use `` here
', '```\nuse `` here
'],
+ ['a run and a code-span-like pair', '``` ``
', '``` ``
'],
+ ])('every backtick of the run is escaped, so %s stays literal on write-back', (_label, storage, expected) => {
+ const md = converter.storageToMarkdown(storage);
+ expect(converter.markdownToStorage(md)).toBe(`${expected}\n`);
+ });
+
+ test('a literal ``` inside a callout stays escaped and the code after it is untouched', () => {
+ expect(converter.storageToMarkdown(macro('info', '```
') + code))
+ .toBe(`> **INFO**\n> \\\`\\\`\\\`\n\n${FENCED}`);
+ });
+
+ test('the code macro body is never escaped', () => {
+ expect(converter.storageToMarkdown(codeMacro('', '```\n inner\n```')))
+ .toBe('````\n```\n inner\n```\n````');
+ });
+
+ test('code spans and mid-line backticks are left as written', () => {
+ expect(converter.storageToMarkdown('```
')).toBe('```` ``` ````');
+ expect(converter.storageToMarkdown('use ```js fences
')).toBe('use ```js fences');
+ });
+
+ test('round-trip keeps the literal text as prose and the code body byte-exact', () => {
+ const md = converter.storageToMarkdown(`\`\`\`
${code}`);
+ const storage1 = converter.markdownToStorage(md);
+ expect(storage1).toContain('```
');
+ expect(storage1).toContain('');
+ // Writing back labels a language-less fence `text`; that is unrelated here.
+ expect(converter.storageToMarkdown(storage1))
+ .toBe('\\`\\`\\`\n\n```text\n indented\n code here\n```');
+ });
+});
diff --git a/tests/markdown-cleanup.test.js b/tests/markdown-cleanup.test.js
index b30aa12..5b6556f 100644
--- a/tests/markdown-cleanup.test.js
+++ b/tests/markdown-cleanup.test.js
@@ -5,6 +5,7 @@ const {
escapeSentinels,
finalizeSentinels,
fenceLength,
+ escapeFenceLikeText,
splitOnFences,
isFenceOpenLine,
cleanupOutsideFence,
@@ -297,3 +298,39 @@ describe('markdown-cleanup QUOTE_MARK sentinel (#244)', () => {
expect(finalizeSentinels(`${I}${HARD_BREAK}${escapeSentinels(literal)}`)).toBe(` \n${literal}`);
});
});
+
+describe('escapeFenceLikeText', () => {
+ test.each([
+ ['```', '\\`\\`\\`'],
+ ['````', '\\`\\`\\`\\`'],
+ ['```js', '\\`\\`\\`js'],
+ [' ```', ' \\`\\`\\`'],
+ ['a\n```\nb', 'a\n\\`\\`\\`\nb'],
+ ['```\n```', '\\`\\`\\`\n\\`\\`\\`'],
+ ])('escapes a line-leading run of backticks: %j', (input, expected) => {
+ expect(escapeFenceLikeText(input)).toBe(expected);
+ });
+
+ test.each([
+ 'use ```js fences',
+ 'two `` backticks',
+ 'one ` backtick',
+ 'no backticks',
+ '',
+ ])('leaves text without a line-leading 3+ run alone: %j', (input) => {
+ expect(escapeFenceLikeText(input)).toBe(input);
+ });
+
+ test('NBSP counts as indentation, and extra indent alternatives can be supplied', () => {
+ expect(escapeFenceLikeText('\u00a0```')).toBe('\u00a0\\`\\`\\`');
+ expect(escapeFenceLikeText(' ```')).toBe(' ```');
+ expect(escapeFenceLikeText(' ```', ' ')).toBe(' \\`\\`\\`');
+ });
+
+ test('an escaped line can no longer open a fence', () => {
+ const text = `${escapeFenceLikeText('```')}\n\n\`\`\`\n code\n\`\`\``;
+ const segments = splitOnFences(text);
+ expect(segments).toHaveLength(3);
+ expect(segments[1]).toBe('```\n code\n```');
+ });
+});