Archilyzer · Source

archilyzer

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

commit f2123449ace96b7c09b53fb6ab223caad734d472
parent 0c7509c25fe7c3239294274e3aa4618be118299e
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun, 30 Aug 2026 13:07:35 -0400

sites: the hub is the family's, and the Sites group is one entry

/homepage edited sites/_homepage/homepage.json — the family's own hub, and a
thing that can never be a [siteId] because isValidSiteId rejects the underscore.
It is the Hub section of /sites now, its copy verbatim; the h1 and the "Config"
h2 do not survive, because the section IS the config.

That closes the fold. The Sites group is one entry and the nav is eleven — the
end state plans/editor-operations-ia.md has been carrying since slice 1 — and
nav.test.ts is what keeps it there: four groups, eleven links, distinct hrefs,
and none of the ten routes retired by slices 4, 8 and 5.

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

Diffstat:
Deditor/app/homepage/HomepageConfigForm.tsx | 85-------------------------------------------------------------------------------
Deditor/app/homepage/actions.ts | 52----------------------------------------------------
Deditor/app/homepage/page.tsx | 33---------------------------------
Aeditor/app/lib/nav.test.ts | 45+++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/lib/nav.ts | 9+++------
Aeditor/app/sites/components/HomepageConfigForm.tsx | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/sites/lib/homepageActions.ts | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/sites/page.tsx | 19+++++++++++++++++++
Meditor/e2e/navigation.spec.ts | 8+++++++-
Meditor/next.config.ts | 1+
10 files changed, 212 insertions(+), 177 deletions(-)

