@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 +30 -3
- package/package.json +1 -1
- package/public/viewer.html +190 -2
- package/skills/open-assets/SKILL.md +23 -11
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
package/public/viewer.html
CHANGED
|
@@ -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(
|
|
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)
|
|
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
|
-
"
|
|
379
|
-
"
|
|
380
|
-
"
|
|
381
|
-
"
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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:
|