feat(ui): clickable document viewer — open any cited document in the browser from chat chips and the sources table

This commit is contained in:
2026-08-22 02:08:49 -04:00
parent 7e8d14702e
commit 6ec6181c7b
15 changed files with 1218 additions and 58 deletions
+11 -41
View File
@@ -65,46 +65,14 @@ const reducedMotion =
typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
const SCROLL = reducedMotion ? "auto" : "smooth";
/* ---------- tiny, safe markdown renderer (no external libs, no CDN) ---------- */
export function escapeHtml(s) {
return s.replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]));
}
export function renderMarkdown(md) {
// 1. Protect fenced code blocks.
const codeBlocks = [];
let text = md.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => {
codeBlocks.push(`<pre><code>${escapeHtml(code.replace(/\n$/, ""))}</code></pre>`);
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>");
// 3. Paragraphs (double newline separated).
text = text
.split(/\n{2,}/)
.map((block) => {
const b = block.trim();
if (!b) return "";
if (/^<(h\d|ul|ol|pre|li)/.test(b)) return b;
return `<p>${b.replace(/\n/g, "<br>")}</p>`;
})
.join("");
// 4. Restore code blocks.
return text.replace(/\u0000CODE(\d+)\u0000/g, (_m, i) => codeBlocks[Number(i)]);
/* ---------- document viewer link (phase 10) ----------
* Every cited document opens in the viewer, in a NEW tab. Both query
* values are percent-encoded: real paths contain slashes and sometimes
* spaces, which would otherwise corrupt the query string. (The renderer
* renderMarkdown/escapeHtml now lives in assets/markdown.js — a classic
* script loaded by index.html and document.html before these modules.) */
export function documentUrl(source, path) {
return "/document.html?source=" + encodeURIComponent(source) + "&path=" + encodeURIComponent(path);
}
/* ---------- avatar glyphs (phase 08: emoji-free chrome) ----------
@@ -323,7 +291,9 @@ function appendSources(wrap, sources) {
const chip = document.createElement("a");
chip.className = "source-chip";
chip.setAttribute("role", "listitem");
chip.href = "/sources.html";
chip.href = documentUrl(s.source, s.path);
chip.target = "_blank"; // open the full document in a new tab
chip.rel = "noopener";
chip.textContent = label;
chip.title = label;
meta.appendChild(chip);