@sprlab/wccompiler 0.0.2 → 0.2.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 +268 -51
- package/bin/wcc.js +65 -100
- package/bin/wcc.test.js +119 -0
- package/lib/codegen.js +850 -170
- package/lib/compiler.js +100 -25
- package/lib/config.js +33 -43
- package/lib/css-scoper.js +13 -0
- package/lib/dev-server.js +19 -0
- package/lib/parser.js +1001 -109
- package/lib/printer.js +92 -78
- package/lib/reactive-runtime.js +1 -0
- package/lib/tree-walker.js +682 -43
- package/lib/types.js +192 -0
- package/lib/wcc-runtime.js +26 -0
- package/package.json +14 -9
- package/types/wcc.d.ts +27 -0
- package/types/wcc.test.js +46 -0
package/lib/parser.js
CHANGED
|
@@ -1,74 +1,304 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Parser for .
|
|
2
|
+
* Parser for .ts/.js component source files using defineComponent().
|
|
3
3
|
*
|
|
4
4
|
* Extracts:
|
|
5
|
-
* -
|
|
6
|
-
* -
|
|
7
|
-
* -
|
|
8
|
-
* -
|
|
9
|
-
* -
|
|
10
|
-
* - Function declarations
|
|
5
|
+
* - defineComponent({ tag, template, styles }) metadata
|
|
6
|
+
* - signal() declarations
|
|
7
|
+
* - computed() declarations
|
|
8
|
+
* - effect() declarations
|
|
9
|
+
* - Top-level function declarations
|
|
11
10
|
*
|
|
12
|
-
* Tree walking (bindings, events,
|
|
13
|
-
* here — that's the responsibility of tree-walker.js
|
|
11
|
+
* Tree walking (bindings, events, processedTemplate) is NOT handled
|
|
12
|
+
* here — that's the responsibility of tree-walker.js.
|
|
14
13
|
*/
|
|
15
14
|
|
|
16
|
-
|
|
15
|
+
/** @import { ParseResult, ReactiveVar, ComputedDef, EffectDef, MethodDef, PropDef, LifecycleHook, RefDeclaration } from './types.js' */
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
*/
|
|
22
|
-
function extractBlock(html, blockName) {
|
|
23
|
-
const re = new RegExp(`<${blockName}>([\\s\\S]*?)<\\/${blockName}>`);
|
|
24
|
-
const m = html.match(re);
|
|
25
|
-
return m ? m[1] : null;
|
|
26
|
-
}
|
|
17
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
18
|
+
import { resolve, dirname, extname } from 'node:path';
|
|
19
|
+
import { transform } from 'esbuild';
|
|
27
20
|
|
|
28
|
-
// ──
|
|
21
|
+
// ── Macro import stripping ───────────────────────────────────────────
|
|
29
22
|
|
|
30
23
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
|
33
30
|
*/
|
|
34
|
-
function
|
|
35
|
-
return
|
|
31
|
+
export function stripMacroImport(source) {
|
|
32
|
+
return source.replace(
|
|
33
|
+
/import\s*\{[^}]*\}\s*from\s*['"](?:wcc|@sprlab\/wccompiler)['"]\s*;?/g,
|
|
34
|
+
''
|
|
35
|
+
);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// ── Name conversion ─────────────────────────────────────────────────
|
|
39
|
+
|
|
38
40
|
/**
|
|
39
41
|
* Convert a kebab-case tag name to PascalCase class name.
|
|
40
|
-
* e.g. "
|
|
42
|
+
* e.g. "wcc-counter" → "WccCounter"
|
|
43
|
+
*
|
|
44
|
+
* @param {string} tagName
|
|
45
|
+
* @returns {string}
|
|
41
46
|
*/
|
|
42
|
-
function toClassName(tagName) {
|
|
47
|
+
export function toClassName(tagName) {
|
|
43
48
|
return tagName
|
|
44
49
|
.split('-')
|
|
45
50
|
.map(part => part.charAt(0).toUpperCase() + part.slice(1))
|
|
46
51
|
.join('');
|
|
47
52
|
}
|
|
48
53
|
|
|
49
|
-
// ──
|
|
54
|
+
// ── Type stripping ──────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Strip TypeScript type annotations using esbuild, producing plain JavaScript.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} tsCode - TypeScript source code
|
|
60
|
+
* @returns {Promise<string>} - JavaScript without type annotations
|
|
61
|
+
*/
|
|
62
|
+
export async function stripTypes(tsCode) {
|
|
63
|
+
try {
|
|
64
|
+
const result = await transform(tsCode, {
|
|
65
|
+
loader: 'ts',
|
|
66
|
+
target: 'esnext',
|
|
67
|
+
sourcemap: false,
|
|
68
|
+
});
|
|
69
|
+
return result.code;
|
|
70
|
+
} catch (err) {
|
|
71
|
+
const error = new Error(`TypeScript syntax error: ${err.message}`);
|
|
72
|
+
/** @ts-expect-error — custom error code for programmatic handling */
|
|
73
|
+
error.code = 'TS_SYNTAX_ERROR';
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
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) ────────────────
|
|
50
118
|
|
|
51
119
|
/**
|
|
52
|
-
* Extract
|
|
53
|
-
*
|
|
54
|
-
*
|
|
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[]}
|
|
55
127
|
*/
|
|
56
|
-
function
|
|
57
|
-
const m =
|
|
128
|
+
function extractPropsArray(source) {
|
|
129
|
+
const m = source.match(/defineProps\(\s*\[([^\]]*)\]\s*\)/);
|
|
58
130
|
if (!m) return [];
|
|
59
131
|
|
|
60
|
-
const
|
|
132
|
+
const body = m[1];
|
|
61
133
|
const props = [];
|
|
62
|
-
const re = /'([^']+)'/g;
|
|
134
|
+
const re = /['"]([^'"]+)['"]/g;
|
|
63
135
|
let match;
|
|
64
|
-
while ((match = re.exec(
|
|
136
|
+
while ((match = re.exec(body)) !== null) {
|
|
65
137
|
props.push(match[1]);
|
|
66
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
|
+
}
|
|
67
267
|
|
|
68
|
-
|
|
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) {
|
|
69
299
|
const seen = new Set();
|
|
70
300
|
const duplicates = new Set();
|
|
71
|
-
for (const p of
|
|
301
|
+
for (const p of propNames) {
|
|
72
302
|
if (seen.has(p)) duplicates.add(p);
|
|
73
303
|
seen.add(p);
|
|
74
304
|
}
|
|
@@ -77,93 +307,508 @@ function extractProps(script, fileName) {
|
|
|
77
307
|
const error = new Error(
|
|
78
308
|
`Error en '${fileName}': props duplicados: ${names}`
|
|
79
309
|
);
|
|
310
|
+
/** @ts-expect-error — custom error code for programmatic handling */
|
|
80
311
|
error.code = 'DUPLICATE_PROPS';
|
|
81
312
|
throw error;
|
|
82
313
|
}
|
|
314
|
+
}
|
|
83
315
|
|
|
84
|
-
|
|
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;
|
|
85
412
|
}
|
|
86
413
|
|
|
87
|
-
// ──
|
|
414
|
+
// ── Emits validation ────────────────────────────────────────────────
|
|
88
415
|
|
|
89
416
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
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
|
|
93
422
|
*/
|
|
94
|
-
function
|
|
95
|
-
|
|
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 = [];
|
|
96
645
|
let depth = 0;
|
|
97
646
|
|
|
98
|
-
for (const line of
|
|
99
|
-
// Track brace depth
|
|
647
|
+
for (const line of source.split('\n')) {
|
|
648
|
+
// Track brace depth to skip nested blocks
|
|
100
649
|
for (const ch of line) {
|
|
101
650
|
if (ch === '{') depth++;
|
|
102
651
|
if (ch === '}') depth--;
|
|
103
652
|
}
|
|
653
|
+
if (depth > 0) continue;
|
|
104
654
|
|
|
105
|
-
//
|
|
106
|
-
|
|
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;
|
|
107
658
|
|
|
108
|
-
|
|
109
|
-
|
|
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;
|
|
110
664
|
|
|
111
|
-
|
|
112
|
-
if (m) {
|
|
113
|
-
vars.push({ name: m[1], value: m[2] });
|
|
114
|
-
}
|
|
665
|
+
constants.push({ name: m[1], value });
|
|
115
666
|
}
|
|
116
667
|
|
|
117
|
-
return
|
|
668
|
+
return constants;
|
|
118
669
|
}
|
|
119
670
|
|
|
120
|
-
// ── Computed
|
|
671
|
+
// ── Computed extraction ─────────────────────────────────────────────
|
|
121
672
|
|
|
122
673
|
/**
|
|
123
|
-
* Extract computed
|
|
124
|
-
* Pattern: const name = computed(() => expr)
|
|
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[]}
|
|
125
681
|
*/
|
|
126
|
-
function extractComputeds(
|
|
682
|
+
export function extractComputeds(source) {
|
|
683
|
+
/** @type {ComputedDef[]} */
|
|
127
684
|
const out = [];
|
|
128
|
-
const re = /(?:const|let|var)\s+(\w+)\s*=\s*computed\(\s*\(\)\s*=>\s
|
|
685
|
+
const re = /(?:const|let|var)\s+(\w+)\s*=\s*computed\(\s*\(\)\s*=>\s*/g;
|
|
129
686
|
let m;
|
|
130
|
-
while ((m = re.exec(
|
|
131
|
-
|
|
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
|
+
}
|
|
132
722
|
}
|
|
133
723
|
return out;
|
|
134
724
|
}
|
|
135
725
|
|
|
136
|
-
// ──
|
|
726
|
+
// ── Effect extraction ───────────────────────────────────────────────
|
|
137
727
|
|
|
138
728
|
/**
|
|
139
|
-
* Extract
|
|
140
|
-
* Pattern:
|
|
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[]}
|
|
141
735
|
*/
|
|
142
|
-
function
|
|
143
|
-
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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++;
|
|
153
793
|
}
|
|
154
|
-
|
|
794
|
+
|
|
795
|
+
return effects;
|
|
155
796
|
}
|
|
156
797
|
|
|
157
|
-
// ── Function
|
|
798
|
+
// ── Function extraction ─────────────────────────────────────────────
|
|
158
799
|
|
|
159
800
|
/**
|
|
160
|
-
* Extract top-level function declarations.
|
|
801
|
+
* Extract top-level function declarations from source.
|
|
161
802
|
* Pattern: function name(params) { body }
|
|
162
|
-
* Uses brace tracking to capture the full function body.
|
|
803
|
+
* Uses brace depth tracking to capture the full function body.
|
|
804
|
+
*
|
|
805
|
+
* @param {string} source
|
|
806
|
+
* @returns {MethodDef[]}
|
|
163
807
|
*/
|
|
164
|
-
function extractFunctions(
|
|
808
|
+
export function extractFunctions(source) {
|
|
809
|
+
/** @type {MethodDef[]} */
|
|
165
810
|
const functions = [];
|
|
166
|
-
const lines =
|
|
811
|
+
const lines = source.split('\n');
|
|
167
812
|
let i = 0;
|
|
168
813
|
|
|
169
814
|
while (i < lines.length) {
|
|
@@ -215,55 +860,302 @@ function extractFunctions(script) {
|
|
|
215
860
|
return functions;
|
|
216
861
|
}
|
|
217
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
|
+
|
|
218
973
|
// ── Main parse function ─────────────────────────────────────────────
|
|
219
974
|
|
|
220
975
|
/**
|
|
221
|
-
* Parse
|
|
976
|
+
* Parse a .ts/.js component source file into a ParseResult IR.
|
|
222
977
|
*
|
|
223
|
-
* @param {string}
|
|
224
|
-
* @
|
|
225
|
-
* @
|
|
978
|
+
* @param {string} filePath — Absolute path to the source file
|
|
979
|
+
* @returns {Promise<ParseResult>}
|
|
980
|
+
* @throws {Error} with code MISSING_DEFINE_COMPONENT, TEMPLATE_NOT_FOUND, STYLES_NOT_FOUND, TS_SYNTAX_ERROR
|
|
226
981
|
*/
|
|
227
|
-
export function parse(
|
|
228
|
-
|
|
982
|
+
export async function parse(filePath) {
|
|
983
|
+
// 1. Read the source file
|
|
984
|
+
const rawSource = readFileSync(filePath, 'utf-8');
|
|
985
|
+
|
|
986
|
+
// 2. Strip macro imports
|
|
987
|
+
let source = stripMacroImport(rawSource);
|
|
988
|
+
|
|
989
|
+
// 3. Extract props from generic form BEFORE type stripping (esbuild removes generics)
|
|
990
|
+
const propsFromGeneric = extractPropsGeneric(source);
|
|
991
|
+
const propsObjectNameFromGeneric = extractPropsObjectName(source);
|
|
992
|
+
|
|
993
|
+
// 3b. Extract emits from call signatures form BEFORE type stripping
|
|
994
|
+
const emitsFromCallSignatures = extractEmitsFromCallSignatures(source);
|
|
995
|
+
const emitsObjectNameFromGeneric = extractEmitsObjectNameFromGeneric(source);
|
|
996
|
+
|
|
997
|
+
// 4. Validate props assignment (before type strip, on original source)
|
|
998
|
+
validatePropsAssignment(source, filePath);
|
|
999
|
+
|
|
1000
|
+
// 4b. Validate emits assignment (before type strip, on original source)
|
|
1001
|
+
validateEmitsAssignment(source, filePath);
|
|
1002
|
+
|
|
1003
|
+
// 5. Strip TypeScript types if .ts file
|
|
1004
|
+
const ext = extname(filePath);
|
|
1005
|
+
if (ext === '.ts') {
|
|
1006
|
+
source = await stripTypes(source);
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// 6. Extract defineComponent
|
|
1010
|
+
const componentDef = extractDefineComponent(source);
|
|
1011
|
+
if (!componentDef) {
|
|
1012
|
+
const error = new Error(
|
|
1013
|
+
`Error en '${filePath}': defineComponent() es obligatorio`
|
|
1014
|
+
);
|
|
1015
|
+
/** @ts-expect-error — custom error code for programmatic handling */
|
|
1016
|
+
error.code = 'MISSING_DEFINE_COMPONENT';
|
|
1017
|
+
throw error;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
const { tag: tagName, template: templatePath, styles: stylesPath } = componentDef;
|
|
229
1021
|
const className = toClassName(tagName);
|
|
1022
|
+
const sourceDir = dirname(filePath);
|
|
230
1023
|
|
|
231
|
-
//
|
|
232
|
-
const
|
|
233
|
-
if (
|
|
1024
|
+
// 7. Resolve external files
|
|
1025
|
+
const resolvedTemplatePath = resolve(sourceDir, templatePath);
|
|
1026
|
+
if (!existsSync(resolvedTemplatePath)) {
|
|
234
1027
|
const error = new Error(
|
|
235
|
-
`Error en '${
|
|
1028
|
+
`Error en '${filePath}': template no encontrado: '${templatePath}'`
|
|
236
1029
|
);
|
|
237
|
-
error
|
|
1030
|
+
/** @ts-expect-error — custom error code for programmatic handling */
|
|
1031
|
+
error.code = 'TEMPLATE_NOT_FOUND';
|
|
238
1032
|
throw error;
|
|
239
1033
|
}
|
|
1034
|
+
const template = readFileSync(resolvedTemplatePath, 'utf-8');
|
|
1035
|
+
|
|
1036
|
+
let style = '';
|
|
1037
|
+
if (stylesPath) {
|
|
1038
|
+
const resolvedStylesPath = resolve(sourceDir, stylesPath);
|
|
1039
|
+
if (!existsSync(resolvedStylesPath)) {
|
|
1040
|
+
const error = new Error(
|
|
1041
|
+
`Error en '${filePath}': styles no encontrado: '${stylesPath}'`
|
|
1042
|
+
);
|
|
1043
|
+
/** @ts-expect-error — custom error code for programmatic handling */
|
|
1044
|
+
error.code = 'STYLES_NOT_FOUND';
|
|
1045
|
+
throw error;
|
|
1046
|
+
}
|
|
1047
|
+
style = readFileSync(resolvedStylesPath, 'utf-8');
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// 8. Extract lifecycle hooks (before other extractions to avoid misidentification)
|
|
1051
|
+
const { onMountHooks, onDestroyHooks } = extractLifecycleHooks(source);
|
|
1052
|
+
|
|
1053
|
+
// 8b. Strip lifecycle hook blocks from source to prevent signal/computed/effect/function
|
|
1054
|
+
// extractors from misidentifying code inside hook bodies
|
|
1055
|
+
let sourceForExtraction = source;
|
|
1056
|
+
const hookLinePattern = /\bonMount\s*\(|\bonDestroy\s*\(/;
|
|
1057
|
+
const sourceLines = sourceForExtraction.split('\n');
|
|
1058
|
+
const filteredLines = [];
|
|
1059
|
+
let skipDepth = 0;
|
|
1060
|
+
let skipping = false;
|
|
1061
|
+
for (const line of sourceLines) {
|
|
1062
|
+
if (!skipping && hookLinePattern.test(line)) {
|
|
1063
|
+
skipping = true;
|
|
1064
|
+
skipDepth = 0;
|
|
1065
|
+
for (const ch of line) {
|
|
1066
|
+
if (ch === '{') skipDepth++;
|
|
1067
|
+
if (ch === '}') skipDepth--;
|
|
1068
|
+
}
|
|
1069
|
+
if (skipDepth <= 0) skipping = false;
|
|
1070
|
+
continue;
|
|
1071
|
+
}
|
|
1072
|
+
if (skipping) {
|
|
1073
|
+
for (const ch of line) {
|
|
1074
|
+
if (ch === '{') skipDepth++;
|
|
1075
|
+
if (ch === '}') skipDepth--;
|
|
1076
|
+
}
|
|
1077
|
+
if (skipDepth <= 0) skipping = false;
|
|
1078
|
+
continue;
|
|
1079
|
+
}
|
|
1080
|
+
filteredLines.push(line);
|
|
1081
|
+
}
|
|
1082
|
+
sourceForExtraction = filteredLines.join('\n');
|
|
1083
|
+
|
|
1084
|
+
// 9. Extract reactive declarations and functions (from filtered source)
|
|
1085
|
+
const signals = extractSignals(sourceForExtraction);
|
|
1086
|
+
const computeds = extractComputeds(sourceForExtraction);
|
|
1087
|
+
const effects = extractEffects(sourceForExtraction);
|
|
1088
|
+
const methods = extractFunctions(sourceForExtraction);
|
|
1089
|
+
const refs = extractRefs(sourceForExtraction);
|
|
1090
|
+
const constantVars = extractConstants(sourceForExtraction);
|
|
1091
|
+
|
|
1092
|
+
// 9. Extract props (array form — after type strip, if generic didn't find any)
|
|
1093
|
+
const propsFromArray = propsFromGeneric.length > 0 ? [] : extractPropsArray(source);
|
|
1094
|
+
let propNames = propsFromGeneric.length > 0 ? propsFromGeneric : propsFromArray;
|
|
1095
|
+
|
|
1096
|
+
// 10. Extract props defaults (after type strip)
|
|
1097
|
+
const propsDefaults = extractPropsDefaults(source);
|
|
1098
|
+
|
|
1099
|
+
// If neither generic nor array form found props, but defaults were found,
|
|
1100
|
+
// use the defaults object keys as prop names (object-only form: defineProps({ key: val }))
|
|
1101
|
+
if (propNames.length === 0 && Object.keys(propsDefaults).length > 0) {
|
|
1102
|
+
propNames = Object.keys(propsDefaults);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// 11. Extract propsObjectName (use generic result if found, otherwise post-strip)
|
|
1106
|
+
const propsObjectName = propsObjectNameFromGeneric ?? extractPropsObjectName(source);
|
|
1107
|
+
|
|
1108
|
+
// 12. Validate props
|
|
1109
|
+
validateDuplicateProps(propNames, filePath);
|
|
1110
|
+
|
|
1111
|
+
const signalNameSet = new Set(signals.map(s => s.name));
|
|
1112
|
+
const computedNameSet = new Set(computeds.map(c => c.name));
|
|
1113
|
+
// No constant extraction in v2 core, but use an empty set for validation
|
|
1114
|
+
const constantNameSet = new Set(constantVars.map(v => v.name));
|
|
1115
|
+
validatePropsConflicts(propsObjectName, signalNameSet, computedNameSet, constantNameSet, filePath);
|
|
1116
|
+
|
|
1117
|
+
// 13. Build PropDef[]
|
|
1118
|
+
/** @type {PropDef[]} */
|
|
1119
|
+
const propDefs = propNames.map(name => ({
|
|
1120
|
+
name,
|
|
1121
|
+
default: propsDefaults[name] ?? 'undefined',
|
|
1122
|
+
attrName: camelToKebab(name),
|
|
1123
|
+
}));
|
|
1124
|
+
|
|
1125
|
+
// 14. Extract emits (array form — after type strip, if call signatures didn't find any)
|
|
1126
|
+
const emitsFromArray = emitsFromCallSignatures.length > 0 ? [] : extractEmits(source);
|
|
1127
|
+
const emitNames = emitsFromCallSignatures.length > 0 ? emitsFromCallSignatures : emitsFromArray;
|
|
1128
|
+
|
|
1129
|
+
// 15. Extract emitsObjectName (use generic result if found, otherwise post-strip)
|
|
1130
|
+
const emitsObjectName = emitsObjectNameFromGeneric ?? extractEmitsObjectName(source);
|
|
240
1131
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
const trimmedScript = script.trim();
|
|
1132
|
+
// 16. Validate emits
|
|
1133
|
+
validateDuplicateEmits(emitNames, filePath);
|
|
244
1134
|
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
const computeds = extractComputeds(trimmedScript);
|
|
249
|
-
const watchers = extractWatchers(trimmedScript);
|
|
250
|
-
const methods = extractFunctions(trimmedScript);
|
|
1135
|
+
const propNameSet = new Set(propNames);
|
|
1136
|
+
validateEmitsConflicts(emitsObjectName, signalNameSet, computedNameSet, constantNameSet, propNameSet, propsObjectName, filePath);
|
|
1137
|
+
validateUndeclaredEmits(source, emitsObjectName, emitNames, filePath);
|
|
251
1138
|
|
|
1139
|
+
// 17. Return ParseResult
|
|
252
1140
|
return {
|
|
253
1141
|
tagName,
|
|
254
1142
|
className,
|
|
255
1143
|
template,
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
props,
|
|
259
|
-
reactiveVars,
|
|
1144
|
+
style,
|
|
1145
|
+
signals,
|
|
260
1146
|
computeds,
|
|
261
|
-
|
|
1147
|
+
effects,
|
|
1148
|
+
constantVars,
|
|
262
1149
|
methods,
|
|
263
|
-
|
|
1150
|
+
propDefs,
|
|
1151
|
+
propsObjectName: propsObjectName ?? null,
|
|
1152
|
+
emits: emitNames,
|
|
1153
|
+
emitsObjectName: emitsObjectName ?? null,
|
|
264
1154
|
bindings: [],
|
|
265
1155
|
events: [],
|
|
266
|
-
slots: [],
|
|
267
1156
|
processedTemplate: null,
|
|
1157
|
+
onMountHooks,
|
|
1158
|
+
onDestroyHooks,
|
|
1159
|
+
refs,
|
|
268
1160
|
};
|
|
269
1161
|
}
|