@open-assets/open-assets 0.1.12 → 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.
package/README.md CHANGED
@@ -10,6 +10,17 @@ Dev server and export tool for app marketing assets. Like Storybook, but for mar
10
10
 
11
11
  Design your App Store screenshots, app icons, logos, OG images, favicons, and more in HTML/CSS/SVG, preview them live in the browser, and export pixel-perfect PNGs at any size — including directly into your Xcode project.
12
12
 
13
+ ## Examples
14
+
15
+ <p><img src="public/img/examples/only-recipes-icon.png" width="14" height="14" /> <a href="https://onlyrecipes.app"><strong>Only Recipes</strong></a> (<a href="examples/only-recipes">examples</a>)</p>
16
+ <img src="public/img/examples/only-recipes.png" alt="Only Recipes screenshots" width="100%" />
17
+
18
+ <p><img src="public/img/examples/cat-iq-test-icon.png" width="14" height="14" /> <a href="https://apps.apple.com/us/app/cat-iq-test/id6759520249"><strong>Cat IQ Test</strong></a> (<a href="examples/cat-iq-test">examples</a>)</p>
19
+ <img src="public/img/examples/cat-iq-test.png" alt="Cat IQ Test screenshots" width="100%" />
20
+
21
+ <p><img src="public/img/examples/no-context-icon.png" width="14" height="14" /> <a href="https://nocontextbot.com"><strong>No Context Bot</strong></a> (<a href="examples/no-context">examples</a>)</p>
22
+ <img src="public/img/examples/no-context.png" alt="No Context Bot screenshots" width="100%" />
23
+
13
24
  ## Installation
14
25
 
15
26
  ```bash
@@ -48,7 +59,7 @@ Then use `oa` anywhere:
48
59
 
49
60
  ```bash
50
61
  oa dev
51
- oa render --all
62
+ oa render --force
52
63
  oa init
53
64
  ```
54
65
 
@@ -101,7 +112,7 @@ Claude reads your `assets.json` and `publicDir` to understand the project struct
101
112
 
102
113
  ```bash
103
114
  # With Tailwind
104
- concurrently "npx @tailwindcss/cli -i src/styles.css -o dist/styles.css --watch" "npx open-assets dev"
115
+ concurrently "npx @tailwindcss/cli -i assets/styles.css -o dist/styles.css --watch" "npx open-assets dev"
105
116
 
106
117
  # Without Tailwind
107
118
  npx open-assets dev
@@ -112,7 +123,7 @@ Opens a live preview UI at `http://localhost:3200` with zoom/pan controls and ex
112
123
  ### 4. Export
113
124
 
114
125
  ```bash
115
- npx open-assets render --all
126
+ npx open-assets render --force
116
127
  ```
117
128
 
118
129
  Exports every template at every configured export size into `./exports/`, organized as `exports/{collection}/{size}/{template}.png`.
@@ -130,7 +141,7 @@ Capture real app screenshots via Playwright or XCTest, then use them inside your
130
141
  npx playwright test --project=screenshots
131
142
 
132
143
  # 2. Export marketing screenshots with those captures embedded
133
- npx open-assets render --all
144
+ npx open-assets render --force
134
145
  ```
135
146
 
136
147
  Reference captured screenshots in your HTML templates:
@@ -141,7 +152,7 @@ Reference captured screenshots in your HTML templates:
141
152
  The `assets.json` `command` field stores the export command so automation tools know what to run:
142
153
  ```json
143
154
  {
144
- "command": "npx open-assets render --all"
155
+ "command": "npx open-assets render --force"
145
156
  }
146
157
  ```
147
158
 
@@ -181,12 +192,11 @@ Render assets headlessly via the command line, without opening a browser.
181
192
 
182
193
  ```bash
183
194
  open-assets render # Render all collections at source size
184
- open-assets render --all # Export at EVERY configured size
195
+ open-assets render --force # Export at every size, ignore cache
185
196
  open-assets render --collection screenshots # Render a specific collection
186
197
  open-assets render --template 01-hero # Render a single template
187
198
  open-assets render --size iphone-6.9 # Use a named export size
188
199
  open-assets render --width 1320 --height 2868 # Custom size
189
- open-assets render --force # Re-render everything, ignore cache
190
200
  open-assets render -o ./build # Custom output directory
191
201
  open-assets render --json # Output results as JSON (for CI)
192
202
  open-assets render --quiet # Suppress progress logs
@@ -200,8 +210,7 @@ Options:
200
210
  | `--size <name>` | — | — | Use a named export size from config |
201
211
  | `--width <px>` | — | — | Custom output width |
202
212
  | `--height <px>` | — | — | Custom output height |
203
- | `--all` | — | — | Export at every size defined in the config |
204
- | `--force` | — | — | Re-render all assets even if unchanged |
213
+ | `-f, --force` | — | — | Export at every size and re-render all (ignores cache) |
205
214
  | `-o, --output <dir>` | `OPEN_ASSETS_OUTPUT` | `./exports` | Output directory |
206
215
  | `--config <path>` | `OPEN_ASSETS_CONFIG` | `assets.json` | Path to config file |
207
216
  | `--parallel <count>` | `OPEN_ASSETS_PARALLEL` | `1` | Number of parallel renders |
@@ -215,7 +224,7 @@ Options:
215
224
  open-assets render --collection screenshots --template 01-hero --size iphone-6.9
216
225
 
217
226
  # One template at all device sizes
218
- open-assets render --collection screenshots --template 01-hero --all
227
+ open-assets render --collection screenshots --template 01-hero --force
219
228
 
220
229
  # All templates at one device size
221
230
  open-assets render --collection screenshots --size iphone-6.9
@@ -295,7 +304,7 @@ Your project needs a `assets.json` at its root. This file defines the collection
295
304
  "version": 1,
296
305
  "name": "My App",
297
306
  "publicDir": "public",
298
- "command": "npx open-assets render --all",
307
+ "command": "npx open-assets render --force",
299
308
  "collections": [ ... ]
300
309
  }
