@sprlab/wccompiler 0.2.1 → 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/parser.js CHANGED
@@ -12,44 +12,44 @@
12
12
  * here — that's the responsibility of tree-walker.js.
13
13
  */
14
14
 
15
- /** @import { ParseResult, ReactiveVar, ComputedDef, EffectDef, MethodDef, PropDef, LifecycleHook, RefDeclaration } from './types.js' */
15
+ /** @import { ParseResult, PropDef } from './types.js' */
16
16
 
17
17
  import { readFileSync, existsSync } from 'node:fs';
18
18
  import { resolve, dirname, extname } from 'node:path';
19
19
  import { transform } from 'esbuild';
20
20
 
21
- // ── Macro import stripping ───────────────────────────────────────────
22
-
23
- /**
24
- * Remove `import { ... } from 'wcc'` and `import { ... } from '@sprlab/wccompiler'`
25
- * statements from source content. These imports are purely cosmetic (for IDE DX)
26
- * and must be stripped before any further processing.
27
- *
28
- * @param {string} source - Raw source content
29
- * @returns {string} Source with macro imports removed
30
- */
31
- export function stripMacroImport(source) {
32
- return source.replace(
33
- /import\s*\{[^}]*\}\s*from\s*['"](?:wcc|@sprlab\/wccompiler)['"]\s*;?/g,
34
- ''
35
- );
36
- }
37
-
38
- // ── Name conversion ─────────────────────────────────────────────────
39
-
40
- /**
41
- * Convert a kebab-case tag name to PascalCase class name.
42
- * e.g. "wcc-counter" → "WccCounter"
43
- *
44
- * @param {string} tagName
45
- * @returns {string}
46
- */
47
- export function toClassName(tagName) {
48
- return tagName
49
- .split('-')
50
- .map(part => part.charAt(0).toUpperCase() + part.slice(1))
51
- .join('');
52
- }
21
+ // Re-export all pure extraction functions so existing consumers are unaffected
22
+ export * from './parser-extractors.js';
23
+
24
+ import {
25
+ stripMacroImport,
26
+ toClassName,
27
+ camelToKebab,
28
+ extractPropsGeneric,
29
+ extractPropsArray,
30
+ extractPropsDefaults,
31
+ extractPropsObjectName,
32
+ extractEmitsFromCallSignatures,
33
+ extractEmits,
34
+ extractEmitsObjectName,
35
+ extractEmitsObjectNameFromGeneric,
36
+ extractSignals,
37
+ extractComputeds,
38
+ extractEffects,
39
+ extractWatchers,
40
+ extractFunctions,
41
+ extractLifecycleHooks,
42
+ extractRefs,
43
+ extractConstants,
44
+ extractDefineComponent,
45
+ validatePropsAssignment,
46
+ validateDuplicateProps,
47
+ validatePropsConflicts,
48
+ validateEmitsAssignment,
49
+ validateDuplicateEmits,
50
+ validateEmitsConflicts,
51
+ validateUndeclaredEmits,
52
+ } from './parser-extractors.js';
53
53
 
54
54
  // ── Type stripping ──────────────────────────────────────────────────
55
55
 
@@ -75,901 +75,6 @@ export async function stripTypes(tsCode) {
75
75
  }
76
76
  }
77
77
 
