@sprlab/wccompiler 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +154 -34
- package/bin/wcc.js +4 -5
- package/bin/wcc.test.js +23 -16
- package/lib/codegen.js +336 -114
- package/lib/compiler-browser.js +526 -0
- package/lib/compiler.js +225 -91
- package/lib/dev-server.js +55 -17
- package/lib/parser-extractors.js +1029 -0
- package/lib/parser.js +36 -929
- package/lib/reactive-runtime.js +35 -4
- package/lib/sfc-parser.js +262 -0
- package/lib/tree-walker.js +18 -10
- package/lib/types.js +11 -0
- package/package.json +3 -3
- package/types/wcc.d.ts +6 -6
- package/types/wcc.test.js +2 -2
- package/lib/printer.js +0 -118
package/lib/reactive-runtime.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Template string containing the mini reactive runtime
|
|
2
|
+
* Template string containing the mini reactive runtime.
|
|
3
3
|
* This gets inlined at the top of each compiled component so the output
|
|
4
4
|
* is fully self-contained with zero imports.
|
|
5
5
|
*
|
|
@@ -7,12 +7,16 @@
|
|
|
7
7
|
* - __signal(initialValue): getter/setter function with subscriber tracking
|
|
8
8
|
* - __computed(fn): cached derived value that auto-invalidates
|
|
9
9
|
* - __effect(fn): runs fn immediately and re-runs when dependencies change
|
|
10
|
+
* - __batch(fn): batch multiple signal writes, flush effects once at the end
|
|
10
11
|
*
|
|
11
12
|
* Dependency tracking uses a global stack (__currentEffect).
|
|
13
|
+
* Batching uses a depth counter — nested batches are supported.
|
|
12
14
|
*/
|
|
13
15
|
/** @type {string} */
|
|
14
16
|
export const reactiveRuntime = `
|
|
15
17
|
let __currentEffect = null;
|
|
18
|
+
let __batchDepth = 0;
|
|
19
|
+
const __pendingEffects = [];
|
|
16
20
|
|
|
17
21
|
function __signal(initial) {
|
|
18
22
|
let _value = initial;
|
|
@@ -25,7 +29,13 @@ function __signal(initial) {
|
|
|
25
29
|
const old = _value;
|
|
26
30
|
_value = args[0];
|
|
27
31
|
if (old !== _value) {
|
|
28
|
-
|
|
32
|
+
if (__batchDepth > 0) {
|
|
33
|
+
for (const fn of _subs) {
|
|
34
|
+
if (!__pendingEffects.includes(fn)) __pendingEffects.push(fn);
|
|
35
|
+
}
|
|
36
|
+
} else {
|
|
37
|
+
for (const fn of [..._subs]) fn();
|
|
38
|
+
}
|
|
29
39
|
}
|
|
30
40
|
};
|
|
31
41
|
}
|
|
@@ -35,7 +45,13 @@ function __computed(fn) {
|
|
|
35
45
|
const _subs = new Set();
|
|
36
46
|
const recompute = () => {
|
|
37
47
|
_dirty = true;
|
|
38
|
-
|
|
48
|
+
if (__batchDepth > 0) {
|
|
49
|
+
for (const fn of _subs) {
|
|
50
|
+
if (!__pendingEffects.includes(fn)) __pendingEffects.push(fn);
|
|
51
|
+
}
|
|
52
|
+
} else {
|
|
53
|
+
for (const fn of [..._subs]) fn();
|
|
54
|
+
}
|
|
39
55
|
};
|
|
40
56
|
return () => {
|
|
41
57
|
if (__currentEffect) _subs.add(__currentEffect);
|
|
@@ -51,12 +67,27 @@ function __computed(fn) {
|
|
|
51
67
|
}
|
|
52
68
|
|
|
53
69
|
function __effect(fn) {
|
|
70
|
+
let _cleanup = null;
|
|
54
71
|
const run = () => {
|
|
72
|
+
if (typeof _cleanup === 'function') _cleanup();
|
|
55
73
|
const prev = __currentEffect;
|
|
56
74
|
__currentEffect = run;
|
|
57
|
-
fn();
|
|
75
|
+
_cleanup = fn();
|
|
58
76
|
__currentEffect = prev;
|
|
59
77
|
};
|
|
60
78
|
run();
|
|
61
79
|
}
|
|
80
|
+
|
|
81
|
+
function __batch(fn) {
|
|
82
|
+
__batchDepth++;
|
|
83
|
+
try {
|
|
84
|
+
fn();
|
|
85
|
+
} finally {
|
|
86
|
+
__batchDepth--;
|
|
87
|
+
if (__batchDepth === 0) {
|
|
88
|
+
const pending = __pendingEffects.splice(0);
|
|
89
|
+
for (const f of pending) f();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
62
93
|
`;
|
|
@@ -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
|
+
}
|
package/lib/tree-walker.js
CHANGED
|
@@ -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
|
|
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
|
|
255
|
-
type: bindingType(
|
|
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
|
|
284
|
-
type: bindingType(
|
|
291
|
+
name,
|
|
292
|
+
type: bindingType(name),
|
|
285
293
|
path: [...parentPath, `childNodes[${baseIndex + offset}]`],
|
|
286
294
|
});
|
|
287
295
|
offset++;
|
package/lib/types.js
CHANGED
|
@@ -26,6 +26,16 @@
|
|
|
26
26
|
/**
|
|
27
27
|
* @typedef {Object} LifecycleHook
|
|
28
28
|
* @property {string} body — The callback body (JavaScript code)
|
|
29
|
+
* @property {boolean} async — Whether the callback is async
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @typedef {Object} WatcherDef
|
|
34
|
+
* @property {'signal' | 'getter'} kind — Type of watch target
|
|
35
|
+
* @property {string} target — Signal/computed name (for kind='signal') or getter expression (for kind='getter')
|
|
36
|
+
* @property {string} newParam — Parameter name for new value (e.g., 'newVal')
|
|
37
|
+
* @property {string} oldParam — Parameter name for old value (e.g., 'oldVal')
|
|
38
|
+
* @property {string} body — Callback body
|
|
29
39
|
*/
|
|
30
40
|
|
|
31
41
|
/**
|
|
@@ -68,6 +78,7 @@
|
|
|
68
78
|
* @property {ComputedDef[]} computeds — computed() declarations
|
|
69
79
|
* @property {EffectDef[]} effects — effect() declarations
|
|
70
80
|
* @property {ConstantVar[]} constantVars — Plain const declarations (non-reactive)
|
|
81
|
+
* @property {WatcherDef[]} watchers — watch() declarations
|
|
71
82
|
* @property {MethodDef[]} methods — function declarations
|
|
72
83
|
* @property {PropDef[]} propDefs — Prop definitions with names and defaults
|
|
73
84
|
* @property {string|null} propsObjectName — Variable name from `const X = defineProps(...)`
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sprlab/wccompiler",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Zero-runtime compiler that transforms .
|
|
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,13 +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: Signal<T>, fn: (newVal: T, oldVal: T) => void): void;
|
|
11
|
+
export function watch<T>(target: () => T, fn: (newVal: T, oldVal: T) => void): void;
|
|
10
12
|
export function defineComponent(options: {
|
|
11
13
|
tag: string;
|
|
12
|
-
template: string;
|
|
13
|
-
styles?: string;
|
|
14
14
|
}): void;
|
|
15
15
|
|
|
16
|
-
export function defineProps<T extends Record<string, any>>(defaults
|
|
16
|
+
export function defineProps<T extends Record<string, any>>(defaults: T): T;
|
|
17
17
|
export function defineProps(names: string[]): Record<string, any>;
|
|
18
18
|
|
|
19
19
|
export function defineEmits<T>(): T;
|
|
@@ -21,7 +21,7 @@ declare module 'wcc' {
|
|
|
21
21
|
|
|
22
22
|
export function templateRef(name: string): { value: HTMLElement | null };
|
|
23
23
|
|
|
24
|
-
export function onMount(fn: () => void): void;
|
|
25
|
-
export function onDestroy(fn: () => void): void;
|
|
26
|
-
export function
|
|
24
|
+
export function onMount(fn: () => void | Promise<void>): void;
|
|
25
|
+
export function onDestroy(fn: () => void | Promise<void>): void;
|
|
26
|
+
export function defineExpose(bindings: Record<string, any>): void;
|
|
27
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
|
|
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
|
-
}
|