@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.
@@ -0,0 +1,262 @@
1
+ /**
2
+ * SFC Parser — extracts <script>, <template> and <style> blocks from .wcc files.
3
+ *
4
+ * Pure ESM module with no Node.js dependencies (usable in browser and server).
5
+ *
6
+ * Two-phase algorithm:
7
+ * Phase 1: Block extraction via regex
8
+ * Phase 2: Validation (required blocks, duplicates, unexpected content, defineComponent)
9
+ */
10
+
11
+ /**
12
+ * @typedef {Object} SFCDescriptor
13
+ * @property {string} script — Content of the <script> block
14
+ * @property {string} template — Content of the <template> block
15
+ * @property {string} style — Content of the <style> block ('' if absent)
16
+ * @property {string} lang — 'ts' | 'js'
17
+ * @property {string} tag — Tag name extracted from defineComponent({ tag })
18
+ */
19
+
20
+ // ── Helpers ─────────────────────────────────────────────────────────
21
+
22
+ /**
23
+ * Create an Error with a `.code` property (project convention).
24
+ * @param {string} code
25
+ * @param {string} message
26
+ * @returns {Error}
27
+ */
28
+ function sfcError(code, message) {
29
+ const error = new Error(message);
30
+ /** @ts-expect-error — custom error code for programmatic handling */
31
+ error.code = code;
32
+ return error;
33
+ }
34
+
35
+ // ── Phase 1: Block extraction ───────────────────────────────────────
36
+
37
+ /**
38
+ * @typedef {Object} BlockMatch
39
+ * @property {string} content — Inner content between open and close tags
40
+ * @property {string} attrs — Attributes string from the opening tag
41
+ * @property {number} start — Start index of the opening tag in source
42
+ * @property {number} end — End index (after closing tag) in source
43
+ */
44
+
45
+ /**
46
+ * Find all occurrences of a given block type in the source.
47
+ *
48
+ * @param {string} source
49
+ * @param {string} blockName — 'script' | 'template' | 'style'
50
+ * @returns {BlockMatch[]}
51
+ */
52
+ function findBlocks(source, blockName) {
53
+ const openRe = new RegExp(`<${blockName}(\\s[^>]*)?>`, 'g');
54
+ const closeTag = `</${blockName}>`;
55
+ /** @type {BlockMatch[]} */
56
+ const matches = [];
57
+ let m;
58
+
59
+ while ((m = openRe.exec(source)) !== null) {
60
+ const openEnd = m.index + m[0].length;
61
+ const closeIdx = source.indexOf(closeTag, openEnd);
62
+ if (closeIdx === -1) continue; // unclosed tag — skip
63
+
64
+ matches.push({
65
+ content: source.slice(openEnd, closeIdx),
66
+ attrs: m[1] || '',
67
+ start: m.index,
68
+ end: closeIdx + closeTag.length,
69
+ });
70
+ }
71
+
72
+ return matches;
73
+ }
74
+
75
+ /**
76
+ * Extract the `lang` attribute value from an attributes string.
77
+ * Returns 'ts' if lang="ts", otherwise 'js'.
78
+ *
79
+ * @param {string} attrs
80
+ * @returns {'ts' | 'js'}
81
+ */
82
+ function extractLang(attrs) {
83
+ const langMatch = attrs.match(/lang\s*=\s*["']([^"']+)["']/);
84
+ return langMatch && langMatch[1] === 'ts' ? 'ts' : 'js';
85
+ }
86
+
87
+ // ── Phase 2: Validation ─────────────────────────────────────────────
88
+
89
+ /**
90
+ * Extract the tag name from a defineComponent({ tag: '...' }) call.
91
+ *
92
+ * @param {string} script
93
+ * @param {string} fileName
94
+ * @returns {string}
95
+ */
96
+ function extractTagFromDefineComponent(script, fileName) {
97
+ const dcMatch = script.match(/defineComponent\(\s*\{([^}]*)\}\s*\)/);
98
+ if (!dcMatch) {
99
+ throw sfcError(
100
+ 'MISSING_DEFINE_COMPONENT',
101
+ `Error en '${fileName}': defineComponent() es obligatorio`
102
+ );
103
+ }
104
+
105
+ const body = dcMatch[1];
106
+
107
+ // Reject template/styles fields inside defineComponent in SFC mode
108
+ if (/\btemplate\s*:/.test(body)) {
109
+ throw sfcError(
110
+ 'SFC_INLINE_PATHS_FORBIDDEN',
111
+ `SFC file '${fileName}': template/styles paths are not allowed in SFC mode (content is inline)`
112
+ );
113
+ }
114
+ if (/\bstyles\s*:/.test(body)) {
115
+ throw sfcError(
116
+ 'SFC_INLINE_PATHS_FORBIDDEN',
117
+ `SFC file '${fileName}': template/styles paths are not allowed in SFC mode (content is inline)`
118
+ );
119
+ }
120
+
121
+ const tagMatch = body.match(/tag\s*:\s*['"]([^'"]+)['"]/);
122
+ if (!tagMatch) {
123
+ throw sfcError(
124
+ 'MISSING_DEFINE_COMPONENT',
125
+ `Error en '${fileName}': defineComponent() must include a tag field`
126
+ );
127
+ }
128
+
129
+ return tagMatch[1];
130
+ }
131
+
132
+ /**
133
+ * Check that no non-whitespace content exists outside the recognized blocks.
134
+ *
135
+ * @param {string} source
136
+ * @param {Array<{start: number, end: number}>} blockRanges — sorted by start
137
+ * @param {string} fileName
138
+ */
139
+ function validateNoUnexpectedContent(source, blockRanges, fileName) {
140
+ let cursor = 0;
141
+
142
+ for (const range of blockRanges) {
143
+ const outside = source.slice(cursor, range.start);
144
+ if (outside.trim().length > 0) {
145
+ throw sfcError(
146
+ 'SFC_UNEXPECTED_CONTENT',
147
+ `SFC file '${fileName}' contains unexpected content outside blocks`
148
+ );
149
+ }
150
+ cursor = range.end;
151
+ }
152
+
153
+ // Check trailing content after last block
154
+ const trailing = source.slice(cursor);
155
+ if (trailing.trim().length > 0) {
156
+ throw sfcError(
157
+ 'SFC_UNEXPECTED_CONTENT',
158
+ `SFC file '${fileName}' contains unexpected content outside blocks`
159
+ );
160
+ }
161
+ }
162
+
163
+ // ── Public API ──────────────────────────────────────────────────────
164
+
165
+ /**
166
+ * Parse an SFC source string and extract its blocks.
167
+ *
168
+ * @param {string} source — Full content of the .wcc file
169
+ * @param {string} [fileName='<unknown>'] — File name for error messages
170
+ * @returns {SFCDescriptor}
171
+ * @throws {Error} with codes: SFC_MISSING_TEMPLATE, SFC_MISSING_SCRIPT,
172
+ * SFC_DUPLICATE_BLOCK, SFC_UNEXPECTED_CONTENT,
173
+ * SFC_INLINE_PATHS_FORBIDDEN, MISSING_DEFINE_COMPONENT
174
+ */
175
+ export function parseSFC(source, fileName = '<unknown>') {
176
+ // ── Phase 1: Extract blocks ─────────────────────────────────────
177
+
178
+ const scriptBlocks = findBlocks(source, 'script');
179
+ const templateBlocks = findBlocks(source, 'template');
180
+ const styleBlocks = findBlocks(source, 'style');
181
+
182
+ // Check for duplicates
183
+ if (scriptBlocks.length > 1) {
184
+ throw sfcError(
185
+ 'SFC_DUPLICATE_BLOCK',
186
+ `SFC file '${fileName}' contains duplicate <script> blocks`
187
+ );
188
+ }
189
+ if (templateBlocks.length > 1) {
190
+ throw sfcError(
191
+ 'SFC_DUPLICATE_BLOCK',
192
+ `SFC file '${fileName}' contains duplicate <template> blocks`
193
+ );
194
+ }
195
+ if (styleBlocks.length > 1) {
196
+ throw sfcError(
197
+ 'SFC_DUPLICATE_BLOCK',
198
+ `SFC file '${fileName}' contains duplicate <style> blocks`
199
+ );
200
+ }
201
+
202
+ // ── Phase 2: Validation ─────────────────────────────────────────
203
+
204
+ // Required blocks
205
+ if (templateBlocks.length === 0) {
206
+ throw sfcError(
207
+ 'SFC_MISSING_TEMPLATE',
208
+ `SFC file '${fileName}' is missing a <template> block`
209
+ );
210
+ }
211
+ if (scriptBlocks.length === 0) {
212
+ throw sfcError(
213
+ 'SFC_MISSING_SCRIPT',
214
+ `SFC file '${fileName}' is missing a <script> block`
215
+ );
216
+ }
217
+
218
+ // Collect all block ranges for unexpected-content check
219
+ /** @type {Array<{start: number, end: number}>} */
220
+ const allRanges = [
221
+ ...scriptBlocks,
222
+ ...templateBlocks,
223
+ ...styleBlocks,
224
+ ].sort((a, b) => a.start - b.start);
225
+
226
+ validateNoUnexpectedContent(source, allRanges, fileName);
227
+
228
+ // Extract block contents
229
+ const scriptContent = scriptBlocks[0].content;
230
+ const templateContent = templateBlocks[0].content;
231
+ const styleContent = styleBlocks.length > 0 ? styleBlocks[0].content : '';
232
+ const lang = extractLang(scriptBlocks[0].attrs);
233
+
234
+ // Validate defineComponent and extract tag
235
+ const tag = extractTagFromDefineComponent(scriptContent, fileName);
236
+
237
+ return {
238
+ script: scriptContent,
239
+ template: templateContent,
240
+ style: styleContent,
241
+ lang,
242
+ tag,
243
+ };
244
+ }
245
+
246
+ /**
247
+ * Pretty-printer: reconstruct an SFC string from a descriptor.
248
+ *
249
+ * @param {SFCDescriptor} descriptor
250
+ * @returns {string}
251
+ */
252
+ export function printSFC(descriptor) {
253
+ const langAttr = descriptor.lang === 'ts' ? ' lang="ts"' : '';
254
+ let result = `<script${langAttr}>${descriptor.script}</script>\n\n`;
255
+ result += `<template>${descriptor.template}</template>`;
256
+
257
+ if (descriptor.style && descriptor.style.length > 0) {
258
+ result += `\n\n<style>${descriptor.style}</style>`;
259
+ }
260
+
261
+ return result;
262
+ }
@@ -117,9 +117,10 @@ export function walkTree(rootEl, signalNames, computedNames, propNames = new Set
117
117
  if (['show', 'model', 'if', 'else-if', 'else', 'each', 'ref'].includes(attr.name)) continue;
118
118
 
119
119
  // Check for {{interpolation}} in attribute value
120
- const interpMatch = attr.value.match(/^\{\{([\w.]+)\}\}$/);
120
+ const interpMatch = attr.value.match(/^\{\{([\w.()]+)\}\}$/);
121
121
  if (interpMatch) {
122
- const expr = interpMatch[1];
122
+ const rawExpr = interpMatch[1];
123
+ const expr = rawExpr.endsWith('()') ? rawExpr.slice(0, -2) : rawExpr;
123
124
  propBindings.push({
124
125
  attr: attr.name,
125
126
  expr,
@@ -130,14 +131,13 @@ export function walkTree(rootEl, signalNames, computedNames, propNames = new Set
130
131
  }
131
132
  }
132
133
 
133
- if (propBindings.length > 0) {
134
- childComponents.push({
135
- tag: tagLower,
136
- varName: `__child${childIdx++}`,
137
- path: [...pathParts],
138
- propBindings,
139
- });
140
- }
134
+ // Always register child component for auto-import (even without prop bindings)
135
+ childComponents.push({
136
+ tag: tagLower,
137
+ varName: `__child${childIdx++}`,
138
+ path: [...pathParts],
139
+ propBindings,
140
+ });
141
141
  }
142
142
 
143
143
  // Check for @event attributes
@@ -240,19 +240,25 @@ export function walkTree(rootEl, signalNames, computedNames, propNames = new Set
240
240
  }
241
241
 
242
242
  // --- Text node with interpolations ---
243
- if (node.nodeType === 3 && /\{\{[\w.]+\}\}/.test(node.textContent)) {
243
+ if (node.nodeType === 3 && /\{\{[\w.()]+\}\}/.test(node.textContent)) {
244
244
  const text = node.textContent;
245
245
  const trimmed = text.trim();
246
- const soleMatch = trimmed.match(/^\{\{([\w.]+)\}\}$/);
246
+ const soleMatch = trimmed.match(/^\{\{([\w.()]+)\}\}$/);
247
247
  const parent = node.parentNode;
248
248
 
249
+ // Strip trailing () from expression to get the base name for type lookup
250
+ function baseName(expr) {
251
+ return expr.endsWith('()') ? expr.slice(0, -2) : expr;
252
+ }
253
+
249
254
  // Case 1: {{var}} is the sole content of the parent element and parent has only one child text node
250
255
  if (soleMatch && parent.childNodes.length === 1) {
251
256
  const varName = `__b${bindIdx++}`;
257
+ const name = baseName(soleMatch[1]);
252
258
  bindings.push({
253
259
  varName,
254
- name: soleMatch[1],
255
- type: bindingType(soleMatch[1]),
260
+ name,
261
+ type: bindingType(name),
256
262
  path: pathParts.slice(0, -1), // path to parent, not text node
257
263
  });
258
264
  parent.textContent = '';
@@ -262,7 +268,7 @@ export function walkTree(rootEl, signalNames, computedNames, propNames = new Set
262
268
  // Case 2: Mixed text and interpolations — split into spans
263
269
  const doc = node.ownerDocument;
264
270
  const fragment = doc.createDocumentFragment();
265
- const parts = text.split(/(\{\{[\w.]+\}\})/);
271
+ const parts = text.split(/(\{\{[\w.()]+\}\})/);
266
272
  const parentPath = pathParts.slice(0, -1);
267
273
 
268
274
  // Find the index of this text node among its siblings
@@ -274,14 +280,15 @@ export function walkTree(rootEl, signalNames, computedNames, propNames = new Set
274
280
 
275
281
  let offset = 0;
276
282
  for (const part of parts) {
277
- const bm = part.match(/^\{\{([\w.]+)\}\}$/);
283
+ const bm = part.match(/^\{\{([\w.()]+)\}\}$/);
278
284
  if (bm) {
279
285
  fragment.appendChild(doc.createElement('span'));
280
286
  const varName = `__b${bindIdx++}`;
287
+ const name = baseName(bm[1]);
281
288
  bindings.push({
282
289
  varName,
283
- name: bm[1],
284
- type: bindingType(bm[1]),
290
+ name,
291
+ type: bindingType(name),
285
292
  path: [...parentPath, `childNodes[${baseIndex + offset}]`],
286
293
  });
287
294
  offset++;
package/lib/types.js CHANGED
@@ -31,7 +31,8 @@
31
31
 
32
32
  /**
33
33
  * @typedef {Object} WatcherDef
34
- * @property {string} targetSignal/prop/computed name to watch (e.g., 'count')
34
+ * @property {'signal' | 'getter'} kindType of watch target
35
+ * @property {string} target — Signal/computed name (for kind='signal') or getter expression (for kind='getter')
35
36
  * @property {string} newParam — Parameter name for new value (e.g., 'newVal')
36
37
  * @property {string} oldParam — Parameter name for old value (e.g., 'oldVal')
37
38
  * @property {string} body — Callback body
@@ -98,6 +99,7 @@
98
99
  * @property {RefBinding[]} refBindings — ref attribute bindings from template (empty array if none)
99
100
  * @property {ChildComponentBinding[]} childComponents — Child component bindings (empty array if none)
100
101
  * @property {ChildComponentImport[]} childImports — Resolved child component imports (empty array if none)
102
+ * @property {string[]} exposeNames — Property names from defineExpose (empty array if none)
101
103
  */
102
104
 
103
105
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@sprlab/wccompiler",
3
- "version": "0.3.0",
4
- "description": "Zero-runtime compiler that transforms .ts/.js component files into native web components with signals-based reactivity",
3
+ "version": "0.4.1",
4
+ "description": "Zero-runtime compiler that transforms .wcc single-file components into native web components with signals-based reactivity",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "wcc": "./bin/wcc.js"
@@ -44,4 +44,4 @@
44
44
  "node": "24.0.0",
45
45
  "yarn": "4.12.0"
46
46
  }
47
- }
47
+ }
package/types/wcc.d.ts CHANGED
@@ -7,22 +7,21 @@ declare module 'wcc' {
7
7
  export function signal<T>(value: T): Signal<T>;
8
8
  export function computed<T>(fn: () => T): () => T;
9
9
  export function effect(fn: () => void): void;
10
- export function watch<T>(target: string, fn: (newVal: T, oldVal: T) => void): void;
10
+ export function watch<T>(target: Signal<T>, fn: (newVal: T, oldVal: T) => void): void;
11
+ export function watch<T>(target: () => T, fn: (newVal: T, oldVal: T) => void): void;
11
12
  export function defineComponent(options: {
12
13
  tag: string;
13
- template: string;
14
- styles?: string;
15
14
  }): void;
16
15
 
17
- export function defineProps<T extends Record<string, any>>(defaults?: Partial<T>): T;
16
+ export function defineProps<T extends Record<string, any>>(defaults: T): T;
18
17
  export function defineProps(names: string[]): Record<string, any>;
19
18
 
20
19
  export function defineEmits<T>(): T;
21
20
  export function defineEmits(names: string[]): (name: string, detail?: any) => void;
22
21
 
23
- export function templateRef(name: string): { value: HTMLElement | null };
22
+ export function templateRef<T = HTMLElement>(name: string): { value: (T & HTMLElement) | null };
24
23
 
25
24
  export function onMount(fn: () => void | Promise<void>): void;
26
25
  export function onDestroy(fn: () => void | Promise<void>): void;
27
- export function templateBindings(bindings: Record<string, any>): void;
26
+ export function defineExpose(bindings: Record<string, any>): void;
28
27
  }
package/types/wcc.test.js CHANGED
@@ -30,8 +30,8 @@ describe('type declarations (wcc.d.ts)', () => {
30
30
  const content = readFileSync(dtsPath, 'utf-8');
31
31
  expect(content).toContain('function defineComponent');
32
32
  expect(content).toContain('tag: string');
33
- expect(content).toContain('template: string');
34
- expect(content).toContain('styles?: string');
33
+ expect(content).not.toContain('template?: string');
34
+ expect(content).not.toContain('styles?: string');
35
35
  });
36
36
 
37
37
  it('Signal<T> interface has call signature (): T', () => {
package/lib/printer.js DELETED
@@ -1,118 +0,0 @@
1
- /**
2
- * Pretty Printer — serializes a ParseResult IR back to valid .js source format.
3
- *
4
- * Used for round-trip testing: parse → prettyPrint → parse should yield
5
- * an equivalent IR.
6
- */
7
-
8
- /** @import { ParseResult } from './types.js' */
9
-
10
- /**
11
- * Pretty-print a ParseResult IR back to component source format.
12
- *
13
- * @param {ParseResult} ir — The intermediate representation
14
- * @returns {string} Reconstructed source code
15
- */
16
- export function prettyPrint(ir) {
17
- const sections = [];
18
-
19
- // 1. Import statement — include only macros actually used
20
- const macros = ['defineComponent'];
21
- if ((ir.propDefs || []).length > 0) macros.push('defineProps');
22
- if ((ir.emits || []).length > 0) macros.push('defineEmits');
23
- if (ir.signals.length > 0) macros.push('signal');
24
- if (ir.computeds.length > 0) macros.push('computed');
25
- if (ir.effects.length > 0) macros.push('effect');
26
- if ((ir.onMountHooks || []).length > 0) macros.push('onMount');
27
- if ((ir.onDestroyHooks || []).length > 0) macros.push('onDestroy');
28
- sections.push(`import { ${macros.join(', ')} } from 'wcc'`);
29
-
30
- // 2. defineComponent call
31
- const defParts = [];
32
- defParts.push(` tag: '${ir.tagName}',`);
33
- defParts.push(` template: './${ir.tagName}.html',`);
34
- if (ir.style !== '') {
35
- defParts.push(` styles: './${ir.tagName}.css',`);
36
- }
37
- sections.push(`export default defineComponent({\n${defParts.join('\n')}\n})`);
38
-
39
- // 3. defineProps (if present)
40
- if ((ir.propDefs || []).length > 0) {
41
- const propsObjName = ir.propsObjectName || 'props';
42
- const propEntries = ir.propDefs.map(p => `${p.name}: ${p.default}`);
43
- sections.push(`const ${propsObjName} = defineProps({ ${propEntries.join(', ')} })`);
44
- }
45
-
46
- // 3b. defineEmits (if present)
47
- if ((ir.emits || []).length > 0) {
48
- const emitsObjName = ir.emitsObjectName || 'emit';
49
- const emitEntries = ir.emits.map(e => `'${e}'`).join(', ');
50
- sections.push(`const ${emitsObjName} = defineEmits([${emitEntries}])`);
51
- }
52
-
53
- // 4. Signal declarations
54
- if (ir.signals.length > 0) {
55
- const signalLines = ir.signals.map(
56
- s => `const ${s.name} = signal(${s.value})`
57
- );
58
- sections.push(signalLines.join('\n'));
59
- }
60
-
61
- // 5. Computed declarations
62
- if (ir.computeds.length > 0) {
63
- const computedLines = ir.computeds.map(
64
- c => `const ${c.name} = computed(() => ${c.body})`
65
- );
66
- sections.push(computedLines.join('\n'));
67
- }
68
-
69
- // 6. Effect declarations
70
- if (ir.effects.length > 0) {
71
- const effectBlocks = ir.effects.map(e => {
72
- const indentedBody = e.body
73
- .split('\n')
74
- .map(line => ` ${line}`)
75
- .join('\n');
76
- return `effect(() => {\n${indentedBody}\n})`;
77
- });
78
- sections.push(effectBlocks.join('\n\n'));
79
- }
80
-
81
- // 7. Function declarations
82
- if (ir.methods.length > 0) {
83
- const fnBlocks = ir.methods.map(m => {
84
- const indentedBody = m.body
85
- .split('\n')
86
- .map(line => ` ${line}`)
87
- .join('\n');
88
- return `function ${m.name}(${m.params}) {\n${indentedBody}\n}`;
89
- });
90
- sections.push(fnBlocks.join('\n\n'));
91
- }
92
-
93
- // 8. Lifecycle hooks — onMount
94
- if ((ir.onMountHooks || []).length > 0) {
95
- const mountBlocks = ir.onMountHooks.map(h => {
96
- const indentedBody = h.body
97
- .split('\n')
98
- .map(line => ` ${line}`)
99
- .join('\n');
100
- return `onMount(() => {\n${indentedBody}\n})`;
101
- });
102
- sections.push(mountBlocks.join('\n\n'));
103
- }
104
-
105
- // 9. Lifecycle hooks — onDestroy
106
- if ((ir.onDestroyHooks || []).length > 0) {
107
- const destroyBlocks = ir.onDestroyHooks.map(h => {
108
- const indentedBody = h.body
109
- .split('\n')
110
- .map(line => ` ${line}`)
111
- .join('\n');
112
- return `onDestroy(() => {\n${indentedBody}\n})`;
113
- });
114
- sections.push(destroyBlocks.join('\n\n'));
115
- }
116
-
117
- return sections.join('\n\n') + '\n';
118
- }