fix: restore safe HTML element rendering and suppress plantuml warning (#70)

* fix: restore safe HTML element rendering and suppress plantuml warning

- Add safe HTML tag allowlist in inlineMarkdown: br, hr, kbd, mark,
  sub, sup, ins, wbr, details, summary, abbr, small, u, s
  (attribute-free only — XSS safe, tags with attributes stay escaped)
- Add <details>/<summary> block-level protection pass with recursive
  markdown rendering of inner content
- Add plantuml to _NO_HIGHLIGHT_LANGS (suppresses highlight.js warning
  for unsupported language)
- CSS for details (collapsible, overflow hidden), kbd (mono font,
  key style), mark (yellow-glow token for theme adaptation)

* fix: restrict safe tags to inline-only, broaden details regex

- Remove hr, details, summary from inline _SAFE_TAGS allowlist (they
  are block-level and produce invalid HTML inside <p> wrappers)
- Make <details> regex newline-optional so same-line
  <details><summary>Title</summary> patterns are captured
This commit is contained in:
Patrick Buckley
2026-03-15 02:34:03 -07:00
committed by GitHub
parent 376da3d084
commit 68c991fbdd
2 changed files with 69 additions and 0 deletions
+40
View File
@@ -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 =
/&lt;(\/?(?:br|kbd|mark|sub|sup|ins|wbr|abbr|small|u|s))(?:\s*\/?)&gt;/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, "<strong>$1</strong>");
// Italic (asterisks only)
@@ -304,6 +313,30 @@ function renderMarkdown(text) {
return "\x00CB" + (codeBlocks.length - 1) + "\x00";
});
// Protect <details> blocks (safe HTML — attribute-free only)
var detailsBlocks = [];
text = text.replace(
/<details>\s*\n?([\s\S]*?)<\/details>/gi,
function (m, inner) {
var sumMatch = inner.match(
/^\s*<summary>([\s\S]*?)<\/summary>\s*\n?([\s\S]*)/i,
);
var html;
if (sumMatch) {
html =
"<details><summary>" +
inlineMarkdown(sumMatch[1].trim()) +
"</summary>" +
renderMarkdown(sumMatch[2]) +
"</details>";
} else {
html = "<details>" + renderMarkdown(inner) + "</details>";
}
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(/<p>\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(/<p>\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,
+29
View File
@@ -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);