commit 82f10323cbc619c506a07f2d473f7ed840954ef4
parent 396968443b31dd22dba28246c41db1c8b00ea05f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 2 Jul 2026 02:09:07 -0400
Phase 6c: hub build + page — INSTANCE_MODE=hub renders the federated shelf
Wire the hub shell into the export app behind INSTANCE_MODE=hub:
- export/app/lib/site.ts: in hub mode currentSite() synthesizes a Site from the
instance-level HomepageConfig ("Archilyzer", ships a PWA), so layout/manifest/
Header/Footer keep working unchanged. Reads INSTANCE_MODE directly to avoid a
cycle with mode.ts.
- export/app/page.tsx: hub mode renders <HubHome/> and skips the site-only
countTranscripts() (the hub has no local summaries).
- export/app/components/hub/HubHome.tsx: functional hub shell — add-by-URL
(validateSite → addSite with validating/error states), the registered-archive
list (accent spine + remove for external adds), and MultiSiteDataProvider →
TranscriptSearch for one search across every archive. The designed
archive-shelf hero + badge vocabulary come in the hub-frontend phase.
- common/bin/compose-hub.ts + build:hub/compose:hub scripts: emit
/hub-sites.json (built-in pool from listSites), CORS /_headers, and the hub
SW (falls back to the site SW until the dedicated hub SW lands). No per-site
data — the hub federates cross-origin at runtime.
- siteRegistry.ts: inline parseSiteUrl so the client bundle no longer drags in
the fs-backed lib/site.ts (was breaking the hub build).
Verified: `pnpm build:hub` succeeds (8 pages), emits hub-sites.json/_headers/
sw.js, manifest reads "Archilyzer", home prerenders the shelf. Site mode is
untouched (instanceMode defaults "site") — profile-row e2e green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat:
7 files changed, 276 insertions(+), 1 deletion(-)
diff --git a/.gitignore b/.gitignore
@@ -59,6 +59,7 @@ yarn-error.log*
/export/public/site.json
/export/public/_headers
/export/public/sw.js
+/export/public/hub-sites.json
/export/.export-index/
/transcripts/index.mdb/
diff --git a/common/bin/compose-hub.ts b/common/bin/compose-hub.ts
@@ -0,0 +1,103 @@
+#!/usr/bin/env tsx
+// Composes the served public dir for the Archilyzer HUB build (the export app
+// built with INSTANCE_MODE=hub) ahead of `next build`. Unlike compose-site.ts
+// there is no SITE_ID and no per-site data: the hub is a federating shell that
+// reads every archive cross-origin at runtime. It emits:
+//
+// public/hub-sites.json <- the built-in trusted pool (listSites with a siteUrl)
+// public/_headers <- CORS for the hub's own served JSON
+// public/sw.js <- the hub service worker (the hub always ships a PWA)
+//
+// The hub's branding ("Archilyzer") is resolved at build/render time from the
+// HomepageConfig (see export/app/lib/site.ts hubSite()), not composed here.
+
+import path from "node:path";
+import { cp, rm, writeFile, access } from "node:fs/promises";
+import { getPaths } from "../lib/paths";
+import { listSites, resolveHubUrl } from "../lib/site";
+import { SITE_DESCRIPTOR_VERSION } from "../lib/siteDescriptor";
+
+// CORS for the hub's own JSON (hub-sites.json). The hub is primarily a reader,
+// but keeping its endpoints CORS-open lets a hub-of-hubs federate it too. Same
+// rationale as compose-site.ts.
+const CORS_HEADERS = `# Generated by compose-hub.ts — do not edit by hand.
+/hub-sites.json
+ Access-Control-Allow-Origin: *
+/site.json
+ Access-Control-Allow-Origin: *
+/summaries/*
+ Access-Control-Allow-Origin: *
+/subs/*
+ Access-Control-Allow-Origin: *
+/transcripts/*
+ Access-Control-Allow-Origin: *
+/stats/*
+ Access-Control-Allow-Origin: *
+`;
+
+// One entry the hub registry loads at boot to seed its trusted built-in pool.
+// The origin is derived from siteUrl on the client (coerceBuiltin), so a pool
+// site without a siteUrl can't be federated and is dropped.
+type HubSiteEntry = {
+ siteId: string;
+ siteTitle: string;
+ siteUrl: string;
+ accent?: string;
+ hubUrl?: string;
+ pwa: boolean;
+ contract: number;
+};
+
+async function exists(p: string): Promise<boolean> {
+ try {
+ await access(p);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function main(): Promise<void> {
+ const paths = getPaths();
+ const publicDir = paths.exportPublicDir;
+
+ // Built-in pool: every configured site that publishes a public URL.
+ const builtins: HubSiteEntry[] = [];
+ for (const site of listSites(paths)) {
+ if (!site.siteUrl) continue;
+ builtins.push({
+ siteId: site.siteId,
+ siteTitle: site.siteTitle,
+ siteUrl: site.siteUrl,
+ ...(site.accent ? { accent: site.accent } : {}),
+ ...(resolveHubUrl(site) ? { hubUrl: resolveHubUrl(site) } : {}),
+ pwa: site.pwa === true,
+ contract: SITE_DESCRIPTOR_VERSION,
+ });
+ }
+ await writeFile(
+ path.join(publicDir, "hub-sites.json"),
+ JSON.stringify(builtins),
+ );
+
+ await writeFile(path.join(publicDir, "_headers"), CORS_HEADERS);
+
+ // The hub always ships a PWA. Copy the hub service worker into place. Until
+ // the dedicated hub SW lands (Phase 7), fall back to the site SW so the PWA
+ // still registers.
+ const swDest = path.join(publicDir, "sw.js");
+ await rm(swDest, { force: true });
+ const hubSw = path.join(paths.monorepoRoot, "export", "service-worker", "sw-hub.js");
+ const siteSw = path.join(paths.monorepoRoot, "export", "service-worker", "site-sw.js");
+ const swSrc = (await exists(hubSw)) ? hubSw : siteSw;
+ if (await exists(swSrc)) await cp(swSrc, swDest);
+
+ console.log(
+ `compose-hub: ${builtins.length} built-in pool site(s) into ${publicDir}.`,
+ );
+}
+
+main().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/common/components/siteRegistry.ts b/common/components/siteRegistry.ts
@@ -21,7 +21,16 @@
import { useEffect, useSyncExternalStore } from "react";
import { SITE_DESCRIPTOR_VERSION } from "../lib/siteDescriptor";
import type { PublicSiteDescriptor } from "../lib/siteDescriptor";
-import { parseSiteUrl } from "../lib/site";
+
+// Normalize a raw URL into a trimmed absolute http(s) URL with no trailing
+// slash, or undefined. Inlined (rather than importing from lib/site.ts) to keep
+// this module free of the fs-backed site loader — it runs in the client bundle.
+function parseSiteUrl(input: unknown): string | undefined {
+ if (typeof input !== "string") return undefined;
+ const trimmed = input.trim().replace(/\/+$/, "");
+ if (!/^https?:\/\/\S+/i.test(trimmed)) return undefined;
+ return trimmed;
+}
export type RegisteredSite = {
// "" = the hub's own same-origin pool; otherwise a full origin
diff --git a/export/app/components/hub/HubHome.tsx b/export/app/components/hub/HubHome.tsx
@@ -0,0 +1,131 @@
+"use client";
+
+// The hub home: a shelf of federated archives above one unified search across
+// all of them. This is the functional Phase-6 shell (add-by-URL + registry list
+// + merged search); the designed archive-shelf hero with per-site accent spines
+// and the badge vocabulary lands in the hub-frontend phase.
+
+import { useMemo, useState } from "react";
+import { PlayerProvider } from "yt-dlp-transcript-common/components/PlayerProvider";
+import TranscriptModal from "yt-dlp-transcript-common/components/TranscriptModal";
+import TranscriptSearch from "yt-dlp-transcript-common/components/TranscriptSearch";
+import {
+ MultiSiteDataProvider,
+ type FederatedSite,
+} from "yt-dlp-transcript-common/components/SearchDataContext";
+import {
+ useRegistry,
+ validateSite,
+} from "yt-dlp-transcript-common/components/siteRegistry";
+
+type AddState =
+ | { status: "idle" }
+ | { status: "validating" }
+ | { status: "error"; message: string };
+
+export default function HubHome() {
+ const { sites, addSite, removeSite } = useRegistry();
+ const [url, setUrl] = useState("");
+ const [add, setAdd] = useState<AddState>({ status: "idle" });
+
+ // The provider merges the hub's own same-origin pool (origin "") plus every
+ // registered origin. Accent rides along for provenance in the UI.
+ const federated = useMemo<FederatedSite[]>(
+ () =>
+ sites.map((s) => ({
+ origin: s.origin,
+ siteTitle: s.siteTitle,
+ accent: s.accent,
+ })),
+ [sites],
+ );
+
+ async function onAdd(e: React.FormEvent<HTMLFormElement>) {
+ e.preventDefault();
+ if (add.status === "validating") return;
+ setAdd({ status: "validating" });
+ const result = await validateSite(url);
+ if (result.ok) {
+ addSite(result.site);
+ setUrl("");
+ setAdd({ status: "idle" });
+ } else {
+ setAdd({ status: "error", message: result.reason });
+ }
+ }
+
+ return (
+ <PlayerProvider>
+ <div className="flex flex-col gap-6">
+ <section className="flex flex-col gap-3">
+ <h1 className="font-display text-3xl font-semibold tracking-tight text-foreground">
+ Your archives
+ </h1>
+ <form onSubmit={onAdd} className="flex flex-wrap items-center gap-2">
+ <input
+ type="url"
+ inputMode="url"
+ placeholder="Add an archive — https://archive.example.com"
+ value={url}
+ onChange={(e) => {
+ setUrl(e.target.value);
+ if (add.status === "error") setAdd({ status: "idle" });
+ }}
+ className="flex-1 min-w-64 rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground focus-visible:outline-2 focus-visible:outline-brand"
+ aria-label="Archive URL"
+ />
+ <button
+ type="submit"
+ disabled={add.status === "validating" || !url.trim()}
+ className="rounded-md bg-brand px-4 py-2 text-sm font-medium text-brand-ink disabled:opacity-50"
+ >
+ {add.status === "validating" ? "Validating…" : "Add"}
+ </button>
+ </form>
+ {add.status === "error" && (
+ <p className="text-sm text-destructive" role="alert">
+ {add.message}
+ </p>
+ )}
+ {sites.length === 0 ? (
+ <p className="text-sm text-muted-foreground">
+ No archives yet. Add one above to start searching across it.
+ </p>
+ ) : (
+ <ul className="flex flex-wrap gap-2 list-none">
+ {sites.map((s) => (
+ <li
+ key={s.origin || s.siteId}
+ className="flex items-center gap-2 rounded-md border border-border px-3 py-1.5 text-sm"
+ style={
+ s.accent
+ ? { borderLeft: `3px solid ${s.accent}` }
+ : undefined
+ }
+ >
+ <span className="font-medium text-foreground">
+ {s.siteTitle}
+ </span>
+ {s.kind === "external" && (
+ <button
+ type="button"
+ onClick={() => removeSite(s.origin)}
+ className="text-muted-foreground hover:text-destructive transition-colors"
+ aria-label={`Remove ${s.siteTitle}`}
+ >
+ ×
+ </button>
+ )}
+ </li>
+ ))}
+ </ul>
+ )}
+ </section>
+ <MultiSiteDataProvider sites={federated}>
+ <TranscriptSearch />
+ </MultiSiteDataProvider>
+ </div>
+ <TranscriptModal />
+ </PlayerProvider>
+ );
+}
diff --git a/export/app/lib/site.ts b/export/app/lib/site.ts
@@ -6,6 +6,10 @@ import {
parseSite,
type Site,
} from "yt-dlp-transcript-common/lib/site";
+import {
+ getHomepageConfig,
+ resolveHomepageSocialLinks,
+} from "yt-dlp-transcript-common/lib/homepage";
// The export app builds ONE site at a time, selected by the SITE_ID env var
// (set by the build:site script). At dev time SITE_ID may be unset; we then
@@ -17,7 +21,26 @@ import {
// neutral default rather than throwing — otherwise `next dev` would crash on
// boot before any site is configured (and the e2e harness couldn't start the
// server). A real build always passes SITE_ID, so production is unaffected.
+// In hub mode the app has no single Site — its branding is the instance-level
+// HomepageConfig ("Archilyzer"). Synthesize a Site from it so layout/manifest/
+// Header/Footer keep working unchanged (no channels/groups; the hub ships a PWA
+// by default). Read INSTANCE_MODE directly here rather than importing mode.ts,
+// which imports currentSite() (would be a cycle).
+function hubSite(): Site {
+ const cfg = getHomepageConfig();
+ return parseSite("hub", {
+ siteTitle: cfg.siteTitle,
+ siteDescription: cfg.siteDescription,
+ headerTitle: cfg.headerTitle,
+ homeTagline: cfg.homeTagline,
+ ...(cfg.siteUrl ? { siteUrl: cfg.siteUrl } : {}),
+ socialLinks: resolveHomepageSocialLinks(cfg),
+ pwa: true,
+ });
+}
+
export function currentSite(): Site {
+ if (process.env.INSTANCE_MODE === "hub") return hubSite();
const paths = getPaths();
const explicit = process.env.SITE_ID;
if (explicit) return getSite(explicit, paths);
diff --git a/export/app/page.tsx b/export/app/page.tsx
@@ -4,9 +4,15 @@ import TranscriptModal from "yt-dlp-transcript-common/components/TranscriptModal
import TranscriptSearch from "yt-dlp-transcript-common/components/TranscriptSearch";
import { SingleSiteDataProvider } from "yt-dlp-transcript-common/components/SearchDataContext";
import { currentSite } from "./lib/site";
+import { instanceMode } from "./lib/mode";
import NewSinceLastVisit from "./components/NewSinceLastVisit";
+import HubHome from "./components/hub/HubHome";
export default async function Home() {
+ // Hub mode renders the federated shelf + cross-site search and reads no local
+ // summaries manifest (there is none), so branch before countTranscripts().
+ if (instanceMode() === "hub") return <HubHome />;
+
const settings = currentSite();
const count = await countTranscripts();
diff --git a/export/package.json b/export/package.json
@@ -11,9 +11,11 @@
"build:data": "pnpm run build:index && pnpm run build:stats && pnpm run build:templates",
"detect:duplicates": "NODE_OPTIONS=--max-old-space-size=8192 tsx ../common/bin/duplicate-shorts.ts",
"compose:site": "tsx ../common/bin/compose-site.ts",
+ "compose:hub": "tsx ../common/bin/compose-hub.ts",
"prebuild": "pnpm run build:data",
"build": "pnpm run compose:site && next build",
"build:nodata": "pnpm run compose:site && next build",
+ "build:hub": "pnpm run compose:hub && INSTANCE_MODE=hub next build",
"start": "serve out",
"lint": "eslint",
"e2e": "playwright test",