Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Mcommon/publish/inputSig.test.ts | 50++++++++++++++++++++++++++++++++++++++++++++++++--
Mcommon/publish/inputSig.ts | 37+++++++++++++++++++++++++++++--------
Mcommon/publish/stageBodies.ts | 9++++++++-
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, + }), }; }