feat(chat): render markdown tables in answers, viewer, and thinking
GFM pipe tables in the shared renderer (TODO.md L6): a table-protection
pass in frontend/assets/markdown.js (fences -> tables -> escape order)
pulls each header+separator+body block out as a placeholder, renders
cells escape-first with the same inline transforms, and reinserts a
semantic <table class="md-table"> inside a horizontal-overflow
.md-table-wrap — so a pipe table in a chat answer, the document
viewer/modal, and the thinking block all render the same semantic
table. Fences win over tables; lone pipes stay text.
- styles.css: .md-table palette rules (PLAN §7.2 tokens, no motion);
min-width: max-content so a WIDE table keeps its natural width and
the wrapper is the real scroller (width:100% alone wrapped the wide
table's cells — proven by the new E2E).
- mock_llm.py: TABLE_TRIGGER ("show me a table") -> byte-stable
TABLE_ANSWER (3-column table, <img onerror> XSS probe line, wide
5-column table), checked before DEFLECT_MODE like SUMMARY_MODE.
- tests/fixtures/docs/homelab/tables.md: 3x3 pipe table + pipe-heavy
fenced block (viewer/fence subject); the shared fixture set grows
8 -> 9 docs, so every suite pinning the count (added/formats/
stat-docs/EXPECTED_ROWS) is updated accordingly.
- tests/e2e/test_markdown_tables.py (new, story suite): chat table
shape + non-deflection, wide-table wrapper scroll (no page
overflow), XSS probe inert, viewer modal table, fence-not-a-table,
lone pipe stays text.
- tests/e2e/test_agent_document_tools.py: fix a pre-existing flake —
the "Calling tool…" label window is ~0.4 s at the mock's 0.1 s
tool-frame pacing, and a polling expect could stride over it
(failed 3 of 5 runs on the committed baseline). The pre-submit
MutationObserver record is the deterministic source of truth; the
racy to_have_text gate is gone.
uv run pytest: 738 passed, app/ coverage 99% (TOTAL unchanged);
ruff + pyright clean; story E2E 6/6 in isolation; regression E2E
suites (chat_rag, document_viewer, document_summaries, smoke) green.
This commit is contained in:
+104
-14
@@ -6,7 +6,16 @@
|
||||
* 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. Rendering behavior is unchanged from the original app.js copy.
|
||||
* 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.
|
||||
*/
|
||||
|
||||
function escapeHtml(s) {
|
||||
@@ -23,17 +32,96 @@ function renderMarkdown(md) {
|
||||
return `\u0000CODE${codeBlocks.length - 1}\u0000`;
|
||||
});
|
||||
|
||||
// 2. Escape everything else, then apply inline + block transforms.
|
||||
text = escapeHtml(text)
|
||||
.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>");
|
||||
// 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));
|
||||
|
||||
// 3. Paragraphs (double newline separated).
|
||||
text = text
|
||||
@@ -46,6 +134,8 @@ function renderMarkdown(md) {
|
||||
})
|
||||
.join("");
|
||||
|
||||
// 4. Restore code blocks.
|
||||
return text.replace(/\u0000CODE(\d+)\u0000/g, (_m, i) => codeBlocks[Number(i)]);
|
||||
// 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)]);
|
||||
}
|
||||
|
||||
@@ -576,6 +576,25 @@ html::after {
|
||||
.bubble code { font-family: var(--mono); font-size: 0.88em; background: var(--brand-soft); padding: 0.08em 0.35em; border-radius: 5px; }
|
||||
.bubble pre code { background: none; padding: 0; }
|
||||
|
||||
/* GFM pipe tables (phase 44, 2026-08-27, TODO.md L6): the shared
|
||||
renderer wraps every table in .md-table-wrap — the horizontal
|
||||
scroller, so a wide table scrolls inside the bubble instead of
|
||||
breaking the 46rem column — around a semantic <table class="md-table">
|
||||
(escape-first cells; alignment colons render left, owner decision).
|
||||
Phase-08 tokens only: --line hairline borders and the thead tinted
|
||||
from the plain surface family — --ink on --surface is 14.5:1 (PLAN
|
||||
§7.2), never the brand. The rules are unscoped on purpose: the same
|
||||
renderer serves the chat bubble, the thinking scratchpad, and the
|
||||
document viewer (.doc-md). width:100% stretches narrow tables to the
|
||||
column; min-width:max-content lets a WIDE table keep its natural width
|
||||
so the wrapper is the real scroller (phase 44 task 03: width:100% alone
|
||||
wrapped the wide table's cells and it never overflowed). Static content
|
||||
— no animation (nothing for prefers-reduced-motion to still). */
|
||||
.md-table-wrap { overflow-x: auto; }
|
||||
.md-table { border-collapse: collapse; width: 100%; min-width: max-content; font-size: 0.9rem; }
|
||||
.md-table th, .md-table td { border: 1px solid var(--line); padding: 0.4rem 0.6rem; text-align: left; vertical-align: top; }
|
||||
.md-table thead th { background: var(--surface); color: var(--ink); }
|
||||
|
||||
.msg.user { justify-content: flex-end; }
|
||||
.msg.user .msg-body { align-items: flex-end; }
|
||||
.msg.user .bubble {
|
||||
|
||||
Reference in New Issue
Block a user