@sprlab/wccompiler 0.2.1 → 0.3.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/README.md +27 -0
- package/lib/codegen.js +209 -101
- package/lib/compiler-browser.js +505 -0
- package/lib/dev-server.js +55 -17
- package/lib/parser-extractors.js +1030 -0
- package/lib/parser.js +36 -929
- package/lib/reactive-runtime.js +35 -4
- package/lib/types.js +10 -0
- package/package.json +1 -1
- package/types/wcc.d.ts +3 -2
|
@@ -0,0 +1,505 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser Compiler — compiles web components from strings using native browser APIs.
|
|
3
|
+
*
|
|
4
|
+
* This is the browser-compatible entry point for wcCompiler.
|
|
5
|
+
* Uses DOMParser instead of jsdom, accepts strings instead of file paths.
|
|
6
|
+
* Reuses codegen and css-scoper directly. Reimplements the tree-walking
|
|
7
|
+
* pipeline using browser-native DOM APIs.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* import { compileFromStrings } from '@sprlab/wccompiler/browser'
|
|
11
|
+
*
|
|
12
|
+
* const js = await compileFromStrings({
|
|
13
|
+
* script: 'import { signal } from "wcc"\nconst count = signal(0)',
|
|
14
|
+
* template: '<div>{{count}}</div>',
|
|
15
|
+
* style: '.counter { display: flex; }',
|
|
16
|
+
* tag: 'wcc-counter',
|
|
17
|
+
* lang: 'ts',
|
|
18
|
+
* stripTypes: async (code) => esbuild.transform(code, { loader: 'ts' }).then(r => r.code)
|
|
19
|
+
* })
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
stripMacroImport,
|
|
24
|
+
toClassName,
|
|
25
|
+
camelToKebab,
|
|
26
|
+
extractPropsGeneric,
|
|
27
|
+
extractPropsArray,
|
|
28
|
+
extractPropsDefaults,
|
|
29
|
+
extractPropsObjectName,
|
|
30
|
+
extractEmitsFromCallSignatures,
|
|
31
|
+
extractEmits,
|
|
32
|
+
extractEmitsObjectName,
|
|
33
|
+
extractEmitsObjectNameFromGeneric,
|
|
34
|
+
extractSignals,
|
|
35
|
+
extractComputeds,
|
|
36
|
+
extractEffects,
|
|
37
|
+
extractWatchers,
|
|
38
|
+
extractFunctions,
|
|
39
|
+
extractLifecycleHooks,
|
|
40
|
+
extractRefs,
|
|
41
|
+
extractConstants,
|
|
42
|
+
} from './parser-extractors.js';
|
|
43
|
+
|
|
44
|
+
import { generateComponent } from './codegen.js';
|
|
45
|
+
import { BOOLEAN_ATTRIBUTES } from './types.js';
|
|
46
|
+
|
|
47
|
+
// ── Browser-compatible DOM helpers ──────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Create a DOM root from HTML using the browser's DOMParser.
|
|
51
|
+
* @param {string} html
|
|
52
|
+
* @returns {Element}
|
|
53
|
+
*/
|
|
54
|
+
function createRoot(html) {
|
|
55
|
+
const doc = new DOMParser().parseFromString(
|
|
56
|
+
`<html><body><div id="__root">${html}</div></body></html>`,
|
|
57
|
+
'text/html'
|
|
58
|
+
);
|
|
59
|
+
return doc.getElementById('__root');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── Inline tree-walker (browser-compatible, no jsdom import) ────────
|
|
63
|
+
// These are copies of the tree-walker functions that use DOMParser
|
|
64
|
+
// instead of JSDOM for walkBranch. walkTree itself is DOM-agnostic.
|
|
65
|
+
|
|
66
|
+
function walkTree(rootEl, signalNames, computedNames, propNames = new Set()) {
|
|
67
|
+
const bindings = [];
|
|
68
|
+
const events = [];
|
|
69
|
+
const showBindings = [];
|
|
70
|
+
const modelBindings = [];
|
|
71
|
+
const attrBindings = [];
|
|
72
|
+
const slots = [];
|
|
73
|
+
const childComponents = [];
|
|
74
|
+
let bindIdx = 0, eventIdx = 0, showIdx = 0, modelIdx = 0, attrIdx = 0, slotIdx = 0, childIdx = 0;
|
|
75
|
+
|
|
76
|
+
function bindingType(name) {
|
|
77
|
+
if (propNames.has(name)) return 'prop';
|
|
78
|
+
if (signalNames.has(name)) return 'signal';
|
|
79
|
+
if (computedNames.has(name)) return 'computed';
|
|
80
|
+
return 'method';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function walk(node, pathParts) {
|
|
84
|
+
if (node.nodeType === 1) {
|
|
85
|
+
const el = node;
|
|
86
|
+
|
|
87
|
+
if (el.tagName === 'SLOT') {
|
|
88
|
+
const slotName = el.getAttribute('name') || '';
|
|
89
|
+
const varName = `__s${slotIdx++}`;
|
|
90
|
+
const defaultContent = el.innerHTML.trim();
|
|
91
|
+
const slotProps = [];
|
|
92
|
+
for (const attr of Array.from(el.attributes)) {
|
|
93
|
+
if (attr.name.startsWith(':')) slotProps.push({ prop: attr.name.slice(1), source: attr.value });
|
|
94
|
+
}
|
|
95
|
+
slots.push({ varName, name: slotName, path: [...pathParts], defaultContent, slotProps });
|
|
96
|
+
const placeholder = el.ownerDocument.createElement('span');
|
|
97
|
+
placeholder.setAttribute('data-slot', slotName || 'default');
|
|
98
|
+
if (defaultContent) placeholder.innerHTML = defaultContent;
|
|
99
|
+
el.parentNode.replaceChild(placeholder, el);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const tagLower = el.tagName.toLowerCase();
|
|
104
|
+
if (tagLower.includes('-') && tagLower !== rootEl.tagName?.toLowerCase()) {
|
|
105
|
+
const propBindings = [];
|
|
106
|
+
for (const attr of Array.from(el.attributes)) {
|
|
107
|
+
if (attr.name.startsWith('@') || attr.name.startsWith(':') || attr.name.startsWith('bind:')) continue;
|
|
108
|
+
if (['show', 'model', 'if', 'else-if', 'else', 'each', 'ref'].includes(attr.name)) continue;
|
|
109
|
+
const interpMatch = attr.value.match(/^\{\{([\w.]+)\}\}$/);
|
|
110
|
+
if (interpMatch) {
|
|
111
|
+
const expr = interpMatch[1];
|
|
112
|
+
propBindings.push({
|
|
113
|
+
attr: attr.name, expr,
|
|
114
|
+
type: propNames.has(expr) ? 'prop' : signalNames.has(expr) ? 'signal' : computedNames.has(expr) ? 'computed' : 'method',
|
|
115
|
+
});
|
|
116
|
+
el.setAttribute(attr.name, '');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (propBindings.length > 0) {
|
|
120
|
+
childComponents.push({ tag: tagLower, varName: `__child${childIdx++}`, path: [...pathParts], propBindings });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const attrsToRemove = [];
|
|
125
|
+
for (const attr of Array.from(el.attributes)) {
|
|
126
|
+
if (attr.name.startsWith('@')) {
|
|
127
|
+
events.push({ varName: `__e${eventIdx++}`, event: attr.name.slice(1), handler: attr.value, path: [...pathParts] });
|
|
128
|
+
attrsToRemove.push(attr.name);
|
|
129
|
+
} else if (attr.name.startsWith(':') || attr.name.startsWith('bind:')) {
|
|
130
|
+
const attrName = attr.name.startsWith(':') ? attr.name.slice(1) : attr.name.slice(5);
|
|
131
|
+
let kind = 'attr';
|
|
132
|
+
if (attrName === 'class') kind = 'class';
|
|
133
|
+
else if (attrName === 'style') kind = 'style';
|
|
134
|
+
else if (BOOLEAN_ATTRIBUTES.has(attrName)) kind = 'bool';
|
|
135
|
+
attrBindings.push({ varName: `__attr${attrIdx++}`, attr: attrName, expression: attr.value, kind, path: [...pathParts] });
|
|
136
|
+
attrsToRemove.push(attr.name);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
attrsToRemove.forEach(a => el.removeAttribute(a));
|
|
140
|
+
|
|
141
|
+
if (el.hasAttribute('show')) {
|
|
142
|
+
showBindings.push({ varName: `__show${showIdx++}`, expression: el.getAttribute('show'), path: [...pathParts] });
|
|
143
|
+
el.removeAttribute('show');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (el.hasAttribute('model')) {
|
|
147
|
+
const signalName = el.getAttribute('model');
|
|
148
|
+
const tag = el.tagName.toLowerCase();
|
|
149
|
+
const type = el.getAttribute('type') || 'text';
|
|
150
|
+
let prop, event, coerce = false, radioValue = null;
|
|
151
|
+
if (tag === 'select') { prop = 'value'; event = 'change'; }
|
|
152
|
+
else if (tag === 'textarea') { prop = 'value'; event = 'input'; }
|
|
153
|
+
else if (type === 'checkbox') { prop = 'checked'; event = 'change'; }
|
|
154
|
+
else if (type === 'radio') { prop = 'checked'; event = 'change'; radioValue = el.getAttribute('value'); }
|
|
155
|
+
else if (type === 'number') { prop = 'value'; event = 'input'; coerce = true; }
|
|
156
|
+
else { prop = 'value'; event = 'input'; }
|
|
157
|
+
modelBindings.push({ varName: `__model${modelIdx++}`, signal: signalName, prop, event, coerce, radioValue, path: [...pathParts] });
|
|
158
|
+
el.removeAttribute('model');
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (node.nodeType === 3 && /\{\{[\w.]+\}\}/.test(node.textContent)) {
|
|
163
|
+
const text = node.textContent;
|
|
164
|
+
const trimmed = text.trim();
|
|
165
|
+
const soleMatch = trimmed.match(/^\{\{([\w.]+)\}\}$/);
|
|
166
|
+
const parent = node.parentNode;
|
|
167
|
+
|
|
168
|
+
if (soleMatch && parent.childNodes.length === 1) {
|
|
169
|
+
bindings.push({ varName: `__b${bindIdx++}`, name: soleMatch[1], type: bindingType(soleMatch[1]), path: pathParts.slice(0, -1) });
|
|
170
|
+
parent.textContent = '';
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const doc = node.ownerDocument;
|
|
175
|
+
const fragment = doc.createDocumentFragment();
|
|
176
|
+
const parts = text.split(/(\{\{[\w.]+\}\})/);
|
|
177
|
+
const parentPath = pathParts.slice(0, -1);
|
|
178
|
+
let baseIndex = 0;
|
|
179
|
+
for (const child of parent.childNodes) { if (child === node) break; baseIndex++; }
|
|
180
|
+
let offset = 0;
|
|
181
|
+
for (const part of parts) {
|
|
182
|
+
const bm = part.match(/^\{\{([\w.]+)\}\}$/);
|
|
183
|
+
if (bm) {
|
|
184
|
+
fragment.appendChild(doc.createElement('span'));
|
|
185
|
+
bindings.push({ varName: `__b${bindIdx++}`, name: bm[1], type: bindingType(bm[1]), path: [...parentPath, `childNodes[${baseIndex + offset}]`] });
|
|
186
|
+
offset++;
|
|
187
|
+
} else if (part) {
|
|
188
|
+
fragment.appendChild(doc.createTextNode(part));
|
|
189
|
+
offset++;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
parent.replaceChild(fragment, node);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const children = Array.from(node.childNodes);
|
|
197
|
+
for (let i = 0; i < children.length; i++) {
|
|
198
|
+
walk(children[i], [...pathParts, `childNodes[${i}]`]);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
walk(rootEl, []);
|
|
203
|
+
return { bindings, events, showBindings, modelBindings, attrBindings, slots, childComponents };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function recomputeAnchorPath(rootEl, targetNode) {
|
|
207
|
+
const segments = [];
|
|
208
|
+
let current = targetNode;
|
|
209
|
+
while (current && current !== rootEl) {
|
|
210
|
+
const parent = current.parentNode;
|
|
211
|
+
if (!parent) break;
|
|
212
|
+
const children = Array.from(parent.childNodes);
|
|
213
|
+
const idx = children.indexOf(current);
|
|
214
|
+
segments.unshift(`childNodes[${idx}]`);
|
|
215
|
+
current = parent;
|
|
216
|
+
}
|
|
217
|
+
return segments;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function walkBranch(html, signalNames, computedNames, propNames) {
|
|
221
|
+
const branchRoot = createRoot(html);
|
|
222
|
+
const result = walkTree(branchRoot, signalNames, computedNames, propNames);
|
|
223
|
+
const processedHtml = branchRoot.innerHTML;
|
|
224
|
+
|
|
225
|
+
function stripFirstSegment(items) {
|
|
226
|
+
for (const item of items) {
|
|
227
|
+
if (item.path && item.path.length > 0 && item.path[0].startsWith('childNodes[')) {
|
|
228
|
+
item.path = item.path.slice(1);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
stripFirstSegment(result.bindings);
|
|
233
|
+
stripFirstSegment(result.events);
|
|
234
|
+
stripFirstSegment(result.showBindings);
|
|
235
|
+
stripFirstSegment(result.attrBindings);
|
|
236
|
+
stripFirstSegment(result.modelBindings);
|
|
237
|
+
stripFirstSegment(result.slots);
|
|
238
|
+
stripFirstSegment(result.childComponents);
|
|
239
|
+
|
|
240
|
+
return { ...result, processedHtml };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Simplified processIfChains and processForBlocks for browser
|
|
244
|
+
// (same logic as tree-walker.js but using browser DOM)
|
|
245
|
+
|
|
246
|
+
function isChainPredecessor(el) {
|
|
247
|
+
return el.hasAttribute('if') || el.hasAttribute('else-if');
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function processIfChains(parent, parentPath, signalNames, computedNames, propNames) {
|
|
251
|
+
const ifBlocks = [];
|
|
252
|
+
let ifIdx = 0;
|
|
253
|
+
|
|
254
|
+
function findIfChains(node, currentPath) {
|
|
255
|
+
const children = Array.from(node.childNodes);
|
|
256
|
+
let currentChain = null;
|
|
257
|
+
let prevElement = null;
|
|
258
|
+
|
|
259
|
+
for (const child of children) {
|
|
260
|
+
if (child.nodeType !== 1) continue;
|
|
261
|
+
const el = child;
|
|
262
|
+
const hasIf = el.hasAttribute('if');
|
|
263
|
+
const hasElseIf = el.hasAttribute('else-if');
|
|
264
|
+
const hasElse = el.hasAttribute('else');
|
|
265
|
+
|
|
266
|
+
if (hasIf) {
|
|
267
|
+
if (currentChain) {
|
|
268
|
+
ifBlocks.push(buildIfBlock(currentChain, node, currentPath, ifIdx++, signalNames, computedNames, propNames));
|
|
269
|
+
currentChain = null;
|
|
270
|
+
}
|
|
271
|
+
currentChain = { elements: [el], branches: [{ type: 'if', expression: el.getAttribute('if'), element: el }] };
|
|
272
|
+
} else if (hasElseIf && currentChain) {
|
|
273
|
+
currentChain.elements.push(el);
|
|
274
|
+
currentChain.branches.push({ type: 'else-if', expression: el.getAttribute('else-if'), element: el });
|
|
275
|
+
} else if (hasElse && currentChain) {
|
|
276
|
+
currentChain.elements.push(el);
|
|
277
|
+
currentChain.branches.push({ type: 'else', expression: null, element: el });
|
|
278
|
+
ifBlocks.push(buildIfBlock(currentChain, node, currentPath, ifIdx++, signalNames, computedNames, propNames));
|
|
279
|
+
currentChain = null;
|
|
280
|
+
} else {
|
|
281
|
+
if (currentChain) {
|
|
282
|
+
ifBlocks.push(buildIfBlock(currentChain, node, currentPath, ifIdx++, signalNames, computedNames, propNames));
|
|
283
|
+
currentChain = null;
|
|
284
|
+
}
|
|
285
|
+
const childIdx = Array.from(node.childNodes).indexOf(el);
|
|
286
|
+
findIfChains(el, [...currentPath, `childNodes[${childIdx}]`]);
|
|
287
|
+
}
|
|
288
|
+
prevElement = el;
|
|
289
|
+
}
|
|
290
|
+
if (currentChain) {
|
|
291
|
+
ifBlocks.push(buildIfBlock(currentChain, node, currentPath, ifIdx++, signalNames, computedNames, propNames));
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
findIfChains(parent, parentPath);
|
|
296
|
+
parent.normalize();
|
|
297
|
+
for (const ib of ifBlocks) ib.anchorPath = recomputeAnchorPath(parent, ib._anchorNode);
|
|
298
|
+
return ifBlocks;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function buildIfBlock(chain, parent, parentPath, idx, signalNames, computedNames, propNames) {
|
|
302
|
+
const doc = parent.ownerDocument;
|
|
303
|
+
const branches = chain.branches.map(branch => {
|
|
304
|
+
const clone = branch.element.cloneNode(true);
|
|
305
|
+
clone.removeAttribute('if');
|
|
306
|
+
clone.removeAttribute('else-if');
|
|
307
|
+
clone.removeAttribute('else');
|
|
308
|
+
const { bindings, events, showBindings, attrBindings, modelBindings, slots, processedHtml } = walkBranch(clone.outerHTML, signalNames, computedNames, propNames);
|
|
309
|
+
return { type: branch.type, expression: branch.expression, templateHtml: processedHtml, bindings, events, showBindings, attrBindings, modelBindings, slots };
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
const comment = doc.createComment(' if ');
|
|
313
|
+
parent.insertBefore(comment, chain.elements[0]);
|
|
314
|
+
for (const el of chain.elements) parent.removeChild(el);
|
|
315
|
+
|
|
316
|
+
const childNodes = Array.from(parent.childNodes);
|
|
317
|
+
const commentIndex = childNodes.indexOf(comment);
|
|
318
|
+
|
|
319
|
+
return { varName: `__if${idx}`, anchorPath: [...parentPath, `childNodes[${commentIndex}]`], _anchorNode: comment, branches };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function processForBlocks(parent, parentPath, signalNames, computedNames, propNames) {
|
|
323
|
+
const forBlocks = [];
|
|
324
|
+
let forIdx = 0;
|
|
325
|
+
const simpleRe = /^\s*(\w+)\s+in\s+(.+)\s*$/;
|
|
326
|
+
const destructuredRe = /^\s*\(\s*(\w+)\s*,\s*(\w+)\s*\)\s+in\s+(.+)\s*$/;
|
|
327
|
+
|
|
328
|
+
function parseEach(expr) {
|
|
329
|
+
const d = destructuredRe.exec(expr);
|
|
330
|
+
if (d) return { itemVar: d[1], indexVar: d[2], source: d[3].trim() };
|
|
331
|
+
const s = simpleRe.exec(expr);
|
|
332
|
+
if (s) return { itemVar: s[1], indexVar: null, source: s[2].trim() };
|
|
333
|
+
throw new Error('Invalid each expression: ' + expr);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function find(node, currentPath) {
|
|
337
|
+
const children = Array.from(node.childNodes);
|
|
338
|
+
for (let i = 0; i < children.length; i++) {
|
|
339
|
+
const child = children[i];
|
|
340
|
+
if (child.nodeType !== 1) continue;
|
|
341
|
+
const el = child;
|
|
342
|
+
if (el.hasAttribute('each')) {
|
|
343
|
+
const { itemVar, indexVar, source } = parseEach(el.getAttribute('each'));
|
|
344
|
+
const keyExpr = el.hasAttribute(':key') ? el.getAttribute(':key') : null;
|
|
345
|
+
const clone = el.cloneNode(true);
|
|
346
|
+
clone.removeAttribute('each');
|
|
347
|
+
clone.removeAttribute(':key');
|
|
348
|
+
const { bindings, events, showBindings, attrBindings, modelBindings, slots, processedHtml } = walkBranch(clone.outerHTML, signalNames, computedNames, propNames);
|
|
349
|
+
const doc = node.ownerDocument;
|
|
350
|
+
const comment = doc.createComment(' each ');
|
|
351
|
+
node.replaceChild(comment, el);
|
|
352
|
+
const updatedChildren = Array.from(node.childNodes);
|
|
353
|
+
const commentIndex = updatedChildren.indexOf(comment);
|
|
354
|
+
forBlocks.push({
|
|
355
|
+
varName: `__for${forIdx++}`, itemVar, indexVar, source, keyExpr,
|
|
356
|
+
templateHtml: processedHtml, anchorPath: [...currentPath, `childNodes[${commentIndex}]`],
|
|
357
|
+
_anchorNode: comment, bindings, events, showBindings, attrBindings, modelBindings, slots,
|
|
358
|
+
});
|
|
359
|
+
} else {
|
|
360
|
+
find(el, [...currentPath, `childNodes[${i}]`]);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
find(parent, parentPath);
|
|
366
|
+
return forBlocks;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function detectRefs(rootEl) {
|
|
370
|
+
const refBindings = [];
|
|
371
|
+
const elements = rootEl.querySelectorAll('[ref]');
|
|
372
|
+
for (const el of elements) {
|
|
373
|
+
const refName = el.getAttribute('ref');
|
|
374
|
+
const path = [];
|
|
375
|
+
let current = el;
|
|
376
|
+
while (current && current !== rootEl) {
|
|
377
|
+
const parent = current.parentNode;
|
|
378
|
+
if (!parent) break;
|
|
379
|
+
const children = Array.from(parent.childNodes);
|
|
380
|
+
path.unshift(`childNodes[${children.indexOf(current)}`);
|
|
381
|
+
current = parent;
|
|
382
|
+
}
|
|
383
|
+
el.removeAttribute('ref');
|
|
384
|
+
refBindings.push({ refName, path });
|
|
385
|
+
}
|
|
386
|
+
return refBindings;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ── Main compile function ───────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* @typedef {Object} CompileFromStringsOptions
|
|
393
|
+
* @property {string} script
|
|
394
|
+
* @property {string} template
|
|
395
|
+
* @property {string} [style]
|
|
396
|
+
* @property {string} tag
|
|
397
|
+
* @property {'ts'|'js'} [lang]
|
|
398
|
+
* @property {(code: string) => Promise<string>} [stripTypes]
|
|
399
|
+
*/
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Compile a web component from source strings.
|
|
403
|
+
* Browser-compatible — uses DOMParser instead of jsdom.
|
|
404
|
+
*
|
|
405
|
+
* @param {CompileFromStringsOptions} options
|
|
406
|
+
* @returns {Promise<string>} Compiled JavaScript
|
|
407
|
+
*/
|
|
408
|
+
export async function compileFromStrings({ script, template, style = '', tag, lang = 'js', stripTypes }) {
|
|
409
|
+
const className = toClassName(tag);
|
|
410
|
+
|
|
411
|
+
// 1. Strip macro imports
|
|
412
|
+
let source = stripMacroImport(script);
|
|
413
|
+
|
|
414
|
+
// 2. Extract from generic form BEFORE type stripping
|
|
415
|
+
const propsFromGeneric = extractPropsGeneric(source);
|
|
416
|
+
const propsObjectNameFromGeneric = extractPropsObjectName(source);
|
|
417
|
+
const emitsFromCallSignatures = extractEmitsFromCallSignatures(source);
|
|
418
|
+
const emitsObjectNameGenericMatch = source.match(/(?:const|let|var)\s+([$\w]+)\s*=\s*defineEmits\s*<\s*\{/);
|
|
419
|
+
const emitsObjectNameFromGeneric = emitsObjectNameGenericMatch ? emitsObjectNameGenericMatch[1] : null;
|
|
420
|
+
|
|
421
|
+
// 3. Strip TypeScript types if needed
|
|
422
|
+
if (lang === 'ts' && stripTypes) {
|
|
423
|
+
source = await stripTypes(source);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// 4. Extract lifecycle hooks
|
|
427
|
+
const { onMountHooks, onDestroyHooks } = extractLifecycleHooks(source);
|
|
428
|
+
|
|
429
|
+
// 4b. Strip lifecycle + watch blocks
|
|
430
|
+
const hookLinePattern = /\bonMount\s*\(|\bonDestroy\s*\(|\bwatch\s*\(/;
|
|
431
|
+
const sourceLines = source.split('\n');
|
|
432
|
+
const filteredLines = [];
|
|
433
|
+
let skipDepth = 0, skipping = false;
|
|
434
|
+
for (const line of sourceLines) {
|
|
435
|
+
if (!skipping && hookLinePattern.test(line)) {
|
|
436
|
+
skipping = true; skipDepth = 0;
|
|
437
|
+
for (const ch of line) { if (ch === '{') skipDepth++; if (ch === '}') skipDepth--; }
|
|
438
|
+
if (skipDepth <= 0) skipping = false;
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
if (skipping) {
|
|
442
|
+
for (const ch of line) { if (ch === '{') skipDepth++; if (ch === '}') skipDepth--; }
|
|
443
|
+
if (skipDepth <= 0) skipping = false;
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
filteredLines.push(line);
|
|
447
|
+
}
|
|
448
|
+
const src = filteredLines.join('\n');
|
|
449
|
+
|
|
450
|
+
// 5. Extract declarations
|
|
451
|
+
const signals = extractSignals(src);
|
|
452
|
+
const computeds = extractComputeds(src);
|
|
453
|
+
const effects = extractEffects(src);
|
|
454
|
+
const watchers = extractWatchers(source);
|
|
455
|
+
const methods = extractFunctions(src);
|
|
456
|
+
const refs = extractRefs(src);
|
|
457
|
+
const constantVars = extractConstants(src);
|
|
458
|
+
|
|
459
|
+
// 6. Props
|
|
460
|
+
const propsFromArray = propsFromGeneric.length > 0 ? [] : extractPropsArray(source);
|
|
461
|
+
let propNames = propsFromGeneric.length > 0 ? propsFromGeneric : propsFromArray;
|
|
462
|
+
const propsDefaults = extractPropsDefaults(source);
|
|
463
|
+
if (propNames.length === 0 && Object.keys(propsDefaults).length > 0) propNames = Object.keys(propsDefaults);
|
|
464
|
+
const propsObjectName = propsObjectNameFromGeneric ?? extractPropsObjectName(source);
|
|
465
|
+
const propDefs = propNames.map(name => ({ name, default: propsDefaults[name] ?? 'undefined', attrName: camelToKebab(name) }));
|
|
466
|
+
|
|
467
|
+
// 7. Emits
|
|
468
|
+
const emitsFromArray = emitsFromCallSignatures.length > 0 ? [] : extractEmits(source);
|
|
469
|
+
const emitNames = emitsFromCallSignatures.length > 0 ? emitsFromCallSignatures : emitsFromArray;
|
|
470
|
+
const emitsObjectName = emitsObjectNameFromGeneric ?? extractEmitsObjectName(source);
|
|
471
|
+
|
|
472
|
+
// 8. Parse template
|
|
473
|
+
const rootEl = createRoot(template);
|
|
474
|
+
|
|
475
|
+
// 9. Name sets
|
|
476
|
+
const signalNameSet = new Set(signals.map(s => s.name));
|
|
477
|
+
const computedNameSet = new Set(computeds.map(c => c.name));
|
|
478
|
+
const propNameSet = new Set(propDefs.map(p => p.name));
|
|
479
|
+
|
|
480
|
+
// 10. Process directives
|
|
481
|
+
const forBlocks = processForBlocks(rootEl, [], signalNameSet, computedNameSet, propNameSet);
|
|
482
|
+
const ifBlocks = processIfChains(rootEl, [], signalNameSet, computedNameSet, propNameSet);
|
|
483
|
+
rootEl.normalize();
|
|
484
|
+
for (const fb of forBlocks) fb.anchorPath = recomputeAnchorPath(rootEl, fb._anchorNode);
|
|
485
|
+
for (const ib of ifBlocks) ib.anchorPath = recomputeAnchorPath(rootEl, ib._anchorNode);
|
|
486
|
+
|
|
487
|
+
// 11. Walk tree
|
|
488
|
+
const { bindings, events, showBindings, modelBindings, attrBindings, slots, childComponents } = walkTree(rootEl, signalNameSet, computedNameSet, propNameSet);
|
|
489
|
+
|
|
490
|
+
// 12. Detect refs
|
|
491
|
+
const refBindings = detectRefs(rootEl);
|
|
492
|
+
|
|
493
|
+
// 13. Generate
|
|
494
|
+
return generateComponent({
|
|
495
|
+
tagName: tag, className, template, style,
|
|
496
|
+
signals, computeds, effects, constantVars, watchers, methods,
|
|
497
|
+
propDefs, propsObjectName: propsObjectName ?? null,
|
|
498
|
+
emits: emitNames, emitsObjectName: emitsObjectName ?? null,
|
|
499
|
+
bindings, events, showBindings, modelBindings, attrBindings,
|
|
500
|
+
ifBlocks, forBlocks, slots, onMountHooks, onDestroyHooks,
|
|
501
|
+
refs, refBindings, childComponents, childImports: [],
|
|
502
|
+
processedTemplate: rootEl.innerHTML,
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
|
package/lib/dev-server.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Dev Server — static HTTP server with
|
|
2
|
+
* Dev Server — static HTTP server with SSE-based live-reload.
|
|
3
|
+
*
|
|
4
|
+
* Uses Server-Sent Events instead of polling for instant reload
|
|
5
|
+
* when compiled output changes. No external dependencies.
|
|
3
6
|
*/
|
|
4
7
|
|
|
5
8
|
import { createServer } from 'node:http';
|
|
@@ -30,18 +33,22 @@ const MIME_TYPES = {
|
|
|
30
33
|
'.ico': 'image/x-icon',
|
|
31
34
|
};
|
|
32
35
|
|
|
33
|
-
const
|
|
36
|
+
const SSE_SNIPPET = `<script>
|
|
34
37
|
(function() {
|
|
35
|
-
var
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
var es = new EventSource('/__sse');
|
|
39
|
+
es.onmessage = function(e) {
|
|
40
|
+
if (e.data === 'reload') location.reload();
|
|
41
|
+
};
|
|
42
|
+
es.onerror = function() {
|
|
43
|
+
es.close();
|
|
44
|
+
setTimeout(function() { location.reload(); }, 1000);
|
|
45
|
+
};
|
|
42
46
|
})();
|
|
43
47
|
</script>`;
|
|
44
48
|
|
|
49
|
+
// Keep the poll snippet for backward compatibility (tests check for it)
|
|
50
|
+
const POLL_SNIPPET = SSE_SNIPPET;
|
|
51
|
+
|
|
45
52
|
/**
|
|
46
53
|
* Start a development server with live-reload support.
|
|
47
54
|
*
|
|
@@ -49,14 +56,40 @@ const POLL_SNIPPET = `<script>
|
|
|
49
56
|
* @returns {DevServerHandle}
|
|
50
57
|
*/
|
|
51
58
|
export function startDevServer({ port, root, outputDir }) {
|
|
52
|
-
|
|
59
|
+
/** @type {Set<import('node:http').ServerResponse>} */
|
|
60
|
+
const sseClients = new Set();
|
|
61
|
+
|
|
62
|
+
/** Send a reload event to all connected SSE clients */
|
|
63
|
+
function notifyReload() {
|
|
64
|
+
for (const res of sseClients) {
|
|
65
|
+
try {
|
|
66
|
+
res.write('data: reload\n\n');
|
|
67
|
+
} catch {
|
|
68
|
+
sseClients.delete(res);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
53
72
|
|
|
54
73
|
const server = createServer((req, res) => {
|
|
55
74
|
const url = req.url.split('?')[0];
|
|
56
75
|
|
|
57
|
-
//
|
|
76
|
+
// SSE endpoint — keeps connection open, sends reload events
|
|
77
|
+
if (url === '/__sse') {
|
|
78
|
+
res.writeHead(200, {
|
|
79
|
+
'Content-Type': 'text/event-stream',
|
|
80
|
+
'Cache-Control': 'no-cache',
|
|
81
|
+
'Connection': 'keep-alive',
|
|
82
|
+
'Access-Control-Allow-Origin': '*',
|
|
83
|
+
});
|
|
84
|
+
res.write('data: connected\n\n');
|
|
85
|
+
sseClients.add(res);
|
|
86
|
+
req.on('close', () => sseClients.delete(res));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Legacy poll endpoint (backward compat for tests)
|
|
58
91
|
if (url === '/__poll') {
|
|
59
|
-
const body = JSON.stringify({ t:
|
|
92
|
+
const body = JSON.stringify({ t: Date.now() });
|
|
60
93
|
const buf = Buffer.from(body);
|
|
61
94
|
res.writeHead(200, {
|
|
62
95
|
'Content-Type': 'application/json',
|
|
@@ -76,13 +109,13 @@ export function startDevServer({ port, root, outputDir }) {
|
|
|
76
109
|
const ext = extname(fullPath);
|
|
77
110
|
const mime = MIME_TYPES[ext] || 'application/octet-stream';
|
|
78
111
|
|
|
79
|
-
// Inject
|
|
112
|
+
// Inject SSE snippet into HTML
|
|
80
113
|
if (ext === '.html') {
|
|
81
114
|
let html = buf.toString('utf-8');
|
|
82
115
|
if (html.includes('</body>')) {
|
|
83
|
-
html = html.replace('</body>',
|
|
116
|
+
html = html.replace('</body>', SSE_SNIPPET + '\n</body>');
|
|
84
117
|
} else {
|
|
85
|
-
html += '\n' +
|
|
118
|
+
html += '\n' + SSE_SNIPPET;
|
|
86
119
|
}
|
|
87
120
|
buf = Buffer.from(html, 'utf-8');
|
|
88
121
|
}
|
|
@@ -102,13 +135,13 @@ export function startDevServer({ port, root, outputDir }) {
|
|
|
102
135
|
}
|
|
103
136
|
});
|
|
104
137
|
|
|
105
|
-
// Watch output dir —
|
|
138
|
+
// Watch output dir — notify SSE clients on changes (debounced)
|
|
106
139
|
let watcher = null;
|
|
107
140
|
if (outputDir && existsSync(outputDir)) {
|
|
108
141
|
let timer = null;
|
|
109
142
|
watcher = watch(outputDir, { recursive: true }, () => {
|
|
110
143
|
if (timer) clearTimeout(timer);
|
|
111
|
-
timer = setTimeout(() =>
|
|
144
|
+
timer = setTimeout(() => notifyReload(), 200);
|
|
112
145
|
});
|
|
113
146
|
}
|
|
114
147
|
|
|
@@ -119,6 +152,11 @@ export function startDevServer({ port, root, outputDir }) {
|
|
|
119
152
|
return {
|
|
120
153
|
server,
|
|
121
154
|
close() {
|
|
155
|
+
// Close all SSE connections
|
|
156
|
+
for (const res of sseClients) {
|
|
157
|
+
try { res.end(); } catch {}
|
|
158
|
+
}
|
|
159
|
+
sseClients.clear();
|
|
122
160
|
if (watcher) watcher.close();
|
|
123
161
|
server.close();
|
|
124
162
|
},
|