Archilyzer · Source

archilyzer

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

commit b4322ab1cd3b4f67e0ed1ec7a4d4ccec5a1c2218
parent 2baea15e823029add6e25dc281eaaba8575c09c9
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat, 10 Oct 2026 02:39:16 -0400

publish: the ops build verb takes allowMissingMedia; `publish build <id> --out <dir>` (release 19 A8)

POST /api/ops/publish {verb: "build", allowMissingMedia: true} carries the
flag onto the queued build stage (`--allow-missing-media`, as `build site`
already could), local builds only — the docker runner refuses it.
`archilyzer publish build` takes --allow-missing-media too, and --out <dir>:
after the build (or the no-op of a fresh one) the site's bundle is laid out
in <dir> for a server that is not Pages — hard links into the bundle where
the filesystem allows (a build installs a new bundle, never edits one), else
copies; the destination is emptied first and refused, before any build,
where a local deploy's would be. Not a deploy: nothing recorded. The publish
lane over `pnpm ops lane` shipped in A4.

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

Diffstat:
MCOMMANDS.md | 4++--
MOPERATING.md | 6++++++
Mcommon/bin/_cli.test.ts | 9++++++++-
Mcommon/bin/archilyzer.ts | 16++++++++++++++--
Acommon/bin/publish-out.test.ts | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/bin/publish.ts | 82+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/api/ops/publish/route.test.ts | 27+++++++++++++++++++++++++++
Meditor/app/api/ops/publish/route.ts | 33++++++++++++++++++++++++++++-----
Meditor/app/sites/lib/publishCore.ts | 6+++++-
Mscripts/archilyzer-ops.mjs | 4+++-
10 files changed, 272 insertions(+), 12 deletions(-)

