// The hub's build-time numbers: the homepage's "Official instances" figures, // shipped to the hub as `public/hub-summary.json` so its cards say exactly what // the homepage's cards say. // // Why build time and not a live sum: a member's public `corpus.json` carries // channel and video counts only — no recordings-with-a-download-date, no hours, // no "gone" — so a live sum could never match the homepage. Both files are // projections of ONE `buildHomepageSummary` call over the same stats walk // (controller/poolSummary.ts), and the hub is rebuilt alongside the sites. // // The file is OPTIONAL. An older hub build, or a hub composed where there is no // index to walk, has none; the hub then renders its cards without figures — // never an error. It is read same-origin, so it is not in HUB_CORS_PATHS, and // the deploy guard (builtHubProblem) does not require it. // // Pure and client-safe: the hub page parses it in the browser, and lists and // colours its official instances by it (officialInstances, below). import type { HomepageOfficialTotals, HomepageSummary, } from "./homepageSummary"; import { parseAccent } from "./accent"; import { isAccentId, type AccentId } from "./brand"; import { siteChartColors, siteColor } from "./siteColor"; export const HUB_SUMMARY_FILE = "hub-summary.json"; export const HUB_SUMMARY_VERSION = 1; // One official instance's card figures. Every figure is optional: the homepage // summary's v5 per-site fields are, and a card omits what it does not have. export type HubSummarySite = { siteId: string; siteTitle: string; siteDescription?: string; siteUrl: string; channels?: number; recordings?: number; transcripts?: number; // the homepage summary's `transcribed.total` hoursArchived?: number; gone?: number; accent?: string; // The named accent behind `accent` (homepageSummary.ts `accentId`): the hub // paints it per base. Additive and optional, so still version 1 — a reader // that predates it ignores it, and a file without it paints the hex. accentId?: AccentId; }; export type HubSummary = { version: number; generatedAt: string; official: HomepageOfficialTotals | null; sites: HubSummarySite[]; }; // The projection: only what the hub's cards and H1 read, from the SAME summary // object compose-homepage writes. export function toHubSummary(summary: HomepageSummary): HubSummary { return { version: HUB_SUMMARY_VERSION, generatedAt: summary.generatedAt, official: summary.official ?? null, sites: summary.sites.map((s) => ({ siteId: s.siteId, siteTitle: s.siteTitle, ...(s.siteDescription ? { siteDescription: s.siteDescription } : {}), siteUrl: s.siteUrl, ...(s.channels !== undefined ? { channels: s.channels } : {}), ...(s.recordings !== undefined ? { recordings: s.recordings } : {}), transcripts: s.transcribed.total, ...(s.hoursArchived !== undefined ? { hoursArchived: s.hoursArchived } : {}), ...(s.gone !== undefined ? { gone: s.gone } : {}), ...(s.accent ? { accent: s.accent } : {}), ...(s.accentId ? { accentId: s.accentId } : {}), })), }; } function isObject(v: unknown): v is Record { return typeof v === "object" && v !== null && !Array.isArray(v); } function count(v: unknown): number | undefined { return typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : undefined; } function text(v: unknown): string | undefined { return typeof v === "string" && v.trim() ? v : undefined; } function parseOfficial(v: unknown): HomepageOfficialTotals | null { if (!isObject(v)) return null; const keys = [ "sites", "channels", "recordings", "transcripts", "hoursArchived", "gone", ] as const; const out = {} as HomepageOfficialTotals; for (const k of keys) { const n = count(v[k]); if (n === undefined) return null; out[k] = n; } return out; } function parseSite(v: unknown): HubSummarySite | null { if (!isObject(v)) return null; const siteId = text(v.siteId); const siteTitle = text(v.siteTitle); const siteUrl = text(v.siteUrl); if (!siteId || !siteTitle || !siteUrl) return null; const out: HubSummarySite = { siteId, siteTitle, siteUrl }; const desc = text(v.siteDescription); if (desc) out.siteDescription = desc; for (const k of [ "channels", "recordings", "transcripts", "hoursArchived", "gone", ] as const) { const n = count(v[k]); if (n !== undefined) out[k] = n; } const accent = text(v.accent); if (accent) out.accent = accent; if (isAccentId(v.accentId)) out.accentId = v.accentId; return out; } // Lenient reader for the fetched file: anything unreadable is null (the hub // then shows its cards without figures), a malformed site entry is dropped, a // malformed figure is omitted. A version this reader does not know is null — // it may mean something else. export function parseHubSummary(json: unknown): HubSummary | null { if (!isObject(json)) return null; if (json.version !== HUB_SUMMARY_VERSION) return null; const sites = Array.isArray(json.sites) ? json.sites.map(parseSite).filter((s): s is HubSummarySite => s !== null) : []; return { version: HUB_SUMMARY_VERSION, generatedAt: typeof json.generatedAt === "string" ? json.generatedAt : "", official: parseOfficial(json.official), sites, }; } // The card figures for one built-in member, matched by siteId first and by // origin as a fallback (a site renamed between builds keeps its URL). export function hubSummarySiteFor( summary: HubSummary | null, member: { siteId: string; origin?: string }, ): HubSummarySite | null { if (!summary) return null; const byId = summary.sites.find((s) => s.siteId === member.siteId); if (byId) return byId; if (!member.origin) return null; return ( summary.sites.find((s) => { try { return new URL(s.siteUrl).origin === member.origin; } catch { return false; } }) ?? null ); } // One official instance as every hub surface shows it: its figures, and the // colour it wears on its card's stripe, its scope chip's dot, its results' left // edge and its channel group. export type OfficialInstance = { site: T; figures: HubSummarySite | null; accent: string; }; // The official instances in the homepage's order, each with one colour. // // ORDER: the summary's `sites` order — the homepage's (transcripts, most first: // homepageSummary.ts), the order its ArchiveCards and growth chart draw in, so // the hub and the homepage list the instances on the same rows. A member the // summary does not name follows, in the order given. With no summary the order // given (hub-sites.json's) stands. // // COLOUR (lib/siteColor.ts): the colour its homepage card wears — the site's // own accent, else the summary's, else its chart colour (siteChartColors over // the summary's sites: its accent family's slot, or seriesColor() at its index // when that is free). A NAMED accent is painted per base (`var(--swatch-)`) // from the summary's `accentId`, and a custom hex is fitted to each base // (perBaseColor, release 11); hub-sites.json carries only the on-dark hex, // so the id is used only when the two files name the same colour (one compose // writes both, so they agree unless a site's own is newer). A member the // summary does not name takes the chart colours after the summary's, so it // never wears one a named instance's chart wears. With no summary that is its // place in the list. function colourOf( site: { accent?: string }, figures: HubSummarySite | null, ): { accent?: string; accentId?: AccentId } { const own = parseAccent(site.accent); const summary = parseAccent(figures?.accent); if (own && own !== summary) return { accent: own }; return { accent: own ?? summary, accentId: figures?.accentId }; } export function officialInstances< T extends { siteId: string; origin?: string; accent?: string }, >(members: readonly T[], summary: HubSummary | null): OfficialInstance[] { const placed = members.map((site, given) => { const figures = hubSummarySiteFor(summary, site); const at = figures && summary ? summary.sites.indexOf(figures) : -1; return { site, figures, at, given }; }); const named = placed .filter((p) => p.at >= 0) .sort((a, b) => a.at - b.at || a.given - b.given); const unnamed = placed.filter((p) => p.at < 0); const listed = summary?.sites.length ?? 0; const chart = siteChartColors([ ...(summary?.sites ?? []), ...unnamed.map(() => ({})), ]); return [ ...named.map((p) => ({ site: p.site, figures: p.figures, accent: siteColor(colourOf(p.site, p.figures), chart[p.at]), })), ...unnamed.map((p, k) => ({ site: p.site, figures: null, accent: siteColor(colourOf(p.site, null), chart[listed + k]), })), ]; }