@sprlab/wccompiler 0.12.0 → 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/README.md CHANGED
@@ -458,12 +458,13 @@ Consumer (receives data via template props):
458
458
 
459
459
  ## Nested Components
460
460
 
461
- Components can use other components in their templates. Import the child `.wcc` file and use its tag in the template:
461
+ Components can import and use other components in their templates using PascalCase tags:
462
462
 
463
463
  ```html
464
+ <!-- src/nested/wcc-profile.wcc -->
464
465
  <script>
465
466
  import { defineComponent, signal } from 'wcc'
466
- import './wcc-badge.wcc'
467
+ import WccBadge from './wcc-badge.wcc'
467
468
 
468
469
  export default defineComponent({ tag: 'wcc-profile' })
469
470
 
@@ -476,15 +477,17 @@ function increment() {
476
477
 
477
478
  <template>
478
479
  <div class="profile">
479
- <wcc-badge :count="count()" @click="increment"></wcc-badge>
480
+ <WccBadge :count="count()" @click="increment"></WccBadge>
480
481
  </div>
481
482
  </template>
482
483
  ```
483
484
 
484
- - **Manual import**: `import './wcc-child.wcc'` — the compiler registers the child component
485
- - **Auto-detect**: If a custom element tag in the template matches a `.wcc` file in the same directory, it's auto-imported
485
+ - **Named import**: `import WccBadge from './wcc-badge.wcc'` — the PascalCase identifier becomes the tag alias in the template
486
+ - **Side-effect import**: `import './wcc-child.wcc'` registers the child without using it in the template (for programmatic creation)
486
487
  - **Reactive props**: Use `:prop="expr"` to pass reactive data down — updates automatically when the expression changes
487
488
  - **Event listening**: Use `@event="handler"` to listen to custom events emitted by the child
489
+ - **Compile-time validation**: Using a PascalCase tag without a matching import throws an error at build time
490
+ - **Hyphenated tags**: Tags like `<my-element>` without a corresponding import are treated as plain custom elements (no import generated)
488
491
 
489
492
  ## Lifecycle Hooks
490
493
 
@@ -613,12 +616,48 @@ timer.value!.start() // ✅ typed
613
616
  ## CLI
614
617
 
615
618
  ```bash
616
- wcc build # Compile all .wcc files from input/ to output/
617
- wcc dev # Build + watch + live-reload dev server
619
+ wcc build # Compile all .wcc files from input/ to output/
620
+ wcc build --bundle # Compile + produce a single bundle.js (works from file://)
621
+ wcc build --minify # Compile with minification
622
+ wcc build --bundle --minify # Production bundle (smallest output)
623
+ wcc dev # Build + watch + live-reload dev server
618
624
  ```
619
625
 
620
626
  The CLI discovers all `.wcc` files in your source directory and compiles each into a standalone `.js` file.
621
627
 
628
+ ### Bundle Mode
629
+
630
+ The `--bundle` flag produces a single `bundle.js` file that includes all components and their dependencies in one IIFE (Immediately Invoked Function Expression). This file:
631
+
632
+ - Works with `<script src="bundle.js">` (no `type="module"` needed)
633
+ - Works from `file://` protocol (no server required)
634
+ - Includes all child component imports resolved and inlined
635
+ - Includes the reactive runtime
636
+ - Supports `--minify` for production
637
+
638
+ ```html
639
+ <!-- Works by double-clicking the HTML file — no server needed -->
640
+ <!DOCTYPE html>
641
+ <html>
642
+ <body>
643
+ <wcc-my-app></wcc-my-app>
644
+ <script src="dist/bundle.js"></script>
645
+ </body>
646
+ </html>
647
+ ```
648
+
649
+ **When to use `--bundle`:**
650
+ - Static HTML files opened from disk
651
+ - Electron apps loading local files
652
+ - Offline-first applications
653
+ - Quick prototyping without a dev server
654
+ - Distributing a complete app as HTML + JS
655
+
656
+ **When NOT to use `--bundle`:**
657
+ - Apps served via HTTP (use ES modules for better caching)
658
+ - When you need per-component lazy loading
659
+ - When using a bundler like Vite/Webpack (they handle bundling themselves)
660
+
622
661
  ### Configuration
623
662
 
624
663
  Create `wcc.config.js` in your project root:
package/bin/wcc.js CHANGED
@@ -291,6 +291,26 @@ function discoverFiles(dir) {
291
291
  return results;
292
292
  }
293
293
 
294
+ /**
295
+ * Discovers compiled .js entry points in the output directory.
296
+ * Excludes runtime files, stubs, and metadata.
297
+ */
298
+ function discoverCompiledEntries(outputDir) {
299
+ const skip = new Set(['__wcc-signals.js', 'wcc-runtime.js', 'wcc-react.js', 'wcc-vue.js', 'bundle.js']);
300
+ const results = [];
301
+ function walk(dir) {
302
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
303
+ if (entry.isDirectory()) {
304
+ walk(join(dir, entry.name));
305
+ } else if (entry.isFile() && entry.name.endsWith('.js') && !skip.has(entry.name) && !entry.name.endsWith('.d.ts')) {
306
+ results.push(join(dir, entry.name));
307
+ }
308
+ }
309
+ }
310
+ walk(outputDir);
311
+ return results;
312
+ }
313
+
294
314
  async function main() {
295
315
  const cwd = process.cwd();
296
316
  const config = await loadConfig(cwd);
@@ -298,10 +318,43 @@ async function main() {
298
318
  // CLI flags override config
299
319
  if (process.argv.includes('--minify')) config.minify = true;
300
320
  if (process.argv.includes('--comments')) config.comments = true;
321
+ const shouldBundle = process.argv.includes('--bundle');
301
322
 
302
323
  if (command === 'build') {
303
324
  const errors = await build(config, cwd);
304
325
  if (errors > 0) process.exit(1);
326
+
327
+ // Bundle step: produce a single IIFE file from all compiled entry points
328
+ if (shouldBundle) {
329
+ const { build: esbuild } = await import('esbuild');
330
+ const outputDir = resolve(cwd, config.output);
331
+ const entryPoints = discoverCompiledEntries(outputDir);
332
+
333
+ if (entryPoints.length > 0) {
334
+ // Generate a virtual entry that imports all components
335
+ const bundleEntry = join(outputDir, '__bundle-entry.js');
336
+ const imports = entryPoints.map(f => {
337
+ let rel = relative(outputDir, f).replace(/\\/g, '/');
338
+ if (!rel.startsWith('.')) rel = './' + rel;
339
+ return `import '${rel}';`;
340
+ }).join('\n');
341
+ writeFileSync(bundleEntry, imports);
342
+
343
+ await esbuild({
344
+ entryPoints: [bundleEntry],
345
+ bundle: true,
346
+ format: 'iife',
347
+ outfile: join(outputDir, 'bundle.js'),
348
+ minify: !!config.minify,
349
+ });
350
+
351
+ // Clean up temp entry
352
+ const { unlinkSync } = await import('node:fs');
353
+ unlinkSync(bundleEntry);
354
+
355
+ console.log(`Bundled ${entryPoints.length} components → ${config.output}/bundle.js`);
356
+ }
357
+ }
305
358
  } else if (command === 'dev') {
306
359
  await build(config, cwd);
307
360
  const outputDir = resolve(cwd, config.output);
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.0",
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": {