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 <noreply@anthropic.com>
This commit is contained in:
Christopher Monsanto
2026-08-17 17:57:53 -04:00
parent 8f58cf3da0
commit 3e87df43da
6 changed files with 170 additions and 157 deletions

View File

@@ -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

View File

@@ -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");
});

View File

@@ -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<string>();
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<string>();
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.
},
});

View File

@@ -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<string>();
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<string>();
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"});
// }
// });

View File

@@ -30,14 +30,23 @@ export interface DeployCtx {
hash(...srcs : CopySource[]) : string;
}
export interface DeploySpec {
finish : (ctx : DeployCtx) => void | Promise<void>;
export type DeployFn = (ctx : DeployCtx) => void | Promise<void>;
// 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 {

View File

@@ -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<Map<string, DeploySpec | null>> {
const specs = new Map<string, DeploySpec | null>();
// 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<Map<string, readonly DeployFn[]>> {
const specs = new Map<string, readonly DeployFn[]>();
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<string, DeploySpec | null>, 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<string, readonly DeployFn[]>, 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<ActionQueue | null> {
async function runFinish(fns : readonly DeployFn[], verbose : boolean) : Promise<ActionQueue | null> {
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;