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