@rrjs/babel-plugin 0.1.1 → 0.2.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/LICENSE +21 -0
- package/README.md +82 -9
- package/dist/index.d.ts +21 -0
- package/dist/index.js +168 -39
- package/dist/module-contracts.d.ts +32 -0
- package/dist/module-contracts.js +32 -0
- package/dist/run-once.d.ts +4 -0
- package/dist/run-once.js +2004 -0
- package/package.json +25 -8
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Saman Abaasi
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,11 +1,26 @@
|
|
|
1
|
-
# babel-plugin
|
|
1
|
+
# @rrjs/babel-plugin
|
|
2
2
|
|
|
3
|
-
Babel plugin that compiles
|
|
3
|
+
Babel plugin that compiles React components for `@rrjs/renderer`. By default each
|
|
4
|
+
component body runs once per mount, state is read as an ordinary value, and DOM
|
|
5
|
+
updates and list changes are applied directly with no keyed reconciliation.
|
|
6
|
+
Source outside the supported subset is a build error rather than a silent
|
|
7
|
+
difference.
|
|
8
|
+
|
|
9
|
+
Two paths, selected with the `runOnce` option:
|
|
10
|
+
|
|
11
|
+
| | default (`runOnce`) | `runOnce: false` |
|
|
12
|
+
| --- | --- | --- |
|
|
13
|
+
| Component source | ordinary React: `count` | adapted: state is a getter, `count()` |
|
|
14
|
+
| Lists | direct insert/move/remove | reconciling `list()` |
|
|
15
|
+
| Unsupported source | rejected at build time | compiled, sometimes incorrectly |
|
|
16
|
+
|
|
17
|
+
Version 0.2.0 made `runOnce` the default. Builds that relied on the previous
|
|
18
|
+
default must pass `runOnce: false` explicitly.
|
|
4
19
|
|
|
5
20
|
## Install
|
|
6
21
|
|
|
7
22
|
```bash
|
|
8
|
-
npm install -D babel-plugin
|
|
23
|
+
npm install -D @rrjs/babel-plugin @babel/core
|
|
9
24
|
```
|
|
10
25
|
|
|
11
26
|
## Setup
|
|
@@ -16,7 +31,7 @@ npm install -D babel-plugin-reactive-react @babel/core
|
|
|
16
31
|
// vite.config.ts
|
|
17
32
|
import { defineConfig } from 'vite'
|
|
18
33
|
import babel from '@babel/core'
|
|
19
|
-
import reactiveReact from 'babel-plugin
|
|
34
|
+
import reactiveReact from '@rrjs/babel-plugin'
|
|
20
35
|
|
|
21
36
|
export default defineConfig({
|
|
22
37
|
esbuild: { jsx: 'preserve' },
|
|
@@ -39,14 +54,63 @@ export default defineConfig({
|
|
|
39
54
|
})
|
|
40
55
|
```
|
|
41
56
|
|
|
42
|
-
|
|
57
|
+
### Module contracts for imports
|
|
58
|
+
|
|
59
|
+
The default compiler handles components and hooks defined in the module it is
|
|
60
|
+
compiling. Imported hooks and components need an explicit build-tool contract. Define one manifest for the source root and
|
|
61
|
+
resolve every transformed module by its exact path:
|
|
62
|
+
|
|
63
|
+
```ts
|
|
64
|
+
import reactiveReact, {
|
|
65
|
+
defineModuleContracts,
|
|
66
|
+
resolveModuleMetadata,
|
|
67
|
+
} from '@rrjs/babel-plugin'
|
|
68
|
+
|
|
69
|
+
const contracts = defineModuleContracts({
|
|
70
|
+
root: '/absolute/project/src',
|
|
71
|
+
modules: {
|
|
72
|
+
'App.tsx': {
|
|
73
|
+
imports: {
|
|
74
|
+
'./theme': { hooks: ['useTheme'] },
|
|
75
|
+
'./Rows': {
|
|
76
|
+
components: {
|
|
77
|
+
Rows: {
|
|
78
|
+
props: ['rows'],
|
|
79
|
+
operationKeys: { rows: 'id' },
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
'Rows.tsx': {
|
|
86
|
+
operationProps: ['rows'],
|
|
87
|
+
operationKeyProps: { rows: 'id' },
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
const options = {
|
|
93
|
+
moduleMetadata: resolveModuleMetadata(contracts, filename),
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Import contracts use the source string and exported name, so named aliases keep
|
|
98
|
+
the same identity. Manifest module keys must be normalized, root-relative paths;
|
|
99
|
+
unknown paths fail explicitly. These contracts are manually asserted facts. The
|
|
100
|
+
compiler does not inspect an imported implementation or infer arbitrary module
|
|
101
|
+
graphs. Operation props require a direct item-property JSX key, and accepted map
|
|
102
|
+
updates must preserve that key. Opaque list replacements remain unsupported.
|
|
103
|
+
|
|
104
|
+
In your application's entry file:
|
|
43
105
|
|
|
44
106
|
```js
|
|
45
|
-
import {
|
|
46
|
-
|
|
107
|
+
import { mount } from '@rrjs/renderer'
|
|
108
|
+
import { App } from './App'
|
|
109
|
+
|
|
110
|
+
mount(App, document.getElementById('app'))
|
|
47
111
|
```
|
|
48
112
|
|
|
49
|
-
The plugin
|
|
113
|
+
The plugin injects `import { h, list } from '@rrjs/renderer'` into every file that actually emits those calls. You do not assign `globalThis.h`. `@rrjs/renderer` must be installed in the app — it is a peer dependency of the plugin.
|
|
50
114
|
|
|
51
115
|
## What it transforms
|
|
52
116
|
|
|
@@ -61,6 +125,15 @@ The plugin assumes `h` is in scope where JSX is used.
|
|
|
61
125
|
|
|
62
126
|
Static literals are not wrapped. Event handlers (anything matching `on[A-Z]`) are passed through directly.
|
|
63
127
|
|
|
128
|
+
### SVG namespace boundary
|
|
129
|
+
|
|
130
|
+
Intrinsic SVG descendants in one compiled JSX tree are marked so the renderer
|
|
131
|
+
creates them in the SVG namespace; children of `foreignObject` return to HTML.
|
|
132
|
+
The tested aliases are `className`, `strokeWidth` and `xlinkHref`. An SVG-only
|
|
133
|
+
intrinsic without a visible `svg` ancestor and a component child directly under
|
|
134
|
+
SVG are rejected because cross-component namespace context is not implemented.
|
|
135
|
+
Namespaced JSX and the broader SVG attribute surface are outside this contract.
|
|
136
|
+
|
|
64
137
|
## Why thunks
|
|
65
138
|
|
|
66
139
|
Inside the renderer, thunks run inside an `effect`. That effect subscribes to any signals read by the thunk. When those signals change, the thunk re-runs and the bound DOM node updates — without re-running the component function. This is what makes the "component runs once" property possible.
|
|
@@ -86,4 +159,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
|
86
159
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
87
160
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
88
161
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
89
|
-
SOFTWARE.
|
|
162
|
+
SOFTWARE.
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,23 @@
|
|
|
1
1
|
import type { PluginObj, PluginPass } from '@babel/core';
|
|
2
|
+
import type { ModuleMetadata } from './module-contracts.js';
|
|
3
|
+
export { defineModuleContracts, resolveModuleMetadata, } from './module-contracts.js';
|
|
4
|
+
export type { ImportedComponentContract, ImportedModuleContract, ModuleContractManifest, ModuleMetadata, } from './module-contracts.js';
|
|
5
|
+
export type PluginOptions = {
|
|
6
|
+
/**
|
|
7
|
+
* Compile the checked React-source subset without component re-execution or
|
|
8
|
+
* list reconciliation. Default: true. Set false for the older path, which
|
|
9
|
+
* wraps reactive reads instead and does not run component bodies once.
|
|
10
|
+
*/
|
|
11
|
+
runOnce?: boolean;
|
|
12
|
+
/** Module to import `h` and `list` from. Default: `@rrjs/renderer`. */
|
|
13
|
+
importSource?: string;
|
|
14
|
+
/**
|
|
15
|
+
* When false, assume `h`/`list` are already in scope. Used by the audit
|
|
16
|
+
* harness, which evals compiled output with `new Function('h', 'list', ...)`.
|
|
17
|
+
* Default: true.
|
|
18
|
+
*/
|
|
19
|
+
injectImports?: boolean;
|
|
20
|
+
/** Explicit module-graph facts supplied by an integrating build tool. */
|
|
21
|
+
moduleMetadata?: ModuleMetadata;
|
|
22
|
+
};
|
|
2
23
|
export default function reactiveReactPlugin(): PluginObj<PluginPass>;
|
package/dist/index.js
CHANGED
|
@@ -1,22 +1,137 @@
|
|
|
1
1
|
import * as t from '@babel/types';
|
|
2
2
|
import jsxSyntaxPlugin from '@babel/plugin-syntax-jsx';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
import { addNamed } from '@babel/helper-module-imports';
|
|
4
|
+
import { compileRunOnce } from './run-once.js';
|
|
5
|
+
const SVG_ONLY_INTRINSICS = new Set([
|
|
6
|
+
'animate', 'animateMotion', 'animateTransform', 'circle', 'clipPath', 'defs',
|
|
7
|
+
'desc', 'ellipse', 'feBlend', 'feColorMatrix', 'feComponentTransfer',
|
|
8
|
+
'feComposite', 'feConvolveMatrix', 'feDiffuseLighting', 'feDisplacementMap',
|
|
9
|
+
'feDistantLight', 'feDropShadow', 'feFlood', 'feFuncA', 'feFuncB', 'feFuncG',
|
|
10
|
+
'feFuncR', 'feGaussianBlur', 'feImage', 'feMerge', 'feMergeNode',
|
|
11
|
+
'feMorphology', 'feOffset', 'fePointLight', 'feSpecularLighting', 'feSpotLight',
|
|
12
|
+
'feTile', 'feTurbulence', 'filter', 'foreignObject', 'g', 'image', 'line',
|
|
13
|
+
'linearGradient', 'marker', 'mask', 'metadata', 'mpath', 'path', 'pattern',
|
|
14
|
+
'polygon', 'polyline', 'radialGradient', 'rect', 'stop', 'switch', 'symbol',
|
|
15
|
+
'text', 'textPath', 'tspan', 'use', 'view',
|
|
16
|
+
]);
|
|
17
|
+
function annotateIntrinsicNamespaces(program) {
|
|
18
|
+
program.traverse({
|
|
19
|
+
JSXElement(path) {
|
|
20
|
+
const opening = path.node.openingElement;
|
|
21
|
+
if (!t.isJSXIdentifier(opening.name))
|
|
22
|
+
return;
|
|
23
|
+
const name = opening.name.name;
|
|
24
|
+
const parent = path.findParent(parent => parent.isJSXElement());
|
|
25
|
+
const inherited = parent?.node.openingElement.extra?.rrjsChildNamespace;
|
|
26
|
+
if (/^[A-Z]/.test(name)) {
|
|
27
|
+
if (inherited === 'svg') {
|
|
28
|
+
throw path.buildCodeFrameError('SVG component children require cross-component namespace compilation');
|
|
29
|
+
}
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const namespace = name === 'svg' ? 'svg' : inherited ?? 'html';
|
|
33
|
+
if (namespace === 'html' && SVG_ONLY_INTRINSICS.has(name)) {
|
|
34
|
+
throw path.buildCodeFrameError(`SVG intrinsic <${name}> requires an <svg> ancestor in the same compiled module`);
|
|
35
|
+
}
|
|
36
|
+
opening.extra = {
|
|
37
|
+
...(opening.extra ?? {}),
|
|
38
|
+
rrjsNamespace: namespace,
|
|
39
|
+
rrjsChildNamespace: namespace === 'svg' && name === 'foreignObject' ? 'html' : namespace,
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
function rewriteRunOnceReactDomImports(program, importSource) {
|
|
45
|
+
for (const statement of program.get('body')) {
|
|
46
|
+
if (!statement.isImportDeclaration() || statement.node.source.value !== 'react-dom')
|
|
47
|
+
continue;
|
|
48
|
+
for (const specifier of statement.get('specifiers')) {
|
|
49
|
+
if (!specifier.isImportSpecifier()) {
|
|
50
|
+
throw specifier.buildCodeFrameError('Strict portal compilation requires a named createPortal import from react-dom');
|
|
51
|
+
}
|
|
52
|
+
const imported = specifier.node.imported;
|
|
53
|
+
const name = t.isIdentifier(imported) ? imported.name : imported.value;
|
|
54
|
+
if (name !== 'createPortal') {
|
|
55
|
+
throw specifier.buildCodeFrameError(`Unsupported react-dom import ${name}; only named createPortal is supported`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
statement.node.source = t.stringLiteral(importSource);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
export { defineModuleContracts, resolveModuleMetadata, } from './module-contracts.js';
|
|
62
|
+
function makeRuntime(path, state) {
|
|
63
|
+
const opts = (state.opts ?? {});
|
|
64
|
+
const inject = opts.injectImports !== false;
|
|
65
|
+
const source = opts.importSource ?? '@rrjs/renderer';
|
|
66
|
+
if (!inject) {
|
|
67
|
+
return {
|
|
68
|
+
h: () => t.identifier('h'),
|
|
69
|
+
list: () => t.identifier('list'),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
h: () => {
|
|
74
|
+
let id = state.get('rrjs.h');
|
|
75
|
+
if (!id) {
|
|
76
|
+
id = addNamed(path, 'h', source);
|
|
77
|
+
state.set('rrjs.h', id);
|
|
78
|
+
}
|
|
79
|
+
return t.cloneNode(id);
|
|
80
|
+
},
|
|
81
|
+
list: () => {
|
|
82
|
+
let id = state.get('rrjs.list');
|
|
83
|
+
if (!id) {
|
|
84
|
+
id = addNamed(path, 'list', source);
|
|
85
|
+
state.set('rrjs.list', id);
|
|
86
|
+
}
|
|
87
|
+
return t.cloneNode(id);
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
7
91
|
export default function reactiveReactPlugin() {
|
|
8
92
|
return {
|
|
9
93
|
name: 'babel-plugin-reactive-react',
|
|
10
94
|
inherits: jsxSyntaxPlugin.default ?? jsxSyntaxPlugin,
|
|
11
95
|
visitor: {
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
path
|
|
96
|
+
Program(path, state) {
|
|
97
|
+
const opts = state.opts;
|
|
98
|
+
annotateIntrinsicNamespaces(path);
|
|
99
|
+
if (opts?.runOnce === false)
|
|
100
|
+
return;
|
|
101
|
+
rewriteRunOnceReactDomImports(path, opts.importSource ?? '@rrjs/renderer');
|
|
102
|
+
let helper;
|
|
103
|
+
let selector;
|
|
104
|
+
compileRunOnce(path, () => {
|
|
105
|
+
if (opts.injectImports === false)
|
|
106
|
+
return t.identifier('derive');
|
|
107
|
+
helper ??= addNamed(path, 'derive', '@rrjs/react-compat');
|
|
108
|
+
return t.cloneNode(helper);
|
|
109
|
+
}, () => {
|
|
110
|
+
if (opts.injectImports === false)
|
|
111
|
+
return t.identifier('choose');
|
|
112
|
+
selector ??= addNamed(path, 'choose', opts.importSource ?? '@rrjs/renderer');
|
|
113
|
+
return t.cloneNode(selector);
|
|
114
|
+
}, (name) => {
|
|
115
|
+
if (opts.injectImports === false)
|
|
116
|
+
return t.identifier(name);
|
|
117
|
+
const key = `rrjs.${name}`;
|
|
118
|
+
let id = state.get(key);
|
|
119
|
+
if (!id) {
|
|
120
|
+
id = addNamed(path, name, opts.importSource ?? '@rrjs/renderer');
|
|
121
|
+
state.set(key, id);
|
|
122
|
+
}
|
|
123
|
+
return t.cloneNode(id);
|
|
124
|
+
}, opts.moduleMetadata);
|
|
125
|
+
},
|
|
126
|
+
JSXElement(path, state) {
|
|
127
|
+
const rt = makeRuntime(path, state);
|
|
128
|
+
path.replaceWith(transformElement(path.node, rt));
|
|
15
129
|
},
|
|
16
|
-
JSXFragment(path) {
|
|
130
|
+
JSXFragment(path, state) {
|
|
17
131
|
// Fragments: <>...</> → [child, child, ...]
|
|
18
132
|
// We use an array because there's no h() call for fragments yet
|
|
19
|
-
const
|
|
133
|
+
const rt = makeRuntime(path, state);
|
|
134
|
+
const children = filterChildren(path.node.children).map(c => transformChild(c, rt));
|
|
20
135
|
path.replaceWith(t.arrayExpression(children));
|
|
21
136
|
},
|
|
22
137
|
},
|
|
@@ -53,13 +168,20 @@ function getAttributeName(name) {
|
|
|
53
168
|
return name.name;
|
|
54
169
|
throw new Error('Namespaced JSX attributes not supported');
|
|
55
170
|
}
|
|
56
|
-
function transformElement(element) {
|
|
171
|
+
function transformElement(element, rt) {
|
|
57
172
|
const openingElement = element.openingElement;
|
|
58
173
|
const tag = transformTag(openingElement.name);
|
|
59
174
|
const isNativeElement = t.isStringLiteral(tag);
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
175
|
+
let props = transformProps(openingElement.attributes, isNativeElement);
|
|
176
|
+
if (isNativeElement && openingElement.extra?.rrjsNamespace === 'svg') {
|
|
177
|
+
const marker = t.objectProperty(t.stringLiteral('__rrjsNamespace'), t.stringLiteral('svg'));
|
|
178
|
+
if (t.isObjectExpression(props))
|
|
179
|
+
props.properties.unshift(marker);
|
|
180
|
+
else
|
|
181
|
+
props = t.objectExpression([marker]);
|
|
182
|
+
}
|
|
183
|
+
const children = filterChildren(element.children).map(c => transformChild(c, rt));
|
|
184
|
+
return t.callExpression(rt.h(), [
|
|
63
185
|
tag,
|
|
64
186
|
props,
|
|
65
187
|
...children,
|
|
@@ -122,35 +244,39 @@ function transformAttributeValue(name, value, isNativeElement) {
|
|
|
122
244
|
return t.nullLiteral();
|
|
123
245
|
}
|
|
124
246
|
// ─── Transform a child of a JSX element ──────────────────────────────────────
|
|
125
|
-
function transformChild(child) {
|
|
247
|
+
function transformChild(child, rt) {
|
|
126
248
|
if (t.isJSXText(child)) {
|
|
127
249
|
return t.stringLiteral(child.value);
|
|
128
250
|
}
|
|
129
251
|
if (t.isJSXExpressionContainer(child)) {
|
|
130
252
|
const expr = child.expression;
|
|
253
|
+
if (expr.extra?.rrjsRegion)
|
|
254
|
+
return expr;
|
|
131
255
|
if (t.isJSXEmptyExpression(expr)) {
|
|
132
256
|
return t.nullLiteral();
|
|
133
257
|
}
|
|
134
258
|
if (isStaticExpression(expr)) {
|
|
135
259
|
return expr;
|
|
136
260
|
}
|
|
261
|
+
// The strict run-once pass marks maps whose source membership is a fixed
|
|
262
|
+
// source array. Execute ordinary Array#map once instead of importing the
|
|
263
|
+
// keyed reconciler used by the plugin's general mode.
|
|
264
|
+
if (expr.extra?.rrjsStaticMap)
|
|
265
|
+
return wrapInThunk(expr);
|
|
137
266
|
// Try the list-as-JSX transform first.
|
|
138
267
|
// If the expression matches items.map((item) => <X key={...} />),
|
|
139
268
|
// rewrite it to a list() call for keyed reconciliation.
|
|
140
|
-
const listCall = tryTransformMapToList(expr);
|
|
269
|
+
const listCall = tryTransformMapToList(expr, rt);
|
|
141
270
|
if (listCall)
|
|
142
271
|
return listCall;
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
if (t.isCallExpression(expr)) {
|
|
146
|
-
return expr;
|
|
147
|
-
}
|
|
272
|
+
// Calls may read signals, including through helpers. Evaluate them inside
|
|
273
|
+
// the child's reactive binding just like other dynamic expressions.
|
|
148
274
|
return wrapInThunk(expr);
|
|
149
275
|
}
|
|
150
276
|
if (t.isJSXElement(child) || t.isJSXFragment(child)) {
|
|
151
277
|
return t.isJSXElement(child)
|
|
152
|
-
? transformElement(child)
|
|
153
|
-
: t.arrayExpression(filterChildren(child.children).map(transformChild));
|
|
278
|
+
? transformElement(child, rt)
|
|
279
|
+
: t.arrayExpression(filterChildren(child.children).map(c => transformChild(c, rt)));
|
|
154
280
|
}
|
|
155
281
|
if (t.isJSXSpreadChild(child)) {
|
|
156
282
|
return child.expression;
|
|
@@ -167,22 +293,25 @@ function wrapInThunk(expr) {
|
|
|
167
293
|
// to list(() => items, (item) => item.id, (item) => h(Foo, ...)).
|
|
168
294
|
// This wires standard JSX iteration into the keyed list reconciler
|
|
169
295
|
// without requiring developers to call list() directly.
|
|
170
|
-
function tryTransformMapToList(expr) {
|
|
296
|
+
function tryTransformMapToList(expr, rt) {
|
|
171
297
|
// Must be a method call to .map
|
|
172
298
|
if (!t.isCallExpression(expr))
|
|
173
299
|
return null;
|
|
174
300
|
if (!t.isMemberExpression(expr.callee))
|
|
175
301
|
return null;
|
|
176
|
-
if (!t.isIdentifier(expr.callee.property, { name: 'map' }))
|
|
302
|
+
if (expr.callee.computed || !t.isIdentifier(expr.callee.property, { name: 'map' }))
|
|
177
303
|
return null;
|
|
178
304
|
// Must have a single callback argument
|
|
179
305
|
if (expr.arguments.length !== 1)
|
|
180
306
|
return null;
|
|
181
307
|
const callback = expr.arguments[0];
|
|
182
|
-
|
|
308
|
+
// Function expressions can depend on their own `this` or `arguments`.
|
|
309
|
+
// Preserve their ordinary map execution until those semantics are modeled.
|
|
310
|
+
if (!t.isArrowFunctionExpression(callback) || callback.async)
|
|
183
311
|
return null;
|
|
184
|
-
//
|
|
185
|
-
|
|
312
|
+
// The keyed renderer currently supplies only a reactive item proxy. Keep
|
|
313
|
+
// native map evaluation for callbacks that also need an index/source array.
|
|
314
|
+
if (callback.params.length !== 1)
|
|
186
315
|
return null;
|
|
187
316
|
const itemParam = callback.params[0];
|
|
188
317
|
if (!t.isIdentifier(itemParam))
|
|
@@ -194,13 +323,13 @@ function tryTransformMapToList(expr) {
|
|
|
194
323
|
returnedJsx = callback.body;
|
|
195
324
|
}
|
|
196
325
|
else if (t.isBlockStatement(callback.body)) {
|
|
197
|
-
//
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
326
|
+
// Extracting only the return would discard declarations, side effects,
|
|
327
|
+
// or branches. More complex callbacks use the reactive child path.
|
|
328
|
+
if (callback.body.body.length !== 1)
|
|
329
|
+
return null;
|
|
330
|
+
const stmt = callback.body.body[0];
|
|
331
|
+
if (t.isReturnStatement(stmt) && t.isJSXElement(stmt.argument))
|
|
332
|
+
returnedJsx = stmt.argument;
|
|
204
333
|
}
|
|
205
334
|
if (!returnedJsx)
|
|
206
335
|
return null;
|
|
@@ -220,8 +349,8 @@ function tryTransformMapToList(expr) {
|
|
|
220
349
|
// (item) => <transformed JSX>
|
|
221
350
|
// )
|
|
222
351
|
const sourceExpression = expr.callee.object;
|
|
223
|
-
const transformedRenderJsx = transformElement(returnedJsx);
|
|
224
|
-
return t.callExpression(
|
|
352
|
+
const transformedRenderJsx = transformElement(returnedJsx, rt);
|
|
353
|
+
return t.callExpression(rt.list(), [
|
|
225
354
|
// getItems: () => items
|
|
226
355
|
t.arrowFunctionExpression([], sourceExpression),
|
|
227
356
|
// getKey: (item) => item.id
|
|
@@ -243,11 +372,11 @@ function isStaticExpression(expr) {
|
|
|
243
372
|
t.isBigIntLiteral(expr));
|
|
244
373
|
}
|
|
245
374
|
function filterChildren(children) {
|
|
246
|
-
//
|
|
375
|
+
// Preserve intentional inline spaces between children. Whitespace containing
|
|
376
|
+
// a line break is formatting indentation and does not produce a React child.
|
|
247
377
|
return children.filter(child => {
|
|
248
378
|
if (t.isJSXText(child)) {
|
|
249
|
-
|
|
250
|
-
return child.value.trim().length > 0;
|
|
379
|
+
return child.value.trim().length > 0 || !/[\r\n]/.test(child.value);
|
|
251
380
|
}
|
|
252
381
|
return true;
|
|
253
382
|
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type ImportedComponentContract = {
|
|
2
|
+
props: string[];
|
|
3
|
+
/** Operation-bearing array prop to its required direct JSX key property. */
|
|
4
|
+
operationKeys?: Record<string, string>;
|
|
5
|
+
};
|
|
6
|
+
export type ImportedModuleContract = {
|
|
7
|
+
/** Named hook exports whose direct return value is reactive. */
|
|
8
|
+
hooks?: string[];
|
|
9
|
+
/** Named component exports and their accepted prop contracts. */
|
|
10
|
+
components?: Record<string, string[] | ImportedComponentContract>;
|
|
11
|
+
};
|
|
12
|
+
export type ModuleMetadata = {
|
|
13
|
+
/** Contracts keyed by the import source exactly as written in this module. */
|
|
14
|
+
imports?: Record<string, ImportedModuleContract>;
|
|
15
|
+
/** @deprecated Local-name contracts retained for existing integrations. */
|
|
16
|
+
importedHooks?: string[];
|
|
17
|
+
/** @deprecated Local-name contracts retained for existing integrations. */
|
|
18
|
+
importedComponents?: Record<string, string[] | ImportedComponentContract>;
|
|
19
|
+
operationProps?: string[];
|
|
20
|
+
/** Exported operation-bearing prop to its required direct JSX key property. */
|
|
21
|
+
operationKeyProps?: Record<string, string>;
|
|
22
|
+
};
|
|
23
|
+
export type ModuleContractManifest = {
|
|
24
|
+
/** Absolute directory against which module keys are resolved. */
|
|
25
|
+
root: string;
|
|
26
|
+
/** Exact root-relative module IDs, using forward slashes. */
|
|
27
|
+
modules: Record<string, ModuleMetadata>;
|
|
28
|
+
};
|
|
29
|
+
/** Validate and retain a build-tool module contract manifest. */
|
|
30
|
+
export declare function defineModuleContracts<T extends ModuleContractManifest>(manifest: T): T;
|
|
31
|
+
/** Resolve metadata by exact module identity. Query strings are ignored. */
|
|
32
|
+
export declare function resolveModuleMetadata(manifest: ModuleContractManifest, moduleId: string): ModuleMetadata;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
function normalized(value) {
|
|
2
|
+
return value.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
3
|
+
}
|
|
4
|
+
/** Validate and retain a build-tool module contract manifest. */
|
|
5
|
+
export function defineModuleContracts(manifest) {
|
|
6
|
+
const seen = new Set();
|
|
7
|
+
for (const key of Object.keys(manifest.modules)) {
|
|
8
|
+
const clean = normalized(key).replace(/^\.\//, '');
|
|
9
|
+
if (!clean || clean.startsWith('/') || clean === '..' || clean.startsWith('../')) {
|
|
10
|
+
throw new Error(`runOnce: module contract key must be root-relative: ${key}`);
|
|
11
|
+
}
|
|
12
|
+
if (clean !== key)
|
|
13
|
+
throw new Error(`runOnce: module contract key is not normalized: ${key}`);
|
|
14
|
+
if (seen.has(clean))
|
|
15
|
+
throw new Error(`runOnce: duplicate module contract key: ${key}`);
|
|
16
|
+
seen.add(clean);
|
|
17
|
+
}
|
|
18
|
+
return manifest;
|
|
19
|
+
}
|
|
20
|
+
/** Resolve metadata by exact module identity. Query strings are ignored. */
|
|
21
|
+
export function resolveModuleMetadata(manifest, moduleId) {
|
|
22
|
+
const root = normalized(manifest.root);
|
|
23
|
+
const id = normalized(moduleId.split('?')[0]);
|
|
24
|
+
const prefix = `${root}/`;
|
|
25
|
+
if (!id.startsWith(prefix))
|
|
26
|
+
throw new Error(`runOnce: module is outside the contract root: ${moduleId}`);
|
|
27
|
+
const relative = id.slice(prefix.length);
|
|
28
|
+
const metadata = manifest.modules[relative];
|
|
29
|
+
if (!metadata)
|
|
30
|
+
throw new Error(`runOnce: no module contract for ${relative}`);
|
|
31
|
+
return metadata;
|
|
32
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { NodePath } from '@babel/core';
|
|
2
|
+
import * as t from '@babel/types';
|
|
3
|
+
import type { ModuleMetadata } from './module-contracts.js';
|
|
4
|
+
export declare function compileRunOnce(program: NodePath<t.Program>, derive: () => t.Identifier, choose: () => t.Identifier, rendererHelper: (name: 'operationList' | 'listAppend' | 'listPrepend' | 'listClear' | 'listTruncate' | 'listSplice' | 'listReverse' | 'listFilter' | 'listSort' | 'listMap' | 'listMove') => t.Identifier, moduleMetadata?: ModuleMetadata): void;
|