@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/compiler.js
CHANGED
|
@@ -1,49 +1,124 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Compiler —
|
|
3
|
-
* into a single compile(filePath, config) function.
|
|
2
|
+
* Compiler — orchestrates the full compilation pipeline for wcCompiler v2.
|
|
4
3
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Pipeline: parse → jsdom template → tree-walk → codegen
|
|
5
|
+
*
|
|
6
|
+
* Takes a .ts/.js source file path and produces a self-contained
|
|
7
|
+
* JavaScript web component string.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
|
-
import { readFileSync } from 'node:fs';
|
|
10
|
-
import { basename } from 'node:path';
|
|
11
10
|
import { JSDOM } from 'jsdom';
|
|
12
11
|
import { parse } from './parser.js';
|
|
13
|
-
import { walkTree } from './tree-walker.js';
|
|
12
|
+
import { walkTree, processIfChains, processForBlocks, recomputeAnchorPath, detectRefs } from './tree-walker.js';
|
|
14
13
|
import { generateComponent } from './codegen.js';
|
|
15
|
-
|
|
16
14
|
/**
|
|
17
|
-
* Compile a single .
|
|
15
|
+
* Compile a single .ts/.js source file into a self-contained JS component.
|
|
18
16
|
*
|
|
19
|
-
* @param {string} filePath
|
|
20
|
-
* @param {object} [config]
|
|
21
|
-
* @returns {string} The generated JavaScript component code
|
|
17
|
+
* @param {string} filePath — Absolute or relative path to the source file
|
|
18
|
+
* @param {object} [config] — Optional config (reserved for future options)
|
|
19
|
+
* @returns {Promise<string>} The generated JavaScript component code
|
|
22
20
|
*/
|
|
23
|
-
export function compile(filePath, config) {
|
|
24
|
-
// 1.
|
|
25
|
-
const
|
|
26
|
-
const fileName = basename(filePath);
|
|
27
|
-
|
|
28
|
-
// 2. Parse the HTML into the IR
|
|
29
|
-
const parseResult = parse(html, fileName);
|
|
21
|
+
export async function compile(filePath, config) {
|
|
22
|
+
// 1. Parse the source file
|
|
23
|
+
const parseResult = await parse(filePath);
|
|
30
24
|
|
|
31
|
-
//
|
|
25
|
+
// 2. Parse template HTML into jsdom DOM
|
|
32
26
|
const dom = new JSDOM(`<div id="__root">${parseResult.template}</div>`);
|
|
33
27
|
const rootEl = dom.window.document.getElementById('__root');
|
|
34
28
|
|
|
35
|
-
|
|
29
|
+
// 3. Build name sets
|
|
30
|
+
const signalNames = new Set(parseResult.signals.map(s => s.name));
|
|
36
31
|
const computedNames = new Set(parseResult.computeds.map(c => c.name));
|
|
37
|
-
const
|
|
32
|
+
const propNames = new Set((parseResult.propDefs || []).map(p => p.name));
|
|
33
|
+
|
|
34
|
+
// 4. Process each blocks BEFORE if chains (replaces each elements with comment anchors)
|
|
35
|
+
const forBlocks = processForBlocks(rootEl, [], signalNames, computedNames, propNames);
|
|
36
|
+
|
|
37
|
+
// 5. Process conditional chains BEFORE walkTree (if/else-if/else)
|
|
38
|
+
// This replaces conditional elements with comment anchors so walkTree
|
|
39
|
+
// doesn't discover bindings inside conditional branches at the top level.
|
|
40
|
+
const ifBlocks = processIfChains(rootEl, [], signalNames, computedNames, propNames);
|
|
41
|
+
|
|
42
|
+
// 6. Normalize DOM after all directive processing to merge adjacent text nodes
|
|
43
|
+
rootEl.normalize();
|
|
44
|
+
|
|
45
|
+
// 7. Recompute anchor paths after normalization since text node merging
|
|
46
|
+
// may have changed childNode indices
|
|
47
|
+
for (const fb of forBlocks) {
|
|
48
|
+
fb.anchorPath = recomputeAnchorPath(rootEl, fb._anchorNode);
|
|
49
|
+
}
|
|
50
|
+
for (const ib of ifBlocks) {
|
|
51
|
+
ib.anchorPath = recomputeAnchorPath(rootEl, ib._anchorNode);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// 8. Walk the tree (discovers bindings/events/showBindings/slots in non-conditional content)
|
|
55
|
+
const { bindings, events, showBindings, modelBindings, attrBindings, slots } = walkTree(rootEl, signalNames, computedNames, propNames);
|
|
56
|
+
|
|
57
|
+
// 9. Detect refs (after walkTree — ref attributes are compile-time directives)
|
|
58
|
+
const refBindings = detectRefs(rootEl);
|
|
59
|
+
|
|
60
|
+
// 10. Validate refs
|
|
61
|
+
const refs = parseResult.refs || [];
|
|
62
|
+
|
|
63
|
+
// REF_NOT_FOUND: templateRef('name') with no matching ref="name" in template
|
|
64
|
+
for (const decl of refs) {
|
|
65
|
+
if (!refBindings.find(b => b.refName === decl.refName)) {
|
|
66
|
+
const error = new Error(`templateRef('${decl.refName}') has no matching ref="${decl.refName}" in template`);
|
|
67
|
+
/** @ts-expect-error — custom error code */
|
|
68
|
+
error.code = 'REF_NOT_FOUND';
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Unused ref warning: ref="name" in template with no matching templateRef('name') in script
|
|
74
|
+
for (const binding of refBindings) {
|
|
75
|
+
if (!refs.find(d => d.refName === binding.refName)) {
|
|
76
|
+
console.warn(`Warning: ref="${binding.refName}" in template has no matching templateRef('${binding.refName}') in script`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
38
79
|
|
|
39
|
-
|
|
80
|
+
// 10b. Validate model bindings — target must be a signal (not prop, computed, or constant)
|
|
81
|
+
const constantNames = new Set((parseResult.constantVars || []).map(v => v.name));
|
|
82
|
+
for (const mb of modelBindings) {
|
|
83
|
+
if (propNames.has(mb.signal)) {
|
|
84
|
+
const error = new Error(`model cannot bind to prop '${mb.signal}' (read-only)`);
|
|
85
|
+
/** @ts-expect-error — custom error code */
|
|
86
|
+
error.code = 'MODEL_READONLY';
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
if (computedNames.has(mb.signal)) {
|
|
90
|
+
const error = new Error(`model cannot bind to computed '${mb.signal}' (read-only)`);
|
|
91
|
+
/** @ts-expect-error — custom error code */
|
|
92
|
+
error.code = 'MODEL_READONLY';
|
|
93
|
+
throw error;
|
|
94
|
+
}
|
|
95
|
+
if (constantNames.has(mb.signal)) {
|
|
96
|
+
const error = new Error(`model cannot bind to constant '${mb.signal}' (read-only)`);
|
|
97
|
+
/** @ts-expect-error — custom error code */
|
|
98
|
+
error.code = 'MODEL_READONLY';
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
if (!signalNames.has(mb.signal)) {
|
|
102
|
+
const error = new Error(`model references undeclared variable '${mb.signal}'`);
|
|
103
|
+
/** @ts-expect-error — custom error code */
|
|
104
|
+
error.code = 'MODEL_UNKNOWN_VAR';
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
40
108
|
|
|
41
|
-
//
|
|
109
|
+
// 11. Merge results into ParseResult
|
|
42
110
|
parseResult.bindings = bindings;
|
|
43
111
|
parseResult.events = events;
|
|
112
|
+
parseResult.showBindings = showBindings;
|
|
113
|
+
parseResult.modelBindings = modelBindings;
|
|
114
|
+
parseResult.attrBindings = attrBindings;
|
|
115
|
+
parseResult.ifBlocks = ifBlocks;
|
|
116
|
+
parseResult.forBlocks = forBlocks;
|
|
44
117
|
parseResult.slots = slots;
|
|
118
|
+
parseResult.refBindings = refBindings;
|
|
119
|
+
// Recompute processedTemplate after all directive replacements (including ref removal)
|
|
45
120
|
parseResult.processedTemplate = rootEl.innerHTML;
|
|
46
121
|
|
|
47
|
-
//
|
|
122
|
+
// 12. Generate component
|
|
48
123
|
return generateComponent(parseResult);
|
|
49
124
|
}
|
package/lib/config.js
CHANGED
|
@@ -1,60 +1,50 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
1
3
|
import { pathToFileURL } from 'node:url';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
3
|
-
import { resolve, join } from 'node:path';
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
}
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {Object} WccConfig
|
|
7
|
+
* @property {number} port — Dev server port (default: 4100)
|
|
8
|
+
* @property {string} input — Source directory (default: 'src')
|
|
9
|
+
* @property {string} output — Output directory (default: 'dist')
|
|
10
|
+
*/
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
|
-
* Load
|
|
13
|
-
* Returns defaults if the
|
|
13
|
+
* Load wcc.config.js from the project root.
|
|
14
|
+
* Returns defaults if the file doesn't exist.
|
|
15
|
+
* Validates port (finite number), input (non-empty string), output (non-empty string).
|
|
14
16
|
*
|
|
15
|
-
* @param {string} projectRoot
|
|
16
|
-
* @returns {Promise<
|
|
17
|
+
* @param {string} projectRoot
|
|
18
|
+
* @returns {Promise<WccConfig>}
|
|
17
19
|
*/
|
|
18
20
|
export async function loadConfig(projectRoot) {
|
|
21
|
+
const defaults = { port: 4100, input: 'src', output: 'dist' };
|
|
19
22
|
const configPath = resolve(projectRoot, 'wcc.config.js');
|
|
20
23
|
|
|
21
|
-
if (!existsSync(configPath))
|
|
22
|
-
return { ...DEFAULTS };
|
|
23
|
-
}
|
|
24
|
+
if (!existsSync(configPath)) return defaults;
|
|
24
25
|
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
const
|
|
26
|
+
const configUrl = pathToFileURL(configPath).href;
|
|
27
|
+
// Add cache-busting query to avoid ESM module cache issues
|
|
28
|
+
const mod = await import(`${configUrl}?t=${Date.now()}`);
|
|
29
|
+
const userConfig = mod.default || mod;
|
|
28
30
|
|
|
29
|
-
const config = { ...
|
|
30
|
-
const errors = [];
|
|
31
|
+
const config = { ...defaults, ...userConfig };
|
|
31
32
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
}
|
|
33
|
+
// Validate
|
|
34
|
+
if (typeof config.port !== 'number' || !isFinite(config.port)) {
|
|
35
|
+
const error = new Error(`Error en wcc.config.js: port debe ser un número finito`);
|
|
36
|
+
error.code = 'INVALID_CONFIG';
|
|
37
|
+
throw error;
|
|
38
38
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
} else {
|
|
44
|
-
config.input = raw.input;
|
|
45
|
-
}
|
|
39
|
+
if (typeof config.input !== 'string' || !config.input.trim()) {
|
|
40
|
+
const error = new Error(`Error en wcc.config.js: input debe ser un string no vacío`);
|
|
41
|
+
error.code = 'INVALID_CONFIG';
|
|
42
|
+
throw error;
|
|
46
43
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
} else {
|
|
52
|
-
config.output = raw.output;
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
if (errors.length > 0) {
|
|
57
|
-
throw new Error(`Error en wcc.config.js: ${errors.join('; ')}`);
|
|
44
|
+
if (typeof config.output !== 'string' || !config.output.trim()) {
|
|
45
|
+
const error = new Error(`Error en wcc.config.js: output debe ser un string no vacío`);
|
|
46
|
+
error.code = 'INVALID_CONFIG';
|
|
47
|
+
throw error;
|
|
58
48
|
}
|
|
59
49
|
|
|
60
50
|
return config;
|
package/lib/css-scoper.js
CHANGED
|
@@ -69,6 +69,10 @@ export function scopeCSS(css, tagName) {
|
|
|
69
69
|
/**
|
|
70
70
|
* Prefix comma-separated selectors with the tag name.
|
|
71
71
|
* e.g. ".foo, .bar" → "tag .foo, tag .bar"
|
|
72
|
+
*
|
|
73
|
+
* @param {string} raw - Raw selector string (may be comma-separated)
|
|
74
|
+
* @param {string} tagName - Component tag name to prefix
|
|
75
|
+
* @returns {string} Prefixed selector string
|
|
72
76
|
*/
|
|
73
77
|
function prefixSelectors(raw, tagName) {
|
|
74
78
|
return raw
|
|
@@ -86,6 +90,10 @@ function prefixSelectors(raw, tagName) {
|
|
|
86
90
|
/**
|
|
87
91
|
* Consume a { ... } block starting at the opening brace.
|
|
88
92
|
* Returns the text (including braces) and the position after the closing brace.
|
|
93
|
+
*
|
|
94
|
+
* @param {string} css - Full CSS string
|
|
95
|
+
* @param {number} start - Index of the opening brace
|
|
96
|
+
* @returns {{text: string, end: number}} Consumed block text and position after closing brace
|
|
89
97
|
*/
|
|
90
98
|
function consumeBlock(css, start) {
|
|
91
99
|
let depth = 0;
|
|
@@ -112,6 +120,11 @@ function consumeBlock(css, start) {
|
|
|
112
120
|
* Consume an at-rule starting at '@'.
|
|
113
121
|
* Handles both block at-rules (@media { ... }) and statement at-rules (@import ...).
|
|
114
122
|
* For block at-rules, recursively scopes selectors inside.
|
|
123
|
+
*
|
|
124
|
+
* @param {string} css - Full CSS string
|
|
125
|
+
* @param {number} start - Index of the '@' character
|
|
126
|
+
* @param {string} tagName - Component tag name for scoping nested selectors
|
|
127
|
+
* @returns {{text: string, end: number}} Consumed at-rule text and position after it
|
|
115
128
|
*/
|
|
116
129
|
function consumeAtRule(css, start, tagName) {
|
|
117
130
|
// Read the at-rule prelude (everything up to '{' or ';')
|
package/lib/dev-server.js
CHANGED
|
@@ -6,6 +6,19 @@ import { createServer } from 'node:http';
|
|
|
6
6
|
import { readFileSync, watch, existsSync } from 'node:fs';
|
|
7
7
|
import { resolve, extname } from 'node:path';
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {Object} DevServerOptions
|
|
11
|
+
* @property {number} port
|
|
12
|
+
* @property {string} root
|
|
13
|
+
* @property {string} outputDir
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @typedef {Object} DevServerHandle
|
|
18
|
+
* @property {import('node:http').Server} server
|
|
19
|
+
* @property {() => void} close
|
|
20
|
+
*/
|
|
21
|
+
|
|
9
22
|
const MIME_TYPES = {
|
|
10
23
|
'.html': 'text/html; charset=utf-8',
|
|
11
24
|
'.js': 'text/javascript; charset=utf-8',
|
|
@@ -29,6 +42,12 @@ const POLL_SNIPPET = `<script>
|
|
|
29
42
|
})();
|
|
30
43
|
</script>`;
|
|
31
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Start a development server with live-reload support.
|
|
47
|
+
*
|
|
48
|
+
* @param {DevServerOptions} options
|
|
49
|
+
* @returns {DevServerHandle}
|
|
50
|
+
*/
|
|
32
51
|
export function startDevServer({ port, root, outputDir }) {
|
|
33
52
|
let changeTs = Date.now();
|
|
34
53
|
|