diff --git a/editor/app/homepage/HomepageConfigForm.tsx b/editor/app/homepage/HomepageConfigForm.tsx @@ -1,85 +0,0 @@ -"use client"; - -import { useActionState } from "react"; -import type { HomepageConfig } from "yt-dlp-transcript-common/lib/homepage"; -import { saveHomepageConfigAction, type SaveResult } from "./actions"; - -const input = - "rounded-md border border-border bg-card px-3 py-1.5 text-sm"; -const label = "flex flex-col gap-1 text-sm font-medium"; - -export function HomepageConfigForm({ config }: { config: HomepageConfig }) { - const [state, formAction, pending] = useActionState< - SaveResult | undefined, - FormData - >(saveHomepageConfigAction, undefined); - - return ( - <form action={formAction} className="flex flex-col gap-3 max-w-xl"> - <label className={label}> - Site title - <input className={input} name="siteTitle" defaultValue={config.siteTitle} /> - </label> - <label className={label}> - Header title - <input - className={input} - name="headerTitle" - defaultValue={config.headerTitle} - /> - </label> - <label className={label}> - Description - <input - className={input} - name="siteDescription" - defaultValue={config.siteDescription} - /> - </label> - <label className={label}> - Home tagline - <input - className={input} - name="homeTagline" - defaultValue={config.homeTagline} - /> - </label> - <label className={label}> - Public URL - <input - className={input} - name="siteUrl" - placeholder="https://archilyzer.pages.dev" - defaultValue={config.siteUrl ?? ""} - /> - </label> - <label className={label}> - Cloudflare Pages project - <input - className={input} - name="cloudflareProject" - defaultValue={config.cloudflareProject ?? ""} - /> - </label> - <div className="flex items-center gap-3"> - <button - type="submit" - disabled={pending} - className="rounded-md bg-info px-3 py-1.5 text-sm font-medium text-info-foreground hover:bg-info disabled:opacity-50" - > - {pending ? "Saving…" : "Save config"} - </button> - {state && !state.ok && ( - <span className="text-sm text-destructive"> - {state.error} - </span> - )} - {state && state.ok && ( - <span className="text-sm text-success"> - Saved. - </span> - )} - </div> - </form> - ); -} diff --git a/editor/app/homepage/actions.ts b/editor/app/homepage/actions.ts @@ -1,52 +0,0 @@ -"use server"; - -import { revalidatePath } from "next/cache"; -import { getPaths } from "yt-dlp-transcript-common/lib/paths"; -import { - getHomepageConfig, - writeHomepageConfig, - parseHomepageUrl, - type HomepageConfig, -} from "yt-dlp-transcript-common/lib/homepage"; - -export type SaveResult = { ok: true } | { ok: false; error: string }; - -// Branding/config for the hub landing site. The hub is a single cross-site -// landing page (no authored Markdown pages), so this is the only homepage action. -export async function saveHomepageConfigAction( - _prev: SaveResult | undefined, - formData: FormData, -): Promise<SaveResult> { - const paths = getPaths(); - const current = getHomepageConfig(paths); - const siteTitle = String(formData.get("siteTitle") ?? "").trim(); - const headerTitle = String(formData.get("headerTitle") ?? "").trim(); - if (!siteTitle) return { ok: false, error: "Site title is required" }; - if (!headerTitle) return { ok: false, error: "Header title is required" }; - const siteUrlRaw = String(formData.get("siteUrl") ?? "").trim(); - if (siteUrlRaw && !parseHomepageUrl(siteUrlRaw)) { - return { - ok: false, - error: "Public URL must be an absolute http(s):// URL, or left blank.", - }; - } - const cloudflareProject = String( - formData.get("cloudflareProject") ?? "", - ).trim(); - const next: HomepageConfig = { - ...current, - siteTitle, - headerTitle, - siteDescription: String(formData.get("siteDescription") ?? "").trim(), - homeTagline: String(formData.get("homeTagline") ?? "").trim(), - siteUrl: parseHomepageUrl(siteUrlRaw), - cloudflareProject: cloudflareProject || undefined, - }; - try { - await writeHomepageConfig(next, paths); - } catch (err) { - return { ok: false, error: err instanceof Error ? err.message : String(err) }; - } - revalidatePath("/homepage"); - return { ok: true }; -} diff --git a/editor/app/homepage/page.tsx b/editor/app/homepage/page.tsx @@ -1,33 +0,0 @@ -import type { Metadata } from "next"; -import { getPaths } from "yt-dlp-transcript-common/lib/paths"; -import { getHomepageConfig } from "yt-dlp-transcript-common/lib/homepage"; -import { HomepageConfigForm } from "./HomepageConfigForm"; - -export const dynamic = "force-dynamic"; -export const metadata: Metadata = { title: "Homepage" }; - -export default function HomepageAdminPage() { - const paths = getPaths(); - const config = getHomepageConfig(paths); - - return ( - <div className="flex flex-col gap-8"> - <div> - <h1 className="text-2xl font-semibold">Homepage (project site)</h1> - <p className="mt-1 text-sm text-muted-foreground"> - The <code>homepage</code> package builds Archilyzer&rsquo;s own site — - the marketing home, the documentation and the source download. Its{" "} - <code>/stats/</code> route is a cross-site dashboard built from the - whole channel pool. Only the operator-facing bits are edited here: the - wordmark and page title are the product&rsquo;s own, but these social - links and the deploy target are yours. - </p> - </div> - - <section className="flex flex-col gap-3"> - <h2 className="text-lg font-semibold">Config</h2> - <HomepageConfigForm config={config} /> - </section> - </div> - ); -} diff --git a/editor/app/lib/nav.test.ts b/editor/app/lib/nav.test.ts @@ -0,0 +1,45 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { NAV_GROUPS, NAV_LINKS } from "./nav"; + +// Run with: pnpm -C editor exec tsx --test "app/**/*.test.ts" +// +// The nav's arithmetic, not its looks. The IA doc's end state is FOUR GROUPS +// and ELEVEN entries, and slice 5 reached it by folding the Sites group's five +// satellites into /sites and the site's tabs. This test is what stops the +// twelfth entry from being added beside them instead of under one of them. + +test("four groups, one noun each", () => { + assert.deepEqual( + NAV_GROUPS.map((g) => g.label), + ["Corpus", "Operations", "Sites", "Machine"], + ); +}); + +test("eleven entries, every href distinct", () => { + assert.equal(NAV_LINKS.length, 11); + assert.equal(new Set(NAV_LINKS.map((l) => l.href)).size, 11); +}); + +test("the Sites group is one entry", () => { + const sites = NAV_GROUPS.find((g) => g.label === "Sites"); + assert.deepEqual(sites?.links.map((l) => l.href), ["/sites"]); +}); + +test("no retired route is in the nav", () => { + const hrefs = new Set(NAV_LINKS.map((l) => l.href)); + for (const retired of [ + "/charts", + "/aliases", + "/deploy", + "/build", + "/homepage", + "/jobs/active", + "/jobs/queue", + "/actionable", + "/auto-queue", + "/scheduler", + ]) { + assert.equal(hrefs.has(retired), false, `${retired} is retired`); + } +}); diff --git a/editor/app/lib/nav.ts b/editor/app/lib/nav.ts @@ -3,7 +3,6 @@ import { ClipboardCheck, Cpu, Globe, - House, LayoutDashboard, ListChecks, ListPlus, @@ -34,8 +33,8 @@ import { // are the `song ▸` group now, at the same URLs they always had." // // So: FOLD, DO NOT ADD, and every retired route REDIRECTS rather than 404s -// (see editor/next.config.ts). The entries marked "interim" below are the ones -// later slices fold into the page above them; the end state is eleven. +// (see editor/next.config.ts). The entries marked "interim" were folded by +// later slices; the end state is eleven, and slice 5 reached it (2026-08-30). export type NavBadgeKey = "jobs" | "changelog" | "cleanable"; @@ -81,9 +80,7 @@ export const NAV_GROUPS: NavGroup[] = [ { label: "Sites", links: [ - { href: "/sites", label: "Sites", icon: Globe, keywords: "family charts stats graphs search aliases synonyms suggestions regex search terms deploy publish release cloudflare build static export pool index stats archives normalize live chat" }, - // Interim: slice 5 folds this one into the family page (/sites). - { href: "/homepage", label: "Homepage", icon: House, keywords: "hub" }, + { href: "/sites", label: "Sites", icon: Globe, keywords: "family charts stats graphs search aliases synonyms suggestions regex search terms deploy publish release cloudflare build static export pool index stats archives normalize live chat hub homepage" }, ], }, { diff --git a/editor/app/sites/components/HomepageConfigForm.tsx b/editor/app/sites/components/HomepageConfigForm.tsx @@ -0,0 +1,85 @@ +"use client"; + +import { useActionState } from "react"; +import type { HomepageConfig } from "yt-dlp-transcript-common/lib/homepage"; +import { saveHomepageConfigAction, type SaveResult } from "../lib/homepageActions"; + +const input = + "rounded-md border border-border bg-card px-3 py-1.5 text-sm"; +const label = "flex flex-col gap-1 text-sm font-medium"; + +export function HomepageConfigForm({ config }: { config: HomepageConfig }) { + const [state, formAction, pending] = useActionState< + SaveResult | undefined, + FormData + >(saveHomepageConfigAction, undefined); + + return ( + <form action={formAction} className="flex flex-col gap-3 max-w-xl"> + <label className={label}> + Site title + <input className={input} name="siteTitle" defaultValue={config.siteTitle} /> + </label> + <label className={label}> + Header title + <input + className={input} + name="headerTitle" + defaultValue={config.headerTitle} + /> + </label> + <label className={label}> + Description + <input + className={input} + name="siteDescription" + defaultValue={config.siteDescription} + /> + </label> + <label className={label}> + Home tagline + <input + className={input} + name="homeTagline" + defaultValue={config.homeTagline} + /> + </label> + <label className={label}> + Public URL + <input + className={input} + name="siteUrl" + placeholder="https://archilyzer.pages.dev" + defaultValue={config.siteUrl ?? ""} + /> + </label> + <label className={label}> + Cloudflare Pages project + <input + className={input} + name="cloudflareProject" + defaultValue={config.cloudflareProject ?? ""} + /> + </label> + <div className="flex items-center gap-3"> + <button + type="submit" + disabled={pending} + className="rounded-md bg-info px-3 py-1.5 text-sm font-medium text-info-foreground hover:bg-info disabled:opacity-50" + > + {pending ? "Saving…" : "Save config"} + </button> + {state && !state.ok && ( + <span className="text-sm text-destructive"> + {state.error} + </span> + )} + {state && state.ok && ( + <span className="text-sm text-success"> + Saved. + </span> + )} + </div> + </form> + ); +} diff --git a/editor/app/sites/lib/homepageActions.ts b/editor/app/sites/lib/homepageActions.ts @@ -0,0 +1,52 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { + getHomepageConfig, + writeHomepageConfig, + parseHomepageUrl, + type HomepageConfig, +} from "yt-dlp-transcript-common/lib/homepage"; + +export type SaveResult = { ok: true } | { ok: false; error: string }; + +// Branding/config for the hub landing site. The hub is a single cross-site +// landing page (no authored Markdown pages), so this is the only homepage action. +export async function saveHomepageConfigAction( + _prev: SaveResult | undefined, + formData: FormData, +): Promise<SaveResult> { + const paths = getPaths(); + const current = getHomepageConfig(paths); + const siteTitle = String(formData.get("siteTitle") ?? "").trim(); + const headerTitle = String(formData.get("headerTitle") ?? "").trim(); + if (!siteTitle) return { ok: false, error: "Site title is required" }; + if (!headerTitle) return { ok: false, error: "Header title is required" }; + const siteUrlRaw = String(formData.get("siteUrl") ?? "").trim(); + if (siteUrlRaw && !parseHomepageUrl(siteUrlRaw)) { + return { + ok: false, + error: "Public URL must be an absolute http(s):// URL, or left blank.", + }; + } + const cloudflareProject = String( + formData.get("cloudflareProject") ?? "", + ).trim(); + const next: HomepageConfig = { + ...current, + siteTitle, + headerTitle, + siteDescription: String(formData.get("siteDescription") ?? "").trim(), + homeTagline: String(formData.get("homeTagline") ?? "").trim(), + siteUrl: parseHomepageUrl(siteUrlRaw), + cloudflareProject: cloudflareProject || undefined, + }; + try { + await writeHomepageConfig(next, paths); + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + revalidatePath("/sites"); + return { ok: true }; +} diff --git a/editor/app/sites/page.tsx b/editor/app/sites/page.tsx @@ -9,6 +9,7 @@ import { suggestNextVersion, } from "yt-dlp-transcript-common/lib/changelog"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { getHomepageConfig } from "yt-dlp-transcript-common/lib/homepage"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { listSites } from "yt-dlp-transcript-common/lib/site"; import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; @@ -19,6 +20,7 @@ import { BuildButtons } from "./components/BuildButtons"; import { BuildModeToggle } from "./components/BuildModeToggle"; import { BuildSitesPanel } from "./components/BuildSitesPanel"; import { CutReleaseForm } from "./components/CutReleaseForm"; +import { HomepageConfigForm } from "./components/HomepageConfigForm"; import { DeleteSiteButton, MigrateButton } from "./components/SiteListActions"; export const dynamic = "force-dynamic"; @@ -173,6 +175,23 @@ export default async function SitesPage() { /> </section> + {/* The family's own hub — a site of sorts, but never a [siteId]: its + config lives in sites/_homepage, which isValidSiteId rejects. */} + <section className="flex flex-col gap-3 border-t border-border pt-6"> + <div> + <h2 className="text-lg font-semibold">Hub</h2> + <p className="mt-1 text-sm text-muted-foreground"> + The <code>homepage</code> package builds Archilyzer&rsquo;s own site — + the marketing home, the documentation and the source download. Its{" "} + <code>/stats/</code> route is a cross-site dashboard built from the + whole channel pool. Only the operator-facing bits are edited here: the + wordmark and page title are the product&rsquo;s own, but these social + links and the deploy target are yours. + </p> + </div> + <HomepageConfigForm config={getHomepageConfig(paths)} /> + </section> + {/* The shared pool: corpus-wide, no site involved. */} <section className="flex flex-col gap-3 border-t border-border pt-6"> <div> diff --git a/editor/e2e/navigation.spec.ts b/editor/e2e/navigation.spec.ts @@ -130,7 +130,13 @@ test.describe("navigation", () => { } await writeSite("testsite", {}); - for (const retired of ["/charts", "/aliases", "/deploy", "/build"]) { + for (const retired of [ + "/charts", + "/aliases", + "/deploy", + "/build", + "/homepage", + ]) { await page.goto(retired); await expect(page).toHaveURL(/\/sites$/); await expect( diff --git a/editor/next.config.ts b/editor/next.config.ts @@ -103,6 +103,7 @@ const nextConfig: NextConfig = { { source: "/deploy", has: [siteQuery], destination: "/sites/:site/publish", permanent: false }, { source: "/deploy", destination: "/sites", permanent: false }, { source: "/build", destination: "/sites", permanent: false }, + { source: "/homepage", destination: "/sites", permanent: false }, ]; }, // Serve the built export artifacts (stats/summaries/transcripts) through a