Archilyzer · Source

archilyzer

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

commit 7e3c150d847135d55e7c16172b3aa8823f46c6e3
parent be6430022594f56975b421910cfd448da580931d
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu, 24 Sep 2026 13:18:20 -0400

lib: siteSchema — one definition of site.json

common/lib/siteSchema.ts (server-only): siteFieldsSchema(siteId) composes
settingsField(coerce) per key over the existing parsers (parseChannelGroups,
parseSiteChannels, parseSocialLinks, parseAccent, parseSiteUrl,
parseRelatedSites), each .describe()d from its SITE_FIELD_DOCS entry;
siteSchema(siteId) adds the one object step for the two sibling-dependent
fields (defaultGroupId, membership groupIds) and re-emits every key in
order, since zod 4 omits an absent key whose transform returns undefined
and parseSite has always emitted them. parseSite = siteSchema(id).parse;
siteToDisk is the old persist-only-non-defaults literal, pure.

The Site / SiteChannelMembership / RelatedSiteGroup types, SITE_ID_RE and
the three parsers moved with it; lib/site.ts `export *`s the module (no
importer moved) and keeps I/O: getSite reads through readJsonFileSync,
writeSite runs the throwing validations then writes siteToDisk through
writeJsonAtomic. Docs records: SITE_FIELD_DOCS,
SITE_CHANNEL_MEMBERSHIP_FIELD_DOCS, RELATED_SITE_GROUP_FIELD_DOCS, and
CHANNEL_GROUP_FIELD_DOCS in channelGroups.ts.

Parity: 20,000 random inputs parse deepStrictEqual (and key-order equal)
to main's parseSite; 3,000 random writeSite calls give byte-identical files
or the identical throw.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Mcommon/lib/channelGroups.ts | 22++++++++++++++++------
Mcommon/lib/site.ts | 294++++++-------------------------------------------------------------------------
Acommon/lib/siteSchema.test.ts | 229+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/siteSchema.ts | 327+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 594 insertions(+), 278 deletions(-)

