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:
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