@open-assets/open-assets 0.4.0 → 0.5.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/renderer.mjs CHANGED
@@ -2,128 +2,254 @@ 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
+ async function acquirePage() {
49
+ const browser = await getBrowser();
50
+ while (pagePool.length > 0) {
51
+ const page = pagePool.pop();
52
+ // Drop pages tied to a closed or replaced browser instance
53
+ if (!page.isClosed() && page.browser() === browser) return page;
10
54
  }
11
- return browser;
55
+ return browser.newPage();
56
+ }
57
+
58
+ function releasePage(page) {
59
+ if (page && !page.isClosed()) pagePool.push(page);
12
60
  }
13
61
 
14
62
  export async function closeBrowser() {
15
- if (browser) {
16
- await browser.close();
17
- browser = null;
63
+ pagePool.length = 0;
64
+ if (browserPromise) {
65
+ const pending = browserPromise;
66
+ browserPromise = null;
67
+ try {
68
+ const browser = await pending;
69
+ await browser.close();
70
+ } catch {
71
+ // Launch already failed; nothing to close.
72
+ }
18
73
  }
19
74
  }
20
75
 
21
76
  /**
22
- * Render an HTML file to an image buffer at the given dimensions.
23
- * Scales from sourceSize to target size via CSS zoom.
77
+ * Deterministic readiness barrier, replacing fixed sleeps: yields a macrotask
78
+ * (so MutationObserver-driven DOM updates like locale image swaps have run),
79
+ * forces a layout (so font loads triggered by first layout have started),
80
+ * then waits for fonts and image decodes. No frame waits here: rAF can stall
81
+ * indefinitely in a non-active tab, and the screenshot capture itself forces
82
+ * a fresh frame that flushes pending style and layout.
83
+ */
84
+ async function settle(page) {
85
+ await page.evaluate(async () => {
86
+ await new Promise((r) => setTimeout(r, 0));
87
+ document.documentElement.getBoundingClientRect();
88
+ await document.fonts.ready;
89
+ const images = document.images ? Array.from(document.images) : [];
90
+ await Promise.all(images.map((img) => img.decode().catch(() => {})));
91
+ });
92
+ }
93
+
94
+ /**
95
+ * Render an HTML file at multiple sizes from a single navigation.
96
+ * The page is loaded (and localized) once; each variant re-applies
97
+ * viewport + CSS zoom and takes its own screenshot.
98
+ *
24
99
  * @param {string} projectDir
25
100
  * @param {string} htmlFile
26
- * @param {number} width
27
- * @param {number} height
28
101
  * @param {number} sourceW
29
102
  * @param {number} sourceH
30
- * @param {{ format?: "png"|"jpeg", quality?: number, background?: string, localization?: { strings: object, locale: string, direction: string } }} [options]
103
+ * @param {{ width: number, height: number, format?: "png"|"jpeg", quality?: number }[]} variants
104
+ * @param {{ background?: string, localization?: { strings: object, locale: string, direction: string }, timeout?: number }} [options]
105
+ * @returns {Promise<Buffer[]>} Screenshot buffers in variant order
31
106
  */
32
- export async function renderScreenshot(
107
+ export async function renderVariants(
33
108
  projectDir,
34
109
  htmlFile,
35
- width,
36
- height,
37
110
  sourceW,
38
111
  sourceH,
112
+ variants,
39
113
  options = {}
40
114
  ) {
41
- const b = await getBrowser();
42
- const page = await b.newPage();
115
+ const page = await acquirePage();
116
+ try {
117
+ const first = variants[0];
118
+ await page.setViewport({
119
+ width: first.width,
120
+ height: first.height,
121
+ deviceScaleFactor: 1,
122
+ });
123
+
124
+ const filePath = resolve(projectDir, htmlFile);
125
+ await page.goto(`file://${filePath}`, {
126
+ waitUntil: "load",
127
+ timeout: options.timeout || 30000,
128
+ });
43
129
 
44
- const scale = Math.max(width / sourceW, height / sourceH);
130
+ // Inject localized strings and set lang before rendering
131
+ if (options.localization) {
132
+ const { strings, locale, direction } = options.localization;
133
+ await page.evaluate((strings, locale, dir) => {
134
+ // Set lang on root for font selection; do NOT set dir on root
135
+ // because it flips the entire CSS layout (flexbox, margins, etc.)
136
+ // which breaks fixed-pixel marketing layouts.
137
+ document.documentElement.lang = locale;
45
138
 
46
- await page.setViewport({ width, height, deviceScaleFactor: 1 });
139
+ const formatter = new Intl.NumberFormat(locale);
47
140
 
48
- const filePath = resolve(projectDir, htmlFile);
49
- await page.goto(`file://${filePath}`, { waitUntil: "networkidle0" });
50
- await new Promise((r) => setTimeout(r, 500));
141
+ function processValue(value) {
142
+ return value.replace(/\{\{n:(\d+(?:\.\d+)?)\}\}/g, (_, num) => {
143
+ return formatter.format(parseFloat(num));
144
+ });
145
+ }
146
+
147
+ const walker = document.createTreeWalker(
148
+ document.documentElement,
149
+ NodeFilter.SHOW_TEXT
150
+ );
151
+ while (walker.nextNode()) {
152
+ const node = walker.currentNode;
153
+ let text = node.textContent;
154
+ let changed = false;
155
+ text = text.replace(/\{\{([a-zA-Z_][\w.]*)\}\}/g, (match, key) => {
156
+ if (strings[key] !== undefined) {
157
+ changed = true;
158
+ return processValue(strings[key]);
159
+ }
160
+ return match;
161
+ });
162
+ if (changed) {
163
+ node.textContent = text;
164
+ // Apply text direction on the parent element so RTL text
165
+ // renders correctly without flipping the overall page layout.
166
+ if (dir === "rtl") {
167
+ node.parentElement.dir = dir;
168
+ }
169
+ }
170
+ }
171
+ }, strings, locale, direction);
172
+ }
51
173
 
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;
174
+ // Handle background: transparent by default for PNG, or use specified color
175
+ if (options.background) {
176
+ await page.evaluate((color) => {
177
+ document.documentElement.style.backgroundColor = color;
178
+ }, options.background);
179
+ }
60
180
 
61
- const formatter = new Intl.NumberFormat(locale);
181
+ await settle(page);
62
182
 
63
- function processValue(value) {
64
- return value.replace(/\{\{n:(\d+(?:\.\d+)?)\}\}/g, (_, num) => {
65
- return formatter.format(parseFloat(num));
66
- });
67
- }
183
+ const buffers = [];
184
+ for (const variant of variants) {
185
+ const { width, height } = variant;
186
+ await page.setViewport({ width, height, deviceScaleFactor: 1 });
68
187
 
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;
188
+ const scale = Math.max(width / sourceW, height / sourceH);
189
+ // Always assign zoom (clearing it at scale 1) so a previous variant's
190
+ // zoom can't leak into this one, and freeze animations at t=0 so the
191
+ // capture is deterministic. The screenshot's forced frame applies both.
192
+ await page.evaluate((zoom) => {
193
+ document.documentElement.style.zoom = zoom;
194
+ for (const anim of document.getAnimations()) {
195
+ try {
196
+ anim.pause();
197
+ anim.currentTime = 0;
198
+ } catch {
199
+ // Some animations (e.g. infinite transitions) can reject; skip.
90
200
  }
91
201
  }
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
- }
202
+ }, scale === 1 ? "" : String(scale));
103
203
 
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
- }
204
+ const format = variant.format || "png";
205
+ const screenshotOpts = {
206
+ type: format,
207
+ clip: { x: 0, y: 0, width, height },
208
+ };
209
+ if (format === "png" && !options.background) {
210
+ screenshotOpts.omitBackground = true;
211
+ }
212
+ if (format === "jpeg" && variant.quality) {
213
+ screenshotOpts.quality = variant.quality;
214
+ }
215
+ buffers.push(await page.screenshot(screenshotOpts));
216
+ }
111
217
 
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;
218
+ return buffers;
219
+ } finally {
220
+ releasePage(page);
122
221
  }
222
+ }
123
223
 
