commit c36a19c846d57f29b34ebb1e217ceb4623b9600e
parent 0d0c9082a4e42a07384dd27ce387f08cc91755a9
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 6 Oct 2026 09:36:17 -0400
publish: inputSig covers what the export build renders — the resolved social links, the hub url, buildArchives and the footer's sibling sites (review HIGH)
A site is fresh exactly when compose AND the export build would produce the
same bundle; editing a social link, the homepage url or a sibling's url or
title now makes every affected site stale.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
3 files changed, 85 insertions(+), 11 deletions(-)
diff --git a/common/publish/inputSig.test.ts b/common/publish/inputSig.test.ts
@@ -52,12 +52,17 @@ function setup() {
a: {},
x: { sourceKind: "social", platform: "twitter" } as Partial<ChannelConfig>,
};
- let settings: { social?: { x?: { visibility?: string } } } = {};
+ let settings: Record<string, unknown> = { socialLinks: [], buildArchives: true };
+ let sites: Site[] = [
+ site,
+ { siteId: "ani", siteTitle: "Ani", siteUrl: "https://ani.pages.dev", channels: [] } as unknown as Site,
+ ];
const sig = () =>
siteInputSig({
paths,
site,
settings: settings as never,
+ sites,
configOf: (slug) => configs[slug] as ChannelConfig,
});
const bump = (file: string) => {
@@ -71,8 +76,15 @@ function setup() {
sig,
bump,
setXPrivate: () => {
- settings = { social: { x: { visibility: "private" } } };
+ settings = { ...settings, social: { x: { visibility: "private" } } };
},
+ setSettings: (over: Record<string, unknown>) => {
+ settings = { ...settings, ...over };
+ },
+ setSites: (next: Site[]) => {
+ sites = next;
+ },
+ site,
};
}
@@ -134,6 +146,40 @@ test("inputSig moves with each input compose reads", async () => {
}
});
+test("inputSig moves with what the export BUILD renders: social links, the hub url, buildArchives, the sibling sites", async () => {
+ const s = setup();
+ try {
+ let last = await s.sig();
+ const moved = async (what: string) => {
+ const now = await s.sig();
+ assert.notEqual(now, last, what);
+ last = now;
+ };
+ s.setSettings({ socialLinks: [{ label: "X", url: "https://x.com/a" }] });
+ await moved("settings.socialLinks (the header/footer links)");
+ s.setSettings({ homepageUrl: "https://hub.pages.dev" });
+ await moved("settings.homepageUrl (the site's hubUrl)");
+ s.setSettings({ buildArchives: false });
+ await moved("settings.buildArchives");
+ const ani = { siteId: "ani", siteTitle: "Ani", siteUrl: "https://ani.pages.dev", channels: [] };
+ s.setSites([s.site, { ...ani, siteTitle: "Ani 2" } as unknown as Site]);
+ await moved("a sibling's title");
+ s.setSites([s.site, { ...ani, siteTitle: "Ani 2", siteUrl: "https://ani2.pages.dev" } as unknown as Site]);
+ await moved("a sibling's url");
+ s.setSites([s.site, { ...ani, siteTitle: "Ani 2", siteUrl: "https://ani2.pages.dev" } as unknown as Site, {
+ siteId: "bon", siteTitle: "Bon", siteUrl: "https://bon.pages.dev", channels: [],
+ } as unknown as Site]);
+ await moved("a sibling launched");
+ // A sibling the footer does not list (no url) changes nothing.
+ s.setSites([s.site, { ...ani, siteTitle: "Ani 2", siteUrl: "https://ani2.pages.dev" } as unknown as Site, {
+ siteId: "bon", siteTitle: "Bon", siteUrl: "https://bon.pages.dev", channels: [],
+ } as unknown as Site, { siteId: "dark", siteTitle: "Dark", channels: [] } as unknown as Site]);
+ assert.equal(await s.sig(), last, "an unlinkable sibling is not in the footer");
+ } finally {
+ rmSync(s.root, { recursive: true, force: true });
+ }
+});
+
test("a manifest-only member tree is signed as one, not as absent", async () => {
const s = setup();
try {
diff --git a/common/publish/inputSig.ts b/common/publish/inputSig.ts
@@ -1,9 +1,10 @@
// What a site's (and the hub's) build reads, as one sha1 — computed by the
// update-index stage and stored in the IndexStamp (release 18).
//
-// THE RULE: a site is fresh exactly when compose would skip everything. So
-// the signature is made of what compose-site reads, signed with compose's own
-// `dirSignature` (lib/dirSignature.ts) and compose's own manifest-only rule:
+// THE RULE: a site is fresh exactly when compose AND the export build would
+// produce the same bundle. So the signature is made of what they read, signed
+// with compose's own `dirSignature` (lib/dirSignature.ts) and compose's own
+// manifest-only rule:
//
// - the site's whole `.export-index/sites/<id>/` tree (summaries, stats,
// chart templates, tag counts, the subs/posts/digests manifests, the
@@ -14,10 +15,12 @@
// (`sites/<id>/`: tags, aliases, reports);
// - the corpus-wide files compose reads at compose time (search aliases,
// curated tags, the duplicates report and its overrides), by size + mtime;
-// - the `archiveStorage` and `social.x.visibility` settings.
+// - the `archiveStorage`, `social.x.visibility` and `buildArchives` settings;
+// - what `next build` renders beyond compose (release 18 S1 review): the
+// resolved social links, the hub url, and the footer's sibling sites.
//
-// A superset of compose's skip inputs is CONSERVATIVE: a needless rebuild,
-// never a wrong skip. `hubSig` = sha1(stampId, homepage.json, each listed
+// A superset of those inputs is CONSERVATIVE: a needless rebuild, never a
+// wrong skip. `hubSig` = sha1(stampId, homepage.json, each listed
// site's id + siteUrl + title).
import { createHash } from "node:crypto";
@@ -30,7 +33,15 @@ import { DUPLICATES_FILENAME, DUPLICATE_OVERRIDES_FILENAME } from "../lib/duplic
import type { Paths } from "../lib/paths";
import { publishedMemberSlugs } from "../lib/postsVisibility";
import type { SiteSettings } from "../lib/settings";
-import { siteConfigFile, siteDir, siteIndexDir, type Site } from "../lib/site";
+import {
+ resolveHubUrl,
+ resolveRelatedSites,
+ resolveSocialLinks,
+ siteConfigFile,
+ siteDir,
+ siteIndexDir,
+ type Site,
+} from "../lib/site";
import { isListedSite } from "../lib/siteSchema";
import { INDEX_SCANNED_AT_KEY } from "../lib/stats";
import { readChannelConfig } from "../controller/channels";
@@ -77,7 +88,9 @@ async function channelTreeSig(root: string, slug: string, cache: TreeSigCache):
export type SiteSigInputs = {
paths: Paths;
site: Site;
- settings: Pick<SiteSettings, "archiveStorage" | "social">;
+ settings: Pick<SiteSettings, "archiveStorage" | "social" | "socialLinks" | "homepageUrl" | "buildArchives">;
+ // Every configured site: the footer's sibling list is part of the bundle.
+ sites: Site[];
// The site's members' configs (the visibility rule reads their platform).
configOf: (slug: string) => ChannelConfig | null | undefined;
cache?: TreeSigCache;
@@ -119,8 +132,16 @@ export async function siteInputSig(i: SiteSigInputs): Promise<string> {
`settings\t${JSON.stringify({
archiveStorage: settings.archiveStorage ?? null,
xVisibility: settings.social?.x?.visibility ?? null,
+ buildArchives: settings.buildArchives ?? null,
})}`,
);
+ // What the export BUILD renders from beyond compose's inputs, resolved as it
+ // resolves them: the header/footer social links, the hub link the descriptor
+ // carries, and the footer's sibling sites (their urls, titles, listing).
+ const full = settings as SiteSettings;
+ lines.push(`social-links\t${JSON.stringify(resolveSocialLinks(site, full) ?? null)}`);
+ lines.push(`hub-url\t${resolveHubUrl(site, full) ?? ""}`);
+ lines.push(`related-sites\t${JSON.stringify(resolveRelatedSites(site, i.sites))}`);
return sha1(lines.join("\n"));
}
diff --git a/common/publish/stageBodies.ts b/common/publish/stageBodies.ts
@@ -261,7 +261,14 @@ async function runUpdateIndex(ctx: StageContext): Promise<StageOutcome> {
siteEntries[site.siteId] = {
siteFp: meta.siteFp[site.siteId] ?? null,
statsFp: meta.statsFp[site.siteId] ?? null,
- inputSig: await siteInputSig({ paths, site, settings, configOf: (slug) => configs.get(slug), cache }),
+ inputSig: await siteInputSig({
+ paths,
+ site,
+ settings,
+ sites,
+ configOf: (slug) => configs.get(slug),
+ cache,
+ }),
};
}