import fs from "node:fs"; import { writeJsonAtomic } from "./jsonFile-server"; import { getPaths, type Paths } from "./paths"; import { PROJECT_NAME, PROJECT_TAGLINE } from "./project"; import { getSettings, parseSocialLinks, type SiteSettings, type SocialLink, } from "./settings"; import { socialLinksForSave } from "./socialLinks"; // The Archilyzer hub/homepage is a SINGLE, instance-level landing site (the // `homepage` SSG package) that sits above the per-content sites. Unlike a Site, // it has no channel membership and no grouping layout — it's just branding, // social links, and an optional cross-site chart dashboard (chart-templates.json, // same shape sites use); the home route renders a cross-site stats landing built // from the whole channel pool. Config lives at homepageConfigFile // (sites/_homepage/homepage.json); operational config stays global in // settings.json. export type HomepageConfig = { siteTitle: string; siteDescription: string; headerTitle: string; homeTagline: string; // Per-hub social links. `undefined` = inherit the global default from // SiteSettings.socialLinks; an array (even empty) overrides it. Same semantics // as Site.socialLinks — resolve with resolveHomepageSocialLinks() at render. socialLinks?: SocialLink[]; // Absolute public URL of the deployed hub, e.g. "https://archilyzer-hub.pages.dev". siteUrl?: string; // Cloudflare Pages project the hub deploys to (e.g. "archilyzer-hub"; never // "archilyzer", the homepage's — the hub's deploy refuses it, hubProjectProblem). cloudflareProject?: string; // The transcript modal's per-video export controls (Download menu, Copy MD; // the yt-dlp clip command shows either way) on the hub's Browse and Ask pages — the same switch // as site.json `transcriptDownloads`. Opt-OUT: absent = on; only an explicit // false is read, and only false is written. transcriptDownloads?: boolean; }; // Neutral defaults for an unconfigured install. These are the PRODUCT strings // (common/lib/project.ts) standing in until the operator names their own // deployment in the editor — which is why they're pulled from the constant // rather than spelled out here twice. function defaults(): HomepageConfig { return { siteTitle: PROJECT_NAME, siteDescription: PROJECT_TAGLINE, headerTitle: PROJECT_NAME, homeTagline: "", }; } // Normalize a raw siteUrl into a trimmed absolute http(s) URL with no trailing // slash, or undefined. Mirrors parseSiteUrl in site.ts. export function parseHomepageUrl(input: unknown): string | undefined { if (typeof input !== "string") return undefined; const trimmed = input.trim().replace(/\/+$/, ""); if (!/^https?:\/\/\S+/i.test(trimmed)) return undefined; return trimmed; } export function parseHomepageConfig(raw: unknown): HomepageConfig { const base = defaults(); const r = raw && typeof raw === "object" ? (raw as Record) : {}; const str = (key: keyof HomepageConfig, fallback: string): string => typeof r[key] === "string" ? (r[key] as string) : fallback; return { siteTitle: str("siteTitle", base.siteTitle), siteDescription: str("siteDescription", base.siteDescription), headerTitle: str("headerTitle", base.headerTitle), homeTagline: str("homeTagline", base.homeTagline), socialLinks: Array.isArray(r.socialLinks) ? parseSocialLinks(r.socialLinks) : undefined, siteUrl: parseHomepageUrl(r.siteUrl), cloudflareProject: typeof r.cloudflareProject === "string" && r.cloudflareProject.trim() ? r.cloudflareProject.trim() : undefined, transcriptDownloads: r.transcriptDownloads === false ? false : undefined, }; } export function getHomepageConfig(paths: Paths = getPaths()): HomepageConfig { let parsed: unknown = {}; try { parsed = JSON.parse(fs.readFileSync(paths.homepageConfigFile, "utf8")); } catch { parsed = {}; } return parseHomepageConfig(parsed); } // The effective social links for the hub: its own override when present, else // the global default. Mirrors resolveSocialLinks for Sites. export function resolveHomepageSocialLinks( config: HomepageConfig, settings: SiteSettings = getSettings(), ): SocialLink[] { return config.socialLinks ?? settings.socialLinks; } export async function writeHomepageConfig( config: HomepageConfig, paths: Paths = getPaths(), ): Promise { // A link whose SVG is unchanged from homepage.json on disk is kept as it is; // a new or edited one is checked (lib/socialLinks.ts socialLinksForSave). let socialLinks: SocialLink[] | undefined; if (config.socialLinks !== undefined) { const r = socialLinksForSave( parseSocialLinks(config.socialLinks), getHomepageConfig(paths).socialLinks, ); if ("refused" in r) { throw new Error(`Social link "${r.refused.label}" has an invalid SVG: ${r.refused.problem}`); } socialLinks = r.links; } const merged: HomepageConfig = { siteTitle: config.siteTitle, siteDescription: config.siteDescription, headerTitle: config.headerTitle, homeTagline: config.homeTagline, ...(socialLinks !== undefined ? { socialLinks } : {}), ...(parseHomepageUrl(config.siteUrl) ? { siteUrl: parseHomepageUrl(config.siteUrl) } : {}), ...(config.cloudflareProject && config.cloudflareProject.trim() ? { cloudflareProject: config.cloudflareProject.trim() } : {}), ...(config.transcriptDownloads === false ? { transcriptDownloads: false } : {}), }; // mkdir: the parent of homepageConfigFile IS homepageDir (lib/paths.ts). await writeJsonAtomic(paths.homepageConfigFile, merged, { mkdir: true }); }