Archilyzer · Source

archilyzer

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

commit d93cd7ec27ac5431bbe651a4f56dc1689124b856
parent 8fd36c5c246f56dff446f25bd2879ccc72c23fb2
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat, 12 Sep 2026 02:03:45 -0400

common: the published contract is a module, and it owns the URL shapes

`lib/archive/contract.ts` is the bottom of the archive stack: CONTRACT (the
version constants Phase 0 collapsed), pageFileName, and — new — the URL SHAPES
that go with them. `buildSiteCorpus` now CALLS `manifestUrl` / `corpusUrl` /
`rootFileUrl` / `archiveUrl` instead of interpolating the same strings a second
time, so the reader landing in the next commit reproduces what the writer emits
by construction rather than by two people editing the same literal twice.

CONTRACT and pageFileName MOVED rather than being re-exported from corpus.ts /
manifest.ts, and the reason is a hard failure, not taste: `manifest.ts` reads
`CONTRACT.pagePad` at module scope, so `corpus -> archive/contract -> manifest
-> corpus` is a TDZ ReferenceError the moment anything imports corpus.ts first.
The contract module imports nothing from either, which makes the graph a DAG;
`corpus.ts` and `manifest.ts` re-export the names, so every existing import site
(`from "./corpus"`, `from "./manifest"`, the three per-layer pageFileName
aliases) is untouched.

Also here: `shipsPwa(site)` once (compose-site.ts and export/app/lib/mode.ts
still hold their copies — rewiring them is S2c), `ROOT_FILES`, and the hub member
types. Those collapse to TWO, not the planned one, and the reason is on the
wire: the input spelling is siteTitle/siteUrl and the emitted corpus.json entry
is title/url with two derived pointers beside it. corpus.json is frozen, so what
collapses is each direction — one `HubMemberInput` (was that plus compose-hub's
`HubSiteEntry`) and one `HubCorpusSite`, with mcp's `HubSite` now a Pick of the
emitted type so the read and write spellings cannot drift.

`io-stats.ts` is the MCP_IO_STATS counter lifted out of mcp/src/source.ts with
its enable flag guarded (`typeof process`), so the reader can be imported from a
browser bundle. Nothing calls it yet.

`stats/` gets URLs without joining CONTRACT.layers: it follows the same
manifest -> page walk but corpus.json's shardScheme does not document it, so the
builders take a wider `ArchiveTree` and the published layer list stays frozen.

common's test glob only reached one directory deep, so `lib/archive/*.test.ts`
would have been collected by nobody; it now also globs `<root>/*/*.test.ts`.

Gates: tsc --noEmit clean in all six packages. common 1060/1060 (1051 + 9 new
contract tests, none lost). compose-site over the FACTS.md fixture corpus
(one-youtube-channel-with-data + a hand-written sites/testsite/site.json),
before and after: 16 files under public/, 7 under index/, IDENTICAL modulo the
build clock — `generatedAt` in corpus.json, site.json, the five manifests and
the archive zip's channel.json, plus the zip's own size, which moves by a byte
because its entry mtimes do (its entries diff clean).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Acommon/lib/archive/contract.test.ts | 168+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/archive/contract.ts | 197+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/archive/io-stats.ts | 40++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/corpus.ts | 98+++++++++++++++++++++++++++++++------------------------------------------------
Mcommon/lib/manifest.ts | 10+++++++---
Mcommon/package.json | 2+-
6 files changed, 451 insertions(+), 64 deletions(-)

