embedded-react 0.2.3 → 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/aot/compile.mjs +2407 -697
- package/aot/screenshot-smoke.mjs +34 -17
- package/aot/style-map.mjs +156 -80
- package/assets/bake-font.mjs +45 -21
- package/assets/bake-image.mjs +7 -5
- package/assets/bake-svg.mjs +563 -0
- package/assets/build-builtin-font.mjs +25 -12
- package/assets/emit-c.mjs +52 -20
- package/assets/emit-container.mjs +5 -3
- package/assets/emit-pack.mjs +8 -2
- package/assets/index.mjs +25 -16
- package/assets/rasterize.mjs +45 -11
- package/assets/svg-loader.mjs +81 -0
- package/build.mjs +43 -20
- package/cli.mjs +258 -20
- package/pack-container.mjs +84 -35
- package/package.json +9 -3
- package/persist-transform.mjs +23 -9
- package/qjsc-wasm.mjs +83 -0
- package/sim/embedded-react.cjs +2 -0
- package/sim/embedded-react.js +1 -1
- package/sim/embedded-react.wasm +0 -0
- package/sim-server.mjs +160 -48
- package/src/embedded-react/Animated.js +51 -36
- package/src/embedded-react/AppRegistry.js +4 -4
- package/src/embedded-react/Easing.js +1 -1
- package/src/embedded-react/LayoutAnimation.js +13 -6
- package/src/embedded-react/StyleSheet.js +1 -1
- package/src/embedded-react/imperative.js +19 -7
- package/src/embedded-react/index.js +8 -8
- package/src/embedded-react/layout-anim-config.js +13 -9
- package/src/embedded-react/split-style.js +6 -6
- package/src/embedded-react/svg-ops.js +369 -41
- package/src/embedded-react/usePersistentState.js +3 -3
- package/src/host-config.js +137 -18
- package/src/native-ui.js +3 -1
- package/src/props.js +22 -10
- package/src/renderer.js +3 -3
package/cli.mjs
CHANGED
|
@@ -23,13 +23,26 @@
|
|
|
23
23
|
// embedded-react.wasm host page, and hot-reloads on save (useState preserved). No native toolchain — the
|
|
24
24
|
// .wasm ships prebuilt in this package. See tools/web-sim/README.md.
|
|
25
25
|
|
|
26
|
-
import {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
import {
|
|
27
|
+
copyFileSync,
|
|
28
|
+
existsSync,
|
|
29
|
+
mkdirSync,
|
|
30
|
+
readFileSync,
|
|
31
|
+
writeFileSync,
|
|
32
|
+
} from 'node:fs';
|
|
33
|
+
import {createRequire} from 'node:module';
|
|
34
|
+
import {fileURLToPath, pathToFileURL} from 'node:url';
|
|
35
|
+
import {basename, dirname, relative, resolve} from 'node:path';
|
|
36
|
+
import {tmpdir} from 'node:os';
|
|
37
|
+
import {buildApp, runDevServer} from './sim-server.mjs';
|
|
31
38
|
|
|
32
39
|
const PKG_ROOT = dirname(fileURLToPath(import.meta.url));
|
|
40
|
+
const require = createRequire(import.meta.url);
|
|
41
|
+
|
|
42
|
+
// QuickJS release the bytecode container targets — MUST match bridges/quickjs/CMakeLists.txt and
|
|
43
|
+
// ER_QUICKJS_TAG in er_runtime.c (the device loader rejects a mismatch). The wasm that compiles the
|
|
44
|
+
// bytecode embeds this same QuickJS.
|
|
45
|
+
const QJS_TAG = 'v0.15.0';
|
|
33
46
|
|
|
34
47
|
function usage() {
|
|
35
48
|
console.log(`embedded-react — React Native for embedded MCUs
|
|
@@ -37,8 +50,14 @@ function usage() {
|
|
|
37
50
|
Usage:
|
|
38
51
|
embedded-react dev [entry] [--port <n>] Run the WASM simulator with hot reload
|
|
39
52
|
embedded-react export [entry] [--out <dir>] Build a self-contained static playground (no server)
|
|
53
|
+
embedded-react build [entry] [--out <dir>] Build the device artifact:
|
|
54
|
+
default → app.erpkg (Flow A: QuickJS bytecode + assets,
|
|
55
|
+
upload to the device's config region)
|
|
56
|
+
--aot → app.gen.c/.h + assets.generated.c (Flow B:
|
|
57
|
+
no QuickJS, compiled into firmware)
|
|
40
58
|
|
|
41
|
-
entry
|
|
59
|
+
entry dev/export/build use ./index.jsx, ./src/index.jsx, or package.json "main";
|
|
60
|
+
build --aot uses ./App.jsx or ./src/App.jsx. Pass one to override.
|
|
42
61
|
`);
|
|
43
62
|
}
|
|
44
63
|
|
|
@@ -46,15 +65,22 @@ Usage:
|
|
|
46
65
|
function simDirOrExit() {
|
|
47
66
|
const simDir = resolve(PKG_ROOT, 'sim');
|
|
48
67
|
if (!existsSync(resolve(simDir, 'embedded-react.wasm'))) {
|
|
49
|
-
console.error(
|
|
50
|
-
|
|
68
|
+
console.error(
|
|
69
|
+
'The prebuilt simulator (sim/embedded-react.wasm) is missing from this install.',
|
|
70
|
+
);
|
|
71
|
+
console.error(
|
|
72
|
+
'A published embedded-react package ships it; building from source needs the Emscripten SDK.',
|
|
73
|
+
);
|
|
51
74
|
process.exit(1);
|
|
52
75
|
}
|
|
53
76
|
return simDir;
|
|
54
77
|
}
|
|
55
78
|
|
|
56
79
|
const libSrc = () => resolve(PKG_ROOT, 'src/embedded-react/index.js');
|
|
57
|
-
const nodePaths =
|
|
80
|
+
const nodePaths = cwd => [
|
|
81
|
+
resolve(PKG_ROOT, 'node_modules'),
|
|
82
|
+
resolve(cwd, 'node_modules'),
|
|
83
|
+
];
|
|
58
84
|
|
|
59
85
|
/** Resolve the app entry: an explicit arg, else common conventions, else package.json "main"/"source". */
|
|
60
86
|
function resolveEntry(cwd, explicit) {
|
|
@@ -66,7 +92,14 @@ function resolveEntry(cwd, explicit) {
|
|
|
66
92
|
}
|
|
67
93
|
return p;
|
|
68
94
|
}
|
|
69
|
-
for (const rel of [
|
|
95
|
+
for (const rel of [
|
|
96
|
+
'index.jsx',
|
|
97
|
+
'src/index.jsx',
|
|
98
|
+
'index.tsx',
|
|
99
|
+
'src/index.tsx',
|
|
100
|
+
'App.jsx',
|
|
101
|
+
'src/App.jsx',
|
|
102
|
+
]) {
|
|
70
103
|
const p = resolve(cwd, rel);
|
|
71
104
|
if (existsSync(p)) return p;
|
|
72
105
|
}
|
|
@@ -84,8 +117,12 @@ function resolveEntry(cwd, explicit) {
|
|
|
84
117
|
/* ignore */
|
|
85
118
|
}
|
|
86
119
|
}
|
|
87
|
-
console.error(
|
|
88
|
-
|
|
120
|
+
console.error(
|
|
121
|
+
'No app entry found. Pass one explicitly: embedded-react dev <entry.jsx>',
|
|
122
|
+
);
|
|
123
|
+
console.error(
|
|
124
|
+
'(looked for ./index.jsx, ./src/index.jsx, … and package.json "main")',
|
|
125
|
+
);
|
|
89
126
|
process.exit(1);
|
|
90
127
|
}
|
|
91
128
|
|
|
@@ -93,12 +130,14 @@ async function dev(args) {
|
|
|
93
130
|
const cwd = process.cwd();
|
|
94
131
|
const portIdx = args.indexOf('--port');
|
|
95
132
|
const port = portIdx >= 0 ? parseInt(args[portIdx + 1], 10) : 3333;
|
|
96
|
-
const explicit = args.find(
|
|
133
|
+
const explicit = args.find(
|
|
134
|
+
(a, i) => !a.startsWith('--') && (portIdx < 0 || i !== portIdx + 1),
|
|
135
|
+
);
|
|
97
136
|
|
|
98
137
|
const simDir = simDirOrExit();
|
|
99
138
|
const entry = resolveEntry(cwd, explicit);
|
|
100
139
|
const outDir = resolve(tmpdir(), 'embedded-react-sim');
|
|
101
|
-
mkdirSync(outDir, {
|
|
140
|
+
mkdirSync(outDir, {recursive: true});
|
|
102
141
|
|
|
103
142
|
await runDevServer({
|
|
104
143
|
entry,
|
|
@@ -117,20 +156,217 @@ async function exportApp(args) {
|
|
|
117
156
|
const cwd = process.cwd();
|
|
118
157
|
const outIdx = args.indexOf('--out');
|
|
119
158
|
const outDir = resolve(cwd, outIdx >= 0 ? args[outIdx + 1] : 'sim-export');
|
|
120
|
-
const explicit = args.find(
|
|
159
|
+
const explicit = args.find(
|
|
160
|
+
(a, i) => !a.startsWith('--') && (outIdx < 0 || i !== outIdx + 1),
|
|
161
|
+
);
|
|
121
162
|
|
|
122
163
|
const simDir = simDirOrExit();
|
|
123
164
|
const entry = resolveEntry(cwd, explicit);
|
|
124
165
|
const pub = resolve(outDir, 'public');
|
|
125
|
-
mkdirSync(pub, {
|
|
166
|
+
mkdirSync(pub, {recursive: true});
|
|
126
167
|
|
|
127
168
|
// Bundle the app + bake assets into public/, then drop the prebuilt module + host page alongside.
|
|
128
|
-
await buildApp({
|
|
129
|
-
|
|
169
|
+
await buildApp({
|
|
170
|
+
entry,
|
|
171
|
+
projectRoot: cwd,
|
|
172
|
+
libSrc: libSrc(),
|
|
173
|
+
nodePaths: nodePaths(cwd),
|
|
174
|
+
outDir: pub,
|
|
175
|
+
});
|
|
176
|
+
for (const f of ['embedded-react.js', 'embedded-react.wasm'])
|
|
177
|
+
copyFileSync(resolve(simDir, f), resolve(pub, f));
|
|
130
178
|
copyFileSync(resolve(simDir, 'index.html'), resolve(outDir, 'index.html'));
|
|
131
179
|
|
|
132
|
-
console.log(
|
|
133
|
-
|
|
180
|
+
console.log(
|
|
181
|
+
`✓ exported a static playground → ${relative(cwd, outDir) || '.'}/`,
|
|
182
|
+
);
|
|
183
|
+
console.log(
|
|
184
|
+
` serve it over http (e.g. \`npx serve ${relative(cwd, outDir) || '.'}\`) or deploy the folder to any static host.`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Resolve the root component file for the AOT compiler (App.jsx), distinct from the registry entry. */
|
|
189
|
+
function resolveAppComponent(cwd, explicit) {
|
|
190
|
+
if (explicit) {
|
|
191
|
+
const p = resolve(cwd, explicit);
|
|
192
|
+
if (!existsSync(p)) {
|
|
193
|
+
console.error(`Entry not found: ${p}`);
|
|
194
|
+
process.exit(1);
|
|
195
|
+
}
|
|
196
|
+
return p;
|
|
197
|
+
}
|
|
198
|
+
for (const rel of ['App.jsx', 'src/App.jsx', 'App.tsx', 'src/App.tsx']) {
|
|
199
|
+
const p = resolve(cwd, rel);
|
|
200
|
+
if (existsSync(p)) return p;
|
|
201
|
+
}
|
|
202
|
+
console.error(
|
|
203
|
+
'No App component found. Pass one explicitly: embedded-react build --aot <App.jsx>',
|
|
204
|
+
);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Flow B (--aot): compile the App component to C (app.gen.{c,h}) + bake assets, to compile into firmware. */
|
|
209
|
+
async function buildAot(cwd, explicit, outDir) {
|
|
210
|
+
const {compileSource, bakeSvgArtifacts} = await import('./aot/compile.mjs');
|
|
211
|
+
const {bakeAssets} = await import('./assets/index.mjs');
|
|
212
|
+
const appPath = resolveAppComponent(cwd, explicit);
|
|
213
|
+
const appDir = dirname(appPath);
|
|
214
|
+
const src = readFileSync(appPath, 'utf8');
|
|
215
|
+
let result;
|
|
216
|
+
try {
|
|
217
|
+
// Bake <Svg source> .svg imports → vector artifacts (incl. gradients), then compile with them in hand.
|
|
218
|
+
const svgArtifacts = await bakeSvgArtifacts(src, appDir);
|
|
219
|
+
result = compileSource(src, 'app', {filename: appPath, svgArtifacts});
|
|
220
|
+
} catch (e) {
|
|
221
|
+
console.error(e && e.aotLoc ? e.message : e?.message || String(e));
|
|
222
|
+
process.exit(1);
|
|
223
|
+
}
|
|
224
|
+
writeFileSync(resolve(outDir, 'app.gen.c'), result.c);
|
|
225
|
+
writeFileSync(resolve(outDir, 'app.gen.h'), result.h);
|
|
226
|
+
|
|
227
|
+
const imageJobs = result.images.map(im => ({
|
|
228
|
+
name: im.name,
|
|
229
|
+
path: resolve(appDir, im.importPath),
|
|
230
|
+
}));
|
|
231
|
+
for (const j of imageJobs) {
|
|
232
|
+
if (!existsSync(j.path)) {
|
|
233
|
+
console.error(`<Image> asset "${j.name}" not found at ${j.path}`);
|
|
234
|
+
process.exit(1);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
const baked = bakeAssets({images: imageJobs, fonts: [], outDir});
|
|
238
|
+
console.log(
|
|
239
|
+
`✓ Flow B (AOT) → ${relative(cwd, outDir) || '.'}/app.gen.c (+ app.gen.h, assets.generated.c — ${baked.images} image(s))`,
|
|
240
|
+
);
|
|
241
|
+
console.log(
|
|
242
|
+
' No QuickJS on the device: compile these into your firmware against the engine (er_scene.h).',
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Flow A (default): bundle → QuickJS bytecode (via the prebuilt wasm) + baked assets → app.erpkg. */
|
|
247
|
+
async function buildContainer(cwd, explicit, outDir) {
|
|
248
|
+
const esbuild = require('esbuild');
|
|
249
|
+
const {bakeImage} = await import('./assets/bake-image.mjs');
|
|
250
|
+
const {bakeFont} = await import('./assets/bake-font.mjs');
|
|
251
|
+
const {emitAssetPack} = await import('./assets/emit-pack.mjs');
|
|
252
|
+
const {emitContainer} = await import('./assets/emit-container.mjs');
|
|
253
|
+
const {registerSvgVectorLoader} = await import('./assets/svg-loader.mjs');
|
|
254
|
+
const {compileToBytecode} = await import('./qjsc-wasm.mjs');
|
|
255
|
+
|
|
256
|
+
const simDir = simDirOrExit();
|
|
257
|
+
const entry = resolveEntry(cwd, explicit);
|
|
258
|
+
|
|
259
|
+
// Bundle the app (IIFE) with import-driven asset discovery (same as the dev/pack paths).
|
|
260
|
+
const images = new Map();
|
|
261
|
+
const fonts = new Map();
|
|
262
|
+
const assetPlugin = {
|
|
263
|
+
name: 'embedded-react-assets',
|
|
264
|
+
setup(b) {
|
|
265
|
+
b.onLoad({filter: /\.(png|jpe?g|webp|gif|bmp)$/i}, a => {
|
|
266
|
+
const n = basename(a.path).replace(/\.[^.]+$/, '');
|
|
267
|
+
images.set(n, a.path);
|
|
268
|
+
return {
|
|
269
|
+
contents: `module.exports = ${JSON.stringify(n)};`,
|
|
270
|
+
loader: 'js',
|
|
271
|
+
};
|
|
272
|
+
});
|
|
273
|
+
b.onLoad({filter: /\.(ttf|otf)$/i}, a => {
|
|
274
|
+
const f = basename(a.path).replace(/\.[^.]+$/, '');
|
|
275
|
+
fonts.set(f, a.path);
|
|
276
|
+
return {
|
|
277
|
+
contents: `module.exports = ${JSON.stringify(f)};`,
|
|
278
|
+
loader: 'js',
|
|
279
|
+
};
|
|
280
|
+
});
|
|
281
|
+
registerSvgVectorLoader(b, (name, p) => images.set(name, p)); // raster-fallback SVGs join the image pack
|
|
282
|
+
},
|
|
283
|
+
};
|
|
284
|
+
// The bundle is an intermediate (it becomes bytecode in the .erpkg) — keep it out of the user's outDir
|
|
285
|
+
// so `dist/` ends up holding only app.erpkg.
|
|
286
|
+
const tmp = resolve(tmpdir(), 'embedded-react-build');
|
|
287
|
+
mkdirSync(tmp, {recursive: true});
|
|
288
|
+
const bundlePath = resolve(tmp, 'app.bundle.js');
|
|
289
|
+
await esbuild.build({
|
|
290
|
+
entryPoints: [entry],
|
|
291
|
+
bundle: true,
|
|
292
|
+
format: 'iife',
|
|
293
|
+
outfile: bundlePath,
|
|
294
|
+
platform: 'neutral',
|
|
295
|
+
target: 'es2020',
|
|
296
|
+
jsx: 'automatic',
|
|
297
|
+
alias: {'embedded-react': libSrc()},
|
|
298
|
+
nodePaths: nodePaths(cwd),
|
|
299
|
+
plugins: [assetPlugin],
|
|
300
|
+
define: {'process.env.NODE_ENV': '"production"'},
|
|
301
|
+
legalComments: 'none',
|
|
302
|
+
logLevel: 'silent',
|
|
303
|
+
});
|
|
304
|
+
const bundleSrc = readFileSync(bundlePath, 'utf8');
|
|
305
|
+
|
|
306
|
+
// Precompile to QuickJS bytecode through the prebuilt sim wasm — no native toolchain.
|
|
307
|
+
const bytecode = await compileToBytecode(bundleSrc, simDir);
|
|
308
|
+
|
|
309
|
+
// Bake imported images/fonts into an ERPK pack (font sizes discovered from the bundle).
|
|
310
|
+
const discoveredSizes = [
|
|
311
|
+
...new Set(
|
|
312
|
+
[...bundleSrc.matchAll(/\bfontSize\s*:\s*(\d+(?:\.\d+)?)/g)].map(m =>
|
|
313
|
+
Math.round(Number(m[1])),
|
|
314
|
+
),
|
|
315
|
+
),
|
|
316
|
+
].sort((a, b) => a - b);
|
|
317
|
+
let cfg = {};
|
|
318
|
+
const cp = resolve(cwd, 'assets.config.js');
|
|
319
|
+
if (existsSync(cp))
|
|
320
|
+
cfg = (await import(pathToFileURL(cp).href)).default || {};
|
|
321
|
+
const fontConfig = cfg.fonts || {};
|
|
322
|
+
const fontJobs = [...fonts.entries()].map(([family, path]) => {
|
|
323
|
+
const fc = fontConfig[family] || {};
|
|
324
|
+
return {
|
|
325
|
+
path,
|
|
326
|
+
family,
|
|
327
|
+
sizes: fc.sizes?.length
|
|
328
|
+
? fc.sizes
|
|
329
|
+
: discoveredSizes.length
|
|
330
|
+
? discoveredSizes
|
|
331
|
+
: [16],
|
|
332
|
+
bpp: fc.bpp ?? 4,
|
|
333
|
+
glyphs: fc.glyphs ?? 'ascii',
|
|
334
|
+
};
|
|
335
|
+
});
|
|
336
|
+
const imageJobs = [...images.entries()].map(([name, path]) => ({path, name}));
|
|
337
|
+
const bakedImages = imageJobs.map(bakeImage);
|
|
338
|
+
const bakedFonts = fontJobs.map(bakeFont);
|
|
339
|
+
const assetPack =
|
|
340
|
+
bakedImages.length || bakedFonts.length
|
|
341
|
+
? emitAssetPack({images: bakedImages, fonts: bakedFonts})
|
|
342
|
+
: null;
|
|
343
|
+
|
|
344
|
+
const container = emitContainer({bytecode, assetPack, qjsTag: QJS_TAG});
|
|
345
|
+
const outPath = resolve(outDir, 'app.erpkg');
|
|
346
|
+
writeFileSync(outPath, container);
|
|
347
|
+
const kb = n => `${(n / 1024).toFixed(1)} KB`;
|
|
348
|
+
console.log(
|
|
349
|
+
`✓ Flow A → ${relative(cwd, outPath) || 'app.erpkg'} (${kb(container.length)}; qjs ${QJS_TAG}, bytecode ${kb(bytecode.length)}` +
|
|
350
|
+
(assetPack ? `, assets ${kb(assetPack.length)}` : '') +
|
|
351
|
+
')',
|
|
352
|
+
);
|
|
353
|
+
console.log(
|
|
354
|
+
" Upload app.erpkg to your device's config region (er_runtime_load_container), or run it on the desktop host.",
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** `embedded-react build [--aot] [entry] [--out dir]` — produce the device artifact. */
|
|
359
|
+
async function build(args) {
|
|
360
|
+
const cwd = process.cwd();
|
|
361
|
+
const aot = args.includes('--aot');
|
|
362
|
+
const outIdx = args.indexOf('--out');
|
|
363
|
+
const outDir = resolve(cwd, outIdx >= 0 ? args[outIdx + 1] : 'dist');
|
|
364
|
+
const explicit = args.find(
|
|
365
|
+
(a, i) => !a.startsWith('--') && (outIdx < 0 || i !== outIdx + 1),
|
|
366
|
+
);
|
|
367
|
+
mkdirSync(outDir, {recursive: true});
|
|
368
|
+
if (aot) await buildAot(cwd, explicit, outDir);
|
|
369
|
+
else await buildContainer(cwd, explicit, outDir);
|
|
134
370
|
}
|
|
135
371
|
|
|
136
372
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
@@ -138,6 +374,8 @@ if (cmd === 'dev') {
|
|
|
138
374
|
await dev(rest);
|
|
139
375
|
} else if (cmd === 'export') {
|
|
140
376
|
await exportApp(rest);
|
|
377
|
+
} else if (cmd === 'build') {
|
|
378
|
+
await build(rest);
|
|
141
379
|
} else if (!cmd || cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
142
380
|
usage();
|
|
143
381
|
} else {
|
package/pack-container.mjs
CHANGED
|
@@ -28,15 +28,22 @@
|
|
|
28
28
|
//
|
|
29
29
|
// Prereq: the bytecode precompiler (er-bridge-quickjs-compile) must be built. This script searches the
|
|
30
30
|
// usual build dirs and tells you how to build it if it's missing (override with ER_COMPILE_BIN).
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
33
|
-
import {
|
|
34
|
-
import {
|
|
35
|
-
import {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
31
|
+
import {build} from 'esbuild';
|
|
32
|
+
import {spawnSync} from 'node:child_process';
|
|
33
|
+
import {fileURLToPath, pathToFileURL} from 'node:url';
|
|
34
|
+
import {dirname, resolve, basename} from 'node:path';
|
|
35
|
+
import {
|
|
36
|
+
existsSync,
|
|
37
|
+
readdirSync,
|
|
38
|
+
readFileSync,
|
|
39
|
+
writeFileSync,
|
|
40
|
+
mkdirSync,
|
|
41
|
+
} from 'node:fs';
|
|
42
|
+
import {bakeImage} from './assets/bake-image.mjs';
|
|
43
|
+
import {bakeFont} from './assets/bake-font.mjs';
|
|
44
|
+
import {emitAssetPack} from './assets/emit-pack.mjs';
|
|
45
|
+
import {emitContainer} from './assets/emit-container.mjs';
|
|
46
|
+
import {registerSvgVectorLoader} from './assets/svg-loader.mjs';
|
|
40
47
|
|
|
41
48
|
// QuickJS release the bytecode targets — MUST match the FetchContent pin in
|
|
42
49
|
// bridges/quickjs/CMakeLists.txt and ER_QUICKJS_TAG in er_runtime.c. The loader rejects a mismatch.
|
|
@@ -54,7 +61,9 @@ const demoDir = resolve(demosDir, demo);
|
|
|
54
61
|
const entry = resolve(demoDir, 'index.jsx');
|
|
55
62
|
if (!existsSync(entry)) {
|
|
56
63
|
const available = existsSync(demosDir)
|
|
57
|
-
? readdirSync(demosDir, {
|
|
64
|
+
? readdirSync(demosDir, {withFileTypes: true})
|
|
65
|
+
.filter(d => d.isDirectory())
|
|
66
|
+
.map(d => d.name)
|
|
58
67
|
: [];
|
|
59
68
|
console.error(`Demo "${demo}" not found (expected ${entry}).`);
|
|
60
69
|
console.error(`Available demos: ${available.join(', ') || '(none)'}`);
|
|
@@ -66,14 +75,32 @@ const exe = process.platform === 'win32' ? '.exe' : '';
|
|
|
66
75
|
const compileBin =
|
|
67
76
|
process.env.ER_COMPILE_BIN ||
|
|
68
77
|
[
|
|
69
|
-
resolve(
|
|
70
|
-
|
|
71
|
-
|
|
78
|
+
resolve(
|
|
79
|
+
repoRoot,
|
|
80
|
+
'bridges/quickjs/build',
|
|
81
|
+
`er-bridge-quickjs-compile${exe}`,
|
|
82
|
+
),
|
|
83
|
+
resolve(
|
|
84
|
+
repoRoot,
|
|
85
|
+
'examples/linux/build/bridges/quickjs',
|
|
86
|
+
`er-bridge-quickjs-compile${exe}`,
|
|
87
|
+
),
|
|
88
|
+
resolve(
|
|
89
|
+
repoRoot,
|
|
90
|
+
'tools/simulator/build/bridges/quickjs',
|
|
91
|
+
`er-bridge-quickjs-compile${exe}`,
|
|
92
|
+
),
|
|
72
93
|
].find(existsSync);
|
|
73
94
|
if (!compileBin) {
|
|
74
|
-
console.error(
|
|
75
|
-
|
|
76
|
-
|
|
95
|
+
console.error(
|
|
96
|
+
'Bytecode precompiler (er-bridge-quickjs-compile) not found. Build it once:',
|
|
97
|
+
);
|
|
98
|
+
console.error(
|
|
99
|
+
' cmake -S bridges/quickjs -B bridges/quickjs/build -G "MinGW Makefiles"',
|
|
100
|
+
);
|
|
101
|
+
console.error(
|
|
102
|
+
' cmake --build bridges/quickjs/build --target er-bridge-quickjs-compile',
|
|
103
|
+
);
|
|
77
104
|
console.error('(or set ER_COMPILE_BIN to the binary path)');
|
|
78
105
|
process.exit(1);
|
|
79
106
|
}
|
|
@@ -84,20 +111,27 @@ const fonts = new Map(); // family -> path
|
|
|
84
111
|
const assetPlugin = {
|
|
85
112
|
name: 'embedded-react-assets',
|
|
86
113
|
setup(b) {
|
|
87
|
-
b.onLoad({
|
|
114
|
+
b.onLoad({filter: /\.(png|jpe?g|webp|gif|bmp)$/i}, args => {
|
|
88
115
|
const name = basename(args.path).replace(/\.[^.]+$/, '');
|
|
89
116
|
images.set(name, args.path);
|
|
90
|
-
return {
|
|
117
|
+
return {
|
|
118
|
+
contents: `module.exports = ${JSON.stringify(name)};`,
|
|
119
|
+
loader: 'js',
|
|
120
|
+
};
|
|
91
121
|
});
|
|
92
|
-
b.onLoad({
|
|
122
|
+
b.onLoad({filter: /\.(ttf|otf)$/i}, args => {
|
|
93
123
|
const family = basename(args.path).replace(/\.[^.]+$/, '');
|
|
94
124
|
fonts.set(family, args.path);
|
|
95
|
-
return {
|
|
125
|
+
return {
|
|
126
|
+
contents: `module.exports = ${JSON.stringify(family)};`,
|
|
127
|
+
loader: 'js',
|
|
128
|
+
};
|
|
96
129
|
});
|
|
130
|
+
registerSvgVectorLoader(b, (name, p) => images.set(name, p)); // raster-fallback SVGs join the image pack
|
|
97
131
|
},
|
|
98
132
|
};
|
|
99
133
|
|
|
100
|
-
mkdirSync(distDir, {
|
|
134
|
+
mkdirSync(distDir, {recursive: true});
|
|
101
135
|
const bundlePath = resolve(distDir, 'app.bundle.js');
|
|
102
136
|
await build({
|
|
103
137
|
entryPoints: [entry],
|
|
@@ -107,10 +141,10 @@ await build({
|
|
|
107
141
|
platform: 'neutral',
|
|
108
142
|
target: 'es2020',
|
|
109
143
|
jsx: 'automatic',
|
|
110
|
-
alias: {
|
|
144
|
+
alias: {'embedded-react': libEntry},
|
|
111
145
|
nodePaths: [nodeModules],
|
|
112
146
|
plugins: [assetPlugin],
|
|
113
|
-
define: {
|
|
147
|
+
define: {'process.env.NODE_ENV': '"production"'},
|
|
114
148
|
legalComments: 'none',
|
|
115
149
|
logLevel: 'info',
|
|
116
150
|
});
|
|
@@ -118,7 +152,7 @@ console.log(`Bundled demo "${demo}" -> dist/app.bundle.js`);
|
|
|
118
152
|
|
|
119
153
|
// --- Precompile to QuickJS bytecode -------------------------------------------------------------
|
|
120
154
|
const qbcPath = resolve(distDir, 'app.bundle.qbc');
|
|
121
|
-
const cc = spawnSync(compileBin, [bundlePath, qbcPath], {
|
|
155
|
+
const cc = spawnSync(compileBin, [bundlePath, qbcPath], {stdio: 'inherit'});
|
|
122
156
|
if (cc.status !== 0) {
|
|
123
157
|
console.error('Bytecode compile failed.');
|
|
124
158
|
process.exit(1);
|
|
@@ -128,34 +162,49 @@ const bytecode = readFileSync(qbcPath);
|
|
|
128
162
|
// --- Bake imported assets into an ERPK pack (same sizing rules as build.mjs / sim.mjs) ----------
|
|
129
163
|
const bundleSrc = readFileSync(bundlePath, 'utf8');
|
|
130
164
|
const discoveredSizes = [
|
|
131
|
-
...new Set(
|
|
165
|
+
...new Set(
|
|
166
|
+
[...bundleSrc.matchAll(/\bfontSize\s*:\s*(\d+(?:\.\d+)?)/g)].map(m =>
|
|
167
|
+
Math.round(Number(m[1])),
|
|
168
|
+
),
|
|
169
|
+
),
|
|
132
170
|
].sort((a, b) => a - b);
|
|
133
171
|
|
|
134
172
|
let config = {};
|
|
135
173
|
const configPath = resolve(demoDir, 'assets.config.js');
|
|
136
|
-
if (existsSync(configPath))
|
|
174
|
+
if (existsSync(configPath))
|
|
175
|
+
config = (await import(pathToFileURL(configPath).href)).default || {};
|
|
137
176
|
const fontConfig = config.fonts || {};
|
|
138
177
|
|
|
139
178
|
const fontJobs = [...fonts.entries()].map(([family, path]) => {
|
|
140
179
|
const fc = fontConfig[family] || {};
|
|
141
|
-
const sizes =
|
|
142
|
-
|
|
180
|
+
const sizes =
|
|
181
|
+
fc.sizes && fc.sizes.length
|
|
182
|
+
? fc.sizes
|
|
183
|
+
: discoveredSizes.length
|
|
184
|
+
? discoveredSizes
|
|
185
|
+
: [16];
|
|
186
|
+
return {path, family, sizes, bpp: fc.bpp ?? 4, glyphs: fc.glyphs ?? 'ascii'};
|
|
143
187
|
});
|
|
144
|
-
const imageJobs = [...images.entries()].map(([name, path]) => ({
|
|
188
|
+
const imageJobs = [...images.entries()].map(([name, path]) => ({path, name}));
|
|
145
189
|
|
|
146
|
-
const bakedImages = imageJobs.map(
|
|
147
|
-
const bakedFonts = fontJobs.map(
|
|
148
|
-
const assetPack =
|
|
190
|
+
const bakedImages = imageJobs.map(i => bakeImage(i));
|
|
191
|
+
const bakedFonts = fontJobs.map(f => bakeFont(f));
|
|
192
|
+
const assetPack =
|
|
193
|
+
bakedImages.length || bakedFonts.length
|
|
194
|
+
? emitAssetPack({images: bakedImages, fonts: bakedFonts})
|
|
195
|
+
: null;
|
|
149
196
|
const fontSizeCount = bakedFonts.reduce((n, f) => n + f.sizes.length, 0);
|
|
150
197
|
|
|
151
198
|
// --- Wrap into the ERCF container ---------------------------------------------------------------
|
|
152
|
-
const container = emitContainer({
|
|
199
|
+
const container = emitContainer({bytecode, assetPack, qjsTag: QJS_TAG});
|
|
153
200
|
const outPath = resolve(distDir, 'app.erpkg');
|
|
154
201
|
writeFileSync(outPath, container);
|
|
155
202
|
|
|
156
|
-
const kb =
|
|
203
|
+
const kb = n => `${(n / 1024).toFixed(1)} KB`;
|
|
157
204
|
console.log(
|
|
158
205
|
`Packed config -> dist/app.erpkg (${kb(container.length)})\n` +
|
|
159
206
|
` qjs ${QJS_TAG} · bytecode ${kb(bytecode.length)}` +
|
|
160
|
-
(assetPack
|
|
207
|
+
(assetPack
|
|
208
|
+
? ` · assets ${kb(assetPack.length)} (${bakedImages.length} image(s), ${fontSizeCount} font size(s))`
|
|
209
|
+
: ' · no assets'),
|
|
161
210
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "embedded-react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "React Native-style component package + reconciler that drives the embedded-react C engine through the QuickJS NativeUI bridge (Flow A).",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"persist-transform.mjs",
|
|
47
47
|
"cli.mjs",
|
|
48
48
|
"sim-server.mjs",
|
|
49
|
+
"qjsc-wasm.mjs",
|
|
49
50
|
"sim/",
|
|
50
51
|
"README.md",
|
|
51
52
|
"LICENSE",
|
|
@@ -69,18 +70,23 @@
|
|
|
69
70
|
"test": "vitest run",
|
|
70
71
|
"test:watch": "vitest",
|
|
71
72
|
"test:runtime": "node test/runtime/run.mjs",
|
|
72
|
-
"test:bytecode": "node test/runtime/run.mjs --bytecode"
|
|
73
|
+
"test:bytecode": "node test/runtime/run.mjs --bytecode",
|
|
74
|
+
"format": "cd ../../.. && prettier --write \"**/*.{js,jsx,mjs,cjs}\"",
|
|
75
|
+
"format:check": "cd ../../.. && prettier --check \"**/*.{js,jsx,mjs,cjs}\""
|
|
73
76
|
},
|
|
74
77
|
"dependencies": {
|
|
75
78
|
"@babel/core": "^7.29.7",
|
|
76
79
|
"@babel/plugin-syntax-jsx": "^7.29.7",
|
|
80
|
+
"@resvg/resvg-js": "^2.6.2",
|
|
77
81
|
"esbuild": "^0.28.1",
|
|
78
82
|
"opentype.js": "^2.0.0",
|
|
79
83
|
"pngjs": "^7.0.0",
|
|
80
84
|
"react": "18.3.1",
|
|
81
|
-
"react-reconciler": "0.29.2"
|
|
85
|
+
"react-reconciler": "0.29.2",
|
|
86
|
+
"svgson": "^5.3.1"
|
|
82
87
|
},
|
|
83
88
|
"devDependencies": {
|
|
89
|
+
"prettier": "^3.4.2",
|
|
84
90
|
"vitest": "^3.2.4"
|
|
85
91
|
},
|
|
86
92
|
"overrides": {
|
package/persist-transform.mjs
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
// stable across the edits you make most often (JSX/logic). Adding/removing a useState in a component,
|
|
25
25
|
// or renaming it, shifts that component's keys and resets its state — press R in the sim for a clean
|
|
26
26
|
// reset any time.
|
|
27
|
-
import {
|
|
27
|
+
import {transformSync} from '@babel/core';
|
|
28
28
|
import syntaxJsx from '@babel/plugin-syntax-jsx';
|
|
29
29
|
|
|
30
30
|
/** Best-effort name of the function/component enclosing a path (for a stable, readable key). */
|
|
@@ -34,15 +34,21 @@ function enclosingName(path) {
|
|
|
34
34
|
if (fn.node.id && fn.node.id.name) return fn.node.id.name; // function Foo() {}
|
|
35
35
|
const parent = fn.parentPath && fn.parentPath.node;
|
|
36
36
|
if (parent) {
|
|
37
|
-
if (parent.type === 'VariableDeclarator' && parent.id && parent.id.name)
|
|
37
|
+
if (parent.type === 'VariableDeclarator' && parent.id && parent.id.name)
|
|
38
|
+
return parent.id.name; // const Foo = () =>
|
|
38
39
|
if (parent.key && parent.key.name) return parent.key.name; // method / property
|
|
39
|
-
if (
|
|
40
|
+
if (
|
|
41
|
+
parent.type === 'AssignmentExpression' &&
|
|
42
|
+
parent.left &&
|
|
43
|
+
parent.left.name
|
|
44
|
+
)
|
|
45
|
+
return parent.left.name;
|
|
40
46
|
}
|
|
41
47
|
return '_';
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
/** Babel plugin: useState(init) → __erPersistState("key", init), importing the helper when used. */
|
|
45
|
-
function persistPlugin({
|
|
51
|
+
function persistPlugin({types: t}) {
|
|
46
52
|
return {
|
|
47
53
|
name: 'er-persist-usestate',
|
|
48
54
|
visitor: {
|
|
@@ -56,14 +62,19 @@ function persistPlugin({ types: t }) {
|
|
|
56
62
|
path.unshiftContainer(
|
|
57
63
|
'body',
|
|
58
64
|
t.importDeclaration(
|
|
59
|
-
[
|
|
65
|
+
[
|
|
66
|
+
t.importSpecifier(
|
|
67
|
+
t.identifier('__erPersistState'),
|
|
68
|
+
t.identifier('usePersistentState'),
|
|
69
|
+
),
|
|
70
|
+
],
|
|
60
71
|
t.stringLiteral('embedded-react'),
|
|
61
72
|
),
|
|
62
73
|
);
|
|
63
74
|
},
|
|
64
75
|
},
|
|
65
76
|
CallExpression(path, state) {
|
|
66
|
-
if (!t.isIdentifier(path.node.callee, {
|
|
77
|
+
if (!t.isIdentifier(path.node.callee, {name: 'useState'})) return;
|
|
67
78
|
// Only rewrite the real useState (an imported binding from react / embedded-react).
|
|
68
79
|
const binding = path.scope.getBinding('useState');
|
|
69
80
|
if (!binding || binding.kind !== 'module') return;
|
|
@@ -77,7 +88,10 @@ function persistPlugin({ types: t }) {
|
|
|
77
88
|
const key = `${state.opts.moduleId}::${fnName}#${idx}`;
|
|
78
89
|
|
|
79
90
|
path.replaceWith(
|
|
80
|
-
t.callExpression(t.identifier('__erPersistState'), [
|
|
91
|
+
t.callExpression(t.identifier('__erPersistState'), [
|
|
92
|
+
t.stringLiteral(key),
|
|
93
|
+
...path.node.arguments,
|
|
94
|
+
]),
|
|
81
95
|
);
|
|
82
96
|
state.erUsed = true;
|
|
83
97
|
path.skip();
|
|
@@ -116,8 +130,8 @@ export function transformPersist(code, moduleId) {
|
|
|
116
130
|
babelrc: false,
|
|
117
131
|
configFile: false,
|
|
118
132
|
sourceType: 'module',
|
|
119
|
-
parserOpts: {
|
|
120
|
-
plugins: [syntaxJsx, [persistPlugin, {
|
|
133
|
+
parserOpts: {plugins: ['jsx']},
|
|
134
|
+
plugins: [syntaxJsx, [persistPlugin, {moduleId}]],
|
|
121
135
|
});
|
|
122
136
|
return out.code;
|
|
123
137
|
}
|