@open-assets/open-assets 0.1.12 → 0.3.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 +60 -6
- package/lib/add.mjs +6 -6
- package/lib/init.mjs +1 -1
- package/lib/list.mjs +7 -9
- package/lib/localization.mjs +159 -0
- package/lib/lockfile.mjs +81 -6
- package/lib/manifest.mjs +11 -4
- package/lib/render.mjs +156 -93
- package/lib/renderer.mjs +84 -3
- package/lib/server.mjs +44 -23
- package/lib/validate.mjs +43 -10
- 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 +155 -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
|
|
|
@@ -88,10 +89,10 @@ program
|
|
|
88
89
|
.option("--tag <tag>", "Render only collections with this tag")
|
|
89
90
|
.option("--template <name>", "Render only the template with this name")
|
|
90
91
|
.option("--size <name>", "Use a named export size from config")
|
|
92
|
+
.option("--locale <code>", "Render only this locale (e.g. en, ar, ja)")
|
|
91
93
|
.option("--width <px>", "Output width in pixels (custom size)")
|
|
92
94
|
.option("--height <px>", "Output height in pixels (custom size)")
|
|
93
|
-
.option("--
|
|
94
|
-
.option("--force", "Re-render all assets even if unchanged")
|
|
95
|
+
.option("-f, --force", "Export at every size and re-render all assets (ignores cache)")
|
|
95
96
|
.option("-o, --output <dir>", "Output directory (env: OPEN_ASSETS_OUTPUT)", env("OPEN_ASSETS_OUTPUT", "./exports"))
|
|
96
97
|
.option("--config <path>", "Path to config file (env: OPEN_ASSETS_CONFIG)", env("OPEN_ASSETS_CONFIG", "assets.json"))
|
|
97
98
|
.option("--parallel <count>", "Number of parallel renders (env: OPEN_ASSETS_PARALLEL)", env("OPEN_ASSETS_PARALLEL", "1"))
|
|
@@ -189,8 +190,9 @@ program
|
|
|
189
190
|
.description("Validate the assets.json and check that all referenced files exist")
|
|
190
191
|
.argument("[dir]", "Project directory containing assets.json", ".")
|
|
191
192
|
.option("--config <path>", "Path to config file", env("OPEN_ASSETS_CONFIG", "assets.json"))
|
|
193
|
+
.option("--fix", "Interactively fix issues (e.g. remove missing templates)")
|
|
192
194
|
.action(async (dir, opts) => {
|
|
193
|
-
const { loadConfig } = await import("../lib/manifest.mjs");
|
|
195
|
+
const { loadConfig, saveConfig } = await import("../lib/manifest.mjs");
|
|
194
196
|
const { validateConfig } = await import("../lib/validate.mjs");
|
|
195
197
|
|
|
196
198
|
const projectDir = resolve(dir);
|
|
@@ -204,7 +206,7 @@ program
|
|
|
204
206
|
|
|
205
207
|
console.log(`\n Validating ${opts.config}...\n`);
|
|
206
208
|
|
|
207
|
-
const { checks, errors } = validateConfig(manifest, projectDir);
|
|
209
|
+
const { checks, errors, missingTemplates, emptyCollections } = validateConfig(manifest, projectDir);
|
|
208
210
|
|
|
209
211
|
for (const check of checks) {
|
|
210
212
|
console.log(` ${check.ok ? "\u2713" : "\u2717"} ${check.message}`);
|
|
@@ -215,6 +217,58 @@ program
|
|
|
215
217
|
console.log(" All checks passed.\n");
|
|
216
218
|
} else {
|
|
217
219
|
console.log(` ${errors} error(s) found.\n`);
|
|
220
|
+
|
|
221
|
+
const fixable = missingTemplates.length > 0 || emptyCollections.length > 0;
|
|
222
|
+
|
|
223
|
+
if (fixable && opts.fix) {
|
|
224
|
+
const { createInterface } = await import("readline");
|
|
225
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
226
|
+
const ask = (question) =>
|
|
227
|
+
new Promise((resolve) => rl.question(question, (answer) => resolve(answer.trim().toLowerCase())));
|
|
228
|
+
|
|
229
|
+
let changes = 0;
|
|
230
|
+
|
|
231
|
+
for (const missing of missingTemplates) {
|
|
232
|
+
const answer = await ask(
|
|
233
|
+
` Remove template "${missing.templateSrc}" from collection "${missing.collectionId}"? (y/N) `
|
|
234
|
+
);
|
|
235
|
+
if (answer === "y" || answer === "yes") {
|
|
236
|
+
const col = manifest.collections.find((c) => c.id === missing.collectionId);
|
|
237
|
+
if (col) {
|
|
238
|
+
col.templates = col.templates.filter((t) => t.src !== missing.templateSrc);
|
|
239
|
+
changes++;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Include collections that were already empty plus ones emptied by template removal
|
|
245
|
+
const emptyIds = new Set(emptyCollections.map((e) => e.collectionId));
|
|
246
|
+
for (const col of manifest.collections) {
|
|
247
|
+
if (!col.templates || col.templates.length === 0) {
|
|
248
|
+
emptyIds.add(col.id);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
for (const collectionId of emptyIds) {
|
|
253
|
+
const answer = await ask(
|
|
254
|
+
` Remove empty collection "${collectionId}"? (y/N) `
|
|
255
|
+
);
|
|
256
|
+
if (answer === "y" || answer === "yes") {
|
|
257
|
+
manifest.collections = manifest.collections.filter((c) => c.id !== collectionId);
|
|
258
|
+
changes++;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
rl.close();
|
|
263
|
+
|
|
264
|
+
if (changes > 0) {
|
|
265
|
+
saveConfig(projectDir, opts.config, manifest);
|
|
266
|
+
console.log(`\n Updated ${opts.config} (${changes} fix(es) applied).\n`);
|
|
267
|
+
}
|
|
268
|
+
} else if (fixable) {
|
|
269
|
+
console.log(` Run with --fix to remove missing templates and empty collections.\n`);
|
|
270
|
+
}
|
|
271
|
+
|
|
218
272
|
process.exit(1);
|
|
219
273
|
}
|
|
220
274
|
});
|
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
|
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from "fs";
|
|
2
|
+
import { resolve } from "path";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Locales that use right-to-left script direction.
|
|
6
|
+
* Includes base language codes — region variants (e.g. "ar-SA") are
|
|
7
|
+
* detected by matching the base language.
|
|
8
|
+
*/
|
|
9
|
+
const RTL_LANGUAGES = new Set([
|
|
10
|
+
"ar", // Arabic
|
|
11
|
+
"he", // Hebrew
|
|
12
|
+
"fa", // Persian (Farsi)
|
|
13
|
+
"ur", // Urdu
|
|
14
|
+
"yi", // Yiddish
|
|
15
|
+
"ps", // Pashto
|
|
16
|
+
"sd", // Sindhi
|
|
17
|
+
"ku", // Kurdish (Sorani)
|
|
18
|
+
"dv", // Divehi (Maldivian)
|
|
19
|
+
"ug", // Uyghur
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Check if a locale uses right-to-left text direction.
|
|
24
|
+
* Matches exact locale codes and base language (e.g. "ar-SA" → "ar").
|
|
25
|
+
*/
|
|
26
|
+
export function isRtl(locale) {
|
|
27
|
+
if (RTL_LANGUAGES.has(locale)) return true;
|
|
28
|
+
const base = locale.split("-")[0];
|
|
29
|
+
return RTL_LANGUAGES.has(base);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Get text direction for a locale: "rtl" or "ltr".
|
|
34
|
+
*/
|
|
35
|
+
export function getDirection(locale) {
|
|
36
|
+
return isRtl(locale) ? "rtl" : "ltr";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Load and parse a localizations JSON file (iOS .xcstrings-inspired format).
|
|
41
|
+
*
|
|
42
|
+
* Expected format:
|
|
43
|
+
* {
|
|
44
|
+
* "sourceLanguage": "en",
|
|
45
|
+
* "strings": {
|
|
46
|
+
* "key_name": {
|
|
47
|
+
* "localizations": {
|
|
48
|
+
* "en": { "value": "Hello" },
|
|
49
|
+
* "ar": { "value": "مرحبا" }
|
|
50
|
+
* }
|
|
51
|
+
* }
|
|
52
|
+
* }
|
|
53
|
+
* }
|
|
54
|
+
*/
|
|
55
|
+
export function loadLocalizations(projectDir, localizationsPath) {
|
|
56
|
+
const fullPath = resolve(projectDir, localizationsPath);
|
|
57
|
+
if (!existsSync(fullPath)) {
|
|
58
|
+
throw new Error(`Localizations file not found: ${localizationsPath}`);
|
|
59
|
+
}
|
|
60
|
+
try {
|
|
61
|
+
return JSON.parse(readFileSync(fullPath, "utf-8"));
|
|
62
|
+
} catch (err) {
|
|
63
|
+
throw new Error(`Invalid JSON in ${localizationsPath}: ${err.message}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve all localized string values for a given locale.
|
|
69
|
+
* Falls back: exact locale → base language → source language.
|
|
70
|
+
*
|
|
71
|
+
* Returns a flat { key: value } map.
|
|
72
|
+
*/
|
|
73
|
+
export function resolveStrings(localizations, locale) {
|
|
74
|
+
const strings = {};
|
|
75
|
+
const sourceLanguage = localizations.sourceLanguage || "en";
|
|
76
|
+
|
|
77
|
+
for (const [key, entry] of Object.entries(localizations.strings || {})) {
|
|
78
|
+
const l10ns = entry.localizations || {};
|
|
79
|
+
const value =
|
|
80
|
+
l10ns[locale]?.value ??
|
|
81
|
+
l10ns[locale.split("-")[0]]?.value ??
|
|
82
|
+
l10ns[sourceLanguage]?.value;
|
|
83
|
+
if (value !== undefined) {
|
|
84
|
+
strings[key] = value;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return strings;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Collect all unique locales defined across all string entries.
|
|
93
|
+
* If filterLocales is provided, returns only those that exist in the data.
|
|
94
|
+
* Results are sorted alphabetically.
|
|
95
|
+
*/
|
|
96
|
+
export function getLocales(localizations, filterLocales) {
|
|
97
|
+
const allLocales = new Set();
|
|
98
|
+
for (const entry of Object.values(localizations.strings || {})) {
|
|
99
|
+
for (const locale of Object.keys(entry.localizations || {})) {
|
|
100
|
+
allLocales.add(locale);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (filterLocales && filterLocales.length > 0) {
|
|
105
|
+
return filterLocales.filter((l) => allLocales.has(l));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return [...allLocales].sort();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* JavaScript snippet injected into the Puppeteer page to perform
|
|
113
|
+
* localization substitution on the live DOM.
|
|
114
|
+
*
|
|
115
|
+
* Replaces {{key}} placeholders in text nodes with localized values.
|
|
116
|
+
* Processes {{n:NUMBER}} patterns in values for locale-aware number formatting.
|
|
117
|
+
* Sets lang and dir attributes on <html>.
|
|
118
|
+
*/
|
|
119
|
+
export function buildLocalizationScript(strings, locale, direction) {
|
|
120
|
+
return `
|
|
121
|
+
(function() {
|
|
122
|
+
document.documentElement.lang = ${JSON.stringify(locale)};
|
|
123
|
+
|
|
124
|
+
const strings = ${JSON.stringify(strings)};
|
|
125
|
+
const locale = ${JSON.stringify(locale)};
|
|
126
|
+
const dir = ${JSON.stringify(direction)};
|
|
127
|
+
const formatter = new Intl.NumberFormat(locale);
|
|
128
|
+
|
|
129
|
+
function processValue(value) {
|
|
130
|
+
return value.replace(/\\{\\{n:(\\d+(?:\\.\\d+)?)\\}\\}/g, function(_, num) {
|
|
131
|
+
return formatter.format(parseFloat(num));
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const walker = document.createTreeWalker(
|
|
136
|
+
document.documentElement,
|
|
137
|
+
NodeFilter.SHOW_TEXT
|
|
138
|
+
);
|
|
139
|
+
while (walker.nextNode()) {
|
|
140
|
+
var node = walker.currentNode;
|
|
141
|
+
var text = node.textContent;
|
|
142
|
+
var changed = false;
|
|
143
|
+
text = text.replace(/\\{\\{([a-zA-Z_][\\w.]*)\\}\\}/g, function(match, key) {
|
|
144
|
+
if (strings[key] !== undefined) {
|
|
145
|
+
changed = true;
|
|
146
|
+
return processValue(strings[key]);
|
|
147
|
+
}
|
|
148
|
+
return match;
|
|
149
|
+
});
|
|
150
|
+
if (changed) {
|
|
151
|
+
node.textContent = text;
|
|
152
|
+
if (dir === "rtl") {
|
|
153
|
+
node.parentElement.dir = dir;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
})();
|
|
158
|
+
`;
|
|
159
|
+
}
|