diff --git a/turnstone/ui/static/renderer.js b/turnstone/ui/static/renderer.js index 1928def6..81063c67 100644 --- a/turnstone/ui/static/renderer.js +++ b/turnstone/ui/static/renderer.js @@ -3,9 +3,18 @@ // --------------------------------------------------------------------------- // Inline formatting // --------------------------------------------------------------------------- +// Safe inline HTML tags allowed through escapeHtml (no attributes — XSS safe) +// Block-level tags (details, summary, hr) handled by their own protection passes +var _SAFE_TAGS = + /<(\/?(?:br|kbd|mark|sub|sup|ins|wbr|abbr|small|u|s))(?:\s*\/?)>/gi; + function inlineMarkdown(text) { // Escape HTML first so only tags we generate are real text = escapeHtml(text); + // Restore safe HTML tags (attribute-free only — already escaped so no XSS) + text = text.replace(_SAFE_TAGS, function (m, tag) { + return "<" + tag + ">"; + }); // Bold (asterisks only — underscores cause false positives on snake_case) text = text.replace(/\*\*(.+?)\*\*/g, "$1"); // Italic (asterisks only) @@ -304,6 +313,30 @@ function renderMarkdown(text) { return "\x00CB" + (codeBlocks.length - 1) + "\x00"; }); + // Protect
blocks (safe HTML — attribute-free only) + var detailsBlocks = []; + text = text.replace( + /
\s*\n?([\s\S]*?)<\/details>/gi, + function (m, inner) { + var sumMatch = inner.match( + /^\s*([\s\S]*?)<\/summary>\s*\n?([\s\S]*)/i, + ); + var html; + if (sumMatch) { + html = + "
" + + inlineMarkdown(sumMatch[1].trim()) + + "" + + renderMarkdown(sumMatch[2]) + + "
"; + } else { + html = "
" + renderMarkdown(inner) + "
"; + } + detailsBlocks.push(html); + return "\x00DT" + (detailsBlocks.length - 1) + "\x00"; + }, + ); + // Protect display math ($$...$$) — must come before inline code/math var mathBlocks = []; text = text.replace(/\$\$([\s\S]+?)\$\$/g, function (m, tex) { @@ -579,6 +612,12 @@ function renderMarkdown(text) { result = result.replace(/\x00CB(\d+)\x00/g, function (m, idx) { return codeBlocks[parseInt(idx)]; }); + result = result.replace(/

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

\x00BQ(\d+)\x00<\/p>/g, function (m, idx) { return bqBlocks[parseInt(idx)]; }); @@ -618,6 +657,7 @@ var _NO_HIGHLIGHT_LANGS = { plain: true, nohighlight: true, mermaid: true, + plantuml: true, }; var _TERMINAL_LANGS = { bash: true, diff --git a/turnstone/ui/static/style.css b/turnstone/ui/static/style.css index 06106e13..0166a551 100644 --- a/turnstone/ui/static/style.css +++ b/turnstone/ui/static/style.css @@ -358,6 +358,35 @@ body { position: static; } .msg-assistant .mermaid-container svg * { animation: none !important; } } +/* Safe HTML elements */ +.msg-assistant details { + margin: 8px 0; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg-surface); + overflow: hidden; +} +.msg-assistant details summary { + padding: 8px 12px; + cursor: pointer; + font-weight: 600; + color: var(--fg-bright); + user-select: none; +} +.msg-assistant details summary:hover { color: var(--accent); } +.msg-assistant details[open] > summary { border-bottom: 1px solid var(--border); } +.msg-assistant details > :not(summary) { padding: 0 12px; } +.msg-assistant kbd { + background: var(--bg-highlight); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); + padding: 1px 5px; + font-size: 11px; + font-family: var(--font-mono); + box-shadow: 0 1px 0 var(--border-strong); +} +.msg-assistant mark { background: var(--yellow-glow); color: var(--fg-bright); border-radius: 2px; padding: 0 2px; } + /* GFM Callouts / Alerts */ .msg-assistant .callout { border-left: 3px solid var(--border-strong);