#!/usr/bin/env tsx // Composes the served `public/` dir for the Archilyzer hub (homepage package) // ahead of `next build`. Unlike compose-site.ts this is a SINGLE, pool-wide // build — there is no SITE_ID and no per-site filtering. It produces the data // files the hub's cross-site landing needs: // // public/stats/{manifest,page-NNNN}.json <- whole-pool stats (every channel) // public/channel-sites.json <- channel slug -> [siteId, ...] // public/homepage-summary.json <- small cross-site landing summary // // None of the three names an unlisted site (site.json `listed: false`) or holds // a channel only unlisted sites expose (lib/siteSchema.ts isListedSite). // // Requires build:index to have populated the cues LMDB first (the homepage // prebuild chains it), same as the export pipeline. import path from "node:path"; import { getPaths, type Paths } from "../lib/paths"; import { writeJsonAtomic as writeJsonAtomicShared } from "../lib/jsonFile-server"; import { buildPoolSummary } from "../controller/poolSummary"; import { isListedSite } from "../lib/site"; import { runIfEntryPoint } from "./_cli"; // Where the homepage Next.js app serves static assets from. Overridable for e2e // test isolation, mirroring EXPORT_PUBLIC_DIR. function homepagePublicDir(monorepoRoot: string): string { return ( process.env.HOMEPAGE_PUBLIC_DIR ?? path.join(monorepoRoot, "homepage", "public") ); } // Compact, no trailing newline — the homepage files' historical bytes. function writeJsonAtomic(filePath: string, value: unknown): Promise { return writeJsonAtomicShared(filePath, value, { indent: 0, newline: false }); } export async function main(opts: { paths?: Paths } = {}): Promise { const paths = opts.paths ?? getPaths(); const publicDir = homepagePublicDir(paths.monorepoRoot); const statsDir = path.join(publicDir, "stats"); // Whole-pool stats (every non-excluded channel) for the cross-site charts, // then the summary over them. The hub's cards read a projection of the SAME // call (compose-hub.ts, lib/hubSummary.ts) — the input gathering lives in // controller/poolSummary.ts so the two cannot drift. const { summary, channelSites, sites } = await buildPoolSummary({ paths, statsDir, }); // channel slug -> the ids of the listed content sites that expose it. Drives // `groupBy: "site"` in the hub's dashboard (see channelSites.tsx). await writeJsonAtomic( path.join(publicDir, "channel-sites.json"), channelSites, ); // Lightweight cross-site summary for the landing page (pre-binned monthly // counts, per-site totals/sparklines, recent additions). Embedded into the // SSG HTML so the front page never fetches the full stats dataset. await writeJsonAtomic( path.join(publicDir, "homepage-summary.json"), summary, ); const unlisted = sites.filter((s) => !isListedSite(s)).length; console.log( `compose-homepage: ${Object.keys(channelSites).length} channel(s) mapped across ${sites.length - unlisted} listed site(s)` + (unlisted > 0 ? ` (${unlisted} unlisted left out)` : "") + "; " + `summary covers ${summary.totals.transcripts} transcription(s) / ${summary.totals.downloads} download(s) ` + `across ${summary.sites.length} public site(s) into ${publicDir}.`, ); } runIfEntryPoint(import.meta.url, () => main());