@open-assets/open-assets 0.4.0 → 0.5.1

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/renderer.mjs CHANGED
@@ -2,128 +2,291 @@ import { launch } from "puppeteer";
2
2
  import { resolve } from "path";
3
3
  import { writeFileSync, mkdirSync, readdirSync, unlinkSync } from "fs";
4
4
 
5
- let browser = null;
5
+ // The browser is launched once per process and shared. Storing the launch
6
+ // promise (not the instance) makes concurrent callers await the same launch
7
+ // instead of racing to spawn duplicate Chromium processes.
8
+ let browserPromise = null;
9
+
10
+ // Pages are pooled and reused across renders: page.goto fully replaces the
11
+ // document, and newPage()/close() cost 100-300ms per image in headless mode.
12
+ const pagePool = [];
6
13
 
7
14
  async function getBrowser() {
8
- if (!browser || !browser.connected) {
9
- browser = await launch({ headless: true });
15
+ for (;;) {
16
+ if (!browserPromise) {
17
+ browserPromise = launch({
18
+ headless: true,
19
+ // Pages render concurrently; without these flags Chromium throttles
20
+ // timers and frames in non-active tabs, stalling background renders.
21
+ args: [
22
+ "--disable-background-timer-throttling",
23
+ "--disable-backgrounding-occluded-windows",
24
+ "--disable-renderer-backgrounding",
25
+ ],
26
+ }).catch((err) => {
27
+ browserPromise = null;
28
+ throw err;
29
+ });
30
+ }
31
+ const current = browserPromise;
32
+ const browser = await current;
33
+ if (browser.connected) return browser;
34
+ // Browser died; clear the cached promise (unless someone already
35
+ // replaced it) and relaunch.
36
+ if (browserPromise === current) browserPromise = null;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Pre-launch the shared browser so the first render doesn't pay the ~2s
42
+ * launch cost interactively.
43
+ */
44
+ export function warmupBrowser() {
45
+ return getBrowser();
46
+ }
47
+
48
+ // CDP sessions used to keep pooled pages renderable while they sit in the
49
+ // background. Keyed weakly so a closed page's session is not retained.
50
+ const pageSessions = new WeakMap();
51
+
52
+ async function acquirePage() {
53
+ const browser = await getBrowser();
54
+ while (pagePool.length > 0) {
55
+ const page = pagePool.pop();
56
+ // Drop pages tied to a closed or replaced browser instance
57
+ if (!page.isClosed() && page.browser() === browser) return page;
58
+ }
59
+ // Every page gets its own browser context, which puts it in its own window.
60
+ // Pages sharing a context are tabs in one window, and headless Chrome only
61
+ // drives the rendering pipeline for the active tab: the rest are frozen, so
62
+ // anything that waits on that pipeline (timers, document.fonts.ready,
63
+ // img.decode()) never settles and the render hangs until protocolTimeout.
64
+ const context = await browser.createBrowserContext();
65
+ const page = await context.newPage();
66
+ // Separate windows still compete for focus, so tell each page to consider
67
+ // itself focused. Without this a page can be treated as occluded and stall
68
+ // the same way a background tab does.
69
+ try {
70
+ const session = await page.createCDPSession();
71
+ await session.send("Emulation.setFocusEmulationEnabled", { enabled: true });
72
+ pageSessions.set(page, session);
73
+ } catch {
74
+ // Best-effort: the browser context already prevents the hard stall.
10
75
  }
11
- return browser;
76
+ return page;
77
+ }
78
+
79
+ /**
80
+ * Assert that a page is in the "active" lifecycle state. Chromium may freeze a
81
+ * page that has been idle in the pool; a frozen page runs no timers, so the
82
+ * readiness barrier in settle() would never resolve.
83
+ */
84
+ async function activatePage(page) {
85
+ const session = pageSessions.get(page);
86
+ if (!session) return;
87
+ try {
88
+ await session.send("Page.setWebLifecycleState", { state: "active" });
89
+ } catch {
90
+ // Best-effort; not all builds implement this on every target.
91
+ }
92
+ }
93
+
94
+ function releasePage(page) {
95
+ if (page && !page.isClosed()) pagePool.push(page);
12
96
  }
13
97
 
14
98
  export async function closeBrowser() {
15
- if (browser) {
16
- await browser.close();
17
- browser = null;
99
+ pagePool.length = 0;
100
+ if (browserPromise) {
101
+ const pending = browserPromise;
102
+ browserPromise = null;
103
+ try {
104
+ const browser = await pending;
105
+ await browser.close();
106
+ } catch {
107
+ // Launch already failed; nothing to close.
108
+ }
18
109
  }
19
110
  }
20
111
 
21
112
  /**
22
- * Render an HTML file to an image buffer at the given dimensions.
23
- * Scales from sourceSize to target size via CSS zoom.
113
+ * Deterministic readiness barrier, replacing fixed sleeps: yields a macrotask
114
+ * (so MutationObserver-driven DOM updates like locale image swaps have run),
115
+ * forces a layout (so font loads triggered by first layout have started),
116
+ * then waits for fonts and image decodes. No frame waits here: rAF can stall
117
+ * indefinitely in a non-active tab, and the screenshot capture itself forces
118
+ * a fresh frame that flushes pending style and layout.
119
+ */
120
+ async function settle(page) {
121
+ await page.evaluate(async () => {
122
+ await new Promise((r) => setTimeout(r, 0));
123
+ document.documentElement.getBoundingClientRect();
124
+ await document.fonts.ready;
125
+ const images = document.images ? Array.from(document.images) : [];
126
+ await Promise.all(images.map((img) => img.decode().catch(() => {})));
127
+ });
128
+ }
129
+
130
+ /**
131
+ * Render an HTML file at multiple sizes from a single navigation.
132
+ * The page is loaded (and localized) once; each variant re-applies
133
+ * viewport + CSS zoom and takes its own screenshot.
134
+ *
24
135
  * @param {string} projectDir
25
136
  * @param {string} htmlFile
26
- * @param {number} width
27
- * @param {number} height
28
137
  * @param {number} sourceW
29
138
  * @param {number} sourceH
30
- * @param {{ format?: "png"|"jpeg", quality?: number, background?: string, localization?: { strings: object, locale: string, direction: string } }} [options]
139
+ * @param {{ width: number, height: number, format?: "png"|"jpeg", quality?: number }[]} variants
140
+ * @param {{ background?: string, localization?: { strings: object, locale: string, direction: string }, timeout?: number }} [options]
141
+ * @returns {Promise<Buffer[]>} Screenshot buffers in variant order
31
142
  */
32
- export async function renderScreenshot(
143
+ export async function renderVariants(
33
144
  projectDir,
34
145
  htmlFile,
35
- width,
36
- height,
37
146
  sourceW,
38
147
  sourceH,
148
+ variants,
39
149
  options = {}
40
150
  ) {
41
- const b = await getBrowser();
42
- const page = await b.newPage();
151
+ const page = await acquirePage();
152
+ try {
153
+ await activatePage(page);
154
+ const first = variants[0];
155
+ await page.setViewport({
156
+ width: first.width,
157
+ height: first.height,
158
+ deviceScaleFactor: 1,
159
+ });
43
160
 
44
- const scale = Math.max(width / sourceW, height / sourceH);
161
+ const filePath = resolve(projectDir, htmlFile);
162
+ await page.goto(`file://${filePath}`, {
163
+ waitUntil: "load",
164
+ timeout: options.timeout || 30000,
165
+ });
45
166
 
46
- await page.setViewport({ width, height, deviceScaleFactor: 1 });
167
+ // Inject localized strings and set lang before rendering
168
+ if (options.localization) {
169
+ const { strings, locale, direction } = options.localization;
170
+ await page.evaluate((strings, locale, dir) => {
171
+ // Set lang on root for font selection; do NOT set dir on root
172
+ // because it flips the entire CSS layout (flexbox, margins, etc.)
173
+ // which breaks fixed-pixel marketing layouts.
174
+ document.documentElement.lang = locale;
47
175
 
48
- const filePath = resolve(projectDir, htmlFile);
49
- await page.goto(`file://${filePath}`, { waitUntil: "networkidle0" });
50
- await new Promise((r) => setTimeout(r, 500));
176
+ const formatter = new Intl.NumberFormat(locale);
51
177
 
52
- // Inject localized strings and set lang/dir before rendering
53
- if (options.localization) {
54
- const { strings, locale, direction } = options.localization;
55
- await page.evaluate((strings, locale, dir) => {
56
- // Set lang on root for font selection; do NOT set dir on root
57
- // because it flips the entire CSS layout (flexbox, margins, etc.)
58
- // which breaks fixed-pixel marketing layouts.
59
- document.documentElement.lang = locale;
178
+ function processValue(value) {
179
+ return value.replace(/\{\{n:(\d+(?:\.\d+)?)\}\}/g, (_, num) => {
180
+ return formatter.format(parseFloat(num));
181
+ });
182
+ }
60
183
 
61
- const formatter = new Intl.NumberFormat(locale);
184
+ const walker = document.createTreeWalker(
185
+ document.documentElement,
186
+ NodeFilter.SHOW_TEXT
187
+ );
188
+ while (walker.nextNode()) {
189
+ const node = walker.currentNode;
190
+ let text = node.textContent;
191
+ let changed = false;
192
+ text = text.replace(/\{\{([a-zA-Z_][\w.]*)\}\}/g, (match, key) => {
193
+ if (strings[key] !== undefined) {
194
+ changed = true;
195
+ return processValue(strings[key]);
196
+ }
197
+ return match;
198
+ });
199
+ if (changed) {
200
+ node.textContent = text;
201
+ // Apply text direction on the parent element so RTL text
202
+ // renders correctly without flipping the overall page layout.
203
+ if (dir === "rtl") {
204
+ node.parentElement.dir = dir;
205
+ }
206
+ }
207
+ }
208
+ }, strings, locale, direction);
209
+ }
62
210
 
63
- function processValue(value) {
64
- return value.replace(/\{\{n:(\d+(?:\.\d+)?)\}\}/g, (_, num) => {
65
- return formatter.format(parseFloat(num));
66
- });
67
- }
211
+ // Handle background: transparent by default for PNG, or use specified color
212
+ if (options.background) {
213
+ await page.evaluate((color) => {
214
+ document.documentElement.style.backgroundColor = color;
215
+ }, options.background);
216
+ }
68
217
 
69
- const walker = document.createTreeWalker(
70
- document.documentElement,
71
- NodeFilter.SHOW_TEXT
72
- );
73
- while (walker.nextNode()) {
74
- const node = walker.currentNode;
75
- let text = node.textContent;
76
- let changed = false;
77
- text = text.replace(/\{\{([a-zA-Z_][\w.]*)\}\}/g, (match, key) => {
78
- if (strings[key] !== undefined) {
79
- changed = true;
80
- return processValue(strings[key]);
81
- }
82
- return match;
83
- });
84
- if (changed) {
85
- node.textContent = text;
86
- // Apply text direction on the parent element so RTL text
87
- // renders correctly without flipping the overall page layout.
88
- if (dir === "rtl") {
89
- node.parentElement.dir = dir;
218
+ await settle(page);
219
+
220
+ const buffers = [];
221
+ for (const variant of variants) {
222
+ const { width, height } = variant;
223
+ await page.setViewport({ width, height, deviceScaleFactor: 1 });
224
+
225
+ const scale = Math.max(width / sourceW, height / sourceH);
226
+ // Always assign zoom (clearing it at scale 1) so a previous variant's
227
+ // zoom can't leak into this one, and freeze animations at t=0 so the
228
+ // capture is deterministic. The screenshot's forced frame applies both.
229
+ await page.evaluate((zoom) => {
230
+ document.documentElement.style.zoom = zoom;
231
+ for (const anim of document.getAnimations()) {
232
+ try {
233
+ anim.pause();
234
+ anim.currentTime = 0;
235
+ } catch {
236
+ // Some animations (e.g. infinite transitions) can reject; skip.
90
237
  }
91
238
  }
92
- }
93
- }, strings, locale, direction);
94
- await new Promise((r) => setTimeout(r, 200));
95
- }
96
-
97
- if (scale !== 1) {
98
- await page.evaluate((z) => {
99
- document.documentElement.style.zoom = z;
100
- }, scale);
101
- await new Promise((r) => setTimeout(r, 200));
102
- }
239
+ }, scale === 1 ? "" : String(scale));
103
240
 
104
- // Handle background: transparent by default for PNG, or use specified color
105
- const bg = options.background;
106
- if (bg) {
107
- await page.evaluate((color) => {
108
- document.documentElement.style.backgroundColor = color;
109
- }, bg);
110
- }
241
+ const format = variant.format || "png";
242
+ const screenshotOpts = {
243
+ type: format,
244
+ clip: { x: 0, y: 0, width, height },
245
+ };
246
+ if (format === "png" && !options.background) {
247
+ screenshotOpts.omitBackground = true;
248
+ }
249
+ if (format === "jpeg" && variant.quality) {
250
+ screenshotOpts.quality = variant.quality;
251
+ }
252
+ buffers.push(await page.screenshot(screenshotOpts));
253
+ }
111
254
 
112
- const format = options.format || "png";
113
- const screenshotOpts = {
114
- type: format,
115
- clip: { x: 0, y: 0, width, height },
116
- };
117
- if (format === "png" && !bg) {
118
- screenshotOpts.omitBackground = true;
119
- }
120
- if (format === "jpeg" && options.quality) {
121
- screenshotOpts.quality = options.quality;
255
+ return buffers;
256
+ } finally {
257
+ releasePage(page);
122
258
  }
259
+ }
123
260
 
124
- const buffer = await page.screenshot(screenshotOpts);
125
-
126
- await page.close();
261
+ /**
262
+ * Render an HTML file to an image buffer at the given dimensions.
263
+ * Scales from sourceSize to target size via CSS zoom.
264
+ * Single-variant wrapper around renderVariants.
265
+ * @param {string} projectDir
266
+ * @param {string} htmlFile
267
+ * @param {number} width
268
+ * @param {number} height
269
+ * @param {number} sourceW
270
+ * @param {number} sourceH
271
+ * @param {{ format?: "png"|"jpeg", quality?: number, background?: string, localization?: { strings: object, locale: string, direction: string }, timeout?: number }} [options]
272
+ */
273
+ export async function renderScreenshot(
274
+ projectDir,
275
+ htmlFile,
276
+ width,
277
+ height,
278
+ sourceW,
279
+ sourceH,
280
+ options = {}
281
+ ) {
282
+ const [buffer] = await renderVariants(
283
+ projectDir,
284
+ htmlFile,
285
+ sourceW,
286
+ sourceH,
287
+ [{ width, height, format: options.format, quality: options.quality }],
288
+ options
289
+ );
127
290
  return buffer;
128
291
  }
129
292
 
package/lib/server.mjs CHANGED
@@ -62,7 +62,7 @@ export async function startServer(projectDir, options = {}, renderer) {
62
62
  if (!renderer) {
63
63
  renderer = await import("./renderer.mjs");
64
64
  }
65
- const { renderScreenshot, runXcodeOutput, closeBrowser } = renderer;
65
+ const { renderScreenshot, runXcodeOutput, closeBrowser, warmupBrowser } = renderer;
66
66
 
67
67
  const port = options.port || 3200;
68
68
  const host = options.host || "localhost";
@@ -159,7 +159,11 @@ export async function startServer(projectDir, options = {}, renderer) {
159
159
  const sw = sourceW || width;
160
160
  const sh = sourceH || height;
161
161
  log(`Rendering ${file} at ${width}x${height} (source ${sw}x${sh})${localization ? ` [${localization.locale}]` : ''}...`);
162
- const buffer = await renderScreenshot(projectDir, file, width, height, sw, sh, { background, localization });
162
+ const buffer = await renderScreenshot(projectDir, file, width, height, sw, sh, {
163
+ background,
164
+ localization,
165
+ timeout: options.renderTimeout,
166
+ });
163
167
  res.writeHead(200, {
164
168
  "Content-Type": "image/png",
165
169
  "Content-Length": buffer.length,
@@ -315,27 +319,31 @@ export async function startServer(projectDir, options = {}, renderer) {
315
319
  tryListen();
316
320
  });
317
321
 
318
- return { port: actualPort };
319
-
320
322
  function cleanup() {
321
323
  for (const w of watchers) w.close();
322
324
  for (const client of sseClients) client.end();
323
325
  sseClients.clear();
324
326
  }
325
327
 
326
- process.on("SIGINT", async () => {
328
+ const shutdown = async () => {
327
329
  cleanup();
328
330
  await closeBrowser();
329
331
  server.close();
330
332
  process.exit(0);
331
- });
333
+ };
332
334
 
333
- process.on("SIGTERM", async () => {
334
- cleanup();
335
- await closeBrowser();
336
- server.close();
337
- process.exit(0);
335
+ process.once("SIGINT", shutdown);
336
+ process.once("SIGTERM", shutdown);
337
+ server.on("close", () => {
338
+ process.removeListener("SIGINT", shutdown);
339
+ process.removeListener("SIGTERM", shutdown);
338
340
  });
339
341
 
340
- return server;
342
+ // Pre-launch the shared browser in the background so the first interactive
343
+ // render doesn't pay the ~2s launch cost. Failures surface on first render.
344
+ if (typeof warmupBrowser === "function") {
345
+ Promise.resolve(warmupBrowser()).catch(() => {});
346
+ }
347
+
348
+ return { port: actualPort, server };
341
349
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@open-assets/open-assets",
3
- "version": "0.4.0",
4
- "description": "Dev server and export tool for app screenshots, icons, and logos. Like Storybook for marketing assets.",
3
+ "version": "0.5.1",
4
+ "description": "Screenshots, icons, and marketing assets, made a breeze in your git repo. Design in HTML/CSS/SVG, render at every size, export anywhere. Like Storybook for marketing assets.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "open-assets": "bin/open-assets.mjs"
@@ -40,7 +40,7 @@
40
40
  },
41
41
  "scripts": {
42
42
  "assets": "node bin/open-assets.mjs render --force -o ./public/img",
43
- "test": "node --experimental-vm-modules node_modules/.bin/jest",
43
+ "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
44
44
  "release": "./scripts/release.sh"
45
45
  },
46
46
  "homepage": "https://openassets.dev",
Binary file
Binary file
@@ -17,11 +17,12 @@ open-assets dev
17
17
  # Or with Tailwind
18
18
  concurrently "npx @tailwindcss/cli -i assets/styles.css -o dist/styles.css --watch" "open-assets dev"
19
19
 
20
- # Headless render
20
+ # Headless render (incremental: unchanged templates are skipped via assets.lock)
21
+ open-assets render # everything, at every export size
22
+ open-assets render --template log-out # one template only
23
+ open-assets render --collection ui-icons # one collection
21
24
  open-assets render --collection screenshots --size iphone-6.9
22
- open-assets render --collection icon --force
23
- open-assets render --force
24
- open-assets render --template 01-hero --force
25
+ open-assets render --force # re-render everything, ignoring the cache
25
26
  ```
26
27
 
27
28
  ## Concepts
@@ -43,7 +44,7 @@ The `assets.json` at the project root defines all asset collections. All collect
43
44
  "version": 1,
44
45
  "name": "App Display Name",
45
46
  "publicDir": "public",
46
- "command": "npx open-assets render --force",
47
+ "command": "npx open-assets render",
47
48
  "collections": [
48
49
  {
49
50
  "id": "unique-id",
@@ -76,7 +77,7 @@ The `assets.json` at the project root defines all asset collections. All collect
76
77
 
77
78
  ### Output Types
78
79
 
79
- Export entries with a `type` field are post-render actions (run with `--force`):
80
+ Export entries with a `type` field are post-render actions that run after every render:
80
81
 
81
82
  | Type | Config | Description |
82
83
  |------|--------|-------------|
@@ -195,16 +196,35 @@ Export entries with a `type` field are post-render actions (run with `--force`):
195
196
  | `--template <name>` | Render only the template with this name |
196
197
  | `--size <name>` | Use a named export size from config |
197
198
  | `--locale <code>` | Render only this locale (e.g. `en`, `ar`, `ja`) |
198
- | `-f, --force` | Export at every size and re-render all (ignores cache) |
199
+ | `-f, --force` | Re-render everything, ignoring the incremental cache |
199
200
  | `-o, --output <dir>` | Output directory (default: `./exports`) |
200
201
  | `--json` | Output results as JSON |
201
202
  | `-q, --quiet` | Suppress progress logs |
202
203
 
204
+ Set `OPEN_ASSETS_CONCURRENCY` to change how many templates render at once. It defaults to `min(cores - 1, 2)`; higher values queue behind Chromium's capture path rather than going faster.
205
+
203
206
  Flags compose naturally:
204
207
  - `--collection screenshots --template 01-hero --size iphone-6.9` → single template, single size
205
- - `--collection screenshots --template 01-hero --force` → one template, all sizes
208
+ - `--collection screenshots --template 01-hero` → one template, all sizes
206
209
  - `--collection screenshots --size iphone-6.9` → all templates, one size
207
210
 
211
+ #### Performance: prefer targeted renders when iterating
212
+
213
+ A plain `render` is incremental: unchanged templates are skipped via `assets.lock`, so it is usually fast. The renderer also loads each template once per locale and screenshots every export size from that single page load, with a couple of templates rendering concurrently. Still, `--force` re-renders **every collection** in `assets.json` (icons, splash, badges, screenshots, etc.), so when you're iterating on a single asset, scope the render:
214
+
215
+ ```bash
216
+ # Iterating on one icon (fastest)
217
+ open-assets render --template log-out
218
+
219
+ # Iterating on one collection
220
+ open-assets render --collection ui-icons
221
+
222
+ # Re-render everything, ignoring the cache
223
+ open-assets render --force
224
+ ```
225
+
226
+ Rule of thumb: plain `render` is the default; it only re-renders what changed. Add `--template <name>` or `--collection <id>` to scope further. Reserve `--force` for when the cache must be bypassed: changed shared CSS, changed images in `publicDir`, or a rare global rebuild.
227
+
208
228
  ### add commands
209
229
 
210
230
  Interactive commands for managing the config without editing JSON by hand. All support `--config <path>` for custom config filenames.
@@ -330,6 +350,10 @@ Alternatively, add a new object to the `collections` array in `assets.json` manu
330
350
  | Web | OG Image | 1200 | 630 |
331
351
  | Mac App Store | Retina | 2880 | 1800 |
332
352
 
353
+ ## Version Control
354
+
355
+ **Commit `assets.lock` and `exports/` to the repository. DO NOT gitignore them.** The lockfile enables incremental builds, and the exports are the final rendered assets that other parts of the project (Xcode asset catalogs, web builds, etc.) depend on. Only `dist/` (compiled Tailwind) should be gitignored.
356
+
333
357
  ## Incremental Builds (assets.lock)
334
358
 
335
359
  The `render` command maintains a `assets.lock` file with SHA256 checksums of source files. On subsequent renders, unchanged assets are skipped automatically. Use `--force` to re-render everything.
@@ -371,41 +395,29 @@ Create a JSON file (iOS `.xcstrings`-inspired format) that maps string keys to p
371
395
 
372
396
  ### Wiring localizations to a collection
373
397
 
374
- Add `localizations` (path to the JSON file) and optionally `locales` (filter to specific locales) to a collection in `assets.json`. You can also set `defaultLocalization` at the root level of `assets.json` to control which locale is selected by default in the dev server's locale picker (falls back to `sourceLanguage` from the localizations file, then `"en"`):
398
+ Add `localizations` (path to the JSON file) and optionally `locales` (filter to specific locales) to a collection in `assets.json`:
375
399
 
376
400
  ```json
377
401
  {
378
- "version": 1,
379
- "name": "My App",
380
- "defaultLocalization": "en",
381
- "collections": [
382
- {
383
- "id": "screenshots",
384
- "label": "App Store Screenshots",
385
- "sourceSize": { "width": 440, "height": 956 },
386
- "localizations": "localizations.json",
387
- "locales": ["en", "ar", "ja", "de"],
388
- "templates": [
389
- { "src": "assets/screenshots/01-hero.html", "name": "01-hero", "label": "Hero" }
390
- ],
391
- "export": [
392
- { "name": "iphone-6.9", "label": "iPhone 6.9\"", "size": { "width": 1320, "height": 2868 } }
393
- ]
394
- }
402
+ "id": "screenshots",
403
+ "label": "App Store Screenshots",
404
+ "sourceSize": { "width": 440, "height": 956 },
405
+ "localizations": "localizations.json",
406
+ "locales": ["en", "ar", "ja", "de"],
407
+ "templates": [
408
+ { "src": "assets/screenshots/01-hero.html", "name": "01-hero", "label": "Hero" }
409
+ ],
410
+ "export": [
411
+ { "name": "iphone-6.9", "label": "iPhone 6.9\"", "size": { "width": 1320, "height": 2868 } }
395
412
  ]
396
413
  }
397
414
  ```
398
415
 
399
416
  | Field | Type | Description |
400
417
  |-------|------|-------------|
401
- | `defaultLocalization` | string | Root-level. Default locale for the dev server locale picker. Falls back to `sourceLanguage` then `"en"`. |
402
418
  | `localizations` | string | Path to the localizations JSON file (relative to project root) |
403
419
  | `locales` | string[] | Optional filter — render only these locales. Omit to render all locales in the file. |
404
420
 
405
- ### Dev server locale picker
406
-
407
- When a collection has `localizations` configured, the dev server shows a locale picker dropdown in the toolbar. Selecting a locale injects the localized strings into all template iframes in real time. The selected locale is also persisted in the URL (`?locale=ar`) and used for exports — exported PNGs will include the localized text for the active locale.
408
-
409
421
  ### Using placeholders in templates
410
422
 
411
423
  Templates use `{{key}}` mustache-style placeholders that get replaced with localized values before rendering:
@@ -458,13 +470,13 @@ With `outFile`, use the `{locale}` template variable:
458
470
 
459
471
  ```bash
460
472
  # Render all locales for a collection
461
- open-assets render --collection screenshots --force
473
+ open-assets render --collection screenshots
462
474
 
463
475
  # Render only Arabic
464
- open-assets render --collection screenshots --force --locale ar
476
+ open-assets render --collection screenshots --locale ar
465
477
 
466
478
  # Render a single template in Japanese
467
- open-assets render --collection screenshots --template 01-hero --locale ja --force
479
+ open-assets render --collection screenshots --template 01-hero --locale ja
468
480
  ```
469
481
 
470
482
  ### Locale fallback order
@@ -479,7 +491,7 @@ When resolving a string for a locale:
479
491
  ```
480
492
  project/
481
493
  assets.json
482
- assets.lock # Auto-generated cache (commit this)
494
+ assets.lock # Auto-generated cache — commit this
483
495
  public/ # Shared assets (images, logos, icons, photos)
484
496
  logo-round.png
485
497
  social/
@@ -500,7 +512,7 @@ project/
500
512
  default.html
501
513
  dist/
502
514
  styles.css # Compiled Tailwind
503
- exports/ # Rendered output (add to .gitignore)
515
+ exports/ # Rendered output — commit this
504
516
  ```
505
517
 
506
518
  ---
Binary file