From ac851f12c8043c03e267aa04f298d1af2a351942 Mon Sep 17 00:00:00 2001 From: pchuri Date: Sun, 4 Oct 2026 21:01:54 +0900 Subject: [PATCH 1/2] fix(converter): escape literal fence-like prose so it cannot capture code blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A prose line starting with 3+ backticks was indistinguishable from a fence the converter emitted. splitOnFences paired it with the next real fence, so the real code body fell outside the fence and went through whitespace cleanup, silently changing the code. This affected both html → markdown and storage → markdown (the read path). Escape the first backtick of a line-leading run in prose (``` becomes \```) in both converters, through one shared helper. Code bodies, code spans, attribute values and mid-line runs are left alone, and backtick entities in html prose are decoded first so they cannot reintroduce a literal fence. The escaped text also round-trips as prose instead of turning into a code block on write-back. Fixes #254 --- lib/html-to-markdown.js | 20 ++++++++++- lib/markdown-cleanup.js | 15 ++++++++ lib/storage-walker.js | 6 ++-- tests/html-to-markdown.test.js | 43 ++++++++++++++++++++--- tests/macro-converter.test.js | 63 +++++++++++++++++++++++++++++++--- tests/markdown-cleanup.test.js | 31 +++++++++++++++++ 6 files changed, 167 insertions(+), 11 deletions(-) diff --git a/lib/html-to-markdown.js b/lib/html-to-markdown.js index 11fec4d..4293e35 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,25 @@ 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. +const FENCE_PROTECTED_RE = /|]*>[\s\S]*?<\/code>|<[^>]*>/gi; +const BACKTICK_ENTITY_RE = /�*96;|�*60;/gi; +function escapeLiteralFences(html) { + const escapeProse = (text) => escapeFenceLikeText(text.replace(BACKTICK_ENTITY_RE, '`')); + let out = ''; + let last = 0; + for (const m of html.matchAll(FENCE_PROTECTED_RE)) { + 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..347587f 100644 --- a/lib/markdown-cleanup.js +++ b/lib/markdown-cleanup.js @@ -30,6 +30,20 @@ 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 the first backtick (`\```) keeps the line +// literal text, which is also what the author meant, and matches the escape +// the walker already applies to
continuation lines. Converter-emitted +// fences are the only lines that may open one. Apply to prose only, never to +// code bodies or code spans. Only a run at the start of the text or of a line +// is escaped; mid-line runs cannot open a fence and stay as written. +const FENCE_LIKE_RE = /(^|\n)([ \t]*)(`{3,})/g; +function escapeFenceLikeText(text) { + return text.replace(FENCE_LIKE_RE, (_, lead, indent, ticks) => `${lead}${indent}\\${ticks}`); +} + // 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 +281,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..db9dd2c 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,39 @@ 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 = `
  • a
    • b
    \`\`\`
${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('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..b9b21cb 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(''); }); @@ -2633,3 +2635,56 @@ 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 = `
  • a
    • b
    \`\`\`
${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 ``` 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..d9e5d0c 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,33 @@ 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('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```'); + }); +}); From bf12dd35b698512b2afb2f69d569d07e13a0f09e Mon Sep 17 00:00:00 2001 From: pchuri Date: Sun, 4 Oct 2026 21:15:31 +0900 Subject: [PATCH 2/2] fix(converter): harden literal fence escaping after review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Escape every backtick of the leading run, not just the first, so the rest cannot pair with later backticks into a code span on write-back. - Treat NBSP (literal,  ,  ,  ) as indentation before the run, in both converters. - Only treat `<` followed by a letter, `/` or `!` as a tag in html → markdown, so prose like `a < b` no longer hides a fence line. - Skip the
/ region patterns when no closing tag exists, which
  removes a quadratic rescan on unclosed 
.
- Document that text starting at an inline-element boundary counts as a
  line start.
---
 lib/html-to-markdown.js        | 19 ++++++++++++++----
 lib/markdown-cleanup.js        | 31 ++++++++++++++++++++---------
 tests/html-to-markdown.test.js | 33 ++++++++++++++++++++++++-------
 tests/macro-converter.test.js  | 36 +++++++++++++++++++++++-----------
 tests/markdown-cleanup.test.js | 18 +++++++++++------
 5 files changed, 100 insertions(+), 37 deletions(-)

diff --git a/lib/html-to-markdown.js b/lib/html-to-markdown.js
index 4293e35..ef0f8ff 100644
--- a/lib/html-to-markdown.js
+++ b/lib/html-to-markdown.js
@@ -200,14 +200,25 @@ function convertLists(src) {
 // 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.
-const FENCE_PROTECTED_RE = /|]*>[\s\S]*?<\/code>|<[^>]*>/gi;
+// 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, '`'));
+  const escapeProse = (text) => escapeFenceLikeText(text.replace(BACKTICK_ENTITY_RE, '`'), NBSP_ENTITY);
   let out = '';
   let last = 0;
