@open-assets/open-assets 0.3.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.
@@ -80,6 +80,14 @@
80
80
 
81
81
  .hint { position: fixed; bottom: 12px; left: 50%; transform: translateX(-50%); font-size: 11px; color: #555; z-index: 100; pointer-events: none; }
82
82
  .hint kbd { display: inline-block; padding: 1px 5px; border: 1px solid #444; border-radius: 3px; background: #1a1a1a; font-family: inherit; font-size: 10px; color: #888; }
83
+
84
+ /* Locale picker */
85
+ .locale-picker { display: none; align-items: center; gap: 6px; margin-left: 8px; }
86
+ .locale-picker.visible { display: flex; }
87
+ .locale-picker label { font-size: 11px; color: #666; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; }
88
+ .locale-select { padding: 4px 8px; border-radius: 5px; border: 1px solid rgba(255,255,255,0.12); background: rgba(255,255,255,0.06); color: #ccc; font-size: 12px; font-weight: 600; font-family: inherit; cursor: pointer; appearance: none; -webkit-appearance: none; padding-right: 22px; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' fill='none'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23888' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 6px center; }
89
+ .locale-select:hover { border-color: rgba(255,255,255,0.25); color: #fff; }
90
+ .locale-select:focus { outline: none; border-color: rgba(255,255,255,0.3); }
83
91
  </style>
84
92
  </head>
85
93
  <body>
@@ -88,6 +96,10 @@
88
96
  <img class="toolbar-icon" src="/img/icon/32/icon.png" alt="" />
89
97
  <h1 id="app-title">Loading...</h1>
90
98
  <div class="segmented" id="tab-bar"></div>
99
+ <div class="locale-picker" id="locale-picker">
100
+ <label>Locale</label>
101
+ <select class="locale-select" id="locale-select"></select>
102
+ </div>
91
103
  </div>
92
104
  <div class="toolbar-right">
93
105
  <button class="toolbar-btn" id="zoom-out" title="Zoom out">−</button>
@@ -118,6 +130,9 @@
118
130
  // ---- State ----
119
131
  let manifest = null;
120
132
  let activeCollection = null;
133
+ let localeData = null; // raw localizations JSON (xcstrings format)
134
+ let availableLocales = []; // locale codes for active collection
135
+ let activeLocale = null; // currently selected locale (null = no localization)
121
136
 
122
137
  // ---- DOM refs ----
123
138
  const canvas = document.getElementById('canvas');
@@ -127,6 +142,8 @@
127
142
  const exportDropdown = document.getElementById('export-dropdown');
128
143
  const exportToggle = document.getElementById('export-toggle');
129
144
  const toast = document.getElementById('toast');
145
+ const localePicker = document.getElementById('locale-picker');
146
+ const localeSelect = document.getElementById('locale-select');
130
147
 
131
148
  // ---- Zoom / Pan ----
132
149
  let scale = 1, panX = 0, panY = 0, isPanning = false, startX = 0, startY = 0, startPanX = 0, startPanY = 0;
@@ -161,12 +178,158 @@
161
178
  function showToast(msg) { toast.textContent = msg; toast.classList.add('show'); }
162
179
  function hideToast() { toast.classList.remove('show'); }
163
180
 
181
+ // ---- Localization helpers ----
182
+
183
+ const RTL_LANGUAGES = new Set(['ar','he','fa','ur','yi','ps','sd','ku','dv','ug']);
184
+
185
+ function isRtl(locale) {
186
+ if (RTL_LANGUAGES.has(locale)) return true;
187
+ return RTL_LANGUAGES.has(locale.split('-')[0]);
188
+ }
189
+
190
+ function resolveStrings(data, locale) {
191
+ const strings = {};
192
+ const sourceLang = data.sourceLanguage || 'en';
193
+ for (const [key, entry] of Object.entries(data.strings || {})) {
194
+ const l10ns = entry.localizations || {};
195
+ const value = l10ns[locale]?.value ?? l10ns[locale.split('-')[0]]?.value ?? l10ns[sourceLang]?.value;
196
+ if (value !== undefined) strings[key] = value;
197
+ }
198
+ return strings;
199
+ }
200
+
201
+ function getLocalization() {
202
+ if (!activeLocale || !localeData) return undefined;
203
+ return {
204
+ strings: resolveStrings(localeData, activeLocale),
205
+ locale: activeLocale,
206
+ direction: isRtl(activeLocale) ? 'rtl' : 'ltr',
207
+ };
208
+ }
209
+
210
+ async function loadLocalizationsForCollection(col) {
211
+ if (!col.localizations) {
212
+ localeData = null;
213
+ availableLocales = [];
214
+ activeLocale = null;
215
+ localePicker.classList.remove('visible');
216
+ return;
217
+ }
218
+ try {
219
+ const res = await fetch(`/api/localizations?collection=${encodeURIComponent(col.id)}`);
220
+ const json = await res.json();
221
+ if (!json.ok) throw new Error(json.error);
222
+ localeData = json.data;
223
+ availableLocales = json.locales;
224
+ const defaultLocale = json.defaultLocale;
225
+
226
+ // Restore from URL param, or use defaultLocalization, or first available
227
+ const params = new URLSearchParams(window.location.search);
228
+ const urlLocale = params.get('locale');
229
+ if (urlLocale && availableLocales.includes(urlLocale)) {
230
+ activeLocale = urlLocale;
231
+ } else if (availableLocales.includes(defaultLocale)) {
232
+ activeLocale = defaultLocale;
233
+ } else {
234
+ activeLocale = availableLocales[0] || null;
235
+ }
236
+
237
+ buildLocalePicker();
238
+ localePicker.classList.add('visible');
239
+ } catch (err) {
240
+ console.error('Failed to load localizations:', err);
241
+ localeData = null;
242
+ availableLocales = [];
243
+ activeLocale = null;
244
+ localePicker.classList.remove('visible');
245
+ }
246
+ }
247
+
248
+ function buildLocalePicker() {
249
+ localeSelect.innerHTML = '';
250
+ for (const locale of availableLocales) {
251
+ const opt = document.createElement('option');
252
+ opt.value = locale;
253
+ opt.textContent = locale;
254
+ if (locale === activeLocale) opt.selected = true;
255
+ localeSelect.appendChild(opt);
256
+ }
257
+ }
258
+
259
+ localeSelect.addEventListener('change', () => {
260
+ activeLocale = localeSelect.value;
261
+ // Update URL without reload
262
+ const params = new URLSearchParams(window.location.search);
263
+ params.set('locale', activeLocale);
264
+ history.replaceState(null, '', '?' + params.toString());
265
+ applyLocalizationToIframes();
266
+ });
267
+
268
+ function applyLocalizationToIframes() {
269
+ const l10n = getLocalization();
270
+ if (!l10n) return;
271
+ const iframes = inner.querySelectorAll('iframe');
272
+ for (const iframe of iframes) {
273
+ applyLocalizationToIframe(iframe, l10n);
274
+ }
275
+ }
276
+
277
+ function applyLocalizationToIframe(iframe, l10n) {
278
+ if (!l10n) return;
279
+ try {
280
+ const doc = iframe.contentDocument || iframe.contentWindow?.document;
281
+ if (!doc || !doc.documentElement) return;
282
+
283
+ // Reload iframe to get fresh {{key}} placeholders, then inject
284
+ const src = iframe.dataset.file || iframe.getAttribute('src')?.split('?')[0];
285
+ if (!src) return;
286
+
287
+ iframe.onload = function() {
288
+ try {
289
+ const freshDoc = iframe.contentDocument || iframe.contentWindow?.document;
290
+ if (!freshDoc) return;
291
+ injectLocalization(freshDoc, l10n);
292
+ } catch (e) { /* cross-origin or timing */ }
293
+ iframe.onload = null;
294
+ };
295
+ iframe.src = src + '?_l=' + Date.now();
296
+ } catch (e) { /* ignore */ }
297
+ }
298
+
299
+ function injectLocalization(doc, l10n) {
300
+ const { strings, locale, direction } = l10n;
301
+ doc.documentElement.lang = locale;
302
+
303
+ const formatter = new Intl.NumberFormat(locale);
304
+ function processValue(value) {
305
+ return value.replace(/\{\{n:(\d+(?:\.\d+)?)\}\}/g, (_, num) => formatter.format(parseFloat(num)));
306
+ }
307
+
308
+ const walker = doc.createTreeWalker(doc.documentElement, NodeFilter.SHOW_TEXT);
309
+ while (walker.nextNode()) {
310
+ const node = walker.currentNode;
311
+ let text = node.textContent;
312
+ let changed = false;
313
+ text = text.replace(/\{\{([a-zA-Z_][\w.]*)\}\}/g, (match, key) => {
314
+ if (strings[key] !== undefined) { changed = true; return processValue(strings[key]); }
315
+ return match;
316
+ });
317
+ if (changed) {
318
+ node.textContent = text;
319
+ if (direction === 'rtl') node.parentElement.dir = direction;
320
+ }
321
+ }
322
+ }
323
+
164
324
  // ---- Render screenshot via Puppeteer server ----
165
325
  async function renderScreenshot(file, width, height, sourceW, sourceH) {
326
+ const body = { file, width, height, sourceW, sourceH };
327
+ const l10n = getLocalization();
328
+ if (l10n) body.localization = l10n;
166
329
  const res = await fetch('/api/render-screenshot', {
167
330
  method: 'POST',
168
331
  headers: { 'Content-Type': 'application/json' },
169
- body: JSON.stringify({ file, width, height, sourceW, sourceH }),
332
+ body: JSON.stringify(body),
170
333
  });
171
334
  if (!res.ok) throw new Error(`Server error: ${res.status}`);
172
335
  return await res.blob();
@@ -233,6 +396,15 @@
233
396
  iframe.style.width = sw + 'px';
234
397
  iframe.style.height = sh + 'px';
235
398
 
399
+ // Apply localization after iframe loads
400
+ const l10n = getLocalization();
401
+ if (l10n) {
402
+ iframe.addEventListener('load', function onLoad() {
403
+ try { injectLocalization(iframe.contentDocument || iframe.contentWindow.document, l10n); } catch(e) {}
404
+ iframe.removeEventListener('load', onLoad);
405
+ });
406
+ }
407
+
236
408
  border.appendChild(iframe);
237
409
  slot.appendChild(border);
238
410
  }
@@ -462,6 +634,7 @@
462
634
  activeCollection = manifest.collections.find((c) => c.id === colId) || manifest.collections[0];
463
635
 
464
636
  buildTabBar();
637
+ await loadLocalizationsForCollection(activeCollection);
465
638
  buildCanvas();
466
639
  buildExportDropdown();
467
640
  requestAnimationFrame(fitAll);
@@ -489,9 +662,16 @@
489
662
 
490
663
  // Reload iframes whose src matches the changed file
491
664
  const iframes = inner.querySelectorAll('iframe');
665
+ const l10n = getLocalization();
492
666
  for (const iframe of iframes) {
493
667
  const src = iframe.dataset.file || iframe.getAttribute('src')?.split('?')[0];
494
668
  if (src && changedFile.endsWith(src.replace(/^\//, ''))) {
669
+ if (l10n) {
670
+ iframe.onload = function() {
671
+ try { injectLocalization(iframe.contentDocument || iframe.contentWindow.document, l10n); } catch(e) {}
672
+ iframe.onload = null;
673
+ };
674
+ }
495
675
  iframe.src = src + bust;
496
676
  }
497
677
  }
@@ -510,7 +690,15 @@
510
690
  if (['css', 'js', 'mjs', 'json', 'woff', 'woff2', 'ttf'].includes(ext)) {
511
691
  for (const iframe of iframes) {
512
692
  const src = iframe.dataset.file || iframe.getAttribute('src')?.split('?')[0];
513
- if (src) iframe.src = src + bust;
693
+ if (src) {
694
+ if (l10n) {
695
+ iframe.onload = function() {
696
+ try { injectLocalization(iframe.contentDocument || iframe.contentWindow.document, l10n); } catch(e) {}
697
+ iframe.onload = null;
698
+ };
699
+ }
700
+ iframe.src = src + bust;
701
+ }
514
702
  }
515
703
  }
516
704
  }
@@ -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.
@@ -446,13 +468,13 @@ With `outFile`, use the `{locale}` template variable:
446
468
 
447
469
  ```bash
448
470
  # Render all locales for a collection
449
- open-assets render --collection screenshots --force
471
+ open-assets render --collection screenshots
450
472
 
451
473
  # Render only Arabic
452
- open-assets render --collection screenshots --force --locale ar
474
+ open-assets render --collection screenshots --locale ar
453
475
 
454
476
  # Render a single template in Japanese
455
- open-assets render --collection screenshots --template 01-hero --locale ja --force
477
+ open-assets render --collection screenshots --template 01-hero --locale ja
456
478
  ```
457
479
 
458
480
  ### Locale fallback order
@@ -467,7 +489,7 @@ When resolving a string for a locale:
467
489
  ```
468
490
  project/
469
491
  assets.json
470
- assets.lock # Auto-generated cache (commit this)
492
+ assets.lock # Auto-generated cache — commit this
471
493
  public/ # Shared assets (images, logos, icons, photos)
472
494
  logo-round.png
473
495
  social/
@@ -488,7 +510,7 @@ project/
488
510
  default.html
489
511
  dist/
490
512
  styles.css # Compiled Tailwind
491
- exports/ # Rendered output (add to .gitignore)
513
+ exports/ # Rendered output — commit this
492
514
  ```
493
515
 
494
516
  ---
Binary file