Archilyzer · Source

archilyzer

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

commit d76d2719c92e74f3e5661a9a0a446bc372369f2c
parent 16b38668a3cb8675d409c043e29be9ce6877cb4f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu, 24 Sep 2026 13:50:08 -0400

review fixes: one jsonFile chain per process, null patches fail loudly, doc prose

- jsonFile-server: tmpSeq, the write chains and the file locks live on
  globalThis.__yttJsonFile__ (the house pattern — jobs/registry.ts,
  controller/autoRunner.ts), so runners armed from instrumentation.ts and
  server actions share ONE chain even when Next loads the module twice; the
  temp name gains 4 random bytes (`${file}.tmp-${pid}-${seq}-${hex}`). Test:
  a second import of the module (a distinct instance) shares the state and
  its writes serialise with the first's. Header no longer says "every".
- storageLocations re-point: a null patchChannelConfig (config vanished or
  unreadable after preflight) THROWS, so the ledger rolls the link back,
  instead of recording configWritten with no dataDir on disk.
- relocateChannelMedia move-out: a null patch (config vanished between the
  read and the patch) falls back to writing the job's own copy, the same as
  the no-config branch already did, instead of writing nothing.
- writeChannelConfig returns what it wrote; patchChannelConfig parses once.
- availability-server: dead writeJsonAtomic import removed.
- siteSchema: the z.object is built once at module load; parseSite fills
  siteId after the parse (siteFieldsSchema / siteSchema are constants now).
- SITE.md / CHANNEL.md prose: the keys a site save always writes are named;
  "absent means inherit" is stated only for the overrides (name, url,
  dataDir, subLangs and the stamps are simply unset); the two whole-config
  fallbacks are named; the doubled downloadFilter / audioCheck headings are
  one each. Regenerated; two new pinning tests.

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

Diffstat:
MCHANNEL.md | 8++------
MSITE.md | 2+-
Mcommon/controller/channels.ts | 8++++----
Mcommon/controller/relocateChannelMedia.ts | 14+++++++++-----
Mcommon/controller/storageLocations.ts | 10+++++++++-
Mcommon/lib/availability-server.ts | 1-
Mcommon/lib/fileSchemaDocs.test.ts | 18+++++++++++++++++-
Mcommon/lib/fileSchemaDocs.ts | 37++++++++++++++++++++++---------------
Mcommon/lib/jsonFile-server.test.ts | 22++++++++++++++++++++++
Mcommon/lib/jsonFile-server.ts | 46++++++++++++++++++++++++++++++++++++----------
Mcommon/lib/siteSchema.test.ts | 4++--
Mcommon/lib/siteSchema.ts | 111+++++++++++++++++++++++++++++++++++++++----------------------------------------
12 files changed, 179 insertions(+), 102 deletions(-)

