@open-assets/open-assets 0.3.0 → 0.4.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
@@ -2,6 +2,7 @@ import { createServer } from "http";
2
2
  import { readFileSync, existsSync, statSync, watch } from "fs";
3
3
  import { resolve, join, extname } from "path";
4
4
  import { fileURLToPath } from "url";
5
+ import { loadLocalizations, resolveStrings, getLocales, getDirection } from "./localization.mjs";
5
6
 
6
7
  const __dirname = resolve(fileURLToPath(import.meta.url), "..");
7
8
  const VIEWER_PATH = join(__dirname, "..", "public", "viewer.html");
@@ -146,7 +147,7 @@ export async function startServer(projectDir, options = {}, renderer) {
146
147
  // API: render screenshot (unified for all collection types)
147
148
  if (req.method === "POST" && pathname === "/api/render-screenshot") {
148
149
  try {
149
- const { file, width, height, sourceW, sourceH, background } = JSON.parse(
150
+ const { file, width, height, sourceW, sourceH, background, localization } = JSON.parse(
150
151
  await readBody(req)
151
152
  );
152
153
  if (!file || !width || !height) {
@@ -157,8 +158,8 @@ export async function startServer(projectDir, options = {}, renderer) {
157
158
  }
158
159
  const sw = sourceW || width;
159
160
  const sh = sourceH || height;
160
- log(`Rendering ${file} at ${width}x${height} (source ${sw}x${sh})...`);
161
- const buffer = await renderScreenshot(projectDir, file, width, height, sw, sh, { background });
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
163
  res.writeHead(200, {
163
164
  "Content-Type": "image/png",
164
165
  "Content-Length": buffer.length,
@@ -172,6 +173,32 @@ export async function startServer(projectDir, options = {}, renderer) {
172
173
  return;
173
174
  }
174
175
 
176
+ // API: get localization data for a collection
177
+ if (req.method === "GET" && pathname === "/api/localizations") {
178
+ try {
179
+ const colId = url.searchParams.get("collection");
180
+ if (!colId) {
181
+ res.writeHead(400, { "Content-Type": "application/json" });
182
+ return res.end(JSON.stringify({ ok: false, error: "Missing collection param" }));
183
+ }
184
+ const col = manifest.collections.find((c) => c.id === colId);
185
+ if (!col || !col.localizations) {
186
+ res.writeHead(404, { "Content-Type": "application/json" });
187
+ return res.end(JSON.stringify({ ok: false, error: "Collection has no localizations" }));
188
+ }
189
+ const data = loadLocalizations(projectDir, col.localizations);
190
+ const locales = getLocales(data, col.locales);
191
+ const defaultLocale = manifest.defaultLocalization || data.sourceLanguage || "en";
192
+ res.writeHead(200, { "Content-Type": "application/json" });
193
+ res.end(JSON.stringify({ ok: true, data, locales, defaultLocale }));
194
+ } catch (err) {
195
+ console.error(err);
196
+ res.writeHead(500, { "Content-Type": "application/json" });
197
+ res.end(JSON.stringify({ ok: false, error: err.message }));
198
+ }
199
+ return;
200
+ }
201
+
175
202
  // API: run xcode output for a collection
176
203
  if (req.method === "POST" && pathname === "/api/run-output") {
177
204
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-assets/open-assets",
3
- "version": "0.3.0",
3
+ "version": "0.4.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": {
@@ -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
  }
@@ -371,29 +371,41 @@ Create a JSON file (iOS `.xcstrings`-inspired format) that maps string keys to p
371
371
 
372
372
  ### Wiring localizations to a collection
373
373
 
374
- Add `localizations` (path to the JSON file) and optionally `locales` (filter to specific locales) to a collection in `assets.json`:
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"`):
375
375
 
376
376
  ```json
377
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 } }
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
+ }
388
395
  ]
389
396
  }
390
397
  ```
391
398
 
392
399
  | Field | Type | Description |
393
400
  |-------|------|-------------|
401
+ | `defaultLocalization` | string | Root-level. Default locale for the dev server locale picker. Falls back to `sourceLanguage` then `"en"`. |
394
402
  | `localizations` | string | Path to the localizations JSON file (relative to project root) |
395
403
  | `locales` | string[] | Optional filter — render only these locales. Omit to render all locales in the file. |
396
404
 
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
+
397
409
  ### Using placeholders in templates
398
410
 
399
411
  Templates use `{{key}}` mustache-style placeholders that get replaced with localized values before rendering: