@sprlab/wccompiler 0.3.0 → 0.4.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/compiler.js CHANGED
@@ -1,107 +1,238 @@
1
1
  /**
2
2
  * Compiler — orchestrates the full compilation pipeline for wcCompiler v2.
3
3
  *
4
- * Pipeline: parse → jsdom template → tree-walk → codegen
4
+ * Pipeline: parse SFC → jsdom template → tree-walk → codegen
5
5
  *
6
- * Takes a .ts/.js source file path and produces a self-contained
7
- * JavaScript web component string.
6
+ * Takes a .wcc file path and produces a self-contained JavaScript web component string.
8
7
  */
9
8
 
10
9
  import { JSDOM } from 'jsdom';
11
- import { readFileSync, readdirSync, existsSync } from 'node:fs';
12
- import { resolve, relative, dirname, extname } from 'node:path';
13
- import { parse } from './parser.js';
10
+ import { readFileSync, existsSync } from 'node:fs';
11
+ import { dirname, extname, basename, resolve } from 'node:path';
14
12
  import { walkTree, processIfChains, processForBlocks, recomputeAnchorPath, detectRefs } from './tree-walker.js';
15
13
  import { generateComponent } from './codegen.js';
14
+ import { parseSFC } from './sfc-parser.js';
15
+ import {
16
+ stripMacroImport,
17
+ toClassName,
18
+ camelToKebab,
19
+ extractPropsGeneric,
20
+ extractPropsArray,
21
+ extractPropsDefaults,
22
+ extractPropsObjectName,
23
+ extractEmitsFromCallSignatures,
24
+ extractEmits,
25
+ extractEmitsObjectName,
26
+ extractEmitsObjectNameFromGeneric,
27
+ extractSignals,
28
+ extractComputeds,
29
+ extractEffects,
30
+ extractWatchers,
31
+ extractFunctions,
32
+ extractLifecycleHooks,
33
+ extractRefs,
34
+ extractConstants,
35
+ validatePropsAssignment,
36
+ validateDuplicateProps,
37
+ validatePropsConflicts,
38
+ validateEmitsAssignment,
39
+ validateDuplicateEmits,
40
+ validateEmitsConflicts,
41
+ validateUndeclaredEmits,
42
+ } from './parser-extractors.js';
43
+ import { stripTypes } from './parser.js';
44
+
16
45
  /**
17
- * Resolve a child component's import path by searching for a source file
18
- * whose defineComponent({ tag }) matches the given tag name.
46
+ * Resolve a child component's source file path by tag name.
47
+ *
48
+ * Searches for a file named after the tag in the source directory,
49
+ * trying extensions in priority order: .wcc, .js, .ts
19
50
  *
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
51
+ * @param {string} tag — The custom element tag name (e.g., 'wcc-child')
52
+ * @param {string} sourceDir — Directory of the parent component
53
+ * @param {object} [config] — Optional config (reserved for future use)
54
+ * @returns {string | null} Relative import path (e.g., './wcc-child.js') or null if not found
24
55
  */
25
56
  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
57
+ const extensions = ['.wcc', '.js', '.ts'];
58
+ for (const ext of extensions) {
59
+ const candidate = resolve(sourceDir, `${tag}${ext}`);
60
+ if (existsSync(candidate)) {
61
+ // Return as a relative .js import path (compiled output)
62
+ return `./${tag}.js`;
66
63
  }
67
64
  }
68
-
69
65
  return null;
70
66
  }
71
67
 
72
68
  /**
73
- * Compile a single .ts/.js source file into a self-contained JS component.
69
+ * Compile a single .wcc SFC file into a self-contained JS component.
70
+ *
71
+ * Reads the file, parses the SFC blocks, extracts reactive declarations
72
+ * from the script block using parser-extractors.js, and processes template
73
+ * and style through the existing pipeline (jsdom → tree-walker → codegen).
74
74
  *
75
- * @param {string} filePath — Absolute or relative path to the source file
75
+ * @param {string} filePath — Absolute or relative path to the .wcc file
76
76
  * @param {object} [config] — Optional config (reserved for future options)
77
77
  * @returns {Promise<string>} The generated JavaScript component code
78
78
  */