301
310
  ```
@@ -321,14 +330,12 @@ All collections follow the same structure. No `type` field needed.
321
330
  "sourceSize": { "width": 440, "height": 956 },
322
331
  "borderRadius": 4,
323
332
  "templates": [
324
- { "src": "src/screenshots/01-hero.html", "name": "01-hero", "label": "Hero" },
325
- { "src": "src/screenshots/02-features.html", "name": "02-features", "label": "Features" }
333
+ { "src": "assets/screenshots/01-hero.html", "name": "01-hero", "label": "Hero" },
334
+ { "src": "assets/screenshots/02-features.html", "name": "02-features", "label": "Features" }
326
335
  ],
327
336
  "export": [
328
337
  { "name": "iphone-6.9", "label": "iPhone 6.9\"", "size": { "width": 1320, "height": 2868 } },
329
- { "name": "iphone-6.7", "label": "iPhone 6.7\"", "size": { "width": 1290, "height": 2796 } }
330
- ],
331
- "outputs": [
338
+ { "name": "iphone-6.7", "label": "iPhone 6.7\"", "size": { "width": 1290, "height": 2796 } },
332
339
  { "type": "xcode", "path": "../MyApp/Assets.xcassets/AppIcon.appiconset" }
333
340
  ]
334
341
  }
@@ -344,16 +351,18 @@ All collections follow the same structure. No `type` field needed.
344
351
  | `templates[].src` | string | Path to HTML/SVG file (relative to project root) |
345
352
  | `templates[].name` | string | Filename for exports (used by `--template` flag) |
346
353
  | `templates[].label` | string | Display label |
347
- | `export` | array | Flat array of export size definitions |
354
+ | `export` | array | Array of export sizes and output actions |
348
355
  | `export[].name` | string | Size identifier (used by `--size` flag) |
349
356
  | `export[].label` | string | Display label |
350
357
  | `export[].size` | object | `{ width, height }` — output dimensions in pixels |
351
358
  | `export[].outFile` | string | Optional output path for this size (supports .png, .jpg) |
352
- | `outputs` | array | Optional post-render actions |
359
+ | `export[].type` | string | Output action type (`xcode`, `copy-source`). Entries with `type` are post-render actions, not sizes |
353
360
  | `customExport` | object | Optional `{ defaultWidth, defaultHeight }` for custom size UI |
354
361
 
355
362
  ### Output Types
356
363
 
364
+ Export entries with a `type` field are post-render actions (run with `--force`):
365
+
357
366
  | Type | Config | Description |
358
367
  |------|--------|-------------|
359
368
  | `xcode` | `{ "type": "xcode", "path": "..." }` | Render icon and write to Xcode asset catalog |
@@ -369,8 +378,8 @@ All collections follow the same structure. No `type` field needed.
369
378
  "sourceSize": { "width": 440, "height": 956 },
370
379
  "borderRadius": 4,
371
380
  "templates": [
372
- { "src": "src/screenshots/01-hero.html", "name": "01-hero", "label": "Hero" },
373
- { "src": "src/screenshots/02-import.html", "name": "02-import", "label": "Import" }
381
+ { "src": "assets/screenshots/01-hero.html", "name": "01-hero", "label": "Hero" },
382
+ { "src": "assets/screenshots/02-import.html", "name": "02-import", "label": "Import" }
374
383
  ],
375
384
  "export": [
376
385
  { "name": "iphone-6.9", "label": "iPhone 6.9\"", "size": { "width": 1320, "height": 2868 } },
@@ -389,14 +398,12 @@ All collections follow the same structure. No `type` field needed.
389
398
  "sourceSize": { "width": 1024, "height": 1024 },
390
399
  "borderRadius": 224,
391
400
  "templates": [
392
- { "src": "src/icon.html", "name": "icon", "label": "App Icon" }
401
+ { "src": "assets/icon.html", "name": "icon", "label": "App Icon" }
393
402
  ],
394
403
  "export": [
395
404
  { "name": "1024", "label": "1024px", "size": { "width": 1024, "height": 1024 } },
396
405
  { "name": "180", "label": "180px", "size": { "width": 180, "height": 180 } },
397
- { "name": "120", "label": "120px", "size": { "width": 120, "height": 120 } }
398
- ],
399
- "outputs": [
406
+ { "name": "120", "label": "120px", "size": { "width": 120, "height": 120 } },
400
407
  { "type": "xcode", "path": "../MyApp/Assets.xcassets/AppIcon.appiconset" }
401
408
  ]
402
409
  }
@@ -410,9 +417,9 @@ All collections follow the same structure. No `type` field needed.
410
417
  "sourceSize": { "width": 1024, "height": 1024 },
411
418
  "borderRadius": 224,
412
419
  "templates": [
413
- { "src": "src/icons/concept-a.html", "name": "concept-a", "label": "Concept A" },
414
- { "src": "src/icons/concept-b.html", "name": "concept-b", "label": "Concept B" },
415
- { "src": "src/icons/seasonal-winter.html", "name": "seasonal-winter", "label": "Winter" }
420
+ { "src": "assets/icons/concept-a.html", "name": "concept-a", "label": "Concept A" },
421
+ { "src": "assets/icons/concept-b.html", "name": "concept-b", "label": "Concept B" },
422
+ { "src": "assets/icons/seasonal-winter.html", "name": "seasonal-winter", "label": "Winter" }
416
423
  ],
417
424
  "export": [
418
425
  { "name": "1024", "label": "1024px", "size": { "width": 1024, "height": 1024 } },
@@ -428,16 +435,14 @@ All collections follow the same structure. No `type` field needed.
428
435
  "label": "Logo",
429
436
  "sourceSize": { "width": 940, "height": 940 },
430
437
  "templates": [
431
- { "src": "src/logo.svg", "name": "logo", "label": "Logo" },
432
- { "src": "src/logo-dark.svg", "name": "logo-dark", "label": "Dark" },
433
- { "src": "src/wordmark.svg", "name": "wordmark", "label": "Wordmark" }
438
+ { "src": "assets/logo.svg", "name": "logo", "label": "Logo" },
439
+ { "src": "assets/logo-dark.svg", "name": "logo-dark", "label": "Dark" },
440
+ { "src": "assets/wordmark.svg", "name": "wordmark", "label": "Wordmark" }
434
441
  ],
435
442
  "export": [
436
443
  { "name": "2048", "label": "2048px", "size": { "width": 2048, "height": 2048 } },
437
444
  { "name": "1024", "label": "1024px", "size": { "width": 1024, "height": 1024 } },
438
- { "name": "512", "label": "512px", "size": { "width": 512, "height": 512 } }
439
- ],
440
- "outputs": [
445
+ { "name": "512", "label": "512px", "size": { "width": 512, "height": 512 } },
441
446
  { "type": "copy-source", "format": "svg" }
442
447
  ]
443
448
  }
@@ -450,7 +455,7 @@ All collections follow the same structure. No `type` field needed.
450
455
  "label": "Social Cards",
451
456
  "sourceSize": { "width": 1200, "height": 630 },
452
457
  "templates": [
453
- { "src": "src/og/default.html", "name": "default", "label": "Default OG" }
458
+ { "src": "assets/og/default.html", "name": "default", "label": "Default OG" }
454
459
  ],
455
460
  "export": [
456
461
  { "name": "og", "label": "OG Image", "size": { "width": 1200, "height": 630 } }
@@ -465,7 +470,7 @@ All collections follow the same structure. No `type` field needed.
465
470
  "label": "Favicons",
466
471
  "sourceSize": { "width": 512, "height": 512 },
467
472
  "templates": [
468
- { "src": "src/favicon.html", "name": "favicon", "label": "Favicon" }
473
+ { "src": "assets/favicon.html", "name": "favicon", "label": "Favicon" }
469
474
  ],
470
475
  "export": [
471
476
  { "name": "512", "label": "512px", "size": { "width": 512, "height": 512 } },
@@ -482,7 +487,7 @@ All collections follow the same structure. No `type` field needed.
482
487
  The `render` command maintains a `assets.lock` file that stores SHA256 checksums of each source HTML/SVG file. On subsequent renders, unchanged assets are skipped automatically:
483
488
 
484
489
  ```
485
- $ open-assets render --all
490
+ $ open-assets render --force
486
491
  Skipping 01-hero at 1320x2868 (unchanged)
