// THE TWO KEY TABLES GENERATED FROM THE FILE SCHEMAS: SITE.md (a site's // `site.json`) and CHANNEL.md (a channel's `config.json`), both at the repo // root. // // one-core phase 3 slice 4b; the sibling of settingsDocs.ts (SETTINGS.md), and // rendered with its table renderer. Pure renderers — common/bin/ // file-schemas-docs.ts writes the files, and fileSchemaDocs.test.ts asserts the // committed bytes are what these return, so neither can be edited by hand. // // Everything comes from the schemas' docs records: the keys and their order // from SITE_FIELD_DOCS / CHANNEL_CONFIG_FIELD_DOCS, the defaults (site.json only // — a channel's optional keys have no default, absent means inherit) from // `parseSite(id, {})`, the nested tables from the records beside each type. // // No `.example` files: a site needs an id (its directory), and the smallest // channel is one line, spelled out in CHANNEL.md. import { CHANNEL_GROUP_FIELD_DOCS } from "./channelGroups"; import { AUDIO_CHECK_FIELD_DOCS, CHANNEL_CONFIG_FIELD_DOCS, CHANNEL_SYNC_STATE_KEYS, DOWNLOAD_FILTER_FIELD_DOCS, } from "./channelConfig"; import { RECORDED_DATE_RULE_FIELD_DOCS } from "./recordedDate"; import { SOCIAL_LINK_FIELD_DOCS } from "./settingsSchema"; import { RELATED_SITE_GROUP_FIELD_DOCS, SITE_CHANNEL_MEMBERSHIP_FIELD_DOCS, SITE_FIELD_DOCS, SITE_PUBLISH_FIELD_DOCS, parseSite, type Site, } from "./siteSchema"; import { cell, defaultCell, isScalar, renderTable, type KeyTable } from "./settingsDocs"; const GENERATED = ""; const REGENERATE = "Regenerate this file with " + "`pnpm --filter yt-dlp-transcript-common exec tsx bin/file-schemas-docs.ts`."; // The default as a cell, where `undefined` — a key parseSite emits but a file // need not spell — is "absent". function siteDefaultCell(v: unknown): string { return v === undefined ? "absent" : defaultCell(v); } const SITE_NESTED: Partial> = { socialLinks: [{ path: "socialLinks[]", docs: SOCIAL_LINK_FIELD_DOCS }], groups: [{ path: "groups[]", docs: CHANNEL_GROUP_FIELD_DOCS }], channels: [{ path: "channels[]", docs: SITE_CHANNEL_MEMBERSHIP_FIELD_DOCS }], relatedSites: [{ path: "relatedSites[]", docs: RELATED_SITE_GROUP_FIELD_DOCS }], publish: [{ path: "publish", docs: SITE_PUBLISH_FIELD_DOCS }], }; export function renderSiteMarkdown(): string { const d = parseSite("", {}) as Record; const keys = Object.keys(SITE_FIELD_DOCS) as Array; const out: string[] = []; out.push("# site.json keys"); out.push(""); out.push(GENERATED); out.push(""); out.push( "One public site: its branding, its channel grouping and which channels " + "it exposes, persisted to `transcripts/sites//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`).", ); out.push(""); out.push( "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, " + "when a social link's SVG is not safe to inline, or when a public " + "site's `publish.auto` deploys and it has no `cloudflareProject`.", ); out.push(""); out.push(REGENERATE); out.push(""); out.push("| Key | Default |"); out.push("|---|---|"); for (const key of keys) { const def = key === "siteId" ? "the directory name" : siteDefaultCell(d[key]); out.push(`| [\`${key}\`](#${key.toLowerCase()}) | ${def} |`); } out.push(""); for (const key of keys) { out.push(`## \`${key}\``); out.push(""); out.push(SITE_FIELD_DOCS[key]); out.push(""); const v = d[key]; if (key === "siteId") continue; if (v === undefined || isScalar(v)) { out.push(`Default: ${siteDefaultCell(v)}`); out.push(""); for (const table of SITE_NESTED[key] ?? []) renderTable(out, table); } else { for (const table of SITE_NESTED[key] ?? []) renderTable(out, table); out.push("Default:"); out.push(""); out.push("```json"); out.push(JSON.stringify(v, null, 2)); out.push("```"); out.push(""); } } return out.join("\n"); } // A nested block's keys are, like the top level's, absent unless spelled — // except `audioCheck.enabled`, without which the block is dropped. const CHANNEL_NESTED: Partial> = { downloadFilter: [ { path: "downloadFilter", docs: DOWNLOAD_FILTER_FIELD_DOCS, defaults: () => "absent" }, ], audioCheck: [ { path: "audioCheck", docs: AUDIO_CHECK_FIELD_DOCS, defaults: (key) => (key === "enabled" ? "required" : "absent"), }, ], recordedDate: [ { path: "recordedDate", docs: RECORDED_DATE_RULE_FIELD_DOCS, defaults: () => "required", }, ], }; function channelKind(key: string): string { if (key === "handling") return "required"; if ((CHANNEL_SYNC_STATE_KEYS as readonly string[]).includes(key)) return "sync state"; return "config"; } export function renderChannelMarkdown(): string { const keys = Object.keys(CHANNEL_CONFIG_FIELD_DOCS); const out: string[] = []; out.push("# Channel config.json keys"); out.push(""); out.push(GENERATED); out.push(""); out.push( "One channel of the corpus, persisted to " + "`transcripts/channels//config.json`. The schema is " + "`common/lib/channelConfigSchema.ts` over the coercions in " + "`common/lib/channelConfig.ts`. Which sites expose a channel is " + "`site.json`'s business — see [SITE.md](SITE.md); global settings are " + "[SETTINGS.md](SETTINGS.md).", ); out.push(""); out.push( "`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" }`.', ); out.push(""); out.push( "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`, `mediaDir`, `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.", ); out.push(""); 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. 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 `mediaDir` from their own copy of the config.", ); out.push(""); out.push(REGENERATE); out.push(""); out.push("| Key | Kind | Description |"); out.push("|---|---|---|"); for (const key of keys) { const docs = (CHANNEL_CONFIG_FIELD_DOCS as Record)[key]; const nested = CHANNEL_NESTED[key] ? ` See [\`${key}\`](#${key.toLowerCase()}).` : ""; out.push(`| \`${key}\` | ${channelKind(key)} | ${cell(docs)}${nested} |`); } out.push(""); // Each nested table is rendered with its own `#### ` heading, which // is the anchor the row links above point at. for (const key of Object.keys(CHANNEL_NESTED)) { for (const table of CHANNEL_NESTED[key] ?? []) renderTable(out, table); } return out.join("\n"); }