201 lines
7.7 KiB
JavaScript
201 lines
7.7 KiB
JavaScript
/* Brain of Reese — document viewer (phase 10) + the shared document
|
|
* renderer (phase 26).
|
|
*
|
|
* Two jobs:
|
|
*
|
|
* 1. renderDocument(doc, { titleEl, metaEl, contentEl }) — the
|
|
* EXPORTED rendering core. The /document.html page and the document
|
|
* modal (assets/document-modal.js, phase 26) both render through it,
|
|
* so the two surfaces can never drift:
|
|
*
|
|
* • 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).
|
|
*
|
|
* 2. The /document.html page itself: reads `source`/`path` query
|
|
* params, fetches the stateless content endpoint
|
|
* (GET /api/documents/content — database only, no filesystem), and
|
|
* renders via renderDocument into #doc-title / #doc-meta /
|
|
* #doc-content. A missing document (unknown pair, missing params,
|
|
* network error) shows the designed not-found card with a link back
|
|
* to the Sources page.
|
|
*
|
|
* 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
|
|
* (innerHTML goes through renderMarkdown, which escapes first).
|
|
*
|
|
* Phase 19: the viewer joins the shared header (assets/header.js) — the
|
|
* whoami fetch is the module's cached promise (one request per page,
|
|
* shared with initSharedHeader's toggling), and the bar gains the New
|
|
* chat button: on a non-chat page "new chat" means going to the chat,
|
|
* fresh (clear the phase-14 conversation key, then navigate to "/").
|
|
*
|
|
* Phase 26 (import safety): the modal module imports renderDocument
|
|
* from THIS file on the chat/sources pages, so everything
|
|
* /document.html-specific runs only when #doc-title exists (the guard
|
|
* around the page block below). Importing renderDocument elsewhere has
|
|
* no side effects: no back-link resolution, no whoami, no content
|
|
* fetch, no New Chat binding.
|
|
*/
|
|
|
|
import { clearChatStorage, fetchIsAdmin, initSharedHeader } from "./header.js";
|
|
|
|
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;
|
|
}
|
|
|
|
/* ---------- shared renderer (phase 26, task 02) ----------
|
|
* Populates the three elements every render surface provides: a title,
|
|
* a .doc-meta badge row (source · format · mono path · indexed ·
|
|
* chunks), and a content container — .doc-md for md/markdown (the
|
|
* shared escape-first renderer), <pre class="doc-raw"> otherwise. The
|
|
* XSS contract: innerHTML only through renderMarkdown; every
|
|
* document-derived string is a text node. */
|
|
export function renderDocument(doc, { titleEl, metaEl, contentEl }) {
|
|
titleEl.textContent = doc.title;
|
|
|
|
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"}`),
|
|
);
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
/* ---------- /document.html page (phases 10/13/19) ----------
|
|
* Phase 26: viewer-page-specific — see the import-safety note in the
|
|
* header. The guard is #doc-title: it exists only on this page, so the
|
|
* modal module's `import { renderDocument } from "./document.js"` on
|
|
* the chat/sources pages evaluates none of the code below. */
|
|
if (document.querySelector("#doc-title")) {
|
|
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 — the dedicated page is
|
|
* reachable directly (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";
|
|
}
|
|
|
|
/* renderDocument fills the page elements; the page additionally owns
|
|
* the document.title (the modal keeps the page title untouched). */
|
|
function render(doc) {
|
|
renderDocument(doc, { titleEl, metaEl, contentEl });
|
|
document.title = `${doc.title} · Brain of Reese`;
|
|
}
|
|
|
|
function showNotFound() {
|
|
titleEl.textContent = "Document not found";
|
|
document.title = "Document not found · Brain of Reese";
|
|
metaEl.replaceChildren();
|
|
contentEl.replaceChildren();
|
|
notFoundEl.hidden = false;
|
|
}
|
|
|
|
/* Phase 19: the shared header controls (Sign in / Sign out — exactly
|
|
* one visible) are toggled here; the viewer has no nav, so there is
|
|
* no #nav-sources for the module to touch. Independent of the doc
|
|
* fetch (its own IIFE — load() below never waits on it).
|
|
* (fetchIsAdmin is imported for parity with the other header
|
|
* consumers — the module's cached promise is the single whoami per
|
|
* page either way.) */
|
|
(async () => {
|
|
await initSharedHeader();
|
|
})();
|
|
|
|
/* Phase 19: New chat on a non-chat page means "go to the chat,
|
|
* fresh": clear the phase-14 conversation key, then land on the chat
|
|
* page — its empty state, since the conversation is gone from storage. */
|
|
const newChatBtn = document.querySelector("#new-chat-btn");
|
|
if (newChatBtn) {
|
|
newChatBtn.addEventListener("click", () => {
|
|
clearChatStorage();
|
|
window.location.href = "/";
|
|
});
|
|
}
|
|
|
|
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();
|
|
}
|