commit d1c783cde9dc61ae5af5a588b517a4d669f612b2
parent 1914e869fcf6d6537fe61a71c1c8844541880eb4
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 29 Jun 2026 10:00:55 -0400
feat(deploy): rework deploy page with build-then-deploy, batch builds, build-mode toggle
Reworks /deploy into a build/deploy lifecycle (Release notes -> Build & deploy
-> Individual steps -> Build multiple sites). Adds a one-click build-then-deploy
composite job (single combined log + cancel; cancel mid-build skips deploy), a
multi-site batch panel with a live per-site log lane each, and a persisted
Basic/Docker build-mode toggle. Docker mode is seamed but falls back to the basic
build with a notice; the container pipeline is a follow-up.
- common/lib/settings.ts: BuildPipelineSettings block (mode/maxParallelBuilds/
dockerImage/dockerfile) with default+sanitize, wired through get/writeSettings
- common/lib/paths.ts: exportBuildsDir seam (env-overridable)
- common/jobs/runChild.ts: line-buffered child streaming with SIGTERM->SIGKILL
- editor/app/deploy/buildDeployCore.ts: mode-routing runBuildPhase + runDeployIntoLog
- editor/app/build/buildAction.ts: buildAndDeployAction composite; buildExportAction
onto the mode seam
- deploy/settings UI + new components; e2e: deploy-page spec, updated build/settings
tsc clean across editor/export/common; 16 e2e pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Diffstat:
18 files changed, 1116 insertions(+), 82 deletions(-)
diff --git a/common/jobs/runChild.ts b/common/jobs/runChild.ts
@@ -0,0 +1,78 @@
+import { execa, type ResultPromise } from "execa";
+
+export type RunChildOpts = {
+ command: string;
+ args: string[];
+ cwd: string;
+ env?: Record<string, string | undefined>;
+ // Optional prefix prepended to every streamed line (e.g. "[testsite] "), so a
+ // single combined log can interleave several children legibly.
+ label?: string;
+};
+
+// Spawn a child process and stream its combined stdout/stderr into `onLog`,
+// resolving with the exit code. Used inside a managed function's `fn` to run a
+// sub-command as part of a larger job (build-then-deploy, multi-phase builds)
+// while keeping ONE job/stream/cancel. Mirrors streamCommand's execa usage
+// (all:true, buffer:false, reject:false) so behavior matches the top-level
+// managed command. On `signal` abort the child is SIGTERM'd, then SIGKILL'd
+// after a grace period — the same escalation registry.cancel() applies to the
+// top-level child. Resolves the abort case with the child's (likely non-zero)
+// exit code; the caller gates on `signal.aborted` to decide what to do next.
+export async function runChildIntoLog(
+ onLog: (line: string) => void,
+ signal: AbortSignal,
+ opts: RunChildOpts,
+): Promise<number> {
+ const prefix = opts.label ?? "";
+ const child: ResultPromise = execa(opts.command, opts.args, {
+ cwd: opts.cwd,
+ env: opts.env,
+ all: true,
+ buffer: false,
+ reject: false,
+ });
+
+ // Line-buffer: execa chunks aren't line-aligned, and the managed-function
+ // onLog appends a newline per call, so emitting raw chunks would inject
+ // spurious newlines mid-line. Accumulate and emit only COMPLETE lines (without
+ // the trailing "\n", which onLog re-adds), holding any partial until more data
+ // arrives or the child exits. The optional label prefixes each line.
+ let buf = "";
+ const flushLines = () => {
+ let nl = buf.indexOf("\n");
+ while (nl !== -1) {
+ onLog(prefix + buf.slice(0, nl));
+ buf = buf.slice(nl + 1);
+ nl = buf.indexOf("\n");
+ }
+ };
+ child.all?.on("data", (chunk: Buffer) => {
+ buf += chunk.toString("utf8");
+ flushLines();
+ });
+ child.all?.on("error", () => {});
+
+ let killTimer: ReturnType<typeof setTimeout> | null = null;
+ const onAbort = () => {
+ child.kill("SIGTERM");
+ killTimer = setTimeout(() => {
+ if (child.killed === false) child.kill("SIGKILL");
+ }, 5_000);
+ killTimer.unref?.();
+ };
+ if (signal.aborted) onAbort();
+ else signal.addEventListener("abort", onAbort, { once: true });
+
+ try {
+ const result = await child;
+ return result.exitCode ?? 1;
+ } finally {
+ if (buf.length > 0) {
+ onLog(prefix + buf);
+ buf = "";
+ }
+ if (killTimer) clearTimeout(killTimer);
+ signal.removeEventListener("abort", onAbort);
+ }
+}
diff --git a/common/lib/paths.ts b/common/lib/paths.ts
@@ -68,6 +68,11 @@ export type Paths = {
exportSharedTranscriptsDir: string;
exportSharedSubsDir: string;
exportSitesIndexDir: string;
+ // Per-site extracted static output (`out/`) from an isolated (Docker) build,
+ // keyed exportBuildsDir/<siteId>. Sibling of .export-index. The wrangler deploy
+ // reads a site's bundle from here in docker mode (in basic mode the deploy
+ // reads export/out directly). Env-overridable so test data stays isolated.
+ exportBuildsDir: string;
settingsFile: string;
chartsConfigFile: string;
ytdlpBin: string;
@@ -133,6 +138,9 @@ export function getPaths(): Paths {
exportSharedTranscriptsDir: path.join(exportSharedDir, "transcripts"),
exportSharedSubsDir: path.join(exportSharedDir, "subs"),
exportSitesIndexDir: path.join(exportIndexDir, "sites"),
+ exportBuildsDir:
+ process.env.EXPORT_BUILDS_DIR ??
+ path.join(path.dirname(exportPublicDir), ".export-builds"),
settingsFile:
process.env.SETTINGS_FILE ?? path.join(monorepoRoot, "settings.json"),
chartsConfigFile:
diff --git a/common/lib/settings.ts b/common/lib/settings.ts
@@ -120,6 +120,29 @@ export type SiteSettings = {
// sync scheduler runs the backup on the configured cadence. See
// common/controller/backupSavedVideos.ts.
savedVideoBackup: SavedVideoBackupSettings;
+ // How the static export is built: "basic" reuses the single export/ tree and
+ // serializes builds on one queue (the long-standing behavior); "docker" runs
+ // each site's build in an isolated container for safe parallelism. The Docker
+ // pipeline itself is a follow-up; this block persists the chosen mode plus the
+ // container/concurrency knobs the deploy page and the future orchestrator read.
+ buildPipeline: BuildPipelineSettings;
+};
+
+// "basic" — `pnpm run build` in export/, serialized on the build queue (shared
+// output tree → no safe parallelism).
+// "docker" — isolated per-site container builds (follow-up); enables real
+// parallel multi-site builds capped by maxParallelBuilds.
+export type BuildMode = "basic" | "docker";
+
+export type BuildPipelineSettings = {
+ mode: BuildMode;
+ // Cap on concurrent per-site container builds in docker mode. Ignored in basic
+ // mode (which is always serial). Clamped to [1, BUILD_MAX_PARALLEL_MAX].
+ maxParallelBuilds: number;
+ // Tag of the reusable build image (built once, reused for every site).
+ dockerImage: string;
+ // Dockerfile path relative to the monorepo root, used to (re)build the image.
+ dockerfile: string;
};
export type SavedVideoBackupSettings = {
@@ -355,6 +378,50 @@ export function sanitizeSavedVideoBackup(
};
}
+export const BUILD_MAX_PARALLEL_DEFAULT = 2;
+export const BUILD_MAX_PARALLEL_MAX = 16;
+export const DEFAULT_BUILD_IMAGE = "yt-dlp-transcript-browser-build";
+export const DEFAULT_BUILD_DOCKERFILE = "Dockerfile.build";
+
+export function isBuildMode(v: unknown): v is BuildMode {
+ return v === "basic" || v === "docker";
+}
+
+export function defaultBuildPipeline(): BuildPipelineSettings {
+ return {
+ mode: "basic",
+ maxParallelBuilds: BUILD_MAX_PARALLEL_DEFAULT,
+ dockerImage: DEFAULT_BUILD_IMAGE,
+ dockerfile: DEFAULT_BUILD_DOCKERFILE,
+ };
+}
+
+// Coerce a raw settings.buildPipeline value into a clean BuildPipelineSettings,
+// falling back to defaults for missing/ill-typed fields.
+export function sanitizeBuildPipeline(value: unknown): BuildPipelineSettings {
+ const d = defaultBuildPipeline();
+ if (!value || typeof value !== "object") return d;
+ const r = value as Record<string, unknown>;
+ const dockerImage =
+ typeof r.dockerImage === "string" && r.dockerImage.trim()
+ ? r.dockerImage.trim()
+ : d.dockerImage;
+ const dockerfile =
+ typeof r.dockerfile === "string" && r.dockerfile.trim()
+ ? r.dockerfile.trim()
+ : d.dockerfile;
+ return {
+ mode: isBuildMode(r.mode) ? r.mode : d.mode,
+ maxParallelBuilds: clampPositiveInt(
+ r.maxParallelBuilds,
+ d.maxParallelBuilds,
+ BUILD_MAX_PARALLEL_MAX,
+ ),
+ dockerImage,
+ dockerfile,
+ };
+}
+
function defaults(): SiteSettings {
return {
adminTitle: DEFAULT_ADMIN_TITLE,
@@ -374,6 +441,7 @@ function defaults(): SiteSettings {
autoQueue: defaultAutoQueue(),
socialLinks: [],
savedVideoBackup: defaultSavedVideoBackup(),
+ buildPipeline: defaultBuildPipeline(),
};
}
@@ -537,6 +605,7 @@ export function getSettings(): SiteSettings {
merged.autoQueue = sanitizeAutoQueue(merged.autoQueue);
merged.socialLinks = parseSocialLinks(merged.socialLinks);
merged.savedVideoBackup = sanitizeSavedVideoBackup(merged.savedVideoBackup);
+ merged.buildPipeline = sanitizeBuildPipeline(merged.buildPipeline);
// Workers. When the file predates the worker model (no `workers` key),
// synthesize a default list from the (now-settled) active app + per-app
// configs so existing installs behave identically. Otherwise sanitize the
@@ -708,6 +777,7 @@ export async function writeSettings(next: SiteSettings): Promise<void> {
autoQueue: sanitizeAutoQueue(next.autoQueue),
socialLinks,
savedVideoBackup: sanitizeSavedVideoBackup(next.savedVideoBackup),
+ buildPipeline: sanitizeBuildPipeline(next.buildPipeline),
};
const tmp = `${file}.tmp-${process.pid}`;
await fs.promises.writeFile(tmp, JSON.stringify(merged, null, 2) + "\n");
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **The Deploy page is reworked around a clearer build/deploy lifecycle, with one-click build-then-deploy and batch multi-site builds.** The page now reads top-to-bottom as you'd actually ship: **Release notes** (the `## [Unreleased]` changelog preview + Cut release) → **Build & deploy** → optional **Individual steps** → **Build multiple sites**. A new **Build & deploy** button runs the build and, only if it succeeds (and wasn't cancelled), deploys it — as a single managed job with one combined streamed log and one Cancel (`buildAndDeployAction`, a composite `runManagedFunction`; cancelling mid-build skips the deploy). The new **Build multiple sites** panel kicks off a build (optionally build+deploy) for several sites at once, each rendered as its own live status-chipped log lane (`BuildSitesPanel` + `JobLane`); in Basic mode the jobs serialize on the shared build/deploy queue (the `export/` output tree is shared), with a note that true parallelism arrives with Docker mode. A **Build mode** toggle (Basic | Docker) on the page persists the choice as the default (`settings.buildPipeline`, also editable on Settings); Docker mode is a follow-up and currently falls back to a basic build with an inline notice. The build/deploy commands now share a child-streaming helper (`common/jobs/runChild.ts`) and mode-routing core (`editor/app/deploy/buildDeployCore.ts`). See `editor/app/deploy/{page.tsx,buildAction usage,components/*}`, `editor/app/build/buildAction.ts`, and `common/lib/settings.ts`.
- **Truncated transcripts are now detected and flagged for re-download.** When an audio download silently stops early (yt-dlp exits `ok`, `download-outcome.json` records success), whisper transcribes only the few minutes that landed — so a 2h22m video ends up with a ~7-minute transcript and nothing warns you. A new coverage check (last cue end ÷ video duration) flags any non-livestream video ≥10min whose transcript covers <50% of its runtime. The single source of truth is `common/lib/transcriptCoverage.ts` (`transcriptCoverage` + `isIncompleteTranscript`, with named thresholds), read from each video's `transcript.cues.json` so the existing corpus is flagged with no migration. Surfaced everywhere: a new **`incompleteTranscript`** channel-snapshot bucket → an **"Incomplete transcript"** filter chip and an **amber transcribed-dot** in the per-channel video list; a warning banner on the video page ("Transcript covers 6:52 of 2:22:21 (4.8%)…") with a one-click **Re-download & re-transcribe** button; and an **"Channels with incomplete (truncated) transcripts"** section on `/actionable`. The fix action (`redownloadIncompleteTranscriptAction`) deletes the truncated audio first, then re-downloads and re-transcribes — re-running whisper alone would just reproduce the short transcript. See `common/controller/channelSnapshot.ts`, `editor/app/channels/[slug]/{lib/videoRows.ts,lib/videoRowsServer.ts,lib/stageStatus.ts,components/VideoListPane.tsx,videos/[id]/{components/VideoPanel.tsx,videoActions.ts,page.tsx},page.tsx}`, and `editor/app/actionable/{lib/loadActionable.ts,page.tsx}`.
- **Auto-queue rules with no bucket now draw from *all* of a runner's buckets, and auto-download can resume partial downloads.** A policy-tree rule left at the **"all buckets (default)"** setting (previously just labeled *default*) now draws from the **union** of every bucket that runner kind tracks — deduped, in priority order — instead of only the single primary bucket. This fixes channels (e.g. an Odysee channel mid-download) that quietly stopped being auto-downloaded once their remaining work drifted entirely into **partially-downloaded** videos: those have a `.part` file but no completed audio, so they live in the `partialDownloads` bucket and were **absent from `undownloadedIds`** — the only bucket auto-download used to load. The download runner now loads `partialDownloads` alongside `undownloadedIds` (partials first, so in-progress downloads resume via `downloadOneManaged` before fresh ones start), and exposes `partialDownloads` as a selectable bucket in the policy editor so you can dedicate a high-priority rule to resuming partials. The per-kind bucket lists are consolidated behind a single `bucketsForKind` source of truth shared by the runner, the per-rule pending-count helper, and the editor's bucket picker (so they can't drift). Note: a *bucketless* auto-transcribe rule now also drains `failedListed` after `downloadedNoTranscript` (it already loaded both); platform rate-limit backoff is unchanged and remains an independent reason a throttled platform may pause. See `common/jobs/autoQueuePolicy.ts` (`buildPendingByLeaf` + `bucketsForKind` + unit tests), `common/controller/autoRunner.ts`, and `editor/app/auto-queue/{page.tsx,components/PolicyTreeEditor.tsx}`.
- **Hub homepage redesigned into a cross-site landing; the homepage page-creator is removed.** The hub's home page is now a single mobile-first cross-site landing (headline KPIs and one stacked activity chart with Metric [Transcribed/Downloaded] · Breakdown [By site/By channel] · Bucket [Week/Month/Cumulative] · Range [90d/12mo/All] · Display [Share/Counts] controls, plus a metric-aware site-links grid with sparklines and a "#1 this month" badge), built from a small `homepage-summary.json` pre-computed by `compose-homepage`. The separate `/stats` dashboard route folds into it. Consequently the hub's **Markdown-pages subsystem is dropped**: **Manage → Homepage** now edits only branding (the Pages list, New-page, and the page editor are gone), and the homepage config no longer carries a `nav`. The page server actions (`saveHomepagePageAction`/`deleteHomepagePageAction`), `editor/app/homepage/pages/*`, `PageEditor.tsx`, `common/lib/{homepagePages,homepageConstants}.ts`, and `paths.homepagePagesDir` are removed. See `editor/app/homepage/{page.tsx,actions.ts}`, `common/bin/compose-homepage.ts`, `common/lib/{homepageSummary,homepageChart}.ts`, and the `homepage/` package. (Re-addable later if needed.)
diff --git a/editor/app/build/buildAction.ts b/editor/app/build/buildAction.ts
@@ -15,13 +15,19 @@ import {
archiveCombinedLiveChat,
} from "yt-dlp-transcript-common/controller/archiveLiveChat";
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { getSite } from "yt-dlp-transcript-common/lib/site";
import {
- runManagedCommand,
runManagedFunction,
type StreamActionResult,
} from "yt-dlp-transcript-common/jobs/streamCommand";
+import {
+ resolveOutDir,
+ runBuildPhase,
+ runDeployIntoLog,
+} from "../deploy/buildDeployCore";
const DEFAULT_BUILD_QUEUE = "build";
+const DEPLOY_QUEUE = "deploy";
export async function buildIndexAction(
queueKey?: string,
@@ -59,22 +65,69 @@ export async function buildExportAction(
queueKey?: string,
): Promise<StreamActionResult> {
const paths = getPaths();
- if (!siteId.trim()) {
+ const id = siteId.trim();
+ if (!id) {
return { ok: false, error: "Select a site to build" };
}
- return runManagedCommand({
+ // 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.
+ return runManagedFunction({
kind: "build-export",
queueKey: queueKey === undefined ? DEFAULT_BUILD_QUEUE : queueKey.trim(),
paths,
- cwd: paths.exportDir,
- command: "pnpm",
- args: ["run", "build"],
- env: {
- ...process.env,
- NODE_ENV: "production",
- TRANSCRIPTS_DIR: paths.transcriptsDir,
- EXPORT_PUBLIC_DIR: paths.exportPublicDir,
- SITE_ID: siteId.trim(),
+ fn: async (onLog, signal) => {
+ const code = await runBuildPhase(onLog, signal, id, paths);
+ 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,
+): 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);
+ // 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");
+ 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}).`);
+ }
},
});
}
diff --git a/editor/app/deploy/buildDeployCore.ts b/editor/app/deploy/buildDeployCore.ts
@@ -0,0 +1,86 @@
+// Shared, server-only build/deploy primitives used by the build/deploy server
+// actions (buildAction.ts, deployAction.ts). These take an `onLog` callback and
+// an AbortSignal, so they CANNOT live in a "use server" module (every export
+// there becomes a server action, which forbids non-serializable args). Keep them
+// here as plain helpers and import them into the thin action wrappers.
+
+import path from "node:path";
+import { runChildIntoLog } from "yt-dlp-transcript-common/jobs/runChild";
+import type { Paths } from "yt-dlp-transcript-common/lib/paths";
+import { getSettings } from "yt-dlp-transcript-common/lib/settings";
+import type { Site } from "yt-dlp-transcript-common/lib/site";
+
+// Where the static output that should be deployed for a site lives. Today the
+// build always writes export/out (basic build), so deploy reads it in both
+// modes. The Docker follow-up will write each site's `out/` to
+// paths.exportBuildsDir/<siteId> and switch this to return that per-site dir in
+// docker mode; until then docker mode falls back to a basic build (see
+// runBuildPhase), so export/out is correct everywhere.
+export function resolveOutDir(_siteId: string, paths: Paths): string {
+ return path.join(paths.exportDir, "out");
+}
+
+// Run the build phase for one site, streaming into `onLog`, returning the exit
+// code. Routes on the persisted build mode: "basic" runs `pnpm run build` in
+// export/ (serialized upstream on the build queue, since the export/ tree is
+// shared); "docker" is a follow-up — until the container pipeline lands it logs
+// a notice and falls back to the basic build so builds always succeed.
+export async function runBuildPhase(
+ onLog: (line: string) => void,
+ signal: AbortSignal,
+ siteId: string,
+ paths: Paths,
+): Promise<number> {
+ const { mode } = getSettings().buildPipeline;
+ if (mode === "docker") {
+ onLog(
+ "[notice] Docker build mode isn't wired up yet — running the basic build " +
+ "for now. Parallel container builds arrive in a follow-up.\n",
+ );
+ }
+ return runChildIntoLog(onLog, signal, {
+ command: "pnpm",
+ args: ["run", "build"],
+ cwd: paths.exportDir,
+ env: {
+ ...process.env,
+ NODE_ENV: "production",
+ TRANSCRIPTS_DIR: paths.transcriptsDir,
+ EXPORT_PUBLIC_DIR: paths.exportPublicDir,
+ SITE_ID: siteId,
+ },
+ });
+}
+
+// Deploy a previously-built static bundle (`outDir`) to the site's Cloudflare
+// Pages project, streaming into `onLog`, returning the exit code. Runs on the
+// host with the host's Cloudflare credentials (process.env) — deploy never runs
+// inside a container, so container wrangler auth is never needed.
+export async function runDeployIntoLog(
+ onLog: (line: string) => void,
+ signal: AbortSignal,
+ site: Site,
+ outDir: string,
+ paths: Paths,
+): Promise<number> {
+ return runChildIntoLog(onLog, signal, {
+ command: "pnpm",
+ args: [
+ "dlx",
+ "wrangler",
+ "pages",
+ "deploy",
+ outDir,
+ "--project-name",
+ site.cloudflareProject as string,
+ ],
+ cwd: paths.exportDir,
+ env: {
+ ...process.env,
+ NODE_ENV: "production",
+ TRANSCRIPTS_DIR: paths.transcriptsDir,
+ EXPORT_PUBLIC_DIR: paths.exportPublicDir,
+ SITE_ID: site.siteId,
+ },
+ });
+}
diff --git a/editor/app/deploy/buildModeAction.ts b/editor/app/deploy/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 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/BuildDeployButton.tsx b/editor/app/deploy/components/BuildDeployButton.tsx
@@ -0,0 +1,58 @@
+"use client";
+
+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);
+
+ let notice: React.ReactNode;
+ if (!siteId) {
+ notice = (
+ <p className="text-sm text-amber-600 dark:text-amber-400">
+ Select a specific site from the sidebar to build & deploy.
+ </p>
+ );
+ } else if (!cloudflareProject) {
+ notice = (
+ <p className="text-sm text-amber-600 dark:text-amber-400">
+ <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-zinc-500">
+ Builds then deploys <strong>{siteTitle ?? siteId}</strong> (
+ <code>{siteId}</code> → <code>{cloudflareProject}</code>).
+ </p>
+ );
+ }
+
+ return (
+ <StreamActionLog
+ trigger={() => buildAndDeployAction(siteId as string)}
+ cancelAction={cancelJobAction}
+ buttonLabel="Build & deploy"
+ runningLabel="Building & deploying…"
+ label="Build and deploy"
+ disabled={!ready}
+ extraControls={notice}
+ />
+ );
+}
diff --git a/editor/app/deploy/components/BuildModeToggle.tsx b/editor/app/deploy/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 "../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-zinc-500">Build mode</span>
+ <div
+ role="group"
+ aria-label="Build mode"
+ className="inline-flex w-fit rounded-md border border-zinc-300 dark:border-zinc-700 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-zinc-900 text-zinc-100 dark:bg-zinc-100 dark:text-zinc-900"
+ : "bg-transparent text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800")
+ }
+ >
+ {opt.label}
+ </button>
+ );
+ })}
+ </div>
+ {mode === "docker" && (
+ <span className="text-xs text-amber-600 dark:text-amber-400">
+ 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
@@ -0,0 +1,145 @@
+"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 (
+ <div className="flex flex-col gap-4">
+ <div className="flex flex-col gap-2 rounded-md border border-zinc-200 dark:border-zinc-800 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-zinc-500">{s.siteId}</code>
+ {!s.cloudflareProject && (
+ <span className="text-xs text-amber-600 dark:text-amber-400">
+ no deploy target
+ </span>
+ )}
+ </label>
+ ))}
+ {sites.length === 0 && (
+ <span className="text-sm italic text-zinc-500">
+ 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-zinc-900 dark:bg-zinc-100 text-zinc-100 dark:text-zinc-900 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-zinc-600 dark:text-zinc-300">
+ <input
+ type="checkbox"
+ checked={deployAfter}
+ onChange={(e) => setDeployAfter(e.target.checked)}
+ />
+ Deploy after build
+ </label>
+ <span className="text-xs text-zinc-500">
+ {selected.size} selected
+ </span>
+ </div>
+
+ {missingProject && (
+ <p className="text-xs text-amber-600 dark:text-amber-400">
+ Some selected sites have no Cloudflare Pages project — those will build
+ only (deploy is skipped for them).
+ </p>
+ )}
+ {serial && (
+ <p className="text-xs text-zinc-500">
+ Basic mode runs these one at a time (the build output tree is shared).
+ Switch to Docker mode for true parallel builds — a follow-up.
+ </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/JobLane.tsx b/editor/app/deploy/components/JobLane.tsx
@@ -0,0 +1,210 @@
+"use client";
+
+import { useEffect, useRef, useState } from "react";
+import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand";
+import { cancelJobAction } from "../../jobs/actions";
+
+type LaneStatus =
+ | "starting"
+ | "queued"
+ | "running"
+ | "done"
+ | "failed"
+ | "cancelled"
+ | "error";
+
+type Props = {
+ title: string;
+ subtitle: string;
+ // Launched once on mount. Returns the managed-job descriptor (or an error).
+ trigger: () => Promise<StreamActionResult>;
+};
+
+const CHIP: Record<LaneStatus, { label: string; cls: string }> = {
+ starting: { label: "Starting…", cls: "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300" },
+ queued: { label: "Queued", cls: "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300" },
+ running: { label: "Running", cls: "bg-sky-100 text-sky-700 dark:bg-sky-950 dark:text-sky-300" },
+ done: { label: "Done", cls: "bg-emerald-100 text-emerald-700 dark:bg-emerald-950 dark:text-emerald-300" },
+ failed: { label: "Failed", cls: "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300" },
+ cancelled: { label: "Cancelled", cls: "bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300" },
+ error: { label: "Error", cls: "bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300" },
+};
+
+// A compact, auto-starting lane for one site's build (or build+deploy). Reuses
+// the managed-job stream + done promise: the stream feeds the log, `done` gives
+// the terminal status. A light poll surfaces queued/running + queue position
+// before the job reaches a terminal state. Mirrors StreamActionLog's plumbing in
+// miniature so several lanes can run at once on the batch panel.
+export function JobLane({ title, subtitle, trigger }: Props) {
+ const [status, setStatus] = useState<LaneStatus>("starting");
+ const [log, setLog] = useState("");
+ const [error, setError] = useState<string | null>(null);
+ const [jobId, setJobId] = useState<string | null>(null);
+ const [queuePos, setQueuePos] = useState<number | null>(null);
+ const [cancelling, setCancelling] = useState(false);
+ const preRef = useRef<HTMLPreElement | null>(null);
+ const startedRef = useRef(false);
+
+ useEffect(() => {
+ // Strict-mode mounts effects twice in dev; guard so the job launches once.
+ if (startedRef.current) return;
+ startedRef.current = true;
+ let cancelled = false;
+ let stopped = false;
+ let pollTimer: ReturnType<typeof setTimeout> | null = null;
+
+ async function poll(id: string) {
+ try {
+ const res = await fetch(
+ `/api/jobs/${encodeURIComponent(id)}/log?from=999999999`,
+ { cache: "no-store" },
+ );
+ if (res.ok) {
+ const data = (await res.json()) as {
+ status: string;
+ queuePosition?: number;
+ };
+ if (!cancelled) {
+ if (data.status === "queued") {
+ setStatus("queued");
+ setQueuePos(
+ typeof data.queuePosition === "number" && data.queuePosition > 0
+ ? data.queuePosition
+ : null,
+ );
+ } else if (data.status === "running") {
+ setStatus("running");
+ setQueuePos(null);
+ }
+ }
+ }
+ } catch {
+ /* transient; keep polling */
+ }
+ if (!cancelled && !stopped) pollTimer = setTimeout(() => poll(id), 1000);
+ }
+
+ (async () => {
+ let result: StreamActionResult;
+ try {
+ result = await trigger();
+ } catch (e) {
+ if (!cancelled) {
+ setStatus("error");
+ setError((e as Error).message);
+ }
+ return;
+ }
+ if (cancelled) return;
+ if (!result.ok) {
+ setStatus("error");
+ setError(result.error);
+ return;
+ }
+ setJobId(result.jobId);
+ setStatus("running");
+ poll(result.jobId);
+
+ const reader = result.stream.getReader();
+ try {
+ while (true) {
+ const { value, done } = await reader.read();
+ if (done) break;
+ if (value && !cancelled) setLog((prev) => prev + value);
+ }
+ } catch {
+ /* stream tear-down */
+ }
+ const term = await result.done;
+ stopped = true;
+ if (pollTimer) clearTimeout(pollTimer);
+ if (!cancelled) {
+ setQueuePos(null);
+ setStatus(
+ term.status === "done"
+ ? "done"
+ : term.status === "failed"
+ ? "failed"
+ : "cancelled",
+ );
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ if (pollTimer) clearTimeout(pollTimer);
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // Keep the log scrolled to the bottom as it grows.
+ useEffect(() => {
+ const el = preRef.current;
+ if (el) el.scrollTop = el.scrollHeight;
+ }, [log]);
+
+ async function handleCancel() {
+ if (!jobId) return;
+ setCancelling(true);
+ try {
+ await cancelJobAction(jobId);
+ } finally {
+ setCancelling(false);
+ }
+ }
+
+ const chip = CHIP[status];
+ const live = status === "queued" || status === "running" || status === "starting";
+
+ return (
+ <div className="rounded-md border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900">
+ <div className="flex items-center gap-3 px-3 py-2">
+ <span
+ className={`shrink-0 rounded-full px-2 py-0.5 text-xs font-medium ${chip.cls}`}
+ >
+ {status === "queued" && queuePos
+ ? `Queued · ${queuePos}`
+ : chip.label}
+ </span>
+ <div className="min-w-0 flex-1">
+ <div className="truncate text-sm font-medium">{title}</div>
+ <div className="truncate text-xs text-zinc-500">{subtitle}</div>
+ </div>
+ {live && jobId && (
+ <button
+ type="button"
+ onClick={handleCancel}
+ disabled={cancelling}
+ aria-label={`Cancel ${title}`}
+ className="shrink-0 rounded border border-red-300 dark:border-red-800 px-2 py-1 text-xs font-medium text-red-700 dark:text-red-300 hover:bg-red-50 dark:hover:bg-red-950 disabled:opacity-50"
+ >
+ {cancelling ? "Cancelling…" : "Cancel"}
+ </button>
+ )}
+ </div>
+ {error && (
+ <div
+ role="alert"
+ className="mx-3 mb-2 rounded border border-red-300 bg-red-50 dark:border-red-800 dark:bg-red-950 px-2 py-1.5 text-xs text-red-700 dark:text-red-300"
+ >
+ {error}
+ </div>
+ )}
+ {(log || live) && (
+ <details className="px-3 pb-2">
+ <summary className="cursor-pointer text-xs text-zinc-500 select-none">
+ Log
+ </summary>
+ <pre
+ ref={preRef}
+ role="log"
+ aria-label={`${title} output`}
+ className="mt-1 h-48 overflow-auto whitespace-pre-wrap rounded border border-zinc-200 dark:border-zinc-800 bg-zinc-100 dark:bg-zinc-950 p-2 text-xs font-mono"
+ >
+ {log || "Waiting for output…"}
+ </pre>
+ </details>
+ )}
+ </div>
+ );
+}
diff --git a/editor/app/deploy/deployAction.ts b/editor/app/deploy/deployAction.ts
@@ -3,9 +3,10 @@
import { getPaths } from "yt-dlp-transcript-common/lib/paths";
import { getSite } from "yt-dlp-transcript-common/lib/site";
import {
- runManagedCommand,
+ runManagedFunction,
type StreamActionResult,
} from "yt-dlp-transcript-common/jobs/streamCommand";
+import { resolveOutDir, runDeployIntoLog } from "./buildDeployCore";
const DEPLOY_QUEUE = "deploy";
@@ -23,27 +24,20 @@ export async function deployExportAction(
error: `Site "${site.siteId}" has no Cloudflare Pages project configured.`,
};
}
- return runManagedCommand({
+ return runManagedFunction({
kind: "deploy-export",
queueKey: DEPLOY_QUEUE,
paths,
- cwd: paths.exportDir,
- command: "pnpm",
- args: [
- "dlx",
- "wrangler",
- "pages",
- "deploy",
- "out",
- "--project-name",
- site.cloudflareProject,
- ],
- env: {
- ...process.env,
- NODE_ENV: "production",
- TRANSCRIPTS_DIR: paths.transcriptsDir,
- EXPORT_PUBLIC_DIR: paths.exportPublicDir,
- SITE_ID: site.siteId,
+ fn: async (onLog, signal) => {
+ const code = await runDeployIntoLog(
+ onLog,
+ signal,
+ site,
+ resolveOutDir(site.siteId, paths),
+ paths,
+ );
+ if (signal.aborted) return;
+ if (code !== 0) throw new Error(`Deploy failed (exit ${code}).`);
},
});
}
diff --git a/editor/app/deploy/page.tsx b/editor/app/deploy/page.tsx
@@ -8,9 +8,13 @@ import {
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 { BuildSitesPanel } from "./components/BuildSitesPanel";
import { CutReleaseForm } from "./components/CutReleaseForm";
import { DeployButton } from "./components/DeployButton";
import { resolveActiveSite } from "../lib/activeSite";
@@ -51,42 +55,36 @@ export default async function DeployPage({
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 activeSiteTitle = activeSiteId
- ? (listSites(paths).find((s) => s.siteId === activeSiteId)?.siteTitle ??
- activeSiteId)
- : null;
+ 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">
- <h1 className="text-2xl font-semibold">Deploy</h1>
+ <div className="flex flex-col gap-1">
+ <h1 className="text-2xl font-semibold">Deploy</h1>
+ <p className="text-sm text-zinc-500">
+ 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">Build static export</h2>
+ <h2 className="text-lg font-semibold">Release notes</h2>
<p className="text-sm text-zinc-500">
- Spawns <code>pnpm run build</code> in <code>export/</code> — runs
- the index build, then <code>next build</code> to produce the
- static site at <code>export/out/</code>.
- </p>
- </div>
- <BuildExportButton
- existingQueues={existingQueues}
- siteId={activeSiteId}
- siteTitle={activeSiteTitle}
- />
- </section>
-
- <section className="flex flex-col gap-3 border-t border-zinc-200 dark:border-zinc-800 pt-6">
- <div>
- <h2 className="text-lg font-semibold">Pending export changes</h2>
- <p className="text-sm text-zinc-500">
- Bullets currently sitting under <code>## [Unreleased]</code> in{" "}
- <code>export/CHANGELOG.md</code>. These become the body of the
- next cut release.
+ 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 ? (
@@ -98,16 +96,6 @@ export default async function DeployPage({
No pending changes yet.
</div>
)}
- </section>
-
- <section className="flex flex-col gap-3 border-t border-zinc-200 dark:border-zinc-800 pt-6">
- <div>
- <h2 className="text-lg font-semibold">Cut release</h2>
- <p className="text-sm text-zinc-500">
- Turns the current <code>## [Unreleased]</code> heading into a
- dated semver heading.
- </p>
- </div>
<CutReleaseForm
workspace="export"
lastVersion={lastVersion}
@@ -116,14 +104,13 @@ export default async function DeployPage({
/>
</section>
+ {/* 2 — Build & deploy (primary path) for the active site. */}
<section className="flex flex-col gap-3 border-t border-zinc-200 dark:border-zinc-800 pt-6">
<div>
- <h2 className="text-lg font-semibold">Deploy static export</h2>
+ <h2 className="text-lg font-semibold">Build & deploy</h2>
<p className="text-sm text-zinc-500">
- Runs <code>pnpm run deploy</code> in <code>export/</code>,
- publishing the most recently built static site at{" "}
- <code>export/out/</code>. Build first if needed using{" "}
- <code>Build static export</code> at the top of this page.
+ 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-zinc-500 mt-1">
Last deployed:{" "}
@@ -132,7 +119,70 @@ export default async function DeployPage({
: "never"}
</p>
</div>
- <DeployButton siteId={activeSiteId} siteTitle={activeSiteTitle} />
+ <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-zinc-200 dark:border-zinc-800 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-zinc-500">
+ 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-zinc-500">
+ 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-zinc-200 dark:border-zinc-800 pt-6">
+ <div>
+ <h2 className="text-lg font-semibold">Build multiple sites</h2>
+ <p className="text-sm text-zinc-500">
+ Pick sites to build (and optionally deploy) together. Each gets 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/settings/actions.ts b/editor/app/settings/actions.ts
@@ -5,6 +5,8 @@ import {
AUTO_REFRESH_INTERVAL_MAX_SECONDS,
AUTO_REFRESH_INTERVAL_MIN_SECONDS,
DEFAULT_REPORT_DEBOUNCE_PRESET,
+ defaultBuildPipeline,
+ isBuildMode,
isReportDebouncePreset,
getSettings,
MIN_FREE_DISK_GB_MAX,
@@ -175,6 +177,19 @@ export async function saveSettingsAction(
socialLinks.push({ ...link, svg });
}
+ // Build pipeline. Values are clamped/coerced by sanitizeBuildPipeline inside
+ // writeSettings, so we only read the form here (NaN/blank → default). The
+ // deploy-page toggle also writes `mode`; whichever saves last wins.
+ const dB = defaultBuildPipeline();
+ const buildModeRaw = String(formData.get("buildMode") ?? "").trim();
+ const buildPipeline = {
+ mode: isBuildMode(buildModeRaw) ? buildModeRaw : dB.mode,
+ maxParallelBuilds:
+ Number.parseInt(String(formData.get("maxParallelBuilds") ?? "").trim(), 10),
+ dockerImage: String(formData.get("dockerImage") ?? "").trim() || dB.dockerImage,
+ dockerfile: String(formData.get("dockerfile") ?? "").trim() || dB.dockerfile,
+ };
+
const next: SiteSettings = {
adminTitle,
maxTranscriptPageBytes: parsed,
@@ -201,6 +216,7 @@ export async function saveSettingsAction(
// Preserve the saved-video backup config on an unrelated settings save (the
// Saved Videos page edits it). writeSettings re-sanitizes it regardless.
savedVideoBackup: getSettings().savedVideoBackup,
+ buildPipeline,
};
try {
await writeSettings(next);
diff --git a/editor/app/settings/components/SettingsForm.tsx b/editor/app/settings/components/SettingsForm.tsx
@@ -241,6 +241,53 @@ export function SettingsForm({ initial, apps }: Props) {
</p>
</fieldset>
<fieldset className="flex flex-col gap-3 border border-zinc-200 dark:border-zinc-800 rounded p-3">
+ <legend className="px-1 text-sm font-medium">Build pipeline</legend>
+ <p className="text-xs text-zinc-500">
+ How the static export is built. <strong>Basic</strong> runs the build
+ in <code>export/</code> and serializes builds on one queue.{" "}
+ <strong>Docker</strong> will run each site's build in an isolated
+ 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>{" "}
+ page.
+ </p>
+ <label className="flex flex-col gap-1 text-sm">
+ <span className="font-medium">Build mode</span>
+ <select
+ name="buildMode"
+ defaultValue={initial.buildPipeline.mode}
+ className="rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-2 py-1 text-sm"
+ >
+ <option value="basic">Basic — serial build queue</option>
+ <option value="docker">
+ Docker — isolated parallel builds (follow-up)
+ </option>
+ </select>
+ </label>
+ <Field
+ label="Max parallel builds (Docker mode)"
+ name="maxParallelBuilds"
+ defaultValue={String(initial.buildPipeline.maxParallelBuilds)}
+ type="number"
+ hint="Cap on concurrent per-site container builds in Docker mode (1–16). Ignored in Basic mode, which is always serial."
+ />
+ <Field
+ label="Docker image tag"
+ name="dockerImage"
+ defaultValue={initial.buildPipeline.dockerImage}
+ hint="Tag of the reusable build image, built once and reused for every site."
+ />
+ <Field
+ label="Dockerfile path"
+ name="dockerfile"
+ defaultValue={initial.buildPipeline.dockerfile}
+ hint="Path (relative to the repo root) used to build the image."
+ />
+ </fieldset>
+ <fieldset className="flex flex-col gap-3 border border-zinc-200 dark:border-zinc-800 rounded p-3">
<legend className="px-1 text-sm font-medium">Social links</legend>
<p className="text-xs text-zinc-500">
Default social links shown in every site's footer. Each site can
diff --git a/editor/e2e/build.spec.ts b/editor/e2e/build.spec.ts
@@ -82,21 +82,13 @@ 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();
- // Now at the top of the deploy page, above the changelog preview.
+ // On the deploy page, under the "Individual steps" disclosure.
await page.goto("/deploy");
+ await page.getByText("Individual steps").click();
const exportHeading = page.getByRole("heading", {
name: "Build static export",
});
- const pendingHeading = page.getByRole("heading", {
- name: "Pending export changes",
- });
await expect(exportHeading).toBeVisible();
- await expect(pendingHeading).toBeVisible();
-
- // The export section comes before the pending-changes section.
- const exportBox = await exportHeading.boundingBox();
- const pendingBox = await pendingHeading.boundingBox();
- expect(exportBox!.y).toBeLessThan(pendingBox!.y);
const exportButton = page.getByRole("button", {
name: "Build static export",
diff --git a/editor/e2e/deploy-page.spec.ts b/editor/e2e/deploy-page.spec.ts
@@ -0,0 +1,105 @@
+import { test, expect } from "@playwright/test";
+import { resetData, writeSite } from "./helpers";
+
+test.beforeEach(async () => {
+ await resetData("empty");
+});
+
+test("deploy page reads as a lifecycle: release → build & deploy → batch", async ({
+ page,
+}) => {
+ await writeSite("testsite", { cloudflareProject: "proj" });
+ await page.goto("/deploy?site=testsite");
+
+ const release = page.getByRole("heading", { name: "Release notes" });
+ const buildDeploy = page.getByRole("heading", { name: "Build & deploy" });
+ const batch = page.getByRole("heading", { name: "Build multiple sites" });
+ await expect(release).toBeVisible();
+ await expect(buildDeploy).toBeVisible();
+ await expect(batch).toBeVisible();
+
+ // 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);
+});
+
+test("Build & deploy is enabled only when the active site has a Cloudflare project", async ({
+ page,
+}) => {
+ await writeSite("with-proj", { cloudflareProject: "proj" });
+ await writeSite("no-proj", {});
+
+ // With a Cloudflare project → enabled.
+ await page.goto("/deploy?site=with-proj");
+ await expect(
+ page.getByRole("button", { name: "Build & deploy" }),
+ ).toBeEnabled();
+
+ // Without one → disabled, with an explanatory notice.
+ await page.goto("/deploy?site=no-proj");
+ await expect(
+ page.getByRole("button", { name: "Build & deploy" }),
+ ).toBeDisabled();
+ await expect(
+ page.getByText(/has no Cloudflare Pages project configured/i),
+ ).toBeVisible();
+});
+
+test("build-mode toggle persists the choice and shows the Docker follow-up note", async ({
+ page,
+}) => {
+ await writeSite("testsite", { cloudflareProject: "proj" });
+ await page.goto("/deploy?site=testsite");
+
+ const group = page.getByRole("group", { name: "Build mode" });
+ await expect(group.getByRole("button", { name: "Basic" })).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+
+ await group.getByRole("button", { name: "Docker" }).click();
+ await expect(group.getByRole("button", { name: "Docker" })).toHaveAttribute(
+ "aria-pressed",
+ "true",
+ );
+ await expect(page.getByText(/Docker builds are a follow-up/i)).toBeVisible();
+
+ // Persisted: reload and the toggle is still on Docker.
+ await page.goto("/deploy?site=testsite");
+ await expect(
+ page
+ .getByRole("group", { name: "Build mode" })
+ .getByRole("button", { name: "Docker" }),
+ ).toHaveAttribute("aria-pressed", "true");
+});
+
+test("batch panel: selecting sites enables the launch button and reflects deploy intent", async ({
+ page,
+}) => {
+ // Deliberately does NOT click the launch button — that would spawn a real
+ // `pnpm run build`, which the e2e suite never does (cf. build.spec.ts only
+ // 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");
+
+ const launch = page.getByRole("button", { name: "Build selected" });
+ await expect(launch).toBeDisabled();
+
+ await page.getByLabel("Select Alpha").check();
+ await page.getByLabel("Select Beta").check();
+ await expect(launch).toBeEnabled();
+ await expect(page.getByText("2 selected")).toBeVisible();
+
+ // Opting into deploy renames the launch button.
+ await page.getByLabel("Deploy after build").check();
+ await expect(
+ page.getByRole("button", { name: "Build & deploy selected" }),
+ ).toBeVisible();
+
+ // Basic mode (the default) notes that the batch runs serially.
+ await expect(page.getByText(/Basic mode runs these one at a time/i)).toBeVisible();
+});
diff --git a/editor/e2e/settings.spec.ts b/editor/e2e/settings.spec.ts
@@ -133,3 +133,35 @@ test("rejects out-of-range maxTranscriptPageBytes", async ({ page }) => {
"between",
);
});
+
+test("saves build pipeline settings (mode, concurrency, image)", async ({
+ page,
+}) => {
+ await page.goto("/settings");
+ await page.getByLabel("Build mode").selectOption("docker");
+ await page.getByLabel(/max parallel builds/i).fill("4");
+ await page.getByLabel(/docker image tag/i).fill("my-build-image");
+ await page.getByRole("button", { name: /save settings/i }).click();
+ await expect(
+ page.getByRole("status").filter({ hasText: "Saved" }),
+ ).toBeVisible();
+
+ const saved = await readJson<{
+ buildPipeline?: {
+ mode: string;
+ maxParallelBuilds: number;
+ dockerImage: string;
+ };
+ }>("test-settings.json");
+ expect(saved.buildPipeline?.mode).toBe("docker");
+ 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");
+ await expect(
+ page
+ .getByRole("group", { name: "Build mode" })
+ .getByRole("button", { name: "Docker" }),
+ ).toHaveAttribute("aria-pressed", "true");
+});