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
+13
View File
@@ -72,6 +72,19 @@ uv run uvicorn app.main:app --reload
> 📝 **After this, day-to-day is just: edit markdown → re-run the import.**
> See [Updating the documents](#updating-the-documents) below.
## Using the UI
- **Chat** (`/`) — ask questions; answers stream in with **source chips**
that cite the exact documents used. Clicking a chip opens that document
**in a new tab**.
- **Document viewer** (`/document.html?source=…&path=…`) — the full text of
any indexed document, served from the database (no filesystem access):
markdown is rendered, every other format (`yaml`, `json`, `py`, `txt`, …)
is shown as escaped monospace text. Unknown documents get a designed
not-found state with a link back to the index.
- **Sources** (`/sources.html`) — the indexed document list; the *Path*
column links each document to the viewer in a new tab.
## Updating the documents
**This is the workflow you'll use most.** The knowledge base is refreshed by
+48 -3
View File
@@ -1,17 +1,32 @@
"""GET /api/docs — the indexed document list (feeds the Sources page)."""
"""GET /api/docs — the indexed document list (feeds the Sources page).
GET /api/documents/content — one indexed document's full content (feeds the
clickable document viewer, phase 10). DB-only by design: the (source, path)
pair is looked up as a row, so there is no filesystem access and no
path-traversal surface — ``../``-style values simply aren't rows (→ 404).
"""
from __future__ import annotations
from fastapi import APIRouter, Depends
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.db import get_db
from app.models import Chunk, Document
from app.schemas import DocList, DocSummary
from app.schemas import DocContent, DocList, DocSummary
router = APIRouter(tags=["kb"])
def doc_format(path: str) -> str:
"""Lowercased path suffix without its dot (``kubernetes.md`` → ``md``,
``notes/deep.Markdown`` → ``markdown``); ``text`` when the path has no
suffix — the value shown in the viewer's format badge."""
return Path(path).suffix.lower().lstrip(".") or "text"
@router.get("/docs", response_model=DocList)
def list_documents(db: Session = Depends(get_db)) -> DocList: # noqa: B008
"""All indexed documents with per-document chunk counts.
@@ -45,3 +60,33 @@ def list_documents(db: Session = Depends(get_db)) -> DocList: # noqa: B008
for row in rows
]
)
@router.get("/documents/content", response_model=DocContent)
def get_document_content(
source: str, path: str, db: Session = Depends(get_db) # noqa: B008
) -> DocContent:
"""Full content of one indexed document, looked up by ``(source, path)``.
Stateless (A10) and database-only: unknown pairs — including traversal
strings such as ``../../etc/passwd`` — are just non-existent rows and
map to 404 ``{detail: "document not found"}``.
"""
row = db.execute(
select(Document, func.count(Chunk.id).label("chunks"))
.outerjoin(Chunk, Chunk.document_id == Document.id)
.where(Document.source == source, Document.path == path)
.group_by(Document.id)
).first()
if row is None:
raise HTTPException(status_code=404, detail="document not found")
doc, chunks = row
return DocContent(
source=doc.source,
path=doc.path,
title=doc.title,
format=doc_format(doc.path),
content=doc.content,
indexed_at=doc.indexed_at.isoformat(),
chunks=chunks,
)
+12
View File
@@ -61,3 +61,15 @@ class DocList(BaseModel):
"""Response of ``GET /api/docs`` (empty list → designed empty state)."""
documents: list[DocSummary]
class DocContent(BaseModel):
"""One indexed document's full content (feeds the viewer page, phase 10)."""
source: str
path: str
title: str
format: str
content: str
indexed_at: str
chunks: int
+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);
+116
View File
@@ -0,0 +1,116 @@
/* 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: prefer the browser's own history when there is one (the viewer was
* opened from this tab's session); a fresh tab lands on the Sources page. */
backLink.addEventListener("click", (e) => {
if (window.history.length > 1) {
e.preventDefault();
window.history.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();
+51
View File
@@ -0,0 +1,51 @@
/* Brain of Reese — shared markdown renderer (no external libs, no CDN).
*
* Extracted from app.js (phase 10) so the chat page and the document
* viewer share the exact same escape-first renderer: every character is
* HTML-escaped before any markup transform runs, so document (or user)
* content can never inject live HTML/XSS. Classic script on purpose:
* index.html and document.html load it via a plain relative <script src>
* and both module scripts (app.js / document.js) call the globals it
* defines. Rendering behavior is unchanged from the original app.js copy.
*/
function escapeHtml(s) {
return s.replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]));
}
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)]);
}
+27 -4
View File
@@ -21,6 +21,12 @@ function fmtDate(iso) {
}
}
/* Viewer link (phase 10) — same encoded URL the chat chips use; both query
* values are percent-encoded (paths contain slashes, sometimes spaces). */
export function documentUrl(source, path) {
return "/document.html?source=" + encodeURIComponent(source) + "&path=" + encodeURIComponent(path);
}
async function loadDocs() {
let r;
try {
@@ -56,13 +62,30 @@ async function loadDocs() {
function makeRow(d) {
const tr = document.createElement("tr");
const cells = [d.source, d.path, d.title, String(d.chunks), fmtDate(d.indexed_at)];
for (const value of cells) {
const sourceTd = document.createElement("td");
sourceTd.textContent = d.source; // document-derived text — never innerHTML
tr.appendChild(sourceTd);
// Path cell: a link to the document viewer (phase 10), full path as the
// accessible/hover name (the column is ellipsized).
const pathTd = document.createElement("td");
pathTd.title = d.path; // full path on hover (column is ellipsized)
const link = document.createElement("a");
link.className = "doc-link";
link.href = documentUrl(d.source, d.path);
link.target = "_blank"; // open the full document in a new tab
link.rel = "noopener";
link.title = d.path; // full path as the link's hover/accessible name
link.textContent = d.path;
pathTd.appendChild(link);
tr.appendChild(pathTd);
for (const value of [d.title, String(d.chunks), fmtDate(d.indexed_at)]) {
const td = document.createElement("td");
td.textContent = value; // document-derived text — never innerHTML
td.textContent = value;
tr.appendChild(td);
}
tr.children[1].title = d.path; // full path on hover (column is ellipsized)
return tr;
}
+177 -3
View File
@@ -153,8 +153,10 @@ body::after {
top: 0;
z-index: 20;
}
/* 2px brand→cyan gradient hairline under the sticky header (phase 08). */
.app-header::after {
/* 2px brand→cyan gradient hairline under the sticky header (phase 08;
shared by the app header and the document-viewer header, phase 10). */
.app-header::after,
.doc-header::after {
content: "";
position: absolute;
inset-inline: 0;
@@ -326,7 +328,7 @@ body::after {
text-overflow: ellipsis;
white-space: nowrap;
}
.source-chip:hover { background: #2a345f; }
.source-chip:hover { background: #2a345f; text-decoration: underline; }
/* "Maybe try" chips under a deflected bubble (phase 04). Unlike the
onboarding row (which scrolls horizontally on mobile), this group wraps
@@ -547,6 +549,173 @@ body::after {
.docs-table tbody tr:hover { background: var(--bg); }
.docs-table tbody tr:last-child td { border-bottom: 0; }
/* ---------- Document viewer (phase 10) ---------- */
.doc-header {
position: sticky;
top: 0;
z-index: 20;
background: var(--surface);
}
.doc-header-inner {
padding-block: 0.7rem;
display: flex;
align-items: center;
gap: 0.9rem;
}
/* Back link: pill with an SVG arrow + "Sources" (>=44px touch target). */
.doc-back {
display: inline-flex;
align-items: center;
gap: 0.4rem;
flex: 0 0 auto;
min-height: 44px;
padding: 0.45rem 0.9rem;
border-radius: 999px;
background: var(--brand-soft);
border: 1px solid var(--line);
color: var(--brand-ink); /* 6.9:1 on --brand-soft */
font-weight: 600;
font-size: 0.95rem;
text-decoration: none;
}
.doc-back:hover { background: #2a345f; }
.doc-back svg { width: 16px; height: 16px; display: block; }
.doc-title-block { min-width: 0; }
#doc-title {
margin: 0;
font-size: 1.3rem;
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Meta row: source badge · format badge · mono path · indexed · chunks. */
.doc-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
margin-top: 0.35rem;
font-size: 0.78rem;
color: var(--ink-soft);
}
.doc-source-badge {
background: var(--brand-soft);
border: 1px solid var(--line);
color: var(--brand-ink);
font-weight: 600;
border-radius: 999px;
padding: 0.02rem 0.55rem;
}
.format-badge {
font-family: var(--mono);
background: var(--brand-soft);
border: 1px solid var(--line);
color: var(--brand-ink);
border-radius: var(--radius-sm);
padding: 0.02rem 0.45rem;
}
.doc-path {
font-family: var(--mono);
font-size: 0.75rem;
max-width: 26rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.doc-chunks { font-family: var(--mono); }
.doc-shell {
display: flex;
flex-direction: column;
gap: 1rem;
flex: 1;
}
#doc-content { display: flex; flex-direction: column; }
.doc-loading { margin: 1.5rem auto; text-align: center; color: var(--ink-soft); }
/* Markdown: the centered, ≤46rem reading column (PLAN §7.1). */
.doc-md {
width: 100%;
max-width: 46rem;
margin-inline: auto;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.5rem 1.75rem;
overflow-wrap: anywhere;
}
.doc-md p { margin: 0.35rem 0; }
.doc-md h3 { margin: 1.1rem 0 0.4rem; font-size: 1.15rem; }
.doc-md h4 { margin: 0.9rem 0 0.35rem; font-size: 1rem; }
.doc-md > :first-child { margin-top: 0; }
.doc-md ul { margin: 0.4rem 0; padding-left: 1.3rem; }
.doc-md pre {
background: #0d1120;
color: #e6e9f2;
padding: 0.7rem 0.9rem;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
overflow-x: auto;
font-size: 0.85rem;
font-family: var(--mono);
}
.doc-md code { font-family: var(--mono); font-size: 0.88em; background: var(--brand-soft); padding: 0.08em 0.35em; border-radius: 5px; }
.doc-md pre code { background: none; padding: 0; }
/* Raw (non-markdown) formats: full-width mono pre, horizontal scroll. */
.doc-raw {
width: 100%;
margin: 0;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 1.25rem 1.5rem;
overflow-x: auto;
white-space: pre;
font-family: var(--mono);
font-size: 0.85rem;
line-height: 1.5;
}
/* Designed not-found state (no emoji — plain SVG mark, phase 08 rule). */
.doc-not-found {
width: 100%;
max-width: 30rem;
margin: 2rem auto;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 2.25rem 1.75rem;
text-align: center;
}
.doc-not-found-glyph { color: var(--ink-soft); width: 44px; height: 44px; margin-inline: auto; }
.doc-not-found-glyph svg { width: 44px; height: 44px; display: block; }
.doc-not-found h2 { margin: 0.8rem 0 0.4rem; font-size: 1.3rem; }
.doc-not-found-sub { margin: 0 0 1.25rem; color: var(--ink-soft); }
.doc-open-sources {
display: inline-flex;
align-items: center;
min-height: 44px;
padding: 0.5rem 1.1rem;
background: var(--brand);
color: var(--bg); /* dark ink on brand: 5.2:1 */
font-weight: 700;
text-decoration: none;
border-radius: var(--radius-sm);
}
.doc-open-sources:hover { background: #7d88f5; }
/* Viewer links: Sources-table path cell + chat source chips (phase 10). */
.doc-link {
color: var(--brand-ink); /* 8.7:1 on --surface */
text-decoration: none;
}
.doc-link:hover, .doc-link:focus-visible { text-decoration: underline; }
/* ---------- Footer ---------- */
.app-footer {
border-top: 1px solid var(--line);
@@ -574,6 +743,11 @@ body::after {
.suggestions { flex-wrap: nowrap; overflow-x: auto; justify-content: flex-start;
padding-bottom: 0.4rem; -webkit-overflow-scrolling: touch; scrollbar-width: thin; }
.suggestion-chip { flex: 0 0 auto; }
.doc-header-inner { flex-wrap: wrap; gap: 0.5rem; padding-block: 0.6rem; }
#doc-title { font-size: 1.1rem; }
.doc-path { max-width: 16rem; }
.doc-md { padding: 1.1rem 1rem; }
.doc-raw { padding: 1rem; font-size: 0.8rem; }
.composer { padding: 0.5rem; }
.footer-inner { flex-direction: column; gap: 0.2rem; text-align: center; }
main { padding-bottom: env(safe-area-inset-bottom, 0); }
+58
View File
@@ -0,0 +1,58 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="description" content="Read a document indexed in Brain of Reese.">
<title>Document · Brain of Reese</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns=%22http://www.w3.org/2000/svg%22%20viewBox=%220%200%2064%2064%22%3E%3Cpath%20d=%22M32%204%2055%2018v28L32%2060%209%2046V18Z%22%20fill=%22%23121a2e%22%20stroke=%22%236d78f2%22%20stroke-width=%224%22%20stroke-linejoin=%22round%22/%3E%3Ccircle%20cx=%2232%22%20cy=%2232%22%20r=%226.5%22%20fill=%22%236d78f2%22/%3E%3Cpath%20d=%22M32%2025.5V16M32%2048v-9.5M25.5%2032H16M48%2032h-9.5%22%20stroke=%22%2322d3ee%22%20stroke-width=%223%22%20stroke-linecap=%22round%22/%3E%3C/svg%3E">
<link rel="stylesheet" href="/assets/styles.css">
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<header class="doc-header">
<div class="container doc-header-inner">
<a class="doc-back" id="doc-back" href="/sources.html">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 12H5"/><path d="m12 19-7-7 7-7"/></svg>
<span>Sources</span>
</a>
<div class="doc-title-block">
<h1 id="doc-title">Loading…</h1>
<div id="doc-meta" class="doc-meta"></div>
</div>
</div>
</header>
<main id="main" class="app-main" tabindex="-1">
<!-- aria-live wraps the load → content swap so screen readers hear the
document land (phase 10 a11y contract). -->
<div class="container doc-shell" aria-live="polite">
<div id="doc-content">
<p class="doc-loading" role="status">Loading document…</p>
</div>
<div class="doc-not-found" id="doc-not-found" hidden>
<div class="doc-not-found-glyph" aria-hidden="true">
<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><path d="M12 4h16l8 8v28a4 4 0 0 1-4 4H12a4 4 0 0 1-4-4V8a4 4 0 0 1 4-4Z"/><path d="M28 4v8h8"/><path d="m19 22 10 10M29 22l-10 10"/></svg>
</div>
<h2>Document not found</h2>
<p class="doc-not-found-sub">
This document isn't in the knowledge base — it may have been removed
from the index, or the notes were re-imported.
</p>
<a class="doc-open-sources" href="/sources.html">Open Sources</a>
</div>
</div>
</main>
<footer class="app-footer">
<div class="container footer-inner">
<span>Powered by Reese's self-hosted models</span>
</div>
</footer>
<script src="assets/markdown.js"></script>
<script type="module" src="assets/document.js"></script>
</body>
</html>
+1
View File
@@ -73,6 +73,7 @@
</div>
</footer>
<script src="assets/markdown.js"></script>
<script type="module" src="/assets/app.js"></script>
</body>
</html>
+6 -1
View File
@@ -98,10 +98,15 @@ def test_on_topic_question_streams_grounded_answer(
# Grounded: a kubernetes.md source chip renders under the bubble
# (top-N docs can add more chips; the question's doc must be among them).
# Phase 10: chips open the document viewer in a new tab (encoded URL).
chip = page.locator(".msg.brain .source-chip", has_text="kubernetes.md")
expect(chip).to_have_count(1)
expect(chip.first).to_contain_text("kubernetes.md")
expect(chip.first).to_have_attribute("href", "/sources.html")
expect(chip.first).to_have_attribute(
"href", "/document.html?source=docs&path=homelab%2Fkubernetes.md"
)
expect(chip.first).to_have_attribute("target", "_blank")
expect(chip.first).to_have_attribute("rel", "noopener")
# Button recovers: enabled + "Send" (never stale).
expect(page.locator("#send-btn")).to_be_enabled()
+270
View File
@@ -0,0 +1,270 @@
"""Phase 10 E2E (Playwright): the clickable document viewer.
Story: ``.agent/user_stories/document-viewer.md``
Run in isolation (DB must be up: ``podman compose up -d db``):
uv run pytest tests/e2e/test_document_viewer.py -v --no-cov
Seeding reuses the real importer against ``tests/fixtures/docs/`` with the
deterministic mock embeddings (same pattern as the earlier story suites).
Test → story mapping (Playwright Mapping Rule):
1. ``test_source_chip_opens_document`` — chip → NEW TAB → viewer with
title + known content string + format badge.
2. ``test_sources_row_links_to_viewer`` — Sources path link (yaml
fixture) → viewer with raw content in a ``pre``.
3. ``test_markdown_renders_and_stays_xss_safe`` — md fixture containing
``<script>alert(1)</script>`` renders as visible escaped text (no
execution).
4. ``test_missing_doc_shows_not_found`` — unknown doc → not-found
state + Sources link; no console crash.
5. ``test_viewer_theme_and_no_cdn`` — dark theme + every
``script[src]`` / ``link[href]`` local or ``data:`` + a11y frame.
"""
from __future__ import annotations
import asyncio
import re
from datetime import UTC, datetime
from pathlib import Path
from threading import Thread
from typing import Any
from playwright.sync_api import Page, expect
from sqlalchemy import text
from app.config import Settings
from app.db import SessionLocal
from app.models import Document
from app.rag.importer import ImportSummary, import_sources
from app.rag.llm import LLMClient
REPO = Path(__file__).resolve().parents[2]
FIXTURES = REPO / "tests" / "fixtures" / "docs"
QUESTION = "How is my Kubernetes cluster set up?"
async def _import_fixtures(mock_port: int) -> ImportSummary:
kwargs: dict[str, Any] = {"_env_file": None, "llm_base_url": f"http://127.0.0.1:{mock_port}/v1"}
settings = Settings(**kwargs) # pyright: ignore[reportCallIssue]
return await import_sources([FIXTURES], LLMClient(settings))
def _run_in_thread(coro: Any) -> Any:
"""Run a coroutine on a worker thread.
Playwright's sync API keeps an asyncio loop running on the test thread,
so ``asyncio.run`` cannot be called directly from a test body.
"""
box: dict[str, Any] = {}
def runner() -> None:
try:
box["value"] = asyncio.run(coro)
except BaseException as e: # noqa: BLE001 — re-raised on the test thread
box["error"] = e
t = Thread(target=runner)
t.start()
t.join()
if "error" in box:
raise box["error"]
return box["value"]
def _reset_db(mock_port: int, seed: bool) -> ImportSummary | None:
"""Truncate the KB (and query log), then optionally re-import fixtures."""
with SessionLocal() as db:
db.execute(text("TRUNCATE chunks, documents, query_log"))
db.commit()
if not seed:
return None
return _run_in_thread(_import_fixtures(mock_port))
# ---------------------------------------------------------------------------
# 1. Chat source chip → new tab → full document
# ---------------------------------------------------------------------------
def test_source_chip_opens_document(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
page.set_default_timeout(30_000)
page.goto(app_url)
page.fill("#message-input", QUESTION)
page.click("#send-btn")
chip = page.locator(".msg.brain .source-chip", has_text="kubernetes.md")
expect(chip).to_have_count(1, timeout=30_000)
# New-tab contract: same-origin viewer URL, both query values encoded
# (the path's slashes come out as %2F — exactly why encoding matters).
expect(chip.first).to_have_attribute(
"href", "/document.html?source=docs&path=homelab%2Fkubernetes.md"
)
expect(chip.first).to_have_attribute("target", "_blank")
expect(chip.first).to_have_attribute("rel", "noopener")
with page.expect_popup() as popup_info:
chip.first.click()
viewer = popup_info.value
expect(viewer).to_have_url(
re.compile(
re.escape(f"{app_url}/document.html?source=docs&path=homelab%2Fkubernetes.md")
)
)
expect(viewer.locator("#doc-title")).to_have_text("Kubernetes Homelab Cluster")
# Meta row: source badge · format badge · mono path · indexed · chunks.
expect(viewer.locator("#doc-meta .doc-source-badge")).to_have_text("docs")
expect(viewer.locator("#doc-meta .format-badge")).to_have_text("md")
expect(viewer.locator("#doc-meta .doc-path")).to_have_text("homelab/kubernetes.md")
expect(viewer.locator("#doc-meta .doc-indexed")).to_contain_text("Indexed")
assert re.fullmatch(r"\d+ chunks?", viewer.locator("#doc-meta .doc-chunks").inner_text())
# Full document, rendered markdown in the centered column (not a pre).
expect(viewer.locator("#doc-content .doc-md")).to_have_count(1)
expect(viewer.locator("#doc-content")).to_contain_text("Talos Linux on three nodes")
# ---------------------------------------------------------------------------
# 2. Sources table path link → viewer (yaml → raw pre)
# ---------------------------------------------------------------------------
def test_sources_row_links_to_viewer(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
page.goto(f"{app_url}/sources.html")
row = page.locator("#docs-tbody tr", has_text="gitlab-compose.yaml")
expect(row).to_have_count(1)
link = row.locator("td:nth-child(2) a.doc-link")
expect(link).to_have_count(1)
# Encoded URL: the slashes in the path value come out as %2F.
expect(link).to_have_attribute(
"href",
"/document.html?source=docs&path=homelab%2Fcontainer_gitlab%2Fgitlab-compose.yaml",
)
expect(link).to_have_attribute("target", "_blank")
expect(link).to_have_attribute("rel", "noopener")
expect(link).to_have_attribute("title", "homelab/container_gitlab/gitlab-compose.yaml")
with page.expect_popup() as popup_info:
link.click()
viewer = popup_info.value
expect(viewer.locator("#doc-title")).to_have_text("gitlab-compose")
expect(viewer.locator("#doc-meta .format-badge")).to_have_text("yaml")
# Non-markdown formats render as escaped monospace text in a pre.
pre = viewer.locator("#doc-content pre.doc-raw")
expect(pre).to_have_count(1)
expect(pre).to_contain_text("gitlab/gitlab-ce:17.2.1-ce.0")
font = pre.evaluate("el => getComputedStyle(el).fontFamily")
assert "mono" in font
# ---------------------------------------------------------------------------
# 3. Markdown renders through the shared renderer and stays XSS-safe
# ---------------------------------------------------------------------------
def test_markdown_renders_and_stays_xss_safe(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
# A document whose content carries a hostile <script> line. The viewer
# is database-only, so it can be seeded straight into the KB.
with SessionLocal() as db:
db.add(
Document(
source="docs",
path="notes/xss-fixture.md",
full_path="/tmp/xss-fixture.md",
title="Xss Fixture",
content="# Xss Fixture\n\n<script>alert(1)</script>\n\nXSS-FIXTURE-MARKER",
content_hash="c" * 64,
indexed_at=datetime.now(UTC),
)
)
db.commit()
dialogs: list[str] = []
def _catch_dialog(d) -> None: # a fired dialog == executed script
dialogs.append(d.message)
d.dismiss()
page.on("dialog", _catch_dialog)
page.goto(f"{app_url}/document.html?source=docs&path=notes%2Fxss-fixture.md")
expect(page.locator("#doc-title")).to_have_text("Xss Fixture")
# The tag shows up as VISIBLE, ESCAPED text — rendered, never executed.
expect(page.locator("#doc-content")).to_contain_text("<script>alert(1)</script>")
expect(page.locator("#doc-content")).to_contain_text("XSS-FIXTURE-MARKER")
assert page.locator("#doc-content script").count() == 0, "hostile script became live HTML"
assert dialogs == [], f"dialog fired — script executed: {dialogs}"
# ---------------------------------------------------------------------------
# 4. Missing document → designed not-found state, no console crash
# ---------------------------------------------------------------------------
def test_missing_doc_shows_not_found(
page: Page, app_url: str, mock_llm: int, db_ready: None
) -> None:
_reset_db(mock_llm, seed=True)
errors: list[str] = []
page.on("pageerror", lambda e: errors.append(str(e)))
page.goto(f"{app_url}/document.html?source=docs&path=definitely/not/here.md")
expect(page.locator("#doc-title")).to_have_text("Document not found")
card = page.locator("#doc-not-found")
expect(card).to_be_visible()
expect(card).to_contain_text("Document not found")
expect(card.locator("a.doc-open-sources")).to_have_attribute("href", "/sources.html")
expect(page.locator("#doc-content")).to_be_empty()
# Missing params → the same designed state (no fetch, no crash).
page.goto(f"{app_url}/document.html")
expect(page.locator("#doc-not-found")).to_be_visible()
assert errors == [], f"console crashes: {errors}"
# ---------------------------------------------------------------------------
# 5. Dark theme + all assets local + a11y frame
# ---------------------------------------------------------------------------
def test_viewer_theme_and_no_cdn(page: Page, app_url: str, mock_llm: int, db_ready: None) -> None:
_reset_db(mock_llm, seed=True)
page.goto(f"{app_url}/document.html?source=docs&path=homelab%2Fkubernetes.md")
expect(page.locator("#doc-content .doc-md")).not_to_be_empty()
# Dark theme inherited from phase 08 (same sampling as that story).
bg = page.evaluate("() => getComputedStyle(document.documentElement).backgroundColor")
assert bg == "rgb(10, 14, 23)"
# No-CDN: every script/link reference is same-origin or a data: URI.
refs = page.evaluate(
"""() => [...document.querySelectorAll("script[src], link[href]")]
.map((el) => el.src || el.href)"""
)
assert refs, "expected local asset references on /document.html"
for ref in refs:
assert ref.startswith(app_url) or ref.startswith("data:"), f"non-local: {ref}"
# A11y frame: landmarks, skip link, aria-live around the load→content
# swap, and focus moved to main on load.
expect(page.locator("header.doc-header")).to_have_count(1)
expect(page.locator("main#main")).to_have_count(1)
expect(page.locator("footer.app-footer")).to_have_count(1)
expect(page.locator(".skip-link")).to_have_count(1)
expect(page.locator(".doc-shell")).to_have_attribute("aria-live", "polite")
assert page.evaluate("() => document.activeElement && document.activeElement.id") == "main"
# Markdown column centered and capped at 46rem (736px at 16px root).
box = page.locator("#doc-content .doc-md").bounding_box()
assert box is not None and box["width"] <= 736 + 1
+23 -6
View File
@@ -52,12 +52,16 @@ def test_suggestions_honors_bor_suggestions_env_override(monkeypatch) -> None:
@pytest.mark.parametrize(
("path", "marker"),
[("/", "Brain of Reese"), ("/sources.html", "Knowledge base")],
[
("/", "Brain of Reese"),
("/sources.html", "Knowledge base"),
("/document.html", "Brain of Reese"), # phase 10: viewer page
],
)
def test_html_pages_served_locally_no_cdn(client, path: str, marker: str) -> None:
"""No-CDN check (PLAN §7.3, re-verified on BOTH pages in phase 07):
each page is served by FastAPI and references only same-origin assets
(no https:// script/link tags)."""
"""No-CDN check (PLAN §7.3, re-verified on BOTH pages in phase 07 and
on the viewer page in phase 10): each page is served by FastAPI and
references only same-origin assets (no https:// script/link tags)."""
r = client.get(path)
assert r.status_code == 200
assert marker in r.text
@@ -68,6 +72,9 @@ def test_html_pages_served_locally_no_cdn(client, path: str, marker: str) -> Non
def test_styles_and_js_served(client) -> None:
assert client.get("/assets/styles.css").status_code == 200
assert client.get("/assets/app.js").status_code == 200
assert client.get("/assets/sources.js").status_code == 200
assert client.get("/assets/markdown.js").status_code == 200 # phase 10: shared renderer
assert client.get("/assets/document.js").status_code == 200 # phase 10: viewer page
# Emoji code points banned from UI chrome (phase 08): the pictograph
@@ -92,10 +99,20 @@ def _find_emoji(text: str) -> list[str]:
@pytest.mark.parametrize(
"path", ["/", "/sources.html", "/assets/app.js", "/assets/styles.css"]
"path",
[
"/",
"/sources.html",
"/document.html",
"/assets/app.js",
"/assets/sources.js",
"/assets/markdown.js",
"/assets/document.js",
"/assets/styles.css",
],
)
def test_ui_chrome_has_no_emoji(client, path: str) -> None:
"""Permanent regression guard (phase 08): the UI chrome — both pages,
"""Permanent regression guard (phase 08): the UI chrome — all pages,
the JS that renders it, and the stylesheet — is emoji-free."""
r = client.get(path)
assert r.status_code == 200
+137
View File
@@ -0,0 +1,137 @@
"""Integration tests: GET /api/documents/content — the viewer's data source.
Uses the real compose Postgres (``db`` fixture) and FastAPI's TestClient:
* 200 with the full field set for a seeded document (all formats);
* 404 for an unknown (source, path) pair;
* 404 for traversal-style ``path`` values (no filesystem access → no leak).
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import text
from app.models import Chunk, Document
def _seed_doc(
db,
source: str = "Homelab",
path: str = "kubernetes.md",
title: str = "Kubernetes Homelab Cluster",
content: str = "# Kubernetes\n\nTalos on 3 nodes.",
chunks: int = 2,
) -> None:
"""Truncate the KB and insert one document with ``chunks`` chunk rows."""
db.execute(text("TRUNCATE chunks, documents"))
db.commit()
doc = Document(
source=source,
path=path,
full_path=f"/tmp/{path}",
title=title,
content=content,
content_hash="a" * 64,
indexed_at=datetime.now(UTC),
)
db.add(doc)
db.flush()
if chunks:
db.add_all(
Chunk(document_id=doc.id, position=i, content=f"chunk {i}", embedding=[0.01] * 768)
for i in range(chunks)
)
db.commit()
def test_content_200_all_fields(client, db) -> None:
_seed_doc(db)
try:
r = client.get(
"/api/documents/content",
params={"source": "Homelab", "path": "kubernetes.md"},
)
assert r.status_code == 200
body = r.json()
assert set(body) == {"source", "path", "title", "format", "content", "indexed_at", "chunks"}
assert body["source"] == "Homelab"
assert body["path"] == "kubernetes.md"
assert body["title"] == "Kubernetes Homelab Cluster"
assert body["format"] == "md"
assert body["content"] == "# Kubernetes\n\nTalos on 3 nodes."
assert body["chunks"] == 2
datetime.fromisoformat(body["indexed_at"]) # raises if not ISO-8601
finally:
db.execute(text("TRUNCATE chunks, documents"))
db.commit()
def test_content_404_unknown_path(client, db) -> None:
_seed_doc(db)
try:
r = client.get(
"/api/documents/content",
params={"source": "Homelab", "path": "nope/missing.md"},
)
assert r.status_code == 404
assert r.json() == {"detail": "document not found"}
# A pair that exists under a DIFFERENT source is also 404 — both
# values must match the row.
r = client.get(
"/api/documents/content",
params={"source": "Deployments", "path": "kubernetes.md"},
)
assert r.status_code == 404
finally:
db.execute(text("TRUNCATE chunks, documents"))
db.commit()
def test_content_404_traversal_style_path_no_leak(client, db) -> None:
"""DB-only lookup: traversal strings are just non-existent rows — 404,
and the response must not carry anything from the filesystem."""
_seed_doc(db)
try:
for path in ("../../etc/passwd", "../kubernetes.md", "..%2F..%2Fetc%2Fpasswd"):
r = client.get(
"/api/documents/content",
params={"source": "Homelab", "path": path},
)
assert r.status_code == 404, path
assert r.json() == {"detail": "document not found"}, path
assert "root:" not in r.text, path
finally:
db.execute(text("TRUNCATE chunks, documents"))
db.commit()
def test_content_format_from_suffix(client, db) -> None:
"""format = lowercased path suffix: yaml documents (phase 09 corpus) and
the no-suffix fallback both flow through the same endpoint."""
try:
_seed_doc(
db,
path="container_gitlab/gitlab-compose.yaml",
title="gitlab-compose",
content="services:\n gitlab:\n image: gitlab/gitlab-ce",
chunks=0,
)
r = client.get(
"/api/documents/content",
params={"source": "Homelab", "path": "container_gitlab/gitlab-compose.yaml"},
)
assert r.status_code == 200
body = r.json()
assert body["format"] == "yaml"
assert body["chunks"] == 0 # outerjoin → zero, not missing
_seed_doc(db, path="README", title="README", content="plain text, no suffix", chunks=0)
r = client.get(
"/api/documents/content",
params={"source": "Homelab", "path": "README"},
)
assert r.status_code == 200
assert r.json()["format"] == "text" # no-suffix fallback
finally:
db.execute(text("TRUNCATE chunks, documents"))
db.commit()
+268
View File
@@ -0,0 +1,268 @@
"""Unit: document viewer (phase 10).
Python side:
* ``doc_format`` — format from the path suffix (incl. ``.markdown`` and the
no-suffix fallback);
* the content endpoint's 200/404 mapping — tested WITHOUT a database by
stubbing the session via FastAPI's dependency override (unknown pairs and
traversal-style paths map to 404 ``{detail: "document not found"}``;
known pairs map to the full ``DocContent`` shape).
Frontend side:
* the viewer URL builder — its real query-encoding behavior (paths with
spaces/slashes) executed under node when available, plus source pins that
run everywhere;
* the shared-renderer extraction — ``markdown.js`` holds the renderer,
loaded by BOTH pages via a relative ``<script src>`` before the module
scripts.
"""
from __future__ import annotations
import re
import shutil
import subprocess
from datetime import UTC, datetime
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from app.api.docs import doc_format
from app.db import get_db
from app.main import create_app
from app.models import Document
FRONTEND = Path(__file__).resolve().parents[2] / "frontend"
APP_JS = FRONTEND / "assets" / "app.js"
SOURCES_JS = FRONTEND / "assets" / "sources.js"
DOCUMENT_JS = FRONTEND / "assets" / "document.js"
MARKDOWN_JS = FRONTEND / "assets" / "markdown.js"
HAVE_NODE = shutil.which("node") is not None
def _read(path: Path) -> str:
return path.read_text(encoding="utf-8")
# ---------------------------------------------------------------------------
# doc_format — format-from-suffix (phase 10, PLAN §4)
# ---------------------------------------------------------------------------
@pytest.mark.parametrize(
("path", "expected"),
[
("kubernetes.md", "md"),
("notes/sub/deep.markdown", "markdown"),
("NOTES/ARCHIVE.MD", "md"),
("homelab/container_gitlab/gitlab-compose.yaml", "yaml"),
("homelab/networking/static-dns.json", "json"),
("homelab/scripts/uptime_probe.py", "py"),
("homelab/ssh/ssh_aliases.txt", "txt"),
("noext", "text"), # no suffix → fallback
("a/b", "text"), # no suffix → fallback
],
)
def test_doc_format_from_suffix(path: str, expected: str) -> None:
assert doc_format(path) == expected
# ---------------------------------------------------------------------------
# Content endpoint mapping — stubbed session, no database required
# ---------------------------------------------------------------------------
class _FakeResult:
def __init__(self, row: object) -> None:
self._row = row
def first(self) -> object:
return self._row
class _FakeSession:
def __init__(self, row: object) -> None:
self._row = row
def execute(self, _stmt: object) -> _FakeResult:
return _FakeResult(self._row)
def _client_with_row(row: object) -> TestClient:
"""Fresh app whose ``get_db`` dependency is a stub returning ``row``
(``None`` → no matching document row)."""
app = create_app()
app.dependency_overrides[get_db] = lambda: _FakeSession(row)
return TestClient(app)
def test_content_unknown_pair_maps_to_404() -> None:
with _client_with_row(None) as client:
r = client.get(
"/api/documents/content",
params={"source": "Homelab", "path": "nope.md"},
)
assert r.status_code == 404
assert r.json() == {"detail": "document not found"}
def test_content_traversal_style_path_maps_to_404() -> None:
"""``../``-style values are just non-existent rows → 404, never a
file read (DB-only endpoint, no filesystem access)."""
with _client_with_row(None) as client:
r = client.get(
"/api/documents/content",
params={"source": "Homelab", "path": "../../etc/passwd"},
)
assert r.status_code == 404
assert r.json() == {"detail": "document not found"}
assert "root:" not in r.text # nothing leaked
def test_content_known_pair_maps_to_doc_content() -> None:
doc = Document(
source="Homelab",
path="notes/deep mark.md",
full_path="/tmp/deep mark.md",
title="Deep Mark",
content="# Deep Mark\n\nbody",
content_hash="f" * 64,
)
doc.indexed_at = datetime(2026, 8, 22, 1, 2, 3, tzinfo=UTC)
with _client_with_row((doc, 3)) as client:
r = client.get(
"/api/documents/content",
params={"source": "Homelab", "path": "notes/deep mark.md"},
)
assert r.status_code == 200
body = r.json()
assert set(body) == {"source", "path", "title", "format", "content", "indexed_at", "chunks"}
assert body["source"] == "Homelab"
assert body["path"] == "notes/deep mark.md"
assert body["title"] == "Deep Mark"
assert body["format"] == "md"
assert body["content"] == "# Deep Mark\n\nbody"
assert body["indexed_at"] == "2026-08-22T01:02:03+00:00"
assert body["chunks"] == 3
def test_content_requires_both_params() -> None:
with _client_with_row(None) as client:
assert client.get("/api/documents/content").status_code == 422
assert client.get("/api/documents/content", params={"source": "Homelab"}).status_code == 422
# ---------------------------------------------------------------------------
# Viewer URL builder — encoded query (spaces/slashes in real paths)
# ---------------------------------------------------------------------------
def test_viewer_url_builder_present_in_chat_and_sources() -> None:
"""Both entry points (chat chips, Sources rows) build the same
encoded viewer URL and open it in a new tab with rel=noopener."""
for name, js in (("app.js", _read(APP_JS)), ("sources.js", _read(SOURCES_JS))):
assert "function documentUrl(source, path)" in js, name
assert '"/document.html?source=" + encodeURIComponent(' in js, name
assert '"&path=" + encodeURIComponent(' in js, name
app_js = _read(APP_JS)
assert "chip.href = documentUrl(s.source, s.path)" in app_js
assert 'chip.target = "_blank"' in app_js
assert 'chip.rel = "noopener"' in app_js
sources_js = _read(SOURCES_JS)
assert 'link.className = "doc-link"' in sources_js
assert "link.href = documentUrl(d.source, d.path)" in sources_js
assert 'link.target = "_blank"' in sources_js
assert 'link.rel = "noopener"' in sources_js
# The full path stays the hover name on the ellipsized cell AND the link.
assert "pathTd.title = d.path" in sources_js
assert "link.title = d.path" in sources_js
def _run_node(script: str) -> str:
proc = subprocess.run(["node", "-e", script], capture_output=True, text=True, timeout=60)
assert proc.returncode == 0, f"node failed: {proc.stderr}"
return proc.stdout
def _extract_function(js: str, name: str) -> str:
match = re.search(rf"(?:export )?function {name}\(source, path\) \{{.*?\n\}}", js, re.S)
assert match, f"{name}(source, path) not found"
return match.group(0).replace("export ", "", 1)
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_viewer_url_builder_encodes_spaces_and_slashes() -> None:
"""Behavioral check of the real builder (app.js) under node: slashes
and spaces in source/path values must come out percent-encoded."""
fn = _extract_function(_read(APP_JS), "documentUrl")
out = _run_node(
f"{fn}\n"
"console.log(documentUrl('Homelab', 'kubernetes.md'));\n"
"console.log(documentUrl('Homelab', 'notes/my file.yaml'));\n"
"console.log(documentUrl('H omelab', 'a/b.md'));"
)
assert out.splitlines() == [
"/document.html?source=Homelab&path=kubernetes.md",
"/document.html?source=Homelab&path=notes%2Fmy%20file.yaml",
"/document.html?source=H%20omelab&path=a%2Fb.md",
]
# ---------------------------------------------------------------------------
# Shared renderer extraction (phase 10 step 2)
# ---------------------------------------------------------------------------
def test_renderer_extracted_to_shared_markdown_js() -> None:
"""The renderer moved to assets/markdown.js (not duplicated in app.js)
and BOTH pages load it via a relative <script src> before their module
scripts — so the globals exist when app.js/document.js run."""
md = _read(MARKDOWN_JS)
assert "function renderMarkdown(md)" in md
assert "function escapeHtml(s)" in md
app_js = _read(APP_JS)
assert "function renderMarkdown" not in app_js, "renderer must live in markdown.js"
assert "function escapeHtml" not in app_js
for page in ("index.html", "document.html"):
html = _read(FRONTEND / page)
assert re.search(r'<script src="assets/markdown\.js"></script>', html), (
f"{page} must load markdown.js via a relative <script src>"
)
assert html.index('src="assets/markdown.js"') < html.index('type="module"'), (
f"{page}: markdown.js must load before the module script"
)
@pytest.mark.skipif(not HAVE_NODE, reason="node not available")
def test_markdown_renderer_stays_xss_safe_and_unchanged() -> None:
"""Behavioral check (node) that the extracted renderer still escapes
first: hostile content never becomes live HTML; basic transforms work."""
js = _read(MARKDOWN_JS)
out = _run_node(
js
+ "\nconsole.log(renderMarkdown('# Title\\n\\n<script>alert(1)</script>"
+ "\\n\\n**bold** and `code`'));"
)
html = out.strip()
assert "<script>" not in html # never live HTML
assert "&lt;script&gt;alert(1)&lt;/script&gt;" in html
assert "<strong>bold</strong>" in html
assert "<code>code</code>" in html
assert "<h3>Title</h3>" in html
def test_viewer_js_rendering_contracts() -> None:
"""document.js: raw formats go in via textContent (never parsed as
HTML), markdown via the shared renderer, 404 → designed not-found
state, back link prefers browser history when there is one."""
js = _read(DOCUMENT_JS)
assert "pre.textContent = doc.content" in js # raw formats: text node
assert "renderMarkdown(doc.content)" in js # md/markdown: shared renderer
assert "showNotFound" in js
assert "history.length > 1" in js
assert "encodeURIComponent" in js # content fetch uses the same encoding