diff --git a/common/lib/channelGroups.ts b/common/lib/channelGroups.ts @@ -3,23 +3,33 @@ // on each ChannelConfig (group?: string) — group definitions themselves live // in SiteSettings.groups so they can be edited centrally without touching // every channel config when a description or default-selected value changes. +// +// Pure, no runtime imports: `"use client"` code imports these. +import type { FieldDocs } from "./fieldDocs"; + +// Each field is documented in CHANNEL_GROUP_FIELD_DOCS below. export type ChannelGroup = { id: string; name: string; description?: string; selectedByDefault: boolean; order?: number; - // Provenance accent, set only in hub mode where each group is a federated - // site (id = origin): the site's own accent, rendered as a swatch on the - // group header so origin is legible in the channel filter. Absent in - // single-site mode. accent?: string; - // Render this group's channels as loose individual chips instead of a - // collapsible group chip; absent = false. inline?: boolean; }; +export const CHANNEL_GROUP_FIELD_DOCS: FieldDocs<ChannelGroup> = { + id: "Group id: a lowercase slug (`[a-z0-9][a-z0-9-]*`), unique within the list. An entry with an invalid or repeated id is dropped.", + name: 'Header text. May be blank — the UI then shows no header (and falls back to the id in admin contexts) — but must be a string. Trimmed.', + description: "Optional description under the header. Trimmed; blank = none.", + selectedByDefault: "Whether this group's channels start checked in the export UI's channel filter. Only `true` counts.", + order: "Optional explicit ordering hint (lower first); floored. Unordered groups sort after ordered ones, then by name.", + accent: + "Provenance accent, set only in hub mode where each group is a federated site (id = origin): that site's own accent, drawn as a swatch on the group header. Never read from a site.json — absent in single-site mode.", + inline: "Render this group's channels as loose individual chips instead of one collapsible group chip. Stored only when `true`.", +}; + export const DEFAULT_GROUP_FALLBACK_ID = "default"; // Synthesized fallback used when settings has no groups configured at all, diff --git a/common/lib/site.ts b/common/lib/site.ts @@ -1,17 +1,9 @@ -import { writeJsonAtomic } from "./jsonFile-server"; import fs from "node:fs"; import path from "node:path"; -import { - DEFAULT_GROUP_FALLBACK_ID, - FALLBACK_GROUP, - parseChannelGroups, - resolveDefaultGroupId, - type ChannelGroup, -} from "./channelGroups"; +import { parseChannelGroups, resolveDefaultGroupId } from "./channelGroups"; import { getPaths, type Paths } from "./paths"; import { TAGS_FILENAME } from "./curatedTags"; import type { SiteChannelIndex } from "./channelPriority"; -import { parseAccent } from "./accent"; import { getSettings, normalizeSocialSvg, @@ -19,6 +11,14 @@ import { type SiteSettings, type SocialLink, } from "./settings"; +import { readJsonFileSync, writeJsonAtomic } from "./jsonFile-server"; +import { + isValidSiteId, + parseSite, + parseSiteUrl, + siteToDisk, + type Site, +} from "./siteSchema"; // A Site is a selection + presentation layer over the single global channel // pool. Branding, social links, the channel grouping layout AND which channels @@ -26,95 +26,11 @@ import { // can power several public sites that overlap on channels without duplicating // any downloads. Operational config (transcribe bin, cookies, rate limits) // stays global in settings.json — see common/lib/settings.ts. - -export type SiteChannelMembership = { - // Channel slug (directory name under transcripts/channels/). - slug: string; - // Group this channel belongs to WITHIN this site. Resolved against - // site.groups at index-build time; the same channel can sit in different - // groups on different sites. Falls back to site.defaultGroupId when unknown. - groupId?: string; - // Optional explicit ordering hint within the site (lower first). - order?: number; -}; - -export type Site = { - siteId: string; - siteTitle: string; - siteDescription: string; - headerTitle: string; - homeTagline: string; - // Optional per-site brand accent ("#rrggbb"). Overrides the family brass on - // this site's public build (see siteAccentVars / export layout). Undefined = - // inherit the family brass. - accent?: string; - // Per-site social links. `undefined` (no `socialLinks` key in site.json) - // means inherit the global default from SiteSettings.socialLinks; an array - // (even empty) overrides it. Resolve with resolveSocialLinks() at render. - socialLinks?: SocialLink[]; - // Channel grouping layout for THIS site (cosmetic + default-selection - // buckets the export UI renders). Mirrors what used to live in - // SiteSettings.groups but is now per-site. - groups: ChannelGroup[]; - defaultGroupId: string; - // The channels this site exposes. A channel absent from this list is not - // built or deployed for this site even though its data exists in the pool. - channels: SiteChannelMembership[]; - // Cloudflare Pages project name this site deploys to - // (`wrangler pages deploy out --project-name <cloudflareProject>`). - cloudflareProject?: string; - // Absolute public URL of this site's deployment, e.g. "https://jeralyzer.com". - // Drives the cross-site footer list (see resolveRelatedSites). A site with no - // siteUrl is omitted from every other site's list — there's no link target. - siteUrl?: string; - // Per-site override that pulls specific siblings to the front of the footer's - // cross-site list, in named groups. Siblings not named here fall into a - // trailing auto "Other sites" group. Absent/empty = one flat list of every - // sibling. siteIds are resolved against the live pool at render time, so an - // id for a site that doesn't exist (yet) is harmless — it's just skipped. - relatedSites?: RelatedSiteGroup[]; - // Whether this site ships an installable PWA (service worker + web manifest). - // Default (undefined/false) = a "dumb instance": it serves the CORS-enabled - // JSON federation contract but is not independently installable, so a visitor - // trusts only the hub PWA. Set true to make this site its own installable PWA. - // See export/app/lib/mode.ts (shipsPwa) and common/lib/siteDescriptor.ts. - pwa?: boolean; - // Whether the site build generates downloadable transcript/live-chat archive - // zips into public/archives (and links them on the Downloads page). This is - // an opt-OUT: undefined/true = on, only explicit `false` disables. Also gated - // by the global setting and a per-build flag (see compose-site.ts). Default-on - // because bulk download is the point of publishing a corpus. - archives?: boolean; - // Per-site override for the served-file size cap (bytes). Any archive larger - // than this is dropped from what's served and flagged in the manifest so a - // capped host (Cloudflare Pages: 25 MB) won't reject the deploy. 0 = no cap. - // Absent = the global DEFAULT_ARCHIVE_MAX_BYTES / MAX_ARCHIVE_BYTES env. - archiveMaxBytes?: number; - // Whether this site publishes the Duplicates page (and its Header nav link). - // Opt-OUT: undefined/true = on, only explicit `false` hides it. Even when on, - // the link/page auto-hide when the site has no in-scope duplicate clusters (the - // build simply writes no duplicates.json — see compose-site.ts / hasDuplicates). - duplicates?: boolean; - // Per-site override for the hub this site belongs under (the PWA it points - // visitors toward). Absent = inherit the family default SiteSettings.homepageUrl. - // Resolve with resolveHubUrl(). Surfaced on /site.json so a hub can tell member - // sites (that name it) from arbitrary added origins. - hubUrl?: string; -}; - -export type RelatedSiteGroup = { - // Optional muted heading shown above the group; omit for an unlabeled group. - label?: string; - // Sibling site ids, in display order. - siteIds: string[]; -}; - -// siteId shares the group-id grammar: lowercase slug, used as a directory name. -export const SITE_ID_RE = /^[a-z0-9][a-z0-9-]*$/; - -export function isValidSiteId(id: unknown): id is string { - return typeof id === "string" && SITE_ID_RE.test(id); -} +// +// The file's SHAPE — every key, its default, its coercion, its documentation — +// is lib/siteSchema.ts (one-core phase 3 slice 4b), re-exported here in full so +// no importer moved. What is left in this file is I/O and the resolvers. +export * from "./siteSchema"; export function siteDir(paths: Paths, siteId: string): string { return path.join(paths.sitesDir, siteId); @@ -169,138 +85,6 @@ export function siteStatsDir(paths: Paths, siteId: string): string { return path.join(siteIndexDir(paths, siteId), "stats"); } -function defaults(siteId: string): Site { - return { - siteId, - siteTitle: "Transcript Browser", - siteDescription: "Browse and search video transcripts", - headerTitle: "Transcript Browser", - homeTagline: "", - // socialLinks left undefined = inherit the global default. - // A single default group selected by default keeps a site with no explicit - // group config behaving like the pre-grouping UI (one bucket, all checked). - groups: [{ ...FALLBACK_GROUP }], - defaultGroupId: DEFAULT_GROUP_FALLBACK_ID, - channels: [], - }; -} - -export function parseSiteChannels(input: unknown): SiteChannelMembership[] { - if (!Array.isArray(input)) return []; - const out: SiteChannelMembership[] = []; - const seen = new Set<string>(); - for (const raw of input) { - if (!raw || typeof raw !== "object") continue; - const r = raw as Record<string, unknown>; - const slug = typeof r.slug === "string" ? r.slug.trim() : ""; - if (!slug || seen.has(slug)) continue; - seen.add(slug); - const entry: SiteChannelMembership = { slug }; - if (typeof r.groupId === "string" && r.groupId.trim()) { - entry.groupId = r.groupId.trim(); - } - if (typeof r.order === "number" && Number.isFinite(r.order)) { - entry.order = Math.floor(r.order); - } - out.push(entry); - } - return out; -} - -// Normalize a raw siteUrl into a trimmed absolute http(s) URL with no trailing -// slash, or undefined when missing/not a usable absolute URL. Relative or -// scheme-less values are rejected — a cross-site link must be absolute. -export function parseSiteUrl(input: unknown): string | undefined { - if (typeof input !== "string") return undefined; - const trimmed = input.trim().replace(/\/+$/, ""); - if (!/^https?:\/\/\S+/i.test(trimmed)) return undefined; - return trimmed; -} - -// Parse the relatedSites override: an ordered list of { label?, siteIds[] } -// groups. Keeps only valid site ids, dedupes within a group, drops groups with -// no valid ids, and preserves order. Existence against the live pool is NOT -// checked here — resolveRelatedSites does that at render time. -export function parseRelatedSites(input: unknown): RelatedSiteGroup[] { - if (!Array.isArray(input)) return []; - const out: RelatedSiteGroup[] = []; - for (const raw of input) { - if (!raw || typeof raw !== "object") continue; - const r = raw as Record<string, unknown>; - const ids = Array.isArray(r.siteIds) ? r.siteIds : []; - const siteIds: string[] = []; - const seen = new Set<string>(); - for (const id of ids) { - if (!isValidSiteId(id) || seen.has(id)) continue; - seen.add(id); - siteIds.push(id); - } - if (siteIds.length === 0) continue; - const label = - typeof r.label === "string" && r.label.trim() ? r.label.trim() : undefined; - out.push(label ? { label, siteIds } : { siteIds }); - } - return out; -} - -// Parse a raw site.json object into a fully-resolved Site, applying defaults -// and validating groups/defaultGroupId/channel membership. Mirrors getSettings. -export function parseSite(siteId: string, raw: unknown): Site { - const base = defaults(siteId); - const r = - raw && typeof raw === "object" ? (raw as Record<string, unknown>) : {}; - const str = (key: keyof Site, fallback: string): string => - typeof r[key] === "string" ? (r[key] as string) : fallback; - - const groups = parseChannelGroups(r.groups); - const resolvedGroups = groups.length > 0 ? groups : [{ ...FALLBACK_GROUP }]; - const defaultGroupId = resolveDefaultGroupId(r.defaultGroupId, resolvedGroups); - - // Drop memberships whose groupId is unknown to a concrete group (fold to - // default at render time via resolveChannelGroupId, but keep the explicit - // value when valid so the editor round-trips it). - const channels = parseSiteChannels(r.channels).map((c) => { - if (c.groupId && !resolvedGroups.some((g) => g.id === c.groupId)) { - const { groupId: _drop, ...rest } = c; - return rest; - } - return c; - }); - - return { - siteId, - siteTitle: str("siteTitle", base.siteTitle), - siteDescription: str("siteDescription", base.siteDescription), - headerTitle: str("headerTitle", base.headerTitle), - homeTagline: str("homeTagline", base.homeTagline), - // Key present (array) = override; absent = inherit the global default. - socialLinks: Array.isArray(r.socialLinks) - ? parseSocialLinks(r.socialLinks) - : undefined, - groups: resolvedGroups, - defaultGroupId, - channels, - cloudflareProject: - typeof r.cloudflareProject === "string" && r.cloudflareProject.trim() - ? r.cloudflareProject.trim() - : undefined, - accent: parseAccent(r.accent), - siteUrl: parseSiteUrl(r.siteUrl), - relatedSites: parseRelatedSites(r.relatedSites), - pwa: r.pwa === true, - // Opt-out: only an explicit false disables. Absent/true stays on. - archives: r.archives !== false, - duplicates: r.duplicates !== false, - archiveMaxBytes: - typeof r.archiveMaxBytes === "number" && - Number.isFinite(r.archiveMaxBytes) && - r.archiveMaxBytes >= 0 - ? Math.floor(r.archiveMaxBytes) - : undefined, - hubUrl: parseSiteUrl(r.hubUrl), - }; -} - // The hub URL this site points visitors toward: its own override, else the // family default (SiteSettings.homepageUrl). Undefined when neither is set. export function resolveHubUrl( @@ -391,13 +175,10 @@ export function getSite(siteId: string, paths: Paths = getPaths()): Site { if (!isValidSiteId(siteId)) { throw new Error(`Invalid site id: ${String(siteId)}`); } - let parsed: unknown = {}; - try { - parsed = JSON.parse(fs.readFileSync(siteConfigFile(paths, siteId), "utf8")); - } catch { - parsed = {}; - } - return parseSite(siteId, parsed); + // Missing, unreadable or not JSON reads as the empty file: every field its + // default. A read never throws past the id check. + const read = readJsonFileSync(siteConfigFile(paths, siteId)); + return parseSite(siteId, read.ok ? read.value : {}); } // List the ids of all configured sites (directories under sitesDir that hold a @@ -460,42 +241,11 @@ export async function writeSite( socialLinks.push({ ...link, svg }); } } - const channels = parseSiteChannels(site.channels).filter( - (c) => !c.groupId || groups.some((g) => g.id === c.groupId), + await writeJsonAtomic( + siteConfigFile(paths, site.siteId), + siteToDisk({ ...site, groups, defaultGroupId, socialLinks }), + { mkdir: true }, ); - const merged: Site = { - siteId: site.siteId, - siteTitle: site.siteTitle, - siteDescription: site.siteDescription, - headerTitle: site.headerTitle, - homeTagline: site.homeTagline, - ...(socialLinks !== undefined ? { socialLinks } : {}), - groups, - defaultGroupId, - channels, - ...(site.cloudflareProject && site.cloudflareProject.trim() - ? { cloudflareProject: site.cloudflareProject.trim() } - : {}), - ...(parseAccent(site.accent) ? { accent: parseAccent(site.accent) } : {}), - ...(parseSiteUrl(site.siteUrl) ? { siteUrl: parseSiteUrl(site.siteUrl) } : {}), - ...(parseRelatedSites(site.relatedSites).length > 0 - ? { relatedSites: parseRelatedSites(site.relatedSites) } - : {}), - ...(site.pwa ? { pwa: true } : {}), - // Persist only the non-default: archives is on unless explicitly disabled. - ...(site.archives === false ? { archives: false } : {}), - ...(site.duplicates === false ? { duplicates: false } : {}), - ...(typeof site.archiveMaxBytes === "number" && - Number.isFinite(site.archiveMaxBytes) && - site.archiveMaxBytes >= 0 - ? { archiveMaxBytes: Math.floor(site.archiveMaxBytes) } - : {}), - ...(parseSiteUrl(site.hubUrl) ? { hubUrl: parseSiteUrl(site.hubUrl) } : {}), - }; - const dir = siteDir(paths, site.siteId); - await fs.promises.mkdir(dir, { recursive: true }); - const file = siteConfigFile(paths, site.siteId); - await writeJsonAtomic(file, merged); } export async function deleteSite( diff --git a/common/lib/siteSchema.test.ts b/common/lib/siteSchema.test.ts @@ -0,0 +1,229 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile } from "node:fs/promises"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { z } from "zod"; +import { + SITE_FIELD_DOCS, + SITE_KEYS, + parseSite, + siteFieldsSchema, + siteToDisk, + type Site, +} from "./siteSchema"; +import { getSite, siteConfigFile, writeSite } from "./site"; +import type { Paths } from "./paths"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); + +// The schema's per-key output and the hand-written `Site` name the same keys, +// and the output is assignable to `Site` (defaultGroupId aside: it is resolved +// by the object step). The reverse direction does not hold for the optional +// keys — zod emits them as required `T | undefined` — which is slice 4a's +// deviation 2 again. +type Fields = z.output<ReturnType<typeof siteFieldsSchema>>; +type SameKeys<A, B> = [keyof A] extends [keyof B] + ? [keyof B] extends [keyof A] + ? true + : false + : false; +const keysMatch: SameKeys<Fields, Site> = true; +const outputFits: Omit<Fields, "defaultGroupId"> extends Omit<Site, "defaultGroupId"> + ? true + : false = true; + +const SVG = + '<svg viewBox="0 0 24 24"><path d="M12 2a10 10 0 0 0-3 19.5"/></svg>'; + +test("the shape pins: schema keys = Site keys = SITE_FIELD_DOCS keys", () => { + assert.equal(keysMatch, true); + assert.equal(outputFits, true); + assert.deepEqual( + Object.keys(siteFieldsSchema("x").shape), + Object.keys(SITE_FIELD_DOCS), + ); + assert.deepEqual([...SITE_KEYS], Object.keys(SITE_FIELD_DOCS)); +}); + +test("empty, null, [] and a number all read as the defaults, every key emitted", () => { + const want = parseSite("s", {}); + assert.deepEqual(Object.keys(want), Object.keys(SITE_FIELD_DOCS)); + assert.equal(want.siteId, "s"); + assert.equal(want.siteTitle, "Transcript Browser"); + assert.equal(want.groups.length, 1); + assert.equal(want.groups[0].id, "default"); + assert.equal(want.defaultGroupId, "default"); + assert.equal(want.archives, true); + assert.equal(want.duplicates, true); + assert.equal(want.pwa, false); + assert.deepEqual(want.relatedSites, []); + assert.equal(want.socialLinks, undefined); + assert.ok("socialLinks" in want); + for (const raw of [null, [], 3, "x", undefined]) { + assert.deepEqual(parseSite("s", raw), want, JSON.stringify(raw)); + } +}); + +test("the siteId comes from the caller, never the file", () => { + assert.equal(parseSite("real", { siteId: "other" }).siteId, "real"); +}); + +test("unknown keys are dropped", () => { + const site = parseSite("s", { bogus: 1, siteTitle: "T" }) as Record<string, unknown>; + assert.equal("bogus" in site, false); + assert.equal(site.siteTitle, "T"); +}); + +test("a membership naming an unknown group loses its groupId; a known one keeps it", () => { + const site = parseSite("s", { + groups: [ + { id: "a", name: "A", selectedByDefault: true }, + { id: "b", name: "B" }, + ], + defaultGroupId: "zzz", + channels: [ + { slug: "one", groupId: "b" }, + { slug: "two", groupId: "nope", order: 2.7 }, + { slug: "one" }, + { slug: " " }, + ], + }); + assert.equal(site.defaultGroupId, "a"); + assert.deepEqual(site.channels, [ + { slug: "one", groupId: "b" }, + { slug: "two", order: 2 }, + ]); +}); + +test("socialLinks: absent inherits (undefined), [] overrides", () => { + assert.equal(parseSite("s", {}).socialLinks, undefined); + assert.deepEqual(parseSite("s", { socialLinks: [] }).socialLinks, []); + assert.equal(parseSite("s", { socialLinks: "x" }).socialLinks, undefined); +}); + +test("archives / duplicates are off only when explicitly false", () => { + for (const v of [undefined, true, 0, "false", null]) { + assert.equal(parseSite("s", { archives: v }).archives, true, String(v)); + assert.equal(parseSite("s", { duplicates: v }).duplicates, true, String(v)); + } + assert.equal(parseSite("s", { archives: false }).archives, false); + assert.equal(parseSite("s", { duplicates: false }).duplicates, false); +}); + +test("siteToDisk persists only the non-defaults", () => { + const disk = siteToDisk(parseSite("s", {})) as Record<string, unknown>; + assert.deepEqual(Object.keys(disk), [ + "siteId", + "siteTitle", + "siteDescription", + "headerTitle", + "homeTagline", + "groups", + "defaultGroupId", + "channels", + ]); + const full = siteToDisk( + parseSite("s", { + archives: false, + duplicates: false, + pwa: true, + archiveMaxBytes: 1024.9, + siteUrl: "https://a.example/", + hubUrl: "http://hub.example", + accent: "#ABCDEF", + cloudflareProject: " proj ", + relatedSites: [{ label: "x", siteIds: ["b"] }], + socialLinks: [], + }), + ); + assert.equal(full.archives, false); + assert.equal(full.duplicates, false); + assert.equal(full.pwa, true); + assert.equal(full.archiveMaxBytes, 1024); + assert.equal(full.siteUrl, "https://a.example"); + assert.equal(full.accent, "#abcdef"); + assert.equal(full.cloudflareProject, "proj"); + assert.deepEqual(full.socialLinks, []); +}); + +function fixtures(): Array<[string, unknown]> { + const out: Array<[string, unknown]> = [ + ["empty", {}], + [ + "everything", + { + siteId: "s", + siteTitle: "T", + siteDescription: "D", + headerTitle: "H", + homeTagline: "tag", + accent: "#123456", + socialLinks: [{ label: "L", url: "https://x.example", svg: SVG }], + groups: [ + { id: "a", name: " A ", selectedByDefault: true, order: 1.5, inline: true }, + { id: "b", name: "B", description: " d " }, + { id: "a", name: "dup" }, + { id: "BAD", name: "x" }, + ], + defaultGroupId: "b", + channels: [{ slug: "c1", groupId: "a", order: 3 }, { slug: "c2", groupId: "q" }], + cloudflareProject: "p", + siteUrl: "https://s.example//", + relatedSites: [{ siteIds: ["x", "x", "BAD"] }, { label: " ", siteIds: [] }], + pwa: true, + archives: false, + archiveMaxBytes: -1, + duplicates: false, + hubUrl: "ftp://nope", + bogus: true, + }, + ], + ]; + const e2e = path.join(HERE, "..", "..", "editor", "e2e", "fixtures", "sites", "testsite", "site.json"); + out.push(["e2e testsite", JSON.parse(fs.readFileSync(e2e, "utf8"))]); + return out; +} + +test("round-trip: parseSite(siteToDisk(parseSite(x))) = parseSite(x)", () => { + for (const [name, raw] of fixtures()) { + const once = parseSite("s", raw); + const twice = parseSite("s", JSON.parse(JSON.stringify(siteToDisk(once)))); + assert.deepEqual(twice, once, name); + } +}); + +function scratchPaths(dir: string): Paths { + return { sitesDir: dir } as Paths; +} + +test("writeSite throws on no groups, a default outside the groups, and an unsafe SVG", async () => { + const paths = scratchPaths(await mkdtemp(path.join(os.tmpdir(), "site-"))); + const base = parseSite("s", {}); + await assert.rejects(writeSite({ ...base, groups: [] }, paths), /At least one channel group/); + await assert.rejects( + writeSite({ ...base, defaultGroupId: "elsewhere" }, paths), + /not in the configured groups/, + ); + await assert.rejects( + writeSite( + { ...base, socialLinks: [{ label: "L", url: "https://x.example", svg: "<svg><script/></svg>" }] }, + paths, + ), + /invalid SVG/, + ); + assert.equal(fs.existsSync(siteConfigFile(paths, "s")), false); +}); + +test("writeSite → getSite round-trips, and the file holds only non-defaults", async () => { + const paths = scratchPaths(await mkdtemp(path.join(os.tmpdir(), "site-"))); + const site = parseSite("s", { siteTitle: "Mine", archives: false }); + await writeSite(site, paths); + assert.deepEqual(getSite("s", paths), site); + const disk = JSON.parse(await readFile(siteConfigFile(paths, "s"), "utf8")); + assert.equal(disk.archives, false); + assert.equal("duplicates" in disk, false); + assert.equal("pwa" in disk, false); +}); diff --git a/common/lib/siteSchema.ts b/common/lib/siteSchema.ts @@ -0,0 +1,327 @@ +// THE site.json SCHEMA — one definition of `sites/<id>/site.json`, used by the +// reader, the writer and SITE.md. +// +// one-core phase 3 slice 4b, on slice 4a's pattern (lib/settingsSchema.ts): +// every field is `settingsField(coerce)` over the parser that already existed — +// parseChannelGroups, resolveDefaultGroupId, parseSiteChannels, +// parseSocialLinks, parseAccent, parseSiteUrl, parseRelatedSites — so no +// boundary moved; zod supplies the key plumbing and strips unknown keys. No +// `.default()`, no `.passthrough()`. +// +// WHAT A READ PROMISES (parseSite): it never throws, and it emits EVERY key — +// an absent optional key is present with the value `undefined` — exactly as the +// hand-written parseSite did. Two fields depend on a sibling (the default group +// must be one of the groups; a membership's group must exist), so the schema is +// the per-key object followed by ONE object-level step that resolves them. +// +// WHAT A WRITE PROMISES (writeSite in lib/site.ts): the throwing validations, +// then `siteToDisk` below — which persists only what is not a default — through +// the atomic writer. `siteToDisk(parseSite(x))` parses back to `parseSite(x)` +// (siteSchema.test.ts pins that over fixtures). +// +// The types moved here from lib/site.ts with their parsers (lib/site.ts +// re-exports all of it, so no importer changed); each field's documentation is +// its `*_FIELD_DOCS` entry, type-checked complete (lib/fieldDocs.ts) and +// rendered into SITE.md by common/bin/file-schemas-docs.ts. +// +// SERVER-ONLY: zod. The one client importer of these types, SiteForm.tsx, +// imports them with `import type`. The PUBLIC `export/public/site.json` +// (lib/siteDescriptor.ts) is a different file with a different schema. + +import { z } from "zod"; +import { + FALLBACK_GROUP, + parseChannelGroups, + resolveDefaultGroupId, + type ChannelGroup, +} from "./channelGroups"; +import { parseAccent } from "./accent"; +import { parseSocialLinks, type SocialLink } from "./settingsSchema"; +import { settingsField } from "./settingsFieldSchemas"; +import type { FieldDocs } from "./fieldDocs"; + +// Each field is documented in SITE_CHANNEL_MEMBERSHIP_FIELD_DOCS below. +export type SiteChannelMembership = { + slug: string; + groupId?: string; + order?: number; +}; + +export const SITE_CHANNEL_MEMBERSHIP_FIELD_DOCS: FieldDocs<SiteChannelMembership> = { + slug: "Channel slug (its directory name under `transcripts/channels/`). Blank and duplicate slugs are dropped.", + groupId: + "Group this channel belongs to WITHIN this site. The same channel can sit in different groups on different sites. A value naming no configured group is dropped on read and falls back to `defaultGroupId` at render time.", + order: "Optional explicit ordering hint within the site (lower first); floored to an integer.", +}; + +// Each field is documented in RELATED_SITE_GROUP_FIELD_DOCS below. +export type RelatedSiteGroup = { + label?: string; + siteIds: string[]; +}; + +export const RELATED_SITE_GROUP_FIELD_DOCS: FieldDocs<RelatedSiteGroup> = { + label: "Optional muted heading shown above the group; omit for an unlabeled group.", + siteIds: + "Sibling site ids, in display order. Invalid and repeated ids are dropped, and a group left with none is dropped. Ids are resolved against the live pool at render time, so an id for a site that does not exist (yet) is harmless — it is skipped.", +}; + +// A Site is a selection + presentation layer over the single global channel +// pool. Each field is documented in SITE_FIELD_DOCS below. +export type Site = { + siteId: string; + siteTitle: string; + siteDescription: string; + headerTitle: string; + homeTagline: string; + socialLinks?: SocialLink[]; + groups: ChannelGroup[]; + defaultGroupId: string; + channels: SiteChannelMembership[]; + cloudflareProject?: string; + accent?: string; + siteUrl?: string; + relatedSites?: RelatedSiteGroup[]; + pwa?: boolean; + archives?: boolean; + duplicates?: boolean; + archiveMaxBytes?: number; + hubUrl?: string; +}; + +export const SITE_FIELD_DOCS: FieldDocs<Site> = { + siteId: + "The site's id: a lowercase slug (`[a-z0-9][a-z0-9-]*`), and its directory name under `sites/`. The directory is authoritative — a read takes the id from the path, never from the file.", + siteTitle: "The site's title (browser tab, manifest, headings).", + siteDescription: "One-line description (meta description, manifest).", + headerTitle: "The title shown in the site header.", + homeTagline: "Tagline under the home page title. Empty = none.", + socialLinks: + "Per-site social links. ABSENT means inherit the global default (`settings.json` `socialLinks`); an array — even an empty one — overrides it. Each link's SVG must be safe to inline or the save is refused.", + groups: + "Channel grouping layout for THIS site: the buckets the export UI renders channel checkboxes in, and which are selected by default. At least one is required on save; a file with none reads as one inline fallback group.", + defaultGroupId: + "The group a channel falls into when its membership names none (or an unknown one). Must name a configured group on save; on read an unknown value resolves to the first group.", + channels: + "The channels this site exposes. A channel absent from this list is not built or deployed for this site even though its data exists in the pool.", + cloudflareProject: + "Cloudflare Pages project name this site deploys to (`wrangler pages deploy out --project-name <cloudflareProject>`). Trimmed; blank = none.", + accent: + 'Per-site brand accent, `"#rrggbb"`. Overrides the family brass on this site\'s public build. Absent = inherit the family brass. Any other spelling is dropped.', + siteUrl: + "Absolute public URL of this site's deployment, e.g. `https://jeralyzer.pages.dev` (trimmed, trailing slashes removed; anything not absolute http(s) is dropped). Drives the cross-site footer: a site with no siteUrl is omitted from every other site's list.", + relatedSites: + "Pulls specific siblings to the front of the footer's cross-site list, in named groups. Siblings not named here fall into a trailing \"Other sites\" group. Absent/empty = one flat list of every sibling.", + pwa: + "Whether this site ships an installable PWA (service worker + web manifest). Default false: a \"dumb instance\" that serves the CORS-enabled JSON federation contract but is not independently installable, so a visitor trusts only the hub PWA. Stored only when true.", + archives: + "Whether the site build generates downloadable transcript/live-chat archive zips (and links them on the Downloads page). Opt-OUT: absent/true = on, only an explicit `false` disables. Also gated by the global setting and a per-build flag.", + duplicates: + "Whether this site publishes the Duplicates page (and its header link). Opt-OUT: absent/true = on, only an explicit `false` hides it. Even when on, the page auto-hides when the site has no in-scope duplicate clusters.", + archiveMaxBytes: + "Per-site served-file size cap in bytes: any archive larger is dropped from what is served and flagged in the manifest, so a capped host (Cloudflare Pages: 25 MB) will not reject the deploy. 0 = no cap. Absent = the global default. Negative or non-numeric values are dropped.", + hubUrl: + "Per-site override for the hub this site belongs under (the PWA it points visitors toward). Absent = the family default, `settings.json` `homepageUrl`. Surfaced on the public /site.json so a hub can tell member sites from arbitrary added origins.", +}; + +// siteId shares the group-id grammar: lowercase slug, used as a directory name. +export const SITE_ID_RE = /^[a-z0-9][a-z0-9-]*$/; + +export function isValidSiteId(id: unknown): id is string { + return typeof id === "string" && SITE_ID_RE.test(id); +} + +export const SITE_DEFAULT_TITLE = "Transcript Browser"; +export const SITE_DEFAULT_DESCRIPTION = "Browse and search video transcripts"; + +export function parseSiteChannels(input: unknown): SiteChannelMembership[] { + if (!Array.isArray(input)) return []; + const out: SiteChannelMembership[] = []; + const seen = new Set<string>(); + for (const raw of input) { + if (!raw || typeof raw !== "object") continue; + const r = raw as Record<string, unknown>; + const slug = typeof r.slug === "string" ? r.slug.trim() : ""; + if (!slug || seen.has(slug)) continue; + seen.add(slug); + const entry: SiteChannelMembership = { slug }; + if (typeof r.groupId === "string" && r.groupId.trim()) { + entry.groupId = r.groupId.trim(); + } + if (typeof r.order === "number" && Number.isFinite(r.order)) { + entry.order = Math.floor(r.order); + } + out.push(entry); + } + return out; +} + +// Normalize a raw siteUrl into a trimmed absolute http(s) URL with no trailing +// slash, or undefined when missing/not a usable absolute URL. Relative or +// scheme-less values are rejected — a cross-site link must be absolute. +export function parseSiteUrl(input: unknown): string | undefined { + if (typeof input !== "string") return undefined; + const trimmed = input.trim().replace(/\/+$/, ""); + if (!/^https?:\/\/\S+/i.test(trimmed)) return undefined; + return trimmed; +} + +// Parse the relatedSites override: an ordered list of { label?, siteIds[] } +// groups. Keeps only valid site ids, dedupes within a group, drops groups with +// no valid ids, and preserves order. Existence against the live pool is NOT +// checked here — resolveRelatedSites does that at render time. +export function parseRelatedSites(input: unknown): RelatedSiteGroup[] { + if (!Array.isArray(input)) return []; + const out: RelatedSiteGroup[] = []; + for (const raw of input) { + if (!raw || typeof raw !== "object") continue; + const r = raw as Record<string, unknown>; + const ids = Array.isArray(r.siteIds) ? r.siteIds : []; + const siteIds: string[] = []; + const seen = new Set<string>(); + for (const id of ids) { + if (!isValidSiteId(id) || seen.has(id)) continue; + seen.add(id); + siteIds.push(id); + } + if (siteIds.length === 0) continue; + const label = + typeof r.label === "string" && r.label.trim() ? r.label.trim() : undefined; + out.push(label ? { label, siteIds } : { siteIds }); + } + return out; +} + +// The per-key coercions. Each is total over `unknown`. +const stringOr = (fallback: string) => (v: unknown): string => + typeof v === "string" ? v : fallback; + +function groupsOrFallback(v: unknown): ChannelGroup[] { + const groups = parseChannelGroups(v); + // A single default group selected by default keeps a site with no explicit + // group config behaving like the pre-grouping UI (one bucket, all checked). + return groups.length > 0 ? groups : [{ ...FALLBACK_GROUP }]; +} + +function archiveMaxBytesOf(v: unknown): number | undefined { + return typeof v === "number" && Number.isFinite(v) && v >= 0 + ? Math.floor(v) + : undefined; +} + +// The per-key object. Its key order is the order parseSite has always emitted +// (and SITE_FIELD_DOCS's). Exported for the shape test only. +export function siteFieldsSchema(siteId: string) { + const d = SITE_FIELD_DOCS; + return z.object({ + siteId: settingsField((): string => siteId).describe(d.siteId), + siteTitle: settingsField(stringOr(SITE_DEFAULT_TITLE)).describe(d.siteTitle), + siteDescription: settingsField(stringOr(SITE_DEFAULT_DESCRIPTION)).describe( + d.siteDescription, + ), + headerTitle: settingsField(stringOr(SITE_DEFAULT_TITLE)).describe(d.headerTitle), + homeTagline: settingsField(stringOr("")).describe(d.homeTagline), + // Key present (array) = override; absent = inherit the global default. + socialLinks: settingsField((v): SocialLink[] | undefined => + Array.isArray(v) ? parseSocialLinks(v) : undefined, + ).describe(d.socialLinks), + groups: settingsField(groupsOrFallback).describe(d.groups), + // Resolved against `groups` in the object step below. + defaultGroupId: settingsField((v): unknown => v).describe(d.defaultGroupId), + channels: settingsField(parseSiteChannels).describe(d.channels), + cloudflareProject: settingsField((v): string | undefined => + typeof v === "string" && v.trim() ? v.trim() : undefined, + ).describe(d.cloudflareProject), + accent: settingsField(parseAccent).describe(d.accent), + siteUrl: settingsField(parseSiteUrl).describe(d.siteUrl), + relatedSites: settingsField(parseRelatedSites).describe(d.relatedSites), + pwa: settingsField((v): boolean => v === true).describe(d.pwa), + // Opt-out: only an explicit false disables. Absent/true stays on. + archives: settingsField((v): boolean => v !== false).describe(d.archives), + duplicates: settingsField((v): boolean => v !== false).describe(d.duplicates), + archiveMaxBytes: settingsField(archiveMaxBytesOf).describe(d.archiveMaxBytes), + hubUrl: settingsField(parseSiteUrl).describe(d.hubUrl), + }); +} + +// The whole schema: the per-key object, then the two sibling-dependent fields. +// +// THE OBJECT STEP ALSO RE-EMITS EVERY KEY, in order. zod 4 omits a key that was +// absent from the input when its transform returns `undefined`, so the per-key +// output lacks `socialLinks`, `accent`, … on a file that does not spell them — +// where parseSite has always emitted them, present and `undefined`. Rebuilding +// from SITE_KEYS keeps that shape (and the key order) exactly. +export function siteSchema(siteId: string) { + return siteFieldsSchema(siteId).transform((s): Site => { + const groups = s.groups; + const resolved: Partial<Record<keyof Site, unknown>> = { + ...s, + defaultGroupId: resolveDefaultGroupId(s.defaultGroupId, groups), + // Drop a membership's groupId that names no configured group (it folds to + // the default at render time via resolveChannelGroupId); keep a valid one + // so the editor round-trips it. + channels: s.channels.map((c) => { + if (c.groupId && !groups.some((g) => g.id === c.groupId)) { + const { groupId: _drop, ...rest } = c; + return rest; + } + return c; + }), + }; + const out: Partial<Record<keyof Site, unknown>> = {}; + for (const key of SITE_KEYS) out[key] = resolved[key]; + return out as Site; + }); +} + +// The keys of site.json, in the schema's order — SITE.md's order, and the +// unknown-key oracle. +export const SITE_KEYS = Object.keys(SITE_FIELD_DOCS) as ReadonlyArray<keyof Site>; + +// Parse a raw site.json value into a fully-resolved Site. Never throws: a +// missing, non-object or ill-typed file reads as the defaults field by field. +export function parseSite(siteId: string, raw: unknown): Site { + const obj = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : {}; + return siteSchema(siteId).parse(obj); +} + +// What a save puts on disk for an ALREADY-VALIDATED site: only the keys that +// are not defaults. Pure — writeSite (lib/site.ts) runs the throwing +// validations first and passes their normalized results in. +export function siteToDisk(site: Site): Site { + const groups = parseChannelGroups(site.groups); + const channels = parseSiteChannels(site.channels).filter( + (c) => !c.groupId || groups.some((g) => g.id === c.groupId), + ); + const relatedSites = parseRelatedSites(site.relatedSites); + const accent = parseAccent(site.accent); + const siteUrl = parseSiteUrl(site.siteUrl); + const hubUrl = parseSiteUrl(site.hubUrl); + const archiveMaxBytes = archiveMaxBytesOf(site.archiveMaxBytes); + return { + siteId: site.siteId, + siteTitle: site.siteTitle, + siteDescription: site.siteDescription, + headerTitle: site.headerTitle, + homeTagline: site.homeTagline, + // undefined socialLinks = inherit the global default; only an explicit + // override (an array, even empty) is persisted. + ...(site.socialLinks !== undefined ? { socialLinks: site.socialLinks } : {}), + groups, + defaultGroupId: site.defaultGroupId, + channels, + ...(site.cloudflareProject && site.cloudflareProject.trim() + ? { cloudflareProject: site.cloudflareProject.trim() } + : {}), + ...(accent ? { accent } : {}), + ...(siteUrl ? { siteUrl } : {}), + ...(relatedSites.length > 0 ? { relatedSites } : {}), + ...(site.pwa ? { pwa: true } : {}), + // Persist only the non-default: archives is on unless explicitly disabled. + ...(site.archives === false ? { archives: false } : {}), + ...(site.duplicates === false ? { duplicates: false } : {}), + ...(archiveMaxBytes !== undefined ? { archiveMaxBytes } : {}), + ...(hubUrl ? { hubUrl } : {}), + }; +} +