Archilyzer · Source

archilyzer

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

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:
M.gitignore | 1+
Acommon/bin/compose-hub.ts | 103+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/components/siteRegistry.ts | 11++++++++++-
Aexport/app/components/hub/HubHome.tsx | 131+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/app/lib/site.ts | 23+++++++++++++++++++++++
Mexport/app/page.tsx | 6++++++
Mexport/package.json | 2++
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",