487
492
  Rendering 03-new-screen at 1320x2868...
488
493
  → exports/screenshots/iphone-6.9/03-new-screen.png
@@ -494,7 +499,7 @@ Use `--force` to re-render everything regardless of the cache.
494
499
 
495
500
  **Limitation**: Only source HTML/SVG files are checksummed. Changes to referenced assets (images in `publicDir`, compiled Tailwind CSS) won't trigger re-renders — use `--force` when those change.
496
501
 
497
- Add `assets.lock` to your `.gitignore` (done automatically by `open-assets init`).
502
+ Commit `assets.lock` to your repository so teammates and CI benefit from the cache.
498
503
 
499
504
  ## CI/CD with GitHub Actions
500
505
 
@@ -517,7 +522,7 @@ jobs:
517
522
  node-version: 20
518
523
  - run: npm ci
519
524
  - run: npx open-assets validate
520
- - run: npx open-assets render --all --json --quiet
525
+ - run: npx open-assets render --force --json --quiet
521
526
  - uses: actions/upload-artifact@v4
522
527
  with:
523
528
  name: screenshots
@@ -544,14 +549,14 @@ jobs:
544
549
  ```
545
550
  project/
546
551
  assets.json
547
- assets.lock # Auto-generated cache (gitignored)
552
+ assets.lock # Auto-generated cache (commit this)
548
553
  public/ # Shared assets (configured via publicDir)
549
554
  logo-round.png
550
555
  social/
551
556
  youtube.svg
552
557
  screenshots/ # Real app screenshots from UI tests
553
558
  01-home.png
554
- src/
559
+ assets/
555
560
  styles.css # Tailwind input
556
561
  icon.html # App icon template
557
562
  logo.svg # Vector logo template
@@ -63,7 +63,7 @@ program
63
63
 
64
64
  const { startServer } = await import("../lib/server.mjs");
65
65
  const port = parseInt(opts.port, 10);
66
- startServer(projectDir, {
66
+ const { port: actualPort } = await startServer(projectDir, {
67
67
  port,
68
68
  host: opts.host,
69
69
  quiet: opts.quiet,
@@ -74,7 +74,8 @@ program
74
74
 
75
75
  if (opts.open) {
76
76
  const open = (await import("open")).default;
77
- setTimeout(() => open(`http://${opts.host}:${port}`), 500);
77
+ const displayHost = opts.host === "0.0.0.0" ? "localhost" : opts.host;
78
+ setTimeout(() => open(`http://${displayHost}:${actualPort}`), 500);
78
79
  }
79
80
  });
80
81
 
@@ -90,8 +91,7 @@ program
90
91
  .option("--size <name>", "Use a named export size from config")
91
92
  .option("--width <px>", "Output width in pixels (custom size)")
92
93
  .option("--height <px>", "Output height in pixels (custom size)")
93
- .option("--all", "Export at every size defined in the config")
94
- .option("--force", "Re-render all assets even if unchanged")
94
+ .option("-f, --force", "Export at every size and re-render all assets (ignores cache)")
95
95
  .option("-o, --output <dir>", "Output directory (env: OPEN_ASSETS_OUTPUT)", env("OPEN_ASSETS_OUTPUT", "./exports"))
96
96
  .option("--config <path>", "Path to config file (env: OPEN_ASSETS_CONFIG)", env("OPEN_ASSETS_CONFIG", "assets.json"))
97
97
  .option("--parallel <count>", "Number of parallel renders (env: OPEN_ASSETS_PARALLEL)", env("OPEN_ASSETS_PARALLEL", "1"))
@@ -189,8 +189,9 @@ program
189
189
  .description("Validate the assets.json and check that all referenced files exist")
190
190
  .argument("[dir]", "Project directory containing assets.json", ".")
191
191
  .option("--config <path>", "Path to config file", env("OPEN_ASSETS_CONFIG", "assets.json"))
192
+ .option("--fix", "Interactively fix issues (e.g. remove missing templates)")
192
193
  .action(async (dir, opts) => {
193
- const { loadConfig } = await import("../lib/manifest.mjs");
194
+ const { loadConfig, saveConfig } = await import("../lib/manifest.mjs");
194
195
  const { validateConfig } = await import("../lib/validate.mjs");
195
196
 
196
197
  const projectDir = resolve(dir);
@@ -204,7 +205,7 @@ program
204
205
 
205
206
  console.log(`\n Validating ${opts.config}...\n`);
206
207
 
207
- const { checks, errors } = validateConfig(manifest, projectDir);
208
+ const { checks, errors, missingTemplates, emptyCollections } = validateConfig(manifest, projectDir);
208
209
 
209
210
  for (const check of checks) {
210
211
  console.log(` ${check.ok ? "\u2713" : "\u2717"} ${check.message}`);
@@ -215,6 +216,58 @@ program
215
216
  console.log(" All checks passed.\n");
216
217
  } else {
217
218
  console.log(` ${errors} error(s) found.\n`);
219
+
220
+ const fixable = missingTemplates.length > 0 || emptyCollections.length > 0;
221
+
222
+ if (fixable && opts.fix) {
223
+ const { createInterface } = await import("readline");
224
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
225
+ const ask = (question) =>
226
+ new Promise((resolve) => rl.question(question, (answer) => resolve(answer.trim().toLowerCase())));
227
+
228
+ let changes = 0;
229
+
230
+ for (const missing of missingTemplates) {
231
+ const answer = await ask(
232
+ ` Remove template "${missing.templateSrc}" from collection "${missing.collectionId}"? (y/N) `
233
+ );
234
+ if (answer === "y" || answer === "yes") {
235
+ const col = manifest.collections.find((c) => c.id === missing.collectionId);
236
+ if (col) {
237
+ col.templates = col.templates.filter((t) => t.src !== missing.templateSrc);
238
+ changes++;
239
+ }
240
+ }
241
+ }
242
+
243
+ // Include collections that were already empty plus ones emptied by template removal
244
+ const emptyIds = new Set(emptyCollections.map((e) => e.collectionId));
245
+ for (const col of manifest.collections) {
246
+ if (!col.templates || col.templates.length === 0) {
247
+ emptyIds.add(col.id);
248
+ }
249
+ }
250
+
251
+ for (const collectionId of emptyIds) {
252
+ const answer = await ask(
253
+ ` Remove empty collection "${collectionId}"? (y/N) `
254
+ );
255
+ if (answer === "y" || answer === "yes") {
256
+ manifest.collections = manifest.collections.filter((c) => c.id !== collectionId);
257
+ changes++;
258
+ }
259
+ }
260
+
261
+ rl.close();
262
+
263
+ if (changes > 0) {
264
+ saveConfig(projectDir, opts.config, manifest);
265
+ console.log(`\n Updated ${opts.config} (${changes} fix(es) applied).\n`);
266
+ }
267
+ } else if (fixable) {
268
+ console.log(` Run with --fix to remove missing templates and empty collections.\n`);
269
+ }
270
+
218
271
  process.exit(1);
219
272
  }
220
273
  });
