feat(ui): clickable document viewer — open any cited document in the browser from chat chips and the sources table
This commit is contained in:
+11
-41
@@ -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) => ({
|
||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||
}[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);
|
||||
|
||||
Reference in New Issue
Block a user