diff --git a/backend/export.py b/backend/export.py index 1d3b597..cdc7e78 100644 --- a/backend/export.py +++ b/backend/export.py @@ -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
+ // 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, '>'); - 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( diff --git a/frontend/app.js b/frontend/app.js index 3658cec..965cc47 100644 --- a/frontend/app.js +++ b/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
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, '>'); + + 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( + '', + `
`, + `
${title}
`, + `
`, + '', + bodyLines.join('\n'), + '', + `
`, + `
`, + '' + ); + } + 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, '>'); - - 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( - '', - `
`, - `
${title}
`, - `
`, - '', - bodyLines.join('\n'), - '', - `
`, - `
`, - '' - ); - } - contentToRender = outLines.join('\n'); - } + // (Callouts handled in Step 0, before code-block extraction.) contentToRender = contentToRender.replace( /^(\s*[-*+]\s+\[[xX ]\]\s+)(\d+)\.(\s)/gm,