package/lib/add.mjs CHANGED
@@ -150,8 +150,8 @@ const COLLECTION_PRESETS = [
150
150
  { name: "800", label: "800px", size: { width: 800, height: 800 } },
151
151
  { name: "400", label: "400px", size: { width: 400, height: 400 } },
152
152
  { name: "200", label: "200px", size: { width: 200, height: 200 } },
153
+ { type: "copy-source", format: "svg" },
153
154
  ],
154
- outputs: [{ type: "copy-source", format: "svg" }],
155
155
  },
156
156
  {
157
157
  value: "wordmark",
@@ -169,8 +169,8 @@ const COLLECTION_PRESETS = [
169
169
  { name: "1200x400", label: "Large", size: { width: 1200, height: 400 } },
170
170
  { name: "600x200", label: "Medium", size: { width: 600, height: 200 } },
171
171
  { name: "300x100", label: "Small", size: { width: 300, height: 100 } },
172
+ { type: "copy-source", format: "svg" },
172
173
  ],
173
- outputs: [{ type: "copy-source", format: "svg" }],
174
174
  },
175
175
  {
176
176
  value: "feature-graphic",
@@ -223,7 +223,7 @@ export async function addCollection(projectDir, configName) {
223
223
  // 1. Choose from preset or custom
224
224
  const preset = await askSelect(rl, "Choose a collection type:", COLLECTION_PRESETS);
225
225
 
226
- let id, label, sourceSize, borderRadius, format, tags, exportList, outputs, templateDir, templateName, templateLabel;
226
+ let id, label, sourceSize, borderRadius, format, tags, exportList, templateDir, templateName, templateLabel;
227
227
 
228
228
  if (preset.value === "custom") {
229
229
  // Custom collection
@@ -256,7 +256,9 @@ export async function addCollection(projectDir, configName) {
256
256
  exportList = [
257
257
  { name: `${sourceSize.width}x${sourceSize.height}`, label: "Source Size", size: { width: sourceSize.width, height: sourceSize.height } },
258
258
  ];
259
- outputs = format === "svg" ? [{ type: "copy-source", format: "svg" }] : undefined;
259
+ if (format === "svg") {
260
+ exportList.push({ type: "copy-source", format: "svg" });
261
+ }
260
262
  templateDir = `src/${id}` === `src/${id}` && id.includes("/") ? `src` : (sourceSize.width === sourceSize.height ? "src" : `src/${id}`);
261
263
  templateDir = `src`;
262
264
  templateName = id;
@@ -270,7 +272,6 @@ export async function addCollection(projectDir, configName) {
270
272
  format = preset.format;
271
273
  tags = preset.tags;
272
274
  exportList = preset.export;
273
- outputs = preset.outputs;
274
275
  templateDir = preset.templateDir;
275
276
  templateName = preset.templateName;
276
277
  templateLabel = preset.templateLabel;
@@ -310,7 +311,6 @@ export async function addCollection(projectDir, configName) {
310
311
  ],
311
312
  export: exportList,
312
313
  };
313
- if (outputs) collection.outputs = outputs;
314
314
 
315
315
  manifest.collections.push(collection);
316
316
 
package/lib/init.mjs CHANGED
@@ -641,7 +641,7 @@ export async function scaffoldProject(dir) {
641
641
  console.log(" Created assets.json");
642
642
 
643
643
  // Write .gitignore
644
- writeFileSync(join(dir, ".gitignore"), "dist/\nexports/\nassets.lock\nnode_modules/\n");
644
+ writeFileSync(join(dir, ".gitignore"), "dist/\nexports/\nnode_modules/\n");
645
645
  console.log(" Created .gitignore");
646
646
 
647
647
  console.log();
package/lib/list.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { flatSizes } from "./manifest.mjs";
1
+ import { flatSizes, flatOutputs } from "./manifest.mjs";
2
2
 
3
3
  /**
4
4
  * Filter collections by tag. Returns filtered array.
@@ -47,17 +47,15 @@ export function formatCollectionList(manifest, collections) {
47
47
  lines.push(` \u2022 ${template.label || template.name} \u2192 ${template.src}`);
48
48
  }
49
49
 
50
- for (const entry of col.export || []) {
50
+ for (const entry of flatSizes(col)) {
51
51
  lines.push(` \u21b3 ${entry.label} (${entry.size.width}\u00d7${entry.size.height})`);
52
52
  }
53
53
 
54
- if (col.outputs) {
55
- for (const output of col.outputs) {
56
- if (output.type === "xcode") {
57
- lines.push(` \u21b3 Xcode: ${output.path}`);
58
- } else if (output.type === "copy-source") {
59
- lines.push(` \u21b3 Copy source: ${output.format}`);
60
- }
54
+ for (const output of flatOutputs(col)) {
55
+ if (output.type === "xcode") {
56
+ lines.push(` \u21b3 Xcode: ${output.path}`);
57
+ } else if (output.type === "copy-source") {
58
+ lines.push(` \u21b3 Copy source: ${output.format}`);
61
59
  }
62
60
  }
63
61
  }
package/lib/lockfile.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFileSync, writeFileSync, existsSync } from "fs";
2
2
  import { createHash } from "crypto";
3
- import { resolve, join } from "path";
3
+ import { resolve, join, dirname } from "path";
4
4
 
5
5
  const LOCK_FILENAME = "assets.lock";
6
6
 
@@ -27,13 +27,64 @@ export function writeLockfile(projectDir, lockData) {
27
27
  writeFileSync(lockPath, JSON.stringify(lockData, null, 2) + "\n");
28
28
  }
29
29
 
30
+ /**
31
+ * Extract local file dependencies referenced by an HTML file.
32
+ * Parses src="...", href="...", and url(...) references, filtering out
33
+ * remote URLs and returning only paths that exist on disk.
34
+ * Returns a sorted, deduplicated array of absolute paths.
35
+ */
36
+ export function extractLocalDeps(filePath) {
37
+ if (!existsSync(filePath)) return [];
38
+ const content = readFileSync(filePath, "utf-8");
39
+ const dir = dirname(filePath);
40
+
41
+ const refs = new Set();
42
+ const srcHrefPattern = /(?:src|href)=["']([^"']+)["']/gi;
43
+ const cssUrlPattern = /url\(["']?([^"')]+)["']?\)/gi;
44
+
45
+ for (const pattern of [srcHrefPattern, cssUrlPattern]) {
46
+ let match;
47
+ while ((match = pattern.exec(content)) !== null) {
48
+ refs.add(match[1]);
49
+ }
50
+ }
51
+
52
+ const localPaths = [];
53
+ for (const ref of refs) {
54
+ if (/^(https?:\/\/|data:|\/\/|#)/.test(ref)) continue;
55
+ if (!ref) continue;
56
+ const abs = resolve(dir, ref);
57
+ if (existsSync(abs)) {
58
+ localPaths.push(abs);
59
+ }
60
+ }
61
+
62
+ return [...new Set(localPaths)].sort();
63
+ }
64
+
30
65
  /**
31
66
  * Compute SHA256 checksum of a file's contents.
67
+ * For .html files, includes the content of all local dependencies
68
+ * (images, SVGs, etc.) referenced via src/href/url() attributes.
32
69
  * Returns "sha256:<hex>" or null if file doesn't exist.
33
70
  */
34
71
  export function computeChecksum(filePath) {
35
72
  if (!existsSync(filePath)) return null;
36
73
  const content = readFileSync(filePath);
74
+
75
+ if (filePath.endsWith(".html")) {
76
+ const deps = extractLocalDeps(filePath);
77
+ if (deps.length > 0) {
78
+ const hash = createHash("sha256");
79
+ hash.update(content);
80
+ for (const dep of deps) {
81
+ hash.update("\0");
82
+ hash.update(readFileSync(dep));
83
+ }
84
+ return `sha256:${hash.digest("hex")}`;
85
+ }
86
+ }
87
+
37
88
  const hash = createHash("sha256").update(content).digest("hex");
38
89
  return `sha256:${hash}`;
39
90
  }
package/lib/manifest.mjs CHANGED
@@ -2,10 +2,17 @@ import { existsSync, readFileSync, writeFileSync } from "fs";
2
2
  import { resolve } from "path";
3
3
 
4
4
  /**
5
- * Get all export sizes from a collection.
5
+ * Get all export sizes from a collection (entries without a type field).
6
6
  */
7
7
  export function flatSizes(collection) {
8
- return collection.export || [];
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);
9
16
  }
10
17
 
11
18
  /**
@@ -77,7 +84,7 @@ export function filterTemplates(templates, templateName) {
77
84
  /**
78
85
  * Determine which sizes to render for a collection given CLI options.
79
86
  * @param {object} collection - The collection from the config
80
- * @param {{ width?: string|number, height?: string|number, size?: string, all?: boolean, platform?: string }} opts
87
+ * @param {{ width?: string|number, height?: string|number, size?: string, force?: boolean, platform?: string }} opts
81
88
  * @returns {{ sizes: object[], warning?: string }}
82
89
  */
83
90
  export function resolveRenderSizes(collection, opts = {}) {
@@ -109,7 +116,7 @@ export function resolveRenderSizes(collection, opts = {}) {
109
116
  };
110
117
  }
111
118
 
112
- if (opts.all) {
119
+ if (opts.force) {
113
120
  const sizes = flatSizes(collection);
114
121
  return { sizes };
115
122
  }
package/lib/render.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { resolve, join } from "path";
2
2
  import { mkdirSync, writeFileSync, copyFileSync, existsSync } from "fs";
3
- import { filterCollections, filterTemplates, resolveRenderSizes } from "./manifest.mjs";
3
+ import { filterCollections, filterTemplates, resolveRenderSizes, flatOutputs } from "./manifest.mjs";
4
4
 
5
5
  /**
6
6
  * @typedef {object} RenderDeps
@@ -27,8 +27,7 @@ import { filterCollections, filterTemplates, resolveRenderSizes } from "./manife
27
27
  * @param {string} [options.format] - Output format override (png or jpg)
28
28
  * @param {string|number} [options.width] - Custom width
29
29
  * @param {string|number} [options.height] - Custom height
30
- * @param {boolean} [options.all] - Export at all sizes
31
- * @param {boolean} [options.force] - Re-render even if unchanged
30
+ * @param {boolean} [options.force] - Export at all sizes and re-render (ignores cache)
32
31
  * @param {string} [options.output] - Output directory path
33
32
  * @param {boolean} [options.quiet] - Suppress logs
34
33
  * @param {RenderDeps} deps - Injected dependencies
@@ -67,7 +66,7 @@ export async function renderAssets(projectDir, manifest, options, deps, log) {
67
66
  width: options.width,
68
67
  height: options.height,
69
68
  size: options.size,
70
- all: options.all,
69
+ force: options.force,
71
70
  });
72
71
 
73
72
  if (warning) {
@@ -80,7 +79,11 @@ export async function renderAssets(projectDir, manifest, options, deps, log) {
80
79
  continue;
81
80
  }
82
81
 
83
- const useSubDirs = sizesToRender.length > 1 || options.all;
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;
84
87
 
85
88
  for (const entry of sizesToRender) {
86
89
  const w = entry.size.width;
@@ -103,9 +106,15 @@ export async function renderAssets(projectDir, manifest, options, deps, log) {
103
106
  const format = outExt === "jpg" || outExt === "jpeg" ? "jpeg" : "png";
104
107
  const defaultExt = format === "jpeg" ? "jpg" : "png";
105
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
+
106
115
  const outPath = entry.outFile
107
116
  ? resolve(projectDir, entry.outFile.replace(/\{template\}/g, template.name))
108
- : join(sizeDir, `${template.name}.${defaultExt}`);
117
+ : join(sizeDir, fileName);
109
118
 
110
119
  const assetKey = `${col.id}/${template.name}`;
111
120
  const variantKey = entry.name;
@@ -146,7 +155,7 @@ export async function renderAssets(projectDir, manifest, options, deps, log) {
146
155
  h,
147
156
  sourceW,
148
157
  sourceH,
149
- { format }
158
+ { format, background: col.background }
150
159
  );
151
160
  writeFileSync(outPath, buffer);
152
161
  results.push({
@@ -165,30 +174,34 @@ export async function renderAssets(projectDir, manifest, options, deps, log) {
165
174
  }
166
175
 
167
176
  // Handle outputs (xcode, copy-source, etc.)
168
- if (col.outputs && options.all) {
169
- for (const output of col.outputs) {
177
+ const outputs = flatOutputs(col);
178
+ if (outputs.length > 0 && options.force) {
179
+ for (const output of outputs) {
170
180
  if (output.type === "xcode") {
171
181
  _log(`\n Exporting to Xcode: ${output.path}`);
172
182
  const path = await deps.runXcodeOutput(projectDir, col, output);
173
183
  results.push({ collection: col.id, type: "xcode", path });
174
184
  } else if (output.type === "copy-source") {
175
185
  const format = output.format || "svg";
176
- const copyDir = join(outputDir, col.id, format);
186
+ const copyDir = join(outputDir, col.id);
177
187
  mkdirSync(copyDir, { recursive: true });
178
- for (const template of templates) {
179
- if (template.src.endsWith(`.${format}`)) {
180
- const srcPath = resolve(projectDir, template.src);
181
- const destPath = join(copyDir, `${template.name}.${format}`);
182
- if (existsSync(srcPath)) {
183
- copyFileSync(srcPath, destPath);
184
- results.push({
185
- collection: col.id,
186
- template: template.name,
187
- type: `copy-${format}`,
188
- path: destPath,
189
- });
190
- _log(` → ${destPath}`);
191
- }
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}`);
192
205
  }
193
206
  }
194
207
  }
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
 
@@ -27,7 +27,7 @@ export async function closeBrowser() {
27
27
  * @param {number} height
28
28
  * @param {number} sourceW
29
29
  * @param {number} sourceH
30
- * @param {{ format?: "png"|"jpeg", quality?: number }} [options]
30
+ * @param {{ format?: "png"|"jpeg", quality?: number, background?: string }} [options]
31
31
  */
32
32
  export async function renderScreenshot(
33
33
  projectDir,
@@ -56,11 +56,22 @@ export async function renderScreenshot(
56
56
  await new Promise((r) => setTimeout(r, 200));
57
57
  }
58
58
 
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
+
59
67
  const format = options.format || "png";
60
68
  const screenshotOpts = {
61
69
  type: format,
62
70
  clip: { x: 0, y: 0, width, height },
63
71
  };
72
+ if (format === "png" && !bg) {
73
+ screenshotOpts.omitBackground = true;
74
+ }
64
75
  if (format === "jpeg" && options.quality) {
65
76
  screenshotOpts.quality = options.quality;
66
77
  }
@@ -82,14 +93,39 @@ export async function runXcodeOutput(projectDir, collection, outputConfig) {
82
93
 
83
94
  const outputDir = resolve(projectDir, outputConfig.path);
84
95
  mkdirSync(outputDir, { recursive: true });
85
- const outputPath = resolve(outputDir, "AppIcon.png");
86
96
 
87
97
  const sw = collection.sourceSize.width;
88
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
+ }
89
108
 
90
109
  const buffer = await renderScreenshot(projectDir, template.src, sw, sh, sw, sh);
91
110
  writeFileSync(outputPath, buffer);
92
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
+
93
129
  console.log(`Updated: ${outputPath}`);
94
130
  return outputPath;
95
131
  }
package/lib/server.mjs CHANGED
@@ -146,7 +146,7 @@ export async function startServer(projectDir, options = {}, renderer) {
146
146
  // API: render screenshot (unified for all collection types)
147
147
  if (req.method === "POST" && pathname === "/api/render-screenshot") {
148
148
  try {
149
- const { file, width, height, sourceW, sourceH } = JSON.parse(
149
+ const { file, width, height, sourceW, sourceH, background } = JSON.parse(
150
150
  await readBody(req)
151
151
  );
152
152
  if (!file || !width || !height) {
@@ -158,7 +158,7 @@ export async function startServer(projectDir, options = {}, renderer) {
158
158
  const sw = sourceW || width;
159
159
  const sh = sourceH || height;
160
160
  log(`Rendering ${file} at ${width}x${height} (source ${sw}x${sh})...`);
161
- const buffer = await renderScreenshot(projectDir, file, width, height, sw, sh);
161
+ const buffer = await renderScreenshot(projectDir, file, width, height, sw, sh, { background });
162
162
  res.writeHead(200, {
163
163
  "Content-Type": "image/png",
164
164
  "Content-Length": buffer.length,
@@ -181,7 +181,8 @@ export async function startServer(projectDir, options = {}, renderer) {
181
181
  res.writeHead(404, { "Content-Type": "application/json" });
182
182
  return res.end(JSON.stringify({ ok: false, error: "Collection not found" }));
183
183
  }
184
- const output = col.outputs?.[outputIndex || 0];
184
+ const outputs = (col.export || []).filter((e) => e.type);
185
+ const output = outputs[outputIndex || 0];
185
186
  if (!output) {
186
187
  res.writeHead(404, { "Content-Type": "application/json" });
187
188
  return res.end(JSON.stringify({ ok: false, error: "Output not found" }));
@@ -245,30 +246,50 @@ export async function startServer(projectDir, options = {}, renderer) {
245
246
  res.end();
246
247
  });
247
248
 
248
- server.listen(port, host, () => {
249
- log();
250
- log(` open-assets dev server`);
251
- log(` ───────────────────────`);
252
- log(` Local: http://${host}:${port}`);
253
- if (host === "0.0.0.0") {
254
- log(` Network: http://<your-ip>:${port}`);
255
- }
256
- log(` Project: ${projectDir}`);
257
- if (staticDirs.length > 0) {
258
- log(` Static: ${staticDirs.join(", ")}`);
259
- }
260
- log();
261
- });
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
+ });
262
267
 
263
- server.on("error", (err) => {
264
- if (err.code === "EADDRINUSE") {
265
- console.error(`Error: Port ${port} is already in use.`);
266
- console.error(`Try a different port: open-assets dev --port ${port + 1}`);
267
- 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
+ });
268
286
  }
269
- throw err;
287
+
288
+ tryListen();
270
289
  });
271
290
 
291
+ return { port: actualPort };
292
+
272
293
  function cleanup() {
273
294
  for (const w of watchers) w.close();
274
295
  for (const client of sseClients) client.end();
package/lib/validate.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from "fs";
2
2
  import { resolve } from "path";
3
- import { flatSizes } from "./manifest.mjs";
3
+ import { flatSizes, flatOutputs } from "./manifest.mjs";
4
4
 
5
5
  /**
6
6
  * Validate a config object and check that referenced files exist.
@@ -12,6 +12,8 @@ import { flatSizes } from "./manifest.mjs";
12
12
  */
13
13
  export function validateConfig(manifest, projectDir) {
14
14
  const checks = [];
15
+ const missingTemplates = [];
16
+ const emptyCollections = [];
15
17
  let errors = 0;
16
18
 
17
19
  const ok = (msg) => checks.push({ ok: true, message: msg });
@@ -31,7 +33,7 @@ export function validateConfig(manifest, projectDir) {
31
33
  ok(`${manifest.collections.length} collection(s) defined`);
32
34
  } else {
33
35
  fail("Missing or empty 'collections' array");
34
- return { checks, errors };
36
+ return { checks, errors, missingTemplates, emptyCollections };
35
37
  }
36
38
 
37
39
  // Check each collection
@@ -54,6 +56,7 @@ export function validateConfig(manifest, projectDir) {
54
56
 
55
57
  if (!col.templates || col.templates.length === 0) {
56
58
  fail(`Collection "${col.id}" has no templates`);
59
+ emptyCollections.push({ collectionId: col.id });
57
60
  } else {
58
61
  for (const template of col.templates) {
59
62
  if (!template.src) {
@@ -65,6 +68,11 @@ export function validateConfig(manifest, projectDir) {
65
68
  ok(`${template.src} exists`);
66
69
  } else {
67
70
  fail(`${template.src} not found`);
71
+ missingTemplates.push({
72
+ collectionId: col.id,
73
+ templateSrc: template.src,
74
+ templateName: template.name || template.label,
75
+ });
68
76
  }
69
77
  }
70
78
  }
@@ -74,17 +82,12 @@ export function validateConfig(manifest, projectDir) {
74
82
  ok(`Collection "${col.id}" has ${sizes.length} export size(s)`);
75
83
  }
76
84
 
77
- // Validate outputs
78
- if (col.outputs) {
79
- for (const output of col.outputs) {
80
- if (!output.type) {
81
- fail(`Collection "${col.id}" has an output without 'type'`);
82
- } else {
83
- ok(`Output: ${output.type}`);
84
- }
85
- }
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}`);
86
89
  }
87
90
  }
88
91
 
89
- return { checks, errors };
92
+ return { checks, errors, missingTemplates, emptyCollections };
90
93
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-assets/open-assets",
3
- "version": "0.1.12",
3
+ "version": "0.2.0",
4
4
  "description": "Dev server and export tool for app screenshots, icons, and logos. Like Storybook for marketing assets.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -39,7 +39,7 @@
39
39
  "access": "public"
40
40
  },
41
41
  "scripts": {
42
- "assets": "node bin/open-assets.mjs render --all -o ./public/img",
42
+ "assets": "node bin/open-assets.mjs render --force -o ./public/img",
43
43
  "test": "node --experimental-vm-modules node_modules/.bin/jest",
44
44
  "release": "./scripts/release.sh"
45
45
  },
@@ -254,46 +254,46 @@
254
254
  const sh = col.sourceSize.height;
255
255
  const hasMultipleTemplates = col.templates.length > 1;
256
256
 
257
- // Outputs (e.g., Xcode)
258
- if (col.outputs) {
259
- for (let i = 0; i < col.outputs.length; i++) {
260
- const output = col.outputs[i];
261
- if (output.type === 'xcode') {
262
- const sectionLabel = document.createElement('div');
263
- sectionLabel.className = 'section-label';
264
- sectionLabel.textContent = 'Xcode';
265
- exportDropdown.appendChild(sectionLabel);
266
-
267
- const btn = document.createElement('button');
268
- btn.innerHTML = 'Export to Xcode <span class="dim">.appiconset</span>';
269
- btn.addEventListener('click', () => runOutput(col.id, i));
270
- exportDropdown.appendChild(btn);
271
-
272
- exportDropdown.appendChild(document.createElement('hr'));
273
- } else if (output.type === 'copy-source') {
274
- const sectionLabel = document.createElement('div');
275
- sectionLabel.className = 'section-label';
276
- sectionLabel.textContent = output.format.toUpperCase();
277
- exportDropdown.appendChild(sectionLabel);
278
-
279
- // SVG download for each template
280
- for (const template of col.templates) {
281
- if (template.src.endsWith('.' + output.format)) {
282
- const btn = document.createElement('button');
283
- const displayName = hasMultipleTemplates ? template.label : `Download ${output.format.toUpperCase()}`;
284
- btn.innerHTML = `${displayName} <span class="dim">${output.format}</span>`;
285
- btn.addEventListener('click', () => downloadSource(template));
286
- exportDropdown.appendChild(btn);
287
- }
257
+ // Outputs (e.g., Xcode, copy-source) — export entries with a type field
258
+ const allExports = col.export || [];
259
+ const outputs = allExports.filter(e => e.type);
260
+ for (let i = 0; i < outputs.length; i++) {
261
+ const output = outputs[i];
262
+ if (output.type === 'xcode') {
263
+ const sectionLabel = document.createElement('div');
264
+ sectionLabel.className = 'section-label';
265
+ sectionLabel.textContent = 'Xcode';
266
+ exportDropdown.appendChild(sectionLabel);
267
+
268
+ const btn = document.createElement('button');
269
+ btn.innerHTML = 'Export to Xcode <span class="dim">.appiconset</span>';
270
+ btn.addEventListener('click', () => runOutput(col.id, i));
271
+ exportDropdown.appendChild(btn);
272
+
273
+ exportDropdown.appendChild(document.createElement('hr'));
274
+ } else if (output.type === 'copy-source') {
275
+ const sectionLabel = document.createElement('div');
276
+ sectionLabel.className = 'section-label';
277
+ sectionLabel.textContent = output.format.toUpperCase();
278
+ exportDropdown.appendChild(sectionLabel);
279
+
280
+ // SVG download for each template
281
+ for (const template of col.templates) {
282
+ if (template.src.endsWith('.' + output.format)) {
283
+ const btn = document.createElement('button');
284
+ const displayName = hasMultipleTemplates ? template.label : `Download ${output.format.toUpperCase()}`;
285
+ btn.innerHTML = `${displayName} <span class="dim">${output.format}</span>`;
286
+ btn.addEventListener('click', () => downloadSource(template));
287
+ exportDropdown.appendChild(btn);
288
288
  }
289
-
290
- exportDropdown.appendChild(document.createElement('hr'));
291
289
  }
290
+
291
+ exportDropdown.appendChild(document.createElement('hr'));
292
292
  }
293
293
  }
294
294
 
295
- // Export sizes
296
- const sizes = col.export || [];
295
+ // Export sizes (entries without a type field)
296
+ const sizes = allExports.filter(e => !e.type);
297
297
  if (sizes.length > 0) {
298
298
  const sectionLabel = document.createElement('div');
299
299
  sectionLabel.className = 'section-label';
@@ -15,14 +15,13 @@ open-assets is a dev server and export tool for app marketing assets — screens
15
15
  open-assets dev
16
16
 
17
17
  # Or with Tailwind
18
- concurrently "npx @tailwindcss/cli -i src/styles.css -o dist/styles.css --watch" "open-assets dev"
18
+ concurrently "npx @tailwindcss/cli -i assets/styles.css -o dist/styles.css --watch" "open-assets dev"
19
19
 
20
20
  # Headless render
21
21
  open-assets render --collection screenshots --size iphone-6.9
22
- open-assets render --collection icon --all
23
- open-assets render --all
24
- open-assets render --template 01-hero --all
25
- open-assets render --force # ignore cache, re-render everything
22
+ open-assets render --collection icon --force
23
+ open-assets render --force
24
+ open-assets render --template 01-hero --force
26
25
  ```
27
26
 
28
27
  ## Concepts
@@ -44,7 +43,7 @@ The `assets.json` at the project root defines all asset collections. All collect
44
43
  "version": 1,
45
44
  "name": "App Display Name",
46
45
  "publicDir": "public",
47
- "command": "npx open-assets render --all",
46
+ "command": "npx open-assets render --force",
48
47
  "collections": [
49
48
  {
50
49
  "id": "unique-id",
@@ -52,12 +51,11 @@ The `assets.json` at the project root defines all asset collections. All collect
52
51
  "sourceSize": { "width": 440, "height": 956 },
53
52
  "borderRadius": 4,
54
53
  "templates": [
55
- { "src": "src/screenshots/01-hero.html", "name": "01-hero", "label": "Hero" }
54
+ { "src": "assets/screenshots/01-hero.html", "name": "01-hero", "label": "Hero" }
56
55
  ],
57
56
  "export": [
58
57
  { "name": "iphone-6.9", "label": "iPhone 6.9\"", "size": { "width": 1320, "height": 2868 } }
59
58
  ],
60
- "outputs": [],
61
59
  "customExport": { "defaultWidth": 1320, "defaultHeight": 2868 }
62
60
  }
63
61
  ]
@@ -73,12 +71,13 @@ The `assets.json` at the project root defines all asset collections. All collect
73
71
  | `sourceSize` | object | `{ width, height }` — dimensions templates are authored at |
74
72
  | `borderRadius` | number | Border radius for preview frames (px) |
75
73
  | `templates` | array | Source files: `{ src, name, label }` |
76
- | `export` | array | Export sizes: `[{ name, label, size: { width, height }, outFile? }]` |
77
- | `outputs` | array | Post-render actions (optional) |
74
+ | `export` | array | Export sizes and output actions. Entries with a `type` field are post-render actions |
78
75
  | `customExport` | object | Custom size defaults for UI (optional) |
79
76
 
80
77
  ### Output Types
81
78
 
79
+ Export entries with a `type` field are post-render actions (run with `--force`):
80
+
82
81
  | Type | Config | Description |
83
82
  |------|--------|-------------|
84
83
  | `xcode` | `{ "type": "xcode", "path": "..." }` | Render icon and write to Xcode asset catalog |
@@ -94,8 +93,8 @@ The `assets.json` at the project root defines all asset collections. All collect
94
93
  "sourceSize": { "width": 440, "height": 956 },
95
94
  "borderRadius": 4,
96
95
  "templates": [
97
- { "src": "src/screenshots/01-hero.html", "name": "01-hero", "label": "Hero" },
98
- { "src": "src/screenshots/02-features.html", "name": "02-features", "label": "Features" }
96
+ { "src": "assets/screenshots/01-hero.html", "name": "01-hero", "label": "Hero" },
97
+ { "src": "assets/screenshots/02-features.html", "name": "02-features", "label": "Features" }
99
98
  ],
100
99
  "export": [
101
100
  { "name": "iphone-6.9", "label": "iPhone 6.9\"", "size": { "width": 1320, "height": 2868 } },
@@ -112,13 +111,13 @@ The `assets.json` at the project root defines all asset collections. All collect
112
111
  "sourceSize": { "width": 1024, "height": 1024 },
113
112
  "borderRadius": 224,
114
113
  "templates": [
115
- { "src": "src/icon.html", "name": "icon", "label": "App Icon" }
114
+ { "src": "assets/icon.html", "name": "icon", "label": "App Icon" }
116
115
  ],
117
116
  "export": [
118
117
  { "name": "1024", "label": "1024px", "size": { "width": 1024, "height": 1024 } },
119
- { "name": "180", "label": "180px", "size": { "width": 180, "height": 180 } }
120
- ],
121
- "outputs": [{ "type": "xcode", "path": "../MyApp/Assets.xcassets/AppIcon.appiconset" }]
118
+ { "name": "180", "label": "180px", "size": { "width": 180, "height": 180 } },
119
+ { "type": "xcode", "path": "../MyApp/Assets.xcassets/AppIcon.appiconset" }
120
+ ]
122
121
  }
123
122
  ```
124
123
 
@@ -130,8 +129,8 @@ The `assets.json` at the project root defines all asset collections. All collect
130
129
  "sourceSize": { "width": 1024, "height": 1024 },
131
130
  "borderRadius": 224,
132
131
  "templates": [
133
- { "src": "src/icons/concept-a.html", "name": "concept-a", "label": "Concept A" },
134
- { "src": "src/icons/concept-b.html", "name": "concept-b", "label": "Concept B" }
132
+ { "src": "assets/icons/concept-a.html", "name": "concept-a", "label": "Concept A" },
133
+ { "src": "assets/icons/concept-b.html", "name": "concept-b", "label": "Concept B" }
135
134
  ],
136
135
  "export": [
137
136
  { "name": "1024", "label": "1024px", "size": { "width": 1024, "height": 1024 } }
@@ -146,14 +145,14 @@ The `assets.json` at the project root defines all asset collections. All collect
146
145
  "label": "Logo",
147
146
  "sourceSize": { "width": 940, "height": 940 },
148
147
  "templates": [
149
- { "src": "src/logo.svg", "name": "logo", "label": "Logo" },
150
- { "src": "src/logo-dark.svg", "name": "logo-dark", "label": "Dark" }
148
+ { "src": "assets/logo.svg", "name": "logo", "label": "Logo" },
149
+ { "src": "assets/logo-dark.svg", "name": "logo-dark", "label": "Dark" }
151
150
  ],
152
151
  "export": [
153
152
  { "name": "1024", "label": "1024px", "size": { "width": 1024, "height": 1024 } },
154
- { "name": "512", "label": "512px", "size": { "width": 512, "height": 512 } }
155
- ],
156
- "outputs": [{ "type": "copy-source", "format": "svg" }]
153
+ { "name": "512", "label": "512px", "size": { "width": 512, "height": 512 } },
154
+ { "type": "copy-source", "format": "svg" }
155
+ ]
157
156
  }
158
157
  ```
159
158
 
@@ -164,7 +163,7 @@ The `assets.json` at the project root defines all asset collections. All collect
164
163
  "label": "Social Cards",
165
164
  "sourceSize": { "width": 1200, "height": 630 },
166
165
  "templates": [
167
- { "src": "src/og/default.html", "name": "default", "label": "Default OG" }
166
+ { "src": "assets/og/default.html", "name": "default", "label": "Default OG" }
168
167
  ],
169
168
  "export": [
170
169
  { "name": "og", "label": "OG Image", "size": { "width": 1200, "height": 630 } }
@@ -181,7 +180,7 @@ The `assets.json` at the project root defines all asset collections. All collect
181
180
  | `open-assets dev [dir]` | Start dev server with live preview and export UI |
182
181
  | `open-assets render [dir]` | Render assets headlessly via CLI |
183
182
  | `open-assets list [dir]` | List all collections and templates in the config |
184
- | `open-assets validate [dir]` | Validate assets.json and check referenced files exist |
183
+ | `open-assets validate [dir]` | Validate assets.json and check referenced files exist. Use `--fix` to interactively remove missing templates |
185
184
  | `open-assets init [dir]` | Scaffold a new assets.json and example assets |
186
185
  | `open-assets add collection [dir]` | Add a new collection (from presets or custom) |
187
186
  | `open-assets add template [dir]` | Add a new template to an existing collection |
@@ -195,15 +194,14 @@ The `assets.json` at the project root defines all asset collections. All collect
195
194
  | `--collection <id>` | Render only the collection with this ID |
196
195
  | `--template <name>` | Render only the template with this name |
197
196
  | `--size <name>` | Use a named export size from config |
198
- | `--all` | Export at every size defined in the config |
199
- | `--force` | Re-render all assets even if unchanged |
197
+ | `-f, --force` | Export at every size and re-render all (ignores cache) |
200
198
  | `-o, --output <dir>` | Output directory (default: `./exports`) |
201
199
  | `--json` | Output results as JSON |
202
200
  | `-q, --quiet` | Suppress progress logs |
203
201
 
204
202
  Flags compose naturally:
205
203
  - `--collection screenshots --template 01-hero --size iphone-6.9` → single template, single size
206
- - `--collection screenshots --template 01-hero --all` → one template, all sizes
204
+ - `--collection screenshots --template 01-hero --force` → one template, all sizes
207
205
  - `--collection screenshots --size iphone-6.9` → all templates, one size
208
206
 
209
207
  ### add commands
@@ -342,14 +340,14 @@ Note: only source HTML/SVG files are checksummed. Changes to referenced assets (
342
340
  ```
343
341
  project/
344
342
  assets.json
345
- assets.lock # Auto-generated, add to .gitignore
343
+ assets.lock # Auto-generated cache (commit this)
346
344
  public/ # Shared assets (images, logos, icons, photos)
347
345
  logo-round.png
348
346
  social/
349
347
  youtube.svg
350
348
  screenshots/ # Real app screenshots for use in templates
351
349
  01.png
352
- src/
350
+ assets/
353
351
  styles.css # Tailwind input
354
352
  icon.html # App icon template
355
353
  icons/ # Icon concept explorations