@open-assets/open-assets 0.1.2
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.
- package/LICENSE +21 -0
- package/README.md +564 -0
- package/bin/open-assets.mjs +476 -0
- package/lib/init.mjs +545 -0
- package/lib/lockfile.mjs +65 -0
- package/lib/renderer.mjs +81 -0
- package/lib/server.mjs +283 -0
- package/package.json +46 -0
- package/public/viewer.html +518 -0
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { resolve, join } from "path";
|
|
4
|
+
import { existsSync, readFileSync } from "fs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Resolve an option value with env var fallback.
|
|
8
|
+
* CLI flags take precedence over env vars, which take precedence over defaults.
|
|
9
|
+
*/
|
|
10
|
+
function env(name, fallback) {
|
|
11
|
+
return process.env[name] ?? fallback;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Get all export sizes from a collection, flattened.
|
|
16
|
+
*/
|
|
17
|
+
function flatSizes(col) {
|
|
18
|
+
return (col.exportSizes || []).flatMap((group) => group.sizes || []);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const program = new Command();
|
|
22
|
+
|
|
23
|
+
const pkg = JSON.parse(
|
|
24
|
+
readFileSync(new URL("../package.json", import.meta.url), "utf-8")
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
program
|
|
28
|
+
.name("open-assets")
|
|
29
|
+
.description("Dev server and export tool for app marketing assets")
|
|
30
|
+
.version(pkg.version);
|
|
31
|
+
|
|
32
|
+
// ── dev ──────────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
program
|
|
35
|
+
.command("dev")
|
|
36
|
+
.description("Start the dev server with live preview and export UI")
|
|
37
|
+
.argument("[dir]", "Project directory containing manifest.json", ".")
|
|
38
|
+
.option("-p, --port <port>", "Port to listen on (env: OPEN_ASSETS_PORT)", env("OPEN_ASSETS_PORT", "3200"))
|
|
39
|
+
.option("-H, --host <host>", "Host to bind to (env: OPEN_ASSETS_HOST)", env("OPEN_ASSETS_HOST", "localhost"))
|
|
40
|
+
.option("--no-open", "Don't auto-open the browser (env: OPEN_ASSETS_NO_OPEN)")
|
|
41
|
+
.option("-q, --quiet", "Suppress server logs (env: OPEN_ASSETS_QUIET)", env("OPEN_ASSETS_QUIET") === "true")
|
|
42
|
+
.option("--ci", "CI mode: no browser, quiet logging", env("CI") === "true")
|
|
43
|
+
.option("--manifest <path>", "Path to manifest file (env: OPEN_ASSETS_MANIFEST)", env("OPEN_ASSETS_MANIFEST", "manifest.json"))
|
|
44
|
+
.option("--static-dir <dirs...>", "Additional static directories to serve")
|
|
45
|
+
.option("--render-timeout <ms>", "Puppeteer render timeout in ms (env: OPEN_ASSETS_RENDER_TIMEOUT)", env("OPEN_ASSETS_RENDER_TIMEOUT", "30000"))
|
|
46
|
+
.action(async (dir, opts) => {
|
|
47
|
+
const projectDir = resolve(dir);
|
|
48
|
+
const manifestPath = resolve(projectDir, opts.manifest);
|
|
49
|
+
|
|
50
|
+
if (!existsSync(manifestPath)) {
|
|
51
|
+
console.error(
|
|
52
|
+
`Error: ${opts.manifest} not found in ${projectDir}\n` +
|
|
53
|
+
`Run "open-assets init" to create one.`
|
|
54
|
+
);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// CI mode implies quiet + no-open
|
|
59
|
+
if (opts.ci) {
|
|
60
|
+
opts.quiet = true;
|
|
61
|
+
opts.open = false;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Env var fallback for no-open
|
|
65
|
+
if (env("OPEN_ASSETS_NO_OPEN") === "true") {
|
|
66
|
+
opts.open = false;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const { startServer } = await import("../lib/server.mjs");
|
|
70
|
+
const port = parseInt(opts.port, 10);
|
|
71
|
+
startServer(projectDir, {
|
|
72
|
+
port,
|
|
73
|
+
host: opts.host,
|
|
74
|
+
quiet: opts.quiet,
|
|
75
|
+
manifestPath,
|
|
76
|
+
staticDirs: opts.staticDir,
|
|
77
|
+
renderTimeout: parseInt(opts.renderTimeout, 10),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (opts.open) {
|
|
81
|
+
const open = (await import("open")).default;
|
|
82
|
+
setTimeout(() => open(`http://${opts.host}:${port}`), 500);
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// ── render ───────────────────────────────────────────────────────────────────
|
|
87
|
+
|
|
88
|
+
program
|
|
89
|
+
.command("render")
|
|
90
|
+
.description("Render assets headlessly via CLI")
|
|
91
|
+
.argument("[dir]", "Project directory containing manifest.json", ".")
|
|
92
|
+
.option("--collection <id>", "Render only the collection with this ID")
|
|
93
|
+
.option("--tag <tag>", "Render only collections with this tag")
|
|
94
|
+
.option("--template <name>", "Render only the template with this name")
|
|
95
|
+
.option("--size <name>", "Use a named export size from manifest")
|
|
96
|
+
.option("--platform <name>", "Render only sizes for this platform")
|
|
97
|
+
.option("--width <px>", "Output width in pixels (custom size)")
|
|
98
|
+
.option("--height <px>", "Output height in pixels (custom size)")
|
|
99
|
+
.option("--all", "Export at every size defined in the manifest")
|
|
100
|
+
.option("--force", "Re-render all assets even if unchanged")
|
|
101
|
+
.option("-o, --output <dir>", "Output directory (env: OPEN_ASSETS_OUTPUT)", env("OPEN_ASSETS_OUTPUT", "./exports"))
|
|
102
|
+
.option("--manifest <path>", "Path to manifest file (env: OPEN_ASSETS_MANIFEST)", env("OPEN_ASSETS_MANIFEST", "manifest.json"))
|
|
103
|
+
.option("--parallel <count>", "Number of parallel renders (env: OPEN_ASSETS_PARALLEL)", env("OPEN_ASSETS_PARALLEL", "1"))
|
|
104
|
+
.option("--render-timeout <ms>", "Puppeteer render timeout in ms", env("OPEN_ASSETS_RENDER_TIMEOUT", "30000"))
|
|
105
|
+
.option("--json", "Output results as JSON (for CI/scripting)")
|
|
106
|
+
.option("-q, --quiet", "Suppress progress logs", env("OPEN_ASSETS_QUIET") === "true")
|
|
107
|
+
.action(async (dir, opts) => {
|
|
108
|
+
const projectDir = resolve(dir);
|
|
109
|
+
const manifestPath = resolve(projectDir, opts.manifest);
|
|
110
|
+
|
|
111
|
+
if (!existsSync(manifestPath)) {
|
|
112
|
+
console.error(`Error: ${opts.manifest} not found in ${projectDir}`);
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
117
|
+
|
|
118
|
+
const { renderScreenshot, runXcodeOutput, closeBrowser } = await import(
|
|
119
|
+
"../lib/renderer.mjs"
|
|
120
|
+
);
|
|
121
|
+
const { readLockfile, writeLockfile, computeChecksum, isUpToDate, recordExport } = await import(
|
|
122
|
+
"../lib/lockfile.mjs"
|
|
123
|
+
);
|
|
124
|
+
const { mkdirSync, writeFileSync, copyFileSync } = await import("fs");
|
|
125
|
+
|
|
126
|
+
const outputDir = resolve(opts.output);
|
|
127
|
+
mkdirSync(outputDir, { recursive: true });
|
|
128
|
+
|
|
129
|
+
let collections = manifest.collections;
|
|
130
|
+
if (opts.collection) {
|
|
131
|
+
collections = collections.filter((c) => c.id === opts.collection);
|
|
132
|
+
}
|
|
133
|
+
if (opts.tag) {
|
|
134
|
+
collections = collections.filter((c) => (c.tags || []).includes(opts.tag));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (collections.length === 0) {
|
|
138
|
+
const filter = opts.collection ? `collection "${opts.collection}"` : `tag "${opts.tag}"`;
|
|
139
|
+
console.error(`Error: no collections matched ${filter} in manifest`);
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const lockData = readLockfile(projectDir);
|
|
144
|
+
const results = [];
|
|
145
|
+
let skipped = 0;
|
|
146
|
+
const startTime = Date.now();
|
|
147
|
+
|
|
148
|
+
for (const col of collections) {
|
|
149
|
+
const sourceW = col.sourceSize.width;
|
|
150
|
+
const sourceH = col.sourceSize.height;
|
|
151
|
+
|
|
152
|
+
// Filter templates
|
|
153
|
+
const templates = opts.template
|
|
154
|
+
? col.templates.filter((t) => t.name === opts.template || t.label === opts.template)
|
|
155
|
+
: col.templates;
|
|
156
|
+
|
|
157
|
+
// Determine which sizes to render
|
|
158
|
+
let sizesToRender = [];
|
|
159
|
+
|
|
160
|
+
if (opts.width && opts.height) {
|
|
161
|
+
// Custom size from CLI
|
|
162
|
+
sizesToRender = [{
|
|
163
|
+
name: `${opts.width}x${opts.height}`,
|
|
164
|
+
label: `${opts.width}x${opts.height}`,
|
|
165
|
+
width: parseInt(opts.width),
|
|
166
|
+
height: parseInt(opts.height),
|
|
167
|
+
}];
|
|
168
|
+
} else if (opts.size) {
|
|
169
|
+
// Specific named size
|
|
170
|
+
const found = flatSizes(col).find((s) => s.name === opts.size || s.label === opts.size);
|
|
171
|
+
if (found) {
|
|
172
|
+
sizesToRender = [found];
|
|
173
|
+
} else {
|
|
174
|
+
if (!opts.quiet) console.log(` Warning: size "${opts.size}" not found in collection "${col.id}", skipping`);
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
} else if (opts.all) {
|
|
178
|
+
// All sizes
|
|
179
|
+
sizesToRender = flatSizes(col);
|
|
180
|
+
// Filter by platform if specified
|
|
181
|
+
if (opts.platform) {
|
|
182
|
+
sizesToRender = (col.exportSizes || [])
|
|
183
|
+
.filter((g) => g.platform && g.platform.toLowerCase().includes(opts.platform.toLowerCase()))
|
|
184
|
+
.flatMap((g) => g.sizes || []);
|
|
185
|
+
}
|
|
186
|
+
} else {
|
|
187
|
+
// Default: render at source size
|
|
188
|
+
sizesToRender = [{
|
|
189
|
+
name: `${sourceW}x${sourceH}`,
|
|
190
|
+
label: `${sourceW}x${sourceH}`,
|
|
191
|
+
width: sourceW,
|
|
192
|
+
height: sourceH,
|
|
193
|
+
}];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (sizesToRender.length === 0) {
|
|
197
|
+
if (!opts.quiet) console.log(` No export sizes found for collection "${col.id}"`);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Determine if we need subdirectories (multiple sizes = subdirs)
|
|
202
|
+
const useSubDirs = sizesToRender.length > 1 || opts.all;
|
|
203
|
+
|
|
204
|
+
for (const size of sizesToRender) {
|
|
205
|
+
const sizeDir = useSubDirs
|
|
206
|
+
? join(outputDir, col.id, size.name)
|
|
207
|
+
: join(outputDir, col.id);
|
|
208
|
+
mkdirSync(sizeDir, { recursive: true });
|
|
209
|
+
|
|
210
|
+
if (!opts.quiet && sizesToRender.length > 1) {
|
|
211
|
+
const platformInfo = (col.exportSizes || [])
|
|
212
|
+
.find((g) => (g.sizes || []).some((s) => s.name === size.name));
|
|
213
|
+
const platform = platformInfo?.platform ? `[${platformInfo.platform}] ` : '';
|
|
214
|
+
console.log(`\n ${platform}${size.label} (${size.width}x${size.height})`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
for (const template of templates) {
|
|
218
|
+
const outPath = join(sizeDir, `${template.name}.png`);
|
|
219
|
+
const assetKey = `${col.id}/${template.name}`;
|
|
220
|
+
const variantKey = size.name;
|
|
221
|
+
const srcPath = resolve(projectDir, template.src);
|
|
222
|
+
const checksum = computeChecksum(srcPath);
|
|
223
|
+
|
|
224
|
+
// Skip if unchanged
|
|
225
|
+
if (!opts.force && checksum && isUpToDate(lockData, assetKey, variantKey, checksum, outPath)) {
|
|
226
|
+
if (!opts.quiet) console.log(` Skipping ${template.name} at ${size.width}x${size.height} (unchanged)`);
|
|
227
|
+
skipped++;
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (!opts.quiet) console.log(` Rendering ${template.name} at ${size.width}x${size.height}...`);
|
|
232
|
+
|
|
233
|
+
// Detect SVG source → use copy-source approach for SVG outputs
|
|
234
|
+
const isSvg = template.src.endsWith(".svg");
|
|
235
|
+
|
|
236
|
+
if (isSvg && size.width === sourceW && size.height === sourceH) {
|
|
237
|
+
// For SVGs at source size, just copy the source
|
|
238
|
+
copyFileSync(srcPath, outPath.replace(".png", ".svg"));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const buffer = await renderScreenshot(
|
|
242
|
+
projectDir,
|
|
243
|
+
template.src,
|
|
244
|
+
size.width,
|
|
245
|
+
size.height,
|
|
246
|
+
sourceW,
|
|
247
|
+
sourceH
|
|
248
|
+
);
|
|
249
|
+
writeFileSync(outPath, buffer);
|
|
250
|
+
results.push({
|
|
251
|
+
collection: col.id,
|
|
252
|
+
template: template.name,
|
|
253
|
+
path: outPath,
|
|
254
|
+
width: size.width,
|
|
255
|
+
height: size.height,
|
|
256
|
+
size: buffer.length,
|
|
257
|
+
});
|
|
258
|
+
if (checksum) recordExport(lockData, assetKey, variantKey, checksum, outPath);
|
|
259
|
+
if (!opts.quiet) console.log(` → ${outPath}`);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Handle outputs (xcode, copy-source, etc.)
|
|
264
|
+
if (col.outputs && opts.all) {
|
|
265
|
+
for (const output of col.outputs) {
|
|
266
|
+
if (output.type === "xcode") {
|
|
267
|
+
if (!opts.quiet) console.log(`\n Exporting to Xcode: ${output.path}`);
|
|
268
|
+
const path = await runXcodeOutput(projectDir, col, output);
|
|
269
|
+
results.push({ collection: col.id, type: "xcode", path });
|
|
270
|
+
} else if (output.type === "copy-source") {
|
|
271
|
+
// Copy source files (e.g., SVG originals)
|
|
272
|
+
const format = output.format || "svg";
|
|
273
|
+
const copyDir = join(outputDir, col.id, format);
|
|
274
|
+
mkdirSync(copyDir, { recursive: true });
|
|
275
|
+
for (const template of templates) {
|
|
276
|
+
if (template.src.endsWith(`.${format}`)) {
|
|
277
|
+
const srcPath = resolve(projectDir, template.src);
|
|
278
|
+
const destPath = join(copyDir, `${template.name}.${format}`);
|
|
279
|
+
if (existsSync(srcPath)) {
|
|
280
|
+
copyFileSync(srcPath, destPath);
|
|
281
|
+
results.push({ collection: col.id, template: template.name, type: `copy-${format}`, path: destPath });
|
|
282
|
+
if (!opts.quiet) console.log(` → ${destPath}`);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Persist lockfile
|
|
292
|
+
writeLockfile(projectDir, lockData);
|
|
293
|
+
|
|
294
|
+
await closeBrowser();
|
|
295
|
+
|
|
296
|
+
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
|
|
297
|
+
|
|
298
|
+
if (opts.json) {
|
|
299
|
+
console.log(JSON.stringify({ ok: true, assets: results, skipped, elapsed: `${elapsed}s` }, null, 2));
|
|
300
|
+
} else if (!opts.quiet) {
|
|
301
|
+
const parts = [`${results.length} asset(s) rendered`];
|
|
302
|
+
if (skipped > 0) parts.push(`${skipped} skipped (unchanged)`);
|
|
303
|
+
console.log(`\nDone. ${parts.join(", ")} in ${elapsed}s.`);
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// ── list ─────────────────────────────────────────────────────────────────────
|
|
308
|
+
|
|
309
|
+
program
|
|
310
|
+
.command("list")
|
|
311
|
+
.description("List all collections and templates defined in the manifest")
|
|
312
|
+
.argument("[dir]", "Project directory containing manifest.json", ".")
|
|
313
|
+
.option("--manifest <path>", "Path to manifest file", env("OPEN_ASSETS_MANIFEST", "manifest.json"))
|
|
314
|
+
.option("--tag <tag>", "List only collections with this tag")
|
|
315
|
+
.option("--json", "Output as JSON")
|
|
316
|
+
.action(async (dir, opts) => {
|
|
317
|
+
const projectDir = resolve(dir);
|
|
318
|
+
const manifestPath = resolve(projectDir, opts.manifest);
|
|
319
|
+
|
|
320
|
+
if (!existsSync(manifestPath)) {
|
|
321
|
+
console.error(`Error: ${opts.manifest} not found in ${projectDir}`);
|
|
322
|
+
process.exit(1);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
326
|
+
|
|
327
|
+
if (opts.json) {
|
|
328
|
+
console.log(JSON.stringify(manifest, null, 2));
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Show available tags if defined
|
|
333
|
+
if (manifest.tags && manifest.tags.length > 0 && !opts.tag) {
|
|
334
|
+
console.log(`\n Tags: ${manifest.tags.map((t) => t.id).join(", ")}`);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
let cols = manifest.collections;
|
|
338
|
+
if (opts.tag) {
|
|
339
|
+
cols = cols.filter((c) => (c.tags || []).includes(opts.tag));
|
|
340
|
+
if (cols.length === 0) {
|
|
341
|
+
console.error(`\n No collections found with tag "${opts.tag}"\n`);
|
|
342
|
+
process.exit(1);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
console.log(`\n ${manifest.name}\n`);
|
|
347
|
+
for (const col of cols) {
|
|
348
|
+
const templateCount = col.templates.length;
|
|
349
|
+
const sizeCount = flatSizes(col).length;
|
|
350
|
+
const tagStr = (col.tags && col.tags.length > 0) ? ` [${col.tags.join(", ")}]` : "";
|
|
351
|
+
console.log(` ${col.label} (${col.id})${tagStr} — ${templateCount} template(s), ${sizeCount} size(s)`);
|
|
352
|
+
|
|
353
|
+
for (const template of col.templates) {
|
|
354
|
+
console.log(` • ${template.label || template.name} → ${template.src}`);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
for (const group of col.exportSizes || []) {
|
|
358
|
+
for (const size of group.sizes || []) {
|
|
359
|
+
const platform = group.platform ? `${group.platform}: ` : '';
|
|
360
|
+
console.log(` ↳ ${platform}${size.label} (${size.width}×${size.height})`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (col.outputs) {
|
|
365
|
+
for (const output of col.outputs) {
|
|
366
|
+
if (output.type === "xcode") console.log(` ↳ Xcode: ${output.path}`);
|
|
367
|
+
else if (output.type === "copy-source") console.log(` ↳ Copy source: ${output.format}`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
console.log();
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// ── validate ─────────────────────────────────────────────────────────────────
|
|
375
|
+
|
|
376
|
+
program
|
|
377
|
+
.command("validate")
|
|
378
|
+
.description("Validate the manifest.json and check that all referenced files exist")
|
|
379
|
+
.argument("[dir]", "Project directory containing manifest.json", ".")
|
|
380
|
+
.option("--manifest <path>", "Path to manifest file", env("OPEN_ASSETS_MANIFEST", "manifest.json"))
|
|
381
|
+
.action(async (dir, opts) => {
|
|
382
|
+
const projectDir = resolve(dir);
|
|
383
|
+
const manifestPath = resolve(projectDir, opts.manifest);
|
|
384
|
+
|
|
385
|
+
if (!existsSync(manifestPath)) {
|
|
386
|
+
console.error(`Error: ${opts.manifest} not found in ${projectDir}`);
|
|
387
|
+
process.exit(1);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
let manifest;
|
|
391
|
+
try {
|
|
392
|
+
manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
393
|
+
} catch (err) {
|
|
394
|
+
console.error(`Error: Invalid JSON in ${opts.manifest}: ${err.message}`);
|
|
395
|
+
process.exit(1);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
let errors = 0;
|
|
399
|
+
const ok = (msg) => console.log(` ✓ ${msg}`);
|
|
400
|
+
const fail = (msg) => { console.log(` ✗ ${msg}`); errors++; };
|
|
401
|
+
|
|
402
|
+
console.log(`\n Validating ${opts.manifest}...\n`);
|
|
403
|
+
|
|
404
|
+
// Check top-level fields
|
|
405
|
+
if (manifest.name) ok(`name: "${manifest.name}"`);
|
|
406
|
+
else fail("Missing top-level 'name' field");
|
|
407
|
+
|
|
408
|
+
if (Array.isArray(manifest.collections) && manifest.collections.length > 0) {
|
|
409
|
+
ok(`${manifest.collections.length} collection(s) defined`);
|
|
410
|
+
} else {
|
|
411
|
+
fail("Missing or empty 'collections' array");
|
|
412
|
+
process.exit(1);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Check each collection
|
|
416
|
+
const ids = new Set();
|
|
417
|
+
for (const col of manifest.collections) {
|
|
418
|
+
if (!col.id) { fail(`Collection missing 'id' field`); continue; }
|
|
419
|
+
if (ids.has(col.id)) { fail(`Duplicate collection id: "${col.id}"`); }
|
|
420
|
+
ids.add(col.id);
|
|
421
|
+
|
|
422
|
+
if (!col.sourceSize || !col.sourceSize.width || !col.sourceSize.height) {
|
|
423
|
+
fail(`Collection "${col.id}" missing or invalid 'sourceSize'`);
|
|
424
|
+
} else {
|
|
425
|
+
ok(`Collection "${col.id}" sourceSize: ${col.sourceSize.width}×${col.sourceSize.height}`);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
if (!col.templates || col.templates.length === 0) {
|
|
429
|
+
fail(`Collection "${col.id}" has no templates`);
|
|
430
|
+
} else {
|
|
431
|
+
for (const template of col.templates) {
|
|
432
|
+
if (!template.src) {
|
|
433
|
+
fail(`Collection "${col.id}" has a template without 'src'`);
|
|
434
|
+
continue;
|
|
435
|
+
}
|
|
436
|
+
const filePath = resolve(projectDir, template.src);
|
|
437
|
+
if (existsSync(filePath)) ok(`${template.src} exists`);
|
|
438
|
+
else fail(`${template.src} not found`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const sizes = flatSizes(col);
|
|
443
|
+
if (sizes.length > 0) {
|
|
444
|
+
ok(`Collection "${col.id}" has ${sizes.length} export size(s)`);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Validate outputs
|
|
448
|
+
if (col.outputs) {
|
|
449
|
+
for (const output of col.outputs) {
|
|
450
|
+
if (!output.type) fail(`Collection "${col.id}" has an output without 'type'`);
|
|
451
|
+
else ok(`Output: ${output.type}`);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
console.log();
|
|
457
|
+
if (errors === 0) {
|
|
458
|
+
console.log(" All checks passed.\n");
|
|
459
|
+
} else {
|
|
460
|
+
console.log(` ${errors} error(s) found.\n`);
|
|
461
|
+
process.exit(1);
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
// ── init ─────────────────────────────────────────────────────────────────────
|
|
466
|
+
|
|
467
|
+
program
|
|
468
|
+
.command("init")
|
|
469
|
+
.description("Scaffold a new manifest.json and example assets")
|
|
470
|
+
.argument("[dir]", "Target directory", ".")
|
|
471
|
+
.action(async (dir) => {
|
|
472
|
+
const { scaffoldProject } = await import("../lib/init.mjs");
|
|
473
|
+
await scaffoldProject(resolve(dir));
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
program.parse();
|