@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/lib/compiler.js CHANGED
@@ -1,49 +1,124 @@
1
1
  /**
2
- * Compiler — integrates parser, tree-walker, css-scoper, and codegen
3
- * into a single compile(filePath, config) function.
2
+ * Compiler — orchestrates the full compilation pipeline for wcCompiler v2.
4
3
  *
5
- * This is the main entry point for compiling a .html source file
6
- * into a self-contained .js web component.
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 .html source file into a self-contained JS component string.
15
+ * Compile a single .ts/.js source file into a self-contained JS component.
18
16
  *
19
- * @param {string} filePath - Absolute or relative path to the .html source file
20
- * @param {object} [config] - Optional config (currently unused, reserved for future options)
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. Read the source file
25
- const html = readFileSync(filePath, 'utf-8');
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
- // 3. Create a jsdom DOM from the template and walk it
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
- const propsSet = new Set(parseResult.props);
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 rootVarNames = new Set(parseResult.reactiveVars.map(v => v.name));
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
- const { bindings, events, slots } = walkTree(rootEl, propsSet, computedNames, rootVarNames);
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
- // 4. Update the parseResult with tree-walker results
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
- // 5. Generate the component code
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
- const DEFAULTS = {
6
- port: 4100,
7
- input: 'src',
8
- output: 'dist',
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 and validate wcc.config.js from the given project root.
13
- * Returns defaults if the config file doesn't exist.
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 - Absolute path to the project root
16
- * @returns {Promise<{port: number, input: string, output: string}>}
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 fileUrl = pathToFileURL(configPath).href;
26
- const mod = await import(fileUrl);
27
- const raw = mod.default ?? mod;
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 = { ...DEFAULTS };
30
- const errors = [];
31
+ const config = { ...defaults, ...userConfig };
31
32
 
32
- if ('port' in raw) {
33
- if (typeof raw.port !== 'number' || !Number.isFinite(raw.port)) {
34
- errors.push("la propiedad 'port' debe ser un número válido");
35
- } else {
36
- config.port = raw.port;
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
- if ('input' in raw) {
41
- if (typeof raw.input !== 'string' || raw.input.trim() === '') {
42
- errors.push("la propiedad 'input' debe ser un string no vacío");
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
- if ('output' in raw) {
49
- if (typeof raw.output !== 'string' || raw.output.trim() === '') {
50
- errors.push("la propiedad 'output' debe ser un string no vacío");
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