Files
brain-of-reese/frontend/assets/markdown.js
T
ducoterra b34b8ca672
Build and Push Containers / build-and-push-app (push) Successful in 1m46s
Build and Push Containers / build-and-push-db (push) Successful in 12s
phase: 182_verbatim_show_text
**Phase 182 — final verification pass (all 8 tasks already complete; this was verification + docs + one defect fix).**

- Verified: `app/rag/verbatim.py` (locator grammar, `splitlines()` slice, line-boundary cap, `resolve_answer_blocks`), `SHOW_TEXT_TOOL` + `shown_texts`, `SHOW_TEXT_CLAUSE`, `done.answer_text` (9 fields, key dropped when empty) + `ChatMessageRecord.answer_text` (≤50, 600 000-char cap counted in the 8 M budget), `verbatim.js` + 4 `{verbatim:true}` renderers, `Containerfile` minify step, `.env.example` 3 vars.
- `uv run python -c "…answer_text, show_text_max_chars, show_text_max_blocks"` → `True 12000 5`; each negative refuses startup naming its field; `0` accepted as inert ✅
- `bash .agents/validate.sh` → **validation OK**: 4320 unit+integration tests green, `TOTAL 6849 17 → 99%` (gate app/ >90% OK), `ruff check .` "All checks passed!", `pyright` 0 errors (re-run after my edits, 08:28).
- E2E, each in isolation `--no-cov`: `test_verbatim_show_text.py` 13 passed (panel bytes == `documents.content`, server caption, clipboard == server string, `/document.html?source=…&path=…`, honest unresolved state, reload+share, `show_image` regression, flag-off app) ✅; green: `test_answer_images` `test_harness_aligned_tools` `test_agent_document_tools` `test_markdown_tables` `test_markdown_inline_links_images` `test_svg_document_preview` `test_smoke` `test_document_viewer` `test_markdown_blockquotes` `test_chat_persistence` `test_thinking_timeline` `test_chat_rag` `test_settings_mobile_value_width`.
- **Defect found & fixed (pre-existing, not phase 182):** `tests/e2e/test_admin_settings_panel.py::test_admin_sees_groups_and_source_badges` failed — its whole-page `assert "reese" not in page.content()` tripped on the shell's onboarding chips, which follow the newest saved chats in the one shared E2E DB (a leftover `reeselink.json` question from a tool suite). Scoped the strict sweep to `.settings-shell` and kept a whole-page `reese:reese` credential needle → 12 passed with the leftovers still present.
- Docs: README fixed 2 inconsistencies — Quick Start omitted the **required** `BOR_ADMIN_PASSWORD`/`BOR_SESSION_SECRET` (the app fail-loud refuses boot) and the broken anchor `#chat--` → `#chat-`; full re-read of README (1816 lines) + AGENTS confirmed the phase-182 feature/settings/log/UI-contract text matches the code, and every `.env.example` var + every `Settings` field is documented.
- Notable: kept `resolve_answer_blocks` synchronous on the SSE terminal path (bounded indexed SELECTs, matching the existing `query_log` precedent and the phase's documented fence-lookup bound); no live-infra changes made.
- Next pending phase: none — `todo/` holds only 182 (its 8 task files are in `complete/`; the harness commits + moves `00_phase.md`).
2026-10-15 08:33:12 -04:00

507 lines
27 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 &gt;-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 `[![alt](src)](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 `&amp;`/`&#39;`/`&quot;` 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): `![alt](src)` →
* `<img class="md-img" src="…" alt="…" loading="lazy">` and
* `[![alt](src)](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 `![alt](src "title")` 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).
*
* Phase 182 (task 06) adds the OPT-IN verbatim pass and the second,
* optional argument: `renderMarkdown(md, opts)`, where the ONLY truthy path
* is `opts && opts.verbatim === true`. When on, a ` ```bor ` LOCATOR fence
* (``` ```bor homelab/runbooks/nginx.md#12-40 ````) is replaced — info
* line, body and closing fence — with a `\u0000VERBATIMn\u0000` placeholder
* that the final restore step emits as
* `<div class="verbatim verbatim-pending" data-bor-locator="…">`.
* WHY THE MODEL'S BODY IS DISCARDED (the phase's core contract, locked A7):
* the bytes inside a `bor` fence are the MODEL's re-typed copy of a
* knowledge-base file, and a re-typed copy is exactly the bug this phase
* kills — it can silently differ from the indexed bytes. So the renderer
* keeps only the LOCATION (normalized by the local `borLocator`, the client
* mirror of Python `app.rag.verbatim.canonical_locator`) and paints nothing
* but the pending caption; the server
* (`app.rag.verbatim.resolve_answer_blocks`) supplies the real bytes and
* `verbatim.js` (task 07) hydrates the placeholder by matching
* `data-bor-locator`. An UNPARSEABLE locator is put back byte-for-byte and
* falls through to the ordinary fence pass — no placeholder, no badge, the
* model's own body — and a fence left OPEN at the very end of the input
* (the info line typed, the body not yet streamed) shows that same pending
* state instead of raw backticks. The server's `fences_in` is STRICTER
* there (it ignores an unclosed fence: the server only ever sees a finished
* answer, the client sees a partial one); a half-typed BODY therefore still
* reads as ordinary text until its closing fence lands, exactly like every
* other incomplete fence does today. PASS ORDER with the option on:
* verbatim → fences → tables → escape+inline → blockquotes → paragraphs →
* restore (a ` ``` ` inside a `bor` body ends the fence at the FIRST closer
* — the documented limitation the server shares, so both sides agree). The
* placeholder is the one block element the paragraph pass lets through
* unwrapped. BYTE-IDENTICAL WHEN OFF: with no second argument — every
* existing call site, the document viewer (`document.js`), the thinking
* blocks and the user bubbles — `opts` is undefined, the pass never runs,
* no `\u0000VERBATIM` placeholder exists anywhere in the text, the restore
* replace is a no-op, and a document that legitimately contains a ` ```bor `
* fence still renders its own text (the phase-44/138/180 fixtures stay
* byte-identical, pinned by their own suites).
*/
function escapeHtml(s) {
return s.replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[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 `&amp;` / `&#39;` / `&quot;` — 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 `&amp;` / `&#39;` / `&quot;` (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"' : "") +
`>`
);
}
// Phase 182 (task 06): the CLIENT MIRROR of Python
// `app.rag.verbatim.canonical_locator` — the tolerant locator grammar of
// phase 182 D7, re-implemented here because the renderer runs in the
// browser and cannot import Python. PARITY IS LOAD-BEARING: the canonical
// string this returns becomes the `data-bor-locator` key that `verbatim.js`
// (task 07) matches against the server's `done.answer_text[].locator`, so a
// divergence means a resolved block never hydrates. The parity table in
// tests/unit/test_frontend_verbatim_markdown.py pins both sides; `fences_in`
// on the server pins the fence shape.
//
// Grammar: a combined `source/path` (the `read`/`grep` identity contract,
// split at the FIRST `/`) plus an OPTIONAL separator (` `, `:`, `#`, `#L`)
// and line range — `12-40`, `12\u201340` (en dash), `L12-L40`, or a bare `12`
// (one line). Returns `source/path` for a whole document and
// `source/path#12-40` for a range (canonical DIGITS only, never the en dash,
// never the `L`), or `""` (NOT a locator) for an empty/whitespace input, a
// value with no `/` in the identity, a non-numeric or non-positive bound,
// `start > end`, trailing junk after the range, a dangling separator
// (`a.md#`), or a leading `bor` token the caller left in.
function borLocator(raw) {
const text = String(raw).trim();
if (!text) return "";
// A caller that pasted the fence's language token in has already lost the
// identity — refuse rather than guess which token was the path.
if (text.split(" ")[0] === "bor") return "";
// Leftmost match of: one-or-more separators + the range, ANCHORED to the
// end of the string (so a range-shaped tail followed by more text is
// junk, not a range). Everything before it is the identity.
const match = /[ \t:#]+(L?\d+(?:[ \t]*[-\u2013][ \t]*L?\d+)?)$/i.exec(text);
const identity = match ? text.slice(0, match.index) : text;
const rangePart = match ? match[1] : null;
// One whitespace-free `source/path` token, never ending in a dangling
// separator (`a.md#` with no range is a typo, not a whole document).
if (!identity || /\s/.test(identity)) return "";
if (/[#:\-]$/.test(identity)) return "";
if (!identity.includes("/")) return "";
if (rangePart === null) return identity;
const bounds = rangePart.split(/[\u2013-]/).map((b) => b.trim().replace(/^[Ll]+/, ""));
if (bounds.some((b) => !/^\d+$/.test(b))) return "";
const start = parseInt(bounds[0], 10);
const end = bounds.length === 2 ? parseInt(bounds[1], 10) : start;
if (start < 1 || end < 1 || start > end) return "";
return `${identity}#${start}-${end}`;
}
function renderMarkdown(md, opts) {
// 0. Protect the phase-182 ` ```bor ` LOCATOR fences — BEFORE the fence
// pass, so a locator fence never reaches the ordinary code renderer.
// OPT-IN ONLY (the byte-identity contract in the header): `opts` is
// undefined at every existing call site and `opts.verbatim === true` is
// the single truthy path. The whole match is DROPPED — info line, body
// and closing fence — because the model's copy of the file is exactly
// what must never be painted (locked A7); only the normalized locator is
// kept. The closing alternative (`(?![\s\S])`) matches a fence left OPEN
// at the very end of the input, so as soon as the model has typed the
// info line the answer shows the pending state instead of raw backticks
// (the deliberate asymmetry with the server's stricter `fences_in`: the
// server only ever sees a finished answer, the client sees a partial
// one). A bare ` ```bor ` with no path does NOT match — it is an ordinary
// code block and must survive to the fence pass untouched — and an
// unparseable locator is returned byte-for-byte, degrading to the
// `<pre><code>` that pass produces today with the model's own body.
const verbatim = [];
let text = md;
if (opts && opts.verbatim === true) {
text = text.replace(/^```bor[ \t]+\S[^\n]*\n(?:[\s\S]*?^```[ \t]*$|(?![\s\S]))/gm, (match) => {
const infoLine = match.slice(0, match.indexOf("\n")).replace(/^```bor[ \t]+/, "").trim();
const canonical = borLocator(infoLine);
if (canonical === "") return match;
verbatim.push(canonical);
return `\u0000VERBATIM${verbatim.length - 1}\u0000`;
});
}
// 1. Protect fenced code blocks.
const codeBlocks = [];
text = text.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 —
// `![alt](src)` inside `[…](url)`, i.e. `[![alt](src)](url)` — gets
// its OWN rule, AHEAD of both the image and the link rules —
// without it the image rule consumes the inner `![alt](src)` 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
// `![alt](src)` → 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 &gt;, 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*(?:&gt;)+ ? is ONE
// <blockquote class="md-quote">: the leading (?:&gt;)+ 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*(?:&gt;)+ ?/.test(quoteLines[qpos])) {
quoted.push(quoteLines[qpos]);
qpos += 1;
continue;
}
const members = [];
while (qpos < quoteLines.length && /^\s*(?:&gt;)+ ?/.test(quoteLines[qpos])) {
members.push(quoteLines[qpos].replace(/^\s*(?:&gt;)+ ?/, ""));
qpos += 1;
}
quoted.push(`<blockquote class="md-quote">${members.join("<br>")}</blockquote>`);
}
text = quoted.join("\n");
// 3. Paragraphs (double newline separated). The first guard test is the
// untouched pre-existing one; the second is the phase-182 placeholder,
// which restores to a `<div>` (a BLOCK element) and must not be wrapped
// in a `<p>` the way a code placeholder is. It can only ever match when
// `opts.verbatim` was on, so every other caller keeps byte-identical
// paragraph behavior.
text = text
.split(/\n{2,}/)
.map((block) => {
const b = block.trim();
if (!b) return "";
if (/^<(h\d|ul|ol|pre|li|blockquote)/.test(b) || /^\u0000VERBATIM\d+\u0000$/.test(b)) return b;
return `<p>${b.replace(/\n/g, "<br>")}</p>`;
})
.join("");
// 4. Restore protected spans: tables (already final HTML), code, and the
// phase-182 verbatim placeholders. A placeholder restores as the PENDING
// panel and NEVER as the model's bytes (locked A7): the caption carries
// the `source/path` identity, the state line is a polite live region
// (`role="status"` announces the pending text once and the task-07
// hydration swap as a change), and every interpolated value goes through
// escapeHtml — the locator is model-written text landing in an ATTRIBUTE,
// so this is the escape-first boundary with no exception.
return text
.replace(/\u0000TABLE(\d+)\u0000/g, (_m, i) => tables[Number(i)])
.replace(/\u0000CODE(\d+)\u0000/g, (_m, i) => codeBlocks[Number(i)])
.replace(/\u0000VERBATIM(\d+)\u0000/g, (_m, i) => {
const canonical = verbatim[Number(i)];
const identity = canonical.split("#")[0];
return (
`<div class="verbatim verbatim-pending" data-bor-locator="${escapeHtml(canonical)}">` +
`<span class="verbatim-caption">${escapeHtml(identity)}</span>` +
`<span class="verbatim-state" role="status">Retrieving the exact text…</span>` +
"</div>"
);
});
}