@sprlab/wccompiler 0.3.0 → 0.4.1

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,241 @@
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
+ extractExpose,
36
+ validatePropsAssignment,
37
+ validateDuplicateProps,
38
+ validatePropsConflicts,
39
+ validateEmitsAssignment,
40
+ validateDuplicateEmits,
41
+ validateEmitsConflicts,
42
+ validateUndeclaredEmits,
43
+ } from './parser-extractors.js';
44
+ import { stripTypes } from './parser.js';
45
+
16
46
  /**
17
- * Resolve a child component's import path by searching for a source file
18
- * whose defineComponent({ tag }) matches the given tag name.
47
+ * Resolve a child component's source file path by tag name.
48
+ *
49
+ * Searches for a file named after the tag in the source directory,
50
+ * trying extensions in priority order: .wcc, .js, .ts
19
51
  *
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
52
+ * @param {string} tag — The custom element tag name (e.g., 'wcc-child')
53
+ * @param {string} sourceDir — Directory of the parent component
54
+ * @param {object} [config] — Optional config (reserved for future use)
55
+ * @returns {string | null} Relative import path (e.g., './wcc-child.js') or null if not found
24
56
  */
25
57
  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
58
+ const extensions = ['.wcc', '.js', '.ts'];
59
+ for (const ext of extensions) {
60
+ const candidate = resolve(sourceDir, `${tag}${ext}`);
61
+ if (existsSync(candidate)) {
62
+ // Return as a relative .js import path (compiled output)
63
+ return `./${tag}.js`;
66
64
  }
67
65
  }
68
-
69
66
  return null;
70
67
  }
71
68
 
72
69
  /**
73
- * Compile a single .ts/.js source file into a self-contained JS component.
70
+ * Compile a single .wcc SFC file into a self-contained JS component.
71
+ *
72
+ * Reads the file, parses the SFC blocks, extracts reactive declarations
73
+ * from the script block using parser-extractors.js, and processes template
74
+ * and style through the existing pipeline (jsdom → tree-walker → codegen).
74
75
  *
75
- * @param {string} filePath — Absolute or relative path to the source file
76
+ * @param {string} filePath — Absolute or relative path to the .wcc file
76
77
  * @param {object} [config] — Optional config (reserved for future options)
77
78
  * @returns {Promise<string>} The generated JavaScript component code
78
79
  */
79
- export async function compile(filePath, config) {
80
- // 1. Parse the source file
81
- const parseResult = await parse(filePath);
80
+ async function compileSFC(filePath, config) {
81
+ // 1. Read and parse the SFC file
82
+ const rawSource = readFileSync(filePath, 'utf-8');
83
+ const fileName = basename(filePath);
84
+ const descriptor = parseSFC(rawSource, fileName);
82
85
 
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');
86
+ // 2. Process script block mirrors parser.js logic
87
+ let source = stripMacroImport(descriptor.script);
88
+
89
+ // 3. Extract props/emits from generic forms BEFORE type stripping
90
+ const propsFromGeneric = extractPropsGeneric(source);
91
+ const propsObjectNameFromGeneric = extractPropsObjectName(source);
92
+ const emitsFromCallSignatures = extractEmitsFromCallSignatures(source);
93
+ const emitsObjectNameFromGeneric = extractEmitsObjectNameFromGeneric(source);
86
94
 
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));
95
+ // 4. Validate props/emits assignment (before type strip)
96
+ validatePropsAssignment(source, filePath);
97
+ validateEmitsAssignment(source, filePath);
91
98
 
