@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.
- package/README.md +141 -25
- package/bin/open-assets.mjs +2 -3
- package/lib/init.mjs +1 -1
- package/lib/lockfile.mjs +6 -3
- package/lib/manifest.mjs +5 -17
- package/lib/render.mjs +301 -176
- package/lib/renderer.mjs +214 -88
- package/lib/server.mjs +49 -14
- package/package.json +3 -3
- package/public/img/banner/1280.png +0 -0
- package/public/img/examples/tax-days-icon.png +0 -0
- package/public/img/examples/tax-days.png +0 -0
- package/public/img/examples/thoughtful-icon.png +0 -0
- package/public/img/examples/thoughtful.png +0 -0
- package/public/viewer.html +190 -2
- package/skills/open-assets/SKILL.md +35 -13
- package/public/img/banner/1280/banner.png +0 -0
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
|
}
|
|
@@ -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 --
|
|
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
|
|
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
|
|
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` |
|
|
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
|
|
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
|
|
471
|
+
open-assets render --collection screenshots
|
|
450
472
|
|
|
451
473
|
# Render only Arabic
|
|
452
|
-
open-assets render --collection screenshots --
|
|
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
|
|
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
|
|
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
|
|
513
|
+
exports/ # Rendered output — commit this
|
|
492
514
|
```
|
|
493
515
|
|
|
494
516
|
---
|
|
Binary file
|