mirror of
https://github.com/smogon/sprites.git
synced 2026-08-25 03:07:29 -05:00
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:
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
|
||||
import path from 'path';
|
||||
|
||||
export default path.resolve(import.meta.dirname, "../../");
|
||||
export default path.resolve(import.meta.dirname, '../../');
|
||||
|
||||
78
ps.build.ts
78
ps.build.ts
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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]!));
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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']),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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} {
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user