commit eb5e845e405d3e215af31d12aaad040b50049782
parent b57aca2ccd9644e0fc3d8214a25c53fbaa32833f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sun, 30 Aug 2026 13:00:29 -0400
sites: Publish is the site's tab; the family page cuts the release and batches the builds
/deploy was half one site's page and half the family's. The per-site half —
"Build & deploy" and the "Individual steps" disclosure — is /sites/<id>/publish,
the fourth tab; the family's half — Release notes, the Basic/Docker build mode
and "Build all sites" / "Or pick specific sites" — is on /sites, under the list.
The deploy and build server modules are editor/app/sites/lib now and their
components are flat in editor/app/sites/components beside SiteForm; build/ and
deploy/ were already one module in two directories (buildAction imported
buildDeployCore, four deploy components imported buildAction) and are one
directory at last. /changelog keeps CutReleaseForm, one import line moved.
The three all-sites notices are gone with the null site they described:
BuildDeployButton, BuildExportButton and DeployButton take a real siteId and
siteTitle, because a site's own page always has one. The
missing-Cloudflare-project branch is untouched — that is what the button says
when it cannot deploy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat:
37 files changed, 1450 insertions(+), 1460 deletions(-)
diff --git a/editor/app/build/buildAction.ts b/editor/app/build/buildAction.ts
@@ -1,449 +0,0 @@
-"use server";
-
-import { revalidatePath } from "next/cache";
-import { buildIndex } from "yt-dlp-transcript-common/controller/buildIndex";
-import { buildStats } from "yt-dlp-transcript-common/controller/buildStats";
-import { normalizeAllTranscripts } from "yt-dlp-transcript-common/controller/normalizeAll";
-import { normalizeAllLiveChat } from "yt-dlp-transcript-common/controller/normalizeAllLiveChat";
-import {
- archiveTranscripts,
- archiveCombinedTranscripts,
- type ArchiveBuildOptions,
-} from "yt-dlp-transcript-common/controller/archiveTranscripts";
-import {
- archiveLiveChat,
- archiveCombinedLiveChat,
-} from "yt-dlp-transcript-common/controller/archiveLiveChat";
-import { getPaths } from "yt-dlp-transcript-common/lib/paths";
-import { getSite, listSites, type Site } from "yt-dlp-transcript-common/lib/site";
-import {
- runManagedFunction,
- type StreamActionResult,
-} from "yt-dlp-transcript-common/jobs/streamCommand";
-import {
- dockerAvailable,
- dockerSiteOutDir,
- resolveOutDir,
- runArchiveUploadIntoLog,
- runBuildPhase,
- runDeployIntoLog,
- runDockerBuildAllPhase,
- runDockerDeployAllPhase,
- type SiteBuildOutcome,
- type SiteDeployOutcome,
-} from "../deploy/buildDeployCore";
-
-const DEFAULT_BUILD_QUEUE = "build";
-const DEPLOY_QUEUE = "deploy";
-
-export async function buildIndexAction(
- queueKey?: string,
-): Promise<StreamActionResult> {
- const paths = getPaths();
- const result = await runManagedFunction({
- kind: "build-index",
- queueKey: queueKey === undefined ? DEFAULT_BUILD_QUEUE : queueKey.trim(),
- paths,
- fn: async (onLog) => {
- await buildIndex({ paths, onLog });
- },
- });
- return result;
-}
-
-export async function buildStatsAction(
- queueKey?: string,
-): Promise<StreamActionResult> {
- const paths = getPaths();
- return runManagedFunction({
- kind: "build-stats",
- queueKey: queueKey === undefined ? DEFAULT_BUILD_QUEUE : queueKey.trim(),
- paths,
- fn: async (onLog, signal) => {
- await buildStats({ paths, onLog, signal });
- // every site's Charts tab previews the dataset this just rebuilt.
- revalidatePath("/sites/[siteId]/charts", "page");
- },
- });
-}
-
-export async function buildExportAction(
- siteId: string,
- queueKey?: string,
- skipData?: boolean,
- skipArchives?: boolean,
-): Promise<StreamActionResult> {
- const paths = getPaths();
- const id = siteId.trim();
- if (!id) {
- return { ok: false, error: "Select a site to build" };
- }
- // Run as a managed function (not a raw command) so the build can route on the
- // persisted build mode (basic vs docker) via runBuildPhase, and so the same
- // child-streaming path is shared with build-then-deploy. Cancel still works:
- // the abort signal is forwarded to the child by runChildIntoLog.
- // `skipData` composes from the existing .export-index staging without
- // regenerating the pool-wide index/stats/charts.
- return runManagedFunction({
- kind: "build-export",
- queueKey: queueKey === undefined ? DEFAULT_BUILD_QUEUE : queueKey.trim(),
- paths,
- fn: async (onLog, signal) => {
- const code = await runBuildPhase(onLog, signal, id, paths, {
- skipData,
- skipArchives,
- });
- if (signal.aborted) return;
- if (code !== 0) throw new Error(`Build failed (exit ${code})`);
- },
- });
-}
-
-// One-click build-then-deploy: build the active site and, only if the build
-// succeeds (and wasn't cancelled), deploy it — all in ONE managed job so the UI
-// shows a single combined streamed log with a single Cancel. Serialized on the
-// deploy queue so it never overlaps a standalone deploy or another build-deploy.
-export async function buildAndDeployAction(
- siteId: string,
- skipArchives?: boolean,
-): Promise<StreamActionResult> {
- const paths = getPaths();
- const id = siteId.trim();
- if (!id) {
- return { ok: false, error: "Select a site to build and deploy" };
- }
- const site = getSite(id, paths);
- if (!site.cloudflareProject) {
- return {
- ok: false,
- error: `Site "${site.siteId}" has no Cloudflare Pages project configured.`,
- };
- }
- return runManagedFunction({
- kind: "build-deploy",
- queueKey: DEPLOY_QUEUE,
- paths,
- fn: async (onLog, signal) => {
- onLog("=== Build ===\n");
- const buildCode = await runBuildPhase(onLog, signal, id, paths, {
- skipArchives,
- });
- // A cancel mid-build must NOT proceed to deploy.
- if (signal.aborted) return;
- if (buildCode !== 0) {
- throw new Error(`Build failed (exit ${buildCode}) — not deploying.`);
- }
- onLog("\n=== Deploy ===\n");
- // Push oversize archives to R2 before the Pages deploy (no-op when R2
- // isn't configured), so the published manifest URLs resolve.
- const uploadCode = await runArchiveUploadIntoLog(onLog, signal, site, paths);
- if (signal.aborted) return;
- if (uploadCode !== 0) {
- throw new Error(`Archive R2 upload failed (exit ${uploadCode}).`);
- }
- const deployCode = await runDeployIntoLog(
- onLog,
- signal,
- site,
- resolveOutDir(id, paths),
- paths,
- );
- if (signal.aborted) return;
- if (deployCode !== 0) {
- throw new Error(`Deploy failed (exit ${deployCode}).`);
- }
- },
- });
-}
-
-export async function normalizeTranscriptsAction(
- queueKey?: string,
-): Promise<StreamActionResult> {
- const paths = getPaths();
- return runManagedFunction({
- kind: "normalize-transcripts",
- queueKey: queueKey === undefined ? DEFAULT_BUILD_QUEUE : queueKey.trim(),
- paths,
- fn: async (onLog, signal) => {
- await normalizeAllTranscripts({ paths, onLog, signal });
- },
- });
-}
-
-export async function archiveTranscriptsAction(
- build: Partial<ArchiveBuildOptions>,
- queueKey?: string,
-): Promise<StreamActionResult> {
- const paths = getPaths();
- return runManagedFunction({
- kind: "archive-transcripts",
- queueKey: queueKey === undefined ? DEFAULT_BUILD_QUEUE : queueKey.trim(),
- paths,
- fn: async (onLog, signal) => {
- await archiveTranscripts({ paths, onLog, signal, build });
- },
- });
-}
-
-export async function archiveCombinedTranscriptsAction(
- build: Partial<ArchiveBuildOptions>,
- queueKey?: string,
-): Promise<StreamActionResult> {
- const paths = getPaths();
- return runManagedFunction({
- kind: "archive-combined-transcripts",
- queueKey: queueKey === undefined ? DEFAULT_BUILD_QUEUE : queueKey.trim(),
- paths,
- fn: async (onLog, signal) => {
- await archiveCombinedTranscripts({ paths, onLog, signal, build });
- },
- });
-}
-
-export async function normalizeLiveChatAction(
- queueKey?: string,
-): Promise<StreamActionResult> {
- const paths = getPaths();
- return runManagedFunction({
- kind: "normalize-live-chat",
- queueKey: queueKey === undefined ? DEFAULT_BUILD_QUEUE : queueKey.trim(),
- paths,
- fn: async (onLog, signal) => {
- await normalizeAllLiveChat({ paths, onLog, signal });
- },
- });
-}
-
-export async function archiveLiveChatAction(
- build: Partial<ArchiveBuildOptions>,
- queueKey?: string,
-): Promise<StreamActionResult> {
- const paths = getPaths();
- return runManagedFunction({
- kind: "archive-live-chat",
- queueKey: queueKey === undefined ? DEFAULT_BUILD_QUEUE : queueKey.trim(),
- paths,
- fn: async (onLog, signal) => {
- await archiveLiveChat({ paths, onLog, signal, build });
- },
- });
-}
-
-export async function archiveCombinedLiveChatAction(
- build: Partial<ArchiveBuildOptions>,
- queueKey?: string,
-): Promise<StreamActionResult> {
- const paths = getPaths();
- return runManagedFunction({
- kind: "archive-combined-live-chat",
- queueKey: queueKey === undefined ? DEFAULT_BUILD_QUEUE : queueKey.trim(),
- paths,
- fn: async (onLog, signal) => {
- await archiveCombinedLiveChat({ paths, onLog, signal, build });
- },
- });
-}
-
-// Build EVERY configured site, then deploy each — the docker-pipeline entry point.
-// With a container engine available: run the shared data phase + archive warm
-// once, fan the per-site builds out in parallel (capped by maxParallelBuilds),
-// then deploy the built sites SERIALLY on the host. Without one: fall back to a
-// serial host build+deploy per site. One managed job (single streamed log +
-// Cancel), serialized on the deploy queue so it never overlaps another deploy.
-export async function buildAndDeployAllSitesAction(
- skipArchives?: boolean,
-): Promise<StreamActionResult> {
- const paths = getPaths();
- const sites = listSites(paths);
- if (sites.length === 0) return { ok: false, error: "No sites configured." };
- return runManagedFunction({
- kind: "build-deploy-all",
- queueKey: DEPLOY_QUEUE,
- paths,
- fn: async (onLog, signal) => {
- const useDocker = await dockerAvailable(signal);
- if (signal.aborted) return;
-
- if (!useDocker) {
- onLog(
- "[notice] No container engine available (start the daemon, or set " +
- "DOCKER_BIN) — falling back to serial host build+deploy.\n",
- );
- const deploys = await basicBuildAndDeployAll(
- onLog,
- signal,
- sites,
- paths,
- skipArchives,
- );
- if (signal.aborted) return;
- summarize(onLog, deploys);
- return;
- }
-
- // Docker: build all (Phase A + B), barrier, then deploy all serially (C).
- const outcomes = await runDockerBuildAllPhase(onLog, signal, sites, paths, {
- skipArchives,
- });
- if (signal.aborted) return;
- const builtOk = new Set(
- outcomes.filter((o) => o.code === 0).map((o) => o.siteId),
- );
- onLog(`\n=== Phase C: deploy (${builtOk.size}/${sites.length} built) ===`);
- const deploys = await runDockerDeployAllPhase(
- onLog,
- signal,
- sites,
- builtOk,
- paths,
- (id) => dockerSiteOutDir(paths, id),
- );
- if (signal.aborted) return;
- summarize(onLog, deploys, outcomes);
- },
- });
-}
-
-// Build EVERY configured site WITHOUT deploying — e.g. to warm caches or validate
-// that each site composes and next-builds. Docker mode fans the builds out in
-// parallel; otherwise it's a serial host loop (whose shared export/out is
-// overwritten per site, so only the last survives — build-all is chiefly a docker
-// feature). Queued on the build queue.
-export async function buildAllSitesAction(
- skipArchives?: boolean,
-): Promise<StreamActionResult> {
- const paths = getPaths();
- const sites = listSites(paths);
- if (sites.length === 0) return { ok: false, error: "No sites configured." };
- return runManagedFunction({
- kind: "build-all",
- queueKey: DEFAULT_BUILD_QUEUE,
- paths,
- fn: async (onLog, signal) => {
- const useDocker = await dockerAvailable(signal);
- if (signal.aborted) return;
- let outcomes: SiteBuildOutcome[];
- if (useDocker) {
- outcomes = await runDockerBuildAllPhase(onLog, signal, sites, paths, {
- skipArchives,
- });
- } else {
- onLog(
- "[notice] No container engine available — building sites serially on the host.\n",
- );
- outcomes = [];
- for (let i = 0; i < sites.length; i++) {
- if (signal.aborted) return;
- const site = sites[i];
- onLog(`\n=== Build ${site.siteId} (${i + 1}/${sites.length}) ===`);
- const code = await runBuildPhase(onLog, signal, site.siteId, paths, {
- skipData: i > 0,
- skipArchives,
- });
- outcomes.push({ siteId: site.siteId, code });
- }
- }
- if (signal.aborted) return;
- const ok = outcomes.filter((o) => o.code === 0).length;
- const failed = outcomes
- .filter((o) => o.code !== 0)
- .map((o) => `${o.siteId} (exit ${o.code})`);
- onLog(`\n=== Summary: ${ok}/${sites.length} built ===`);
- if (failed.length) throw new Error(`Build failures: ${failed.join(", ")}`);
- },
- });
-}
-
-// Serial host build+deploy fallback for buildAndDeployAllSitesAction when no
-// container engine is available. Interleaved (build A → deploy A → build B …)
-// because the basic build shares export/out — building all first would leave only
-// the last site's bundle. Rebuilds the pool-wide data once (first site).
-async function basicBuildAndDeployAll(
- onLog: (line: string) => void,
- signal: AbortSignal,
- sites: Site[],
- paths: ReturnType<typeof getPaths>,
- skipArchives?: boolean,
-): Promise<SiteDeployOutcome[]> {
- const basicOut = resolveOutDir("", paths);
- const deploys: SiteDeployOutcome[] = [];
- for (let i = 0; i < sites.length; i++) {
- if (signal.aborted) break;
- const site = sites[i];
- onLog(`\n=== Build ${site.siteId} (${i + 1}/${sites.length}) ===`);
- const code = await runBuildPhase(onLog, signal, site.siteId, paths, {
- skipData: i > 0,
- skipArchives,
- });
- if (signal.aborted) break;
- if (code !== 0) {
- onLog(`[${site.siteId}] build FAILED (exit ${code}) — skipping deploy`);
- deploys.push({
- siteId: site.siteId,
- status: "skipped",
- reason: `build exit ${code}`,
- });
- continue;
- }
- if (!site.cloudflareProject) {
- onLog(`[${site.siteId}] deploy skipped — no Cloudflare project configured`);
- deploys.push({
- siteId: site.siteId,
- status: "skipped",
- reason: "no cloudflareProject",
- });
- continue;
- }
- onLog(`=== Deploy ${site.siteId} ===`);
- const up = await runArchiveUploadIntoLog(onLog, signal, site, paths);
- if (signal.aborted) break;
- if (up !== 0) {
- onLog(`[${site.siteId}] deploy FAILED — R2 upload exit ${up}`);
- deploys.push({
- siteId: site.siteId,
- status: "failed",
- reason: `R2 upload exit ${up}`,
- });
- continue;
- }
- const dep = await runDeployIntoLog(onLog, signal, site, basicOut, paths);
- if (signal.aborted) break;
- if (dep !== 0) {
- onLog(`[${site.siteId}] deploy FAILED — exit ${dep}`);
- deploys.push({
- siteId: site.siteId,
- status: "failed",
- reason: `deploy exit ${dep}`,
- });
- continue;
- }
- onLog(`[${site.siteId}] deployed.`);
- deploys.push({ siteId: site.siteId, status: "deployed" });
- }
- return deploys;
-}
-
-// Log the final tally and THROW if anything failed, so the managed job ends in a
-// failed state the UI surfaces. Skips are reported but not failures.
-function summarize(
- onLog: (line: string) => void,
- deploys: SiteDeployOutcome[],
- builds?: SiteBuildOutcome[],
-): void {
- const deployed = deploys
- .filter((d) => d.status === "deployed")
- .map((d) => d.siteId);
- const skipped = deploys.filter((d) => d.status === "skipped");
- const failed = [
- ...(builds ?? [])
- .filter((b) => b.code !== 0)
- .map((b) => `${b.siteId} (build exit ${b.code})`),
- ...deploys
- .filter((d) => d.status === "failed")
- .map((d) => `${d.siteId} (deploy: ${d.reason})`),
- ];
- onLog(`\n=== Summary: ${deployed.length} deployed ===`);
- if (deployed.length) onLog(` deployed: ${deployed.join(", ")}`);
- for (const s of skipped) onLog(` skipped ${s.siteId}: ${s.reason}`);
- if (failed.length) throw new Error(`Failures: ${failed.join(", ")}`);
-}
diff --git a/editor/app/build/components/BuildButtons.tsx b/editor/app/build/components/BuildButtons.tsx
@@ -17,7 +17,7 @@ import {
buildStatsAction,
normalizeLiveChatAction,
normalizeTranscriptsAction,
-} from "../buildAction";
+} from "../../sites/lib/buildAction";
import { ArchiveOptionsControl } from "./ArchiveOptionsControl";
type Props = {
diff --git a/editor/app/changelog/page.tsx b/editor/app/changelog/page.tsx
@@ -9,7 +9,7 @@ import {
suggestNextVersion,
} from "yt-dlp-transcript-common/lib/changelog";
import { MarkChangelogSeen } from "../components/MarkChangelogSeen";
-import { CutReleaseForm } from "../deploy/components/CutReleaseForm";
+import { CutReleaseForm } from "../sites/components/CutReleaseForm";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Changelog" };
diff --git a/editor/app/deploy/buildModeAction.ts b/editor/app/deploy/buildModeAction.ts
@@ -1,26 +0,0 @@
-"use server";
-
-import { revalidatePath } from "next/cache";
-import {
- getSettings,
- isBuildMode,
- writeSettings,
- type BuildMode,
-} from "yt-dlp-transcript-common/lib/settings";
-
-// Persist the build-mode choice (Basic vs Docker) from the deploy page so it
-// becomes the default for every subsequent build. Only the mode is touched here;
-// the image/concurrency knobs are edited on the Settings page. writeSettings
-// re-sanitizes the whole object regardless.
-export async function setBuildModeAction(
- mode: BuildMode,
-): Promise<{ ok: boolean }> {
- if (!isBuildMode(mode)) return { ok: false };
- const settings = getSettings();
- await writeSettings({
- ...settings,
- buildPipeline: { ...settings.buildPipeline, mode },
- });
- revalidatePath("/deploy");
- return { ok: true };
-}
diff --git a/editor/app/deploy/components/BuildAllSitesButton.tsx b/editor/app/deploy/components/BuildAllSitesButton.tsx
@@ -1,78 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import {
- buildAllSitesAction,
- buildAndDeployAllSitesAction,
-} from "../../build/buildAction";
-import { JobLane } from "./JobLane";
-
-type Lane = { deploy: boolean; skipArchives: boolean; key: number };
-
-// One-click orchestrated pipeline over EVERY site as a SINGLE managed job (one
-// log, one Cancel): in Docker mode it runs the shared data phase + archive warm
-// once, fans the per-site builds out in parallel (capped by maxParallelBuilds),
-// then deploys the built sites serially. Without a container engine it falls back
-// to a serial host build+deploy. Distinct from the per-site panel below, which
-// launches one separate job per selected site.
-export function BuildAllSitesButton({ dockerMode }: { dockerMode: boolean }) {
- const [deploy, setDeploy] = useState(true);
- const [skipArchives, setSkipArchives] = useState(false);
- const [lane, setLane] = useState<Lane | null>(null);
- const [run, setRun] = useState(0);
-
- function launch() {
- const next = run + 1;
- setRun(next);
- setLane({ deploy, skipArchives, key: next });
- }
-
- return (
- <div className="flex flex-col gap-3">
- <div className="flex flex-wrap items-center gap-3">
- <button
- type="button"
- onClick={launch}
- className="px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90"
- >
- {deploy ? "Build & deploy all sites" : "Build all sites"}
- </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>
- <label className="flex items-center gap-2 text-sm text-muted-foreground">
- <input
- type="checkbox"
- checked={skipArchives}
- onChange={(e) => setSkipArchives(e.target.checked)}
- />
- Skip archive zips
- </label>
- </div>
- <p className="text-xs text-muted-foreground">
- {dockerMode
- ? "Docker pipeline: shared data phase runs once, per-site builds run in parallel, then deploys run serially."
- : "Docker mode is off — this runs a serial host build+deploy fallback (one site at a time)."}
- </p>
- {lane && (
- <JobLane
- key={lane.key}
- title={lane.deploy ? "Build & deploy all sites" : "Build all sites"}
- subtitle={
- dockerMode ? "Docker pipeline · parallel builds" : "Serial host fallback"
- }
- trigger={() =>
- lane.deploy
- ? buildAndDeployAllSitesAction(lane.skipArchives)
- : buildAllSitesAction(lane.skipArchives)
- }
- />
- )}
- </div>
- );
-}
diff --git a/editor/app/deploy/components/BuildDeployButton.tsx b/editor/app/deploy/components/BuildDeployButton.tsx
@@ -1,74 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
-import { buildAndDeployAction } from "../../build/buildAction";
-import { cancelJobAction } from "../../jobs/actions";
-
-type Props = {
- // The active site to build+deploy, or null under "all sites".
- siteId: string | null;
- siteTitle: string | null;
- // The site's Cloudflare Pages project, or null if it has none (can't deploy).
- cloudflareProject: string | null;
-};
-
-// One-click build-then-deploy for the active site: builds, and on success
-// deploys — one combined streamed log, one Cancel (see buildAndDeployAction).
-export function BuildDeployButton({
- siteId,
- siteTitle,
- cloudflareProject,
-}: Props) {
- const ready = Boolean(siteId && cloudflareProject);
- const [skipArchives, setSkipArchives] = useState(false);
-
- let notice: React.ReactNode;
- if (!siteId) {
- notice = (
- <p className="text-sm text-warning">
- Select a specific site from the sidebar to build & deploy.
- </p>
- );
- } else if (!cloudflareProject) {
- notice = (
- <p className="text-sm text-warning">
- <strong>{siteTitle ?? siteId}</strong> has no Cloudflare Pages project
- configured — set one on the site's page to deploy.
- </p>
- );
- } else {
- notice = (
- <p className="text-sm text-muted-foreground">
- Builds then deploys <strong>{siteTitle ?? siteId}</strong> (
- <code>{siteId}</code> → <code>{cloudflareProject}</code>).
- </p>
- );
- }
-
- return (
- <StreamActionLog
- trigger={() => buildAndDeployAction(siteId as string, skipArchives)}
- cancelAction={cancelJobAction}
- buttonLabel="Build & deploy"
- runningLabel="Building & deploying…"
- label="Build and deploy"
- disabled={!ready}
- extraControls={
- <div className="flex flex-col gap-2">
- {notice}
- {ready && (
- <label className="flex items-center gap-2 text-sm">
- <input
- type="checkbox"
- checked={skipArchives}
- onChange={(e) => setSkipArchives(e.target.checked)}
- />
- Skip archive zips (transcript & live-chat downloads)
- </label>
- )}
- </div>
- }
- />
- );
-}
diff --git a/editor/app/deploy/components/BuildExportButton.tsx b/editor/app/deploy/components/BuildExportButton.tsx
@@ -1,79 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
-import { buildExportAction } from "../../build/buildAction";
-import { QueueControl } from "../../components/QueueControl";
-import { cancelJobAction } from "../../jobs/actions";
-
-type Props = {
- existingQueues: string[];
- // The active site to build, or null under "all sites".
- siteId: string | null;
- siteTitle: string | null;
-};
-
-const DEFAULT_BUILD_QUEUE = "build";
-
-export function BuildExportButton({ existingQueues, siteId, siteTitle }: Props) {
- const [exportQueue, setExportQueue] = useState(DEFAULT_BUILD_QUEUE);
- const [skipData, setSkipData] = useState(false);
- const [skipArchives, setSkipArchives] = useState(false);
-
- return (
- <StreamActionLog
- trigger={() =>
- buildExportAction(siteId as string, exportQueue, skipData, skipArchives)
- }
- cancelAction={cancelJobAction}
- buttonLabel="Build static export"
- runningLabel="Building static export…"
- disabled={!siteId}
- extraControls={
- <div className="flex flex-col gap-2">
- {siteId ? (
- <p className="text-sm text-muted-foreground">
- Building <strong>{siteTitle ?? siteId}</strong> (
- <code>{siteId}</code>).
- </p>
- ) : (
- <p className="text-sm text-warning">
- Select a specific site from the sidebar to build.
- </p>
- )}
- <label className="flex items-center gap-2 text-sm">
- <input
- type="checkbox"
- checked={skipData}
- onChange={(e) => setSkipData(e.target.checked)}
- />
- Skip data rebuild (index, stats, charts)
- </label>
- <p className="text-xs text-muted-foreground">
- Reuses the existing index/stats — only composes the site and runs
- next build. Do a full build first if data is stale.
- </p>
- <label className="flex items-center gap-2 text-sm">
- <input
- type="checkbox"
- checked={skipArchives}
- onChange={(e) => setSkipArchives(e.target.checked)}
- />
- Skip archive zips (transcript & live-chat downloads)
- </label>
- <p className="text-xs text-muted-foreground">
- Skips generating the downloadable archive zips for this build only.
- Leave off to keep the site's Downloads page current.
- </p>
- <QueueControl
- value={exportQueue}
- onChange={setExportQueue}
- defaultQueueKey={DEFAULT_BUILD_QUEUE}
- existingQueues={existingQueues}
- actionLabel="Build static export"
- />
- </div>
- }
- />
- );
-}
diff --git a/editor/app/deploy/components/BuildModeToggle.tsx b/editor/app/deploy/components/BuildModeToggle.tsx
@@ -1,63 +0,0 @@
-"use client";
-
-import { useState, useTransition } from "react";
-import type { BuildMode } from "yt-dlp-transcript-common/lib/settings";
-import { setBuildModeAction } from "../buildModeAction";
-
-// Segmented Basic | Docker control. Persists the choice as the global build-mode
-// default (setBuildModeAction → settings.json) so every subsequent build uses it.
-export function BuildModeToggle({ mode: initialMode }: { mode: BuildMode }) {
- const [mode, setMode] = useState<BuildMode>(initialMode);
- const [pending, startTransition] = useTransition();
-
- function choose(next: BuildMode) {
- if (next === mode || pending) return;
- setMode(next);
- startTransition(async () => {
- const res = await setBuildModeAction(next);
- if (!res.ok) setMode(initialMode);
- });
- }
-
- const options: { value: BuildMode; label: string }[] = [
- { value: "basic", label: "Basic" },
- { value: "docker", label: "Docker" },
- ];
-
- return (
- <div className="flex flex-col gap-1.5">
- <span className="text-xs font-medium text-muted-foreground">Build mode</span>
- <div
- role="group"
- aria-label="Build mode"
- className="inline-flex w-fit rounded-md border border-border overflow-hidden text-sm"
- >
- {options.map((opt) => {
- const active = mode === opt.value;
- return (
- <button
- key={opt.value}
- type="button"
- aria-pressed={active}
- disabled={pending}
- onClick={() => choose(opt.value)}
- className={
- "px-3 py-1.5 font-medium transition-colors disabled:opacity-60 " +
- (active
- ? "bg-primary text-primary-foreground"
- : "bg-transparent text-muted-foreground hover:bg-muted")
- }
- >
- {opt.label}
- </button>
- );
- })}
- </div>
- {mode === "docker" && (
- <span className="text-xs text-warning">
- Docker builds are a follow-up — runs the basic build for now.
- </span>
- )}
- </div>
- );
-}
diff --git a/editor/app/deploy/components/BuildSitesPanel.tsx b/editor/app/deploy/components/BuildSitesPanel.tsx
@@ -1,153 +0,0 @@
-"use client";
-
-import { useState } from "react";
-import { buildExportAction, buildAndDeployAction } from "../../build/buildAction";
-import { JobLane } from "./JobLane";
-
-export type SiteOption = {
- siteId: string;
- siteTitle: string;
- cloudflareProject: string | null;
-};
-
-type Lane = { siteId: string; title: string; deploy: boolean; key: string };
-
-// Batch build (and optionally deploy) several sites at once. Each selected site
-// launches its own managed job, rendered as its own live JobLane. In Basic mode
-// the jobs share the build/deploy queue and run one at a time (the export/ tree
-// is shared); Docker mode (a follow-up) unlocks true parallelism.
-export function BuildSitesPanel({
- sites,
- serial,
-}: {
- sites: SiteOption[];
- serial: boolean;
-}) {
- const [selected, setSelected] = useState<Set<string>>(new Set());
- const [deployAfter, setDeployAfter] = useState(false);
- const [lanes, setLanes] = useState<Lane[] | null>(null);
- // Bumps so re-running the same selection remounts fresh lanes.
- const [run, setRun] = useState(0);
-
- function toggle(siteId: string) {
- setSelected((prev) => {
- const next = new Set(prev);
- if (next.has(siteId)) next.delete(siteId);
- else next.add(siteId);
- return next;
- });
- }
-
- function launch() {
- const chosen = sites.filter((s) => selected.has(s.siteId));
- if (chosen.length === 0) return;
- const nextRun = run + 1;
- setRun(nextRun);
- setLanes(
- chosen.map((s) => ({
- siteId: s.siteId,
- title: s.siteTitle,
- deploy: deployAfter && Boolean(s.cloudflareProject),
- key: `${nextRun}:${s.siteId}`,
- })),
- );
- }
-
- const missingProject =
- deployAfter &&
- sites.some((s) => selected.has(s.siteId) && !s.cloudflareProject);
-
- return (
- // Named group: this panel's "Deploy after build" checkbox is a twin of the
- // one in BuildAllSitesButton, and both sit in the same <section> on /deploy.
- // Without a name to scope by, getByLabel("Deploy after build") resolves to
- // two elements and every assertion on it dies with a strict-mode violation.
- <div
- role="group"
- aria-label="Build specific sites"
- className="flex flex-col gap-4"
- >
- <div className="flex flex-col gap-2 rounded-md border border-border p-3">
- <div className="flex flex-wrap gap-x-6 gap-y-2">
- {sites.map((s) => (
- <label
- key={s.siteId}
- className="flex items-center gap-2 text-sm"
- >
- <input
- type="checkbox"
- checked={selected.has(s.siteId)}
- onChange={() => toggle(s.siteId)}
- aria-label={`Select ${s.siteTitle}`}
- />
- <span className="font-medium">{s.siteTitle}</span>
- <code className="text-xs text-muted-foreground">{s.siteId}</code>
- {!s.cloudflareProject && (
- <span className="text-xs text-warning">
- no deploy target
- </span>
- )}
- </label>
- ))}
- {sites.length === 0 && (
- <span className="text-sm italic text-muted-foreground">
- No sites configured.
- </span>
- )}
- </div>
- </div>
-
- <div className="flex flex-wrap items-center gap-3">
- <button
- type="button"
- onClick={launch}
- disabled={selected.size === 0}
- className="px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50"
- >
- {deployAfter ? "Build & deploy selected" : "Build selected"}
- </button>
- <label className="flex items-center gap-2 text-sm text-muted-foreground">
- <input
- type="checkbox"
- checked={deployAfter}
- onChange={(e) => setDeployAfter(e.target.checked)}
- />
- Deploy after build
- </label>
- <span className="text-xs text-muted-foreground">
- {selected.size} selected
- </span>
- </div>
-
- {missingProject && (
- <p className="text-xs text-warning">
- Some selected sites have no Cloudflare Pages project — those will build
- only (deploy is skipped for them).
- </p>
- )}
- {serial && (
- <p className="text-xs text-muted-foreground">
- Basic mode runs these one at a time (the build output tree is shared).
- Use “Build all sites” above in Docker mode for true parallel builds.
- </p>
- )}
-
- {lanes && lanes.length > 0 && (
- <div className="flex flex-col gap-2">
- {lanes.map((lane) => (
- <JobLane
- key={lane.key}
- title={lane.title}
- subtitle={`${lane.siteId}${lane.deploy ? " · build + deploy" : " · build"}`}
- trigger={() =>
- lane.deploy
- ? buildAndDeployAction(lane.siteId)
- : buildExportAction(lane.siteId)
- }
- />
- ))}
- </div>
- )}
- </div>
- );
-}
diff --git a/editor/app/deploy/components/CutReleaseForm.tsx b/editor/app/deploy/components/CutReleaseForm.tsx
@@ -1,91 +0,0 @@
-"use client";
-
-import { useActionState } from "react";
-import {
- cutReleaseAction,
- type CutReleaseState,
-} from "../cutReleaseAction";
-
-type Props = {
- workspace: "editor" | "export";
- lastVersion: string | null;
- suggestedVersion: string;
- hasUnreleased: boolean;
-};
-
-export function CutReleaseForm({
- workspace,
- lastVersion,
- suggestedVersion,
- hasUnreleased,
-}: Props) {
- const [state, formAction, pending] = useActionState<
- CutReleaseState | undefined,
- FormData
- >(cutReleaseAction, undefined);
-
- return (
- <form action={formAction} className="flex flex-col gap-2">
- <div className="text-sm text-muted-foreground">
- {lastVersion
- ? `Last released: ${lastVersion}`
- : "No releases yet."}
- </div>
- <div className="flex flex-wrap items-center gap-2">
- <input type="hidden" name="workspace" value={workspace} />
- <label className="sr-only" htmlFor={`version-${workspace}`}>
- New version
- </label>
- <input
- id={`version-${workspace}`}
- name="version"
- type="text"
- placeholder={suggestedVersion}
- defaultValue={suggestedVersion}
- disabled={!hasUnreleased || pending}
- className="w-40 px-3 py-2 rounded-md border border-border bg-card text-sm font-mono"
- aria-describedby={`hint-${workspace}`}
- />
- <button
- type="submit"
- disabled={!hasUnreleased || pending}
- className="px-3 py-2 rounded-md text-sm bg-primary text-primary-foreground hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed"
- >
- {pending ? "Cutting…" : "Cut release"}
- </button>
- <label className="flex items-center gap-1 text-sm text-muted-foreground">
- <input
- type="checkbox"
- name="commit"
- value="true"
- defaultChecked
- disabled={!hasUnreleased || pending}
- />
- <span>Commit changelog</span>
- </label>
- </div>
- <div id={`hint-${workspace}`} className="text-xs text-muted-foreground">
- {hasUnreleased
- ? "Date stamps to today automatically."
- : "Nothing pending under [Unreleased] yet."}
- </div>
- {state && !state.ok && (
- <div
- role="alert"
- className="text-sm text-destructive bg-destructive-soft border border-destructive/30 rounded-md px-3 py-2"
- >
- {state.error}
- </div>
- )}
- {state && state.ok && (
- <div
- role="status"
- className="text-sm text-success"
- >
- Cut release {state.version}.
- {state.committed ? " Committed." : " (not committed)"}
- </div>
- )}
- </form>
- );
-}
diff --git a/editor/app/deploy/components/DeployButton.tsx b/editor/app/deploy/components/DeployButton.tsx
@@ -1,36 +0,0 @@
-"use client";
-
-import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
-import { cancelJobAction } from "../../jobs/actions";
-import { deployExportAction } from "../deployAction";
-
-type Props = {
- // The active site to deploy, or null under "all sites".
- siteId: string | null;
- siteTitle: string | null;
-};
-
-export function DeployButton({ siteId, siteTitle }: Props) {
- return (
- <StreamActionLog
- trigger={() => deployExportAction(siteId as string)}
- cancelAction={cancelJobAction}
- buttonLabel="Deploy"
- runningLabel="Deploying…"
- label="Deploy export"
- disabled={!siteId}
- extraControls={
- siteId ? (
- <p className="text-sm text-muted-foreground">
- Deploying <strong>{siteTitle ?? siteId}</strong> (
- <code>{siteId}</code>).
- </p>
- ) : (
- <p className="text-sm text-warning">
- Select a specific site from the sidebar to deploy.
- </p>
- )
- }
- />
- );
-}
diff --git a/editor/app/deploy/cutReleaseAction.ts b/editor/app/deploy/cutReleaseAction.ts
@@ -1,116 +0,0 @@
-"use server";
-
-import fs from "node:fs";
-import path from "node:path";
-import { revalidatePath } from "next/cache";
-import { getPaths } from "yt-dlp-transcript-common/lib/paths";
-import {
- cutRelease,
- CutReleaseError,
-} from "yt-dlp-transcript-common/lib/changelog";
-import { commitPath, listDirtyPaths } from "yt-dlp-transcript-common/lib/git";
-
-export type CutReleaseState =
- | { ok: true; version: string; committed: boolean }
- | { ok: false; error: string };
-
-function changelogPathFor(workspace: "editor" | "export"): string {
- const paths = getPaths();
- return workspace === "export"
- ? paths.exportChangelogFile
- : paths.editorChangelogFile;
-}
-
-function todayISO(): string {
- const now = new Date();
- const y = now.getFullYear().toString().padStart(4, "0");
- const m = (now.getMonth() + 1).toString().padStart(2, "0");
- const d = now.getDate().toString().padStart(2, "0");
- return `${y}-${m}-${d}`;
-}
-
-export async function cutReleaseAction(
- _prev: CutReleaseState | undefined,
- formData: FormData,
-): Promise<CutReleaseState> {
- const workspaceRaw = formData.get("workspace");
- const versionRaw = formData.get("version");
- const commitRaw = formData.get("commit");
- const workspace =
- workspaceRaw === "editor" || workspaceRaw === "export"
- ? workspaceRaw
- : null;
- const version =
- typeof versionRaw === "string" ? versionRaw.trim() : "";
- const shouldCommit = commitRaw === "true" || commitRaw === "on";
- if (!workspace) {
- return { ok: false, error: "Invalid workspace." };
- }
- if (!version) {
- return { ok: false, error: "Version is required." };
- }
- const paths = getPaths();
- const filePath = changelogPathFor(workspace);
- const relPath = path.relative(paths.monorepoRoot, filePath);
- // Before mutating anything, refuse to commit over an otherwise-dirty tree so
- // the release commit contains only the changelog. A dirty changelog itself is
- // fine (uncommitted [Unreleased] bullets get folded into the release commit).
- if (shouldCommit) {
- let dirty: string[];
- try {
- dirty = await listDirtyPaths(paths.monorepoRoot);
- } catch (err) {
- return {
- ok: false,
- error: `Could not check git status: ${(err as Error).message}`,
- };
- }
- const others = dirty.filter((p) => p !== relPath);
- if (others.length > 0) {
- return {
- ok: false,
- error: `Other uncommitted changes present (${others.join(
- ", ",
- )}). Commit or stash them before cutting a release.`,
- };
- }
- }
- let source: string;
- try {
- source = await fs.promises.readFile(filePath, "utf8");
- } catch (err) {
- return {
- ok: false,
- error: `Could not read ${filePath}: ${(err as Error).message}`,
- };
- }
- let next: string;
- try {
- next = cutRelease(source, version, todayISO());
- } catch (err) {
- if (err instanceof CutReleaseError) {
- return { ok: false, error: err.message };
- }
- throw err;
- }
- const tmp = `${filePath}.tmp-${process.pid}`;
- await fs.promises.writeFile(tmp, next);
- await fs.promises.rename(tmp, filePath);
- if (shouldCommit) {
- const result = await commitPath(
- paths.monorepoRoot,
- relPath,
- `Release ${workspace} ${version}`,
- );
- if (!result.ok) {
- return {
- ok: false,
- error: `Cut release ${version}, but the commit failed: ${result.error}`,
- };
- }
- }
- revalidatePath("/");
- revalidatePath("/changelog");
- revalidatePath("/deploy");
- return { ok: true, version, committed: shouldCommit };
-}
diff --git a/editor/app/deploy/page.tsx b/editor/app/deploy/page.tsx
@@ -1,199 +0,0 @@
-import { readFileSync, statSync } from "node:fs";
-import path from "node:path";
-import type { Metadata } from "next";
-import { Changelog } from "yt-dlp-transcript-common/components/Changelog";
-import {
- extractUnreleasedSection,
- getLatestReleasedVersion,
- suggestNextVersion,
-} from "yt-dlp-transcript-common/lib/changelog";
-import { getPaths } from "yt-dlp-transcript-common/lib/paths";
-import { getSettings } from "yt-dlp-transcript-common/lib/settings";
-import { listSiteIds, listSites } from "yt-dlp-transcript-common/lib/site";
-import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
-import { BuildExportButton } from "./components/BuildExportButton";
-import { BuildDeployButton } from "./components/BuildDeployButton";
-import { BuildModeToggle } from "./components/BuildModeToggle";
-import { BuildAllSitesButton } from "./components/BuildAllSitesButton";
-import { BuildSitesPanel } from "./components/BuildSitesPanel";
-import { CutReleaseForm } from "./components/CutReleaseForm";
-import { DeployButton } from "./components/DeployButton";
-import { resolveActiveSite } from "../lib/activeSite";
-
-export const dynamic = "force-dynamic";
-export const metadata: Metadata = { title: "Deploy" };
-
-function loadExportChangelog(): string | null {
- try {
- return readFileSync(
- path.join(getPaths().exportDir, "CHANGELOG.md"),
- "utf8",
- );
- } catch {
- return null;
- }
-}
-
-function getLastDeployedAt(): string | null {
- try {
- const indexPath = path.join(getPaths().exportDir, "out", "index.html");
- return statSync(indexPath).mtime.toISOString();
- } catch {
- return null;
- }
-}
-
-export default async function DeployPage({
- searchParams,
-}: {
- searchParams: Promise<{ site?: string }>;
-}) {
- const paths = getPaths();
- const source = loadExportChangelog();
- const lastVersion = source ? getLatestReleasedVersion(source) : null;
- const suggestedVersion = suggestNextVersion(lastVersion);
- const unreleased = source ? extractUnreleasedSection(source) : "";
- const hasUnreleased = unreleased.length > 0;
- const lastDeployedAt = getLastDeployedAt();
- const existingQueues = getRegistry().activeQueueNames();
- const buildMode = getSettings().buildPipeline.mode;
- const sites = listSites(paths);
- const { site } = await searchParams;
- const active = resolveActiveSite(site, listSiteIds(paths));
- // Build/deploy target the single active site; null under "all sites".
- const activeSiteId = active.isAll ? null : active.siteId;
- const activeSite = activeSiteId
- ? sites.find((s) => s.siteId === activeSiteId)
- : undefined;
- const activeSiteTitle = activeSite?.siteTitle ?? activeSiteId ?? null;
- const activeCloudflareProject = activeSite?.cloudflareProject ?? null;
-
- return (
- <div className="flex flex-col gap-8 max-w-3xl">
- <div className="flex flex-col gap-1">
- <h1 className="text-2xl font-semibold">Deploy</h1>
- <p className="text-sm text-muted-foreground">
- Cut a release, then build & deploy the active site — or batch
- several sites at once below.
- </p>
- </div>
-
- {/* 1 — Release: stage the changelog before shipping. */}
- <section className="flex flex-col gap-3">
- <div>
- <h2 className="text-lg font-semibold">Release notes</h2>
- <p className="text-sm text-muted-foreground">
- Bullets under <code>## [Unreleased]</code> in{" "}
- <code>export/CHANGELOG.md</code> become the next cut release. Cut it
- before you build & deploy.
- </p>
- </div>
- {hasUnreleased && source ? (
- <div className="rounded-md border border-border bg-card px-4 py-3">
- <Changelog source={source} limit={1} />
- </div>
- ) : (
- <div className="text-sm italic text-muted-foreground">
- No pending changes yet.
- </div>
- )}
- <CutReleaseForm
- workspace="export"
- lastVersion={lastVersion}
- suggestedVersion={suggestedVersion}
- hasUnreleased={hasUnreleased}
- />
- </section>
-
- {/* 2 — Build & deploy (primary path) for the active site. */}
- <section className="flex flex-col gap-3 border-t border-border pt-6">
- <div>
- <h2 className="text-lg font-semibold">Build & deploy</h2>
- <p className="text-sm text-muted-foreground">
- Builds the static export, then deploys it to Cloudflare Pages in one
- step. Cancelling before the build finishes skips the deploy.
- </p>
- <p className="text-sm text-muted-foreground mt-1">
- Last deployed:{" "}
- {lastDeployedAt
- ? new Date(lastDeployedAt).toLocaleString()
- : "never"}
- </p>
- </div>
- <BuildModeToggle mode={buildMode} />
- <BuildDeployButton
- siteId={activeSiteId}
- siteTitle={activeSiteTitle}
- cloudflareProject={activeCloudflareProject}
- />
- </section>
-
- {/* 3 — Individual steps, for when you only want one half. */}
- <section className="flex flex-col gap-3 border-t border-border pt-6">
- <details>
- <summary className="cursor-pointer text-lg font-semibold select-none">
- Individual steps
- </summary>
- <div className="flex flex-col gap-6 pt-4">
- <div className="flex flex-col gap-3">
- <div>
- <h3 className="font-semibold">Build static export</h3>
- <p className="text-sm text-muted-foreground">
- Spawns <code>pnpm run build</code> in <code>export/</code> —
- index build, then <code>next build</code> to{" "}
- <code>export/out/</code>. No deploy.
- </p>
- </div>
- <BuildExportButton
- existingQueues={existingQueues}
- siteId={activeSiteId}
- siteTitle={activeSiteTitle}
- />
- </div>
- <div className="flex flex-col gap-3">
- <div>
- <h3 className="font-semibold">Deploy static export</h3>
- <p className="text-sm text-muted-foreground">
- Publishes the most recently built site at{" "}
- <code>export/out/</code> to Cloudflare Pages. Build first.
- </p>
- </div>
- <DeployButton
- siteId={activeSiteId}
- siteTitle={activeSiteTitle}
- />
- </div>
- </div>
- </details>
- </section>
-
- {/* 4 — Batch: build (and optionally deploy) several sites at once. */}
- <section className="flex flex-col gap-3 border-t border-border pt-6">
- <div>
- <h2 className="text-lg font-semibold">Build all sites</h2>
- <p className="text-sm text-muted-foreground">
- One job over every site: the shared data phase runs once, per-site
- builds run in parallel (Docker mode), then deploys run serially.
- </p>
- </div>
- <BuildAllSitesButton dockerMode={buildMode === "docker"} />
-
- <div className="mt-4">
- <h3 className="font-semibold">Or pick specific sites</h3>
- <p className="text-sm text-muted-foreground">
- Build (and optionally deploy) selected sites, each in its own live log
- lane.
- </p>
- </div>
- <BuildSitesPanel
- sites={sites.map((s) => ({
- siteId: s.siteId,
- siteTitle: s.siteTitle,
- cloudflareProject: s.cloudflareProject ?? null,
- }))}
- serial={buildMode === "basic"}
- />
- </section>
- </div>
- );
-}
diff --git a/editor/app/lib/nav.ts b/editor/app/lib/nav.ts
@@ -8,7 +8,6 @@ import {
LayoutDashboard,
ListChecks,
ListPlus,
- Rocket,
ScrollText,
Settings,
Tv,
@@ -83,10 +82,8 @@ 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" },
- // Interim: slice 5 folds these three into /sites — Deploy into the site's
- // Publish tab and the family page, Build and Homepage into the family page.
- { href: "/deploy", label: "Deploy", icon: Rocket, keywords: "publish release" },
+ { href: "/sites", label: "Sites", icon: Globe, keywords: "family charts stats graphs search aliases synonyms suggestions regex search terms deploy publish release cloudflare" },
+ // Interim: slice 5 folds these two into the family page (/sites).
{ href: "/build", label: "Build", icon: Hammer, keywords: "static export" },
{ href: "/homepage", label: "Homepage", icon: House, keywords: "hub" },
],
diff --git a/editor/app/settings/components/SettingsForm.tsx b/editor/app/settings/components/SettingsForm.tsx
@@ -285,8 +285,8 @@ export function SettingsForm({ initial }: Props) {
container for safe parallel multi-site builds — the container pipeline
is a follow-up, so Docker currently falls back to a basic build. The
mode can also be toggled on the{" "}
- <a href="/deploy" className="underline">
- Deploy
+ <a href="/sites" className="underline">
+ Sites
</a>{" "}
page.
</p>
diff --git a/editor/app/sites/[siteId]/components/SiteTabs.tsx b/editor/app/sites/[siteId]/components/SiteTabs.tsx
@@ -26,7 +26,11 @@ export function SiteTabs({ siteId }: { siteId: string }) {
active={segment === "aliases"}
label="Search aliases"
/>
- {/* commit 2 adds: <Tab href={`/sites/${siteId}/publish`} active={segment === "publish"} label="Publish" /> */}
+ <Tab
+ href={`/sites/${siteId}/publish`}
+ active={segment === "publish"}
+ label="Publish"
+ />
</nav>
);
}
diff --git a/editor/app/sites/[siteId]/publish/page.tsx b/editor/app/sites/[siteId]/publish/page.tsx
@@ -0,0 +1,109 @@
+import { statSync } from "node:fs";
+import path from "node:path";
+import type { Metadata } from "next";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { getRegistry } from "yt-dlp-transcript-common/jobs/registry";
+import { BuildDeployButton } from "../../components/BuildDeployButton";
+import { BuildExportButton } from "../../components/BuildExportButton";
+import { DeployButton } from "../../components/DeployButton";
+import { getSiteCached } from "../lib/siteCache";
+
+export const dynamic = "force-dynamic";
+// → "Publish — <id> — Sites" through the layout's title template.
+export const metadata: Metadata = { title: "Publish" };
+
+// The mtime of the last built export. resolveOutDir ignores the site id, so
+// this is the family's most recent build, not this site's — carried as-is from
+// the retired /deploy page.
+function getLastDeployedAt(): string | null {
+ try {
+ const indexPath = path.join(getPaths().exportDir, "out", "index.html");
+ return statSync(indexPath).mtime.toISOString();
+ } catch {
+ return null;
+ }
+}
+
+export default async function SitePublishPage({
+ params,
+}: {
+ params: Promise<{ siteId: string }>;
+}) {
+ const { siteId } = await params;
+ const site = getSiteCached(siteId);
+ if (!site) notFound();
+ const siteTitle = site.siteTitle || siteId;
+ const lastDeployedAt = getLastDeployedAt();
+ const existingQueues = getRegistry().activeQueueNames();
+
+ return (
+ <div className="flex flex-col gap-8 max-w-3xl">
+ {/* 1 — Build & deploy (primary path) for this site. */}
+ <section className="flex flex-col gap-3">
+ <div>
+ <h2 className="text-lg font-semibold">Build & deploy</h2>
+ <p className="text-sm text-muted-foreground">
+ Builds the static export, then deploys it to Cloudflare Pages in one
+ step. Cancelling before the build finishes skips the deploy.
+ </p>
+ <p className="text-sm text-muted-foreground mt-1">
+ Last deployed:{" "}
+ {lastDeployedAt
+ ? new Date(lastDeployedAt).toLocaleString()
+ : "never"}
+ </p>
+ <p className="text-sm text-muted-foreground mt-1">
+ Build mode (Basic or Docker) is set on the{" "}
+ <Link className="underline" href="/sites">
+ Sites
+ </Link>{" "}
+ page.
+ </p>
+ </div>
+ <BuildDeployButton
+ siteId={siteId}
+ siteTitle={siteTitle}
+ cloudflareProject={site.cloudflareProject ?? null}
+ />
+ </section>
+
+ {/* 2 — Individual steps, for when you only want one half. */}
+ <section className="flex flex-col gap-3 border-t border-border pt-6">
+ <details>
+ <summary className="cursor-pointer text-lg font-semibold select-none">
+ Individual steps
+ </summary>
+ <div className="flex flex-col gap-6 pt-4">
+ <div className="flex flex-col gap-3">
+ <div>
+ <h3 className="font-semibold">Build static export</h3>
+ <p className="text-sm text-muted-foreground">
+ Spawns <code>pnpm run build</code> in <code>export/</code> —
+ index build, then <code>next build</code> to{" "}
+ <code>export/out/</code>. No deploy.
+ </p>
+ </div>
+ <BuildExportButton
+ existingQueues={existingQueues}
+ siteId={siteId}
+ siteTitle={siteTitle}
+ />
+ </div>
+ <div className="flex flex-col gap-3">
+ <div>
+ <h3 className="font-semibold">Deploy static export</h3>
+ <p className="text-sm text-muted-foreground">
+ Publishes the most recently built site at{" "}
+ <code>export/out/</code> to Cloudflare Pages. Build first.
+ </p>
+ </div>
+ <DeployButton siteId={siteId} siteTitle={siteTitle} />
+ </div>
+ </div>
+ </details>
+ </section>
+ </div>
+ );
+}
diff --git a/editor/app/sites/components/BuildAllSitesButton.tsx b/editor/app/sites/components/BuildAllSitesButton.tsx
@@ -0,0 +1,78 @@
+"use client";
+
+import { useState } from "react";
+import {
+ buildAllSitesAction,
+ buildAndDeployAllSitesAction,
+} from "../lib/buildAction";
+import { JobLane } from "./JobLane";
+
+type Lane = { deploy: boolean; skipArchives: boolean; key: number };
+
+// One-click orchestrated pipeline over EVERY site as a SINGLE managed job (one
+// log, one Cancel): in Docker mode it runs the shared data phase + archive warm
+// once, fans the per-site builds out in parallel (capped by maxParallelBuilds),
+// then deploys the built sites serially. Without a container engine it falls back
+// to a serial host build+deploy. Distinct from the per-site panel below, which
+// launches one separate job per selected site.
+export function BuildAllSitesButton({ dockerMode }: { dockerMode: boolean }) {
+ const [deploy, setDeploy] = useState(true);
+ const [skipArchives, setSkipArchives] = useState(false);
+ const [lane, setLane] = useState<Lane | null>(null);
+ const [run, setRun] = useState(0);
+
+ function launch() {
+ const next = run + 1;
+ setRun(next);
+ setLane({ deploy, skipArchives, key: next });
+ }
+
+ return (
+ <div className="flex flex-col gap-3">
+ <div className="flex flex-wrap items-center gap-3">
+ <button
+ type="button"
+ onClick={launch}
+ className="px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90"
+ >
+ {deploy ? "Build & deploy all sites" : "Build all sites"}
+ </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>
+ <label className="flex items-center gap-2 text-sm text-muted-foreground">
+ <input
+ type="checkbox"
+ checked={skipArchives}
+ onChange={(e) => setSkipArchives(e.target.checked)}
+ />
+ Skip archive zips
+ </label>
+ </div>
+ <p className="text-xs text-muted-foreground">
+ {dockerMode
+ ? "Docker pipeline: shared data phase runs once, per-site builds run in parallel, then deploys run serially."
+ : "Docker mode is off — this runs a serial host build+deploy fallback (one site at a time)."}
+ </p>
+ {lane && (
+ <JobLane
+ key={lane.key}
+ title={lane.deploy ? "Build & deploy all sites" : "Build all sites"}
+ subtitle={
+ dockerMode ? "Docker pipeline · parallel builds" : "Serial host fallback"
+ }
+ trigger={() =>
+ lane.deploy
+ ? buildAndDeployAllSitesAction(lane.skipArchives)
+ : buildAllSitesAction(lane.skipArchives)
+ }
+ />
+ )}
+ </div>
+ );
+}
diff --git a/editor/app/sites/components/BuildDeployButton.tsx b/editor/app/sites/components/BuildDeployButton.tsx
@@ -0,0 +1,69 @@
+"use client";
+
+import { useState } from "react";
+import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
+import { buildAndDeployAction } from "../lib/buildAction";
+import { cancelJobAction } from "../../jobs/actions";
+
+type Props = {
+ // The site whose Publish tab this is. Always a real site now — the control
+ // lives at /sites/<id>/publish, so there is no "all sites" state to disable.
+ siteId: string;
+ siteTitle: string;
+ // The site's Cloudflare Pages project, or null if it has none (can't deploy).
+ cloudflareProject: string | null;
+};
+
+// One-click build-then-deploy for this site: builds, and on success deploys —
+// one combined streamed log, one Cancel (see buildAndDeployAction).
+export function BuildDeployButton({
+ siteId,
+ siteTitle,
+ cloudflareProject,
+}: Props) {
+ const ready = Boolean(cloudflareProject);
+ const [skipArchives, setSkipArchives] = useState(false);
+
+ let notice: React.ReactNode;
+ if (!cloudflareProject) {
+ notice = (
+ <p className="text-sm text-warning">
+ <strong>{siteTitle}</strong> has no Cloudflare Pages project
+ configured — set one on the site's page to deploy.
+ </p>
+ );
+ } else {
+ notice = (
+ <p className="text-sm text-muted-foreground">
+ Builds then deploys <strong>{siteTitle}</strong> (
+ <code>{siteId}</code> → <code>{cloudflareProject}</code>).
+ </p>
+ );
+ }
+
+ return (
+ <StreamActionLog
+ trigger={() => buildAndDeployAction(siteId, skipArchives)}
+ cancelAction={cancelJobAction}
+ buttonLabel="Build & deploy"
+ runningLabel="Building & deploying…"
+ label="Build and deploy"
+ disabled={!ready}
+ extraControls={
+ <div className="flex flex-col gap-2">
+ {notice}
+ {ready && (
+ <label className="flex items-center gap-2 text-sm">
+ <input
+ type="checkbox"
+ checked={skipArchives}
+ onChange={(e) => setSkipArchives(e.target.checked)}
+ />
+ Skip archive zips (transcript & live-chat downloads)
+ </label>
+ )}
+ </div>
+ }
+ />
+ );
+}
diff --git a/editor/app/sites/components/BuildExportButton.tsx b/editor/app/sites/components/BuildExportButton.tsx
@@ -0,0 +1,71 @@
+"use client";
+
+import { useState } from "react";
+import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
+import { buildExportAction } from "../lib/buildAction";
+import { QueueControl } from "../../components/QueueControl";
+import { cancelJobAction } from "../../jobs/actions";
+
+type Props = {
+ existingQueues: string[];
+ // The site whose Publish tab this is — always a real site.
+ siteId: string;
+ siteTitle: string;
+};
+
+const DEFAULT_BUILD_QUEUE = "build";
+
+export function BuildExportButton({ existingQueues, siteId, siteTitle }: Props) {
+ const [exportQueue, setExportQueue] = useState(DEFAULT_BUILD_QUEUE);
+ const [skipData, setSkipData] = useState(false);
+ const [skipArchives, setSkipArchives] = useState(false);
+
+ return (
+ <StreamActionLog
+ trigger={() =>
+ buildExportAction(siteId, exportQueue, skipData, skipArchives)
+ }
+ cancelAction={cancelJobAction}
+ buttonLabel="Build static export"
+ runningLabel="Building static export…"
+ extraControls={
+ <div className="flex flex-col gap-2">
+ <p className="text-sm text-muted-foreground">
+ Building <strong>{siteTitle}</strong> (<code>{siteId}</code>).
+ </p>
+ <label className="flex items-center gap-2 text-sm">
+ <input
+ type="checkbox"
+ checked={skipData}
+ onChange={(e) => setSkipData(e.target.checked)}
+ />
+ Skip data rebuild (index, stats, charts)
+ </label>
+ <p className="text-xs text-muted-foreground">
+ Reuses the existing index/stats — only composes the site and runs
+ next build. Do a full build first if data is stale.
+ </p>
+ <label className="flex items-center gap-2 text-sm">
+ <input
+ type="checkbox"
+ checked={skipArchives}
+ onChange={(e) => setSkipArchives(e.target.checked)}
+ />
+ Skip archive zips (transcript & live-chat downloads)
+ </label>
+ <p className="text-xs text-muted-foreground">
+ Skips generating the downloadable archive zips for this build only.
+ Leave off to keep the site's Downloads page current.
+ </p>
+ <QueueControl
+ value={exportQueue}
+ onChange={setExportQueue}
+ defaultQueueKey={DEFAULT_BUILD_QUEUE}
+ existingQueues={existingQueues}
+ actionLabel="Build static export"
+ />
+ </div>
+ }
+ />
+ );
+}
diff --git a/editor/app/sites/components/BuildModeToggle.tsx b/editor/app/sites/components/BuildModeToggle.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import { useState, useTransition } from "react";
+import type { BuildMode } from "yt-dlp-transcript-common/lib/settings";
+import { setBuildModeAction } from "../lib/buildModeAction";
+
+// Segmented Basic | Docker control. Persists the choice as the global build-mode
+// default (setBuildModeAction → settings.json) so every subsequent build uses it.
+export function BuildModeToggle({ mode: initialMode }: { mode: BuildMode }) {
+ const [mode, setMode] = useState<BuildMode>(initialMode);
+ const [pending, startTransition] = useTransition();
+
+ function choose(next: BuildMode) {
+ if (next === mode || pending) return;
+ setMode(next);
+ startTransition(async () => {
+ const res = await setBuildModeAction(next);
+ if (!res.ok) setMode(initialMode);
+ });
+ }
+
+ const options: { value: BuildMode; label: string }[] = [
+ { value: "basic", label: "Basic" },
+ { value: "docker", label: "Docker" },
+ ];
+
+ return (
+ <div className="flex flex-col gap-1.5">
+ <span className="text-xs font-medium text-muted-foreground">Build mode</span>
+ <div
+ role="group"
+ aria-label="Build mode"
+ className="inline-flex w-fit rounded-md border border-border overflow-hidden text-sm"
+ >
+ {options.map((opt) => {
+ const active = mode === opt.value;
+ return (
+ <button
+ key={opt.value}
+ type="button"
+ aria-pressed={active}
+ disabled={pending}
+ onClick={() => choose(opt.value)}
+ className={
+ "px-3 py-1.5 font-medium transition-colors disabled:opacity-60 " +
+ (active
+ ? "bg-primary text-primary-foreground"
+ : "bg-transparent text-muted-foreground hover:bg-muted")
+ }
+ >
+ {opt.label}
+ </button>
+ );
+ })}
+ </div>
+ {mode === "docker" && (
+ <span className="text-xs text-warning">
+ Docker builds are a follow-up — runs the basic build for now.
+ </span>
+ )}
+ </div>
+ );
+}
diff --git a/editor/app/sites/components/BuildSitesPanel.tsx b/editor/app/sites/components/BuildSitesPanel.tsx
@@ -0,0 +1,153 @@
+"use client";
+
+import { useState } from "react";
+import { buildExportAction, buildAndDeployAction } from "../lib/buildAction";
+import { JobLane } from "./JobLane";
+
+export type SiteOption = {
+ siteId: string;
+ siteTitle: string;
+ cloudflareProject: string | null;
+};
+
+type Lane = { siteId: string; title: string; deploy: boolean; key: string };
+
+// Batch build (and optionally deploy) several sites at once. Each selected site
+// launches its own managed job, rendered as its own live JobLane. In Basic mode
+// the jobs share the build/deploy queue and run one at a time (the export/ tree
+// is shared); Docker mode (a follow-up) unlocks true parallelism.
+export function BuildSitesPanel({
+ sites,
+ serial,
+}: {
+ sites: SiteOption[];
+ serial: boolean;
+}) {
+ const [selected, setSelected] = useState<Set<string>>(new Set());
+ const [deployAfter, setDeployAfter] = useState(false);
+ const [lanes, setLanes] = useState<Lane[] | null>(null);
+ // Bumps so re-running the same selection remounts fresh lanes.
+ const [run, setRun] = useState(0);
+
+ function toggle(siteId: string) {
+ setSelected((prev) => {
+ const next = new Set(prev);
+ if (next.has(siteId)) next.delete(siteId);
+ else next.add(siteId);
+ return next;
+ });
+ }
+
+ function launch() {
+ const chosen = sites.filter((s) => selected.has(s.siteId));
+ if (chosen.length === 0) return;
+ const nextRun = run + 1;
+ setRun(nextRun);
+ setLanes(
+ chosen.map((s) => ({
+ siteId: s.siteId,
+ title: s.siteTitle,
+ deploy: deployAfter && Boolean(s.cloudflareProject),
+ key: `${nextRun}:${s.siteId}`,
+ })),
+ );
+ }
+
+ const missingProject =
+ deployAfter &&
+ sites.some((s) => selected.has(s.siteId) && !s.cloudflareProject);
+
+ return (
+ // Named group: this panel's "Deploy after build" checkbox is a twin of the
+ // one in BuildAllSitesButton, and both sit in the same <section> on /sites.
+ // Without a name to scope by, getByLabel("Deploy after build") resolves to
+ // two elements and every assertion on it dies with a strict-mode violation.
+ <div
+ role="group"
+ aria-label="Build specific sites"
+ className="flex flex-col gap-4"
+ >
+ <div className="flex flex-col gap-2 rounded-md border border-border p-3">
+ <div className="flex flex-wrap gap-x-6 gap-y-2">
+ {sites.map((s) => (
+ <label
+ key={s.siteId}
+ className="flex items-center gap-2 text-sm"
+ >
+ <input
+ type="checkbox"
+ checked={selected.has(s.siteId)}
+ onChange={() => toggle(s.siteId)}
+ aria-label={`Select ${s.siteTitle}`}
+ />
+ <span className="font-medium">{s.siteTitle}</span>
+ <code className="text-xs text-muted-foreground">{s.siteId}</code>
+ {!s.cloudflareProject && (
+ <span className="text-xs text-warning">
+ no deploy target
+ </span>
+ )}
+ </label>
+ ))}
+ {sites.length === 0 && (
+ <span className="text-sm italic text-muted-foreground">
+ No sites configured.
+ </span>
+ )}
+ </div>
+ </div>
+
+ <div className="flex flex-wrap items-center gap-3">
+ <button
+ type="button"
+ onClick={launch}
+ disabled={selected.size === 0}
+ className="px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50"
+ >
+ {deployAfter ? "Build & deploy selected" : "Build selected"}
+ </button>
+ <label className="flex items-center gap-2 text-sm text-muted-foreground">
+ <input
+ type="checkbox"
+ checked={deployAfter}
+ onChange={(e) => setDeployAfter(e.target.checked)}
+ />
+ Deploy after build
+ </label>
+ <span className="text-xs text-muted-foreground">
+ {selected.size} selected
+ </span>
+ </div>
+
+ {missingProject && (
+ <p className="text-xs text-warning">
+ Some selected sites have no Cloudflare Pages project — those will build
+ only (deploy is skipped for them).
+ </p>
+ )}
+ {serial && (
+ <p className="text-xs text-muted-foreground">
+ Basic mode runs these one at a time (the build output tree is shared).
+ Use “Build all sites” above in Docker mode for true parallel builds.
+ </p>
+ )}
+
+ {lanes && lanes.length > 0 && (
+ <div className="flex flex-col gap-2">
+ {lanes.map((lane) => (
+ <JobLane
+ key={lane.key}
+ title={lane.title}
+ subtitle={`${lane.siteId}${lane.deploy ? " · build + deploy" : " · build"}`}
+ trigger={() =>
+ lane.deploy
+ ? buildAndDeployAction(lane.siteId)
+ : buildExportAction(lane.siteId)
+ }
+ />
+ ))}
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/editor/app/sites/components/CutReleaseForm.tsx b/editor/app/sites/components/CutReleaseForm.tsx
@@ -0,0 +1,91 @@
+"use client";
+
+import { useActionState } from "react";
+import {
+ cutReleaseAction,
+ type CutReleaseState,
+} from "../lib/cutReleaseAction";
+
+type Props = {
+ workspace: "editor" | "export";
+ lastVersion: string | null;
+ suggestedVersion: string;
+ hasUnreleased: boolean;
+};
+
+export function CutReleaseForm({
+ workspace,
+ lastVersion,
+ suggestedVersion,
+ hasUnreleased,
+}: Props) {
+ const [state, formAction, pending] = useActionState<
+ CutReleaseState | undefined,
+ FormData
+ >(cutReleaseAction, undefined);
+
+ return (
+ <form action={formAction} className="flex flex-col gap-2">
+ <div className="text-sm text-muted-foreground">
+ {lastVersion
+ ? `Last released: ${lastVersion}`
+ : "No releases yet."}
+ </div>
+ <div className="flex flex-wrap items-center gap-2">
+ <input type="hidden" name="workspace" value={workspace} />
+ <label className="sr-only" htmlFor={`version-${workspace}`}>
+ New version
+ </label>
+ <input
+ id={`version-${workspace}`}
+ name="version"
+ type="text"
+ placeholder={suggestedVersion}
+ defaultValue={suggestedVersion}
+ disabled={!hasUnreleased || pending}
+ className="w-40 px-3 py-2 rounded-md border border-border bg-card text-sm font-mono"
+ aria-describedby={`hint-${workspace}`}
+ />
+ <button
+ type="submit"
+ disabled={!hasUnreleased || pending}
+ className="px-3 py-2 rounded-md text-sm bg-primary text-primary-foreground hover:opacity-90 disabled:opacity-40 disabled:cursor-not-allowed"
+ >
+ {pending ? "Cutting…" : "Cut release"}
+ </button>
+ <label className="flex items-center gap-1 text-sm text-muted-foreground">
+ <input
+ type="checkbox"
+ name="commit"
+ value="true"
+ defaultChecked
+ disabled={!hasUnreleased || pending}
+ />
+ <span>Commit changelog</span>
+ </label>
+ </div>
+ <div id={`hint-${workspace}`} className="text-xs text-muted-foreground">
+ {hasUnreleased
+ ? "Date stamps to today automatically."
+ : "Nothing pending under [Unreleased] yet."}
+ </div>
+ {state && !state.ok && (
+ <div
+ role="alert"
+ className="text-sm text-destructive bg-destructive-soft border border-destructive/30 rounded-md px-3 py-2"
+ >
+ {state.error}
+ </div>
+ )}
+ {state && state.ok && (
+ <div
+ role="status"
+ className="text-sm text-success"
+ >
+ Cut release {state.version}.
+ {state.committed ? " Committed." : " (not committed)"}
+ </div>
+ )}
+ </form>
+ );
+}
diff --git a/editor/app/sites/components/DeployButton.tsx b/editor/app/sites/components/DeployButton.tsx
@@ -0,0 +1,28 @@
+"use client";
+
+import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog";
+import { cancelJobAction } from "../../jobs/actions";
+import { deployExportAction } from "../lib/deployAction";
+
+type Props = {
+ // The site whose Publish tab this is — always a real site.
+ siteId: string;
+ siteTitle: string;
+};
+
+export function DeployButton({ siteId, siteTitle }: Props) {
+ return (
+ <StreamActionLog
+ trigger={() => deployExportAction(siteId)}
+ cancelAction={cancelJobAction}
+ buttonLabel="Deploy"
+ runningLabel="Deploying…"
+ label="Deploy export"
+ extraControls={
+ <p className="text-sm text-muted-foreground">
+ Deploying <strong>{siteTitle}</strong> (<code>{siteId}</code>).
+ </p>
+ }
+ />
+ );
+}
diff --git a/editor/app/deploy/components/JobLane.tsx b/editor/app/sites/components/JobLane.tsx
diff --git a/editor/app/sites/lib/buildAction.ts b/editor/app/sites/lib/buildAction.ts
@@ -0,0 +1,449 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { buildIndex } from "yt-dlp-transcript-common/controller/buildIndex";
+import { buildStats } from "yt-dlp-transcript-common/controller/buildStats";
+import { normalizeAllTranscripts } from "yt-dlp-transcript-common/controller/normalizeAll";
+import { normalizeAllLiveChat } from "yt-dlp-transcript-common/controller/normalizeAllLiveChat";
+import {
+ archiveTranscripts,
+ archiveCombinedTranscripts,
+ type ArchiveBuildOptions,
+} from "yt-dlp-transcript-common/controller/archiveTranscripts";
+import {
+ archiveLiveChat,
+ archiveCombinedLiveChat,
+} from "yt-dlp-transcript-common/controller/archiveLiveChat";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { getSite, listSites, type Site } from "yt-dlp-transcript-common/lib/site";
+import {
+ runManagedFunction,
+ type StreamActionResult,
+} from "yt-dlp-transcript-common/jobs/streamCommand";
+import {
+ dockerAvailable,
+ dockerSiteOutDir,
+ resolveOutDir,
+ runArchiveUploadIntoLog,
+ runBuildPhase,
+ runDeployIntoLog,
+ runDockerBuildAllPhase,
+ runDockerDeployAllPhase,
+ type SiteBuildOutcome,
+ type SiteDeployOutcome,
+} from "./buildDeployCore";
+
+const DEFAULT_BUILD_QUEUE = "build";
+const DEPLOY_QUEUE = "deploy";
+
+export async function buildIndexAction(
+ queueKey?: string,
+): Promise<StreamActionResult> {
+ const paths = getPaths();
+ const result = await runManagedFunction({
+ kind: "build-index",
+ queueKey: queueKey === undefined ? DEFAULT_BUILD_QUEUE : queueKey.trim(),
+ paths,
+ fn: async (onLog) => {
+ await buildIndex({ paths, onLog });
+ },
+ });
+ return result;
+}
+
+export async function buildStatsAction(
+ queueKey?: string,
+): Promise<StreamActionResult> {
+ const paths = getPaths();
+ return runManagedFunction({
+ kind: "build-stats",
+ queueKey: queueKey === undefined ? DEFAULT_BUILD_QUEUE : queueKey.trim(),
+ paths,
+ fn: async (onLog, signal) => {
+ await buildStats({ paths, onLog, signal });
+ // every site's Charts tab previews the dataset this just rebuilt.
+ revalidatePath("/sites/[siteId]/charts", "page");
+ },
+ });
+}
+
+export async function buildExportAction(
+ siteId: string,
+ queueKey?: string,
+ skipData?: boolean,
+ skipArchives?: boolean,
+): Promise<StreamActionResult> {
+ const paths = getPaths();
+ const id = siteId.trim();
+ if (!id) {
+ return { ok: false, error: "Select a site to build" };
+ }
+ // Run as a managed function (not a raw command) so the build can route on the
+ // persisted build mode (basic vs docker) via runBuildPhase, and so the same
+ // child-streaming path is shared with build-then-deploy. Cancel still works:
+ // the abort signal is forwarded to the child by runChildIntoLog.
+ // `skipData` composes from the existing .export-index staging without
+ // regenerating the pool-wide index/stats/charts.
+ return runManagedFunction({
+ kind: "build-export",
+ queueKey: queueKey === undefined ? DEFAULT_BUILD_QUEUE : queueKey.trim(),
+ paths,
+ fn: async (onLog, signal) => {
+ const code = await runBuildPhase(onLog, signal, id, paths, {
+ skipData,
+ skipArchives,
+ });
+ if (signal.aborted) return;
+ if (code !== 0) throw new Error(`Build failed (exit ${code})`);
+ },
+ });
+}
+
+// One-click build-then-deploy: build the active site and, only if the build
+// succeeds (and wasn't cancelled), deploy it — all in ONE managed job so the UI
+// shows a single combined streamed log with a single Cancel. Serialized on the
+// deploy queue so it never overlaps a standalone deploy or another build-deploy.
+export async function buildAndDeployAction(
+ siteId: string,
+ skipArchives?: boolean,
+): Promise<StreamActionResult> {
+ const paths = getPaths();
+ const id = siteId.trim();
+ if (!id) {
+ return { ok: false, error: "Select a site to build and deploy" };
+ }
+ const site = getSite(id, paths);
+ if (!site.cloudflareProject) {
+ return {
+ ok: false,
+ error: `Site "${site.siteId}" has no Cloudflare Pages project configured.`,
+ };
+ }
+ return runManagedFunction({
+ kind: "build-deploy",
+ queueKey: DEPLOY_QUEUE,
+ paths,
+ fn: async (onLog, signal) => {
+ onLog("=== Build ===\n");
+ const buildCode = await runBuildPhase(onLog, signal, id, paths, {
+ skipArchives,
+ });
+ // A cancel mid-build must NOT proceed to deploy.
+ if (signal.aborted) return;
+ if (buildCode !== 0) {
+ throw new Error(`Build failed (exit ${buildCode}) — not deploying.`);
+ }
+ onLog("\n=== Deploy ===\n");
+ // Push oversize archives to R2 before the Pages deploy (no-op when R2
+ // isn't configured), so the published manifest URLs resolve.
+ const uploadCode = await runArchiveUploadIntoLog(onLog, signal, site, paths);
+ if (signal.aborted) return;
+ if (uploadCode !== 0) {
+ throw new Error(`Archive R2 upload failed (exit ${uploadCode}).`);
+ }
+ const deployCode = await runDeployIntoLog(
+ onLog,
+ signal,
+ site,
+ resolveOutDir(id, paths),
+ paths,
+ );
+ if (signal.aborted) return;
+ if (deployCode !== 0) {
+ throw new Error(`Deploy failed (exit ${deployCode}).`);
+ }
+ },
+ });
+}
+
+export async function normalizeTranscriptsAction(
+ queueKey?: string,
+): Promise<StreamActionResult> {
+ const paths = getPaths();
+ return runManagedFunction({
+ kind: "normalize-transcripts",
+ queueKey: queueKey === undefined ? DEFAULT_BUILD_QUEUE : queueKey.trim(),
+ paths,
+ fn: async (onLog, signal) => {
+ await normalizeAllTranscripts({ paths, onLog, signal });
+ },
+ });
+}
+
+export async function archiveTranscriptsAction(
+ build: Partial<ArchiveBuildOptions>,
+ queueKey?: string,
+): Promise<StreamActionResult> {
+ const paths = getPaths();
+ return runManagedFunction({
+ kind: "archive-transcripts",
+ queueKey: queueKey === undefined ? DEFAULT_BUILD_QUEUE : queueKey.trim(),
+ paths,
+ fn: async (onLog, signal) => {
+ await archiveTranscripts({ paths, onLog, signal, build });
+ },
+ });
+}
+
+export async function archiveCombinedTranscriptsAction(
+ build: Partial<ArchiveBuildOptions>,
+ queueKey?: string,
+): Promise<StreamActionResult> {
+ const paths = getPaths();
+ return runManagedFunction({
+ kind: "archive-combined-transcripts",
+ queueKey: queueKey === undefined ? DEFAULT_BUILD_QUEUE : queueKey.trim(),
+ paths,
+ fn: async (onLog, signal) => {
+ await archiveCombinedTranscripts({ paths, onLog, signal, build });
+ },
+ });
+}
+
+export async function normalizeLiveChatAction(
+ queueKey?: string,
+): Promise<StreamActionResult> {
+ const paths = getPaths();
+ return runManagedFunction({
+ kind: "normalize-live-chat",
+ queueKey: queueKey === undefined ? DEFAULT_BUILD_QUEUE : queueKey.trim(),
+ paths,
+ fn: async (onLog, signal) => {
+ await normalizeAllLiveChat({ paths, onLog, signal });
+ },
+ });
+}
+
+export async function archiveLiveChatAction(
+ build: Partial<ArchiveBuildOptions>,
+ queueKey?: string,
+): Promise<StreamActionResult> {
+ const paths = getPaths();
+ return runManagedFunction({
+ kind: "archive-live-chat",
+ queueKey: queueKey === undefined ? DEFAULT_BUILD_QUEUE : queueKey.trim(),
+ paths,
+ fn: async (onLog, signal) => {
+ await archiveLiveChat({ paths, onLog, signal, build });
+ },
+ });
+}
+
+export async function archiveCombinedLiveChatAction(
+ build: Partial<ArchiveBuildOptions>,
+ queueKey?: string,
+): Promise<StreamActionResult> {
+ const paths = getPaths();
+ return runManagedFunction({
+ kind: "archive-combined-live-chat",
+ queueKey: queueKey === undefined ? DEFAULT_BUILD_QUEUE : queueKey.trim(),
+ paths,
+ fn: async (onLog, signal) => {
+ await archiveCombinedLiveChat({ paths, onLog, signal, build });
+ },
+ });
+}
+
+// Build EVERY configured site, then deploy each — the docker-pipeline entry point.
+// With a container engine available: run the shared data phase + archive warm
+// once, fan the per-site builds out in parallel (capped by maxParallelBuilds),
+// then deploy the built sites SERIALLY on the host. Without one: fall back to a
+// serial host build+deploy per site. One managed job (single streamed log +
+// Cancel), serialized on the deploy queue so it never overlaps another deploy.
+export async function buildAndDeployAllSitesAction(
+ skipArchives?: boolean,
+): Promise<StreamActionResult> {
+ const paths = getPaths();
+ const sites = listSites(paths);
+ if (sites.length === 0) return { ok: false, error: "No sites configured." };
+ return runManagedFunction({
+ kind: "build-deploy-all",
+ queueKey: DEPLOY_QUEUE,
+ paths,
+ fn: async (onLog, signal) => {
+ const useDocker = await dockerAvailable(signal);
+ if (signal.aborted) return;
+
+ if (!useDocker) {
+ onLog(
+ "[notice] No container engine available (start the daemon, or set " +
+ "DOCKER_BIN) — falling back to serial host build+deploy.\n",
+ );
+ const deploys = await basicBuildAndDeployAll(
+ onLog,
+ signal,
+ sites,
+ paths,
+ skipArchives,
+ );
+ if (signal.aborted) return;
+ summarize(onLog, deploys);
+ return;
+ }
+
+ // Docker: build all (Phase A + B), barrier, then deploy all serially (C).
+ const outcomes = await runDockerBuildAllPhase(onLog, signal, sites, paths, {
+ skipArchives,
+ });
+ if (signal.aborted) return;
+ const builtOk = new Set(
+ outcomes.filter((o) => o.code === 0).map((o) => o.siteId),
+ );
+ onLog(`\n=== Phase C: deploy (${builtOk.size}/${sites.length} built) ===`);
+ const deploys = await runDockerDeployAllPhase(
+ onLog,
+ signal,
+ sites,
+ builtOk,
+ paths,
+ (id) => dockerSiteOutDir(paths, id),
+ );
+ if (signal.aborted) return;
+ summarize(onLog, deploys, outcomes);
+ },
+ });
+}
+
+// Build EVERY configured site WITHOUT deploying — e.g. to warm caches or validate
+// that each site composes and next-builds. Docker mode fans the builds out in
+// parallel; otherwise it's a serial host loop (whose shared export/out is
+// overwritten per site, so only the last survives — build-all is chiefly a docker
+// feature). Queued on the build queue.
+export async function buildAllSitesAction(
+ skipArchives?: boolean,
+): Promise<StreamActionResult> {
+ const paths = getPaths();
+ const sites = listSites(paths);
+ if (sites.length === 0) return { ok: false, error: "No sites configured." };
+ return runManagedFunction({
+ kind: "build-all",
+ queueKey: DEFAULT_BUILD_QUEUE,
+ paths,
+ fn: async (onLog, signal) => {
+ const useDocker = await dockerAvailable(signal);
+ if (signal.aborted) return;
+ let outcomes: SiteBuildOutcome[];
+ if (useDocker) {
+ outcomes = await runDockerBuildAllPhase(onLog, signal, sites, paths, {
+ skipArchives,
+ });
+ } else {
+ onLog(
+ "[notice] No container engine available — building sites serially on the host.\n",
+ );
+ outcomes = [];
+ for (let i = 0; i < sites.length; i++) {
+ if (signal.aborted) return;
+ const site = sites[i];
+ onLog(`\n=== Build ${site.siteId} (${i + 1}/${sites.length}) ===`);
+ const code = await runBuildPhase(onLog, signal, site.siteId, paths, {
+ skipData: i > 0,
+ skipArchives,
+ });
+ outcomes.push({ siteId: site.siteId, code });
+ }
+ }
+ if (signal.aborted) return;
+ const ok = outcomes.filter((o) => o.code === 0).length;
+ const failed = outcomes
+ .filter((o) => o.code !== 0)
+ .map((o) => `${o.siteId} (exit ${o.code})`);
+ onLog(`\n=== Summary: ${ok}/${sites.length} built ===`);
+ if (failed.length) throw new Error(`Build failures: ${failed.join(", ")}`);
+ },
+ });
+}
+
+// Serial host build+deploy fallback for buildAndDeployAllSitesAction when no
+// container engine is available. Interleaved (build A → deploy A → build B …)
+// because the basic build shares export/out — building all first would leave only
+// the last site's bundle. Rebuilds the pool-wide data once (first site).
+async function basicBuildAndDeployAll(
+ onLog: (line: string) => void,
+ signal: AbortSignal,
+ sites: Site[],
+ paths: ReturnType<typeof getPaths>,
+ skipArchives?: boolean,
+): Promise<SiteDeployOutcome[]> {
+ const basicOut = resolveOutDir("", paths);
+ const deploys: SiteDeployOutcome[] = [];
+ for (let i = 0; i < sites.length; i++) {
+ if (signal.aborted) break;
+ const site = sites[i];
+ onLog(`\n=== Build ${site.siteId} (${i + 1}/${sites.length}) ===`);
+ const code = await runBuildPhase(onLog, signal, site.siteId, paths, {
+ skipData: i > 0,
+ skipArchives,
+ });
+ if (signal.aborted) break;
+ if (code !== 0) {
+ onLog(`[${site.siteId}] build FAILED (exit ${code}) — skipping deploy`);
+ deploys.push({
+ siteId: site.siteId,
+ status: "skipped",
+ reason: `build exit ${code}`,
+ });
+ continue;
+ }
+ if (!site.cloudflareProject) {
+ onLog(`[${site.siteId}] deploy skipped — no Cloudflare project configured`);
+ deploys.push({
+ siteId: site.siteId,
+ status: "skipped",
+ reason: "no cloudflareProject",
+ });
+ continue;
+ }
+ onLog(`=== Deploy ${site.siteId} ===`);
+ const up = await runArchiveUploadIntoLog(onLog, signal, site, paths);
+ if (signal.aborted) break;
+ if (up !== 0) {
+ onLog(`[${site.siteId}] deploy FAILED — R2 upload exit ${up}`);
+ deploys.push({
+ siteId: site.siteId,
+ status: "failed",
+ reason: `R2 upload exit ${up}`,
+ });
+ continue;
+ }
+ const dep = await runDeployIntoLog(onLog, signal, site, basicOut, paths);
+ if (signal.aborted) break;
+ if (dep !== 0) {
+ onLog(`[${site.siteId}] deploy FAILED — exit ${dep}`);
+ deploys.push({
+ siteId: site.siteId,
+ status: "failed",
+ reason: `deploy exit ${dep}`,
+ });
+ continue;
+ }
+ onLog(`[${site.siteId}] deployed.`);
+ deploys.push({ siteId: site.siteId, status: "deployed" });
+ }
+ return deploys;
+}
+
+// Log the final tally and THROW if anything failed, so the managed job ends in a
+// failed state the UI surfaces. Skips are reported but not failures.
+function summarize(
+ onLog: (line: string) => void,
+ deploys: SiteDeployOutcome[],
+ builds?: SiteBuildOutcome[],
+): void {
+ const deployed = deploys
+ .filter((d) => d.status === "deployed")
+ .map((d) => d.siteId);
+ const skipped = deploys.filter((d) => d.status === "skipped");
+ const failed = [
+ ...(builds ?? [])
+ .filter((b) => b.code !== 0)
+ .map((b) => `${b.siteId} (build exit ${b.code})`),
+ ...deploys
+ .filter((d) => d.status === "failed")
+ .map((d) => `${d.siteId} (deploy: ${d.reason})`),
+ ];
+ onLog(`\n=== Summary: ${deployed.length} deployed ===`);
+ if (deployed.length) onLog(` deployed: ${deployed.join(", ")}`);
+ for (const s of skipped) onLog(` skipped ${s.siteId}: ${s.reason}`);
+ if (failed.length) throw new Error(`Failures: ${failed.join(", ")}`);
+}
diff --git a/editor/app/deploy/buildDeployCore.ts b/editor/app/sites/lib/buildDeployCore.ts
diff --git a/editor/app/sites/lib/buildModeAction.ts b/editor/app/sites/lib/buildModeAction.ts
@@ -0,0 +1,26 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import {
+ getSettings,
+ isBuildMode,
+ writeSettings,
+ type BuildMode,
+} from "yt-dlp-transcript-common/lib/settings";
+
+// Persist the build-mode choice (Basic vs Docker) from the family page (/sites)
+// so it becomes the default for every subsequent build. Only the mode is touched
+// here; the image/concurrency knobs are edited on the Settings page.
+// writeSettings re-sanitizes the whole object regardless.
+export async function setBuildModeAction(
+ mode: BuildMode,
+): Promise<{ ok: boolean }> {
+ if (!isBuildMode(mode)) return { ok: false };
+ const settings = getSettings();
+ await writeSettings({
+ ...settings,
+ buildPipeline: { ...settings.buildPipeline, mode },
+ });
+ revalidatePath("/sites");
+ return { ok: true };
+}
diff --git a/editor/app/sites/lib/cutReleaseAction.ts b/editor/app/sites/lib/cutReleaseAction.ts
@@ -0,0 +1,116 @@
+"use server";
+
+import fs from "node:fs";
+import path from "node:path";
+import { revalidatePath } from "next/cache";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import {
+ cutRelease,
+ CutReleaseError,
+} from "yt-dlp-transcript-common/lib/changelog";
+import { commitPath, listDirtyPaths } from "yt-dlp-transcript-common/lib/git";
+
+export type CutReleaseState =
+ | { ok: true; version: string; committed: boolean }
+ | { ok: false; error: string };
+
+function changelogPathFor(workspace: "editor" | "export"): string {
+ const paths = getPaths();
+ return workspace === "export"
+ ? paths.exportChangelogFile
+ : paths.editorChangelogFile;
+}
+
+function todayISO(): string {
+ const now = new Date();
+ const y = now.getFullYear().toString().padStart(4, "0");
+ const m = (now.getMonth() + 1).toString().padStart(2, "0");
+ const d = now.getDate().toString().padStart(2, "0");
+ return `${y}-${m}-${d}`;
+}
+
+export async function cutReleaseAction(
+ _prev: CutReleaseState | undefined,
+ formData: FormData,
+): Promise<CutReleaseState> {
+ const workspaceRaw = formData.get("workspace");
+ const versionRaw = formData.get("version");
+ const commitRaw = formData.get("commit");
+ const workspace =
+ workspaceRaw === "editor" || workspaceRaw === "export"
+ ? workspaceRaw
+ : null;
+ const version =
+ typeof versionRaw === "string" ? versionRaw.trim() : "";
+ const shouldCommit = commitRaw === "true" || commitRaw === "on";
+ if (!workspace) {
+ return { ok: false, error: "Invalid workspace." };
+ }
+ if (!version) {
+ return { ok: false, error: "Version is required." };
+ }
+ const paths = getPaths();
+ const filePath = changelogPathFor(workspace);
+ const relPath = path.relative(paths.monorepoRoot, filePath);
+ // Before mutating anything, refuse to commit over an otherwise-dirty tree so
+ // the release commit contains only the changelog. A dirty changelog itself is
+ // fine (uncommitted [Unreleased] bullets get folded into the release commit).
+ if (shouldCommit) {
+ let dirty: string[];
+ try {
+ dirty = await listDirtyPaths(paths.monorepoRoot);
+ } catch (err) {
+ return {
+ ok: false,
+ error: `Could not check git status: ${(err as Error).message}`,
+ };
+ }
+ const others = dirty.filter((p) => p !== relPath);
+ if (others.length > 0) {
+ return {
+ ok: false,
+ error: `Other uncommitted changes present (${others.join(
+ ", ",
+ )}). Commit or stash them before cutting a release.`,
+ };
+ }
+ }
+ let source: string;
+ try {
+ source = await fs.promises.readFile(filePath, "utf8");
+ } catch (err) {
+ return {
+ ok: false,
+ error: `Could not read ${filePath}: ${(err as Error).message}`,
+ };
+ }
+ let next: string;
+ try {
+ next = cutRelease(source, version, todayISO());
+ } catch (err) {
+ if (err instanceof CutReleaseError) {
+ return { ok: false, error: err.message };
+ }
+ throw err;
+ }
+ const tmp = `${filePath}.tmp-${process.pid}`;
+ await fs.promises.writeFile(tmp, next);
+ await fs.promises.rename(tmp, filePath);
+ if (shouldCommit) {
+ const result = await commitPath(
+ paths.monorepoRoot,
+ relPath,
+ `Release ${workspace} ${version}`,
+ );
+ if (!result.ok) {
+ return {
+ ok: false,
+ error: `Cut release ${version}, but the commit failed: ${result.error}`,
+ };
+ }
+ }
+ revalidatePath("/");
+ revalidatePath("/changelog");
+ revalidatePath("/sites");
+ return { ok: true, version, committed: shouldCommit };
+}
diff --git a/editor/app/deploy/deployAction.ts b/editor/app/sites/lib/deployAction.ts
diff --git a/editor/app/sites/page.tsx b/editor/app/sites/page.tsx
@@ -1,59 +1,155 @@
+import { readFileSync } from "node:fs";
+import path from "node:path";
import type { Metadata } from "next";
import Link from "next/link";
+import { Changelog } from "yt-dlp-transcript-common/components/Changelog";
+import {
+ extractUnreleasedSection,
+ getLatestReleasedVersion,
+ suggestNextVersion,
+} from "yt-dlp-transcript-common/lib/changelog";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
import { listSites } from "yt-dlp-transcript-common/lib/site";
+import { BuildAllSitesButton } from "./components/BuildAllSitesButton";
+import { BuildModeToggle } from "./components/BuildModeToggle";
+import { BuildSitesPanel } from "./components/BuildSitesPanel";
+import { CutReleaseForm } from "./components/CutReleaseForm";
import { DeleteSiteButton, MigrateButton } from "./components/SiteListActions";
export const dynamic = "force-dynamic";
export const metadata: Metadata = { title: "Sites" };
-export default function SitesPage() {
- const sites = listSites(getPaths());
+function loadExportChangelog(): string | null {
+ try {
+ return readFileSync(path.join(getPaths().exportDir, "CHANGELOG.md"), "utf8");
+ } catch {
+ return null;
+ }
+}
+
+// The family page: the list of sites, then everything that applies to the whole
+// family rather than to one of them. A site's own controls — its settings, its
+// charts, its search aliases, its build & deploy — are its TABS, under
+// /sites/<id>.
+//
+// Section order is the noun first, then the lifecycle the retired /deploy page
+// read as: the list (the family is what this page is about), then the release,
+// then the batch build.
+export default async function SitesPage() {
+ const paths = getPaths();
+ const sites = listSites(paths);
+ const source = loadExportChangelog();
+ const lastVersion = source ? getLatestReleasedVersion(source) : null;
+ const suggestedVersion = suggestNextVersion(lastVersion);
+ const unreleased = source ? extractUnreleasedSection(source) : "";
+ const hasUnreleased = unreleased.length > 0;
+ const buildMode = getSettings().buildPipeline.mode;
+
return (
- <div className="flex flex-col gap-4">
- <div className="flex items-center justify-between gap-2">
- <h1 className="text-2xl font-semibold">Sites</h1>
- <Link
- href="/sites/new"
- className="px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90"
- >
- + New site
- </Link>
- </div>
- <p className="text-sm text-muted-foreground max-w-2xl">
- Each site is a selection + branding over the shared channel pool. The
- same channel can appear on several sites; its downloads are stored once
- and reused.
- </p>
- {sites.length === 0 && (
- <div className="flex flex-col gap-3 rounded border border-border p-4">
- <p className="text-sm">
- No sites configured yet. If you're upgrading an existing
- single-site install, migrate your current branding, channel groups,
- and charts into a first site:
+ <div className="flex flex-col gap-8">
+ <section className="flex flex-col gap-4">
+ <div className="flex items-center justify-between gap-2">
+ <h1 className="text-2xl font-semibold">Sites</h1>
+ <Link
+ href="/sites/new"
+ className="px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90"
+ >
+ + New site
+ </Link>
+ </div>
+ <p className="text-sm text-muted-foreground max-w-2xl">
+ Each site is a selection + branding over the shared channel pool. The
+ same channel can appear on several sites; its downloads are stored once
+ and reused.
+ </p>
+ {sites.length === 0 && (
+ <div className="flex flex-col gap-3 rounded border border-border p-4">
+ <p className="text-sm">
+ No sites configured yet. If you're upgrading an existing
+ single-site install, migrate your current branding, channel groups,
+ and charts into a first site:
+ </p>
+ <MigrateButton />
+ </div>
+ )}
+ <ul className="flex flex-col divide-y divide-border">
+ {sites.map((s) => (
+ <li
+ key={s.siteId}
+ className="flex items-center justify-between gap-3 py-3"
+ >
+ <Link href={`/sites/${s.siteId}`} className="min-w-0">
+ <div className="font-medium">{s.siteTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ <code>{s.siteId}</code> · {s.channels.length} channel
+ {s.channels.length === 1 ? "" : "s"} · {s.groups.length} group
+ {s.groups.length === 1 ? "" : "s"}
+ {s.cloudflareProject ? ` · ${s.cloudflareProject}` : ""}
+ </div>
+ </Link>
+ <DeleteSiteButton siteId={s.siteId} />
+ </li>
+ ))}
+ </ul>
+ </section>
+
+ {/* Release: stage the changelog before shipping anything. */}
+ <section className="flex flex-col gap-3 border-t border-border pt-6">
+ <div>
+ <h2 className="text-lg font-semibold">Release notes</h2>
+ <p className="text-sm text-muted-foreground">
+ Bullets under <code>## [Unreleased]</code> in{" "}
+ <code>export/CHANGELOG.md</code> become the next cut release. Cut it
+ before you build & deploy.
</p>
- <MigrateButton />
</div>
- )}
- <ul className="flex flex-col divide-y divide-border">
- {sites.map((s) => (
- <li
- key={s.siteId}
- className="flex items-center justify-between gap-3 py-3"
- >
- <Link href={`/sites/${s.siteId}`} className="min-w-0">
- <div className="font-medium">{s.siteTitle}</div>
- <div className="text-xs text-muted-foreground">
- <code>{s.siteId}</code> · {s.channels.length} channel
- {s.channels.length === 1 ? "" : "s"} · {s.groups.length} group
- {s.groups.length === 1 ? "" : "s"}
- {s.cloudflareProject ? ` · ${s.cloudflareProject}` : ""}
- </div>
- </Link>
- <DeleteSiteButton siteId={s.siteId} />
- </li>
- ))}
- </ul>
+ {hasUnreleased && source ? (
+ <div className="rounded-md border border-border bg-card px-4 py-3">
+ <Changelog source={source} limit={1} />
+ </div>
+ ) : (
+ <div className="text-sm italic text-muted-foreground">
+ No pending changes yet.
+ </div>
+ )}
+ <CutReleaseForm
+ workspace="export"
+ lastVersion={lastVersion}
+ suggestedVersion={suggestedVersion}
+ hasUnreleased={hasUnreleased}
+ />
+ </section>
+
+ {/* Batch: build (and optionally deploy) several sites at once. The mode
+ decides whether the batch fans out, so it is set here beside it. */}
+ <section className="flex flex-col gap-3 border-t border-border pt-6">
+ <div>
+ <h2 className="text-lg font-semibold">Build all sites</h2>
+ <p className="text-sm text-muted-foreground">
+ One job over every site: the shared data phase runs once, per-site
+ builds run in parallel (Docker mode), then deploys run serially.
+ </p>
+ </div>
+ <BuildModeToggle mode={buildMode} />
+ <BuildAllSitesButton dockerMode={buildMode === "docker"} />
+
+ <div className="mt-4">
+ <h3 className="font-semibold">Or pick specific sites</h3>
+ <p className="text-sm text-muted-foreground">
+ Build (and optionally deploy) selected sites, each in its own live log
+ lane.
+ </p>
+ </div>
+ <BuildSitesPanel
+ sites={sites.map((s) => ({
+ siteId: s.siteId,
+ siteTitle: s.siteTitle,
+ cloudflareProject: s.cloudflareProject ?? null,
+ }))}
+ serial={buildMode === "basic"}
+ />
+ </section>
</div>
);
}
diff --git a/editor/e2e/build.spec.ts b/editor/e2e/build.spec.ts
@@ -82,8 +82,8 @@ test("build static export lives on the deploy page, not the build page", async (
).toHaveCount(0);
await expect(page.getByRole("heading", { name: "Build index" })).toBeVisible();
- // On the deploy page, under the "Individual steps" disclosure.
- await page.goto("/deploy");
+ // On the site's Publish tab, under the "Individual steps" disclosure.
+ await page.goto("/sites/testsite/publish");
await page.getByText("Individual steps").click();
const exportHeading = page.getByRole("heading", {
name: "Build static export",
diff --git a/editor/e2e/deploy-page.spec.ts b/editor/e2e/deploy-page.spec.ts
@@ -2,7 +2,7 @@ import { test, expect } from "@playwright/test";
import type { Page } from "@playwright/test";
import { resetData, writeSite, readJson } from "./helpers";
-// The "pick specific sites" panel (BuildSitesPanel). /deploy renders two
+// The "pick specific sites" panel (BuildSitesPanel). /sites renders two
// controls named "Deploy after build" — one here, one in BuildAllSitesButton —
// so anything targeting this panel's copy must be scoped to it by name.
const batchPanel = (page: Page) =>
@@ -12,25 +12,34 @@ test.beforeEach(async () => {
await resetData("empty");
});
-test("deploy page reads as a lifecycle: release → build & deploy → batch", async ({
+test("publishing splits: the family page releases and batches, the site page builds & deploys", async ({
page,
}) => {
await writeSite("testsite", { cloudflareProject: "proj" });
- await page.goto("/deploy?site=testsite");
+ await page.goto("/sites");
const release = page.getByRole("heading", { name: "Release notes" });
- const buildDeploy = page.getByRole("heading", { name: "Build & deploy" });
const batch = page.getByRole("heading", { name: "Build all sites" });
await expect(release).toBeVisible();
- await expect(buildDeploy).toBeVisible();
await expect(batch).toBeVisible();
+ // Order: the release sits above the batch panel.
+ expect((await release.boundingBox())!.y).toBeLessThan(
+ (await batch.boundingBox())!.y,
+ );
+ await expect(
+ page.getByRole("heading", { name: "Build & deploy", exact: true }),
+ ).toHaveCount(0);
- // Order: release sits above build & deploy, which sits above the batch panel.
- const releaseY = (await release.boundingBox())!.y;
- const buildDeployY = (await buildDeploy.boundingBox())!.y;
- const batchY = (await batch.boundingBox())!.y;
- expect(releaseY).toBeLessThan(buildDeployY);
- expect(buildDeployY).toBeLessThan(batchY);
+ await page.goto("/sites/testsite/publish");
+ await expect(
+ page.getByRole("heading", { name: "Build & deploy", exact: true }),
+ ).toBeVisible();
+ await expect(
+ page.getByRole("heading", { name: "Build all sites" }),
+ ).toHaveCount(0);
+ await expect(
+ page.getByRole("heading", { name: "Release notes" }),
+ ).toHaveCount(0);
});
test("Build & deploy is enabled only when the active site has a Cloudflare project", async ({
@@ -40,20 +49,18 @@ test("Build & deploy is enabled only when the active site has a Cloudflare proje
await writeSite("no-proj", {});
// With a Cloudflare project → enabled.
- await page.goto("/deploy?site=with-proj");
+ await page.goto("/sites/with-proj/publish");
await expect(
- // exact: true — getByRole's name match is a case-insensitive SUBSTRING by
- // default, so a bare "Build & deploy" also matches "Build & deploy all
- // sites" in the batch section below.
+ // exact: true — kept from the /deploy days, where "Build & deploy all
+ // sites" sat below; harmless here.
page.getByRole("button", { name: "Build & deploy", exact: true }),
).toBeEnabled();
// Without one → disabled, with an explanatory notice.
- await page.goto("/deploy?site=no-proj");
+ await page.goto("/sites/no-proj/publish");
await expect(
- // exact: true — getByRole's name match is a case-insensitive SUBSTRING by
- // default, so a bare "Build & deploy" also matches "Build & deploy all
- // sites" in the batch section below.
+ // exact: true — kept from the /deploy days, where "Build & deploy all
+ // sites" sat below; harmless here.
page.getByRole("button", { name: "Build & deploy", exact: true }),
).toBeDisabled();
await expect(
@@ -65,7 +72,7 @@ test("build-mode toggle persists the choice and shows the Docker follow-up note"
page,
}) => {
await writeSite("testsite", { cloudflareProject: "proj" });
- await page.goto("/deploy?site=testsite");
+ await page.goto("/sites");
const group = page.getByRole("group", { name: "Build mode" });
await expect(group.getByRole("button", { name: "Basic" })).toHaveAttribute(
@@ -108,7 +115,7 @@ test("build-mode toggle persists the choice and shows the Docker follow-up note"
.toBe("docker");
// Persisted: reload and the toggle is still on Docker.
- await page.goto("/deploy?site=testsite");
+ await page.goto("/sites");
await expect(
page
.getByRole("group", { name: "Build mode" })
@@ -124,7 +131,7 @@ test("batch panel: selecting sites enables the launch button and reflects deploy
// runs the lightweight Build index job). We verify the panel's UI wiring.
await writeSite("alpha", { siteTitle: "Alpha", cloudflareProject: "a" });
await writeSite("beta", { siteTitle: "Beta", cloudflareProject: "b" });
- await page.goto("/deploy?site=alpha");
+ await page.goto("/sites");
const launch = page.getByRole("button", { name: "Build selected" });
await expect(launch).toBeDisabled();
diff --git a/editor/e2e/navigation.spec.ts b/editor/e2e/navigation.spec.ts
@@ -130,7 +130,7 @@ test.describe("navigation", () => {
}
await writeSite("testsite", {});
- for (const retired of ["/charts", "/aliases"]) {
+ for (const retired of ["/charts", "/aliases", "/deploy"]) {
await page.goto(retired);
await expect(page).toHaveURL(/\/sites$/);
await expect(
diff --git a/editor/e2e/settings.spec.ts b/editor/e2e/settings.spec.ts
@@ -89,8 +89,8 @@ test("saves build pipeline settings (mode, concurrency, image)", async ({
expect(saved.buildPipeline?.maxParallelBuilds).toBe(4);
expect(saved.buildPipeline?.dockerImage).toBe("my-build-image");
- // The deploy page reflects the persisted mode.
- await page.goto("/deploy");
+ // The Sites page reflects the persisted mode.
+ await page.goto("/sites");
await expect(
page
.getByRole("group", { name: "Build mode" })
diff --git a/editor/e2e/site-scope.spec.ts b/editor/e2e/site-scope.spec.ts
@@ -80,29 +80,24 @@ test("charts is a site's tab, and the picker follows the path", async ({
await expect(page).toHaveURL(/\/sites$/);
});
-test("deploy targets the active site and is disabled under all sites", async ({
+test("publish targets the site in the path, and says when it cannot deploy", async ({
page,
}) => {
- await twoSites();
- await page.goto("/deploy?site=__all__");
- // Under "all sites" the primary Build & deploy path is blocked: its notice is
- // shown and the button is disabled. (The "Build static export" step now lives
- // in a collapsed "Individual steps" details, so it's asserted via the primary
- // visible path here.)
+ await twoSites(); // alpha has no cloudflareProject
+ await page.goto("/sites/alpha/publish");
await expect(
- page.getByText(/select a specific site from the sidebar to build & deploy\./i),
+ page.getByRole("heading", { name: "Build & deploy", exact: true }),
).toBeVisible();
- // exact: the "Build & deploy all sites" batch button also matches otherwise.
await expect(
page.getByRole("button", { name: "Build & deploy", exact: true }),
).toBeDisabled();
-
- await page.goto("/deploy?site=alpha");
- // Selecting a specific site clears the "pick a site" notice (build/deploy is
- // now scoped to alpha).
await expect(
- page.getByText(/select a specific site from the sidebar to build & deploy\./i),
- ).toHaveCount(0);
+ page.getByText(/has no Cloudflare Pages project configured/i),
+ ).toBeVisible();
+ await expect(page.getByText(/select a specific site/i)).toHaveCount(0);
+ await expect(page.getByLabel("Active site")).toHaveValue("alpha");
+ await page.getByLabel("Active site").selectOption("beta");
+ await expect(page).toHaveURL(/\/sites\/beta\/publish$/);
});
test("pool views show every site regardless of the selector", async ({
diff --git a/editor/next.config.ts b/editor/next.config.ts
@@ -100,6 +100,8 @@ const nextConfig: NextConfig = {
{ source: "/charts", destination: "/sites", permanent: false },
{ source: "/aliases", has: [siteQuery], destination: "/sites/:site/aliases", permanent: false },
{ source: "/aliases", destination: "/sites", permanent: false },
+ { source: "/deploy", has: [siteQuery], destination: "/sites/:site/publish", permanent: false },
+ { source: "/deploy", destination: "/sites", permanent: false },
];
},
// Serve the built export artifacts (stats/summaries/transcripts) through a