@sprlab/wccompiler 0.12.1 → 0.13.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/lib/codegen.js CHANGED
@@ -877,6 +877,7 @@ export function generateComponent(parseResult, options = {}) {
877
877
  childImports = [],
878
878
  exposeNames = [],
879
879
  modelDefs = [],
880
+ dynamicComponents = [],
880
881
  } = parseResult;
881
882
 
882
883
  const signalNames = signals.map(s => s.name);
@@ -903,7 +904,7 @@ export function generateComponent(parseResult, options = {}) {
903
904
  // ── 1. Reactive runtime (shared import or inline) ──
904
905
  if (options.comments) lines.push('// ── Runtime ──────────────────────────────────────────');
905
906
  // Determine which runtime functions this component needs
906
- const needsEffect = effects.length > 0 || bindings.length > 0 || showBindings.length > 0 || modelBindings.length > 0 || modelPropBindings.length > 0 || attrBindings.length > 0 || ifBlocks.length > 0 || forBlocks.length > 0 || watchers.length > 0 || childComponents.length > 0 || slots.some(s => s.slotProps.length > 0);
907
+ const needsEffect = effects.length > 0 || bindings.length > 0 || showBindings.length > 0 || modelBindings.length > 0 || modelPropBindings.length > 0 || attrBindings.length > 0 || ifBlocks.length > 0 || forBlocks.length > 0 || watchers.length > 0 || childComponents.length > 0 || dynamicComponents.length > 0 || slots.some(s => s.slotProps.length > 0);
907
908
  const needsComputed = computeds.length > 0;
908
909
  const needsUntrack = watchers.length > 0;
909
910
 
@@ -1186,6 +1187,15 @@ export function generateComponent(parseResult, options = {}) {
1186
1187
  lines.push(` this.${vn}_nodes = [];`);
1187
1188
  }
1188
1189
 
1190
+ // ── dynamic component: anchor reference, state init ──
1191
+ for (const dyn of dynamicComponents) {
1192
+ const vn = dyn.varName;
1193
+ lines.push(` this.${vn}_anchor = ${pathExpr(dyn.anchorPath, '__root')};`);
1194
+ lines.push(` this.${vn}_current = null;`);
1195
+ lines.push(` this.${vn}_tag = null;`);
1196
+ lines.push(` this.${vn}_propDisposers = [];`);
1197
+ }
1198
+
1189
1199
  // ── Ref DOM reference assignments (before appendChild moves nodes) ──
1190
1200
  for (const rb of refBindings) {
1191
1201
  lines.push(` this._ref_${rb.refName} = ${pathExpr(rb.path, '__root')};`);
@@ -1671,6 +1681,41 @@ export function generateComponent(parseResult, options = {}) {
1671
1681
  }
1672
1682
  }
1673
1683
 
1684
+ // ── dynamic component effects ──
1685
+ for (const dyn of dynamicComponents) {
1686
+ const vn = dyn.varName;
1687
+ const isExpr = transformExpr(dyn.isExpression, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames, methodNames, modelVarMap);
1688
+ lines.push(' this.__disposers.push(__effect(() => {');
1689
+ lines.push(` const __tag = ${isExpr};`);
1690
+ lines.push(` if (__tag === this.${vn}_tag) return;`);
1691
+ lines.push(` if (this.${vn}_current) {`);
1692
+ lines.push(` this.${vn}_propDisposers.forEach(d => d());`);
1693
+ lines.push(` this.${vn}_propDisposers = [];`);
1694
+ lines.push(` this.${vn}_current.remove();`);
1695
+ lines.push(` this.${vn}_current = null;`);
1696
+ lines.push(' }');
1697
+ lines.push(' if (__tag) {');
1698
+ lines.push(' const el = document.createElement(__tag);');
1699
+ // Emit nested prop effects
1700
+ for (const prop of dyn.props) {
1701
+ const propExprTransformed = transformExpr(prop.expression, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames, methodNames, modelVarMap);
1702
+ lines.push(` this.${vn}_propDisposers.push(__effect(() => {`);
1703
+ lines.push(` el.setAttribute('${prop.attr}', ${propExprTransformed});`);
1704
+ lines.push(' }));');
1705
+ }
1706
+ // Emit event listeners
1707
+ for (const evt of dyn.events) {
1708
+ const handlerExpr = generateEventHandler(evt.handler, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, constantNames, modelVarMap);
1709
+ lines.push(` el.addEventListener('${evt.event}', ${handlerExpr});`);
1710
+ }
1711
+ lines.push(` this.${vn}_anchor.parentNode.insertBefore(el, this.${vn}_anchor);`);
1712
+ lines.push(' customElements.upgrade(el);');
1713
+ lines.push(` this.${vn}_current = el;`);
1714
+ lines.push(' }');
1715
+ lines.push(` this.${vn}_tag = __tag;`);
1716
+ lines.push(' }));');
1717
+ }
1718
+
1674
1719
  // Lifecycle: onMount hooks (at the very end of connectedCallback)
1675
1720
  for (const hook of onMountHooks) {
1676
1721
  const body = transformMethodBody(hook.body, signalNames, computedNames, propsObjectName, propNames, emitsObjectName, refVarNames, constantNames, modelVarMap);
package/lib/compiler.js CHANGED
@@ -9,7 +9,7 @@
9
9
  import { parseHTML } from 'linkedom';
10
10
  import { readFileSync } from 'node:fs';
11
11
  import { basename } from 'node:path';
12
- import { walkTree, processIfChains, processForBlocks, recomputeAnchorPath, detectRefs } from './tree-walker.js';
12
+ import { walkTree, processIfChains, processForBlocks, processDynamicComponents, recomputeAnchorPath, detectRefs } from './tree-walker.js';
13
13
  import { generateComponent } from './codegen.js';
14
14
  import { parseSFC } from './sfc-parser.js';
15
15
  import {
@@ -290,6 +290,7 @@ async function compileSFC(filePath, config) {
290
290
  childImports: [],
291
291
  exposeNames,
292
292
  modelDefs,
293
+ dynamicComponents: [],
293
294
  };
294
295
 
295
296
  // 16. Process template through linkedom → tree-walker → codegen
@@ -307,6 +308,7 @@ async function compileSFC(filePath, config) {
307
308
 
308
309
  const forBlocks = processForBlocks(rootEl, [], signalNames, computedNames, propNamesSet);
309
310
  const ifBlocks = processIfChains(rootEl, [], signalNames, computedNames, propNamesSet);
311
+ const dynamicComponents = processDynamicComponents(rootEl, []);
310
312
 
311
313
  rootEl.normalize();
312
314
 
@@ -316,6 +318,9 @@ async function compileSFC(filePath, config) {
316
318
  for (const ib of ifBlocks) {
317
319
  ib.anchorPath = recomputeAnchorPath(rootEl, ib._anchorNode);
318
320
  }
321
+ for (const dc of dynamicComponents) {
322
+ dc.anchorPath = recomputeAnchorPath(rootEl, dc._anchorNode);
323
+ }
319
324
 
320
325
  const { bindings, events, showBindings, modelBindings, modelPropBindings, attrBindings, slots, childComponents } = walkTree(rootEl, signalNames, computedNames, propNamesSet);
321
326
 
@@ -412,6 +417,7 @@ async function compileSFC(filePath, config) {
412
417
  parseResult.slots = slots;
413
418
  parseResult.refBindings = refBindings;
414
419
  parseResult.childComponents = childComponents;
420
+ parseResult.dynamicComponents = dynamicComponents;
415
421
 
416
422
  parseResult.childImports = childImports;
417
423
  parseResult.processedTemplate = rootEl.innerHTML;
@@ -71,6 +71,11 @@ export function normalizeTemplate(html, options) {
71
71
  const TAG_RE = /<(\/?)([A-Za-z][\w-]*)((?:\s[^>]*?)?)(\/?)>/g;
72
72
 
73
73
  return html.replace(TAG_RE, (match, closingSlash, tagName, attrs, selfClosing) => {
74
+ // Guard: preserve <component> tags as-is — this is a compiler directive, not a custom element
75
+ if (tagName.toLowerCase() === 'component') {
76
+ return match;
77
+ }
78
+
74
79
  let normalizedTag = tagName;
75
80
 
76
81
  // Step 1: Convert PascalCase to kebab-case
@@ -15,7 +15,7 @@
15
15
  import { parseHTML } from 'linkedom';
16
16
  import { BOOLEAN_ATTRIBUTES } from './types.js';
17
17
 
18
- /** @import { Binding, EventBinding, IfBlock, IfBranch, ShowBinding, AttrBinding, ForBlock, ModelBinding, ModelPropBinding, SlotBinding, SlotProp, RefBinding, ChildComponentBinding, ChildPropBinding } from './types.js' */
18
+ /** @import { Binding, EventBinding, IfBlock, IfBranch, ShowBinding, AttrBinding, ForBlock, ModelBinding, ModelPropBinding, SlotBinding, SlotProp, RefBinding, ChildComponentBinding, ChildPropBinding, DynamicComponentBinding, DynPropBinding, DynEventBinding } from './types.js' */
19
19
 
20
20
  /**
21
21
  * Walk a DOM tree rooted at rootEl, discovering bindings and events.
@@ -871,6 +871,96 @@ export function processForBlocks(parent, parentPath, signalNames, computedNames,
871
871
  }
872
872
 
873
873
 
874
+ // ── Dynamic component processing ────────────────────────────────────
875
+
876
+ /**
877
+ * Process dynamic component elements (`<component :is="expr">`) in descendants of a parent element.
878
+ * Recursively detects `<component>` elements, validates the `:is` attribute,
879
+ * extracts prop/event bindings, and replaces them with comment anchors.
880
+ *
881
+ * @param {Element} parent - Root element to search
882
+ * @param {string[]} parentPath - DOM path to parent from __root
883
+ * @returns {DynamicComponentBinding[]}
884
+ */
885
+ export function processDynamicComponents(parent, parentPath) {
886
+ /** @type {DynamicComponentBinding[]} */
887
+ const dynamicComponents = [];
888
+ let dynIdx = 0;
889
+
890
+ /**
891
+ * Recursively search for <component> elements in the subtree.
892
+ * @param {Element} node
893
+ * @param {string[]} currentPath
894
+ */
895
+ function findDynamicComponents(node, currentPath) {
896
+ const children = Array.from(node.childNodes);
897
+ for (let i = 0; i < children.length; i++) {
898
+ const child = children[i];
899
+ if (child.nodeType !== 1) continue;
900
+ const el = /** @type {Element} */ (child);
901
+
902
+ if (el.tagName === 'COMPONENT') {
903
+ // Validate :is attribute is present
904
+ const isExpr = el.getAttribute(':is');
905
+ if (!isExpr) {
906
+ const error = new Error(':is attribute is required on <component> elements');
907
+ /** @ts-expect-error — custom error code */
908
+ error.code = 'MISSING_IS_ATTRIBUTE';
909
+ throw error;
910
+ }
911
+
912
+ // Collect prop bindings (:attr="expr", excluding :is)
913
+ /** @type {DynPropBinding[]} */
914
+ const props = [];
915
+ // Collect event bindings (@event="handler")
916
+ /** @type {DynEventBinding[]} */
917
+ const events = [];
918
+
919
+ for (const attr of Array.from(el.attributes)) {
920
+ if (attr.name.startsWith(':') && attr.name !== ':is') {
921
+ props.push({
922
+ attr: attr.name.slice(1),
923
+ expression: attr.value,
924
+ });
925
+ } else if (attr.name.startsWith('@')) {
926
+ events.push({
927
+ event: attr.name.slice(1),
928
+ handler: attr.value,
929
+ });
930
+ }
931
+ }
932
+
933
+ // Replace <component> with a comment node <!-- dynamic -->
934
+ const doc = node.ownerDocument;
935
+ const comment = doc.createComment(' dynamic ');
936
+ node.replaceChild(comment, el);
937
+
938
+ // Calculate anchorPath
939
+ const updatedChildren = Array.from(node.childNodes);
940
+ const commentIndex = updatedChildren.indexOf(comment);
941
+ const anchorPath = [...currentPath, `childNodes[${commentIndex}]`];
942
+
943
+ // Create DynamicComponentBinding
944
+ dynamicComponents.push({
945
+ varName: `__dyn${dynIdx++}`,
946
+ isExpression: isExpr,
947
+ props,
948
+ events,
949
+ anchorPath,
950
+ _anchorNode: comment,
951
+ });
952
+ } else {
953
+ // Recurse into non-component elements to find nested dynamic components
954
+ const childPath = [...currentPath, `childNodes[${i}]`];
955
+ findDynamicComponents(el, childPath);
956
+ }
957
+ }
958
+ }
959
+
960
+ findDynamicComponents(parent, parentPath);
961
+ return dynamicComponents;
962
+ }
963
+
874
964
  // ── Ref detection ───────────────────────────────────────────────────
875
965
 
876
966
  /**
package/lib/types.js CHANGED
@@ -100,6 +100,7 @@
100
100
  * @property {RefBinding[]} refBindings — ref attribute bindings from template (empty array if none)
101
101
  * @property {ChildComponentBinding[]} childComponents — Child component bindings (empty array if none)
102
102
  * @property {ChildComponentImport[]} childImports — Resolved child component imports (empty array if none)
103
+ * @property {DynamicComponentBinding[]} dynamicComponents — Dynamic component bindings (empty array if none)
103
104
  * @property {string[]} exposeNames — Property names from defineExpose (empty array if none)
104
105
  */
105
106
 
@@ -227,6 +228,27 @@
227
228
  * @property {boolean} sideEffect — true if this is a side-effect import (no identifier)
228
229
  */
229
230
 
231
+ /**
232
+ * @typedef {Object} DynPropBinding
233
+ * @property {string} attr — Attribute name (e.g., 'label', 'count')
234
+ * @property {string} expression — Expression string (e.g., "name()", "props.title")
235
+ */
236
+
237
+ /**
238
+ * @typedef {Object} DynEventBinding
239
+ * @property {string} event — Event name (e.g., 'click', 'change')
240
+ * @property {string} handler — Handler expression (e.g., "handleClick", "handleClick($event)")
241
+ */
242
+
243
+ /**
244
+ * @typedef {Object} DynamicComponentBinding
245
+ * @property {string} varName — Unique name: '__dyn0', '__dyn1', ...
246
+ * @property {string} isExpression — The :is attribute expression (e.g., "currentTag()")
247
+ * @property {DynPropBinding[]} props — Prop bindings from :attr="expr" attributes
248
+ * @property {DynEventBinding[]} events — Event bindings from @event="handler" attributes
249
+ * @property {string[]} anchorPath — DOM path to comment anchor from __root
250
+ */
251
+
230
252
  /**
231
253
  * Set of HTML attributes that use property assignment instead of setAttribute.
232
254
  * @type {Set<string>}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sprlab/wccompiler",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "description": "Zero-runtime compiler that transforms .wcc single-file components into native web components with signals-based reactivity",
5
5
  "type": "module",
6
6
  "exports": {