-  for (const m of html.matchAll(FENCE_PROTECTED_RE)) {
+  for (const m of html.matchAll(fenceProtectedRe(html))) {
     out += escapeProse(html.slice(last, m.index)) + m[0];
     last = m.index + m[0].length;
   }
diff --git a/lib/markdown-cleanup.js b/lib/markdown-cleanup.js
index 347587f..5e06f7a 100644
--- a/lib/markdown-cleanup.js
+++ b/lib/markdown-cleanup.js
@@ -33,15 +33,28 @@ function fenceLength(decodedBody) {
 // 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 the first backtick (`\```) keeps the line
-// literal text, which is also what the author meant, and matches the escape
-// the walker already applies to 
continuation lines. Converter-emitted -// fences are the only lines that may open one. Apply to prose only, never to -// code bodies or code spans. Only a run at the start of the text or of a line -// is escaped; mid-line runs cannot open a fence and stay as written. -const FENCE_LIKE_RE = /(^|\n)([ \t]*)(`{3,})/g; -function escapeFenceLikeText(text) { - return text.replace(FENCE_LIKE_RE, (_, lead, indent, ticks) => `${lead}${indent}\\${ticks}`); +// 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 diff --git a/tests/html-to-markdown.test.js b/tests/html-to-markdown.test.js index db9dd2c..3a0ff49 100644 --- a/tests/html-to-markdown.test.js +++ b/tests/html-to-markdown.test.js @@ -181,7 +181,7 @@ describe('htmlToMarkdown', () => { // 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)', () => { @@ -490,20 +490,39 @@ describe('htmlToMarkdown', () => { test('a literal ``` after a nested list does not capture the next code block', () => { const html = `
  • a
    • b
    \`\`\`
${PRE}`; - expect(htmlToMarkdown(html)).toBe(`- a\n - b\n\n \\\`\`\`\n\n${FENCED}`); + 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}`); + 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}`); + 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}`); + 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', () => {
diff --git a/tests/macro-converter.test.js b/tests/macro-converter.test.js
index b9b21cb..459e780 100644
--- a/tests/macro-converter.test.js
+++ b/tests/macro-converter.test.js
@@ -2184,19 +2184,19 @@ 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); // The escaped literal stays prose; the loose text continues that paragraph // (no blank line separates them) and is not turned into code. @@ -2526,7 +2526,7 @@ describe('MacroConverter storageToMarkdown
in flattened contexts (#242)', ['-', '\\-'], ['===', '\\==='], ['* * *', '\\* * *'], - ['```js', '\\```js'], + ['```js', '\\`\\`\\`js'], ['~~~', '\\~~~'], ['--- | ---', '\\--- | ---'], ['|:---|---:|', '\\|:---|---:|'], @@ -2646,26 +2646,40 @@ describe('MacroConverter storageToMarkdown literal fence-like text (#254)', () = 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}`); + expect(converter.storageToMarkdown(`

\`\`\`

${code}`)).toBe(`\\\`\\\`\\\`\n\n${FENCED}`); }); test('a literal ``` after a nested list does not capture the next code macro', () => { const storage = `
  • a
    • b
    \`\`\`
${code}`; - expect(converter.storageToMarkdown(storage)).toBe(`- a\n - b\n\n \\\`\`\`\n\n${FENCED}`); + 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}`); + 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}`); + 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}`); + .toBe(`> **INFO**\n> \\\`\\\`\\\`\n\n${FENCED}`); }); test('the code macro body is never escaped', () => { @@ -2685,6 +2699,6 @@ describe('MacroConverter storageToMarkdown literal fence-like text (#254)', () = 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```'); + .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 d9e5d0c..5b6556f 100644 --- a/tests/markdown-cleanup.test.js +++ b/tests/markdown-cleanup.test.js @@ -301,12 +301,12 @@ describe('markdown-cleanup QUOTE_MARK sentinel (#244)', () => { describe('escapeFenceLikeText', () => { test.each([ - ['```', '\\```'], - ['````', '\\````'], - ['```js', '\\```js'], - [' ```', ' \\```'], - ['a\n```\nb', 'a\n\\```\nb'], - ['```\n```', '\\```\n\\```'], + ['```', '\\`\\`\\`'], + ['````', '\\`\\`\\`\\`'], + ['```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); }); @@ -321,6 +321,12 @@ describe('escapeFenceLikeText', () => { 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);