@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/lib/server.mjs CHANGED
@@ -146,7 +146,7 @@ export async function startServer(projectDir, options = {}, renderer) {
146
146
  // API: render screenshot (unified for all collection types)
147
147
  if (req.method === "POST" && pathname === "/api/render-screenshot") {
148
148
  try {
149
- const { file, width, height, sourceW, sourceH } = JSON.parse(
149
+ const { file, width, height, sourceW, sourceH, background } = JSON.parse(
150
150
  await readBody(req)
151
151
  );
152
152
  if (!file || !width || !height) {
@@ -158,7 +158,7 @@ export async function startServer(projectDir, options = {}, renderer) {
158
158
  const sw = sourceW || width;
159
159
  const sh = sourceH || height;
160
160
  log(`Rendering ${file} at ${width}x${height} (source ${sw}x${sh})...`);
161
- const buffer = await renderScreenshot(projectDir, file, width, height, sw, sh);
161
+ const buffer = await renderScreenshot(projectDir, file, width, height, sw, sh, { background });
162
162
  res.writeHead(200, {
163
163
  "Content-Type": "image/png",
164
164
  "Content-Length": buffer.length,
@@ -181,7 +181,8 @@ export async function startServer(projectDir, options = {}, renderer) {
181
181
  res.writeHead(404, { "Content-Type": "application/json" });
182
182
  return res.end(JSON.stringify({ ok: false, error: "Collection not found" }));
183
183
  }
184
- const output = col.outputs?.[outputIndex || 0];
184
+ const outputs = (col.export || []).filter((e) => e.type);
185
+ const output = outputs[outputIndex || 0];
185
186
  if (!output) {
186
187
  res.writeHead(404, { "Content-Type": "application/json" });
187
188
  return res.end(JSON.stringify({ ok: false, error: "Output not found" }));
@@ -245,30 +246,50 @@ export async function startServer(projectDir, options = {}, renderer) {
245
246
  res.end();
246
247
  });
247
248
 
248
- server.listen(port, host, () => {
249
- log();
250
- log(` open-assets dev server`);
251
- log(` ───────────────────────`);
252
- log(` Local: http://${host}:${port}`);
253
- if (host === "0.0.0.0") {
254
- log(` Network: http://<your-ip>:${port}`);
255
- }
256
- log(` Project: ${projectDir}`);
257
- if (staticDirs.length > 0) {
258
- log(` Static: ${staticDirs.join(", ")}`);
259
- }
260
- log();
261
- });
249
+ const maxRetries = 10;
250
+ let currentPort = port;
251
+ let resolved = false;
252
+
253
+ const actualPort = await new Promise((resolvePort) => {
254
+ function tryListen() {
255
+ server.once("error", (err) => {
256
+ if (err.code === "EADDRINUSE") {
257
+ if (currentPort - port >= maxRetries) {
258
+ console.error(`Error: Ports ${port}–${currentPort} are all in use.`);
259
+ process.exit(1);
260
+ }
261
+ currentPort++;
262
+ server.close(() => tryListen());
263
+ return;
264
+ }
265
+ throw err;
266
+ });
262
267
 
263
- server.on("error", (err) => {
264
- if (err.code === "EADDRINUSE") {
265
- console.error(`Error: Port ${port} is already in use.`);
266
- console.error(`Try a different port: open-assets dev --port ${port + 1}`);
267
- process.exit(1);
268
+ server.listen(currentPort, host, () => {
269
+ if (resolved) return;
270
+ resolved = true;
271
+ const displayHost = host === "0.0.0.0" ? "localhost" : host;
272
+ if (currentPort !== port) {
273
+ log();
274
+ log(` Port ${port} is in use, using ${currentPort} instead.`);
275
+ }
276
+ log();
277
+ log(` Open Assets is running on http://${displayHost}:${currentPort}`);
278
+ log();
279
+ log(` Project: ${projectDir}`);
280
+ if (staticDirs.length > 0) {
281
+ log(` Static: ${staticDirs.join(", ")}`);
282
+ }
283
+ log();
284
+ resolvePort(currentPort);
285
+ });
268
286
  }
269
- throw err;
287
+
288
+ tryListen();
270
289
  });
271
290
 
291
+ return { port: actualPort };
292
+
272
293
  function cleanup() {
273
294
  for (const w of watchers) w.close();
274
295
  for (const client of sseClients) client.end();
package/lib/validate.mjs CHANGED
@@ -1,6 +1,7 @@
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
+ import { loadLocalizations, getLocales } from "./localization.mjs";
4
5
 
5
6
  /**
6
7
  * Validate a config object and check that referenced files exist.
@@ -12,6 +13,8 @@ import { flatSizes } from "./manifest.mjs";
12
13
  */
13
14
  export function validateConfig(manifest, projectDir) {
14
15
  const checks = [];
16
+ const missingTemplates = [];
17
+ const emptyCollections = [];
15
18
  let errors = 0;
16
19
 
17
20
  const ok = (msg) => checks.push({ ok: true, message: msg });
@@ -31,7 +34,7 @@ export function validateConfig(manifest, projectDir) {
31
34
  ok(`${manifest.collections.length} collection(s) defined`);
32
35
  } else {
33
36
  fail("Missing or empty 'collections' array");
34
- return { checks, errors };
37
+ return { checks, errors, missingTemplates, emptyCollections };
35
38
  }
36
39
 
37
40
  // Check each collection
@@ -54,6 +57,7 @@ export function validateConfig(manifest, projectDir) {
54
57
 
55
58
  if (!col.templates || col.templates.length === 0) {
56
59
  fail(`Collection "${col.id}" has no templates`);
60
+ emptyCollections.push({ collectionId: col.id });
57
61
  } else {
58
62
  for (const template of col.templates) {
59
63
  if (!template.src) {
@@ -65,6 +69,11 @@ export function validateConfig(manifest, projectDir) {
65
69
  ok(`${template.src} exists`);
66
70
  } else {
67
71
  fail(`${template.src} not found`);
72
+ missingTemplates.push({
73
+ collectionId: col.id,
74
+ templateSrc: template.src,
75
+ templateName: template.name || template.label,
76
+ });
68
77
  }
69
78
  }
70
79
  }
@@ -74,17 +83,41 @@ export function validateConfig(manifest, projectDir) {
74
83
  ok(`Collection "${col.id}" has ${sizes.length} export size(s)`);
75
84
  }
76
85
 
77
- // Validate outputs
78
- if (col.outputs) {
79
- for (const output of col.outputs) {
80
- if (!output.type) {
81
- fail(`Collection "${col.id}" has an output without 'type'`);
82
- } else {
83
- ok(`Output: ${output.type}`);
86
+ // Validate localizations if configured
87
+ if (col.localizations) {
88
+ const l10nPath = resolve(projectDir, col.localizations);
89
+ if (!existsSync(l10nPath)) {
90
+ fail(`Collection "${col.id}" localizations file not found: ${col.localizations}`);
91
+ } else {
92
+ try {
93
+ const l10nData = loadLocalizations(projectDir, col.localizations);
94
+ if (!l10nData.sourceLanguage) {
95
+ fail(`Collection "${col.id}" localizations missing "sourceLanguage"`);
96
+ }
97
+ if (!l10nData.strings || Object.keys(l10nData.strings).length === 0) {
98
+ fail(`Collection "${col.id}" localizations has no strings defined`);
99
+ } else {
100
+ const locales = getLocales(l10nData, col.locales);
101
+ ok(`Collection "${col.id}" localizations: ${Object.keys(l10nData.strings).length} key(s), ${locales.length} locale(s)`);
102
+ if (col.locales) {
103
+ const missing = col.locales.filter((l) => !locales.includes(l));
104
+ for (const m of missing) {
105
+ fail(`Collection "${col.id}" requests locale "${m}" but it is not defined in ${col.localizations}`);
106
+ }
107
+ }
108
+ }
109
+ } catch (err) {
110
+ fail(`Collection "${col.id}" localizations error: ${err.message}`);
84
111
  }
85
112
  }
86
113
  }
114
+
115
+ // Validate outputs (export entries with a type field)
116
+ const outputs = flatOutputs(col);
117
+ for (const output of outputs) {
118
+ ok(`Output: ${output.type}`);
119
+ }
87
120
  }
88
121
 
89
- return { checks, errors };
122
+ return { checks, errors, missingTemplates, emptyCollections };
90
123
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-assets/open-assets",
3
- "version": "0.1.12",
3
+ "version": "0.3.0",
4
4
  "description": "Dev server and export tool for app screenshots, icons, and logos. Like Storybook for marketing assets.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -39,7 +39,7 @@
39
39
  "access": "public"
40
40
  },
41
41
  "scripts": {
42
- "assets": "node bin/open-assets.mjs render --all -o ./public/img",
42
+ "assets": "node bin/open-assets.mjs render --force -o ./public/img",
43
43
  "test": "node --experimental-vm-modules node_modules/.bin/jest",
44
44
  "release": "./scripts/release.sh"
45
45
  },
@@ -254,46 +254,46 @@
254
254
  const sh = col.sourceSize.height;
255
255
  const hasMultipleTemplates = col.templates.length > 1;
256
256
 
257
- // Outputs (e.g., Xcode)
258
- if (col.outputs) {
259
- for (let i = 0; i < col.outputs.length; i++) {
260
- const output = col.outputs[i];
261
- if (output.type === 'xcode') {
262
- const sectionLabel = document.createElement('div');
263
- sectionLabel.className = 'section-label';
264
- sectionLabel.textContent = 'Xcode';
265
- exportDropdown.appendChild(sectionLabel);
266
-
267
- const btn = document.createElement('button');
268
- btn.innerHTML = 'Export to Xcode <span class="dim">.appiconset</span>';
269
- btn.addEventListener('click', () => runOutput(col.id, i));
270
- exportDropdown.appendChild(btn);
271
-
272
- exportDropdown.appendChild(document.createElement('hr'));
273
- } else if (output.type === 'copy-source') {
274
- const sectionLabel = document.createElement('div');
275
- sectionLabel.className = 'section-label';
276
- sectionLabel.textContent = output.format.toUpperCase();
277
- exportDropdown.appendChild(sectionLabel);
278
-
279
- // SVG download for each template
280
- for (const template of col.templates) {
281
- if (template.src.endsWith('.' + output.format)) {
282
- const btn = document.createElement('button');
283
- const displayName = hasMultipleTemplates ? template.label : `Download ${output.format.toUpperCase()}`;
284
- btn.innerHTML = `${displayName} <span class="dim">${output.format}</span>`;
285
- btn.addEventListener('click', () => downloadSource(template));
286
- exportDropdown.appendChild(btn);
287
- }
257
+ // Outputs (e.g., Xcode, copy-source) — export entries with a type field
258
+ const allExports = col.export || [];
259
+ const outputs = allExports.filter(e => e.type);
260
+ for (let i = 0; i < outputs.length; i++) {
261
+ const output = outputs[i];
262
+ if (output.type === 'xcode') {
263
+ const sectionLabel = document.createElement('div');
264
+ sectionLabel.className = 'section-label';
265
+ sectionLabel.textContent = 'Xcode';
266
+ exportDropdown.appendChild(sectionLabel);
267
+
268
+ const btn = document.createElement('button');
269
+ btn.innerHTML = 'Export to Xcode <span class="dim">.appiconset</span>';
270
+ btn.addEventListener('click', () => runOutput(col.id, i));
271
+ exportDropdown.appendChild(btn);
272
+
273
+ exportDropdown.appendChild(document.createElement('hr'));
274
+ } else if (output.type === 'copy-source') {
275
+ const sectionLabel = document.createElement('div');
276
+ sectionLabel.className = 'section-label';
277
+ sectionLabel.textContent = output.format.toUpperCase();
278
+ exportDropdown.appendChild(sectionLabel);
279
+
280
+ // SVG download for each template
281
+ for (const template of col.templates) {
282
+ if (template.src.endsWith('.' + output.format)) {
283
+ const btn = document.createElement('button');
284
+ const displayName = hasMultipleTemplates ? template.label : `Download ${output.format.toUpperCase()}`;
285
+ btn.innerHTML = `${displayName} <span class="dim">${output.format}</span>`;
286
+ btn.addEventListener('click', () => downloadSource(template));
287
+ exportDropdown.appendChild(btn);
288
288
  }
289
-
290
- exportDropdown.appendChild(document.createElement('hr'));
291
289
  }
290
+
291
+ exportDropdown.appendChild(document.createElement('hr'));
292
292
  }
293
293
  }
294
294
 
295
- // Export sizes
296
- const sizes = col.export || [];
295
+ // Export sizes (entries without a type field)
296
+ const sizes = allExports.filter(e => !e.type);
297
297
  if (sizes.length > 0) {
298
298
  const sectionLabel = document.createElement('div');
299
299
  sectionLabel.className = 'section-label';
@@ -15,14 +15,13 @@ open-assets is a dev server and export tool for app marketing assets — screens
15
15
  open-assets dev
16
16
 
17
17
  # Or with Tailwind
18
- concurrently "npx @tailwindcss/cli -i src/styles.css -o dist/styles.css --watch" "open-assets dev"
18
+ concurrently "npx @tailwindcss/cli -i assets/styles.css -o dist/styles.css --watch" "open-assets dev"
19
19
 
20
20
  # Headless render
21
21
  open-assets render --collection screenshots --size iphone-6.9
22
- open-assets render --collection icon --all
23
- open-assets render --all
24
- open-assets render --template 01-hero --all
25
- open-assets render --force # ignore cache, re-render everything
22
+ open-assets render --collection icon --force
23
+ open-assets render --force
24
+ open-assets render --template 01-hero --force
26
25
  ```
27
26
 
28
27
  ## Concepts
@@ -44,7 +43,7 @@ The `assets.json` at the project root defines all asset collections. All collect
44
43
  "version": 1,
45
44
  "name": "App Display Name",
46
45
  "publicDir": "public",
47
- "command": "npx open-assets render --all",
46
+ "command": "npx open-assets render --force",
48
47
  "collections": [
49
48
  {
50
49
  "id": "unique-id",
@@ -52,12 +51,11 @@ The `assets.json` at the project root defines all asset collections. All collect
52
51
  "sourceSize": { "width": 440, "height": 956 },
53
52
  "borderRadius": 4,
54
53
  "templates": [
55
- { "src": "src/screenshots/01-hero.html", "name": "01-hero", "label": "Hero" }
54
+ { "src": "assets/screenshots/01-hero.html", "name": "01-hero", "label": "Hero" }
56
55
  ],
57
56
  "export": [
58
57
  { "name": "iphone-6.9", "label": "iPhone 6.9\"", "size": { "width": 1320, "height": 2868 } }
59
58
  ],
60
- "outputs": [],
61
59
  "customExport": { "defaultWidth": 1320, "defaultHeight": 2868 }
62
60
  }
63
61
  ]
@@ -73,12 +71,13 @@ The `assets.json` at the project root defines all asset collections. All collect
73
71
  | `sourceSize` | object | `{ width, height }` — dimensions templates are authored at |
74
72
  | `borderRadius` | number | Border radius for preview frames (px) |
75
73
  | `templates` | array | Source files: `{ src, name, label }` |
76
- | `export` | array | Export sizes: `[{ name, label, size: { width, height }, outFile? }]` |
77
- | `outputs` | array | Post-render actions (optional) |
74
+ | `export` | array | Export sizes and output actions. Entries with a `type` field are post-render actions |
78
75
  | `customExport` | object | Custom size defaults for UI (optional) |
79
76
 
80
77
  ### Output Types
81
78
 
79
+ Export entries with a `type` field are post-render actions (run with `--force`):
80
+
82
81
  | Type | Config | Description |
83
82
  |------|--------|-------------|
84
83
  | `xcode` | `{ "type": "xcode", "path": "..." }` | Render icon and write to Xcode asset catalog |
@@ -94,8 +93,8 @@ The `assets.json` at the project root defines all asset collections. All collect
94
93
  "sourceSize": { "width": 440, "height": 956 },
95
94
  "borderRadius": 4,
96
95
  "templates": [
97
- { "src": "src/screenshots/01-hero.html", "name": "01-hero", "label": "Hero" },
98
- { "src": "src/screenshots/02-features.html", "name": "02-features", "label": "Features" }
96
+ { "src": "assets/screenshots/01-hero.html", "name": "01-hero", "label": "Hero" },
97
+ { "src": "assets/screenshots/02-features.html", "name": "02-features", "label": "Features" }
99
98
  ],
100
99
  "export": [
101
100
  { "name": "iphone-6.9", "label": "iPhone 6.9\"", "size": { "width": 1320, "height": 2868 } },
@@ -112,13 +111,13 @@ The `assets.json` at the project root defines all asset collections. All collect
112
111
  "sourceSize": { "width": 1024, "height": 1024 },
113
112
  "borderRadius": 224,
114
113
  "templates": [
115
- { "src": "src/icon.html", "name": "icon", "label": "App Icon" }
114
+ { "src": "assets/icon.html", "name": "icon", "label": "App Icon" }
116
115
  ],
117
116
  "export": [
118
117
  { "name": "1024", "label": "1024px", "size": { "width": 1024, "height": 1024 } },
119
- { "name": "180", "label": "180px", "size": { "width": 180, "height": 180 } }
120
- ],
121
- "outputs": [{ "type": "xcode", "path": "../MyApp/Assets.xcassets/AppIcon.appiconset" }]
118
+ { "name": "180", "label": "180px", "size": { "width": 180, "height": 180 } },
119
+ { "type": "xcode", "path": "../MyApp/Assets.xcassets/AppIcon.appiconset" }
120
+ ]
122
121
  }
123
122
  ```
124
123
 
@@ -130,8 +129,8 @@ The `assets.json` at the project root defines all asset collections. All collect
130
129
  "sourceSize": { "width": 1024, "height": 1024 },
131
130
  "borderRadius": 224,
132
131
  "templates": [
133
- { "src": "src/icons/concept-a.html", "name": "concept-a", "label": "Concept A" },
134
- { "src": "src/icons/concept-b.html", "name": "concept-b", "label": "Concept B" }
132
+ { "src": "assets/icons/concept-a.html", "name": "concept-a", "label": "Concept A" },
133
+ { "src": "assets/icons/concept-b.html", "name": "concept-b", "label": "Concept B" }
135
134
  ],
136
135
  "export": [
137
136
  { "name": "1024", "label": "1024px", "size": { "width": 1024, "height": 1024 } }
@@ -146,14 +145,14 @@ The `assets.json` at the project root defines all asset collections. All collect
146
145
  "label": "Logo",
147
146
  "sourceSize": { "width": 940, "height": 940 },
148
147
  "templates": [
149
- { "src": "src/logo.svg", "name": "logo", "label": "Logo" },
150
- { "src": "src/logo-dark.svg", "name": "logo-dark", "label": "Dark" }
148
+ { "src": "assets/logo.svg", "name": "logo", "label": "Logo" },
149
+ { "src": "assets/logo-dark.svg", "name": "logo-dark", "label": "Dark" }
151
150
  ],
152
151
  "export": [
153
152
  { "name": "1024", "label": "1024px", "size": { "width": 1024, "height": 1024 } },
154
- { "name": "512", "label": "512px", "size": { "width": 512, "height": 512 } }
155
- ],
156
- "outputs": [{ "type": "copy-source", "format": "svg" }]
153
+ { "name": "512", "label": "512px", "size": { "width": 512, "height": 512 } },
154
+ { "type": "copy-source", "format": "svg" }
155
+ ]
157
156
  }
158
157
  ```
159
158
 
@@ -164,7 +163,7 @@ The `assets.json` at the project root defines all asset collections. All collect
164
163
  "label": "Social Cards",
165
164
  "sourceSize": { "width": 1200, "height": 630 },
166
165
  "templates": [
167
- { "src": "src/og/default.html", "name": "default", "label": "Default OG" }
166
+ { "src": "assets/og/default.html", "name": "default", "label": "Default OG" }
168
167
  ],
169
168
  "export": [
170
169
  { "name": "og", "label": "OG Image", "size": { "width": 1200, "height": 630 } }
@@ -181,7 +180,7 @@ The `assets.json` at the project root defines all asset collections. All collect
181
180
  | `open-assets dev [dir]` | Start dev server with live preview and export UI |
182
181
  | `open-assets render [dir]` | Render assets headlessly via CLI |
183
182
  | `open-assets list [dir]` | List all collections and templates in the config |
184
- | `open-assets validate [dir]` | Validate assets.json and check referenced files exist |
183
+ | `open-assets validate [dir]` | Validate assets.json and check referenced files exist. Use `--fix` to interactively remove missing templates |
185
184
  | `open-assets init [dir]` | Scaffold a new assets.json and example assets |
186
185
  | `open-assets add collection [dir]` | Add a new collection (from presets or custom) |
187
186
  | `open-assets add template [dir]` | Add a new template to an existing collection |
@@ -195,15 +194,15 @@ The `assets.json` at the project root defines all asset collections. All collect
195
194
  | `--collection <id>` | Render only the collection with this ID |
196
195
  | `--template <name>` | Render only the template with this name |
197
196
  | `--size <name>` | Use a named export size from config |
198
- | `--all` | Export at every size defined in the config |
199
- | `--force` | Re-render all assets even if unchanged |
197
+ | `--locale <code>` | Render only this locale (e.g. `en`, `ar`, `ja`) |
198
+ | `-f, --force` | Export at every size and re-render all (ignores cache) |
200
199
  | `-o, --output <dir>` | Output directory (default: `./exports`) |
201
200
  | `--json` | Output results as JSON |
202
201
  | `-q, --quiet` | Suppress progress logs |
203
202
 
204
203
  Flags compose naturally:
205
204
  - `--collection screenshots --template 01-hero --size iphone-6.9` → single template, single size
206
- - `--collection screenshots --template 01-hero --all` → one template, all sizes
205
+ - `--collection screenshots --template 01-hero --force` → one template, all sizes
207
206
  - `--collection screenshots --size iphone-6.9` → all templates, one size
208
207
 
209
208
  ### add commands
@@ -337,19 +336,145 @@ The `render` command maintains a `assets.lock` file with SHA256 checksums of sou
337
336
 
338
337
  Note: only source HTML/SVG files are checksummed. Changes to referenced assets (images in publicDir, compiled CSS) will not trigger re-renders — use `--force` when those change.
339
338
 
339
+ ## Localization (i18n)
340
+
341
+ Collections can be localized so that templates render in multiple languages. When localizations are configured, each template is rendered once per locale, producing locale-specific exports with proper text direction (LTR/RTL).
342
+
343
+ ### Localizations file
344
+
345
+ Create a JSON file (iOS `.xcstrings`-inspired format) that maps string keys to per-locale values:
346
+
347
+ ```json
348
+ {
349
+ "sourceLanguage": "en",
350
+ "strings": {
351
+ "hero_title": {
352
+ "localizations": {
353
+ "en": { "value": "Track your tax days" },
354
+ "ar": { "value": "تتبع أيام الضرائب الخاصة بك" },
355
+ "ja": { "value": "税金の日を追跡する" },
356
+ "de": { "value": "Verfolge deine Steuertage" }
357
+ }
358
+ },
359
+ "day_count_label": {
360
+ "localizations": {
361
+ "en": { "value": "You have {{n:183}} days remaining" },
362
+ "ar": { "value": "لديك {{n:183}} يوم متبقي" },
363
+ "de": { "value": "Sie haben {{n:183}} Tage übrig" }
364
+ }
365
+ }
366
+ }
367
+ }
368
+ ```
369
+
370
+ **Number formatting:** Use `{{n:NUMBER}}` inside localized values for locale-aware number formatting (e.g., `1,234` in English, `1.234` in German). Numbers are formatted using `Intl.NumberFormat` for the target locale.
371
+
372
+ ### Wiring localizations to a collection
373
+
374
+ Add `localizations` (path to the JSON file) and optionally `locales` (filter to specific locales) to a collection in `assets.json`:
375
+
376
+ ```json
377
+ {
378
+ "id": "screenshots",
379
+ "label": "App Store Screenshots",
380
+ "sourceSize": { "width": 440, "height": 956 },
381
+ "localizations": "localizations.json",
382
+ "locales": ["en", "ar", "ja", "de"],
383
+ "templates": [
384
+ { "src": "assets/screenshots/01-hero.html", "name": "01-hero", "label": "Hero" }
385
+ ],
386
+ "export": [
387
+ { "name": "iphone-6.9", "label": "iPhone 6.9\"", "size": { "width": 1320, "height": 2868 } }
388
+ ]
389
+ }
390
+ ```
391
+
392
+ | Field | Type | Description |
393
+ |-------|------|-------------|
394
+ | `localizations` | string | Path to the localizations JSON file (relative to project root) |
395
+ | `locales` | string[] | Optional filter — render only these locales. Omit to render all locales in the file. |
396
+
397
+ ### Using placeholders in templates
398
+
399
+ Templates use `{{key}}` mustache-style placeholders that get replaced with localized values before rendering:
400
+
401
+ ```html
402
+ <h1>{{hero_title}}</h1>
403
+ <p>{{day_count_label}}</p>
404
+ ```
405
+
406
+ The source language values serve as the default/fallback content visible during development. At render time, placeholders are substituted with the localized value for each target locale.
407
+
408
+ ### RTL support
409
+
410
+ Right-to-left locales (Arabic, Hebrew, Persian, Urdu, etc.) are automatically detected. For RTL locales, the renderer sets `dir="rtl"` and `lang` on the `<html>` element before taking the screenshot.
411
+
412
+ **Design templates with RTL in mind:**
413
+ - Use `logical` CSS properties (`margin-inline-start` instead of `margin-left`, `padding-inline-end` instead of `padding-right`)
414
+ - Use flexbox `gap` instead of directional margins between items
415
+ - Avoid hardcoded `text-align: left` — use `text-align: start` instead
416
+ - For layouts that must flip in RTL, use `[dir="rtl"]` CSS selectors:
417
+ ```css
418
+ .arrow { transform: rotate(0deg); }
419
+ [dir="rtl"] .arrow { transform: rotate(180deg); }
420
+ ```
421
+
422
+ ### Output structure with locales
423
+
424
+ When localizations are active, locale codes are inserted into the output path:
425
+
426
+ ```
427
+ exports/
428
+ screenshots/
429
+ en/
430
+ iphone-6.9/
431
+ 01-hero.png
432
+ ar/
433
+ iphone-6.9/
434
+ 01-hero.png
435
+ ja/
436
+ iphone-6.9/
437
+ 01-hero.png
438
+ ```
439
+
440
+ With `outFile`, use the `{locale}` template variable:
441
+ ```json
442
+ { "outFile": "output/{locale}/{template}.png" }
443
+ ```
444
+
445
+ ### CLI
446
+
447
+ ```bash
448
+ # Render all locales for a collection
449
+ open-assets render --collection screenshots --force
450
+
451
+ # Render only Arabic
452
+ open-assets render --collection screenshots --force --locale ar
453
+
454
+ # Render a single template in Japanese
455
+ open-assets render --collection screenshots --template 01-hero --locale ja --force
456
+ ```
457
+
458
+ ### Locale fallback order
459
+
460
+ When resolving a string for a locale:
461
+ 1. Exact match (e.g., `es-419`)
462
+ 2. Base language (e.g., `es`)
463
+ 3. Source language (e.g., `en`)
464
+
340
465
  ## File Structure Convention
341
466
 
342
467
  ```
343
468
  project/
344
469
  assets.json
345
- assets.lock # Auto-generated, add to .gitignore
470
+ assets.lock # Auto-generated cache (commit this)
346
471
  public/ # Shared assets (images, logos, icons, photos)
347
472
  logo-round.png
348
473
  social/
349
474
  youtube.svg
350
475
  screenshots/ # Real app screenshots for use in templates
351
476
  01.png
352
- src/
477
+ assets/
353
478
  styles.css # Tailwind input
354
479
  icon.html # App icon template
355
480
  icons/ # Icon concept explorations