@open-assets/open-assets 0.1.11 → 0.2.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.
@@ -0,0 +1,132 @@
1
+ import { existsSync, readFileSync, writeFileSync } from "fs";
2
+ import { resolve } from "path";
3
+
4
+ /**
5
+ * Get all export sizes from a collection (entries without a type field).
6
+ */
7
+ export function flatSizes(collection) {
8
+ return (collection.export || []).filter((e) => !e.type);
9
+ }
10
+
11
+ /**
12
+ * Get all output actions from a collection (entries with a type field).
13
+ */
14
+ export function flatOutputs(collection) {
15
+ return (collection.export || []).filter((e) => e.type);
16
+ }
17
+
18
+ /**
19
+ * Load and parse a config file from disk.
20
+ * @param {string} projectDir - Absolute path to the project directory
21
+ * @param {string} [configName="assets.json"] - Config filename
22
+ * @returns {object} Parsed config
23
+ * @throws {Error} If the file is missing or contains invalid JSON
24
+ */
25
+ export function loadConfig(projectDir, configName = "assets.json") {
26
+ const configPath = resolve(projectDir, configName);
27
+ if (!existsSync(configPath)) {
28
+ throw new Error(`${configName} not found in ${projectDir}`);
29
+ }
30
+ try {
31
+ return JSON.parse(readFileSync(configPath, "utf-8"));
32
+ } catch (err) {
33
+ throw new Error(`Invalid JSON in ${configName}: ${err.message}`);
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Save a config to disk.
39
+ * @param {string} projectDir - Absolute path to the project directory
40
+ * @param {string} configName - Config filename
41
+ * @param {object} config - The config object to save
42
+ */
43
+ export function saveConfig(projectDir, configName, config) {
44
+ const configPath = resolve(projectDir, configName);
45
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
46
+ }
47
+
48
+ /**
49
+ * Filter collections by ID and/or tag.
50
+ * @param {object[]} collections
51
+ * @param {{ collection?: string, tag?: string }} filters
52
+ * @returns {{ collections: object[], error?: string }}
53
+ */
54
+ export function filterCollections(collections, filters = {}) {
55
+ let result = collections;
56
+ if (filters.collection) {
57
+ result = result.filter((c) => c.id === filters.collection);
58
+ }
59
+ if (filters.tag) {
60
+ result = result.filter((c) => (c.tags || []).includes(filters.tag));
61
+ }
62
+ if (result.length === 0) {
63
+ const filter = filters.collection
64
+ ? `collection "${filters.collection}"`
65
+ : `tag "${filters.tag}"`;
66
+ return { collections: [], error: `No collections matched ${filter} in config` };
67
+ }
68
+ return { collections: result };
69
+ }
70
+
71
+ /**
72
+ * Filter templates by name or label.
73
+ * @param {object[]} templates
74
+ * @param {string} [templateName] - If provided, filter to matching name/label
75
+ * @returns {object[]}
76
+ */
77
+ export function filterTemplates(templates, templateName) {
78
+ if (!templateName) return templates;
79
+ return templates.filter(
80
+ (t) => t.name === templateName || t.label === templateName
81
+ );
82
+ }
83
+
84
+ /**
85
+ * Determine which sizes to render for a collection given CLI options.
86
+ * @param {object} collection - The collection from the config
87
+ * @param {{ width?: string|number, height?: string|number, size?: string, force?: boolean, platform?: string }} opts
88
+ * @returns {{ sizes: object[], warning?: string }}
89
+ */
90
+ export function resolveRenderSizes(collection, opts = {}) {
91
+ const sourceW = collection.sourceSize.width;
92
+ const sourceH = collection.sourceSize.height;
93
+
94
+ if (opts.width && opts.height) {
95
+ const w = typeof opts.width === "number" ? opts.width : parseInt(opts.width);
96
+ const h = typeof opts.height === "number" ? opts.height : parseInt(opts.height);
97
+ return {
98
+ sizes: [{
99
+ name: `${w}x${h}`,
100
+ label: `${w}x${h}`,
101
+ size: { width: w, height: h },
102
+ }],
103
+ };
104
+ }
105
+
106
+ if (opts.size) {
107
+ const found = flatSizes(collection).find(
108
+ (s) => s.name === opts.size || s.label === opts.size
109
+ );
110
+ if (found) {
111
+ return { sizes: [found] };
112
+ }
113
+ return {
114
+ sizes: [],
115
+ warning: `size "${opts.size}" not found in collection "${collection.id}", skipping`,
116
+ };
117
+ }
118
+
119
+ if (opts.force) {
120
+ const sizes = flatSizes(collection);
121
+ return { sizes };
122
+ }
123
+
124
+ // Default: render at source size
125
+ return {
126
+ sizes: [{
127
+ name: `${sourceW}x${sourceH}`,
128
+ label: `${sourceW}x${sourceH}`,
129
+ size: { width: sourceW, height: sourceH },
130
+ }],
131
+ };
132
+ }
package/lib/render.mjs ADDED
@@ -0,0 +1,219 @@
1
+ import { resolve, join } from "path";
2
+ import { mkdirSync, writeFileSync, copyFileSync, existsSync } from "fs";
3
+ import { filterCollections, filterTemplates, resolveRenderSizes, flatOutputs } from "./manifest.mjs";
4
+
5
+ /**
6
+ * @typedef {object} RenderDeps
7
+ * @property {(projectDir: string, htmlFile: string, w: number, h: number, sw: number, sh: number) => Promise<Buffer>} renderScreenshot
8
+ * @property {(projectDir: string, collection: object, outputConfig: object) => Promise<string>} runXcodeOutput
9
+ * @property {() => Promise<void>} closeBrowser
10
+ * @property {(projectDir: string) => object} readLockfile
11
+ * @property {(projectDir: string, data: object) => void} writeLockfile
12
+ * @property {(filePath: string) => string|null} computeChecksum
13
+ * @property {(lockData: object, assetKey: string, variantKey: string, checksum: string, outputPath: string) => boolean} isUpToDate
14
+ * @property {(lockData: object, assetKey: string, variantKey: string, checksum: string, outputPath: string) => void} recordExport
15
+ */
16
+
17
+ /**
18
+ * Render assets according to the manifest and options.
19
+ *
20
+ * @param {string} projectDir - Absolute path to the project directory
21
+ * @param {object} manifest - Parsed manifest object
22
+ * @param {object} options - Render options from CLI
23
+ * @param {string} [options.collection] - Filter by collection ID
24
+ * @param {string} [options.tag] - Filter by tag
25
+ * @param {string} [options.template] - Filter by template name/label
26
+ * @param {string} [options.size] - Named export size
27
+ * @param {string} [options.format] - Output format override (png or jpg)
28
+ * @param {string|number} [options.width] - Custom width
29
+ * @param {string|number} [options.height] - Custom height
30
+ * @param {boolean} [options.force] - Export at all sizes and re-render (ignores cache)
31
+ * @param {string} [options.output] - Output directory path
32
+ * @param {boolean} [options.quiet] - Suppress logs
33
+ * @param {RenderDeps} deps - Injected dependencies
34
+ * @param {(msg: string) => void} [log] - Optional log function
35
+ * @returns {Promise<{ results: object[], skipped: number, elapsed: string, error?: string }>}
36
+ */
37
+ export async function renderAssets(projectDir, manifest, options, deps, log) {
38
+ const noop = () => {};
39
+ const _log = log || noop;
40
+
41
+ // Filter collections
42
+ const { collections, error } = filterCollections(manifest.collections, {
43
+ collection: options.collection,
44
+ tag: options.tag,
45
+ });
46
+
47
+ if (error) {
48
+ return { results: [], skipped: 0, elapsed: "0.0s", error };
49
+ }
50
+
51
+ const outputDir = resolve(options.output || "./exports");
52
+ mkdirSync(outputDir, { recursive: true });
53
+
54
+ const lockData = deps.readLockfile(projectDir);
55
+ const results = [];
56
+ let skipped = 0;
57
+ const startTime = Date.now();
58
+
59
+ for (const col of collections) {
60
+ const sourceW = col.sourceSize.width;
61
+ const sourceH = col.sourceSize.height;
62
+
63
+ const templates = filterTemplates(col.templates, options.template);
64
+
65
+ const { sizes: sizesToRender, warning } = resolveRenderSizes(col, {
66
+ width: options.width,
67
+ height: options.height,
68
+ size: options.size,
69
+ force: options.force,
70
+ });
71
+
72
+ if (warning) {
73
+ _log(` Warning: ${warning}`);
74
+ continue;
75
+ }
76
+
77
+ if (sizesToRender.length === 0) {
78
+ _log(` No export sizes found for collection "${col.id}"`);
79
+ continue;
80
+ }
81
+
82
+ const singleTemplate = templates.length === 1;
83
+ const multipleSizes = sizesToRender.length > 1 || options.force;
84
+ // Single-template collections use flat layout with entry.name as filename
85
+ // Multi-template collections use subdirs per size
86
+ const useSubDirs = multipleSizes && !singleTemplate;
87
+
88
+ for (const entry of sizesToRender) {
89
+ const w = entry.size.width;
90
+ const h = entry.size.height;
91
+
92
+ const sizeDir = useSubDirs
93
+ ? join(outputDir, col.id, entry.name)
94
+ : join(outputDir, col.id);
95
+ mkdirSync(sizeDir, { recursive: true });
96
+
97
+ if (sizesToRender.length > 1) {
98
+ _log(`\n ${entry.label} (${w}x${h})`);
99
+ }
100
+
101
+ for (const template of templates) {
102
+ // Determine output format from outFile extension or default to png
103
+ const outExt = entry.outFile
104
+ ? entry.outFile.split(".").pop().toLowerCase()
105
+ : "png";
106
+ const format = outExt === "jpg" || outExt === "jpeg" ? "jpeg" : "png";
107
+ const defaultExt = format === "jpeg" ? "jpg" : "png";
108
+
109
+ // Single-template: use entry.name as filename (e.g. logo-1024.png)
110
+ // Multi-template: use template.name as filename (e.g. 01-hero.png)
111
+ const fileName = singleTemplate && multipleSizes
112
+ ? `${entry.name}.${defaultExt}`
113
+ : `${template.name}.${defaultExt}`;
114
+
115
+ const outPath = entry.outFile
116
+ ? resolve(projectDir, entry.outFile.replace(/\{template\}/g, template.name))
117
+ : join(sizeDir, fileName);
118
+
119
+ const assetKey = `${col.id}/${template.name}`;
120
+ const variantKey = entry.name;
121
+ const srcPath = resolve(projectDir, template.src);
122
+ const checksum = deps.computeChecksum(srcPath);
123
+
124
+ // Skip if unchanged
125
+ if (
126
+ !options.force &&
127
+ checksum &&
128
+ deps.isUpToDate(lockData, assetKey, variantKey, checksum, outPath)
129
+ ) {
130
+ _log(
131
+ ` Skipping ${template.name} at ${w}x${h} (unchanged)`
132
+ );
133
+ skipped++;
134
+ continue;
135
+ }
136
+
137
+ _log(
138
+ ` Rendering ${template.name} at ${w}x${h}...`
139
+ );
140
+
141
+ const isSvg = template.src.endsWith(".svg");
142
+ if (isSvg && w === sourceW && h === sourceH) {
143
+ copyFileSync(srcPath, outPath.replace(`.${defaultExt}`, ".svg"));
144
+ }
145
+
146
+ // Ensure output directory exists when using outFile
147
+ if (entry.outFile) {
148
+ mkdirSync(resolve(projectDir, join(outPath, "..")), { recursive: true });
149
+ }
150
+
151
+ const buffer = await deps.renderScreenshot(
152
+ projectDir,
153
+ template.src,
154
+ w,
155
+ h,
156
+ sourceW,
157
+ sourceH,
158
+ { format, background: col.background }
159
+ );
160
+ writeFileSync(outPath, buffer);
161
+ results.push({
162
+ collection: col.id,
163
+ template: template.name,
164
+ path: outPath,
165
+ width: w,
166
+ height: h,
167
+ size: buffer.length,
168
+ });
169
+ if (checksum) {
170
+ deps.recordExport(lockData, assetKey, variantKey, checksum, outPath);
171
+ }
172
+ _log(` → ${outPath}`);
173
+ }
174
+ }
175
+
176
+ // Handle outputs (xcode, copy-source, etc.)
177
+ const outputs = flatOutputs(col);
178
+ if (outputs.length > 0 && options.force) {
179
+ for (const output of outputs) {
180
+ if (output.type === "xcode") {
181
+ _log(`\n Exporting to Xcode: ${output.path}`);
182
+ const path = await deps.runXcodeOutput(projectDir, col, output);
183
+ results.push({ collection: col.id, type: "xcode", path });
184
+ } else if (output.type === "copy-source") {
185
+ const format = output.format || "svg";
186
+ const copyDir = join(outputDir, col.id);
187
+ mkdirSync(copyDir, { recursive: true });
188
+ const matchingTemplates = templates.filter((t) => t.src.endsWith(`.${format}`));
189
+ for (const template of matchingTemplates) {
190
+ const srcPath = resolve(projectDir, template.src);
191
+ // Use entry name when single match, template name when multiple
192
+ const baseName = output.name && matchingTemplates.length === 1
193
+ ? output.name
194
+ : template.name;
195
+ const destPath = join(copyDir, `${baseName}.${format}`);
196
+ if (existsSync(srcPath)) {
197
+ copyFileSync(srcPath, destPath);
198
+ results.push({
199
+ collection: col.id,
200
+ template: template.name,
201
+ type: `copy-${format}`,
202
+ path: destPath,
203
+ });
204
+ _log(` → ${destPath}`);
205
+ }
206
+ }
207
+ }
208
+ }
209
+ }
210
+ }
211
+
212
+ // Persist lockfile
213
+ deps.writeLockfile(projectDir, lockData);
214
+
215
+ await deps.closeBrowser();
216
+
217
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
218
+ return { results, skipped, elapsed: `${elapsed}s` };
219
+ }
package/lib/renderer.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { launch } from "puppeteer";
2
2
  import { resolve } from "path";
3
- import { writeFileSync, mkdirSync } from "fs";
3
+ import { writeFileSync, mkdirSync, readdirSync, unlinkSync } from "fs";
4
4
 
5
5
  let browser = null;
6
6
 
@@ -19,8 +19,15 @@ export async function closeBrowser() {
19
19
  }
20
20
 
21
21
  /**
22
- * Render an HTML file to a PNG buffer at the given dimensions.
22
+ * Render an HTML file to an image buffer at the given dimensions.
23
23
  * Scales from sourceSize to target size via CSS zoom.
24
+ * @param {string} projectDir
25
+ * @param {string} htmlFile
26
+ * @param {number} width
27
+ * @param {number} height
28
+ * @param {number} sourceW
29
+ * @param {number} sourceH
30
+ * @param {{ format?: "png"|"jpeg", quality?: number, background?: string }} [options]
24
31
  */
25
32
  export async function renderScreenshot(
26
33
  projectDir,
@@ -28,7 +35,8 @@ export async function renderScreenshot(
28
35
  width,
29
36
  height,
30
37
  sourceW,
31
- sourceH
38
+ sourceH,
39
+ options = {}
32
40
  ) {
33
41
  const b = await getBrowser();
34
42
  const page = await b.newPage();
@@ -48,10 +56,27 @@ export async function renderScreenshot(
48
56
  await new Promise((r) => setTimeout(r, 200));
49
57
  }
50
58
 
51
- const buffer = await page.screenshot({
52
- type: "png",
59
+ // Handle background: transparent by default for PNG, or use specified color
60
+ const bg = options.background;
61
+ if (bg) {
62
+ await page.evaluate((color) => {
63
+ document.documentElement.style.backgroundColor = color;
64
+ }, bg);
65
+ }
66
+
67
+ const format = options.format || "png";
68
+ const screenshotOpts = {
69
+ type: format,
53
70
  clip: { x: 0, y: 0, width, height },
54
- });
71
+ };
72
+ if (format === "png" && !bg) {
73
+ screenshotOpts.omitBackground = true;
74
+ }
75
+ if (format === "jpeg" && options.quality) {
76
+ screenshotOpts.quality = options.quality;
77
+ }
78
+
79
+ const buffer = await page.screenshot(screenshotOpts);
55
80
 
56
81
  await page.close();
57
82
  return buffer;
@@ -68,14 +93,39 @@ export async function runXcodeOutput(projectDir, collection, outputConfig) {
68
93
 
69
94
  const outputDir = resolve(projectDir, outputConfig.path);
70
95
  mkdirSync(outputDir, { recursive: true });
71
- const outputPath = resolve(outputDir, "AppIcon.png");
72
96
 
73
97
  const sw = collection.sourceSize.width;
74
98
  const sh = collection.sourceSize.height;
99
+ const filename = "AppIcon.png";
100
+ const outputPath = resolve(outputDir, filename);
101
+
102
+ // Remove stale images that don't match the current output filename
103
+ for (const entry of readdirSync(outputDir)) {
104
+ if (entry !== filename && /\.(png|jpg|jpeg)$/i.test(entry)) {
105
+ unlinkSync(resolve(outputDir, entry));
106
+ }
107
+ }
75
108
 
76
109
  const buffer = await renderScreenshot(projectDir, template.src, sw, sh, sw, sh);
77
110
  writeFileSync(outputPath, buffer);
78
111
 
112
+ const contentsJson = {
113
+ images: [
114
+ {
115
+ filename,
116
+ idiom: "universal",
117
+ platform: "ios",
118
+ size: `${sw}x${sh}`,
119
+ },
120
+ ],
121
+ info: {
122
+ author: "xcode",
123
+ version: 1,
124
+ },
125
+ };
126
+ const contentsPath = resolve(outputDir, "Contents.json");
127
+ writeFileSync(contentsPath, JSON.stringify(contentsJson, null, 2) + "\n");
128
+
79
129
  console.log(`Updated: ${outputPath}`);
80
130
  return outputPath;
81
131
  }
package/lib/server.mjs CHANGED
@@ -2,7 +2,6 @@ import { createServer } from "http";
2
2
  import { readFileSync, existsSync, statSync, watch } from "fs";
3
3
  import { resolve, join, extname } from "path";
4
4
  import { fileURLToPath } from "url";
5
- import { renderScreenshot, runXcodeOutput, closeBrowser } from "./renderer.mjs";
6
5
 
7
6
  const __dirname = resolve(fileURLToPath(import.meta.url), "..");
8
7
  const VIEWER_PATH = join(__dirname, "..", "public", "viewer.html");
@@ -28,7 +27,7 @@ const MIME_TYPES = {
28
27
  ".webm": "video/webm",
29
28
  };
30
29
 
31
- function getMime(filePath) {
30
+ export function getMime(filePath) {
32
31
  return MIME_TYPES[extname(filePath).toLowerCase()] || "application/octet-stream";
33
32
  }
34
33
 
@@ -52,11 +51,22 @@ function readBody(req) {
52
51
  });
53
52
  }
54
53
 
55
- export function startServer(projectDir, options = {}) {
54
+ /**
55
+ * @param {string} projectDir
56
+ * @param {object} [options]
57
+ * @param {{ renderScreenshot: Function, runXcodeOutput: Function, closeBrowser: Function }} [renderer]
58
+ * Optional renderer dependency injection. Falls back to importing ./renderer.mjs.
59
+ */
60
+ export async function startServer(projectDir, options = {}, renderer) {
61
+ if (!renderer) {
62
+ renderer = await import("./renderer.mjs");
63
+ }
64
+ const { renderScreenshot, runXcodeOutput, closeBrowser } = renderer;
65
+
56
66
  const port = options.port || 3200;
57
67
  const host = options.host || "localhost";
58
68
  const quiet = options.quiet || false;
59
- const manifestPath = options.manifestPath || resolve(projectDir, "manifest.json");
69
+ const manifestPath = options.manifestPath || resolve(projectDir, "assets.json");
60
70
  const staticDirs = (options.staticDirs || []).map((d) => resolve(d));
61
71
 
62
72
  const log = quiet ? () => {} : console.log.bind(console);
@@ -136,7 +146,7 @@ export function startServer(projectDir, options = {}) {
136
146
  // API: render screenshot (unified for all collection types)
137
147
  if (req.method === "POST" && pathname === "/api/render-screenshot") {
138
148
  try {
139
- const { file, width, height, sourceW, sourceH } = JSON.parse(
149
+ const { file, width, height, sourceW, sourceH, background } = JSON.parse(
140
150
  await readBody(req)
141
151
  );
142
152
  if (!file || !width || !height) {
@@ -148,7 +158,7 @@ export function startServer(projectDir, options = {}) {
148
158
  const sw = sourceW || width;
149
159
  const sh = sourceH || height;
150
160
  log(`Rendering ${file} at ${width}x${height} (source ${sw}x${sh})...`);
151
- const buffer = await renderScreenshot(projectDir, file, width, height, sw, sh);
161
+ const buffer = await renderScreenshot(projectDir, file, width, height, sw, sh, { background });
152
162
  res.writeHead(200, {
153
163
  "Content-Type": "image/png",
154
164
  "Content-Length": buffer.length,
@@ -171,7 +181,8 @@ export function startServer(projectDir, options = {}) {
171
181
  res.writeHead(404, { "Content-Type": "application/json" });
172
182
  return res.end(JSON.stringify({ ok: false, error: "Collection not found" }));
173
183
  }
174
- const output = col.outputs?.[outputIndex || 0];
184
+ const outputs = (col.export || []).filter((e) => e.type);
185
+ const output = outputs[outputIndex || 0];
175
186
  if (!output) {
176
187
  res.writeHead(404, { "Content-Type": "application/json" });
177
188
  return res.end(JSON.stringify({ ok: false, error: "Output not found" }));
@@ -235,30 +246,50 @@ export function startServer(projectDir, options = {}) {
235
246
  res.end();
236
247
  });
237
248
 
238
- server.listen(port, host, () => {
239
- log();
240
- log(` open-assets dev server`);
241
- log(` ───────────────────────`);
242
- log(` Local: http://${host}:${port}`);
243
- if (host === "0.0.0.0") {
244
- log(` Network: http://<your-ip>:${port}`);
245
- }
246
- log(` Project: ${projectDir}`);
247
- if (staticDirs.length > 0) {
248
- log(` Static: ${staticDirs.join(", ")}`);
249
- }
250
- log();
251
- });
249
+ const maxRetries = 10;
250
+ let currentPort = port;
251
+ let resolved = false;
252
+
253
+ const actualPort = await new Promise((resolvePort) => {
254
+ function tryListen() {
255
+ server.once("error", (err) => {
256
+ if (err.code === "EADDRINUSE") {
257
+ if (currentPort - port >= maxRetries) {
258
+ console.error(`Error: Ports ${port}–${currentPort} are all in use.`);
259
+ process.exit(1);
260
+ }
261
+ currentPort++;
262
+ server.close(() => tryListen());
263
+ return;
264
+ }
265
+ throw err;
266
+ });
252
267
 
253
- server.on("error", (err) => {
254
- if (err.code === "EADDRINUSE") {
255
- console.error(`Error: Port ${port} is already in use.`);
256
- console.error(`Try a different port: open-assets dev --port ${port + 1}`);
257
- process.exit(1);
268
+ server.listen(currentPort, host, () => {
269
+ if (resolved) return;
270
+ resolved = true;
271
+ const displayHost = host === "0.0.0.0" ? "localhost" : host;
272
+ if (currentPort !== port) {
273
+ log();
274
+ log(` Port ${port} is in use, using ${currentPort} instead.`);
275
+ }
276
+ log();
277
+ log(` Open Assets is running on http://${displayHost}:${currentPort}`);
278
+ log();
279
+ log(` Project: ${projectDir}`);
280
+ if (staticDirs.length > 0) {
281
+ log(` Static: ${staticDirs.join(", ")}`);
282
+ }
283
+ log();
284
+ resolvePort(currentPort);
285
+ });
258
286
  }
259
- throw err;
287
+
288
+ tryListen();
260
289
  });
261
290
 
291
+ return { port: actualPort };
292
+
262
293
  function cleanup() {
263
294
  for (const w of watchers) w.close();
264
295
  for (const client of sseClients) client.end();
@@ -0,0 +1,93 @@
1
+ import { existsSync } from "fs";
2
+ import { resolve } from "path";
3
+ import { flatSizes, flatOutputs } from "./manifest.mjs";
4
+
5
+ /**
6
+ * Validate a config object and check that referenced files exist.
7
+ * Returns structured results instead of printing/exiting.
8
+ *
9
+ * @param {object} manifest - Parsed config object
10
+ * @param {string} projectDir - Absolute path to the project directory
11
+ * @returns {{ checks: Array<{ ok: boolean, message: string }>, errors: number }}
12
+ */
13
+ export function validateConfig(manifest, projectDir) {
14
+ const checks = [];
15
+ const missingTemplates = [];
16
+ const emptyCollections = [];
17
+ let errors = 0;
18
+
19
+ const ok = (msg) => checks.push({ ok: true, message: msg });
20
+ const fail = (msg) => {
21
+ checks.push({ ok: false, message: msg });
22
+ errors++;
23
+ };
24
+
25
+ // Check top-level fields
26
+ if (manifest.name) {
27
+ ok(`name: "${manifest.name}"`);
28
+ } else {
29
+ fail("Missing top-level 'name' field");
30
+ }
31
+
32
+ if (Array.isArray(manifest.collections) && manifest.collections.length > 0) {
33
+ ok(`${manifest.collections.length} collection(s) defined`);
34
+ } else {
35
+ fail("Missing or empty 'collections' array");
36
+ return { checks, errors, missingTemplates, emptyCollections };
37
+ }
38
+
39
+ // Check each collection
40
+ const ids = new Set();
41
+ for (const col of manifest.collections) {
42
+ if (!col.id) {
43
+ fail("Collection missing 'id' field");
44
+ continue;
45
+ }
46
+ if (ids.has(col.id)) {
47
+ fail(`Duplicate collection id: "${col.id}"`);
48
+ }
49
+ ids.add(col.id);
50
+
51
+ if (!col.sourceSize || !col.sourceSize.width || !col.sourceSize.height) {
52
+ fail(`Collection "${col.id}" missing or invalid 'sourceSize'`);
53
+ } else {
54
+ ok(`Collection "${col.id}" sourceSize: ${col.sourceSize.width}\u00d7${col.sourceSize.height}`);
55
+ }
56
+
57
+ if (!col.templates || col.templates.length === 0) {
58
+ fail(`Collection "${col.id}" has no templates`);
59
+ emptyCollections.push({ collectionId: col.id });
60
+ } else {
61
+ for (const template of col.templates) {
62
+ if (!template.src) {
63
+ fail(`Collection "${col.id}" has a template without 'src'`);
64
+ continue;
65
+ }
66
+ const filePath = resolve(projectDir, template.src);
67
+ if (existsSync(filePath)) {
68
+ ok(`${template.src} exists`);
69
+ } else {
70
+ fail(`${template.src} not found`);
71
+ missingTemplates.push({
72
+ collectionId: col.id,
73
+ templateSrc: template.src,
74
+ templateName: template.name || template.label,
75
+ });
76
+ }
77
+ }
78
+ }
79
+
80
+ const sizes = flatSizes(col);
81
+ if (sizes.length > 0) {
82
+ ok(`Collection "${col.id}" has ${sizes.length} export size(s)`);
83
+ }
84
+
85
+ // Validate outputs (export entries with a type field)
86
+ const outputs = flatOutputs(col);
87
+ for (const output of outputs) {
88
+ ok(`Output: ${output.type}`);
89
+ }
90
+ }
91
+
92
+ return { checks, errors, missingTemplates, emptyCollections };
93
+ }