From 2ef8a8711bfab19cc76c946a4d245312bb2e7fd1 Mon Sep 17 00:00:00 2001 From: Patrick Buckley Date: Sun, 15 Mar 2026 00:04:01 -0700 Subject: [PATCH] feat: rich markdown renderer with LaTeX support for server web UI (#65) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: rich markdown renderer with LaTeX support for server web UI Extract markdown rendering from app.js into dedicated renderer.js with full GFM support: tables (alignment, hover, striping), nested lists, task list checkboxes, nested blockquotes, images (click-to-load for privacy), and inline/display LaTeX math via self-hosted KaTeX 0.16.38. Security: escape image/link URLs to prevent attribute injection, block javascript: scheme in links, add rel="noopener noreferrer", images require explicit click to load (no automatic external requests). Accessibility: scope="col" on table headers, tabindex on scrollable table containers, aria-labels on task checkboxes and image placeholders, KaTeX error color override for WCAG AA contrast, reduced-motion support. * fix: address code review — XSS hardening and list type splitting - Escape all text through escapeHtml() at start of inlineMarkdown() so only renderer-generated tags appear in innerHTML (prevents raw HTML/script injection from LLM output) - Replace inline onclick handler on image placeholders with data-* attributes and delegated DOM event listeners (prevents entity decoding XSS in event handler attributes) - Split list blocks into separate " : ""); - inList = false; - } - out.push("
"); - continue; - } - - // Headers - const hm = line.match(/^(#{1,6})\s+(.+)/); - if (hm) { - if (inList) { - out.push(listType === "ul" ? "" : ""); - inList = false; - } - const level = hm[1].length; - out.push( - "" + inlineMarkdown(hm[2]) + "", - ); - continue; - } - - // Blockquote - if (line.startsWith("> ")) { - if (inList) { - out.push(listType === "ul" ? "" : ""); - inList = false; - } - out.push( - "
" + inlineMarkdown(line.slice(2)) + "
", - ); - continue; - } - - // Unordered list - const ulm = line.match(/^(\s*)[-*+]\s+(.+)/); - if (ulm) { - if (!inList || listType !== "ul") { - if (inList) out.push(listType === "ul" ? "" : ""); - out.push("
    "); - inList = true; - listType = "ul"; - } - out.push("
  • " + inlineMarkdown(ulm[2]) + "
  • "); - continue; - } - - // Ordered list - const olm = line.match(/^(\s*)\d+[.)]\s+(.+)/); - if (olm) { - if (!inList || listType !== "ol") { - if (inList) out.push(listType === "ul" ? "
" : ""); - out.push("
    "); - inList = true; - listType = "ol"; - } - out.push("
  1. " + inlineMarkdown(olm[2]) + "
  2. "); - continue; - } - - // Close list if we hit a non-list line - if (inList && line.trim() === "") { - out.push(listType === "ul" ? "" : "
"); - inList = false; - } - - // Paragraph / plain text - if (line.trim() === "") { - out.push(""); - } else { - out.push("

" + inlineMarkdown(line) + "

"); - } - } - if (inList) out.push(listType === "ul" ? "" : ""); - - let result = out.join("\n"); - - // Restore code blocks and inline code - result = result.replace(/\x00CB(\d+)\x00/g, function (m, idx) { - return codeBlocks[parseInt(idx)]; - }); - result = result.replace(/\x00IC(\d+)\x00/g, function (m, idx) { - return inlineCodes[parseInt(idx)]; - }); - - return result; -} - -function inlineMarkdown(text) { - // Bold - text = text.replace(/\*\*(.+?)\*\*/g, "$1"); - text = text.replace(/__(.+?)__/g, "$1"); - // Italic - text = text.replace(/\*(.+?)\*/g, "$1"); - text = text.replace(/_(.+?)_/g, "$1"); - // Strikethrough - text = text.replace(/~~(.+?)~~/g, "$1"); - // Links - text = text.replace( - /\[([^\]]+)\]\(([^)]+)\)/g, - '$1', - ); - return text; -} - // === Tab / Workstream management === function renderTabBar() { diff --git a/turnstone/ui/static/index.html b/turnstone/ui/static/index.html index 18100fb4..273a2f6c 100644 --- a/turnstone/ui/static/index.html +++ b/turnstone/ui/static/index.html @@ -8,6 +8,7 @@ + @@ -125,6 +126,8 @@ window.TURNSTONE_KB_SHORTCUTS = [ + + diff --git a/turnstone/ui/static/renderer.js b/turnstone/ui/static/renderer.js new file mode 100644 index 00000000..23345f59 --- /dev/null +++ b/turnstone/ui/static/renderer.js @@ -0,0 +1,405 @@ +// renderer.js — Markdown + LaTeX rendering (no external deps except KaTeX) + +// --------------------------------------------------------------------------- +// Inline formatting +// --------------------------------------------------------------------------- +function inlineMarkdown(text) { + // Escape HTML first so only tags we generate are real + text = escapeHtml(text); + // Bold (asterisks only — underscores cause false positives on snake_case) + text = text.replace(/\*\*(.+?)\*\*/g, "$1"); + // Italic (asterisks only) + text = text.replace( + /(?$1", + ); + // Strikethrough + text = text.replace(/~~(.+?)~~/g, "$1"); + // Images (must come before links — render as click-to-load placeholder) + text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, function (m, alt, url) { + var safeAlt = alt || "Image"; + var domain = ""; + try { + domain = escapeHtml(new URL(url).hostname); + } catch (e) { + domain = url.length > 40 ? url.slice(0, 40) + "…" : url; + } + return ( + '' + + '🖼 ' + + '' + + safeAlt + + "" + + '' + + domain + + "" + + "" + ); + }); + // Links (block javascript: scheme) + text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (m, label, url) { + if (/^\s*javascript:/i.test(url)) return m; + return ( + '' + + label + + "" + ); + }); + return text; +} + +// Attach click-to-load listener for image placeholders (delegated) +document.addEventListener("click", function (e) { + var ph = e.target.closest(".img-placeholder"); + if (!ph) return; + var img = document.createElement("img"); + img.src = ph.getAttribute("data-src"); + img.alt = ph.getAttribute("data-alt"); + img.loading = "lazy"; + ph.replaceWith(img); +}); +document.addEventListener("keydown", function (e) { + if (e.key !== "Enter") return; + var ph = e.target.closest(".img-placeholder"); + if (!ph) return; + ph.click(); +}); + +// --------------------------------------------------------------------------- +// List rendering (nested + task lists) +// --------------------------------------------------------------------------- +function renderListBlock(items) { + if (items.length === 0) return ""; + var minIndent = items[0].indent; + for (var i = 1; i < items.length; i++) { + if (items[i].indent < minIndent) minIndent = items[i].indent; + } + // Split into separate lists when marker type changes at top indent level + var segments = []; + var cur = [items[0]]; + for (var i = 1; i < items.length; i++) { + if (items[i].indent <= minIndent && items[i].ordered !== cur[0].ordered) { + segments.push(cur); + cur = [items[i]]; + } else { + cur.push(items[i]); + } + } + segments.push(cur); + if (segments.length > 1) { + return segments.map(renderListBlock).join("\n"); + } + var type = items[0].ordered ? "ol" : "ul"; + var html = "<" + type + ">"; + var i = 0; + while (i < items.length) { + var item = items[i]; + if (item.indent <= minIndent) { + var content = item.content; + // Task list checkboxes + var taskMatch = content.match(/^\[([ xX])\]\s*(.*)/); + if (taskMatch) { + var checked = taskMatch[1] !== " "; + content = + ' ' + + inlineMarkdown(taskMatch[2]); + } else { + content = inlineMarkdown(content); + } + // Collect children (deeper indent items following this one) + var children = []; + var j = i + 1; + while (j < items.length && items[j].indent > minIndent) { + children.push(items[j]); + j++; + } + if (children.length > 0) { + html += "
  • " + content + renderListBlock(children) + "
  • "; + } else { + html += "
  • " + content + "
  • "; + } + i = j; + } else { + i++; + } + } + html += ""; + return html; +} + +// --------------------------------------------------------------------------- +// LaTeX rendering via KaTeX +// --------------------------------------------------------------------------- +function renderLatex(tex, displayMode) { + if (typeof katex === "undefined") return escapeHtml(tex); + try { + return katex.renderToString(tex, { + displayMode: displayMode, + throwOnError: false, + errorColor: "#f87171", + output: "html", + }); + } catch (e) { + return '' + escapeHtml(tex) + ""; + } +} + +// --------------------------------------------------------------------------- +// Main markdown renderer +// --------------------------------------------------------------------------- +function renderMarkdown(text) { + // Pre-pass: extract blockquote blocks and recursively render. + // Must run FIRST (before code/math protection) so the recursive call + // processes raw markdown, not text with outer-scope placeholders. + var bqBlocks = []; + (function () { + var blines = text.split("\n"); + var result = []; + var i = 0; + while (i < blines.length) { + if (blines[i].startsWith("> ") || blines[i] === ">") { + var inner = []; + while ( + i < blines.length && + (blines[i].startsWith("> ") || blines[i] === ">") + ) { + inner.push(blines[i] === ">" ? "" : blines[i].slice(2)); + i++; + } + bqBlocks.push( + "
    " + renderMarkdown(inner.join("\n")) + "
    ", + ); + result.push("\x00BQ" + (bqBlocks.length - 1) + "\x00"); + } else { + result.push(blines[i]); + i++; + } + } + text = result.join("\n"); + })(); + + // Protect code blocks + var codeBlocks = []; + text = text.replace(/```(\w*)\n([\s\S]*?)```/g, function (m, lang, code) { + codeBlocks.push( + '
    ' +
    +        escapeHtml(code.replace(/\n$/, "")) +
    +        "
    ", + ); + return "\x00CB" + (codeBlocks.length - 1) + "\x00"; + }); + + // Protect display math ($$...$$) — must come before inline code/math + var mathBlocks = []; + text = text.replace(/\$\$([\s\S]+?)\$\$/g, function (m, tex) { + mathBlocks.push(renderLatex(tex.trim(), true)); + return "\x00MB" + (mathBlocks.length - 1) + "\x00"; + }); + + // Protect inline code + var inlineCodes = []; + text = text.replace(/`([^`\n]+)`/g, function (m, code) { + inlineCodes.push("" + escapeHtml(code) + ""); + return "\x00IC" + (inlineCodes.length - 1) + "\x00"; + }); + + // Protect inline math ($...$) — after inline code so `$x$` in code is safe + var inlineMaths = []; + text = text.replace(/\$([^\$\n]+?)\$/g, function (m, tex) { + inlineMaths.push(renderLatex(tex, false)); + return "\x00IM" + (inlineMaths.length - 1) + "\x00"; + }); + + // Protect markdown tables (extract before line-by-line processing) + var tableBlocks = []; + (function () { + var tlines = text.split("\n"); + var sepRe = /^\|?(\s*:?-{1,}:?\s*\|)+\s*:?-{1,}:?\s*\|?\s*$/; + var result = []; + var i = 0; + while (i < tlines.length) { + if ( + i + 1 < tlines.length && + tlines[i].includes("|") && + sepRe.test(tlines[i + 1]) + ) { + var headerLine = tlines[i]; + var sepLine = tlines[i + 1]; + var sepCells = sepLine + .replace(/^\|/, "") + .replace(/\|?\s*$/, "") + .split("|"); + var aligns = sepCells.map(function (c) { + c = c.trim(); + if (c.startsWith(":") && c.endsWith(":")) return "center"; + if (c.endsWith(":")) return "right"; + return "left"; + }); + var hdrCells = headerLine + .replace(/^\|/, "") + .replace(/\|?\s*$/, "") + .split("|") + .map(function (c) { + return c.trim(); + }); + var dataRows = []; + var j = i + 2; + while ( + j < tlines.length && + tlines[j].includes("|") && + tlines[j].trim() !== "" + ) { + var row = tlines[j] + .replace(/^\|/, "") + .replace(/\|?\s*$/, "") + .split("|") + .map(function (c) { + return c.trim(); + }); + dataRows.push(row); + j++; + } + var html = + '
    '; + html += ""; + for (var k = 0; k < hdrCells.length; k++) { + var align = aligns[k] || "left"; + html += + '"; + } + html += ""; + for (var r = 0; r < dataRows.length; r++) { + html += ""; + for (var k = 0; k < hdrCells.length; k++) { + var align = aligns[k] || "left"; + var cell = dataRows[r][k] || ""; + html += + '"; + } + html += ""; + } + html += "
    ' + + inlineMarkdown(hdrCells[k]) + + "
    ' + + inlineMarkdown(cell) + + "
    "; + tableBlocks.push(html); + result.push("\x00TB" + (tableBlocks.length - 1) + "\x00"); + i = j; + } else { + result.push(tlines[i]); + i++; + } + } + text = result.join("\n"); + })(); + + // Process block-level elements per line + var lines = text.split("\n"); + var out = []; + + for (var i = 0; i < lines.length; i++) { + var line = lines[i]; + + // Horizontal rule + if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(line)) { + out.push("
    "); + continue; + } + + // Headers + var hm = line.match(/^(#{1,6})\s+(.+)/); + if (hm) { + var level = hm[1].length; + out.push( + "" + inlineMarkdown(hm[2]) + "", + ); + continue; + } + + // Lists — collect consecutive list lines, then render with nesting + var ulm = line.match(/^(\s*)[-*+]\s+(.*)/); + var olm = !ulm ? line.match(/^(\s*)\d+[.)]\s+(.*)/) : null; + if (ulm || olm) { + var listItems = []; + while (i < lines.length) { + var um = lines[i].match(/^(\s*)[-*+]\s+(.*)/); + var om = !um ? lines[i].match(/^(\s*)\d+[.)]\s+(.*)/) : null; + if (um || om) { + var lm = um || om; + listItems.push({ + indent: lm[1].length, + ordered: !!om, + content: lm[2], + }); + i++; + } else { + break; + } + } + i--; // for-loop will increment + out.push(renderListBlock(listItems)); + continue; + } + + // Paragraph / plain text + if (line.trim() === "") { + out.push(""); + } else { + out.push("

    " + inlineMarkdown(line) + "

    "); + } + } + + var result = out.join("\n"); + + // Restore protected blocks + result = result.replace(/\x00CB(\d+)\x00/g, function (m, idx) { + return codeBlocks[parseInt(idx)]; + }); + result = result.replace(/

    \x00BQ(\d+)\x00<\/p>/g, function (m, idx) { + return bqBlocks[parseInt(idx)]; + }); + result = result.replace(/\x00BQ(\d+)\x00/g, function (m, idx) { + return bqBlocks[parseInt(idx)]; + }); + result = result.replace(/

    \x00MB(\d+)\x00<\/p>/g, function (m, idx) { + return mathBlocks[parseInt(idx)]; + }); + result = result.replace(/\x00MB(\d+)\x00/g, function (m, idx) { + return mathBlocks[parseInt(idx)]; + }); + result = result.replace(/

    \x00TB(\d+)\x00<\/p>/g, function (m, idx) { + return tableBlocks[parseInt(idx)]; + }); + result = result.replace(/\x00TB(\d+)\x00/g, function (m, idx) { + return tableBlocks[parseInt(idx)]; + }); + result = result.replace(/\x00IC(\d+)\x00/g, function (m, idx) { + return inlineCodes[parseInt(idx)]; + }); + result = result.replace(/\x00IM(\d+)\x00/g, function (m, idx) { + return inlineMaths[parseInt(idx)]; + }); + + return result; +} diff --git a/turnstone/ui/static/style.css b/turnstone/ui/static/style.css index 29b06ea5..42eae158 100644 --- a/turnstone/ui/static/style.css +++ b/turnstone/ui/static/style.css @@ -240,12 +240,18 @@ /* ========================================================================== Markdown styling ========================================================================== */ -.msg-assistant h1, .msg-assistant h2, .msg-assistant h3 { color: var(--accent); margin: 8px 0 4px; font-family: var(--font-display); } +/* Override KaTeX body rule — our layout requires body as a static flex container */ +body { position: static; } +.msg-assistant h1, .msg-assistant h2, .msg-assistant h3, .msg-assistant h4, .msg-assistant h5, .msg-assistant h6 { color: var(--accent); margin: 8px 0 4px; font-family: var(--font-display); } .msg-assistant h1 { font-size: 18px; } .msg-assistant h2 { font-size: 16px; } .msg-assistant h3 { font-size: 14px; } +.msg-assistant h4 { font-size: 13px; font-weight: 600; } +.msg-assistant h5 { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; } +.msg-assistant h6 { font-size: 12px; font-weight: 500; color: var(--fg-dim); } .msg-assistant strong { color: var(--fg-bright); } .msg-assistant em { color: var(--magenta); } +.msg-assistant del { color: var(--fg-dim); text-decoration: line-through; } .msg-assistant code { background: var(--code-bg); padding: 2px 6px; @@ -268,6 +274,36 @@ .msg-assistant blockquote { border-left: 3px solid var(--accent-dim); padding-left: 12px; color: var(--fg-dim); margin: 6px 0; } .msg-assistant hr { border: none; border-top: 1px solid var(--border); margin: 8px 0; } .msg-assistant p { margin: 4px 0; } +.msg-assistant .table-wrap { overflow-x: auto; margin: 8px 0; -webkit-overflow-scrolling: touch; } +.msg-assistant .table-wrap:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } +.msg-assistant table { border-collapse: collapse; width: auto; min-width: 50%; font-size: 13px; } +.msg-assistant thead { border-bottom: 2px solid var(--border-strong); } +.msg-assistant th { background: var(--code-bg); font-weight: 600; color: var(--fg-bright); } +.msg-assistant th, .msg-assistant td { padding: 6px 12px; border: 1px solid var(--border-strong); } +.msg-assistant tbody tr { transition: background 0.12s ease; } +.msg-assistant tbody tr:nth-child(even) { background: var(--row-alt); } +.msg-assistant tbody tr:hover { background: var(--bg-highlight); } +.msg-assistant .align-left { text-align: left; } +.msg-assistant .align-center { text-align: center; } +.msg-assistant .align-right { text-align: right; } +.msg-assistant img { display: block; max-width: 100%; height: auto; border-radius: var(--radius); margin: 4px 0; } +.msg-assistant .img-placeholder { + display: inline-flex; align-items: center; gap: 8px; + padding: 8px 14px; margin: 4px 0; + background: var(--code-bg); border: 1px solid var(--border-strong); + border-radius: var(--radius); cursor: pointer; + transition: border-color 0.12s ease; +} +.msg-assistant .img-placeholder:hover { border-color: var(--accent); } +.msg-assistant .img-placeholder:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; } +.msg-assistant .img-placeholder-icon { font-size: 16px; } +.msg-assistant .img-placeholder-label { color: var(--fg-bright); font-size: 13px; } +.msg-assistant .img-placeholder-domain { color: var(--fg-dim); font-size: 11px; } +.msg-assistant li > input[type="checkbox"] { margin-right: 6px; vertical-align: middle; accent-color: var(--accent); } +.msg-assistant blockquote blockquote { margin: 4px 0; border-left-color: var(--border-strong); } +.msg-assistant blockquote blockquote blockquote { border-left-color: var(--border); } +.msg-assistant .katex-display { margin: 8px 0; padding: 8px 0; overflow-x: auto; overflow-y: hidden; } +.msg-assistant .katex-error { color: var(--red) !important; font-size: 12px; } /* ========================================================================== Input area @@ -787,5 +823,6 @@ #plan-buttons button, #input-area button, .dashboard-new-btn, .dashboard-input, #health-indicator, #hamburger-btn, - #mcp-status { transition: none; } + #mcp-status, .msg-assistant tbody tr, + .msg-assistant .img-placeholder { transition: none; } }