// The documentation view (US09-01): the manuals in `docs/`, rendered in the app. // // The same markdown files are the repository's documentation and the deployment's // documentation. An operator who was handed a URL and an access secret has no // checkout in front of them, and the network the application runs on is not assumed // to reach a CDN — so the renderer is vendored and everything here is same-origin. // // Nothing on this page is authenticated. It is served from the static mount beside // the application shell, exactly like `index.html`: the troubleshooting page is // needed most by whoever cannot get past the access secret, and no documentation // file contains anything a session would protect. // // `marked` and `mermaid` are loaded lazily, on the first documentation page and the // first diagram. They are large, and every other view does without them. import { el, errorBanner, setActiveNav } from "./dom.js"; const DOCS_BASE = "/docs"; const VENDOR = "/app/js/vendor"; const INDEX_PAGE = "index"; // A page name comes from the hash, so it is caller input: no scheme, no traversal, // no absolute path. The server would refuse those too; this refuses them earlier and // without a request that looks like an attempt. const PAGE_PATTERN = /^[\w-]+(\/[\w-]+)*$/; let markedPromise; let mermaidPromise; function loadMarked() { markedPromise ??= import(`${VENDOR}/marked.esm.js`).then((module) => module.marked); return markedPromise; } // mermaid ships one large UMD bundle rather than a self-contained ES module, so it // arrives through a script element. `script-src 'self'` allows it because it is ours // and same-origin; nothing here relaxes that. function loadMermaid() { mermaidPromise ??= new Promise((resolve, reject) => { const script = document.createElement("script"); script.src = `${VENDOR}/mermaid.min.js`; script.onload = () => resolve(window.mermaid); script.onerror = () => reject(new Error("the diagram renderer could not be loaded")); document.head.appendChild(script); }); return mermaidPromise; } // ── the pure parts, unit-tested in js/tests/unit.js ────────────────────────── /** A stable, readable heading anchor. Letters and digits of any script survive. */ export function slug(text) { const cleaned = String(text) .toLowerCase() .trim() .replace(/[^\p{L}\p{N}\s-]/gu, "") .replace(/[\s-]+/g, "-") .replace(/^-|-$/g, ""); return cleaned || "section"; } /** Assign every heading an id, disambiguating repeats the way a reader would * expect: the first `Notes` keeps `#notes`, the second becomes `#notes-2`. */ export function assignHeadingIds(headings) { const used = new Map(); for (const heading of headings) { const base = slug(heading.textContent); const seen = (used.get(base) || 0) + 1; used.set(base, seen); heading.id = seen === 1 ? base : `${base}-${seen}`; } return headings; } /** * Where a link inside a documentation page should go. * * Markdown links between documents are relative file paths, which a browser would * treat as downloads that leave the application. Returns the in-app target for a * link to another document or to a heading, and `null` for anything else — external * links stay exactly as the author wrote them. */ export function resolveDocLink(currentPage, href) { if (!href) return null; if (/^[a-z][a-z\d+.-]*:/i.test(href) || href.startsWith("//")) return null; if (href.startsWith("#")) return { page: currentPage, anchor: href.slice(1) }; const [target, anchor = ""] = href.split("#"); if (!/\.md$/i.test(target)) return null; // Resolved against the current document, so `../guides/x.md` means what it means // in the repository. The origin is a placeholder; only the path is used. const resolved = new URL(target, `https://docs.invalid/${currentPage}.md`); const page = decodeURIComponent(resolved.pathname).replace(/^\//, "").replace(/\.md$/i, ""); return PAGE_PATTERN.test(page) ? { page, anchor } : null; } /** * Point every in-app link at its route, and leave every other link alone. * * The resolved page is kept on the element, so the reading order can be read back * from the rendered index rather than parsed out of the markdown a second time. */ export function rewriteLinks(article, page) { for (const link of article.querySelectorAll("a[href]")) { const href = link.getAttribute("href"); const target = resolveDocLink(page, href); if (target) { link.setAttribute("href", docHash(target.page, target.anchor)); link.dataset.docPage = target.page; } else if (/^https?:/i.test(href)) { link.setAttribute("rel", "noreferrer noopener"); link.setAttribute("target", "_blank"); } } return article; } /** The reading order, taken from the index document itself — one list, in one * place, that is equally the index on Gitea and the navigation here. */ export function documentIndex(indexElement) { const pages = []; for (const link of indexElement.querySelectorAll("a[data-doc-page]")) { const page = link.dataset.docPage; if (page !== INDEX_PAGE && !pages.some((entry) => entry.page === page)) { pages.push({ page, title: link.textContent.trim() }); } } return pages; } // ── the view ───────────────────────────────────────────────────────────────── async function fetchPage(page) { const response = await fetch(`${DOCS_BASE}/${page}.md`, { headers: { accept: "text/markdown" } }); if (!response.ok) throw new Error(`documentation page unavailable: ${response.status}`); return response.text(); } async function toArticle(markdown, page) { const marked = await loadMarked(); const article = el("article", { class: "doc", "data-testid": "doc", "data-page": page }); // The only innerHTML in the application, and deliberate: rendering markdown *is* // producing HTML. The input is a file from this repository, and the page's CSP // (`script-src 'self'`, no `'unsafe-inline'`) means injected script and inline // handlers do not run even if one ever were not. article.innerHTML = marked.parse(markdown, { async: false }); assignHeadingIds(article.querySelectorAll("h1, h2, h3, h4, h5, h6")); rewriteLinks(article, page); return article; } function docHash(page, anchor = "") { const query = new URLSearchParams(anchor ? { page, anchor } : { page }); return `#/docs?${query}`; } /** Diagrams are authored as ```mermaid blocks so the source is diffable and Gitea * renders them natively. A failure here replaces the diagram, never the page. */ async function renderDiagrams(article) { const blocks = [...article.querySelectorAll("pre > code.language-mermaid")]; if (!blocks.length) return; let mermaid; try { mermaid = await loadMermaid(); mermaid.initialize({ startOnLoad: false, securityLevel: "strict", theme: "dark" }); } catch (error) { blocks.forEach((block) => block.closest("pre").replaceWith(errorBanner(error.message))); return; } for (const [index, block] of blocks.entries()) { const figure = el("figure", { class: "diagram", "data-testid": "diagram" }); block.closest("pre").replaceWith(figure); try { const { svg } = await mermaid.render(`diagram-${index}-${Date.now()}`, block.textContent); figure.innerHTML = svg; } catch (error) { figure.replaceWith(errorBanner(`Diagram could not be drawn: ${error.message}`)); } } } function notFound() { return el( "div", { class: "alert", role: "alert", "data-testid": "doc-not-found" }, "That documentation page does not exist. ", el("a", { class: "link", href: docHash(INDEX_PAGE) }, "Back to the documentation index") ); } function sidebar(pages, current) { return el( "nav", { class: "card", "aria-label": "Documentation" }, el("h2", {}, "Documentation"), el( "ul", { class: "album-list", "data-testid": "doc-pages" }, el( "li", {}, el( "a", { href: docHash(INDEX_PAGE), "aria-current": current === INDEX_PAGE ? "true" : false, }, "Index" ) ), ...pages.map(({ page, title }) => el( "li", {}, el( "a", { href: docHash(page), "data-page": page, "aria-current": page === current ? "true" : false, }, title ) ) ) ) ); } export async function renderDocs(root, params = {}) { setActiveNav("docs"); const requested = params.page || INDEX_PAGE; const page = PAGE_PATTERN.test(requested) ? requested : ""; let indexArticle; try { indexArticle = await toArticle(await fetchPage(INDEX_PAGE), INDEX_PAGE); } catch (error) { root.replaceChildren(errorBanner(`Documentation is unavailable: ${error.message}`)); return; } const pages = documentIndex(indexArticle); let article; if (!page) article = notFound(); else if (page === INDEX_PAGE) article = indexArticle; else { try { article = await toArticle(await fetchPage(page), page); } catch { article = notFound(); } } root.replaceChildren(el("div", { class: "two-pane" }, sidebar(pages, page), article)); await renderDiagrams(article); scrollToAnchor(params.anchor); } // A documentation anchor cannot live in the hash — the hash is the route — so it // travels as a parameter and is applied after the page renders. function scrollToAnchor(anchor) { if (!anchor) return; const target = document.getElementById(anchor); if (target) target.scrollIntoView({ block: "start" }); }