mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-12 23:12:23 -06:00
feat: rich markdown renderer with LaTeX support for server web UI (#65)
* 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 <ul>/<ol> when marker type changes at the same indent level (mixed ordered/unordered sequences)
This commit is contained in:
@@ -91,14 +91,16 @@ turnstone/
|
||||
_config.py Base ChannelConfig dataclass
|
||||
discord/ Discord adapter (bot, cog, views, streaming, config)
|
||||
shared_static/ Shared design system (base.css, auth.js, theme.js, toast.js, utils.js, kb.js)
|
||||
katex-0.16.38/ Vendored KaTeX math rendering library (MIT, woff2 fonts)
|
||||
ui/
|
||||
colors.py ANSI color constants with NO_COLOR support
|
||||
markdown.py Streaming terminal markdown renderer (line-buffered)
|
||||
spinner.py Braille character spinner (daemon thread)
|
||||
static/
|
||||
index.html Single-page app shell (links to CSS and JS)
|
||||
style.css Page-specific UI styles (dashboard layout, approval blocks)
|
||||
app.js Page-specific client-side JavaScript (SSE, workstreams, markdown)
|
||||
style.css Page-specific UI styles (dashboard, markdown elements, approval blocks)
|
||||
renderer.js Markdown + LaTeX renderer (tables, nested lists, blockquotes, KaTeX math)
|
||||
app.js Page-specific client-side JavaScript (SSE, workstreams, tool approval)
|
||||
tools/
|
||||
*.json 15 tool schemas (OpenAI function-calling format + turnstone metadata)
|
||||
```
|
||||
|
||||
@@ -75,6 +75,7 @@ package "turnstone/ui/" <<Rectangle>> {
|
||||
component [colors.py\nANSI colors] as colors <<ui>>
|
||||
component [markdown.py\nMD rendering] as markdown <<ui>>
|
||||
component [spinner.py\nTerminal spinner] as spinner <<ui>>
|
||||
component [renderer.js\nBrowser MD + LaTeX] as renderer <<ui>>
|
||||
}
|
||||
|
||||
' API schemas
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c9daca81971ba7a8ed6736d23d5373c69435158fa6240b9880d14fc4759ab580
|
||||
size 329673
|
||||
oid sha256:efcc7cbe8161a54b5ec24bdfd47e8a142f70029e6e66c707e811b99369f85ebf
|
||||
size 310079
|
||||
|
||||
@@ -76,6 +76,7 @@ include = [
|
||||
"turnstone/console/static/*.js",
|
||||
"turnstone/shared_static/*.css",
|
||||
"turnstone/shared_static/*.js",
|
||||
"turnstone/shared_static/katex-0.16.38/**/*",
|
||||
"turnstone/sdk/py.typed",
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2013-2020 Khan Academy and other contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -246,144 +246,6 @@ document
|
||||
}
|
||||
});
|
||||
|
||||
// --- Markdown rendering (basic regex, no external libs) ---
|
||||
function renderMarkdown(text) {
|
||||
// Protect code blocks first
|
||||
const codeBlocks = [];
|
||||
text = text.replace(/```(\w*)\n([\s\S]*?)```/g, function (m, lang, code) {
|
||||
codeBlocks.push(
|
||||
'<pre><code class="lang-' +
|
||||
escapeHtml(lang) +
|
||||
'">' +
|
||||
escapeHtml(code.replace(/\n$/, "")) +
|
||||
"</code></pre>",
|
||||
);
|
||||
return "\x00CB" + (codeBlocks.length - 1) + "\x00";
|
||||
});
|
||||
|
||||
// Protect inline code
|
||||
const inlineCodes = [];
|
||||
text = text.replace(/`([^`\n]+)`/g, function (m, code) {
|
||||
inlineCodes.push("<code>" + escapeHtml(code) + "</code>");
|
||||
return "\x00IC" + (inlineCodes.length - 1) + "\x00";
|
||||
});
|
||||
|
||||
// Process block-level elements per line
|
||||
const lines = text.split("\n");
|
||||
const out = [];
|
||||
let inList = false;
|
||||
let listType = "";
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
let line = lines[i];
|
||||
|
||||
// Horizontal rule
|
||||
if (/^(\*{3,}|-{3,}|_{3,})\s*$/.test(line)) {
|
||||
if (inList) {
|
||||
out.push(listType === "ul" ? "</ul>" : "</ol>");
|
||||
inList = false;
|
||||
}
|
||||
out.push("<hr>");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Headers
|
||||
const hm = line.match(/^(#{1,6})\s+(.+)/);
|
||||
if (hm) {
|
||||
if (inList) {
|
||||
out.push(listType === "ul" ? "</ul>" : "</ol>");
|
||||
inList = false;
|
||||
}
|
||||
const level = hm[1].length;
|
||||
out.push(
|
||||
"<h" + level + ">" + inlineMarkdown(hm[2]) + "</h" + level + ">",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blockquote
|
||||
if (line.startsWith("> ")) {
|
||||
if (inList) {
|
||||
out.push(listType === "ul" ? "</ul>" : "</ol>");
|
||||
inList = false;
|
||||
}
|
||||
out.push(
|
||||
"<blockquote>" + inlineMarkdown(line.slice(2)) + "</blockquote>",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unordered list
|
||||
const ulm = line.match(/^(\s*)[-*+]\s+(.+)/);
|
||||
if (ulm) {
|
||||
if (!inList || listType !== "ul") {
|
||||
if (inList) out.push(listType === "ul" ? "</ul>" : "</ol>");
|
||||
out.push("<ul>");
|
||||
inList = true;
|
||||
listType = "ul";
|
||||
}
|
||||
out.push("<li>" + inlineMarkdown(ulm[2]) + "</li>");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ordered list
|
||||
const olm = line.match(/^(\s*)\d+[.)]\s+(.+)/);
|
||||
if (olm) {
|
||||
if (!inList || listType !== "ol") {
|
||||
if (inList) out.push(listType === "ul" ? "</ul>" : "</ol>");
|
||||
out.push("<ol>");
|
||||
inList = true;
|
||||
listType = "ol";
|
||||
}
|
||||
out.push("<li>" + inlineMarkdown(olm[2]) + "</li>");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Close list if we hit a non-list line
|
||||
if (inList && line.trim() === "") {
|
||||
out.push(listType === "ul" ? "</ul>" : "</ol>");
|
||||
inList = false;
|
||||
}
|
||||
|
||||
// Paragraph / plain text
|
||||
if (line.trim() === "") {
|
||||
out.push("");
|
||||
} else {
|
||||
out.push("<p>" + inlineMarkdown(line) + "</p>");
|
||||
}
|
||||
}
|
||||
if (inList) out.push(listType === "ul" ? "</ul>" : "</ol>");
|
||||
|
||||
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, "<strong>$1</strong>");
|
||||
text = text.replace(/__(.+?)__/g, "<strong>$1</strong>");
|
||||
// Italic
|
||||
text = text.replace(/\*(.+?)\*/g, "<em>$1</em>");
|
||||
text = text.replace(/_(.+?)_/g, "<em>$1</em>");
|
||||
// Strikethrough
|
||||
text = text.replace(/~~(.+?)~~/g, "<del>$1</del>");
|
||||
// Links
|
||||
text = text.replace(
|
||||
/\[([^\]]+)\]\(([^)]+)\)/g,
|
||||
'<a href="$2" target="_blank">$1</a>',
|
||||
);
|
||||
return text;
|
||||
}
|
||||
|
||||
// === Tab / Workstream management ===
|
||||
|
||||
function renderTabBar() {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Outfit:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/shared/base.css">
|
||||
<link rel="stylesheet" href="/shared/katex-0.16.38/katex.min.css">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
@@ -125,6 +126,8 @@ window.TURNSTONE_KB_SHORTCUTS = [
|
||||
<script src="/shared/theme.js"></script>
|
||||
<script src="/shared/auth.js"></script>
|
||||
<script src="/shared/kb.js"></script>
|
||||
<script src="/shared/katex-0.16.38/katex.min.js"></script>
|
||||
<script src="/static/renderer.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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, "<strong>$1</strong>");
|
||||
// Italic (asterisks only)
|
||||
text = text.replace(
|
||||
/(?<!\*)\*([^\s*](?:.*?[^\s*])?)\*(?!\*)/g,
|
||||
"<em>$1</em>",
|
||||
);
|
||||
// Strikethrough
|
||||
text = text.replace(/~~(.+?)~~/g, "<del>$1</del>");
|
||||
// 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 (
|
||||
'<span class="img-placeholder" tabindex="0" role="button" ' +
|
||||
'aria-label="Load image: ' +
|
||||
safeAlt +
|
||||
'" ' +
|
||||
'data-src="' +
|
||||
url +
|
||||
'" data-alt="' +
|
||||
safeAlt +
|
||||
'">' +
|
||||
'<span class="img-placeholder-icon">🖼</span> ' +
|
||||
'<span class="img-placeholder-label">' +
|
||||
safeAlt +
|
||||
"</span>" +
|
||||
'<span class="img-placeholder-domain">' +
|
||||
domain +
|
||||
"</span>" +
|
||||
"</span>"
|
||||
);
|
||||
});
|
||||
// Links (block javascript: scheme)
|
||||
text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (m, label, url) {
|
||||
if (/^\s*javascript:/i.test(url)) return m;
|
||||
return (
|
||||
'<a href="' +
|
||||
url +
|
||||
'" target="_blank" rel="noopener noreferrer">' +
|
||||
label +
|
||||
"</a>"
|
||||
);
|
||||
});
|
||||
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 =
|
||||
'<input type="checkbox" disabled' +
|
||||
(checked ? " checked" : "") +
|
||||
' aria-label="' +
|
||||
escapeHtml(taskMatch[2]) +
|
||||
'"> ' +
|
||||
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 += "<li>" + content + renderListBlock(children) + "</li>";
|
||||
} else {
|
||||
html += "<li>" + content + "</li>";
|
||||
}
|
||||
i = j;
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
html += "</" + type + ">";
|
||||
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 '<code class="katex-error">' + escapeHtml(tex) + "</code>";
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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(
|
||||
"<blockquote>" + renderMarkdown(inner.join("\n")) + "</blockquote>",
|
||||
);
|
||||
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(
|
||||
'<pre><code class="lang-' +
|
||||
escapeHtml(lang) +
|
||||
'">' +
|
||||
escapeHtml(code.replace(/\n$/, "")) +
|
||||
"</code></pre>",
|
||||
);
|
||||
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("<code>" + escapeHtml(code) + "</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 =
|
||||
'<div class="table-wrap" tabindex="0" role="region" aria-label="Data table"><table>';
|
||||
html += "<thead><tr>";
|
||||
for (var k = 0; k < hdrCells.length; k++) {
|
||||
var align = aligns[k] || "left";
|
||||
html +=
|
||||
'<th scope="col" class="align-' +
|
||||
align +
|
||||
'">' +
|
||||
inlineMarkdown(hdrCells[k]) +
|
||||
"</th>";
|
||||
}
|
||||
html += "</tr></thead><tbody>";
|
||||
for (var r = 0; r < dataRows.length; r++) {
|
||||
html += "<tr>";
|
||||
for (var k = 0; k < hdrCells.length; k++) {
|
||||
var align = aligns[k] || "left";
|
||||
var cell = dataRows[r][k] || "";
|
||||
html +=
|
||||
'<td class="align-' +
|
||||
align +
|
||||
'">' +
|
||||
inlineMarkdown(cell) +
|
||||
"</td>";
|
||||
}
|
||||
html += "</tr>";
|
||||
}
|
||||
html += "</tbody></table></div>";
|
||||
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("<hr>");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Headers
|
||||
var hm = line.match(/^(#{1,6})\s+(.+)/);
|
||||
if (hm) {
|
||||
var level = hm[1].length;
|
||||
out.push(
|
||||
"<h" + level + ">" + inlineMarkdown(hm[2]) + "</h" + level + ">",
|
||||
);
|
||||
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("<p>" + inlineMarkdown(line) + "</p>");
|
||||
}
|
||||
}
|
||||
|
||||
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(/<p>\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(/<p>\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(/<p>\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;
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user