@sprlab/wccompiler 0.2.0 → 0.2.1

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/lib/codegen.js CHANGED
@@ -322,6 +322,8 @@ export function generateComponent(parseResult) {
322
322
  constantVars = [],
323
323
  refs = [],
324
324
  refBindings = [],
325
+ childComponents = [],
326
+ childImports = [],
325
327
  } = parseResult;
326
328
 
327
329
  const signalNames = signals.map(s => s.name);
@@ -336,6 +338,14 @@ export function generateComponent(parseResult) {
336
338
  lines.push(reactiveRuntime.trim());
337
339
  lines.push('');
338
340
 
341
+ // ── 1b. Child component imports ──
342
+ for (const ci of childImports) {
343
+ lines.push(`import '${ci.importPath}';`);
344
+ }
345
+ if (childImports.length > 0) {
346
+ lines.push('');
347
+ }
348
+
339
349
  // ── 2. CSS injection (scoped CSS into document.head, always) ──
340
350
  if (style) {
341
351
  const scoped = scopeCSS(style, tagName);
@@ -410,6 +420,11 @@ export function generateComponent(parseResult) {
410
420
  lines.push(` this.${s.varName} = ${pathExpr(s.path, '__root')};`);
411
421
  }
412
422
 
423
+ // Assign DOM refs for child component instances
424
+ for (const cc of childComponents) {
425
+ lines.push(` this.${cc.varName} = ${pathExpr(cc.path, '__root')};`);
426
+ }
427
+
413
428
  // Assign DOM refs for attr bindings (reuse ref when same path)
414
429
  const attrPathMap = new Map();
415
430
  for (const ab of attrBindings) {
@@ -542,6 +557,27 @@ export function generateComponent(parseResult) {
542
557
  }
543
558
  }
544
559
 
560
+ // Child component reactive prop bindings
561
+ for (const cc of childComponents) {
562
+ for (const pb of cc.propBindings) {
563
+ let ref;
564
+ if (pb.type === 'prop') {
565
+ ref = `this._s_${pb.expr}()`;
566
+ } else if (pb.type === 'computed') {
567
+ ref = `this._c_${pb.expr}()`;
568
+ } else if (pb.type === 'signal') {
569
+ ref = `this._${pb.expr}()`;
570
+ } else if (pb.type === 'constant') {
571
+ ref = `this._const_${pb.expr}`;
572
+ } else {
573
+ ref = `this._${pb.expr}()`;
574
+ }
575
+ lines.push(' __effect(() => {');
576
+ lines.push(` this.${cc.varName}.setAttribute('${pb.attr}', ${ref} ?? '');`);
577
+ lines.push(' });');
578
+ }
579
+ }
580
+
545
581
  // User effects
546
582
  for (const eff of effects) {
547
583
  const body = transformMethodBody(eff.body, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, refVarNames, constantNames);
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
 
@@ -15,7 +15,7 @@
15
15
  import { JSDOM } from 'jsdom';
16
16
  import { BOOLEAN_ATTRIBUTES } from './types.js';
17
17
 
18
- /** @import { Binding, EventBinding, IfBlock, IfBranch, ShowBinding, AttrBinding, ForBlock, ModelBinding, SlotBinding, SlotProp, RefBinding } from './types.js' */
18
+ /** @import { Binding, EventBinding, IfBlock, IfBranch, ShowBinding, AttrBinding, ForBlock, ModelBinding, SlotBinding, SlotProp, RefBinding, ChildComponentBinding, ChildPropBinding } from './types.js' */
19
19
 
20
20
  /**
21
21
  * Walk a DOM tree rooted at rootEl, discovering bindings and events.
@@ -24,7 +24,7 @@ import { BOOLEAN_ATTRIBUTES } from './types.js';
24
24
  * @param {Set<string>} signalNames — Set of signal variable names
25
25
  * @param {Set<string>} computedNames — Set of computed variable names
26
26
  * @param {Set<string>} [propNames] — Set of prop names from defineProps
27
- * @returns {{ bindings: Binding[], events: EventBinding[], showBindings: ShowBinding[], modelBindings: ModelBinding[], attrBindings: AttrBinding[], slots: SlotBinding[] }}
27
+ * @returns {{ bindings: Binding[], events: EventBinding[], showBindings: ShowBinding[], modelBindings: ModelBinding[], attrBindings: AttrBinding[], slots: SlotBinding[], childComponents: ChildComponentBinding[] }}
28
28
  */
29
29
  export function walkTree(rootEl, signalNames, computedNames, propNames = new Set()) {
30
30
  /** @type {Binding[]} */
@@ -39,12 +39,15 @@ export function walkTree(rootEl, signalNames, computedNames, propNames = new Set
39
39
  const attrBindings = [];
40
40
  /** @type {SlotBinding[]} */
41
41
  const slots = [];
42
+ /** @type {ChildComponentBinding[]} */
43
+ const childComponents = [];
42
44
  let bindIdx = 0;
43
45
  let eventIdx = 0;
44
46
  let showIdx = 0;
45
47
  let modelIdx = 0;
46
48
  let attrIdx = 0;
47
49
  let slotIdx = 0;
50
+ let childIdx = 0;
48
51
 
49
52
  /**
50
53
  * Determine the binding type for a variable name.
@@ -103,6 +106,40 @@ export function walkTree(rootEl, signalNames, computedNames, propNames = new Set
103
106
  return; // Don't recurse into the replaced element
104
107
  }
105
108
 
109
+ // Detect child custom elements (tag name contains a hyphen)
110
+ const tagLower = el.tagName.toLowerCase();
111
+ if (tagLower.includes('-') && tagLower !== rootEl.tagName?.toLowerCase()) {
112
+ /** @type {ChildPropBinding[]} */
113
+ const propBindings = [];
114
+ for (const attr of Array.from(el.attributes)) {
115
+ // Skip directive attributes (@event, :bind, show, model, etc.)
116
+ if (attr.name.startsWith('@') || attr.name.startsWith(':') || attr.name.startsWith('bind:')) continue;
117
+ if (['show', 'model', 'if', 'else-if', 'else', 'each', 'ref'].includes(attr.name)) continue;
118
+
119
+ // Check for {{interpolation}} in attribute value
120
+ const interpMatch = attr.value.match(/^\{\{([\w.]+)\}\}$/);
121
+ if (interpMatch) {
122
+ const expr = interpMatch[1];
123
+ propBindings.push({
124
+ attr: attr.name,
125
+ expr,
126
+ type: propNames.has(expr) ? 'prop' : signalNames.has(expr) ? 'signal' : computedNames.has(expr) ? 'computed' : 'method',
127
+ });
128
+ // Clear the interpolation from the attribute — the effect sets it at runtime
129
+ el.setAttribute(attr.name, '');
130
+ }
131
+ }
132
+
133
+ if (propBindings.length > 0) {
134
+ childComponents.push({
135
+ tag: tagLower,
136
+ varName: `__child${childIdx++}`,
137
+ path: [...pathParts],
138
+ propBindings,
139
+ });
140
+ }
141
+ }
142
+
106
143
  // Check for @event attributes
107
144
  const attrsToRemove = [];
108
145
  for (const attr of Array.from(el.attributes)) {
@@ -265,7 +302,7 @@ export function walkTree(rootEl, signalNames, computedNames, propNames = new Set
265
302
  }
266
303
 
267
304
  walk(rootEl, []);
268
- return { bindings, events, showBindings, modelBindings, attrBindings, slots };
305
+ return { bindings, events, showBindings, modelBindings, attrBindings, slots, childComponents };
269
306
  }
270
307
 
271
308
  // ── Conditional chain processing (if / else-if / else) ──────────────
@@ -338,6 +375,7 @@ export function walkBranch(html, signalNames, computedNames, propNames) {
338
375
  stripFirstSegment(result.attrBindings);
339
376
  stripFirstSegment(result.modelBindings);
340
377
  stripFirstSegment(result.slots);
378
+ stripFirstSegment(result.childComponents);
341
379
 
342
380
  return {
343
381
  bindings: result.bindings,
@@ -346,6 +384,7 @@ export function walkBranch(html, signalNames, computedNames, propNames) {
346
384
  attrBindings: result.attrBindings,
347
385
  modelBindings: result.modelBindings,
348
386
  slots: result.slots,
387
+ childComponents: result.childComponents,
349
388
  processedHtml,
350
389
  };
351
390
  }
package/lib/types.js CHANGED
@@ -86,6 +86,8 @@
86
86
  * @property {SlotBinding[]} slots — Slot bindings (empty array if no slots)
87
87
  * @property {RefDeclaration[]} refs — templateRef declarations from script (empty array if none)
88
88
  * @property {RefBinding[]} refBindings — ref attribute bindings from template (empty array if none)
89
+ * @property {ChildComponentBinding[]} childComponents — Child component bindings (empty array if none)
90
+ * @property {ChildComponentImport[]} childImports — Resolved child component imports (empty array if none)
89
91
  */
90
92
 
91
93
  /**
@@ -179,6 +181,27 @@
179
181
  * @property {SlotProp[]} slotProps — Array of :prop="expr" bindings on the slot
180
182
  */
181
183
 
184
+ /**
185
+ * @typedef {Object} ChildPropBinding
186
+ * @property {string} attr — Attribute name on the child element (e.g., 'label')
187
+ * @property {string} expr — Expression from {{expr}} (e.g., 'role')
188
+ * @property {string} type — Binding source type: 'signal' | 'computed' | 'prop' | 'constant' | 'method'
189
+ */
190
+
191
+ /**
192
+ * @typedef {Object} ChildComponentBinding
193
+ * @property {string} tag — Child component tag name (e.g., 'wcc-badge')
194
+ * @property {string} varName — Internal ref name (e.g., '__child0')
195
+ * @property {string[]} path — DOM path from __root
196
+ * @property {ChildPropBinding[]} propBindings — Reactive attribute bindings
197
+ */
198
+
199
+ /**
200
+ * @typedef {Object} ChildComponentImport
201
+ * @property {string} tag — Child component tag name
202
+ * @property {string} importPath — Relative import path (e.g., './wcc-badge.js')
203
+ */
204
+
182
205
  /**
183
206
  * Set of HTML attributes that use property assignment instead of setAttribute.
184
207
  * @type {Set<string>}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sprlab/wccompiler",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "description": "Zero-runtime compiler that transforms .ts/.js component files into native web components with signals-based reactivity",
5
5
  "type": "module",
6
6
  "bin": {