@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 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
 
@@ -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("--all", "Export at every size defined in the config")
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, 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
  }
@@ -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
+ }