import fs from "node:fs"; import path from "node:path"; import { parseChannelGroups, resolveDefaultGroupId } from "./channelGroups"; import { getPaths, type Paths } from "./paths"; import { TAGS_FILENAME } from "./curatedTags"; import type { SiteChannelIndex } from "./channelPriority"; import { getSettings, parseSocialLinks, type SiteSettings, type SocialLink, } from "./settings"; import { socialLinksForSave } from "./socialLinks"; import { readJsonFileSync, withJsonFileLock, writeJsonAtomic } from "./jsonFile-server"; import { isListedSite, isPrivateSite, isValidSiteId, parseSite, parseSiteUrl, sitePublishProblem, siteToDisk, type Site, } from "./siteSchema"; // A Site is a selection + presentation layer over the single global channel // pool. Branding, social links, the channel grouping layout AND which channels // are members all live here (sites//site.json), so one editor install // can power several public sites that overlap on channels without duplicating // any downloads. Operational config (transcribe bin, cookies, rate limits) // stays global in settings.json — see common/lib/settings.ts. // // The file's SHAPE — every key, its default, its coercion, its documentation — // is lib/siteSchema.ts (one-core phase 3 slice 4b), re-exported here in full so // no importer moved. What is left in this file is I/O and the resolvers. export * from "./siteSchema"; export function siteDir(paths: Paths, siteId: string): string { return path.join(paths.sitesDir, siteId); } export function siteConfigFile(paths: Paths, siteId: string): string { return path.join(siteDir(paths, siteId), "site.json"); } export function siteChartTemplatesFile(paths: Paths, siteId: string): string { return path.join(siteDir(paths, siteId), "chart-templates.json"); } // Per-site search-alias overrides. Merged over the global dictionary // (paths.globalAliasesFile) into the site's shipped /search-aliases.json at // compose time. See common/lib/aliasesStore.ts. export function siteAliasesFile(paths: Paths, siteId: string): string { return path.join(siteDir(paths, siteId), "search-aliases.json"); } // Per-site curated-tag overlay: presentation fields (label/groupLabel/colour/ // order/hidden) and site-only rules or tags, layered over the corpus // vocabulary (paths.globalTagsFile) at compose time. It carries no assignments // — those are global facts. See common/lib/curatedTagsStore.ts. export function siteTagsFile(paths: Paths, siteId: string): string { return path.join(siteDir(paths, siteId), TAGS_FILENAME); } // Staging output dirs (NOT served) where per-site aggregates are built before // composition into export/public. See common/lib/paths.ts. export function siteIndexDir(paths: Paths, siteId: string): string { return path.join(paths.exportSitesIndexDir, siteId); } export function siteSummariesDir(paths: Paths, siteId: string): string { return path.join(siteIndexDir(paths, siteId), "summaries"); } export function siteSubsDir(paths: Paths, siteId: string): string { return path.join(siteIndexDir(paths, siteId), "subs"); } export function sitePostsDir(paths: Paths, siteId: string): string { return path.join(siteIndexDir(paths, siteId), "posts"); } export function siteDigestsDir(paths: Paths, siteId: string): string { return path.join(siteIndexDir(paths, siteId), "digests"); } export function siteStatsDir(paths: Paths, siteId: string): string { return path.join(siteIndexDir(paths, siteId), "stats"); } // The hub URL this site points visitors toward: its own override, else the // family default (SiteSettings.homepageUrl). Undefined when neither is set — // and always for a PRIVATE site (`audience: "private"`), which belongs under no // hub: a hub tells its members by the hubUrl they publish. export function resolveHubUrl( site: Site, settings: SiteSettings = getSettings(), ): string | undefined { if (isPrivateSite(site)) return undefined; return site.hubUrl ?? parseSiteUrl(settings.homepageUrl); } // The set of channel slugs a site exposes. Used to scope the editor's // site-specific views (dashboard, channels) down to one site's membership. export function siteChannelSlugs(site: Site): Set { return new Set(site.channels.map((c) => c.slug)); } // siteId -> that site's channel slugs: the one shape `resolveFocusSlugs` // (lib/channelPriority.ts) resolves a `{kind:"site"}` focus against. // // ONE SPELLING, four callers. It was inlined four times — the runner's // `priorityContextFor`, the sync tick, the /channels writer and the status // payload — and four copies of "read every site.json and map it" is four // places for a focus to resolve against a different set. Read at the moment // the focus is resolved, never stored: a site focus tracks the site's // membership rather than freezing a list, which is the whole reason // `focus.kind === "site"` exists. // // THE CALLER DECIDES WHETHER TO CALL IT AT ALL. `{kind:"channels"}` and // `{kind:"none"}` resolve from the document alone, so every caller gates this // on `focus.kind === "site"` and a corpus with no site focus never reads the // sites directory. export function siteChannelIndex(paths: Paths = getPaths()): SiteChannelIndex { const index: Record = {}; for (const site of listSites(paths)) { index[site.siteId] = [...siteChannelSlugs(site)]; } return index; } // The effective social links for a site: its own override when present, else // the global default. Pass `settings` to avoid a redundant read; defaults to // getSettings() for callers that don't already have it. export function resolveSocialLinks( site: Site, settings: SiteSettings = getSettings(), ): SocialLink[] { return site.socialLinks ?? settings.socialLinks; } // A single resolved cross-site link and a named bucket of them. 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, // 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 // were no override groups (a plain flat list), else labeled "Other sites". export function resolveRelatedSites( current: Site, all: Site[] = listSites(), ): CrossSiteGroup[] { const byId = new Map(); for (const s of all) { 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(); const groups: CrossSiteGroup[] = []; for (const g of current.relatedSites ?? []) { const sites: CrossSiteLink[] = []; for (const id of g.siteIds) { const link = byId.get(id); if (!link || used.has(id)) continue; used.add(id); sites.push(link); } if (sites.length > 0) groups.push({ label: g.label, sites }); } const rest = [...byId.values()].filter((l) => !used.has(l.siteId)); if (rest.length > 0) { groups.push({ label: groups.length > 0 ? "Other sites" : undefined, sites: rest }); } return groups; } export function getSite(siteId: string, paths: Paths = getPaths()): Site { if (!isValidSiteId(siteId)) { throw new Error(`Invalid site id: ${String(siteId)}`); } // Missing, unreadable or not JSON reads as the empty file: every field its // default. A read never throws past the id check. const read = readJsonFileSync(siteConfigFile(paths, siteId)); return parseSite(siteId, read.ok ? read.value : {}); } // List the ids of all configured sites (directories under sitesDir that hold a // site.json), sorted for stable iteration. export function listSiteIds(paths: Paths = getPaths()): string[] { let entries: fs.Dirent[]; try { entries = fs.readdirSync(paths.sitesDir, { withFileTypes: true }); } catch { return []; } const ids: string[] = []; for (const e of entries) { if (!e.isDirectory()) continue; if (!isValidSiteId(e.name)) continue; if (!fs.existsSync(siteConfigFile(paths, e.name))) continue; ids.push(e.name); } return ids.sort((a, b) => a.localeCompare(b)); } export function listSites(paths: Paths = getPaths()): Site[] { return listSiteIds(paths).map((id) => getSite(id, paths)); } // The site selectors and the export dev server fall back to this when no site // is explicitly chosen: the lone site if there's exactly one, else null. export function defaultSiteId(paths: Paths = getPaths()): string | null { const ids = listSiteIds(paths); return ids.length === 1 ? ids[0] : null; } // The social links a JSON file on disk holds now (shape-checked only), or none. function storedSocialLinks(file: string): SocialLink[] { try { const raw = JSON.parse(fs.readFileSync(file, "utf8")) as { socialLinks?: unknown }; return parseSocialLinks(raw?.socialLinks); } catch { return []; } } export async function writeSite( site: Site, paths: Paths = getPaths(), ): Promise { if (!isValidSiteId(site.siteId)) { throw new Error(`Invalid site id: ${String(site.siteId)}`); } const groups = parseChannelGroups(site.groups); if (groups.length === 0) { throw new Error("At least one channel group is required"); } const defaultGroupId = resolveDefaultGroupId(site.defaultGroupId, groups); if (defaultGroupId !== site.defaultGroupId) { throw new Error( `Default group "${String(site.defaultGroupId)}" is not in the configured groups`, ); } // A public site whose publish policy deploys needs a Pages project (release // 18). A private one is clamped to "build" by siteToDisk, not refused. const publishProblem = sitePublishProblem(site); if (publishProblem) throw new Error(publishProblem); // undefined socialLinks = inherit the global default; only validate/persist a // key when the site explicitly overrides (an array, even empty). // A link whose SVG is unchanged from the file on disk is kept as it is; a new // or edited one is checked (lib/socialLinks.ts socialLinksForSave). let socialLinks: SocialLink[] | undefined; if (site.socialLinks !== undefined) { const r = socialLinksForSave( parseSocialLinks(site.socialLinks), storedSocialLinks(siteConfigFile(paths, site.siteId)), ); if ("refused" in r) { throw new Error(`Social link "${r.refused.label}" has an invalid SVG: ${r.refused.problem}`); } socialLinks = r.links; } await writeJsonAtomic( siteConfigFile(paths, site.siteId), siteToDisk({ ...site, groups, defaultGroupId, socialLinks }), { mkdir: true }, ); } // PATCH SOME KEYS OF A SITE — the writer for a surface that owns a few keys and // none of the others (the Reports tab's `reports`). The site is read from disk // INSIDE the file lock and the patch is applied to that, so a patch never // writes back a copy of the other keys read earlier, and two patches never // interleave. A function patch is given the site as it is now (to reorder a // list it did not read itself). Validated and written by writeSite. Null when // the site has no site.json: a patch never creates a site. export async function patchSite( siteId: string, patch: | Partial> | ((current: Site) => Partial>), paths: Paths = getPaths(), ): Promise { if (!isValidSiteId(siteId)) { throw new Error(`Invalid site id: ${String(siteId)}`); } const file = siteConfigFile(paths, siteId); return withJsonFileLock(file, async () => { if (!fs.existsSync(file)) return null; const current = getSite(siteId, paths); const changes = typeof patch === "function" ? patch(current) : patch; await writeSite({ ...current, ...changes, siteId }, paths); return getSite(siteId, paths); }); } export async function deleteSite( siteId: string, paths: Paths = getPaths(), ): Promise { if (!isValidSiteId(siteId)) { throw new Error(`Invalid site id: ${String(siteId)}`); } await fs.promises.rm(siteDir(paths, siteId), { recursive: true, force: true }); }