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:
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 } : {}),
+ };
+}
+