@sprlab/wccompiler 0.12.1 → 0.14.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.
@@ -1,545 +1,545 @@
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
- import { parseSFC } from './sfc-parser.js';
47
-
48
- // ── Browser-compatible DOM helpers ──────────────────────────────────
49
-
50
- /**
51
- * Create a DOM root from HTML using the browser's DOMParser.
52
- * @param {string} html
53
- * @returns {Element}
54
- */
55
- function createRoot(html) {
56
- const doc = new DOMParser().parseFromString(
57
- `<html><body><div id="__root">${html}</div></body></html>`,
58
- 'text/html'
59
- );
60
- return doc.getElementById('__root');
61
- }
62
-
63
- // ── Inline tree-walker (browser-compatible, no jsdom import) ────────
64
- // These are copies of the tree-walker functions that use DOMParser
65
- // instead of JSDOM for walkBranch. walkTree itself is DOM-agnostic.
66
-
67
- function walkTree(rootEl, signalNames, computedNames, propNames = new Set()) {
68
- const bindings = [];
69
- const events = [];
70
- const showBindings = [];
71
- const modelBindings = [];
72
- const modelPropBindings = [];
73
- const attrBindings = [];
74
- const slots = [];
75
- const childComponents = [];
76
- let bindIdx = 0, eventIdx = 0, showIdx = 0, modelIdx = 0, modelPropIdx = 0, attrIdx = 0, slotIdx = 0, childIdx = 0;
77
-
78
- function bindingType(name) {
79
- if (propNames.has(name)) return 'prop';
80
- if (signalNames.has(name)) return 'signal';
81
- if (computedNames.has(name)) return 'computed';
82
- return 'method';
83
- }
84
-
85
- function walk(node, pathParts) {
86
- if (node.nodeType === 1) {
87
- const el = node;
88
-
89
- if (el.tagName === 'SLOT') {
90
- const slotName = el.getAttribute('name') || '';
91
- const varName = `__s${slotIdx++}`;
92
- const defaultContent = el.innerHTML.trim();
93
- const slotProps = [];
94
- for (const attr of Array.from(el.attributes)) {
95
- if (attr.name.startsWith(':')) slotProps.push({ prop: attr.name.slice(1), source: attr.value });
96
- }
97
- slots.push({ varName, name: slotName, path: [...pathParts], defaultContent, slotProps });
98
- const placeholder = el.ownerDocument.createElement('span');
99
- placeholder.setAttribute('data-slot', slotName || 'default');
100
- if (defaultContent) placeholder.innerHTML = defaultContent;
101
- el.parentNode.replaceChild(placeholder, el);
102
- return;
103
- }
104
-
105
- const tagLower = el.tagName.toLowerCase();
106
- if (tagLower.includes('-') && tagLower !== rootEl.tagName?.toLowerCase()) {
107
- const propBindings = [];
108
- for (const attr of Array.from(el.attributes)) {
109
- if (attr.name.startsWith('@') || attr.name.startsWith(':') || attr.name.startsWith('bind:') || attr.name.startsWith('model:')) continue;
110
- if (['show', 'model', 'if', 'else-if', 'else', 'each', 'ref'].includes(attr.name)) continue;
111
- const interpMatch = attr.value.match(/^\{\{([\w.]+)\}\}$/);
112
- if (interpMatch) {
113
- const expr = interpMatch[1];
114
- propBindings.push({
115
- attr: attr.name, expr,
116
- type: propNames.has(expr) ? 'prop' : signalNames.has(expr) ? 'signal' : computedNames.has(expr) ? 'computed' : 'method',
117
- });
118
- el.setAttribute(attr.name, '');
119
- }
120
- }
121
- if (propBindings.length > 0) {
122
- childComponents.push({ tag: tagLower, varName: `__child${childIdx++}`, path: [...pathParts], propBindings });
123
- }
124
- }
125
-
126
- const attrsToRemove = [];
127
- for (const attr of Array.from(el.attributes)) {
128
- if (attr.name.startsWith('@')) {
129
- events.push({ varName: `__e${eventIdx++}`, event: attr.name.slice(1), handler: attr.value, path: [...pathParts] });
130
- attrsToRemove.push(attr.name);
131
- } else if (attr.name.startsWith(':') || attr.name.startsWith('bind:')) {
132
- const attrName = attr.name.startsWith(':') ? attr.name.slice(1) : attr.name.slice(5);
133
- let kind = 'attr';
134
- if (attrName === 'class') kind = 'class';
135
- else if (attrName === 'style') kind = 'style';
136
- else if (BOOLEAN_ATTRIBUTES.has(attrName)) kind = 'bool';
137
- attrBindings.push({ varName: `__attr${attrIdx++}`, attr: attrName, expression: attr.value, kind, path: [...pathParts] });
138
- attrsToRemove.push(attr.name);
139
- }
140
- }
141
- attrsToRemove.forEach(a => el.removeAttribute(a));
142
-
143
- if (el.hasAttribute('show')) {
144
- showBindings.push({ varName: `__show${showIdx++}`, expression: el.getAttribute('show'), path: [...pathParts] });
145
- el.removeAttribute('show');
146
- }
147
-
148
- if (el.hasAttribute('model')) {
149
- const signalName = el.getAttribute('model');
150
- const tag = el.tagName.toLowerCase();
151
- const type = el.getAttribute('type') || 'text';
152
- let prop, event, coerce = false, radioValue = null;
153
- if (tag === 'select') { prop = 'value'; event = 'change'; }
154
- else if (tag === 'textarea') { prop = 'value'; event = 'input'; }
155
- else if (type === 'checkbox') { prop = 'checked'; event = 'change'; }
156
- else if (type === 'radio') { prop = 'checked'; event = 'change'; radioValue = el.getAttribute('value'); }
157
- else if (type === 'number') { prop = 'value'; event = 'input'; coerce = true; }
158
- else { prop = 'value'; event = 'input'; }
159
- modelBindings.push({ varName: `__model${modelIdx++}`, signal: signalName, prop, event, coerce, radioValue, path: [...pathParts] });
160
- el.removeAttribute('model');
161
- }
162
-
163
- // Detect model:propName="signalName" attributes (for custom element binding)
164
- const modelPropAttrsToRemove = [];
165
- for (const attr of Array.from(el.attributes)) {
166
- if (attr.name.startsWith('model:')) {
167
- const propName = attr.name.slice(6);
168
- const signal = attr.value;
169
- const tag = el.tagName.toLowerCase();
170
- if (!tag.includes('-')) {
171
- const error = new Error(`model:propName is only valid on custom elements (tag must contain a hyphen)`);
172
- error.code = 'MODEL_PROP_INVALID_TARGET';
173
- throw error;
174
- }
175
- modelPropBindings.push({ varName: `__modelProp${modelPropIdx++}`, propName, signal, path: [...pathParts] });
176
- modelPropAttrsToRemove.push(attr.name);
177
- }
178
- }
179
- modelPropAttrsToRemove.forEach(a => el.removeAttribute(a));
180
- }
181
-
182
- if (node.nodeType === 3 && /\{\{[\w.]+\}\}/.test(node.textContent)) {
183
- const text = node.textContent;
184
- const trimmed = text.trim();
185
- const soleMatch = trimmed.match(/^\{\{([\w.]+)\}\}$/);
186
- const parent = node.parentNode;
187
-
188
- if (soleMatch && parent.childNodes.length === 1) {
189
- bindings.push({ varName: `__b${bindIdx++}`, name: soleMatch[1], type: bindingType(soleMatch[1]), path: pathParts.slice(0, -1) });
190
- parent.textContent = '';
191
- return;
192
- }
193
-
194
- const doc = node.ownerDocument;
195
- const fragment = doc.createDocumentFragment();
196
- const parts = text.split(/(\{\{[\w.]+\}\})/);
197
- const parentPath = pathParts.slice(0, -1);
198
- let baseIndex = 0;
199
- for (const child of parent.childNodes) { if (child === node) break; baseIndex++; }
200
- let offset = 0;
201
- for (const part of parts) {
202
- const bm = part.match(/^\{\{([\w.]+)\}\}$/);
203
- if (bm) {
204
- fragment.appendChild(doc.createElement('span'));
205
- bindings.push({ varName: `__b${bindIdx++}`, name: bm[1], type: bindingType(bm[1]), path: [...parentPath, `childNodes[${baseIndex + offset}]`] });
206
- offset++;
207
- } else if (part) {
208
- fragment.appendChild(doc.createTextNode(part));
209
- offset++;
210
- }
211
- }
212
- parent.replaceChild(fragment, node);
213
- return;
214
- }
215
-
216
- const children = Array.from(node.childNodes);
217
- for (let i = 0; i < children.length; i++) {
218
- walk(children[i], [...pathParts, `childNodes[${i}]`]);
219
- }
220
- }
221
-
222
- walk(rootEl, []);
223
- return { bindings, events, showBindings, modelBindings, modelPropBindings, attrBindings, slots, childComponents };
224
- }
225
-
226
- function recomputeAnchorPath(rootEl, targetNode) {
227
- const segments = [];
228
- let current = targetNode;
229
- while (current && current !== rootEl) {
230
- const parent = current.parentNode;
231
- if (!parent) break;
232
- const children = Array.from(parent.childNodes);
233
- const idx = children.indexOf(current);
234
- segments.unshift(`childNodes[${idx}]`);
235
- current = parent;
236
- }
237
- return segments;
238
- }
239
-
240
- function walkBranch(html, signalNames, computedNames, propNames) {
241
- const branchRoot = createRoot(html);
242
- const result = walkTree(branchRoot, signalNames, computedNames, propNames);
243
- const processedHtml = branchRoot.innerHTML;
244
-
245
- function stripFirstSegment(items) {
246
- for (const item of items) {
247
- if (item.path && item.path.length > 0 && item.path[0].startsWith('childNodes[')) {
248
- item.path = item.path.slice(1);
249
- }
250
- }
251
- }
252
- stripFirstSegment(result.bindings);
253
- stripFirstSegment(result.events);
254
- stripFirstSegment(result.showBindings);
255
- stripFirstSegment(result.attrBindings);
256
- stripFirstSegment(result.modelBindings);
257
- stripFirstSegment(result.slots);
258
- stripFirstSegment(result.childComponents);
259
-
260
- return { ...result, processedHtml };
261
- }
262
-
263
- // Simplified processIfChains and processForBlocks for browser
264
- // (same logic as tree-walker.js but using browser DOM)
265
-
266
- function isChainPredecessor(el) {
267
- return el.hasAttribute('if') || el.hasAttribute('else-if');
268
- }
269
-
270
- function processIfChains(parent, parentPath, signalNames, computedNames, propNames) {
271
- const ifBlocks = [];
272
- let ifIdx = 0;
273
-
274
- function findIfChains(node, currentPath) {
275
- const children = Array.from(node.childNodes);
276
- let currentChain = null;
277
- let prevElement = null;
278
-
279
- for (const child of children) {
280
- if (child.nodeType !== 1) continue;
281
- const el = child;
282
- const hasIf = el.hasAttribute('if');
283
- const hasElseIf = el.hasAttribute('else-if');
284
- const hasElse = el.hasAttribute('else');
285
-
286
- if (hasIf) {
287
- if (currentChain) {
288
- ifBlocks.push(buildIfBlock(currentChain, node, currentPath, ifIdx++, signalNames, computedNames, propNames));
289
- currentChain = null;
290
- }
291
- currentChain = { elements: [el], branches: [{ type: 'if', expression: el.getAttribute('if'), element: el }] };
292
- } else if (hasElseIf && currentChain) {
293
- currentChain.elements.push(el);
294
- currentChain.branches.push({ type: 'else-if', expression: el.getAttribute('else-if'), element: el });
295
- } else if (hasElse && currentChain) {
296
- currentChain.elements.push(el);
297
- currentChain.branches.push({ type: 'else', expression: null, element: el });
298
- ifBlocks.push(buildIfBlock(currentChain, node, currentPath, ifIdx++, signalNames, computedNames, propNames));
299
- currentChain = null;
300
- } else {
301
- if (currentChain) {
302
- ifBlocks.push(buildIfBlock(currentChain, node, currentPath, ifIdx++, signalNames, computedNames, propNames));
303
- currentChain = null;
304
- }
305
- const childIdx = Array.from(node.childNodes).indexOf(el);
306
- findIfChains(el, [...currentPath, `childNodes[${childIdx}]`]);
307
- }
308
- prevElement = el;
309
- }
310
- if (currentChain) {
311
- ifBlocks.push(buildIfBlock(currentChain, node, currentPath, ifIdx++, signalNames, computedNames, propNames));
312
- }
313
- }
314
-
315
- findIfChains(parent, parentPath);
316
- parent.normalize();
317
- for (const ib of ifBlocks) ib.anchorPath = recomputeAnchorPath(parent, ib._anchorNode);
318
- return ifBlocks;
319
- }
320
-
321
- function buildIfBlock(chain, parent, parentPath, idx, signalNames, computedNames, propNames) {
322
- const doc = parent.ownerDocument;
323
- const branches = chain.branches.map(branch => {
324
- const clone = branch.element.cloneNode(true);
325
- clone.removeAttribute('if');
326
- clone.removeAttribute('else-if');
327
- clone.removeAttribute('else');
328
- const { bindings, events, showBindings, attrBindings, modelBindings, slots, processedHtml } = walkBranch(clone.outerHTML, signalNames, computedNames, propNames);
329
- return { type: branch.type, expression: branch.expression, templateHtml: processedHtml, bindings, events, showBindings, attrBindings, modelBindings, slots };
330
- });
331
-
332
- const comment = doc.createComment(' if ');
333
- parent.insertBefore(comment, chain.elements[0]);
334
- for (const el of chain.elements) parent.removeChild(el);
335
-
336
- const childNodes = Array.from(parent.childNodes);
337
- const commentIndex = childNodes.indexOf(comment);
338
-
339
- return { varName: `__if${idx}`, anchorPath: [...parentPath, `childNodes[${commentIndex}]`], _anchorNode: comment, branches };
340
- }
341
-
342
- function processForBlocks(parent, parentPath, signalNames, computedNames, propNames) {
343
- const forBlocks = [];
344
- let forIdx = 0;
345
- const simpleRe = /^\s*(\w+)\s+in\s+(.+)\s*$/;
346
- const destructuredRe = /^\s*\(\s*(\w+)\s*,\s*(\w+)\s*\)\s+in\s+(.+)\s*$/;
347
-
348
- function parseEach(expr) {
349
- const d = destructuredRe.exec(expr);
350
- if (d) return { itemVar: d[1], indexVar: d[2], source: d[3].trim() };
351
- const s = simpleRe.exec(expr);
352
- if (s) return { itemVar: s[1], indexVar: null, source: s[2].trim() };
353
- throw new Error('Invalid each expression: ' + expr);
354
- }
355
-
356
- function find(node, currentPath) {
357
- const children = Array.from(node.childNodes);
358
- for (let i = 0; i < children.length; i++) {
359
- const child = children[i];
360
- if (child.nodeType !== 1) continue;
361
- const el = child;
362
- if (el.hasAttribute('each')) {
363
- const { itemVar, indexVar, source } = parseEach(el.getAttribute('each'));
364
- const keyExpr = el.hasAttribute(':key') ? el.getAttribute(':key') : null;
365
- const clone = el.cloneNode(true);
366
- clone.removeAttribute('each');
367
- clone.removeAttribute(':key');
368
- const { bindings, events, showBindings, attrBindings, modelBindings, slots, processedHtml } = walkBranch(clone.outerHTML, signalNames, computedNames, propNames);
369
- const doc = node.ownerDocument;
370
- const comment = doc.createComment(' each ');
371
- node.replaceChild(comment, el);
372
- const updatedChildren = Array.from(node.childNodes);
373
- const commentIndex = updatedChildren.indexOf(comment);
374
- forBlocks.push({
375
- varName: `__for${forIdx++}`, itemVar, indexVar, source, keyExpr,
376
- templateHtml: processedHtml, anchorPath: [...currentPath, `childNodes[${commentIndex}]`],
377
- _anchorNode: comment, bindings, events, showBindings, attrBindings, modelBindings, slots,
378
- });
379
- } else {
380
- find(el, [...currentPath, `childNodes[${i}]`]);
381
- }
382
- }
383
- }
384
-
385
- find(parent, parentPath);
386
- return forBlocks;
387
- }
388
-
389
- function detectRefs(rootEl) {
390
- const refBindings = [];
391
- const elements = rootEl.querySelectorAll('[ref]');
392
- for (const el of elements) {
393
- const refName = el.getAttribute('ref');
394
- const path = [];
395
- let current = el;
396
- while (current && current !== rootEl) {
397
- const parent = current.parentNode;
398
- if (!parent) break;
399
- const children = Array.from(parent.childNodes);
400
- path.unshift(`childNodes[${children.indexOf(current)}`);
401
- current = parent;
402
- }
403
- el.removeAttribute('ref');
404
- refBindings.push({ refName, path });
405
- }
406
- return refBindings;
407
- }
408
-
409
- // ── Main compile function ───────────────────────────────────────────
410
-
411
- /**
412
- * @typedef {Object} CompileFromStringsOptions
413
- * @property {string} script
414
- * @property {string} template
415
- * @property {string} [style]
416
- * @property {string} tag
417
- * @property {'ts'|'js'} [lang]
418
- * @property {(code: string) => Promise<string>} [stripTypes]
419
- */
420
-
421
- /**
422
- * Compile a web component from source strings.
423
- * Browser-compatible — uses DOMParser instead of jsdom.
424
- *
425
- * @param {CompileFromStringsOptions} options
426
- * @returns {Promise<string>} Compiled JavaScript
427
- */
428
- export async function compileFromStrings({ script, template, style = '', tag, lang = 'js', stripTypes }) {
429
- const className = toClassName(tag);
430
-
431
- // 1. Strip macro imports
432
- let source = stripMacroImport(script);
433
-
434
- // 2. Extract from generic form BEFORE type stripping
435
- const propsFromGeneric = extractPropsGeneric(source);
436
- const propsObjectNameFromGeneric = extractPropsObjectName(source);
437
- const emitsFromCallSignatures = extractEmitsFromCallSignatures(source);
438
- const emitsObjectNameGenericMatch = source.match(/(?:const|let|var)\s+([$\w]+)\s*=\s*defineEmits\s*<\s*\{/);
439
- const emitsObjectNameFromGeneric = emitsObjectNameGenericMatch ? emitsObjectNameGenericMatch[1] : null;
440
-
441
- // 3. Strip TypeScript types if needed
442
- if (lang === 'ts' && stripTypes) {
443
- source = await stripTypes(source);
444
- }
445
-
446
- // 4. Extract lifecycle hooks
447
- const { onMountHooks, onDestroyHooks } = extractLifecycleHooks(source);
448
-
449
- // 4b. Strip lifecycle + watch blocks
450
- const hookLinePattern = /\bonMount\s*\(|\bonDestroy\s*\(|\bwatch\s*\(/;
451
- const sourceLines = source.split('\n');
452
- const filteredLines = [];
453
- let skipDepth = 0, skipping = false;
454
- for (const line of sourceLines) {
455
- if (!skipping && hookLinePattern.test(line)) {
456
- skipping = true; skipDepth = 0;
457
- for (const ch of line) { if (ch === '{') skipDepth++; if (ch === '}') skipDepth--; }
458
- if (skipDepth <= 0) skipping = false;
459
- continue;
460
- }
461
- if (skipping) {
462
- for (const ch of line) { if (ch === '{') skipDepth++; if (ch === '}') skipDepth--; }
463
- if (skipDepth <= 0) skipping = false;
464
- continue;
465
- }
466
- filteredLines.push(line);
467
- }
468
- const src = filteredLines.join('\n');
469
-
470
- // 5. Extract declarations
471
- const signals = extractSignals(src);
472
- const computeds = extractComputeds(src);
473
- const effects = extractEffects(src);
474
- const watchers = extractWatchers(source);
475
- const methods = extractFunctions(src);
476
- const refs = extractRefs(src);
477
- const constantVars = extractConstants(src);
478
-
479
- // 6. Props
480
- const propsFromArray = propsFromGeneric.length > 0 ? [] : extractPropsArray(source);
481
- let propNames = propsFromGeneric.length > 0 ? propsFromGeneric : propsFromArray;
482
- const propsDefaults = extractPropsDefaults(source);
483
- if (propNames.length === 0 && Object.keys(propsDefaults).length > 0) propNames = Object.keys(propsDefaults);
484
- const propsObjectName = propsObjectNameFromGeneric ?? extractPropsObjectName(source);
485
- const propDefs = propNames.map(name => ({ name, default: propsDefaults[name] ?? 'undefined', attrName: camelToKebab(name) }));
486
-
487
- // 7. Emits
488
- const emitsFromArray = emitsFromCallSignatures.length > 0 ? [] : extractEmits(source);
489
- const emitNames = emitsFromCallSignatures.length > 0 ? emitsFromCallSignatures : emitsFromArray;
490
- const emitsObjectName = emitsObjectNameFromGeneric ?? extractEmitsObjectName(source);
491
-
492
- // 8. Parse template
493
- const rootEl = createRoot(template);
494
-
495
- // 9. Name sets
496
- const signalNameSet = new Set(signals.map(s => s.name));
497
- const computedNameSet = new Set(computeds.map(c => c.name));
498
- const propNameSet = new Set(propDefs.map(p => p.name));
499
-
500
- // 10. Process directives
501
- const forBlocks = processForBlocks(rootEl, [], signalNameSet, computedNameSet, propNameSet);
502
- const ifBlocks = processIfChains(rootEl, [], signalNameSet, computedNameSet, propNameSet);
503
- rootEl.normalize();
504
- for (const fb of forBlocks) fb.anchorPath = recomputeAnchorPath(rootEl, fb._anchorNode);
505
- for (const ib of ifBlocks) ib.anchorPath = recomputeAnchorPath(rootEl, ib._anchorNode);
506
-
507
- // 11. Walk tree
508
- const { bindings, events, showBindings, modelBindings, modelPropBindings, attrBindings, slots, childComponents } = walkTree(rootEl, signalNameSet, computedNameSet, propNameSet);
509
-
510
- // 12. Detect refs
511
- const refBindings = detectRefs(rootEl);
512
-
513
- // 13. Generate
514
- return generateComponent({
515
- tagName: tag, className, template, style,
516
- signals, computeds, effects, constantVars, watchers, methods,
517
- propDefs, propsObjectName: propsObjectName ?? null,
518
- emits: emitNames, emitsObjectName: emitsObjectName ?? null,
519
- bindings, events, showBindings, modelBindings, attrBindings,
520
- ifBlocks, forBlocks, slots, onMountHooks, onDestroyHooks,
521
- refs, refBindings, childComponents, childImports: [],
522
- processedTemplate: rootEl.innerHTML,
523
- });
524
- }
525
-
526
- /**
527
- * Compile an SFC component from a source string (browser-compatible).
528
- * Parses the SFC to extract blocks, then delegates to compileFromStrings.
529
- *
530
- * @param {string} source — Full content of the .wcc file
531
- * @param {{ stripTypes?: (code: string) => Promise<string> }} [options]
532
- * @returns {Promise<string>} Compiled JavaScript
533
- */
534
- export async function compileFromSFC(source, options) {
535
- const descriptor = parseSFC(source);
536
- return compileFromStrings({
537
- script: descriptor.script,
538
- template: descriptor.template,
539
- style: descriptor.style,
540
- tag: descriptor.tag,
541
- lang: descriptor.lang,
542
- stripTypes: options?.stripTypes,
543
- });
544
- }
545
-
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
+ import { parseSFC } from './sfc-parser.js';
47
+
48
+ // ── Browser-compatible DOM helpers ──────────────────────────────────
49
+
50
+ /**
51
+ * Create a DOM root from HTML using the browser's DOMParser.
52
+ * @param {string} html
53
+ * @returns {Element}
54
+ */
55
+ function createRoot(html) {
56
+ const doc = new DOMParser().parseFromString(
57
+ `<html><body><div id="__root">${html}</div></body></html>`,
58
+ 'text/html'
59
+ );
60
+ return doc.getElementById('__root');
61
+ }
62
+
63
+ // ── Inline tree-walker (browser-compatible, no jsdom import) ────────
64
+ // These are copies of the tree-walker functions that use DOMParser
65
+ // instead of JSDOM for walkBranch. walkTree itself is DOM-agnostic.
66
+
67
+ function walkTree(rootEl, signalNames, computedNames, propNames = new Set()) {
68
+ const bindings = [];
69
+ const events = [];
70
+ const showBindings = [];
71
+ const modelBindings = [];
72
+ const modelPropBindings = [];
73
+ const attrBindings = [];
74
+ const slots = [];
75
+ const childComponents = [];
76
+ let bindIdx = 0, eventIdx = 0, showIdx = 0, modelIdx = 0, modelPropIdx = 0, attrIdx = 0, slotIdx = 0, childIdx = 0;
77
+
78
+ function bindingType(name) {
79
+ if (propNames.has(name)) return 'prop';
80
+ if (signalNames.has(name)) return 'signal';
81
+ if (computedNames.has(name)) return 'computed';
82
+ return 'method';
83
+ }
84
+
85
+ function walk(node, pathParts) {
86
+ if (node.nodeType === 1) {
87
+ const el = node;
88
+
89
+ if (el.tagName === 'SLOT') {
90
+ const slotName = el.getAttribute('name') || '';
91
+ const varName = `__s${slotIdx++}`;
92
+ const defaultContent = el.innerHTML.trim();
93
+ const slotProps = [];
94
+ for (const attr of Array.from(el.attributes)) {
95
+ if (attr.name.startsWith(':')) slotProps.push({ prop: attr.name.slice(1), source: attr.value });
96
+ }
97
+ slots.push({ varName, name: slotName, path: [...pathParts], defaultContent, slotProps });
98
+ const placeholder = el.ownerDocument.createElement('span');
99
+ placeholder.setAttribute('data-slot', slotName || 'default');
100
+ if (defaultContent) placeholder.innerHTML = defaultContent;
101
+ el.parentNode.replaceChild(placeholder, el);
102
+ return;
103
+ }
104
+
105
+ const tagLower = el.tagName.toLowerCase();
106
+ if (tagLower.includes('-') && tagLower !== rootEl.tagName?.toLowerCase()) {
107
+ const propBindings = [];
108
+ for (const attr of Array.from(el.attributes)) {
109
+ if (attr.name.startsWith('@') || attr.name.startsWith(':') || attr.name.startsWith('bind:') || attr.name.startsWith('model:')) continue;
110
+ if (['show', 'model', 'if', 'else-if', 'else', 'each', 'ref'].includes(attr.name)) continue;
111
+ const interpMatch = attr.value.match(/^\{\{([\w.]+)\}\}$/);
112
+ if (interpMatch) {
113
+ const expr = interpMatch[1];
114
+ propBindings.push({
115
+ attr: attr.name, expr,
116
+ type: propNames.has(expr) ? 'prop' : signalNames.has(expr) ? 'signal' : computedNames.has(expr) ? 'computed' : 'method',
117
+ });
118
+ el.setAttribute(attr.name, '');
119
+ }
120
+ }
121
+ if (propBindings.length > 0) {
122
+ childComponents.push({ tag: tagLower, varName: `__child${childIdx++}`, path: [...pathParts], propBindings });
123
+ }
124
+ }
125
+
126
+ const attrsToRemove = [];
127
+ for (const attr of Array.from(el.attributes)) {
128
+ if (attr.name.startsWith('@')) {
129
+ events.push({ varName: `__e${eventIdx++}`, event: attr.name.slice(1), handler: attr.value, path: [...pathParts] });
130
+ attrsToRemove.push(attr.name);
131
+ } else if (attr.name.startsWith(':') || attr.name.startsWith('bind:')) {
132
+ const attrName = attr.name.startsWith(':') ? attr.name.slice(1) : attr.name.slice(5);
133
+ let kind = 'attr';
134
+ if (attrName === 'class') kind = 'class';
135
+ else if (attrName === 'style') kind = 'style';
136
+ else if (BOOLEAN_ATTRIBUTES.has(attrName)) kind = 'bool';
137
+ attrBindings.push({ varName: `__attr${attrIdx++}`, attr: attrName, expression: attr.value, kind, path: [...pathParts] });
138
+ attrsToRemove.push(attr.name);
139
+ }
140
+ }
141
+ attrsToRemove.forEach(a => el.removeAttribute(a));
142
+
143
+ if (el.hasAttribute('show')) {
144
+ showBindings.push({ varName: `__show${showIdx++}`, expression: el.getAttribute('show'), path: [...pathParts] });
145
+ el.removeAttribute('show');
146
+ }
147
+
148
+ if (el.hasAttribute('model')) {
149
+ const signalName = el.getAttribute('model');
150
+ const tag = el.tagName.toLowerCase();
151
+ const type = el.getAttribute('type') || 'text';
152
+ let prop, event, coerce = false, radioValue = null;
153
+ if (tag === 'select') { prop = 'value'; event = 'change'; }
154
+ else if (tag === 'textarea') { prop = 'value'; event = 'input'; }
155
+ else if (type === 'checkbox') { prop = 'checked'; event = 'change'; }
156
+ else if (type === 'radio') { prop = 'checked'; event = 'change'; radioValue = el.getAttribute('value'); }
157
+ else if (type === 'number') { prop = 'value'; event = 'input'; coerce = true; }
158
+ else { prop = 'value'; event = 'input'; }
159
+ modelBindings.push({ varName: `__model${modelIdx++}`, signal: signalName, prop, event, coerce, radioValue, path: [...pathParts] });
160
+ el.removeAttribute('model');
161
+ }
162
+
163
+ // Detect model:propName="signalName" attributes (for custom element binding)
164
+ const modelPropAttrsToRemove = [];
165
+ for (const attr of Array.from(el.attributes)) {
166
+ if (attr.name.startsWith('model:')) {
167
+ const propName = attr.name.slice(6);
168
+ const signal = attr.value;
169
+ const tag = el.tagName.toLowerCase();
170
+ if (!tag.includes('-')) {
171
+ const error = new Error(`model:propName is only valid on custom elements (tag must contain a hyphen)`);
172
+ error.code = 'MODEL_PROP_INVALID_TARGET';
173
+ throw error;
174
+ }
175
+ modelPropBindings.push({ varName: `__modelProp${modelPropIdx++}`, propName, signal, path: [...pathParts] });
176
+ modelPropAttrsToRemove.push(attr.name);
177
+ }
178
+ }
179
+ modelPropAttrsToRemove.forEach(a => el.removeAttribute(a));
180
+ }
181
+
182
+ if (node.nodeType === 3 && /\{\{[\w.]+\}\}/.test(node.textContent)) {
183
+ const text = node.textContent;
184
+ const trimmed = text.trim();
185
+ const soleMatch = trimmed.match(/^\{\{([\w.]+)\}\}$/);
186
+ const parent = node.parentNode;
187
+
188
+ if (soleMatch && parent.childNodes.length === 1) {
189
+ bindings.push({ varName: `__b${bindIdx++}`, name: soleMatch[1], type: bindingType(soleMatch[1]), path: pathParts.slice(0, -1) });
190
+ parent.textContent = '';
191
+ return;
192
+ }
193
+
194
+ const doc = node.ownerDocument;
195
+ const fragment = doc.createDocumentFragment();
196
+ const parts = text.split(/(\{\{[\w.]+\}\})/);
197
+ const parentPath = pathParts.slice(0, -1);
198
+ let baseIndex = 0;
199
+ for (const child of parent.childNodes) { if (child === node) break; baseIndex++; }
200
+ let offset = 0;
201
+ for (const part of parts) {
202
+ const bm = part.match(/^\{\{([\w.]+)\}\}$/);
203
+ if (bm) {
204
+ fragment.appendChild(doc.createElement('span'));
205
+ bindings.push({ varName: `__b${bindIdx++}`, name: bm[1], type: bindingType(bm[1]), path: [...parentPath, `childNodes[${baseIndex + offset}]`] });
206
+ offset++;
207
+ } else if (part) {
208
+ fragment.appendChild(doc.createTextNode(part));
209
+ offset++;
210
+ }
211
+ }
212
+ parent.replaceChild(fragment, node);
213
+ return;
214
+ }
215
+
216
+ const children = Array.from(node.childNodes);
217
+ for (let i = 0; i < children.length; i++) {
218
+ walk(children[i], [...pathParts, `childNodes[${i}]`]);
219
+ }
220
+ }
221
+
222
+ walk(rootEl, []);
223
+ return { bindings, events, showBindings, modelBindings, modelPropBindings, attrBindings, slots, childComponents };
224
+ }
225
+
226
+ function recomputeAnchorPath(rootEl, targetNode) {
227
+ const segments = [];
228
+ let current = targetNode;
229
+ while (current && current !== rootEl) {
230
+ const parent = current.parentNode;
231
+ if (!parent) break;
232
+ const children = Array.from(parent.childNodes);
233
+ const idx = children.indexOf(current);
234
+ segments.unshift(`childNodes[${idx}]`);
235
+ current = parent;
236
+ }
237
+ return segments;
238
+ }
239
+
240
+ function walkBranch(html, signalNames, computedNames, propNames) {
241
+ const branchRoot = createRoot(html);
242
+ const result = walkTree(branchRoot, signalNames, computedNames, propNames);
243
+ const processedHtml = branchRoot.innerHTML;
244
+
245
+ function stripFirstSegment(items) {
246
+ for (const item of items) {
247
+ if (item.path && item.path.length > 0 && item.path[0].startsWith('childNodes[')) {
248
+ item.path = item.path.slice(1);
249
+ }
250
+ }
251
+ }
252
+ stripFirstSegment(result.bindings);
253
+ stripFirstSegment(result.events);
254
+ stripFirstSegment(result.showBindings);
255
+ stripFirstSegment(result.attrBindings);
256
+ stripFirstSegment(result.modelBindings);
257
+ stripFirstSegment(result.slots);
258
+ stripFirstSegment(result.childComponents);
259
+
260
+ return { ...result, processedHtml };
261
+ }
262
+
263
+ // Simplified processIfChains and processForBlocks for browser
264
+ // (same logic as tree-walker.js but using browser DOM)
265
+
266
+ function isChainPredecessor(el) {
267
+ return el.hasAttribute('if') || el.hasAttribute('else-if');
268
+ }
269
+
270
+ function processIfChains(parent, parentPath, signalNames, computedNames, propNames) {
271
+ const ifBlocks = [];
272
+ let ifIdx = 0;
273
+
274
+ function findIfChains(node, currentPath) {
275
+ const children = Array.from(node.childNodes);
276
+ let currentChain = null;
277
+ let prevElement = null;
278
+
279
+ for (const child of children) {
280
+ if (child.nodeType !== 1) continue;
281
+ const el = child;
282
+ const hasIf = el.hasAttribute('if');
283
+ const hasElseIf = el.hasAttribute('else-if');
284
+ const hasElse = el.hasAttribute('else');
285
+
286
+ if (hasIf) {
287
+ if (currentChain) {
288
+ ifBlocks.push(buildIfBlock(currentChain, node, currentPath, ifIdx++, signalNames, computedNames, propNames));
289
+ currentChain = null;
290
+ }
291
+ currentChain = { elements: [el], branches: [{ type: 'if', expression: el.getAttribute('if'), element: el }] };
292
+ } else if (hasElseIf && currentChain) {
293
+ currentChain.elements.push(el);
294
+ currentChain.branches.push({ type: 'else-if', expression: el.getAttribute('else-if'), element: el });
295
+ } else if (hasElse && currentChain) {
296
+ currentChain.elements.push(el);
297
+ currentChain.branches.push({ type: 'else', expression: null, element: el });
298
+ ifBlocks.push(buildIfBlock(currentChain, node, currentPath, ifIdx++, signalNames, computedNames, propNames));
299
+ currentChain = null;
300
+ } else {
301
+ if (currentChain) {
302
+ ifBlocks.push(buildIfBlock(currentChain, node, currentPath, ifIdx++, signalNames, computedNames, propNames));
303
+ currentChain = null;
304
+ }
305
+ const childIdx = Array.from(node.childNodes).indexOf(el);
306
+ findIfChains(el, [...currentPath, `childNodes[${childIdx}]`]);
307
+ }
308
+ prevElement = el;
309
+ }
310
+ if (currentChain) {
311
+ ifBlocks.push(buildIfBlock(currentChain, node, currentPath, ifIdx++, signalNames, computedNames, propNames));
312
+ }
313
+ }
314
+
315
+ findIfChains(parent, parentPath);
316
+ parent.normalize();
317
+ for (const ib of ifBlocks) ib.anchorPath = recomputeAnchorPath(parent, ib._anchorNode);
318
+ return ifBlocks;
319
+ }
320
+
321
+ function buildIfBlock(chain, parent, parentPath, idx, signalNames, computedNames, propNames) {
322
+ const doc = parent.ownerDocument;
323
+ const branches = chain.branches.map(branch => {
324
+ const clone = branch.element.cloneNode(true);
325
+ clone.removeAttribute('if');
326
+ clone.removeAttribute('else-if');
327
+ clone.removeAttribute('else');
328
+ const { bindings, events, showBindings, attrBindings, modelBindings, slots, processedHtml } = walkBranch(clone.outerHTML, signalNames, computedNames, propNames);
329
+ return { type: branch.type, expression: branch.expression, templateHtml: processedHtml, bindings, events, showBindings, attrBindings, modelBindings, slots };
330
+ });
331
+
332
+ const comment = doc.createComment(' if ');
333
+ parent.insertBefore(comment, chain.elements[0]);
334
+ for (const el of chain.elements) parent.removeChild(el);
335
+
336
+ const childNodes = Array.from(parent.childNodes);
337
+ const commentIndex = childNodes.indexOf(comment);
338
+
339
+ return { varName: `__if${idx}`, anchorPath: [...parentPath, `childNodes[${commentIndex}]`], _anchorNode: comment, branches };
340
+ }
341
+
342
+ function processForBlocks(parent, parentPath, signalNames, computedNames, propNames) {
343
+ const forBlocks = [];
344
+ let forIdx = 0;
345
+ const simpleRe = /^\s*(\w+)\s+in\s+(.+)\s*$/;
346
+ const destructuredRe = /^\s*\(\s*(\w+)\s*,\s*(\w+)\s*\)\s+in\s+(.+)\s*$/;
347
+
348
+ function parseEach(expr) {
349
+ const d = destructuredRe.exec(expr);
350
+ if (d) return { itemVar: d[1], indexVar: d[2], source: d[3].trim() };
351
+ const s = simpleRe.exec(expr);
352
+ if (s) return { itemVar: s[1], indexVar: null, source: s[2].trim() };
353
+ throw new Error('Invalid each expression: ' + expr);
354
+ }
355
+
356
+ function find(node, currentPath) {
357
+ const children = Array.from(node.childNodes);
358
+ for (let i = 0; i < children.length; i++) {
359
+ const child = children[i];
360
+ if (child.nodeType !== 1) continue;
361
+ const el = child;
362
+ if (el.hasAttribute('each')) {
363
+ const { itemVar, indexVar, source } = parseEach(el.getAttribute('each'));
364
+ const keyExpr = el.hasAttribute(':key') ? el.getAttribute(':key') : null;
365
+ const clone = el.cloneNode(true);
366
+ clone.removeAttribute('each');
367
+ clone.removeAttribute(':key');
368
+ const { bindings, events, showBindings, attrBindings, modelBindings, slots, processedHtml } = walkBranch(clone.outerHTML, signalNames, computedNames, propNames);
369
+ const doc = node.ownerDocument;
370
+ const comment = doc.createComment(' each ');
371
+ node.replaceChild(comment, el);
372
+ const updatedChildren = Array.from(node.childNodes);
373
+ const commentIndex = updatedChildren.indexOf(comment);
374
+ forBlocks.push({
375
+ varName: `__for${forIdx++}`, itemVar, indexVar, source, keyExpr,
376
+ templateHtml: processedHtml, anchorPath: [...currentPath, `childNodes[${commentIndex}]`],
377
+ _anchorNode: comment, bindings, events, showBindings, attrBindings, modelBindings, slots,
378
+ });
379
+ } else {
380
+ find(el, [...currentPath, `childNodes[${i}]`]);
381
+ }
382
+ }
383
+ }
384
+
385
+ find(parent, parentPath);
386
+ return forBlocks;
387
+ }
388
+
389
+ function detectRefs(rootEl) {
390
+ const refBindings = [];
391
+ const elements = rootEl.querySelectorAll('[ref]');
392
+ for (const el of elements) {
393
+ const refName = el.getAttribute('ref');
394
+ const path = [];
395
+ let current = el;
396
+ while (current && current !== rootEl) {
397
+ const parent = current.parentNode;
398
+ if (!parent) break;
399
+ const children = Array.from(parent.childNodes);
400
+ path.unshift(`childNodes[${children.indexOf(current)}`);
401
+ current = parent;
402
+ }
403
+ el.removeAttribute('ref');
404
+ refBindings.push({ refName, path });
405
+ }
406
+ return refBindings;
407
+ }
408
+
409
+ // ── Main compile function ───────────────────────────────────────────
410
+
411
+ /**
412
+ * @typedef {Object} CompileFromStringsOptions
413
+ * @property {string} script
414
+ * @property {string} template
415
+ * @property {string} [style]
416
+ * @property {string} tag
417
+ * @property {'ts'|'js'} [lang]
418
+ * @property {(code: string) => Promise<string>} [stripTypes]
419
+ */
420
+
421
+ /**
422
+ * Compile a web component from source strings.
423
+ * Browser-compatible — uses DOMParser instead of jsdom.
424
+ *
425
+ * @param {CompileFromStringsOptions} options
426
+ * @returns {Promise<string>} Compiled JavaScript
427
+ */
428
+ export async function compileFromStrings({ script, template, style = '', tag, lang = 'js', stripTypes }) {
429
+ const className = toClassName(tag);
430
+
431
+ // 1. Strip macro imports
432
+ let source = stripMacroImport(script);
433
+
434
+ // 2. Extract from generic form BEFORE type stripping
435
+ const propsFromGeneric = extractPropsGeneric(source);
436
+ const propsObjectNameFromGeneric = extractPropsObjectName(source);
437
+ const emitsFromCallSignatures = extractEmitsFromCallSignatures(source);
438
+ const emitsObjectNameGenericMatch = source.match(/(?:const|let|var)\s+([$\w]+)\s*=\s*defineEmits\s*<\s*\{/);
439
+ const emitsObjectNameFromGeneric = emitsObjectNameGenericMatch ? emitsObjectNameGenericMatch[1] : null;
440
+
441
+ // 3. Strip TypeScript types if needed
442
+ if (lang === 'ts' && stripTypes) {
443
+ source = await stripTypes(source);
444
+ }
445
+
446
+ // 4. Extract lifecycle hooks
447
+ const { onMountHooks, onDestroyHooks } = extractLifecycleHooks(source);
448
+
449
+ // 4b. Strip lifecycle + watch blocks
450
+ const hookLinePattern = /\bonMount\s*\(|\bonDestroy\s*\(|\bwatch\s*\(/;
451
+ const sourceLines = source.split('\n');
452
+ const filteredLines = [];
453
+ let skipDepth = 0, skipping = false;
454
+ for (const line of sourceLines) {
455
+ if (!skipping && hookLinePattern.test(line)) {
456
+ skipping = true; skipDepth = 0;
457
+ for (const ch of line) { if (ch === '{') skipDepth++; if (ch === '}') skipDepth--; }
458
+ if (skipDepth <= 0) skipping = false;
459
+ continue;
460
+ }
461
+ if (skipping) {
462
+ for (const ch of line) { if (ch === '{') skipDepth++; if (ch === '}') skipDepth--; }
463
+ if (skipDepth <= 0) skipping = false;
464
+ continue;
465
+ }
466
+ filteredLines.push(line);
467
+ }
468
+ const src = filteredLines.join('\n');
469
+
470
+ // 5. Extract declarations
471
+ const signals = extractSignals(src);
472
+ const computeds = extractComputeds(src);
473
+ const effects = extractEffects(src);
474
+ const watchers = extractWatchers(source);
475
+ const methods = extractFunctions(src);
476
+ const refs = extractRefs(src);
477
+ const constantVars = extractConstants(src);
478
+
479
+ // 6. Props
480
+ const propsFromArray = propsFromGeneric.length > 0 ? [] : extractPropsArray(source);
481
+ let propNames = propsFromGeneric.length > 0 ? propsFromGeneric : propsFromArray;
482
+ const propsDefaults = extractPropsDefaults(source);
483
+ if (propNames.length === 0 && Object.keys(propsDefaults).length > 0) propNames = Object.keys(propsDefaults);
484
+ const propsObjectName = propsObjectNameFromGeneric ?? extractPropsObjectName(source);
485
+ const propDefs = propNames.map(name => ({ name, default: propsDefaults[name] ?? 'undefined', attrName: camelToKebab(name) }));
486
+
487
+ // 7. Emits
488
+ const emitsFromArray = emitsFromCallSignatures.length > 0 ? [] : extractEmits(source);
489
+ const emitNames = emitsFromCallSignatures.length > 0 ? emitsFromCallSignatures : emitsFromArray;
490
+ const emitsObjectName = emitsObjectNameFromGeneric ?? extractEmitsObjectName(source);
491
+
492
+ // 8. Parse template
493
+ const rootEl = createRoot(template);
494
+
495
+ // 9. Name sets
496
+ const signalNameSet = new Set(signals.map(s => s.name));
497
+ const computedNameSet = new Set(computeds.map(c => c.name));
498
+ const propNameSet = new Set(propDefs.map(p => p.name));
499
+
500
+ // 10. Process directives
501
+ const forBlocks = processForBlocks(rootEl, [], signalNameSet, computedNameSet, propNameSet);
502
+ const ifBlocks = processIfChains(rootEl, [], signalNameSet, computedNameSet, propNameSet);
503
+ rootEl.normalize();
504
+ for (const fb of forBlocks) fb.anchorPath = recomputeAnchorPath(rootEl, fb._anchorNode);
505
+ for (const ib of ifBlocks) ib.anchorPath = recomputeAnchorPath(rootEl, ib._anchorNode);
506
+
507
+ // 11. Walk tree
508
+ const { bindings, events, showBindings, modelBindings, modelPropBindings, attrBindings, slots, childComponents } = walkTree(rootEl, signalNameSet, computedNameSet, propNameSet);
509
+
510
+ // 12. Detect refs
511
+ const refBindings = detectRefs(rootEl);
512
+
513
+ // 13. Generate
514
+ return generateComponent({
515
+ tagName: tag, className, template, style,
516
+ signals, computeds, effects, constantVars, watchers, methods,
517
+ propDefs, propsObjectName: propsObjectName ?? null,
518
+ emits: emitNames, emitsObjectName: emitsObjectName ?? null,
519
+ bindings, events, showBindings, modelBindings, attrBindings,
520
+ ifBlocks, forBlocks, slots, onMountHooks, onDestroyHooks,
521
+ refs, refBindings, childComponents, childImports: [],
522
+ processedTemplate: rootEl.innerHTML,
523
+ });
524
+ }
525
+
526
+ /**
527
+ * Compile an SFC component from a source string (browser-compatible).
528
+ * Parses the SFC to extract blocks, then delegates to compileFromStrings.
529
+ *
530
+ * @param {string} source — Full content of the .wcc file
531
+ * @param {{ stripTypes?: (code: string) => Promise<string> }} [options]
532
+ * @returns {Promise<string>} Compiled JavaScript
533
+ */
534
+ export async function compileFromSFC(source, options) {
535
+ const descriptor = parseSFC(source);
536
+ return compileFromStrings({
537
+ script: descriptor.script,
538
+ template: descriptor.template,
539
+ style: descriptor.style,
540
+ tag: descriptor.tag,
541
+ lang: descriptor.lang,
542
+ stripTypes: options?.stripTypes,
543
+ });
544
+ }
545
+