124
- const buffer = await page.screenshot(screenshotOpts);
125
-
126
- await page.close();
224
+ /**
225
+ * Render an HTML file to an image buffer at the given dimensions.
226
+ * Scales from sourceSize to target size via CSS zoom.
227
+ * Single-variant wrapper around renderVariants.
228
+ * @param {string} projectDir
229
+ * @param {string} htmlFile
230
+ * @param {number} width
231
+ * @param {number} height
232
+ * @param {number} sourceW
233
+ * @param {number} sourceH
234
+ * @param {{ format?: "png"|"jpeg", quality?: number, background?: string, localization?: { strings: object, locale: string, direction: string }, timeout?: number }} [options]
235
+ */
236
+ export async function renderScreenshot(
237
+ projectDir,
238
+ htmlFile,
239
+ width,
240
+ height,
241
+ sourceW,
242
+ sourceH,
243
+ options = {}
244
+ ) {
245
+ const [buffer] = await renderVariants(
246
+ projectDir,
247
+ htmlFile,
248
+ sourceW,
249
+ sourceH,
250
+ [{ width, height, format: options.format, quality: options.quality }],
251
+ options
252
+ );
127
253
  return buffer;
128
254
  }
129
255
 
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.0",
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,33 @@ 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
 
203
204
  Flags compose naturally:
