@macrulez/devtoolz 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/README.md +80 -5
  2. package/dist/cli.js +9 -4
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/image-batch/backup.d.ts +22 -0
  5. package/dist/commands/image-batch/backup.js +55 -0
  6. package/dist/commands/image-batch/backup.js.map +1 -0
  7. package/dist/commands/image-batch/cache.d.ts +15 -0
  8. package/dist/commands/image-batch/cache.js +38 -0
  9. package/dist/commands/image-batch/cache.js.map +1 -0
  10. package/dist/commands/image-batch/cli.d.ts +15 -0
  11. package/dist/commands/image-batch/cli.js +308 -0
  12. package/dist/commands/image-batch/cli.js.map +1 -0
  13. package/dist/commands/image-batch/codecs.d.ts +38 -0
  14. package/dist/commands/image-batch/codecs.js +366 -0
  15. package/dist/commands/image-batch/codecs.js.map +1 -0
  16. package/dist/commands/image-batch/config.d.ts +119 -0
  17. package/dist/commands/image-batch/config.js +693 -0
  18. package/dist/commands/image-batch/config.js.map +1 -0
  19. package/dist/commands/image-batch/errors.d.ts +5 -0
  20. package/dist/commands/image-batch/errors.js +8 -0
  21. package/dist/commands/image-batch/errors.js.map +1 -0
  22. package/dist/commands/image-batch/geometry.d.ts +26 -0
  23. package/dist/commands/image-batch/geometry.js +90 -0
  24. package/dist/commands/image-batch/geometry.js.map +1 -0
  25. package/dist/commands/image-batch/inputs.d.ts +22 -0
  26. package/dist/commands/image-batch/inputs.js +87 -0
  27. package/dist/commands/image-batch/inputs.js.map +1 -0
  28. package/dist/commands/image-batch/manage-cli.d.ts +2 -0
  29. package/dist/commands/image-batch/manage-cli.js +199 -0
  30. package/dist/commands/image-batch/manage-cli.js.map +1 -0
  31. package/dist/commands/image-batch/manage.d.ts +50 -0
  32. package/dist/commands/image-batch/manage.js +1000 -0
  33. package/dist/commands/image-batch/manage.js.map +1 -0
  34. package/dist/commands/image-batch/overwrite.d.ts +14 -0
  35. package/dist/commands/image-batch/overwrite.js +31 -0
  36. package/dist/commands/image-batch/overwrite.js.map +1 -0
  37. package/dist/commands/image-batch/plan.d.ts +86 -0
  38. package/dist/commands/image-batch/plan.js +396 -0
  39. package/dist/commands/image-batch/plan.js.map +1 -0
  40. package/dist/commands/image-batch/process.d.ts +8 -0
  41. package/dist/commands/image-batch/process.js +97 -0
  42. package/dist/commands/image-batch/process.js.map +1 -0
  43. package/dist/commands/image-batch/report.d.ts +6 -0
  44. package/dist/commands/image-batch/report.js +251 -0
  45. package/dist/commands/image-batch/report.js.map +1 -0
  46. package/dist/commands/image-batch/restore.d.ts +29 -0
  47. package/dist/commands/image-batch/restore.js +125 -0
  48. package/dist/commands/image-batch/restore.js.map +1 -0
  49. package/dist/commands/image-batch/run.d.ts +122 -0
  50. package/dist/commands/image-batch/run.js +644 -0
  51. package/dist/commands/image-batch/run.js.map +1 -0
  52. package/dist/commands/image-batch/sharpen.d.ts +16 -0
  53. package/dist/commands/image-batch/sharpen.js +32 -0
  54. package/dist/commands/image-batch/sharpen.js.map +1 -0
  55. package/dist/commands/image-batch/template.d.ts +45 -0
  56. package/dist/commands/image-batch/template.js +142 -0
  57. package/dist/commands/image-batch/template.js.map +1 -0
  58. package/dist/commands/image-hash/core.d.ts +6 -1
  59. package/dist/commands/image-hash/core.js +12 -2
  60. package/dist/commands/image-hash/core.js.map +1 -1
  61. package/dist/commands/image-hash/hash.d.ts +2 -0
  62. package/dist/commands/image-hash/hash.js +8 -1
  63. package/dist/commands/image-hash/hash.js.map +1 -1
  64. package/dist/commands/image-hash/report.js +2 -0
  65. package/dist/commands/image-hash/report.js.map +1 -1
  66. package/dist/commands/image-hash/run.d.ts +1 -0
  67. package/dist/commands/image-hash/run.js +4 -2
  68. package/dist/commands/image-hash/run.js.map +1 -1
  69. package/dist/commands/image-hash/sharp-loader.d.ts +2 -0
  70. package/dist/commands/image-hash/sharp-loader.js +7 -5
  71. package/dist/commands/image-hash/sharp-loader.js.map +1 -1
  72. package/dist/format/bytes.d.ts +2 -0
  73. package/dist/format/bytes.js +17 -0
  74. package/dist/format/bytes.js.map +1 -0
  75. package/dist/index.d.ts +5 -0
  76. package/dist/index.js +3 -0
  77. package/dist/index.js.map +1 -1
  78. package/dist/utils/closest.d.ts +2 -0
  79. package/dist/utils/closest.js +35 -0
  80. package/dist/utils/closest.js.map +1 -0
  81. package/dist/utils/glob.d.ts +4 -0
  82. package/dist/utils/glob.js +86 -0
  83. package/dist/utils/glob.js.map +1 -0
  84. package/dist/utils/tty.d.ts +60 -0
  85. package/dist/utils/tty.js +390 -0
  86. package/dist/utils/tty.js.map +1 -0
  87. package/package.json +2 -1
