**Phase 138 final verification pass — all green, no defects found.** - Verified: blockquote pass in `frontend/assets/markdown.js` (step 2b, between escape+inline and paragraphs), guard extended to `|blockquote`, docstring updated; `.md-quote` CSS (left border + padding + `--ink-soft`, no fill, palette vars only, contrast comments, user-bubble override); unit pins + node behavior; dedicated E2E (chat / viewer / restore); mock `QUOTE_TRIGGER`/`QUOTE_ANSWER`; fixture `tests/fixtures/quote_docs/`. Thinking block confirmed on the same shared renderer (app.js L2025/L3095). - `uv run pytest tests/e2e/test_markdown_blockquotes.py -v --no-cov` → 3 passed (isolation) - `uv run pytest --cov=app --cov-report=term-missing` → 3268 passed, 9 skipped; TOTAL **99%** (>90%) - `uv run ruff check . && uv run pyright` → All checks passed; 0 errors - Criteria: quotes render in chat/viewer/restore ✅ (E2E) + thinking ✅ (shared renderer + unscoped AA CSS); `>>` collapses, fence/table `>` untouched ✅ (unit+e2e); non-quote byte-identical ✅ (exact fixture; phase-44/renderer suites pass **unedited**, confirmed by empty `git diff --stat`) - No deviations; commit + phase-dir move left to the harness per pipeline rules (working tree clean of stray changes — only phase-138 diffs present) - Next pending phase: **139_read_range_tool_lines**
191 lines
8.3 KiB
JavaScript
191 lines
8.3 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.
|
|
*/
|
|
|
|
function escapeHtml(s) {
|
|
return s.replace(/[&<>"']/g, (c) => ({
|
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|
}[c]));
|
|
}
|
|
|
|
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>")
|
|
.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)]);
|
|
}
|