134 lines
4.3 KiB
JavaScript
134 lines
4.3 KiB
JavaScript
/* Brain of Reese — document viewer (phase 10).
|
|
*
|
|
* Reads `source`/`path` query params, fetches the stateless content
|
|
* endpoint (GET /api/documents/content — database only, no filesystem),
|
|
* and renders:
|
|
*
|
|
* • md / markdown → the shared escape-first renderer (markdown.js) in a
|
|
* ≤46rem centered column;
|
|
* • any other → the raw content as a text node inside
|
|
* <pre class="doc-raw"> (mono, horizontal scroll).
|
|
*
|
|
* XSS-safe by construction: markdown is escaped before transform, raw
|
|
* formats are set via textContent, and every document-derived string
|
|
* (title, badges, path) is written with textContent — never innerHTML.
|
|
*
|
|
* A missing document (unknown pair, missing params, network error) shows
|
|
* the designed not-found card with a link back to the Sources page.
|
|
*/
|
|
|
|
const params = new URLSearchParams(window.location.search);
|
|
const source = params.get("source") || "";
|
|
const path = params.get("path") || "";
|
|
|
|
const titleEl = document.querySelector("#doc-title");
|
|
const metaEl = document.querySelector("#doc-meta");
|
|
const contentEl = document.querySelector("#doc-content");
|
|
const notFoundEl = document.querySelector("#doc-not-found");
|
|
const mainEl = document.querySelector("#main");
|
|
const backLink = document.querySelector("#doc-back");
|
|
|
|
/* Back button (phase 13): the return target comes from the `back` query
|
|
* param, not the browser history — both entry points (chat source chips
|
|
* and the Sources table) open the viewer in a NEW tab, where there is no
|
|
* history to go back to. The param is honored only for same-origin
|
|
* relative URLs (starts with "/" but not "//"), so absolute (https://…),
|
|
* protocol-relative (//…), and pseudo-protocol (javascript:…) values are
|
|
* rejected; anything else falls back to the Sources page. The static
|
|
* href="/sources.html" in document.html remains the no-JS fallback, and
|
|
* with the href set the anchor's default click behavior IS the
|
|
* deterministic navigation (no browser-history heuristics). */
|
|
const backParam = params.get("back") || "";
|
|
const backTarget =
|
|
backParam.startsWith("/") && !backParam.startsWith("//")
|
|
? backParam
|
|
: "/sources.html";
|
|
backLink.href = backTarget;
|
|
const backLabel = backLink.querySelector("span");
|
|
if (backLabel) {
|
|
backLabel.textContent =
|
|
backTarget === "/"
|
|
? "Chat"
|
|
: backTarget === "/sources.html"
|
|
? "Sources"
|
|
: "Back";
|
|
}
|
|
|
|
function fmtDate(iso) {
|
|
try {
|
|
return new Date(iso).toLocaleString();
|
|
} catch {
|
|
return iso;
|
|
}
|
|
}
|
|
|
|
function contentUrl(s, p) {
|
|
return "/api/documents/content?source=" + encodeURIComponent(s) + "&path=" + encodeURIComponent(p);
|
|
}
|
|
|
|
function metaBadge(cls, text) {
|
|
const el = document.createElement("span");
|
|
el.className = cls;
|
|
el.textContent = text;
|
|
return el;
|
|
}
|
|
|
|
function render(doc) {
|
|
titleEl.textContent = doc.title;
|
|
document.title = `${doc.title} · Brain of Reese`;
|
|
|
|
const pathCode = document.createElement("code");
|
|
pathCode.className = "doc-path";
|
|
pathCode.textContent = doc.path;
|
|
metaEl.replaceChildren(
|
|
metaBadge("doc-source-badge", doc.source),
|
|
metaBadge("format-badge", doc.format),
|
|
pathCode,
|
|
metaBadge("doc-indexed", `Indexed ${fmtDate(doc.indexed_at)}`),
|
|
metaBadge("doc-chunks", `${doc.chunks} chunk${doc.chunks === 1 ? "" : "s"}`),
|
|
);
|
|
|
|
notFoundEl.hidden = true;
|
|
contentEl.replaceChildren();
|
|
if (doc.format === "md" || doc.format === "markdown") {
|
|
const wrap = document.createElement("div");
|
|
wrap.className = "doc-md";
|
|
wrap.innerHTML = renderMarkdown(doc.content); // escape-first: XSS-safe
|
|
contentEl.appendChild(wrap);
|
|
} else {
|
|
const pre = document.createElement("pre");
|
|
pre.className = "doc-raw";
|
|
pre.textContent = doc.content; // text node: never parsed as HTML
|
|
contentEl.appendChild(pre);
|
|
}
|
|
}
|
|
|
|
function showNotFound() {
|
|
titleEl.textContent = "Document not found";
|
|
document.title = "Document not found · Brain of Reese";
|
|
metaEl.replaceChildren();
|
|
contentEl.replaceChildren();
|
|
notFoundEl.hidden = false;
|
|
}
|
|
|
|
async function load() {
|
|
try {
|
|
if (!source || !path) {
|
|
showNotFound();
|
|
return;
|
|
}
|
|
const r = await fetch(contentUrl(source, path));
|
|
if (!r.ok) {
|
|
showNotFound();
|
|
return;
|
|
}
|
|
render(await r.json());
|
|
} catch {
|
|
showNotFound();
|
|
} finally {
|
|
mainEl.focus(); // move focus to main on load (a11y)
|
|
}
|
|
}
|
|
|
|
load();
|