@sprlab/wccompiler 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,
@@ -240,19 +241,25 @@ export function walkTree(rootEl, signalNames, computedNames, propNames = new Set
240
241
  }
241
242
 
242
243
  // --- Text node with interpolations ---
243
- if (node.nodeType === 3 && /\{\{[\w.]+\}\}/.test(node.textContent)) {
244
+ if (node.nodeType === 3 && /\{\{[\w.()]+\}\}/.test(node.textContent)) {
244
245
  const text = node.textContent;
245
246
  const trimmed = text.trim();
246
- const soleMatch = trimmed.match(/^\{\{([\w.]+)\}\}$/);
247
+ const soleMatch = trimmed.match(/^\{\{([\w.()]+)\}\}$/);
247
248
  const parent = node.parentNode;
248
249
 
250
+ // Strip trailing () from expression to get the base name for type lookup
251
+ function baseName(expr) {
252
+ return expr.endsWith('()') ? expr.slice(0, -2) : expr;
253
+ }
254
+
249
255
  // Case 1: {{var}} is the sole content of the parent element and parent has only one child text node
250
256
  if (soleMatch && parent.childNodes.length === 1) {
251
257
  const varName = `__b${bindIdx++}`;
258
+ const name = baseName(soleMatch[1]);
252
259
  bindings.push({
253
260
  varName,
254
- name: soleMatch[1],
255
- type: bindingType(soleMatch[1]),
261
+ name,
262
+ type: bindingType(name),
256
263
  path: pathParts.slice(0, -1), // path to parent, not text node
257
264
  });
258
265
  parent.textContent = '';
@@ -262,7 +269,7 @@ export function walkTree(rootEl, signalNames, computedNames, propNames = new Set
262
269
  // Case 2: Mixed text and interpolations — split into spans
263
270
  const doc = node.ownerDocument;
264
271
  const fragment = doc.createDocumentFragment();
265
- const parts = text.split(/(\{\{[\w.]+\}\})/);
272
+ const parts = text.split(/(\{\{[\w.()]+\}\})/);
266
273
  const parentPath = pathParts.slice(0, -1);
267
274
 
268
275
  // Find the index of this text node among its siblings
@@ -274,14 +281,15 @@ export function walkTree(rootEl, signalNames, computedNames, propNames = new Set
274
281
 
275
282
  let offset = 0;
276
283
  for (const part of parts) {
277
- const bm = part.match(/^\{\{([\w.]+)\}\}$/);
284
+ const bm = part.match(/^\{\{([\w.()]+)\}\}$/);
278
285
  if (bm) {
279
286
  fragment.appendChild(doc.createElement('span'));
280
287
  const varName = `__b${bindIdx++}`;
288
+ const name = baseName(bm[1]);
281
289
  bindings.push({
282
290
  varName,
283
- name: bm[1],
284
- type: bindingType(bm[1]),
291
+ name,
292
+ type: bindingType(name),
285
293
  path: [...parentPath, `childNodes[${baseIndex + offset}]`],
286
294
  });
287
295
  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
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.0",
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,14 +7,13 @@ 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;
@@ -24,5 +23,5 @@ declare module 'wcc' {
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
- }