204
205
  - `--collection screenshots --template 01-hero --size iphone-6.9` → single template, single size
205
- - `--collection screenshots --template 01-hero --force` → one template, all sizes
206
+ - `--collection screenshots --template 01-hero` → one template, all sizes
206
207
  - `--collection screenshots --size iphone-6.9` → all templates, one size
207
208
 
209
+ #### Performance: prefer targeted renders when iterating
210
+
211
+ 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 several templates rendering in parallel. 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:
212
+
213
+ ```bash
214
+ # Iterating on one icon (fastest)
215
+ open-assets render --template log-out
216
+
217
+ # Iterating on one collection
218
+ open-assets render --collection ui-icons
219
+
220
+ # Re-render everything, ignoring the cache
221
+ open-assets render --force
222
+ ```
223
+
224
+ 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.
225
+
208
226
  ### add commands
209
227
 
210
228
  Interactive commands for managing the config without editing JSON by hand. All support `--config <path>` for custom config filenames.
@@ -330,6 +348,10 @@ Alternatively, add a new object to the `collections` array in `assets.json` manu
330
348
  | Web | OG Image | 1200 | 630 |
331
349
  | Mac App Store | Retina | 2880 | 1800 |
332
350
 
351
+ ## Version Control
352
+
353
+ **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.
354
+
333
355
  ## Incremental Builds (assets.lock)
334
356
 
335
357
  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 +393,29 @@ Create a JSON file (iOS `.xcstrings`-inspired format) that maps string keys to p
371
393
 
372
394
  ### Wiring localizations to a collection
373
395
 
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"`):
396
+ Add `localizations` (path to the JSON file) and optionally `locales` (filter to specific locales) to a collection in `assets.json`:
375
397
 
376
398
  ```json
377
399
  {
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
- }
400
+ "id": "screenshots",
401
+ "label": "App Store Screenshots",
402
+ "sourceSize": { "width": 440, "height": 956 },
403
+ "localizations": "localizations.json",
404
+ "locales": ["en", "ar", "ja", "de"],
405
+ "templates": [
406
+ { "src": "assets/screenshots/01-hero.html", "name": "01-hero", "label": "Hero" }
407
+ ],
408
+ "export": [
409
+ { "name": "iphone-6.9", "label": "iPhone 6.9\"", "size": { "width": 1320, "height": 2868 } }
395
410
  ]
396
411
  }
397
412
  ```
398
413
 
399
414
  | Field | Type | Description |
400
415
  |-------|------|-------------|
401
- | `defaultLocalization` | string | Root-level. Default locale for the dev server locale picker. Falls back to `sourceLanguage` then `"en"`. |
402
416
  | `localizations` | string | Path to the localizations JSON file (relative to project root) |
403
417
  | `locales` | string[] | Optional filter — render only these locales. Omit to render all locales in the file. |
404
418
 
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
419
  ### Using placeholders in templates
410
420
 
411
421
  Templates use `{{key}}` mustache-style placeholders that get replaced with localized values before rendering:
@@ -458,13 +468,13 @@ With `outFile`, use the `{locale}` template variable:
458
468
 
459
469
  ```bash
460
470
  # Render all locales for a collection
461
- open-assets render --collection screenshots --force
471
+ open-assets render --collection screenshots
462
472
 
463
473
  # Render only Arabic
464
- open-assets render --collection screenshots --force --locale ar
474
+ open-assets render --collection screenshots --locale ar
465
475
 
466
476
  # Render a single template in Japanese
467
- open-assets render --collection screenshots --template 01-hero --locale ja --force
477
+ open-assets render --collection screenshots --template 01-hero --locale ja
468
478
  ```
469
479
 
470
480
  ### Locale fallback order
@@ -479,7 +489,7 @@ When resolving a string for a locale:
479
489
  ```
480
490
  project/
481
491
  assets.json
482
- assets.lock # Auto-generated cache (commit this)
492
+ assets.lock # Auto-generated cache — commit this
483
493
  public/ # Shared assets (images, logos, icons, photos)
484
494
  logo-round.png
485
495
  social/
@@ -500,7 +510,7 @@ project/
500
510
  default.html
501
511
  dist/
502
512
  styles.css # Compiled Tailwind
503
- exports/ # Rendered output (add to .gitignore)
513
+ exports/ # Rendered output — commit this
504
514
  ```
505
515
 
506
516
  ---
Binary file