@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 +45 -40
- package/bin/open-assets.mjs +59 -6
- package/lib/add.mjs +6 -6
- package/lib/init.mjs +1 -1
- package/lib/list.mjs +7 -9
- package/lib/lockfile.mjs +52 -1
- package/lib/manifest.mjs +11 -4
- package/lib/render.mjs +37 -24
- package/lib/renderer.mjs +39 -3
- package/lib/server.mjs +44 -23
- package/lib/validate.mjs +15 -12
- package/package.json +2 -2
- package/public/img/examples/cat-iq-test-icon.png +0 -0
- package/public/img/examples/cat-iq-test.png +0 -0
- package/public/img/examples/no-context-icon.png +0 -0
- package/public/img/examples/no-context.png +0 -0
- package/public/img/examples/only-recipes-icon.png +0 -0
- package/public/img/examples/only-recipes.png +0 -0
- package/public/viewer.html +35 -35
- package/skills/open-assets/SKILL.md +28 -30
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 --
|
|
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
|
|
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 --
|
|
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 --
|
|
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 --
|
|
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 --
|
|
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
|
-
|
|
|
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 --
|
|
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 --
|
|
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": "
|
|
325
|
-
{ "src": "
|
|
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 |
|
|
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
|
-
| `
|
|
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": "
|
|
373
|
-
{ "src": "
|
|
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": "
|
|
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": "
|
|
414
|
-
{ "src": "
|
|
415
|
-
{ "src": "
|
|
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": "
|
|
432
|
-
{ "src": "
|
|
433
|
-
{ "src": "
|
|
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": "
|
|
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": "
|
|
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 --
|
|
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
|
-
|
|
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 --
|
|
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 (
|
|
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
|
-
|
|
559
|
+
assets/
|
|
555
560
|
styles.css # Tailwind input
|
|
556
561
|
icon.html # App icon template
|
|
557
562
|
logo.svg # Vector logo template
|
package/bin/open-assets.mjs
CHANGED
|
@@ -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
|
-
|
|
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("--
|
|
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,
|
|
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
|
-
|
|
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/\
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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,
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
169
|
-
|
|
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
|
|
186
|
+
const copyDir = join(outputDir, col.id);
|
|
177
187
|
mkdirSync(copyDir, { recursive: true });
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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.
|
|
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 --
|
|
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
|
},
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/public/viewer.html
CHANGED
|
@@ -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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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 =
|
|
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
|
|
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 --
|
|
23
|
-
open-assets render --
|
|
24
|
-
open-assets render --template 01-hero --
|
|
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 --
|
|
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": "
|
|
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
|
|
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": "
|
|
98
|
-
{ "src": "
|
|
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": "
|
|
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
|
-
|
|
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": "
|
|
134
|
-
{ "src": "
|
|
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": "
|
|
150
|
-
{ "src": "
|
|
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
|
-
|
|
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": "
|
|
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
|
-
|
|
|
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 --
|
|
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
|
|
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
|
-
|
|
350
|
+
assets/
|
|
353
351
|
styles.css # Tailwind input
|
|
354
352
|
icon.html # App icon template
|
|
355
353
|
icons/ # Icon concept explorations
|