From 3e87df43da15c45dd5558d9805c57a4b8682792e Mon Sep 17 00:00:00 2001 From: Christopher Monsanto Date: Mon, 17 Aug 2026 17:57:53 -0400 Subject: [PATCH] Replace defineDeploy with free-floating deploy() blocks deploy(ctx => ...) registers like rule() does, so a buildFile co-locates each shipping block with the rules it ships instead of one monolithic finish; the blocks run in registration order after the build, sharing one output tree per module. The CLI slices the registry across its sequential imports to know which blocks belong to which file. Co-Authored-By: Claude Fable 5 --- README.md | 7 +-- assets.build.ts | 102 +++++++++++++++++++------------------- ps.build.ts | 45 +++++++++-------- smogon.build.ts | 113 ++++++++++++++++++++++-------------------- tools/deploy/api.ts | 21 +++++--- tools/deploy/index.ts | 39 +++++++-------- 6 files changed, 170 insertions(+), 157 deletions(-) diff --git a/README.md b/README.md index 17a45bf8..acdae0b2 100644 --- a/README.md +++ b/README.md @@ -53,8 +53,9 @@ Install dependencies once with `pnpm install`. Each deploy is a root `*.build.ts` module: it declares its build rules (shared sets are plain functions in `rules/`; declaring an identical rule twice is a no-op returning the existing artifacts, so any number of deploys -can call the same set) and a `finish` function that maps the built artifacts -to their published names. Build outputs are content-addressed: +can call the same set) and, next to each set of rules, a `deploy(ctx => ...)` +block that maps the built artifacts to their published names; the blocks run +in registration order after the build, sharing one output tree per module. Build outputs are content-addressed: rules declare nominal output filenames but the store names every object by the hash of its bytes (under `.build/cas/`), so incrementality keys on content, same-byte renames rebuild nothing, and hash-stamped publishing @@ -139,7 +140,7 @@ DEFAULT_ADVPNG=-z4 -i5000 its output bytes (the spritesheet builders do), the rule must set `nameSensitive: true` or renames will leave its output silently stale. - Rules must be declared when a deploy module is imported (top level), not - inside `finish` — the build runs before finish does. + inside a `deploy` block — the build runs before the blocks do. ## License diff --git a/assets.build.ts b/assets.build.ts index 5783574c..ff290322 100644 --- a/assets.build.ts +++ b/assets.build.ts @@ -3,9 +3,10 @@ import {gen6Padded, itemPadded} from './rules/minisprites.ts'; import {Manifest, itemspritecopy, newspritecopy, spritecopy} from './rules/publish.ts'; import {forEachRule, rule} from './tools/build/artifact.ts'; import {spriteglob} from './tools/build/helpers.ts'; -import {defineDeploy} from './tools/deploy/api.ts'; +import {deploy} from './tools/deploy/api.ts'; -// Smogdex minisprites (webp) +// Smogdex minisprites (webp), shipped under a whole-set content hash with +// a pointer in __meta/ for the dex to read. const minispriteInputs = spriteglob(["src/minisprites/pokemon/gen6/*", "src/minisprites/items/*"], {a: false}); @@ -14,6 +15,14 @@ const webpMinisprites = forEachRule(minispriteInputs, { cmds: ["cwebp -z 9 %f -o %o"], }, "%B.webp"); +deploy(ctx => { + const h = ctx.hash(...webpMinisprites); + for (const f of webpMinisprites) { + newspritecopy(ctx, f, {dir: "minisprites/" + h}); + } + ctx.write("__meta/minisprites-hash.txt", h); +}); + // Smogdex spritesheet. The sheet tool bakes sprite ids parsed from the %f // filenames into the css, hence nameSensitive. @@ -32,56 +41,47 @@ const [sheetPng, sheetCss] = rule(minispriteInputs, { const sheetWebp = rule(sheetPng, ["cwebp -z 9 %f -o %o"], "spritesheet.webp"); -// Forumsprites sources. Rules must be declared at import time (the build -// runs before finish), so these calls cannot live inside finish(). +// Hash-stamped css + webp. The css suffix pointer rides in __meta/ for the +// dex to read. +deploy(ctx => { + const wh = ctx.hash(sheetWebp); + ctx.copy(sheetWebp, `spritesheet-${wh}.webp`); + const src = ctx.read(sheetCss); + const css = src.replaceAll('url("./spritesheet.webp")', `url("./spritesheet-${wh}.webp")`); + if (css === src) { + throw new Error("spritesheet.css: no webp urls rewritten"); + } + // Suffix from source content: the rewritten css is a pure function + // of (css, webp), so this changes exactly when the served bytes + // change. + const ch = ctx.hash(sheetCss, sheetWebp); + ctx.write(`spritesheet-${ch}.css`, css); + ctx.write("__meta/spritesheet_css_suffix.txt", `-${ch}\n`); +}); + +// Forumsprites: padded minisprites under stamped names, with the +// unhashed -> hashed mapping in a manifest. + const forumItems = itemPadded(); const forumG6 = gen6Padded(); -export default defineDeploy({ - finish(ctx) { - // Dex spritesheet assets: hash-stamped css + webp. The css suffix - // pointer rides in __meta/ for the dex to read. - { - const wh = ctx.hash(sheetWebp); - ctx.copy(sheetWebp, `spritesheet-${wh}.webp`); - const src = ctx.read(sheetCss); - const css = src.replaceAll('url("./spritesheet.webp")', `url("./spritesheet-${wh}.webp")`); - if (css === src) { - throw new Error("spritesheet.css: no webp urls rewritten"); - } - // Suffix from source content: the rewritten css is a pure function - // of (css, webp), so this changes exactly when the served bytes - // change. - const ch = ctx.hash(sheetCss, sheetWebp); - ctx.write(`spritesheet-${ch}.css`, css); - ctx.write("__meta/spritesheet_css_suffix.txt", `-${ch}\n`); - } - - { - const h = ctx.hash(...webpMinisprites); - for (const f of webpMinisprites) { - newspritecopy(ctx, f, {dir: "minisprites/" + h}); - } - ctx.write("__meta/minisprites-hash.txt", h); - } - - { - const manifest = new Manifest(ctx); - for (const f of forumItems) { - itemspritecopy(manifest, f, {dir: "forumsprites"}); - } - for (const f of forumG6) { - spritecopy(manifest, f, {dir: "forumsprites"}, true); - } - manifest.write("__meta/forumsprites/manifest.json"); - } - - { - const manifest = new Manifest(ctx); - for (const f of ctx.list("src/pmd")) { - spritecopy(manifest, f, {dir: "pmd"}); - } - manifest.write("__meta/pmd/manifest.json"); - } - }, +deploy(ctx => { + const manifest = new Manifest(ctx); + for (const f of forumItems) { + itemspritecopy(manifest, f, {dir: "forumsprites"}); + } + for (const f of forumG6) { + spritecopy(manifest, f, {dir: "forumsprites"}, true); + } + manifest.write("__meta/forumsprites/manifest.json"); +}); + +// PMD sprites ship as-is, stamped. + +deploy(ctx => { + const manifest = new Manifest(ctx); + for (const f of ctx.list("src/pmd")) { + spritecopy(manifest, f, {dir: "pmd"}); + } + manifest.write("__meta/pmd/manifest.json"); }); diff --git a/ps.build.ts b/ps.build.ts index 61efffb3..a591e3a6 100644 --- a/ps.build.ts +++ b/ps.build.ts @@ -5,7 +5,7 @@ import {gen10Modelslike} from './rules/modelslike.ts'; import {type Sprite, toPSID} from './rules/publish.ts'; import {forEachRule, rule} from './tools/build/artifact.ts'; import {PNG_DETERMINISTIC, base, compresspng, pad, spriteglob} from './tools/build/helpers.ts'; -import {type DeployCtx, defineDeploy} from './tools/deploy/api.ts'; +import {type DeployCtx, deploy} from './tools/deploy/api.ts'; // PS spritesheets. The sheet tools readdir the minisprite dirs and resolve // ids through the sprite data, baking input names into the sheet layout, @@ -79,8 +79,30 @@ forEachRule(dexMissing, { ], }, "%B.png"); +// ani/: the models plus champions backfill, under PS ids. + const aniChampions = gen10Modelslike(); +deploy(ctx => { + const seenModels = new Set(); + + for (const f of ctx.list("src/models")) { + seenModels.add(f.name); + psSpritecopy(ctx, f, "ani"); + } + + for (const f of aniChampions) { + if (seenModels.has(f.name)) { + continue; + } + seenModels.add(f.name); + psSpritecopy(ctx, f, "ani"); + } + + // TODO: ship the padded dex, sheets, trainers, types/categories when + // the PS deploy is revived; the rules above keep them building. +}); + // PS ids keep the forme dash, unlike the smogon aliases. function psSpritecopy(ctx : DeployCtx, f : Sprite, dir : string) : void { const sn = spritedata.parseFilename(f.name); @@ -116,24 +138,3 @@ function psSpritecopy(ctx : DeployCtx, f : Sprite, dir : string) : void { ctx.copy(f, `${dir}/${name}.${f.ext}`); } -export default defineDeploy({ - finish(ctx) { - const seenModels = new Set(); - - for (const f of ctx.list("src/models")) { - seenModels.add(f.name); - psSpritecopy(ctx, f, "ani"); - } - - for (const f of aniChampions) { - if (seenModels.has(f.name)) { - continue; - } - seenModels.add(f.name); - psSpritecopy(ctx, f, "ani"); - } - - // TODO: ship the padded dex, sheets, trainers, types/categories when - // the PS deploy is revived; the rules above keep them building. - }, -}); diff --git a/smogon.build.ts b/smogon.build.ts index ec017211..5927270b 100644 --- a/smogon.build.ts +++ b/smogon.build.ts @@ -2,68 +2,71 @@ import {gen6Trimmed} from './rules/minisprites.ts'; import {gen10Modelslike, gen5Gifs, gen9Modelslike} from './rules/modelslike.ts'; import {Manifest, type Sprite, spritecopy} from './rules/publish.ts'; -import {defineDeploy} from './tools/deploy/api.ts'; +import {deploy} from './tools/deploy/api.ts'; + +// xy/ animations: first source wins per sprite name. const xyModels = gen9Modelslike(); const xyChampions = gen10Modelslike(); const xyGen5 = gen5Gifs(); + +deploy(ctx => { + const seenModels = new Set(); + const manifest = new Manifest(ctx); + const xycopy = (f : Sprite) => { + if (seenModels.has(f.name)) { + return; + } + seenModels.add(f.name); + spritecopy(manifest, f, {dir: "xy"}); + }; + + for (const f of ctx.list("src/models")) { + xycopy(f); + } + for (const f of xyModels) { + xycopy(f); + } + for (const f of xyChampions) { + xycopy(f); + } + // Non-model CAPs + for (const f of ctx.list("src/sprites/gen5")) { + if (f.ext === 'gif') { + xycopy(f); + } + } + for (const f of xyGen5) { + xycopy(f); + } + manifest.write("xy/manifest.json"); +}); + +// xyicons/: trimmed gen6 minisprites. + const xyIcons = gen6Trimmed(); +deploy(ctx => { + const manifest = new Manifest(ctx); + for (const f of xyIcons) { + spritecopy(manifest, f, {dir: "xyicons"}); + } + manifest.write("xyicons/manifest.json"); +}); + // Deprecated, unstamped sets: // const xyItems = itemTrimmed(); (rules/minisprites.ts) // const fb = fbSprites(); (rules/social.ts) // const twitter = twitterSprites(); (rules/social.ts) - -export default defineDeploy({ - finish(ctx) { - // xy/ animations: first source wins per sprite name. - const seenModels = new Set(); - const xyManifest = new Manifest(ctx); - const xycopy = (f : Sprite) => { - if (seenModels.has(f.name)) { - return; - } - seenModels.add(f.name); - spritecopy(xyManifest, f, {dir: "xy"}); - }; - - for (const f of ctx.list("src/models")) { - xycopy(f); - } - for (const f of xyModels) { - xycopy(f); - } - for (const f of xyChampions) { - xycopy(f); - } - // Non-model CAPs - for (const f of ctx.list("src/sprites/gen5")) { - if (f.ext === 'gif') { - xycopy(f); - } - } - for (const f of xyGen5) { - xycopy(f); - } - xyManifest.write("xy/manifest.json"); - - { - const manifest = new Manifest(ctx); - for (const f of xyIcons) { - spritecopy(manifest, f, {dir: "xyicons"}); - } - manifest.write("xyicons/manifest.json"); - } - - // Deprecated, unstamped: - // for (const f of xyItems) { - // itemspritecopy(?, f, {dir: "xyitems"}); - // } - // for (const f of fb) { - // spritecopy(?, f, {dir: "fbsprites/xy"}); - // } - // for (const f of twitter) { - // spritecopy(?, f, {dir: "twittersprites/xy"}); - // } - }, -}); +// +// deploy(ctx => { +// for (const f of xyItems) { +// itemspritecopy(?, f, {dir: "xyitems"}); +// } +// for (const f of fb) { +// spritecopy(?, f, {dir: "fbsprites/xy"}); +// } +// for (const f of twitter) { +// spritecopy(?, f, {dir: "twittersprites/xy"}); +// } +// }); diff --git a/tools/deploy/api.ts b/tools/deploy/api.ts index 238c1c6a..fb064574 100644 --- a/tools/deploy/api.ts +++ b/tools/deploy/api.ts @@ -30,14 +30,23 @@ export interface DeployCtx { hash(...srcs : CopySource[]) : string; } -export interface DeploySpec { - finish : (ctx : DeployCtx) => void | Promise; +export type DeployFn = (ctx : DeployCtx) => void | Promise; + +// Like rule(): a buildFile registers free-floating deploy blocks next to the +// rules they ship. They run in registration order after the build, sharing +// one ctx (and so one output tree) per buildFile. +const deploys : DeployFn[] = []; + +export function deploy(fn : DeployFn) : void { + deploys.push(fn); } -// Identity helper: a deploy module declares its rules at top level and -// `export default defineDeploy({finish})`. -export function defineDeploy(spec : DeploySpec) : DeploySpec { - return spec; +export function getDeploys() : readonly DeployFn[] { + return deploys; +} + +export function resetDeploys() : void { + deploys.length = 0; } function shortHash(digest : Buffer) : string { diff --git a/tools/deploy/index.ts b/tools/deploy/index.ts index 9fb36ee2..7aeb97d5 100644 --- a/tools/deploy/index.ts +++ b/tools/deploy/index.ts @@ -14,7 +14,7 @@ import {BuildError} from '../build/errors.ts'; import {killAllProcessGroups} from '../build/exec.ts'; import {setConfig} from '../build/helpers.ts'; import {Store, acquireLock, dbVersion} from '../build/store.ts'; -import {type DeploySpec, makeCtx} from './api.ts'; +import {type DeployFn, getDeploys, makeCtx} from './api.ts'; import {loadDeployConfig, matchSubsets} from './config.ts'; import {ActionQueue} from './queue.ts'; @@ -103,19 +103,15 @@ function discoverDeployFiles() : string[] { return files; } -// Importing a deploy module declares its rules; the default export carries -// the finish function. -async function importDeploys(files : string[]) : Promise> { - const specs = new Map(); +// Importing a deploy module declares its rules and registers its deploy +// blocks; the registry delta over each sequential import is that file's +// blocks. +async function importDeploys(files : string[]) : Promise> { + const specs = new Map(); for (const file of files) { - const mod : {default? : unknown} = await import(pathToFileURL(nodePath.resolve(file)).href); - const spec = mod.default; - if (typeof spec === 'object' && spec !== null - && typeof (spec as DeploySpec).finish === 'function') { - specs.set(file, spec as DeploySpec); - } else { - specs.set(file, null); - } + const before = getDeploys().length; + await import(pathToFileURL(nodePath.resolve(file)).href); + specs.set(file, getDeploys().slice(before)); } return specs; } @@ -188,17 +184,20 @@ async function buildThen(decls : readonly RuleDecl[], opts : CommonOpts, gc : bo } } -function finishOf(specs : Map, file : string) : DeploySpec { - const spec = specs.get(file); - if (spec === null || spec === undefined) { - throw new BuildError(`${file} does not default-export a deploy (use defineDeploy)`); +function finishOf(specs : Map, file : string) : readonly DeployFn[] { + const fns = specs.get(file); + if (fns === undefined || fns.length === 0) { + throw new BuildError(`${file} registers no deploy blocks (use deploy())`); } - return spec; + return fns; } -async function runFinish(spec : DeploySpec, verbose : boolean) : Promise { +async function runFinish(fns : readonly DeployFn[], verbose : boolean) : Promise { const aq = new ActionQueue(); - await spec.finish(makeCtx(CAS_DIR, aq)); + const ctx = makeCtx(CAS_DIR, aq); + for (const fn of fns) { + await fn(ctx); + } if (!aq.valid) { aq.print(verbose ? 'all' : 'errors'); return null;