92
- // 4. Process each blocks BEFORE if chains (replaces each elements with comment anchors)
93
- const forBlocks = processForBlocks(rootEl, [], signalNames, computedNames, propNames);
99
+ // 5. Strip TypeScript types if lang === 'ts'
100
+ if (descriptor.lang === 'ts') {
101
+ source = await stripTypes(source);
102
+ }
103
+
104
+ // 6. Extract component metadata
105
+ const tagName = descriptor.tag;
106
+ const className = toClassName(tagName);
107
+ const template = descriptor.template;
108
+ const style = descriptor.style;
94
109
 
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);
110
+ // 7. Extract lifecycle hooks (before other extractions)
111
+ const { onMountHooks, onDestroyHooks } = extractLifecycleHooks(source);
112
+
113
+ // 7b. Strip lifecycle/watcher blocks from source for extraction
114
+ let sourceForExtraction = source;
115
+ const hookLinePattern = /\bonMount\s*\(|\bonDestroy\s*\(|\bwatch\s*\(/;
116
+ const sourceLines = sourceForExtraction.split('\n');
117
+ const filteredLines = [];
118
+ let skipDepth = 0;
119
+ let skipping = false;
120
+ for (const line of sourceLines) {
121
+ if (!skipping && hookLinePattern.test(line)) {
122
+ skipping = true;
123
+ skipDepth = 0;
124
+ for (const ch of line) {
125
+ if (ch === '{') skipDepth++;
126
+ if (ch === '}') skipDepth--;
127
+ }
128
+ if (skipDepth <= 0) skipping = false;
129
+ continue;
130
+ }
131
+ if (skipping) {
132
+ for (const ch of line) {
133
+ if (ch === '{') skipDepth++;
134
+ if (ch === '}') skipDepth--;
135
+ }
136
+ if (skipDepth <= 0) skipping = false;
137
+ continue;
138
+ }
139
+ filteredLines.push(line);
140
+ }
141
+ sourceForExtraction = filteredLines.join('\n');
142
+
143
+ // 8. Extract reactive declarations and functions
144
+ const signals = extractSignals(sourceForExtraction);
145
+ const computeds = extractComputeds(sourceForExtraction);
146
+ const effects = extractEffects(sourceForExtraction);
147
+ const watchers = extractWatchers(source);
148
+ const methods = extractFunctions(sourceForExtraction);
149
+ const refs = extractRefs(sourceForExtraction);
150
+ const constantVars = extractConstants(sourceForExtraction);
151
+ const exposeNames = extractExpose(source);
152
+
153
+ // 9. Extract props (array form — after type strip, if generic didn't find any)
154
+ const propsFromArray = propsFromGeneric.length > 0 ? [] : extractPropsArray(source);
155
+ let propNames = propsFromGeneric.length > 0 ? propsFromGeneric : propsFromArray;
156
+
157
+ // 10. Extract props defaults
158
+ const propsDefaults = extractPropsDefaults(source);
159
+ if (propNames.length === 0 && Object.keys(propsDefaults).length > 0) {
160
+ propNames = Object.keys(propsDefaults);
161
+ }
162
+
163
+ // 11. Extract propsObjectName
164
+ const propsObjectName = propsObjectNameFromGeneric ?? extractPropsObjectName(source);
165
+
166
+ // 12. Validate props
167
+ validateDuplicateProps(propNames, filePath);
168
+ const signalNameSet = new Set(signals.map(s => s.name));
169
+ const computedNameSet = new Set(computeds.map(c => c.name));
170
+ const constantNameSet = new Set(constantVars.map(v => v.name));
171
+ validatePropsConflicts(propsObjectName, signalNameSet, computedNameSet, constantNameSet, filePath);
172
+
173
+ /** @type {import('./types.js').PropDef[]} */
174
+ const propDefs = propNames.map(name => ({
175
+ name,
176
+ default: propsDefaults[name] ?? 'undefined',
177
+ attrName: camelToKebab(name),
178
+ }));
179
+
180
+ // 13. Extract emits
181
+ const emitsFromArray = emitsFromCallSignatures.length > 0 ? [] : extractEmits(source);
182
+ const emitNames = emitsFromCallSignatures.length > 0 ? emitsFromCallSignatures : emitsFromArray;
183
+ const emitsObjectName = emitsObjectNameFromGeneric ?? extractEmitsObjectName(source);
184
+
185
+ // 14. Validate emits
186
+ validateDuplicateEmits(emitNames, filePath);
187
+ const propNameSet = new Set(propNames);
188
+ validateEmitsConflicts(emitsObjectName, signalNameSet, computedNameSet, constantNameSet, propNameSet, propsObjectName, filePath);
189
+ validateUndeclaredEmits(source, emitsObjectName, emitNames, filePath);
190
+
191
+ // 15. Build initial ParseResult
192
+ /** @type {import('./types.js').ParseResult} */
193
+ const parseResult = {
194
+ tagName,
195
+ className,
196
+ template,
197
+ style,
198
+ signals,
199
+ computeds,
200
+ effects,
201
+ constantVars,
202
+ watchers,
203
+ methods,
204
+ propDefs,
205
+ propsObjectName: propsObjectName ?? null,
206
+ emits: emitNames,
207
+ emitsObjectName: emitsObjectName ?? null,
208
+ bindings: [],
209
+ events: [],
210
+ processedTemplate: null,
211
+ ifBlocks: [],
212
+ showBindings: [],
213
+ forBlocks: [],
214
+ onMountHooks,
215
+ onDestroyHooks,
216
+ modelBindings: [],
217
+ attrBindings: [],
218
+ slots: [],
219
+ refs,
220
+ refBindings: [],
221
+ childComponents: [],
222
+ childImports: [],
223
+ exposeNames,
224
+ };
225
+
226
+ // 16. Process template through jsdom → tree-walker → codegen (same as compile())
227
+ const dom = new JSDOM(`<div id="__root">${template}</div>`);
228
+ const rootEl = dom.window.document.getElementById('__root');
229
+
230
+ const signalNames = new Set(signals.map(s => s.name));
231
+ const computedNames = new Set(computeds.map(c => c.name));
232
+ const propNamesSet = new Set(propDefs.map(p => p.name));
233
+
234
+ const forBlocks = processForBlocks(rootEl, [], signalNames, computedNames, propNamesSet);
235
+ const ifBlocks = processIfChains(rootEl, [], signalNames, computedNames, propNamesSet);
99
236
 
100
- // 6. Normalize DOM after all directive processing to merge adjacent text nodes
101
237
  rootEl.normalize();
102
238
 
103
- // 7. Recompute anchor paths after normalization since text node merging
104
- // may have changed childNode indices
105
239
  for (const fb of forBlocks) {
106
240
  fb.anchorPath = recomputeAnchorPath(rootEl, fb._anchorNode);
107
241
  }
@@ -109,16 +243,11 @@ export async function compile(filePath, config) {
109
243
  ib.anchorPath = recomputeAnchorPath(rootEl, ib._anchorNode);
110
244
  }
111
245
 
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);
246
+ const { bindings, events, showBindings, modelBindings, attrBindings, slots, childComponents } = walkTree(rootEl, signalNames, computedNames, propNamesSet);
114
247
 
115
- // 9. Detect refs (after walkTree — ref attributes are compile-time directives)
116
248
  const refBindings = detectRefs(rootEl);
117
249
 
118
- // 10. Validate refs
119
- const refs = parseResult.refs || [];
120
-
121
- // REF_NOT_FOUND: templateRef('name') with no matching ref="name" in template
250
+ // 17. Validate refs
122
251
  for (const decl of refs) {
123
252
  if (!refBindings.find(b => b.refName === decl.refName)) {
124
253
  const error = new Error(`templateRef('${decl.refName}') has no matching ref="${decl.refName}" in template`);
@@ -127,18 +256,16 @@ export async function compile(filePath, config) {
127
256
  throw error;
128
257
  }
129
258
  }
130
-
131
- // Unused ref warning: ref="name" in template with no matching templateRef('name') in script
132
259
  for (const binding of refBindings) {
133
260
  if (!refs.find(d => d.refName === binding.refName)) {
134
261
  console.warn(`Warning: ref="${binding.refName}" in template has no matching templateRef('${binding.refName}') in script`);
135
262
  }
136
263
  }
137
264
 
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));
265
+ // 17b. Validate model bindings
266
+ const constantNamesForModel = new Set(constantVars.map(v => v.name));
140
267
  for (const mb of modelBindings) {
141
- if (propNames.has(mb.signal)) {
268
+ if (propNamesSet.has(mb.signal)) {
142
269
  const error = new Error(`model cannot bind to prop '${mb.signal}' (read-only)`);
143
270
  /** @ts-expect-error — custom error code */
144
271
  error.code = 'MODEL_READONLY';
@@ -150,7 +277,7 @@ export async function compile(filePath, config) {
150
277
  error.code = 'MODEL_READONLY';
151
278
  throw error;
152
279
  }
153
- if (constantNames.has(mb.signal)) {
280
+ if (constantNamesForModel.has(mb.signal)) {
154
281
  const error = new Error(`model cannot bind to constant '${mb.signal}' (read-only)`);
155
282
  /** @ts-expect-error — custom error code */
156
283
  error.code = 'MODEL_READONLY';
@@ -164,7 +291,7 @@ export async function compile(filePath, config) {
164
291
  }
165
292
  }
166
293
 
167
- // 11. Resolve child component imports
294
+ // 18. Resolve child component imports
168
295
  /** @type {import('./types.js').ChildComponentImport[]} */
169
296
  const childImports = [];
170
297
  if (childComponents.length > 0) {
@@ -181,7 +308,7 @@ export async function compile(filePath, config) {
181
308
  }
182
309
  }
183
310
 
184
- // 12. Merge results into ParseResult
311
+ // 19. Merge tree-walker results into ParseResult
185
312
  parseResult.bindings = bindings;
186
313
  parseResult.events = events;
187
314
  parseResult.showBindings = showBindings;
@@ -193,9 +320,19 @@ export async function compile(filePath, config) {
193
320
  parseResult.refBindings = refBindings;
194
321
  parseResult.childComponents = childComponents;
195
322
  parseResult.childImports = childImports;
196
- // Recompute processedTemplate after all directive replacements (including ref removal)
197
323
  parseResult.processedTemplate = rootEl.innerHTML;
198
324
 
199
- // 12. Generate component
325
+ // 20. Generate component
200
326
  return generateComponent(parseResult);
201
327
  }
328
+
329
+ /**
330
+ * Compile a single .wcc SFC file into a self-contained JS component.
331
+ *
332
+ * @param {string} filePath — Absolute or relative path to the .wcc file
333
+ * @param {object} [config] — Optional config (reserved for future options)
334
+ * @returns {Promise<string>} The generated JavaScript component code
335
+ */
336
+ export async function compile(filePath, config) {
337
+ return compileSFC(filePath, config);
338
+ }
package/lib/dev-server.js CHANGED
@@ -36,8 +36,25 @@ const MIME_TYPES = {
36
36
  const SSE_SNIPPET = `<script>
37
37
  (function() {
38
38
  var es = new EventSource('/__sse');
39
+ var overlay = null;
40
+ function showError(msg) {
41
+ hideError();
42
+ overlay = document.createElement('div');
43
+ overlay.id = '__wcc_error_overlay';
44
+ overlay.style.cssText = 'position:fixed;inset:0;z-index:99999;background:rgba(0,0,0,0.85);color:#fff;font-family:monospace;font-size:14px;padding:32px;overflow:auto;display:flex;align-items:flex-start;justify-content:center;';
45
+ var box = document.createElement('div');
46
+ box.style.cssText = 'background:#1e1e1e;border:2px solid #f44;border-radius:8px;padding:24px;max-width:700px;width:100%;white-space:pre-wrap;word-break:break-word;';
47
+ box.innerHTML = '<div style="color:#f44;font-size:16px;font-weight:bold;margin-bottom:12px;">\\u274C Compilation Error</div>' + msg.replace(/</g,'&lt;').replace(/>/g,'&gt;');
48
+ overlay.appendChild(box);
49
+ overlay.addEventListener('click', hideError);
50
+ document.body.appendChild(overlay);
51
+ }
52
+ function hideError() {
53
+ if (overlay) { overlay.remove(); overlay = null; }
54
+ }
39
55
  es.onmessage = function(e) {
40
- if (e.data === 'reload') location.reload();
56
+ if (e.data === 'reload') { hideError(); location.reload(); }
57
+ else if (e.data.startsWith('error:')) { showError(e.data.slice(6).replace(/\\\\n/g,'\\n')); }
41
58
  };
42
59
  es.onerror = function() {
43
60
  es.close();
@@ -70,6 +87,17 @@ export function startDevServer({ port, root, outputDir }) {
70
87
  }
71
88
  }
72
89
 
90
+ /** Send an error event to all connected SSE clients */
91
+ function notifyError(message) {
92
+ for (const res of sseClients) {
93
+ try {
94
+ res.write(`data: error:${message.replace(/\n/g, '\\n')}\n\n`);
95
+ } catch {
96
+ sseClients.delete(res);
97
+ }
98
+ }
99
+ }
100
+
73
101
  const server = createServer((req, res) => {
74
102
  const url = req.url.split('?')[0];
75
103
 
@@ -151,6 +179,7 @@ export function startDevServer({ port, root, outputDir }) {
151
179
 
152
180
  return {
153
181
  server,
182
+ notifyError,
154
183
  close() {
155
184
  // Close all SSE connections
156
185
  for (const res of sseClients) {
@@ -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
  }
@@ -1028,3 +1027,26 @@ export function extractRefs(source) {
1028
1027
  }
1029
1028
  return refs;
1030
1029
  }
1030
+
1031
+ // ── defineExpose extraction ─────────────────────────────────────────
1032
+
1033
+ /**
1034
+ * Extract property names from defineExpose({ key1, key2, ... }).
1035
+ * Supports shorthand properties: defineExpose({ doubled, handleUpdate })
1036
+ *
1037
+ * @param {string} source — Source code (after type stripping)
1038
+ * @returns {string[]} Array of exposed property names
1039
+ */
1040
+ export function extractExpose(source) {
1041
+ const m = source.match(/defineExpose\(\s*\{([^}]*)\}\s*\)/);
1042
+ if (!m) return [];
1043
+
1044
+ const body = m[1];
1045
+ const names = [];
1046
+ const re = /\b(\w+)\b/g;
1047
+ let match;
1048
+ while ((match = re.exec(body)) !== null) {
1049
+ names.push(match[1]);
1050
+ }
1051
+ return names;
1052
+ }