79
- export async function compile(filePath, config) {
80
- // 1. Parse the source file
81
- const parseResult = await parse(filePath);
79
+ async function compileSFC(filePath, config) {
80
+ // 1. Read and parse the SFC file
81
+ const rawSource = readFileSync(filePath, 'utf-8');
82
+ const fileName = basename(filePath);
83
+ const descriptor = parseSFC(rawSource, fileName);
82
84
 
83
- // 2. Parse template HTML into jsdom DOM
84
- const dom = new JSDOM(`<div id="__root">${parseResult.template}</div>`);
85
- const rootEl = dom.window.document.getElementById('__root');
85
+ // 2. Process script block mirrors parser.js logic
86
+ let source = stripMacroImport(descriptor.script);
87
+
88
+ // 3. Extract props/emits from generic forms BEFORE type stripping
89
+ const propsFromGeneric = extractPropsGeneric(source);
90
+ const propsObjectNameFromGeneric = extractPropsObjectName(source);
91
+ const emitsFromCallSignatures = extractEmitsFromCallSignatures(source);
92
+ const emitsObjectNameFromGeneric = extractEmitsObjectNameFromGeneric(source);
86
93
 
87
- // 3. Build name sets
88
- const signalNames = new Set(parseResult.signals.map(s => s.name));
89
- const computedNames = new Set(parseResult.computeds.map(c => c.name));
90
- const propNames = new Set((parseResult.propDefs || []).map(p => p.name));
94
+ // 4. Validate props/emits assignment (before type strip)
95
+ validatePropsAssignment(source, filePath);
96
+ validateEmitsAssignment(source, filePath);
91
97
 
92
- // 4. Process each blocks BEFORE if chains (replaces each elements with comment anchors)
93
- const forBlocks = processForBlocks(rootEl, [], signalNames, computedNames, propNames);
98
+ // 5. Strip TypeScript types if lang === 'ts'
99
+ if (descriptor.lang === 'ts') {
100
+ source = await stripTypes(source);
101
+ }
102
+
103
+ // 6. Extract component metadata
104
+ const tagName = descriptor.tag;
105
+ const className = toClassName(tagName);
106
+ const template = descriptor.template;
107
+ const style = descriptor.style;
94
108
 
95
- // 5. Process conditional chains BEFORE walkTree (if/else-if/else)
96
- // This replaces conditional elements with comment anchors so walkTree
97
- // doesn't discover bindings inside conditional branches at the top level.
98
- const ifBlocks = processIfChains(rootEl, [], signalNames, computedNames, propNames);
109
+ // 7. Extract lifecycle hooks (before other extractions)
110
+ const { onMountHooks, onDestroyHooks } = extractLifecycleHooks(source);
111
+
112
+ // 7b. Strip lifecycle/watcher blocks from source for extraction
113
+ let sourceForExtraction = source;
114
+ const hookLinePattern = /\bonMount\s*\(|\bonDestroy\s*\(|\bwatch\s*\(/;
115
+ const sourceLines = sourceForExtraction.split('\n');
116
+ const filteredLines = [];
117
+ let skipDepth = 0;
118
+ let skipping = false;
119
+ for (const line of sourceLines) {
120
+ if (!skipping && hookLinePattern.test(line)) {
121
+ skipping = true;
122
+ skipDepth = 0;
123
+ for (const ch of line) {
124
+ if (ch === '{') skipDepth++;
125
+ if (ch === '}') skipDepth--;
126
+ }
127
+ if (skipDepth <= 0) skipping = false;
128
+ continue;
129
+ }
130
+ if (skipping) {
131
+ for (const ch of line) {
132
+ if (ch === '{') skipDepth++;
133
+ if (ch === '}') skipDepth--;
134
+ }
135
+ if (skipDepth <= 0) skipping = false;
136
+ continue;
137
+ }
138
+ filteredLines.push(line);
139
+ }
140
+ sourceForExtraction = filteredLines.join('\n');
141
+
142
+ // 8. Extract reactive declarations and functions
143
+ const signals = extractSignals(sourceForExtraction);
144
+ const computeds = extractComputeds(sourceForExtraction);
145
+ const effects = extractEffects(sourceForExtraction);
146
+ const watchers = extractWatchers(source);
147
+ const methods = extractFunctions(sourceForExtraction);
148
+ const refs = extractRefs(sourceForExtraction);
149
+ const constantVars = extractConstants(sourceForExtraction);
150
+
151
+ // 9. Extract props (array form — after type strip, if generic didn't find any)
152
+ const propsFromArray = propsFromGeneric.length > 0 ? [] : extractPropsArray(source);
153
+ let propNames = propsFromGeneric.length > 0 ? propsFromGeneric : propsFromArray;
154
+
155
+ // 10. Extract props defaults
156
+ const propsDefaults = extractPropsDefaults(source);
157
+ if (propNames.length === 0 && Object.keys(propsDefaults).length > 0) {
158
+ propNames = Object.keys(propsDefaults);
159
+ }
160
+
161
+ // 11. Extract propsObjectName
162
+ const propsObjectName = propsObjectNameFromGeneric ?? extractPropsObjectName(source);
163
+
164
+ // 12. Validate props
165
+ validateDuplicateProps(propNames, filePath);
166
+ const signalNameSet = new Set(signals.map(s => s.name));
167
+ const computedNameSet = new Set(computeds.map(c => c.name));
168
+ const constantNameSet = new Set(constantVars.map(v => v.name));
169
+ validatePropsConflicts(propsObjectName, signalNameSet, computedNameSet, constantNameSet, filePath);
170
+
171
+ /** @type {import('./types.js').PropDef[]} */
172
+ const propDefs = propNames.map(name => ({
173
+ name,
174
+ default: propsDefaults[name] ?? 'undefined',
175
+ attrName: camelToKebab(name),
176
+ }));
177
+
178
+ // 13. Extract emits
179
+ const emitsFromArray = emitsFromCallSignatures.length > 0 ? [] : extractEmits(source);
180
+ const emitNames = emitsFromCallSignatures.length > 0 ? emitsFromCallSignatures : emitsFromArray;
181
+ const emitsObjectName = emitsObjectNameFromGeneric ?? extractEmitsObjectName(source);
182
+
183
+ // 14. Validate emits
184
+ validateDuplicateEmits(emitNames, filePath);
185
+ const propNameSet = new Set(propNames);
186
+ validateEmitsConflicts(emitsObjectName, signalNameSet, computedNameSet, constantNameSet, propNameSet, propsObjectName, filePath);
187
+ validateUndeclaredEmits(source, emitsObjectName, emitNames, filePath);
188
+
189
+ // 15. Build initial ParseResult
190
+ /** @type {import('./types.js').ParseResult} */
191
+ const parseResult = {
192
+ tagName,
193
+ className,
194
+ template,
195
+ style,
196
+ signals,
197
+ computeds,
198
+ effects,
199
+ constantVars,
200
+ watchers,
201
+ methods,
202
+ propDefs,
203
+ propsObjectName: propsObjectName ?? null,
204
+ emits: emitNames,
205
+ emitsObjectName: emitsObjectName ?? null,
206
+ bindings: [],
207
+ events: [],
208
+ processedTemplate: null,
209
+ ifBlocks: [],
210
+ showBindings: [],
211
+ forBlocks: [],
212
+ onMountHooks,
213
+ onDestroyHooks,
214
+ modelBindings: [],
215
+ attrBindings: [],
216
+ slots: [],
217
+ refs,
218
+ refBindings: [],
219
+ childComponents: [],
220
+ childImports: [],
221
+ };
222
+
223
+ // 16. Process template through jsdom → tree-walker → codegen (same as compile())
224
+ const dom = new JSDOM(`<div id="__root">${template}</div>`);
225
+ const rootEl = dom.window.document.getElementById('__root');
226
+
227
+ const signalNames = new Set(signals.map(s => s.name));
228
+ const computedNames = new Set(computeds.map(c => c.name));
229
+ const propNamesSet = new Set(propDefs.map(p => p.name));
230
+
231
+ const forBlocks = processForBlocks(rootEl, [], signalNames, computedNames, propNamesSet);
232
+ const ifBlocks = processIfChains(rootEl, [], signalNames, computedNames, propNamesSet);
99
233
 
100
- // 6. Normalize DOM after all directive processing to merge adjacent text nodes
101
234
  rootEl.normalize();
102
235
 
103
- // 7. Recompute anchor paths after normalization since text node merging
104
- // may have changed childNode indices
105
236
  for (const fb of forBlocks) {
106
237
  fb.anchorPath = recomputeAnchorPath(rootEl, fb._anchorNode);
107
238
  }
@@ -109,16 +240,11 @@ export async function compile(filePath, config) {
109
240
  ib.anchorPath = recomputeAnchorPath(rootEl, ib._anchorNode);
110
241
  }
111
242
 
112
- // 8. Walk the tree (discovers bindings/events/showBindings/slots in non-conditional content)
113
- const { bindings, events, showBindings, modelBindings, attrBindings, slots, childComponents } = walkTree(rootEl, signalNames, computedNames, propNames);
243
+ const { bindings, events, showBindings, modelBindings, attrBindings, slots, childComponents } = walkTree(rootEl, signalNames, computedNames, propNamesSet);
114
244
 
115
- // 9. Detect refs (after walkTree — ref attributes are compile-time directives)
116
245
  const refBindings = detectRefs(rootEl);
117
246
 
118
- // 10. Validate refs
119
- const refs = parseResult.refs || [];
120
-
121
- // REF_NOT_FOUND: templateRef('name') with no matching ref="name" in template
247
+ // 17. Validate refs
122
248
  for (const decl of refs) {
123
249
  if (!refBindings.find(b => b.refName === decl.refName)) {
124
250
  const error = new Error(`templateRef('${decl.refName}') has no matching ref="${decl.refName}" in template`);
@@ -127,18 +253,16 @@ export async function compile(filePath, config) {
127
253
  throw error;
128
254
  }
129
255
  }
130
-
131
- // Unused ref warning: ref="name" in template with no matching templateRef('name') in script
132
256
  for (const binding of refBindings) {
133
257
  if (!refs.find(d => d.refName === binding.refName)) {
134
258
  console.warn(`Warning: ref="${binding.refName}" in template has no matching templateRef('${binding.refName}') in script`);
135
259
  }
136
260
  }
137
261
 
138
- // 10b. Validate model bindings — target must be a signal (not prop, computed, or constant)
139
- const constantNames = new Set((parseResult.constantVars || []).map(v => v.name));
262
+ // 17b. Validate model bindings
263
+ const constantNamesForModel = new Set(constantVars.map(v => v.name));
140
264
  for (const mb of modelBindings) {
141
- if (propNames.has(mb.signal)) {
265
+ if (propNamesSet.has(mb.signal)) {
142
266
  const error = new Error(`model cannot bind to prop '${mb.signal}' (read-only)`);
143
267
  /** @ts-expect-error — custom error code */
144
268
  error.code = 'MODEL_READONLY';
@@ -150,7 +274,7 @@ export async function compile(filePath, config) {
150
274
  error.code = 'MODEL_READONLY';
151
275
  throw error;
152
276
  }
153
- if (constantNames.has(mb.signal)) {
277
+ if (constantNamesForModel.has(mb.signal)) {
154
278
  const error = new Error(`model cannot bind to constant '${mb.signal}' (read-only)`);
155
279
  /** @ts-expect-error — custom error code */
156
280
  error.code = 'MODEL_READONLY';
@@ -164,7 +288,7 @@ export async function compile(filePath, config) {
164
288
  }
165
289
  }
166
290
 
167
- // 11. Resolve child component imports
291
+ // 18. Resolve child component imports
168
292
  /** @type {import('./types.js').ChildComponentImport[]} */
169
293
  const childImports = [];
170
294
  if (childComponents.length > 0) {
@@ -181,7 +305,7 @@ export async function compile(filePath, config) {
181
305
  }
182
306
  }
183
307
 
184
- // 12. Merge results into ParseResult
308
+ // 19. Merge tree-walker results into ParseResult
185
309
  parseResult.bindings = bindings;
186
310
  parseResult.events = events;
187
311
  parseResult.showBindings = showBindings;
@@ -193,9 +317,19 @@ export async function compile(filePath, config) {
193
317
  parseResult.refBindings = refBindings;
194
318
  parseResult.childComponents = childComponents;
195
319
  parseResult.childImports = childImports;
196
- // Recompute processedTemplate after all directive replacements (including ref removal)
197
320
  parseResult.processedTemplate = rootEl.innerHTML;
198
321
 
199
- // 12. Generate component
322
+ // 20. Generate component
200
323
  return generateComponent(parseResult);
201
324
  }
325
+
326
+ /**
327
+ * Compile a single .wcc SFC file into a self-contained JS component.
328
+ *
329
+ * @param {string} filePath — Absolute or relative path to the .wcc file
330
+ * @param {object} [config] — Optional config (reserved for future options)
331
+ * @returns {Promise<string>} The generated JavaScript component code
332
+ */
333
+ export async function compile(filePath, config) {
334
+ return compileSFC(filePath, config);
335
+ }
@@ -244,25 +244,15 @@ export function extractPropsObjectName(source) {
244
244
  // ── Props validation ────────────────────────────────────────────────
245
245
 
246
246
  /**
247
- * Validate that defineProps is assigned to a variable.
248
- * Throws PROPS_ASSIGNMENT_REQUIRED if bare defineProps() call detected.
247
+ * Validate that defineProps is assigned to a variable (if props are accessed via object).
248
+ * No longer throws bare defineProps() calls are valid when props are only used in template.
249
249
  *
250
- * @param {string} source
251
- * @param {string} fileName
250
+ * @param {string} _source
251
+ * @param {string} _fileName
252
252
  */
253
- export function validatePropsAssignment(source, fileName) {
254
- // Check if defineProps appears in source
255
- if (!/defineProps\s*[<(]/.test(source)) return;
256
-
257
- // Check if it's assigned to a variable
258
- if (extractPropsObjectName(source) !== null) return;
259
-
260
- const error = new Error(
261
- `Error en '${fileName}': defineProps() debe asignarse a una variable (const props = defineProps(...))`
262
- );
263
- /** @ts-expect-error — custom error code for programmatic handling */
264
- error.code = 'PROPS_ASSIGNMENT_REQUIRED';
265
- throw error;
253
+ export function validatePropsAssignment(_source, _fileName) {
254
+ // No-op: bare defineProps() is valid in .wcc SFC format
255
+ // Props are accessible in the template without needing a variable reference
266
256
  }
267
257
 
268
258
  /**
@@ -606,7 +596,7 @@ export function extractSignals(source) {
606
596
  /**
607
597
  * Known macro/reactive call patterns that should NOT be treated as constants.
608
598
  */
609
- export const REACTIVE_CALLS = /\b(?:signal|computed|effect|watch|defineProps|defineEmits|defineComponent|templateRef|templateBindings|onMount|onDestroy)\s*[<(]/;
599
+ export const REACTIVE_CALLS = /\b(?:signal|computed|effect|watch|defineProps|defineEmits|defineComponent|templateRef|defineExpose|onMount|onDestroy)\s*[<(]/;
610
600
 
611
601
  /**
612
602
  * Extract plain const/let/var declarations that are NOT reactive calls.
@@ -775,7 +765,9 @@ export function extractEffects(source) {
775
765
 
776
766
  /**
777
767
  * Extract watch() declarations from source.
778
- * Pattern: watch('target', (newParam, oldParam) => { body })
768
+ * Supports two forms:
769
+ * Form 1 — Signal direct: watch(count, (newVal, oldVal) => { body })
770
+ * Form 2 — Getter function: watch(() => expr, (newVal, oldVal) => { body })
779
771
  * Uses brace depth tracking to capture multi-line bodies.
780
772
  *
781
773
  * @param {string} source
@@ -789,9 +781,16 @@ export function extractWatchers(source) {
789
781
 
790
782
  while (i < lines.length) {
791
783
  const line = lines[i];
792
- const m = line.match(/\bwatch\(\s*['"](\w+)['"]\s*,\s*\((\w+)\s*,\s*(\w+)\)\s*=>\s*\{/);
784
+
785
+ // Form 2 — Getter function: watch(() => expr, (newVal, oldVal) => {
786
+ const mGetter = line.match(/\bwatch\s*\(\s*\(\)\s*=>\s*(.+?)\s*,\s*\((\w+)\s*,\s*(\w+)\)\s*=>\s*\{/);
787
+ // Form 1 — Signal direct: watch(identifier, (newVal, oldVal) => {
788
+ const mSignal = !mGetter ? line.match(/\bwatch\s*\(\s*(\w+)\s*,\s*\((\w+)\s*,\s*(\w+)\)\s*=>\s*\{/) : null;
789
+
790
+ const m = mGetter || mSignal;
793
791
 
794
792
  if (m) {
793
+ const kind = mGetter ? 'getter' : 'signal';
795
794
  const target = m[1];
796
795
  const newParam = m[2];
797
796
  const oldParam = m[3];
@@ -843,7 +842,7 @@ export function extractWatchers(source) {
843
842
  if (minIndent === Infinity) minIndent = 0;
844
843
  const body = bodyLines.map(bl => bl.substring(minIndent)).join('\n').trim();
845
844
 
846
- watchers.push({ target, newParam, oldParam, body });
845
+ watchers.push({ kind, target, newParam, oldParam, body });
847
846
  }
848
847
  i++;
849
848
  }