From 9b05eface0e209db9bc6951b9fac7c75f4b32df2 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Mon, 24 Aug 2026 21:17:32 +0530 Subject: [PATCH 1/3] fix(markdown): resolve formatting parser boundaries --- desktop-app/resources/js/preview-worker.js | 396 +++++++++++++++------ desktop-app/resources/js/script.js | 336 +++++++++++------ preview-worker.js | 396 +++++++++++++++------ script.js | 336 +++++++++++------ tests/e2e/data-loss-hardening.spec.js | 2 + tests/e2e/formatting-regressions.spec.js | 224 ++++++++++++ tests/e2e/tab-split-sidebar-update.spec.js | 1 + 7 files changed, 1251 insertions(+), 440 deletions(-) create mode 100644 tests/e2e/formatting-regressions.spec.js diff --git a/desktop-app/resources/js/preview-worker.js b/desktop-app/resources/js/preview-worker.js index 989b3566..cde51f8c 100644 --- a/desktop-app/resources/js/preview-worker.js +++ b/desktop-app/resources/js/preview-worker.js @@ -25,18 +25,22 @@ const markedOptions = { const BLOCK_MATH_MARKER_PATTERN = /^\$\$/m; const BLOCK_MATH_PATTERN = /^\$\$[ \t]*\n?([\s\S]*?)\n?\$\$[ \t]*(?:\n|$)/; +const INLINE_MATH_START_PATTERN = /\\(?:\$|\(|\[)|\$/; const DEFINITION_LIST_ITEM_PATTERN = /^:[ \t]+(.*)$/; const SUPERSCRIPT_PATTERN = /^\^(?!\s)([^^\n]*?\S)\^(?!\^)/; const SUBSCRIPT_PATTERN = /^~(?!~)(?!\s)([^~\n]*?\S)~(?!~)/; const HIGHLIGHT_PATTERN = /^==(?=\S)([\s\S]*?\S)==/; const MARKDOWN_LIST_MARKER_PATTERN = /^(\s*)(?:[-*+]\s+|\d+\.\s+|>\s+)/; +const DEFINITION_LIST_DISALLOWED_TERM_PATTERN = /^[ \t]{0,3}(?:`{3,}|~{3,}|#{1,6}(?:[ \t]+|$)|<\/?[a-zA-Z][\w:-]*(?:\s|>|\/>))/; const EMPTY_LINE_PATTERN = /^\s*$/; let suppressFootnotePreprocess = false; +let preserveExtendedMarkdownState = false; const footnoteDefinitions = new Map(); const footnoteOrder = []; const footnoteRefCounts = new Map(); const footnoteFirstRefId = new Map(); +const usedHeadingIds = new Set(); let anonymousFootnoteCounter = 0; function escapeHtml(str) { @@ -61,6 +65,7 @@ function resetExtendedMarkdownState() { footnoteOrder.length = 0; footnoteRefCounts.clear(); footnoteFirstRefId.clear(); + usedHeadingIds.clear(); anonymousFootnoteCounter = 0; } @@ -104,82 +109,135 @@ function renderDefinitionContent(content, options) { .join(""); } -function extractFootnoteDefinitions(markdown) { - const lines = markdown.split("\n"); - const preservedLines = []; - let index = 0; +function renderFootnotesSection() { + const footnotesHtml = footnoteOrder + .filter((id) => footnoteDefinitions.has(id)) + .map((id) => { + const normalizedId = normalizeFootnoteId(id); + const backRefId = footnoteFirstRefId.get(id) || `fnref-${normalizedId}`; + const backRefHtml = ``; + const noteHtml = renderDefinitionContent(footnoteDefinitions.get(id) || "", { appendHtml: backRefHtml }); + return `
  • ${noteHtml}
  • `; + }) + .join(""); - while (index < lines.length) { - const match = /^([ \t]{0,3})\[\^([^\]\n]+)\]:[ \t]*(.*)$/.exec(lines[index]); - if (!match) { - preservedLines.push(lines[index]); - index += 1; - continue; - } + return footnotesHtml + ? `

      ${footnotesHtml}
    ` + : ""; +} - const baseIndent = match[1] || ""; - const id = match[2].trim(); - const definitionLines = [match[3] || ""]; - index += 1; +function isEscapedCharacter(source, index) { + let backslashCount = 0; + for (let cursor = index - 1; cursor >= 0 && source[cursor] === "\\"; cursor -= 1) { + backslashCount += 1; + } + return backslashCount % 2 === 1; +} - while (index < lines.length) { - const line = lines[index]; - if (!line.startsWith(baseIndent)) break; - const lineAfterBase = line.slice(baseIndent.length); - const indentedMatch = /^(?: {2,}|\t)(.*)$/.exec(lineAfterBase); - if (indentedMatch) { - definitionLines.push(indentedMatch[1]); - index += 1; - continue; - } - if (lineAfterBase.trim() === "") { - const nextLine = lines[index + 1] || ""; - const nextAfterBase = nextLine.startsWith(baseIndent) ? nextLine.slice(baseIndent.length) : ""; - if (/^(?: {2,}|\t)/.test(nextAfterBase)) { - definitionLines.push(""); - index += 1; - continue; - } - } - break; - } +function findClosingMathDelimiter(source, delimiter, startIndex) { + for (let index = startIndex; index <= source.length - delimiter.length; index += 1) { + if (source[index] === "\n") return -1; + if (source.startsWith(delimiter, index) && !isEscapedCharacter(source, index)) return index; + } + return -1; +} - footnoteDefinitions.set(id, definitionLines.join("\n").trim()); +function tokenizeDollarMath(source) { + if (source.startsWith("$$")) { + const closeIndex = findClosingMathDelimiter(source, "$$", 2); + if (closeIndex > 2) { + return { type: "inlineMath", raw: source.slice(0, closeIndex + 2), display: true }; + } + return null; } + if (source[0] !== "$" || !source[1] || /[\s$]/.test(source[1])) return null; + for (let index = 1; index < source.length; index += 1) { + if (source[index] === "\n") break; + if (source[index] !== "$" || isEscapedCharacter(source, index)) continue; + if (/\s/.test(source[index - 1]) || /\d/.test(source[index + 1] || "")) return null; + return { type: "inlineMath", raw: source.slice(0, index + 1), display: false }; + } + return null; +} - return preservedLines.join("\n"); +function createUniqueHeadingId(raw) { + const baseId = String(raw || "") + .toLowerCase() + .trim() + .replace(/<[^>]*>/g, '') + .replace(/\s+/g, '-') + .replace(/[^\w-]/g, '') + .replace(/-+/g, '-') || 'heading'; + let id = baseId; + let suffix = 0; + while (usedHeadingIds.has(id)) { + suffix += 1; + id = `${baseId}-${suffix}`; + } + usedHeadingIds.add(id); + return id; } -function applyFootnotes(markdown) { - const markdownWithReferences = markdown.replace(/\[\^([^\]\n]+)\]/g, function(match, idText) { - const id = idText.trim(); - if (!id) return match; - if (!footnoteOrder.includes(id)) footnoteOrder.push(id); +function normalizeWorkerMarkmapFences(markdown) { + const lines = String(markdown || '').split(/\r?\n/); + const output = []; + let index = 0; - const refCount = (footnoteRefCounts.get(id) || 0) + 1; - footnoteRefCounts.set(id, refCount); + while (index < lines.length) { + const opening = lines[index].match(/^([ \t]{0,3})(`{3,}|~{3,})([ \t]*)(.*)$/); + const info = opening ? opening[4].trim() : ''; + if (!opening || !/^markmap(?:\s|$)/i.test(info)) { + output.push(lines[index]); + index += 1; + continue; + } - const normalizedId = normalizeFootnoteId(id); - const refId = `fnref-${normalizedId}${refCount > 1 ? `-${refCount}` : ""}`; - if (!footnoteFirstRefId.has(id)) footnoteFirstRefId.set(id, refId); + const indent = opening[1]; + const fence = opening[2]; + const marker = fence[0]; + const content = []; + let nestedFence = null; + let maxInnerFenceLength = fence.length; + let closeIndex = -1; + + for (let scan = index + 1; scan < lines.length; scan += 1) { + const line = lines[scan]; + const fenceMatch = line.match(/^[ \t]{0,3}(`{3,}|~{3,})([ \t]*.*)$/); + if (fenceMatch) { + const currentFence = fenceMatch[1]; + const currentMarker = currentFence[0]; + const tail = fenceMatch[2].trim(); + if (currentMarker === marker) { + maxInnerFenceLength = Math.max(maxInnerFenceLength, currentFence.length); + } + if (nestedFence) { + if (currentMarker === nestedFence.marker && currentFence.length >= nestedFence.length && tail === '') { + nestedFence = null; + } + } else if (currentMarker === marker && currentFence.length >= fence.length && tail === '') { + closeIndex = scan; + break; + } else if (tail !== '') { + nestedFence = { marker: currentMarker, length: currentFence.length }; + } + } + content.push(line); + } - const noteNumber = footnoteOrder.indexOf(id) + 1; - return `[${noteNumber}]`; - }); + if (closeIndex === -1) { + output.push(lines[index]); + index += 1; + continue; + } - const footnotesHtml = footnoteOrder - .filter((id) => footnoteDefinitions.has(id)) - .map((id) => { - const normalizedId = normalizeFootnoteId(id); - const backRefId = footnoteFirstRefId.get(id) || `fnref-${normalizedId}`; - const backRefHtml = ``; - const noteHtml = renderDefinitionContent(footnoteDefinitions.get(id) || "", { appendHtml: backRefHtml }); - return `
  • ${noteHtml}
  • `; - }) - .join(""); + const normalizedFence = marker.repeat(maxInnerFenceLength + 1); + output.push(`${indent}${normalizedFence}${opening[3]}${opening[4]}`); + output.push(...content); + output.push(`${indent}${normalizedFence}`); + index = closeIndex + 1; + } - if (!footnotesHtml) return markdownWithReferences; - return `${markdownWithReferences}\n\n

      ${footnotesHtml}
    `; + return output.join('\n'); } function configureMarked() { @@ -199,7 +257,119 @@ function configureMarked() { return { type: "blockMath", raw: match[0], text: match[1] }; }, renderer(token) { - return `
    $$\n${token.text}\n$$
    \n`; + return `
    $$\n${escapeHtml(token.text)}\n$$
    \n`; + }, + }; + + const footnoteDefinitionExtension = { + name: "footnoteDefinition", + level: "block", + start(src) { + const match = src.match(/(?:^|\n)[ \t]{0,3}\[\^[^\]\n]+\]:/); + if (!match) return undefined; + return match.index + (match[0][0] === "\n" ? 1 : 0); + }, + tokenizer(src) { + if (suppressFootnotePreprocess) return undefined; + const lines = src.split("\n"); + const match = /^([ \t]{0,3})\[\^([^\]\n]+)\]:[ \t]*(.*)$/.exec(lines[0]); + if (!match) return undefined; + const baseIndent = match[1] || ""; + const id = match[2].trim(); + const definitionLines = [match[3] || ""]; + const rawLines = [lines[0]]; + let index = 1; + while (index < lines.length) { + const line = lines[index]; + if (!line.startsWith(baseIndent)) break; + const lineAfterBase = line.slice(baseIndent.length); + const indentedMatch = /^(?: {2,}|\t)(.*)$/.exec(lineAfterBase); + if (indentedMatch) { + rawLines.push(line); + definitionLines.push(indentedMatch[1]); + index += 1; + continue; + } + if (lineAfterBase.trim() === "") { + const nextLine = lines[index + 1] || ""; + const nextAfterBase = nextLine.startsWith(baseIndent) ? nextLine.slice(baseIndent.length) : ""; + if (/^(?: {2,}|\t)/.test(nextAfterBase)) { + rawLines.push(line); + definitionLines.push(""); + index += 1; + continue; + } + } + break; + } + let raw = rawLines.join("\n"); + if (src.startsWith(raw + "\n")) raw += "\n"; + footnoteDefinitions.set(id, definitionLines.join("\n").trim()); + return { type: "footnoteDefinition", raw }; + }, + renderer() { + return ""; + }, + }; + + const footnoteReferenceExtension = { + name: "footnoteReference", + level: "inline", + start(src) { + if (suppressFootnotePreprocess) return undefined; + const index = src.indexOf("[^"); + return index >= 0 ? index : undefined; + }, + tokenizer(src) { + if (suppressFootnotePreprocess) return undefined; + const match = /^\[\^([^\]\n]+)\]/.exec(src); + if (!match) return undefined; + return { type: "footnoteReference", raw: match[0], id: match[1].trim() }; + }, + renderer(token) { + const id = token.id; + if (!id) return token.raw; + if (!footnoteOrder.includes(id)) footnoteOrder.push(id); + const refCount = (footnoteRefCounts.get(id) || 0) + 1; + footnoteRefCounts.set(id, refCount); + const normalizedId = normalizeFootnoteId(id); + const refId = `fnref-${normalizedId}${refCount > 1 ? `-${refCount}` : ""}`; + if (!footnoteFirstRefId.has(id)) footnoteFirstRefId.set(id, refId); + const noteNumber = footnoteOrder.indexOf(id) + 1; + return `[${noteNumber}]`; + }, + }; + + const inlineMathExtension = { + name: "inlineMath", + level: "inline", + start(src) { + const match = INLINE_MATH_START_PATTERN.exec(src); + return match ? match.index : undefined; + }, + tokenizer(src) { + if (src.startsWith("\\$")) return { type: "inlineMath", raw: "\\$", literalDollar: true }; + if (src.startsWith("\\(") || src.startsWith("\\[")) { + const opening = src.slice(0, 2); + const closing = opening === "\\(" ? "\\)" : "\\]"; + const closeIndex = findClosingMathDelimiter(src, closing, 2); + if (closeIndex >= 2) { + return { + type: "inlineMath", + raw: src.slice(0, closeIndex + 2), + display: opening === "\\[", + }; + } + return undefined; + } + if (src[0] !== "$" || isEscapedCharacter(src, 0)) return undefined; + const mathToken = tokenizeDollarMath(src); + return mathToken || { type: "inlineMath", raw: "$", literalDollar: true }; + }, + renderer(token) { + if (token.literalDollar) return '$'; + const displayClass = token.display ? ' math-display-inline' : ''; + return `${escapeHtml(token.raw)}`; }, }; @@ -214,13 +384,22 @@ function configureMarked() { const lines = src.split("\n"); if (lines.length < 2) return undefined; - const term = lines[0]; - if (EMPTY_LINE_PATTERN.test(term) || MARKDOWN_LIST_MARKER_PATTERN.test(term)) return undefined; - if (!DEFINITION_LIST_ITEM_PATTERN.test(lines[1])) return undefined; - + const terms = []; + const rawLines = []; + let index = 0; + while (index < lines.length && !DEFINITION_LIST_ITEM_PATTERN.test(lines[index])) { + const term = lines[index]; + if ( + EMPTY_LINE_PATTERN.test(term) || + MARKDOWN_LIST_MARKER_PATTERN.test(term) || + DEFINITION_LIST_DISALLOWED_TERM_PATTERN.test(term) + ) return undefined; + terms.push(term.trim()); + rawLines.push(term); + index += 1; + } + if (terms.length === 0 || index >= lines.length) return undefined; const definitions = []; - const rawLines = [term]; - let index = 1; while (index < lines.length) { const itemMatch = DEFINITION_LIST_ITEM_PATTERN.exec(lines[index]); if (!itemMatch) break; @@ -255,14 +434,16 @@ function configureMarked() { if (definitions.length === 0) return undefined; let raw = rawLines.join("\n"); if (src.startsWith(raw + "\n")) raw += "\n"; - return { type: "definitionList", raw, term: term.trim(), definitions }; + return { type: "definitionList", raw, terms, definitions }; }, renderer(token) { - const termHtml = parseInlineWithoutFootnotes(token.term); + const termHtml = token.terms + .map((term) => `
    ${parseInlineWithoutFootnotes(term)}
    `) + .join(""); const definitionHtml = token.definitions .map((definition) => `
    ${renderDefinitionContent(definition)}
    `) .join(""); - return `
    ${termHtml}
    ${definitionHtml}
    \n`; + return `
    ${termHtml}${definitionHtml}
    \n`; }, }; @@ -278,7 +459,7 @@ function configureMarked() { return match ? { type: "superscript", raw: match[0], text: match[1] } : undefined; }, renderer(token) { - return `${marked.parseInline(token.text)}`; + return `${parseInlineWithoutFootnotes(token.text)}`; }, }; @@ -294,7 +475,7 @@ function configureMarked() { return match ? { type: "subscript", raw: match[0], text: match[1] } : undefined; }, renderer(token) { - return `${marked.parseInline(token.text)}`; + return `${parseInlineWithoutFootnotes(token.text)}`; }, }; @@ -310,7 +491,7 @@ function configureMarked() { return match ? { type: "highlight", raw: match[0], text: match[1] } : undefined; }, renderer(token) { - return `${marked.parseInline(token.text)}`; + return `${parseInlineWithoutFootnotes(token.text)}`; }, }; @@ -390,16 +571,7 @@ function configureMarked() { }; renderer.heading = function(text, level, raw) { - let id = raw - .toLowerCase() - .trim() - .replace(/<[^>]*>/g, '') - .replace(/\s+/g, '-') - .replace(/[^\w-]/g, '') - .replace(/-+/g, '-'); - if (!id) { - id = `heading-worker-${Math.random().toString(36).substr(2, 9)}`; - } + const id = createUniqueHeadingId(raw); return `${text}`; }; @@ -472,7 +644,10 @@ function configureMarked() { marked.use({ extensions: [ blockMathExtension, + footnoteDefinitionExtension, definitionListExtension, + inlineMathExtension, + footnoteReferenceExtension, superscriptExtension, subscriptExtension, highlightExtension, @@ -480,10 +655,12 @@ function configureMarked() { hooks: { preprocess(markdown) { if (suppressFootnotePreprocess) return markdown; - resetExtendedMarkdownState(); - const normalizedMarkdown = normalizeMarkmapFences(markdown); - const protectedMarkdown = normalizedMarkdown.replace(/\\\$/g, "$"); - return applyFootnotes(extractFootnoteDefinitions(protectedMarkdown)); + if (!preserveExtendedMarkdownState) resetExtendedMarkdownState(); + return normalizeMarkmapFences(markdown); + }, + postprocess(html) { + if (suppressFootnotePreprocess) return html; + return html + renderFootnotesSection(); }, }, }); @@ -580,7 +757,7 @@ function splitMarkdownBlocks(markdown) { } function renderSegmentedMarkdown(markdown, options) { - const normalizedMarkdown = normalizeMarkmapFences(markdown); + const normalizedMarkdown = normalizeWorkerMarkmapFences(markdown); if (!isSegmentedPreviewSafe(normalizedMarkdown)) { return { mode: "full-required", reason: "unsafe-markdown" }; } @@ -591,21 +768,28 @@ function renderSegmentedMarkdown(markdown, options) { } const seenHashes = new Map(); - const renderedBlocks = blocks.map((block) => { - const hash = hashString(block.source); - const seenCount = seenHashes.get(hash) || 0; - seenHashes.set(hash, seenCount + 1); - const html = marked.parse(block.source); - return { - id: `preview-block-${hash}-${seenCount}`, - hash, - html, - htmlLength: html.length, - sourceLength: block.source.length, - startLine: block.startLine, - endLine: block.endLine, - }; - }); + resetExtendedMarkdownState(); + preserveExtendedMarkdownState = true; + let renderedBlocks; + try { + renderedBlocks = blocks.map((block) => { + const hash = hashString(block.source); + const seenCount = seenHashes.get(hash) || 0; + seenHashes.set(hash, seenCount + 1); + const html = marked.parse(block.source); + return { + id: `preview-block-${hash}-${seenCount}`, + hash, + html, + htmlLength: html.length, + sourceLength: block.source.length, + startLine: block.startLine, + endLine: block.endLine, + }; + }); + } finally { + preserveExtendedMarkdownState = false; + } return { mode: "segmented", diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index 40e4c56a..1c8c0e11 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -2211,6 +2211,7 @@ document.addEventListener("DOMContentLoaded", async function () { const renderer = new marked.Renderer(); const BLOCK_MATH_MARKER_PATTERN = /^\$\$/m; const BLOCK_MATH_PATTERN = /^\$\$[ \t]*\n?([\s\S]*?)\n?\$\$[ \t]*(?:\n|$)/; + const INLINE_MATH_START_PATTERN = /\\(?:\$|\(|\[)|\$/; const RAW_MATH_TEXT_PATTERN = /\$\$|\$[^$]|\\\(|\\\[/; const MATHJAX_TEXT_TARGET_SELECTOR = 'p, li, td, th, dd, dt, blockquote, figcaption, h1, h2, h3, h4, h5, h6, .math-block'; const DEFINITION_LIST_ITEM_PATTERN = /^:[ \t]+(.*)$/; @@ -2218,11 +2219,13 @@ document.addEventListener("DOMContentLoaded", async function () { const SUBSCRIPT_PATTERN = /^~(?!~)(?!\s)([^~\n]*?\S)~(?!~)/; const HIGHLIGHT_PATTERN = /^==(?=\S)([\s\S]*?\S)==/; const MARKDOWN_LIST_MARKER_PATTERN = /^(\s*)(?:[-*+]\s+|\d+\.\s+|>\s+)/; + const DEFINITION_LIST_DISALLOWED_TERM_PATTERN = /^[ \t]{0,3}(?:`{3,}|~{3,}|#{1,6}(?:[ \t]+|$)|<\/?[a-zA-Z][\w:-]*(?:\s|>|\/>))/; const EMPTY_LINE_PATTERN = /^\s*$/; const footnoteDefinitions = new Map(); const footnoteOrder = []; const footnoteRefCounts = new Map(); const footnoteFirstRefId = new Map(); + const usedHeadingIds = new Set(); let anonymousFootnoteCounter = 0; let suppressFootnotePreprocess = false; @@ -2231,6 +2234,7 @@ document.addEventListener("DOMContentLoaded", async function () { footnoteOrder.length = 0; footnoteRefCounts.clear(); footnoteFirstRefId.clear(); + usedHeadingIds.clear(); anonymousFootnoteCounter = 0; } @@ -2484,85 +2488,7 @@ document.addEventListener("DOMContentLoaded", async function () { .join(""); } - function extractFootnoteDefinitions(markdown) { - const lines = markdown.split("\n"); - const preservedLines = []; - let index = 0; - - while (index < lines.length) { - const match = /^([ \t]{0,3})\[\^([^\]\n]+)\]:[ \t]*(.*)$/.exec(lines[index]); - if (!match) { - preservedLines.push(lines[index]); - index += 1; - continue; - } - - const baseIndent = match[1] || ""; - const id = match[2].trim(); - const definitionLines = [match[3] || ""]; - index += 1; - - while (index < lines.length) { - const line = lines[index]; - if (!line.startsWith(baseIndent)) { - break; - } - - const lineAfterBase = line.slice(baseIndent.length); - const indentedMatch = /^(?: {2,}|\t)(.*)$/.exec(lineAfterBase); - if (indentedMatch) { - definitionLines.push(indentedMatch[1]); - index += 1; - continue; - } - - if (lineAfterBase.trim() === "") { - const nextLine = lines[index + 1] || ""; - const nextAfterBase = nextLine.startsWith(baseIndent) - ? nextLine.slice(baseIndent.length) - : ""; - if (/^(?: {2,}|\t)/.test(nextAfterBase)) { - definitionLines.push(""); - index += 1; - continue; - } - } - - break; - } - - footnoteDefinitions.set(id, definitionLines.join("\n").trim()); - } - - return preservedLines.join("\n"); - } - - function applyFootnotes(markdown) { - const markdownWithReferences = markdown.replace(/\[\^([^\]\n]+)\]/g, function(match, idText) { - const id = idText.trim(); - if (!id) { - return match; - } - - if (!footnoteOrder.includes(id)) { - footnoteOrder.push(id); - } - - const refCount = (footnoteRefCounts.get(id) || 0) + 1; - footnoteRefCounts.set(id, refCount); - - const normalizedId = normalizeFootnoteId(id); - const refId = `fnref-${normalizedId}${refCount > 1 ? `-${refCount}` : ""}`; - if (!footnoteFirstRefId.has(id)) { - footnoteFirstRefId.set(id, refId); - } - - const noteNumber = footnoteOrder.indexOf(id) + 1; - const safeRefId = escapeHtmlAttribute(refId); - const safeNormalizedId = escapeHtmlAttribute(normalizedId); - return `[${noteNumber}]`; - }); - + function renderFootnotesSection() { const footnotesHtml = footnoteOrder .filter((id) => footnoteDefinitions.has(id)) .map((id) => { @@ -2579,11 +2505,76 @@ document.addEventListener("DOMContentLoaded", async function () { }) .join(""); - if (!footnotesHtml) { - return markdownWithReferences; + return footnotesHtml + ? `

      ${footnotesHtml}
    ` + : ""; + } + + function isEscapedCharacter(source, index) { + let backslashCount = 0; + for (let cursor = index - 1; cursor >= 0 && source[cursor] === "\\"; cursor -= 1) { + backslashCount += 1; } + return backslashCount % 2 === 1; + } - return `${markdownWithReferences}\n\n

      ${footnotesHtml}
    `; + function findClosingMathDelimiter(source, delimiter, startIndex) { + for (let index = startIndex; index <= source.length - delimiter.length; index += 1) { + if (source[index] === "\n") return -1; + if (source.startsWith(delimiter, index) && !isEscapedCharacter(source, index)) { + return index; + } + } + return -1; + } + + function tokenizeDollarMath(source) { + if (source.startsWith("$$")) { + const closeIndex = findClosingMathDelimiter(source, "$$", 2); + if (closeIndex > 2) { + return { + type: "inlineMath", + raw: source.slice(0, closeIndex + 2), + display: true, + }; + } + return null; + } + + if (source[0] !== "$" || !source[1] || /[\s$]/.test(source[1])) { + return null; + } + + for (let index = 1; index < source.length; index += 1) { + if (source[index] === "\n") break; + if (source[index] !== "$" || isEscapedCharacter(source, index)) continue; + if (/\s/.test(source[index - 1]) || /\d/.test(source[index + 1] || "")) return null; + return { + type: "inlineMath", + raw: source.slice(0, index + 1), + display: false, + }; + } + + return null; + } + + function createUniqueHeadingId(raw) { + const baseId = String(raw || "") + .toLowerCase() + .trim() + .replace(/<[^>]*>/g, '') + .replace(/\s+/g, '-') + .replace(/[^\w-]/g, '') + .replace(/-+/g, '-') || 'heading'; + let id = baseId; + let suffix = 0; + while (usedHeadingIds.has(id)) { + suffix += 1; + id = `${baseId}-${suffix}`; + } + usedHeadingIds.add(id); + return id; } const blockMathExtension = { @@ -2608,9 +2599,126 @@ document.addEventListener("DOMContentLoaded", async function () { }; }, renderer(token) { - return `
    $$\n${token.text}\n$$
    \n`; + return `
    $$\n${escapeHtml(token.text)}\n$$
    \n`; } }; + const footnoteDefinitionExtension = { + name: "footnoteDefinition", + level: "block", + start(src) { + const match = src.match(/(?:^|\n)[ \t]{0,3}\[\^[^\]\n]+\]:/); + if (!match) return undefined; + return match.index + (match[0][0] === "\n" ? 1 : 0); + }, + tokenizer(src) { + if (suppressFootnotePreprocess) return undefined; + const lines = src.split("\n"); + const match = /^([ \t]{0,3})\[\^([^\]\n]+)\]:[ \t]*(.*)$/.exec(lines[0]); + if (!match) return undefined; + + const baseIndent = match[1] || ""; + const id = match[2].trim(); + const definitionLines = [match[3] || ""]; + const rawLines = [lines[0]]; + let index = 1; + while (index < lines.length) { + const line = lines[index]; + if (!line.startsWith(baseIndent)) break; + const lineAfterBase = line.slice(baseIndent.length); + const indentedMatch = /^(?: {2,}|\t)(.*)$/.exec(lineAfterBase); + if (indentedMatch) { + rawLines.push(line); + definitionLines.push(indentedMatch[1]); + index += 1; + continue; + } + if (lineAfterBase.trim() === "") { + const nextLine = lines[index + 1] || ""; + const nextAfterBase = nextLine.startsWith(baseIndent) + ? nextLine.slice(baseIndent.length) + : ""; + if (/^(?: {2,}|\t)/.test(nextAfterBase)) { + rawLines.push(line); + definitionLines.push(""); + index += 1; + continue; + } + } + break; + } + + let raw = rawLines.join("\n"); + if (src.startsWith(raw + "\n")) raw += "\n"; + footnoteDefinitions.set(id, definitionLines.join("\n").trim()); + return { type: "footnoteDefinition", raw }; + }, + renderer() { + return ""; + }, + }; + const footnoteReferenceExtension = { + name: "footnoteReference", + level: "inline", + start(src) { + if (suppressFootnotePreprocess) return undefined; + const index = src.indexOf("[^"); + return index >= 0 ? index : undefined; + }, + tokenizer(src) { + if (suppressFootnotePreprocess) return undefined; + const match = /^\[\^([^\]\n]+)\]/.exec(src); + if (!match) return undefined; + return { type: "footnoteReference", raw: match[0], id: match[1].trim() }; + }, + renderer(token) { + const id = token.id; + if (!id) return token.raw; + if (!footnoteOrder.includes(id)) footnoteOrder.push(id); + const refCount = (footnoteRefCounts.get(id) || 0) + 1; + footnoteRefCounts.set(id, refCount); + const normalizedId = normalizeFootnoteId(id); + const refId = `fnref-${normalizedId}${refCount > 1 ? `-${refCount}` : ""}`; + if (!footnoteFirstRefId.has(id)) footnoteFirstRefId.set(id, refId); + const noteNumber = footnoteOrder.indexOf(id) + 1; + return `[${noteNumber}]`; + }, + }; + const inlineMathExtension = { + name: "inlineMath", + level: "inline", + start(src) { + const match = INLINE_MATH_START_PATTERN.exec(src); + return match ? match.index : undefined; + }, + tokenizer(src) { + if (src.startsWith("\\$")) { + return { type: "inlineMath", raw: "\\$", literalDollar: true }; + } + if (src.startsWith("\\(") || src.startsWith("\\[")) { + const opening = src.slice(0, 2); + const closing = opening === "\\(" ? "\\)" : "\\]"; + const closeIndex = findClosingMathDelimiter(src, closing, 2); + if (closeIndex >= 2) { + return { + type: "inlineMath", + raw: src.slice(0, closeIndex + 2), + display: opening === "\\[", + }; + } + return undefined; + } + if (src[0] !== "$" || isEscapedCharacter(src, 0)) return undefined; + const mathToken = tokenizeDollarMath(src); + return mathToken || { type: "inlineMath", raw: "$", literalDollar: true }; + }, + renderer(token) { + if (token.literalDollar) { + return '$'; + } + const displayClass = token.display ? ' math-display-inline' : ''; + return `${escapeHtml(token.raw)}`; + }, + }; const definitionListExtension = { name: "definitionList", level: "block", @@ -2627,18 +2735,22 @@ document.addEventListener("DOMContentLoaded", async function () { return undefined; } - const term = lines[0]; - if (EMPTY_LINE_PATTERN.test(term) || MARKDOWN_LIST_MARKER_PATTERN.test(term)) { - return undefined; - } - - if (!DEFINITION_LIST_ITEM_PATTERN.test(lines[1])) { - return undefined; + const terms = []; + const rawLines = []; + let index = 0; + while (index < lines.length && !DEFINITION_LIST_ITEM_PATTERN.test(lines[index])) { + const term = lines[index]; + if ( + EMPTY_LINE_PATTERN.test(term) || + MARKDOWN_LIST_MARKER_PATTERN.test(term) || + DEFINITION_LIST_DISALLOWED_TERM_PATTERN.test(term) + ) return undefined; + terms.push(term.trim()); + rawLines.push(term); + index += 1; } - + if (terms.length === 0 || index >= lines.length) return undefined; const definitions = []; - const rawLines = [term]; - let index = 1; while (index < lines.length) { const itemMatch = DEFINITION_LIST_ITEM_PATTERN.exec(lines[index]); if (!itemMatch) { @@ -2689,16 +2801,18 @@ document.addEventListener("DOMContentLoaded", async function () { return { type: "definitionList", raw: raw, - term: term.trim(), + terms: terms, definitions: definitions, }; }, renderer(token) { - const termHtml = parseInlineWithoutFootnotes(token.term); + const termHtml = token.terms + .map((term) => `
    ${parseInlineWithoutFootnotes(term)}
    `) + .join(""); const definitionHtml = token.definitions .map((definition) => `
    ${renderDefinitionContent(definition)}
    `) .join(""); - return `
    ${termHtml}
    ${definitionHtml}
    \n`; + return `
    ${termHtml}${definitionHtml}
    \n`; }, }; const superscriptExtension = { @@ -2720,7 +2834,7 @@ document.addEventListener("DOMContentLoaded", async function () { }; }, renderer(token) { - return `${marked.parseInline(token.text)}`; + return `${parseInlineWithoutFootnotes(token.text)}`; }, }; const subscriptExtension = { @@ -2742,7 +2856,7 @@ document.addEventListener("DOMContentLoaded", async function () { }; }, renderer(token) { - return `${marked.parseInline(token.text)}`; + return `${parseInlineWithoutFootnotes(token.text)}`; }, }; const highlightExtension = { @@ -2764,7 +2878,7 @@ document.addEventListener("DOMContentLoaded", async function () { }; }, renderer(token) { - return `${marked.parseInline(token.text)}`; + return `${parseInlineWithoutFootnotes(token.text)}`; }, }; @@ -2863,16 +2977,7 @@ document.addEventListener("DOMContentLoaded", async function () { }; renderer.heading = function (text, level, raw) { - let id = raw - .toLowerCase() - .trim() - .replace(/<[^>]*>/g, '') - .replace(/\s+/g, '-') - .replace(/[^\w-]/g, '') - .replace(/-+/g, '-'); - if (!id) { - id = 'heading-' + Math.random().toString(36).substr(2, 9); - } + const id = createUniqueHeadingId(raw); return `${text}`; }; @@ -2945,7 +3050,10 @@ document.addEventListener("DOMContentLoaded", async function () { marked.use({ extensions: [ blockMathExtension, + footnoteDefinitionExtension, definitionListExtension, + inlineMathExtension, + footnoteReferenceExtension, superscriptExtension, subscriptExtension, highlightExtension, @@ -2956,11 +3064,11 @@ document.addEventListener("DOMContentLoaded", async function () { return markdown; } resetExtendedMarkdownState(); - // ✅ Replace escaped dollar signs before marked.js strips the backslash. - // This prevents MathJax from treating lone $ as a math delimiter. - const normalizedMarkdown = normalizeMarkmapFences(markdown); - const protectedMarkdown = normalizedMarkdown.replace(/\\\$/g, '$'); - return applyFootnotes(extractFootnoteDefinitions(protectedMarkdown)); + return normalizeMarkmapFences(markdown); + }, + postprocess(html) { + if (suppressFootnotePreprocess) return html; + return html + renderFootnotesSection(); }, }, }); diff --git a/preview-worker.js b/preview-worker.js index 989b3566..cde51f8c 100644 --- a/preview-worker.js +++ b/preview-worker.js @@ -25,18 +25,22 @@ const markedOptions = { const BLOCK_MATH_MARKER_PATTERN = /^\$\$/m; const BLOCK_MATH_PATTERN = /^\$\$[ \t]*\n?([\s\S]*?)\n?\$\$[ \t]*(?:\n|$)/; +const INLINE_MATH_START_PATTERN = /\\(?:\$|\(|\[)|\$/; const DEFINITION_LIST_ITEM_PATTERN = /^:[ \t]+(.*)$/; const SUPERSCRIPT_PATTERN = /^\^(?!\s)([^^\n]*?\S)\^(?!\^)/; const SUBSCRIPT_PATTERN = /^~(?!~)(?!\s)([^~\n]*?\S)~(?!~)/; const HIGHLIGHT_PATTERN = /^==(?=\S)([\s\S]*?\S)==/; const MARKDOWN_LIST_MARKER_PATTERN = /^(\s*)(?:[-*+]\s+|\d+\.\s+|>\s+)/; +const DEFINITION_LIST_DISALLOWED_TERM_PATTERN = /^[ \t]{0,3}(?:`{3,}|~{3,}|#{1,6}(?:[ \t]+|$)|<\/?[a-zA-Z][\w:-]*(?:\s|>|\/>))/; const EMPTY_LINE_PATTERN = /^\s*$/; let suppressFootnotePreprocess = false; +let preserveExtendedMarkdownState = false; const footnoteDefinitions = new Map(); const footnoteOrder = []; const footnoteRefCounts = new Map(); const footnoteFirstRefId = new Map(); +const usedHeadingIds = new Set(); let anonymousFootnoteCounter = 0; function escapeHtml(str) { @@ -61,6 +65,7 @@ function resetExtendedMarkdownState() { footnoteOrder.length = 0; footnoteRefCounts.clear(); footnoteFirstRefId.clear(); + usedHeadingIds.clear(); anonymousFootnoteCounter = 0; } @@ -104,82 +109,135 @@ function renderDefinitionContent(content, options) { .join(""); } -function extractFootnoteDefinitions(markdown) { - const lines = markdown.split("\n"); - const preservedLines = []; - let index = 0; +function renderFootnotesSection() { + const footnotesHtml = footnoteOrder + .filter((id) => footnoteDefinitions.has(id)) + .map((id) => { + const normalizedId = normalizeFootnoteId(id); + const backRefId = footnoteFirstRefId.get(id) || `fnref-${normalizedId}`; + const backRefHtml = ``; + const noteHtml = renderDefinitionContent(footnoteDefinitions.get(id) || "", { appendHtml: backRefHtml }); + return `
  • ${noteHtml}
  • `; + }) + .join(""); - while (index < lines.length) { - const match = /^([ \t]{0,3})\[\^([^\]\n]+)\]:[ \t]*(.*)$/.exec(lines[index]); - if (!match) { - preservedLines.push(lines[index]); - index += 1; - continue; - } + return footnotesHtml + ? `

      ${footnotesHtml}
    ` + : ""; +} - const baseIndent = match[1] || ""; - const id = match[2].trim(); - const definitionLines = [match[3] || ""]; - index += 1; +function isEscapedCharacter(source, index) { + let backslashCount = 0; + for (let cursor = index - 1; cursor >= 0 && source[cursor] === "\\"; cursor -= 1) { + backslashCount += 1; + } + return backslashCount % 2 === 1; +} - while (index < lines.length) { - const line = lines[index]; - if (!line.startsWith(baseIndent)) break; - const lineAfterBase = line.slice(baseIndent.length); - const indentedMatch = /^(?: {2,}|\t)(.*)$/.exec(lineAfterBase); - if (indentedMatch) { - definitionLines.push(indentedMatch[1]); - index += 1; - continue; - } - if (lineAfterBase.trim() === "") { - const nextLine = lines[index + 1] || ""; - const nextAfterBase = nextLine.startsWith(baseIndent) ? nextLine.slice(baseIndent.length) : ""; - if (/^(?: {2,}|\t)/.test(nextAfterBase)) { - definitionLines.push(""); - index += 1; - continue; - } - } - break; - } +function findClosingMathDelimiter(source, delimiter, startIndex) { + for (let index = startIndex; index <= source.length - delimiter.length; index += 1) { + if (source[index] === "\n") return -1; + if (source.startsWith(delimiter, index) && !isEscapedCharacter(source, index)) return index; + } + return -1; +} - footnoteDefinitions.set(id, definitionLines.join("\n").trim()); +function tokenizeDollarMath(source) { + if (source.startsWith("$$")) { + const closeIndex = findClosingMathDelimiter(source, "$$", 2); + if (closeIndex > 2) { + return { type: "inlineMath", raw: source.slice(0, closeIndex + 2), display: true }; + } + return null; } + if (source[0] !== "$" || !source[1] || /[\s$]/.test(source[1])) return null; + for (let index = 1; index < source.length; index += 1) { + if (source[index] === "\n") break; + if (source[index] !== "$" || isEscapedCharacter(source, index)) continue; + if (/\s/.test(source[index - 1]) || /\d/.test(source[index + 1] || "")) return null; + return { type: "inlineMath", raw: source.slice(0, index + 1), display: false }; + } + return null; +} - return preservedLines.join("\n"); +function createUniqueHeadingId(raw) { + const baseId = String(raw || "") + .toLowerCase() + .trim() + .replace(/<[^>]*>/g, '') + .replace(/\s+/g, '-') + .replace(/[^\w-]/g, '') + .replace(/-+/g, '-') || 'heading'; + let id = baseId; + let suffix = 0; + while (usedHeadingIds.has(id)) { + suffix += 1; + id = `${baseId}-${suffix}`; + } + usedHeadingIds.add(id); + return id; } -function applyFootnotes(markdown) { - const markdownWithReferences = markdown.replace(/\[\^([^\]\n]+)\]/g, function(match, idText) { - const id = idText.trim(); - if (!id) return match; - if (!footnoteOrder.includes(id)) footnoteOrder.push(id); +function normalizeWorkerMarkmapFences(markdown) { + const lines = String(markdown || '').split(/\r?\n/); + const output = []; + let index = 0; - const refCount = (footnoteRefCounts.get(id) || 0) + 1; - footnoteRefCounts.set(id, refCount); + while (index < lines.length) { + const opening = lines[index].match(/^([ \t]{0,3})(`{3,}|~{3,})([ \t]*)(.*)$/); + const info = opening ? opening[4].trim() : ''; + if (!opening || !/^markmap(?:\s|$)/i.test(info)) { + output.push(lines[index]); + index += 1; + continue; + } - const normalizedId = normalizeFootnoteId(id); - const refId = `fnref-${normalizedId}${refCount > 1 ? `-${refCount}` : ""}`; - if (!footnoteFirstRefId.has(id)) footnoteFirstRefId.set(id, refId); + const indent = opening[1]; + const fence = opening[2]; + const marker = fence[0]; + const content = []; + let nestedFence = null; + let maxInnerFenceLength = fence.length; + let closeIndex = -1; + + for (let scan = index + 1; scan < lines.length; scan += 1) { + const line = lines[scan]; + const fenceMatch = line.match(/^[ \t]{0,3}(`{3,}|~{3,})([ \t]*.*)$/); + if (fenceMatch) { + const currentFence = fenceMatch[1]; + const currentMarker = currentFence[0]; + const tail = fenceMatch[2].trim(); + if (currentMarker === marker) { + maxInnerFenceLength = Math.max(maxInnerFenceLength, currentFence.length); + } + if (nestedFence) { + if (currentMarker === nestedFence.marker && currentFence.length >= nestedFence.length && tail === '') { + nestedFence = null; + } + } else if (currentMarker === marker && currentFence.length >= fence.length && tail === '') { + closeIndex = scan; + break; + } else if (tail !== '') { + nestedFence = { marker: currentMarker, length: currentFence.length }; + } + } + content.push(line); + } - const noteNumber = footnoteOrder.indexOf(id) + 1; - return `[${noteNumber}]`; - }); + if (closeIndex === -1) { + output.push(lines[index]); + index += 1; + continue; + } - const footnotesHtml = footnoteOrder - .filter((id) => footnoteDefinitions.has(id)) - .map((id) => { - const normalizedId = normalizeFootnoteId(id); - const backRefId = footnoteFirstRefId.get(id) || `fnref-${normalizedId}`; - const backRefHtml = ``; - const noteHtml = renderDefinitionContent(footnoteDefinitions.get(id) || "", { appendHtml: backRefHtml }); - return `
  • ${noteHtml}
  • `; - }) - .join(""); + const normalizedFence = marker.repeat(maxInnerFenceLength + 1); + output.push(`${indent}${normalizedFence}${opening[3]}${opening[4]}`); + output.push(...content); + output.push(`${indent}${normalizedFence}`); + index = closeIndex + 1; + } - if (!footnotesHtml) return markdownWithReferences; - return `${markdownWithReferences}\n\n

      ${footnotesHtml}
    `; + return output.join('\n'); } function configureMarked() { @@ -199,7 +257,119 @@ function configureMarked() { return { type: "blockMath", raw: match[0], text: match[1] }; }, renderer(token) { - return `
    $$\n${token.text}\n$$
    \n`; + return `
    $$\n${escapeHtml(token.text)}\n$$
    \n`; + }, + }; + + const footnoteDefinitionExtension = { + name: "footnoteDefinition", + level: "block", + start(src) { + const match = src.match(/(?:^|\n)[ \t]{0,3}\[\^[^\]\n]+\]:/); + if (!match) return undefined; + return match.index + (match[0][0] === "\n" ? 1 : 0); + }, + tokenizer(src) { + if (suppressFootnotePreprocess) return undefined; + const lines = src.split("\n"); + const match = /^([ \t]{0,3})\[\^([^\]\n]+)\]:[ \t]*(.*)$/.exec(lines[0]); + if (!match) return undefined; + const baseIndent = match[1] || ""; + const id = match[2].trim(); + const definitionLines = [match[3] || ""]; + const rawLines = [lines[0]]; + let index = 1; + while (index < lines.length) { + const line = lines[index]; + if (!line.startsWith(baseIndent)) break; + const lineAfterBase = line.slice(baseIndent.length); + const indentedMatch = /^(?: {2,}|\t)(.*)$/.exec(lineAfterBase); + if (indentedMatch) { + rawLines.push(line); + definitionLines.push(indentedMatch[1]); + index += 1; + continue; + } + if (lineAfterBase.trim() === "") { + const nextLine = lines[index + 1] || ""; + const nextAfterBase = nextLine.startsWith(baseIndent) ? nextLine.slice(baseIndent.length) : ""; + if (/^(?: {2,}|\t)/.test(nextAfterBase)) { + rawLines.push(line); + definitionLines.push(""); + index += 1; + continue; + } + } + break; + } + let raw = rawLines.join("\n"); + if (src.startsWith(raw + "\n")) raw += "\n"; + footnoteDefinitions.set(id, definitionLines.join("\n").trim()); + return { type: "footnoteDefinition", raw }; + }, + renderer() { + return ""; + }, + }; + + const footnoteReferenceExtension = { + name: "footnoteReference", + level: "inline", + start(src) { + if (suppressFootnotePreprocess) return undefined; + const index = src.indexOf("[^"); + return index >= 0 ? index : undefined; + }, + tokenizer(src) { + if (suppressFootnotePreprocess) return undefined; + const match = /^\[\^([^\]\n]+)\]/.exec(src); + if (!match) return undefined; + return { type: "footnoteReference", raw: match[0], id: match[1].trim() }; + }, + renderer(token) { + const id = token.id; + if (!id) return token.raw; + if (!footnoteOrder.includes(id)) footnoteOrder.push(id); + const refCount = (footnoteRefCounts.get(id) || 0) + 1; + footnoteRefCounts.set(id, refCount); + const normalizedId = normalizeFootnoteId(id); + const refId = `fnref-${normalizedId}${refCount > 1 ? `-${refCount}` : ""}`; + if (!footnoteFirstRefId.has(id)) footnoteFirstRefId.set(id, refId); + const noteNumber = footnoteOrder.indexOf(id) + 1; + return `[${noteNumber}]`; + }, + }; + + const inlineMathExtension = { + name: "inlineMath", + level: "inline", + start(src) { + const match = INLINE_MATH_START_PATTERN.exec(src); + return match ? match.index : undefined; + }, + tokenizer(src) { + if (src.startsWith("\\$")) return { type: "inlineMath", raw: "\\$", literalDollar: true }; + if (src.startsWith("\\(") || src.startsWith("\\[")) { + const opening = src.slice(0, 2); + const closing = opening === "\\(" ? "\\)" : "\\]"; + const closeIndex = findClosingMathDelimiter(src, closing, 2); + if (closeIndex >= 2) { + return { + type: "inlineMath", + raw: src.slice(0, closeIndex + 2), + display: opening === "\\[", + }; + } + return undefined; + } + if (src[0] !== "$" || isEscapedCharacter(src, 0)) return undefined; + const mathToken = tokenizeDollarMath(src); + return mathToken || { type: "inlineMath", raw: "$", literalDollar: true }; + }, + renderer(token) { + if (token.literalDollar) return '$'; + const displayClass = token.display ? ' math-display-inline' : ''; + return `${escapeHtml(token.raw)}`; }, }; @@ -214,13 +384,22 @@ function configureMarked() { const lines = src.split("\n"); if (lines.length < 2) return undefined; - const term = lines[0]; - if (EMPTY_LINE_PATTERN.test(term) || MARKDOWN_LIST_MARKER_PATTERN.test(term)) return undefined; - if (!DEFINITION_LIST_ITEM_PATTERN.test(lines[1])) return undefined; - + const terms = []; + const rawLines = []; + let index = 0; + while (index < lines.length && !DEFINITION_LIST_ITEM_PATTERN.test(lines[index])) { + const term = lines[index]; + if ( + EMPTY_LINE_PATTERN.test(term) || + MARKDOWN_LIST_MARKER_PATTERN.test(term) || + DEFINITION_LIST_DISALLOWED_TERM_PATTERN.test(term) + ) return undefined; + terms.push(term.trim()); + rawLines.push(term); + index += 1; + } + if (terms.length === 0 || index >= lines.length) return undefined; const definitions = []; - const rawLines = [term]; - let index = 1; while (index < lines.length) { const itemMatch = DEFINITION_LIST_ITEM_PATTERN.exec(lines[index]); if (!itemMatch) break; @@ -255,14 +434,16 @@ function configureMarked() { if (definitions.length === 0) return undefined; let raw = rawLines.join("\n"); if (src.startsWith(raw + "\n")) raw += "\n"; - return { type: "definitionList", raw, term: term.trim(), definitions }; + return { type: "definitionList", raw, terms, definitions }; }, renderer(token) { - const termHtml = parseInlineWithoutFootnotes(token.term); + const termHtml = token.terms + .map((term) => `
    ${parseInlineWithoutFootnotes(term)}
    `) + .join(""); const definitionHtml = token.definitions .map((definition) => `
    ${renderDefinitionContent(definition)}
    `) .join(""); - return `
    ${termHtml}
    ${definitionHtml}
    \n`; + return `
    ${termHtml}${definitionHtml}
    \n`; }, }; @@ -278,7 +459,7 @@ function configureMarked() { return match ? { type: "superscript", raw: match[0], text: match[1] } : undefined; }, renderer(token) { - return `${marked.parseInline(token.text)}`; + return `${parseInlineWithoutFootnotes(token.text)}`; }, }; @@ -294,7 +475,7 @@ function configureMarked() { return match ? { type: "subscript", raw: match[0], text: match[1] } : undefined; }, renderer(token) { - return `${marked.parseInline(token.text)}`; + return `${parseInlineWithoutFootnotes(token.text)}`; }, }; @@ -310,7 +491,7 @@ function configureMarked() { return match ? { type: "highlight", raw: match[0], text: match[1] } : undefined; }, renderer(token) { - return `${marked.parseInline(token.text)}`; + return `${parseInlineWithoutFootnotes(token.text)}`; }, }; @@ -390,16 +571,7 @@ function configureMarked() { }; renderer.heading = function(text, level, raw) { - let id = raw - .toLowerCase() - .trim() - .replace(/<[^>]*>/g, '') - .replace(/\s+/g, '-') - .replace(/[^\w-]/g, '') - .replace(/-+/g, '-'); - if (!id) { - id = `heading-worker-${Math.random().toString(36).substr(2, 9)}`; - } + const id = createUniqueHeadingId(raw); return `${text}`; }; @@ -472,7 +644,10 @@ function configureMarked() { marked.use({ extensions: [ blockMathExtension, + footnoteDefinitionExtension, definitionListExtension, + inlineMathExtension, + footnoteReferenceExtension, superscriptExtension, subscriptExtension, highlightExtension, @@ -480,10 +655,12 @@ function configureMarked() { hooks: { preprocess(markdown) { if (suppressFootnotePreprocess) return markdown; - resetExtendedMarkdownState(); - const normalizedMarkdown = normalizeMarkmapFences(markdown); - const protectedMarkdown = normalizedMarkdown.replace(/\\\$/g, "$"); - return applyFootnotes(extractFootnoteDefinitions(protectedMarkdown)); + if (!preserveExtendedMarkdownState) resetExtendedMarkdownState(); + return normalizeMarkmapFences(markdown); + }, + postprocess(html) { + if (suppressFootnotePreprocess) return html; + return html + renderFootnotesSection(); }, }, }); @@ -580,7 +757,7 @@ function splitMarkdownBlocks(markdown) { } function renderSegmentedMarkdown(markdown, options) { - const normalizedMarkdown = normalizeMarkmapFences(markdown); + const normalizedMarkdown = normalizeWorkerMarkmapFences(markdown); if (!isSegmentedPreviewSafe(normalizedMarkdown)) { return { mode: "full-required", reason: "unsafe-markdown" }; } @@ -591,21 +768,28 @@ function renderSegmentedMarkdown(markdown, options) { } const seenHashes = new Map(); - const renderedBlocks = blocks.map((block) => { - const hash = hashString(block.source); - const seenCount = seenHashes.get(hash) || 0; - seenHashes.set(hash, seenCount + 1); - const html = marked.parse(block.source); - return { - id: `preview-block-${hash}-${seenCount}`, - hash, - html, - htmlLength: html.length, - sourceLength: block.source.length, - startLine: block.startLine, - endLine: block.endLine, - }; - }); + resetExtendedMarkdownState(); + preserveExtendedMarkdownState = true; + let renderedBlocks; + try { + renderedBlocks = blocks.map((block) => { + const hash = hashString(block.source); + const seenCount = seenHashes.get(hash) || 0; + seenHashes.set(hash, seenCount + 1); + const html = marked.parse(block.source); + return { + id: `preview-block-${hash}-${seenCount}`, + hash, + html, + htmlLength: html.length, + sourceLength: block.source.length, + startLine: block.startLine, + endLine: block.endLine, + }; + }); + } finally { + preserveExtendedMarkdownState = false; + } return { mode: "segmented", diff --git a/script.js b/script.js index 40e4c56a..1c8c0e11 100644 --- a/script.js +++ b/script.js @@ -2211,6 +2211,7 @@ document.addEventListener("DOMContentLoaded", async function () { const renderer = new marked.Renderer(); const BLOCK_MATH_MARKER_PATTERN = /^\$\$/m; const BLOCK_MATH_PATTERN = /^\$\$[ \t]*\n?([\s\S]*?)\n?\$\$[ \t]*(?:\n|$)/; + const INLINE_MATH_START_PATTERN = /\\(?:\$|\(|\[)|\$/; const RAW_MATH_TEXT_PATTERN = /\$\$|\$[^$]|\\\(|\\\[/; const MATHJAX_TEXT_TARGET_SELECTOR = 'p, li, td, th, dd, dt, blockquote, figcaption, h1, h2, h3, h4, h5, h6, .math-block'; const DEFINITION_LIST_ITEM_PATTERN = /^:[ \t]+(.*)$/; @@ -2218,11 +2219,13 @@ document.addEventListener("DOMContentLoaded", async function () { const SUBSCRIPT_PATTERN = /^~(?!~)(?!\s)([^~\n]*?\S)~(?!~)/; const HIGHLIGHT_PATTERN = /^==(?=\S)([\s\S]*?\S)==/; const MARKDOWN_LIST_MARKER_PATTERN = /^(\s*)(?:[-*+]\s+|\d+\.\s+|>\s+)/; + const DEFINITION_LIST_DISALLOWED_TERM_PATTERN = /^[ \t]{0,3}(?:`{3,}|~{3,}|#{1,6}(?:[ \t]+|$)|<\/?[a-zA-Z][\w:-]*(?:\s|>|\/>))/; const EMPTY_LINE_PATTERN = /^\s*$/; const footnoteDefinitions = new Map(); const footnoteOrder = []; const footnoteRefCounts = new Map(); const footnoteFirstRefId = new Map(); + const usedHeadingIds = new Set(); let anonymousFootnoteCounter = 0; let suppressFootnotePreprocess = false; @@ -2231,6 +2234,7 @@ document.addEventListener("DOMContentLoaded", async function () { footnoteOrder.length = 0; footnoteRefCounts.clear(); footnoteFirstRefId.clear(); + usedHeadingIds.clear(); anonymousFootnoteCounter = 0; } @@ -2484,85 +2488,7 @@ document.addEventListener("DOMContentLoaded", async function () { .join(""); } - function extractFootnoteDefinitions(markdown) { - const lines = markdown.split("\n"); - const preservedLines = []; - let index = 0; - - while (index < lines.length) { - const match = /^([ \t]{0,3})\[\^([^\]\n]+)\]:[ \t]*(.*)$/.exec(lines[index]); - if (!match) { - preservedLines.push(lines[index]); - index += 1; - continue; - } - - const baseIndent = match[1] || ""; - const id = match[2].trim(); - const definitionLines = [match[3] || ""]; - index += 1; - - while (index < lines.length) { - const line = lines[index]; - if (!line.startsWith(baseIndent)) { - break; - } - - const lineAfterBase = line.slice(baseIndent.length); - const indentedMatch = /^(?: {2,}|\t)(.*)$/.exec(lineAfterBase); - if (indentedMatch) { - definitionLines.push(indentedMatch[1]); - index += 1; - continue; - } - - if (lineAfterBase.trim() === "") { - const nextLine = lines[index + 1] || ""; - const nextAfterBase = nextLine.startsWith(baseIndent) - ? nextLine.slice(baseIndent.length) - : ""; - if (/^(?: {2,}|\t)/.test(nextAfterBase)) { - definitionLines.push(""); - index += 1; - continue; - } - } - - break; - } - - footnoteDefinitions.set(id, definitionLines.join("\n").trim()); - } - - return preservedLines.join("\n"); - } - - function applyFootnotes(markdown) { - const markdownWithReferences = markdown.replace(/\[\^([^\]\n]+)\]/g, function(match, idText) { - const id = idText.trim(); - if (!id) { - return match; - } - - if (!footnoteOrder.includes(id)) { - footnoteOrder.push(id); - } - - const refCount = (footnoteRefCounts.get(id) || 0) + 1; - footnoteRefCounts.set(id, refCount); - - const normalizedId = normalizeFootnoteId(id); - const refId = `fnref-${normalizedId}${refCount > 1 ? `-${refCount}` : ""}`; - if (!footnoteFirstRefId.has(id)) { - footnoteFirstRefId.set(id, refId); - } - - const noteNumber = footnoteOrder.indexOf(id) + 1; - const safeRefId = escapeHtmlAttribute(refId); - const safeNormalizedId = escapeHtmlAttribute(normalizedId); - return `[${noteNumber}]`; - }); - + function renderFootnotesSection() { const footnotesHtml = footnoteOrder .filter((id) => footnoteDefinitions.has(id)) .map((id) => { @@ -2579,11 +2505,76 @@ document.addEventListener("DOMContentLoaded", async function () { }) .join(""); - if (!footnotesHtml) { - return markdownWithReferences; + return footnotesHtml + ? `

      ${footnotesHtml}
    ` + : ""; + } + + function isEscapedCharacter(source, index) { + let backslashCount = 0; + for (let cursor = index - 1; cursor >= 0 && source[cursor] === "\\"; cursor -= 1) { + backslashCount += 1; } + return backslashCount % 2 === 1; + } - return `${markdownWithReferences}\n\n

      ${footnotesHtml}
    `; + function findClosingMathDelimiter(source, delimiter, startIndex) { + for (let index = startIndex; index <= source.length - delimiter.length; index += 1) { + if (source[index] === "\n") return -1; + if (source.startsWith(delimiter, index) && !isEscapedCharacter(source, index)) { + return index; + } + } + return -1; + } + + function tokenizeDollarMath(source) { + if (source.startsWith("$$")) { + const closeIndex = findClosingMathDelimiter(source, "$$", 2); + if (closeIndex > 2) { + return { + type: "inlineMath", + raw: source.slice(0, closeIndex + 2), + display: true, + }; + } + return null; + } + + if (source[0] !== "$" || !source[1] || /[\s$]/.test(source[1])) { + return null; + } + + for (let index = 1; index < source.length; index += 1) { + if (source[index] === "\n") break; + if (source[index] !== "$" || isEscapedCharacter(source, index)) continue; + if (/\s/.test(source[index - 1]) || /\d/.test(source[index + 1] || "")) return null; + return { + type: "inlineMath", + raw: source.slice(0, index + 1), + display: false, + }; + } + + return null; + } + + function createUniqueHeadingId(raw) { + const baseId = String(raw || "") + .toLowerCase() + .trim() + .replace(/<[^>]*>/g, '') + .replace(/\s+/g, '-') + .replace(/[^\w-]/g, '') + .replace(/-+/g, '-') || 'heading'; + let id = baseId; + let suffix = 0; + while (usedHeadingIds.has(id)) { + suffix += 1; + id = `${baseId}-${suffix}`; + } + usedHeadingIds.add(id); + return id; } const blockMathExtension = { @@ -2608,9 +2599,126 @@ document.addEventListener("DOMContentLoaded", async function () { }; }, renderer(token) { - return `
    $$\n${token.text}\n$$
    \n`; + return `
    $$\n${escapeHtml(token.text)}\n$$
    \n`; } }; + const footnoteDefinitionExtension = { + name: "footnoteDefinition", + level: "block", + start(src) { + const match = src.match(/(?:^|\n)[ \t]{0,3}\[\^[^\]\n]+\]:/); + if (!match) return undefined; + return match.index + (match[0][0] === "\n" ? 1 : 0); + }, + tokenizer(src) { + if (suppressFootnotePreprocess) return undefined; + const lines = src.split("\n"); + const match = /^([ \t]{0,3})\[\^([^\]\n]+)\]:[ \t]*(.*)$/.exec(lines[0]); + if (!match) return undefined; + + const baseIndent = match[1] || ""; + const id = match[2].trim(); + const definitionLines = [match[3] || ""]; + const rawLines = [lines[0]]; + let index = 1; + while (index < lines.length) { + const line = lines[index]; + if (!line.startsWith(baseIndent)) break; + const lineAfterBase = line.slice(baseIndent.length); + const indentedMatch = /^(?: {2,}|\t)(.*)$/.exec(lineAfterBase); + if (indentedMatch) { + rawLines.push(line); + definitionLines.push(indentedMatch[1]); + index += 1; + continue; + } + if (lineAfterBase.trim() === "") { + const nextLine = lines[index + 1] || ""; + const nextAfterBase = nextLine.startsWith(baseIndent) + ? nextLine.slice(baseIndent.length) + : ""; + if (/^(?: {2,}|\t)/.test(nextAfterBase)) { + rawLines.push(line); + definitionLines.push(""); + index += 1; + continue; + } + } + break; + } + + let raw = rawLines.join("\n"); + if (src.startsWith(raw + "\n")) raw += "\n"; + footnoteDefinitions.set(id, definitionLines.join("\n").trim()); + return { type: "footnoteDefinition", raw }; + }, + renderer() { + return ""; + }, + }; + const footnoteReferenceExtension = { + name: "footnoteReference", + level: "inline", + start(src) { + if (suppressFootnotePreprocess) return undefined; + const index = src.indexOf("[^"); + return index >= 0 ? index : undefined; + }, + tokenizer(src) { + if (suppressFootnotePreprocess) return undefined; + const match = /^\[\^([^\]\n]+)\]/.exec(src); + if (!match) return undefined; + return { type: "footnoteReference", raw: match[0], id: match[1].trim() }; + }, + renderer(token) { + const id = token.id; + if (!id) return token.raw; + if (!footnoteOrder.includes(id)) footnoteOrder.push(id); + const refCount = (footnoteRefCounts.get(id) || 0) + 1; + footnoteRefCounts.set(id, refCount); + const normalizedId = normalizeFootnoteId(id); + const refId = `fnref-${normalizedId}${refCount > 1 ? `-${refCount}` : ""}`; + if (!footnoteFirstRefId.has(id)) footnoteFirstRefId.set(id, refId); + const noteNumber = footnoteOrder.indexOf(id) + 1; + return `[${noteNumber}]`; + }, + }; + const inlineMathExtension = { + name: "inlineMath", + level: "inline", + start(src) { + const match = INLINE_MATH_START_PATTERN.exec(src); + return match ? match.index : undefined; + }, + tokenizer(src) { + if (src.startsWith("\\$")) { + return { type: "inlineMath", raw: "\\$", literalDollar: true }; + } + if (src.startsWith("\\(") || src.startsWith("\\[")) { + const opening = src.slice(0, 2); + const closing = opening === "\\(" ? "\\)" : "\\]"; + const closeIndex = findClosingMathDelimiter(src, closing, 2); + if (closeIndex >= 2) { + return { + type: "inlineMath", + raw: src.slice(0, closeIndex + 2), + display: opening === "\\[", + }; + } + return undefined; + } + if (src[0] !== "$" || isEscapedCharacter(src, 0)) return undefined; + const mathToken = tokenizeDollarMath(src); + return mathToken || { type: "inlineMath", raw: "$", literalDollar: true }; + }, + renderer(token) { + if (token.literalDollar) { + return '$'; + } + const displayClass = token.display ? ' math-display-inline' : ''; + return `${escapeHtml(token.raw)}`; + }, + }; const definitionListExtension = { name: "definitionList", level: "block", @@ -2627,18 +2735,22 @@ document.addEventListener("DOMContentLoaded", async function () { return undefined; } - const term = lines[0]; - if (EMPTY_LINE_PATTERN.test(term) || MARKDOWN_LIST_MARKER_PATTERN.test(term)) { - return undefined; - } - - if (!DEFINITION_LIST_ITEM_PATTERN.test(lines[1])) { - return undefined; + const terms = []; + const rawLines = []; + let index = 0; + while (index < lines.length && !DEFINITION_LIST_ITEM_PATTERN.test(lines[index])) { + const term = lines[index]; + if ( + EMPTY_LINE_PATTERN.test(term) || + MARKDOWN_LIST_MARKER_PATTERN.test(term) || + DEFINITION_LIST_DISALLOWED_TERM_PATTERN.test(term) + ) return undefined; + terms.push(term.trim()); + rawLines.push(term); + index += 1; } - + if (terms.length === 0 || index >= lines.length) return undefined; const definitions = []; - const rawLines = [term]; - let index = 1; while (index < lines.length) { const itemMatch = DEFINITION_LIST_ITEM_PATTERN.exec(lines[index]); if (!itemMatch) { @@ -2689,16 +2801,18 @@ document.addEventListener("DOMContentLoaded", async function () { return { type: "definitionList", raw: raw, - term: term.trim(), + terms: terms, definitions: definitions, }; }, renderer(token) { - const termHtml = parseInlineWithoutFootnotes(token.term); + const termHtml = token.terms + .map((term) => `
    ${parseInlineWithoutFootnotes(term)}
    `) + .join(""); const definitionHtml = token.definitions .map((definition) => `
    ${renderDefinitionContent(definition)}
    `) .join(""); - return `
    ${termHtml}
    ${definitionHtml}
    \n`; + return `
    ${termHtml}${definitionHtml}
    \n`; }, }; const superscriptExtension = { @@ -2720,7 +2834,7 @@ document.addEventListener("DOMContentLoaded", async function () { }; }, renderer(token) { - return `${marked.parseInline(token.text)}`; + return `${parseInlineWithoutFootnotes(token.text)}`; }, }; const subscriptExtension = { @@ -2742,7 +2856,7 @@ document.addEventListener("DOMContentLoaded", async function () { }; }, renderer(token) { - return `${marked.parseInline(token.text)}`; + return `${parseInlineWithoutFootnotes(token.text)}`; }, }; const highlightExtension = { @@ -2764,7 +2878,7 @@ document.addEventListener("DOMContentLoaded", async function () { }; }, renderer(token) { - return `${marked.parseInline(token.text)}`; + return `${parseInlineWithoutFootnotes(token.text)}`; }, }; @@ -2863,16 +2977,7 @@ document.addEventListener("DOMContentLoaded", async function () { }; renderer.heading = function (text, level, raw) { - let id = raw - .toLowerCase() - .trim() - .replace(/<[^>]*>/g, '') - .replace(/\s+/g, '-') - .replace(/[^\w-]/g, '') - .replace(/-+/g, '-'); - if (!id) { - id = 'heading-' + Math.random().toString(36).substr(2, 9); - } + const id = createUniqueHeadingId(raw); return `${text}`; }; @@ -2945,7 +3050,10 @@ document.addEventListener("DOMContentLoaded", async function () { marked.use({ extensions: [ blockMathExtension, + footnoteDefinitionExtension, definitionListExtension, + inlineMathExtension, + footnoteReferenceExtension, superscriptExtension, subscriptExtension, highlightExtension, @@ -2956,11 +3064,11 @@ document.addEventListener("DOMContentLoaded", async function () { return markdown; } resetExtendedMarkdownState(); - // ✅ Replace escaped dollar signs before marked.js strips the backslash. - // This prevents MathJax from treating lone $ as a math delimiter. - const normalizedMarkdown = normalizeMarkmapFences(markdown); - const protectedMarkdown = normalizedMarkdown.replace(/\\\$/g, '$'); - return applyFootnotes(extractFootnoteDefinitions(protectedMarkdown)); + return normalizeMarkmapFences(markdown); + }, + postprocess(html) { + if (suppressFootnotePreprocess) return html; + return html + renderFootnotesSection(); }, }, }); diff --git a/tests/e2e/data-loss-hardening.spec.js b/tests/e2e/data-loss-hardening.spec.js index f465e52e..3c5d6c05 100644 --- a/tests/e2e/data-loss-hardening.spec.js +++ b/tests/e2e/data-loss-hardening.spec.js @@ -341,6 +341,7 @@ test('IndexedDB fallback journal recovers a normal edit when localStorage is ful test('encrypted dirty journal restores the latest Secret Workspace edit after abrupt close', async ({ context, page }) => { await openApp(page); await page.locator('.document-tree-row[data-tree-id="workspace_secret"] .document-tree-main').click(); + await expect(page.locator('#secret-workspace-key')).toBeFocused(); await page.locator('#secret-workspace-key').fill('durable-secret-key'); await page.locator('#secret-workspace-key-confirm').fill('durable-secret-key'); await page.locator('#secret-workspace-modal-confirm').click(); @@ -365,6 +366,7 @@ test('encrypted dirty journal restores the latest Secret Workspace edit after ab const reopened = await context.newPage(); await openApp(reopened); await reopened.locator('.document-tree-row[data-tree-id="workspace_secret"] .document-tree-main').click(); + await expect(reopened.locator('#secret-workspace-key')).toBeFocused(); await reopened.locator('#secret-workspace-key').fill('durable-secret-key'); await reopened.locator('#secret-workspace-modal-confirm').click(); await expect(reopened.locator('#secret-workspace-modal')).toBeHidden(); diff --git a/tests/e2e/formatting-regressions.spec.js b/tests/e2e/formatting-regressions.spec.js new file mode 100644 index 00000000..90be6c32 --- /dev/null +++ b/tests/e2e/formatting-regressions.spec.js @@ -0,0 +1,224 @@ +const { test, expect } = require('@playwright/test'); +const { openApp, setEditorContent } = require('../helpers/app'); + +test.beforeEach(async ({ page }) => { + await openApp(page); +}); + +test('de-duplicates heading ids and resets slug state for each render', async ({ page }) => { + await setEditorContent(page, [ + '#### Test', + 'First section', + '', + '#### Test', + 'Second section', + '', + '#### Test-1', + 'Explicit suffix collision', + '', + '#### Test', + 'Third duplicate', + '', + '[Second heading](#test-1)' + ].join('\n')); + + await expect(page.locator('#markdown-preview h4')).toHaveCount(4); + await expect(page.locator('#markdown-preview h4').first()).toHaveAttribute('id', 'test'); + await expect(page.locator('#markdown-preview h4').nth(1)).toHaveAttribute('id', 'test-1'); + await expect(page.locator('#markdown-preview h4').nth(2)).toHaveAttribute('id', 'test-1-1'); + await expect(page.locator('#markdown-preview h4').nth(3)).toHaveAttribute('id', 'test-2'); + + await expect(page.locator('#markdown-preview a[href="#test-1"]')).toHaveAttribute('href', '#test-1'); + await page.locator('#markdown-preview a[href="#test-1"]').click(); + await expect(page.locator('#test-1')).toHaveText('Test'); + + await setEditorContent(page, '# Test'); + await expect(page.locator('#markdown-preview h1')).toHaveAttribute('id', 'test'); +}); + +test('de-duplicates headings across segmented worker blocks', async ({ page }) => { + const blocks = ['# Worker duplicate']; + for (let index = 0; index < 75; index += 1) { + blocks.push(`Paragraph ${index} ${'worker filler '.repeat(70)}`); + } + blocks.splice(40, 0, '# Worker duplicate'); + + await setEditorContent(page, blocks.join('\n\n')); + + await expect(page.locator('#markdown-preview .preview-render-block')).not.toHaveCount(0); + await expect(page.locator('#markdown-preview h1')).toHaveCount(2); + await expect(page.locator('#markdown-preview h1').first()).toHaveAttribute('id', 'worker-duplicate'); + await expect(page.locator('#markdown-preview h1').last()).toHaveAttribute('id', 'worker-duplicate-1'); +}); + +test('keeps GFM lists separated when the bullet character changes', async ({ page }) => { + await setEditorContent(page, [ + '- List 1, item 1', + '- List 1, item 2', + '* List 2, item 1', + '', + '+ Same marker, item 1', + '+ Same marker, item 2' + ].join('\n')); + + const lists = page.locator('#markdown-preview > ul'); + await expect(lists).toHaveCount(3); + await expect(lists.nth(0).locator('li')).toHaveCount(2); + await expect(lists.nth(1).locator('li')).toHaveCount(1); + await expect(lists.nth(2).locator('li')).toHaveCount(2); +}); + +test('distinguishes currency, escaped dollars, and valid inline math', async ({ page }) => { + await setEditorContent(page, [ + 'Options ran $20, $45, and $99 for the three tiers.', + '', + 'Escaped prices cost \\$20, \\$45, and \\$99.', + '', + 'Before text $x_1^2$ then $x_2^2$ after text.', + '', + 'A dollar inside math: $\\sqrt{\\$4}$.', + '', + 'Mixed price and math: $20 plus $x^2$.', + '', + 'Whitespace is literal: $ not math$ and $not math $.', + '', + 'Digit adjacency is literal: $x$5.' + ].join('\n')); + + const paragraphs = page.locator('#markdown-preview > p'); + await expect(paragraphs.nth(0)).toContainText('Options ran $20, $45, and $99 for the three tiers.'); + await expect(paragraphs.nth(0).locator('.math-inline')).toHaveCount(0); + await expect(paragraphs.nth(0).locator('.math-literal-dollar')).toHaveCount(3); + await expect(paragraphs.nth(1)).toContainText('Escaped prices cost $20, $45, and $99.'); + await expect(paragraphs.nth(1).locator('.math-inline')).toHaveCount(0); + await expect(paragraphs.nth(1).locator('.math-literal-dollar')).toHaveCount(3); + await expect(page.locator('#markdown-preview .math-inline')).toHaveCount(4); + await expect(page.locator('#markdown-preview .math-inline').nth(0)).toHaveText('$x_1^2$'); + await expect(page.locator('#markdown-preview .math-inline').nth(1)).toHaveText('$x_2^2$'); + await expect(page.locator('#markdown-preview .math-inline').nth(2)).toHaveText('$\\sqrt{\\$4}$'); + await expect(page.locator('#markdown-preview .math-inline').nth(3)).toHaveText('$x^2$'); + await expect(paragraphs.nth(4)).toContainText('Mixed price and math: $20 plus'); + await expect(paragraphs.nth(4).locator('.math-literal-dollar')).toHaveCount(1); +}); + +test('does not transform dollars or footnotes inside code', async ({ page }) => { + await setEditorContent(page, [ + '~~~md', + 'Options ran \\$20, \\$45, and \\$99.', + '[^fenced]: must stay code', + '~~~', + '', + 'Inline code: `[^inline]: must stay code` and `\\$20`.', + '', + 'Real note[^note] and repeated note[^note].', + '', + '[^note]: Footnote body.', + ' Continued body.' + ].join('\n')); + + await expect(page.locator('#markdown-preview pre code')).toHaveText([ + 'Options ran \\$20, \\$45, and \\$99.', + '[^fenced]: must stay code' + ].join('\n')); + await expect(page.locator('#markdown-preview p code').nth(0)).toHaveText('[^inline]: must stay code'); + await expect(page.locator('#markdown-preview p code').nth(1)).toHaveText('\\$20'); + await expect(page.locator('#markdown-preview .footnote-ref')).toHaveCount(2); + await expect(page.locator('#fnref-note')).toHaveText('[1]'); + await expect(page.locator('#fnref-note-2')).toHaveText('[1]'); + await expect(page.locator('#markdown-preview .footnotes')).toContainText('Footnote body.'); + await expect(page.locator('#markdown-preview .footnotes')).toContainText('Continued body.'); +}); + +test('supports Markdown Extra multi-term definition lists without changing ordinary prose', async ({ page }) => { + await setEditorContent(page, [ + '~~~md', + 'Code term A', + 'Code term B', + ': Must stay literal code', + '~~~', + '', + 'Term A', + 'Term B', + ': Shared definition for both terms above', + ': Alternate shared definition', + '', + 'Single term', + ': Single definition', + '', + 'Ordinary line A', + 'Ordinary line B' + ].join('\n')); + + const lists = page.locator('#markdown-preview dl'); + await expect(lists).toHaveCount(2); + await expect(page.locator('#markdown-preview pre code')).toContainText(': Must stay literal code'); + await expect(lists.nth(0).locator('dt')).toHaveText(['Term A', 'Term B']); + await expect(lists.nth(0).locator('dd')).toHaveText([ + 'Shared definition for both terms above', + 'Alternate shared definition' + ]); + await expect(lists.nth(1).locator('dt')).toHaveText(['Single term']); + await expect(page.locator('#markdown-preview > p').last()).toContainText('Ordinary line A'); + await expect(page.locator('#markdown-preview > p').last()).toContainText('Ordinary line B'); +}); + +test('keeps TeX atomic while preserving superscript, subscript, and highlight outside math', async ({ page }) => { + await setEditorContent(page, [ + 'Outside ^two^, ~down~, and ==marked==.', + '', + 'Before text $x_1^2$ then $x_2^2$ after text.', + '', + 'Before text $right\\_ascension = 5h35m$ after text.', + '', + 'Before text $\\left\\{ x \\mid x > 0 \\right\\}$ after text.', + '', + '$A_cE\\left\\{-\\dfrac{du(x_1)}{dx},\\ \\dfrac{du(x_2)}{dx}\\right\\}$', + '', + 'Other delimiters: \\(x^2\\) and \\[y^2\\].', + '', + '$$', + 'A_cE\\left\\{-\\dfrac{du(x_1)}{dx},\\ \\dfrac{du(x_2)}{dx}\\right\\}', + '$$' + ].join('\n')); + + await expect(page.locator('#markdown-preview')).toContainText('Outside two, down, and marked.'); + await expect(page.locator('#markdown-preview > p sup')).toHaveText('two'); + await expect(page.locator('#markdown-preview > p sub')).toHaveText('down'); + await expect(page.locator('#markdown-preview > p mark')).toHaveText('marked'); + + const math = page.locator('#markdown-preview .math-inline'); + await expect(math).toHaveCount(7); + await expect(math.nth(0)).toHaveText('$x_1^2$'); + await expect(math.nth(1)).toHaveText('$x_2^2$'); + await expect(math.nth(2)).toHaveText('$right\\_ascension = 5h35m$'); + await expect(math.nth(3)).toHaveText('$\\left\\{ x \\mid x > 0 \\right\\}$'); + await expect(math.nth(4)).toHaveText('$A_cE\\left\\{-\\dfrac{du(x_1)}{dx},\\ \\dfrac{du(x_2)}{dx}\\right\\}$'); + await expect(math.nth(5)).toHaveText('\\(x^2\\)'); + await expect(math.nth(6)).toHaveText('\\[y^2\\]'); + await expect(page.locator('#markdown-preview .math-block')).toContainText('A_cE\\left\\{'); +}); + +test('preserves standard MathJax color syntax and sanitizes extension output', async ({ page }) => { + await setEditorContent(page, [ + '$\\color{red}{v\\^2} \\color{blue}{G}M\\left(\\color{green}{\\frac{2}{r}} - \\color{purple}{\\frac{1}{a}}\\right)$', + '', + '${\\color{red}v^2} + \\textcolor{blue}{G}M$', + '', + '$x y$', + '', + 'Safe note[^safe].', + '', + '[^safe]: body' + ].join('\n')); + + const math = page.locator('#markdown-preview .math-inline'); + await expect(math).toHaveCount(3); + await expect(math.nth(0)).toContainText('\\color{red}{v\\^2}'); + await expect(math.nth(1)).toContainText('{\\color{red}v^2}'); + await expect(page.locator('#markdown-preview svg')).toHaveCount(0); + await expect(page.locator('#markdown-preview [onload], #markdown-preview [onerror]')).toHaveCount(0); + await expect.poll(() => page.evaluate(() => ({ math: window.__mathXss, note: window.__footnoteXss }))).toEqual({ + math: undefined, + note: undefined + }); +}); diff --git a/tests/e2e/tab-split-sidebar-update.spec.js b/tests/e2e/tab-split-sidebar-update.spec.js index 14246dc6..24173bbc 100644 --- a/tests/e2e/tab-split-sidebar-update.spec.js +++ b/tests/e2e/tab-split-sidebar-update.spec.js @@ -233,6 +233,7 @@ test('closing a tab keeps the document in Files and reopening restores the tab', await page.locator('#tab-new-btn').click(); const closedTabId = await page.locator('#tab-list .tab-item.active').getAttribute('data-tab-id'); await setEditorContent(page, '# Kept document'); + await expect.poll(async () => JSON.stringify(await storedDocuments(page))).toContain('# Kept document'); await page.locator('#tab-list .tab-item.active .tab-close-btn').click(); await expect(page.locator(`.tab-item[data-tab-id="${closedTabId}"]`)).toHaveCount(0); From 0fc25712b76d066dcb18fce53986e981ef0ee2f2 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Mon, 24 Aug 2026 21:38:11 +0530 Subject: [PATCH 2/3] fix(math): scope legacy color arguments --- desktop-app/resources/js/preview-worker.js | 70 +++++++++++++++++++++- desktop-app/resources/js/script.js | 70 +++++++++++++++++++++- preview-worker.js | 70 +++++++++++++++++++++- script.js | 70 +++++++++++++++++++++- tests/e2e/formatting-regressions.spec.js | 25 +++++++- 5 files changed, 290 insertions(+), 15 deletions(-) diff --git a/desktop-app/resources/js/preview-worker.js b/desktop-app/resources/js/preview-worker.js index cde51f8c..ae127b54 100644 --- a/desktop-app/resources/js/preview-worker.js +++ b/desktop-app/resources/js/preview-worker.js @@ -160,6 +160,70 @@ function tokenizeDollarMath(source) { return null; } +function readBalancedTexGroup(source, startIndex) { + if (source[startIndex] !== "{") return null; + let depth = 0; + for (let index = startIndex; index < source.length; index += 1) { + if (isEscapedCharacter(source, index)) continue; + if (source[index] === "{") { + depth += 1; + } else if (source[index] === "}") { + depth -= 1; + if (depth === 0) { + return { endIndex: index, content: source.slice(startIndex + 1, index) }; + } + } + } + return null; +} + +function normalizeLegacyMathSyntax(source) { + const tex = String(source || ""); + let normalized = ""; + let index = 0; + + while (index < tex.length) { + if (tex.startsWith("\\^", index) && !isEscapedCharacter(tex, index)) { + normalized += "^"; + index += 2; + continue; + } + + if ( + !tex.startsWith("\\color", index) || + isEscapedCharacter(tex, index) || + /[a-zA-Z]/.test(tex[index + 6] || "") + ) { + normalized += tex[index]; + index += 1; + continue; + } + + let colorGroupStart = index + 6; + while (/[ \t]/.test(tex[colorGroupStart] || "")) colorGroupStart += 1; + const colorGroup = readBalancedTexGroup(tex, colorGroupStart); + if (!colorGroup) { + normalized += tex[index]; + index += 1; + continue; + } + + let contentGroupStart = colorGroup.endIndex + 1; + while (/[ \t]/.test(tex[contentGroupStart] || "")) contentGroupStart += 1; + const contentGroup = readBalancedTexGroup(tex, contentGroupStart); + if (!contentGroup) { + normalized += tex[index]; + index += 1; + continue; + } + + normalized += `{\\color{${colorGroup.content}}${normalizeLegacyMathSyntax(contentGroup.content)}}`; + index = contentGroup.endIndex + 1; + } + + return normalized; +} + function createUniqueHeadingId(raw) { const baseId = String(raw || "") .toLowerCase() @@ -257,7 +321,7 @@ function configureMarked() { return { type: "blockMath", raw: match[0], text: match[1] }; }, renderer(token) { - return `
    $$\n${escapeHtml(token.text)}\n$$
    \n`; + return `
    $$\n${escapeHtml(normalizeLegacyMathSyntax(token.text))}\n$$
    \n`; }, }; @@ -369,7 +433,7 @@ function configureMarked() { renderer(token) { if (token.literalDollar) return '$'; const displayClass = token.display ? ' math-display-inline' : ''; - return `${escapeHtml(token.raw)}`; + return `${escapeHtml(normalizeLegacyMathSyntax(token.raw))}`; }, }; @@ -560,7 +624,7 @@ function configureMarked() { } if (language === "math") { - return `
    $$\n${code}\n$$
    \n`; + return `
    $$\n${escapeHtml(normalizeLegacyMathSyntax(code))}\n$$
    \n`; } const validLanguage = hljs && hljs.getLanguage(language) ? language : "plaintext"; diff --git a/desktop-app/resources/js/script.js b/desktop-app/resources/js/script.js index 1c8c0e11..f9167374 100644 --- a/desktop-app/resources/js/script.js +++ b/desktop-app/resources/js/script.js @@ -2559,6 +2559,70 @@ document.addEventListener("DOMContentLoaded", async function () { return null; } + function readBalancedTexGroup(source, startIndex) { + if (source[startIndex] !== "{") return null; + let depth = 0; + for (let index = startIndex; index < source.length; index += 1) { + if (isEscapedCharacter(source, index)) continue; + if (source[index] === "{") { + depth += 1; + } else if (source[index] === "}") { + depth -= 1; + if (depth === 0) { + return { endIndex: index, content: source.slice(startIndex + 1, index) }; + } + } + } + return null; + } + + function normalizeLegacyMathSyntax(source) { + const tex = String(source || ""); + let normalized = ""; + let index = 0; + + while (index < tex.length) { + if (tex.startsWith("\\^", index) && !isEscapedCharacter(tex, index)) { + normalized += "^"; + index += 2; + continue; + } + + if ( + !tex.startsWith("\\color", index) || + isEscapedCharacter(tex, index) || + /[a-zA-Z]/.test(tex[index + 6] || "") + ) { + normalized += tex[index]; + index += 1; + continue; + } + + let colorGroupStart = index + 6; + while (/[ \t]/.test(tex[colorGroupStart] || "")) colorGroupStart += 1; + const colorGroup = readBalancedTexGroup(tex, colorGroupStart); + if (!colorGroup) { + normalized += tex[index]; + index += 1; + continue; + } + + let contentGroupStart = colorGroup.endIndex + 1; + while (/[ \t]/.test(tex[contentGroupStart] || "")) contentGroupStart += 1; + const contentGroup = readBalancedTexGroup(tex, contentGroupStart); + if (!contentGroup) { + normalized += tex[index]; + index += 1; + continue; + } + + normalized += `{\\color{${colorGroup.content}}${normalizeLegacyMathSyntax(contentGroup.content)}}`; + index = contentGroup.endIndex + 1; + } + + return normalized; + } + function createUniqueHeadingId(raw) { const baseId = String(raw || "") .toLowerCase() @@ -2599,7 +2663,7 @@ document.addEventListener("DOMContentLoaded", async function () { }; }, renderer(token) { - return `
    $$\n${escapeHtml(token.text)}\n$$
    \n`; + return `
    $$\n${escapeHtml(normalizeLegacyMathSyntax(token.text))}\n$$
    \n`; } }; const footnoteDefinitionExtension = { @@ -2716,7 +2780,7 @@ document.addEventListener("DOMContentLoaded", async function () { return '$'; } const displayClass = token.display ? ' math-display-inline' : ''; - return `${escapeHtml(token.raw)}`; + return `${escapeHtml(normalizeLegacyMathSyntax(token.raw))}`; }, }; const definitionListExtension = { @@ -2966,7 +3030,7 @@ document.addEventListener("DOMContentLoaded", async function () { } if (language === 'math') { - return `
    $$\n${code}\n$$
    \n`; + return `
    $$\n${escapeHtml(normalizeLegacyMathSyntax(code))}\n$$
    \n`; } const validLanguage = hljs.getLanguage(language) ? language : "plaintext"; diff --git a/preview-worker.js b/preview-worker.js index cde51f8c..ae127b54 100644 --- a/preview-worker.js +++ b/preview-worker.js @@ -160,6 +160,70 @@ function tokenizeDollarMath(source) { return null; } +function readBalancedTexGroup(source, startIndex) { + if (source[startIndex] !== "{") return null; + let depth = 0; + for (let index = startIndex; index < source.length; index += 1) { + if (isEscapedCharacter(source, index)) continue; + if (source[index] === "{") { + depth += 1; + } else if (source[index] === "}") { + depth -= 1; + if (depth === 0) { + return { endIndex: index, content: source.slice(startIndex + 1, index) }; + } + } + } + return null; +} + +function normalizeLegacyMathSyntax(source) { + const tex = String(source || ""); + let normalized = ""; + let index = 0; + + while (index < tex.length) { + if (tex.startsWith("\\^", index) && !isEscapedCharacter(tex, index)) { + normalized += "^"; + index += 2; + continue; + } + + if ( + !tex.startsWith("\\color", index) || + isEscapedCharacter(tex, index) || + /[a-zA-Z]/.test(tex[index + 6] || "") + ) { + normalized += tex[index]; + index += 1; + continue; + } + + let colorGroupStart = index + 6; + while (/[ \t]/.test(tex[colorGroupStart] || "")) colorGroupStart += 1; + const colorGroup = readBalancedTexGroup(tex, colorGroupStart); + if (!colorGroup) { + normalized += tex[index]; + index += 1; + continue; + } + + let contentGroupStart = colorGroup.endIndex + 1; + while (/[ \t]/.test(tex[contentGroupStart] || "")) contentGroupStart += 1; + const contentGroup = readBalancedTexGroup(tex, contentGroupStart); + if (!contentGroup) { + normalized += tex[index]; + index += 1; + continue; + } + + normalized += `{\\color{${colorGroup.content}}${normalizeLegacyMathSyntax(contentGroup.content)}}`; + index = contentGroup.endIndex + 1; + } + + return normalized; +} + function createUniqueHeadingId(raw) { const baseId = String(raw || "") .toLowerCase() @@ -257,7 +321,7 @@ function configureMarked() { return { type: "blockMath", raw: match[0], text: match[1] }; }, renderer(token) { - return `
    $$\n${escapeHtml(token.text)}\n$$
    \n`; + return `
    $$\n${escapeHtml(normalizeLegacyMathSyntax(token.text))}\n$$
    \n`; }, }; @@ -369,7 +433,7 @@ function configureMarked() { renderer(token) { if (token.literalDollar) return '$'; const displayClass = token.display ? ' math-display-inline' : ''; - return `${escapeHtml(token.raw)}`; + return `${escapeHtml(normalizeLegacyMathSyntax(token.raw))}`; }, }; @@ -560,7 +624,7 @@ function configureMarked() { } if (language === "math") { - return `
    $$\n${code}\n$$
    \n`; + return `
    $$\n${escapeHtml(normalizeLegacyMathSyntax(code))}\n$$
    \n`; } const validLanguage = hljs && hljs.getLanguage(language) ? language : "plaintext"; diff --git a/script.js b/script.js index 1c8c0e11..f9167374 100644 --- a/script.js +++ b/script.js @@ -2559,6 +2559,70 @@ document.addEventListener("DOMContentLoaded", async function () { return null; } + function readBalancedTexGroup(source, startIndex) { + if (source[startIndex] !== "{") return null; + let depth = 0; + for (let index = startIndex; index < source.length; index += 1) { + if (isEscapedCharacter(source, index)) continue; + if (source[index] === "{") { + depth += 1; + } else if (source[index] === "}") { + depth -= 1; + if (depth === 0) { + return { endIndex: index, content: source.slice(startIndex + 1, index) }; + } + } + } + return null; + } + + function normalizeLegacyMathSyntax(source) { + const tex = String(source || ""); + let normalized = ""; + let index = 0; + + while (index < tex.length) { + if (tex.startsWith("\\^", index) && !isEscapedCharacter(tex, index)) { + normalized += "^"; + index += 2; + continue; + } + + if ( + !tex.startsWith("\\color", index) || + isEscapedCharacter(tex, index) || + /[a-zA-Z]/.test(tex[index + 6] || "") + ) { + normalized += tex[index]; + index += 1; + continue; + } + + let colorGroupStart = index + 6; + while (/[ \t]/.test(tex[colorGroupStart] || "")) colorGroupStart += 1; + const colorGroup = readBalancedTexGroup(tex, colorGroupStart); + if (!colorGroup) { + normalized += tex[index]; + index += 1; + continue; + } + + let contentGroupStart = colorGroup.endIndex + 1; + while (/[ \t]/.test(tex[contentGroupStart] || "")) contentGroupStart += 1; + const contentGroup = readBalancedTexGroup(tex, contentGroupStart); + if (!contentGroup) { + normalized += tex[index]; + index += 1; + continue; + } + + normalized += `{\\color{${colorGroup.content}}${normalizeLegacyMathSyntax(contentGroup.content)}}`; + index = contentGroup.endIndex + 1; + } + + return normalized; + } + function createUniqueHeadingId(raw) { const baseId = String(raw || "") .toLowerCase() @@ -2599,7 +2663,7 @@ document.addEventListener("DOMContentLoaded", async function () { }; }, renderer(token) { - return `
    $$\n${escapeHtml(token.text)}\n$$
    \n`; + return `
    $$\n${escapeHtml(normalizeLegacyMathSyntax(token.text))}\n$$
    \n`; } }; const footnoteDefinitionExtension = { @@ -2716,7 +2780,7 @@ document.addEventListener("DOMContentLoaded", async function () { return '$'; } const displayClass = token.display ? ' math-display-inline' : ''; - return `${escapeHtml(token.raw)}`; + return `${escapeHtml(normalizeLegacyMathSyntax(token.raw))}`; }, }; const definitionListExtension = { @@ -2966,7 +3030,7 @@ document.addEventListener("DOMContentLoaded", async function () { } if (language === 'math') { - return `
    $$\n${code}\n$$
    \n`; + return `
    $$\n${escapeHtml(normalizeLegacyMathSyntax(code))}\n$$
    \n`; } const validLanguage = hljs.getLanguage(language) ? language : "plaintext"; diff --git a/tests/e2e/formatting-regressions.spec.js b/tests/e2e/formatting-regressions.spec.js index 90be6c32..b37b30e6 100644 --- a/tests/e2e/formatting-regressions.spec.js +++ b/tests/e2e/formatting-regressions.spec.js @@ -198,12 +198,24 @@ test('keeps TeX atomic while preserving superscript, subscript, and highlight ou await expect(page.locator('#markdown-preview .math-block')).toContainText('A_cE\\left\\{'); }); -test('preserves standard MathJax color syntax and sanitizes extension output', async ({ page }) => { +test('scopes legacy MathJax color syntax without changing standard switches or sanitization', async ({ page }) => { await setEditorContent(page, [ '$\\color{red}{v\\^2} \\color{blue}{G}M\\left(\\color{green}{\\frac{2}{r}} - \\color{purple}{\\frac{1}{a}}\\right)$', '', '${\\color{red}v^2} + \\textcolor{blue}{G}M$', '', + '$\\color{red}{outer \\color{blue}{inner} outer} + z$', + '', + '$\\color{orange} x + y$', + '', + '$$', + '\\color{teal}{A_{nested}}', + '$$', + '', + '```math', + '\\color{brown}{B^2}', + '```', + '', '$x y$', '', 'Safe note[^safe].', @@ -212,9 +224,16 @@ test('preserves standard MathJax color syntax and sanitizes extension output', a ].join('\n')); const math = page.locator('#markdown-preview .math-inline'); - await expect(math).toHaveCount(3); - await expect(math.nth(0)).toContainText('\\color{red}{v\\^2}'); + await expect(math).toHaveCount(5); + await expect(math.nth(0)).toContainText('${\\color{red}v^2} {\\color{blue}G}M\\left({\\color{green}\\frac{2}{r}} - {\\color{purple}\\frac{1}{a}}\\right)$'); + await expect(math.nth(0)).not.toContainText('\\^'); await expect(math.nth(1)).toContainText('{\\color{red}v^2}'); + await expect(math.nth(2)).toContainText('${\\color{red}outer {\\color{blue}inner} outer} + z$'); + await expect(math.nth(3)).toContainText('$\\color{orange} x + y$'); + const mathBlocks = page.locator('#markdown-preview .math-block'); + await expect(mathBlocks).toHaveCount(2); + await expect(mathBlocks.nth(0)).toContainText('{\\color{teal}A_{nested}}'); + await expect(mathBlocks.nth(1)).toContainText('{\\color{brown}B^2}'); await expect(page.locator('#markdown-preview svg')).toHaveCount(0); await expect(page.locator('#markdown-preview [onload], #markdown-preview [onerror]')).toHaveCount(0); await expect.poll(() => page.evaluate(() => ({ math: window.__mathXss, note: window.__footnoteXss }))).toEqual({ From fc54b9749d5bc93624abde4f8ab3c14c00428776 Mon Sep 17 00:00:00 2001 From: Baivab Sarkar Date: Mon, 24 Aug 2026 23:07:46 +0530 Subject: [PATCH 3/3] fix(markdown): harden renderer edge cases --- desktop-app/resources/js/preview-worker.js | 90 +++++++++---- desktop-app/resources/js/script.js | 94 ++++++++----- preview-worker.js | 90 +++++++++---- script.js | 94 ++++++++----- tests/e2e/formatting-regressions.spec.js | 148 +++++++++++++++++++++ 5 files changed, 398 insertions(+), 118 deletions(-) diff --git a/desktop-app/resources/js/preview-worker.js b/desktop-app/resources/js/preview-worker.js index ae127b54..9a81e6d5 100644 --- a/desktop-app/resources/js/preview-worker.js +++ b/desktop-app/resources/js/preview-worker.js @@ -30,8 +30,9 @@ const DEFINITION_LIST_ITEM_PATTERN = /^:[ \t]+(.*)$/; const SUPERSCRIPT_PATTERN = /^\^(?!\s)([^^\n]*?\S)\^(?!\^)/; const SUBSCRIPT_PATTERN = /^~(?!~)(?!\s)([^~\n]*?\S)~(?!~)/; const HIGHLIGHT_PATTERN = /^==(?=\S)([\s\S]*?\S)==/; -const MARKDOWN_LIST_MARKER_PATTERN = /^(\s*)(?:[-*+]\s+|\d+\.\s+|>\s+)/; -const DEFINITION_LIST_DISALLOWED_TERM_PATTERN = /^[ \t]{0,3}(?:`{3,}|~{3,}|#{1,6}(?:[ \t]+|$)|<\/?[a-zA-Z][\w:-]*(?:\s|>|\/>))/; +const MARKDOWN_LIST_MARKER_PATTERN = /^(\s*)(?:[-*+]\s+|\d{1,9}[.)]\s+|>\s*)/; +const DEFINITION_LIST_DISALLOWED_TERM_PATTERN = /^(?: {4}|\t|[ \t]{0,3}(?:`{3,}|~{3,}|#{1,6}(?:[ \t]+|$)|(?:[*_-][ \t]*){3,}$|[=-]+[ \t]*$|\[[^\]\n]+\]:[ \t]*\S|', + '', + 'Visible content.', + '', + filler + ].join('\n')); + + await expect(page.locator('#markdown-preview .preview-render-block')).toHaveCount(0); + await expect(page.locator('#markdown-preview')).not.toContainText('hidden first'); + await expect(page.locator('#markdown-preview')).not.toContainText('hidden second'); + await expect(page.locator('#markdown-preview')).toContainText('Visible content.'); +}); + test('keeps GFM lists separated when the bullet character changes', async ({ page }) => { await setEditorContent(page, [ '- List 1, item 1', @@ -129,6 +210,37 @@ test('does not transform dollars or footnotes inside code', async ({ page }) => await expect(page.locator('#markdown-preview .footnotes')).toContainText('Continued body.'); }); +test('matches footnote labels case-insensitively and links every defined reference', async ({ page }) => { + await setEditorContent(page, [ + 'Undefined[^missing].', + '', + 'Case reference[^Note].', + '', + 'Repeated[^repeat] and again[^REPEAT].', + '', + 'Slug collision[^a!] and another[^a?].', + '', + '[^note]: Case-insensitive definition.', + '[^repeat]: Repeated definition.', + '[^a!]: First collision definition.', + '[^a?]: Second collision definition.' + ].join('\n')); + + await expect(page.locator('#markdown-preview > p').first()).toContainText('Undefined[^missing].'); + await expect(page.locator('#markdown-preview .footnote-ref')).toHaveText(['[1]', '[2]', '[2]', '[3]', '[4]']); + await expect(page.locator('#markdown-preview .footnotes li')).toHaveCount(4); + await expect(page.locator('#fn-note')).toContainText('Case-insensitive definition.'); + await expect(page.locator('#fn-repeat')).toContainText('Repeated definition.'); + await expect(page.locator('#fn-a')).toContainText('First collision definition.'); + await expect(page.locator('#fn-a-1')).toContainText('Second collision definition.'); + + const repeatedBackrefs = page.locator('#fn-repeat .footnote-backref'); + await expect(repeatedBackrefs).toHaveCount(2); + await expect(repeatedBackrefs.nth(0)).toHaveAttribute('href', '#fnref-repeat'); + await expect(repeatedBackrefs.nth(1)).toHaveAttribute('href', '#fnref-repeat-2'); + await expect(repeatedBackrefs.nth(1)).toContainText('2'); +}); + test('supports Markdown Extra multi-term definition lists without changing ordinary prose', async ({ page }) => { await setEditorContent(page, [ '~~~md', @@ -162,6 +274,42 @@ test('supports Markdown Extra multi-term definition lists without changing ordin await expect(page.locator('#markdown-preview > p').last()).toContainText('Ordinary line B'); }); +test('does not let definition lists preempt GFM and CommonMark block constructs', async ({ page }) => { + await setEditorContent(page, [ + '1) Ordered item', + ': lazy continuation in the list', + '', + 'Setext heading', + '---', + ': paragraph after the heading', + '', + '***', + ': paragraph after the thematic break', + '', + ' indented code', + ': paragraph after the code', + '', + '[ref]: https://example.com', + ': paragraph after the reference definition', + '', + '[uses ref][ref]', + '', + 'A | B', + '--- | ---', + ': table row' + ].join('\n')); + + await expect(page.locator('#markdown-preview dl')).toHaveCount(0); + await expect(page.locator('#markdown-preview ol')).toHaveCount(1); + await expect(page.locator('#markdown-preview ol')).toContainText(': lazy continuation in the list'); + await expect(page.locator('#markdown-preview h2')).toHaveText('Setext heading'); + await expect(page.locator('#markdown-preview hr')).toHaveCount(1); + await expect(page.locator('#markdown-preview pre code')).toHaveText('indented code\n'); + await expect(page.locator('#markdown-preview a[href="https://example.com"]')).toHaveText('uses ref'); + await expect(page.locator('#markdown-preview table')).toHaveCount(1); + await expect(page.locator('#markdown-preview table tbody td').first()).toHaveText(': table row'); +}); + test('keeps TeX atomic while preserving superscript, subscript, and highlight outside math', async ({ page }) => { await setEditorContent(page, [ 'Outside ^two^, ~down~, and ==marked==.',