@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/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
|
-
|
|
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
|
+
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
*
|
|
23
|
-
*
|
|
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
|
|
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
|
|
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
|
|
42
|
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
+
const formatter = new Intl.NumberFormat(locale);
|
|
47
140
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
181
|
+
await settle(page);
|
|
62
182
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
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;
|
|
218
|
+
return buffers;
|
|
219
|
+
} finally {
|
|
220
|
+
releasePage(page);
|
|
122
221
|
}
|
|
222
|
+
}
|
|
123
223
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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, {
|
|
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
|
-
|
|
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.
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
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);
|
|
311
340
|
});
|
|
312
341
|
|
|
313
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
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
|
|
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
|