@sprlab/wccompiler 0.2.0 → 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.
@@ -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/compiler.js CHANGED
@@ -8,9 +8,67 @@
8
8
  */
9
9
 
10
10
  import { JSDOM } from 'jsdom';
11
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
12
+ import { resolve, relative, dirname, extname } from 'node:path';
11
13
  import { parse } from './parser.js';
12
14
  import { walkTree, processIfChains, processForBlocks, recomputeAnchorPath, detectRefs } from './tree-walker.js';
13
15
  import { generateComponent } from './codegen.js';
16
+ /**
17
+ * Resolve a child component's import path by searching for a source file
18
+ * whose defineComponent({ tag }) matches the given tag name.
19
+ *
20
+ * @param {string} tag — Child component tag name (e.g., 'wcc-badge')
21
+ * @param {string} sourceDir — Directory of the parent component source file
22
+ * @param {object} [config] — Optional config with input/output dirs
23
+ * @returns {string | null} Relative import path (e.g., './wcc-badge.js') or null if not found
24
+ */
25
+ function resolveChildComponent(tag, sourceDir, config) {
26
+ // Search in the same directory and subdirectories for a matching source file
27
+ const searchDirs = [sourceDir];
28
+
29
+ // Also search parent directory (common case: components in sibling folders)
30
+ const parentDir = dirname(sourceDir);
31
+ if (parentDir !== sourceDir) {
32
+ searchDirs.push(parentDir);
33
+ }
34
+
35
+ for (const dir of searchDirs) {
36
+ if (!existsSync(dir)) continue;
37
+ try {
38
+ const entries = readdirSync(dir, { withFileTypes: true, recursive: true });
39
+ for (const entry of entries) {
40
+ if (!entry.isFile()) continue;
41
+ const ext = extname(entry.name);
42
+ if (ext !== '.js' && ext !== '.ts') continue;
43
+ if (entry.name.includes('.test.')) continue;
44
+ if (entry.name.endsWith('.d.ts')) continue;
45
+
46
+ const fullPath = resolve(dir, entry.parentPath ? relative(dir, entry.parentPath) : '', entry.name);
47
+ try {
48
+ const content = readFileSync(fullPath, 'utf-8');
49
+ // Quick check: does this file define the component with the matching tag?
50
+ const tagMatch = content.match(/defineComponent\(\s*\{[^}]*tag\s*:\s*['"]([^'"]+)['"]/);
51
+ if (tagMatch && tagMatch[1] === tag) {
52
+ // Compute relative path from sourceDir to this file, with .js extension
53
+ let relPath = relative(sourceDir, fullPath);
54
+ // Ensure .js extension (replace .ts)
55
+ relPath = relPath.replace(/\.ts$/, '.js');
56
+ // Ensure starts with ./
57
+ if (!relPath.startsWith('.')) relPath = './' + relPath;
58
+ return relPath;
59
+ }
60
+ } catch {
61
+ // Skip files that can't be read
62
+ }
63
+ }
64
+ } catch {
65
+ // Skip dirs that can't be listed
66
+ }
67
+ }
68
+
69
+ return null;
70
+ }
71
+
14
72
  /**
15
73
  * Compile a single .ts/.js source file into a self-contained JS component.
16
74
  *
@@ -52,7 +110,7 @@ export async function compile(filePath, config) {
52
110
  }
53
111
 
54
112
  // 8. Walk the tree (discovers bindings/events/showBindings/slots in non-conditional content)
55
- const { bindings, events, showBindings, modelBindings, attrBindings, slots } = walkTree(rootEl, signalNames, computedNames, propNames);
113
+ const { bindings, events, showBindings, modelBindings, attrBindings, slots, childComponents } = walkTree(rootEl, signalNames, computedNames, propNames);
56
114
 
57
115
  // 9. Detect refs (after walkTree — ref attributes are compile-time directives)
58
116
  const refBindings = detectRefs(rootEl);
@@ -106,7 +164,24 @@ export async function compile(filePath, config) {
106
164
  }
107
165
  }
108
166
 
109
- // 11. Merge results into ParseResult
167
+ // 11. Resolve child component imports
168
+ /** @type {import('./types.js').ChildComponentImport[]} */
169
+ const childImports = [];
170
+ if (childComponents.length > 0) {
171
+ const uniqueTags = [...new Set(childComponents.map(c => c.tag))];
172
+ const sourceDir = dirname(filePath);
173
+
174
+ for (const tag of uniqueTags) {
175
+ const resolved = resolveChildComponent(tag, sourceDir, config);
176
+ if (resolved) {
177
+ childImports.push({ tag, importPath: resolved });
178
+ } else {
179
+ console.warn(`Warning: child component <${tag}> used in template but source file not found`);
180
+ }
181
+ }
182
+ }
183
+
184
+ // 12. Merge results into ParseResult
110
185
  parseResult.bindings = bindings;
111
186
  parseResult.events = events;
112
187
  parseResult.showBindings = showBindings;
@@ -116,6 +191,8 @@ export async function compile(filePath, config) {
116
191
  parseResult.forBlocks = forBlocks;
117
192
  parseResult.slots = slots;
118
193
  parseResult.refBindings = refBindings;
194
+ parseResult.childComponents = childComponents;
195
+ parseResult.childImports = childImports;
119
196
  // Recompute processedTemplate after all directive replacements (including ref removal)
120
197
  parseResult.processedTemplate = rootEl.innerHTML;
121
198