commit 03cb237c64a43ff2ad687e1074d3cc60b909f106
parent ba5ae2347fc02867852ddc27aa079bd6b16db7f5
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 29 Sep 2026 20:46:18 -0400
common: site.json `listed` — a per-site opt-out, absent = listed, only false written; one predicate, isListedSite; the footer never links an unlisted sibling
`listed` sits after `siteUrl` in the schema, SITE_FIELD_DOCS, siteFieldsSchema
and siteToDisk; SITE.md regenerated. isListedSite and
channelsOnlyOnUnlistedSites live beside the key in siteSchema.ts and are
exported from lib/site like isValidSiteId, so the pure summary builder can use
them without file I/O. resolveRelatedSites drops an unlisted sibling, even one
a featured group names; an unlisted site's own footer still lists the rest.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
4 files changed, 108 insertions(+), 3 deletions(-)
diff --git a/SITE.md b/SITE.md
@@ -23,6 +23,7 @@ Regenerate this file with `pnpm --filter yt-dlp-transcript-common exec tsx bin/f
| [`cloudflareProject`](#cloudflareproject) | absent |
| [`accent`](#accent) | absent |
| [`siteUrl`](#siteurl) | absent |
+| [`listed`](#listed) | `true` |
| [`relatedSites`](#relatedsites) | `[]` |
| [`pwa`](#pwa) | `false` |
| [`archives`](#archives) | `true` |
@@ -157,6 +158,12 @@ Absolute public URL of this site's deployment, e.g. `https://jeralyzer.pages.dev
Default: absent
+## `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.
+
+Default: `true`
+
## `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.
diff --git a/common/lib/site.ts b/common/lib/site.ts
@@ -13,6 +13,7 @@ import {
import { socialLinksForSave } from "./socialLinks";
import { readJsonFileSync, writeJsonAtomic } from "./jsonFile-server";
import {
+ isListedSite,
isValidSiteId,
parseSite,
parseSiteUrl,
@@ -138,7 +139,9 @@ export type CrossSiteLink = { siteId: string; title: string; url: string };
export type CrossSiteGroup = { label?: string; sites: CrossSiteLink[] };
// Resolve the footer's cross-site list for `current` against the full pool.
-// Siblings that lack a siteUrl (or are `current`) are not linkable and dropped.
+// Siblings that lack a siteUrl (or are `current`) are not linkable and dropped,
+// and so is an unlisted sibling (`listed: false`, isListedSite) — even one a
+// featured group names. An unlisted `current` still lists its siblings.
// `current.relatedSites` groups render first, in order, each filtered to known
// linkable ids (unknown/used/self skipped, empty groups dropped). Every still-
// unused sibling lands in a trailing remainder group — unlabeled when there
@@ -149,7 +152,7 @@ export function resolveRelatedSites(
): CrossSiteGroup[] {
const byId = new Map<string, CrossSiteLink>();
for (const s of all) {
- if (s.siteId === current.siteId || !s.siteUrl) continue;
+ if (s.siteId === current.siteId || !s.siteUrl || !isListedSite(s)) continue;
byId.set(s.siteId, { siteId: s.siteId, title: s.siteTitle, url: s.siteUrl });
}
const used = new Set<string>();
diff --git a/common/lib/siteSchema.test.ts b/common/lib/siteSchema.test.ts
@@ -9,12 +9,14 @@ import type { z } from "zod";
import {
SITE_FIELD_DOCS,
SITE_KEYS,
+ channelsOnlyOnUnlistedSites,
+ isListedSite,
parseSite,
siteFieldsSchema,
siteToDisk,
type Site,
} from "./siteSchema";
-import { getSite, siteConfigFile, writeSite } from "./site";
+import { getSite, resolveRelatedSites, siteConfigFile, writeSite } from "./site";
import type { Paths } from "./paths";
const HERE = path.dirname(fileURLToPath(import.meta.url));
@@ -60,6 +62,7 @@ test("empty, null, [] and a number all read as the defaults, every key emitted",
assert.equal(want.duplicates, true);
assert.equal(want.transcriptDownloads, true);
assert.equal(want.pwa, false);
+ assert.equal(want.listed, true);
assert.deepEqual(want.relatedSites, []);
assert.equal(want.socialLinks, undefined);
assert.ok("socialLinks" in want);
@@ -231,6 +234,7 @@ function fixtures(): Array<[string, unknown]> {
channels: [{ slug: "c1", groupId: "a", order: 3 }, { slug: "c2", groupId: "q" }],
cloudflareProject: "p",
siteUrl: "https://s.example//",
+ listed: false,
relatedSites: [{ siteIds: ["x", "x", "BAD"] }, { label: " ", siteIds: [] }],
pwa: true,
archives: false,
@@ -285,6 +289,59 @@ test("writeSite throws on no groups, a default outside the groups, and an unsafe
assert.equal(fs.existsSync(siteConfigFile(paths, "s")), false);
});
+test("listed: absent reads listed, only an explicit false unlists, and only false is written", async () => {
+ for (const v of [undefined, true, 0, "false", null]) {
+ assert.equal(parseSite("s", { listed: v }).listed, true, String(v));
+ assert.equal("listed" in siteToDisk(parseSite("s", { listed: v })), false, String(v));
+ }
+ assert.equal(parseSite("s", { listed: false }).listed, false);
+ // `true` in a caller's Site is the default, so it is not written either.
+ assert.equal("listed" in siteToDisk({ ...parseSite("s", {}), listed: true }), false);
+
+ // Through the real writer and reader: false survives, absent reads listed.
+ const paths = scratchPaths(await mkdtemp(path.join(os.tmpdir(), "site-")));
+ const hidden = parseSite("s", { siteTitle: "Hidden", siteUrl: "https://h.example", listed: false });
+ await writeSite(hidden, paths);
+ assert.equal(JSON.parse(await readFile(siteConfigFile(paths, "s"), "utf8")).listed, false);
+ assert.deepEqual(getSite("s", paths), hidden);
+ await writeSite({ ...hidden, listed: true }, paths);
+ const disk = JSON.parse(await readFile(siteConfigFile(paths, "s"), "utf8"));
+ assert.equal("listed" in disk, false);
+ assert.equal(getSite("s", paths).listed, true);
+});
+
+test("isListedSite is the key's default; channelsOnlyOnUnlistedSites keeps a shared channel with the listed site", () => {
+ assert.equal(isListedSite({}), true);
+ assert.equal(isListedSite({ listed: true }), true);
+ assert.equal(isListedSite({ listed: false }), false);
+ const sites = [
+ parseSite("shown", { channels: [{ slug: "shared" }, { slug: "mine" }] }),
+ parseSite("hidden", { listed: false, channels: [{ slug: "shared" }, { slug: "secret" }] }),
+ parseSite("hidden2", { listed: false, channels: [{ slug: "secret" }, { slug: "secret2" }] }),
+ ];
+ assert.deepEqual([...channelsOnlyOnUnlistedSites(sites)].sort(), ["secret", "secret2"]);
+ assert.deepEqual([...channelsOnlyOnUnlistedSites([sites[0]])], []);
+});
+
+test("the footer never links an unlisted sibling, and an unlisted site's own footer still lists the rest", () => {
+ const current = parseSite("cur", {
+ siteUrl: "https://cur.example",
+ relatedSites: [{ label: "Friends", siteIds: ["hidden", "shown"] }],
+ });
+ const shown = parseSite("shown", { siteTitle: "Shown", siteUrl: "https://shown.example" });
+ const hidden = parseSite("hidden", { siteTitle: "Hidden", siteUrl: "https://hidden.example", listed: false });
+ const other = parseSite("other", { siteTitle: "Other", siteUrl: "https://other.example" });
+ const ids = (groups: ReturnType<typeof resolveRelatedSites>) =>
+ groups.flatMap((g) => g.sites.map((s) => s.siteId));
+ // Named in a featured group or not, the unlisted site is not linked.
+ assert.deepEqual(ids(resolveRelatedSites(current, [current, shown, hidden, other])), ["shown", "other"]);
+ // The unlisted site is still built as before: its footer lists its siblings.
+ assert.deepEqual(
+ ids(resolveRelatedSites({ ...hidden, relatedSites: [] }, [current, shown, hidden, other])),
+ ["cur", "shown", "other"],
+ );
+});
+
test("writeSite → getSite round-trips, and the file holds only non-defaults", async () => {
const paths = scratchPaths(await mkdtemp(path.join(os.tmpdir(), "site-")));
const site = parseSite("s", { siteTitle: "Mine", archives: false });
@@ -295,4 +352,5 @@ test("writeSite → getSite round-trips, and the file holds only non-defaults",
assert.equal("duplicates" in disk, false);
assert.equal("transcriptDownloads" in disk, false);
assert.equal("pwa" in disk, false);
+ assert.equal("listed" in disk, false);
});
diff --git a/common/lib/siteSchema.ts b/common/lib/siteSchema.ts
@@ -84,6 +84,7 @@ export type Site = {
cloudflareProject?: string;
accent?: string;
siteUrl?: string;
+ listed?: boolean;
relatedSites?: RelatedSiteGroup[];
pwa?: boolean;
archives?: boolean;
@@ -116,6 +117,8 @@ export const SITE_FIELD_DOCS: FieldDocs<Site> = {
'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.",
relatedSites:
"Pulls specific siblings to the front of the footer's cross-site list, in named groups. Siblings not named here fall into a trailing \"Other sites\" group. Absent/empty = one flat list of every sibling.",
pwa:
@@ -139,6 +142,36 @@ 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.
+export function isListedSite(site: Pick<Site, "listed">): boolean {
+ return site.listed !== false;
+}
+
+// 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<Site, "listed" | "channels">[],
+): Set<string> {
+ const onListed = new Set<string>();
+ const onUnlisted = new Set<string>();
+ 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";
@@ -246,6 +279,8 @@ export const siteFieldsSchema = z.object({
).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),
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.
@@ -335,6 +370,8 @@ export function siteToDisk(site: Site): Site {
: {}),
...(accent ? { accent } : {}),
...(siteUrl ? { siteUrl } : {}),
+ // Listed is the default: only the opt-out is persisted.
+ ...(site.listed === false ? { listed: false } : {}),
...(relatedSites.length > 0 ? { relatedSites } : {}),
...(site.pwa ? { pwa: true } : {}),
// Persist only the non-default: archives is on unless explicitly disabled.