@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 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-reactive-react
1
+ # @rrjs/babel-plugin
2
2
 
3
- Babel plugin that compiles JSX to `h()` calls compatible with `@rrjs/renderer`. Dynamic expressions inside JSX are wrapped in thunks so the renderer can establish reactive bindings.
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-reactive-react @babel/core
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-reactive-react'
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
- In your entry file:
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 { h } from '@rrjs/renderer'
46
- ;(globalThis as any).h = h
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 assumes `h` is in scope where JSX is used.
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
- // ─── The Plugin ──────────────────────────────────────────────────────────────
4
- // Transforms JSX into h() calls.
5
- // Dynamic expressions (signals, computeds, variables that may change)
6
- // are wrapped in thunks so the renderer can subscribe them to signal changes.
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
- JSXElement(path) {
13
- const replacement = transformElement(path.node);
14
- path.replaceWith(replacement);
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 children = filterChildren(path.node.children).map(transformChild);
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
- const props = transformProps(openingElement.attributes, isNativeElement);
61
- const children = filterChildren(element.children).map(transformChild);
62
- return t.callExpression(t.identifier('h'), [
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
- // Call expressions (like h(...) or list(...)) return Nodes directly
144
- // and must NOT be wrapped in a thunk.
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
- if (!t.isArrowFunctionExpression(callback) && !t.isFunctionExpression(callback))
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
- // Callback should have at least one parameter (the item)
185
- if (callback.params.length === 0)
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
- // Find a top-level return statement
198
- for (const stmt of callback.body.body) {
199
- if (t.isReturnStatement(stmt) && t.isJSXElement(stmt.argument)) {
200
- returnedJsx = stmt.argument;
201
- break;
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(t.identifier('list'), [
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
- // Strip whitespace-only JSXText nodes (caused by JSX formatting)
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
- // Trim and check if anything remains
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;