Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 1cb25dfd56bf02f1f99c1f3d9805acc92041e6d0
parent ec414bf6b0750b4894eb8f0f656e10f6c2398ba4
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 28 Sep 2026 09:14:24 -0400

hub: the copy lines carry their link in the line — copy`…${link("…")}…` (a Copy {before, link?, after, text}) replaces the [bracket] + regex; copyWithLink / copyText deleted; one link per line, a second throws at module load; the four lines ship as the operator ruled (2026-09-28), DRAFT markers removed; LinkedCopy takes a Copy; the hub specs read .text / .link; +4 unit tests (export/app/ask/hubScopeCopy.test.ts)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mexport/app/ask/LinkedCopy.tsx | 17++++++++---------
Aexport/app/ask/hubScopeCopy.test.ts | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/app/ask/hubScopeCopy.ts | 119++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
Mexport/e2e-hub/ask.spec.ts | 12++++--------
Mexport/e2e-hub/federated-search.spec.ts | 18++++++++----------
5 files changed, 159 insertions(+), 68 deletions(-)

diff --git a/export/app/ask/LinkedCopy.tsx b/export/app/ask/LinkedCopy.tsx @@ -1,22 +1,21 @@ "use client"; -// A copy line from hubScopeCopy.ts as a reader sees it: its one [bracketed] -// phrase, when it has one, a link to the hub's front page. +// A copy line from hubScopeCopy.ts as a reader sees it: its words, and its +// ${link("…")} phrase, when it has one, a link to the hub's front page. import Link from "next/link"; -import { copyWithLink } from "./hubScopeCopy"; +import type { Copy } from "./hubScopeCopy"; -export function LinkedCopy({ copy }: { copy: string }) { - const { before, link, after } = copyWithLink(copy); +export function LinkedCopy({ copy }: { copy: Copy }) { return ( <> - {before} - {link && ( + {copy.before} + {copy.link && ( <Link href="/" className="text-brand underline-offset-2 hover:underline"> - {link} + {copy.link} </Link> )} - {after} + {copy.after} </> ); } diff --git a/export/app/ask/hubScopeCopy.test.ts b/export/app/ask/hubScopeCopy.test.ts @@ -0,0 +1,61 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + ASK_SCOPE_LINE, + LIVE_CHAT_MISSING, + NO_ARCHIVES_IN_SCOPE, + NO_ARCHIVES_ON_HUB, + copy, + link, +} from "./hubScopeCopy"; + +// The hub's copy lines carry their link in the line itself — copy`…${link("…")}…` +// — so nothing is parsed out of the words (it used to be a [bracketed] phrase +// found by a regex; the operator ruled that out, 2026-09-28). + +test("a line with a link: its text, and the words either side of the link", () => { + assert.deepEqual(copy`Choose some on the ${link("hub's front page")} to ask.`, { + before: "Choose some on the ", + link: "hub's front page", + after: " to ask.", + text: "Choose some on the hub's front page to ask.", + }); +}); + +test("a line with no link is all text; plain interpolations are text, brackets too", () => { + const n = 3; + assert.deepEqual(copy`Searching ${n} of ${"4"} [archives].`, { + before: "Searching 3 of 4 [archives].", + after: "", + text: "Searching 3 of 4 [archives].", + }); + // A link may sit anywhere, and text after it may interpolate too. + assert.deepEqual(copy`${link("Here")} — ${2} left`, { + before: "", + link: "Here", + after: " — 2 left", + text: "Here — 2 left", + }); +}); + +test("two links in one line throw (a programmer error, caught when the line is built)", () => { + assert.throws( + () => copy`${link("one")} and ${link("two")}`, + /links one phrase at most: "one" and "two"/, + ); +}); + +test("the four shipped lines read as the operator ruled them (2026-09-28)", () => { + assert.equal(NO_ARCHIVES_IN_SCOPE.text, "No archives selected. Choose some on the hub's front page to ask."); + assert.equal(NO_ARCHIVES_IN_SCOPE.link, "hub's front page"); + assert.equal(NO_ARCHIVES_ON_HUB.text, "This hub has no archives yet. Add one on the hub's front page to ask."); + assert.equal(NO_ARCHIVES_ON_HUB.link, "hub's front page"); + assert.equal(LIVE_CHAT_MISSING, "live chat didn't load"); + assert.deepEqual(ASK_SCOPE_LINE(1, 2), { + before: "Searching 1 of 2 archives — ", + link: "change on the front page", + after: ".", + text: "Searching 1 of 2 archives — change on the front page.", + }); + assert.equal(ASK_SCOPE_LINE(1, 1)?.text, "Searching 1 of 1 archive — change on the front page."); +}); diff --git a/export/app/ask/hubScopeCopy.ts b/export/app/ask/hubScopeCopy.ts @@ -1,50 +1,87 @@ -// The hub's /ask line 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 operator's copy — change it here -// and nowhere else (AskChat renders it, the hub e2e asserts it). The words in -// [brackets] become a link to the hub's front page, where the chips are; drop -// the brackets for no link. -export const NO_ARCHIVES_IN_SCOPE = - "No archives selected. Choose some on the [hub's front page] to ask."; - -// DRAFT (release 11, 2026-09-28) — awaiting the operator's ruling -// The hub's /ask line for 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 [bracketed] words link to the hub's front -// page, where the form to add an archive is; drop the brackets for no link. -export const NO_ARCHIVES_ON_HUB = - "This hub has no archives yet. Add one on the [hub's front page] to ask."; - -// DRAFT (release 11, 2026-09-28) — awaiting the operator's ruling +// 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"; -// DRAFT (release 11, 2026-09-28) — awaiting the operator's ruling // 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. The [bracketed] words link to the front page; -// drop the brackets for no link. Return null to show no line — e.g. -// `inScope === total ? null : …` to show it only when some archives are off. -// Not shown when none are in scope (NO_ARCHIVES_IN_SCOPE says so instead). -export const ASK_SCOPE_LINE = (inScope: number, total: number): string | null => - `Searching ${inScope} of ${total} ${total === 1 ? "archive" : "archives"} — [change on the front page].`; - -// A copy line split around its one [bracketed] link. -export function copyWithLink(copy: string): { - before: string; - link?: string; - after: string; -} { - const m = /^([^[]*)\[([^\]]+)\]([\s\S]*)$/.exec(copy); - return m ? { before: m[1], link: m[2], after: m[3] } : { before: copy, after: "" }; -} +// 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")}.`; -// The line as a reader sees it (the brackets gone). -export function copyText(copy: string): string { - const { before, link = "", after } = copyWithLink(copy); - return before + link + after; -} +// Evaluated once here, so a second link in it throws when this module loads +// rather than when /ask renders. +ASK_SCOPE_LINE(1, 2); diff --git a/export/e2e-hub/ask.spec.ts b/export/e2e-hub/ask.spec.ts @@ -1,9 +1,5 @@ import { expect, test, type Page, type Route } from "@playwright/test"; -import { - NO_ARCHIVES_ON_HUB, - copyText, - copyWithLink, -} from "../app/ask/hubScopeCopy"; +import { NO_ARCHIVES_ON_HUB } from "../app/ask/hubScopeCopy"; // The hub's /ask route. // @@ -78,11 +74,11 @@ test.describe("hub /ask", () => { .locator("form") .filter({ has: page.getByRole("button", { name: "Ask", exact: true }) }); const blocked = composer.getByTestId("ask-blocked"); - await expect(blocked).toHaveText(copyText(NO_ARCHIVES_ON_HUB)); + await expect(blocked).toHaveText(NO_ARCHIVES_ON_HUB.text); await expect(blocked).toHaveAttribute("role", "status"); - // Its [bracketed] words (when the copy has any) link to the front page, + // Its ${link()} phrase (when the copy has one) links to the front page, // where the form to add an archive is. - const { link } = copyWithLink(NO_ARCHIVES_ON_HUB); + const { link } = NO_ARCHIVES_ON_HUB; if (link) { await expect( blocked.getByRole("link", { name: link, exact: true }), diff --git a/export/e2e-hub/federated-search.spec.ts b/export/e2e-hub/federated-search.spec.ts @@ -3,8 +3,6 @@ import { ASK_SCOPE_LINE, LIVE_CHAT_MISSING, NO_ARCHIVES_IN_SCOPE, - copyText, - copyWithLink, } from "../app/ask/hubScopeCopy"; // The hub's federated search, per archive: two official members (hub-sites.json) @@ -420,14 +418,14 @@ test.describe("hub federated search — scope, per-archive state, attribution", expect(mocks.a.requests).toContain("/summaries/page-0000.json"); expect(mocks.b.requests).toEqual([]); - // And it says so: one line, its [bracketed] words a link to the chips. + // And it says so: one line, its ${link()} phrase a link to the chips. const scope = page.getByTestId("ask-scope"); const oneOfTwo = ASK_SCOPE_LINE(1, 2); if (oneOfTwo === null) { await expect(scope).toHaveCount(0); } else { - await expect(scope).toHaveText(copyText(oneOfTwo)); - const { link } = copyWithLink(oneOfTwo); + await expect(scope).toHaveText(oneOfTwo.text); + const { link } = oneOfTwo; if (link) { await expect( scope.getByRole("link", { name: link, exact: true }), @@ -448,7 +446,7 @@ test.describe("hub federated search — scope, per-archive state, attribution", expect(mocks.b.requests).toContain("/summaries/page-0000.json"); const twoOfTwo = ASK_SCOPE_LINE(2, 2); if (twoOfTwo === null) await expect(scope).toHaveCount(0); - else await expect(scope).toHaveText(copyText(twoOfTwo)); + else await expect(scope).toHaveText(twoOfTwo.text); }); test("/ask is not ready until the hub's own list is in, even with an added archive ready", async ({ @@ -513,7 +511,7 @@ test.describe("hub federated search — scope, per-archive state, attribution", ).toBeVisible(); const both = ASK_SCOPE_LINE(2, 2); if (both !== null) { - await expect(page.getByTestId("ask-scope")).toHaveText(copyText(both)); + await expect(page.getByTestId("ask-scope")).toHaveText(both.text); } }); @@ -659,11 +657,11 @@ test.describe("hub federated search — scope, per-archive state, attribution", .locator("form") .filter({ has: page.getByRole("button", { name: "Ask", exact: true }) }); const blocked = composer.getByTestId("ask-blocked"); - await expect(blocked).toHaveText(copyText(NO_ARCHIVES_IN_SCOPE)); + await expect(blocked).toHaveText(NO_ARCHIVES_IN_SCOPE.text); await expect(page.getByTestId("ask-blocked")).toHaveCount(1); - // Its [bracketed] words (when the copy has any) link to the front page, + // Its ${link()} phrase (when the copy has one) links to the front page, // where the chips are. - const { link } = copyWithLink(NO_ARCHIVES_IN_SCOPE); + const { link } = NO_ARCHIVES_IN_SCOPE; if (link) { await expect( blocked.getByRole("link", { name: link, exact: true }),