fix(callouts): render nested code blocks inside GFM/GLFM alerts

The callout preprocessor ran AFTER the fenced-code extraction pass, so
a code block nested inside a callout blockquote (each line prefixed
with `> `) was captured whole \u2014 including the leading `> ` on the
closing fence. When the placeholder was later restored inside the raw
`<div class="callout-body">`, the closing line `> \`\`\`` no longer sat
at 0-3 leading spaces, so CommonMark did not recognize it as a fence
closer. marked.js ran the block unclosed, swallowed the callout's
`</div></div>` and every downstream node, and the render leaked as raw
blockquote text.

Reorder the pipeline so callouts are converted BEFORE code-block
extraction. The scan is fence-aware (backticks and tildes) so a
literal `> [!TYPE]\' inside a top-level code block is not misread as a
callout. Mirrored in backend/export.py so the standalone export matches
the in-app preview.

Verified end-to-end against a 12-case matrix covering GFM (github.com)
and GLFM (gitlab.com) alert syntax: nested backtick fence (bug),
nested tilde fence, all 5 types in UPPERCASE, GitLab lowercase with
custom title, empty callout, plain blockquote passthrough, literal
`[!TIP]` inside top-level backtick and tilde fences (regression),
rich body markdown, paragraph separators, adjacent callouts, and
nested-alert attempts (GFM disallows nesting).

Signed-off-by: pablon <73798198+pablon@users.noreply.github.com>
This commit is contained in:
pablon 2026-07-09 15:17:35 +02:00
parent 64aa99f791
commit 465daa0f78
No known key found for this signature in database
2 changed files with 107 additions and 53 deletions

View File

@ -809,23 +809,44 @@ def generate_export_html(
// Raw markdown content // Raw markdown content
const markdown = `{escaped_content}`; const markdown = `{escaped_content}`;
// GitHub-style callouts mirrors the in-app preview preprocessor. // GFM/GLFM callouts. Runs BEFORE code-block extraction so fences
// nested in a callout blockquote lose their `> ` prefix on the closer
// otherwise the restored block sits inside <div class="callout-body">
// without a valid CommonMark fence closer and runs unclosed.
// Fence-aware so literal `> [!TIPO]` inside a top-level code block
// is not misread. Mirrors the in-app preview preprocessor.
let processed; let processed;
{{ {{
const CALLOUT_RE = /^>\\s*\\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\\]\\s*(.*)$/i; const CALLOUT_RE = /^>\\s*\\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\\]\\s*(.*)$/i;
const CALLOUT_ICONS = {{ note: '', tip: '💡', important: '', warning: '⚠️', caution: '🛑' }}; const CALLOUT_ICONS = {{ note: '', tip: '💡', important: '', warning: '⚠️', caution: '🛑' }};
const CALLOUT_TITLES = {{ note: 'Note', tip: 'Tip', important: 'Important', warning: 'Warning', caution: 'Caution' }}; const CALLOUT_TITLES = {{ note: 'Note', tip: 'Tip', important: 'Important', warning: 'Warning', caution: 'Caution' }};
const FENCE_OPEN_RE = /^\\s{{0,3}}(`{{3,}}|~{{3,}})/;
const escapeAttr = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); const escapeAttr = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const codeBlocks = [];
processed = markdown const srcLines = markdown.split('\\n');
.replace(/```[\\s\\S]*?```/g, function(m) {{ codeBlocks.push(m); return '\\x00CODEBLOCK' + (codeBlocks.length - 1) + '\\x00'; }})
.replace(/`[^`]+`/g, function(m) {{ codeBlocks.push(m); return '\\x00CODEBLOCK' + (codeBlocks.length - 1) + '\\x00'; }});
const srcLines = processed.split('\\n');
const outLines = []; const outLines = [];
let li = 0; let li = 0;
let fenceChar = null;
let fenceLen = 0;
while (li < srcLines.length) {{ while (li < srcLines.length) {{
const m = srcLines[li].match(CALLOUT_RE); const line = srcLines[li];
if (!m) {{ outLines.push(srcLines[li]); li++; continue; }} if (fenceChar) {{
outLines.push(line);
const closeRe = new RegExp('^\\\\s{{0,3}}' + (fenceChar === '`' ? '`' : '~') + '{{' + fenceLen + ',}}\\\\s*$');
if (closeRe.test(line)) {{ fenceChar = null; fenceLen = 0; }}
li++;
continue;
}}
const fenceOpen = line.match(FENCE_OPEN_RE);
if (fenceOpen) {{
fenceChar = fenceOpen[1][0];
fenceLen = fenceOpen[1].length;
outLines.push(line);
li++;
continue;
}}
const m = line.match(CALLOUT_RE);
if (!m) {{ outLines.push(line); li++; continue; }}
const type = m[1].toLowerCase(); const type = m[1].toLowerCase();
const title = escapeAttr((m[2] || '').trim() || CALLOUT_TITLES[type]); const title = escapeAttr((m[2] || '').trim() || CALLOUT_TITLES[type]);
const icon = CALLOUT_ICONS[type]; const icon = CALLOUT_ICONS[type];
@ -848,8 +869,8 @@ def generate_export_html(
'' ''
); );
}} }}
processed = outLines.join('\\n') processed = outLines.join('\\n');
.replace(/\\x00CODEBLOCK(\\d+)\\x00/g, function(_, idx) {{ return codeBlocks[parseInt(idx)]; }});
// Escape same-line block-starters after a task marker so they // Escape same-line block-starters after a task marker so they
// render as literal text. Matches Obsidian. Issue #247. // render as literal text. Matches Obsidian. Issue #247.
processed = processed.replace( processed = processed.replace(

View File

@ -5834,7 +5834,81 @@ function noteApp() {
// Must be done before marked.parse() to avoid conflicts with markdown syntax // Must be done before marked.parse() to avoid conflicts with markdown syntax
// BUT we need to protect code blocks first to avoid converting [[text]] inside code // BUT we need to protect code blocks first to avoid converting [[text]] inside code
const self = this; // Reference for closure const self = this; // Reference for closure
// Step 0: GFM/GLFM callouts. Runs BEFORE code-block extraction so
// fences nested in a callout blockquote lose their `> ` prefix on
// the closer — otherwise CommonMark won't see a valid fence closer
// once restored inside <div class="callout-body"> and the block
// runs unclosed. Fence-aware so `> [!TIPO]` literal inside a
// top-level code block is not misread as a callout.
{
const CALLOUT_RE = /^>\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*(.*)$/i;
const CALLOUT_ICONS = { note: '', tip: '💡', important: '❗', warning: '⚠️', caution: '🛑' };
const CALLOUT_TITLES = { note: 'Note', tip: 'Tip', important: 'Important', warning: 'Warning', caution: 'Caution' };
// Fence opener at 0-3 spaces indent (CommonMark). Captured
// group holds the fence chars so the closer must match char + length.
const FENCE_OPEN_RE = /^\s{0,3}(`{3,}|~{3,})/;
const escapeAttr = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const srcLines = contentToRender.split('\n');
const outLines = [];
let li = 0;
let fenceChar = null; // '`' or '~' when inside a top-level fence
let fenceLen = 0;
while (li < srcLines.length) {
const line = srcLines[li];
// Inside a top-level fence: pass through opaquely.
if (fenceChar) {
outLines.push(line);
const closeRe = new RegExp('^\\s{0,3}' + (fenceChar === '`' ? '`' : '~') + '{' + fenceLen + ',}\\s*$');
if (closeRe.test(line)) { fenceChar = null; fenceLen = 0; }
li++;
continue;
}
const fenceOpen = line.match(FENCE_OPEN_RE);
if (fenceOpen) {
fenceChar = fenceOpen[1][0];
fenceLen = fenceOpen[1].length;
outLines.push(line);
li++;
continue;
}
const m = line.match(CALLOUT_RE);
if (!m) {
outLines.push(line);
li++;
continue;
}
const type = m[1].toLowerCase();
const title = escapeAttr((m[2] || '').trim() || CALLOUT_TITLES[type]);
const icon = CALLOUT_ICONS[type];
const bodyLines = [];
li++;
// Fence lines inside the callout also start with `>`, so
// they get absorbed and stripped, leaving a clean fence.
while (li < srcLines.length && srcLines[li].startsWith('>')) {
bodyLines.push(srcLines[li].replace(/^>\s?/, ''));
li++;
}
outLines.push(
'',
`<div class="callout callout-${type}">`,
`<div class="callout-title"><span class="callout-icon" aria-hidden="true">${icon}</span><span class="callout-title-text">${title}</span></div>`,
`<div class="callout-body">`,
'',
bodyLines.join('\n'),
'',
`</div>`,
`</div>`,
''
);
}
contentToRender = outLines.join('\n');
}
// Step 1: Temporarily replace code blocks and inline code with placeholders // Step 1: Temporarily replace code blocks and inline code with placeholders
const codeBlocks = []; const codeBlocks = [];
// Protect fenced code blocks (```...```) // Protect fenced code blocks (```...```)
@ -5920,48 +5994,7 @@ function noteApp() {
} }
); );
// Step 2c: GitHub-style callouts. Runs inside the code-protection // (Callouts handled in Step 0, before code-block extraction.)
// window. Unknown types fall through to plain blockquotes.
{
const CALLOUT_RE = /^>\s*\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*(.*)$/i;
const CALLOUT_ICONS = { note: '', tip: '💡', important: '❗', warning: '⚠️', caution: '🛑' };
const CALLOUT_TITLES = { note: 'Note', tip: 'Tip', important: 'Important', warning: 'Warning', caution: 'Caution' };
const escapeAttr = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const srcLines = contentToRender.split('\n');
const outLines = [];
let li = 0;
while (li < srcLines.length) {
const m = srcLines[li].match(CALLOUT_RE);
if (!m) {
outLines.push(srcLines[li]);
li++;
continue;
}
const type = m[1].toLowerCase();
const title = escapeAttr((m[2] || '').trim() || CALLOUT_TITLES[type]);
const icon = CALLOUT_ICONS[type];
const bodyLines = [];
li++;
while (li < srcLines.length && srcLines[li].startsWith('>')) {
bodyLines.push(srcLines[li].replace(/^>\s?/, ''));
li++;
}
outLines.push(
'',
`<div class="callout callout-${type}">`,
`<div class="callout-title"><span class="callout-icon" aria-hidden="true">${icon}</span><span class="callout-title-text">${title}</span></div>`,
`<div class="callout-body">`,
'',
bodyLines.join('\n'),
'',
`</div>`,
`</div>`,
''
);
}
contentToRender = outLines.join('\n');
}
contentToRender = contentToRender.replace( contentToRender = contentToRender.replace(
/^(\s*[-*+]\s+\[[xX ]\]\s+)(\d+)\.(\s)/gm, /^(\s*[-*+]\s+\[[xX ]\]\s+)(\d+)\.(\s)/gm,