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;