Archilyzer · Source

archilyzer

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

commit ef3fc94e99f3810b6b3009d284da24b54a93e630
parent a899a505ed1839480c53d8d04c28648fdccded20
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue,  6 Oct 2026 12:26:52 -0400

editor: the /sites Publish panel, the site Publish tab and the ops API as publish stages

/sites: "Build all sites" and the hub's and homepage's build sections become
one Publish panel — a row per site, the hub and the homepage with the four
chips (index | built | deployed | live), Build / Deploy preview / Deploy
production / Deploy local, Publish now and Build all stale with the plan
Publish now would run. The Pool's Build index is the index stage; Build stats
dataset is gone (stats are part of the index update). A site's Publish tab
reads its own built.json / deployed.json for "Last deployed". Every button is
one run of stages on the publish queue (sites/lib/publishActions.ts →
publishCore.ts → enqueuePublishRun), followed in one console
(publishRunStream.ts); a deploy is refused before any job in the deploy
stage's own words (resolveDeployRequest).

POST /api/ops/publish {verb: index|build|deploy|hub|homepage|now|stale} and
GET (the status); the eight old routes stay as aliases with their bodies and
answers. buildAction.ts keeps only the Pool's normalize/archive actions;
hubActions.ts, homepageDeployActions.ts, deployAction.ts and the four old
components are deleted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Aeditor/app/api/ops/_publish.ts | 88+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/api/ops/build-deploy/route.ts | 107++++++++++++++++++++++++++++++++++---------------------------------------------
Meditor/app/api/ops/build-homepage/route.ts | 40+++++++++++++++++++++++++---------------
Meditor/app/api/ops/build-hub/route.ts | 37+++++++++++++++++++++++++------------
Meditor/app/api/ops/build-index/route.ts | 24++++++++++++++++++------
Meditor/app/api/ops/build-site/route.ts | 81++++++++++++++++++++++++++++---------------------------------------------------
Meditor/app/api/ops/deploy-homepage/route.ts | 36++++++++++++++++++++----------------
Meditor/app/api/ops/deploy-hub/route.ts | 44++++++++++++++++++++++----------------------
Meditor/app/api/ops/deploy-site/route.ts | 60+++++++++++++++++++++++++++---------------------------------
Aeditor/app/api/ops/publish/route.ts | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/sites/[siteId]/publish/page.tsx | 91+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
Deditor/app/sites/components/BuildAllSitesButton.tsx | 78------------------------------------------------------------------------------
Meditor/app/sites/components/BuildButtons.tsx | 55+++++++++----------------------------------------------
Meditor/app/sites/components/BuildDeployButton.tsx | 10++++++----
Meditor/app/sites/components/BuildExportButton.tsx | 37+++++++------------------------------
Deditor/app/sites/components/BuildSitesPanel.tsx | 146-------------------------------------------------------------------------------
Meditor/app/sites/components/DeployButton.tsx | 20+++++++++++++-------
Deditor/app/sites/components/HomepageBuildButtons.tsx | 216-------------------------------------------------------------------------------
Deditor/app/sites/components/HubBuildButtons.tsx | 84-------------------------------------------------------------------------------
Aeditor/app/sites/components/PublishPanel.tsx | 508+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/sites/lib/buildAction.ts | 385++-----------------------------------------------------------------------------
Deditor/app/sites/lib/deployAction.ts | 81-------------------------------------------------------------------------------
Deditor/app/sites/lib/homepageDeployActions.ts | 104-------------------------------------------------------------------------------
Deditor/app/sites/lib/hubActions.ts | 101-------------------------------------------------------------------------------
Aeditor/app/sites/lib/publishActions.ts | 152+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/sites/lib/publishCore.ts | 202+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/sites/lib/publishRunStream.ts | 83+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/sites/page.tsx | 127+++++++++++++++++++++++++++++++++++--------------------------------------------
28 files changed, 1550 insertions(+), 1599 deletions(-)