diff --git a/COMMANDS.md b/COMMANDS.md @@ -18,7 +18,7 @@ Run from the repo root (in the container: `docker compose exec editor pnpm archi | `archilyzer compose hub` | | compose the hub's export/public (hub-sites.json, corpus.json, …) | | `archilyzer compose homepage` | | compose homepage/public (whole-pool stats + landing summary) | | `archilyzer publish index` | | update the index: the LMDB index, the stats datasets and the chart templates in one child (8 GB heap), then the index stamp every build reads | -| `archilyzer publish build` | `<id\|all> [--runner local\|docker\|auto] [--force] [--skip-archives]` | build a site (or every stale one) into its bundle &lt;exportBuildsDir&gt;/&lt;id&gt;/out from the current index; --runner docker builds every site in containers (host only); a fresh site is a no-op without --force | +| `archilyzer publish build` | `<id\|all> [--runner local\|docker\|auto] [--force] [--skip-archives] [--allow-missing-media] [--out <dir>]` | build a site (or every stale one) into its bundle &lt;exportBuildsDir&gt;/&lt;id&gt;/out from the current index; --runner docker builds every site in containers (host only); a fresh site is a no-op without --force; --allow-missing-media lets a report citation with no prepared media through compose; --out &lt;dir&gt; then lays the site's bundle out in &lt;dir&gt; for a server that is not Pages (hard links where it can, else copies; emptied first and guarded as a local deploy's destination; not a deploy, nothing recorded) | | `archilyzer publish deploy` | `<id\|all> [--preview <branch>] [--to local] [--force]` | ship a site's bundle to its Pages project (a preview with --preview), or with --to local into ARCHILYZER\_SITE\_OUT; a bundle already deployed there is a no-op without --force | | `archilyzer publish hub` | `[--deploy \| --deploy-only] [--preview <branch>] [--force]` | build the hub into its bundle &lt;exportBuildsDir&gt;/\_hub/out, then (--deploy) ship it; --deploy-only ships the bundle as built | | `archilyzer publish homepage` | `[--deploy \| --deploy-only] [--preview <branch>] [--to local] [--force]` | build homepage/out (source mirror included), then (--deploy) ship it; --deploy-only ships it as built | @@ -99,7 +99,7 @@ Drives a running editor over HTTP (`/api/ops/*`, the same actions its pages run) | `transcribe-bucket` | transcribe-bucket transcribes a channel's "downloaded, not transcribed" bucket on the transcription queue, as the channel page's Transcribe button does: {"slug"}. "ids": \[...\] narrows it the same way as on retry-bucket. | | | `fetch-posts` | fetch-posts fetches a social channel's new posts: {"slug"}. "full": true re-walks the whole timeline; "older": true walks back from the oldest archived post through search (X; needs a login), saving its place for the next run, down to "floor": "YYYY-MM-DD" when given; "from": "YYYY-MM-DD" starts the walk afresh there, replacing its saved place (and a "complete") — for a gap above one surviving old post. "limit": N caps the posts one run reads; "pages": N caps the pages (a forum thread: its latest N pages). "full" and "older" together are refused. An older walk over an account that shows no posts (nothing archived, and the last timeline fetch read none) is refused unless "force": true. A drained fetch stops at its next resume point and the next run resumes. | | | `capture-posts` | capture-posts captures archived posts of a social channel (X, forum): a screenshot of each through the connected X profile (a forum thread: its host's forum profile), and its attached media through gallery-dl (a forum thread: the same profile), into the channel's posts-media/&lt;id&gt;/: {"slug", "ids": \[...\]}. Every id must be in the channel's posts archive. "shots": false or "media": false skips that half; posts already captured are skipped unless "force": true. A post that links to an X Article also gets the article (article.json, .md, .png and its images) unless "articles": false; both halves off with "articles": true reads only the articles. Paced like a post fetch, on its queue. | | -| `publish` | publish runs publish stages on the editor's publish queue, one at a time, under one run id: {"verb": …}. "index" updates the index; "build" builds "siteId"/"siteIds" (forced; the index first when stale; "runner": "docker" builds every site in containers); "deploy" ships their built bundles (production, "preview": "&lt;branch&gt;", or "to": "local"; "force" redeploys a bundle already shipped there); "hub" / "homepage" build them, {"deploy": true} deploys after; "now" is Publish now (the stale index, then each policy target); "stale" builds every stale site. The answer lists every job ({target, kind, jobId}) and --wait follows them all. A site never built is refused: "no build of &lt;id&gt; in &lt;dir&gt; — archilyzer publish build &lt;id&gt;". `get publish` is the status. | | +| `publish` | publish runs publish stages on the editor's publish queue, one at a time, under one run id: {"verb": …}. "index" updates the index; "build" builds "siteId"/"siteIds" (forced; the index first when stale; "runner": "docker" builds every site in containers; "allowMissingMedia": true lets a report citation with no prepared media through compose, local builds only); "deploy" ships their built bundles (production, "preview": "&lt;branch&gt;", or "to": "local"; "force" redeploys a bundle already shipped there); "hub" / "homepage" build them, {"deploy": true} deploys after; "now" is Publish now (the stale index, then each policy target); "stale" builds every stale site. The answer lists every job ({target, kind, jobId}) and --wait follows them all. A site never built is refused: "no build of &lt;id&gt; in &lt;dir&gt; — archilyzer publish build &lt;id&gt;". `get publish` is the status. | | | `build-index`, `build-site`, `build-deploy`, `deploy-site`, `build-hub`, `deploy-hub`, `build-homepage`, `deploy-homepage` | build-index, build-site, build-deploy, deploy-site, build-hub, deploy-hub, build-homepage and deploy-homepage are publish's aliases, with their old bodies and answers ("skipData" is accepted and ignored). | `pnpm ops build-deploy --json '{"siteIds":["anilyzer","jeralyzer"]}' --wait`<br>`pnpm ops build-site --json '{"siteId":"anilyzer"}' --wait`<br>`pnpm ops deploy-site --json '{"siteId":"anilyzer","preview":"tags-exclude"}' --wait`<br>`pnpm ops build-hub --wait`<br>`pnpm ops build-hub --json '{"deploy":true}' --wait`<br>`pnpm ops deploy-hub --wait`<br>`pnpm ops build-homepage --json '{"deploy":true}' --wait`<br>`pnpm ops deploy-homepage --json '{"preview":"refresh"}' --wait` | | `build-site`, `build-deploy`, `deploy-site` | build-site, build-deploy and deploy-site all take "siteId" (one) or "siteIds" (a list). | | | `build-hub` | build-hub builds the hub into its bundle; {"deploy": true} deploys it after, and deploy-hub ships the one already built. Both deploy to the Pages project set on /sites under Hub, and take "preview" too. | | diff --git a/OPERATING.md b/OPERATING.md @@ -156,11 +156,17 @@ pnpm ops publish --json '{"verb":"now"}' --wait pnpm ops publish --json '{"verb":"build","siteId":"example-site"}' --wait pnpm ops publish --json '{"verb":"deploy","siteId":"example-site","preview":"check"}' --wait pnpm archilyzer publish status +pnpm archilyzer publish build example-site --out /srv/example-private ``` - Publishing is stages on one queue: update the index, build a site into its bundle, deploy the bundle, each checked live. `now` runs what Publish now runs (the stale index, then each site's policy). Offline, under the same lock: `pnpm archilyzer publish index`, `publish build <id>`, `publish deploy <id> --preview <branch>`. +- A private build served by something other than Pages: `publish build <id> --out <dir>` lays the bundle out in + `<dir>` (hard links where it can; the directory is emptied first and guarded like a local deploy's). A report + citation whose media is not prepared yet passes with `--allow-missing-media` (`"allowMissingMedia": true` on the + ops `build` verb). +- The publish lane: `pnpm ops lane --json '{"lane":"publish","action":"start"}'` (`"held": true` pauses it). - Details, policies and the hub and homepage: [PUBLISH.md](PUBLISH.md). ## Research with the MCP diff --git a/common/bin/_cli.test.ts b/common/bin/_cli.test.ts @@ -436,7 +436,14 @@ test("the stage row: kind and target, the flags the child's argv carries, nothin test("the publish rows and their flags (status and now are S3's)", () => { const row = (...p: string[]) => resolveCommand(COMMANDS, p)?.command; assert.deepEqual(row("publish", "index")?.flags ?? {}, {}); - assert.deepEqual(row("publish", "build", "jer")?.flags, { runner: "string", force: "boolean", "skip-archives": "boolean" }); + assert.deepEqual(row("publish", "build", "jer")?.flags, { + runner: "string", + force: "boolean", + "skip-archives": "boolean", + // release 19 A8 + "allow-missing-media": "boolean", + out: "string", + }); assert.equal(row("publish", "build", "all")?.maxPositionals, 1); assert.deepEqual(row("publish", "deploy", "all")?.flags, { preview: "string", to: "string", force: "boolean" }); assert.deepEqual(row("publish", "hub")?.flags, { diff --git a/common/bin/archilyzer.ts b/common/bin/archilyzer.ts @@ -86,8 +86,14 @@ export const COMMANDS: Command[] = [ { path: ["publish", "build"], usage: - "<id|all> [--runner local|docker|auto] [--force] [--skip-archives] build a site (or every stale one) into its bundle <exportBuildsDir>/<id>/out from the current index; --runner docker builds every site in containers (host only); a fresh site is a no-op without --force", - flags: { runner: "string", force: "boolean", "skip-archives": "boolean" }, + "<id|all> [--runner local|docker|auto] [--force] [--skip-archives] [--allow-missing-media] [--out <dir>] build a site (or every stale one) into its bundle <exportBuildsDir>/<id>/out from the current index; --runner docker builds every site in containers (host only); a fresh site is a no-op without --force; --allow-missing-media lets a report citation with no prepared media through compose; --out <dir> then lays the site's bundle out in <dir> for a server that is not Pages (hard links where it can, else copies; emptied first and guarded as a local deploy's destination; not a deploy, nothing recorded)", + flags: { + runner: "string", + force: "boolean", + "skip-archives": "boolean", + "allow-missing-media": "boolean", + out: "string", + }, maxPositionals: 1, run: async ({ positionals, flags }) => { const [target] = positionals; @@ -96,11 +102,17 @@ export const COMMANDS: Command[] = [ console.error("publish build: give <id|all> [--runner local|docker|auto]"); return 2; } + if (flags.out !== undefined && (typeof flags.out !== "string" || !flags.out.trim())) { + console.error("publish build: --out needs a directory"); + return 2; + } return (await import("./publish")).publishBuild({ target, runner: runner as "local" | "docker" | "auto" | undefined, force: flags.force === true, skipArchives: flags["skip-archives"] === true, + allowMissingMedia: flags["allow-missing-media"] === true, + ...(typeof flags.out === "string" ? { out: flags.out } : {}), }); }, }, diff --git a/common/bin/publish-out.test.ts b/common/bin/publish-out.test.ts @@ -0,0 +1,97 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, readFileSync, statSync, symlinkSync, writeFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +// Run with: pnpm --filter yt-dlp-transcript-common exec tsx --test bin/publish-out.test.ts +// +// `archilyzer publish build <id> --out <dir>` (release 19 A8): the row's +// flags, the refusals that come before any build, and the lay-out itself over +// a hand-made bundle — hard links into it, its symlinks kept, the destination +// emptied, nothing recorded. The build stage is the stage tests'. + +const ROOT = mkdtempSync(path.join(os.tmpdir(), "publish-out-")); +process.env.TRANSCRIPTS_DIR = path.join(ROOT, "transcripts"); +process.env.EXPORT_BUILDS_DIR = path.join(ROOT, "builds"); +mkdirSync(path.join(ROOT, "transcripts", "sites", "demo-site"), { recursive: true }); +writeFileSync(path.join(ROOT, "transcripts", "sites", "demo-site", "site.json"), JSON.stringify({ siteId: "demo-site" })); + +const { COMMANDS } = await import("./archilyzer"); +const { resolveCommand } = await import("./_cli"); +const { layOutBundle, publishBuild } = await import("./publish"); +const { getPaths } = await import("../lib/paths"); +const { writeBuiltStamp } = await import("../publish/stamps"); + +function capture() { + const lines: string[] = []; + const errors: string[] = []; + return { out: { log: (s: string) => lines.push(s), error: (s: string) => errors.push(s) }, lines, errors }; +} + +test("the publish build row takes --allow-missing-media and --out <dir>", () => { + const row = resolveCommand(COMMANDS, ["publish", "build"])?.command; + assert.equal(row?.flags?.["allow-missing-media"], "boolean"); + assert.equal(row?.flags?.out, "string"); +}); + +test("--out with all, an unknown site, or a guarded destination is refused before any build", async () => { + const paths = getPaths(); + const all = capture(); + assert.equal(await publishBuild({ target: "all", out: path.join(ROOT, "x"), paths }, all.out), 2); + assert.match(all.errors.join(""), /--out lays out ONE site's bundle/); + const unknown = capture(); + assert.equal(await publishBuild({ target: "nope", out: path.join(ROOT, "x"), paths }, unknown.out), 2); + assert.match(unknown.errors.join(""), /no site "nope"/); + const guarded = capture(); + assert.equal(await publishBuild({ target: "demo-site", out: paths.transcriptsDir, paths }, guarded.out), 2); + assert.match(guarded.errors.join(""), /--out .* holds the corpus .* --out empties its destination/); + const notBundle = path.join(ROOT, "busy"); + mkdirSync(notBundle); + writeFileSync(path.join(notBundle, "keep.txt"), "x"); + const busy = capture(); + assert.equal(await publishBuild({ target: "demo-site", out: notBundle, paths }, busy.out), 2); + assert.match(busy.errors.join(""), /holds no index\.html/); +}); + +test("the lay-out hard-links the bundle into an emptied destination and records nothing", async () => { + const paths = getPaths(); + const empty = capture(); + assert.equal(await layOutBundle(paths, "demo-site", path.join(ROOT, "none"), empty.out), 3); + assert.match(empty.errors.join(""), /no build of demo-site/); + + const bundle = path.join(paths.exportBuildsDir, "demo-site", "out"); + mkdirSync(path.join(bundle, "channels"), { recursive: true }); + writeFileSync(path.join(bundle, "index.html"), "<html></html>"); + writeFileSync(path.join(bundle, "channels", "a.json"), "{}"); + symlinkSync("index.html", path.join(bundle, "home.html")); + await writeBuiltStamp(paths, { + v: 1, + stampId: "b-1", + target: "demo-site", + kind: "site", + indexStampId: null, + inputSig: "sig", + builtAt: 1, + commit: null, + branch: null, + runner: "local", + audience: "private", + corpusGeneratedAt: null, + files: 2, + bytes: 10, + archivesStaged: 0, + } as never); + const dest = path.join(ROOT, "serve"); + mkdirSync(dest); + writeFileSync(path.join(dest, "index.html"), "old"); + writeFileSync(path.join(dest, "stale.html"), "old"); + const c = capture(); + assert.equal(await layOutBundle(paths, "demo-site", dest, c.out), 0); + assert.match(c.lines.join(""), /build b-1 laid out in .* — 2 hard-linked, 0 copied, 1 symlinks \(not a deploy: nothing recorded\)/); + assert.equal(statSync(path.join(dest, "index.html")).ino, statSync(path.join(bundle, "index.html")).ino); + assert.equal(readFileSync(path.join(dest, "channels", "a.json"), "utf8"), "{}"); + assert.equal(readFileSync(path.join(dest, "home.html"), "utf8"), "<html></html>"); + assert.throws(() => statSync(path.join(dest, "stale.html"))); + assert.throws(() => statSync(path.join(paths.exportBuildsDir, "demo-site", "deployed.json"))); +}); diff --git a/common/bin/publish.ts b/common/bin/publish.ts @@ -4,6 +4,7 @@ // // publish index update-index (as a child: its heap cap) // publish build <id|all> [--runner local|docker|auto] [--force] [--skip-archives] +// [--allow-missing-media] [--out <dir>] // publish deploy <id|all> [--preview <b>] [--to local] [--force] // publish hub [--deploy] [--preview <b>] [--force] // publish homepage [--deploy] [--preview <b>] [--to local] [--force] @@ -23,6 +24,8 @@ import { newStampId } from "../publish/stamps"; import { STAGE_EXIT, runStage, stageCommand, stageMain } from "../publish/stageRun"; import { parseStageArgs, type StageRequest } from "../publish/stages"; import type { CommandContext } from "./_cli"; +import path from "node:path"; +import { copyFile, link, lstat, mkdir, readdir, readlink, rm, symlink } from "node:fs/promises"; type Out = { log: (s: string) => void; error: (s: string) => void }; @@ -109,6 +112,9 @@ export type BuildArgs = { force?: boolean; skipArchives?: boolean; allowMissingMedia?: boolean; + // A private build served elsewhere (release 19 A8): after the build (or the + // no-op of a fresh one), the bundle is laid out in this directory. + out?: string; runId?: string; paths?: Paths; signal?: AbortSignal; @@ -116,7 +122,25 @@ export type BuildArgs = { export async function publishBuild(a: BuildArgs, out: Out = console): Promise<number> { const all = a.target === "all"; + if (all && a.out !== undefined) { + out.error("publish build: --out lays out ONE site's bundle — give a site id, not all"); + return STAGE_EXIT.usage; + } if (!all && !knownSite(a.target, "publish build", out, a.paths)) return STAGE_EXIT.usage; + if (a.out !== undefined) { + // Refused before the build, so a typo'd destination costs no build. + const paths = a.paths ?? getPaths(); + const { bundleDir } = await import("../publish/build"); + const { localDestProblem } = await import("../publish/deployStage"); + const problem = await localDestProblem(path.resolve(a.out), bundleDir(paths, a.target), paths); + if (problem) { + out.error(`publish build: --out ${problem.replace(/a local deploy empties/g, "--out empties")}`); + return STAGE_EXIT.usage; + } + const code = await publishBuild({ ...a, out: undefined }, out); + if (code !== 0) return code; + return layOutBundle(paths, a.target, path.resolve(a.out), out); + } const signal = a.signal ?? interrupted(); let runner: "local" | "docker" = "local"; if (a.runner === "docker" || a.runner === "auto") { @@ -146,6 +170,64 @@ export async function publishBuild(a: BuildArgs, out: Out = console): Promise<nu ); } +// --- publish build --out -------------------------------------------------------- + +// THE SITE'S BUNDLE, LAID OUT IN `dest` for a server that is not Pages (a +// private build served elsewhere). Cheap: every file is a HARD LINK into the +// bundle when the two share a filesystem — a build never edits a bundle in +// place, it installs a new one (publish/build.ts installBundle), so the links +// keep the files of the build they were made from — else a copy. `dest` is +// emptied first (its contents, never the directory: it may be a mount) and +// guarded as a local deploy's destination is (localDestProblem). Not a deploy: +// nothing is recorded in deployed.json. +export async function layOutBundle(paths: Paths, target: string, dest: string, out: Out = console): Promise<number> { + const { bundleDir } = await import("../publish/build"); + const { readBuiltStamp } = await import("../publish/stamps"); + const src = bundleDir(paths, target); + const built = await readBuiltStamp(paths, target); + if (!built) { + out.error(`publish build: no build of ${target} in ${src} — nothing to lay out`); + return STAGE_EXIT.precondition; + } + await mkdir(dest, { recursive: true }); + for (const e of await readdir(dest)) await rm(path.join(dest, e), { recursive: true, force: true }); + const counts = { linked: 0, copied: 0, links: 0 }; + const walk = async (from: string, to: string): Promise<void> => { + for (const e of await readdir(from, { withFileTypes: true })) { + const s = path.join(from, e.name); + const d = path.join(to, e.name); + if (e.isDirectory()) { + await mkdir(d, { recursive: true }); + await walk(s, d); + } else if (e.isSymbolicLink()) { + await symlink(await readlink(s), d); + counts.links++; + } else { + try { + await link(s, d); + counts.linked++; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code !== "EXDEV" && code !== "EPERM" && code !== "EMLINK") throw err; + await copyFile(s, d); + counts.copied++; + } + } + } + }; + const st = await lstat(src).catch(() => null); + if (!st?.isDirectory()) { + out.error(`publish build: the bundle ${src} is missing`); + return STAGE_EXIT.precondition; + } + await walk(src, dest); + out.log( + `[out] ${target}: build ${built.stampId} laid out in ${dest} — ${counts.linked} hard-linked, ` + + `${counts.copied} copied${counts.links ? `, ${counts.links} symlinks` : ""} (not a deploy: nothing recorded).`, + ); + return 0; +} + export type DeployArgs = { target: string; // a site id or "all" preview?: string; diff --git a/editor/app/api/ops/publish/route.test.ts b/editor/app/api/ops/publish/route.test.ts @@ -66,3 +66,30 @@ test("GET publish is the publish status: the index, the lane, a row per target", const anon = await callGet(GET as unknown as Parameters<typeof callGet>[0], undefined, {}, {}); assert.equal(anon.status, 401); }); + +test("build takes allowMissingMedia (a local build's), carried onto the queued stage", async () => { + const docker = await callPost(POST, { verb: "build", runner: "docker", allowMissingMedia: true }); + assert.equal(docker.status, 400); + assert.match(String(docker.body.error), /"allowMissingMedia" is a local build's/); + const notBool = await callPost(POST, { verb: "build", siteId: "pubsite", allowMissingMedia: "yes" }); + assert.equal(notBool.status, 400); + assert.match(String(notBool.body.error), /"allowMissingMedia" must be a boolean/); + const deploy = await callPost(POST, { verb: "deploy", siteId: "pubsite", allowMissingMedia: true }); + assert.equal(deploy.status, 400); + assert.match(String(deploy.body.error), /verb "deploy" takes .* — not allowMissingMedia/); + + const before = new Set(await corpus.listJobIds()); + const queued = await callPost(POST, { verb: "build", siteId: "pubsite", allowMissingMedia: true }); + assert.equal(queued.status, 200, JSON.stringify(queued.body)); + const jobs = queued.body.jobs as { kind: string; target: string; jobId: string }[]; + const build = jobs.find((j) => j.target === "pubsite"); + assert.ok(build, JSON.stringify(jobs)); + const { readFile } = await import("node:fs/promises"); + const path = await import("node:path"); + const fresh = (await corpus.listJobIds()).filter((f) => !before.has(f)); + const metas = await Promise.all( + fresh.map((f) => readFile(path.join(corpus.transcripts, ".jobs", f), "utf8")), + ); + const mine = metas.find((m) => m.includes(build!.jobId)) ?? metas.join("\n"); + assert.match(mine, /allowMissingMedia|allow-missing-media/, mine); +}); diff --git a/editor/app/api/ops/publish/route.ts b/editor/app/api/ops/publish/route.ts @@ -23,11 +23,14 @@ 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? } +// runner?: "local" | "docker", deploy?, skipArchives?, +// allowMissingMedia? } // // 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) +// `runner: "docker"` builds every site in containers (host only); +// `allowMissingMedia: true` lets a report citation with no +// prepared media through compose (local builds only) // deploy each named site's built bundle: production, `preview`, or // `to: "local"` (ARCHILYZER_SITE_OUT); `force` redeploys a // bundle already shipped there @@ -44,7 +47,18 @@ type Verb = (typeof VERBS)[number]; // // 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"]; +const KEYS = [ + "verb", + "siteId", + "siteIds", + "preview", + "to", + "force", + "runner", + "deploy", + "skipArchives", + "allowMissingMedia", +]; function only(body: OpsBody, verb: Verb, allowed: string[]): void { const extra = Object.keys(body).filter((k) => k !== "verb" && !allowed.includes(k)); @@ -75,9 +89,13 @@ export async function POST(request: Request) { return publishResponse(await enqueuePlan(plan)); } case "build": { - only(body, verb, ["siteId", "siteIds", "force", "runner", "skipArchives"]); + only(body, verb, ["siteId", "siteIds", "force", "runner", "skipArchives", "allowMissingMedia"]); const runner = body.runner === undefined ? "local" : oneOf(body, "runner", ["local", "docker"] as const); + const allowMissingMedia = optBool(body, "allowMissingMedia"); if (runner === "docker") { + if (allowMissingMedia !== undefined) { + throw new OpsInputError('"allowMissingMedia" is a local build\'s — the docker runner does not take it'); + } if (body.siteId !== undefined || body.siteIds !== undefined) { throw new OpsInputError('"runner": "docker" builds every site — send no "siteId"/"siteIds"'); } @@ -109,7 +127,12 @@ export async function POST(request: Request) { } const ids = reqSiteIds(body); return publishResponse( - await enqueueAsks(ids.map((target) => ({ target, build: { force: force !== false, skipArchives } }))), + await enqueueAsks( + ids.map((target) => ({ + target, + build: { force: force !== false, skipArchives, ...(allowMissingMedia ? { allowMissingMedia: true } : {}) }, + })), + ), ); } case "deploy": { diff --git a/editor/app/sites/lib/publishCore.ts b/editor/app/sites/lib/publishCore.ts @@ -43,7 +43,10 @@ export type DeployWhere = "production" | "preview" | "local"; export type TargetAsk = { // A site id, "_hub" or "_homepage". target: string; - build?: { force?: boolean; skipArchives?: boolean }; + // allowMissingMedia: a report citation with no prepared media is let + // through compose (`archilyzer publish build --allow-missing-media`; the ops + // publish route's build verb, release 19 A8). + build?: { force?: boolean; skipArchives?: boolean; allowMissingMedia?: boolean }; deploy?: { where: DeployWhere; preview?: string; force?: boolean }; }; @@ -135,6 +138,7 @@ export function wantedPlan( reason: "asked", ...(ask.build.force !== false ? { force: true } : {}), ...(ask.build.skipArchives ? { skipArchives: true } : {}), + ...(ask.build.allowMissingMedia ? { allowMissingMedia: true } : {}), ...(indexStep ? { indexAfter: runStart } : {}), }); } diff --git a/scripts/archilyzer-ops.mjs b/scripts/archilyzer-ops.mjs @@ -710,7 +710,9 @@ export function usage() { 'publish runs publish stages on the editor\'s publish queue, one at a time,', ' under one run id: {"verb": …}. "index" updates the index; "build" builds', ' "siteId"/"siteIds" (forced; the index first when stale; "runner":', - ' "docker" builds every site in containers); "deploy" ships their built', + ' "docker" builds every site in containers; "allowMissingMedia": true lets', + ' a report citation with no prepared media through compose, local builds', + ' only); "deploy" ships their built', ' bundles (production, "preview": "<branch>", or "to": "local"; "force"', ' redeploys a bundle already shipped there); "hub" / "homepage" build', ' them, {"deploy": true} deploys after; "now" is Publish now (the stale',