@opentf/web 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/CHANGELOG.md ADDED
@@ -0,0 +1,12 @@
1
+ # @opentf/web
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Global rebranding to Web App Framework and monorepo restructuring.
8
+ - Renamed all packages from `@opentf/mwaf-*` to `@opentf/web-*`.
9
+ - Updated Custom Element prefix from `mwaf-` to `web-`.
10
+ - Restructured the repository into a scoped monorepo.
11
+ - Updated scaffolding tool `@opentf/create-web` with new templates.
12
+ - Aligned all package versions to 0.1.0-alpha.1.
@@ -0,0 +1,730 @@
1
+ const { addNamed } = require("@babel/helper-module-imports");
2
+ const path = require("path");
3
+
4
+ module.exports = function (babel) {
5
+ const { types: t } = babel;
6
+
7
+ return {
8
+ name: "web-compiler",
9
+ visitor: {
10
+ Program: {
11
+ enter(p, state) {
12
+ state.importsNeeded = new Map();
13
+ state.importSources = new Map();
14
+ state.components = new Map(); // name -> { path, isDefault, isPage }
15
+
16
+ const filename = state.filename || "";
17
+ const pagePatterns = ["page.jsx", "page.tsx", "layout.jsx", "layout.tsx", "404.jsx", "404.tsx"];
18
+ state.isPageFile = pagePatterns.some(p => filename.endsWith(p));
19
+ },
20
+ exit(p, state) {
21
+ // Process all collected components
22
+ state.components.forEach((info, name) => {
23
+ const isRenderFn = state.isPageFile && info.isDefault;
24
+ transformComponent(info.path, name, isRenderFn, t, state);
25
+ });
26
+ }
27
+ },
28
+
29
+ FunctionDeclaration(path, state) {
30
+ if (!path.node.id) return;
31
+ const name = path.node.id.name;
32
+ if (/^[A-Z]/.test(name)) {
33
+ // Check if this function actually uses JSX. If not, it's just a regular function, not a component.
34
+ let hasJSX = false;
35
+ path.traverse({
36
+ JSXElement() { hasJSX = true; },
37
+ JSXFragment() { hasJSX = true; }
38
+ });
39
+
40
+ if (!hasJSX) return;
41
+
42
+ const propsNode = path.node.params[0];
43
+ const observedAttributes = new Set();
44
+
45
+ if (t.isObjectPattern(propsNode)) {
46
+ propsNode.properties.forEach(prop => {
47
+ if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
48
+ observedAttributes.add(prop.key.name);
49
+ }
50
+ });
51
+ }
52
+
53
+ if (state.components.has(name)) {
54
+ const info = state.components.get(name);
55
+ info.observedAttributes = observedAttributes;
56
+ info.path = path;
57
+ } else {
58
+ state.components.set(name, { path, isDefault: false, observedAttributes });
59
+ }
60
+ }
61
+ },
62
+
63
+ ExportDefaultDeclaration(path, state) {
64
+ const decl = path.node.declaration;
65
+ if (t.isFunctionDeclaration(decl)) {
66
+ let hasJSX = false;
67
+ path.traverse({
68
+ JSXElement() { hasJSX = true; },
69
+ JSXFragment() { hasJSX = true; }
70
+ });
71
+
72
+ if (!hasJSX) return;
73
+
74
+ if (decl.id) {
75
+ const name = decl.id.name;
76
+ if (state.components.has(name)) {
77
+ state.components.get(name).isDefault = true;
78
+ } else {
79
+ state.components.set(name, { path: path.get("declaration"), isDefault: true });
80
+ }
81
+ } else {
82
+ // Anonymous function
83
+ state.components.set("_default", { path: path.get("declaration"), isDefault: true });
84
+ }
85
+ }
86
+ },
87
+
88
+ ImportDeclaration(path, state) {
89
+ const source = path.node.source.value;
90
+ path.node.specifiers.forEach(spec => {
91
+ if (t.isImportDefaultSpecifier(spec) || t.isImportSpecifier(spec)) {
92
+ state.importSources.set(spec.local.name, source);
93
+ }
94
+ });
95
+ },
96
+
97
+ CallExpression(path, state) {
98
+ if (t.isIdentifier(path.node.callee)) {
99
+ const name = path.node.callee.name;
100
+ const getImport = (importName, source) => {
101
+ const key = `${importName}:${source}`;
102
+ if (!state.importsNeeded.has(key)) {
103
+ state.importsNeeded.set(key, addNamed(path, importName, source));
104
+ }
105
+ return state.importsNeeded.get(key);
106
+ };
107
+
108
+ if (name === "$state" || name === "$derived" || name === "$ref") {
109
+ // Track this variable as a state variable in the current scope
110
+ const parent = path.findParent(p => p.isVariableDeclarator());
111
+ if (parent && t.isIdentifier(parent.node.id)) {
112
+ if (!state.stateVars) state.stateVars = new Set();
113
+ state.stateVars.add(parent.node.id.name);
114
+ }
115
+ if (name === "$state") {
116
+ path.get("callee").replaceWith(getImport("signal", "@preact/signals-core"));
117
+ } else if (name === "$derived") {
118
+ path.get("callee").replaceWith(getImport("computed", "@preact/signals-core"));
119
+ } else if (name === "$ref") {
120
+ // Track this variable as a ref variable
121
+ const parent = path.findParent(p => p.isVariableDeclarator());
122
+ if (parent && t.isIdentifier(parent.node.id)) {
123
+ if (!state.refVars) state.refVars = new Set();
124
+ state.refVars.add(parent.node.id.name);
125
+ if (!state.stateVars) state.stateVars = new Set();
126
+ state.stateVars.add(parent.node.id.name);
127
+ }
128
+ path.get("callee").replaceWith(getImport("signal", "@preact/signals-core"));
129
+ }
130
+ } else if (name === "$effect") {
131
+ path.get("callee").replaceWith(getImport("effect", "@preact/signals-core"));
132
+ } else if (name === "$expose") {
133
+ // $expose({ a, b }) -> Object.assign(this, { a, b })
134
+ path.get("callee").replaceWith(t.memberExpression(t.identifier("Object"), t.identifier("assign")));
135
+ path.node.arguments.unshift(t.thisExpression());
136
+ } else if (name === "onMount") {
137
+ path.get("callee").replaceWith(getImport("onMount", "@opentf/web"));
138
+ } else if (name === "onCleanup") {
139
+ path.get("callee").replaceWith(getImport("onCleanup", "@opentf/web"));
140
+ } else if (name === "$renderDynamic") {
141
+ path.get("callee").replaceWith(getImport("renderDynamic", "@opentf/web"));
142
+ }
143
+ }
144
+ },
145
+
146
+ Identifier(path, state) {
147
+ if (!state.stateVars || !state.stateVars.has(path.node.name)) return;
148
+ if (path.node._processed) return;
149
+
150
+ // Strictly forbid manual .value access on $state variables
151
+ if (path.parentPath.isMemberExpression() && !path.parentPath.node.computed) {
152
+ if (t.isIdentifier(path.parentPath.node.property, { name: "value" })) {
153
+ throw path.parentPath.buildCodeFrameError(
154
+ `Manual .value access is forbidden for variables declared with $state. The Web App Framework compiler handles this automatically. Remove the .value from '${path.node.name}.value'.`
155
+ );
156
+ }
157
+ if (path.parentPath.node.property === path.node) return;
158
+ }
159
+
160
+ if (path.parentPath.isVariableDeclarator() && path.parentPath.node.id === path.node) return;
161
+ if (path.parentPath.isObjectProperty() && path.parentPath.node.key === path.node && !path.parentPath.node.computed) return;
162
+ if (path.parentPath.isClassProperty() && path.parentPath.node.key === path.node) return;
163
+ if (path.parentPath.isClassMethod() && path.parentPath.node.key === path.node) return;
164
+
165
+ const innerId = t.identifier(path.node.name);
166
+ innerId._processed = true;
167
+ const newNode = t.memberExpression(innerId, t.identifier("value"));
168
+ path.replaceWith(newNode);
169
+ },
170
+
171
+ AssignmentExpression(path, state) {
172
+ if (t.isIdentifier(path.node.left) && state.stateVars?.has(path.node.left.name)) {
173
+ const innerId = t.identifier(path.node.left.name);
174
+ innerId._processed = true;
175
+ path.node.left = t.memberExpression(innerId, t.identifier("value"));
176
+ }
177
+ },
178
+
179
+ UpdateExpression(path, state) {
180
+ if (t.isIdentifier(path.node.argument) && state.stateVars?.has(path.node.argument.name)) {
181
+ const innerId = t.identifier(path.node.argument.name);
182
+ innerId._processed = true;
183
+ path.node.argument = t.memberExpression(innerId, t.identifier("value"));
184
+ }
185
+ },
186
+
187
+ JSXElement(path, state) {
188
+ handleJSXVisitor(path, state, t);
189
+ },
190
+
191
+ JSXFragment(path, state) {
192
+ handleJSXVisitor(path, state, t);
193
+ }
194
+ }
195
+ };
196
+
197
+ function handleJSXVisitor(path, state, t) {
198
+ if (path.node._processed) return;
199
+ const getImport = (importName, source) => {
200
+ const key = `${importName}:${source}`;
201
+ if (!state.importsNeeded.has(key)) {
202
+ state.importsNeeded.set(key, addNamed(path, importName, source));
203
+ }
204
+ return state.importsNeeded.get(key);
205
+ };
206
+
207
+ const { statements, rootId, signals } = transformJSX(path.node, t, state, getImport, path);
208
+
209
+ // Propagate signals to parent component for observedAttributes
210
+ const parentFunc = path.findParent(p =>
211
+ p.isFunctionDeclaration() && (
212
+ /^[A-Z]/.test(p.node.id?.name) ||
213
+ (p.parentPath.isExportDefaultDeclaration() && state.isPageFile)
214
+ )
215
+ );
216
+
217
+ if (parentFunc) {
218
+ let compName = parentFunc.node.id?.name;
219
+ if (!compName && parentFunc.parentPath.isExportDefaultDeclaration()) {
220
+ compName = "_default";
221
+ }
222
+ if (compName) {
223
+ const info = state.components.get(compName);
224
+ if (info) {
225
+ if (!info.observedAttributes) info.observedAttributes = new Set();
226
+ signals.forEach(s => info.observedAttributes.add(s));
227
+ }
228
+ }
229
+ }
230
+
231
+ const iife = t.callExpression(t.arrowFunctionExpression([], t.blockStatement([
232
+ ...statements,
233
+ t.returnStatement(rootId)
234
+ ])), []);
235
+ iife._processed = true;
236
+ path.replaceWith(iife);
237
+ }
238
+
239
+ function transformComponent(componentPath, name, isRenderFn, t, state) {
240
+ const node = componentPath.node;
241
+ const body = node.body;
242
+
243
+ const getImport = (importName, source) => {
244
+ const key = `${importName}:${source}`;
245
+ if (!state.importsNeeded.has(key)) {
246
+ state.importsNeeded.set(key, addNamed(componentPath, importName, source));
247
+ }
248
+ return state.importsNeeded.get(key);
249
+ };
250
+
251
+ let jsxNode = null;
252
+ const originalStatements = [];
253
+
254
+ body.body.forEach(stmt => {
255
+ if (t.isReturnStatement(stmt)) {
256
+ jsxNode = stmt.argument;
257
+ } else {
258
+ originalStatements.push(stmt);
259
+ }
260
+ });
261
+
262
+ if (!jsxNode) return;
263
+
264
+ const res = transformJSX(jsxNode, t, state, getImport, componentPath);
265
+ let statements = res.statements;
266
+ let rootId = res.rootId;
267
+ let signals = res.signals;
268
+
269
+ // Capture the root element in a variable to avoid re-evaluating the IIFE if it is one
270
+ const rootVar = t.identifier("rootElement");
271
+ statements.push(t.variableDeclaration("const", [t.variableDeclarator(rootVar, rootId)]));
272
+ rootId = rootVar;
273
+
274
+
275
+
276
+ const componentInfo = state.components.get(name);
277
+
278
+ // Prop Transformation: If the component uses destructuring in params,
279
+ // we convert it to use `props` and replace usages to maintain reactivity.
280
+ if (!isRenderFn) {
281
+ const propsNode = node.params[0];
282
+ if (t.isObjectPattern(propsNode)) {
283
+ const propNames = new Set();
284
+ propsNode.properties.forEach(prop => {
285
+ if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
286
+ propNames.add(prop.key.name);
287
+ }
288
+ });
289
+
290
+ node.params[0] = t.identifier("props");
291
+
292
+ const replaceProps = (node) => {
293
+ if (!node || typeof node !== "object") return;
294
+
295
+ if (Array.isArray(node)) {
296
+ for (let i = 0; i < node.length; i++) {
297
+ const child = node[i];
298
+ if (t.isIdentifier(child) && propNames.has(child.name)) {
299
+ node[i] = t.memberExpression(t.identifier("props"), t.identifier(child.name));
300
+ } else {
301
+ replaceProps(child);
302
+ }
303
+ }
304
+ return;
305
+ }
306
+
307
+ for (const key in node) {
308
+ const child = node[key];
309
+ if (t.isIdentifier(child) && propNames.has(child.name)) {
310
+ // Only replace if it's NOT a property key in an object or member expression
311
+ if (t.isObjectProperty(node) && key === "key" && !node.computed) continue;
312
+ if (t.isMemberExpression(node) && key === "property" && !node.computed) continue;
313
+ if (t.isClassMethod(node) && key === "key") continue;
314
+ if (t.isJSXAttribute(node) && key === "name") continue;
315
+
316
+ node[key] = t.memberExpression(t.identifier("props"), t.identifier(child.name));
317
+ } else {
318
+ replaceProps(child);
319
+ }
320
+ }
321
+ };
322
+
323
+ replaceProps(originalStatements);
324
+ replaceProps(statements);
325
+ }
326
+ }
327
+
328
+
329
+
330
+ const allSignals = new Set([...signals, ...(componentInfo?.observedAttributes || [])]);
331
+
332
+
333
+ if (isRenderFn) {
334
+ // ... (rest of render logic remains same)
335
+ // Transform to export function render(root) { ... }
336
+ const renderFn = t.functionDeclaration(
337
+ t.identifier("render"),
338
+ [t.identifier("root"), t.identifier("props")],
339
+ t.blockStatement([
340
+ ...(t.isObjectPattern(node.params[0]) ? [t.variableDeclaration("const", [t.variableDeclarator(node.params[0], t.identifier("props"))])] : []),
341
+ ...originalStatements,
342
+ ...statements,
343
+ t.expressionStatement(
344
+ t.callExpression(
345
+ t.memberExpression(t.identifier("root"), t.identifier("appendChild")),
346
+ [rootId]
347
+ )
348
+ )
349
+ ])
350
+ );
351
+
352
+ const parent = componentPath.parentPath;
353
+ if (parent.isExportDefaultDeclaration()) {
354
+ parent.replaceWith(t.exportNamedDeclaration(renderFn));
355
+ } else {
356
+ componentPath.replaceWith(renderFn);
357
+ }
358
+ } else {
359
+ // Transform to Web Component Class
360
+ const tagName = "web-" + name.toLowerCase();
361
+ const observedAttributes = Array.from(allSignals);
362
+ const signalId = getImport("signal", "@preact/signals-core");
363
+ const createPropsProxyId = getImport("createPropsProxy", "@opentf/web");
364
+ const classId = t.identifier(name + "Element");
365
+
366
+ const classDecl = t.classDeclaration(
367
+ classId,
368
+ t.identifier("HTMLElement"),
369
+ t.classBody([
370
+ t.classProperty(t.identifier("observedAttributes"), t.arrayExpression(observedAttributes.map(s => t.stringLiteral(s))), null, null, false, true),
371
+ // Add getters and setters for each observed attribute
372
+ ...observedAttributes.map(s => t.classMethod(
373
+ "set",
374
+ t.identifier(s),
375
+ [t.identifier("val")],
376
+ t.blockStatement([
377
+ t.expressionStatement(t.assignmentExpression(
378
+ "=",
379
+ t.memberExpression(
380
+ t.memberExpression(
381
+ t.memberExpression(t.thisExpression(), t.identifier("_propsSignals")),
382
+ t.stringLiteral(s),
383
+ true
384
+ ),
385
+ t.identifier("value")
386
+ ),
387
+ t.identifier("val")
388
+ )),
389
+ // Sync back to native attributes for class and style
390
+ ...(s === "className" ? [
391
+ t.expressionStatement(t.callExpression(t.memberExpression(t.thisExpression(), t.identifier("setAttribute")), [t.stringLiteral("class"), t.identifier("val")]))
392
+ ] : s === "style" ? [
393
+ t.ifStatement(
394
+ t.logicalExpression("&&", t.identifier("val"), t.binaryExpression("===", t.unaryExpression("typeof", t.identifier("val"), false), t.stringLiteral("object"))),
395
+ t.expressionStatement(t.callExpression(t.memberExpression(t.identifier("Object"), t.identifier("assign")), [t.memberExpression(t.thisExpression(), t.identifier("style")), t.identifier("val")]))
396
+ )
397
+ ] : [])
398
+ ])
399
+ )),
400
+ ...observedAttributes.map(s => t.classMethod(
401
+ "get",
402
+ t.identifier(s),
403
+ [],
404
+ t.blockStatement([
405
+ t.returnStatement(t.memberExpression(
406
+ t.memberExpression(
407
+ t.memberExpression(t.thisExpression(), t.identifier("_propsSignals")),
408
+ t.stringLiteral(s),
409
+ true
410
+ ),
411
+ t.identifier("value")
412
+ ))
413
+ ])
414
+ )),
415
+ t.classMethod("constructor", t.identifier("constructor"), [], t.blockStatement([
416
+ t.expressionStatement(t.callExpression(t.super(), [])),
417
+ t.expressionStatement(t.assignmentExpression("=", t.memberExpression(t.thisExpression(), t.identifier("_propsSignals")), t.objectExpression(
418
+ observedAttributes.map(s => t.objectProperty(t.identifier(s), t.callExpression(signalId, [t.nullLiteral()])))
419
+ )))
420
+ ])),
421
+ t.classMethod("method", t.identifier("attributeChangedCallback"), [t.identifier("name"), t.identifier("_"), t.identifier("value")], t.blockStatement([
422
+ t.expressionStatement(t.assignmentExpression("=", t.memberExpression(t.memberExpression(t.memberExpression(t.thisExpression(), t.identifier("_propsSignals")), t.identifier("name"), true), t.identifier("value")), t.identifier("value")))
423
+ ])),
424
+ t.classMethod("method", t.identifier("connectedCallback"), [], t.blockStatement([
425
+ t.expressionStatement(t.assignmentExpression("=", t.memberExpression(t.thisExpression(), t.identifier("_onMounts")), t.arrayExpression([]))),
426
+ t.expressionStatement(t.assignmentExpression("=", t.memberExpression(t.thisExpression(), t.identifier("_onCleanups")), t.arrayExpression([]))),
427
+ t.variableDeclaration("const", [t.variableDeclarator(t.identifier("props"), t.callExpression(createPropsProxyId, [t.thisExpression()]))]),
428
+ t.expressionStatement(t.assignmentExpression("=", t.memberExpression(t.thisExpression(), t.identifier("_children")), t.callExpression(t.memberExpression(t.identifier("Array"), t.identifier("from")), [t.memberExpression(t.thisExpression(), t.identifier("childNodes"))]))),
429
+ t.whileStatement(t.memberExpression(t.thisExpression(), t.identifier("firstChild")), t.expressionStatement(t.callExpression(t.memberExpression(t.thisExpression(), t.identifier("removeChild")), [t.memberExpression(t.thisExpression(), t.identifier("firstChild"))]))),
430
+
431
+ // Wrap setup in withInstance(this, () => { ... })
432
+ t.expressionStatement(t.callExpression(getImport("withInstance", "@opentf/web"), [
433
+ t.thisExpression(),
434
+ t.arrowFunctionExpression([], t.blockStatement([
435
+ ...originalStatements,
436
+ ...statements,
437
+ t.expressionStatement(t.callExpression(t.memberExpression(t.thisExpression(), t.identifier("appendChild")), [rootId]))
438
+ ]))
439
+ ])),
440
+ // Run onMounts
441
+ t.expressionStatement(t.callExpression(t.memberExpression(t.memberExpression(t.thisExpression(), t.identifier("_onMounts")), t.identifier("forEach")), [t.arrowFunctionExpression([t.identifier("fn")], t.callExpression(t.identifier("fn"), []))]))
442
+ ])),
443
+
444
+
445
+ t.classMethod("method", t.identifier("disconnectedCallback"), [], t.blockStatement([
446
+ t.expressionStatement(t.callExpression(t.memberExpression(t.memberExpression(t.thisExpression(), t.identifier("_onCleanups")), t.identifier("forEach")), [t.arrowFunctionExpression([t.identifier("fn")], t.callExpression(t.identifier("fn"), []))]))
447
+ ]))
448
+ ])
449
+ );
450
+
451
+ const parent = componentPath.parentPath;
452
+
453
+ if (parent.isExportDefaultDeclaration()) {
454
+ componentPath.parentPath.insertBefore(classDecl);
455
+ componentPath.parentPath.insertBefore(t.expressionStatement(
456
+ t.callExpression(
457
+ t.memberExpression(t.identifier("customElements"), t.identifier("define")),
458
+ [t.stringLiteral(tagName), classId]
459
+ )
460
+ ));
461
+ parent.replaceWith(t.exportDefaultDeclaration(classId));
462
+ } else if (parent.isExportNamedDeclaration()) {
463
+ // For named exports, we transform the function into an exported class directly
464
+ const exportedClass = t.classDeclaration(
465
+ t.identifier(name),
466
+ t.identifier("HTMLElement"),
467
+ classDecl.body
468
+ );
469
+ parent.replaceWith(t.exportNamedDeclaration(exportedClass, []));
470
+ parent.insertAfter(t.expressionStatement(
471
+ t.callExpression(
472
+ t.memberExpression(t.identifier("customElements"), t.identifier("define")),
473
+ [t.stringLiteral(tagName), t.identifier(name)]
474
+ )
475
+ ));
476
+ } else {
477
+ componentPath.insertBefore(classDecl);
478
+ componentPath.insertBefore(t.expressionStatement(
479
+ t.callExpression(
480
+ t.memberExpression(t.identifier("customElements"), t.identifier("define")),
481
+ [t.stringLiteral(tagName), classId]
482
+ )
483
+ ));
484
+ componentPath.remove();
485
+ }
486
+ }
487
+ }
488
+
489
+ function transformJSX(node, t, state, getImport, path) {
490
+ if (node._processed) return { statements: [], rootId: node, signals: new Set() };
491
+ node._processed = true;
492
+
493
+ const statements = [];
494
+
495
+ const signals = new Set();
496
+ let counter = 0;
497
+ const nextId = (prefix = "el") => t.identifier(prefix + (counter++));
498
+ const toKebabCase = (str) => str.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
499
+
500
+ function collectSignals(exprNode) {
501
+ if (!exprNode || typeof exprNode !== "object") return;
502
+ if (Array.isArray(exprNode)) {
503
+ exprNode.forEach(collectSignals);
504
+ return;
505
+ }
506
+ if (t.isMemberExpression(exprNode) && t.isIdentifier(exprNode.object, { name: "props" }) && t.isIdentifier(exprNode.property)) {
507
+ signals.add(exprNode.property.name);
508
+ }
509
+ Object.keys(exprNode).forEach(key => {
510
+ if (key === "property" && t.isMemberExpression(exprNode) && !exprNode.computed) return;
511
+ collectSignals(exprNode[key]);
512
+ });
513
+ }
514
+
515
+ function processNode(n, parentElId) {
516
+ if (t.isJSXElement(n)) {
517
+ const tagName = n.openingElement.name.name;
518
+ const isComponent = /^[A-Z]/.test(tagName);
519
+ const elId = nextId();
520
+
521
+ if (isComponent) {
522
+ const componentTagName = "web-" + tagName.toLowerCase();
523
+ statements.push(t.variableDeclaration("const", [
524
+ t.variableDeclarator(elId, t.callExpression(t.memberExpression(t.identifier("document"), t.identifier("createElement")), [t.stringLiteral(componentTagName)]))
525
+ ]));
526
+
527
+ n.openingElement.attributes.forEach(attr => {
528
+ if (t.isJSXSpreadAttribute(attr)) {
529
+ const applySpreadId = getImport("applySpread", "@opentf/web");
530
+ statements.push(t.expressionStatement(t.callExpression(applySpreadId, [elId, attr.argument])));
531
+ return;
532
+ }
533
+ const name = attr.name.name;
534
+ const value = attr.value;
535
+ const targetProp = name === "class" || name === "className" ? "className" : name;
536
+
537
+ if (t.isJSXExpressionContainer(value)) {
538
+ if (name === "ref" && t.isIdentifier(value.expression) && state.refVars?.has(value.expression.name)) {
539
+ const refName = value.expression.name;
540
+ const innerId = t.identifier(refName);
541
+ innerId._processed = true;
542
+ statements.push(t.expressionStatement(t.assignmentExpression(
543
+ "=",
544
+ t.memberExpression(innerId, t.identifier("value")),
545
+ elId
546
+ )));
547
+ return;
548
+ }
549
+ collectSignals(value.expression);
550
+ const effectId = getImport("effect", "@preact/signals-core");
551
+ statements.push(t.expressionStatement(t.callExpression(effectId, [
552
+ t.arrowFunctionExpression([], t.assignmentExpression("=", t.memberExpression(elId, t.identifier(targetProp)), value.expression))
553
+ ])));
554
+ } else {
555
+ statements.push(t.expressionStatement(t.assignmentExpression("=", t.memberExpression(elId, t.identifier(targetProp)), value)));
556
+ }
557
+ });
558
+
559
+
560
+ n.children.forEach(child => {
561
+ const childId = processNode(child, elId);
562
+ if (childId) statements.push(t.expressionStatement(t.callExpression(t.memberExpression(elId, t.identifier("appendChild")), [childId])));
563
+ });
564
+ return elId;
565
+ }
566
+
567
+ const svgTags = ['svg', 'path', 'polyline', 'line', 'circle', 'rect', 'ellipse', 'polygon', 'g', 'text', 'tspan', 'defs', 'lineargradient', 'stop'];
568
+ const isSvg = svgTags.includes(tagName.toLowerCase());
569
+
570
+ statements.push(t.variableDeclaration("const", [
571
+ t.variableDeclarator(
572
+ elId,
573
+ isSvg
574
+ ? t.callExpression(t.memberExpression(t.identifier("document"), t.identifier("createElementNS")), [t.stringLiteral("http://www.w3.org/2000/svg"), t.stringLiteral(tagName)])
575
+ : t.callExpression(t.memberExpression(t.identifier("document"), t.identifier("createElement")), [t.stringLiteral(tagName)])
576
+ )
577
+ ]));
578
+
579
+ n.openingElement.attributes.forEach(attr => {
580
+ if (t.isJSXSpreadAttribute(attr)) {
581
+ const applySpreadId = getImport("applySpread", "@opentf/web");
582
+ statements.push(t.expressionStatement(t.callExpression(applySpreadId, [elId, attr.argument])));
583
+ return;
584
+ }
585
+ const originalName = attr.name.name;
586
+ const name = originalName.toLowerCase();
587
+ const value = attr.value;
588
+
589
+ if (name.startsWith("on")) {
590
+ if (t.isJSXExpressionContainer(value) && t.isJSXEmptyExpression(value.expression)) return;
591
+ statements.push(t.expressionStatement(t.assignmentExpression("=", t.memberExpression(elId, t.identifier(name)), t.isJSXExpressionContainer(value) ? value.expression : value)));
592
+ } else if (t.isJSXExpressionContainer(value)) {
593
+ if (t.isJSXEmptyExpression(value.expression)) return;
594
+
595
+ if (originalName === "ref" && t.isIdentifier(value.expression) && state.refVars?.has(value.expression.name)) {
596
+ const refName = value.expression.name;
597
+ const innerId = t.identifier(refName);
598
+ innerId._processed = true;
599
+ statements.push(t.expressionStatement(t.assignmentExpression(
600
+ "=",
601
+ t.memberExpression(innerId, t.identifier("value")),
602
+ elId
603
+ )));
604
+ return;
605
+ }
606
+
607
+ collectSignals(value.expression);
608
+
609
+ const effectId = getImport("effect", "@preact/signals-core");
610
+ const attrProp = (name === "class" || name === "classname") ? "className" : name;
611
+ const isStyle = attrProp === "style";
612
+ const isProperty = ["className", "style", "value", "checked", "id", "title", "href", "src", "key"].includes(attrProp);
613
+
614
+ if (attrProp === "key") {
615
+ statements.push(t.expressionStatement(t.assignmentExpression("=", t.memberExpression(elId, t.identifier("_key")), value.expression)));
616
+ } else {
617
+ statements.push(t.expressionStatement(t.callExpression(effectId, [t.arrowFunctionExpression([],
618
+ isStyle ? t.callExpression(t.memberExpression(t.identifier("Object"), t.identifier("assign")), [t.memberExpression(elId, t.identifier("style")), value.expression])
619
+ : (isProperty && (!isSvg || attrProp !== "className")) ? t.assignmentExpression("=", t.memberExpression(elId, t.identifier(attrProp)), value.expression)
620
+ : t.callExpression(t.memberExpression(elId, t.identifier("setAttribute")), [t.stringLiteral(attrProp === "className" ? "class" : toKebabCase(originalName)), value.expression])
621
+ )])));
622
+ }
623
+ } else {
624
+ const attrProp = (name === "class" || name === "classname") ? "className" : name;
625
+ const isProperty = ["className", "style", "value", "checked", "id", "title", "href", "src", "key"].includes(attrProp);
626
+
627
+ if (isProperty && (!isSvg || attrProp !== "className")) {
628
+ statements.push(t.expressionStatement(t.assignmentExpression("=", t.memberExpression(elId, t.identifier(attrProp === "key" ? "_key" : attrProp)), value)));
629
+ } else {
630
+ statements.push(t.expressionStatement(t.callExpression(t.memberExpression(elId, t.identifier("setAttribute")), [t.stringLiteral(attrProp === "className" ? "class" : toKebabCase(originalName)), value])));
631
+ }
632
+ }
633
+ });
634
+
635
+ n.children.forEach(child => {
636
+ const childId = processNode(child, elId);
637
+ if (childId) statements.push(t.expressionStatement(t.callExpression(t.memberExpression(elId, t.identifier("appendChild")), [childId])));
638
+ });
639
+ return elId;
640
+ } else if (t.isJSXText(n)) {
641
+ const text = n.value.replace(/\n\s*/g, "");
642
+ if (!text) return null;
643
+ const textId = nextId("text");
644
+ statements.push(t.variableDeclaration("const", [t.variableDeclarator(textId, t.callExpression(t.memberExpression(t.identifier("document"), t.identifier("createTextNode")), [t.stringLiteral(text)]))]));
645
+ return textId;
646
+ } else if (t.isJSXFragment(n)) {
647
+ const fragId = nextId("frag");
648
+ statements.push(t.variableDeclaration("const", [t.variableDeclarator(fragId, t.callExpression(t.memberExpression(t.identifier("document"), t.identifier("createDocumentFragment")), []))]));
649
+ n.children.forEach(child => {
650
+ const childId = processNode(child, fragId);
651
+ if (childId) statements.push(t.expressionStatement(t.callExpression(t.memberExpression(fragId, t.identifier("appendChild")), [childId])));
652
+ });
653
+ return fragId;
654
+ } else if (t.isJSXExpressionContainer(n)) {
655
+ if (t.isJSXEmptyExpression(n.expression)) return null;
656
+
657
+ collectSignals(n.expression);
658
+
659
+ // Recursive helper to transform nested JSX elements and .map() calls
660
+ const transformExpression = (exprNode) => {
661
+ if (!exprNode || typeof exprNode !== "object") return;
662
+ if (Array.isArray(exprNode)) {
663
+ exprNode.forEach(transformExpression);
664
+ return;
665
+ }
666
+
667
+ Object.keys(exprNode).forEach(key => {
668
+ const child = exprNode[key];
669
+ if (!child || typeof child !== "object") return;
670
+
671
+ // Transform nested JSX elements into imperative IIFEs
672
+ if (t.isJSXElement(child) || t.isJSXFragment(child)) {
673
+ if (child._processed) return;
674
+ const { statements: innerStatements, rootId: innerRootId, signals: innerSignals } = transformJSX(child, t, state, getImport, path);
675
+ innerSignals.forEach(s => signals.add(s));
676
+ exprNode[key] = t.callExpression(t.arrowFunctionExpression([], t.blockStatement([
677
+ ...innerStatements,
678
+ t.returnStatement(innerRootId)
679
+ ])), []);
680
+ exprNode[key]._processed = true;
681
+ }
682
+ // Transform .map() into optimized keyed mapped() call
683
+ else if (t.isCallExpression(child) &&
684
+ t.isMemberExpression(child.callee) &&
685
+ t.isIdentifier(child.callee.property, { name: "map" })) {
686
+
687
+ const mappedId = getImport("mapped", "@opentf/web");
688
+ const sourceArray = child.callee.object;
689
+ const mapFn = child.arguments[0];
690
+
691
+ const mappedInstanceId = nextId("mapped");
692
+ statements.push(t.variableDeclaration("const", [
693
+ t.variableDeclarator(mappedInstanceId, t.callExpression(mappedId, [
694
+ t.arrowFunctionExpression([], sourceArray),
695
+ mapFn
696
+ ]))
697
+ ]));
698
+
699
+ exprNode[key] = t.callExpression(mappedInstanceId, []);
700
+ // Recurse into the map function body if needed
701
+ transformExpression(mapFn);
702
+ }
703
+ else {
704
+ transformExpression(child);
705
+ }
706
+ });
707
+ };
708
+
709
+ const wrapper = { expr: n.expression };
710
+ transformExpression(wrapper);
711
+ const finalExpression = wrapper.expr;
712
+
713
+ const renderDynamicId = getImport("renderDynamic", "@opentf/web");
714
+ statements.push(t.expressionStatement(t.callExpression(renderDynamicId, [
715
+ parentElId,
716
+ t.arrowFunctionExpression([], finalExpression)
717
+ ])));
718
+
719
+ return null;
720
+ } else if (t.isExpression(n)) {
721
+ return n;
722
+ }
723
+ }
724
+
725
+
726
+ const rootId = processNode(node, t.identifier("root"));
727
+ return { statements, rootId, signals };
728
+ }
729
+
730
+ };
package/core/mount.js ADDED
@@ -0,0 +1,32 @@
1
+ import { registerRoutes, navigate, router } from '../router/index'
2
+
3
+ /**
4
+ * Bootstraps the Web App Framework Application.
5
+ *
6
+ * @param {Object} options
7
+ * @param {Object} options.pages - Result of import.meta.glob for pages/layouts
8
+ * @param {Function} options.guard - Optional route guard function
9
+ * @param {string} options.targetId - ID of the root element (default: 'app')
10
+ */
11
+ export function mountApp({ pages, guard, targetId = 'app' } = {}) {
12
+ console.log('🚀 Web App Framework Bootstrapping...');
13
+ const root = document.getElementById(targetId)
14
+
15
+ if (pages) {
16
+ console.log('📑 Registering routes...');
17
+ registerRoutes(pages)
18
+ }
19
+
20
+ if (guard) {
21
+ console.log('🛡️ Activating route guard...');
22
+ router.guard = guard;
23
+ }
24
+
25
+ const initialPath = window.location.pathname + window.location.search + window.location.hash;
26
+ console.log('📍 Navigating to:', initialPath);
27
+ navigate(initialPath, root)
28
+
29
+ window.onpopstate = () => {
30
+ navigate(window.location.pathname + window.location.search + window.location.hash, root)
31
+ }
32
+ }
package/index.js ADDED
@@ -0,0 +1,6 @@
1
+ export { mountApp } from './core/mount.js';
2
+ export * from './runtime/dom.js';
3
+ export * from './runtime/lifecycle.js';
4
+ export * from './runtime/props.js';
5
+ export * from './router/index.js';
6
+ export { default as Link } from './router/Link.jsx';
package/package.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@opentf/web",
3
+ "version": "0.1.0",
4
+ "description": "The core engine of the Web App Framework, including the zero-VDOM compiler and signal-based runtime.",
5
+ "main": "index.js",
6
+ "module": "index.js",
7
+ "type": "module",
8
+ "exports": {
9
+ ".": "./index.js",
10
+ "./compiler": "./compiler/babel-plugin.cjs",
11
+ "./runtime": "./runtime/index.js",
12
+ "./router": "./router/index.js"
13
+ },
14
+ "dependencies": {
15
+ "@preact/signals-core": "^1.14.1",
16
+ "@babel/core": "^7.29.0",
17
+ "@babel/helper-module-imports": "^7.28.6",
18
+ "@babel/plugin-syntax-jsx": "^7.28.6"
19
+ },
20
+ "publishConfig": {
21
+ "access": "public"
22
+ }
23
+ }
@@ -0,0 +1,27 @@
1
+ import { navigate } from "./index"
2
+
3
+ export default function Link(props) {
4
+ return (
5
+ <a
6
+ href={props.href}
7
+ className={props.className}
8
+ style={props.style}
9
+ onclick={(e) => {
10
+ if (
11
+ e.defaultPrevented ||
12
+ e.metaKey ||
13
+ e.ctrlKey ||
14
+ e.shiftKey ||
15
+ e.altKey ||
16
+ e.button !== 0
17
+ ) return
18
+
19
+ e.preventDefault()
20
+ navigate(props.href)
21
+ window.scrollTo(0, 0)
22
+ }}
23
+ >
24
+ {props.children}
25
+ </a>
26
+ )
27
+ }
@@ -0,0 +1,234 @@
1
+ import { withInstance } from '../runtime/lifecycle.js'
2
+ import { signal } from '@preact/signals-core'
3
+
4
+ export const routes = {
5
+ pages: {},
6
+ layouts: {},
7
+ notFound: null
8
+ }
9
+
10
+ const routerSignals = {
11
+ pathname: signal(window.location.pathname),
12
+ searchParams: signal(new URLSearchParams(window.location.search)),
13
+ hash: signal(window.location.hash),
14
+ isGuarding: signal(false),
15
+ guard: null
16
+ };
17
+
18
+ export const router = new Proxy(routerSignals, {
19
+ get: (target, key) => {
20
+ if (key === "push") return (path) => navigate(path);
21
+ if (key === "replace") return (path) => navigate(path, undefined, true);
22
+
23
+ const s = target[key];
24
+ if (s && typeof s === 'object' && 'value' in s && typeof s.subscribe === 'function') {
25
+ return s.value;
26
+ }
27
+ return s;
28
+ },
29
+ set: (target, key, val) => {
30
+ const s = target[key];
31
+ if (s && typeof s === 'object' && 'value' in s && typeof s.subscribe === 'function') {
32
+ s.value = val;
33
+ } else {
34
+ target[key] = val;
35
+ }
36
+ return true;
37
+ }
38
+ });
39
+
40
+ let currentPageInstance = null;
41
+
42
+ export function registerRoutes(pages) {
43
+ for (const path in pages) {
44
+ const isLayout = path.endsWith('layout.jsx') || path.endsWith('layout.tsx');
45
+ const isNotFound = path.endsWith('404.jsx') || path.endsWith('404.tsx');
46
+
47
+ let route = path
48
+ .replace(/^.*\/app/, '')
49
+ .replace(/\/(page|layout|404)\.(jsx|tsx)$/, '');
50
+
51
+ if (route === '') route = '/';
52
+
53
+ if (isNotFound) {
54
+ routes.notFound = pages[path];
55
+ } else if (isLayout) {
56
+ routes.layouts[route] = pages[path];
57
+ } else {
58
+ routes.pages[route] = pages[path];
59
+ }
60
+ }
61
+ }
62
+
63
+ function matchRoute(path) {
64
+ for (const route in routes.pages) {
65
+ const pattern = route
66
+ .replace(/\[\.\.\.([^\]]+)\]/g, '(?<$1>.+)')
67
+ .replace(/\[([^\]]+)\]/g, '(?<$1>[^/]+)');
68
+
69
+ const regex = new RegExp(`^${pattern}$`);
70
+ const match = path.match(regex);
71
+ if (match) {
72
+ const params = { ...(match.groups || {}) };
73
+
74
+ for (const key in params) {
75
+ if (route.includes(`[...${key}]`)) {
76
+ params[key] = params[key].split('/');
77
+ }
78
+ }
79
+
80
+ return {
81
+ page: routes.pages[route],
82
+ params,
83
+ route
84
+ };
85
+ }
86
+ }
87
+ return null;
88
+ }
89
+
90
+
91
+ function scrollToHash(hash) {
92
+ if (!hash) return;
93
+ const id = hash.replace('#', '');
94
+ const el = document.getElementById(id);
95
+ if (el) {
96
+ el.scrollIntoView({ behavior: 'smooth' });
97
+ }
98
+ }
99
+
100
+ export async function navigate(path, root = document.getElementById("app"), replace = false, isPopState = false) {
101
+ if (!path) return;
102
+
103
+ const url = new URL(path, window.location.origin);
104
+ const pathname = url.pathname;
105
+ const oldPathname = window.location.pathname;
106
+ const oldSearch = window.location.search;
107
+ const oldHash = window.location.hash;
108
+
109
+ // 1. Run Route Guard if registered
110
+ if (router.guard) {
111
+ const match = matchRoute(pathname);
112
+ const to = {
113
+ path: pathname,
114
+ fullPath: pathname + url.search + url.hash,
115
+ params: match?.params || {},
116
+ query: Object.fromEntries(new URLSearchParams(url.search))
117
+ };
118
+
119
+ routerSignals.isGuarding.value = true;
120
+
121
+ try {
122
+ await new Promise((resolve, reject) => {
123
+ const tools = {
124
+ next: () => resolve(),
125
+ redirect: (p) => {
126
+ navigate(p, root, false);
127
+ reject('redirected');
128
+ },
129
+ replace: (p) => {
130
+ navigate(p, root, true);
131
+ reject('redirected');
132
+ }
133
+ };
134
+
135
+ // Execute guard
136
+ Promise.resolve(routerSignals.guard(to, tools)).catch(reject);
137
+ });
138
+ } catch (e) {
139
+ routerSignals.isGuarding.value = false;
140
+ if (e === 'redirected') return;
141
+ console.error('Route Guard Error:', e);
142
+ return;
143
+ }
144
+
145
+ routerSignals.isGuarding.value = false;
146
+ }
147
+
148
+ if (!isPopState) {
149
+ routerSignals.pathname.value = pathname;
150
+ routerSignals.searchParams.value = new URLSearchParams(url.search);
151
+ routerSignals.hash.value = url.hash;
152
+ }
153
+
154
+ // If only hash changed, don't re-render everything
155
+ if (pathname === oldPathname && url.search === oldSearch && url.hash !== oldHash) {
156
+ if (!isPopState) {
157
+ if (replace) window.history.replaceState({}, '', path);
158
+ else window.history.pushState({}, '', path);
159
+ }
160
+ scrollToHash(url.hash);
161
+ return;
162
+ }
163
+
164
+ const match = matchRoute(pathname) || (routes.notFound ? { page: routes.notFound, params: {}, route: null } : null);
165
+
166
+ if (match) {
167
+ const { page, params, route } = match;
168
+
169
+ if (currentPageInstance && currentPageInstance._onCleanups) {
170
+ currentPageInstance._onCleanups.forEach(fn => fn());
171
+ }
172
+
173
+ const instance = { _onMounts: [], _onCleanups: [] };
174
+ currentPageInstance = instance;
175
+
176
+ const layoutChain = [];
177
+ if (route) {
178
+ let currentPath = route;
179
+ while (true) {
180
+ if (routes.layouts[currentPath]) {
181
+ layoutChain.unshift(routes.layouts[currentPath]);
182
+ }
183
+ if (currentPath === '/') break;
184
+ currentPath = currentPath.substring(0, currentPath.lastIndexOf('/')) || '/';
185
+ }
186
+ }
187
+
188
+ let content = document.createDocumentFragment();
189
+ withInstance(instance, () => {
190
+ page.render(content, { params });
191
+
192
+ for (let i = layoutChain.length - 1; i >= 0; i--) {
193
+ const layout = layoutChain[i];
194
+ const nextContent = document.createDocumentFragment();
195
+ layout.render(nextContent, { children: content, params });
196
+ content = nextContent;
197
+ }
198
+ });
199
+
200
+ if (!root) root = document.getElementById("app");
201
+ if (root) {
202
+ root.innerHTML = '';
203
+ root.appendChild(content);
204
+ }
205
+
206
+ if (instance._onMounts) {
207
+ instance._onMounts.forEach(fn => fn());
208
+ }
209
+
210
+ if (!isPopState) {
211
+ if (replace) window.history.replaceState({}, '', path);
212
+ else window.history.pushState({}, '', path);
213
+ }
214
+
215
+ // Scroll to hash after render
216
+ setTimeout(() => scrollToHash(url.hash), 100);
217
+ } else {
218
+ if (root) root.innerHTML = '<h1>404 Not Found</h1>';
219
+ }
220
+ }
221
+
222
+ if (typeof window !== 'undefined') {
223
+ window.addEventListener('popstate', () => {
224
+ const fullPath = window.location.pathname + window.location.search + window.location.hash;
225
+ routerSignals.pathname.value = window.location.pathname;
226
+ routerSignals.searchParams.value = new URLSearchParams(window.location.search);
227
+ routerSignals.hash.value = window.location.hash;
228
+ navigate(fullPath, undefined, false, true);
229
+ });
230
+ window.addEventListener('hashchange', () => {
231
+ routerSignals.hash.value = window.location.hash;
232
+ scrollToHash(window.location.hash);
233
+ });
234
+ }
package/runtime/For.js ADDED
@@ -0,0 +1,17 @@
1
+ import { mapped, renderDynamic } from "./dom.js";
2
+ import { withInstance } from "./lifecycle.js";
3
+
4
+ export function For(props) {
5
+ // We return a "marker" that renderDynamic will handle
6
+ // But wait, if we are called as a component, we need to render something.
7
+ return mapped(props.each, props.children[0]);
8
+ }
9
+
10
+ // Register as a Web Component to support <For> syntax
11
+ class ForElement extends HTMLElement {
12
+ connectedCallback() {
13
+ const each = this._propsSignals?.each;
14
+ const renderFn = this.childNodes[0]; // Wait, this is tricky
15
+ // ...
16
+ }
17
+ }
package/runtime/dom.js ADDED
@@ -0,0 +1,80 @@
1
+ import { effect } from "@preact/signals-core";
2
+
3
+ export function renderDynamic(parent, fn) {
4
+ const anchor = document.createComment("dynamic");
5
+ parent.appendChild(anchor);
6
+
7
+ let currentNodes = [];
8
+
9
+ effect(() => {
10
+ let value = fn();
11
+ // If the value is a function (e.g., from mapped), execute it to get the nodes
12
+ if (typeof value === "function") value = value();
13
+
14
+ if (value === null || value === undefined || value === false) value = [];
15
+ if (!Array.isArray(value)) value = [value];
16
+
17
+ const nextNodes = value.map(v => {
18
+ if (v instanceof Node) return v;
19
+ return document.createTextNode(String(v));
20
+ });
21
+
22
+ reconcile(anchor.parentNode, anchor, currentNodes, nextNodes);
23
+ currentNodes = nextNodes;
24
+ });
25
+ }
26
+
27
+ export function mapped(source, fn) {
28
+ let cache = new Map(); // key -> { node }
29
+
30
+ return () => {
31
+ const list = (typeof source === "function" ? source() : source.value) || [];
32
+ const nextNodes = [];
33
+ const nextCache = new Map();
34
+
35
+ list.forEach((item, index) => {
36
+ const key = item.key ?? item.id ?? index;
37
+ let cached = cache.get(key);
38
+
39
+ // If the item exists and the reference is identical, reuse the node
40
+ if (cached && cached.item === item) {
41
+ nextNodes.push(cached.node);
42
+ nextCache.set(key, cached);
43
+ } else {
44
+ // If it's a new item OR the data changed (new reference), re-render
45
+ const node = fn(item, index);
46
+ node._key = key;
47
+ nextNodes.push(node);
48
+ nextCache.set(key, { node, item });
49
+ }
50
+ });
51
+
52
+ // Cleanup removed items
53
+ cache.forEach((cached, key) => {
54
+ if (!nextCache.has(key)) {
55
+ if (cached.node.remove) cached.node.remove();
56
+ }
57
+ });
58
+
59
+ cache = nextCache;
60
+ return nextNodes;
61
+ };
62
+ }
63
+
64
+ function reconcile(parent, anchor, oldNodes, nextNodes) {
65
+ // 1. Remove nodes that are no longer present
66
+ const nextSet = new Set(nextNodes);
67
+ oldNodes.forEach(n => {
68
+ if (!nextSet.has(n)) n.remove();
69
+ });
70
+
71
+ // 2. Insert or move nodes from right to left to minimize DOM operations
72
+ let current = anchor;
73
+ for (let i = nextNodes.length - 1; i >= 0; i--) {
74
+ const node = nextNodes[i];
75
+ if (node.nextSibling !== current) {
76
+ parent.insertBefore(node, current);
77
+ }
78
+ current = node;
79
+ }
80
+ }
@@ -0,0 +1,28 @@
1
+ let currentInstance = null;
2
+
3
+ export function withInstance(inst, fn) {
4
+ const prev = currentInstance;
5
+ currentInstance = inst;
6
+ try {
7
+ return fn();
8
+ } finally {
9
+ currentInstance = prev;
10
+ }
11
+ }
12
+
13
+ export function onMount(fn) {
14
+ if (currentInstance) {
15
+ if (!currentInstance._onMounts) currentInstance._onMounts = [];
16
+ currentInstance._onMounts.push(fn);
17
+ } else {
18
+ // If no instance (like in a Page render), run immediately
19
+ fn();
20
+ }
21
+ }
22
+
23
+ export function onCleanup(fn) {
24
+ if (currentInstance) {
25
+ if (!currentInstance._onCleanups) currentInstance._onCleanups = [];
26
+ currentInstance._onCleanups.push(fn);
27
+ }
28
+ }
@@ -0,0 +1,114 @@
1
+ import { effect, signal } from "@preact/signals-core";
2
+
3
+ function toKebabCase(str) {
4
+ return str.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
5
+ }
6
+
7
+ export function createPropsProxy(el) {
8
+ if (!el._propsSignals) el._propsSignals = {};
9
+
10
+ return new Proxy(el, {
11
+ get: (target, key) => {
12
+ if (key === "__signals") return target._propsSignals;
13
+ if (key === "children") return target._children;
14
+ if (typeof key === "symbol") return target[key];
15
+ if (key === "then") return undefined; // Promise check
16
+
17
+ if (!target._propsSignals[key]) {
18
+ const initialValue = target.getAttribute(toKebabCase(key)) || target[key];
19
+ target._propsSignals[key] = signal(initialValue);
20
+ }
21
+ return target._propsSignals[key].value;
22
+ },
23
+ set: (target, key, value) => {
24
+ const isSignal = value && typeof value === "object" && "value" in value && typeof value.subscribe === "function";
25
+
26
+ if (!target._propsSignals[key]) {
27
+ target._propsSignals[key] = signal(isSignal ? value.value : value);
28
+ }
29
+
30
+ if (isSignal) {
31
+ // Link the internal signal to the external one
32
+ if (target._propsSignals[key]._link) target._propsSignals[key]._link(); // Cleanup old link
33
+ target._propsSignals[key]._link = effect(() => {
34
+ target._propsSignals[key].value = value.value;
35
+ });
36
+ } else {
37
+ target._propsSignals[key].value = value;
38
+ }
39
+
40
+ target[key] = value;
41
+ return true;
42
+ },
43
+ ownKeys: (target) => {
44
+ const keys = new Set(Object.keys(target));
45
+ Object.keys(target._propsSignals).forEach(k => keys.add(k));
46
+ keys.add("children");
47
+ return Array.from(keys).filter(k => !k.startsWith("_"));
48
+ },
49
+ getOwnPropertyDescriptor: (target, key) => {
50
+ return {
51
+ enumerable: true,
52
+ configurable: true
53
+ };
54
+ }
55
+ });
56
+ }
57
+
58
+ export function applySpread(el, props) {
59
+ if (!props) return;
60
+
61
+ const signals = props?.__signals;
62
+
63
+ Object.keys(props).forEach(key => {
64
+ let value = props[key];
65
+ if (signals && signals[key]) value = signals[key];
66
+
67
+ const name = key.toLowerCase();
68
+ const attrProp = (name === "class" || name === "classname") ? "className" : key;
69
+ const isProperty = ["className", "style", "value", "checked", "id", "title", "href", "src", "key"].includes(attrProp);
70
+ const isEvent = name.startsWith("on");
71
+
72
+ // Simple heuristic for signal detection: object with .value and .subscribe
73
+ const isSignal = value && typeof value === "object" && "value" in value && typeof value.subscribe === "function";
74
+
75
+ if (isSignal) {
76
+ effect(() => {
77
+ const val = value.value;
78
+ if (isProperty) {
79
+ if (attrProp === "style" && val && typeof val === "object") {
80
+ Object.assign(el.style, val);
81
+ } else if (attrProp === "className" && el instanceof SVGElement) {
82
+ el.setAttribute("class", val);
83
+ } else {
84
+ el[attrProp] = val;
85
+ }
86
+ } else {
87
+ if (val === null || val === undefined || val === false) {
88
+ el.removeAttribute(key);
89
+ } else {
90
+ el.setAttribute(key, val);
91
+ }
92
+ }
93
+ });
94
+ } else {
95
+ if (isEvent) {
96
+ el[name] = value;
97
+ } else if (isProperty) {
98
+ if (attrProp === "style" && value && typeof value === "object") {
99
+ Object.assign(el.style, value);
100
+ } else if (attrProp === "className" && el instanceof SVGElement) {
101
+ el.setAttribute("class", value);
102
+ } else {
103
+ el[attrProp] = value;
104
+ }
105
+ } else {
106
+ if (value === null || value === undefined || value === false) {
107
+ el.removeAttribute(key);
108
+ } else {
109
+ el.setAttribute(key, value);
110
+ }
111
+ }
112
+ }
113
+ });
114
+ }