// THE site.json SCHEMA — one definition of `sites//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, parseAccentSetting, 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. Three fields depend on a sibling (the default // group must be one of the groups; a membership's group must exist; the // wordmark lead must be a proper prefix of the header title), 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 — the eight keys it always writes, and every other // key only when it is not its 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 { parseAccentSetting } from "./accent"; import { wordmarkLeadFor } from "./brand"; import { isPublishPolicy, parseSocialLinks, type PublishPolicy, type SocialLink, } from "./settingsSchema"; import { settingsField } from "./settingsFieldSchemas"; import type { FieldDocs } from "./fieldDocs"; import { REPORT_ID_RE, isReportId } from "./report/schema"; // 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 = { 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 = { 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.", }; // Who a site is built for (release 17 slice XP). "public" (the default, never // written) is every site there has ever been. "private" is the operator's own // reading copy: never deployed (publish/build.ts asks siteDeployProblem in // lib/builtExport.ts before any upload), never listed (isListedSite below), // publishing no hubUrl (lib/site.ts resolveHubUrl), and the only kind of site // that content kept from the public (X posts while `social.x.visibility` is // "private", lib/postsVisibility.ts) is built into. export type SiteAudience = "public" | "private"; export const SITE_AUDIENCES: readonly SiteAudience[] = ["public", "private"]; export function isSiteAudience(v: unknown): v is SiteAudience { return v === "public" || v === "private"; } // THE ONE PREDICATE for a private site. Absent or anything but "private" reads // as public. export function isPrivateSite(site: Pick): boolean { return site.audience === "private"; } // THE ONE PREDICATE for a report-only ("cited") site: search is off. Such a // site publishes only its reports and the moments they cite (corpus.json // `site.scope: "cited"`). Absent or true is a full, searchable site. export function isCitedSite(site: Pick): boolean { return site.search === false; } // LEGACY: `publish: "cited"` (the pre-`search` report-only switch) reads as // `search: false` when the file sets no `search`. siteToDisk never writes // `publish`, so the next save migrates the file. function migrateLegacyPublish(raw: unknown): unknown { if (!raw || typeof raw !== "object" || Array.isArray(raw)) return raw; const r = raw as Record; if (r.publish !== "cited" || r.search !== undefined) return raw; return { ...r, search: false }; } // A report id: a lowercase slug, the directory name under // `sites//reports/`. The grammar is the report document's own // (lib/report/schema.ts) — one definition. export { REPORT_ID_RE }; export function isValidReportId(id: unknown): id is string { return isReportId(id); } // Parse the published report list: valid ids, first occurrence kept, order // preserved. Anything else (a non-array, a bad or repeated id) is dropped. export function parseSiteReports(input: unknown): string[] { if (!Array.isArray(input)) return []; const out: string[] = []; const seen = new Set(); for (const id of input) { if (!isValidReportId(id) || seen.has(id)) continue; seen.add(id); out.push(id); } return out; } // 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; wordmarkLead?: string; homeTagline: string; socialLinks?: SocialLink[]; groups: ChannelGroup[]; defaultGroupId: string; channels: SiteChannelMembership[]; cloudflareProject?: string; accent?: string; siteUrl?: string; listed?: boolean; audience?: SiteAudience; reports?: string[]; relatedSites?: RelatedSiteGroup[]; search?: boolean; pwa?: boolean; archives?: boolean; duplicates?: boolean; transcriptDownloads?: boolean; archiveMaxBytes?: number; hubUrl?: string; publish?: SitePublish; }; export const SITE_FIELD_DOCS: FieldDocs = { 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.", wordmarkLead: 'The heavy first part of the header wordmark: the subject\'s name, e.g. `"Jer"` for `"Jeralyzer"`; the rest is set light. Kept only when it is a proper prefix of `headerTitle` (case-sensitive, shorter than it); anything else is dropped, and a site without one sets its whole title heavy. Never guessed from the title.', 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 `). Trimmed; blank = none.", accent: 'Per-site brand accent: a named accent id (`signal`, `brass`, `vermilion`, `violet`, `sakura`, `blue`, `green`) or a custom `"#rrggbb"`. It is the site\'s accent on every page; a reader does not pick one. Absent = `signal`, the family default. A custom hex is darkened or lightened per base until it reaches 4.5:1. The public `/site.json` always carries a hex: an id is published as its on-dark value. 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.", listed: "Whether the family lists this site. Opt-OUT: absent/true = listed, only an explicit `false` is written. An unlisted site still builds and deploys as before, and its own pages are unchanged; it is left out of the homepage (cards, chart, `/stats`), the hub (members, federated search, `/corpus.json`, `/llms.txt`), every other site's footer, and the published `channel-sites.json` and pooled `stats/`. A channel only unlisted sites expose is in none of the family's public totals; a channel a listed site also exposes is credited to the listed one. A private site and a report-only site (`search: false`) are never listed, whatever `listed` says.", audience: 'Who this site is built for. `"public"` (the default; absent) or `"private"`: the operator\'s own reading copy, built on this machine and never deployed — the deploy stage refuses it before any upload — Deploy production, Deploy preview, Deploy local, Build & deploy, Publish now, the publish lane, `archilyzer publish deploy`, docker/publish-site.sh — while a build without a deploy still works (its publish policy reads as `build`). A private site is never listed (as `listed: false`, whatever `listed` says), publishes no `hubUrl`, and its `/corpus.json` says `"audience": "private"`. Content kept from the public — X posts while `social.x.visibility` is `"private"` — is built only into private sites. Only `"private"` is written.', reports: "The site's published reports, in display order: report ids (lowercase slugs, `[a-z0-9][a-z0-9-]*`), each a directory under `sites//reports/`. A report directory not named here is a draft and is not published. Invalid and repeated ids are dropped. Absent/empty = no reports.", 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.", search: "Whether this site publishes its searchable corpus. Opt-OUT: absent/true = on, only an explicit `false` is written. Off = report-only: the site publishes only its `reports` and the moments they cite — no search, browse, transcripts or archives; `channels` is the pool its citations resolve against, and its `/corpus.json` says `\"scope\": \"cited\"`. A report-only site is never listed (as `listed: false`, whatever `listed` says). Legacy `publish: \"cited\"` reads as `false`.", 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.", transcriptDownloads: "Whether a visitor gets the per-video export controls in the transcript modal: the Download menu (txt / srt / json) and Copy MD. Opt-OUT: absent/true = on, only an explicit `false` hides them. The yt-dlp clip command is not a download (it copies a line, serves no file) and shows either way. The machine contract (`/corpus.json`, manifests, shards, `llms.txt`) is served either way. The editor always shows them.", 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. Absent = the family default, `settings.json` `homepageUrl`. Published on the public `/site.json` and `/corpus.json` so a hub can tell member sites from arbitrary added origins; the header does not link to it (release 14).", publish: "What the publish lane — and Publish now — may do with this site when it is stale (release 18): `{ \"auto\": \"off\" | \"build\" | \"preview\" | \"production\" }`. Absent = `off`: the lane leaves the site alone (a manual Build or Deploy still works, and \"Build all stale\" still builds it). `build` rebuilds its bundle; `preview` also deploys it to the Pages preview branch `settings.json` `publish.previewBranch` names; `production` deploys it to production. A private site is clamped to `build` (it is never deployed); `preview` and `production` need a `cloudflareProject` — a save without one is refused, and a file that says so anyway reads as `build`. Only a policy other than `off` is written.", }; // Each field is documented in SITE_PUBLISH_FIELD_DOCS below. export type SitePublish = { auto: PublishPolicy }; export const SITE_PUBLISH_FIELD_DOCS: FieldDocs = { auto: "The policy: `off` (default), `build`, `preview` or `production` — see `publish` above.", }; // The policy a site may have, given who it is for and where it can go: a // private site at most builds, and a site with no Pages project cannot be // deployed to one. Pure; parseSite and siteToDisk both apply it. export function clampSitePublishPolicy( site: Pick, policy: PublishPolicy, ): PublishPolicy { if (policy !== "preview" && policy !== "production") return policy; if (isPrivateSite(site)) return "build"; if (!site.cloudflareProject?.trim()) return "build"; return policy; } // THE ONE READER of a site's publish policy: absent is "off". export function sitePublishPolicy(site: Pick): PublishPolicy { return site.publish?.auto ?? "off"; } // Why a save of this site's publish policy is refused, or null. A public site // that asks the lane to deploy needs somewhere to deploy to; the sentence // names the key. A private site is not refused: it is clamped to `build`. export function sitePublishProblem( site: Pick, ): string | null { const policy = site.publish?.auto; if (policy !== "preview" && policy !== "production") return null; if (isPrivateSite(site)) return null; if (site.cloudflareProject?.trim()) return null; return `publish.auto "${policy}" deploys the site, and it has no cloudflareProject — set the Pages project, or choose "build"`; } function parseSitePublish(v: unknown): SitePublish | undefined { if (!v || typeof v !== "object" || Array.isArray(v)) return undefined; const auto = (v as Record).auto; if (!isPublishPolicy(auto) || auto === "off") return undefined; return { auto }; } // 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 ONE PREDICATE for `listed` (site.json's opt-out; absent = listed). Every // public output that enumerates the family's sites filters through it: the // homepage summary (lib/homepageSummary.ts), channel-sites.json and the pooled // stats (controller/poolSummary.ts, controller/buildStats.ts), the hub's // member list (bin/compose-hub.ts) and the footer's siblings // (lib/site.ts resolveRelatedSites). The editor's own pages list every site. // Here, beside the key, and exported from lib/site like isValidSiteId, so the // pure summary builder can use it without importing file I/O. // // A PRIVATE site (`audience: "private"`) is never listed, whatever `listed` // says: it is never deployed, so there is nothing at its URL to list. Nor is a // report-only site (`search: false`): it publishes reports and the moments they // cite, not a searchable archive, so it is no hub member (federated search // would find no channel there), no homepage card and in no family total. A // full site with reports is listed as before. export function isListedSite(site: Pick): boolean { return site.listed !== false && !isPrivateSite(site) && !isCitedSite(site); } // The channels whose content belongs to unlisted sites alone: exposed by at // least one site, and by no listed one. No public total counts them. A channel // a listed site also exposes is not here (it is credited to the listed site), // and a channel no site exposes (pool-only) is not here either — the family's // instance-wide totals have always counted it. export function channelsOnlyOnUnlistedSites( sites: readonly Pick[], ): Set { const onListed = new Set(); const onUnlisted = new Set(); for (const site of sites) { const into = isListedSite(site) ? onListed : onUnlisted; for (const c of site.channels) into.add(c.slug); } for (const slug of onListed) onUnlisted.delete(slug); return onUnlisted; } 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(); for (const raw of input) { if (!raw || typeof raw !== "object") continue; const r = raw as Record; 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; const ids = Array.isArray(r.siteIds) ? r.siteIds : []; const siteIds: string[] = []; const seen = new Set(); 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). Built ONCE: `siteId` is never read from the file — // parseSite fills it from the caller — so nothing in the schema depends on it. const d = SITE_FIELD_DOCS; export const siteFieldsSchema = z.object({ siteId: settingsField((): string => "").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), // Checked against `headerTitle` in the object step below. wordmarkLead: settingsField((v): string | undefined => typeof v === "string" && v.trim() ? v.trim() : undefined, ).describe(d.wordmarkLead), 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(parseAccentSetting).describe(d.accent), siteUrl: settingsField(parseSiteUrl).describe(d.siteUrl), // Opt-out: only an explicit false unlists. Absent/true stays listed. listed: settingsField((v): boolean => v !== false).describe(d.listed), // Only "private" is kept; absent (and anything else) is the public default. audience: settingsField((v): SiteAudience | undefined => v === "private" ? "private" : undefined, ).describe(d.audience), reports: settingsField(parseSiteReports).describe(d.reports), relatedSites: settingsField(parseRelatedSites).describe(d.relatedSites), // Opt-out: only an explicit false is report-only. Absent/true stays on. search: settingsField((v): boolean => v !== false).describe(d.search), 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), transcriptDownloads: settingsField((v): boolean => v !== false).describe( d.transcriptDownloads, ), archiveMaxBytes: settingsField(archiveMaxBytesOf).describe(d.archiveMaxBytes), hubUrl: settingsField(parseSiteUrl).describe(d.hubUrl), // Clamped against `audience` and `cloudflareProject` in the object step // below. The legacy `publish: "cited"` (a string) reads as absent here. publish: settingsField(parseSitePublish).describe(d.publish), }); // The whole schema: the per-key object, then the three 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 const siteSchema = z.preprocess(migrateLegacyPublish, siteFieldsSchema).transform((s): Site => { const groups = s.groups; const resolved: Partial> = { ...s, defaultGroupId: resolveDefaultGroupId(s.defaultGroupId, groups), // A lead that is not a proper prefix of the header title is dropped (the // wordmark then sets the whole title heavy; lib/brand.ts splitWordmark). wordmarkLead: wordmarkLeadFor(s.headerTitle, s.wordmarkLead), // 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; }), // A private site at most builds; no Pages project, no deploy policy. publish: s.publish ? { auto: clampSitePublishPolicy(s, s.publish.auto) } : undefined, }; const out: Partial> = {}; 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; // 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 : {}; const site = siteSchema.parse(obj); site.siteId = siteId; return site; } // On disk for an ALREADY-VALIDATED site: siteId, the titles, homeTagline, // groups, defaultGroupId, channels always; any other key only when not its // default. Pure — writeSite (lib/site.ts) validates (throwing) first. 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 reports = parseSiteReports(site.reports); const accent = parseAccentSetting(site.accent); const wordmarkLead = wordmarkLeadFor(site.headerTitle, site.wordmarkLead); const siteUrl = parseSiteUrl(site.siteUrl); const hubUrl = parseSiteUrl(site.hubUrl); const archiveMaxBytes = archiveMaxBytesOf(site.archiveMaxBytes); const parsedPublish = parseSitePublish(site.publish); const publishPolicy = parsedPublish ? clampSitePublishPolicy(site, parsedPublish.auto) : "off"; return { siteId: site.siteId, siteTitle: site.siteTitle, siteDescription: site.siteDescription, headerTitle: site.headerTitle, ...(wordmarkLead ? { wordmarkLead } : {}), 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 } : {}), // Listed is the default: only the opt-out is persisted. ...(site.listed === false ? { listed: false } : {}), // Public is the default: only the private audience is persisted. ...(isPrivateSite(site) ? { audience: "private" as const } : {}), ...(reports.length > 0 ? { reports } : {}), ...(relatedSites.length > 0 ? { relatedSites } : {}), // Search is the default: only report-only is persisted (never `publish`). ...(isCitedSite(site) ? { search: false } : {}), ...(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 } : {}), ...(site.transcriptDownloads === false ? { transcriptDownloads: false } : {}), ...(archiveMaxBytes !== undefined ? { archiveMaxBytes } : {}), ...(hubUrl ? { hubUrl } : {}), // Off is the default: only another policy is persisted (clamped). ...(publishPolicy !== "off" ? { publish: { auto: publishPolicy } } : {}), }; }