@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/lib/renderer.mjs CHANGED
@@ -2,128 +2,254 @@ import { launch } from "puppeteer";
2
2
  import { resolve } from "path";
3
3
  import { writeFileSync, mkdirSync, readdirSync, unlinkSync } from "fs";
4
4
 
5
- let browser = null;
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
- if (!browser || !browser.connected) {
9
- browser = await launch({ headless: true });
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
+ async function acquirePage() {
49
+ const browser = await getBrowser();
50
+ while (pagePool.length > 0) {
51
+ const page = pagePool.pop();
52
+ // Drop pages tied to a closed or replaced browser instance
53
+ if (!page.isClosed() && page.browser() === browser) return page;
10
54
  }
11
- return browser;
55
+ return browser.newPage();
56
+ }
57
+
58
+ function releasePage(page) {
59
+ if (page && !page.isClosed()) pagePool.push(page);
12
60
  }
13
61
 
14
62
  export async function closeBrowser() {
15
- if (browser) {
16
- await browser.close();
17
- browser = null;
63
+ pagePool.length = 0;
64
+ if (browserPromise) {
65
+ const pending = browserPromise;
66
+ browserPromise = null;
67
+ try {
68
+ const browser = await pending;
69
+ await browser.close();
70
+ } catch {
71
+ // Launch already failed; nothing to close.
72
+ }
18
73
  }
19
74
  }
20
75
 
21
76
  /**
22
- * Render an HTML file to an image buffer at the given dimensions.
23
- * Scales from sourceSize to target size via CSS zoom.
77
+ * Deterministic readiness barrier, replacing fixed sleeps: yields a macrotask
78
+ * (so MutationObserver-driven DOM updates like locale image swaps have run),
79
+ * forces a layout (so font loads triggered by first layout have started),
80
+ * then waits for fonts and image decodes. No frame waits here: rAF can stall
81
+ * indefinitely in a non-active tab, and the screenshot capture itself forces
82
+ * a fresh frame that flushes pending style and layout.
83
+ */
84
+ async function settle(page) {
85
+ await page.evaluate(async () => {
86
+ await new Promise((r) => setTimeout(r, 0));
87
+ document.documentElement.getBoundingClientRect();
88
+ await document.fonts.ready;
89
+ const images = document.images ? Array.from(document.images) : [];
90
+ await Promise.all(images.map((img) => img.decode().catch(() => {})));
91
+ });
92
+ }
93
+
94
+ /**
95
+ * Render an HTML file at multiple sizes from a single navigation.
96
+ * The page is loaded (and localized) once; each variant re-applies
97
+ * viewport + CSS zoom and takes its own screenshot.
98
+ *
24
99
  * @param {string} projectDir
25
100
  * @param {string} htmlFile
26
- * @param {number} width
27
- * @param {number} height
28
101
  * @param {number} sourceW
29
102
  * @param {number} sourceH
30
- * @param {{ format?: "png"|"jpeg", quality?: number, background?: string, localization?: { strings: object, locale: string, direction: string } }} [options]
103
+ * @param {{ width: number, height: number, format?: "png"|"jpeg", quality?: number }[]} variants
104
+ * @param {{ background?: string, localization?: { strings: object, locale: string, direction: string }, timeout?: number }} [options]
105
+ * @returns {Promise<Buffer[]>} Screenshot buffers in variant order
31
106
  */
32
- export async function renderScreenshot(
107
+ export async function renderVariants(
33
108
  projectDir,
34
109
  htmlFile,
35
- width,
36
- height,
37
110
  sourceW,
38
111
  sourceH,
112
+ variants,
39
113
  options = {}
40
114
  ) {
41
- const b = await getBrowser();
42
- const page = await b.newPage();
115
+ const page = await acquirePage();
116
+ try {
117
+ const first = variants[0];
118
+ await page.setViewport({
119
+ width: first.width,
120
+ height: first.height,
121
+ deviceScaleFactor: 1,
122
+ });
123
+
124
+ const filePath = resolve(projectDir, htmlFile);
125
+ await page.goto(`file://${filePath}`, {
126
+ waitUntil: "load",
127
+ timeout: options.timeout || 30000,
128
+ });
43
129
 
44
- const scale = Math.max(width / sourceW, height / sourceH);
130
+ // Inject localized strings and set lang before rendering
131
+ if (options.localization) {
132
+ const { strings, locale, direction } = options.localization;
133
+ await page.evaluate((strings, locale, dir) => {
134
+ // Set lang on root for font selection; do NOT set dir on root
135
+ // because it flips the entire CSS layout (flexbox, margins, etc.)
136
+ // which breaks fixed-pixel marketing layouts.
137
+ document.documentElement.lang = locale;
45
138
 
46
- await page.setViewport({ width, height, deviceScaleFactor: 1 });
139
+ const formatter = new Intl.NumberFormat(locale);
47
140
 
48
- const filePath = resolve(projectDir, htmlFile);
49
- await page.goto(`file://${filePath}`, { waitUntil: "networkidle0" });
50
- await new Promise((r) => setTimeout(r, 500));
141
+ function processValue(value) {
142
+ return value.replace(/\{\{n:(\d+(?:\.\d+)?)\}\}/g, (_, num) => {
143
+ return formatter.format(parseFloat(num));
144
+ });
145
+ }
146
+
147
+ const walker = document.createTreeWalker(
148
+ document.documentElement,
149
+ NodeFilter.SHOW_TEXT
150
+ );
151
+ while (walker.nextNode()) {
152
+ const node = walker.currentNode;
153
+ let text = node.textContent;
154
+ let changed = false;
155
+ text = text.replace(/\{\{([a-zA-Z_][\w.]*)\}\}/g, (match, key) => {
156
+ if (strings[key] !== undefined) {
157
+ changed = true;
158
+ return processValue(strings[key]);
159
+ }
160
+ return match;
161
+ });
162
+ if (changed) {
163
+ node.textContent = text;
164
+ // Apply text direction on the parent element so RTL text
165
+ // renders correctly without flipping the overall page layout.
166
+ if (dir === "rtl") {
167
+ node.parentElement.dir = dir;
168
+ }
169
+ }
170
+ }
171
+ }, strings, locale, direction);
172
+ }
51
173
 
52
- // Inject localized strings and set lang/dir before rendering
53
- if (options.localization) {
54
- const { strings, locale, direction } = options.localization;
55
- await page.evaluate((strings, locale, dir) => {
56
- // Set lang on root for font selection; do NOT set dir on root
57
- // because it flips the entire CSS layout (flexbox, margins, etc.)
58
- // which breaks fixed-pixel marketing layouts.
59
- document.documentElement.lang = locale;
174
+ // Handle background: transparent by default for PNG, or use specified color
175
+ if (options.background) {
176
+ await page.evaluate((color) => {
177
+ document.documentElement.style.backgroundColor = color;
178
+ }, options.background);
179
+ }
60
180
 
61
- const formatter = new Intl.NumberFormat(locale);
181
+ await settle(page);
62
182
 
63
- function processValue(value) {
64
- return value.replace(/\{\{n:(\d+(?:\.\d+)?)\}\}/g, (_, num) => {
65
- return formatter.format(parseFloat(num));
66
- });
67
- }
183
+ const buffers = [];
184
+ for (const variant of variants) {
185
+ const { width, height } = variant;
186
+ await page.setViewport({ width, height, deviceScaleFactor: 1 });
68
187
 
69
- const walker = document.createTreeWalker(
70
- document.documentElement,
71
- NodeFilter.SHOW_TEXT
72
- );
73
- while (walker.nextNode()) {
74
- const node = walker.currentNode;
75
- let text = node.textContent;
76
- let changed = false;
77
- text = text.replace(/\{\{([a-zA-Z_][\w.]*)\}\}/g, (match, key) => {
78
- if (strings[key] !== undefined) {
79
- changed = true;
80
- return processValue(strings[key]);
81
- }
82
- return match;
83
- });
84
- if (changed) {
85
- node.textContent = text;
86
- // Apply text direction on the parent element so RTL text
87
- // renders correctly without flipping the overall page layout.
88
- if (dir === "rtl") {
89
- node.parentElement.dir = dir;
188
+ const scale = Math.max(width / sourceW, height / sourceH);
189
+ // Always assign zoom (clearing it at scale 1) so a previous variant's
190
+ // zoom can't leak into this one, and freeze animations at t=0 so the
191
+ // capture is deterministic. The screenshot's forced frame applies both.
192
+ await page.evaluate((zoom) => {
193
+ document.documentElement.style.zoom = zoom;
194
+ for (const anim of document.getAnimations()) {
195
+ try {
196
+ anim.pause();
197
+ anim.currentTime = 0;
198
+ } catch {
199
+ // Some animations (e.g. infinite transitions) can reject; skip.
90
200
  }
91
201
  }
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
- }
202
+ }, scale === 1 ? "" : String(scale));
103
203
 
104
- // Handle background: transparent by default for PNG, or use specified color
105
- const bg = options.background;
106
- if (bg) {
107
- await page.evaluate((color) => {
108
- document.documentElement.style.backgroundColor = color;
109
- }, bg);
110
- }
204
+ const format = variant.format || "png";
205
+ const screenshotOpts = {
206
+ type: format,
207
+ clip: { x: 0, y: 0, width, height },
208
+ };
209
+ if (format === "png" && !options.background) {
210
+ screenshotOpts.omitBackground = true;
211
+ }
212
+ if (format === "jpeg" && variant.quality) {
213
+ screenshotOpts.quality = variant.quality;
214
+ }
215
+ buffers.push(await page.screenshot(screenshotOpts));
216
+ }
111
217
 
112
- const format = options.format || "png";
113
- const screenshotOpts = {
114
- type: format,
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;
218
+ return buffers;
219
+ } finally {
220
+ releasePage(page);
122
221
  }
222
+ }
123
223
 
124
- const buffer = await page.screenshot(screenshotOpts);
125
-
126
- await page.close();
224
+ /**
225
+ * Render an HTML file to an image buffer at the given dimensions.
226
+ * Scales from sourceSize to target size via CSS zoom.
227
+ * Single-variant wrapper around renderVariants.
228
+ * @param {string} projectDir
229
+ * @param {string} htmlFile
230
+ * @param {number} width
231
+ * @param {number} height
232
+ * @param {number} sourceW
233
+ * @param {number} sourceH
234
+ * @param {{ format?: "png"|"jpeg", quality?: number, background?: string, localization?: { strings: object, locale: string, direction: string }, timeout?: number }} [options]
235
+ */
236
+ export async function renderScreenshot(
237
+ projectDir,
238
+ htmlFile,
239
+ width,
240
+ height,
241
+ sourceW,
242
+ sourceH,
243
+ options = {}
244
+ ) {
245
+ const [buffer] = await renderVariants(
246
+ projectDir,
247
+ htmlFile,
248
+ sourceW,
249
+ sourceH,
250
+ [{ width, height, format: options.format, quality: options.quality }],
251
+ options
252
+ );
127
253
  return buffer;
128
254
  }
129
255
 
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");
@@ -61,7 +62,7 @@ export async function startServer(projectDir, options = {}, renderer) {
61
62
  if (!renderer) {
62
63
  renderer = await import("./renderer.mjs");
63
64
  }
64
- const { renderScreenshot, runXcodeOutput, closeBrowser } = renderer;
65
+ const { renderScreenshot, runXcodeOutput, closeBrowser, warmupBrowser } = renderer;
65
66
 
66
67
  const port = options.port || 3200;
67
68
  const host = options.host || "localhost";
@@ -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,12 @@ 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, {
163
+ background,
164
+ localization,
165
+ timeout: options.renderTimeout,
166
+ });
162
167
  res.writeHead(200, {
163
168
  "Content-Type": "image/png",
164
169
  "Content-Length": buffer.length,
@@ -172,6 +177,32 @@ export async function startServer(projectDir, options = {}, renderer) {
172
177
  return;
173
178
  }
174
179
 
180
+ // API: get localization data for a collection
181
+ if (req.method === "GET" && pathname === "/api/localizations") {
182
+ try {
183
+ const colId = url.searchParams.get("collection");
184
+ if (!colId) {
185
+ res.writeHead(400, { "Content-Type": "application/json" });
186
+ return res.end(JSON.stringify({ ok: false, error: "Missing collection param" }));
187
+ }
188
+ const col = manifest.collections.find((c) => c.id === colId);
189
+ if (!col || !col.localizations) {
190
+ res.writeHead(404, { "Content-Type": "application/json" });
191
+ return res.end(JSON.stringify({ ok: false, error: "Collection has no localizations" }));
192
+ }
193
+ const data = loadLocalizations(projectDir, col.localizations);
194
+ const locales = getLocales(data, col.locales);
195
+ const defaultLocale = manifest.defaultLocalization || data.sourceLanguage || "en";
196
+ res.writeHead(200, { "Content-Type": "application/json" });
197
+ res.end(JSON.stringify({ ok: true, data, locales, defaultLocale }));
198
+ } catch (err) {
199
+ console.error(err);
200
+ res.writeHead(500, { "Content-Type": "application/json" });
201
+ res.end(JSON.stringify({ ok: false, error: err.message }));
202
+ }
203
+ return;
204
+ }
205
+
175
206
  // API: run xcode output for a collection
176
207
  if (req.method === "POST" && pathname === "/api/run-output") {
177
208
  try {
@@ -288,27 +319,31 @@ export async function startServer(projectDir, options = {}, renderer) {
288
319
  tryListen();
289
320
  });
290
321
 
291
- return { port: actualPort };
292
-
293
322
  function cleanup() {
294
323
  for (const w of watchers) w.close();
295
324
  for (const client of sseClients) client.end();
296
325
  sseClients.clear();
297
326
  }
298
327
 
299
- process.on("SIGINT", async () => {
328
+ const shutdown = async () => {
300
329
  cleanup();
301
330
  await closeBrowser();
302
331
  server.close();
303
332
  process.exit(0);
304
- });
333
+ };
305
334
 
306
- process.on("SIGTERM", async () => {
307
- cleanup();
308
- await closeBrowser();
309
- server.close();
310
- process.exit(0);
335
+ process.once("SIGINT", shutdown);
336
+ process.once("SIGTERM", shutdown);
337
+ server.on("close", () => {
338
+ process.removeListener("SIGINT", shutdown);
339
+ process.removeListener("SIGTERM", shutdown);
311
340
  });
312
341
 
313
- return server;
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 };
314
349
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@open-assets/open-assets",
3
- "version": "0.3.0",
4
- "description": "Dev server and export tool for app screenshots, icons, and logos. Like Storybook for marketing assets.",
3
+ "version": "0.5.0",
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/.bin/jest",
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