embedded-react 0.1.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/build.mjs ADDED
@@ -0,0 +1,136 @@
1
+ /*
2
+ * Copyright 2026 Cory Lamming
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ // Bundles a demo (React app + reconciler + host config) into a single classic (IIFE) script that
18
+ // QuickJS can run with a plain JS_Eval, and bakes the images/fonts the demo imports into a generated
19
+ // C translation unit (dist/assets.generated.c) exposing er_register_assets(). Globals the bundle
20
+ // expects at runtime (NativeUI, screen, console, timer shims) are provided by the C host (e.g.,
21
+ // examples/linux/main_js.c); the example firmware compiles the generated assets and calls
22
+ // er_register_assets() at boot.
23
+ //
24
+ // Demos live in the top-level demos/ folder, one folder per demo. Pick one with:
25
+ // npm run build # default demo (thermostat)
26
+ // npm run build -- marine-dash # a specific demo by folder name
27
+ // Outputs are always dist/app.bundle.js + dist/assets.generated.{c,h} — the single "active" app the
28
+ // example hosts pick up.
29
+ import { build } from 'esbuild';
30
+ import { fileURLToPath, pathToFileURL } from 'node:url';
31
+ import { dirname, resolve, basename } from 'node:path';
32
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
33
+ import { bakeAssets } from './assets/index.mjs';
34
+
35
+ const here = dirname(fileURLToPath(import.meta.url)); // bridges/quickjs/js
36
+ const repoRoot = resolve(here, '../../..');
37
+ const demosDir = resolve(repoRoot, 'demos');
38
+ const libEntry = resolve(here, 'src/embedded-react/index.js');
39
+ const nodeModules = resolve(here, 'node_modules');
40
+ const distDir = resolve(here, 'dist');
41
+
42
+ const DEFAULT_DEMO = 'thermostat';
43
+ const demo = process.argv[2] || process.env.DEMO || DEFAULT_DEMO;
44
+ const demoDir = resolve(demosDir, demo);
45
+ const entry = resolve(demoDir, 'index.jsx');
46
+
47
+ if (!existsSync(entry)) {
48
+ const available = existsSync(demosDir)
49
+ ? readdirSync(demosDir, { withFileTypes: true })
50
+ .filter((d) => d.isDirectory())
51
+ .map((d) => d.name)
52
+ : [];
53
+ console.error(`Demo "${demo}" not found (expected ${entry}).`);
54
+ console.error(`Available demos: ${available.join(', ') || '(none)'}`);
55
+ process.exit(1);
56
+ }
57
+
58
+ // Asset discovery is import-driven: an `import x from './x.png'` (image) or `import F from './F.ttf'`
59
+ // (font) is intercepted here. The import resolves to the asset's NAME (its file basename) — the
60
+ // string an <Image source>/imageName looks up, or the family a fontFamily uses — and the path is
61
+ // recorded, so it gets baked below. Only what the app imports is baked.
62
+ const images = new Map(); // name -> path
63
+ const fonts = new Map(); // family -> path
64
+ const assetPlugin = {
65
+ name: 'embedded-react-assets',
66
+ setup(build) {
67
+ build.onLoad({ filter: /\.(png|jpe?g|webp|gif|bmp)$/i }, (args) => {
68
+ const name = basename(args.path).replace(/\.[^.]+$/, '');
69
+ images.set(name, args.path);
70
+ return { contents: `module.exports = ${JSON.stringify(name)};`, loader: 'js' };
71
+ });
72
+ build.onLoad({ filter: /\.(ttf|otf)$/i }, (args) => {
73
+ const family = basename(args.path).replace(/\.[^.]+$/, '');
74
+ fonts.set(family, args.path);
75
+ return { contents: `module.exports = ${JSON.stringify(family)};`, loader: 'js' };
76
+ });
77
+ },
78
+ };
79
+
80
+ const bundlePath = resolve(distDir, 'app.bundle.js');
81
+ await build({
82
+ entryPoints: [entry],
83
+ bundle: true,
84
+ format: 'iife',
85
+ outfile: bundlePath,
86
+ platform: 'neutral',
87
+ target: 'es2020',
88
+ jsx: 'automatic',
89
+ // Demos live outside the embedded-react package, so the package self-reference doesn't resolve for
90
+ // them: map the bare `embedded-react` import to the library source and let the demo's bare deps
91
+ // (react, react-reconciler) resolve from this package's node_modules. (The library's own internal
92
+ // imports still resolve relatively / from node_modules as before.)
93
+ alias: { 'embedded-react': libEntry },
94
+ nodePaths: [nodeModules],
95
+ plugins: [assetPlugin],
96
+ // Production React: smaller and avoids dev-only warning machinery that needs more shims.
97
+ define: { 'process.env.NODE_ENV': '"production"' },
98
+ legalComments: 'none',
99
+ logLevel: 'info',
100
+ });
101
+
102
+ console.log(`Bundled demo "${demo}" -> dist/app.bundle.js`);
103
+
104
+ // --- Bake imported assets ---------------------------------------------------------------------
105
+ // Fonts are pre-rasterized at fixed sizes (the engine has no runtime rasterizer), so bake exactly
106
+ // the literal fontSize values the bundle uses. Computed/dynamic sizes can't be discovered statically
107
+ // and will snap to the nearest baked size at runtime; pin them via assets.config.js if needed.
108
+ const bundleSrc = readFileSync(bundlePath, 'utf8');
109
+ const discoveredSizes = [
110
+ ...new Set([...bundleSrc.matchAll(/\bfontSize\s*:\s*(\d+(?:\.\d+)?)/g)].map((m) => Math.round(Number(m[1])))),
111
+ ].sort((a, b) => a - b);
112
+
113
+ // Optional per-demo overrides: demos/<demo>/assets.config.js
114
+ // export default { fonts: { 'Family': { sizes: [..], bpp: 4, glyphs: 'ascii'|'common'|[cps] } } }
115
+ let config = {};
116
+ const configPath = resolve(demoDir, 'assets.config.js');
117
+ if (existsSync(configPath)) {
118
+ config = (await import(pathToFileURL(configPath).href)).default || {};
119
+ }
120
+ const fontConfig = config.fonts || {};
121
+
122
+ const fontJobs = [...fonts.entries()].map(([family, path]) => {
123
+ const fc = fontConfig[family] || {};
124
+ const sizes = fc.sizes && fc.sizes.length ? fc.sizes : discoveredSizes.length ? discoveredSizes : [16];
125
+ return { path, family, sizes, bpp: fc.bpp ?? 4, glyphs: fc.glyphs ?? 'ascii' };
126
+ });
127
+ const imageJobs = [...images.entries()].map(([name, path]) => ({ path, name }));
128
+
129
+ const summary = bakeAssets({ images: imageJobs, fonts: fontJobs, outDir: distDir });
130
+ const fontDesc = fontJobs.length
131
+ ? fontJobs.map((f) => `${f.family}@[${f.sizes.join(',')}]x${f.bpp}bpp`).join(', ')
132
+ : 'none';
133
+ console.log(
134
+ `Baked ${summary.images} image(s), ${summary.fonts} font size(s) -> dist/assets.generated.c\n` +
135
+ ` fonts: ${fontDesc}`,
136
+ );
@@ -0,0 +1,161 @@
1
+ /*
2
+ * Copyright 2026 Cory Lamming
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ // `npm run pack [demo]` — builds a deployable ERCF config container (dist/app.erpkg).
18
+ //
19
+ // The full release path for Flow A: bundle the app (esbuild → IIFE), precompile it to QuickJS
20
+ // bytecode (so the device never ships the parser or source text), bake the imported images/fonts into
21
+ // an ERPK pack, and wrap both — with a QuickJS version stamp and an integrity CRC — into a single
22
+ // .erpkg. That one file is "the config": load it with er_runtime_load_container() on the desktop, or
23
+ // upload it to a device's config region. See bridges/quickjs/js/assets/emit-container.mjs for the
24
+ // format, and er_runtime.h for the loader.
25
+ //
26
+ // npm run pack # default demo (thermostat)
27
+ // npm run pack -- marine-dash # a specific demo by folder name
28
+ //
29
+ // Prereq: the bytecode precompiler (er-bridge-quickjs-compile) must be built. This script searches the
30
+ // usual build dirs and tells you how to build it if it's missing (override with ER_COMPILE_BIN).
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 { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
36
+ import { bakeImage } from './assets/bake-image.mjs';
37
+ import { bakeFont } from './assets/bake-font.mjs';
38
+ import { emitAssetPack } from './assets/emit-pack.mjs';
39
+ import { emitContainer } from './assets/emit-container.mjs';
40
+
41
+ // QuickJS release the bytecode targets — MUST match the FetchContent pin in
42
+ // bridges/quickjs/CMakeLists.txt and ER_QUICKJS_TAG in er_runtime.c. The loader rejects a mismatch.
43
+ const QJS_TAG = 'v0.15.0';
44
+
45
+ const here = dirname(fileURLToPath(import.meta.url)); // bridges/quickjs/js
46
+ const repoRoot = resolve(here, '../../..');
47
+ const demosDir = resolve(repoRoot, 'demos');
48
+ const libEntry = resolve(here, 'src/embedded-react/index.js');
49
+ const nodeModules = resolve(here, 'node_modules');
50
+ const distDir = resolve(here, 'dist');
51
+
52
+ const demo = process.argv[2] || process.env.DEMO || 'thermostat';
53
+ const demoDir = resolve(demosDir, demo);
54
+ const entry = resolve(demoDir, 'index.jsx');
55
+ if (!existsSync(entry)) {
56
+ const available = existsSync(demosDir)
57
+ ? readdirSync(demosDir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)
58
+ : [];
59
+ console.error(`Demo "${demo}" not found (expected ${entry}).`);
60
+ console.error(`Available demos: ${available.join(', ') || '(none)'}`);
61
+ process.exit(1);
62
+ }
63
+
64
+ // --- Locate the bytecode precompiler -----------------------------------------------------------
65
+ const exe = process.platform === 'win32' ? '.exe' : '';
66
+ const compileBin =
67
+ process.env.ER_COMPILE_BIN ||
68
+ [
69
+ resolve(repoRoot, 'bridges/quickjs/build', `er-bridge-quickjs-compile${exe}`),
70
+ resolve(repoRoot, 'examples/linux/build/bridges/quickjs', `er-bridge-quickjs-compile${exe}`),
71
+ resolve(repoRoot, 'tools/simulator/build/bridges/quickjs', `er-bridge-quickjs-compile${exe}`),
72
+ ].find(existsSync);
73
+ if (!compileBin) {
74
+ console.error('Bytecode precompiler (er-bridge-quickjs-compile) not found. Build it once:');
75
+ console.error(' cmake -S bridges/quickjs -B bridges/quickjs/build -G "MinGW Makefiles"');
76
+ console.error(' cmake --build bridges/quickjs/build --target er-bridge-quickjs-compile');
77
+ console.error('(or set ER_COMPILE_BIN to the binary path)');
78
+ process.exit(1);
79
+ }
80
+
81
+ // --- Bundle the app (same import-driven asset discovery as build.mjs) ---------------------------
82
+ const images = new Map(); // name -> path
83
+ const fonts = new Map(); // family -> path
84
+ const assetPlugin = {
85
+ name: 'embedded-react-assets',
86
+ setup(b) {
87
+ b.onLoad({ filter: /\.(png|jpe?g|webp|gif|bmp)$/i }, (args) => {
88
+ const name = basename(args.path).replace(/\.[^.]+$/, '');
89
+ images.set(name, args.path);
90
+ return { contents: `module.exports = ${JSON.stringify(name)};`, loader: 'js' };
91
+ });
92
+ b.onLoad({ filter: /\.(ttf|otf)$/i }, (args) => {
93
+ const family = basename(args.path).replace(/\.[^.]+$/, '');
94
+ fonts.set(family, args.path);
95
+ return { contents: `module.exports = ${JSON.stringify(family)};`, loader: 'js' };
96
+ });
97
+ },
98
+ };
99
+
100
+ mkdirSync(distDir, { recursive: true });
101
+ const bundlePath = resolve(distDir, 'app.bundle.js');
102
+ await build({
103
+ entryPoints: [entry],
104
+ bundle: true,
105
+ format: 'iife',
106
+ outfile: bundlePath,
107
+ platform: 'neutral',
108
+ target: 'es2020',
109
+ jsx: 'automatic',
110
+ alias: { 'embedded-react': libEntry },
111
+ nodePaths: [nodeModules],
112
+ plugins: [assetPlugin],
113
+ define: { 'process.env.NODE_ENV': '"production"' },
114
+ legalComments: 'none',
115
+ logLevel: 'info',
116
+ });
117
+ console.log(`Bundled demo "${demo}" -> dist/app.bundle.js`);
118
+
119
+ // --- Precompile to QuickJS bytecode -------------------------------------------------------------
120
+ const qbcPath = resolve(distDir, 'app.bundle.qbc');
121
+ const cc = spawnSync(compileBin, [bundlePath, qbcPath], { stdio: 'inherit' });
122
+ if (cc.status !== 0) {
123
+ console.error('Bytecode compile failed.');
124
+ process.exit(1);
125
+ }
126
+ const bytecode = readFileSync(qbcPath);
127
+
128
+ // --- Bake imported assets into an ERPK pack (same sizing rules as build.mjs / sim.mjs) ----------
129
+ const bundleSrc = readFileSync(bundlePath, 'utf8');
130
+ const discoveredSizes = [
131
+ ...new Set([...bundleSrc.matchAll(/\bfontSize\s*:\s*(\d+(?:\.\d+)?)/g)].map((m) => Math.round(Number(m[1])))),
132
+ ].sort((a, b) => a - b);
133
+
134
+ let config = {};
135
+ const configPath = resolve(demoDir, 'assets.config.js');
136
+ if (existsSync(configPath)) config = (await import(pathToFileURL(configPath).href)).default || {};
137
+ const fontConfig = config.fonts || {};
138
+
139
+ const fontJobs = [...fonts.entries()].map(([family, path]) => {
140
+ const fc = fontConfig[family] || {};
141
+ const sizes = fc.sizes && fc.sizes.length ? fc.sizes : discoveredSizes.length ? discoveredSizes : [16];
142
+ return { path, family, sizes, bpp: fc.bpp ?? 4, glyphs: fc.glyphs ?? 'ascii' };
143
+ });
144
+ const imageJobs = [...images.entries()].map(([name, path]) => ({ path, name }));
145
+
146
+ const bakedImages = imageJobs.map((i) => bakeImage(i));
147
+ const bakedFonts = fontJobs.map((f) => bakeFont(f));
148
+ const assetPack = bakedImages.length || bakedFonts.length ? emitAssetPack({ images: bakedImages, fonts: bakedFonts }) : null;
149
+ const fontSizeCount = bakedFonts.reduce((n, f) => n + f.sizes.length, 0);
150
+
151
+ // --- Wrap into the ERCF container ---------------------------------------------------------------
152
+ const container = emitContainer({ bytecode, assetPack, qjsTag: QJS_TAG });
153
+ const outPath = resolve(distDir, 'app.erpkg');
154
+ writeFileSync(outPath, container);
155
+
156
+ const kb = (n) => `${(n / 1024).toFixed(1)} KB`;
157
+ console.log(
158
+ `Packed config -> dist/app.erpkg (${kb(container.length)})\n` +
159
+ ` qjs ${QJS_TAG} · bytecode ${kb(bytecode.length)}` +
160
+ (assetPack ? ` · assets ${kb(assetPack.length)} (${bakedImages.length} image(s), ${fontSizeCount} font size(s))` : ' · no assets'),
161
+ );
package/package.json ADDED
@@ -0,0 +1,79 @@
1
+ {
2
+ "name": "embedded-react",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "React Native-style component package + reconciler that drives the embedded-react C engine through the QuickJS NativeUI bridge (Flow A).",
6
+ "license": "Apache-2.0",
7
+ "author": "Cory Lamming",
8
+ "homepage": "https://github.com/TheMasterCoder007/embedded-react#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/TheMasterCoder007/embedded-react.git",
12
+ "directory": "bridges/quickjs/js"
13
+ },
14
+ "bugs": {
15
+ "url": "https://github.com/TheMasterCoder007/embedded-react/issues"
16
+ },
17
+ "keywords": [
18
+ "react",
19
+ "react-native",
20
+ "embedded",
21
+ "microcontroller",
22
+ "esp32",
23
+ "stm32",
24
+ "mcu",
25
+ "jsx",
26
+ "gui",
27
+ "ui",
28
+ "quickjs",
29
+ "aot"
30
+ ],
31
+ "engines": {
32
+ "node": ">=18"
33
+ },
34
+ "exports": {
35
+ ".": "./src/embedded-react/index.js"
36
+ },
37
+ "files": [
38
+ "src/",
39
+ "aot/",
40
+ "assets/",
41
+ "build.mjs",
42
+ "pack-container.mjs",
43
+ "persist-transform.mjs",
44
+ "README.md",
45
+ "LICENSE",
46
+ "NOTICE",
47
+ "!**/__tests__/**",
48
+ "!**/*.test.js"
49
+ ],
50
+ "scripts": {
51
+ "build": "node build.mjs",
52
+ "pack": "node pack-container.mjs",
53
+ "aot": "node aot/compile.mjs",
54
+ "aot:smoke": "node aot/screenshot-smoke.mjs",
55
+ "parity": "node parity.mjs",
56
+ "version:sync": "node ../../../tools/sync-version.mjs",
57
+ "version:check": "node ../../../tools/sync-version.mjs --check",
58
+ "release": "node ../../../tools/release.mjs",
59
+ "build:builtin-font": "node assets/build-builtin-font.mjs",
60
+ "sim": "node sim.mjs",
61
+ "create": "node create-embedded-react.mjs",
62
+ "test": "vitest run",
63
+ "test:watch": "vitest",
64
+ "test:runtime": "node test/runtime/run.mjs",
65
+ "test:bytecode": "node test/runtime/run.mjs --bytecode"
66
+ },
67
+ "dependencies": {
68
+ "react": "18.3.1",
69
+ "react-reconciler": "0.29.2"
70
+ },
71
+ "devDependencies": {
72
+ "@babel/core": "^7.29.7",
73
+ "@babel/plugin-syntax-jsx": "^7.29.7",
74
+ "esbuild": "^0.24.0",
75
+ "opentype.js": "^2.0.0",
76
+ "pngjs": "^7.0.0",
77
+ "vitest": "^2.1.0"
78
+ }
79
+ }
@@ -0,0 +1,106 @@
1
+ /*
2
+ * Copyright 2026 Cory Lamming
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ // Simulator-only Babel transform: rewrites `useState(init)` in app code to a persisting helper so
18
+ // state transparently survives a hot reload (see /SIMULATOR.md, Phase 3 — transparent variant). Plain
19
+ // `useState` from 'react' just persists in the simulator; on a device (no transform, no __erPersist)
20
+ // it's exactly useState. Only the app's own files are transformed — never the library or React (so
21
+ // the helper itself isn't rewritten).
22
+ //
23
+ // Each call is keyed by `module::component#index` (component name + its useState order), which is
24
+ // stable across the edits you make most often (JSX/logic). Adding/removing a useState in a component,
25
+ // or renaming it, shifts that component's keys and resets its state — press R in the sim for a clean
26
+ // reset any time.
27
+ import { transformSync } from '@babel/core';
28
+ import syntaxJsx from '@babel/plugin-syntax-jsx';
29
+
30
+ /** Best-effort name of the function/component enclosing a path (for a stable, readable key). */
31
+ function enclosingName(path) {
32
+ const fn = path.getFunctionParent();
33
+ if (!fn) return '_';
34
+ if (fn.node.id && fn.node.id.name) return fn.node.id.name; // function Foo() {}
35
+ const parent = fn.parentPath && fn.parentPath.node;
36
+ if (parent) {
37
+ if (parent.type === 'VariableDeclarator' && parent.id && parent.id.name) return parent.id.name; // const Foo = () =>
38
+ if (parent.key && parent.key.name) return parent.key.name; // method / property
39
+ if (parent.type === 'AssignmentExpression' && parent.left && parent.left.name) return parent.left.name;
40
+ }
41
+ return '_';
42
+ }
43
+
44
+ /** Babel plugin: useState(init) → __erPersistState("key", init), importing the helper when used. */
45
+ function persistPlugin({ types: t }) {
46
+ return {
47
+ name: 'er-persist-usestate',
48
+ visitor: {
49
+ Program: {
50
+ enter(_path, state) {
51
+ state.erCounters = new Map();
52
+ state.erUsed = false;
53
+ },
54
+ exit(path, state) {
55
+ if (!state.erUsed) return;
56
+ path.unshiftContainer(
57
+ 'body',
58
+ t.importDeclaration(
59
+ [t.importSpecifier(t.identifier('__erPersistState'), t.identifier('usePersistentState'))],
60
+ t.stringLiteral('embedded-react'),
61
+ ),
62
+ );
63
+ },
64
+ },
65
+ CallExpression(path, state) {
66
+ if (!t.isIdentifier(path.node.callee, { name: 'useState' })) return;
67
+ // Only rewrite the real useState (an imported binding from react / embedded-react).
68
+ const binding = path.scope.getBinding('useState');
69
+ if (!binding || binding.kind !== 'module') return;
70
+ const decl = binding.path.parentPath && binding.path.parentPath.node;
71
+ const src = decl && decl.source && decl.source.value;
72
+ if (src !== 'react' && src !== 'embedded-react') return;
73
+
74
+ const fnName = enclosingName(path);
75
+ const idx = state.erCounters.get(fnName) || 0;
76
+ state.erCounters.set(fnName, idx + 1);
77
+ const key = `${state.opts.moduleId}::${fnName}#${idx}`;
78
+
79
+ path.replaceWith(
80
+ t.callExpression(t.identifier('__erPersistState'), [t.stringLiteral(key), ...path.node.arguments]),
81
+ );
82
+ state.erUsed = true;
83
+ path.skip();
84
+ },
85
+ },
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Applies the persist transform to a module's source.
91
+ *
92
+ * @param {string} code Module source (JSX allowed).
93
+ * @param {string} moduleId Stable module identifier (used in keys; e.g. a path relative to the app).
94
+ * @returns {string} Transformed source (JSX preserved for esbuild to handle).
95
+ */
96
+ export function transformPersist(code, moduleId) {
97
+ const out = transformSync(code, {
98
+ filename: moduleId,
99
+ babelrc: false,
100
+ configFile: false,
101
+ sourceType: 'module',
102
+ parserOpts: { plugins: ['jsx'] },
103
+ plugins: [syntaxJsx, [persistPlugin, { moduleId }]],
104
+ });
105
+ return out.code;
106
+ }