diff --git a/common/lib/archive/contract.test.ts b/common/lib/archive/contract.test.ts @@ -0,0 +1,168 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + CONTRACT, + ROOT_FILES, + archiveUrl, + corpusUrl, + isFlatTree, + manifestUrl, + pageFileName, + pageUrl, + rootFileUrl, + shipsPwa, +} from "./contract"; +import { buildSiteCorpus } from "../corpus"; +import type { PublicSiteDescriptor } from "../siteDescriptor"; + +// A minimal descriptor with two channels, one of which ships posts and digests, +// so every conditional manifest pointer buildSiteCorpus can emit is exercised. +function descriptor(siteUrl?: string): PublicSiteDescriptor { + return { + contract: CONTRACT.siteDescriptor, + siteId: "testsite", + siteTitle: "Test Site", + siteDescription: "fixture", + generatedAt: "2026-09-12T00:00:00.000Z", + ...(siteUrl ? { siteUrl } : {}), + channels: [ + { slug: "alpha", name: "Alpha", count: 3 }, + { slug: "beta", name: "Beta", count: 2, groupId: "g1" }, + ], + groups: [], + defaultGroupId: "default", + socialLinks: [], + pwa: false, + } as unknown as PublicSiteDescriptor; +} + +test("page shards are zero-padded to four — page-0.json is a 404", () => { + assert.equal(pageFileName(0), "page-0000.json"); + assert.equal(pageFileName(7), "page-0007.json"); + assert.equal(pageFileName(1234), "page-1234.json"); + assert.equal(pageFileName(12345), "page-12345.json"); + assert.equal(CONTRACT.pagePad, 4); +}); + +test("archiveUrl: absolute with a base, root-relative without", () => { + assert.equal(archiveUrl(undefined, "/corpus.json"), "/corpus.json"); + assert.equal(archiveUrl("https://x.example", "/corpus.json"), "https://x.example/corpus.json"); + // A trailing slash on the base must not double up. + assert.equal(archiveUrl("https://x.example/", "/corpus.json"), "https://x.example/corpus.json"); + assert.equal(archiveUrl("https://x.example///", "/a/b.json"), "https://x.example/a/b.json"); +}); + +test("per-channel trees take a slug, flat trees do not", () => { + assert.equal(isFlatTree("summaries"), true); + assert.equal(isFlatTree("stats"), true); + assert.equal(isFlatTree("transcripts"), false); + + assert.equal(manifestUrl("transcripts", "alpha"), "/transcripts/alpha/manifest.json"); + assert.equal(manifestUrl("summaries"), "/summaries/manifest.json"); + assert.equal(manifestUrl("stats"), "/stats/manifest.json"); + assert.equal(pageUrl("subs", "alpha", 2), "/subs/alpha/page-0002.json"); + assert.equal(pageUrl("summaries", undefined, 2), "/summaries/page-0002.json"); + assert.equal(pageUrl("stats", undefined, 0), "/stats/page-0000.json"); + + // A per-channel tree with no slug is a bug at the call site, not a URL that + // 404s quietly against a real site. + assert.throws(() => manifestUrl("transcripts"), /needs a channel slug/); + assert.throws(() => pageUrl("posts", undefined, 1), /needs a channel slug/); +}); + +test("root files: the four JSON documents a reader fetches by name", () => { + assert.deepEqual([...ROOT_FILES], [ + "corpus.json", + "site.json", + "search-aliases.json", + "duplicates.json", + ]); + assert.equal(corpusUrl(), "/corpus.json"); + assert.equal(corpusUrl("https://x.example"), "https://x.example/corpus.json"); + assert.equal(rootFileUrl("duplicates.json"), "/duplicates.json"); + assert.equal( + rootFileUrl("search-aliases.json", "https://x.example"), + "https://x.example/search-aliases.json", + ); +}); + +// THE reason these functions exist: what a reader builds must be byte-for-byte +// what the writer emitted. Compare against buildSiteCorpus's actual output +// rather than against a second hand-written string. +test("manifestUrl reproduces what buildSiteCorpus emits — absolute, with a siteUrl", () => { + const base = "https://testsite.example"; + const corpus = buildSiteCorpus(descriptor(base), { + hasArchives: false, + postCounts: { beta: 4 }, + digestCounts: { beta: 1 }, + }); + const alpha = corpus.channels[0]; + const beta = corpus.channels[1]; + + assert.equal(alpha.manifests.transcripts, manifestUrl("transcripts", "alpha", base)); + assert.equal(alpha.manifests.subs, manifestUrl("subs", "alpha", base)); + assert.equal(beta.manifests.posts, manifestUrl("posts", "beta", base)); + assert.equal(beta.manifests.digests, manifestUrl("digests", "beta", base)); + assert.equal(alpha.manifests.transcripts, `${base}/transcripts/alpha/manifest.json`); + + // A channel with no posts/digests advertises neither pointer — the layer is + // absent from the wire, not present-and-empty. + assert.equal(alpha.manifests.posts, undefined); + assert.equal(alpha.manifests.digests, undefined); +}); + +test("manifestUrl reproduces what buildSiteCorpus emits — root-relative, no siteUrl", () => { + const corpus = buildSiteCorpus(descriptor(), { hasArchives: false }); + const alpha = corpus.channels[0]; + + assert.equal(alpha.manifests.transcripts, manifestUrl("transcripts", "alpha")); + assert.equal(alpha.manifests.transcripts, "/transcripts/alpha/manifest.json"); + assert.equal(alpha.manifests.subs, "/subs/alpha/manifest.json"); + assert.equal(corpus.site.url, undefined); +}); + +test("the hub corpus's per-member pointers go through the same builders", () => { + const corpus = buildSiteCorpus(descriptor("https://a.example/"), { + hasArchives: false, + }); + // buildSiteCorpus strips the trailing slash exactly as archiveUrl does. + assert.equal( + corpus.channels[0].manifests.transcripts, + "https://a.example/transcripts/alpha/manifest.json", + ); +}); + +test("shipsPwa: the site flag, or hub mode", () => { + const prev = process.env.INSTANCE_MODE; + try { + delete process.env.INSTANCE_MODE; + assert.equal(shipsPwa({}), false); + assert.equal(shipsPwa({ pwa: false }), false); + assert.equal(shipsPwa({ pwa: true }), true); + process.env.INSTANCE_MODE = "hub"; + // Hub mode always ships the PWA, whatever the site says. + assert.equal(shipsPwa({}), true); + assert.equal(shipsPwa({ pwa: false }), true); + process.env.INSTANCE_MODE = "site"; + assert.equal(shipsPwa({}), false); + } finally { + if (prev === undefined) delete process.env.INSTANCE_MODE; + else process.env.INSTANCE_MODE = prev; + } +}); + +test("CONTRACT is frozen where it is published", () => { + // These are on the wire. A change here is a change to every deployed archive's + // machine contract, so it belongs in a slice that says so, not in a refactor. + assert.equal(CONTRACT.corpusSpec, 3); + assert.equal(CONTRACT.siteDescriptor, 1); + assert.equal(CONTRACT.manifest, 3); + assert.equal(CONTRACT.transcriptsManifest, 1); + assert.equal(CONTRACT.subsManifest, 4); + assert.equal(CONTRACT.subsChannelManifest, 1); + assert.equal(CONTRACT.summariesPageSize, 1000); + assert.deepEqual( + [...CONTRACT.layers], + ["transcripts", "subs", "posts", "digests", "summaries"], + ); +}); diff --git a/common/lib/archive/contract.ts b/common/lib/archive/contract.ts @@ -0,0 +1,197 @@ +// THE PUBLISHED CONTRACT, as code. +// +// A published archive is a pile of static JSON whose *shape* is a promise: +// `/corpus.json` names every channel and how to walk manifest -> slugToPage -> +// `page-<NNNN>.json`. Phase 0 collapsed the version constants into one +// `CONTRACT` object; this module is the next step — the URL SHAPES that go with +// them, so a reader and the writer that produced the files agree by +// construction rather than by two people editing the same string twice. +// +// Browser-safe on purpose: every consumer of the contract (the viewer's caches, +// the offline cache, the MCP reader, a project's cue resolver) imports from +// here, and half of them run in a browser. Nothing in this file touches +// `node:*`. +// +// WHY THIS FILE OWNS `CONTRACT` AND `pageFileName` RATHER THAN RE-EXPORTING +// THEM: `lib/corpus.ts` must CALL the URL builders (one definition of the shape +// `buildSiteCorpus` emits), and `lib/manifest.ts` reads `CONTRACT.pagePad` at +// module scope. Importing `CONTRACT` from `corpus.ts` here would close the loop +// corpus -> archive/contract -> manifest -> corpus, and the TDZ read of +// `CONTRACT.manifest` at `manifest.ts`'s top level makes that cycle a hard +// ReferenceError, not a warning. So the contract module sits at the BOTTOM of +// the stack and `corpus.ts` / `manifest.ts` re-export from it: every existing +// import site (`from "./corpus"`, `from "./manifest"`) is unchanged, and the +// dependency graph is a DAG. + +import { DUPLICATES_FILENAME } from "../duplicates"; + +// The machine-readable versions and constants of the published contract. Every +// value here appears on the wire, so a change is a wire change — see the +// per-field notes in lib/corpus.ts for what a bump means to a client. +export const CONTRACT = { + // /corpus.json's own `spec`. `generator` is deliberately unversioned; see the + // note in lib/corpus.ts for why a credit line did not bump the spec. + corpusSpec: 3, + // /site.json's `contract` (siteDescriptor.ts). + siteDescriptor: 1, + // The four manifest versions (manifest.ts). Each is the version field of one + // served document; they move independently and always have. + manifest: 3, + transcriptsManifest: 1, + subsManifest: 4, + subsChannelManifest: 1, + // Records per /summaries/page-NNNN.json. + summariesPageSize: 1000, + // The zero-padding on every page shard's file name. `page-0.json` is a 404 — + // this is the whole reason the constant exists in one place. + pagePad: 4, + // The served trees that follow the manifest -> slugToPage -> page-NNNN walk. + // "summaries" is flat (one manifest, pages, no per-channel level). + layers: ["transcripts", "subs", "posts", "digests", "summaries"], +} as const; + +export type ContractLayer = (typeof CONTRACT.layers)[number]; + +// `stats/` follows the same manifest -> page walk but is NOT a contract layer: +// corpus.json's shardScheme does not document it, so a client that only knows +// the contract must not be told to expect it. It still needs URLs, so the URL +// builders take the wider ArchiveTree and CONTRACT.layers stays frozen. +export type ArchiveTree = ContractLayer | "stats"; + +// The trees with no per-channel level: one manifest at the tree root and pages +// beside it. Everything else is /<tree>/<slug>/…. +const FLAT_TREES: ReadonlySet<string> = new Set(["summaries", "stats"]); + +export function isFlatTree(tree: ArchiveTree): boolean { + return FLAT_TREES.has(tree); +} + +// THE page-shard file name, for every layer. `page-0.json` is a 404 on every +// published archive, so a copy that lost the padding would 404 silently against +// a real site and pass every unit test. lib/manifest.ts re-exports this (and +// the per-layer aliases with it), so the six historical copies stay one. +export function pageFileName(index: number): string { + return `page-${String(index).padStart(CONTRACT.pagePad, "0")}.json`; +} + +// Join an origin base with a root-relative path. When no base is known (a site +// built without a configured siteUrl) the path is left root-relative — still +// correct for a same-origin fetch, just not portable cross-origin. +// +// This is the `join` buildSiteCorpus has always used; every URL builder below +// goes through it, which is what makes "absolute when siteUrl is set, +// root-relative otherwise" one rule instead of a dozen call sites. +export function archiveUrl(base: string | undefined, p: string): string { + if (!base) return p; + return `${base.replace(/\/+$/, "")}${p}`; +} + +// The root-level documents a reader fetches by name. Deliberately only the JSON +// an ArchiveReader (or the viewer's offline cache) actually reads — llms.txt, +// robots.txt and sitemap.xml are human/crawler surfaces with no reader. +// +// duplicates.json is the one that is legitimately absent: compose-site only +// writes it when there is at least one publishable cluster, and corpus.json +// does not declare it, so a 404 here is data, not an error. +export const ROOT_FILES = [ + "corpus.json", + "site.json", + "search-aliases.json", + DUPLICATES_FILENAME, +] as const; + +export type RootFile = (typeof ROOT_FILES)[number]; + +export function rootFileUrl(file: RootFile, base?: string): string { + return archiveUrl(base, `/${file}`); +} + +export function corpusUrl(base?: string): string { + return rootFileUrl("corpus.json", base); +} + +// A tree's manifest URL. `slug` is required for the per-channel trees and +// ignored for the flat ones (summaries, stats). +export function manifestUrl( + tree: ArchiveTree, + slug?: string, + base?: string, +): string { + if (isFlatTree(tree)) return archiveUrl(base, `/${tree}/manifest.json`); + if (!slug) throw new Error(`manifestUrl(${tree}) needs a channel slug`); + return archiveUrl(base, `/${tree}/${slug}/manifest.json`); +} + +// One page shard of a tree. Same slug rule as manifestUrl. +export function pageUrl( + tree: ArchiveTree, + slug: string | undefined, + page: number, + base?: string, +): string { + const file = pageFileName(page); + if (isFlatTree(tree)) return archiveUrl(base, `/${tree}/${file}`); + if (!slug) throw new Error(`pageUrl(${tree}) needs a channel slug`); + return archiveUrl(base, `/${tree}/${slug}/${file}`); +} + +// Whether a build ships an installable PWA — the "dangerous permissions" +// surface: a service worker, a web manifest, and installability. An axis +// INDEPENDENT of the shell: +// - hub mode always ships the PWA (Archilyzer IS the installable app); +// - site mode is a dumb instance by default (federatable JSON only, not +// installable) and opts in per site via the `pwa` config flag. +// +// Was two copies with "keep in sync" comments on each (compose-site.ts and +// export/app/lib/mode.ts); this is the one. `process.env.INSTANCE_MODE` is read +// here exactly as both copies read it — Next inlines that member expression +// into the client bundle at build time, so a `typeof process` guard around it +// would turn hub mode OFF in the browser rather than make it safer. +export function shipsPwa(site: { pwa?: boolean }): boolean { + return site.pwa === true || process.env.INSTANCE_MODE === "hub"; +} + +// ─── The hub's member entry, once ─── +// +// A hub member travelled under four names: `HubSiteEntry` (compose-hub.ts, the +// built-in pool it writes), `HubMemberInput` (buildHubCorpus's input), +// `HubCorpusSite` (the entry it emits into the hub corpus.json) and `HubSite` +// (what mcp's HubSource reads back out of that same file). +// +// They collapse to TWO types, not one, and the reason is on the wire: the input +// spells a member `siteTitle`/`siteUrl` and the emitted document spells it +// `title`/`url` with two derived pointers beside it. corpus.json is frozen, so +// the emitted shape cannot be renamed to match the input shape. What does +// collapse is each DIRECTION: one input type (was two) and one emitted type, +// with the read-back spelling now a Pick of the emitted one so the two can +// never drift. + +// A member site as the hub is TOLD about it: the pool entry compose-hub writes +// and the input buildHubCorpus maps. `accent`, `hubUrl` and `contract` are only +// carried by the pool entry (the client's coerceBuiltin reads them); a member +// without a `siteUrl` cannot be federated and is dropped by both consumers. +export type HubMemberInput = { + siteId: string; + siteTitle: string; + siteUrl: string; + pwa?: boolean; + accent?: string; + hubUrl?: string; + contract?: number; +}; + +// A member site as the hub PUBLISHES it, in the `sites` array of a hub +// corpus.json. Every field is on the wire. +export type HubCorpusSite = { + siteId: string; + title: string; + url: string; + corpus: string; + siteJson: string; + pwa: boolean; +}; + +// What a reader needs off that entry to reach a member: its identity and its +// origin. A Pick rather than a restatement, so adding a field to the published +// entry cannot leave a second copy of its name behind. +export type HubSite = Pick<HubCorpusSite, "siteId" | "title" | "url">; diff --git a/common/lib/archive/io-stats.ts b/common/lib/archive/io-stats.ts @@ -0,0 +1,40 @@ +// ─── I/O instrumentation (opt-in, for mcp/bench) ─── +// +// A process-wide counter of shard reads and parsed bytes, so the benchmark can +// report the STRUCTURAL cost of a query (how many pages, how many bytes) next +// to its wall time. That matters on a shared box specifically: wall time is only +// meaningful when the machine is idle, but read counts and byte counts are +// properties of the query plan and hold under any load. +// +// Off unless MCP_IO_STATS=1, and even then it is two integer adds per read. +// +// Moved here from mcp/src/source.ts with one change: the enable flag is guarded +// so this module can be imported from a browser bundle. `process.env?.X` behind +// a `typeof process` check is a dynamic read Next does NOT inline, which is +// exactly right for a server-only diagnostic — the browser gets `undefined` and +// the counters stay off, rather than the bundle failing on a missing global. + +export type IoStats = { reads: number; bytes: number }; + +const IO_STATS_ON = + typeof process !== "undefined" && process.env?.MCP_IO_STATS === "1"; + +const ioTotals: Record<string, IoStats> = {}; + +export function recordRead(kind: string, bytes: number): void { + if (!IO_STATS_ON) return; + const slot = (ioTotals[kind] ??= { reads: 0, bytes: 0 }); + slot.reads++; + slot.bytes += bytes; +} + +// A snapshot of every counter so far, for diffing across one tool call. +export function ioStatsSnapshot(): Record<string, IoStats> { + const out: Record<string, IoStats> = {}; + for (const [k, v] of Object.entries(ioTotals)) out[k] = { ...v }; + return out; +} + +export function ioStatsEnabled(): boolean { + return IO_STATS_ON; +} diff --git a/common/lib/corpus.ts b/common/lib/corpus.ts @@ -1,5 +1,15 @@ import type { PublicSiteDescriptor } from "./siteDescriptor"; import { PROJECT_GENERATOR } from "./project"; +import { + CONTRACT, + archiveUrl, + corpusUrl, + manifestUrl, + rootFileUrl, + type ContractLayer, + type HubCorpusSite, + type HubMemberInput, +} from "./archive/contract"; // The machine-readable corpus index emitted at `/corpus.json` on every export // bundle (and an aggregate variant on a hub). It does NOT contain transcripts — @@ -22,31 +32,20 @@ import { PROJECT_GENERATOR } from "./project"; // miss data it could otherwise have retrieved. `generator` is an informational // string that points at the software, not at any content; no reader's behaviour // changes by not knowing about it, and the only in-repo consumer -// (mcp/src/source.ts) casts the parsed corpus loosely. Bumping the spec would +// (the archive reader) casts the parsed corpus loosely. Bumping the spec would // force every client to re-evaluate compatibility for a credit line. -export const CONTRACT = { - // /corpus.json's own `spec`. See the note above for why `generator` did not - // bump it. - corpusSpec: 3, - // /site.json's `contract` (siteDescriptor.ts). - siteDescriptor: 1, - // The four manifest versions (manifest.ts). Each is the version field of one - // served document; they move independently and always have. - manifest: 3, - transcriptsManifest: 1, - subsManifest: 4, - subsChannelManifest: 1, - // Records per /summaries/page-NNNN.json. - summariesPageSize: 1000, - // The zero-padding on every page shard's file name. `page-0.json` is a 404 — - // this is the whole reason the constant exists in one place. - pagePad: 4, - // The served trees that follow the manifest -> slugToPage -> page-NNNN walk. - // "summaries" is flat (one manifest, pages, no per-channel level). - layers: ["transcripts", "subs", "posts", "digests", "summaries"], -} as const; - -export type ContractLayer = (typeof CONTRACT.layers)[number]; +// +// CONTRACT ITSELF NOW LIVES IN `lib/archive/contract.ts`, beside the URL +// builders this file calls, and is re-exported here so every `from "./corpus"` +// import site is unchanged. It had to move rather than be imported: `manifest.ts` +// reads `CONTRACT.pagePad` at module scope, so corpus -> archive/contract -> +// manifest -> corpus would be a TDZ ReferenceError, not a lint warning. The +// contract module imports nothing from either, which makes the graph a DAG. +export { CONTRACT }; +export type { ContractLayer }; +// The hub member types moved with it (they are contract shapes, not corpus +// builders) and are re-exported for the same reason. +export type { HubCorpusSite, HubMemberInput }; // Every version constant below is a field of CONTRACT, re-exported under the // name its callers already use. Nothing on the wire changes by moving one. @@ -183,15 +182,6 @@ export type SiteCorpus = { useWithAi: string; }; -export type HubCorpusSite = { - siteId: string; - title: string; - url: string; - corpus: string; - siteJson: string; - pwa: boolean; -}; - export type HubCorpus = { spec: number; kind: "hub"; @@ -204,21 +194,10 @@ export type HubCorpus = { useWithAi: string; }; -// Minimal member shape a hub knows about (mirror of compose-hub.ts HubSiteEntry). -export type HubMemberInput = { - siteId: string; - siteTitle: string; - siteUrl: string; - pwa?: boolean; -}; - -// Join an origin base with a root-relative path. When no base is known (a site -// built without a configured siteUrl) the path is left root-relative — still -// correct for a same-origin fetch, just not portable cross-origin. -function join(base: string | undefined, p: string): string { - if (!base) return p; - return `${base.replace(/\/+$/, "")}${p}`; -} +// The join every URL below goes through — "absolute when siteUrl is set, +// root-relative otherwise" — now defined once in archive/contract.ts, where the +// readers that must reproduce these URLs can reach it. +const join = archiveUrl; // Build the per-site corpus index from the public site descriptor (`/site.json`) // plus whether this build emitted bulk archives. @@ -247,15 +226,14 @@ export function buildSiteCorpus( ...(c.groupId ? { groupId: c.groupId } : {}), ...(postCount ? { postCount } : {}), ...(digestCount ? { digestCount } : {}), + // One definition of the manifest URL shape, shared with every reader. + // The conditional spreads stay here: whether a layer is ADVERTISED is a + // property of this site's build, not of the contract. manifests: { - transcripts: join(base, `/transcripts/${c.slug}/manifest.json`), - subs: join(base, `/subs/${c.slug}/manifest.json`), - ...(postCount - ? { posts: join(base, `/posts/${c.slug}/manifest.json`) } - : {}), - ...(digestCount - ? { digests: join(base, `/digests/${c.slug}/manifest.json`) } - : {}), + transcripts: manifestUrl("transcripts", c.slug, base), + subs: manifestUrl("subs", c.slug, base), + ...(postCount ? { posts: manifestUrl("posts", c.slug, base) } : {}), + ...(digestCount ? { digests: manifestUrl("digests", c.slug, base) } : {}), }, }; }); @@ -311,8 +289,8 @@ export function buildHubCorpus( siteId: m.siteId, title: m.siteTitle, url, - corpus: `${url}/corpus.json`, - siteJson: `${url}/site.json`, + corpus: corpusUrl(url), + siteJson: rootFileUrl("site.json", url), pwa: m.pwa === true, }; }); @@ -361,7 +339,7 @@ export function renderSiteLlmsTxt(corpus: SiteCorpus): string { out.push(""); out.push("## Corpus"); out.push( - `- [corpus.json](${join(base, "/corpus.json")}): machine-readable index — ` + + `- [corpus.json](${corpusUrl(base)}): machine-readable index — ` + `channels and how to fetch any transcript from the paginated JSON shards.`, ); if (corpus.digestScheme) { @@ -417,7 +395,7 @@ export function renderHubLlmsTxt(corpus: HubCorpus): string { out.push(""); out.push("## Federation"); out.push( - `- [corpus.json](${join(base, "/corpus.json")}): machine-readable directory ` + + `- [corpus.json](${corpusUrl(base)}): machine-readable directory ` + `of every member site and its corpus endpoint.`, ); out.push(""); diff --git a/common/lib/manifest.ts b/common/lib/manifest.ts @@ -1,5 +1,6 @@ import type { ChannelGroup } from "./channelGroups"; import { CONTRACT } from "./corpus"; +import { pageFileName } from "./archive/contract"; export type ChannelEntry = { name: string; @@ -41,9 +42,12 @@ export const SUMMARIES_PAGE_SIZE = CONTRACT.summariesPageSize; // in this file (summaries, transcripts, subs) plus one per layer module; // `page-0.json` is a 404 on every published archive, so a copy that lost the // padding would 404 silently against a real site and pass every unit test. -export function pageFileName(index: number): string { - return `page-${String(index).padStart(CONTRACT.pagePad, "0")}.json`; -} +// +// Defined in `archive/contract.ts` — beside CONTRACT.pagePad and the URL +// builders that use it — and re-exported here so the twelve call sites that +// import it from `./manifest` (and the three per-layer aliases in stats.ts / +// posts.ts / digests.ts) are unchanged. +export { pageFileName }; export const TRANSCRIPTS_MANIFEST_VERSION = CONTRACT.transcriptsManifest; diff --git a/common/package.json b/common/package.json @@ -41,7 +41,7 @@ "./styles/*": "./styles/*.ts" }, "scripts": { - "test": "tsx --test \"*.test.ts\" \"{lib,controller,jobs,social,ytdlp,components}/*.test.ts\"" + "test": "tsx --test \"*.test.ts\" \"{lib,controller,jobs,social,ytdlp,components}/*.test.ts\" \"{lib,controller,jobs,social,ytdlp,components}/*/*.test.ts\"" }, "dependencies": { "@sindresorhus/slugify": "^3.0.0",