Something went wrong. Try again.
Web frontend and supporting services for lance.blue
Something went wrong. Try again.
5.3 kB · 163 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164/** * 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<AboutPageId, AboutPage>;};
/** 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<T extends HTMLElement>(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. * * `<details>` 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; }), );}