import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdtemp, readFile } from "node:fs/promises"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { z } from "zod"; import { SITE_FIELD_DOCS, SITE_KEYS, channelsOnlyOnUnlistedSites, clampSitePublishPolicy, isCitedSite, isListedSite, parseSite, parseSiteReports, siteFieldsSchema, sitePublishPolicy, sitePublishProblem, siteToDisk, type Site, } from "./siteSchema"; import { getSite, patchSite, resolveRelatedSites, siteConfigFile, writeSite } from "./site"; import type { Paths } from "./paths"; const HERE = path.dirname(fileURLToPath(import.meta.url)); // The schema's per-key output and the hand-written `Site` name the same keys, // and the output is assignable to `Site` (defaultGroupId aside: it is resolved // 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; type SameKeys = [keyof A] extends [keyof B] ? [keyof B] extends [keyof A] ? true : false : false; const keysMatch: SameKeys = true; const outputFits: Omit extends Omit ? true : false = true; const SVG = ''; 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.shape), Object.keys(SITE_FIELD_DOCS), ); assert.deepEqual([...SITE_KEYS], Object.keys(SITE_FIELD_DOCS)); }); test("empty, null, [] and a number all read as the defaults, every key emitted", () => { const want = parseSite("s", {}); assert.deepEqual(Object.keys(want), Object.keys(SITE_FIELD_DOCS)); assert.equal(want.siteId, "s"); assert.equal(want.siteTitle, "Transcript Browser"); assert.equal(want.groups.length, 1); assert.equal(want.groups[0].id, "default"); assert.equal(want.defaultGroupId, "default"); assert.equal(want.archives, true); 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.search, true); assert.deepEqual(want.reports, []); assert.equal(want.socialLinks, undefined); assert.ok("socialLinks" in want); for (const raw of [null, [], 3, "x", undefined]) { assert.deepEqual(parseSite("s", raw), want, JSON.stringify(raw)); } }); test("the siteId comes from the caller, never the file", () => { assert.equal(parseSite("real", { siteId: "other" }).siteId, "real"); }); test("unknown keys are dropped", () => { const site = parseSite("s", { bogus: 1, siteTitle: "T" }) as Record; assert.equal("bogus" in site, false); assert.equal(site.siteTitle, "T"); }); test("a membership naming an unknown group loses its groupId; a known one keeps it", () => { const site = parseSite("s", { groups: [ { id: "a", name: "A", selectedByDefault: true }, { id: "b", name: "B" }, ], defaultGroupId: "zzz", channels: [ { slug: "one", groupId: "b" }, { slug: "two", groupId: "nope", order: 2.7 }, { slug: "one" }, { slug: " " }, ], }); assert.equal(site.defaultGroupId, "a"); assert.deepEqual(site.channels, [ { slug: "one", groupId: "b" }, { slug: "two", order: 2 }, ]); }); test("socialLinks: absent inherits (undefined), [] overrides", () => { assert.equal(parseSite("s", {}).socialLinks, undefined); assert.deepEqual(parseSite("s", { socialLinks: [] }).socialLinks, []); assert.equal(parseSite("s", { socialLinks: "x" }).socialLinks, undefined); }); test("archives / duplicates / transcriptDownloads are off only when explicitly false", () => { for (const v of [undefined, true, 0, "false", null]) { assert.equal(parseSite("s", { archives: v }).archives, true, String(v)); assert.equal(parseSite("s", { duplicates: v }).duplicates, true, String(v)); assert.equal( parseSite("s", { transcriptDownloads: v }).transcriptDownloads, true, String(v), ); } assert.equal(parseSite("s", { archives: false }).archives, false); assert.equal(parseSite("s", { duplicates: false }).duplicates, false); assert.equal(parseSite("s", { transcriptDownloads: false }).transcriptDownloads, false); }); test("siteToDisk persists only the non-defaults", () => { const disk = siteToDisk(parseSite("s", {})) as Record; assert.deepEqual(Object.keys(disk), [ "siteId", "siteTitle", "siteDescription", "headerTitle", "homeTagline", "groups", "defaultGroupId", "channels", ]); const full = siteToDisk( parseSite("s", { archives: false, duplicates: false, transcriptDownloads: false, pwa: true, archiveMaxBytes: 1024.9, siteUrl: "https://a.example/", hubUrl: "http://hub.example", accent: "#ABCDEF", cloudflareProject: " proj ", relatedSites: [{ label: "x", siteIds: ["b"] }], socialLinks: [], }), ); assert.equal(full.archives, false); assert.equal(full.duplicates, false); assert.equal(full.transcriptDownloads, false); assert.equal(full.pwa, true); assert.equal(full.archiveMaxBytes, 1024); assert.equal(full.siteUrl, "https://a.example"); assert.equal(full.accent, "#abcdef"); assert.equal(full.cloudflareProject, "proj"); assert.deepEqual(full.socialLinks, []); }); test("accent: a named id or a custom hex is kept; anything else is dropped", () => { assert.equal(parseSite("s", { accent: "brass" }).accent, "brass"); assert.equal(parseSite("s", { accent: " Sakura " }).accent, "sakura"); assert.equal(parseSite("s", { accent: "#CC3366" }).accent, "#cc3366"); for (const v of ["gold", "custom", "#abc", 7, null]) { assert.equal(parseSite("s", { accent: v }).accent, undefined, String(v)); } const disk = siteToDisk(parseSite("s", { accent: "Violet" })) as Record; assert.equal(disk.accent, "violet"); assert.equal("accent" in siteToDisk(parseSite("s", { accent: "gold" })), false); }); test("wordmarkLead: kept only as a proper prefix of headerTitle, and written after it", () => { const site = parseSite("s", { headerTitle: "Jeralyzer", wordmarkLead: " Jer " }); assert.equal(site.wordmarkLead, "Jer"); // The key sits right after headerTitle, in the read AND on disk. const keys = Object.keys(site); assert.equal(keys[keys.indexOf("headerTitle") + 1], "wordmarkLead"); const disk = siteToDisk(site) as Record; const diskKeys = Object.keys(disk); assert.equal(disk.wordmarkLead, "Jer"); assert.equal(diskKeys[diskKeys.indexOf("headerTitle") + 1], "wordmarkLead"); // Not a prefix, wrong case, the whole title, longer, blank, not a string: // dropped on read, and never written. for (const lead of ["Hasan", "jer", "Jeralyzer", "Jeralyzers", "", " ", 3]) { const s2 = parseSite("s", { headerTitle: "Jeralyzer", wordmarkLead: lead }); assert.equal(s2.wordmarkLead, undefined, JSON.stringify(lead)); assert.ok("wordmarkLead" in s2, "every key is emitted"); assert.equal("wordmarkLead" in siteToDisk(s2), false, JSON.stringify(lead)); } // siteToDisk re-checks it: a caller's non-prefix lead is not persisted. const edited = { ...site, headerTitle: "Hasanalyzer" }; assert.equal("wordmarkLead" in siteToDisk(edited), false); }); test("writeSite → getSite round-trips a named accent and a wordmark lead", async () => { const paths = scratchPaths(await mkdtemp(path.join(os.tmpdir(), "site-"))); const site = parseSite("s", { siteTitle: "Rekietalyzer", headerTitle: "Rekietalyzer", wordmarkLead: "Rekieta", accent: "vermilion", }); await writeSite(site, paths); assert.deepEqual(getSite("s", paths), site); const disk = JSON.parse(await readFile(siteConfigFile(paths, "s"), "utf8")); assert.equal(disk.accent, "vermilion"); assert.equal(disk.wordmarkLead, "Rekieta"); }); function fixtures(): Array<[string, unknown]> { const out: Array<[string, unknown]> = [ ["empty", {}], [ "everything", { siteId: "s", siteTitle: "T", siteDescription: "D", headerTitle: "H", homeTagline: "tag", accent: "#123456", socialLinks: [{ label: "L", url: "https://x.example", svg: SVG }], groups: [ { id: "a", name: " A ", selectedByDefault: true, order: 1.5, inline: true }, { id: "b", name: "B", description: " d " }, { id: "a", name: "dup" }, { id: "BAD", name: "x" }, ], defaultGroupId: "b", channels: [{ slug: "c1", groupId: "a", order: 3 }, { slug: "c2", groupId: "q" }], cloudflareProject: "p", siteUrl: "https://s.example//", listed: false, search: false, reports: ["r-one", "r-one", "Bad", "", 7, "r-two"], relatedSites: [{ siteIds: ["x", "x", "BAD"] }, { label: " ", siteIds: [] }], pwa: true, archives: false, archiveMaxBytes: -1, duplicates: false, transcriptDownloads: false, hubUrl: "ftp://nope", bogus: true, }, ], ]; out.push([ "named accent + wordmark lead", { headerTitle: "Jeralyzer", wordmarkLead: "Jer", accent: "Brass" }, ]); out.push([ "non-prefix lead + unknown accent", { headerTitle: "Jeralyzer", wordmarkLead: "Hasan", accent: "gold" }, ]); const e2e = path.join(HERE, "..", "..", "editor", "e2e", "fixtures", "sites", "testsite", "site.json"); out.push(["e2e testsite", JSON.parse(fs.readFileSync(e2e, "utf8"))]); return out; } test("round-trip: parseSite(siteToDisk(parseSite(x))) = parseSite(x)", () => { for (const [name, raw] of fixtures()) { const once = parseSite("s", raw); const twice = parseSite("s", JSON.parse(JSON.stringify(siteToDisk(once)))); assert.deepEqual(twice, once, name); } }); function scratchPaths(dir: string): Paths { return { sitesDir: dir } as Paths; } test("writeSite throws on no groups, a default outside the groups, and an unsafe SVG", async () => { const paths = scratchPaths(await mkdtemp(path.join(os.tmpdir(), "site-"))); const base = parseSite("s", {}); await assert.rejects(writeSite({ ...base, groups: [] }, paths), /At least one channel group/); await assert.rejects( writeSite({ ...base, defaultGroupId: "elsewhere" }, paths), /not in the configured groups/, ); await assert.rejects( writeSite( { ...base, socialLinks: [{ label: "L", url: "https://x.example", svg: "" }] }, paths, ), /invalid SVG/, ); 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("search: absent/true is on, only false is report-only, and only false is written", () => { for (const v of [undefined, true, "false", 0, null, "off"]) { const site = parseSite("s", v === undefined ? {} : { search: v }); assert.equal(site.search, true, String(v)); assert.equal(isCitedSite(site), false, String(v)); assert.equal("search" in siteToDisk(site), false, String(v)); } const off = parseSite("s", { search: false }); assert.equal(off.search, false); assert.equal(isCitedSite(off), true); assert.equal(siteToDisk(off).search, false); assert.equal(isCitedSite({}), false); // A search-off site with no reports is still report-only (an empty index). assert.equal(isCitedSite(parseSite("s", { search: false, reports: [] })), true); }); test("legacy publish: \"cited\" reads as search off and is rewritten as search: false", () => { const legacy = parseSite("s", { publish: "cited" }); assert.equal(legacy.search, false); assert.equal(isCitedSite(legacy), true); // `publish` is a key again since release 18 (the lane's policy), and the // legacy string is not one: it reads as absent and is never written back. assert.equal(legacy.publish, undefined); const disk = siteToDisk(legacy); assert.equal(disk.search, false); assert.equal("publish" in disk, false); // Any other publish value is nothing; an explicit search wins over it. for (const v of ["full", "Cited", true, 1]) { assert.equal(parseSite("s", { publish: v }).search, true, String(v)); } assert.equal(parseSite("s", { publish: "cited", search: true }).search, true); }); test("reports: ordered slugs, invalid and repeated ids dropped, written only when non-empty", () => { assert.deepEqual( parseSiteReports(["b-two", "a-one", "b-two", "Upper", "-lead", "has space", "", 3, null, "c3"]), ["b-two", "a-one", "c3"], ); for (const v of [undefined, null, "a-one", { a: 1 }, 5]) { assert.deepEqual(parseSite("s", v === undefined ? {} : { reports: v }).reports, [], String(v)); } const site = parseSite("s", { reports: ["z-last", "a-first", "a-first"] }); assert.deepEqual(site.reports, ["z-last", "a-first"]); assert.deepEqual(siteToDisk(site).reports, ["z-last", "a-first"]); assert.equal("reports" in siteToDisk(parseSite("s", { reports: [] })), false); assert.equal("reports" in siteToDisk(parseSite("s", { reports: ["BAD"] })), false); // siteToDisk re-parses a caller's list: a bad id handed to the writer is not persisted. assert.deepEqual(siteToDisk({ ...site, reports: ["ok", "NOPE", "ok"] }).reports, ["ok"]); }); test("writeSite → getSite round-trips a report-only site and its reports; a full site writes neither key", async () => { const paths = scratchPaths(await mkdtemp(path.join(os.tmpdir(), "site-"))); const cited = parseSite("s", { search: false, reports: ["first", "second"] }); await writeSite(cited, paths); assert.deepEqual(getSite("s", paths), cited); const disk = JSON.parse(await readFile(siteConfigFile(paths, "s"), "utf8")); assert.equal(disk.search, false); assert.equal("publish" in disk, false); assert.deepEqual(disk.reports, ["first", "second"]); await writeSite({ ...cited, search: true, reports: [] }, paths); const plain = JSON.parse(await readFile(siteConfigFile(paths, "s"), "utf8")); assert.equal("search" in plain, false); assert.equal("reports" in plain, false); assert.equal(getSite("s", paths).search, true); assert.deepEqual(getSite("s", paths).reports, []); }); 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("a report-only site is never listed, whatever `listed` says; a full site with reports is listed as before", () => { assert.equal(isListedSite({ search: false }), false); assert.equal(isListedSite({ search: false, listed: true }), false); assert.equal(isListedSite({ search: true }), true); assert.equal(isListedSite(parseSite("full-with-reports", { reports: ["demo-report"] })), true); const sites = [ parseSite("shown", { channels: [{ slug: "shared" }] }), parseSite("reports", { search: false, channels: [{ slug: "shared" }, { slug: "pool-only" }] }), ]; // A cited site's channels are its citations' pool, not a published archive. assert.deepEqual([...channelsOnlyOnUnlistedSites(sites)], ["pool-only"]); }); 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) => 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 }); await writeSite(site, paths); assert.deepEqual(getSite("s", paths), site); const disk = JSON.parse(await readFile(siteConfigFile(paths, "s"), "utf8")); assert.equal(disk.archives, false); assert.equal("duplicates" in disk, false); assert.equal("transcriptDownloads" in disk, false); assert.equal("pwa" in disk, false); assert.equal("listed" in disk, false); assert.equal("search" in disk, false); assert.equal("publish" in disk, false); assert.equal("reports" in disk, false); }); test("patchSite applies a patch to the site on disk now, keeps every other key, and never creates a site", async () => { const paths = scratchPaths(await mkdtemp(path.join(os.tmpdir(), "site-"))); assert.equal(await patchSite("s", { reports: ["one"] }, paths), null); assert.equal(fs.existsSync(siteConfigFile(paths, "s")), false); const site = parseSite("s", { siteTitle: "Mine", archives: false, search: false, reports: ["one", "two"], }); await writeSite(site, paths); // Another writer changes a key after this caller last read the site: the // patch is applied to the file, not to the stale copy. await writeSite({ ...site, siteTitle: "Renamed" }, paths); const patched = await patchSite("s", { reports: ["two", "one"] }, paths); assert.deepEqual(patched, { ...site, siteTitle: "Renamed", reports: ["two", "one"] }); assert.deepEqual(getSite("s", paths), patched); // A function patch sees the site as it is; two patches do not interleave. await Promise.all([ patchSite("s", (cur) => ({ reports: [...(cur.reports ?? []), "three"] }), paths), patchSite("s", (cur) => ({ reports: [...(cur.reports ?? []), "four"] }), paths), ]); assert.deepEqual(getSite("s", paths).reports, ["two", "one", "three", "four"]); // The id is the directory's, whatever a patch says. await patchSite("s", { siteId: "other" } as Partial, paths); assert.equal(getSite("s", paths).siteId, "s"); assert.equal(fs.existsSync(siteConfigFile(paths, "other")), false); }); // RELEASE 18: the per-site publish policy. test("publish.auto: off by default, the four values read, anything else is off", () => { assert.equal(parseSite("s", {}).publish, undefined); assert.equal(sitePublishPolicy(parseSite("s", {})), "off"); const withProject = { cloudflareProject: "proj" }; for (const auto of ["build", "preview", "production"] as const) { assert.deepEqual(parseSite("s", { ...withProject, publish: { auto } }).publish, { auto }); } for (const bad of [{ auto: "off" }, { auto: "always" }, { auto: 3 }, "production", [], null, {}]) { assert.equal(parseSite("s", { ...withProject, publish: bad }).publish, undefined, JSON.stringify(bad)); } // The legacy report-only switch is a string: it is not a policy. const legacy = parseSite("s", { publish: "cited" }); assert.equal(legacy.publish, undefined); assert.equal(legacy.search, false); }); test("publish.auto: a private site at most builds; no Pages project, no deploy policy", () => { const priv = parseSite("s", { audience: "private", cloudflareProject: "p", publish: { auto: "production" } }); assert.deepEqual(priv.publish, { auto: "build" }); const noProject = parseSite("s", { publish: { auto: "preview" } }); assert.deepEqual(noProject.publish, { auto: "build" }); assert.deepEqual(parseSite("s", { publish: { auto: "build" } }).publish, { auto: "build" }); assert.equal(clampSitePublishPolicy({ audience: "private" }, "preview"), "build"); assert.equal(clampSitePublishPolicy({ cloudflareProject: " " }, "production"), "build"); assert.equal(clampSitePublishPolicy({ cloudflareProject: "p" }, "production"), "production"); assert.equal(clampSitePublishPolicy({}, "off"), "off"); }); test("publish.auto: only a policy other than off is written, clamped", () => { const base = parseSite("s", { cloudflareProject: "p" }); assert.equal("publish" in siteToDisk(base), false); assert.equal("publish" in siteToDisk({ ...base, publish: { auto: "off" } }), false); assert.deepEqual(siteToDisk({ ...base, publish: { auto: "preview" } }).publish, { auto: "preview" }); assert.deepEqual( siteToDisk({ ...base, audience: "private", publish: { auto: "production" } }).publish, { auto: "build" }, ); }); test("writeSite refuses a public deploy policy with no cloudflareProject, naming the key", async () => { const paths = scratchPaths(await mkdtemp(path.join(os.tmpdir(), "site-"))); const base = parseSite("s", {}); await assert.rejects(writeSite({ ...base, publish: { auto: "production" } }, paths), /cloudflareProject/); assert.equal(sitePublishProblem({ publish: { auto: "preview" } })?.includes("publish.auto"), true); assert.equal(sitePublishProblem({ publish: { auto: "build" } }), null); assert.equal(sitePublishProblem({ audience: "private", publish: { auto: "production" } }), null); assert.equal(fs.existsSync(siteConfigFile(paths, "s")), false); // A private site is clamped, not refused; a public one with a project saves. await writeSite({ ...base, audience: "private", publish: { auto: "production" } }, paths); assert.deepEqual(getSite("s", paths).publish, { auto: "build" }); await writeSite({ ...base, cloudflareProject: "proj", publish: { auto: "production" } }, paths); assert.deepEqual(getSite("s", paths).publish, { auto: "production" }); });