fix(rendered): widen fencedCodeLanguage filter to all <pre>
Some checks failed
Build and push Joplock image / build-and-push (push) Has been cancelled

A bare <pre> (no <code> child, no language-* class) from pasted external
HTML fell through to Turndown's default fenced-code rule, which does not
widen the fence when the content contains a backtick run and instead
inline-escapes the backticks — producing broken markdown. Match every
<pre>; the replacement already reads textContent when there is no <code>
child, so bare <pre> is fenced (and fence-widened) safely.

Add regression tests for the bare-<pre> backtick-run case and the normal
language-tagged <pre><code> path.
This commit is contained in:
igor 2026-08-03 16:01:13 +12:00
parent 4978590b01
commit 5d251cc015
2 changed files with 35 additions and 1 deletions

View file

@ -3802,7 +3802,12 @@ function getTurndown(){
if(_tdService)return _tdService;
var td=new TurndownService({headingStyle:'atx',hr:'---',codeBlockStyle:'fenced',bulletListMarker:'-',emDelimiter:'*',strongDelimiter:'**',br:''});
// Preserve fenced code language from TinyMCE codesample blocks.
td.addRule('fencedCodeLanguage',{filter:function(n){return n.nodeName==='PRE'&&(!!n.querySelector('code')||/(?:^|\s)language-/.test(n.getAttribute('class')||''))},replacement:function(_c,n){
// Match ALL <pre> (not only <pre><code> or language-* blocks): a bare <pre>
// from pasted external HTML would otherwise fall through to Turndown's default
// fenced-code rule, which does NOT widen the fence when the content contains a
// backtick run — producing broken markdown. The replacement below reads
// textContent when there is no <code> child, so bare <pre> is handled safely.
td.addRule('fencedCodeLanguage',{filter:function(n){return n.nodeName==='PRE'},replacement:function(_c,n){
var codeEl=n.querySelector('code');
var cls=((codeEl&&codeEl.getAttribute('class'))||n.getAttribute('class')||'');
var m=cls.match(/(?:^|\s)language-([\w-]+)/);

View file

@ -155,6 +155,35 @@ test('tinymceToMarkdown preserves a blank line INSIDE a code block (C #include i
`blank line inside code block must be preserved, got: ${JSON.stringify(result)}`);
});
test('tinymceToMarkdown widens fence for a bare <pre> containing a backtick run', () => {
// A bare <pre> (no <code> child, no language-* class) can arrive from pasted
// external HTML. It must still be emitted as a fenced code block whose fence
// is WIDER than any backtick run inside it — otherwise the markdown re-parses
// wrong. Before widening the fencedCodeLanguage filter, a bare <pre> fell
// through to Turndown's default rule, which escapes backticks inline instead.
const ctx = makeTurndownCtx();
runWithDeps(ctx, 'getTurndown', 'tinymceToMarkdown');
const html = '<pre>const x = ```` here</pre>';
const result = vm.runInContext('tinymceToMarkdown(' + JSON.stringify(html) + ')', ctx);
// Fence must be at least 5 backticks (one longer than the 4-backtick run),
// and the backtick run must survive verbatim (not be backslash-escaped).
assert.ok(/`{5,}\n?const x = ```` here\n?`{5,}/.test(result),
`bare <pre> must produce a widened fence, got: ${JSON.stringify(result)}`);
assert.ok(!/\\`/.test(result),
`backticks must not be inline-escaped, got: ${JSON.stringify(result)}`);
});
test('tinymceToMarkdown still fences a normal <pre><code> block with language', () => {
// Regression guard: widening the filter to all <pre> must not break the
// common <pre class="language-x"><code>…</code></pre> path.
const ctx = makeTurndownCtx();
runWithDeps(ctx, 'getTurndown', 'tinymceToMarkdown');
const html = '<pre class="language-js"><code>const y = 1;</code></pre>';
const result = vm.runInContext('tinymceToMarkdown(' + JSON.stringify(html) + ')', ctx);
assert.ok(/```js\nconst y = 1;\n```/.test(result),
`language-tagged fenced block must round-trip, got: ${JSON.stringify(result)}`);
});
test('tinymceToMarkdown still collapses a blank line after a real ATX heading', () => {
const ctx = makeTurndownCtx();
runWithDeps(ctx, 'getTurndown', 'tinymceToMarkdown');