78
- // ── camelCase to kebab-case ─────────────────────────────────────────
79
-
80
- /**
81
- * Convert a camelCase identifier to kebab-case for HTML attribute names.
82
- * e.g. 'itemCount' → 'item-count', 'label' → 'label'
83
- *
84
- * @param {string} name
85
- * @returns {string}
86
- */
87
- export function camelToKebab(name) {
88
- return name.replace(/([a-z0-9])([A-Z])/g, '$1-$2').toLowerCase();
89
- }
90
-
91
- // ── Props extraction (generic form — BEFORE type strip) ─────────────
92
-
93
- /**
94
- * Extract prop names from the TypeScript generic form:
95
- * defineProps<{ label: string, count: number }>({...})
96
- * or defineProps<{ label: string }>()
97
- *
98
- * Must be called BEFORE stripTypes() since esbuild removes generics.
99
- *
100
- * @param {string} source
101
- * @returns {string[]}
102
- */
103
- export function extractPropsGeneric(source) {
104
- const m = source.match(/defineProps\s*<\s*\{([^}]*)\}\s*>/);
105
- if (!m) return [];
106
-
107
- const body = m[1];
108
- const props = [];
109
- const re = /(\w+)\s*[?]?\s*:/g;
110
- let match;
111
- while ((match = re.exec(body)) !== null) {
112
- props.push(match[1]);
113
- }
114
- return props;
115
- }
116
-
117
- // ── Props extraction (array form — AFTER type strip) ────────────────
118
-
119
- /**
120
- * Extract prop names from the array form:
121
- * defineProps(['label', 'count'])
122
- *
123
- * Called AFTER type stripping.
124
- *
125
- * @param {string} source
126
- * @returns {string[]}
127
- */
128
- function extractPropsArray(source) {
129
- const m = source.match(/defineProps\(\s*\[([^\]]*)\]\s*\)/);
130
- if (!m) return [];
131
-
132
- const body = m[1];
133
- const props = [];
134
- const re = /['"]([^'"]+)['"]/g;
135
- let match;
136
- while ((match = re.exec(body)) !== null) {
137
- props.push(match[1]);
138
- }
139
- return props;
140
- }
141
-
142
- // ── Props defaults extraction (AFTER type strip) ────────────────────
143
-
144
- /**
145
- * Extract default values from the defineProps argument object.
146
- * After type stripping, the generic form becomes defineProps({...}).
147
- * The array form is defineProps([...]) — no defaults.
148
- *
149
- * Uses parenthesis depth counting to handle nested objects/arrays.
150
- *
151
- * @param {string} source
152
- * @returns {Record<string, string>}
153
- */
154
- function extractPropsDefaults(source) {
155
- const idx = source.indexOf('defineProps(');
156
- if (idx === -1) return {};
157
-
158
- const start = idx + 'defineProps('.length;
159
- // Check what the argument starts with (skip whitespace)
160
- let argStart = start;
161
- while (argStart < source.length && /\s/.test(source[argStart])) argStart++;
162
-
163
- // If it starts with '[', it's the array form — no defaults
164
- if (source[argStart] === '[') return {};
165
-
166
- // If it doesn't start with '{', no defaults (e.g., empty call)
167
- if (source[argStart] !== '{') return {};
168
-
169
- // Use depth counting to extract the full object literal
170
- let depth = 0;
171
- let i = argStart;
172
- /** @type {string | null} */
173
- let inString = null;
174
-
175
- for (; i < source.length; i++) {
176
- const ch = source[i];
177
-
178
- if (inString) {
179
- if (ch === '\\') { i++; continue; }
180
- if (ch === inString) inString = null;
181
- continue;
182
- }
183
-
184
- if (ch === '"' || ch === "'" || ch === '`') {
185
- inString = ch;
186
- continue;
187
- }
188
-
189
- if (ch === '{') depth++;
190
- if (ch === '}') {
191
- depth--;
192
- if (depth === 0) { i++; break; }
193
- }
194
- }
195
-
196
- const objLiteral = source.slice(argStart, i).trim();
197
- // Remove outer braces
198
- const inner = objLiteral.slice(1, -1).trim();
199
- if (!inner) return {};
200
-
201
- // Parse key: value pairs using depth counting
202
- /** @type {Record<string, string>} */
203
- const defaults = {};
204
- let pos = 0;
205
- while (pos < inner.length) {
206
- // Skip whitespace
207
- while (pos < inner.length && /\s/.test(inner[pos])) pos++;
208
- if (pos >= inner.length) break;
209
-
210
- // Extract key
211
- const keyMatch = inner.slice(pos).match(/^(\w+)\s*:\s*/);
212
- if (!keyMatch) break;
213
- const key = keyMatch[1];
214
- pos += keyMatch[0].length;
215
-
216
- // Extract value using depth counting
217
- let valDepth = 0;
218
- let valStart = pos;
219
- /** @type {string | null} */
220
- let valInString = null;
221
-
222
- for (; pos < inner.length; pos++) {
223
- const ch = inner[pos];
224
-
225
- if (valInString) {
226
- if (ch === '\\') { pos++; continue; }
227
- if (ch === valInString) valInString = null;
228
- continue;
229
- }
230
-
231
- if (ch === '"' || ch === "'" || ch === '`') {
232
- valInString = ch;
233
- continue;
234
- }
235
-
236
- if (ch === '(' || ch === '[' || ch === '{') valDepth++;
237
- if (ch === ')' || ch === ']' || ch === '}') valDepth--;
238
-
239
- if (valDepth === 0 && ch === ',') {
240
- break;
241
- }
242
- }
243
-
244
- const value = inner.slice(valStart, pos).trim();
245
- defaults[key] = value;
246
-
247
- // Skip comma
248
- if (pos < inner.length && inner[pos] === ',') pos++;
249
- }
250
-
251
- return defaults;
252
- }
253
-
254
- // ── Props object name extraction ────────────────────────────────────
255
-
256
- /**
257
- * Extract the variable name from a props object binding.
258
- * Pattern: const/let/var <identifier> = defineProps<...>(...) or defineProps(...)
259
- *
260
- * @param {string} source
261
- * @returns {string | null}
262
- */
263
- export function extractPropsObjectName(source) {
264
- const m = source.match(/(?:const|let|var)\s+([$\w]+)\s*=\s*defineProps\s*[<(]/);
265
- return m ? m[1] : null;
266
- }
267
-
268
- // ── Props validation ────────────────────────────────────────────────
269
-
270
- /**
271
- * Validate that defineProps is assigned to a variable.
272
- * Throws PROPS_ASSIGNMENT_REQUIRED if bare defineProps() call detected.
273
- *
274
- * @param {string} source
275
- * @param {string} fileName
276
- */
277
- function validatePropsAssignment(source, fileName) {
278
- // Check if defineProps appears in source
279
- if (!/defineProps\s*[<(]/.test(source)) return;
280
-
281
- // Check if it's assigned to a variable
282
- if (extractPropsObjectName(source) !== null) return;
283
-
284
- const error = new Error(
285
- `Error en '${fileName}': defineProps() debe asignarse a una variable (const props = defineProps(...))`
286
- );
287
- /** @ts-expect-error — custom error code for programmatic handling */
288
- error.code = 'PROPS_ASSIGNMENT_REQUIRED';
289
- throw error;
290
- }
291
-
292
- /**
293
- * Validate that there are no duplicate prop names.
294
- *
295
- * @param {string[]} propNames
296
- * @param {string} fileName
297
- */
298
- function validateDuplicateProps(propNames, fileName) {
299
- const seen = new Set();
300
- const duplicates = new Set();
301
- for (const p of propNames) {
302
- if (seen.has(p)) duplicates.add(p);
303
- seen.add(p);
304
- }
305
- if (duplicates.size > 0) {
306
- const names = [...duplicates].join(', ');
307
- const error = new Error(
308
- `Error en '${fileName}': props duplicados: ${names}`
309
- );
310
- /** @ts-expect-error — custom error code for programmatic handling */
311
- error.code = 'DUPLICATE_PROPS';
312
- throw error;
313
- }
314
- }
315
-
316
- /**
317
- * Validate that the propsObjectName doesn't collide with signals, computeds, or constants.
318
- *
319
- * @param {string|null} propsObjectName
320
- * @param {Set<string>} signalNames
321
- * @param {Set<string>} computedNames
322
- * @param {Set<string>} constantNames
323
- * @param {string} fileName
324
- */
325
- function validatePropsConflicts(propsObjectName, signalNames, computedNames, constantNames, fileName) {
326
- if (!propsObjectName) return;
327
-
328
- if (signalNames.has(propsObjectName) || computedNames.has(propsObjectName) || constantNames.has(propsObjectName)) {
329
- const error = new Error(
330
- `Error en '${fileName}': '${propsObjectName}' colisiona con una declaración existente`
331
- );
332
- /** @ts-expect-error — custom error code for programmatic handling */
333
- error.code = 'PROPS_OBJECT_CONFLICT';
334
- throw error;
335
- }
336
- }
337
-
338
- // ── Emits extraction (call signatures form — BEFORE type strip) ─────
339
-
340
- /**
341
- * Extract event names from the TypeScript call signatures form:
342
- * defineEmits<{ (e: 'change', value: number): void; (e: 'reset'): void }>()
343
- *
344
- * Must be called BEFORE stripTypes() since esbuild removes generics.
345
- *
346
- * @param {string} source
347
- * @returns {string[]}
348
- */
349
- export function extractEmitsFromCallSignatures(source) {
350
- const m = source.match(/defineEmits\s*<\s*\{([\s\S]*?)\}\s*>\s*\(\s*\)/);
351
- if (!m) return [];
352
-
353
- const body = m[1];
354
- const emits = [];
355
- const re = /\(\s*\w+\s*:\s*['"]([^'"]+)['"]/g;
356
- let match;
357
- while ((match = re.exec(body)) !== null) {
358
- emits.push(match[1]);
359
- }
360
- return emits;
361
- }
362
-
363
- // ── Emits extraction (array form — AFTER type strip) ────────────────
364
-
365
- /**
366
- * Extract event names from the array form:
367
- * defineEmits(['change', 'reset'])
368
- *
369
- * Called AFTER type stripping.
370
- *
371
- * @param {string} source
372
- * @returns {string[]}
373
- */
374
- function extractEmits(source) {
375
- const m = source.match(/defineEmits\(\[([^\]]*)\]\)/);
376
- if (!m) return [];
377
-
378
- const body = m[1];
379
- const emits = [];
380
- const re = /['"]([^'"]+)['"]/g;
381
- let match;
382
- while ((match = re.exec(body)) !== null) {
383
- emits.push(match[1]);
384
- }
385
- return emits;
386
- }
387
-
388
- // ── Emits object name extraction ────────────────────────────────────
389
-
390
- /**
391
- * Extract the variable name from an emits object binding (AFTER type strip).
392
- * Pattern: const/let/var <identifier> = defineEmits(...)
393
- *
394
- * @param {string} source
395
- * @returns {string | null}
396
- */
397
- export function extractEmitsObjectName(source) {
398
- const m = source.match(/(?:const|let|var)\s+([$\w]+)\s*=\s*defineEmits\s*\(/);
399
- return m ? m[1] : null;
400
- }
401
-
402
- /**
403
- * Extract the variable name from an emits object binding (BEFORE type strip, generic form).
404
- * Pattern: const/let/var <identifier> = defineEmits<{...}>()
405
- *
406
- * @param {string} source
407
- * @returns {string | null}
408
- */
409
- function extractEmitsObjectNameFromGeneric(source) {
410
- const m = source.match(/(?:const|let|var)\s+([$\w]+)\s*=\s*defineEmits\s*<\s*\{/);
411
- return m ? m[1] : null;
412
- }
413
-
414
- // ── Emits validation ────────────────────────────────────────────────
415
-
416
- /**
417
- * Validate that defineEmits is assigned to a variable.
418
- * Throws EMITS_ASSIGNMENT_REQUIRED if bare defineEmits() call detected.
419
- *
420
- * @param {string} source
421
- * @param {string} fileName
422
- */
423
- function validateEmitsAssignment(source, fileName) {
424
- // Check if defineEmits appears in source
425
- if (!/defineEmits\s*[<(]/.test(source)) return;
426
-
427
- // Check if it's assigned to a variable (either generic or non-generic form)
428
- if (extractEmitsObjectName(source) !== null) return;
429
- if (extractEmitsObjectNameFromGeneric(source) !== null) return;
430
-
431
- const error = new Error(
432
- `Error en '${fileName}': defineEmits() debe asignarse a una variable (const emit = defineEmits(...))`
433
- );
434
- /** @ts-expect-error — custom error code for programmatic handling */
435
- error.code = 'EMITS_ASSIGNMENT_REQUIRED';
436
- throw error;
437
- }
438
-
439
- /**
440
- * Validate that there are no duplicate event names.
441
- *
442
- * @param {string[]} emitNames
443
- * @param {string} fileName
444
- */
445
- function validateDuplicateEmits(emitNames, fileName) {
446
- const seen = new Set();
447
- const duplicates = new Set();
448
- for (const e of emitNames) {
449
- if (seen.has(e)) duplicates.add(e);
450
- seen.add(e);
451
- }
452
- if (duplicates.size > 0) {
453
- const names = [...duplicates].join(', ');
454
- const error = new Error(
455
- `Error en '${fileName}': emits duplicados: ${names}`
456
- );
457
- /** @ts-expect-error — custom error code for programmatic handling */
458
- error.code = 'DUPLICATE_EMITS';
459
- throw error;
460
- }
461
- }
462
-
463
- /**
464
- * Validate that the emitsObjectName doesn't collide with signals, computeds, constants, props, or propsObjectName.
465
- *
466
- * @param {string|null} emitsObjectName
467
- * @param {Set<string>} signalNames
468
- * @param {Set<string>} computedNames
469
- * @param {Set<string>} constantNames
470
- * @param {Set<string>} propNames
471
- * @param {string|null} propsObjectName
472
- * @param {string} fileName
473
- */
474
- function validateEmitsConflicts(emitsObjectName, signalNames, computedNames, constantNames, propNames, propsObjectName, fileName) {
475
- if (!emitsObjectName) return;
476
-
477
- if (
478
- signalNames.has(emitsObjectName) ||
479
- computedNames.has(emitsObjectName) ||
480
- constantNames.has(emitsObjectName) ||
481
- propNames.has(emitsObjectName) ||
482
- (propsObjectName && emitsObjectName === propsObjectName)
483
- ) {
484
- const error = new Error(
485
- `Error en '${fileName}': '${emitsObjectName}' colisiona con una declaración existente`
486
- );
487
- /** @ts-expect-error — custom error code for programmatic handling */
488
- error.code = 'EMITS_OBJECT_CONFLICT';
489
- throw error;
490
- }
491
- }
492
-
493
- /**
494
- * Escape special regex characters in a string.
495
- *
496
- * @param {string} str
497
- * @returns {string}
498
- */
499
- function escapeRegex(str) {
500
- return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
501
- }
502
-
503
- /**
504
- * Validate that all emit calls use declared event names.
505
- *
506
- * @param {string} source
507
- * @param {string|null} emitsObjectName
508
- * @param {string[]} emits
509
- * @param {string} fileName
510
- */
511
- function validateUndeclaredEmits(source, emitsObjectName, emits, fileName) {
512
- if (!emitsObjectName || emits.length === 0) return;
513
-
514
- const emitsSet = new Set(emits);
515
- const re = new RegExp(`\\b${escapeRegex(emitsObjectName)}\\(\\s*['"]([^'"]+)['"]`, 'g');
516
- let match;
517
- while ((match = re.exec(source)) !== null) {
518
- const eventName = match[1];
519
- if (!emitsSet.has(eventName)) {
520
- const error = new Error(
521
- `Error en '${fileName}': emit no declarado: '${eventName}'`
522
- );
523
- /** @ts-expect-error — custom error code for programmatic handling */
524
- error.code = 'UNDECLARED_EMIT';
525
- throw error;
526
- }
527
- }
528
- }
529
-
530
- // ── defineComponent extraction ──────────────────────────────────────
531
-
532
- /**
533
- * Extract defineComponent({ tag, template, styles }) from source.
534
- *
535
- * @param {string} source
536
- * @returns {{ tag: string, template: string, styles: string | null }}
537
- */
538
- function extractDefineComponent(source) {
539
- const m = source.match(/defineComponent\(\s*\{([^}]*)\}\s*\)/);
540
- if (!m) return null;
541
-
542
- const body = m[1];
543
-
544
- const tagMatch = body.match(/tag\s*:\s*['"]([^'"]+)['"]/);
545
- const templateMatch = body.match(/template\s*:\s*['"]([^'"]+)['"]/);
546
- const stylesMatch = body.match(/styles\s*:\s*['"]([^'"]+)['"]/);
547
-
548
- if (!tagMatch || !templateMatch) return null;
549
-
550
- return {
551
- tag: tagMatch[1],
552
- template: templateMatch[1],
553
- styles: stylesMatch ? stylesMatch[1] : null,
554
- };
555
- }
556
-
557
- // ── Signal extraction ───────────────────────────────────────────────
558
-
559
- /**
560
- * Extract the argument of a `signal(...)` call starting at a given position.
561
- * Uses parenthesis depth counting to correctly handle nested parentheses,
562
- * e.g. `signal([1, 2, 3])` or `signal((a + b) * c)`.
563
- * Also handles string literals so that parentheses inside strings are not counted.
564
- *
565
- * @param {string} source - Source code starting from after `signal(`
566
- * @param {number} startIdx - Index right after `signal(`
567
- * @returns {string} The trimmed argument string, or 'undefined' if empty
568
- */
569
- function extractSignalArgument(source, startIdx) {
570
- let depth = 0;
571
- let i = startIdx;
572
- /** @type {string | null} */
573
- let inString = null;
574
-
575
- for (; i < source.length; i++) {
576
- const ch = source[i];
577
-
578
- // Handle string literal boundaries
579
- if (inString) {
580
- if (ch === '\\') {
581
- i++; // skip escaped character
582
- continue;
583
- }
584
- if (ch === inString) {
585
- inString = null;
586
- }
587
- continue;
588
- }
589
-
590
- if (ch === '"' || ch === "'" || ch === '`') {
591
- inString = ch;
592
- continue;
593
- }
594
-
595
- if (ch === '(') depth++;
596
- if (ch === ')') {
597
- if (depth === 0) break;
598
- depth--;
599
- }
600
- }
601
-
602
- return source.slice(startIdx, i).trim() || 'undefined';
603
- }
604
-
605
- /**
606
- * Extract signal declarations from source.
607
- * Pattern: const/let/var name = signal(value)
608
- *
609
- * @param {string} source
610
- * @returns {ReactiveVar[]}
611
- */
612
- export function extractSignals(source) {
613
- /** @type {ReactiveVar[]} */
614
- const signals = [];
615
- const re = /(?:const|let|var)\s+([$\w]+)\s*=\s*signal\(/g;
616
- let m;
617
-
618
- while ((m = re.exec(source)) !== null) {
619
- const name = m[1];
620
- const argStart = m.index + m[0].length;
621
- const value = extractSignalArgument(source, argStart);
622
- signals.push({ name, value });
623
- }
624
-
625
- return signals;
626
- }
627
-
628
- // ── Constant extraction ─────────────────────────────────────────────
629
-
630
- /**
631
- * Known macro/reactive call patterns that should NOT be treated as constants.
632
- */
633
- const REACTIVE_CALLS = /\b(?:signal|computed|effect|defineProps|defineEmits|defineComponent|templateRef|templateBindings|onMount|onDestroy)\s*[<(]/;
634
-
635
- /**
636
- * Extract plain const/let/var declarations that are NOT reactive calls.
637
- * Only extracts root-level declarations (depth 0).
638
- *
639
- * @param {string} source
640
- * @returns {import('./types.js').ConstantVar[]}
641
- */
642
- export function extractConstants(source) {
643
- /** @type {import('./types.js').ConstantVar[]} */
644
- const constants = [];
645
- let depth = 0;
646
-
647
- for (const line of source.split('\n')) {
648
- // Track brace depth to skip nested blocks
649
- for (const ch of line) {
650
- if (ch === '{') depth++;
651
- if (ch === '}') depth--;
652
- }
653
- if (depth > 0) continue;
654
-
655
- // Match const/let/var name = value at root level
656
- const m = line.match(/^\s*(?:const|let|var)\s+([$\w]+)\s*=\s*(.+?);?\s*$/);
657
- if (!m) continue;
658
-
659
- const value = m[2].trim();
660
- // Skip reactive/macro calls
661
- if (REACTIVE_CALLS.test(value)) continue;
662
- // Skip export default
663
- if (/^\s*export\s+default/.test(line)) continue;
664
-
665
- constants.push({ name: m[1], value });
666
- }
667
-
668
- return constants;
669
- }
670
-
671
- // ── Computed extraction ─────────────────────────────────────────────
672
-
673
- /**
674
- * Extract computed declarations from source.
675
- * Pattern: const/let/var name = computed(() => expr)
676
- * Uses parenthesis depth counting to handle expressions containing parens,
677
- * e.g. `computed(() => count() * 2)`.
678
- *
679
- * @param {string} source
680
- * @returns {ComputedDef[]}
681
- */
682
- export function extractComputeds(source) {
683
- /** @type {ComputedDef[]} */
684
- const out = [];
685
- const re = /(?:const|let|var)\s+(\w+)\s*=\s*computed\(\s*\(\)\s*=>\s*/g;
686
- let m;
687
- while ((m = re.exec(source)) !== null) {
688
- const name = m[1];
689
- const bodyStart = m.index + m[0].length;
690
- // Use depth counting: we're inside `computed(` so depth starts at 1
691
- // We need to find the matching `)` for the outer `computed(` call
692
- let depth = 1;
693
- let i = bodyStart;
694
- /** @type {string | null} */
695
- let inString = null;
696
-
697
- for (; i < source.length; i++) {
698
- const ch = source[i];
699
-
700
- if (inString) {
701
- if (ch === '\\') { i++; continue; }
702
- if (ch === inString) inString = null;
703
- continue;
704
- }
705
-
706
- if (ch === '"' || ch === "'" || ch === '`') {
707
- inString = ch;
708
- continue;
709
- }
710
-
711
- if (ch === '(') depth++;
712
- if (ch === ')') {
713
- depth--;
714
- if (depth === 0) break;
715
- }
716
- }
717
-
718
- const body = source.slice(bodyStart, i).trim();
719
- if (body) {
720
- out.push({ name, body });
721
- }
722
- }
723
- return out;
724
- }
725
-
726
- // ── Effect extraction ───────────────────────────────────────────────
727
-
728
- /**
729
- * Extract effect declarations from source.
730
- * Pattern: effect(() => { body })
731
- * Uses brace depth tracking to capture multi-line bodies.
732
- *
733
- * @param {string} source
734
- * @returns {EffectDef[]}
735
- */
736
- export function extractEffects(source) {
737
- /** @type {EffectDef[]} */
738
- const effects = [];
739
- const lines = source.split('\n');
740
- let i = 0;
741
-
742
- while (i < lines.length) {
743
- const line = lines[i];
744
- const effectMatch = line.match(/\beffect\s*\(\s*\(\s*\)\s*=>\s*\{/);
745
-
746
- if (effectMatch) {
747
- // Collect body by tracking brace depth
748
- let depth = 0;
749
- let bodyLines = [];
750
- let started = false;
751
-
752
- for (let j = i; j < lines.length; j++) {
753
- const l = lines[j];
754
- for (const ch of l) {
755
- if (ch === '{') {
756
- if (started) depth++;
757
- else { depth = 1; started = true; }
758
- }
759
- if (ch === '}') depth--;
760
- }
761
-
762
- if (j === i) {
763
- // First line: capture everything after the opening brace
764
- const braceIdx = l.indexOf('{');
765
- const afterBrace = l.substring(braceIdx + 1);
766
- if (afterBrace.trim()) bodyLines.push(afterBrace);
767
- } else if (depth <= 0) {
768
- // Last line: capture everything before the closing brace
769
- const lastBraceIdx = l.lastIndexOf('}');
770
- const before = l.substring(0, lastBraceIdx);
771
- if (before.trim()) bodyLines.push(before);
772
- i = j;
773
- break;
774
- } else {
775
- bodyLines.push(l);
776
- }
777
- }
778
-
779
- // Dedent body lines
780
- const nonEmptyLines = bodyLines.filter(l => l.trim().length > 0);
781
- let minIndent = Infinity;
782
- for (const bl of nonEmptyLines) {
783
- const leadingSpaces = bl.match(/^(\s*)/)[1].length;
784
- if (leadingSpaces < minIndent) minIndent = leadingSpaces;
785
- }
786
- if (minIndent === Infinity) minIndent = 0;
787
- const dedentedLines = bodyLines.map(bl => bl.substring(minIndent));
788
- const body = dedentedLines.join('\n').trim();
789
-
790
- effects.push({ body });
791
- }
792
- i++;
793
- }
794
-
795
- return effects;
796
- }
797
-
798
- // ── Function extraction ─────────────────────────────────────────────
799
-
800
- /**
801
- * Extract top-level function declarations from source.
802
- * Pattern: function name(params) { body }
803
- * Uses brace depth tracking to capture the full function body.
804
- *
805
- * @param {string} source
806
- * @returns {MethodDef[]}
807
- */
808
- export function extractFunctions(source) {
809
- /** @type {MethodDef[]} */
810
- const functions = [];
811
- const lines = source.split('\n');
812
- let i = 0;
813
-
814
- while (i < lines.length) {
815
- const line = lines[i];
816
- const m = line.match(/^\s*function\s+(\w+)\s*\(([^)]*)\)\s*\{/);
817
- if (m) {
818
- const name = m[1];
819
- const params = m[2].trim();
820
- // Collect body by tracking brace depth
821
- let depth = 0;
822
- let bodyLines = [];
823
- let started = false;
824
-
825
- for (let j = i; j < lines.length; j++) {
826
- const l = lines[j];
827
- for (const ch of l) {
828
- if (ch === '{') {
829
- if (started) depth++;
830
- else { depth = 1; started = true; }
831
- }
832
- if (ch === '}') depth--;
833
- }
834
-
835
- if (j === i) {
836
- // First line: capture everything after the opening brace
837
- const afterBrace = l.substring(l.indexOf('{') + 1);
838
- if (afterBrace.trim()) bodyLines.push(afterBrace);
839
- } else if (depth <= 0) {
840
- // Last line: capture everything before the closing brace
841
- const lastBraceIdx = l.lastIndexOf('}');
842
- const before = l.substring(0, lastBraceIdx);
843
- if (before.trim()) bodyLines.push(before);
844
- i = j;
845
- break;
846
- } else {
847
- bodyLines.push(l);
848
- }
849
- }
850
-
851
- functions.push({
852
- name,
853
- params,
854
- body: bodyLines.join('\n').trim(),
855
- });
856
- }
857
- i++;
858
- }
859
-
860
- return functions;
861
- }
862
-
863
- // ── Lifecycle hook extraction ────────────────────────────────────────
864
-
865
- /**
866
- * Extract lifecycle hooks from the script.
867
- * Patterns: onMount(() => { body }) and onDestroy(() => { body })
868
- * Supports multiple calls of each type.
869
- * Uses brace depth tracking to capture multi-line bodies.
870
- * Only extracts top-level calls (brace depth === 0 when the call is encountered).
871
- *
872
- * @param {string} script - The script content (after type stripping)
873
- * @returns {{ onMountHooks: LifecycleHook[], onDestroyHooks: LifecycleHook[] }}
874
- */
875
- export function extractLifecycleHooks(script) {
876
- /** @type {LifecycleHook[]} */
877
- const onMountHooks = [];
878
- /** @type {LifecycleHook[]} */
879
- const onDestroyHooks = [];
880
- const lines = script.split('\n');
881
- let i = 0;
882
-
883
- while (i < lines.length) {
884
- const line = lines[i];
885
- const mountMatch = line.match(/\bonMount\s*\(\s*\(\s*\)\s*=>\s*\{/);
886
- const destroyMatch = line.match(/\bonDestroy\s*\(\s*\(\s*\)\s*=>\s*\{/);
887
-
888
- if (mountMatch || destroyMatch) {
889
- // Collect body by tracking brace depth
890
- let depth = 0;
891
- let bodyLines = [];
892
- let started = false;
893
-
894
- for (let j = i; j < lines.length; j++) {
895
- const l = lines[j];
896
- for (const ch of l) {
897
- if (ch === '{') {
898
- if (started) depth++;
899
- else { depth = 1; started = true; }
900
- }
901
- if (ch === '}') depth--;
902
- }
903
-
904
- if (j === i) {
905
- // First line: capture everything after the opening brace
906
- const braceIdx = l.indexOf('{');
907
- const afterBrace = l.substring(braceIdx + 1);
908
- // If depth already closed on the first line (single-line hook)
909
- if (depth <= 0) {
910
- // Extract content between first { and last }
911
- const lastBraceIdx = l.lastIndexOf('}');
912
- const inner = l.substring(braceIdx + 1, lastBraceIdx);
913
- if (inner.trim()) bodyLines.push(inner);
914
- i = j;
915
- break;
916
- }
917
- if (afterBrace.trim()) bodyLines.push(afterBrace);
918
- } else if (depth <= 0) {
919
- // Last line: capture everything before the closing brace
920
- const lastBraceIdx = l.lastIndexOf('}');
921
- const before = l.substring(0, lastBraceIdx);
922
- if (before.trim()) bodyLines.push(before);
923
- i = j;
924
- break;
925
- } else {
926
- bodyLines.push(l);
927
- }
928
- }
929
-
930
- // Dedent body lines: remove common leading whitespace
931
- const nonEmptyLines = bodyLines.filter(l => l.trim().length > 0);
932
- let minIndent = Infinity;
933
- for (const bl of nonEmptyLines) {
934
- const leadingSpaces = bl.match(/^(\s*)/)[1].length;
935
- if (leadingSpaces < minIndent) minIndent = leadingSpaces;
936
- }
937
- if (minIndent === Infinity) minIndent = 0;
938
- const dedentedLines = bodyLines.map(bl => bl.substring(minIndent));
939
- const body = dedentedLines.join('\n').trim();
940
-
941
- if (mountMatch) {
942
- onMountHooks.push({ body });
943
- } else {
944
- onDestroyHooks.push({ body });
945
- }
946
- }
947
- i++;
948
- }
949
-
950
- return { onMountHooks, onDestroyHooks };
951
- }
952
-
953
- // ── Ref extraction ───────────────────────────────────────────────────
954
-
955
- /**
956
- * Extract templateRef('name') declarations from component source.
957
- * Pattern: const/let/var varName = templateRef('refName') or templateRef("refName")
958
- *
959
- * @param {string} source — Stripped source code
960
- * @returns {RefDeclaration[]}
961
- */
962
- export function extractRefs(source) {
963
- /** @type {RefDeclaration[]} */
964
- const refs = [];
965
- const re = /(?:const|let|var)\s+([$\w]+)\s*=\s*templateRef\(\s*['"]([^'"]+)['"]\s*\)/g;
966
- let m;
967
- while ((m = re.exec(source)) !== null) {
968
- refs.push({ varName: m[1], refName: m[2] });
969
- }
970
- return refs;
971
- }
972
-
973
78
  // ── Main parse function ─────────────────────────────────────────────
974
79
 
975
80
  /**
@@ -1053,7 +158,7 @@ export async function parse(filePath) {
1053
158
  // 8b. Strip lifecycle hook blocks from source to prevent signal/computed/effect/function
1054
159
  // extractors from misidentifying code inside hook bodies
1055
160
  let sourceForExtraction = source;
1056
- const hookLinePattern = /\bonMount\s*\(|\bonDestroy\s*\(/;
161
+ const hookLinePattern = /\bonMount\s*\(|\bonDestroy\s*\(|\bwatch\s*\(/;
1057
162
  const sourceLines = sourceForExtraction.split('\n');
1058
163
  const filteredLines = [];
1059
164
  let skipDepth = 0;
@@ -1085,6 +190,7 @@ export async function parse(filePath) {
1085
190
  const signals = extractSignals(sourceForExtraction);
1086
191
  const computeds = extractComputeds(sourceForExtraction);
1087
192
  const effects = extractEffects(sourceForExtraction);
193
+ const watchers = extractWatchers(source); // Extract from unfiltered source (like lifecycle hooks)
1088
194
  const methods = extractFunctions(sourceForExtraction);
1089
195
  const refs = extractRefs(sourceForExtraction);
1090
196
  const constantVars = extractConstants(sourceForExtraction);
@@ -1146,6 +252,7 @@ export async function parse(filePath) {
1146
252
  computeds,
1147
253
  effects,
1148
254
  constantVars,
255
+ watchers,
1149
256
  methods,
1150
257
  propDefs,
1151
258
  propsObjectName: propsObjectName ?? null,