/** * The FAQ and About copy, and the small renderer that turns it into DOM. * * The copy lives in about.json and faq.json so editing a sentence is not * editing TypeScript. This file is the schema for those two files and the only * thing that reads them. * * Nothing here goes through innerHTML: inline markup is a tagged object that * becomes a real element, so the content files cannot inject HTML even if * somebody pastes a tag into one. */ import type { AboutPageId } from "../destinations"; import { el } from "../dom"; import aboutJson from "./about.json"; import faqJson from "./faq.json"; /** * A run of text. A bare string is text; the objects are the only markup the * copy is allowed to use, tagged by which key they carry: * * "plain text" * { "b": "bold text" } * { "a": "link text", "href": "https://example.com" } */ type Inline = string | { b: string } | { a: string; href: string }; /** A run of text, or a plain string when there is no markup in it. */ type Text = string | Inline[]; /** * One thing inside a card, tagged the same way: * * { "p": "a paragraph" } * { "list": ["an item", "another"] } → ul.status-list * { "credits": ["a notice", "..."] } → div.credits of paragraphs * * `list` renders as the status list because that is the only list style these * pages have. `credits` is the quoted-notices block at the end of About. */ type Block = { p: Text } | { list: Text[] } | { credits: string[] }; /** * What the page is called. Both pages dropped their visible page head, so this * is the text of the hidden h1 the route owes a screen reader. */ type Head = { title: string }; /** One card: a heading, then its blocks. */ type Section = { heading: string; blocks: Block[] }; /** * One page under About: the line the hub describes it with, and its own cards. * * The FAQ has no sections here — its copy is faq.json, and the About screen * puts that page up with faqCard(). Everything else is sections. */ type AboutPage = { blurb: Text; sections?: Section[] }; /** * About: the hub's own line, then a page per entry in ABOUT_PAGES. * * Keyed by the same ids the navigation uses, and as a Record rather than a * list, so a page added to destinations.ts without copy — or copy left behind * for a page that was removed — is a build error rather than a blank screen. */ type AboutContent = { head: Head; lead: Text; pages: Record; }; /** FAQ: one card of questions. `open` folds the first answer open. */ type FaqContent = { head: Head; questions: { q: string; open?: boolean; blocks: Block[] }[]; }; // The annotation is the check: a content file that does not fit the schema // fails tsc rather than the page. const about: AboutContent = aboutJson; const faq: FaqContent = faqJson; export const aboutHead: Head = about.head; export const faqHead: Head = faq.head; function inline(run: Inline): Node | string { if (typeof run === "string") return run; if ("b" in run) return el("strong", { textContent: run.b }); return el("a", { href: run.href, textContent: run.a }); } /** Fill `node` with text: one textContent when there is no markup in it. */ function fill(node: T, text: Text): T { if (typeof text === "string") { node.textContent = text; } else { node.append(...text.map(inline)); } return node; } function block(item: Block): HTMLElement { if ("p" in item) return fill(el("p"), item.p); if ("list" in item) { return el( "ul", { className: "status-list" }, item.list.map((entry) => fill(el("li"), entry)), ); } return el( "div", { className: "credits" }, item.credits.map((notice) => el("p", { textContent: notice })), ); } /** The line under the heading on the About hub. */ export function aboutLead(): HTMLElement { return fill(el("p", { className: "about-lead" }), about.lead); } /** What the hub says one of its pages is, on the card that leads there. */ export function aboutBlurb(page: AboutPageId): HTMLElement { return fill(el("p", { className: "hint" }), about.pages[page].blurb); } /** The cards of one About page, in file order. Empty for the FAQ, which is * faqCard()'s. */ export function aboutCards(page: AboutPageId): HTMLElement[] { return (about.pages[page].sections ?? []).map((section) => el("section", { className: "card prose" }, [ el("h2", { textContent: section.heading }), ...section.blocks.map(block), ]), ); } /** * The FAQ card: the questions, folded shut. * * `
` rather than a script: the browser already knows how to open and * close one, keyboard and screen reader included, and a closed answer is still * in the page for anything that reads it. * * The first question is open, so the page reads as answers rather than as a row * of shut drawers. Which one that is comes from the content file. */ export function faqCard(): HTMLElement { return el( "section", { className: "card prose" }, faq.questions.map((question) => { const details = el("details", { className: "faq" }, [ el("summary", { textContent: question.q }), el("div", { className: "faq-body" }, question.blocks.map(block)), ]); details.open = question.open ?? false; return details; }), ); }