diff --git a/editor/app/api/ops/_publish.ts b/editor/app/api/ops/_publish.ts @@ -0,0 +1,88 @@ +// The ops API's way into the publish stages (release 18 S4): `POST +// /api/ops/publish` and the eight routes it replaced, which stay as aliases +// with their old bodies and answers (build-index, build-site, build-deploy, +// deploy-site, build-hub, deploy-hub, build-homepage, deploy-homepage). +// +// Adapters only (this directory's rule, _lib.ts): every refusal is +// publishCore's — the deploy stage's own sentence, asked before a job exists — +// and every job is `enqueuePublishRun`'s, under one run id. + +import { NextResponse } from "next/server"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import type { PublishPlan } from "yt-dlp-transcript-common/publish/publishPlan"; +import type { PublishRunResult } from "yt-dlp-transcript-common/publish/publishStages"; +import { + enqueueRun, + publishStatus, + refusal, + stageKindsOf, + wantedPlan, + type TargetAsk, +} from "../../sites/lib/publishCore"; +import { opsFail } from "./_lib"; + +export type OpsRefusal = { target: string; kind?: string; error: string }; + +export type OpsRun = PublishRunResult & { preRefused: OpsRefusal[] }; + +/** Enqueue a plan as it stands (Publish now, Build all stale, the index). */ +export async function enqueuePlan(plan: PublishPlan): Promise<OpsRun> { + const run = await enqueueRun(getPaths(), plan); + return { ...run, preRefused: [] }; +} + +/** + * Enqueue what the asks name: each is refused before any job exists when the + * deploy stage would refuse it (the others still run); the index update goes + * first when a build is asked for and the index is not fresh. + */ +export async function enqueueAsks(asks: TargetAsk[]): Promise<OpsRun> { + const paths = getPaths(); + const ok: TargetAsk[] = []; + const preRefused: OpsRefusal[] = []; + for (const ask of asks) { + const why = await refusal(paths, ask); + if (why) { + const kinds = stageKindsOf(ask.target); + preRefused.push({ target: ask.target, kind: ask.deploy ? kinds.deploy : kinds.build, error: why }); + } else ok.push(ask); + } + if (ok.length === 0) return { runId: "", jobs: [], skipped: [], refused: [], preRefused }; + const status = await publishStatus(paths); + const run = await enqueueRun(paths, wantedPlan(status, ok, { withIndex: true, paths })); + return { ...run, preRefused }; +} + +/** Every refusal of a run, before a job or by the queue. */ +export function refusalsOf(run: OpsRun): OpsRefusal[] { + return [...run.preRefused, ...run.refused]; +} + +/** + * The publish route's answer: `{ok, runId, jobs, skipped, refused}` and a + * top-level `jobId` when exactly one job (so `--wait` follows it). Asking for + * jobs and getting none because every one was refused is a 400 naming the + * reasons; a plan with nothing to run is an `ok` with no jobs. + */ +export function publishResponse(run: OpsRun): NextResponse { + const refused = refusalsOf(run); + if (run.jobs.length === 0 && refused.length > 0) { + return opsFail( + refused.length === 1 ? refused[0].error : refused.map((r) => `${r.target}: ${r.error}`).join("; "), + ); + } + return NextResponse.json({ + ok: true, + runId: run.runId, + jobs: run.jobs, + skipped: run.skipped, + refused, + ...(run.jobs.length === 1 ? { jobId: run.jobs[0].jobId } : {}), + ...(run.jobs.length === 1 && run.jobs[0].previewUrl ? { previewUrl: run.jobs[0].previewUrl } : {}), + }); +} + +/** The last job of a run for `target` (its deploy when it has one), or undefined. */ +export function lastJobOf(run: OpsRun, target: string) { + return [...run.jobs].reverse().find((j) => j.target === target); +} diff --git a/editor/app/api/ops/build-deploy/route.ts b/editor/app/api/ops/build-deploy/route.ts @@ -1,48 +1,20 @@ import { NextResponse } from "next/server"; -import { previewAliasUrl } from "yt-dlp-transcript-common/lib/pagesDeploy"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; -import { getSite } from "yt-dlp-transcript-common/lib/site"; -import { - buildAndDeployAction, - buildAndDeployAllSitesAction, -} from "../../../sites/lib/buildAction"; -import { - fanOutSiteJobs, - jobResponse, - OpsInputError, - ops, - optBool, - optPreviewBranch, - reqSiteIds, -} from "../_lib"; +import { getSite, listSiteIds } from "yt-dlp-transcript-common/lib/site"; +import { siteDeployProblem } from "yt-dlp-transcript-common/lib/builtExport"; +import { enqueueAsks, lastJobOf, refusalsOf } from "../_publish"; +import { OpsInputError, ops, optBool, optPreviewBranch, opsFail, reqSiteIds } from "../_lib"; export const dynamic = "force-dynamic"; -// POST { siteId: string | siteIds: string[], skipArchives?, preview? } -// | { all: true, skipArchives? } -// -> { ok: true, jobs: [{ siteId, jobId, previewUrl? }], -// skipped: [{ siteId, reason }], jobId?, previewUrl? } +// AN ALIAS (release 18): each site's build, then its deploy (the deploy waits on disk for that build). The body and the answer are the +// route's old ones; the work is publish stages on the publish queue +// (../_publish.ts), as `POST /api/ops/publish` {"verb": "build"} runs them. // -// Build THEN deploy: one managed job per site (one log, one Cancel each), so -// the caller polls /api/jobs/<jobId>/log exactly as it does for build-site. -// -// `siteId` and `siteIds` are the same key (reqSiteIds), and the response is -// build-site's: this route took one id and that one a list, so the two halves -// of the same sentence in a runbook needed different JSON. `jobId` is still -// there when exactly one job started, so every existing single-site caller — -// and jobResponse's own shape — is unchanged; `jobs`/`skipped` are additive, -// and `pnpm ops --wait` already reads `jobs[]`. -// -// Asking for builds and getting NONE is still a 400 carrying the reason, not a -// cheerful `{ ok: true, jobs: [] }`: --wait would exit 0 on it and report -// success about a deploy that never started. That, and the per-site error -// handling, now live in fanOutSiteJobs, shared with deploy-site. -// -// `preview` names a branch and makes the DEPLOY half a Cloudflare Pages preview -// (see deploy-site). It is refused alongside `all`: the all-sites path deploys -// every configured site through the docker phase runner, which has no per-site -// branch to thread one through, and silently building every site and shipping -// them to production would be the worst possible reading of the request. +// `all` = every DEPLOYABLE site to production (an explicit request: the +// sites' policies are not asked); a private or project-less site is skipped +// with its reason. → { ok, jobs: [{siteId, jobId, previewUrl?}], skipped } +// with each site's DEPLOY job (the last of its run), as before. export async function POST(request: Request) { return ops( request, @@ -50,37 +22,50 @@ export async function POST(request: Request) { async (body) => { const skipArchives = optBool(body, "skipArchives"); const preview = optPreviewBranch(body); + const paths = getPaths(); + let ids: string[]; + const skippedAll: { siteId: string; reason: string }[] = []; if (optBool(body, "all")) { if (body.siteId !== undefined || body.siteIds !== undefined) { - throw new OpsInputError( - 'send either "siteId"/"siteIds" or "all", not both', - ); + throw new OpsInputError('send either "siteId"/"siteIds" or "all", not both'); } if (preview !== undefined) { throw new OpsInputError( '"preview" is not supported with "all" — name the sites to preview with "siteIds"', ); } - return jobResponse(await buildAndDeployAllSitesAction(skipArchives)); - } - const siteIds = reqSiteIds(body); - return fanOutSiteJobs( - siteIds, - (siteId) => - buildAndDeployAction( - siteId, - skipArchives, - preview ? { previewBranch: preview } : undefined, - ), - preview - ? (siteId) => { - // Only reached for a site whose job STARTED, which the action - // does only once it has a cloudflareProject. - const project = getSite(siteId, getPaths()).cloudflareProject; - return project ? { previewUrl: previewAliasUrl(project, preview) } : {}; - } - : undefined, + ids = []; + for (const id of listSiteIds(paths)) { + const site = getSite(id, paths); + const why = siteDeployProblem(site) ?? (site.cloudflareProject ? null : "no cloudflareProject"); + if (why) skippedAll.push({ siteId: id, reason: why }); + else ids.push(id); + } + if (ids.length === 0) return opsFail("No deployable sites configured."); + } else ids = reqSiteIds(body); + const where = preview ? ("preview" as const) : ("production" as const); + const run = await enqueueAsks( + ids.map((target) => ({ + target, + build: { force: true, skipArchives }, + deploy: { where, ...(preview ? { preview } : {}), force: true }, + })), ); + const jobs = ids.flatMap((siteId) => { + const j = lastJobOf(run, siteId); + return j ? [{ siteId, jobId: j.jobId, ...(j.previewUrl ? { previewUrl: j.previewUrl } : {}) }] : []; + }); + const skipped = [...skippedAll, ...refusalsOf(run).map((r) => ({ siteId: r.target, reason: r.error }))]; + if (jobs.length === 0) { + return opsFail(skipped.length === 1 ? skipped[0].reason : skipped.map((s) => `${s.siteId}: ${s.reason}`).join("; ")); + } + return NextResponse.json({ + ok: true, + runId: run.runId, + jobs, + skipped, + ...(jobs.length === 1 ? { jobId: jobs[0].jobId, ...(jobs[0].previewUrl ? { previewUrl: jobs[0].previewUrl } : {}) } : {}), + }); }, ); } diff --git a/editor/app/api/ops/build-homepage/route.ts b/editor/app/api/ops/build-homepage/route.ts @@ -1,18 +1,16 @@ import { NextResponse } from "next/server"; -import { - buildAndDeployHomepageAction, - buildHomepageAction, -} from "../../../sites/lib/homepageDeployActions"; -import { jobResponse, ops, optBool, optPreviewBranch, OpsInputError } from "../_lib"; +import { HOMEPAGE_TARGET } from "yt-dlp-transcript-common/publish/stamps"; +import { enqueueAsks, lastJobOf, refusalsOf } from "../_publish"; +import { ops, optBool, optPreviewBranch, opsFail, OpsInputError } from "../_lib"; export const dynamic = "force-dynamic"; -// POST { deploy?: boolean, preview? } -> { ok: true, jobId } +// AN ALIAS (release 18): the homepage's build (forced), then with `deploy` its deploy. The body and the answer are the +// route's old ones; the work is publish stages on the publish queue +// (../_publish.ts), as `POST /api/ops/publish` {"verb": "homepage"} runs them. // -// Builds the HOMEPAGE (the `homepage` package, Archilyzer's own site) into -// homepage/out. `deploy: true` makes it the one-job build-then-deploy (`/sites` -// → Homepage, "Deploy after build") to the Pages project `archilyzer`, which -// deploys only when the build exits 0; `preview` needs `deploy`. +// → { ok, jobId } — the run's last job (the deploy when there is one), as +// before; `previewUrl` with a preview. export async function POST(request: Request) { return ops(request, ["deploy", "preview"], async (body) => { const deploy = optBool(body, "deploy") === true; @@ -20,11 +18,23 @@ export async function POST(request: Request) { if (preview !== undefined && !deploy) { throw new OpsInputError('"preview" needs "deploy": true — a build alone deploys nothing'); } - return jobResponse( - deploy - ? await buildAndDeployHomepageAction(preview ? { previewBranch: preview } : undefined) - : await buildHomepageAction(), - ); + const run = await enqueueAsks([ + { + target: HOMEPAGE_TARGET, + build: { force: true }, + ...(deploy + ? { deploy: { where: preview ? ("preview" as const) : ("production" as const), ...(preview ? { preview } : {}), force: true } } + : {}), + }, + ]); + const job = lastJobOf(run, HOMEPAGE_TARGET); + if (!job) return opsFail(refusalsOf(run).map((r) => r.error).join("; ") || "nothing was enqueued"); + return NextResponse.json({ + ok: true, + jobId: job.jobId, + runId: run.runId, + ...(job.previewUrl ? { previewUrl: job.previewUrl } : {}), + }); }); } diff --git a/editor/app/api/ops/build-hub/route.ts b/editor/app/api/ops/build-hub/route.ts @@ -1,15 +1,16 @@ import { NextResponse } from "next/server"; -import { buildAndDeployHubAction, buildHubAction } from "../../../sites/lib/hubActions"; -import { jobResponse, ops, optBool, optPreviewBranch, OpsInputError } from "../_lib"; +import { HUB_TARGET } from "yt-dlp-transcript-common/publish/stamps"; +import { enqueueAsks, lastJobOf, refusalsOf } from "../_publish"; +import { ops, optBool, optPreviewBranch, opsFail, OpsInputError } from "../_lib"; export const dynamic = "force-dynamic"; -// POST { deploy?: boolean, preview? } -> { ok: true, jobId } +// AN ALIAS (release 18): the hub's build (forced), then with `deploy` its deploy. The body and the answer are the +// route's old ones; the work is publish stages on the publish queue +// (../_publish.ts), as `POST /api/ops/publish` {"verb": "hub"} runs them. // -// Builds the HUB (the export app under INSTANCE_MODE=hub) into export/out — the -// same directory a site build uses. `deploy: true` makes it the one-job -// build-then-deploy (`/sites` → Hub, "Deploy after build"), which refuses a -// missing or wrong Pages project BEFORE building; `preview` needs `deploy`. +// → { ok, jobId } — the run's last job (the deploy when there is one), as +// before; `previewUrl` with a preview. export async function POST(request: Request) { return ops(request, ["deploy", "preview"], async (body) => { const deploy = optBool(body, "deploy") === true; @@ -17,11 +18,23 @@ export async function POST(request: Request) { if (preview !== undefined && !deploy) { throw new OpsInputError('"preview" needs "deploy": true — a build alone deploys nothing'); } - return jobResponse( - deploy - ? await buildAndDeployHubAction(preview ? { previewBranch: preview } : undefined) - : await buildHubAction(), - ); + const run = await enqueueAsks([ + { + target: HUB_TARGET, + build: { force: true }, + ...(deploy + ? { deploy: { where: preview ? ("preview" as const) : ("production" as const), ...(preview ? { preview } : {}), force: true } } + : {}), + }, + ]); + const job = lastJobOf(run, HUB_TARGET); + if (!job) return opsFail(refusalsOf(run).map((r) => r.error).join("; ") || "nothing was enqueued"); + return NextResponse.json({ + ok: true, + jobId: job.jobId, + runId: run.runId, + ...(job.previewUrl ? { previewUrl: job.previewUrl } : {}), + }); }); } diff --git a/editor/app/api/ops/build-index/route.ts b/editor/app/api/ops/build-index/route.ts @@ -1,11 +1,23 @@ -import { buildIndexAction } from "../../../sites/lib/buildAction"; -import { jobResponse, ops, optString } from "../_lib"; +import { NextResponse } from "next/server"; +import { indexPlan } from "../../../sites/lib/publishCore"; +import { enqueuePlan, publishResponse } from "../_publish"; +import { ops, optString } from "../_lib"; export const dynamic = "force-dynamic"; -// POST { queueKey? } -> { ok: true, jobId }. Rebuilds the LMDB corpus index. +// AN ALIAS (release 18): the index update. The body and the answer are the +// route's old ones; the work is publish stages on the publish queue +// (../_publish.ts), as `POST /api/ops/publish` {"verb": "index"} runs them. +// +// `queueKey` is accepted and ignored: the index update is a publish stage, and +// the publish queue runs its stages one at a time. export async function POST(request: Request) { - return ops(request, ["queueKey"], async (body) => - jobResponse(await buildIndexAction(optString(body, "queueKey"))), - ); + return ops(request, ["queueKey"], async (body) => { + optString(body, "queueKey"); + return publishResponse(await enqueuePlan(indexPlan())); + }); +} + +export function GET() { + return NextResponse.json({ ok: false, error: "POST only" }, { status: 405 }); } diff --git a/editor/app/api/ops/build-site/route.ts b/editor/app/api/ops/build-site/route.ts @@ -1,69 +1,46 @@ import { NextResponse } from "next/server"; -import { - buildAllSitesAction, - buildExportAction, -} from "../../../sites/lib/buildAction"; -import { - jobResponse, - OpsInputError, - ops, - optBool, - reqSiteIds, -} from "../_lib"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { listSiteIds } from "yt-dlp-transcript-common/lib/site"; +import { enqueueAsks, lastJobOf, refusalsOf } from "../_publish"; +import { OpsInputError, ops, optBool, reqSiteIds } from "../_lib"; export const dynamic = "force-dynamic"; -// POST { siteIds: string[] | siteId: string, skipData?, skipArchives? } -// | { all: true, skipArchives? } +// AN ALIAS (release 18): each site's build, forced, the index first when it is stale. The body and the answer are the +// route's old ones; the work is publish stages on the publish queue +// (../_publish.ts), as `POST /api/ops/publish` {"verb": "build"} runs them. // -// BUILD WITHOUT DEPLOYING. One build-export job per site on the shared build -// queue (they run one at a time, as they do from /sites), returning -// `{ jobs: [{ siteId, jobId }] }` — a list, because there is a job per site and -// a caller waiting on them needs all the ids. `{ all: true }` is the single -// build-all job instead, which is the docker fan-out. -// -// `siteId` and `siteIds` both work, via reqSiteIds: this route and build-deploy -// used to disagree about the spelling, which made the pair unguessable. +// `skipData` is accepted and ignored (the data is the index stage, shared by +// every site); `all` builds every site. → { ok, jobs: [{siteId, jobId}], +// skipped: [{siteId, reason}] } (+ `jobId` when one site), as before. export async function POST(request: Request) { return ops( request, ["siteId", "siteIds", "all", "skipData", "skipArchives"], async (body) => { const skipArchives = optBool(body, "skipArchives"); + optBool(body, "skipData"); + let ids: string[]; if (optBool(body, "all")) { if (body.siteIds !== undefined || body.siteId !== undefined) { - throw new OpsInputError( - 'send either "siteId"/"siteIds" or "all", not both', - ); - } - return jobResponse(await buildAllSitesAction(skipArchives)); - } - const skipData = optBool(body, "skipData"); - const jobs: { siteId: string; jobId: string }[] = []; - const skipped: { siteId: string; reason: string }[] = []; - for (const siteId of reqSiteIds(body)) { - // One site's throw cannot cost the others their job ids — see the same - // guard in build-deploy. - let result: Awaited<ReturnType<typeof buildExportAction>>; - try { - result = await buildExportAction( - siteId, - undefined, - skipData, - skipArchives, - ); - } catch (e) { - skipped.push({ siteId, reason: (e as Error).message }); - continue; - } - if (!result.ok) { - skipped.push({ siteId, reason: result.error }); - continue; + throw new OpsInputError('send either "siteId"/"siteIds" or "all", not both'); } - void result.stream.cancel(); - jobs.push({ siteId, jobId: result.jobId }); - } - return NextResponse.json({ ok: true, jobs, skipped }); + ids = listSiteIds(getPaths()); + if (ids.length === 0) throw new OpsInputError("No sites configured."); + } else ids = reqSiteIds(body); + const run = await enqueueAsks(ids.map((target) => ({ target, build: { force: true, skipArchives } }))); + const jobs = ids.flatMap((siteId) => { + const j = lastJobOf(run, siteId); + return j ? [{ siteId, jobId: j.jobId }] : []; + }); + const skipped = refusalsOf(run).map((r) => ({ siteId: r.target, reason: r.error })); + return NextResponse.json({ + ok: true, + runId: run.runId, + jobs, + skipped, + ...(jobs.length === 1 ? { jobId: jobs[0].jobId } : {}), + }); }, ); } diff --git a/editor/app/api/ops/deploy-homepage/route.ts b/editor/app/api/ops/deploy-homepage/route.ts @@ -1,28 +1,32 @@ import { NextResponse } from "next/server"; -import { previewAliasUrl } from "yt-dlp-transcript-common/lib/pagesDeploy"; -import { HOMEPAGE_PAGES_PROJECT } from "yt-dlp-transcript-common/publish/build"; -import { deployHomepageAction } from "../../../sites/lib/homepageDeployActions"; -import { jobResponse, ops, optPreviewBranch } from "../_lib"; +import { HOMEPAGE_TARGET } from "yt-dlp-transcript-common/publish/stamps"; +import { enqueueAsks, lastJobOf, refusalsOf } from "../_publish"; +import { ops, optPreviewBranch, opsFail } from "../_lib"; export const dynamic = "force-dynamic"; -// POST { preview? } -> { ok: true, jobId, previewUrl? } +// AN ALIAS (release 18): the homepage's BUILT bundle, deployed — never a build. The body and the answer are the +// route's old ones; the work is publish stages on the publish queue +// (../_publish.ts), as `POST /api/ops/publish` {"verb": "homepage"} runs them. // -// DEPLOY ONLY — the homepage already built in homepage/out, to its constant -// Pages project `archilyzer`: production (branch `main`), or with `preview` a -// branch alias, knowable before the job runs. Refused before any job when the -// preview name is bad or homepage/out holds no build. +// → { ok, jobId, previewUrl? }, as before; a homepage never built is refused +// before any job ("no build of _homepage in <dir> — archilyzer publish homepage"). export async function POST(request: Request) { return ops(request, ["preview"], async (body) => { const preview = optPreviewBranch(body); - const res = jobResponse( - await deployHomepageAction(preview ? { previewBranch: preview } : undefined), - ); - if (!preview || res.status !== 200) return res; - const payload = (await res.json()) as Record<string, unknown>; + const run = await enqueueAsks([ + { + target: HOMEPAGE_TARGET, + deploy: { where: preview ? ("preview" as const) : ("production" as const), ...(preview ? { preview } : {}), force: true }, + }, + ]); + const job = lastJobOf(run, HOMEPAGE_TARGET); + if (!job) return opsFail(refusalsOf(run).map((r) => r.error).join("; ") || "nothing was enqueued"); return NextResponse.json({ - ...payload, - previewUrl: previewAliasUrl(HOMEPAGE_PAGES_PROJECT, preview), + ok: true, + jobId: job.jobId, + runId: run.runId, + ...(job.previewUrl ? { previewUrl: job.previewUrl } : {}), }); }); } diff --git a/editor/app/api/ops/deploy-hub/route.ts b/editor/app/api/ops/deploy-hub/route.ts @@ -1,33 +1,33 @@ import { NextResponse } from "next/server"; -import { getHomepageConfig } from "yt-dlp-transcript-common/lib/homepage"; -import { previewAliasUrl } from "yt-dlp-transcript-common/lib/pagesDeploy"; -import { getPaths } from "yt-dlp-transcript-common/lib/paths"; -import { deployHubAction } from "../../../sites/lib/hubActions"; -import { jobResponse, ops, optPreviewBranch } from "../_lib"; +import { HUB_TARGET } from "yt-dlp-transcript-common/publish/stamps"; +import { enqueueAsks, lastJobOf, refusalsOf } from "../_publish"; +import { ops, optPreviewBranch, opsFail } from "../_lib"; export const dynamic = "force-dynamic"; -// POST { preview? } -> { ok: true, jobId, previewUrl? } +// AN ALIAS (release 18): the hub's BUILT bundle, deployed — never a build. The body and the answer are the +// route's old ones; the work is publish stages on the publish queue +// (../_publish.ts), as `POST /api/ops/publish` {"verb": "hub"} runs them. // -// DEPLOY ONLY — the hub already built in export/out, to homepage.json's -// Cloudflare Pages project. Refused before any job when there is no project, -// when the project is the homepage's own ("archilyzer"), or when export/out -// holds a site rather than the hub. `preview` is a branch name, as on -// deploy-site; the alias is knowable before the job runs. +// → { ok, jobId, previewUrl? }, as before; a hub never built is refused +// before any job ("no build of _hub in <dir> — archilyzer publish hub"). export async function POST(request: Request) { return ops(request, ["preview"], async (body) => { const preview = optPreviewBranch(body); - const res = jobResponse( - await deployHubAction(preview ? { previewBranch: preview } : undefined), - ); - if (!preview || res.status !== 200) return res; - // Only reached once the job started, which the action does only with a - // usable project — so this read cannot be what fails. - const project = getHomepageConfig(getPaths()).cloudflareProject; - const payload = (await res.json()) as Record<string, unknown>; - return NextResponse.json( - project ? { ...payload, previewUrl: previewAliasUrl(project, preview) } : payload, - ); + const run = await enqueueAsks([ + { + target: HUB_TARGET, + deploy: { where: preview ? ("preview" as const) : ("production" as const), ...(preview ? { preview } : {}), force: true }, + }, + ]); + const job = lastJobOf(run, HUB_TARGET); + if (!job) return opsFail(refusalsOf(run).map((r) => r.error).join("; ") || "nothing was enqueued"); + return NextResponse.json({ + ok: true, + jobId: job.jobId, + runId: run.runId, + ...(job.previewUrl ? { previewUrl: job.previewUrl } : {}), + }); }); } diff --git a/editor/app/api/ops/deploy-site/route.ts b/editor/app/api/ops/deploy-site/route.ts @@ -1,45 +1,39 @@ import { NextResponse } from "next/server"; -import { previewAliasUrl } from "yt-dlp-transcript-common/lib/pagesDeploy"; -import { getPaths } from "yt-dlp-transcript-common/lib/paths"; -import { getSite } from "yt-dlp-transcript-common/lib/site"; -import { deployExportAction } from "../../../sites/lib/deployAction"; -import { fanOutSiteJobs, ops, optPreviewBranch, reqSiteIds } from "../_lib"; +import { enqueueAsks, lastJobOf, refusalsOf } from "../_publish"; +import { ops, optPreviewBranch, opsFail, reqSiteIds } from "../_lib"; export const dynamic = "force-dynamic"; -// POST { siteId: string | siteIds: string[], preview? } -// -> { ok: true, jobs: [{ siteId, jobId, previewUrl? }], -// skipped: [{ siteId, reason }], jobId?, previewUrl? } +// AN ALIAS (release 18): each site's BUILT bundle, deployed — never a build. The body and the answer are the +// route's old ones; the work is publish stages on the publish queue +// (../_publish.ts), as `POST /api/ops/publish` {"verb": "deploy"} runs them. // -// DEPLOY ONLY — the already-built export/out, no build. build-deploy's other -// half, and the half a preview is actually for: build once, look at the preview, -// then roll the SAME bundle to production without rebuilding it. -// -// `preview` is a branch name. Cloudflare Pages treats a deploy to any branch but -// the project's production branch as a preview, reachable at the branch alias — -// which is why `previewUrl` can be in the response at all, before the job has -// done anything: the alias is a function of the project and the branch, not of -// the deployment. The immutable per-deployment URL only exists afterwards and is -// in the job's log. -// -// Response shape is build-deploy's, so a runbook can swap one for the other. +// A site never built is refused before any job: "no build of <id> in <dir> — +// archilyzer publish build <id>". → { ok, jobs: [{siteId, jobId, +// previewUrl?}], skipped } (+ `jobId` when one site); none started → 400. export async function POST(request: Request) { return ops(request, ["siteId", "siteIds", "preview"], async (body) => { const preview = optPreviewBranch(body); - const siteIds = reqSiteIds(body); - return fanOutSiteJobs( - siteIds, - (siteId) => deployExportAction(siteId, preview ? { previewBranch: preview } : undefined), - preview - ? (siteId) => { - // Only reached for a site whose job STARTED, which the action does - // only once it has a cloudflareProject — so this read cannot be the - // thing that fails. - const project = getSite(siteId, getPaths()).cloudflareProject; - return project ? { previewUrl: previewAliasUrl(project, preview) } : {}; - } - : undefined, + const ids = reqSiteIds(body); + const where = preview ? ("preview" as const) : ("production" as const); + const run = await enqueueAsks( + ids.map((target) => ({ target, deploy: { where, ...(preview ? { preview } : {}), force: true } })), ); + const jobs = ids.flatMap((siteId) => { + const j = lastJobOf(run, siteId); + return j ? [{ siteId, jobId: j.jobId, ...(j.previewUrl ? { previewUrl: j.previewUrl } : {}) }] : []; + }); + const skipped = refusalsOf(run).map((r) => ({ siteId: r.target, reason: r.error })); + if (jobs.length === 0) { + return opsFail(skipped.length === 1 ? skipped[0].reason : skipped.map((s) => `${s.siteId}: ${s.reason}`).join("; ")); + } + return NextResponse.json({ + ok: true, + runId: run.runId, + jobs, + skipped, + ...(jobs.length === 1 ? { jobId: jobs[0].jobId, ...(jobs[0].previewUrl ? { previewUrl: jobs[0].previewUrl } : {}) } : {}), + }); }); } diff --git a/editor/app/api/ops/publish/route.ts b/editor/app/api/ops/publish/route.ts @@ -0,0 +1,152 @@ +import { NextResponse } from "next/server"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { planPublishRun } from "yt-dlp-transcript-common/publish/publishPlan"; +import { readPublishStatus } from "yt-dlp-transcript-common/publish/publishState"; +import { ALL_TARGET, HOMEPAGE_TARGET, HUB_TARGET } from "yt-dlp-transcript-common/publish/stamps"; +import { indexPlan } from "../../../sites/lib/publishCore"; +import { enqueueAsks, enqueuePlan, publishResponse } from "../_publish"; +import { + OpsInputError, + oneOf, + ops, + opsAuth, + optBool, + optPreviewBranch, + reqSiteIds, + type OpsBody, +} from "../_lib"; + +export const dynamic = "force-dynamic"; + +const VERBS = ["index", "build", "deploy", "hub", "homepage", "now", "stale"] as const; +type Verb = (typeof VERBS)[number]; + +// POST { verb: index | build | deploy | hub | homepage | now | stale, +// siteId | siteIds?, preview?, to?: "pages" | "local", force?, +// runner?: "local" | "docker", deploy?, skipArchives? } +// +// index the index update (a no-op when nothing changed) +// build each named site's build, forced — the index first when stale; +// `runner: "docker"` builds every site in containers (host only) +// deploy each named site's built bundle: production, `preview`, or +// `to: "local"` (ARCHILYZER_SITE_OUT); `force` redeploys a +// bundle already shipped there +// hub the hub's build; `deploy: true` then deploys it (production, or +// `preview`) +// homepage the same for the homepage (`to: "local"` deploys locally) +// now Publish now: what the lane would run — the stale index, then +// each policy target (site.json publish.auto, settings.publish) +// stale Build all stale: every stale site, whatever its policy +// +// → { ok, runId, jobs: [{ target, kind, jobId, previewUrl?, existing? }], +// skipped: [{ kind, target, reason }], refused: [{ target, kind?, error }] } +// plus `jobId` when exactly one job. Every request refused → 400. +// +// GET → the publish status (`readPublishStatus`): the index, the lane, a row +// per target with its chips, and the plan Publish now would run. +const KEYS = ["verb", "siteId", "siteIds", "preview", "to", "force", "runner", "deploy", "skipArchives"]; + +function only(body: OpsBody, verb: Verb, allowed: string[]): void { + const extra = Object.keys(body).filter((k) => k !== "verb" && !allowed.includes(k)); + if (extra.length) { + throw new OpsInputError( + `verb "${verb}" takes ${allowed.length ? allowed.join(", ") : "no other keys"} — not ${extra.join(", ")}`, + ); + } +} + +export async function POST(request: Request) { + return ops(request, KEYS, async (body) => { + const verb = oneOf(body, "verb", VERBS); + const force = optBool(body, "force"); + const skipArchives = optBool(body, "skipArchives"); + switch (verb) { + case "index": + only(body, verb, []); + return publishResponse(await enqueuePlan(indexPlan())); + case "now": + case "stale": { + only(body, verb, []); + const status = await readPublishStatus(getPaths()); + const plan = + verb === "now" + ? planPublishRun(status, { deploys: "policy" }) + : planPublishRun(status, { builds: "stale", deploys: "none" }); + return publishResponse(await enqueuePlan(plan)); + } + case "build": { + only(body, verb, ["siteId", "siteIds", "force", "runner", "skipArchives"]); + const runner = body.runner === undefined ? "local" : oneOf(body, "runner", ["local", "docker"] as const); + if (runner === "docker") { + if (body.siteId !== undefined || body.siteIds !== undefined) { + throw new OpsInputError('"runner": "docker" builds every site — send no "siteId"/"siteIds"'); + } + return publishResponse( + await enqueuePlan({ + runStart: Date.now(), + steps: [ + { + kind: "build-site", + target: ALL_TARGET, + runner: "docker", + reason: "asked", + ...(force !== false ? { force: true } : {}), + ...(skipArchives ? { skipArchives: true } : {}), + }, + ], + skipped: [], + }), + ); + } + const ids = reqSiteIds(body); + return publishResponse( + await enqueueAsks(ids.map((target) => ({ target, build: { force: force !== false, skipArchives } }))), + ); + } + case "deploy": { + only(body, verb, ["siteId", "siteIds", "preview", "to", "force"]); + const preview = optPreviewBranch(body); + const to = body.to === undefined ? "pages" : oneOf(body, "to", ["pages", "local"] as const); + if (to === "local" && preview !== undefined) { + throw new OpsInputError("a local deploy has no preview branch — deploy locally or as a preview, not both."); + } + const where = to === "local" ? "local" : preview ? "preview" : "production"; + return publishResponse( + await enqueueAsks( + reqSiteIds(body).map((target) => ({ + target, + deploy: { where, ...(preview ? { preview } : {}), force: force === true }, + })), + ), + ); + } + case "hub": + case "homepage": { + only(body, verb, ["deploy", "preview", "to", "force", "skipArchives"]); + const target = verb === "hub" ? HUB_TARGET : HOMEPAGE_TARGET; + const deploy = optBool(body, "deploy") === true; + const preview = optPreviewBranch(body); + const to = body.to === undefined ? "pages" : oneOf(body, "to", ["pages", "local"] as const); + if (!deploy && (preview !== undefined || body.to !== undefined)) { + throw new OpsInputError('"preview" and "to" need "deploy": true — a build alone deploys nothing'); + } + const where = to === "local" ? "local" : preview ? "preview" : "production"; + return publishResponse( + await enqueueAsks([ + { + target, + build: { force: force !== false, skipArchives }, + ...(deploy ? { deploy: { where, ...(preview ? { preview } : {}), force: true } } : {}), + }, + ]), + ); + } + } + }); +} + +export async function GET(request: Request) { + const denied = opsAuth(request); + if (denied) return denied; + return NextResponse.json({ ok: true, ...(await readPublishStatus(getPaths())) }); +} diff --git a/editor/app/sites/[siteId]/publish/page.tsx b/editor/app/sites/[siteId]/publish/page.tsx @@ -1,9 +1,12 @@ -import { statSync } from "node:fs"; -import path from "node:path"; import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; -import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; +import { getSettings } from "yt-dlp-transcript-common/lib/settings"; +import { + readBuiltStamp, + readDeployedFile, + type DeployRecord, +} from "yt-dlp-transcript-common/publish/stamps"; import { BuildDeployButton } from "../../components/BuildDeployButton"; import { BuildExportButton } from "../../components/BuildExportButton"; import { DeployButton } from "../../components/DeployButton"; @@ -13,16 +16,21 @@ 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; - } +const VERDICT: Record<string, string> = { + ok: "live check ok", + "stale-edge": "live check: the edge still serves an older build", + mismatch: "live check: a different build answers", + unreachable: "live check: unreachable", + skipped: "live check skipped", +}; + +function when(at: number): string { + return new Date(at).toLocaleString(); +} + +function recordLine(r: DeployRecord): string { + const verdict = r.liveCheck ? ` · ${VERDICT[r.liveCheck.verdict] ?? r.liveCheck.verdict}` : ""; + return `${when(r.at)}${r.url ? ` · ${r.url}` : ""}${verdict}`; } export default async function SitePublishPage({ @@ -34,8 +42,17 @@ export default async function SitePublishPage({ const site = getSiteCached(siteId); if (!site) notFound(); const siteTitle = site.siteTitle || siteId; - const lastDeployedAt = getLastDeployedAt(); - const existingQueues = getRegistry().activeQueueNames(); + const paths = getPaths(); + // This site's own records (release 18): its bundle's built.json and what + // was shipped from it, deployed.json — no longer export/out's mtime, which + // was the family's last build, whichever site that was. + const [built, deployed] = await Promise.all([ + readBuiltStamp(paths, siteId), + readDeployedFile(paths, siteId), + ]); + const previews = Object.entries(deployed?.previews ?? {}).sort((a, b) => b[1].at - a[1].at); + const lastPreview = previews[0]; + const previewBranch = getSettings().publish.previewBranch; return ( <div className="flex flex-col gap-8 max-w-3xl"> @@ -44,15 +61,22 @@ export default async function SitePublishPage({ <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"} + Updates the index when it is stale, builds this site into its own + bundle, then deploys that bundle to Cloudflare Pages production — + stages on the publish queue, one at a time. Cancelling the build + leaves the deploy nothing new to ship: it ends refused. </p> + <dl + className="mt-1 grid grid-cols-[auto_1fr] gap-x-3 text-sm text-muted-foreground" + data-testid="last-deployed" + > + <dt>Built:</dt> + <dd>{built ? `${when(built.builtAt)}${built.branch ? ` from ${built.branch}` : ""}` : "never"}</dd> + <dt>Last deployed:</dt> + <dd>{deployed?.production ? recordLine(deployed.production) : "never"}</dd> + <dt>Last preview:</dt> + <dd>{lastPreview ? `${lastPreview[0]} · ${recordLine(lastPreview[1])}` : "never"}</dd> + </dl> </div> <BuildDeployButton siteId={siteId} @@ -72,30 +96,29 @@ export default async function SitePublishPage({ <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. + The site&rsquo;s build stage: compose, then{" "} + <code>next build</code> into{" "} + <code>export/.export-builds/{siteId}/out</code> (and{" "} + <code>export/out</code> links to it). The index is updated + first when it is stale. No deploy. </p> </div> - <BuildExportButton - existingQueues={existingQueues} - siteId={siteId} - siteTitle={siteTitle} - /> + <BuildExportButton 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 — to production, - or to a preview branch you can look at first. Build first. + Publishes this site&rsquo;s built bundle to Cloudflare Pages + — to production, or to a preview branch you can look at + first — and checks the live site. Build first. </p> </div> <DeployButton siteId={siteId} siteTitle={siteTitle} cloudflareProject={site.cloudflareProject ?? null} + previewBranch={previewBranch} /> </div> </div> diff --git a/editor/app/sites/components/BuildAllSitesButton.tsx b/editor/app/sites/components/BuildAllSitesButton.tsx @@ -1,78 +0,0 @@ -"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): whenever a container engine answers 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 one 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() { - 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"> - When a container engine answers: the shared data phase runs once, per-site builds run in parallel in containers, then - deploys run serially. With none, a serial host build and deploy, one site - at a time. - </p> - {lane && ( - <JobLane - key={lane.key} - title={lane.deploy ? "Build & deploy all sites" : "Build all sites"} - subtitle={ - "Containers when an engine answers, else serial on the host" - } - trigger={() => - lane.deploy - ? buildAndDeployAllSitesAction(lane.skipArchives) - : buildAllSitesAction(lane.skipArchives) - } - /> - )} - </div> - ); -} diff --git a/editor/app/sites/components/BuildButtons.tsx b/editor/app/sites/components/BuildButtons.tsx @@ -13,11 +13,10 @@ import { archiveCombinedTranscriptsAction, archiveLiveChatAction, archiveTranscriptsAction, - buildIndexAction, - buildStatsAction, normalizeLiveChatAction, normalizeTranscriptsAction, } from "../lib/buildAction"; +import { updateIndexAction } from "../lib/publishActions"; import { ArchiveOptionsControl } from "./ArchiveOptionsControl"; type Props = { @@ -27,8 +26,6 @@ type Props = { const DEFAULT_BUILD_QUEUE = "build"; export function BuildButtons({ existingQueues }: Props) { - const [indexQueue, setIndexQueue] = useState(DEFAULT_BUILD_QUEUE); - const [statsQueue, setStatsQueue] = useState(DEFAULT_BUILD_QUEUE); const [normalizeQueue, setNormalizeQueue] = useState(DEFAULT_BUILD_QUEUE); const [archiveQueue, setArchiveQueue] = useState(DEFAULT_BUILD_QUEUE); const [combinedQueue, setCombinedQueue] = useState(DEFAULT_BUILD_QUEUE); @@ -58,54 +55,20 @@ export function BuildButtons({ existingQueues }: Props) { <div> <h2 className="text-lg font-semibold">Build index</h2> <p className="text-sm text-muted-foreground"> - Re-scans <code>transcripts/channels/</code> and rewrites paginated - JSON in <code>export/public/</code>. Cheap when nothing changed - (mtime short-circuit). + The index update every site, the hub and the homepage are built + from: the search index over <code>transcripts/channels/</code>, the + stats datasets and the chart templates, in one child process on + the <code>publish</code> queue. Cheap when nothing changed (each + part short-circuits). </p> </div> + {/* No queue control: the index update is a publish stage, and the + publish queue runs its stages one at a time. */} <StreamActionLog - trigger={() => buildIndexAction(indexQueue)} + trigger={() => updateIndexAction()} cancelAction={cancelJobAction} buttonLabel="Build index" runningLabel="Building index…" - extraControls={ - <QueueControl - value={indexQueue} - onChange={setIndexQueue} - defaultQueueKey={DEFAULT_BUILD_QUEUE} - existingQueues={existingQueues} - actionLabel="Build index" - /> - } - /> - </section> - - <section className="flex flex-col gap-3 border-t border-border pt-6"> - <div> - <h2 className="text-lg font-semibold">Build stats dataset</h2> - <p className="text-sm text-muted-foreground"> - Extracts per-video stats (views, likes, comments, duration, - categories, …) into <code>export/public/stats/</code> for the viewer - charts. Reads engagement metrics from each{" "} - <code>metadata.info.json</code>; cue counts come from the search - index, so run <strong>Build index</strong> first. Incremental - (mtime short-circuit). - </p> - </div> - <StreamActionLog - trigger={() => buildStatsAction(statsQueue)} - cancelAction={cancelJobAction} - buttonLabel="Build stats dataset" - runningLabel="Building stats dataset…" - extraControls={ - <QueueControl - value={statsQueue} - onChange={setStatsQueue} - defaultQueueKey={DEFAULT_BUILD_QUEUE} - existingQueues={existingQueues} - actionLabel="Build stats dataset" - /> - } /> </section> diff --git a/editor/app/sites/components/BuildDeployButton.tsx b/editor/app/sites/components/BuildDeployButton.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog"; -import { buildAndDeployAction } from "../lib/buildAction"; +import { buildAndDeployTargetAction } from "../lib/publishActions"; import { cancelJobAction } from "../../jobs/actions"; type Props = { @@ -14,8 +14,10 @@ type Props = { cloudflareProject: string | null; }; -// One-click build-then-deploy for this site: builds, and on success deploys — -// one combined streamed log, one Cancel (see buildAndDeployAction). +// One-click build-then-deploy for this site: the index update when it is +// stale, the build (forced), then its production deploy — publish stages on +// the publish queue, followed in one log (publishActions.ts). The deploy waits +// ON DISK for this run's build: a failed or cancelled build ends it refused. export function BuildDeployButton({ siteId, siteTitle, @@ -43,7 +45,7 @@ export function BuildDeployButton({ return ( <StreamActionLog - trigger={() => buildAndDeployAction(siteId, skipArchives)} + trigger={() => buildAndDeployTargetAction(siteId, { where: "production", skipArchives })} cancelAction={cancelJobAction} buttonLabel="Build & deploy" runningLabel="Building & deploying…" diff --git a/editor/app/sites/components/BuildExportButton.tsx b/editor/app/sites/components/BuildExportButton.tsx @@ -2,29 +2,25 @@ import { useState } from "react"; import { StreamActionLog } from "yt-dlp-transcript-common/components/StreamActionLog"; -import { buildExportAction } from "../lib/buildAction"; -import { QueueControl } from "../../components/QueueControl"; +import { buildTargetAction } from "../lib/publishActions"; 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); +// The site's build stage, forced (a click always builds): the index update +// first when it is stale, then compose + next build into the site's own +// bundle, `export/.export-builds/<id>/out` (release 18). There is no "skip +// data" any more: the data is the index stage, shared by every site. +export function BuildExportButton({ siteId, siteTitle }: Props) { const [skipArchives, setSkipArchives] = useState(false); return ( <StreamActionLog - trigger={() => - buildExportAction(siteId, exportQueue, skipData, skipArchives) - } + trigger={() => buildTargetAction(siteId, { skipArchives })} cancelAction={cancelJobAction} buttonLabel="Build static export" runningLabel="Building static export…" @@ -36,18 +32,6 @@ export function BuildExportButton({ existingQueues, siteId, siteTitle }: Props) <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)} /> @@ -57,13 +41,6 @@ export function BuildExportButton({ existingQueues, siteId, siteTitle }: Props) 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/BuildSitesPanel.tsx b/editor/app/sites/components/BuildSitesPanel.tsx @@ -1,146 +0,0 @@ -"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. The jobs share -// the build/deploy queue and run one at a time (the export/ tree is shared); -// "Build all sites" is the parallel path. -export function BuildSitesPanel({ sites }: { sites: SiteOption[] }) { - 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> - )} - <p className="text-xs text-muted-foreground"> - These run one at a time (the build output tree is shared). “Build all - sites” above builds every site in parallel, in containers, whenever a - container engine answers. - </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/DeployButton.tsx b/editor/app/sites/components/DeployButton.tsx @@ -8,7 +8,7 @@ import { previewBranchProblem, } from "yt-dlp-transcript-common/lib/pagesDeploy"; import { cancelJobAction } from "../../jobs/actions"; -import { deployExportAction } from "../lib/deployAction"; +import { deployTargetAction } from "../lib/publishActions"; type Props = { // The site whose Publish tab this is — always a real site. @@ -19,22 +19,25 @@ type Props = { // there is no preview to offer, and the control says that instead of linking // a hostname that cannot exist. cloudflareProject?: string | null; + // settings.publish.previewBranch: the preview box starts on it. + previewBranch?: string; }; const DEFAULT_PREVIEW_BRANCH = "preview"; -// Two deploys of the same already-built bundle: to production, or to a -// Cloudflare Pages preview branch. +// Two deploys of the site's built bundle (`export/.export-builds/<id>/out`): +// to production, or to a Cloudflare Pages preview branch — each a deploy +// stage on the publish queue, forced (a click always deploys). // // THE PRODUCTION BUTTON SAYS "PRODUCTION" NOW. With a preview control beside // it, a button labelled only "Deploy" is the one an operator clicks by reflex // when they meant the safe one; the extra word is the whole cost of not doing // that. -export function DeployButton({ siteId, siteTitle, cloudflareProject }: Props) { +export function DeployButton({ siteId, siteTitle, cloudflareProject, previewBranch }: Props) { return ( <div className="flex flex-col gap-6"> <StreamActionLog - trigger={() => deployExportAction(siteId)} + trigger={() => deployTargetAction(siteId, { where: "production" })} cancelAction={cancelJobAction} buttonLabel="Deploy to production" runningLabel="Deploying…" @@ -48,6 +51,7 @@ export function DeployButton({ siteId, siteTitle, cloudflareProject }: Props) { <PreviewDeploy siteId={siteId} cloudflareProject={cloudflareProject ?? null} + initialBranch={previewBranch || DEFAULT_PREVIEW_BRANCH} /> </div> ); @@ -56,11 +60,13 @@ export function DeployButton({ siteId, siteTitle, cloudflareProject }: Props) { function PreviewDeploy({ siteId, cloudflareProject, + initialBranch, }: { siteId: string; cloudflareProject: string | null; + initialBranch: string; }) { - const [branch, setBranch] = useState(DEFAULT_PREVIEW_BRANCH); + const [branch, setBranch] = useState(initialBranch); const [deployed, setDeployed] = useState<string | null>(null); // The SAME function the action and the ops route refuse with, so the button // greys out on exactly the names the server would have rejected and the @@ -88,7 +94,7 @@ function PreviewDeploy({ </p> </div> <StreamActionLog - trigger={() => deployExportAction(siteId, { previewBranch: branch })} + trigger={() => deployTargetAction(siteId, { where: "preview", preview: branch })} cancelAction={cancelJobAction} buttonLabel="Deploy preview" runningLabel="Deploying preview…" diff --git a/editor/app/sites/components/HomepageBuildButtons.tsx b/editor/app/sites/components/HomepageBuildButtons.tsx @@ -1,216 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { useRouter } from "next/navigation"; -import { - MAX_PREVIEW_BRANCH, - previewAliasUrl, - previewBranchProblem, -} from "yt-dlp-transcript-common/lib/pagesDeploy"; -import { PROJECT_URL } from "yt-dlp-transcript-common/lib/project"; -import { - buildAndDeployHomepageAction, - buildHomepageAction, - deployHomepageAction, -} from "../lib/homepageDeployActions"; -import { useHydrated } from "../../operations/components/useOperationsStatus"; -import { JobLane, type LaneOutcome } from "./JobLane"; - -type Lane = { - kind: "build" | "build-deploy" | "deploy"; - key: number; - // The preview branch AS LAUNCHED. Editing the field afterwards must not - // change what a running lane says it is deploying to. - preview: string | undefined; -}; - -const TITLE: Record<Lane["kind"], string> = { - build: "Build homepage", - "build-deploy": "Build & deploy homepage", - deploy: "Deploy homepage", -}; - -type Props = { - // The homepage's Pages project — the constant HOMEPAGE_PAGES_PROJECT, passed - // down because it lives beside the deploy code (server-only), not copied. - project: string; - // When homepage/out/index.html was built, formatted by the server; null - // when there is no build there. - builtAt: string | null; -}; - -// The homepage's build and deploy — the `homepage` package, built into -// homepage/out and deployed to its own Pages project. HubBuildButtons' shape -// exactly: one lane at a time (each launch replaces the previous lane; its job -// keeps running and stays on /jobs), and "Deploy after build" starts -// unchecked, because a homepage deploy replaces the software's public site. -// -// Plus a preview branch, which the hub's buttons do not have: empty is -// production, a name is a Cloudflare Pages preview of the same project. The -// name is judged by previewBranchProblem, the function the action and the ops -// route refuse with, so the buttons grey out on exactly the names the server -// would reject. -export function HomepageBuildButtons({ project, builtAt }: Props) { - const [deploy, setDeploy] = useState(false); - const [preview, setPreview] = useState(""); - const [lane, setLane] = useState<Lane | null>(null); - const [run, setRun] = useState(0); - const hydrated = useHydrated(); - const router = useRouter(); - - const branch = preview.trim(); - const problem = branch ? previewBranchProblem(branch) : null; - // Knowable before any deploy: project + branch and nothing else. - const alias = branch && problem === null ? previewAliasUrl(project, branch) : null; - - function launch(kind: Lane["kind"]) { - const next = run + 1; - setRun(next); - setLane({ kind, key: next, preview: branch || undefined }); - } - - // "built <when>" is read from homepage/out when /sites renders, so a lane - // that BUILT has to re-render the page for the line to move (release 13 - // slice W1). Every outcome of a job, not only "done": a failed or cancelled - // build may already have rewritten or emptied homepage/out, and the line — - // which is what Deploy homepage would ship — must say so. "error" started no - // job, and a deploy writes nothing the page reads. StreamActionLog refreshes - // after any run that started, for the same reason. - function settled(l: Lane, outcome: LaneOutcome) { - if (l.kind !== "deploy" && outcome !== "error") router.refresh(); - } - - function trigger(l: Lane) { - const opts = l.preview ? { previewBranch: l.preview } : undefined; - return l.kind === "build" - ? buildHomepageAction() - : l.kind === "build-deploy" - ? buildAndDeployHomepageAction(opts) - : deployHomepageAction(opts); - } - - return ( - // data-hydrated: a TESTING AFFORDANCE, as on the operation lanes. A click - // that lands before React hydrates fires nothing at all, so a spec waits - // for this before it clicks Build homepage. - <div - role="group" - aria-label="Homepage build" - className="flex flex-col gap-3" - data-hydrated={hydrated ? "true" : undefined} - > - <div className="flex flex-wrap items-center gap-3"> - <button - type="button" - onClick={() => launch(deploy ? "build-deploy" : "build")} - // A build alone deploys nothing, so only a build that will deploy - // is held back by a bad preview name. - disabled={deploy && problem !== null} - className="px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50" - > - Build homepage - </button> - <label className="flex items-center gap-2 text-sm text-muted-foreground"> - <input - type="checkbox" - checked={deploy} - onChange={(e) => setDeploy(e.target.checked)} - /> - Deploy after build - </label> - <button - type="button" - onClick={() => launch("deploy")} - disabled={problem !== null} - className="px-3 py-2 rounded-md border border-border text-sm font-medium hover:bg-muted disabled:opacity-50" - > - Deploy homepage - </button> - <label className="flex items-center gap-2 text-sm"> - <span className="text-muted-foreground">Preview branch</span> - <input - type="text" - aria-label="preview branch" - placeholder="none: production" - value={preview} - // Twice the limit, so an over-long name is refused with the - // sentence that says WHY rather than silently truncated. - maxLength={MAX_PREVIEW_BRANCH * 2} - onChange={(e) => setPreview(e.target.value)} - className="w-44 rounded-md border border-border bg-background px-2 py-1 font-mono text-sm" - /> - </label> - </div> - {problem !== null && ( - <p - role="status" - // Not "preview branch problem": a name that CONTAINS the input's - // would make every by-label lookup for the input ambiguous. - aria-label="preview problem" - className="text-sm text-destructive" - > - {problem} - </p> - )} - <p className="text-xs text-muted-foreground" data-testid="homepage-ships"> - {builtAt ? ( - <> - Deploy homepage ships <code>homepage/out</code>, built {builtAt}, - </> - ) : ( - <> - <code>homepage/out</code> holds no build yet, so Deploy homepage has - nothing to ship. A deploy goes - </> - )}{" "} - to <code>{project}</code>{" "} - {!branch ? ( - <> - (production): <UrlLink url={PROJECT_URL} />. - </> - ) : alias ? ( - <> - (preview <code>{branch}</code>): <UrlLink url={alias} />; the live - site is left alone. - </> - ) : ( - <> - (preview <code>{branch}</code>). - </> - )} - </p> - <p className="text-xs text-muted-foreground"> - The homepage reads the search index as it stands: run Build index - (under Pool jobs, below) first when its numbers should move. - </p> - <p className="text-xs text-muted-foreground"> - Build homepage also publishes the source mirror (<code>archilyzer - source publish</code>) and refuses if a denied string survives the - scrub; a refusal removes the last published source, from - homepage/out too. Deploy homepage refuses a build whose source was - not audited under today&rsquo;s rules. - </p> - {lane && ( - <JobLane - key={lane.key} - title={TITLE[lane.kind]} - subtitle={ - lane.kind === "build" - ? "homepage/out" - : `homepage/out · ${project}${lane.preview ? ` (preview ${lane.preview})` : " (production)"}` - } - trigger={() => trigger(lane)} - onSettled={(outcome) => settled(lane, outcome)} - /> - )} - </div> - ); -} - -function UrlLink({ url }: { url: string }) { - return ( - <a className="font-mono underline" href={url} target="_blank" rel="noreferrer"> - {url} - </a> - ); -} diff --git a/editor/app/sites/components/HubBuildButtons.tsx b/editor/app/sites/components/HubBuildButtons.tsx @@ -1,84 +0,0 @@ -"use client"; - -import { useState } from "react"; -import { - buildAndDeployHubAction, - buildHubAction, - deployHubAction, -} from "../lib/hubActions"; -import { JobLane } from "./JobLane"; - -type Lane = { kind: "build" | "build-deploy" | "deploy"; key: number }; - -const TITLE: Record<Lane["kind"], string> = { - build: "Build hub", - "build-deploy": "Build & deploy hub", - deploy: "Deploy hub", -}; - -// The hub's build and deploy — the export app built with INSTANCE_MODE=hub into -// export/out, then deployed to the Cloudflare Pages project set in the form -// above. One lane at a time, like BuildAllSitesButton: each launch replaces the -// previous lane (its job keeps running and stays on /jobs). -// -// "Deploy after build" starts unchecked here, unlike the all-sites batch: a -// hub deploy replaces a public site, and the first one should be a decision. -export function HubBuildButtons({ project }: { project: string | null }) { - const [deploy, setDeploy] = useState(false); - const [lane, setLane] = useState<Lane | null>(null); - const [run, setRun] = useState(0); - - function launch(kind: Lane["kind"]) { - const next = run + 1; - setRun(next); - setLane({ kind, key: next }); - } - - return ( - <div role="group" aria-label="Hub build" className="flex flex-col gap-3"> - <div className="flex flex-wrap items-center gap-3"> - <button - type="button" - onClick={() => launch(deploy ? "build-deploy" : "build")} - className="px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90" - > - Build hub - </button> - <label className="flex items-center gap-2 text-sm text-muted-foreground"> - <input - type="checkbox" - checked={deploy} - onChange={(e) => setDeploy(e.target.checked)} - /> - Deploy after build - </label> - <button - type="button" - onClick={() => launch("deploy")} - className="px-3 py-2 rounded-md border border-border text-sm font-medium hover:bg-muted" - > - Deploy hub - </button> - </div> - <p className="text-xs text-muted-foreground"> - {project - ? `Builds into export/out (shared with site builds) and deploys to the Pages project "${project}". Deploy hub ships what is already built.` - : "Builds into export/out (shared with site builds). Set a Cloudflare Pages project above to deploy."} - </p> - {lane && ( - <JobLane - key={lane.key} - title={TITLE[lane.kind]} - subtitle="INSTANCE_MODE=hub · export/out" - trigger={() => - lane.kind === "build" - ? buildHubAction() - : lane.kind === "build-deploy" - ? buildAndDeployHubAction() - : deployHubAction() - } - /> - )} - </div> - ); -} diff --git a/editor/app/sites/components/PublishPanel.tsx b/editor/app/sites/components/PublishPanel.tsx @@ -0,0 +1,508 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { + MAX_PREVIEW_BRANCH, + previewAliasUrl, + previewBranchProblem, +} from "yt-dlp-transcript-common/lib/pagesDeploy"; +import { useHydrated } from "../../operations/components/useOperationsStatus"; +import { + buildAllStaleAction, + buildAndDeployTargetAction, + buildTargetAction, + deployTargetAction, + publishNowAction, +} from "../lib/publishActions"; +import { JobLane } from "./JobLane"; + +// THE PUBLISH PANEL (release 18): one row per site, then the hub, then the +// homepage — each with the four chips the publish status computes (index | +// built | deployed | live, publish/publishPlan.ts) and its buttons, each +// button one run of publish stages on the `publish` queue +// (sites/lib/publishActions.ts). Above the rows: Publish now (what the lane +// would run — the stale index, then each policy target, never forced), Build +// all stale (every stale site, whatever its policy, no deploys), the lane's +// chip, and the plan Publish now would enqueue, so the button is predictable. +// +// The chips are the server's: a lane that settles refreshes the page. + +export type PanelChip = { tone: "ok" | "stale" | "blocked" | "busy" | "warn" | "off"; text: string }; + +export type PanelRow = { + // A site id, "_hub" or "_homepage". + target: string; + kind: "site" | "hub" | "homepage"; + title: string; + chips: { index: PanelChip; built: PanelChip; deployed: PanelChip; live: PanelChip }; + policy: string; + next: string; + cloudflareProject: string | null; + // Why it never deploys (a private site), or null. + deployProblem: string | null; + // The preview alias of the policy's branch, when known. + previewUrl: string | null; + // When its bundle was built (ms), or null. + builtAt: number | null; + // The public URL a production deploy answers on, or null. + url: string | null; +}; + +export type PanelPlan = { + steps: { kind: string; target: string; reason: string }[]; + skipped: { kind: string; target: string; reason: string }[]; +}; + +type Props = { + rows: PanelRow[]; + index: PanelChip; + lane: PanelChip; + plan: PanelPlan; + // settings.publish.previewBranch: a site row's preview textbox starts on it. + previewBranch: string; + // ARCHILYZER_SITE_OUT / ARCHILYZER_HOMEPAGE_OUT are set: "Deploy local". + siteOut: boolean; + homepageOut: boolean; +}; + +const TONE: Record<PanelChip["tone"], string> = { + ok: "border-success/30 bg-success-soft text-success", + stale: "border-warning/30 bg-warning-soft text-warning", + blocked: "border-destructive/30 bg-destructive-soft text-destructive", + busy: "border-info/30 bg-info-soft text-info", + warn: "border-warning/30 bg-warning-soft text-warning", + off: "border-border bg-muted text-muted-foreground", +}; + +export function Chip({ name, chip }: { name: string; chip: PanelChip }) { + return ( + <span + data-chip={name} + data-tone={chip.tone} + className={`inline-flex max-w-full items-center gap-1 rounded-full border px-2 py-0.5 text-xs ${TONE[chip.tone]}`} + > + <span className="font-mono uppercase tracking-wide opacity-70">{name}</span> + <span className="truncate">{chip.text}</span> + </span> + ); +} + +const PRIMARY = + "px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50"; +const SECONDARY = + "px-3 py-2 rounded-md border border-border text-sm font-medium hover:bg-muted disabled:opacity-50"; + +type Lane = { key: number; title: string; subtitle: string; run: () => ReturnType<typeof publishNowAction> }; + +function useLane() { + const [lane, setLane] = useState<Lane | null>(null); + const [n, setN] = useState(0); + const launch = (l: Omit<Lane, "key">) => { + const key = n + 1; + setN(key); + setLane({ ...l, key }); + }; + return { lane, launch }; +} + +export function PublishPanel({ rows, index, lane, plan, previewBranch, siteOut, homepageOut }: Props) { + const router = useRouter(); + const hydrated = useHydrated(); + const { lane: head, launch } = useLane(); + + return ( + <div className="flex flex-col gap-4" data-hydrated={hydrated ? "true" : undefined}> + <div role="group" aria-label="Publish all" className="flex flex-col gap-3"> + <div className="flex flex-wrap items-center gap-3"> + <button + type="button" + className={PRIMARY} + onClick={() => + launch({ title: "Publish now", subtitle: "the plan below, in order", run: () => publishNowAction() }) + } + > + Publish now + </button> + <button + type="button" + className={SECONDARY} + onClick={() => + launch({ + title: "Build all stale", + subtitle: "every stale site, whatever its policy; nothing deployed", + run: () => buildAllStaleAction(), + }) + } + > + Build all stale + </button> + <Chip name="index" chip={index} /> + <Link href="/operations/publish" aria-label="publish lane" className="no-underline"> + <Chip name="lane" chip={lane} /> + </Link> + </div> + <div className="text-sm" data-testid="publish-plan"> + {plan.steps.length === 0 ? ( + <p className="text-muted-foreground">Publish now has nothing to run: everything its policies cover is current.</p> + ) : ( + <> + <p className="text-muted-foreground">Publish now would run, in order:</p> + <ol className="ml-5 list-decimal"> + {plan.steps.map((s) => ( + <li key={`${s.kind}:${s.target}`}> + <code> + {s.kind} {s.target} + </code>{" "} + <span className="text-muted-foreground">— {s.reason}</span> + </li> + ))} + </ol> + </> + )} + {plan.skipped.length > 0 && ( + <details className="mt-1"> + <summary className="cursor-pointer text-xs text-muted-foreground select-none"> + {plan.skipped.length} left out + </summary> + <ul className="ml-5 list-disc text-xs text-muted-foreground"> + {plan.skipped.map((s) => ( + <li key={`${s.kind}:${s.target}`}> + <code> + {s.kind} {s.target} + </code> + : {s.reason} + </li> + ))} + </ul> + </details> + )} + </div> + {head && ( + <JobLane + key={head.key} + title={head.title} + subtitle={head.subtitle} + trigger={head.run} + onSettled={() => router.refresh()} + /> + )} + </div> + + <ul className="flex flex-col divide-y divide-border rounded-md border border-border"> + {rows.map((row) => ( + <li key={row.target} className="p-3"> + {row.kind === "site" ? ( + <SiteRow row={row} previewBranch={previewBranch} siteOut={siteOut} hydrated={hydrated} /> + ) : ( + <FamilyRow row={row} localOut={row.kind === "homepage" && homepageOut} hydrated={hydrated} /> + )} + </li> + ))} + </ul> + </div> + ); +} + +function RowHead({ row }: { row: PanelRow }) { + return ( + <div className="flex flex-col gap-1"> + <div className="flex flex-wrap items-baseline gap-x-2"> + <h3 className="font-semibold">{row.title}</h3> + {row.kind === "site" && ( + <Link href={`/sites/${row.target}/publish`} className="text-xs text-muted-foreground underline"> + <code>{row.target}</code> + </Link> + )} + <span className="text-xs text-muted-foreground">policy: {row.policy}</span> + </div> + <div className="flex flex-wrap gap-1.5"> + <Chip name="index" chip={row.chips.index} /> + <Chip name="built" chip={row.chips.built} /> + <Chip name="deployed" chip={row.chips.deployed} /> + <Chip name="live" chip={row.chips.live} /> + </div> + <p className="text-xs text-muted-foreground">next: {row.next}</p> + </div> + ); +} + +function PreviewInput({ + value, + onChange, + placeholder, +}: { + value: string; + onChange: (v: string) => void; + placeholder: string; +}) { + return ( + <label className="flex items-center gap-2 text-sm"> + <span className="text-muted-foreground">Preview branch</span> + <input + type="text" + aria-label="preview branch" + placeholder={placeholder} + value={value} + // Twice the limit, so an over-long name is refused with the sentence + // that says WHY rather than silently truncated. + maxLength={MAX_PREVIEW_BRANCH * 2} + onChange={(e) => onChange(e.target.value)} + className="w-44 rounded-md border border-border bg-background px-2 py-1 font-mono text-sm" + /> + </label> + ); +} + +function PreviewProblem({ problem }: { problem: string | null }) { + if (problem === null) return null; + return ( + // Not "preview branch problem": a name that CONTAINS the input's would + // make every by-label lookup for the input ambiguous. + <p role="status" aria-label="preview problem" className="text-sm text-destructive"> + {problem} + </p> + ); +} + +function SiteRow({ + row, + previewBranch, + siteOut, + hydrated, +}: { + row: PanelRow; + previewBranch: string; + siteOut: boolean; + hydrated: boolean; +}) { + const router = useRouter(); + const { lane, launch } = useLane(); + const [preview, setPreview] = useState(previewBranch); + const branch = preview.trim(); + const problem = previewBranchProblem(branch); + const project = row.cloudflareProject; + const pages = row.deployProblem === null && project !== null; + const id = row.target; + + return ( + <div + role="group" + aria-label={`Publish ${id}`} + className="flex flex-col gap-2" + data-hydrated={hydrated ? "true" : undefined} + > + <RowHead row={row} /> + <div className="flex flex-wrap items-center gap-2"> + <button + type="button" + className={PRIMARY} + onClick={() => + launch({ title: `Build ${id}`, subtitle: `.export-builds/${id}/out`, run: () => buildTargetAction(id) }) + } + > + Build + </button> + {pages && ( + <> + <button + type="button" + className={SECONDARY} + disabled={problem !== null} + onClick={() => + launch({ + title: `Deploy preview ${id}`, + subtitle: `${project} (preview ${branch})`, + run: () => deployTargetAction(id, { where: "preview", preview: branch }), + }) + } + > + Deploy preview + </button> + <PreviewInput value={preview} onChange={setPreview} placeholder={previewBranch} /> + <button + type="button" + className={SECONDARY} + onClick={() => + launch({ + title: `Deploy production ${id}`, + subtitle: `${project} (production)`, + run: () => deployTargetAction(id, { where: "production" }), + }) + } + > + Deploy production + </button> + </> + )} + {siteOut && row.deployProblem === null && ( + <button + type="button" + className={SECONDARY} + onClick={() => + launch({ + title: `Deploy local ${id}`, + subtitle: "ARCHILYZER_SITE_OUT", + run: () => deployTargetAction(id, { where: "local" }), + }) + } + > + Deploy local + </button> + )} + </div> + {pages && <PreviewProblem problem={problem} />} + {pages && problem === null && branch && ( + <p className="text-xs text-muted-foreground"> + A preview goes to <UrlLink url={previewAliasUrl(project!, branch)} />; production to{" "} + {row.url ? <UrlLink url={row.url} /> : <code>{project}</code>}. + </p> + )} + {row.deployProblem !== null && <p className="text-xs text-muted-foreground">{row.deployProblem}.</p>} + {row.deployProblem === null && project === null && ( + <p className="text-xs text-warning"> + No Cloudflare Pages project — set one on the site&apos;s page to deploy. + </p> + )} + {lane && ( + <JobLane + key={lane.key} + title={lane.title} + subtitle={lane.subtitle} + trigger={lane.run} + onSettled={() => router.refresh()} + /> + )} + </div> + ); +} + +// The hub's and the homepage's row: the names they have always had ("Build +// hub", "Deploy homepage", "Deploy after build", a preview box that starts +// empty = production), so the family's two sites read as they did. +function FamilyRow({ row, localOut, hydrated }: { row: PanelRow; localOut: boolean; hydrated: boolean }) { + const router = useRouter(); + const { lane, launch } = useLane(); + const [deploy, setDeploy] = useState(false); + const [preview, setPreview] = useState(""); + const noun = row.kind === "hub" ? "hub" : "homepage"; + const target = row.target; + const branch = preview.trim(); + const problem = branch ? previewBranchProblem(branch) : null; + const project = row.cloudflareProject; + const alias = branch && problem === null && project ? previewAliasUrl(project, branch) : null; + const where = branch ? ({ where: "preview", preview: branch } as const) : ({ where: "production" } as const); + const dest = `${project ?? "no project"}${branch ? ` (preview ${branch})` : " (production)"}`; + const out = row.kind === "hub" ? ".export-builds/_hub/out" : "homepage/out"; + const built = row.builtAt === null ? null : new Date(row.builtAt).toLocaleString(); + + return ( + <div + role="group" + aria-label={row.kind === "hub" ? "Hub build" : "Homepage build"} + className="flex flex-col gap-2" + data-hydrated={hydrated ? "true" : undefined} + > + <RowHead row={row} /> + <div className="flex flex-wrap items-center gap-3"> + <button + type="button" + className={PRIMARY} + // A build alone deploys nothing, so only a build that will deploy is + // held back by a bad preview name. + disabled={deploy && problem !== null} + onClick={() => + deploy + ? launch({ + title: `Build & deploy ${noun}`, + subtitle: `${out} · ${dest}`, + run: () => buildAndDeployTargetAction(target, where), + }) + : launch({ title: `Build ${noun}`, subtitle: out, run: () => buildTargetAction(target) }) + } + > + Build {noun} + </button> + <label className="flex items-center gap-2 text-sm text-muted-foreground"> + <input type="checkbox" checked={deploy} onChange={(e) => setDeploy(e.target.checked)} /> + Deploy after build + </label> + <button + type="button" + className={SECONDARY} + disabled={problem !== null} + onClick={() => + launch({ title: `Deploy ${noun}`, subtitle: `${out} · ${dest}`, run: () => deployTargetAction(target, where) }) + } + > + Deploy {noun} + </button> + <PreviewInput value={preview} onChange={setPreview} placeholder="none: production" /> + {localOut && ( + <button + type="button" + className={SECONDARY} + onClick={() => + launch({ + title: `Deploy local ${noun}`, + subtitle: "ARCHILYZER_HOMEPAGE_OUT", + run: () => deployTargetAction(target, { where: "local" }), + }) + } + > + Deploy local + </button> + )} + </div> + <PreviewProblem problem={problem} /> + <p className="text-xs text-muted-foreground" data-testid={`${noun}-ships`}> + {built ? ( + <> + Deploy {noun} ships <code>{out}</code>, built {built}, + </> + ) : ( + <> + <code>{out}</code> holds no build yet, so Deploy {noun} has nothing to ship. A deploy goes + </> + )}{" "} + to <code>{project ?? "no Pages project"}</code>{" "} + {!branch ? ( + <>(production){row.url ? <>: <UrlLink url={row.url} />.</> : "."}</> + ) : alias ? ( + <> + (preview <code>{branch}</code>): <UrlLink url={alias} />; the live site is left alone. + </> + ) : ( + <> + (preview <code>{branch}</code>). + </> + )} + </p> + {row.kind === "homepage" && ( + <p className="text-xs text-muted-foreground"> + Build homepage also publishes the source mirror (<code>archilyzer source publish</code>) and refuses if + a denied string survives the scrub; a refusal removes the last published source, from homepage/out too. + Deploy homepage refuses a build whose source was not audited under today&rsquo;s rules. + </p> + )} + {lane && ( + <JobLane + key={lane.key} + title={lane.title} + subtitle={lane.subtitle} + trigger={lane.run} + onSettled={() => router.refresh()} + /> + )} + </div> + ); +} + +function UrlLink({ url }: { url: string }) { + return ( + <a className="font-mono underline" href={url} target="_blank" rel="noreferrer"> + {url} + </a> + ); +} diff --git a/editor/app/sites/lib/buildAction.ts b/editor/app/sites/lib/buildAction.ts @@ -1,8 +1,10 @@ "use server"; -import { safeRevalidate } from "../../lib/safeRevalidate"; -import { buildIndex } from "yt-dlp-transcript-common/controller/buildIndex"; -import { buildStats } from "yt-dlp-transcript-common/controller/buildStats"; +// The Pool's corpus-wide jobs that are not publish stages: normalize and the +// downloadable archives. Build index, the stats dataset, a site's build and +// deploy, "Build all sites" and the hub's and the homepage's buttons are +// publish stages now (release 18): sites/lib/publishActions.ts. + import { normalizeAllTranscripts } from "yt-dlp-transcript-common/controller/normalizeAll"; import { normalizeAllLiveChat } from "yt-dlp-transcript-common/controller/normalizeAllLiveChat"; import { @@ -15,186 +17,12 @@ import { archiveCombinedLiveChat, } from "yt-dlp-transcript-common/controller/archiveLiveChat"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; -import { previewBranchProblem } from "yt-dlp-transcript-common/lib/pagesDeploy"; -import { - deployAudienceProblem, - siteDeployProblem, -} from "yt-dlp-transcript-common/lib/builtExport"; -import { getSite, listSites, type Site } from "yt-dlp-transcript-common/lib/site"; import { runManagedFunction, type StreamActionResult, } from "yt-dlp-transcript-common/jobs/streamCommand"; -import { - PREVIEW_SHARES_ARCHIVES_NOTICE, - buildAll, - buildSite, - dockerAvailable, - dockerSiteOutDir, - resolveOutDir, - runArchiveUploadIntoLog, - runBuildPhase, - runDeployIntoLog, - runDockerBuildAllPhase, - runDockerDeployAllPhase, - type SiteBuildOutcome, - type SiteDeployOutcome, -} from "yt-dlp-transcript-common/publish/build"; 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. - safeRevalidate([["/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 goes through - // runBuildPhase (a single site always builds in export/), 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 buildSite(id, { - paths, - onLog, - signal, - 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. -// -// `opts.previewBranch` makes the deploy half a Cloudflare Pages PREVIEW (branch -// alias; production untouched) — same job kind, same queue, same single log. -export async function buildAndDeployAction( - siteId: string, - skipArchives?: boolean, - opts?: { previewBranch?: string }, -): Promise<StreamActionResult> { - const paths = getPaths(); - const id = siteId.trim(); - if (!id) { - return { ok: false, error: "Select a site to build and deploy" }; - } - // Refuse an unusable preview name BEFORE the build starts. Discovering a typo - // at the deploy step would cost a whole build to learn it. - const previewBranch = opts?.previewBranch; - if (previewBranch !== undefined) { - const problem = previewBranchProblem(previewBranch); - if (problem) return { ok: false, error: problem }; - } - const branch = previewBranch?.trim(); - const site = getSite(id, paths); - // A private site is never deployed (site.json `audience`), so Build & deploy - // refuses before the build; its Build button still builds it. - const privateProblem = siteDeployProblem(site); - if (privateProblem) return { ok: false, error: `${privateProblem}.` }; - 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 buildSite(id, { - paths, - onLog, - signal, - 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.`); - } - // Asked again before the upload, over the site as it is now and the - // bundle just built: an audience switched to private while this job - // waited on the queue is not deployed. - const audienceProblem = deployAudienceProblem( - getSite(id, paths), - resolveOutDir(id, paths), - ); - if (audienceProblem) throw new Error(`${audienceProblem} — not deploying.`); - onLog(branch ? `\n=== Deploy (preview "${branch}") ===\n` : "\n=== Deploy ===\n"); - if (branch) onLog(PREVIEW_SHARES_ARCHIVES_NOTICE); - // 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, - { previewBranch: branch }, - ); - if (signal.aborted) return; - if (deployCode !== 0) { - throw new Error(`Deploy failed (exit ${deployCode}).`); - } - }, - }); -} export async function normalizeTranscriptsAction( queueKey?: string, @@ -283,206 +111,3 @@ export async function archiveCombinedLiveChatAction( }, }); } - -// 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; - if (!useDocker) { - onLog( - "[notice] No container engine available — building sites serially on the host.\n", - ); - } - const outcomes = await buildAll({ - paths, - onLog, - signal, - mode: useDocker ? "docker" : "basic", - skipArchives, - sites, - }); - 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; - } - // A private site is built and never deployed — skipped before the upload. - const audienceProblem = deployAudienceProblem(site, basicOut); - if (audienceProblem) { - onLog(`[${site.siteId}] deploy skipped — ${audienceProblem}`); - deploys.push({ siteId: site.siteId, status: "skipped", reason: audienceProblem }); - 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/sites/lib/deployAction.ts b/editor/app/sites/lib/deployAction.ts @@ -1,81 +0,0 @@ -"use server"; - -import { - builtAudienceProblem, - builtSiteProblem, - siteDeployProblem, -} from "yt-dlp-transcript-common/lib/builtExport"; -import { getPaths } from "yt-dlp-transcript-common/lib/paths"; -import { previewBranchProblem } from "yt-dlp-transcript-common/lib/pagesDeploy"; -import { getSite } from "yt-dlp-transcript-common/lib/site"; -import { - runManagedFunction, - type StreamActionResult, -} from "yt-dlp-transcript-common/jobs/streamCommand"; -import { - deploySite, - resolveOutDir, -} from "yt-dlp-transcript-common/publish/build"; - -const DEPLOY_QUEUE = "deploy"; - -// Deploy the already-built export/out. With `opts.previewBranch` it goes to a -// Cloudflare Pages PREVIEW instead of production, reachable at the branch alias -// — same job kind, same queue, same log, so nothing downstream has to learn a -// new word for it. -export async function deployExportAction( - siteId: string, - opts?: { previewBranch?: string }, -): Promise<StreamActionResult> { - const paths = getPaths(); - if (!siteId.trim()) { - return { ok: false, error: "Select a site to deploy" }; - } - // NAMING A BRANCH IS THE REQUEST, so an unusable name is a refusal and never - // a quiet fall-through to a production deploy — the one mistake this feature - // must not make. Checked before the job starts, with the same sentence the - // browser control and the ops route give. - const previewBranch = opts?.previewBranch; - if (previewBranch !== undefined) { - const problem = previewBranchProblem(previewBranch); - if (problem) return { ok: false, error: problem }; - } - const branch = previewBranch?.trim(); - const site = getSite(siteId.trim(), paths); - // A private site is never deployed (site.json `audience`): the first answer. - const privateProblem = siteDeployProblem(site); - if (privateProblem) return { ok: false, error: `${privateProblem}.` }; - if (!site.cloudflareProject) { - return { - ok: false, - error: `Site "${site.siteId}" has no Cloudflare Pages project configured.`, - }; - } - // DEPLOY-ONLY SHIPS WHAT IS IN export/out, AND export/out IS SHARED. - // resolveOutDir ignores the site id — the basic build composes every site into - // the same directory — so without this, building jeralyzer and then deploying - // anilyzer put jeralyzer's bundle on anilyzer's Pages project, in production, - // with a green log. The bundle says who it is, in its own site.json. - // - // Refused rather than rebuilt: "deploy the export" is the operator saying - // ship what is there, and quietly building something else would be a far - // bigger surprise than a sentence naming the fix. Checked before the job, so - // the refusal is the action's answer rather than a failed job to go and read - // — and so a preview cannot waste a deploy learning it either. - const outDir = resolveOutDir(site.siteId, paths); - const builtProblem = builtSiteProblem(outDir, site.siteId); - if (builtProblem) return { ok: false, error: builtProblem }; - const builtPrivate = builtAudienceProblem(outDir); - if (builtPrivate) { - return { ok: false, error: `${builtPrivate}. Build ${site.siteId} again, then deploy.` }; - } - return runManagedFunction({ - kind: "deploy-export", - queueKey: DEPLOY_QUEUE, - paths, - // The refusals above answer before a job exists; deploySite checks them - // again when the job actually starts, and does the upload + deploy. - fn: (onLog, signal) => - deploySite(site.siteId, { paths, onLog, signal, previewBranch: branch }), - }); -} diff --git a/editor/app/sites/lib/homepageDeployActions.ts b/editor/app/sites/lib/homepageDeployActions.ts @@ -1,104 +0,0 @@ -"use server"; - -import { builtHomepageProblem } from "yt-dlp-transcript-common/lib/builtExport"; -import { previewBranchProblem } from "yt-dlp-transcript-common/lib/pagesDeploy"; -import { getPaths, type Paths } from "yt-dlp-transcript-common/lib/paths"; -import { - runManagedFunction, - type StreamActionResult, -} from "yt-dlp-transcript-common/jobs/streamCommand"; -import { - buildHomepage, - deployHomepage, - homepageOutDir, -} from "yt-dlp-transcript-common/publish/build"; - -// The homepage's build and deploy, as jobs — the `homepage` package, Archilyzer's -// own site, deployed to the constant Pages project `archilyzer` -// (HOMEPAGE_PAGES_PROJECT). The same three shapes as the hub's (hubActions.ts) -// and the same bodies `archilyzer build homepage` / `deploy homepage` run. -// -// The build writes homepage/out, not export/out, so it cannot race a site -// build for export/ — but `next build` is heavy, and the build queue is what -// serialises heavy builds. The deploys take the deploy queue. -const BUILD_QUEUE = "build"; -const DEPLOY_QUEUE = "deploy"; - -// Refusals a deploy can give BEFORE any job starts: a bad preview name and — -// for a deploy-only — no build in homepage/out. No project check: the project -// is a constant. deployHomepage checks both again when its job runs, because a -// queued deploy can start after the build it was going to ship is gone. -function deployRefusal( - paths: Paths, - previewBranch: string | undefined, - checkBuilt: boolean, -): string | null { - if (previewBranch !== undefined) { - const problem = previewBranchProblem(previewBranch); - if (problem) return problem; - } - if (checkBuilt) return builtHomepageProblem(homepageOutDir(paths)); - return null; -} - -export async function buildHomepageAction(): Promise<StreamActionResult> { - const paths = getPaths(); - return runManagedFunction({ - kind: "build-homepage", - queueKey: BUILD_QUEUE, - paths, - fn: async (onLog, signal) => { - const code = await buildHomepage({ paths, onLog, signal }); - if (signal.aborted) return; - if (code !== 0) throw new Error(`Homepage build failed (exit ${code})`); - }, - }); -} - -// Deploy the homepage already built in homepage/out: production (branch -// `main`), or with `opts.previewBranch` a Cloudflare Pages PREVIEW of the -// `archilyzer` project. -export async function deployHomepageAction(opts?: { - previewBranch?: string; -}): Promise<StreamActionResult> { - const paths = getPaths(); - const refusal = deployRefusal(paths, opts?.previewBranch, true); - if (refusal) return { ok: false, error: refusal }; - const branch = opts?.previewBranch?.trim(); - return runManagedFunction({ - kind: "deploy-homepage", - queueKey: DEPLOY_QUEUE, - paths, - fn: (onLog, signal) => - deployHomepage({ paths, onLog, signal, previewBranch: branch }), - }); -} - -// Build the homepage and, only if that succeeds, deploy it — one job, one log, -// one Cancel, on the deploy queue (as build-deploy-hub is for the hub). A failed -// build never reaches the deploy: homepage/out would still hold the PREVIOUS -// build, and deploying that after a failure would ship old news as new. -export async function buildAndDeployHomepageAction(opts?: { - previewBranch?: string; -}): Promise<StreamActionResult> { - const paths = getPaths(); - // Before the build: learning the preview name is wrong after a build wastes it. - const refusal = deployRefusal(paths, opts?.previewBranch, false); - if (refusal) return { ok: false, error: refusal }; - const branch = opts?.previewBranch?.trim(); - return runManagedFunction({ - kind: "build-deploy-homepage", - queueKey: DEPLOY_QUEUE, - paths, - fn: async (onLog, signal) => { - onLog("=== Build homepage ===\n"); - const code = await buildHomepage({ paths, onLog, signal }); - if (signal.aborted) return; - if (code !== 0) { - throw new Error(`Homepage build failed (exit ${code}) — not deploying.`); - } - onLog("\n=== Deploy homepage ===\n"); - await deployHomepage({ paths, onLog, signal, previewBranch: branch }); - }, - }); -} diff --git a/editor/app/sites/lib/hubActions.ts b/editor/app/sites/lib/hubActions.ts @@ -1,101 +0,0 @@ -"use server"; - -import { builtHubProblem } from "yt-dlp-transcript-common/lib/builtExport"; -import { getHomepageConfig } from "yt-dlp-transcript-common/lib/homepage"; -import { previewBranchProblem } from "yt-dlp-transcript-common/lib/pagesDeploy"; -import { getPaths, type Paths } from "yt-dlp-transcript-common/lib/paths"; -import { - runManagedFunction, - type StreamActionResult, -} from "yt-dlp-transcript-common/jobs/streamCommand"; -import { - buildHub, - deployHub, - hubProjectProblem, - resolveOutDir, -} from "yt-dlp-transcript-common/publish/build"; - -// The hub's build and deploy, as jobs. The hub is the export app built with -// INSTANCE_MODE=hub into the SAME export/out a site build uses, so its build -// takes the build queue (it would race a site build for export/) and its -// deploys take the deploy queue — the queues build-export and deploy-export -// use (buildAction.ts / deployAction.ts). -const BUILD_QUEUE = "build"; -const DEPLOY_QUEUE = "deploy"; - -// Refusals a deploy can give BEFORE any job starts: a bad preview name, no -// project (or the homepage's), and — for a deploy-only — no hub in export/out. -// deployHub checks them again when its job runs, because a queued deploy can -// start after a site build has replaced export/out. -function deployRefusal( - paths: Paths, - previewBranch: string | undefined, - checkBuilt: boolean, -): string | null { - if (previewBranch !== undefined) { - const problem = previewBranchProblem(previewBranch); - if (problem) return problem; - } - const projectProblem = hubProjectProblem(getHomepageConfig(paths).cloudflareProject); - if (projectProblem) return projectProblem; - if (checkBuilt) return builtHubProblem(resolveOutDir("", paths)); - return null; -} - -export async function buildHubAction(): Promise<StreamActionResult> { - const paths = getPaths(); - return runManagedFunction({ - kind: "build-hub", - queueKey: BUILD_QUEUE, - paths, - fn: async (onLog, signal) => { - const code = await buildHub({ paths, onLog, signal }); - if (signal.aborted) return; - if (code !== 0) throw new Error(`Hub build failed (exit ${code})`); - }, - }); -} - -// Deploy the hub already built in export/out. With `opts.previewBranch` it is -// a Cloudflare Pages PREVIEW of the hub's project. -export async function deployHubAction(opts?: { - previewBranch?: string; -}): Promise<StreamActionResult> { - const paths = getPaths(); - const refusal = deployRefusal(paths, opts?.previewBranch, true); - if (refusal) return { ok: false, error: refusal }; - const branch = opts?.previewBranch?.trim(); - return runManagedFunction({ - kind: "deploy-hub", - queueKey: DEPLOY_QUEUE, - paths, - fn: (onLog, signal) => deployHub({ paths, onLog, signal, previewBranch: branch }), - }); -} - -// Build the hub and, only if that succeeds, deploy it — one job, one log, one -// Cancel, on the deploy queue (as build-deploy is for a site). -export async function buildAndDeployHubAction(opts?: { - previewBranch?: string; -}): Promise<StreamActionResult> { - const paths = getPaths(); - // Before the build: learning the project is wrong after a build wastes it. - const refusal = deployRefusal(paths, opts?.previewBranch, false); - if (refusal) return { ok: false, error: refusal }; - const branch = opts?.previewBranch?.trim(); - return runManagedFunction({ - kind: "build-deploy-hub", - queueKey: DEPLOY_QUEUE, - paths, - fn: async (onLog, signal) => { - onLog("=== Build hub ===\n"); - const code = await buildHub({ paths, onLog, signal }); - if (signal.aborted) return; - if (code !== 0) { - throw new Error(`Hub build failed (exit ${code}) — not deploying.`); - } - onLog("\n=== Deploy hub ===\n"); - await deployHub({ paths, onLog, signal, previewBranch: branch }); - }, - }); -} diff --git a/editor/app/sites/lib/publishActions.ts b/editor/app/sites/lib/publishActions.ts @@ -0,0 +1,152 @@ +"use server"; + +// The publish surfaces' server actions (release 18 S4): every button on the +// /sites Publish panel, a site's Publish tab and the Pool's Build index, and +// the publish lane's runner controls on /operations/publish. +// +// Each returns the shape JobLane and StreamActionLog already take — `{ok, +// jobId, stream, done}` or `{ok: false, error, info?}` — plus `runId` and, +// for a preview, `previewUrl`. A run of several stages is followed in ONE +// console (publishRunStream.ts): the index update when a build needs it, the +// build, its deploy. The jobs are the `publish` queue's (publish/ +// publishStages.ts) and run one at a time; their order is on disk. +// +// What these do NOT do: build or deploy in the editor's process. Every stage +// is a child (`archilyzer stage …`), so a heavy build cannot starve the editor. + +import { revalidatePath } from "next/cache"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand"; +import { planPublishRun, type PublishPlan } from "yt-dlp-transcript-common/publish/publishPlan"; +import { + drainPublishRunner, + startPublishRunner, + startPublishRunnerBlockedReason, + stopPublishRunner, +} from "yt-dlp-transcript-common/publish/publishRunner"; +import { readPublishStatus } from "yt-dlp-transcript-common/publish/publishState"; +import { + enqueueRun, + indexPlan, + refusal, + skippedLines, + wantedPlan, + type DeployWhere, + type TargetAsk, +} from "./publishCore"; +import { followRun } from "./publishRunStream"; + +export type PublishActionResult = + | (Extract<StreamActionResult, { ok: true }> & { runId: string; previewUrl?: string }) + | { ok: false; error: string; info?: boolean }; + +function revalidatePublish(): void { + revalidatePath("/sites"); + revalidatePath("/sites/[siteId]/publish", "page"); + revalidatePath("/operations/publish"); +} + +// Enqueue `plan` and hand back one console over it. +async function runPlan(plan: PublishPlan, empty: string): Promise<PublishActionResult> { + const paths = getPaths(); + if (plan.steps.length === 0) { + const why = skippedLines(plan.skipped); + return { ok: false, info: true, error: why.length ? `${empty} ${why.join("; ")}` : empty }; + } + const run = await enqueueRun(paths, plan, { keepStreams: true }); + revalidatePublish(); + if (run.parts.length === 0) { + return { + ok: false, + error: run.refused.map((r) => r.error).join("; ") || "Nothing was enqueued.", + }; + } + const preface = [ + `[publish] run ${run.runId}: ${run.jobs.length} stage${run.jobs.length === 1 ? "" : "s"}`, + ...skippedLines(run.skipped), + ...run.refused.map((r) => `[publish] refused ${r.kind} ${r.target}: ${r.error}`), + ]; + const previewUrl = run.jobs.find((j) => j.previewUrl)?.previewUrl; + return { ...followRun(run.parts, preface), runId: run.runId, ...(previewUrl ? { previewUrl } : {}) }; +} + +async function runAsks(asks: TargetAsk[]): Promise<PublishActionResult> { + const paths = getPaths(); + for (const ask of asks) { + const why = await refusal(paths, ask); + if (why) return { ok: false, error: why }; + } + const status = await readPublishStatus(paths); + return runPlan(wantedPlan(status, asks, { withIndex: true, paths }), "Nothing to run."); +} + +/** Publish now: what the lane would run — the stale index, then each policy target. */ +export async function publishNowAction(): Promise<PublishActionResult> { + const status = await readPublishStatus(getPaths()); + return runPlan(planPublishRun(status, { deploys: "policy" }), "Nothing to publish: everything is current."); +} + +/** Build all stale: every stale site whatever its policy, no deploys. */ +export async function buildAllStaleAction(): Promise<PublishActionResult> { + const status = await readPublishStatus(getPaths()); + return runPlan(planPublishRun(status, { builds: "stale", deploys: "none" }), "Nothing to build: every site is current."); +} + +/** The Pool's Build index: the index update (a no-op when nothing changed). */ +export async function updateIndexAction(): Promise<PublishActionResult> { + return runPlan(indexPlan(), "Nothing to run."); +} + +/** Build one target (a site id, "_hub", "_homepage") — forced; the index first when stale. */ +export async function buildTargetAction( + target: string, + opts: { skipArchives?: boolean } = {}, +): Promise<PublishActionResult> { + return runAsks([{ target, build: { force: true, skipArchives: opts.skipArchives } }]); +} + +/** Deploy what is built: production, a preview branch, or the local service. */ +export async function deployTargetAction( + target: string, + opts: { where: DeployWhere; preview?: string }, +): Promise<PublishActionResult> { + return runAsks([{ target, deploy: { where: opts.where, preview: opts.preview, force: true } }]); +} + +/** Build, then deploy that build (the deploy waits on disk for it). */ +export async function buildAndDeployTargetAction( + target: string, + opts: { where: DeployWhere; preview?: string; skipArchives?: boolean }, +): Promise<PublishActionResult> { + return runAsks([ + { + target, + build: { force: true, skipArchives: opts.skipArchives }, + deploy: { where: opts.where, preview: opts.preview, force: true }, + }, + ]); +} + +// --- the publish lane's runner (/operations/publish) ------------------------- + +export type LaneControlResult = { ok: boolean; error?: string }; + +export async function startPublishLaneAction(): Promise<LaneControlResult> { + const blocked = startPublishRunnerBlockedReason(); + if (blocked) return { ok: false, error: blocked }; + const id = await startPublishRunner(getPaths()); + revalidatePublish(); + return id ? { ok: true } : { ok: false, error: "The publish lane's runner did not start." }; +} + +export async function stopPublishLaneAction(): Promise<LaneControlResult> { + stopPublishRunner(); + revalidatePublish(); + return { ok: true }; +} + +export async function drainPublishLaneAction(): Promise<LaneControlResult> { + drainPublishRunner(); + revalidatePublish(); + return { ok: true }; +} diff --git a/editor/app/sites/lib/publishCore.ts b/editor/app/sites/lib/publishCore.ts @@ -0,0 +1,202 @@ +// THE SURFACES' ONE WAY INTO THE PUBLISH STAGES (release 18 S4). +// +// The /sites Publish panel, a site's Publish tab, the Pool's Build index and +// the ops route (`POST /api/ops/publish` and its old aliases) all ask for +// stages here, as a plan (publish/publishPlan.ts's shape), and all enqueue it +// through `enqueuePublishRun` (publish/publishStages.ts) under one run id — +// so a click and an HTTP caller get the same jobs and the same refusals. +// +// wantedPlan(...) what a button asks for → a plan: the index update first +// when the index is not fresh (a build of stale data ships +// old data — the data phase every build used to run), a +// build `--force` (a manual build always builds), a +// deploy carrying `builtAfter` when its build is in the run +// refusal(...) the sentence a request is refused with BEFORE any job +// exists: the deploy stage's own (resolveDeployRequest), +// word for word, and a preview name's +// enqueueRun(...) the plan as jobs; with `keepStreams` the jobs' streams +// are kept for one console (publishRunStream.ts) +// +// Not a "use server" module (it exports types and sync helpers). + +import { getHomepageConfig } from "yt-dlp-transcript-common/lib/homepage"; +import { previewAliasUrl, previewBranchProblem } from "yt-dlp-transcript-common/lib/pagesDeploy"; +import { getPaths, type Paths } from "yt-dlp-transcript-common/lib/paths"; +import { getSite, isValidSiteId, listSiteIds } from "yt-dlp-transcript-common/lib/site"; +import type { JobDoneResult } from "yt-dlp-transcript-common/jobs/streamCommand"; +import { HOMEPAGE_PAGES_PROJECT } from "yt-dlp-transcript-common/publish/build"; +import { resolveDeployRequest } from "yt-dlp-transcript-common/publish/deployStage"; +import type { PlanSkip, PlanStep, PublishPlan, PublishStatus } from "yt-dlp-transcript-common/publish/publishPlan"; +import { + enqueuePublishRun, + enqueueStage, + newPublishRunId, + type PublishRunResult, +} from "yt-dlp-transcript-common/publish/publishStages"; +import { readPublishStatus } from "yt-dlp-transcript-common/publish/publishState"; +import { STAGES, type StageKind } from "yt-dlp-transcript-common/publish/stages"; +import { HOMEPAGE_TARGET, HUB_TARGET, INDEX_TARGET } from "yt-dlp-transcript-common/publish/stamps"; +import type { RunPart } from "./publishRunStream"; + +export type DeployWhere = "production" | "preview" | "local"; + +export type TargetAsk = { + // A site id, "_hub" or "_homepage". + target: string; + build?: { force?: boolean; skipArchives?: boolean }; + deploy?: { where: DeployWhere; preview?: string; force?: boolean }; +}; + +/** The build and deploy stage kinds of a target. */ +export function stageKindsOf(target: string): { build: StageKind; deploy: StageKind } { + if (target === HUB_TARGET) return { build: "build-hub", deploy: "deploy-hub" }; + if (target === HOMEPAGE_TARGET) return { build: "build-homepage", deploy: "deploy-homepage" }; + return { build: "build-site", deploy: "deploy-site" }; +} + +/** "Build site jeralyzer", "Deploy hub", "Update the index". */ +export function stepLabel(s: { kind: StageKind; target: string }): string { + const label = STAGES[s.kind].label; + return s.target.startsWith("_") ? label : `${label} ${s.target}`; +} + +/** The Pages project a target deploys to, or null (none configured). */ +export function projectOf(paths: Paths, target: string): string | null { + if (target === HUB_TARGET) return getHomepageConfig(paths).cloudflareProject?.trim() || null; + if (target === HOMEPAGE_TARGET) return HOMEPAGE_PAGES_PROJECT; + try { + return getSite(target, paths).cloudflareProject?.trim() || null; + } catch { + return null; + } +} + +/** A target that is not one: an unknown or invalid site id. */ +export function targetProblem(paths: Paths, target: string): string | null { + if (target === HUB_TARGET || target === HOMEPAGE_TARGET) return null; + if (!isValidSiteId(target)) return `"${target}" is not a valid site id`; + // A missing site.json reads as every default (getSite never throws past the + // id): an unknown id is refused here, in the stages' words. + return listSiteIds(paths).includes(target) ? null : `no site "${target}"`; +} + +/** + * Why `ask` is refused before any job exists, or null. A deploy is asked + * through the deploy stage's own resolver (the same sentences the stage would + * end on); when the run builds first, "no build of X" is not a refusal. + */ +export async function refusal(paths: Paths, ask: TargetAsk): Promise<string | null> { + const bad = targetProblem(paths, ask.target); + if (bad) return bad; + if (!ask.deploy) return null; + if (ask.deploy.preview !== undefined) { + const p = previewBranchProblem(ask.deploy.preview); + if (p) return p; + } + const r = await resolveDeployRequest(paths, { + kind: stageKindsOf(ask.target).deploy as "deploy-site" | "deploy-hub" | "deploy-homepage", + target: ask.target, + preview: ask.deploy.where === "preview" ? ask.deploy.preview : undefined, + to: ask.deploy.where === "local" ? "local" : "pages", + }); + if (!("problem" in r)) return null; + if (ask.build && r.problem.startsWith("no build of ")) return null; + return r.problem; +} + +/** + * What the asks enqueue, in order: the index update when `withIndex` says so + * and the index is not fresh; then per ask its build (forced) and its deploy. + */ +export function wantedPlan( + status: Pick<PublishStatus, "index">, + asks: TargetAsk[], + opts: { withIndex: boolean; runStart?: number; paths?: Paths } = { withIndex: true }, +): PublishPlan { + const runStart = opts.runStart ?? Date.now(); + const paths = opts.paths ?? getPaths(); + const steps: PlanStep[] = []; + const indexStep = opts.withIndex && asks.some((a) => a.build) && !status.index.fresh; + if (indexStep) { + steps.push({ + kind: "update-index", + target: INDEX_TARGET, + reason: status.index.freshness.state === "fresh" ? "the index is stale" : status.index.freshness.reason, + }); + } + for (const ask of asks) { + const kinds = stageKindsOf(ask.target); + if (ask.build) { + steps.push({ + kind: kinds.build, + target: ask.target, + reason: "asked", + ...(ask.build.force !== false ? { force: true } : {}), + ...(ask.build.skipArchives ? { skipArchives: true } : {}), + ...(indexStep ? { indexAfter: runStart } : {}), + }); + } + if (ask.deploy) { + const preview = ask.deploy.where === "preview" ? ask.deploy.preview?.trim() : undefined; + const project = preview ? projectOf(paths, ask.target) : null; + steps.push({ + kind: kinds.deploy, + target: ask.target, + reason: "asked", + ...(preview ? { preview } : {}), + ...(ask.deploy.where === "local" ? { to: "local" as const } : {}), + ...(ask.deploy.force ? { force: true } : {}), + ...(ask.build ? { builtAfter: runStart } : {}), + ...(preview && project ? { previewUrl: previewAliasUrl(project, preview) } : {}), + }); + } + } + return { runStart, steps, skipped: [] }; +} + +/** The plan for the Pool's Build index: the index update, always (a no-op when fresh). */ +export function indexPlan(runStart = Date.now()): PublishPlan { + return { runStart, steps: [{ kind: "update-index", target: INDEX_TARGET, reason: "asked" }], skipped: [] }; +} + +export type EnqueuedRun = PublishRunResult & { + // With `keepStreams`: each enqueued job's stream, in plan order. + parts: RunPart[]; +}; + +/** + * Enqueue a plan under one run id. With `keepStreams` every new job's stream + * is teed before enqueuePublishRun releases it, so one console can follow the + * run (publishRunStream.ts followRun). + */ +export async function enqueueRun( + paths: Paths, + plan: PublishPlan, + opts: { keepStreams?: boolean; runId?: string } = {}, +): Promise<EnqueuedRun> { + const kept = new Map<string, { stream: ReadableStream<string>; done: Promise<JobDoneResult> }>(); + const enqueue: typeof enqueueStage = async (p, req, o) => { + const res = await enqueueStage(p, req, o); + if (!res.ok || !opts.keepStreams) return res; + const [released, keep] = res.stream.tee(); + kept.set(res.jobId, { stream: keep, done: res.done }); + return { ...res, stream: released }; + }; + const run = await enqueuePublishRun(paths, plan, { runId: opts.runId ?? newPublishRunId(), enqueue }); + const parts: RunPart[] = run.jobs.map((j) => ({ + label: stepLabel(j), + jobId: j.jobId, + ...(kept.get(j.jobId) ?? {}), + })); + return { ...run, parts }; +} + +/** One line per skipped target of a plan ("skipped hub: its policy is off"). */ +export function skippedLines(skipped: PlanSkip[]): string[] { + return skipped.map((s) => `[publish] skipped ${stepLabel(s)}: ${s.reason}`); +} + +/** The status every surface reads (re-exported for the server pages). */ +export async function publishStatus(paths: Paths = getPaths()): Promise<PublishStatus> { + return readPublishStatus(paths); +} diff --git a/editor/app/sites/lib/publishRunStream.ts b/editor/app/sites/lib/publishRunStream.ts @@ -0,0 +1,83 @@ +// ONE CONSOLE FOR A PUBLISH RUN (release 18 S4). A run is several jobs on the +// `publish` queue — the index update, a build, its deploy — each with its own +// log; the /sites consoles (JobLane, StreamActionLog) follow ONE stream. This +// joins the run's streams in order, each under a `=== <stage> <target> ===` +// line, and settles when the last one does: the run's verdict is its first +// job that did not end `done`, else `done`. +// +// Nothing here chains one job to another — the order is on disk (a build +// carries `indexAfter`, a deploy `builtAfter`; publishStages.ts). A deploy +// whose build failed ends `failed` itself, with the sentence in its log. +// +// Not a "use server" module: a server action file may only export async +// functions, and these are plain helpers the actions call. + +import type { JobDoneResult, StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand"; + +export type RunPart = { + // "Build site jeralyzer" — the stage label and its target. + label: string; + jobId: string; + // Absent for a stage that was already queued (its own console has it). + stream?: ReadableStream<string>; + done?: Promise<JobDoneResult>; +}; + +/** The parts as one stream + one verdict (the first job's id is the run's). */ +export function followRun( + parts: RunPart[], + preface: string[] = [], +): Extract<StreamActionResult, { ok: true }> { + const first = parts[0]; + let resolveDone!: (r: JobDoneResult) => void; + const done = new Promise<JobDoneResult>((r) => { + resolveDone = r; + }); + const stream = new ReadableStream<string>({ + async start(c) { + let closed = false; + const push = (text: string) => { + if (closed) return; + try { + c.enqueue(text); + } catch { + closed = true; + } + }; + for (const line of preface) push(line.endsWith("\n") ? line : `${line}\n`); + let verdict: JobDoneResult | null = null; + for (const [i, part] of parts.entries()) { + if (parts.length > 1) push(`${i === 0 ? "" : "\n"}=== ${part.label} (job ${part.jobId}) ===\n`); + if (!part.stream) { + push(`[publish] ${part.label} was already queued — its log is on /jobs/${part.jobId}\n`); + continue; + } + const reader = part.stream.getReader(); + try { + while (true) { + const { value, done: end } = await reader.read(); + if (end) break; + if (value) push(value); + } + } catch { + /* the job's stream tore down; its log is on disk */ + } + const term = part.done ? await part.done : null; + if (term && term.status !== "done" && verdict === null) verdict = term; + } + resolveDone(verdict ?? { status: "done", jobId: first.jobId }); + if (!closed) { + try { + c.close(); + } catch { + /* already closed */ + } + } + }, + cancel() { + // The consumer left (navigation): the jobs run on, their logs on disk. + for (const p of parts) void p.stream?.cancel().catch(() => {}); + }, + }); + return { ok: true, jobId: first.jobId, stream, done }; +} diff --git a/editor/app/sites/page.tsx b/editor/app/sites/page.tsx @@ -8,38 +8,28 @@ import { suggestNextVersion, } from "yt-dlp-transcript-common/lib/changelog"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; -import { builtHomepageAt } from "yt-dlp-transcript-common/lib/builtExport"; import { getHomepageConfig } from "yt-dlp-transcript-common/lib/homepage"; import { listSites } from "yt-dlp-transcript-common/lib/site"; import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; -import { - HOMEPAGE_PAGES_PROJECT, - homepageOutDir, -} from "yt-dlp-transcript-common/publish/build"; +import type { PublishTargetStatus } from "yt-dlp-transcript-common/views/publishStatus"; +import { readPublishStatus } from "yt-dlp-transcript-common/publish/publishState"; import { liveJobRows } from "../jobs/active/buildActiveJobs"; import { RunningJobsList } from "../jobs/components/RunningJobsList"; -import { BuildAllSitesButton } from "./components/BuildAllSitesButton"; import { BuildButtons } from "./components/BuildButtons"; -import { BuildSitesPanel } from "./components/BuildSitesPanel"; import { CutReleaseForm } from "./components/CutReleaseForm"; -import { HomepageBuildButtons } from "./components/HomepageBuildButtons"; import { HomepageConfigForm } from "./components/HomepageConfigForm"; -import { HubBuildButtons } from "./components/HubBuildButtons"; +import { PublishPanel, type PanelRow } from "./components/PublishPanel"; import { DeleteSiteButton, MigrateButton } from "./components/SiteListActions"; export const dynamic = "force-dynamic"; export const metadata: Metadata = { title: "Sites" }; -// Which live jobs the Pool section lists: the eight kinds the Pool's own -// buttons enqueue (BuildButtons.tsx → sites/lib/buildAction.ts), and only -// those. build-export and build-deploy are a site's Publish tab's, build-all -// and build-deploy-all are the batch panel's above, build-hub / deploy-hub / -// build-deploy-hub the Hub section's, build-homepage / deploy-homepage / -// build-deploy-homepage the Homepage section's — each has its own console and -// is not repeated here. +// Which live jobs the Pool section lists: the kinds the Pool's own buttons +// enqueue (BuildButtons.tsx), and only those — the index update included +// (Build index is a `publish-update-index` stage now, release 18). Every other +// publish stage has its console on its row of the Publish panel. const BUILD_KINDS = new Set([ - "build-index", - "build-stats", + "publish-update-index", "normalize-transcripts", "archive-transcripts", "archive-combined-transcripts", @@ -80,7 +70,7 @@ export default async function SitesPage() { // (it used to drop `progress`, `tasks`, `drainable` and the reorder bounds). const activeJobs = await liveJobRows((j) => BUILD_KINDS.has(j.kind)); const hubConfig = getHomepageConfig(paths); - const homepageBuiltAt = builtHomepageAt(homepageOutDir(paths)); + const status = await readPublishStatus(paths); return ( <div className="flex flex-col gap-8"> @@ -157,77 +147,55 @@ export default async function SitesPage() { /> </section> - {/* Batch: build (and optionally deploy) several sites at once. Build all - fans out in containers whenever an engine answers; there is no mode - switch (dropped 2026-09-28, O6c). */} + {/* Publish: every site, the hub and the homepage as stages (release + 18) — the index update shared by all of them, one build and one + deploy at a time on the publish queue. Replaces "Build all sites" + and the hub's and the homepage's own build sections. */} <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 in containers (when a container engine - answers), then deploys run serially. - </p> - </div> - <BuildAllSitesButton /> - - <div className="mt-4"> - <h3 className="font-semibold">Or pick specific sites</h3> + <h2 className="text-lg font-semibold">Publish</h2> <p className="text-sm text-muted-foreground"> - Build (and optionally deploy) selected sites, each in its own live log - lane. + The index is updated once and every site, the hub and the homepage + are built from it, each into its own bundle; deploys ship that + bundle and check the live site. A site&rsquo;s policy (its + settings: off, build, preview, production) is what Publish now and + the{" "} + <Link href="/operations/publish" className="underline"> + publish lane + </Link>{" "} + do for it; the buttons on a row always run. </p> </div> - <BuildSitesPanel - sites={sites.map((s) => ({ - siteId: s.siteId, - siteTitle: s.siteTitle, - cloudflareProject: s.cloudflareProject ?? null, - }))} + <PublishPanel + rows={[...status.sites, status.hub, status.homepage].map(panelRow)} + index={status.index.chip} + lane={status.lane.chip} + plan={{ + steps: status.plan.steps.map((x) => ({ kind: x.kind, target: x.target, reason: x.reason })), + skipped: status.plan.skipped, + }} + previewBranch={status.settings.previewBranch} + siteOut={Boolean(process.env.ARCHILYZER_SITE_OUT?.trim())} + homepageOut={Boolean(process.env.ARCHILYZER_HOMEPAGE_OUT?.trim())} /> </section> {/* The family's own hub — a site of sorts, but never a [siteId]: its - config lives in sites/_homepage, which isValidSiteId rejects. */} + config lives in sites/_homepage, which isValidSiteId rejects. Its + build and deploy are its row of the Publish panel above. */} <section className="flex flex-col gap-3 border-t border-border pt-6"> <div> - <h2 className="text-lg font-semibold">Hub</h2> + <h2 className="text-lg font-semibold">Hub config</h2> <p className="mt-1 text-sm text-muted-foreground"> The hub is the export app built in hub mode: one search over every site that has a public URL, reading each archive where it is published. This config names and brands it, and its Public URL and Cloudflare Pages project are the hub&rsquo;s own (for example{" "} - <code>archilyzer-hub</code>). + <code>archilyzer-hub</code>). The homepage (Archilyzer&rsquo;s own + site) shares its social links. </p> </div> <HomepageConfigForm config={hubConfig} /> - <HubBuildButtons project={hubConfig.cloudflareProject ?? null} /> - </section> - - {/* The software's own site: the `homepage` package, never the hub. It - has no config of its own here — it shares the hub form's social - links — so the section is only its build and deploy. */} - <section className="flex flex-col gap-3 border-t border-border pt-6"> - <div> - <h2 className="text-lg font-semibold">Homepage</h2> - <p className="mt-1 text-sm text-muted-foreground"> - The <code>homepage</code> package is Archilyzer&rsquo;s own site, - with the docs and the source download. It shares the Hub - form&rsquo;s social links and links to the hub, but it builds into{" "} - <code>homepage/out</code> and deploys to its own Pages project,{" "} - <code>{HOMEPAGE_PAGES_PROJECT}</code>. The buttons run the same code - as <code>archilyzer build homepage</code> and{" "} - <code>archilyzer deploy homepage</code>, as jobs. - </p> - </div> - <HomepageBuildButtons - project={HOMEPAGE_PAGES_PROJECT} - builtAt={ - homepageBuiltAt === null - ? null - : new Date(homepageBuiltAt).toLocaleString() - } - /> </section> {/* The shared pool: corpus-wide, no site involved. */} @@ -256,3 +224,20 @@ export default async function SitesPage() { </div> ); } + +// The panel's row: what the client needs of a target's status, and no more. +function panelRow(t: PublishTargetStatus): PanelRow { + return { + target: t.target, + kind: t.kind, + title: t.title, + chips: t.chips, + policy: t.policy, + next: t.next, + cloudflareProject: t.cloudflareProject, + deployProblem: t.deployProblem ?? null, + previewUrl: t.previewUrl ?? null, + builtAt: t.built?.builtAt ?? null, + url: t.url, + }; +}