// The hub's copy lines for /ask and the scope chips — the operator's words, // ruled 2026-09-28. Change them here and nowhere else: the components render // these values and the hub e2e asserts them. // // To edit a line: // - change the words inside the backticks; // - the phrase wrapped in ${link("…")} is a link to the hub's front page — // move the wrapper to link other words, or drop it (keep the words) for no // link. One link per line at most: a second one throws when this module // loads; // - ASK_SCOPE_LINE may return null to show no line at all (e.g. // `inScope === total ? null : copy`…`` to show it only when some archives // are switched off). // A line with no link can be a plain string (LIVE_CHAT_MISSING). // A copy line: its words, with at most one phrase linked to the hub's front // page. `text` is the line as a reader reads it; `before` + `link` + `after` // is the same line split around the link (`link` absent, and `after` empty, // when there is none). export type Copy = { before: string; link?: string; after: string; text: string }; export class CopyLink { constructor(readonly words: string) {} } // The linked phrase of a copy line (see `copy`). export function link(words: string): CopyLink { return new CopyLink(words); } // The tag for a copy line. Plain interpolations (numbers, strings) are text; // only a link(…) value becomes the link. Nothing is parsed out of the words. export function copy(strings: TemplateStringsArray, ...values: unknown[]): Copy { let before = ""; let after = ""; let linked: string | undefined; const add = (s: string) => { if (linked === undefined) before += s; else after += s; }; strings.forEach((s, i) => { add(s); if (i >= values.length) return; const v = values[i]; if (v instanceof CopyLink) { if (linked !== undefined) { throw new Error( `A copy line links one phrase at most: "${linked}" and "${v.words}" (hubScopeCopy.ts)`, ); } linked = v.words; } else { add(String(v)); } }); return linked === undefined ? { before, after: "", text: before } : { before, link: linked, after, text: before + linked + after }; } // /ask, for a browser that has switched EVERY archive off with the front // page's scope chips: the chat has nothing to search, so its composer is // disabled and this one line says why. The link goes to the chips. export const NO_ARCHIVES_IN_SCOPE = copy`No archives selected. Choose some on the ${link("hub's front page")} to ask.`; // /ask, on a hub with NO archives at all: its /hub-sites.json lists none (a // fresh self-hosted hub) or could not be read, and this browser has added // none. The composer is disabled and this line says why, instead of "Loading // transcripts…" forever. The link goes to the form that adds an archive. export const NO_ARCHIVES_ON_HUB = copy`This hub has no archives yet. Add one on the ${link("hub's front page")} to ask.`; // The note on a hub scope chip (the front page) when that archive's live chat // could not be read: its videos are searched, its live chat is not. A Retry // for the live chat alone sits beside it. Chip notes are lower case, like the // chip's own "loading…" and "failed". export const LIVE_CHAT_MISSING = "live chat didn't load"; // The one line at the top of the hub's /ask that says what the chat searches: // `inScope` of the hub's `total` archives, the rest switched off with the // front page's scope chips. Not shown when none are in scope // (NO_ARCHIVES_IN_SCOPE says so instead). export const ASK_SCOPE_LINE = (inScope: number, total: number): Copy | null => copy`Searching ${inScope} of ${total} ${total === 1 ? "archive" : "archives"} — ${link("change on the front page")}.`; // Evaluated once here, so a second link in it throws when this module loads // rather than when /ask renders. ASK_SCOPE_LINE(1, 2);