264 lines
9.6 KiB
JavaScript
264 lines
9.6 KiB
JavaScript
// 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" });
|
|
}
|