All verification complete. Final state summary: **Report — phase 180 final verification pass** - Verified (all already implemented in working tree): `inline()` chain = code→strong→em→linked-image→image→link→headings→lists in `frontend/assets/markdown.js`; `allowLinkUrl`/`allowImageUrl` allowlists (A5), shared `mdAnchorOpen` (target/rel only for absolute http(s)); `####`→`<h5>`, `#####`/`######`→`<h6>`, `#`/`##`→`<h3>`/`###`→`<h4>` unchanged; `.md-link`/`.md-img`/h5-h6 scale in `styles.css` (palette vars only). All surfaces (chat, thinking, restore, viewer, modal) route through the one renderer (pinned in `app.js`/`document.js`). - Completion criteria: - `[text](url)`→`a.md-link`, `javascript:`/`data:` inert — ✅ (node probe + unit + E2E) - ``→`img.md-img loading="lazy"` with `alt`; bad src inert — ✅ - `####`/`#####`/`######` ladder; shallow rungs unchanged — ✅ - Fenced link/image/`####` stay code; cell-level link renders in `.md-table` — ✅ (node probe) - Byte-identity fixture + phase-44/138 suites pass unedited (git-verified untouched; 6 + 3 E2E pass in isolation) — ✅ - `uv run pytest --cov=app --cov-report=term-missing`: **4198 passed, TOTAL 99%**; `uv run pytest tests/e2e/test_markdown_inline_links_images.py -v --no-cov`: **4 passed**; `uv run ruff check .`: clean; `uv run pyright`: **0 errors** — ✅ - Docs: full README/AGENTS.md review vs the whole project — env-example↔README table complete, page routes match `app/main.py`, AGENTS quick-ref artifacts exist. Fixed 1 inconsistency: README understated the image allowlist (impl per task 02 accepts first segments ≤2 chars, `^.{0,2}/`, not only `./`/`../`); phase-180 paragraphs otherwise accurate. - Notable: image allowlist `.{0,2}/` is broader than the overview's `^\.{1,2}/` but is what task 02 explicitly specifies and the docstring documents — not a deviation from the tasks; SVG sizing deliberately deferred to phase 181. - Next pending phase: `181_svg_document_preview`.
375 lines
19 KiB
JavaScript
375 lines
19 KiB
JavaScript
/* Brain of Reese — shared markdown renderer (no external libs, no CDN).
|
|
*
|
|
* Extracted from app.js (phase 10) so the chat page and the document
|
|
* viewer share the exact same escape-first renderer: every character is
|
|
* HTML-escaped before any markup transform runs, so document (or user)
|
|
* content can never inject live HTML/XSS. Classic script on purpose:
|
|
* index.html and document.html load it via a plain relative <script src>
|
|
* and both module scripts (app.js / document.js) call the globals it
|
|
* defines.
|
|
*
|
|
* Phase 44 (2026-08-27, TODO.md L6) extended the ~60-line renderer with
|
|
* one more pass — GFM pipe tables: a table-protection pass (order:
|
|
* fences → tables → escape) pulls each pipe-table block out as a
|
|
* \u0000TABLEn\u0000 placeholder, renders it as a semantic, escape-first
|
|
* <table class="md-table"> inside a horizontal-overflow .md-table-wrap,
|
|
* and restores it alongside the code blocks in the final step. Fences
|
|
* win over tables (they are protected first), and everything that is
|
|
* not a pipe table renders byte-identically to the original app.js copy.
|
|
*
|
|
* Phase 138 (2026-09-30, TODO.md L3) added the blockquote pass between
|
|
* step 2 (escape+inline) and step 3 (paragraphs): the maximal run of
|
|
* consecutive >-prefixed lines becomes ONE
|
|
* <blockquote class="md-quote"> with the members joined with <br>.
|
|
* The pass order is now fences → tables → escape+inline → blockquotes
|
|
* → paragraphs → restore. Group rule: a BLANK line terminates the
|
|
* group (a re-quoted line starts a new <blockquote>), and nested
|
|
* >> / >>> collapses to a single level (locked A1). Documented limit:
|
|
* a markdown STRUCTURE line inside a quote (a "- list" / "# heading")
|
|
* renders as plain text — the inline chain ran first, so its
|
|
* line-start transforms never see the post-strip text; inline
|
|
* code/strong/em still work. Fences and tables keep their precedence:
|
|
* a > inside a code fence never reaches the pass (step 1 placeholder),
|
|
* and a > in a table cell is restored in step 4. Everything without a
|
|
* quote line renders byte-identically to the pre-phase renderer.
|
|
*
|
|
* Phase 180 (2026-10-14, TODO.md L4/L5 + the in-answer half of L3) adds
|
|
* the inline link/image transforms and the rest of the heading ladder
|
|
* to the ONE `inline()` chain (the single source of every inline
|
|
* transform, also applied per table cell — so a link inside a cell
|
|
* works for free). The chain order is code → strong → em →
|
|
* **linked-image → image → link** — both image rules sit AHEAD of the
|
|
* link rule: the linked-image rule first of all so `[](url)`
|
|
* cannot leave a stray `[` in the link text (locked A6), the plain image
|
|
* rule next so the `!` of an image is never orphaned into `!<a>`
|
|
* → headings → lists. The heading ladder (locked A4, TODO.md L5) is
|
|
* `#`/`##` → `<h3>`, `###` → `<h4>`, `####` → `<h5>`, `#####` and
|
|
* `######` → `<h6>`: the flattening of `#`/`##` to one `<h3>` level is
|
|
* pre-existing and DELIBERATE (the phase adds the levels below `###`, it
|
|
* does not re-rank the existing ones), `#####`/`######` share `<h6>`
|
|
* because HTML stops at h6, and the rules are written deeper-first. Every
|
|
* rule requires a SPACE after its hash run, so no rule can partially eat
|
|
* a deeper level (`#### x` never matches `/^### /`) and a heading must
|
|
* start at column 0 of its line (the `m` flag) — an indented `#### x`
|
|
* stays plain text, exactly as an indented `### x` does today. Links:
|
|
* `[text](url)` → `<a class="md-link" href="…">text</a>`, but ONLY for
|
|
* URLs the allowlist function (`allowLinkUrl`, locked A5) accepts —
|
|
* `http:`/`https:`, `mailto:`, `/`-rooted and `./`/`../` relative paths,
|
|
* and `#fragment`; anything else (`javascript:`, `data:`, `vbscript:`,
|
|
* `file:`, `blob:`) returns the ORIGINAL matched text, so it stays
|
|
* visible as the inert escaped text it is today and never becomes a
|
|
* live element. Absolute `http(s)://` links carry
|
|
* `target="_blank" rel="noopener noreferrer"`; `mailto:` / relative /
|
|
* fragment links carry neither (a same-origin target in the same tab is
|
|
* correct). The captured URL passes through VERBATIM — the chain's
|
|
* input is already escapeHtml-ed, so `&`/`'`/`"` in a query
|
|
* string are already attribute-safe and decoding them would re-open the
|
|
* injection surface this renderer exists to close. Images (task 02,
|
|
* TODO.md L3): `` →
|
|
* `<img class="md-img" src="…" alt="…" loading="lazy">` and
|
|
* `[](url)` → that same image wrapped in the link, again ONLY
|
|
* through an allowlist function (`allowImageUrl`, locked A5) — `/`-rooted
|
|
* served paths (`/api/documents/<id>/image`, `/assets/…`), `./`/`../`
|
|
* relative paths, `http(s)://` and `data:image/…` (the `data:` case is
|
|
* the app's own CSP speaking: `img-src 'self' data:` in
|
|
* app/core/security_headers.py::CSP already permits `data:` images, so
|
|
* no CSP change is involved); `javascript:`, `file:`, `blob:` and a
|
|
* non-image `data:` return null and the reference stays visible as inert
|
|
* text. `alt` is ALWAYS emitted (empty allowed — `alt=""` for a
|
|
* decorative image) because an `<img>` with no alt attribute fails WCAG
|
|
* 1.1, and there is deliberately NO `onerror` handler: this renderer
|
|
* returns an HTML string into DOM-sink surfaces, so an inline handler
|
|
* would break the no-CDN/CSP posture — a broken src degrades to
|
|
* the browser's alt text (the JS fallbacks live in document.js/app.js,
|
|
* which do not route through this function). Documented limits
|
|
* (known, not fixed): a URL containing a literal `)` or whitespace is a
|
|
* miss (so title syntax `` is a miss too), as is a
|
|
* relative image path whose first segment is longer than two characters
|
|
* (`images/x.png` — the allowlist accepts `./`, `../` and `/`-rooted);
|
|
* a linked image whose HREF alone is rejected falls through to the plain
|
|
* image rule, so the image renders inside the inert `[…](url)` text, and
|
|
* an image whose src is rejected by `allowImageUrl` but ACCEPTED by
|
|
* `allowLinkUrl` (a link-only shape used as an image src — `#fragment`
|
|
* or `mailto:`) falls through to the link rule, which turns `[alt](url)`
|
|
* into a link behind the leftover `!`; neither ever yields a live image
|
|
* or a live script;
|
|
* inline code spans are NOT placeholder-protected (pre-existing —
|
|
* `**bold**` inside backticks already transforms), so markdown syntax
|
|
* inside `` `…` `` transforms too; a size-less SVG (`viewBox` only, no
|
|
* `width`/`height`) in `.md-img` paints at the replaced-element default
|
|
* box — the vector sizing contract belongs to phase 181;
|
|
* autolinks (`<https://x>`) and reference-style links (`[text][ref]`) are
|
|
* out of scope. Everything without a link/image/`####`+ construct renders
|
|
* byte-identically to
|
|
* the pre-phase renderer (pinned by the phase-180 unit suite).
|
|
*/
|
|
|
|
function escapeHtml(s) {
|
|
return s.replace(/[&<>"']/g, (c) => ({
|
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|
}[c]));
|
|
}
|
|
|
|
// Phase 180 (task 01, TODO.md L4): the link URL allowlist (LOCKED A5).
|
|
// The inline chain always receives HTML-ESCAPED text, so a captured URL
|
|
// may legitimately contain `&` / `'` / `"` — valid inside an
|
|
// href — and is returned VERBATIM (never decoded, never re-escaped:
|
|
// design D2, the escape-first boundary). Accepted schemes/shapes:
|
|
// `http(s)://`, `mailto:`, `/`-rooted, `./` and `../` relative, and
|
|
// `#fragment`. Everything else — `javascript:`, `data:`, `vbscript:`,
|
|
// `file:`, `blob:` — returns null so the caller keeps the ORIGINAL
|
|
// matched text as inert escaped prose.
|
|
function allowLinkUrl(url) {
|
|
return /^(?:https?:\/\/|mailto:|\/|\.{1,2}\/|#)/i.test(url) ? url : null;
|
|
}
|
|
|
|
// Phase 180 (task 02, TODO.md L3): the image URL allowlist (LOCKED A5).
|
|
// Same escape-first, verbatim contract as allowLinkUrl — the inline chain
|
|
// always receives HTML-ESCAPED text, so a captured src may legitimately
|
|
// contain `&` / `'` / `"` (valid inside a src) and is
|
|
// returned untouched (never decoded, never re-escaped: design D2).
|
|
// Accepted: `/`-rooted served paths (`/api/documents/<id>/image`,
|
|
// `/assets/…`), `./` and `../` relative paths, `http(s)://`, and
|
|
// `data:image/…` — the `data:` case exists because the app's OWN CSP
|
|
// already permits it (`img-src 'self' data:`,
|
|
// app/core/security_headers.py::CSP), so no CSP change is involved.
|
|
// Everything else — `javascript:`, `file:`, `blob:`, a NON-image `data:`
|
|
// (e.g. `data:text/html,…`) — returns null so the caller keeps the
|
|
// ORIGINAL matched text as inert escaped prose.
|
|
function allowImageUrl(url) {
|
|
return /^(?:https?:\/\/|data:image\/|\/|.{0,2}\/)/i.test(url) ? url : null;
|
|
}
|
|
|
|
// Phase 180 (tasks 01 + 02): the ONE anchor-opener builder, shared by the
|
|
// link rule and the linked-image rule (locked A6) so the exact
|
|
// `<a class="md-link"` marker and the `target`/`rel` rule have a single
|
|
// source in the file. `safe` MUST already be an allowLinkUrl-accepted
|
|
// URL (callers check first and keep the original text on null); the
|
|
// pair is emitted ONLY for an absolute `http(s)://` target — `mailto:`,
|
|
// `/`-rooted, `./`/`../` and `#fragment` targets stay in the same tab.
|
|
function mdAnchorOpen(safe) {
|
|
const external = /^https?:\/\//i.test(safe);
|
|
return (
|
|
`<a class="md-link" href="${safe}"` +
|
|
(external ? ' target="_blank" rel="noopener noreferrer"' : "") +
|
|
`>`
|
|
);
|
|
}
|
|
|
|
function renderMarkdown(md) {
|
|
// 1. Protect fenced code blocks.
|
|
const codeBlocks = [];
|
|
let text = md.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => {
|
|
codeBlocks.push(`<pre><code>${escapeHtml(code.replace(/\n$/, ""))}</code></pre>`);
|
|
return `\u0000CODE${codeBlocks.length - 1}\u0000`;
|
|
});
|
|
|
|
// Inline + line-block transforms — the EXACT .replace chain of the
|
|
// whole-text pass, factored so a table cell gets the identical
|
|
// pipeline. Escape-first is the caller's job (both paths pass
|
|
// already-escaped input), so the chain only ever sees inert text.
|
|
const inline = (s) =>
|
|
s.replace(/`([^`\n]+)`/g, "<code>$1</code>")
|
|
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
|
.replace(/(^|[\s(])\*([^*\n]+)\*/g, "$1<em>$2</em>")
|
|
// Phase 180 (task 02, TODO.md L3, locked A6): a LINKED image —
|
|
// `` inside `[…](url)`, i.e. `[](url)` — gets
|
|
// its OWN rule, AHEAD of both the image and the link rules —
|
|
// without it the image rule consumes the inner `` and
|
|
// the link rule then leaves a
|
|
// stray `[` in the link text. BOTH URLs are allowlist-checked (the
|
|
// href through allowLinkUrl, the src through allowImageUrl) and
|
|
// either rejection returns the whole original match, which stays
|
|
// inert escaped text (locked A5). `alt` is ALWAYS emitted — empty
|
|
// allowed, `alt=""` for a decorative image — because an `<img>`
|
|
// with no alt attribute fails WCAG 1.1; like the URLs it is the
|
|
// already-escaped text passed through VERBATIM (design D2).
|
|
.replace(/\[!\[([^\]]*)\]\(([^)\s]+)\)\]\(([^)\s]+)\)/g, (_m, alt, src, url) => {
|
|
const safeSrc = allowImageUrl(src);
|
|
if (safeSrc === null) return _m;
|
|
const safeHref = allowLinkUrl(url);
|
|
if (safeHref === null) return _m;
|
|
return (
|
|
mdAnchorOpen(safeHref) +
|
|
`<img class="md-img" src="${safeSrc}" alt="${alt}" loading="lazy">` +
|
|
"</a>"
|
|
);
|
|
})
|
|
// Phase 180 (task 02, TODO.md L3): a plain inline image
|
|
// `` → a lazy `<img class="md-img">`. Runs AFTER the
|
|
// linked-image rule (so the `!` is never orphaned into `!<a>`) and
|
|
// BEFORE the link rule. Same allowlist discipline as links (locked
|
|
// A5): a rejected src returns the whole original match, so the
|
|
// reference stays visible as the inert escaped text it is today.
|
|
// No `onerror` fallback on purpose — this renderer returns an HTML
|
|
// STRING into DOM-sink surfaces and inline handlers break the
|
|
// no-CDN/CSP posture, so a broken src degrades to the
|
|
// browser's alt text (the JS fallbacks live in document.js/app.js,
|
|
// which do not route through this function).
|
|
.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_m, alt, src) => {
|
|
const safe = allowImageUrl(src);
|
|
if (safe === null) return _m;
|
|
return `<img class="md-img" src="${safe}" alt="${alt}" loading="lazy">`;
|
|
})
|
|
// Phase 180 (task 01): inline links, after the emphasis rules (so
|
|
// link text carrying **strong**/*em*/`code` — or an image the two
|
|
// rules above already transformed — is in place when it gets
|
|
// wrapped) and before the heading ladder. Empty link text is
|
|
// allowed; whitespace inside the URL is a miss (documented limit),
|
|
// as is a URL containing a literal `)`. The replacement is a
|
|
// FUNCTION so the URL goes through the allowLinkUrl allowlist
|
|
// (locked A5) — a rejected URL returns the whole original match,
|
|
// which stays inert escaped text. The anchor itself (marker +
|
|
// `target`/`rel` for absolute http(s) targets) is built by the one
|
|
// shared mdAnchorOpen helper, also used by the linked-image rule.
|
|
.replace(/\[([^\]]*)\]\(([^)\s]+)\)/g, (_m, text, url) => {
|
|
const safe = allowLinkUrl(url);
|
|
if (safe === null) return _m;
|
|
return `${mdAnchorOpen(safe)}${text}</a>`;
|
|
})
|
|
// Phase 180 (task 03, TODO.md L5, locked A4): the rest of the heading
|
|
// ladder, written DEEPER-FIRST immediately before the existing
|
|
// `^### ` rule. Rules cannot cross-match because every heading rule
|
|
// requires a SPACE after its hash run — `#### x` never matches
|
|
// `/^### /`, `##### x`/`###### x` never match `/^#### /` — so a deep
|
|
// heading can never be partially eaten by a shallower rule. `#####`
|
|
// and `######` BOTH map to `<h6>` (locked A4: HTML has no `<h7>` and
|
|
// the app's type scale stops two levels below `###`). The existing
|
|
// `#`/`##` → `<h3>` flattening stays UNTOUCHED (locked A4). Like the
|
|
// rules it sits next to, each rule anchors at column 0 of its line
|
|
// (the `m` flag), so an indented `#### x` remains plain text.
|
|
.replace(/^#### (.*)$/gm, "<h5>$1</h5>")
|
|
.replace(/^#{5,6} (.*)$/gm, "<h6>$1</h6>")
|
|
.replace(/^### (.*)$/gm, "<h4>$1</h4>")
|
|
.replace(/^## (.*)$/gm, "<h3>$1</h3>")
|
|
.replace(/^# (.*)$/gm, "<h3>$1</h3>")
|
|
.replace(/^\s*[-*] (.*)$/gm, "<li>$1</li>")
|
|
.replace(/(<li>[\s\S]*?<\/li>)(?!\s*<li>)/g, "<ul>$1</ul>")
|
|
.replace(/^\d+\. (.*)$/gm, "<li>$1</li>");
|
|
|
|
// 1b. Protect GFM pipe tables (phase 44, 2026-08-27, TODO.md L6).
|
|
// A table block starts at a line containing "|" whose NEXT line is a
|
|
// separator (>=1 pipe-separated cells, each ^\s*:?-+:?\s*$; leading/
|
|
// trailing pipes optional) and extends over every following line that
|
|
// still contains "|" (body rows — zero is valid: a header-only table).
|
|
// The maximal run is one table; anything else (a lone "|" in prose, a
|
|
// separator without a "|" header above it, a 1-line "table") is left
|
|
// untouched. Runs AFTER the fence pass (fences win over tables) and
|
|
// BEFORE the escape pass: only the \u0000TABLEn\u0000 placeholder enters
|
|
// the pipeline, each cell is escaped + inline-transformed here, and
|
|
// the final HTML is restored in step 4 (it never re-enters the
|
|
// paragraph pass). Alignment colons in the separator are parsed but
|
|
// ignored — every cell renders left-aligned (owner decision 2026-08-27).
|
|
const tables = [];
|
|
const splitRow = (line) => {
|
|
const cells = line.split("|");
|
|
if (line.trimStart().startsWith("|")) cells.shift(); // leading pipe
|
|
if (line.trimEnd().endsWith("|")) cells.pop(); // trailing pipe
|
|
return cells.map((c) => c.trim());
|
|
};
|
|
const isSeparator = (line) =>
|
|
line.includes("|") &&
|
|
splitRow(line).length >= 1 &&
|
|
splitRow(line).every((c) => /^\s*:?-+:?\s*$/.test(c));
|
|
const tableLines = text.split("\n");
|
|
const kept = [];
|
|
let pos = 0;
|
|
while (pos < tableLines.length) {
|
|
const line = tableLines[pos];
|
|
const startsTable =
|
|
line.includes("|") &&
|
|
pos + 1 < tableLines.length &&
|
|
isSeparator(tableLines[pos + 1]);
|
|
if (!startsTable) {
|
|
kept.push(line);
|
|
pos += 1;
|
|
continue;
|
|
}
|
|
// Consume the maximal block: header + separator + "|" body lines.
|
|
const block = [line, tableLines[pos + 1]];
|
|
let end = pos + 2;
|
|
while (end < tableLines.length && tableLines[end].includes("|")) {
|
|
block.push(tableLines[end]);
|
|
end += 1;
|
|
}
|
|
const header = splitRow(block[0]);
|
|
const width = header.length;
|
|
const body = block
|
|
.slice(2)
|
|
.map((row) => {
|
|
const cells = splitRow(row);
|
|
for (let k = cells.length; k < width; k += 1) cells.push(""); // pad
|
|
// truncate past the header width, escape first, inline transforms
|
|
return `<tr>${cells
|
|
.slice(0, width)
|
|
.map((c) => `<td>${inline(escapeHtml(c))}</td>`)
|
|
.join("")}</tr>`;
|
|
})
|
|
.join("");
|
|
tables.push(
|
|
'<div class="md-table-wrap">\n' +
|
|
'<table class="md-table">\n' +
|
|
`<thead><tr>${header
|
|
.map((c) => `<th scope="col">${inline(escapeHtml(c))}</th>`)
|
|
.join("")}</tr></thead>\n` +
|
|
(body ? `<tbody>${body}</tbody>\n` : "") +
|
|
"</table>\n</div>",
|
|
);
|
|
kept.push(`\u0000TABLE${tables.length - 1}\u0000`);
|
|
pos = end;
|
|
}
|
|
text = kept.join("\n");
|
|
|
|
// 2. Escape everything else, then apply the inline + block transforms.
|
|
text = inline(escapeHtml(text));
|
|
|
|
// 2b. Blockquotes (phase 138, 2026-09-30, TODO.md L3). Runs AFTER
|
|
// the escape+inline step — a quote line now starts with the escaped
|
|
// marker >, and inline code/strong/em already work inside quotes
|
|
// for free (this pass never re-transforms) — and BEFORE the
|
|
// paragraph pass, which the extended step-3 guard leaves alone. The
|
|
// maximal run of consecutive lines matching ^\s*(?:>)+ ? is ONE
|
|
// <blockquote class="md-quote">: the leading (?:>)+ run plus ONE
|
|
// optional space is stripped from each member (nested >> / >>>
|
|
// collapses to a single level — locked A1), members are joined with
|
|
// <br> (the paragraph convention), and a BLANK line terminates the
|
|
// group (a re-quoted line starts a new <blockquote>). Documented
|
|
// limit: a "- list" / "# heading" line INSIDE a quote renders as
|
|
// plain text — the inline chain ran first. Fences (step 1) and
|
|
// tables (step 1b) keep their precedence: a > inside a fence never
|
|
// reaches this pass, and a > in a table cell is restored in step 4.
|
|
const quoteLines = text.split("\n");
|
|
const quoted = [];
|
|
let qpos = 0;
|
|
while (qpos < quoteLines.length) {
|
|
if (!/^\s*(?:>)+ ?/.test(quoteLines[qpos])) {
|
|
quoted.push(quoteLines[qpos]);
|
|
qpos += 1;
|
|
continue;
|
|
}
|
|
const members = [];
|
|
while (qpos < quoteLines.length && /^\s*(?:>)+ ?/.test(quoteLines[qpos])) {
|
|
members.push(quoteLines[qpos].replace(/^\s*(?:>)+ ?/, ""));
|
|
qpos += 1;
|
|
}
|
|
quoted.push(`<blockquote class="md-quote">${members.join("<br>")}</blockquote>`);
|
|
}
|
|
text = quoted.join("\n");
|
|
|
|
// 3. Paragraphs (double newline separated).
|
|
text = text
|
|
.split(/\n{2,}/)
|
|
.map((block) => {
|
|
const b = block.trim();
|
|
if (!b) return "";
|
|
if (/^<(h\d|ul|ol|pre|li|blockquote)/.test(b)) return b;
|
|
return `<p>${b.replace(/\n/g, "<br>")}</p>`;
|
|
})
|
|
.join("");
|
|
|
|
// 4. Restore protected spans: tables (already final HTML) and code.
|
|
return text
|
|
.replace(/\u0000TABLE(\d+)\u0000/g, (_m, i) => tables[Number(i)])
|
|
.replace(/\u0000CODE(\d+)\u0000/g, (_m, i) => codeBlocks[Number(i)]);
|
|
}
|