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:
parent
64aa99f791
commit
465daa0f78
|
|
@ -809,23 +809,44 @@ def generate_export_html(
|
|||
// Raw markdown 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;
|
||||
{{
|
||||
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 FENCE_OPEN_RE = /^\\s{{0,3}}(`{{3,}}|~{{3,}})/;
|
||||
const escapeAttr = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
const codeBlocks = [];
|
||||
processed = markdown
|
||||
.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 srcLines = markdown.split('\\n');
|
||||
const outLines = [];
|
||||
let li = 0;
|
||||
let fenceChar = null;
|
||||
let fenceLen = 0;
|
||||
while (li < srcLines.length) {{
|
||||
const m = srcLines[li].match(CALLOUT_RE);
|
||||
if (!m) {{ outLines.push(srcLines[li]); li++; continue; }}
|
||||
const line = srcLines[li];
|
||||
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];
|
||||
|
|
@ -848,8 +869,8 @@ def generate_export_html(
|
|||
''
|
||||
);
|
||||
}}
|
||||
processed = outLines.join('\\n')
|
||||
.replace(/\\x00CODEBLOCK(\\d+)\\x00/g, function(_, idx) {{ return codeBlocks[parseInt(idx)]; }});
|
||||
processed = outLines.join('\\n');
|
||||
|
||||
// Escape same-line block-starters after a task marker so they
|
||||
// render as literal text. Matches Obsidian. Issue #247.
|
||||
processed = processed.replace(
|
||||
|
|
|
|||
119
frontend/app.js
119
frontend/app.js
|
|
@ -5834,7 +5834,81 @@ function noteApp() {
|
|||
// 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
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
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
|
||||
const codeBlocks = [];
|
||||
// Protect fenced code blocks (```...```)
|
||||
|
|
@ -5920,48 +5994,7 @@ function noteApp() {
|
|||
}
|
||||
);
|
||||
|
||||
// Step 2c: GitHub-style callouts. Runs inside the code-protection
|
||||
// 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
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');
|
||||
}
|
||||
// (Callouts handled in Step 0, before code-block extraction.)
|
||||
|
||||
contentToRender = contentToRender.replace(
|
||||
/^(\s*[-*+]\s+\[[xX ]\]\s+)(\d+)\.(\s)/gm,
|
||||
|
|
|
|||
Loading…
Reference in New Issue