@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 +12 -0
- package/compiler/babel-plugin.cjs +730 -0
- package/core/mount.js +32 -0
- package/index.js +6 -0
- package/package.json +23 -0
- package/router/Link.jsx +27 -0
- package/router/index.js +234 -0
- package/runtime/For.js +17 -0
- package/runtime/dom.js +80 -0
- package/runtime/lifecycle.js +28 -0
- package/runtime/props.js +114 -0
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
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
|
+
}
|
package/router/Link.jsx
ADDED
|
@@ -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
|
+
}
|
package/router/index.js
ADDED
|
@@ -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
|
+
}
|
package/runtime/props.js
ADDED
|
@@ -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
|
+
}
|