// What is actually sitting in the built export directory. // // WHY THIS EXISTS. The basic (host) build composes into ONE shared directory, // `export/out`, whichever site it built — `resolveOutDir` ignores the site id on // purpose. A deploy-only action (the Publish tab's "Deploy static export", the // ops `deploy-site` route) therefore ships whatever was built LAST, and nothing // checked that it was built for the site being deployed. Building jeralyzer and // then deploying anilyzer put jeralyzer's bundle on anilyzer's Pages project — // in production, silently, with a green log. // // The bundle knows who it is: `compose-site.ts` writes the federation contract // `site.json` into the public dir, carrying `siteId`, and `next build` copies // public/ into out/. So the check is a file read, and it is cheap enough to do // before every deploy. import { existsSync, readdirSync, readFileSync, statSync, type Dirent } from "node:fs"; import path from "node:path"; import { isCitedSite } from "./siteSchema"; import { REPORT_HISTORY_REPO_FILE_RE } from "./report/revisions"; import { MOMENTS_INDEX_PATH, REPORTS_INDEX_PATH, REPORT_EXPORT_FILENAMES, REPORT_FEED_FILENAMES, momentViewPath, reportCitationsDownloadPath, reportViewPath, } from "./report/views"; /** * The site id of the build sitting in `outDir`, or null when there is no * readable build there (no directory, no site.json, unparseable, or a site.json * with no `siteId`). Every one of those means the same thing to a caller — * "nothing deployable was built here" — so they are one answer, not four. */ export function builtSiteIdIn(outDir: string): string | null { let raw: string; try { raw = readFileSync(path.join(outDir, "site.json"), "utf8"); } catch { return null; } try { const parsed: unknown = JSON.parse(raw); if (typeof parsed !== "object" || parsed === null) return null; const id = (parsed as { siteId?: unknown }).siteId; return typeof id === "string" && id.trim() ? id.trim() : null; } catch { return null; } } /** * Why `outDir` may not be deployed as `siteId`, as one sentence — or null when * it holds that site's build. * * Refuses rather than rebuilding, because a deploy-only action is the operator * saying "ship what is there"; quietly building something else would be a much * larger surprise than a refusal naming the fix. * * It refuses exactly what builtBundleProblem refuses — the check the deploy * itself makes before wrangler (publish/deployStage.ts, runDeployStage) — in the * operator's words, so an action's fast answer and the deploy's last word * never disagree about a bundle. */ export function builtSiteProblem(outDir: string, siteId: string): string | null { const built = builtSiteIdIn(outDir); const asked = siteId.trim(); if (built === null) { return `export/out holds no built site — build ${asked} first`; } if (built !== asked) { return `export/out holds a build of "${built}", not "${asked}" — build ${asked} first`; } if (corpusSiteIdIn(outDir) !== asked) { return `export/out holds an incomplete build of "${asked}" (its corpus.json does not name it) — build ${asked} first`; } return citedBuildProblem(outDir) ?? reportHistoryProblem(outDir); } /** * Why the bundle in a per-site container's `outDir` is not `siteId`'s own, as * one sentence naming the directory — or null when it is. * * Stricter than builtSiteProblem: both identity files compose writes must be * there and both must name the site — `site.json`'s `siteId` and * `corpus.json`'s `site.id`. A container build once published the public/ * baked into its image instead of the one compose had just written, so its * out/ carried whatever the image's build context held: no data at all, or a * DIFFERENT site's. docker/build-site.sh refuses to hand back such an out/, * and the container deploy phase refuses to ship one (publish/build.ts). */ export function builtBundleProblem(outDir: string, siteId: string): string | null { const asked = siteId.trim(); const built = builtSiteIdIn(outDir); if (built === null) { return `${outDir} has no site.json naming a site — it is not a build of "${asked}"`; } if (built !== asked) { return `${outDir} holds a build of "${built}", not "${asked}" (site.json)`; } const described = corpusSiteIdIn(outDir); if (described === null) { return `${outDir} has no corpus.json naming a site — it is not a complete build of "${asked}"`; } if (described !== asked) { return `${outDir} describes "${described}", not "${asked}" (corpus.json)`; } return citedBuildProblem(outDir) ?? reportHistoryProblem(outDir); } /** * Why `site` may not be deployed because of who it is built for, as one * sentence — or null when it may (release 17 slice XP). * * A PRIVATE site (`site.json` `audience: "private"`) is the operator's own * reading copy: it may carry what the public may not (X posts while * `social.x.visibility` is "private"), so no deploy path ships it. Every * deploy path asks this BEFORE ANY UPLOAD — the R2 archive push included — in * the place it asks builtBundleProblem: the deploy stage (publish/deployStage.ts) * and its request checks, which every deploy surface goes through. A build * without a deploy is untouched. */ export function siteDeployProblem(site: { siteId: string; audience?: string; }): string | null { if (site.audience !== "private") return null; return ( `Site "${site.siteId}" is private (audience: private): it is built for reading ` + `on this machine and is never deployed. Build it without deploying, or set its ` + `audience to public on its Settings tab` ); } /** * Why the bundle in `outDir` may not be deployed because it was built PRIVATE * — its corpus.json says `"audience": "private"` (compose-site writes it for a * private site) — as one sentence, or null. Asked beside siteDeployProblem, so * a site switched to public after a private build still cannot ship that * build: it is rebuilt first. */ export function builtAudienceProblem(outDir: string): string | null { try { const parsed: unknown = JSON.parse(readFileSync(path.join(outDir, "corpus.json"), "utf8")); const site = (parsed as { site?: { audience?: unknown; id?: unknown } } | null)?.site; if (site?.audience !== "private") return null; const id = typeof site.id === "string" ? ` of "${site.id}"` : ""; return ( `${outDir} holds a private build${id} (its corpus.json says "audience": "private"), ` + `which is never deployed` ); } catch { return null; } } /** * Both audience refusals, the site's first: the one sentence a deploy path * logs or throws, or null. */ export function deployAudienceProblem( site: { siteId: string; audience?: string; search?: boolean }, outDir: string, ): string | null { return siteDeployProblem(site) ?? builtAudienceProblem(outDir) ?? builtScopeProblem(site, outDir); } // ─── A CITED build (a report-only site: site.json `search: false`) ─── // // A cited site publishes its reports and the moments they cite, and nothing // else (plans/report-sites.md). export/public is shared by every site's build // in turn, so compose prunes everything corpus-shaped before it writes a cited // site — and this audit is the second line: after `next build`, a cited out/ // may hold only what the list below names. Anything else (a stale summaries // tree, a transcripts shard, an archive, a service worker) refuses the build // and every deploy of it: builtSiteProblem and builtBundleProblem ask it, so // the deploy stage (publish/deployStage.ts), its request checks and // docker/build-site.sh all refuse what it refuses, and // `archilyzer build site` fails on it (publish/build.ts runBuildPhase). // // The bundle names its own scope — corpus.json's `site.scope: "cited"`, which // compose always writes for a cited site — so the audit needs no site config. // A site configured cited whose out/ is a FULL build is builtScopeProblem's. // What a cited out/ may hold at its top level. export const CITED_OUT_ALLOWED_DIRS: readonly string[] = [ // Next's assets, and the routes a cited build still renders (the shell's // pages that say "not on this site", the not-found page). "_next", "_not-found", "404", "ask", "changelog", "downloads", "duplicates", "offline", // What compose's reports stage writes: the reports and their stills, the // moment pages, the cited media (with each route's placeholder, `_none`). "reports", "m", "media", "icons", ]; export const CITED_OUT_ALLOWED_FILES: readonly string[] = [ "site.json", "corpus.json", "llms.txt", "robots.txt", "sitemap.xml", "_headers", "index.html", "index.txt", "404.html", "favicon.ico", "manifest.webmanifest", // export/public's checked-in assets. "file.svg", "globe.svg", "next.svg", "vercel.svg", "window.svg", ]; // Next's per-segment payloads for the root route (`__next._tree.txt`, …). const NEXT_SEGMENT_FILE_RE = /^__next\..+\.txt$/; // What `media/` may hold: the cited clips and post captures, nothing else. export const CITED_MEDIA_ALLOWED_DIRS: readonly string[] = ["clips", "posts"]; // Cloudflare Pages' own limits: a file of at most 25 MiB, at most 20,000 files. export const PAGES_MAX_FILE_BYTES = 25 * 1024 * 1024; export const PAGES_MAX_FILES = 20_000; // What a step that PUTS a file on a site holds it to: 24 MiB, a mebibyte inside // Pages' own limit (the source mirror's packs, an evidence clip, a report's // exports). One number, so every step refuses the same file. export const PUBLISH_MAX_FILE_BYTES = 24 * 1024 * 1024; /** * Why a file of `bytes` may not be published, as one sentence naming `rel` — or * null when it fits under PUBLISH_MAX_FILE_BYTES. */ export function publishFileSizeProblem(rel: string, bytes: number): string | null { if (bytes <= PUBLISH_MAX_FILE_BYTES) return null; const mib = (n: number) => (n / (1024 * 1024)).toFixed(1); return `${rel} is ${mib(bytes)} MiB, over the publish limit of ${mib(PUBLISH_MAX_FILE_BYTES)} MiB (Pages allows 25 MiB per file)`; } // corpus.json's `site.scope`, or null. function builtScopeIn(outDir: string): string | null { try { const parsed: unknown = JSON.parse(readFileSync(path.join(outDir, "corpus.json"), "utf8")); const scope = (parsed as { site?: { scope?: unknown } } | null)?.site?.scope; return typeof scope === "string" ? scope : null; } catch { return null; } } // Whether the build in `outDir` is a cited site's (its corpus.json says so). export function isCitedBuild(outDir: string): boolean { return builtScopeIn(outDir) === "cited"; } /** * Why the CITED build in `outDir` may not ship, as one sentence naming what * it holds that a cited site may not — or null when it may, or when the build * is not a cited one. */ export function citedBuildProblem(outDir: string): string | null { if (!isCitedBuild(outDir)) return null; const extra: string[] = []; let entries: Dirent[]; try { entries = readdirSync(outDir, { withFileTypes: true }); } catch { return `${outDir} is a cited build that cannot be read`; } for (const e of entries) { if (e.isDirectory()) { if (!CITED_OUT_ALLOWED_DIRS.includes(e.name)) extra.push(`${e.name}/`); } else if (!CITED_OUT_ALLOWED_FILES.includes(e.name) && !NEXT_SEGMENT_FILE_RE.test(e.name)) { extra.push(e.name); } } const mediaDir = path.join(outDir, "media"); if (existsSync(mediaDir)) { for (const e of readdirSync(mediaDir, { withFileTypes: true })) { if (!e.isDirectory() || !CITED_MEDIA_ALLOWED_DIRS.includes(e.name)) { extra.push(`media/${e.name}${e.isDirectory() ? "/" : ""}`); } } } if (extra.length > 0) { extra.sort(); const shown = extra.slice(0, 12).join(", ") + (extra.length > 12 ? `, … (${extra.length} in all)` : ""); return ( `${outDir} is a cited build, which publishes only reports and their moments, ` + `but it also holds ${shown} — compose the site again` ); } // The Pages limits, which a cited build is small enough to walk for — // every file counted, the reports' history clones' included. let files = 0; let historyFiles = 0; const oversize: string[] = []; const walk = (dir: string, rel: string): void => { for (const e of readdirSync(dir, { withFileTypes: true })) { const p = path.join(dir, e.name); const r = rel ? `${rel}/${e.name}` : e.name; if (e.isDirectory()) walk(p, r); else { files++; if (REPORT_HISTORY_REPO_DIR_RE.test(r)) historyFiles++; if (statSync(p).size > PAGES_MAX_FILE_BYTES) oversize.push(r); } } }; walk(outDir, ""); if (oversize.length > 0) { return `${outDir} holds ${oversize.length} file(s) over Pages' 25 MiB limit: ${oversize.slice(0, 5).join(", ")}`; } if (files > PAGES_MAX_FILES) { return ( `${outDir} holds ${files} files${historyFiles ? ` (${historyFiles} in report history clones)` : ""}, ` + `over Pages' limit of ${PAGES_MAX_FILES}` ); } return null; } // A file inside a report's published history clone, by its out/-relative path. const REPORT_HISTORY_REPO_DIR_RE = /^reports\/[^/]+\/history\/repo\//; /** * Why the reports' history clones in `outDir` (`reports//history/repo/`, * publish/reportHistory.ts) may not ship, as one sentence — or null. Any * build, full or cited: a clone may hold only the dumb-HTTP files its stager * copies (REPORT_HISTORY_REPO_FILE_RE — never a config, hook or log of the * store), each within the publish limit (PUBLISH_MAX_FILE_BYTES). */ export function reportHistoryProblem(outDir: string): string | null { const reportsDir = path.join(outDir, "reports"); let reports: Dirent[]; try { reports = readdirSync(reportsDir, { withFileTypes: true }); } catch { return null; } const extra: string[] = []; const oversize: string[] = []; for (const r of reports) { if (!r.isDirectory()) continue; const repo = path.join(reportsDir, r.name, "history", "repo"); if (!existsSync(repo)) continue; const walk = (dir: string, rel: string): void => { for (const e of readdirSync(dir, { withFileTypes: true })) { const p = path.join(dir, e.name); const rr = rel ? `${rel}/${e.name}` : e.name; if (e.isDirectory()) walk(p, rr); else { if (!REPORT_HISTORY_REPO_FILE_RE.test(rr)) extra.push(`reports/${r.name}/history/repo/${rr}`); const problem = publishFileSizeProblem(`reports/${r.name}/history/repo/${rr}`, statSync(p).size); if (problem) oversize.push(problem); } } }; walk(repo, ""); } if (extra.length > 0) { extra.sort(); return ( `${outDir} holds files in a report's history clone that are not published: ${extra.slice(0, 8).join(", ")}` + `${extra.length > 8 ? `, … (${extra.length} in all)` : ""} — compose the site again` ); } if (oversize.length > 0) return `${outDir}: ${oversize[0]}`; return null; } /** * Why `outDir` may not be deployed as `site` because of what it PUBLISHES, as * one sentence, or null: a site configured report-only (`search: false`) whose * build is not a cited one was built before the switch, and would ship the * whole corpus. Asked beside the audience refusals (deployAudienceProblem). */ export function builtScopeProblem( site: { siteId: string; search?: boolean }, outDir: string, ): string | null { if (!isCitedSite(site) || !existsSync(path.join(outDir, "corpus.json"))) return null; if (isCitedBuild(outDir)) return null; return ( `Site "${site.siteId}" publishes only its reports (search: false), but ${outDir} ` + `holds a full build — build ${site.siteId} again, then deploy` ); } // corpus.json's `site.id`, or null when there is no readable one. function corpusSiteIdIn(outDir: string): string | null { try { const parsed: unknown = JSON.parse(readFileSync(path.join(outDir, "corpus.json"), "utf8")); const site = (parsed as { site?: { id?: unknown } } | null)?.site; return typeof site?.id === "string" && site.id.trim() ? site.id.trim() : null; } catch { return null; } } /** * Why `outDir` may not be deployed as the HUB, as one sentence — or null when * it holds a hub build. * * The hub is the export app built with INSTANCE_MODE=hub into export/out, the * directory a site's build uses too (release 18 moves each build into its own * bundle afterwards, `_hub/out`), so the two are told apart by content. A hub * build names itself by what it carries and what it does not: compose-hub writes * `hub-sites.json` and the hub build removes `site.json` first, while a site's * compose removes `hub-sites.json` and writes `site.json`. A site.json here is * therefore a site's bundle, whatever else is beside it. */ export function builtHubProblem(outDir: string): string | null { const site = builtSiteIdIn(outDir); if (site !== null) { return `export/out holds a build of "${site}", not the hub — build the hub first`; } if (!existsSync(path.join(outDir, "hub-sites.json"))) { return "export/out holds no hub build — build the hub first"; } // The hub holds no site's data (compose-hub removes it): a hub bundle that // still carries a site's data trees was composed over one, and could ship // that site's posts — a private site's included. Its one posts tree is the // tombstones compose-hub writes for withdrawn X posts (release 18), and a // tree holding anything but tombstones is a site's. const carried: string[] = HUB_FORBIDDEN_TREES.filter( (tree) => existsSync(path.join(outDir, tree)) && !(tree === "posts" && isTombstonePostsTree(path.join(outDir, tree))), ); // reports/ and m/ are the export app's own routes, rendered into EVERY // build (the hub's included: /reports/, /reports/_none/, /m/_none/) — their // shell pages are not data. What a site's reports stage writes is. const reportData = hubReportDataIn(outDir); if (reportData.length > 0) carried.push(...reportData); if (carried.length > 0) { return ( `export/out holds a hub build that still carries a site's data (${carried.join(", ")}) — ` + `build the hub again` ); } return null; } /** * Whether `postsDir` (a bundle's `posts/`) holds TOMBSTONES and nothing else * (publish/tombstones.ts): a site posts manifest listing no channel, and per * channel dir a posts manifest at pageCount 0 and only `[]` pages. Anything * else — a post, a listed channel, an unreadable file, a stray entry — is not. */ export function isTombstonePostsTree(postsDir: string): boolean { const readJson = (file: string): unknown => { try { return JSON.parse(readFileSync(file, "utf8")); } catch { return undefined; } }; const manifest = readJson(path.join(postsDir, "manifest.json")) as | { channels?: unknown } | undefined; if (!manifest || !Array.isArray(manifest.channels) || manifest.channels.length > 0) return false; let entries: Dirent[]; try { entries = readdirSync(postsDir, { withFileTypes: true }); } catch { return false; } for (const e of entries) { if (e.name === "manifest.json" && e.isFile()) continue; if (!e.isDirectory()) return false; const dir = path.join(postsDir, e.name); const channel = readJson(path.join(dir, "manifest.json")) as | { pageCount?: unknown; slugToPage?: unknown } | undefined; if (!channel || channel.pageCount !== 0) return false; if (channel.slugToPage && Object.keys(channel.slugToPage as object).length > 0) return false; let files: Dirent[]; try { files = readdirSync(dir, { withFileTypes: true }); } catch { return false; } for (const f of files) { if (f.name === "manifest.json") continue; if (!f.isFile() || !/^page-\d+\.json$/.test(f.name)) return false; const page = readJson(path.join(dir, f.name)); if (!Array.isArray(page) || page.length > 0) return false; } } return true; } // The per-site data trees a hub bundle must never carry (the trees of // compose-hub's SITE_ONLY_PUBLIC_ENTRIES). A `posts/` of tombstones only is // the hub's own (isTombstonePostsTree). `reports/` and `m/` are NOT here: the // export app renders its report and moment routes into every build, the hub's // too (`reports/index.html`, the `_none` placeholders), so their presence says // nothing — hubReportDataIn looks for the report DATA inside them instead. // (Listing them refused every hub bundle from 2026-10-05.) const HUB_FORBIDDEN_TREES = [ "summaries", "transcripts", "subs", "posts", "digests", "stats", "archives", "media", ]; // The files only a site's reports stage writes (lib/report/views.ts names // them; publish/composeReports.ts writes them), by name within reports// // and m/…: the report index, each report's page view, its citations, its // exports, its timeline's feeds and its revision history; the moment index // and each moment view. const REPORT_DATA_FILES = new Set([ path.posix.basename(reportViewPath("x")), path.posix.basename(reportCitationsDownloadPath("x", "json")), path.posix.basename(reportCitationsDownloadPath("x", "csv")), ...Object.values(REPORT_EXPORT_FILENAMES), ...Object.values(REPORT_FEED_FILENAMES), // reportHistory.ts: `history/history.json` beside a report's page. "history.json", ]); const MOMENT_DATA_FILE = path.posix.basename(momentViewPath("x")); /** * The report and moment DATA in a bundle's `reports/` and `m/` (root-relative * paths, at most a few): what a site's compose writes there, never what the * export app's own route pages are. Empty for a hub bundle, which carries the * shell pages only. */ export function hubReportDataIn(outDir: string, limit = 5): string[] { const found: string[] = []; for (const index of [REPORTS_INDEX_PATH, MOMENTS_INDEX_PATH]) { if (existsSync(path.join(outDir, index))) found.push(index.replace(/^\//, "")); } const walk = (rel: string, isData: (name: string, depth: number) => boolean, depth: number) => { let entries: Dirent[]; try { entries = readdirSync(path.join(outDir, rel), { withFileTypes: true }); } catch { return; } for (const e of entries) { if (found.length >= limit) return; const child = `${rel}/${e.name}`; if (e.isDirectory()) { // A report's revision history clone (history/repo/) is data whatever it holds. if (rel.startsWith("reports/") && e.name === "repo" && rel.endsWith("/history")) { found.push(`${child}/`); continue; } walk(child, isData, depth + 1); } else if (isData(e.name, depth)) { found.push(child); } } }; walk("reports", (name, depth) => depth >= 1 && REPORT_DATA_FILES.has(name), 0); walk("m", (name) => name === MOMENT_DATA_FILE, 0); return found.slice(0, limit); } /** * Why `outDir` — the homepage package's `homepage/out` — may not be deployed as * the homepage, as one sentence, or null when it holds a build. * * Unlike export/out nothing else ever builds into homepage/out, so the only * question is whether a build is there at all. `index.html` is the file * the deploy stage checks inside its job, so the refusal an operator gets before * the job and the one the job would give agree on what "built" means. */ export function builtHomepageProblem(outDir: string): string | null { if (!existsSync(path.join(outDir, "index.html"))) { return "homepage/out holds no build — build the homepage first"; } return null; } /** * When the homepage build in `outDir` was made (its `index.html`'s mtime, in * ms), or null when builtHomepageProblem would refuse it. What `/sites` shows * as "built " beside Deploy homepage, so the operator sees how old the * bundle a deploy-only would ship is. */ export function builtHomepageAt(outDir: string): number | null { try { return statSync(path.join(outDir, "index.html")).mtimeMs; } catch { return null; } }