@sprlab/wccompiler 0.11.12 → 0.12.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
@@ -923,7 +923,14 @@ export function generateComponent(parseResult, options = {}) {
923
923
 
924
924
  // ── 1b. Child component imports ──
925
925
  for (const ci of childImports) {
926
- lines.push(`import '${ci.importPath}';`);
926
+ if (ci.sideEffect) {
927
+ // Side-effect import: no identifier, child self-registers
928
+ lines.push(`import '${ci.importPath}';`);
929
+ } else {
930
+ // Named import with guarded registration
931
+ lines.push(`import ${ci.identifier} from '${ci.importPath}';`);
932
+ lines.push(`if (!customElements.get(${ci.identifier}.__meta.tag)) customElements.define(${ci.identifier}.__meta.tag, ${ci.identifier});`);
933
+ }
927
934
  }
928
935
  if (childImports.length > 0) {
929
936
  lines.push('');
@@ -2012,7 +2019,11 @@ export function generateComponent(parseResult, options = {}) {
2012
2019
  lines.push('');
2013
2020
 
2014
2021
  // ── 5. Custom element registration ──
2015
- lines.push(`customElements.define('${tagName}', ${className});`);
2022
+ lines.push(`if (!customElements.get('${tagName}')) customElements.define('${tagName}', ${className});`);
2023
+ lines.push('');
2024
+
2025
+ // ── 6. Default export (enables named imports from parent components) ──
2026
+ lines.push(`export default ${className};`);
2016
2027
 
2017
2028
  return lines.join('\n');
2018
2029
  }
package/lib/compiler.js CHANGED
@@ -7,8 +7,8 @@
7
7
  */
8
8
 
9
9
  import { parseHTML } from 'linkedom';
10
- import { readFileSync, existsSync } from 'node:fs';
11
- import { dirname, extname, basename, resolve } from 'node:path';
10
+ import { readFileSync } from 'node:fs';
11
+ import { basename } from 'node:path';
12
12
  import { walkTree, processIfChains, processForBlocks, recomputeAnchorPath, detectRefs } from './tree-walker.js';
13
13
  import { generateComponent } from './codegen.js';
14
14
  import { parseSFC } from './sfc-parser.js';
@@ -43,30 +43,8 @@ import {
43
43
  validateUndeclaredEmits,
44
44
  } from './parser-extractors.js';
45
45
  import { stripTypes } from './parser.js';
46
- import { normalizeTemplate } from './template-normalizer.js';
47
-
48
- /**
49
- * Resolve a child component's source file path by tag name.
50
- *
51
- * Searches for a file named after the tag in the source directory,
52
- * trying extensions in priority order: .wcc, .js, .ts
53
- *
54
- * @param {string} tag — The custom element tag name (e.g., 'wcc-child')
55
- * @param {string} sourceDir — Directory of the parent component
56
- * @param {object} [config] — Optional config (reserved for future use)
57
- * @returns {string | null} Relative import path (e.g., './wcc-child.js') or null if not found
58
- */
59
- function resolveChildComponent(tag, sourceDir, config) {
60
- const extensions = ['.wcc', '.js', '.ts'];
61
- for (const ext of extensions) {
62
- const candidate = resolve(sourceDir, `${tag}${ext}`);
63
- if (existsSync(candidate)) {
64
- // Return as a relative .js import path (compiled output)
65
- return `./${tag}.js`;
66
- }
67
- }
68
- return null;
69
- }
46
+ import { normalizeTemplate, pascalToKebab } from './template-normalizer.js';
47
+ import { extractWccImports } from './import-resolver.js';
70
48
 
71
49
  /**
72
50
  * Compile a single .wcc SFC file into a self-contained JS component.
@@ -88,14 +66,38 @@ async function compileSFC(filePath, config) {
88
66
  // 2. Process script block — mirrors parser.js logic
89
67
  let source = stripMacroImport(descriptor.script);
90
68
 
91
- // 2b. Extract manual .wcc imports (e.g. import './child.wcc') and strip them from source
92
- const manualImports = [];
93
- const wccImportRe = /import\s+['"]([^'"]+\.wcc)['"]\s*;?/g;
94
- let wccImportMatch;
95
- while ((wccImportMatch = wccImportRe.exec(source)) !== null) {
96
- manualImports.push(wccImportMatch[1].replace(/\.wcc$/, '.js'));
69
+ // 2b. Extract .wcc imports using the import resolver
70
+ const wccImports = extractWccImports(source, fileName);
71
+
72
+ // Build importMap: PascalCase identifier → kebab-case tag
73
+ /** @type {Map<string, string>} */
74
+ const importMap = new Map();
75
+ for (const imp of wccImports.named) {
76
+ importMap.set(imp.identifier, pascalToKebab(imp.identifier));
77
+ }
78
+
79
+ // Build childImports from extracted imports
80
+ /** @type {import('./types.js').ChildComponentImport[]} */
81
+ const childImports = [];
82
+ for (const imp of wccImports.named) {
83
+ childImports.push({
84
+ tag: pascalToKebab(imp.identifier),
85
+ identifier: imp.identifier,
86
+ importPath: imp.compiledPath,
87
+ sideEffect: false,
88
+ });
89
+ }
90
+ for (const imp of wccImports.sideEffect) {
91
+ childImports.push({
92
+ tag: '',
93
+ identifier: '',
94
+ importPath: imp.compiledPath,
95
+ sideEffect: true,
96
+ });
97
97
  }
98
- source = source.replace(wccImportRe, '');
98
+
99
+ // Use strippedSource (with .wcc imports removed) for subsequent extraction steps
100
+ source = wccImports.strippedSource;
99
101
 
100
102
  // 3. Extract props/emits from generic forms BEFORE type stripping
101
103
  const propsFromGeneric = extractPropsGeneric(source);
@@ -291,7 +293,7 @@ async function compileSFC(filePath, config) {
291
293
  };
292
294
 
293
295
  // 16. Process template through linkedom → tree-walker → codegen
294
- const normalizedTemplate = normalizeTemplate(template);
296
+ const normalizedTemplate = normalizeTemplate(template, { importMap, fileName });
295
297
  const { document } = parseHTML(`<div id="__root">${normalizedTemplate}</div>`);
296
298
  const rootEl = document.getElementById('__root');
297
299
 
@@ -395,39 +397,8 @@ async function compileSFC(filePath, config) {
395
397
  }
396
398
  }
397
399
 
398
- // 18. Resolve child component imports (from main template + if branches + each blocks)
399
- /** @type {import('./types.js').ChildComponentImport[]} */
400
- const childImports = [];
401
- const allChildTags = new Set(childComponents.map(c => c.tag));
402
-
403
- // Collect child tags from if branches
404
- for (const ifBlock of ifBlocks) {
405
- for (const branch of ifBlock.branches) {
406
- if (branch.childComponents) {
407
- for (const cc of branch.childComponents) allChildTags.add(cc.tag);
408
- }
409
- }
410
- }
411
-
412
- // Collect child tags from each blocks
413
- for (const forBlock of forBlocks) {
414
- if (forBlock.childComponents) {
415
- for (const cc of forBlock.childComponents) allChildTags.add(cc.tag);
416
- }
417
- }
418
-
419
- if (allChildTags.size > 0) {
420
- const sourceDir = dirname(filePath);
421
-
422
- for (const tag of allChildTags) {
423
- const resolved = resolveChildComponent(tag, sourceDir, config);
424
- if (resolved) {
425
- childImports.push({ tag, importPath: resolved });
426
- } else {
427
- console.warn(`Warning: child component <${tag}> used in template but source file not found`);
428
- }
429
- }
430
- }
400
+ // 18. Child imports already built from extractWccImports (step 2b)
401
+ // No filesystem scanning needed — imports are explicit
431
402
 
432
403
  // 19. Merge tree-walker results into ParseResult
433
404
  parseResult.bindings = bindings;
@@ -442,12 +413,6 @@ async function compileSFC(filePath, config) {
442
413
  parseResult.refBindings = refBindings;
443
414
  parseResult.childComponents = childComponents;
444
415
 
445
- // Add manual .wcc imports from script block
446
- for (const imp of manualImports) {
447
- if (!childImports.find(ci => ci.importPath === imp)) {
448
- childImports.push({ tag: '', importPath: imp });
449
- }
450
- }
451
416
  parseResult.childImports = childImports;
452
417
  parseResult.processedTemplate = rootEl.innerHTML;
453
418
 
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Import resolver for .wcc component imports.
3
+ *
4
+ * Extracts and validates `.wcc` import statements from the script block,
5
+ * producing a structured representation of named default imports and
6
+ * side-effect imports. Rejects invalid import forms (namespace, named exports).
7
+ */
8
+
9
+ // ── Types ────────────────────────────────────────────────────────────
10
+
11
+ /**
12
+ * @typedef {Object} WccNamedImport
13
+ * @property {string} identifier — The import name (e.g., 'WccBadge', 'MyButton')
14
+ * @property {string} sourcePath — Original .wcc path (e.g., './wcc-badge.wcc')
15
+ * @property {string} compiledPath — Rewritten .js path (e.g., './wcc-badge.js')
16
+ */
17
+
18
+ /**
19
+ * @typedef {Object} WccSideEffectImport
20
+ * @property {string} sourcePath — Original .wcc path (e.g., './child.wcc')
21
+ * @property {string} compiledPath — Rewritten .js path (e.g., './child.js')
22
+ */
23
+
24
+ /**
25
+ * @typedef {Object} WccImportResult
26
+ * @property {WccNamedImport[]} named — Named default imports
27
+ * @property {WccSideEffectImport[]} sideEffect — Side-effect imports
28
+ * @property {string} strippedSource — Script source with .wcc imports removed
29
+ */
30
+
31
+ // ── Regex patterns ───────────────────────────────────────────────────
32
+
33
+ /**
34
+ * Matches any import statement that references a .wcc file.
35
+ * Captures the full import line for removal and classification.
36
+ *
37
+ * Groups:
38
+ * - Full match: the entire import statement line
39
+ *
40
+ * We use individual patterns below for classification.
41
+ */
42
+ const WCC_IMPORT_LINE_RE = /^[ \t]*import\s+.*?['"]([^'"]+\.wcc)['"]\s*;?[ \t]*$/gm;
43
+
44
+ /**
45
+ * Named default import: import Identifier from './path.wcc'
46
+ */
47
+ const NAMED_DEFAULT_RE = /^[ \t]*import\s+([$\w]+)\s+from\s+['"]([^'"]+\.wcc)['"]\s*;?[ \t]*$/;
48
+
49
+ /**
50
+ * Side-effect import: import './path.wcc'
51
+ */
52
+ const SIDE_EFFECT_RE = /^[ \t]*import\s+['"]([^'"]+\.wcc)['"]\s*;?[ \t]*$/;
53
+
54
+ /**
55
+ * Namespace import: import * as Foo from './path.wcc'
56
+ */
57
+ const NAMESPACE_RE = /^[ \t]*import\s+\*\s+as\s+\w+\s+from\s+['"][^'"]+\.wcc['"]\s*;?[ \t]*$/;
58
+
59
+ /**
60
+ * Named exports import: import { Foo } from './path.wcc'
61
+ * Also matches: import { Foo, Bar } from './path.wcc'
62
+ * Also matches: import { Foo as Bar } from './path.wcc'
63
+ */
64
+ const NAMED_EXPORTS_RE = /^[ \t]*import\s+\{[^}]*\}\s+from\s+['"][^'"]+\.wcc['"]\s*;?[ \t]*$/;
65
+
66
+ // ── Helpers ──────────────────────────────────────────────────────────
67
+
68
+ /**
69
+ * Rewrite a .wcc path to .js by replacing the extension.
70
+ * Preserves all relative path segments.
71
+ *
72
+ * @param {string} wccPath — e.g., '../shared/wcc-button.wcc'
73
+ * @returns {string} — e.g., '../shared/wcc-button.js'
74
+ */
75
+ function rewriteExtension(wccPath) {
76
+ return wccPath.replace(/\.wcc$/, '.js');
77
+ }
78
+
79
+ // ── Main export ──────────────────────────────────────────────────────
80
+
81
+ /**
82
+ * Extract all .wcc imports from a script source string.
83
+ * Validates import forms and rejects invalid patterns.
84
+ *
85
+ * @param {string} source — Script block content
86
+ * @param {string} fileName — Source file name for error messages
87
+ * @returns {WccImportResult}
88
+ * @throws {Error} with code 'INVALID_WCC_IMPORT' for namespace/named exports
89
+ */
90
+ export function extractWccImports(source, fileName) {
91
+ /** @type {WccNamedImport[]} */
92
+ const named = [];
93
+ /** @type {WccSideEffectImport[]} */
94
+ const sideEffect = [];
95
+
96
+ // Collect all .wcc import lines for processing
97
+ const lines = source.split('\n');
98
+ /** @type {Set<number>} */
99
+ const linesToRemove = new Set();
100
+
101
+ for (let i = 0; i < lines.length; i++) {
102
+ const line = lines[i];
103
+
104
+ // Check if this line contains a .wcc import
105
+ if (!line.match(/import\s.*\.wcc['"]/) && !line.match(/import\s+['"][^'"]+\.wcc['"]/)) {
106
+ continue;
107
+ }
108
+
109
+ // Check for invalid forms first
110
+ if (NAMESPACE_RE.test(line)) {
111
+ const error = new Error(
112
+ `Invalid import form in '${fileName}': .wcc files only support default imports (import Foo from './foo.wcc') or side-effect imports (import './foo.wcc')`
113
+ );
114
+ /** @ts-expect-error — custom error code for programmatic handling */
115
+ error.code = 'INVALID_WCC_IMPORT';
116
+ throw error;
117
+ }
118
+
119
+ if (NAMED_EXPORTS_RE.test(line)) {
120
+ const error = new Error(
121
+ `Invalid import form in '${fileName}': .wcc files only support default imports (import Foo from './foo.wcc') or side-effect imports (import './foo.wcc')`
122
+ );
123
+ /** @ts-expect-error — custom error code for programmatic handling */
124
+ error.code = 'INVALID_WCC_IMPORT';
125
+ throw error;
126
+ }
127
+
128
+ // Check for named default import
129
+ const namedMatch = line.match(NAMED_DEFAULT_RE);
130
+ if (namedMatch) {
131
+ const identifier = namedMatch[1];
132
+ const sourcePath = namedMatch[2];
133
+ named.push({
134
+ identifier,
135
+ sourcePath,
136
+ compiledPath: rewriteExtension(sourcePath),
137
+ });
138
+ linesToRemove.add(i);
139
+ continue;
140
+ }
141
+
142
+ // Check for side-effect import
143
+ const sideEffectMatch = line.match(SIDE_EFFECT_RE);
144
+ if (sideEffectMatch) {
145
+ const sourcePath = sideEffectMatch[1];
146
+ sideEffect.push({
147
+ sourcePath,
148
+ compiledPath: rewriteExtension(sourcePath),
149
+ });
150
+ linesToRemove.add(i);
151
+ continue;
152
+ }
153
+ }
154
+
155
+ // Build stripped source by removing .wcc import lines
156
+ const strippedLines = lines.filter((_, i) => !linesToRemove.has(i));
157
+ const strippedSource = strippedLines.join('\n');
158
+
159
+ return { named, sideEffect, strippedSource };
160
+ }
@@ -45,13 +45,19 @@ export function isPascalCase(name) {
45
45
 
46
46
  /**
47
47
  * Normalize a WCC template string:
48
- * 1. Convert PascalCase tags to kebab-case
48
+ * 1. Convert PascalCase tags to kebab-case (validated against importMap if provided)
49
49
  * 2. Expand self-closing custom elements to open+close pairs
50
50
  *
51
51
  * @param {string} html — Raw template HTML
52
+ * @param {object} [options]
53
+ * @param {Map<string, string>} [options.importMap] — PascalCase identifier → kebab-case tag
54
+ * @param {string} [options.fileName] — Source file for error messages
52
55
  * @returns {string} — Normalized HTML ready for DOM parsing
56
+ * @throws {Error} with code 'UNRESOLVED_COMPONENT' if PascalCase tag has no matching import
53
57
  */
54
- export function normalizeTemplate(html) {
58
+ export function normalizeTemplate(html, options) {
59
+ const { importMap, fileName } = options || {};
60
+
55
61
  // Match opening tags (including self-closing): <TagName ...> or <TagName ... />
56
62
  // Also match closing tags: </TagName>
57
63
  //
@@ -69,7 +75,21 @@ export function normalizeTemplate(html) {
69
75
 
70
76
  // Step 1: Convert PascalCase to kebab-case
71
77
  if (isPascalCase(tagName)) {
72
- normalizedTag = pascalToKebab(tagName);
78
+ if (importMap) {
79
+ // Validate against the import map
80
+ if (importMap.has(tagName)) {
81
+ normalizedTag = importMap.get(tagName);
82
+ } else {
83
+ const error = new Error(
84
+ `Unresolved component '<${tagName}>' in '${fileName || 'unknown'}'. Did you forget to import it?`
85
+ );
86
+ error.code = 'UNRESOLVED_COMPONENT';
87
+ throw error;
88
+ }
89
+ } else {
90
+ // Backward compatible: convert all PascalCase to kebab-case
91
+ normalizedTag = pascalToKebab(tagName);
92
+ }
73
93
  }
74
94
 
75
95
  // Step 2: Handle self-closing tags
package/lib/types.js CHANGED
@@ -221,8 +221,10 @@
221
221
 
222
222
  /**
223
223
  * @typedef {Object} ChildComponentImport
224
- * @property {string} tag — Child component tag name
224
+ * @property {string} tag — Child component tag name (kebab-case, used in template)
225
+ * @property {string} identifier — Import identifier (PascalCase, e.g., 'WccBadge')
225
226
  * @property {string} importPath — Relative import path (e.g., './wcc-badge.js')
227
+ * @property {boolean} sideEffect — true if this is a side-effect import (no identifier)
226
228
  */
227
229
 
228
230
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sprlab/wccompiler",
3
- "version": "0.11.12",
3
+ "version": "0.12.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": {