Tighten formatting to the smogon.com house style

Annotation colons are tight (ternaries keep their spaces), plain
double-quoted strings become single-quoted, and .editorconfig comes
over verbatim. Rule cmd values are untouched; the sheet rules reran
once because their tool sources are declared deps, byte-identically.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Christopher Monsanto
2026-08-17 18:55:11 -04:00
parent 3e87df43da
commit ebbb5e6f2e
34 changed files with 501 additions and 500 deletions

View File

@@ -1,8 +1,9 @@
root = true
[*]
end_of_line = lf
[**]
indent_style = space
indent_size = 4
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
end_of_line = lf

View File

@@ -8,38 +8,38 @@ import {deploy} from './tools/deploy/api.ts';
// 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});
const minispriteInputs = spriteglob(['src/minisprites/pokemon/gen6/*', 'src/minisprites/items/*'], {a: false});
const webpMinisprites = forEachRule(minispriteInputs, {
display: "webp minisprite %f",
cmds: ["cwebp -z 9 %f -o %o"],
}, "%B.webp");
display: 'webp minisprite %f',
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});
newspritecopy(ctx, f, {dir: 'minisprites/' + h});
}
ctx.write("__meta/minisprites-hash.txt", 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.
const [sheetPng, sheetCss] = rule(minispriteInputs, {
display: "smogdex sheet",
display: 'smogdex sheet',
nameSensitive: true,
deps: [
"data/species.json",
"data/items.json",
"data/lib/index.ts",
"lib/root/index.ts",
"tools/smogdexspritesheet/index.ts",
'data/species.json',
'data/items.json',
'data/lib/index.ts',
'lib/root/index.ts',
'tools/smogdexspritesheet/index.ts',
],
cmds: ["node tools/smogdexspritesheet/index.ts --image %o1 --stylesheet %o2 -- %f"],
}, ["spritesheet.png", "spritesheet.css"]);
cmds: ['node tools/smogdexspritesheet/index.ts --image %o1 --stylesheet %o2 -- %f'],
}, ['spritesheet.png', 'spritesheet.css']);
const sheetWebp = rule(sheetPng, ["cwebp -z 9 %f -o %o"], "spritesheet.webp");
const sheetWebp = rule(sheetPng, ['cwebp -z 9 %f -o %o'], 'spritesheet.webp');
// Hash-stamped css + webp. The css suffix pointer rides in __meta/ for the
// dex to read.
@@ -49,14 +49,14 @@ deploy(ctx => {
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");
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`);
ctx.write('__meta/spritesheet_css_suffix.txt', `-${ch}\n`);
});
// Forumsprites: padded minisprites under stamped names, with the
@@ -68,20 +68,20 @@ const forumG6 = gen6Padded();
deploy(ctx => {
const manifest = new Manifest(ctx);
for (const f of forumItems) {
itemspritecopy(manifest, f, {dir: "forumsprites"});
itemspritecopy(manifest, f, {dir: 'forumsprites'});
}
for (const f of forumG6) {
spritecopy(manifest, f, {dir: "forumsprites"}, true);
spritecopy(manifest, f, {dir: 'forumsprites'}, true);
}
manifest.write("__meta/forumsprites/manifest.json");
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"});
for (const f of ctx.list('src/pmd')) {
spritecopy(manifest, f, {dir: 'pmd'});
}
manifest.write("__meta/pmd/manifest.json");
manifest.write('__meta/pmd/manifest.json');
});

View File

@@ -3,12 +3,12 @@ import path from 'path';
import fs from 'fs';
import root from '@smogon/sprite-root/index.ts';
const libdir = path.join(root, "data");
const libdir = path.join(root, 'data');
export type Id = string;
export type SpecieEntry = {
type : 'specie',
type: 'specie',
num: number,
formeNum: number,
base: string,
@@ -17,56 +17,56 @@ export type SpecieEntry = {
};
export type ItemEntry = {
type : 'item',
sid : string,
names : string[]
type: 'item',
sid: string,
names: string[]
};
export type Entry = SpecieEntry | ItemEntry;
const objects : Record<Id, Entry> = {};
Object.assign(objects, JSON.parse(fs.readFileSync(path.join(libdir, "species.json"), 'utf8')));
Object.assign(objects, JSON.parse(fs.readFileSync(path.join(libdir, "items.json"), 'utf8')));
const objects: Record<Id, Entry> = {};
Object.assign(objects, JSON.parse(fs.readFileSync(path.join(libdir, 'species.json'), 'utf8')));
Object.assign(objects, JSON.parse(fs.readFileSync(path.join(libdir, 'items.json'), 'utf8')));
const map = new Map<Id, Entry>();
for (const entry of Object.values(objects)) {
map.set(entry.sid, entry);
}
export function get(id : Id) : Entry {
export function get(id: Id): Entry {
const entry = map.get(id);
if (entry === undefined)
throw new Error(`No id for ${id}`);
return entry;
}
export function entries() : Entry[] {
export function entries(): Entry[] {
return Array.from(map.values());
}
// TODO Moved here from deploy/spritename.ts, better place to put these??
export type SpriteFilename = ({
extension : true,
name : string
extension: true,
name: string
} | {
extension : false,
id : Id
extension: false,
id: Id
}) & {
extra : Map<string, string>
extra: Map<string, string>
};
export type InputSpriteFilename = ({
extension : true,
name : string
extension: true,
name: string
} | {
extension? : false,
id : Id
extension?: false,
id: Id
}) & {
extra? : Map<string, string>
extra?: Map<string, string>
};
export function parseFilename(s : string) : SpriteFilename {
export function parseFilename(s: string): SpriteFilename {
if (s.length < 2)
throw new Error(`Filename ${s} needs to be at least 2 characters'`);
@@ -74,7 +74,7 @@ export function parseFilename(s : string) : SpriteFilename {
if (!prefix.match(/[a-z]/))
throw new Error(`Filename ${s} must start with alpha character`);
const parts = s.split("-");
const parts = s.split('-');
const extra = new Map<string, string>();
for (const part of parts.slice(1)) {
if (part.length === 0)
@@ -91,8 +91,8 @@ export function parseFilename(s : string) : SpriteFilename {
}
}
export function formatFilename(si : InputSpriteFilename) {
let s : string;
export function formatFilename(si: InputSpriteFilename) {
let s: string;
if (si.extension) {
s = `x${si.name}`;
} else {

View File

@@ -1,4 +1,4 @@
import path from 'path';
export default path.resolve(import.meta.dirname, "../../");
export default path.resolve(import.meta.dirname, '../../');

View File

@@ -12,19 +12,19 @@ import {type DeployCtx, deploy} from './tools/deploy/api.ts';
// hence nameSensitive.
const sheetDeps = [
"data/species.json",
"data/items.json",
"data/lib/index.ts",
"lib/root/index.ts",
"tools/sheet/index.ts",
'data/species.json',
'data/items.json',
'data/lib/index.ts',
'lib/root/index.ts',
'tools/sheet/index.ts',
];
rule("ps-pokemon.sheet.mjs", {
display: "ps pokemon sheet",
rule('ps-pokemon.sheet.mjs', {
display: 'ps pokemon sheet',
nameSensitive: true,
deps: ["src/minisprites/pokemon/gen6/*", ...sheetDeps],
cmds: ["node tools/sheet/index.ts %f %o", compresspng({config: "SPRITESHEET"})],
}, "pokemonicons-sheet.png");
deps: ['src/minisprites/pokemon/gen6/*', ...sheetDeps],
cmds: ['node tools/sheet/index.ts %f %o', compresspng({config: 'SPRITESHEET'})],
}, 'pokemonicons-sheet.png');
// TODO: reenable when trainers are moved
// rule("ps-trainers.sheet.mjs", {
@@ -33,37 +33,37 @@ rule("ps-pokemon.sheet.mjs", {
// cmds: ["node tools/sheet/index.ts %f %o", compresspng({config: "SPRITESHEET"})],
// }, "trainers-sheet.png");
rule("ps-items.sheet.mjs", {
display: "ps items sheet",
rule('ps-items.sheet.mjs', {
display: 'ps items sheet',
nameSensitive: true,
deps: ["src/minisprites/items/*", ...sheetDeps],
cmds: ["node tools/sheet/index.ts %f %o", compresspng({config: "SPRITESHEET"})],
}, "itemicons-sheet.png");
deps: ['src/minisprites/items/*', ...sheetDeps],
cmds: ['node tools/sheet/index.ts %f %o', compresspng({config: 'SPRITESHEET'})],
}, 'itemicons-sheet.png');
// PS pokeball icons; input order is the sheet order.
rule([
"src/_uncategorized/noncanonical/ui/battle/Ball-Normal.png",
"src/_uncategorized/noncanonical/ui/battle/Ball-Sick.png",
"src/_uncategorized/noncanonical/ui/battle/Ball-Null.png",
'src/_uncategorized/noncanonical/ui/battle/Ball-Normal.png',
'src/_uncategorized/noncanonical/ui/battle/Ball-Sick.png',
'src/_uncategorized/noncanonical/ui/battle/Ball-Null.png',
], {
display: "pokemonicons-pokeball-sheet",
display: 'pokemonicons-pokeball-sheet',
cmds: [
`magick convert ${PNG_DETERMINISTIC} -background transparent -gravity center -extent 40x30 %f +append %o`,
compresspng({config: "SPRITESHEET"}),
compresspng({config: 'SPRITESHEET'}),
],
}, "pokemonicons-pokeball-sheet.png");
}, 'pokemonicons-pokeball-sheet.png');
// Padded Dex, plus missing CAPs backfilled from the gen5/model gifs.
const dex = forEachRule("src/dex/*", {
display: "pad dex %f",
cmds: [pad({w: 120, h: 120}), compresspng({config: "DEX"})],
}, "%b");
const dex = forEachRule('src/dex/*', {
display: 'pad dex %f',
cmds: [pad({w: 120, h: 120}), compresspng({config: 'DEX'})],
}, '%b');
const dexSet = new Set(dex.map(base));
const dexMissing = [];
for (const file of spriteglob(["src/sprites/gen5/*.gif", "src/models/*.gif"], {b: false, s: false})) {
for (const file of spriteglob(['src/sprites/gen5/*.gif', 'src/models/*.gif'], {b: false, s: false})) {
if (!dexSet.has(base(file))) {
dexMissing.push(file);
dexSet.add(base(file));
@@ -71,13 +71,13 @@ for (const file of spriteglob(["src/sprites/gen5/*.gif", "src/models/*.gif"], {b
}
forEachRule(dexMissing, {
display: "missing dex %B",
display: 'missing dex %B',
cmds: [
`magick convert "%f[0]" ${PNG_DETERMINISTIC} -trim %o`,
`magick mogrify ${PNG_DETERMINISTIC} -background transparent -gravity center -resize "120x120>" -extent 120x120 %o`,
compresspng({config: "DEX"}),
compresspng({config: 'DEX'}),
],
}, "%B.png");
}, '%B.png');
// ani/: the models plus champions backfill, under PS ids.
@@ -86,9 +86,9 @@ const aniChampions = gen10Modelslike();
deploy(ctx => {
const seenModels = new Set<string>();
for (const f of ctx.list("src/models")) {
for (const f of ctx.list('src/models')) {
seenModels.add(f.name);
psSpritecopy(ctx, f, "ani");
psSpritecopy(ctx, f, 'ani');
}
for (const f of aniChampions) {
@@ -96,7 +96,7 @@ deploy(ctx => {
continue;
}
seenModels.add(f.name);
psSpritecopy(ctx, f, "ani");
psSpritecopy(ctx, f, 'ani');
}
// TODO: ship the padded dex, sheets, trainers, types/categories when
@@ -104,12 +104,12 @@ deploy(ctx => {
});
// PS ids keep the forme dash, unlike the smogon aliases.
function psSpritecopy(ctx : DeployCtx, f : Sprite, dir : string) : void {
function psSpritecopy(ctx: DeployCtx, f: Sprite, dir: string): void {
const sn = spritedata.parseFilename(f.name);
let name : string;
let name: string;
// Skip asymmetrical for now
if (sn.extra.has("a") || sn.extra.has("b") || sn.extra.has("s")) {
if (sn.extra.has('a') || sn.extra.has('b') || sn.extra.has('s')) {
return;
}
@@ -125,11 +125,11 @@ function psSpritecopy(ctx : DeployCtx, f : Sprite, dir : string) : void {
if (sd.forme) {
name += `-${toPSID(sd.forme)}`;
}
if (sn.extra.has("f")) {
name += "-f";
if (sn.extra.has('f')) {
name += '-f';
}
if (sn.extra.has("g")) {
name += "-gmax";
if (sn.extra.has('g')) {
name += '-gmax';
}
if (f.ext === null) {

View File

@@ -4,30 +4,30 @@ import {compresspng, pad, trimimg} from '../tools/build/helpers.ts';
// Uniform size minisprites
export function gen6Padded() : Artifact[] {
return forEachRule("src/minisprites/pokemon/gen6/*.png", {
display: "pad g6 minisprite %f",
cmds: [pad({w: 40, h: 30}), compresspng({config: "MINISPRITE"})],
}, "%b");
export function gen6Padded(): Artifact[] {
return forEachRule('src/minisprites/pokemon/gen6/*.png', {
display: 'pad g6 minisprite %f',
cmds: [pad({w: 40, h: 30}), compresspng({config: 'MINISPRITE'})],
}, '%b');
}
export function itemPadded() : Artifact[] {
return forEachRule("src/minisprites/items/*.png", {
display: "pad item minisprite %f",
cmds: [pad({w: 24, h: 24}), compresspng({config: "MINISPRITE"})],
}, "%b");
export function itemPadded(): Artifact[] {
return forEachRule('src/minisprites/items/*.png', {
display: 'pad item minisprite %f',
cmds: [pad({w: 24, h: 24}), compresspng({config: 'MINISPRITE'})],
}, '%b');
}
export function gen6Trimmed() : Artifact[] {
return forEachRule("src/minisprites/pokemon/gen6/*.png", {
display: "trim g6 minisprite %f",
cmds: [trimimg(), compresspng({config: "MINISPRITE"})],
}, "%b");
export function gen6Trimmed(): Artifact[] {
return forEachRule('src/minisprites/pokemon/gen6/*.png', {
display: 'trim g6 minisprite %f',
cmds: [trimimg(), compresspng({config: 'MINISPRITE'})],
}, '%b');
}
export function itemTrimmed() : Artifact[] {
return forEachRule("src/minisprites/items/*.png", {
display: "trim item minisprite %f",
cmds: [trimimg(), compresspng({config: "MINISPRITE"})],
}, "%b");
export function itemTrimmed(): Artifact[] {
return forEachRule('src/minisprites/items/*.png', {
display: 'trim item minisprite %f',
cmds: [trimimg(), compresspng({config: 'MINISPRITE'})],
}, '%b');
}

View File

@@ -3,39 +3,39 @@ import {type Artifact, forEachRule} from '../tools/build/artifact.ts';
// Gen 9
export function gen9Modelslike() : Artifact[] {
return forEachRule("src/gen9species/*.png", {
display: "96x96 %f",
export function gen9Modelslike(): Artifact[] {
return forEachRule('src/gen9species/*.png', {
display: '96x96 %f',
// TODO, add customizable compression for gif
// ... or investigate using webp instead of both png/gif here
cmds: [
"magick convert %f -trim +repage -resize 90x90 %o",
"gifsicle -O3 -b %o",
'magick convert %f -trim +repage -resize 90x90 %o',
'gifsicle -O3 -b %o',
],
}, "%B.gif");
}, '%B.gif');
}
// Gen 10
export function gen10Modelslike() : Artifact[] {
return forEachRule("src/champions/*.png", {
display: "96x96 %f",
export function gen10Modelslike(): Artifact[] {
return forEachRule('src/champions/*.png', {
display: '96x96 %f',
// TODO, add customizable compression for gif
// ... or investigate using webp instead of both png/gif here
cmds: [
"magick convert %f -trim +repage -resize 90x90 %o",
"gifsicle -O3 -b %o",
'magick convert %f -trim +repage -resize 90x90 %o',
'gifsicle -O3 -b %o',
],
}, "%B.gif");
}, '%B.gif');
}
// Gen 5 CAPs...
export function gen5Gifs() : Artifact[] {
return forEachRule("src/sprites/gen5/*.png", [
export function gen5Gifs(): Artifact[] {
return forEachRule('src/sprites/gen5/*.png', [
// TODO, add customizable compression for gif
// ... or investigate using webp instead of both png/gif here
"magick convert %f %o",
"gifsicle -O3 -b %o",
], "%B.gif");
'magick convert %f %o',
'gifsicle -O3 -b %o',
], '%B.gif');
}

View File

@@ -10,14 +10,14 @@ export type Sprite = Artifact | SrcFile;
// The unhashed -> hashed name mapping published beside a stamped set.
export class Manifest {
readonly ctx : DeployCtx;
readonly ctx: DeployCtx;
private entries = new Map<string, string>();
constructor(ctx : DeployCtx) {
constructor(ctx: DeployCtx) {
this.ctx = ctx;
}
set(key : string, value : string) : void {
set(key: string, value: string): void {
// ActionQueue only dedups final dsts; hashed dsts differ even when
// unhashed names collide, so check the key explicitly.
if (this.entries.has(key)) {
@@ -26,8 +26,8 @@ export class Manifest {
this.entries.set(key, value);
}
write(dst : string) : void {
const sorted : Record<string, string> = {};
write(dst: string): void {
const sorted: Record<string, string> = {};
for (const k of [...this.entries.keys()].sort()) {
sorted[k] = this.entries.get(k)!;
}
@@ -36,11 +36,11 @@ export class Manifest {
}
export interface Dest {
dir : string;
ext? : string;
dir: string;
ext?: string;
}
function extOf(f : Sprite, ext? : string) : string {
function extOf(f: Sprite, ext?: string): string {
const result = ext ?? f.ext;
if (result === null) {
throw new Error(`Sprite ${f.name} has no extension`);
@@ -48,37 +48,37 @@ function extOf(f : Sprite, ext? : string) : string {
return result;
}
export function toSmogonAlias(name : string) : string {
export function toSmogonAlias(name: string): string {
return name.toLowerCase().
replace(/[ _]+/, "-").
replace(/[ _]+/, '-').
replace(/[^a-z0-9-]+/g, '');
}
export function toPSID(name : string) : string {
export function toPSID(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '');
}
// Copy with a content-hash-stamped name and record the unhashed -> hashed
// mapping in `manifest`.
export function stampcopy(manifest : Manifest, f : Sprite, {dir, ext}: Dest, name : string) : void {
export function stampcopy(manifest: Manifest, f: Sprite, {dir, ext}: Dest, name: string): void {
const h = manifest.ctx.hash(f);
manifest.set(`${name}.${extOf(f, ext)}`, `${name}-${h}.${extOf(f, ext)}`);
manifest.ctx.copy(f, `${dir}/${name}-${h}.${extOf(f, ext)}`);
}
export function spritecopy(manifest : Manifest, f : Sprite, dest : Dest,
allowUnknown = false) : void {
export function spritecopy(manifest: Manifest, f: Sprite, dest: Dest,
allowUnknown = false): void {
const sn = spritedata.parseFilename(f.name);
let name : string;
let name: string;
// Skip asymmetrical for now
if (sn.extra.has("a") || sn.extra.has("b") || sn.extra.has("s")) {
if (sn.extra.has('a') || sn.extra.has('b') || sn.extra.has('s')) {
return;
}
if (sn.extension) {
if (allowUnknown && sn.name === "Unknown") {
name = "unknown";
if (allowUnknown && sn.name === 'Unknown') {
name = 'unknown';
} else {
// Skip this, we don't use Unknown/Substitute
return;
@@ -93,18 +93,18 @@ export function spritecopy(manifest : Manifest, f : Sprite, dest : Dest,
name += `-${toSmogonAlias(sd.forme)}`;
}
}
if (sn.extra.has("f")) {
name += "-f";
if (sn.extra.has('f')) {
name += '-f';
}
if (sn.extra.has("g")) {
name += "-gmax";
if (sn.extra.has('g')) {
name += '-gmax';
}
stampcopy(manifest, f, dest, name);
}
// TODO: merge with above
export function itemspritecopy(manifest : Manifest, f : Sprite, dest : Dest) : void {
export function itemspritecopy(manifest: Manifest, f: Sprite, dest: Dest): void {
const sn = spritedata.parseFilename(f.name);
if (sn.extension) {
throw new Error(`Not an item sprite: ${f.name}`);
@@ -118,7 +118,7 @@ export function itemspritecopy(manifest : Manifest, f : Sprite, dest : Dest) : v
}
}
export function newspritecopy(ctx : DeployCtx, f : Sprite, dest : Dest) : void {
export function newspritecopy(ctx: DeployCtx, f: Sprite, dest: Dest): void {
const sn = spritedata.parseFilename(f.name);
if (sn.extension) {
return;
@@ -126,11 +126,11 @@ export function newspritecopy(ctx : DeployCtx, f : Sprite, dest : Dest) : void {
const sd = spritedata.get(sn.id);
for (const n of sd.type === 'item' ? sd.names : [sd.base + sd.forme]) {
let name = toPSID(n);
if (sn.extra.has("f")) {
name += "f";
if (sn.extra.has('f')) {
name += 'f';
}
if (sn.extra.has("g")) {
name += "gmax";
if (sn.extra.has('g')) {
name += 'gmax';
}
ctx.copy(f, `${dest.dir}/${name}.${extOf(f, dest.ext)}`);
}

View File

@@ -5,10 +5,10 @@ import {PNG_DETERMINISTIC, base, compresspng, spriteglob} from '../tools/build/h
// Smogdex social images: models, backfilled with gen9 species not yet in
// models (first source wins).
function socialInputs() : string[] {
const social = spriteglob(["src/models/*"], {b: false, s: false});
function socialInputs(): string[] {
const social = spriteglob(['src/models/*'], {b: false, s: false});
const socialSeen = new Set(social.map(base));
for (const file of spriteglob(["src/gen9species/*"], {b: false, s: false})) {
for (const file of spriteglob(['src/gen9species/*'], {b: false, s: false})) {
if (!socialSeen.has(base(file))) {
social.push(file);
socialSeen.add(base(file));
@@ -17,22 +17,22 @@ function socialInputs() : string[] {
return social;
}
export function fbSprites() : Artifact[] {
export function fbSprites(): Artifact[] {
return forEachRule(socialInputs(), {
display: "fbsprite %f",
display: 'fbsprite %f',
cmds: [
`magick convert "%f[0]" ${PNG_DETERMINISTIC} -trim -resize 150x150 -background white -gravity center -extent 198x198 -bordercolor black -border 1 %o`,
compresspng({config: "MODELS"}),
compresspng({config: 'MODELS'}),
],
}, "%B.png");
}, '%B.png');
}
export function twitterSprites() : Artifact[] {
export function twitterSprites(): Artifact[] {
return forEachRule(socialInputs(), {
display: "twittersprite %f",
display: 'twittersprite %f',
cmds: [
`magick convert "%f[0]" ${PNG_DETERMINISTIC} -trim -resize 115x115 -background white -gravity center -extent 120x120 %o`,
compresspng({config: "MODELS"}),
compresspng({config: 'MODELS'}),
],
}, "%B.png");
}, '%B.png');
}

View File

@@ -13,15 +13,15 @@ const xyGen5 = gen5Gifs();
deploy(ctx => {
const seenModels = new Set<string>();
const manifest = new Manifest(ctx);
const xycopy = (f : Sprite) => {
const xycopy = (f: Sprite) => {
if (seenModels.has(f.name)) {
return;
}
seenModels.add(f.name);
spritecopy(manifest, f, {dir: "xy"});
spritecopy(manifest, f, {dir: 'xy'});
};
for (const f of ctx.list("src/models")) {
for (const f of ctx.list('src/models')) {
xycopy(f);
}
for (const f of xyModels) {
@@ -31,7 +31,7 @@ deploy(ctx => {
xycopy(f);
}
// Non-model CAPs
for (const f of ctx.list("src/sprites/gen5")) {
for (const f of ctx.list('src/sprites/gen5')) {
if (f.ext === 'gif') {
xycopy(f);
}
@@ -39,7 +39,7 @@ deploy(ctx => {
for (const f of xyGen5) {
xycopy(f);
}
manifest.write("xy/manifest.json");
manifest.write('xy/manifest.json');
});
// xyicons/: trimmed gen6 minisprites.
@@ -49,9 +49,9 @@ const xyIcons = gen6Trimmed();
deploy(ctx => {
const manifest = new Manifest(ctx);
for (const f of xyIcons) {
spritecopy(manifest, f, {dir: "xyicons"});
spritecopy(manifest, f, {dir: 'xyicons'});
}
manifest.write("xyicons/manifest.json");
manifest.write('xyicons/manifest.json');
});
// Deprecated, unstamped sets:

View File

@@ -11,36 +11,36 @@ import {type Cmd, basenameNoExt, flattenCmds, substitute, substituteNames} from
// rebuilds anything. The digest is resolved by the executor (from the store
// on a clean hit, or by running the rule).
export class Artifact {
readonly name : string; // nominal basename sans ext ("abra", "spritesheet")
readonly ext : string; // "png", "gif", ... (no dot)
readonly decl : RuleDecl;
readonly index : number; // position among decl.outputs
private digest : string | null = null;
readonly name: string; // nominal basename sans ext ("abra", "spritesheet")
readonly ext: string; // "png", "gif", ... (no dot)
readonly decl: RuleDecl;
readonly index: number; // position among decl.outputs
private digest: string | null = null;
constructor(name : string, ext : string, decl : RuleDecl, index : number) {
constructor(name: string, ext: string, decl: RuleDecl, index: number) {
this.name = name;
this.ext = ext;
this.decl = decl;
this.index = index;
}
get filename() : string {
get filename(): string {
return `${this.name}.${this.ext}`;
}
// The producing rule's source-path inputs (provenance).
get sources() : string[] {
get sources(): string[] {
return this.decl.inputs.filter(i => typeof i === 'string');
}
get hash() : string {
get hash(): string {
if (this.digest === null) {
throw new Error(`Artifact ${this.filename} has not been built yet`);
}
return this.digest;
}
resolve(digest : string) : void {
resolve(digest: string): void {
if (this.digest !== null && this.digest !== digest) {
throw new Error(`Artifact ${this.filename} resolved twice with different digests`);
}
@@ -51,54 +51,54 @@ export class Artifact {
export type Input = string | Artifact; // string = source path relative to repo root
export interface CmdSpec {
display? : string;
display?: string;
// Tracked-but-not-substituted inputs: part of rule identity, but never
// expanded into %f. Use for files a tool reads on its own (e.g.
// tools/sheet readdirs the minisprite directories).
deps? : Input | Input[];
deps?: Input | Input[];
// IMPORTANT: set this on any rule whose output BYTES depend on input
// NAMES (a tool that readdirs, or parses ids out of %f filenames, e.g.
// the spritesheet builders). Identity is normally content-only, so
// without this flag a same-bytes rename would leave the output stale.
nameSensitive? : boolean;
cmds : Cmd[];
nameSensitive?: boolean;
cmds: Cmd[];
}
export interface RuleDecl {
id : number; // registration order; identity for artifact inputs
inputs : Input[]; // ordered (%f order; some rules are order-sensitive)
deps : Input[];
outputs : Artifact[];
cmds : string[]; // flattened PRE-substitution templates
display : string | null; // nominally substituted; cosmetic
displayTemplate : string | null; // pre-substitution; groups forEach rules
nameSensitive : boolean;
id: number; // registration order; identity for artifact inputs
inputs: Input[]; // ordered (%f order; some rules are order-sensitive)
deps: Input[];
outputs: Artifact[];
cmds: string[]; // flattened PRE-substitution templates
display: string | null; // nominally substituted; cosmetic
displayTemplate: string | null; // pre-substitution; groups forEach rules
nameSensitive: boolean;
}
let decls : RuleDecl[] = [];
let decls: RuleDecl[] = [];
// Declaring an identical rule twice returns the existing artifacts, so
// shared rule sets are plain functions that any number of deploys may call.
let declIndex = new Map<string, RuleDecl>();
export function getDecls() : RuleDecl[] {
export function getDecls(): RuleDecl[] {
return decls;
}
export function resetDecls() : void {
export function resetDecls(): void {
decls = [];
declIndex = new Map();
}
// Nominal path of an input, for %b/%B and displays.
function nominal(i : Input) : string {
function nominal(i: Input): string {
return typeof i === 'string' ? i : i.filename;
}
function extOf(i : Input) : string {
function extOf(i: Input): string {
return typeof i === 'string' ? pathlib.extname(i) : `.${i.ext}`;
}
function pathOrThrow(i : Input, what : string) : string {
function pathOrThrow(i: Input, what: string): string {
if (typeof i !== 'string') {
throw new Error(`nameSensitive rules with artifact ${what} are not yet supported`);
}
@@ -110,7 +110,7 @@ function pathOrThrow(i : Input, what : string) : string {
// source renames and byte-identical duplicates dedupe for free. Output exts
// are included because tools pick output formats from extensions; nominal
// names are not.
export function computeKey(decl : RuleDecl, digestOf : (i : Input) => string) : string {
export function computeKey(decl: RuleDecl, digestOf: (i: Input) => string): string {
const h = createHash('sha256');
h.update([
'v2',
@@ -128,17 +128,17 @@ export function computeKey(decl : RuleDecl, digestOf : (i : Input) => string) :
return h.digest('hex');
}
function normalizeSpec(spec : CmdSpec | Cmd[]) : CmdSpec {
function normalizeSpec(spec: CmdSpec | Cmd[]): CmdSpec {
return Array.isArray(spec) ? {cmds: spec} : spec;
}
// Glob string patterns; artifacts pass through. Order is preserved.
function resolveInputs(input : Input | Input[] | undefined) : Input[] {
function resolveInputs(input: Input | Input[] | undefined): Input[] {
const list = input === undefined ? [] : Array.isArray(input) ? input : [input];
return list.flatMap((i) : Input[] => typeof i === 'string' ? glob(i) : [i]);
return list.flatMap((i): Input[] => typeof i === 'string' ? glob(i) : [i]);
}
function makeDecl(inputs : Input[], deps : Input[], spec : CmdSpec, outputs : string[]) : RuleDecl {
function makeDecl(inputs: Input[], deps: Input[], spec: CmdSpec, outputs: string[]): RuleDecl {
const nominalInputs = inputs.map(nominal);
// %b/%B expand to nominal input names, never CAS paths, so they are
// resolved at declaration. This also lands them in the identity key: a
@@ -153,7 +153,7 @@ function makeDecl(inputs : Input[], deps : Input[], spec : CmdSpec, outputs : st
// An identical declaration returns the already-registered rule. Artifact
// inputs are keyed by their producing rule's id, so chains dedupe too.
const token = (i : Input) => typeof i === 'string' ? `s:${i}` : `a:${i.decl.id}:${i.index}`;
const token = (i: Input) => typeof i === 'string' ? `s:${i}` : `a:${i.decl.id}:${i.index}`;
const identity = [
cmds.join('\0'),
inputs.map(token).join('\0'),
@@ -167,7 +167,7 @@ function makeDecl(inputs : Input[], deps : Input[], spec : CmdSpec, outputs : st
return existing;
}
const decl : RuleDecl = {
const decl: RuleDecl = {
id: decls.length,
inputs,
deps,
@@ -216,20 +216,20 @@ function makeDecl(inputs : Input[], deps : Input[], spec : CmdSpec, outputs : st
// One Artifact per declared output, as a tuple when the output list is a
// literal, so `const [png, css] = rule(...)` needs no undefined checks. A
// single string output returns its Artifact directly.
export function rule(input : Input | Input[], spec : CmdSpec | Cmd[],
output : string) : Artifact;
export function rule(input: Input | Input[], spec: CmdSpec | Cmd[],
output: string): Artifact;
export function rule<const T extends readonly string[]>(
input : Input | Input[], spec : CmdSpec | Cmd[],
output : T) : {[K in keyof T] : Artifact};
export function rule(input : Input | Input[], spec : CmdSpec | Cmd[],
output : string | readonly string[]) : Artifact | Artifact[] {
input: Input | Input[], spec: CmdSpec | Cmd[],
output: T): {[K in keyof T]: Artifact};
export function rule(input: Input | Input[], spec: CmdSpec | Cmd[],
output: string | readonly string[]): Artifact | Artifact[] {
const s = normalizeSpec(spec);
const decl = makeDecl(resolveInputs(input), resolveInputs(s.deps), s, astable(output));
return typeof output === 'string' ? decl.outputs[0]! : decl.outputs;
}
export function forEachRule(input : Input | Input[], spec : CmdSpec | Cmd[],
output : string) : Artifact[] {
export function forEachRule(input: Input | Input[], spec: CmdSpec | Cmd[],
output: string): Artifact[] {
const s = normalizeSpec(spec);
if (/%[fo]/.test(output)) {
throw new Error(`forEachRule output template may only use %b/%B: ${output}`);

View File

@@ -11,18 +11,18 @@ import {hashFileSync} from './hash.ts';
// store. Writers stage the file elsewhere on the same filesystem and insert
// it with an atomic rename.
export function casPath(casDir : string, digest : string, ext : string) : string {
export function casPath(casDir: string, digest: string, ext: string): string {
return pathlib.join(casDir, digest.slice(0, 2), `${digest}.${ext}`);
}
export function casExists(casDir : string, digest : string, ext : string) : boolean {
export function casExists(casDir: string, digest: string, ext: string): boolean {
return casStat(casDir, digest, ext) !== null;
}
// Size of an object, or null if absent. Callers verify it against the
// recorded size: a crash between rename and data flush can leave a
// truncated object, which must read as dirty, not clean.
export function casStat(casDir : string, digest : string, ext : string) : bigint | null {
export function casStat(casDir: string, digest: string, ext: string): bigint | null {
try {
const st = fs.statSync(casPath(casDir, digest, ext), {bigint: true});
return st.isFile() ? st.size : null;
@@ -32,14 +32,14 @@ export function casStat(casDir : string, digest : string, ext : string) : bigint
}
export interface CasObject {
digest : string; // sha256 hex of the bytes
size : bigint;
digest: string; // sha256 hex of the bytes
size: bigint;
}
// Move tmpPath into the store, returning the content digest and size. An
// existing object is trusted only if its bytes actually hash to the digest;
// otherwise (crash-truncated object) the fresh bytes replace it.
export function casInsert(casDir : string, tmpPath : string, ext : string) : CasObject {
export function casInsert(casDir: string, tmpPath: string, ext: string): CasObject {
const digest = hashFileSync(tmpPath).toString('hex');
const size = fs.statSync(tmpPath, {bigint: true}).size;
const target = casPath(casDir, digest, ext);
@@ -63,13 +63,13 @@ export function casInsert(casDir : string, tmpPath : string, ext : string) : Cas
// Remove every object not in `live` (keys are "<digest>.<ext>", the object
// basename) and prune emptied fanout directories. Returns the removal count.
export function casSweep(casDir : string, live : Set<string>) : number {
export function casSweep(casDir: string, live: Set<string>): number {
let removed = 0;
let fanout : fs.Dirent[];
let fanout: fs.Dirent[];
try {
fanout = fs.readdirSync(casDir, {withFileTypes: true});
} catch (err) {
if ((err as {code? : string}).code === 'ENOENT') {
if ((err as {code?: string}).code === 'ENOENT') {
return 0;
}
throw err;

View File

@@ -1,7 +1,7 @@
import fs from 'fs';
export function parseConfig(text : string) : Map<string, string> {
export function parseConfig(text: string): Map<string, string> {
const result = new Map<string, string>();
for (let line of text.split('\n')) {
line = line.trim();
@@ -17,7 +17,7 @@ export function parseConfig(text : string) : Map<string, string> {
return result;
}
export function loadConfig(path : string) : Map<string, string> {
export function loadConfig(path: string): Map<string, string> {
if (!fs.existsSync(path)) {
return new Map();
}

View File

@@ -7,29 +7,29 @@ import {reconcileHashes} from './hash.ts';
import {type Store} from './store.ts';
export interface BuildOpts {
root : string;
store : Store;
casDir : string; // relative to root; substituted into commands
tmpDir : string;
jobs : number;
dryRun : boolean;
failFast : boolean;
verbose : boolean;
root: string;
store: Store;
casDir: string; // relative to root; substituted into commands
tmpDir: string;
jobs: number;
dryRun: boolean;
failFast: boolean;
verbose: boolean;
// GC after a fully successful build: drop rules whose key is no longer
// declared, sweep unreferenced CAS objects, prune the file cache. Only
// safe when `decls` is the full rule universe (a partial build would GC
// the other deploys' state), so the CLI sets it for union builds only.
gc : boolean;
signal : AbortSignal;
log? : (line : string) => void;
logError? : (line : string) => void;
gc: boolean;
signal: AbortSignal;
log?: (line: string) => void;
logError?: (line: string) => void;
}
export interface DriveResult extends BuildResult {
interrupted : boolean;
interrupted: boolean;
}
export async function build(decls : readonly RuleDecl[], opts : BuildOpts) : Promise<DriveResult> {
export async function build(decls: readonly RuleDecl[], opts: BuildOpts): Promise<DriveResult> {
const log = opts.log ?? console.log;
const logError = opts.logError ?? console.error;
const {store} = opts;
@@ -38,7 +38,7 @@ export async function build(decls : readonly RuleDecl[], opts : BuildOpts) : Pro
// producers of its artifact inputs, even ones outside `decls`.
const closure = new Set<RuleDecl>();
const sources = new Set<string>();
const add = (decl : RuleDecl) => {
const add = (decl: RuleDecl) => {
if (closure.has(decl)) {
return;
}

View File

@@ -2,16 +2,16 @@
import {spawn} from 'child_process';
export interface ExecResult {
code : number | null;
signal : NodeJS.Signals | null;
output : string;
durationMs : number;
code: number | null;
signal: NodeJS.Signals | null;
output: string;
durationMs: number;
}
const livePids = new Set<number>();
// Emergency stop (e.g. second Ctrl-C): SIGKILL every live process group.
export function killAllProcessGroups() : void {
export function killAllProcessGroups(): void {
for (const pid of livePids) {
try {
process.kill(-pid, 'SIGKILL');
@@ -22,7 +22,7 @@ export function killAllProcessGroups() : void {
// The command script is fed to sh via stdin rather than -c: a single argv
// entry is capped by the kernel (MAX_ARG_STRLEN, ~128KB) and the largest %f
// expansion is already 80KB+.
export function runShell(command : string, opts : {cwd : string, signal : AbortSignal}) : Promise<ExecResult> {
export function runShell(command: string, opts: {cwd: string, signal: AbortSignal}): Promise<ExecResult> {
return new Promise((resolve, reject) => {
const start = performance.now();
// detached: own process group, so an abort kills grandchildren
@@ -31,14 +31,14 @@ export function runShell(command : string, opts : {cwd : string, signal : AbortS
if (child.pid !== undefined) {
livePids.add(child.pid);
}
const chunks : Buffer[] = [];
const chunks: Buffer[] = [];
child.stdout.on('data', c => chunks.push(c));
child.stderr.on('data', c => chunks.push(c));
child.stdin.on('error', () => {}); // EPIPE if the shell exits early
child.stdin.end(command + '\n');
let killTimer : NodeJS.Timeout | undefined;
const kill = (sig : NodeJS.Signals) => {
let killTimer: NodeJS.Timeout | undefined;
const kill = (sig: NodeJS.Signals) => {
try {
process.kill(-child.pid!, sig);
} catch {}

View File

@@ -12,38 +12,38 @@ import {substitute} from './subst.ts';
export type DirtyReason = 'new' | 'cas-missing';
export type RuleOutcome =
| {status : 'clean'} // key hit, CAS objects present
| {status : 'ran', reason : DirtyReason}
| {status : 'would-run', reason : DirtyReason | 'blocked'} // dry run
| {status : 'failed', message : string}
| {status : 'blocked'}; // a producer failed
| {status: 'clean'} // key hit, CAS objects present
| {status: 'ran', reason: DirtyReason}
| {status: 'would-run', reason: DirtyReason | 'blocked'} // dry run
| {status: 'failed', message: string}
| {status: 'blocked'}; // a producer failed
export interface BuildResult {
outcomes : Map<RuleDecl, RuleOutcome>; // no entry = not attempted (aborted)
keys : Map<RuleDecl, string>; // only decls whose key resolved
ok : boolean; // every decl clean or ran
outcomes: Map<RuleDecl, RuleOutcome>; // no entry = not attempted (aborted)
keys: Map<RuleDecl, string>; // only decls whose key resolved
ok: boolean; // every decl clean or ran
}
export interface ExecutorOpts {
root : string; // cwd for commands
store : Store;
casDir : string; // relative to root (substituted into commands)
tmpDir : string;
jobs : number;
dryRun : boolean;
failFast : boolean;
verbose? : boolean; // annotate run lines with the dirty reason
sourceHashes : Map<string, Buffer>; // every source input/dep, pre-reconciled
signal : AbortSignal;
log? : (line : string) => void;
logError? : (line : string) => void;
root: string; // cwd for commands
store: Store;
casDir: string; // relative to root (substituted into commands)
tmpDir: string;
jobs: number;
dryRun: boolean;
failFast: boolean;
verbose?: boolean; // annotate run lines with the dirty reason
sourceHashes: Map<string, Buffer>; // every source input/dep, pre-reconciled
signal: AbortSignal;
log?: (line: string) => void;
logError?: (line: string) => void;
}
export function label(decl : RuleDecl) : string {
export function label(decl: RuleDecl): string {
return decl.display ?? decl.cmds[0]!;
}
function indent(text : string) : string {
function indent(text: string): string {
return text.replace(/\n$/, '').split('\n').map(l => ' ' + l).join('\n');
}
@@ -54,14 +54,14 @@ class DryDirty extends Error {}
class Aborted extends Error {}
class Semaphore {
private available : number;
private waiters : (() => void)[] = [];
private available: number;
private waiters: (() => void)[] = [];
constructor(n : number) {
constructor(n: number) {
this.available = n;
}
async acquire() : Promise<void> {
async acquire(): Promise<void> {
if (this.available > 0) {
this.available--;
return;
@@ -69,7 +69,7 @@ class Semaphore {
await new Promise<void>(resolve => this.waiters.push(resolve));
}
release() : void {
release(): void {
const waiter = this.waiters.shift();
if (waiter !== undefined) {
waiter();
@@ -85,20 +85,20 @@ class Semaphore {
// The artifact graph is a DAG by construction (a rule can only reference
// artifacts that already exist as values), so there is no cycle check.
export class Executor {
private opts : ExecutorOpts;
private opts: ExecutorOpts;
private memo = new Map<RuleDecl, Promise<string[]>>();
private inflightByKey = new Map<string, Promise<string[]>>();
private outcomes = new Map<RuleDecl, RuleOutcome>();
private keys = new Map<RuleDecl, string>();
private semaphore : Semaphore;
private semaphore: Semaphore;
private failAc = new AbortController();
private runSignal : AbortSignal;
private runSignal: AbortSignal;
private counter = 0;
private tmpSeq = 0;
private log : (line : string) => void;
private logError : (line : string) => void;
private log: (line: string) => void;
private logError: (line: string) => void;
constructor(opts : ExecutorOpts) {
constructor(opts: ExecutorOpts) {
this.opts = opts;
this.semaphore = new Semaphore(opts.jobs);
this.runSignal = AbortSignal.any([opts.signal, this.failAc.signal]);
@@ -106,7 +106,7 @@ export class Executor {
this.logError = opts.logError ?? console.error;
}
async build(decls : readonly RuleDecl[]) : Promise<BuildResult> {
async build(decls: readonly RuleDecl[]): Promise<BuildResult> {
await Promise.allSettled(decls.map(d => this.demand(d)));
const ok = decls.every(d => {
const status = this.outcomes.get(d)?.status;
@@ -115,7 +115,7 @@ export class Executor {
return {outcomes: this.outcomes, keys: this.keys, ok};
}
private demand(decl : RuleDecl) : Promise<string[]> {
private demand(decl: RuleDecl): Promise<string[]> {
let p = this.memo.get(decl);
if (p === undefined) {
// Any non-sentinel escape (a store error, a resolve conflict, a
@@ -136,7 +136,7 @@ export class Executor {
return p;
}
private async digestOf(i : Input) : Promise<string> {
private async digestOf(i: Input): Promise<string> {
if (typeof i !== 'string') {
await this.demand(i.decl);
return i.hash;
@@ -148,8 +148,8 @@ export class Executor {
return hash.toString('hex');
}
private async demandInner(decl : RuleDecl) : Promise<string[]> {
let digests : Map<Input, string>;
private async demandInner(decl: RuleDecl): Promise<string[]> {
let digests: Map<Input, string>;
try {
const inputs = [...decl.inputs, ...decl.deps];
const resolved = await Promise.all(inputs.map(i => this.digestOf(i)));
@@ -191,7 +191,7 @@ export class Executor {
return result;
}
private async perform(decl : RuleDecl, key : string) : Promise<string[]> {
private async perform(decl: RuleDecl, key: string): Promise<string[]> {
const {store, casDir} = this.opts;
const stored = store.lookupRule(key);
if (stored !== null
@@ -201,7 +201,7 @@ export class Executor {
this.outcomes.set(decl, {status: 'clean'});
return stored.map(o => o.digest);
}
const reason : DirtyReason = stored === null ? 'new' : 'cas-missing';
const reason: DirtyReason = stored === null ? 'new' : 'cas-missing';
if (this.opts.dryRun) {
this.outcomes.set(decl, {status: 'would-run', reason});
@@ -220,7 +220,7 @@ export class Executor {
}
}
private async execute(decl : RuleDecl, key : string, reason : DirtyReason) : Promise<string[]> {
private async execute(decl: RuleDecl, key: string, reason: DirtyReason): Promise<string[]> {
const {store, casDir, tmpDir, root} = this.opts;
const ruleTmp = pathlib.join(tmpDir, String(this.tmpSeq++));
fs.mkdirSync(ruleTmp, {recursive: true});
@@ -261,7 +261,7 @@ export class Executor {
// Nothing is recorded for a failure: failed and never-ran are the same
// state, so the rule stays dirty.
private fail(decl : RuleDecl, command : string, output : string, message : string) : never {
private fail(decl: RuleDecl, command: string, output: string, message: string): never {
this.outcomes.set(decl, {status: 'failed', message});
this.logError(`FAILED: ${label(decl)} (${message})`);
this.logError(` command: ${command}`);

View File

@@ -3,25 +3,25 @@ import fs from 'fs';
import {createHash} from 'crypto';
export interface FileStat {
size : bigint;
mtimeNs : bigint;
hash : Buffer;
size: bigint;
mtimeNs: bigint;
hash: Buffer;
}
export function hashFileSync(path : string) : Buffer {
export function hashFileSync(path: string): Buffer {
return createHash('sha256').update(fs.readFileSync(path)).digest();
}
export interface ReconcileResult {
hashes : Map<string, Buffer>; // current content hash for every extant path
updated : Map<string, FileStat>; // cache entries that changed (to persist)
missing : string[]; // paths that don't exist or aren't files
hashes: Map<string, Buffer>; // current content hash for every extant path
updated: Map<string, FileStat>; // cache entries that changed (to persist)
missing: string[]; // paths that don't exist or aren't files
}
// Content hashes with a stat cache: only files whose (size, mtime_ns) changed
// since the recorded cache entry are rehashed. mtime_ns exceeds 2^53, hence
// bigint stats throughout.
export function reconcileHashes(paths : Iterable<string>, cache : Map<string, FileStat>) : ReconcileResult {
export function reconcileHashes(paths: Iterable<string>, cache: Map<string, FileStat>): ReconcileResult {
const hashes = new Map<string, Buffer>();
const updated = new Map<string, FileStat>();
const missing = [];

View File

@@ -7,16 +7,16 @@ import {type Cmd, basenameNoExt} from './subst.ts';
let config = new Map<string, string>();
export function setConfig(cfg : Map<string, string>) : void {
export function setConfig(cfg: Map<string, string>): void {
config = cfg;
}
export function getconfig(name : string) : string | undefined {
export function getconfig(name: string): string | undefined {
const value = config.get(name);
return value === '' ? undefined : value;
}
export function astable(x : string | readonly string[] | undefined) : string[] {
export function astable(x: string | readonly string[] | undefined): string[] {
if (x === undefined) {
return [];
}
@@ -27,7 +27,7 @@ export function astable(x : string | readonly string[] | undefined) : string[] {
// Non-glob strings pass through literally; existence is checked at hash time.
// Results are sorted within a pattern; declared order is preserved across
// patterns (some rules, e.g. the pokeball sheet, are input-order-sensitive).
function globOne(pat : string) : string[] {
function globOne(pat: string): string[] {
if (!pat.includes('*')) {
return [pat];
}
@@ -55,26 +55,26 @@ function globOne(pat : string) : string[] {
return results;
}
export function glob(pats : string | string[]) : string[] {
export function glob(pats: string | string[]): string[] {
return astable(pats).flatMap(globOne);
}
// tup.base: basename without directory or final extension. For an artifact,
// its nominal name.
export function base(x : string | Artifact) : string {
export function base(x: string | Artifact): string {
return typeof x === 'string' ? basenameNoExt(x) : x.name;
}
export interface SpriteData {
id : string;
data : Record<string, string | true>;
id: string;
data: Record<string, string | true>;
}
// Port of util/sprites.lua spritedata. Lua used gmatch("[^-]+"), which skips
// empty segments, hence the filter.
export function spritedata(basename : string) : SpriteData {
export function spritedata(basename: string): SpriteData {
const parts = basename.split('-').filter(p => p !== '');
const data : Record<string, string | true> = {};
const data: Record<string, string | true> = {};
for (const part of parts.slice(1)) {
if (part.length === 1) {
data[part] = true;
@@ -85,7 +85,7 @@ export function spritedata(basename : string) : SpriteData {
return {id: parts[0] ?? '', data};
}
export function spriteglob(pats : string | string[], flagspec? : Record<string, unknown>) : string[] {
export function spriteglob(pats: string | string[], flagspec?: Record<string, unknown>): string[] {
return glob(pats).filter(filename => {
const sd = spritedata(base(filename));
for (const [k, v] of Object.entries(flagspec ?? {})) {
@@ -101,31 +101,31 @@ export function spriteglob(pats : string | string[], flagspec? : Record<string,
// churn every content-addressed name on a rebuild.
export const PNG_DETERMINISTIC = '-define png:exclude-chunks=date,time';
export function pad(opts : {w : number, h : number, input? : string, output? : string}) : string {
export function pad(opts: {w: number, h: number, input?: string, output?: string}): string {
const input = opts.input ?? '%f';
const output = opts.output ?? '%o';
return `magick convert ${input} ${PNG_DETERMINISTIC} -background transparent -gravity center -extent ${opts.w}x${opts.h} ${output}`;
}
export function trimimg(opts : {input? : string, output? : string} = {}) : string {
export function trimimg(opts: {input?: string, output?: string} = {}): string {
return `magick convert ${opts.input ?? '%f'} ${PNG_DETERMINISTIC} -trim ${opts.output ?? '%o'}`;
}
interface CompressOpts {
pngquant? : string;
optipng? : string;
advpng? : string;
pngquant?: string;
optipng?: string;
advpng?: string;
}
function compressopts(program : string, copts : CompressOpts) : void {
function compressopts(program: string, copts: CompressOpts): void {
copts.pngquant = getconfig(`${program}_PNGQUANT`) ?? copts.pngquant;
copts.optipng = getconfig(`${program}_OPTIPNG`) ?? copts.optipng;
copts.advpng = getconfig(`${program}_ADVPNG`) ?? copts.advpng;
}
export function compresspng(opts : {config? : string, output? : string} = {}) : Cmd[] {
export function compresspng(opts: {config?: string, output?: string} = {}): Cmd[] {
const output = opts.output ?? '%o';
const copts : CompressOpts = {};
const copts: CompressOpts = {};
compressopts('DEFAULT', copts);
if (opts.config) {
compressopts(opts.config, copts);

View File

@@ -7,9 +7,9 @@ import {BuildError} from './errors.ts';
import type {FileStat} from './hash.ts';
export interface StoredOutput {
digest : string; // sha256 hex of the bytes; the CAS object is <digest>.<ext>
ext : string;
size : bigint; // verified against the object on every clean check
digest: string; // sha256 hex of the bytes; the CAS object is <digest>.<ext>
ext: string;
size: bigint; // verified against the object on every clean check
}
const DDL = `
@@ -39,15 +39,15 @@ CREATE TABLE IF NOT EXISTS rule_outputs (
);
`;
function userVersion(db : DatabaseSync) : number {
const row = db.prepare('PRAGMA user_version').get() as {user_version : bigint | number};
function userVersion(db: DatabaseSync): number {
const row = db.prepare('PRAGMA user_version').get() as {user_version: bigint | number};
return Number(row.user_version);
}
export class Store {
private db : DatabaseSync;
private db: DatabaseSync;
constructor(dbPath : string) {
constructor(dbPath: string) {
fs.mkdirSync(pathlib.dirname(dbPath), {recursive: true});
this.db = new DatabaseSync(dbPath, {readBigInts: true});
this.db.exec('PRAGMA journal_mode = WAL');
@@ -56,7 +56,7 @@ export class Store {
this.migrate();
}
private transaction<T>(fn : () => T) : T {
private transaction<T>(fn: () => T): T {
this.db.exec('BEGIN');
try {
const result = fn();
@@ -68,7 +68,7 @@ export class Store {
}
}
private migrate() : void {
private migrate(): void {
const version = userVersion(this.db);
if (version === 0) {
this.db.exec('BEGIN;' + DDL + 'PRAGMA user_version = 2; COMMIT;');
@@ -88,10 +88,10 @@ export class Store {
}
}
loadFileCache() : Map<string, FileStat> {
loadFileCache(): Map<string, FileStat> {
const result = new Map<string, FileStat>();
const rows = this.db.prepare('SELECT path, size, mtime_ns, hash FROM file_cache').all() as
unknown as {path : string, size : bigint, mtime_ns : bigint, hash : Uint8Array}[];
unknown as {path: string, size: bigint, mtime_ns: bigint, hash: Uint8Array}[];
for (const row of rows) {
const h = row.hash;
result.set(row.path, {size: row.size, mtimeNs: row.mtime_ns,
@@ -100,7 +100,7 @@ export class Store {
return result;
}
saveFileCache(entries : Map<string, FileStat>) : void {
saveFileCache(entries: Map<string, FileStat>): void {
const upsert = this.db.prepare(`
INSERT INTO file_cache (path, size, mtime_ns, hash) VALUES (?, ?, ?, ?)
ON CONFLICT(path) DO UPDATE SET
@@ -112,9 +112,9 @@ export class Store {
});
}
pruneFileCache(live : Set<string>) : void {
pruneFileCache(live: Set<string>): void {
const paths = this.db.prepare('SELECT path FROM file_cache').all() as
unknown as {path : string}[];
unknown as {path: string}[];
const del = this.db.prepare('DELETE FROM file_cache WHERE path = ?');
this.transaction(() => {
for (const {path} of paths) {
@@ -125,10 +125,10 @@ export class Store {
});
}
lookupRule(key : string) : StoredOutput[] | null {
lookupRule(key: string): StoredOutput[] | null {
return this.transaction(() => {
const rule = this.db.prepare('SELECT id FROM rules WHERE key = ?').get(key) as
{id : bigint} | undefined;
{id: bigint} | undefined;
if (rule === undefined) {
return null;
}
@@ -142,7 +142,7 @@ export class Store {
// One transaction per completed rule: an interrupted build only ever
// contains fully-recorded rules.
recordRule(key : string, cmds : string[], outputs : StoredOutput[]) : void {
recordRule(key: string, cmds: string[], outputs: StoredOutput[]): void {
const upsert = this.db.prepare(`
INSERT INTO rules (key, cmds) VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET cmds = excluded.cmds
@@ -151,16 +151,16 @@ export class Store {
const insOutput = this.db.prepare(
'INSERT INTO rule_outputs (rule_id, ord, digest, ext, size) VALUES (?, ?, ?, ?, ?)');
this.transaction(() => {
const {id} = upsert.get(key, cmds.join('\n')) as {id : bigint};
const {id} = upsert.get(key, cmds.join('\n')) as {id: bigint};
delOutputs.run(id);
outputs.forEach((o, i) => insOutput.run(id, i, o.digest, o.ext, o.size));
});
}
// GC: drop every rule whose key is not live. Returns the removal count.
deleteKeysNotIn(live : Set<string>) : number {
deleteKeysNotIn(live: Set<string>): number {
const keys = this.db.prepare('SELECT id, key FROM rules').all() as
unknown as {id : bigint, key : string}[];
unknown as {id: bigint, key: string}[];
const del = this.db.prepare('DELETE FROM rules WHERE id = ?');
let removed = 0;
this.transaction(() => {
@@ -175,13 +175,13 @@ export class Store {
}
// Every CAS object referenced by some rule, as "<digest>.<ext>" basenames.
liveObjects() : Set<string> {
liveObjects(): Set<string> {
const rows = this.db.prepare('SELECT digest, ext FROM rule_outputs').all() as
unknown as {digest : string, ext : string}[];
unknown as {digest: string, ext: string}[];
return new Set(rows.map(r => `${r.digest}.${r.ext}`));
}
close() : void {
close(): void {
this.db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
this.db.close();
}
@@ -189,7 +189,7 @@ export class Store {
// Schema version of an existing db, without opening it for writing (the
// Store constructor migrates); null if there is no db.
export function dbVersion(dbPath : string) : number | null {
export function dbVersion(dbPath: string): number | null {
if (!fs.existsSync(dbPath)) {
return null;
}
@@ -203,14 +203,14 @@ export function dbVersion(dbPath : string) : number | null {
// Guards against two concurrent builds; the exclusive transaction is released
// by the OS on any crash, so there are no stale lock files.
export function acquireLock(lockPath : string) : () => void {
export function acquireLock(lockPath: string): () => void {
fs.mkdirSync(pathlib.dirname(lockPath), {recursive: true});
const lock = new DatabaseSync(lockPath, {timeout: 0});
try {
lock.exec('BEGIN EXCLUSIVE');
} catch (err) {
lock.close();
if ((err as {errcode? : number}).errcode === 5) { // SQLITE_BUSY
if ((err as {errcode?: number}).errcode === 5) { // SQLITE_BUSY
throw new BuildError('Another build is already running.');
}
throw err;

View File

@@ -5,7 +5,7 @@ import pathlib from 'path';
// of them (flattened, like the Lua flatten()).
export type Cmd = string | Cmd[];
export function flattenCmds(cmds : Cmd) : string[] {
export function flattenCmds(cmds: Cmd): string[] {
if (typeof cmds === 'string') {
const trimmed = cmds.trim();
return trimmed === '' ? [] : [trimmed];
@@ -13,7 +13,7 @@ export function flattenCmds(cmds : Cmd) : string[] {
return cmds.flatMap(flattenCmds);
}
export function basenameNoExt(path : string) : string {
export function basenameNoExt(path: string): string {
const base = pathlib.basename(path);
const dot = base.lastIndexOf('.');
return dot > 0 ? base.slice(0, dot) : base;
@@ -22,8 +22,8 @@ export function basenameNoExt(path : string) : string {
// Expand only the name substitutions (%b/%B). They are static per rule, so
// declarations expand them eagerly; %f/%o/%oN wait for execution, when
// concrete paths exist.
export function substituteNames(s : string, inputs : string[]) : string {
return s.replace(/%([bB])/g, (_, c : string) =>
export function substituteNames(s: string, inputs: string[]): string {
return s.replace(/%([bB])/g, (_, c: string) =>
c === 'b' ? inputs.map(p => pathlib.basename(p)).join(' ')
: inputs.map(basenameNoExt).join(' '));
}
@@ -32,8 +32,8 @@ export function substituteNames(s : string, inputs : string[]) : string {
// %f inputs, space-joined %b input basenames
// %o outputs, space-joined %B input basenames without extension
// %oN (1-based) a single output, for rules with several
export function substitute(s : string, inputs : string[], outputs : string[]) : string {
return s.replace(/%o(\d+)|%([a-zA-Z])/g, (match, n : string | undefined, c : string | undefined) => {
export function substitute(s: string, inputs: string[], outputs: string[]): string {
return s.replace(/%o(\d+)|%([a-zA-Z])/g, (match, n: string | undefined, c: string | undefined) => {
if (n !== undefined) {
const i = Number(n);
if (i < 1 || i > outputs.length) {

View File

@@ -6,7 +6,7 @@ import {type Input, computeKey, forEachRule, getDecls, resetDecls, rule} from '.
beforeEach(resetDecls);
function digests(map : Record<string, string>) : (i : Input) => string {
function digests(map: Record<string, string>): (i: Input) => string {
return i => {
if (typeof i !== 'string') {
return i.hash;

View File

@@ -8,11 +8,11 @@ import {test} from 'node:test';
import {casExists, casInsert, casPath, casStat, casSweep} from '../cas.ts';
function makeTmpRoot() : string {
function makeTmpRoot(): string {
return fs.mkdtempSync(pathlib.join(os.tmpdir(), 'cas-test-'));
}
function stage(root : string, data : string) : string {
function stage(root: string, data: string): string {
const p = pathlib.join(root, `stage-${Math.random().toString(36).slice(2)}`);
fs.writeFileSync(p, data);
return p;

View File

@@ -14,14 +14,14 @@ import {Store} from '../store.ts';
beforeEach(resetDecls);
interface Env {
root : string;
dbPath : string;
casDir : string;
tmpDir : string;
execLog : string;
root: string;
dbPath: string;
casDir: string;
tmpDir: string;
execLog: string;
}
function setup() : Env {
function setup(): Env {
const root = fs.mkdtempSync(pathlib.join(os.tmpdir(), 'executor-test-'));
fs.mkdirSync(pathlib.join(root, 'src'));
return {
@@ -33,19 +33,19 @@ function setup() : Env {
};
}
function src(env : Env, name : string, content : string) : string {
function src(env: Env, name: string, content: string): string {
const p = pathlib.join(env.root, 'src', name);
fs.writeFileSync(p, content);
return p;
}
// A copy rule that also counts its executions in env.execLog.
function copyRule(env : Env, input : string | Artifact, out : string,
extra : Partial<CmdSpec> = {}) : Artifact {
function copyRule(env: Env, input: string | Artifact, out: string,
extra: Partial<CmdSpec> = {}): Artifact {
return rule(input, {cmds: [`cat %f > %o && echo x >> ${env.execLog}`], ...extra}, out);
}
function execCount(env : Env) : number {
function execCount(env: Env): number {
try {
return fs.readFileSync(env.execLog, 'utf8').split('\n').filter(l => l !== '').length;
} catch {
@@ -53,8 +53,8 @@ function execCount(env : Env) : number {
}
}
async function runBuild(env : Env, opts : {dryRun? : boolean, gc? : boolean} = {},
decls = getDecls()) : Promise<DriveResult> {
async function runBuild(env: Env, opts: {dryRun?: boolean, gc?: boolean} = {},
decls = getDecls()): Promise<DriveResult> {
const store = new Store(env.dbPath);
try {
return await build(decls, {
@@ -76,7 +76,7 @@ async function runBuild(env : Env, opts : {dryRun? : boolean, gc? : boolean} = {
}
}
function statuses(result : DriveResult) : RuleOutcome['status'][] {
function statuses(result: DriveResult): RuleOutcome['status'][] {
return getDecls().map(d => result.outcomes.get(d)!.status);
}
@@ -147,7 +147,7 @@ test('byte-identical inputs share one execution across two declarations', async
// The consumer must be a *different* computation: an identical command over
// identical bytes would share the producer's key (by design).
function upcaseRule(env : Env, input : Artifact, out : string) : Artifact {
function upcaseRule(env: Env, input: Artifact, out: string): Artifact {
return rule(input, [`tr a-z A-Z < %f > %o && echo x >> ${env.execLog}`], out);
}
@@ -213,7 +213,7 @@ test('a missing declared output fails the rule', async () => {
assert.ok(!result.ok);
const outcome = result.outcomes.get(getDecls()[0]!)!;
assert.equal(outcome.status, 'failed');
assert.match((outcome as {message : string}).message, /did not produce: missing.css/);
assert.match((outcome as {message: string}).message, /did not produce: missing.css/);
const store = new Store(env.dbPath);
assert.equal(store.liveObjects().size, 0);
store.close();

View File

@@ -9,7 +9,7 @@ import {DatabaseSync} from 'node:sqlite';
import {Store} from '../store.ts';
function makeDbPath() : string {
function makeDbPath(): string {
return pathlib.join(fs.mkdtempSync(pathlib.join(os.tmpdir(), 'store-test-')), 'db.sqlite');
}
@@ -82,7 +82,7 @@ test('migrates a v1 db: drops rule tables, keeps file_cache', () => {
store.close();
const check = new DatabaseSync(dbPath);
const {user_version} = check.prepare('PRAGMA user_version').get() as {user_version : number};
const {user_version} = check.prepare('PRAGMA user_version').get() as {user_version: number};
assert.equal(Number(user_version), 2);
check.close();
});

View File

@@ -12,7 +12,7 @@ import * as pathlib from './path.ts';
// A source-tree file from ctx.list(); shaped like a deploy Path plus the
// repo-relative path.
export interface SrcFile extends pathlib.Path {
path : string;
path: string;
}
export type CopySource = Artifact | SrcFile | string; // string = repo-relative path
@@ -20,64 +20,64 @@ export type CopySource = Artifact | SrcFile | string; // string = repo-relativ
// The finish API: nice naming over built artifacts and raw sources. Ops are
// queued in call order, which is the tar entry order.
export interface DeployCtx {
copy(src : CopySource, dst : string) : void;
write(dst : string, data : string) : void;
read(src : CopySource) : string;
list(dir : string) : SrcFile[];
copy(src: CopySource, dst: string): void;
write(dst: string, data: string): void;
read(src: CopySource): string;
list(dir: string): SrcFile[];
// 8-char base32 content stamp. One source: the digest of its bytes,
// byte-compatible with artifact hashes. Several: a digest of the sorted
// per-source digests (order-insensitive).
hash(...srcs : CopySource[]) : string;
hash(...srcs: CopySource[]): string;
}
export type DeployFn = (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[] = [];
const deploys: DeployFn[] = [];
export function deploy(fn : DeployFn) : void {
export function deploy(fn: DeployFn): void {
deploys.push(fn);
}
export function getDeploys() : readonly DeployFn[] {
export function getDeploys(): readonly DeployFn[] {
return deploys;
}
export function resetDeploys() : void {
export function resetDeploys(): void {
deploys.length = 0;
}
function shortHash(digest : Buffer) : string {
function shortHash(digest: Buffer): string {
return b32encode(digest, 'RFC4648').slice(0, 8);
}
export function makeCtx(casDir : string, queue : ActionQueue) : DeployCtx {
export function makeCtx(casDir: string, queue: ActionQueue): DeployCtx {
// Every path here is repo-root-relative; the CLI chdirs to the root.
const srcPath = (src : CopySource) : string => {
const srcPath = (src: CopySource): string => {
if (src instanceof Artifact) {
return casPath(casDir, src.hash, src.ext);
}
return typeof src === 'string' ? src : src.path;
};
const digestOf = (src : CopySource) : Buffer => {
const digestOf = (src: CopySource): Buffer => {
if (src instanceof Artifact) {
return Buffer.from(src.hash, 'hex');
}
return crypto.createHash('sha256').update(fs.readFileSync(srcPath(src))).digest();
};
return {
copy(src : CopySource, dst : string) : void {
copy(src: CopySource, dst: string): void {
queue.copy(srcPath(src), dst);
},
write(dst : string, data : string) : void {
write(dst: string, data: string): void {
queue.write(data, dst);
},
read(src : CopySource) : string {
read(src: CopySource): string {
return fs.readFileSync(srcPath(src), 'utf8');
},
list(dir : string) : SrcFile[] {
list(dir: string): SrcFile[] {
const result = [];
// Files only, no dotfiles: the same filtering the build-side
// glob applies to rule inputs.
@@ -90,7 +90,7 @@ export function makeCtx(casDir : string, queue : ActionQueue) : DeployCtx {
}
return result;
},
hash(...srcs : CopySource[]) : string {
hash(...srcs: CopySource[]): string {
if (srcs.length === 1) {
return shortHash(digestOf(srcs[0]!));
}

View File

@@ -7,32 +7,32 @@ import JSON5 from 'json5';
import {BuildError} from '../build/errors.ts';
export interface DeployEntry {
subset : string[];
cmd : string;
subset: string[];
cmd: string;
// dir entries get their subset materialized into a temp directory whose
// path replaces %d in cmd; tar entries get the subset tarred on stdin.
dir? : boolean;
dir?: boolean;
}
export interface DeployTarget {
buildFile : string;
deploy : DeployEntry[];
buildFile: string;
deploy: DeployEntry[];
}
export type DeployConfig = Map<string, DeployTarget>;
function isStringArray(v : unknown) : v is string[] {
function isStringArray(v: unknown): v is string[] {
return Array.isArray(v) && v.every(x => typeof x === 'string');
}
export function loadDeployConfig(path : string) : DeployConfig {
let text : string;
export function loadDeployConfig(path: string): DeployConfig {
let text: string;
try {
text = fs.readFileSync(path, 'utf8');
} catch {
throw new BuildError(`missing ${path}; see README ("Deploying") for the schema`);
}
let raw : unknown;
let raw: unknown;
try {
raw = JSON5.parse(text);
} catch (err) {
@@ -42,7 +42,7 @@ export function loadDeployConfig(path : string) : DeployConfig {
throw new BuildError(`${path}: top level must be an object of deploy names`);
}
const config : DeployConfig = new Map();
const config: DeployConfig = new Map();
for (const [name, t] of Object.entries(raw)) {
const target = t as Partial<DeployTarget>;
if (typeof t !== 'object' || t === null || typeof target.buildFile !== 'string'
@@ -70,8 +70,8 @@ export function loadDeployConfig(path : string) : DeployConfig {
// Route finish outputs to deploy entries: per entry, the set of dsts its
// subset globs match. Every glob must match something and every dst must be
// covered by some entry; to unship an output, don't emit it in finish.
export function matchSubsets(dsts : readonly string[],
entries : readonly DeployEntry[]) : Set<string>[] {
export function matchSubsets(dsts: readonly string[],
entries: readonly DeployEntry[]): Set<string>[] {
const covered = new Set<string>();
const perEntry = entries.map(entry => {
const matched = new Set<string>();

View File

@@ -28,17 +28,17 @@ const CAS_DIR = '.build/cas';
const TMP_DIR = '.build/tmp';
interface CommonOpts {
jobs : string;
dryRun? : boolean;
failFast? : boolean;
config : string;
verbose? : boolean;
jobs: string;
dryRun?: boolean;
failFast?: boolean;
config: string;
verbose?: boolean;
}
interface VerbOpts extends CommonOpts {
output? : string;
link? : boolean;
tar? : boolean;
output?: string;
link?: boolean;
tar?: boolean;
}
const USAGE = `usage: node tools/deploy/index.ts <command> [options]
@@ -88,14 +88,14 @@ const VERB_OPTIONS = {
},
} as const;
function requireOutput(opts : VerbOpts) : string {
function requireOutput(opts: VerbOpts): string {
if (opts.output === undefined) {
throw new BuildError('missing -o/--output');
}
return opts.output;
}
function discoverDeployFiles() : string[] {
function discoverDeployFiles(): string[] {
const files = fs.readdirSync('.').filter(f => f.endsWith('.build.ts')).sort();
if (files.length === 0) {
throw new BuildError('No *.build.ts files at the repo root');
@@ -106,7 +106,7 @@ function discoverDeployFiles() : string[] {
// 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[]>> {
async function importDeploys(files: string[]): Promise<Map<string, readonly DeployFn[]>> {
const specs = new Map<string, readonly DeployFn[]>();
for (const file of files) {
const before = getDeploys().length;
@@ -119,8 +119,8 @@ async function importDeploys(files : string[]) : Promise<Map<string, readonly De
// Build `decls` and, on success, run `then` while still holding the lock (a
// concurrent GC must not sweep CAS objects out from under a finish). Returns
// the process exit code.
async function buildThen(decls : readonly RuleDecl[], opts : CommonOpts, gc : boolean,
then? : () => Promise<number>) : Promise<number> {
async function buildThen(decls: readonly RuleDecl[], opts: CommonOpts, gc: boolean,
then?: () => Promise<number>): Promise<number> {
const jobs = Number(opts.jobs);
if (!Number.isInteger(jobs) || jobs < 1) {
throw new BuildError(`Invalid --jobs value: ${opts.jobs}`);
@@ -149,7 +149,7 @@ async function buildThen(decls : readonly RuleDecl[], opts : CommonOpts, gc : bo
};
process.on('SIGINT', onSignal);
process.on('SIGTERM', onSignal);
let ok : boolean;
let ok: boolean;
try {
const result = await build(decls, {
root,
@@ -184,7 +184,7 @@ async function buildThen(decls : readonly RuleDecl[], opts : CommonOpts, gc : bo
}
}
function finishOf(specs : Map<string, readonly DeployFn[]>, file : string) : readonly DeployFn[] {
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())`);
@@ -192,7 +192,7 @@ function finishOf(specs : Map<string, readonly DeployFn[]>, file : string) : rea
return fns;
}
async function runFinish(fns : readonly DeployFn[], verbose : boolean) : Promise<ActionQueue | null> {
async function runFinish(fns: readonly DeployFn[], verbose: boolean): Promise<ActionQueue | null> {
const aq = new ActionQueue();
const ctx = makeCtx(CAS_DIR, aq);
for (const fn of fns) {
@@ -205,14 +205,14 @@ async function runFinish(fns : readonly DeployFn[], verbose : boolean) : Promise
return aq;
}
function waitExit(child : ChildProcess) : Promise<number | null> {
function waitExit(child: ChildProcess): Promise<number | null> {
return new Promise((resolve, reject) => {
child.on('error', reject);
child.on('close', code => resolve(code));
});
}
async function cmdBuild(files : string[], opts : CommonOpts) : Promise<void> {
async function cmdBuild(files: string[], opts: CommonOpts): Promise<void> {
setConfig(loadConfig(opts.config));
// GC needs the full rule universe: only an unfiltered union build
// can know which keys are no longer declared anywhere.
@@ -221,7 +221,7 @@ async function cmdBuild(files : string[], opts : CommonOpts) : Promise<void> {
process.exitCode = await buildThen(getDecls(), opts, gc);
}
async function cmdDeploy(names : string[], opts : VerbOpts) : Promise<void> {
async function cmdDeploy(names: string[], opts: VerbOpts): Promise<void> {
const config = loadDeployConfig('deploy.json5');
if (names.length === 0) {
for (const [name, target] of config) {
@@ -292,7 +292,7 @@ async function cmdDeploy(names : string[], opts : VerbOpts) : Promise<void> {
});
}
async function cmdRun(file : string, opts : VerbOpts) : Promise<void> {
async function cmdRun(file: string, opts: VerbOpts): Promise<void> {
const output = requireOutput(opts);
setConfig(loadConfig(opts.config));
const specs = await importDeploys([file]);
@@ -306,14 +306,14 @@ async function cmdRun(file : string, opts : VerbOpts) : Promise<void> {
});
}
function slugOf(decl : RuleDecl) : string {
function slugOf(decl: RuleDecl): string {
const template = decl.displayTemplate ?? decl.cmds[0]!;
const slug = template.replace(/%[a-zA-Z0-9]+/g, ' ')
.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
return slug === '' ? 'rule' : slug;
}
async function cmdInspect(paths : string[], opts : VerbOpts) : Promise<void> {
async function cmdInspect(paths: string[], opts: VerbOpts): Promise<void> {
const output = requireOutput(opts);
setConfig(loadConfig(opts.config));
await importDeploys(discoverDeployFiles());
@@ -373,7 +373,7 @@ async function cmdInspect(paths : string[], opts : VerbOpts) : Promise<void> {
});
}
async function main(argv : string[]) : Promise<void> {
async function main(argv: string[]): Promise<void> {
const [verb, ...rest] = argv;
if (verb === undefined || verb === '-h' || verb === '--help') {
console.log(USAGE);
@@ -392,13 +392,13 @@ async function main(argv : string[]) : Promise<void> {
} catch (err) {
throw new BuildError(`${(err as Error).message.split('\n')[0]} (-h for usage)`);
}
const v = parsed.values as {[k : string] : string | boolean | undefined};
const v = parsed.values as {[k: string]: string | boolean | undefined};
const positionals = parsed.positionals;
if (v.help) {
console.log(USAGE);
return;
}
const opts : VerbOpts = {
const opts: VerbOpts = {
jobs: v.jobs as string,
dryRun: Boolean(v['dry-run']),
failFast: Boolean(v['fail-fast']),

View File

@@ -2,12 +2,12 @@
import pathlib from 'path';
// Slight variation of pathlib parse, less fields, different ext handling
export type Path = {dir : string, name : string, ext : string | null};
export type Path = {dir: string, name: string, ext: string | null};
export function parse(s : string) : Path {
export function parse(s: string): Path {
let {dir, name, ext: dotext} = pathlib.parse(s);
let ext;
if (dotext === "") {
if (dotext === '') {
ext = null;
} else {
ext = dotext.slice(1);
@@ -15,18 +15,18 @@ export function parse(s : string) : Path {
return {dir, name, ext};
}
export function format({dir, name, ext} : Path) {
const dotext = ext === null ? "" : `.${ext}`;
return pathlib.format({dir, name, ext : dotext});
export function format({dir, name, ext}: Path) {
const dotext = ext === null ? '' : `.${ext}`;
return pathlib.format({dir, name, ext: dotext});
}
export function join(s : string, {dir, name, ext} : Path) : Path {
export function join(s: string, {dir, name, ext}: Path): Path {
return {dir: pathlib.join(s, dir), name, ext};
}
export type Delta = Partial<Path>;
export function update({dir, name, ext} : Path, delta : Delta) : Path {
export function update({dir, name, ext}: Path, delta: Delta): Path {
return {
dir: delta.dir ?? dir,
name: delta.name ?? name,
@@ -38,7 +38,7 @@ export function update({dir, name, ext} : Path, delta : Delta) : Path {
export type PathLike = Path | string;
export function path(p : PathLike, delta? : Delta) : Path {
export function path(p: PathLike, delta?: Delta): Path {
let parsed = typeof p === 'string' ? parse(p) : p;
if (delta !== undefined)
parsed = update(parsed, delta);

View File

@@ -4,35 +4,35 @@ import nodePath from 'path';
import tar from 'tar-stream';
type Op = {
type : 'Write',
data : string,
type: 'Write',
data: string,
} | {
type : 'Copy',
src : string,
type: 'Copy',
src: string,
};
type OpEntry = {
type : 'Op',
op : Op,
dst : string,
valid : 'Success' | 'Absolute' | 'Multiple',
debugObjs : unknown[]
type: 'Op',
op: Op,
dst: string,
valid: 'Success' | 'Absolute' | 'Multiple',
debugObjs: unknown[]
};
type DebugEntry = {
type : 'Debug',
obj : unknown,
stray : boolean
type: 'Debug',
obj: unknown,
stray: boolean
};
export type LogEntry = OpEntry | DebugEntry;
export class ActionQueue {
private seen : Map<string, OpEntry | 'MoreThan1'>;
private seen: Map<string, OpEntry | 'MoreThan1'>;
// Have an accessor for this in the future? idk
public log : LogEntry[];
public valid : boolean;
private debugBuffer : unknown[];
public log: LogEntry[];
public valid: boolean;
private debugBuffer: unknown[];
constructor() {
this.seen = new Map;
@@ -41,27 +41,27 @@ export class ActionQueue {
this.debugBuffer = [];
}
throw(obj : unknown) {
throw(obj: unknown) {
this.gdebug(obj, false);
this.valid = false;
}
debug(obj : unknown) {
debug(obj: unknown) {
this.debugBuffer.push(obj);
}
gdebug(obj : unknown, stray : boolean) {
gdebug(obj: unknown, stray: boolean) {
this.log.push({type: 'Debug', obj, stray});
}
private pushOp(op: Op, dst : string) {
private pushOp(op: Op, dst: string) {
dst = nodePath.normalize(dst);
const entry : OpEntry = {
type : 'Op',
const entry: OpEntry = {
type: 'Op',
op,
dst,
valid : 'Success',
debugObjs : this.debugBuffer
valid: 'Success',
debugObjs: this.debugBuffer
};
this.log.push(entry);
this.debugBuffer = [];
@@ -82,11 +82,11 @@ export class ActionQueue {
}
}
copy(src : string, dst : string) {
copy(src: string, dst: string) {
this.pushOp({type: 'Copy', src}, dst);
}
write(data : string, dst : string) {
write(data: string, dst: string) {
this.pushOp({type: 'Write', data}, dst);
}
@@ -97,7 +97,7 @@ export class ActionQueue {
this.debugBuffer = [];
}
print(level : 'errors' | 'all') {
print(level: 'errors' | 'all') {
for (const entry of this.log) {
if (entry.type === 'Op') {
const op = entry.op;
@@ -108,7 +108,7 @@ export class ActionQueue {
addendum = ` (${entry.valid})`;
}
for (const obj of entry.debugObjs) {
console.error("DEBUG:", obj);
console.error('DEBUG:', obj);
}
if (op.type === 'Copy') {
console.error(`COPY${addendum}: ${op.src} ==> ${entry.dst}`);
@@ -125,7 +125,7 @@ export class ActionQueue {
}
}
async run(dir : string, mode : 'link' | 'copy' | 'tar', filter? : (dst : string) => boolean) {
async run(dir: string, mode: 'link' | 'copy' | 'tar', filter?: (dst: string) => boolean) {
if (!this.valid)
throw new Error(`Invalid ActionQueue`);
if (mode !== 'tar') {
@@ -160,7 +160,7 @@ export class ActionQueue {
}
}
pack(filter? : (dst : string) => boolean) : NodeJS.ReadableStream {
pack(filter?: (dst: string) => boolean): NodeJS.ReadableStream {
if (!this.valid)
throw new Error(`Invalid ActionQueue`);
let t = tar.pack();

View File

@@ -16,16 +16,16 @@ import {ActionQueue} from '../queue.ts';
beforeEach(resetDecls);
function tmpdir() : string {
function tmpdir(): string {
return fs.mkdtempSync(pathlib.join(os.tmpdir(), 'deploy-api-test-'));
}
function shortHash(data : Buffer | string) : string {
function shortHash(data: Buffer | string): string {
return b32encode(createHash('sha256').update(data).digest(), 'RFC4648').slice(0, 8);
}
// Stage `content` as a built artifact in a scratch CAS.
function makeArtifact(casDir : string, content : string, ext : string) {
function makeArtifact(casDir: string, content: string, ext: string) {
const digest = createHash('sha256').update(content).digest('hex');
const artifact = rule('in.png', ['t %f %o'], `art.${ext}`);
artifact.resolve(digest);
@@ -70,7 +70,7 @@ test('ctx queues artifact copies from the CAS, writes and reads', () => {
assert.equal(ctx.read(artifact), 'bytes');
const ops = aq.log.filter(e => e.type === 'Op');
assert.deepEqual(ops.map(e => e.dst), ['m.json', 'sprites/x.webp']);
assert.equal((ops[1] as {op : {src : string}}).op.src, casPath(casDir, artifact.hash, 'webp'));
assert.equal((ops[1] as {op: {src: string}}).op.src, casPath(casDir, artifact.hash, 'webp'));
});
test('ctx.list sorts, parses extensions, skips dotfiles and directories', () => {
@@ -88,13 +88,13 @@ test('ctx.list sorts, parses extensions, skips dotfiles and directories', () =>
]);
});
function packedEntries(aq : ActionQueue, filter? : (dst : string) => boolean)
: Promise<{name : string, data : string}[]> {
function packedEntries(aq: ActionQueue, filter?: (dst: string) => boolean)
: Promise<{name: string, data: string}[]> {
return new Promise((resolve, reject) => {
const extract = tar.extract();
const entries : {name : string, data : string}[] = [];
const entries: {name: string, data: string}[] = [];
extract.on('entry', (header, stream, next) => {
const chunks : Buffer[] = [];
const chunks: Buffer[] = [];
stream.on('data', c => chunks.push(c));
stream.on('end', () => {
entries.push({name: header.name, data: Buffer.concat(chunks).toString()});

View File

@@ -7,7 +7,7 @@ import {test} from 'node:test';
import {loadDeployConfig, matchSubsets} from '../config.ts';
function configFile(text : string) : string {
function configFile(text: string): string {
const dir = fs.mkdtempSync(pathlib.join(os.tmpdir(), 'deploy-config-test-'));
const p = pathlib.join(dir, 'deploy.json5');
fs.writeFileSync(p, text);

View File

@@ -10,30 +10,30 @@ if (!sheetjs || !dest) {
}
// Must have file:/// for Windows
const {default: sheet} = await import(path.join("file:///", process.cwd(), sheetjs));
const {default: sheet} = await import(path.join('file:///', process.cwd(), sheetjs));
for (let i = 0; i < sheet.entries.length; i++) {
if (sheet.entries[i] === undefined)
throw new Error(`gap: ${i}`);
if (sheet.entries[i] === null) {
// ImageMagick blank entry
sheet.entries[i] = "xc:transparent";
sheet.entries[i] = 'xc:transparent';
}
}
// Write list of filenames to stdin, Windows can't handle large cli arg lists.
// Before we wrote a tmp file and deleted it afterwards, but apparently tup
// can't track unlinkSync on Windows?
const proc = cp.spawn("magick", [
"montage",
"@-",
const proc = cp.spawn('magick', [
'montage',
'@-',
// Keep sheet bytes deterministic for the content-addressed build.
"-define", "png:exclude-chunks=date,time",
"-background", "transparent",
"-geometry", `${sheet.width}x${sheet.height}>`,
"-gravity", "center",
"-tile", `${sheet.tile}x`,
"-depth", "8",
'-define', 'png:exclude-chunks=date,time',
'-background', 'transparent',
'-geometry', `${sheet.width}x${sheet.height}>`,
'-gravity', 'center',
'-tile', `${sheet.tile}x`,
'-depth', '8',
dest
], {
stdio: ['pipe', 'inherit', 'inherit']

View File

@@ -27,8 +27,8 @@ const removeRe = /[^a-z0-9-]/g
export function toAlias(s: string) {
s = s.toLowerCase()
s = s.replace(spaceRe, "-")
s = s.replace(removeRe, "")
s = s.replace(spaceRe, '-')
s = s.replace(removeRe, '')
return s
}
@@ -42,11 +42,11 @@ for (let [filename, sprite] of Object.entries(result.coordinates)) {
let data = spritedata.get(parsed.id);
if (data.type === 'specie') {
// TODO would like to use toPSID here, mess with it later.
let name = toAlias(data.base + (data.forme ? "-" + data.forme : ""));
if (parsed.extra.has("g")) {
name += "-gmax";
} else if (parsed.extra.has("f")) {
name += "-f";
let name = toAlias(data.base + (data.forme ? '-' + data.forme : ''));
if (parsed.extra.has('g')) {
name += '-gmax';
} else if (parsed.extra.has('f')) {
name += '-f';
}
sprites.set(name, sprite);
} else {
@@ -56,7 +56,7 @@ for (let [filename, sprite] of Object.entries(result.coordinates)) {
}
}
let stylesheet = "";
let stylesheet = '';
for (let [id, sprite] of sprites) {
// webp reference depends on optimization in Tupfile, fix it later, just need to ship
stylesheet += `.sprite-${id} {

View File

@@ -1,8 +1,8 @@
import cp from 'child_process';
export function getDims(input : string) {
const info = cp.execFileSync('magick', ['convert', input, '-format', "%w+%h+%@", 'info:'],
export function getDims(input: string) {
const info = cp.execFileSync('magick', ['convert', input, '-format', '%w+%h+%@', 'info:'],
{encoding:'utf8'});
const [imageWidth, imageHeight, width, height, left, top] =
@@ -21,13 +21,13 @@ export function getDims(input : string) {
}
}
export function crop(input : string, {width, height, left, top} : {width : number, height: number, left: number, top: number}, output : string) {
export function crop(input: string, {width, height, left, top}: {width: number, height: number, left: number, top: number}, output: string) {
cp.execFileSync('magick', ['convert', input, '+repage', '-crop', `${width}x${height}+${left}+${top}`, output]);
}
// Trim, preserving displacement from center
// Returns crop coords
export function losslessTrim(dims : {width : number, height: number, left: number, top: number, bottom: number, right: number}) {
export function losslessTrim(dims: {width: number, height: number, left: number, top: number, bottom: number, right: number}) {
return {
left: Math.min(dims.left, dims.right),
width: dims.width + Math.abs(dims.left - dims.right),