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:
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’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’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’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’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