diff --git a/CHANNEL.md b/CHANNEL.md @@ -6,9 +6,9 @@ One channel of the corpus, persisted to `transcripts/channels/<slug>/config.json `handling` is the one required key: a file without a valid one is not a channel. The smallest channel is `{ "handling": "youtube", "url": "https://www.youtube.com/@example" }`. -Every other key is optional, and ABSENT MEANS INHERIT: an override that is not set takes the global setting of the same name. So an ill-typed or out-of-range value is not coerced to a default — it is DROPPED, as if the file did not spell it. Unknown keys (including the retired `excludeFromSync`, now a paused `sync` tier in the channel-priority document) are dropped by every read and every write. +Every other key is optional and has NO default of its own: an absent key means whatever its description says — for the per-channel overrides, inherit the global setting of the same name; for `name`, `url`, `dataDir`, `subLangs` and the sync-state stamps, simply unset. So an ill-typed or out-of-range value is not coerced — it is DROPPED, as if the file did not spell it. Unknown keys (including the retired `excludeFromSync`, now a paused `sync` tier in the channel-priority document) are dropped by every read and every write. -The three **sync state** keys are not configuration: the sync, sweep and download passes stamp them, the channel form never does, and they live in the same file on purpose. Every writer after creation PATCHES (`patchChannelConfig`): it re-reads the file at the moment it writes and changes only its own keys, so a stamp and a form save made at once in the editor both land. +The three **sync state** keys are not configuration: the sync, sweep and download passes stamp them, the channel form never does, and they live in the same file on purpose. Writers after creation PATCH (`patchChannelConfig`): each re-reads the file at the moment it writes and changes only its own keys, so a stamp and a form save made at once in the editor both land. The two exceptions write a whole config, and only when there is no readable file to patch: a media move and a channel rename record `dataDir` from their own copy of the config. Regenerate this file with `pnpm --filter yt-dlp-transcript-common exec tsx bin/file-schemas-docs.ts`. @@ -44,8 +44,6 @@ Regenerate this file with `pnpm --filter yt-dlp-transcript-common exec tsx bin/f | `sleepBetweenDownloadsSeconds` | config | Per-channel override for the global pause between downloads. Absent = inherit; 0 = no sleep; floored and capped at 600. | | `audioCheck` | config | Opt-in audio-integrity checking for sources that intermittently serve corrupt audio mid-download (e.g. Odysee "original"): the managed downloader periodically validates the in-progress `.part` file and rolls back to the last known-good snapshot on corruption. transcribe-handling only. See [`audioCheck`](#audiocheck). | -## `downloadFilter` - #### `downloadFilter` | Key | Default | Description | @@ -55,8 +53,6 @@ Regenerate this file with `pnpm --filter yt-dlp-transcript-common exec tsx bin/f | `includeLivestreams` | absent | Opt every livestream VOD in, whatever it is called. A SECOND positive selector beside `include`, not a modifier of it — so `{ includeLivestreams: true }` alone rejects plain uploads and passes livestreams. Stored only when `true`. | | `rejectedLivestreams` | absent | What to do with a livestream the filter REJECTED: `"skip"` (default, and what every channel predating the field did) or `"chat-only"` — the video is not downloaded, its live chat is, and it joins the corpus as a chat track with no captions. Stored only when not `"skip"`, and only beside a real filter: with nothing to reject it names a decision that can never be taken. | -## `audioCheck` - #### `audioCheck` | Key | Default | Description | diff --git a/SITE.md b/SITE.md @@ -4,7 +4,7 @@ One public site: its branding, its channel grouping and which channels it exposes, persisted to `transcripts/sites/<id>/site.json` (the directory under `$SITES_DIR` when that is set). The schema is `common/lib/siteSchema.ts`. Global operational settings are `settings.json` — see [SETTINGS.md](SETTINGS.md). The PUBLIC `/site.json` a built site serves is a different file (`common/lib/siteDescriptor.ts`). -Every key is optional. A missing key reads as its default, an ill-typed one as its default (or is dropped, for the optional ones), and an unknown one is dropped on the next save. A save writes only the keys that differ from the default. It is REFUSED when there is no channel group, when `defaultGroupId` names no group, or when a social link's SVG is not safe to inline. +Every key is optional on read. A missing key reads as its default, an ill-typed one as its default (or is dropped, for the optional ones), and an unknown one is dropped on the next save. A save ALWAYS writes `siteId`, `siteTitle`, `siteDescription`, `headerTitle`, `homeTagline`, `groups`, `defaultGroupId` and `channels`; every other key is written only when it differs from its default (`socialLinks` whenever it is an array, even an empty one). A save is REFUSED when there is no channel group, when `defaultGroupId` names no group, or when a social link's SVG is not safe to inline. Regenerate this file with `pnpm --filter yt-dlp-transcript-common exec tsx bin/file-schemas-docs.ts`. diff --git a/common/controller/channels.ts b/common/controller/channels.ts @@ -470,12 +470,12 @@ export async function listChannelStatsFromSnapshots( // channel at all (no valid `handling`) THROWS rather than writing a file every // reader would then treat as absent. Every caller passes a parsed config, so // this only ever fires on a bug. The e2e fixtures seed their configs raw, on -// purpose, and are unaffected. +// purpose, and are unaffected. Returns what it wrote. export async function writeChannelConfig( paths: Paths, slug: string, config: ChannelConfig, -): Promise<void> { +): Promise<ChannelConfig> { const parsed = channelConfigSchema.parse(config); if (!parsed) { throw new Error( @@ -484,6 +484,7 @@ export async function writeChannelConfig( ); } await writeJsonAtomic(channelConfigPath(paths, slug), parsed, { mkdir: true }); + return parsed; } export type PatchChannelConfigOptions = { @@ -512,8 +513,7 @@ export async function patchChannelConfig( const next: ChannelConfig = { ...current }; for (const key of opts.unset ?? []) delete next[key]; Object.assign(next, patch); - await writeChannelConfig(paths, slug, next); - return channelConfigSchema.parse(next); + return writeChannelConfig(paths, slug, next); }); } diff --git a/common/controller/relocateChannelMedia.ts b/common/controller/relocateChannelMedia.ts @@ -761,13 +761,17 @@ async function moveOut(args: { // Written only now, on success: config.dataDir is a record of what is on // disk, never an intention. Skipped when it already says so, so a rerun // does not rewrite a file it agrees with. + // No readable config.json (it vanished mid-move, before the read or + // between the read and the patch): the job's own copy is the best record + // there is, and the swap has already happened — so write that, never + // nothing. const fresh = await readChannelConfig(paths, slug); - if (!fresh) { - // No readable config.json (it vanished mid-move): the job's own copy is - // the best record there is, and the swap has already happened. + const patched = + fresh && fresh.dataDir?.trim() !== target + ? await patchChannelConfig(paths, slug, { dataDir: target }) + : fresh; + if (!patched) { await writeChannelConfig(paths, slug, { ...args.config, dataDir: target }); - } else if (fresh.dataDir?.trim() !== target) { - await patchChannelConfig(paths, slug, { dataDir: target }); } log(`Swapped: ${dataDir} -> ${target}`); await writeMarker(paths, slug, { diff --git a/common/controller/storageLocations.ts b/common/controller/storageLocations.ts @@ -726,7 +726,15 @@ export async function repointStorageLocation(opts: { await symlink(newTarget, link); entry.relinked = true; } - await patchChannelConfig(opts.paths, slug, { dataDir: newTarget }); + // A null patch means config.json vanished or became unreadable after + // preflight listed the channel: fail this step, so the ledger rolls + // the link back, rather than leave a link no dataDir records. + const written = await patchChannelConfig(opts.paths, slug, { + dataDir: newTarget, + }); + if (!written) { + throw new Error(`channels/${slug}/config.json is missing or unreadable`); + } entry.configWritten = true; } catch (err) { const step = resuming diff --git a/common/lib/availability-server.ts b/common/lib/availability-server.ts @@ -1,4 +1,3 @@ -import { writeJsonAtomic } from "./jsonFile-server"; import { AVAILABILITY_FILENAME, AVAILABILITY_VALUES, diff --git a/common/lib/fileSchemaDocs.test.ts b/common/lib/fileSchemaDocs.test.ts @@ -5,7 +5,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { renderChannelMarkdown, renderSiteMarkdown } from "./fileSchemaDocs"; import { CHANNEL_CONFIG_KEYS, parseChannelConfig } from "./channelConfig"; -import { SITE_KEYS } from "./siteSchema"; +import { SITE_KEYS, parseSite, siteToDisk } from "./siteSchema"; // SITE.md and CHANNEL.md are GENERATED from the file schemas // (common/bin/file-schemas-docs.ts). This is what keeps them generated: a hand @@ -42,3 +42,19 @@ test("CHANNEL.md's smallest channel is a channel", () => { url: "https://www.youtube.com/@example", }); }); + +test("SITE.md names exactly the keys a save always writes", () => { + const always = Object.keys(siteToDisk(parseSite("x", {}))); + const m = /A save ALWAYS writes ([^;]*);/.exec(renderSiteMarkdown()); + assert.ok(m, "the always-written sentence is present"); + const named = [...m![1].matchAll(/`([a-zA-Z]+)`/g)].map((x) => x[1]); + assert.deepEqual(named, always); +}); + +test("CHANNEL.md renders each nested table under one heading", () => { + const md = renderChannelMarkdown(); + for (const key of ["downloadFilter", "audioCheck"]) { + const headings = md.split("\n").filter((l) => l.startsWith("#") && l.includes(`\`${key}\``)); + assert.equal(headings.length, 1, key); + } +}); diff --git a/common/lib/fileSchemaDocs.ts b/common/lib/fileSchemaDocs.ts @@ -71,12 +71,15 @@ export function renderSiteMarkdown(): string { ); out.push(""); out.push( - "Every key is optional. A missing key reads as its default, an ill-typed " + - "one as its default (or is dropped, for the optional ones), and an " + - "unknown one is dropped on the next save. A save writes only the keys " + - "that differ from the default. It is REFUSED when there is no channel " + - "group, when `defaultGroupId` names no group, or when a social link's " + - "SVG is not safe to inline.", + "Every key is optional on read. A missing key reads as its default, an " + + "ill-typed one as its default (or is dropped, for the optional ones), " + + "and an unknown one is dropped on the next save. A save ALWAYS writes " + + "`siteId`, `siteTitle`, `siteDescription`, `headerTitle`, " + + "`homeTagline`, `groups`, `defaultGroupId` and `channels`; every other " + + "key is written only when it differs from its default (`socialLinks` " + + "whenever it is an array, even an empty one). A save is REFUSED when " + + "there is no channel group, when `defaultGroupId` names no group, or " + + "when a social link's SVG is not safe to inline.", ); out.push(""); out.push(REGENERATE); @@ -156,10 +159,12 @@ export function renderChannelMarkdown(): string { ); out.push(""); out.push( - "Every other key is optional, and ABSENT MEANS INHERIT: an override that " + - "is not set takes the global setting of the same name. So an ill-typed " + - "or out-of-range value is not coerced to a default — it is DROPPED, as " + - "if the file did not spell it. Unknown keys (including the retired " + + "Every other key is optional and has NO default of its own: an absent " + + "key means whatever its description says — for the per-channel " + + "overrides, inherit the global setting of the same name; for `name`, " + + "`url`, `dataDir`, `subLangs` and the sync-state stamps, simply unset. " + + "So an ill-typed or out-of-range value is not coerced — it is DROPPED, " + + "as if the file did not spell it. Unknown keys (including the retired " + "`excludeFromSync`, now a paused `sync` tier in the channel-priority " + "document) are dropped by every read and every write.", ); @@ -167,10 +172,12 @@ export function renderChannelMarkdown(): string { out.push( "The three **sync state** keys are not configuration: the sync, sweep and " + "download passes stamp them, the channel form never does, and they live " + - "in the same file on purpose. Every writer after creation PATCHES " + - "(`patchChannelConfig`): it re-reads the file at the moment it writes " + + "in the same file on purpose. Writers after creation PATCH " + + "(`patchChannelConfig`): each re-reads the file at the moment it writes " + "and changes only its own keys, so a stamp and a form save made at once " + - "in the editor both land.", + "in the editor both land. The two exceptions write a whole config, and " + + "only when there is no readable file to patch: a media move and a " + + "channel rename record `dataDir` from their own copy of the config.", ); out.push(""); out.push(REGENERATE); @@ -183,9 +190,9 @@ export function renderChannelMarkdown(): string { out.push(`| \`${key}\` | ${channelKind(key)} | ${cell(docs)}${nested} |`); } out.push(""); + // Each nested table is rendered with its own `#### <path>` heading, which + // is the anchor the row links above point at. for (const key of Object.keys(CHANNEL_NESTED)) { - out.push(`## \`${key}\``); - out.push(""); for (const table of CHANNEL_NESTED[key] ?? []) renderTable(out, table); } return out.join("\n"); diff --git a/common/lib/jsonFile-server.test.ts b/common/lib/jsonFile-server.test.ts @@ -5,6 +5,7 @@ import { mkdtemp, readFile, readdir, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { + jsonFileState, jsonText, pendingJsonWrites, readJsonFile, @@ -60,6 +61,27 @@ test("temp names are unique per write, not per process", () => { const b = tmpPathFor("/x/config.json"); assert.notEqual(a, b); assert.ok(a.startsWith(`/x/config.json.tmp-${process.pid}-`)); + assert.match(a, /\.tmp-\d+-\d+-[0-9a-f]{8}$/); +}); + +test("a second copy of the module shares the one chain on globalThis", async () => { + // Next can load this module twice in one server; simulate the second copy + // with a fresh import (a distinct module URL) and check both see one state. + const copy = (await import(`./jsonFile-server.ts?copy=${Date.now()}`)) as typeof import("./jsonFile-server"); + assert.notEqual(copy.writeJsonAtomic, writeJsonAtomic, "a genuinely separate module instance"); + assert.equal(copy.jsonFileState(), jsonFileState()); + const dir = await scratch(); + const file = path.join(dir, "shared.json"); + const writes = []; + for (let i = 0; i < 20; i++) { + const w = i % 2 === 0 ? writeJsonAtomic : copy.writeJsonAtomic; + writes.push(w(file, { i })); + } + // While in flight, both copies see the same chain entry. + assert.equal(copy.pendingJsonWrites(), pendingJsonWrites()); + await Promise.all(writes); + assert.deepEqual(JSON.parse(await readFile(file, "utf8")), { i: 19 }); + assert.deepEqual(await readdir(dir), ["shared.json"]); }); test("two same-path writers serialise: the last ISSUED lands, no temp is left, no write fails", async () => { diff --git a/common/lib/jsonFile-server.ts b/common/lib/jsonFile-server.ts @@ -1,4 +1,6 @@ -// ONE JSON READER AND ONE ATOMIC JSON WRITER for every config file and sidecar. +// ONE JSON READER AND ONE ATOMIC JSON WRITER for the config files and sidecars. +// (Not yet every JSON writer: the slice 4b record lists the ones still on the +// per-pid temp name.) // // one-core phase 3 slice 4b. Before this module the repo had seven private // copies of `writeJsonAtomic` and some twenty inline `tmp + rename` writes, all @@ -9,11 +11,18 @@ // first's bytes and one `rename` then found no file. This module fixes both // halves of that: // -// - every temp name is unique (`${file}.tmp-${pid}-${seq}`), and +// - every temp name is unique (`${file}.tmp-${pid}-${seq}-${random}`), and // - writes to one ABSOLUTE PATH are CHAINED: a write starts only once the // previous write to that path has settled, so two same-process writers // serialise and the last one to be ISSUED is the one on disk. // +// ONE CHAIN PER PROCESS, NOT PER MODULE COPY. Next can load this module more +// than once in one server (instrumentation.ts arms the runners; server actions +// are another bundle layer), so the counter, the write chains and the locks +// live on `globalThis` — the house pattern (jobs/registry.ts, +// controller/autoRunner.ts) — and the temp name also carries random bytes, so +// its uniqueness never rests on a shared counter alone. +// // The chain is per process. A CLI run beside a live editor (e.g. // `migrate-channel-priority.ts`) is a different process and is NOT covered — // the rename is still atomic, so a reader never sees a torn file, but the @@ -32,6 +41,7 @@ // SERVER-ONLY (node:fs). Named `-server` so a `"use client"` graph never // reaches it. +import { randomBytes } from "node:crypto"; import fs from "node:fs"; import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises"; import path from "node:path"; @@ -99,14 +109,30 @@ export function jsonText(value: unknown, opts: WriteJsonOptions = {}): string { return opts.newline === false ? body : body + "\n"; } -let tmpSeq = 0; +type JsonFileState = { + tmpSeq: number; + chains: Map<string, Promise<void>>; + locks: Map<string, Promise<unknown>>; +}; -// Unique per write, not per process: `${file}.tmp-${pid}-${seq}`. -export function tmpPathFor(file: string): string { - return `${file}.tmp-${process.pid}-${++tmpSeq}`; +declare global { + // eslint-disable-next-line no-var + var __yttJsonFile__: JsonFileState | undefined; } -const chains = new Map<string, Promise<void>>(); +// Exported for the test that proves two module copies share it. +export function jsonFileState(): JsonFileState { + if (!globalThis.__yttJsonFile__) { + globalThis.__yttJsonFile__ = { tmpSeq: 0, chains: new Map(), locks: new Map() }; + } + return globalThis.__yttJsonFile__; +} + +// Unique per write, not per process: `${file}.tmp-${pid}-${seq}-${random}`. +export function tmpPathFor(file: string): string { + const seq = ++jsonFileState().tmpSeq; + return `${file}.tmp-${process.pid}-${seq}-${randomBytes(4).toString("hex")}`; +} async function writeNow( file: string, @@ -136,6 +162,7 @@ export function writeJsonAtomic( ): Promise<void> { const key = path.resolve(file); const text = jsonText(value, opts); + const { chains } = jsonFileState(); const prev = chains.get(key) ?? Promise.resolve(); const next = prev.then( () => writeNow(key, text, opts.mkdir === true), @@ -175,10 +202,9 @@ export function writeJsonAtomicSync( // otherwise read the file before the other wrote it and so drop its patch). // Callers that only write need not take it — writes are chained anyway. Per // process, like the write chain. -const locks = new Map<string, Promise<unknown>>(); - export function withJsonFileLock<T>(file: string, fn: () => Promise<T>): Promise<T> { const key = path.resolve(file); + const { locks } = jsonFileState(); const prev = locks.get(key) ?? Promise.resolve(); const next = prev.then(fn, fn); locks.set(key, next); @@ -191,5 +217,5 @@ export function withJsonFileLock<T>(file: string, fn: () => Promise<T>): Promise // For tests: how many paths currently have a write in flight. export function pendingJsonWrites(): number { - return chains.size; + return jsonFileState().chains.size; } diff --git a/common/lib/siteSchema.test.ts b/common/lib/siteSchema.test.ts @@ -24,7 +24,7 @@ const HERE = path.dirname(fileURLToPath(import.meta.url)); // 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 Fields = z.output<typeof siteFieldsSchema>; type SameKeys<A, B> = [keyof A] extends [keyof B] ? [keyof B] extends [keyof A] ? true @@ -42,7 +42,7 @@ 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(siteFieldsSchema.shape), Object.keys(SITE_FIELD_DOCS), ); assert.deepEqual([...SITE_KEYS], Object.keys(SITE_FIELD_DOCS)); diff --git a/common/lib/siteSchema.ts b/common/lib/siteSchema.ts @@ -210,39 +210,38 @@ function archiveMaxBytesOf(v: unknown): number | 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), - }); -} +// (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), + 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. // @@ -251,28 +250,26 @@ export function siteFieldsSchema(siteId: string) { // 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; - }); -} +export const siteSchema = siteFieldsSchema.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. @@ -282,7 +279,9 @@ export const SITE_KEYS = Object.keys(SITE_FIELD_DOCS) as ReadonlyArray<keyof Sit // 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); + const site = siteSchema.parse(obj); + site.siteId = siteId; + return site; } // What a save puts on disk for an ALREADY-VALIDATED site: only the keys that