Archilyzer · Source

archilyzer

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

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:
Deditor/app/build/buildAction.ts | 449-------------------------------------------------------------------------------
Meditor/app/build/components/BuildButtons.tsx | 2+-
Meditor/app/changelog/page.tsx | 2+-
Deditor/app/deploy/buildModeAction.ts | 26--------------------------
Deditor/app/deploy/components/BuildAllSitesButton.tsx | 78------------------------------------------------------------------------------
Deditor/app/deploy/components/BuildDeployButton.tsx | 74--------------------------------------------------------------------------
Deditor/app/deploy/components/BuildExportButton.tsx | 79-------------------------------------------------------------------------------
Deditor/app/deploy/components/BuildModeToggle.tsx | 63---------------------------------------------------------------
Deditor/app/deploy/components/BuildSitesPanel.tsx | 153-------------------------------------------------------------------------------
Deditor/app/deploy/components/CutReleaseForm.tsx | 91-------------------------------------------------------------------------------
Deditor/app/deploy/components/DeployButton.tsx | 36------------------------------------
Deditor/app/deploy/cutReleaseAction.ts | 116-------------------------------------------------------------------------------
Deditor/app/deploy/page.tsx | 199-------------------------------------------------------------------------------
Meditor/app/lib/nav.ts | 7++-----
Meditor/app/settings/components/SettingsForm.tsx | 4++--
Meditor/app/sites/[siteId]/components/SiteTabs.tsx | 6+++++-
Aeditor/app/sites/[siteId]/publish/page.tsx | 109+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/sites/components/BuildAllSitesButton.tsx | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/sites/components/BuildDeployButton.tsx | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/sites/components/BuildExportButton.tsx | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/sites/components/BuildModeToggle.tsx | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/sites/components/BuildSitesPanel.tsx | 153+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/sites/components/CutReleaseForm.tsx | 91+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/sites/components/DeployButton.tsx | 28++++++++++++++++++++++++++++
Reditor/app/deploy/components/JobLane.tsx -> editor/app/sites/components/JobLane.tsx | 0
Aeditor/app/sites/lib/buildAction.ts | 449+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Reditor/app/deploy/buildDeployCore.ts -> editor/app/sites/lib/buildDeployCore.ts | 0
Aeditor/app/sites/lib/buildModeAction.ts | 26++++++++++++++++++++++++++
Aeditor/app/sites/lib/cutReleaseAction.ts | 116+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Reditor/app/deploy/deployAction.ts -> editor/app/sites/lib/deployAction.ts | 0
Meditor/app/sites/page.tsx | 184++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------
Meditor/e2e/build.spec.ts | 4++--
Meditor/e2e/deploy-page.spec.ts | 51+++++++++++++++++++++++++++++----------------------
Meditor/e2e/navigation.spec.ts | 2+-
Meditor/e2e/settings.spec.ts | 4++--
Meditor/e2e/site-scope.spec.ts | 25++++++++++---------------
Meditor/next.config.ts | 2++
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 &amp; 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&apos;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 &amp; 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 &amp; 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&apos;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 &amp; 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 &amp; 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 &amp; 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 &amp; 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&apos;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 &amp; 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 &amp; 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&apos;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&apos;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&apos;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 &amp; 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