@@ -0,0 +1,693 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { basename, dirname, extname, isAbsolute, join, resolve } from 'node:path';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { didYouMean } from '../../utils/closest.js';
6
+ import { ALL_TYPES, MAX_HAZEHASH_BUDGET, MIN_HAZEHASH_BUDGET, } from '../image-hash/core.js';
7
+ import { normalizeFormat, validateCodecMap, validateQualityInput, } from './codecs.js';
8
+ import { ImageBatchUsageError } from './errors.js';
9
+ import { FIT_MODES, parseSize } from './geometry.js';
10
+ import { SHARPEN_AMOUNTS, SHARPEN_TARGETS, completeSharpen, } from './sharpen.js';
11
+ import { defaultTemplate, parseTemplate } from './template.js';
12
+ export const CONFIG_EXTENSIONS = ['.json', '.js', '.mjs', '.ts'];
13
+ export const CONFIG_DIR_SEGMENTS = ['.devtoolz', 'image-batch'];
14
+ export const METADATA_MODES = ['strip', 'keep', 'keep-icc'];
15
+ export const POSITIONS = [
16
+ 'centre',
17
+ 'center',
18
+ 'north',
19
+ 'northeast',
20
+ 'east',
21
+ 'southeast',
22
+ 'south',
23
+ 'southwest',
24
+ 'west',
25
+ 'northwest',
26
+ 'top',
27
+ 'right top',
28
+ 'right',
29
+ 'right bottom',
30
+ 'bottom',
31
+ 'left bottom',
32
+ 'left',
33
+ 'left top',
34
+ 'entropy',
35
+ 'attention',
36
+ ];
37
+ const RECIPE_KEYS = [
38
+ 'id',
39
+ 'preset',
40
+ 'widths',
41
+ 'heights',
42
+ 'size',
43
+ 'longEdge',
44
+ 'shortEdge',
45
+ 'megapixels',
46
+ 'percent',
47
+ 'scale',
48
+ 'matchOrientation',
49
+ 'maxBytes',
50
+ 'formats',
51
+ 'quality',
52
+ 'codecs',
53
+ 'fit',
54
+ 'position',
55
+ 'background',
56
+ 'withoutEnlargement',
57
+ 'name',
58
+ 'autoOrient',
59
+ 'metadata',
60
+ 'rotate',
61
+ 'flip',
62
+ 'flop',
63
+ 'grayscale',
64
+ 'sharpen',
65
+ 'blur',
66
+ 'flatten',
67
+ ];
68
+ const PRESET_KEYS = [...RECIPE_KEYS.filter((key) => key !== 'preset' && key !== 'id'), 'extends'];
69
+ const DEFAULT_KEYS = RECIPE_KEYS.filter((key) => key !== 'preset' && key !== 'id');
70
+ const CONFIG_KEYS = [
71
+ '$schema',
72
+ 'name',
73
+ 'description',
74
+ 'defaults',
75
+ 'presets',
76
+ 'outputs',
77
+ 'match',
78
+ 'placeholders',
79
+ ];
80
+ const PLACEHOLDER_KEYS = ['hazehash', 'blurhash', 'thumbhash', 'color', 'preview'];
81
+ const FLAG_COLOR = /^(#[0-9a-fA-F]{3,8}|[a-zA-Z]+|rgba?\([^)]*\))$/;
82
+ function isObject(value) {
83
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
84
+ }
85
+ function expectObject(value, where) {
86
+ if (!isObject(value))
87
+ throw new ImageBatchUsageError(`${where}: expected an object`);
88
+ return value;
89
+ }
90
+ function checkKeys(object, allowed, where) {
91
+ for (const key of Object.keys(object)) {
92
+ if (!allowed.includes(key)) {
93
+ throw new ImageBatchUsageError(`${where}: unknown key "${key}".${didYouMean(key, allowed)} Allowed: ${allowed.join(', ')}.`);
94
+ }
95
+ }
96
+ }
97
+ function numberList(value, where, min, max) {
98
+ const list = Array.isArray(value) ? value : [value];
99
+ if (list.length === 0)
100
+ throw new ImageBatchUsageError(`${where}: the list is empty`);
101
+ return list.map((item) => {
102
+ if (typeof item !== 'number' || !Number.isInteger(item) || item < min || item > max) {
103
+ throw new ImageBatchUsageError(`${where}: expected whole numbers ${min}–${max} (got ${JSON.stringify(item)})`);
104
+ }
105
+ return item;
106
+ });
107
+ }
108
+ function scaleList(value, where) {
109
+ const list = Array.isArray(value) ? value : [value];
110
+ if (list.length === 0)
111
+ throw new ImageBatchUsageError(`${where}: the list is empty`);
112
+ return list.map((item) => {
113
+ if (typeof item !== 'number' || !(item > 0) || item > 16) {
114
+ throw new ImageBatchUsageError(`${where}: expected numbers above 0 and up to 16 (got ${JSON.stringify(item)})`);
115
+ }
116
+ return item;
117
+ });
118
+ }
119
+ function decimalList(value, where, min, max) {
120
+ const list = Array.isArray(value) ? value : [value];
121
+ if (list.length === 0)
122
+ throw new ImageBatchUsageError(`${where}: the list is empty`);
123
+ return list.map((item) => {
124
+ if (typeof item !== 'number' || !Number.isFinite(item) || item < min || item > max) {
125
+ throw new ImageBatchUsageError(`${where}: expected numbers from ${min} to ${max} (got ${JSON.stringify(item)})`);
126
+ }
127
+ return item;
128
+ });
129
+ }
130
+ const BYTE_UNITS = {
131
+ '': 1,
132
+ b: 1,
133
+ k: 1024,
134
+ kb: 1024,
135
+ m: 1024 * 1024,
136
+ mb: 1024 * 1024,
137
+ };
138
+ const MIN_MAX_BYTES = 1024;
139
+ export function parseByteSize(value, where) {
140
+ let bytes = Number.NaN;
141
+ if (typeof value === 'number')
142
+ bytes = value;
143
+ else if (typeof value === 'string') {
144
+ const match = /^\s*(\d+(?:\.\d+)?)\s*([a-zA-Z]*)\s*$/.exec(value);
145
+ const unit = match ? BYTE_UNITS[match[2].toLowerCase()] : undefined;
146
+ if (match && unit !== undefined)
147
+ bytes = Math.round(Number(match[1]) * unit);
148
+ }
149
+ if (!Number.isFinite(bytes) || bytes < MIN_MAX_BYTES) {
150
+ throw new ImageBatchUsageError(`${where}: expected a size of at least 1 KB such as "200KB", "1.5MB" or a number of bytes (got ${JSON.stringify(value)})`);
151
+ }
152
+ return bytes;
153
+ }
154
+ function expectBool(value, where) {
155
+ if (typeof value !== 'boolean')
156
+ throw new ImageBatchUsageError(`${where}: expected true or false`);
157
+ return value;
158
+ }
159
+ function expectColor(value, where) {
160
+ if (typeof value !== 'string' || !FLAG_COLOR.test(value.trim())) {
161
+ throw new ImageBatchUsageError(`${where}: expected a color like #ffffff, white or rgba(0,0,0,0) (got ${JSON.stringify(value)})`);
162
+ }
163
+ return value.trim();
164
+ }
165
+ function parseFormats(value, where) {
166
+ if (typeof value === 'string')
167
+ return { formats: [normalizeFormat(value, where)] };
168
+ if (Array.isArray(value)) {
169
+ if (value.length === 0)
170
+ throw new ImageBatchUsageError(`${where}: the list is empty`);
171
+ return {
172
+ formats: value.map((item) => {
173
+ if (typeof item !== 'string') {
174
+ throw new ImageBatchUsageError(`${where}: expected format names (got ${JSON.stringify(item)})`);
175
+ }
176
+ return normalizeFormat(item, where);
177
+ }),
178
+ };
179
+ }
180
+ if (isObject(value)) {
181
+ const codecs = validateCodecMap(value, where);
182
+ const formats = Object.keys(value).map((key) => normalizeFormat(key, where));
183
+ if (formats.length === 0)
184
+ throw new ImageBatchUsageError(`${where}: the map is empty`);
185
+ return { formats, codecs };
186
+ }
187
+ throw new ImageBatchUsageError(`${where}: expected a format name, a list or a map of format → options`);
188
+ }
189
+ function mergeCodecMaps(...maps) {
190
+ const result = {};
191
+ let any = false;
192
+ for (const map of maps) {
193
+ if (!map)
194
+ continue;
195
+ for (const [format, options] of Object.entries(map)) {
196
+ any = true;
197
+ const key = format;
198
+ result[key] = { ...result[key], ...options };
199
+ }
200
+ }
201
+ return any ? result : undefined;
202
+ }
203
+ export function parseRecipeLayer(raw, where, allowed) {
204
+ const object = expectObject(raw, where);
205
+ checkKeys(object, allowed, where);
206
+ const layer = {};
207
+ let formatCodecs;
208
+ for (const [key, value] of Object.entries(object)) {
209
+ const at = `${where}.${key}`;
210
+ switch (key) {
211
+ case 'id':
212
+ case 'preset':
213
+ case 'extends':
214
+ if (typeof value !== 'string' || value.trim() === '') {
215
+ throw new ImageBatchUsageError(`${at}: expected a non-empty string`);
216
+ }
217
+ layer[key] = value;
218
+ break;
219
+ case 'widths':
220
+ layer.widths = numberList(value, at, 1, 65535);
221
+ break;
222
+ case 'heights':
223
+ layer.heights = numberList(value, at, 1, 65535);
224
+ break;
225
+ case 'longEdge':
226
+ layer.longEdge = numberList(value, at, 1, 65535);
227
+ break;
228
+ case 'shortEdge':
229
+ layer.shortEdge = numberList(value, at, 1, 65535);
230
+ break;
231
+ case 'megapixels':
232
+ layer.megapixels = decimalList(value, at, 0.001, 1000);
233
+ break;
234
+ case 'percent':
235
+ layer.percent = decimalList(value, at, 0.1, 1000);
236
+ break;
237
+ case 'maxBytes':
238
+ layer.maxBytes = parseByteSize(value, at);
239
+ break;
240
+ case 'size':
241
+ if (typeof value !== 'string')
242
+ throw new ImageBatchUsageError(`${at}: expected "WxH"`);
243
+ layer.size = parseSize(value, at);
244
+ break;
245
+ case 'scale':
246
+ layer.scale = scaleList(value, at);
247
+ break;
248
+ case 'formats': {
249
+ const parsed = parseFormats(value, at);
250
+ layer.formats = parsed.formats;
251
+ formatCodecs = parsed.codecs;
252
+ break;
253
+ }
254
+ case 'quality':
255
+ layer.quality = validateQualityInput(value, at);
256
+ break;
257
+ case 'codecs':
258
+ layer.codecs = validateCodecMap(value, at);
259
+ break;
260
+ case 'fit':
261
+ if (typeof value !== 'string' || !FIT_MODES.includes(value)) {
262
+ throw new ImageBatchUsageError(`${at}: expected one of ${FIT_MODES.join(', ')}`);
263
+ }
264
+ layer.fit = value;
265
+ break;
266
+ case 'position':
267
+ if (typeof value !== 'string' || !POSITIONS.includes(value)) {
268
+ throw new ImageBatchUsageError(`${at}: expected one of ${POSITIONS.join(', ')}`);
269
+ }
270
+ layer.position = value;
271
+ break;
272
+ case 'background':
273
+ layer.background = expectColor(value, at);
274
+ break;
275
+ case 'withoutEnlargement':
276
+ case 'matchOrientation':
277
+ case 'autoOrient':
278
+ case 'flip':
279
+ case 'flop':
280
+ case 'grayscale':
281
+ layer[key] = expectBool(value, at);
282
+ break;
283
+ case 'name':
284
+ if (typeof value !== 'string')
285
+ throw new ImageBatchUsageError(`${at}: expected a template string`);
286
+ layer.template = { text: value, tokens: parseTemplate(value, at) };
287
+ break;
288
+ case 'metadata':
289
+ if (typeof value !== 'string' || !METADATA_MODES.includes(value)) {
290
+ throw new ImageBatchUsageError(`${at}: expected one of ${METADATA_MODES.join(', ')}`);
291
+ }
292
+ layer.metadata = value;
293
+ break;
294
+ case 'rotate':
295
+ if (value !== 0 && value !== 90 && value !== 180 && value !== 270) {
296
+ throw new ImageBatchUsageError(`${at}: expected 0, 90, 180 or 270`);
297
+ }
298
+ layer.rotate = value;
299
+ break;
300
+ case 'sharpen': {
301
+ const object = expectObject(value, at);
302
+ checkKeys(object, ['for', 'amount'], at);
303
+ const spec = {};
304
+ if (object.for !== undefined) {
305
+ if (!SHARPEN_TARGETS.includes(object.for)) {
306
+ throw new ImageBatchUsageError(`${at}.for: expected one of ${SHARPEN_TARGETS.join(', ')}`);
307
+ }
308
+ spec.for = object.for;
309
+ }
310
+ if (object.amount !== undefined) {
311
+ if (!SHARPEN_AMOUNTS.includes(object.amount)) {
312
+ throw new ImageBatchUsageError(`${at}.amount: expected one of ${SHARPEN_AMOUNTS.join(', ')}`);
313
+ }
314
+ spec.amount = object.amount;
315
+ }
316
+ layer.sharpen = spec;
317
+ break;
318
+ }
319
+ case 'blur':
320
+ if (typeof value !== 'number' || !(value >= 0.3) || value > 1000) {
321
+ throw new ImageBatchUsageError(`${at}: expected a sigma from 0.3 to 1000`);
322
+ }
323
+ layer.blur = value;
324
+ break;
325
+ case 'flatten':
326
+ if (value === true)
327
+ layer.flatten = true;
328
+ else if (value !== false)
329
+ layer.flatten = expectColor(value, at);
330
+ break;
331
+ }
332
+ }
333
+ const merged = mergeCodecMaps(formatCodecs, layer.codecs);
334
+ if (merged)
335
+ layer.codecs = merged;
336
+ return layer;
337
+ }
338
+ function parsePlaceholders(raw, where) {
339
+ const object = expectObject(raw, where);
340
+ checkKeys(object, PLACEHOLDER_KEYS, where);
341
+ const types = [];
342
+ const settings = { types };
343
+ for (const type of ALL_TYPES) {
344
+ const value = object[type];
345
+ if (value === undefined || value === false)
346
+ continue;
347
+ types.push(type);
348
+ if (value === true)
349
+ continue;
350
+ const options = expectObject(value, `${where}.${type}`);
351
+ if (type === 'hazehash') {
352
+ checkKeys(options, ['budget'], `${where}.hazehash`);
353
+ if (options.budget !== undefined) {
354
+ const budget = options.budget;
355
+ if (typeof budget !== 'number' ||
356
+ !Number.isInteger(budget) ||
357
+ budget < MIN_HAZEHASH_BUDGET ||
358
+ budget > MAX_HAZEHASH_BUDGET) {
359
+ throw new ImageBatchUsageError(`${where}.hazehash.budget: expected a whole number of bytes ${MIN_HAZEHASH_BUDGET}–${MAX_HAZEHASH_BUDGET}`);
360
+ }
361
+ settings.budget = budget;
362
+ }
363
+ }
364
+ else if (type === 'blurhash') {
365
+ checkKeys(options, ['components'], `${where}.blurhash`);
366
+ if (options.components !== undefined) {
367
+ const components = options.components;
368
+ if (typeof components !== 'string' || !/^(auto|[1-9]x[1-9])$/i.test(components)) {
369
+ throw new ImageBatchUsageError(`${where}.blurhash.components: expected "auto" or a value like "4x3"`);
370
+ }
371
+ settings.components = components;
372
+ }
373
+ }
374
+ else {
375
+ checkKeys(options, [], `${where}.${type}`);
376
+ }
377
+ }
378
+ return settings;
379
+ }
380
+ export function parseConfig(raw, where = 'config') {
381
+ const object = expectObject(raw, where);
382
+ checkKeys(object, CONFIG_KEYS, where);
383
+ const config = { defaults: {}, presets: {}, outputs: [], match: [] };
384
+ if (object.name !== undefined) {
385
+ if (typeof object.name !== 'string')
386
+ throw new ImageBatchUsageError(`${where}.name: expected a string`);
387
+ config.title = object.name;
388
+ }
389
+ if (object.description !== undefined) {
390
+ if (typeof object.description !== 'string') {
391
+ throw new ImageBatchUsageError(`${where}.description: expected a string`);
392
+ }
393
+ config.description = object.description;
394
+ }
395
+ if (object.defaults !== undefined) {
396
+ config.defaults = parseRecipeLayer(object.defaults, `${where}.defaults`, DEFAULT_KEYS);
397
+ }
398
+ if (object.presets !== undefined) {
399
+ for (const [name, value] of Object.entries(expectObject(object.presets, `${where}.presets`))) {
400
+ config.presets[name] = parseRecipeLayer(value, `${where}.presets.${name}`, PRESET_KEYS);
401
+ }
402
+ }
403
+ if (object.outputs !== undefined) {
404
+ if (!Array.isArray(object.outputs)) {
405
+ throw new ImageBatchUsageError(`${where}.outputs: expected a list of recipes`);
406
+ }
407
+ config.outputs = object.outputs.map((item, i) => parseRecipeLayer(item, `${where}.outputs[${i}]`, RECIPE_KEYS));
408
+ }
409
+ if (object.match !== undefined) {
410
+ if (!Array.isArray(object.match)) {
411
+ throw new ImageBatchUsageError(`${where}.match: expected a list of rules`);
412
+ }
413
+ config.match = object.match.map((item, i) => {
414
+ const at = `${where}.match[${i}]`;
415
+ const rule = expectObject(item, at);
416
+ checkKeys(rule, ['glob', 'outputs'], at);
417
+ if (typeof rule.glob !== 'string' || rule.glob.trim() === '') {
418
+ throw new ImageBatchUsageError(`${at}.glob: expected a glob string`);
419
+ }
420
+ if (!Array.isArray(rule.outputs) || rule.outputs.length === 0) {
421
+ throw new ImageBatchUsageError(`${at}.outputs: expected a non-empty list of recipes`);
422
+ }
423
+ return {
424
+ glob: rule.glob,
425
+ outputs: rule.outputs.map((recipe, j) => parseRecipeLayer(recipe, `${at}.outputs[${j}]`, RECIPE_KEYS)),
426
+ };
427
+ });
428
+ }
429
+ if (object.placeholders !== undefined) {
430
+ config.placeholders = parsePlaceholders(object.placeholders, `${where}.placeholders`);
431
+ }
432
+ const ids = new Set();
433
+ for (const recipe of config.outputs) {
434
+ if (recipe.id === undefined)
435
+ continue;
436
+ if (ids.has(recipe.id)) {
437
+ throw new ImageBatchUsageError(`${where}.outputs: the recipe id "${recipe.id}" is used twice`);
438
+ }
439
+ ids.add(recipe.id);
440
+ }
441
+ for (const [name, preset] of Object.entries(config.presets)) {
442
+ if (preset.extends !== undefined && config.presets[preset.extends] === undefined) {
443
+ throw new ImageBatchUsageError(`${where}.presets.${name}.extends: unknown preset "${preset.extends}".${didYouMean(preset.extends, Object.keys(config.presets))}`);
444
+ }
445
+ }
446
+ for (const recipe of [...config.outputs, ...config.match.flatMap((rule) => rule.outputs)]) {
447
+ if (recipe.preset !== undefined && config.presets[recipe.preset] === undefined) {
448
+ throw new ImageBatchUsageError(`${where}: unknown preset "${recipe.preset}".${didYouMean(recipe.preset, Object.keys(config.presets))}`);
449
+ }
450
+ }
451
+ return config;
452
+ }
453
+ function presetChain(config, name, where) {
454
+ const chain = [];
455
+ const seen = new Set();
456
+ let current = name;
457
+ while (current !== undefined) {
458
+ if (seen.has(current)) {
459
+ throw new ImageBatchUsageError(`${where}: the presets extend each other in a loop at "${current}"`);
460
+ }
461
+ seen.add(current);
462
+ const preset = config.presets[current];
463
+ if (!preset)
464
+ throw new ImageBatchUsageError(`${where}: unknown preset "${current}"`);
465
+ chain.unshift(preset);
466
+ current = preset.extends;
467
+ }
468
+ return chain;
469
+ }
470
+ export function resolveRecipe(config, recipe, overrides = [], where = 'recipe') {
471
+ const layers = [config.defaults];
472
+ if (recipe.preset !== undefined)
473
+ layers.push(...presetChain(config, recipe.preset, where));
474
+ layers.push(recipe, ...overrides);
475
+ const merged = {};
476
+ for (const layer of layers) {
477
+ for (const [key, value] of Object.entries(layer)) {
478
+ if (key === 'codecs' || key === 'quality' || key === 'preset' || key === 'extends')
479
+ continue;
480
+ if (key === 'sharpen' && value !== undefined) {
481
+ merged.sharpen = { ...merged.sharpen, ...value };
482
+ }
483
+ else if (value !== undefined)
484
+ merged[key] = value;
485
+ }
486
+ }
487
+ const methods = [
488
+ merged.widths,
489
+ merged.heights,
490
+ merged.size,
491
+ merged.longEdge,
492
+ merged.shortEdge,
493
+ merged.megapixels,
494
+ merged.percent,
495
+ ];
496
+ const firstMethod = methods.findIndex((method) => method !== undefined);
497
+ const shape = {
498
+ widths: firstMethod === 0,
499
+ heights: firstMethod === 1,
500
+ size: firstMethod === 2,
501
+ longEdge: firstMethod === 3,
502
+ shortEdge: firstMethod === 4,
503
+ megapixels: firstMethod === 5,
504
+ percent: firstMethod === 6,
505
+ scale: firstMethod === -1 &&
506
+ merged.scale !== undefined &&
507
+ !(merged.scale.length === 1 && merged.scale[0] === 1),
508
+ };
509
+ const templateText = merged.template?.text ?? defaultTemplate(shape);
510
+ const template = merged.template ?? {
511
+ text: templateText,
512
+ tokens: parseTemplate(templateText, `${where}.name`),
513
+ };
514
+ const resolved = {
515
+ widths: merged.widths ?? [],
516
+ heights: merged.heights ?? [],
517
+ longEdge: merged.longEdge ?? [],
518
+ shortEdge: merged.shortEdge ?? [],
519
+ megapixels: merged.megapixels ?? [],
520
+ percent: merged.percent ?? [],
521
+ scales: merged.scale ?? [1],
522
+ matchOrientation: merged.matchOrientation ?? false,
523
+ explicitTemplate: merged.template !== undefined,
524
+ formats: merged.formats ?? ['original'],
525
+ layers: layers.map((layer) => {
526
+ const codecLayer = {};
527
+ if (layer.codecs)
528
+ codecLayer.codecs = layer.codecs;
529
+ if (layer.quality !== undefined)
530
+ codecLayer.quality = layer.quality;
531
+ return codecLayer;
532
+ }),
533
+ fit: merged.fit ?? 'inside',
534
+ withoutEnlargement: merged.withoutEnlargement ?? true,
535
+ template,
536
+ autoOrient: merged.autoOrient ?? true,
537
+ metadata: merged.metadata ?? 'strip',
538
+ flip: merged.flip ?? false,
539
+ flop: merged.flop ?? false,
540
+ grayscale: merged.grayscale ?? false,
541
+ };
542
+ if (recipe.id !== undefined)
543
+ resolved.id = recipe.id;
544
+ if (merged.size)
545
+ resolved.size = merged.size;
546
+ if (merged.maxBytes !== undefined)
547
+ resolved.maxBytes = merged.maxBytes;
548
+ if (merged.position)
549
+ resolved.position = merged.position;
550
+ if (merged.background)
551
+ resolved.background = merged.background;
552
+ if (merged.rotate !== undefined && merged.rotate !== 0)
553
+ resolved.rotate = merged.rotate;
554
+ if (merged.sharpen !== undefined)
555
+ resolved.sharpen = completeSharpen(merged.sharpen);
556
+ if (merged.blur !== undefined)
557
+ resolved.blur = merged.blur;
558
+ if (merged.flatten !== undefined)
559
+ resolved.flatten = merged.flatten;
560
+ return resolved;
561
+ }
562
+ export function globalConfigDir(homeDir = homedir()) {
563
+ return join(homeDir, ...CONFIG_DIR_SEGMENTS);
564
+ }
565
+ function listDir(dir, scope) {
566
+ const stat = statSync(dir, { throwIfNoEntry: false });
567
+ if (!stat?.isDirectory())
568
+ return [];
569
+ const found = [];
570
+ for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
571
+ if (!entry.isFile())
572
+ continue;
573
+ const ext = extname(entry.name).toLowerCase();
574
+ if (!CONFIG_EXTENSIONS.includes(ext))
575
+ continue;
576
+ const kind = ext.slice(1);
577
+ found.push({
578
+ name: basename(entry.name, ext),
579
+ path: join(dir, entry.name),
580
+ scope,
581
+ kind,
582
+ editable: kind === 'json',
583
+ });
584
+ }
585
+ return found;
586
+ }
587
+ export function discoverConfigs(options) {
588
+ const found = [];
589
+ const seenDirs = new Set();
590
+ const globalDir = resolve(options.globalDir ?? globalConfigDir());
591
+ const homeGlobalDir = resolve(globalConfigDir());
592
+ let dir = resolve(options.cwd);
593
+ for (;;) {
594
+ const candidate = join(dir, ...CONFIG_DIR_SEGMENTS);
595
+ const resolved = resolve(candidate);
596
+ if (resolved !== globalDir && resolved !== homeGlobalDir && !seenDirs.has(candidate)) {
597
+ seenDirs.add(candidate);
598
+ found.push(...listDir(candidate, 'project'));
599
+ }
600
+ if (existsSync(join(dir, '.git')))
601
+ break;
602
+ const parent = dirname(dir);
603
+ if (parent === dir)
604
+ break;
605
+ dir = parent;
606
+ }
607
+ found.push(...listDir(globalDir, 'global'));
608
+ return found;
609
+ }
610
+ function looksLikePath(ref) {
611
+ return (/[\\/]/.test(ref) ||
612
+ CONFIG_EXTENSIONS.includes(extname(ref).toLowerCase()));
613
+ }
614
+ export function locationForPath(path) {
615
+ const ext = extname(path).toLowerCase();
616
+ if (!CONFIG_EXTENSIONS.includes(ext)) {
617
+ throw new ImageBatchUsageError(`the config file "${path}" must end with ${CONFIG_EXTENSIONS.join(', ')}`);
618
+ }
619
+ const kind = ext.slice(1);
620
+ return {
621
+ name: basename(path, ext),
622
+ path,
623
+ scope: 'project',
624
+ kind,
625
+ editable: kind === 'json',
626
+ };
627
+ }
628
+ export function resolveConfigRef(ref, discovered, cwd) {
629
+ if (looksLikePath(ref)) {
630
+ const path = isAbsolute(ref) ? ref : resolve(cwd, ref);
631
+ if (!existsSync(path)) {
632
+ throw new ImageBatchUsageError(`config file not found: ${path}`);
633
+ }
634
+ return locationForPath(path);
635
+ }
636
+ const matches = discovered.filter((location) => location.name === ref);
637
+ const chosen = matches.find((m) => m.scope === 'project') ?? matches[0];
638
+ if (!chosen) {
639
+ const names = [...new Set(discovered.map((location) => location.name))];
640
+ throw new ImageBatchUsageError(`no config named "${ref}".${didYouMean(ref, names)} ${names.length > 0
641
+ ? `Available: ${names.join(', ')}.`
642
+ : 'None found — create one with `devtoolz image-batch init`.'}`);
643
+ }
644
+ return chosen;
645
+ }
646
+ export async function readConfigSource(location) {
647
+ if (location.kind === 'json') {
648
+ const text = readFileSync(location.path, 'utf8');
649
+ try {
650
+ return JSON.parse(text);
651
+ }
652
+ catch (error) {
653
+ throw new ImageBatchUsageError(`${location.path}: not valid JSON — ${error instanceof Error ? error.message : String(error)}`);
654
+ }
655
+ }
656
+ let url;
657
+ if (location.kind === 'ts') {
658
+ const ts = await import('typescript');
659
+ const source = readFileSync(location.path, 'utf8');
660
+ const output = ts.default.transpileModule(source, {
661
+ compilerOptions: {
662
+ module: ts.default.ModuleKind.ESNext,
663
+ target: ts.default.ScriptTarget.ES2022,
664
+ },
665
+ }).outputText;
666
+ url = `data:text/javascript;base64,${Buffer.from(output).toString('base64')}`;
667
+ }
668
+ else {
669
+ url = `${pathToFileURL(location.path).href}?t=${Date.now()}`;
670
+ }
671
+ let loaded;
672
+ try {
673
+ loaded = (await import(url));
674
+ }
675
+ catch (error) {
676
+ throw new ImageBatchUsageError(`${location.path}: could not be loaded — ${error instanceof Error ? error.message : String(error)}${location.kind === 'ts'
677
+ ? ' (a .ts config is transpiled on its own, so it cannot import other files)'
678
+ : ''}`);
679
+ }
680
+ const exported = loaded.default;
681
+ return typeof exported === 'function' ? await exported() : exported;
682
+ }
683
+ export async function loadConfig(location) {
684
+ const raw = await readConfigSource(location);
685
+ if (raw === undefined) {
686
+ throw new ImageBatchUsageError(`${location.path}: the file has no default export`);
687
+ }
688
+ return { raw, config: parseConfig(raw, location.name) };
689
+ }
690
+ export function emptyConfig() {
691
+ return { defaults: {}, presets: {}, outputs: [], match: [] };
692
+ }
693
+ //# sourceMappingURL=config.js.map