commit 7491a866aff24236e4a4a3e05c6818d4745ef833
parent 2d870074b807ee717c0502ac5f636674ac42972a
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 28 Sep 2026 01:38:21 -0400
sites: a Homepage section after Hub — Build homepage (Deploy after build, unticked), Deploy homepage and a preview branch, as jobs build-homepage / deploy-homepage / build-deploy-homepage
homepageDeployActions.ts mirrors hubActions.ts: build on the build queue,
deploy and build-deploy on the deploy queue, the build-deploy deploying only
on exit 0. Refused before any job: a bad preview name (previewBranchProblem)
and, for a deploy-only, no build in homepage/out (builtHomepageProblem); the
job re-checks both in deployHomepage. HomepageBuildButtons is
HubBuildButtons' shape plus a preview box judged by the same function, and a
line naming what a deploy ships (homepage/out built <when>, to archilyzer,
production or the preview alias, the live URL) and the Build index note. The
Hub paragraph loses its homepage sentence; the Homepage section says it.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
3 files changed, 326 insertions(+), 7 deletions(-)
diff --git a/editor/app/sites/components/HomepageBuildButtons.tsx b/editor/app/sites/components/HomepageBuildButtons.tsx
@@ -0,0 +1,185 @@
+"use client";
+
+import { useState } from "react";
+import {
+ MAX_PREVIEW_BRANCH,
+ previewAliasUrl,
+ previewBranchProblem,
+} from "yt-dlp-transcript-common/lib/pagesDeploy";
+import { PROJECT_URL } from "yt-dlp-transcript-common/lib/project";
+import {
+ buildAndDeployHomepageAction,
+ buildHomepageAction,
+ deployHomepageAction,
+} from "../lib/homepageDeployActions";
+import { JobLane } from "./JobLane";
+
+type Lane = {
+ kind: "build" | "build-deploy" | "deploy";
+ key: number;
+ // The preview branch AS LAUNCHED. Editing the field afterwards must not
+ // change what a running lane says it is deploying to.
+ preview: string | undefined;
+};
+
+const TITLE: Record<Lane["kind"], string> = {
+ build: "Build homepage",
+ "build-deploy": "Build & deploy homepage",
+ deploy: "Deploy homepage",
+};
+
+type Props = {
+ // The homepage's Pages project — the constant HOMEPAGE_PAGES_PROJECT, passed
+ // down because it lives beside the deploy code (server-only), not copied.
+ project: string;
+ // When homepage/out/index.html was built, formatted by the server; null
+ // when there is no build there.
+ builtAt: string | null;
+};
+
+// The homepage's build and deploy — the `homepage` package, built into
+// homepage/out and deployed to its own Pages project. HubBuildButtons' shape
+// exactly: one lane at a time (each launch replaces the previous lane; its job
+// keeps running and stays on /jobs), and "Deploy after build" starts
+// unchecked, because a homepage deploy replaces the software's public site.
+//
+// Plus a preview branch, which the hub's buttons do not have: empty is
+// production, a name is a Cloudflare Pages preview of the same project. The
+// name is judged by previewBranchProblem, the function the action and the ops
+// route refuse with, so the buttons grey out on exactly the names the server
+// would reject.
+export function HomepageBuildButtons({ project, builtAt }: Props) {
+ const [deploy, setDeploy] = useState(false);
+ const [preview, setPreview] = useState("");
+ const [lane, setLane] = useState<Lane | null>(null);
+ const [run, setRun] = useState(0);
+
+ const branch = preview.trim();
+ const problem = branch ? previewBranchProblem(branch) : null;
+ // Knowable before any deploy: project + branch and nothing else.
+ const alias = branch && problem === null ? previewAliasUrl(project, branch) : null;
+
+ function launch(kind: Lane["kind"]) {
+ const next = run + 1;
+ setRun(next);
+ setLane({ kind, key: next, preview: branch || undefined });
+ }
+
+ function trigger(l: Lane) {
+ const opts = l.preview ? { previewBranch: l.preview } : undefined;
+ return l.kind === "build"
+ ? buildHomepageAction()
+ : l.kind === "build-deploy"
+ ? buildAndDeployHomepageAction(opts)
+ : deployHomepageAction(opts);
+ }
+
+ return (
+ <div role="group" aria-label="Homepage build" className="flex flex-col gap-3">
+ <div className="flex flex-wrap items-center gap-3">
+ <button
+ type="button"
+ onClick={() => launch(deploy ? "build-deploy" : "build")}
+ // A build alone deploys nothing, so only a build that will deploy
+ // is held back by a bad preview name.
+ disabled={deploy && problem !== null}
+ className="px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50"
+ >
+ Build homepage
+ </button>
+ <label className="flex items-center gap-2 text-sm text-muted-foreground">
+ <input
+ type="checkbox"
+ checked={deploy}
+ onChange={(e) => setDeploy(e.target.checked)}
+ />
+ Deploy after build
+ </label>
+ <button
+ type="button"
+ onClick={() => launch("deploy")}
+ disabled={problem !== null}
+ className="px-3 py-2 rounded-md border border-border text-sm font-medium hover:bg-muted disabled:opacity-50"
+ >
+ Deploy homepage
+ </button>
+ <label className="flex items-center gap-2 text-sm">
+ <span className="text-muted-foreground">Preview branch</span>
+ <input
+ type="text"
+ aria-label="preview branch"
+ placeholder="none: production"
+ value={preview}
+ // Twice the limit, so an over-long name is refused with the
+ // sentence that says WHY rather than silently truncated.
+ maxLength={MAX_PREVIEW_BRANCH * 2}
+ onChange={(e) => setPreview(e.target.value)}
+ className="w-44 rounded-md border border-border bg-background px-2 py-1 font-mono text-sm"
+ />
+ </label>
+ </div>
+ {problem !== null && (
+ <p
+ role="status"
+ // Not "preview branch problem": a name that CONTAINS the input's
+ // would make every by-label lookup for the input ambiguous.
+ aria-label="preview problem"
+ className="text-sm text-destructive"
+ >
+ {problem}
+ </p>
+ )}
+ <p className="text-xs text-muted-foreground" data-testid="homepage-ships">
+ {builtAt ? (
+ <>
+ Deploy homepage ships <code>homepage/out</code>, built {builtAt},
+ </>
+ ) : (
+ <>
+ <code>homepage/out</code> holds no build yet, so Deploy homepage has
+ nothing to ship. A deploy goes
+ </>
+ )}{" "}
+ to <code>{project}</code>{" "}
+ {!branch ? (
+ <>
+ (production): <UrlLink url={PROJECT_URL} />.
+ </>
+ ) : alias ? (
+ <>
+ (preview <code>{branch}</code>): <UrlLink url={alias} />; the live
+ site is left alone.
+ </>
+ ) : (
+ <>
+ (preview <code>{branch}</code>).
+ </>
+ )}
+ </p>
+ <p className="text-xs text-muted-foreground">
+ The homepage reads the search index as it stands: run Build index
+ (under Pool jobs, below) first when its numbers should move.
+ </p>
+ {lane && (
+ <JobLane
+ key={lane.key}
+ title={TITLE[lane.kind]}
+ subtitle={
+ lane.kind === "build"
+ ? "homepage/out"
+ : `homepage/out · ${project}${lane.preview ? ` (preview ${lane.preview})` : " (production)"}`
+ }
+ trigger={() => trigger(lane)}
+ />
+ )}
+ </div>
+ );
+}
+
+function UrlLink({ url }: { url: string }) {
+ return (
+ <a className="font-mono underline" href={url} target="_blank" rel="noreferrer">
+ {url}
+ </a>
+ );
+}
diff --git a/editor/app/sites/lib/homepageDeployActions.ts b/editor/app/sites/lib/homepageDeployActions.ts
@@ -0,0 +1,104 @@
+"use server";
+
+import { builtHomepageProblem } from "yt-dlp-transcript-common/lib/builtExport";
+import { previewBranchProblem } from "yt-dlp-transcript-common/lib/pagesDeploy";
+import { getPaths, type Paths } from "yt-dlp-transcript-common/lib/paths";
+import {
+ runManagedFunction,
+ type StreamActionResult,
+} from "yt-dlp-transcript-common/jobs/streamCommand";
+import {
+ buildHomepage,
+ deployHomepage,
+ homepageOutDir,
+} from "yt-dlp-transcript-common/publish/build";
+
+// The homepage's build and deploy, as jobs — the `homepage` package, Archilyzer's
+// own site, deployed to the constant Pages project `archilyzer`
+// (HOMEPAGE_PAGES_PROJECT). The same three shapes as the hub's (hubActions.ts)
+// and the same bodies `archilyzer build homepage` / `deploy homepage` run.
+//
+// The build writes homepage/out, not export/out, so it cannot race a site
+// build for export/ — but `next build` is heavy, and the build queue is what
+// serialises heavy builds. The deploys take the deploy queue.
+const BUILD_QUEUE = "build";
+const DEPLOY_QUEUE = "deploy";
+
+// Refusals a deploy can give BEFORE any job starts: a bad preview name and —
+// for a deploy-only — no build in homepage/out. No project check: the project
+// is a constant. deployHomepage checks both again when its job runs, because a
+// queued deploy can start after the build it was going to ship is gone.
+function deployRefusal(
+ paths: Paths,
+ previewBranch: string | undefined,
+ checkBuilt: boolean,
+): string | null {
+ if (previewBranch !== undefined) {
+ const problem = previewBranchProblem(previewBranch);
+ if (problem) return problem;
+ }
+ if (checkBuilt) return builtHomepageProblem(homepageOutDir(paths));
+ return null;
+}
+
+export async function buildHomepageAction(): Promise<StreamActionResult> {
+ const paths = getPaths();
+ return runManagedFunction({
+ kind: "build-homepage",
+ queueKey: BUILD_QUEUE,
+ paths,
+ fn: async (onLog, signal) => {
+ const code = await buildHomepage({ paths, onLog, signal });
+ if (signal.aborted) return;
+ if (code !== 0) throw new Error(`Homepage build failed (exit ${code})`);
+ },
+ });
+}
+
+// Deploy the homepage already built in homepage/out: production (branch
+// `main`), or with `opts.previewBranch` a Cloudflare Pages PREVIEW of the
+// `archilyzer` project.
+export async function deployHomepageAction(opts?: {
+ previewBranch?: string;
+}): Promise<StreamActionResult> {
+ const paths = getPaths();
+ const refusal = deployRefusal(paths, opts?.previewBranch, true);
+ if (refusal) return { ok: false, error: refusal };
+ const branch = opts?.previewBranch?.trim();
+ return runManagedFunction({
+ kind: "deploy-homepage",
+ queueKey: DEPLOY_QUEUE,
+ paths,
+ fn: (onLog, signal) =>
+ deployHomepage({ paths, onLog, signal, previewBranch: branch }),
+ });
+}
+
+// Build the homepage and, only if that succeeds, deploy it — one job, one log,
+// one Cancel, on the deploy queue (as build-deploy-hub is for the hub). A failed
+// build never reaches the deploy: homepage/out would still hold the PREVIOUS
+// build, and deploying that after a failure would ship old news as new.
+export async function buildAndDeployHomepageAction(opts?: {
+ previewBranch?: string;
+}): Promise<StreamActionResult> {
+ const paths = getPaths();
+ // Before the build: learning the preview name is wrong after a build wastes it.
+ const refusal = deployRefusal(paths, opts?.previewBranch, false);
+ if (refusal) return { ok: false, error: refusal };
+ const branch = opts?.previewBranch?.trim();
+ return runManagedFunction({
+ kind: "build-deploy-homepage",
+ queueKey: DEPLOY_QUEUE,
+ paths,
+ fn: async (onLog, signal) => {
+ onLog("=== Build homepage ===\n");
+ const code = await buildHomepage({ paths, onLog, signal });
+ if (signal.aborted) return;
+ if (code !== 0) {
+ throw new Error(`Homepage build failed (exit ${code}) — not deploying.`);
+ }
+ onLog("\n=== Deploy homepage ===\n");
+ await deployHomepage({ paths, onLog, signal, previewBranch: branch });
+ },
+ });
+}
diff --git a/editor/app/sites/page.tsx b/editor/app/sites/page.tsx
@@ -8,10 +8,15 @@ import {
suggestNextVersion,
} from "yt-dlp-transcript-common/lib/changelog";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { builtHomepageAt } from "yt-dlp-transcript-common/lib/builtExport";
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";
+import {
+ HOMEPAGE_PAGES_PROJECT,
+ homepageOutDir,
+} from "yt-dlp-transcript-common/publish/build";
import { liveJobRows } from "../jobs/active/buildActiveJobs";
import { RunningJobsList } from "../jobs/components/RunningJobsList";
import { BuildAllSitesButton } from "./components/BuildAllSitesButton";
@@ -19,6 +24,7 @@ import { BuildButtons } from "./components/BuildButtons";
import { BuildModeToggle } from "./components/BuildModeToggle";
import { BuildSitesPanel } from "./components/BuildSitesPanel";
import { CutReleaseForm } from "./components/CutReleaseForm";
+import { HomepageBuildButtons } from "./components/HomepageBuildButtons";
import { HomepageConfigForm } from "./components/HomepageConfigForm";
import { HubBuildButtons } from "./components/HubBuildButtons";
import { DeleteSiteButton, MigrateButton } from "./components/SiteListActions";
@@ -30,8 +36,9 @@ export const metadata: Metadata = { title: "Sites" };
// buttons enqueue (BuildButtons.tsx → sites/lib/buildAction.ts), and only
// those. build-export and build-deploy are a site's Publish tab's, build-all
// and build-deploy-all are the batch panel's above, build-hub / deploy-hub /
-// build-deploy-hub the Hub section's — each has its own console and is not
-// repeated here.
+// build-deploy-hub the Hub section's, build-homepage / deploy-homepage /
+// build-deploy-homepage the Homepage section's — each has its own console and
+// is not repeated here.
const BUILD_KINDS = new Set([
"build-index",
"build-stats",
@@ -76,6 +83,7 @@ export default async function SitesPage() {
// (it used to drop `progress`, `tasks`, `drainable` and the reorder bounds).
const activeJobs = await liveJobRows((j) => BUILD_KINDS.has(j.kind));
const hubConfig = getHomepageConfig(paths);
+ const homepageBuiltAt = builtHomepageAt(homepageOutDir(paths));
return (
<div className="flex flex-col gap-8">
@@ -192,17 +200,39 @@ export default async function SitesPage() {
site that has a public URL, reading each archive where it is
published. This config names and brands it, and its Public URL and
Cloudflare Pages project are the hub’s own (for example{" "}
- <code>archilyzer-hub</code>). The <code>homepage</code> package —
- Archilyzer’s own site, with the docs and the source download
- — shares these social links and links to the hub, but deploys to
- its own project, <code>archilyzer</code>, with{" "}
- <code>archilyzer deploy homepage</code>.
+ <code>archilyzer-hub</code>).
</p>
</div>
<HomepageConfigForm config={hubConfig} />
<HubBuildButtons project={hubConfig.cloudflareProject ?? null} />
</section>
+ {/* The software's own site: the `homepage` package, never the hub. It
+ has no config of its own here — it shares the hub form's social
+ links — so the section is only its build and deploy. */}
+ <section className="flex flex-col gap-3 border-t border-border pt-6">
+ <div>
+ <h2 className="text-lg font-semibold">Homepage</h2>
+ <p className="mt-1 text-sm text-muted-foreground">
+ The <code>homepage</code> package is Archilyzer’s own site,
+ with the docs and the source download. It shares the Hub
+ form’s social links and links to the hub, but it builds into{" "}
+ <code>homepage/out</code> and deploys to its own Pages project,{" "}
+ <code>{HOMEPAGE_PAGES_PROJECT}</code>. The buttons run the same code
+ as <code>archilyzer build homepage</code> and{" "}
+ <code>archilyzer deploy homepage</code>, as jobs.
+ </p>
+ </div>
+ <HomepageBuildButtons
+ project={HOMEPAGE_PAGES_PROJECT}
+ builtAt={
+ homepageBuiltAt === null
+ ? null
+ : new Date(homepageBuiltAt).toLocaleString()
+ }
+ />
+ </section>
+
{/* The shared pool: corpus-wide, no site involved. */}
<section className="flex flex-col gap-3 border-t border-border pt-6">
<div>