@sprlab/wccompiler 0.0.3 → 0.2.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.
package/lib/compiler.js CHANGED
@@ -1,62 +1,201 @@
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';
11
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
12
+ import { resolve, relative, dirname, extname } from 'node:path';
12
13
  import { parse } from './parser.js';
13
- import { walkTree } from './tree-walker.js';
14
+ import { walkTree, processIfChains, processForBlocks, recomputeAnchorPath, detectRefs } from './tree-walker.js';
14
15
  import { generateComponent } from './codegen.js';
15
-
16
16
  /**
17
- * Compile a single .html source file into a self-contained JS component string.
17
+ * Resolve a child component's import path by searching for a source file
18
+ * whose defineComponent({ tag }) matches the given tag name.
18
19
  *
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
20
+ * @param {string} tag Child component tag name (e.g., 'wcc-badge')
21
+ * @param {string} sourceDir Directory of the parent component source file
22
+ * @param {object} [config] Optional config with input/output dirs
23
+ * @returns {string | null} Relative import path (e.g., './wcc-badge.js') or null if not found
22
24
  */
23
- export function compile(filePath, config) {
24
- // 1. Read the source file
25
- const html = readFileSync(filePath, 'utf-8');
26
- const fileName = basename(filePath);
25
+ function resolveChildComponent(tag, sourceDir, config) {
26
+ // Search in the same directory and subdirectories for a matching source file
27
+ const searchDirs = [sourceDir];
28
+
29
+ // Also search parent directory (common case: components in sibling folders)
30
+ const parentDir = dirname(sourceDir);
31
+ if (parentDir !== sourceDir) {
32
+ searchDirs.push(parentDir);
33
+ }
34
+
35
+ for (const dir of searchDirs) {
36
+ if (!existsSync(dir)) continue;
37
+ try {
38
+ const entries = readdirSync(dir, { withFileTypes: true, recursive: true });
39
+ for (const entry of entries) {
40
+ if (!entry.isFile()) continue;
41
+ const ext = extname(entry.name);
42
+ if (ext !== '.js' && ext !== '.ts') continue;
43
+ if (entry.name.includes('.test.')) continue;
44
+ if (entry.name.endsWith('.d.ts')) continue;
45
+
46
+ const fullPath = resolve(dir, entry.parentPath ? relative(dir, entry.parentPath) : '', entry.name);
47
+ try {
48
+ const content = readFileSync(fullPath, 'utf-8');
49
+ // Quick check: does this file define the component with the matching tag?
50
+ const tagMatch = content.match(/defineComponent\(\s*\{[^}]*tag\s*:\s*['"]([^'"]+)['"]/);
51
+ if (tagMatch && tagMatch[1] === tag) {
52
+ // Compute relative path from sourceDir to this file, with .js extension
53
+ let relPath = relative(sourceDir, fullPath);
54
+ // Ensure .js extension (replace .ts)
55
+ relPath = relPath.replace(/\.ts$/, '.js');
56
+ // Ensure starts with ./
57
+ if (!relPath.startsWith('.')) relPath = './' + relPath;
58
+ return relPath;
59
+ }
60
+ } catch {
61
+ // Skip files that can't be read
62
+ }
63
+ }
64
+ } catch {
65
+ // Skip dirs that can't be listed
66
+ }
67
+ }
68
+
69
+ return null;
70
+ }
27
71
 
28
- // 2. Parse the HTML into the IR
29
- const parseResult = parse(html, fileName);
72
+ /**
73
+ * Compile a single .ts/.js source file into a self-contained JS component.
74
+ *
75
+ * @param {string} filePath — Absolute or relative path to the source file
76
+ * @param {object} [config] — Optional config (reserved for future options)
77
+ * @returns {Promise<string>} The generated JavaScript component code
78
+ */
79
+ export async function compile(filePath, config) {
80
+ // 1. Parse the source file
81
+ const parseResult = await parse(filePath);
30
82
 
31
- // 3. Validate: no text nodes with bindings at template root level
83
+ // 2. Parse template HTML into jsdom DOM
32
84
  const dom = new JSDOM(`<div id="__root">${parseResult.template}</div>`);
33
85
  const rootEl = dom.window.document.getElementById('__root');
34
86
 
35
- for (const child of rootEl.childNodes) {
36
- if (child.nodeType === 3 && /\{\{\w+\}\}/.test(child.textContent)) {
37
- const match = child.textContent.match(/\{\{(\w+)\}\}/);
38
- const error = new Error(
39
- `Error en '${fileName}': el binding {{${match[1]}}} está como texto suelto en el root del template. Debe estar dentro de un elemento (ej: <span>{{${match[1]}}}</span>)`
40
- );
41
- error.code = 'ROOT_TEXT_BINDING';
87
+ // 3. Build name sets
88
+ const signalNames = new Set(parseResult.signals.map(s => s.name));
89
+ const computedNames = new Set(parseResult.computeds.map(c => c.name));
90
+ const propNames = new Set((parseResult.propDefs || []).map(p => p.name));
91
+
92
+ // 4. Process each blocks BEFORE if chains (replaces each elements with comment anchors)
93
+ const forBlocks = processForBlocks(rootEl, [], signalNames, computedNames, propNames);
94
+
95
+ // 5. Process conditional chains BEFORE walkTree (if/else-if/else)
96
+ // This replaces conditional elements with comment anchors so walkTree
97
+ // doesn't discover bindings inside conditional branches at the top level.
98
+ const ifBlocks = processIfChains(rootEl, [], signalNames, computedNames, propNames);
99
+
100
+ // 6. Normalize DOM after all directive processing to merge adjacent text nodes
101
+ rootEl.normalize();
102
+
103
+ // 7. Recompute anchor paths after normalization since text node merging
104
+ // may have changed childNode indices
105
+ for (const fb of forBlocks) {
106
+ fb.anchorPath = recomputeAnchorPath(rootEl, fb._anchorNode);
107
+ }
108
+ for (const ib of ifBlocks) {
109
+ ib.anchorPath = recomputeAnchorPath(rootEl, ib._anchorNode);
110
+ }
111
+
112
+ // 8. Walk the tree (discovers bindings/events/showBindings/slots in non-conditional content)
113
+ const { bindings, events, showBindings, modelBindings, attrBindings, slots, childComponents } = walkTree(rootEl, signalNames, computedNames, propNames);
114
+
115
+ // 9. Detect refs (after walkTree — ref attributes are compile-time directives)
116
+ const refBindings = detectRefs(rootEl);
117
+
118
+ // 10. Validate refs
119
+ const refs = parseResult.refs || [];
120
+
121
+ // REF_NOT_FOUND: templateRef('name') with no matching ref="name" in template
122
+ for (const decl of refs) {
123
+ if (!refBindings.find(b => b.refName === decl.refName)) {
124
+ const error = new Error(`templateRef('${decl.refName}') has no matching ref="${decl.refName}" in template`);
125
+ /** @ts-expect-error — custom error code */
126
+ error.code = 'REF_NOT_FOUND';
42
127
  throw error;
43
128
  }
44
129
  }
45
130
 
46
- // 4. Walk the template DOM
131
+ // Unused ref warning: ref="name" in template with no matching templateRef('name') in script
132
+ for (const binding of refBindings) {
133
+ if (!refs.find(d => d.refName === binding.refName)) {
134
+ console.warn(`Warning: ref="${binding.refName}" in template has no matching templateRef('${binding.refName}') in script`);
135
+ }
136
+ }
47
137
 
48
- const propsSet = new Set(parseResult.props);
49
- const computedNames = new Set(parseResult.computeds.map(c => c.name));
50
- const rootVarNames = new Set(parseResult.reactiveVars.map(v => v.name));
138
+ // 10b. Validate model bindings — target must be a signal (not prop, computed, or constant)
139
+ const constantNames = new Set((parseResult.constantVars || []).map(v => v.name));
140
+ for (const mb of modelBindings) {
141
+ if (propNames.has(mb.signal)) {
142
+ const error = new Error(`model cannot bind to prop '${mb.signal}' (read-only)`);
143
+ /** @ts-expect-error — custom error code */
144
+ error.code = 'MODEL_READONLY';
145
+ throw error;
146
+ }
147
+ if (computedNames.has(mb.signal)) {
148
+ const error = new Error(`model cannot bind to computed '${mb.signal}' (read-only)`);
149
+ /** @ts-expect-error — custom error code */
150
+ error.code = 'MODEL_READONLY';
151
+ throw error;
152
+ }
153
+ if (constantNames.has(mb.signal)) {
154
+ const error = new Error(`model cannot bind to constant '${mb.signal}' (read-only)`);
155
+ /** @ts-expect-error — custom error code */
156
+ error.code = 'MODEL_READONLY';
157
+ throw error;
158
+ }
159
+ if (!signalNames.has(mb.signal)) {
160
+ const error = new Error(`model references undeclared variable '${mb.signal}'`);
161
+ /** @ts-expect-error — custom error code */
162
+ error.code = 'MODEL_UNKNOWN_VAR';
163
+ throw error;
164
+ }
165
+ }
166
+
167
+ // 11. Resolve child component imports
168
+ /** @type {import('./types.js').ChildComponentImport[]} */
169
+ const childImports = [];
170
+ if (childComponents.length > 0) {
171
+ const uniqueTags = [...new Set(childComponents.map(c => c.tag))];
172
+ const sourceDir = dirname(filePath);
51
173
 
52
- const { bindings, events, slots } = walkTree(rootEl, propsSet, computedNames, rootVarNames);
174
+ for (const tag of uniqueTags) {
175
+ const resolved = resolveChildComponent(tag, sourceDir, config);
176
+ if (resolved) {
177
+ childImports.push({ tag, importPath: resolved });
178
+ } else {
179
+ console.warn(`Warning: child component <${tag}> used in template but source file not found`);
180
+ }
181
+ }
182
+ }
53
183
 
54
- // 4. Update the parseResult with tree-walker results
184
+ // 12. Merge results into ParseResult
55
185
  parseResult.bindings = bindings;
56
186
  parseResult.events = events;
187
+ parseResult.showBindings = showBindings;
188
+ parseResult.modelBindings = modelBindings;
189
+ parseResult.attrBindings = attrBindings;
190
+ parseResult.ifBlocks = ifBlocks;
191
+ parseResult.forBlocks = forBlocks;
57
192
  parseResult.slots = slots;
193
+ parseResult.refBindings = refBindings;
194
+ parseResult.childComponents = childComponents;
195
+ parseResult.childImports = childImports;
196
+ // Recompute processedTemplate after all directive replacements (including ref removal)
58
197
  parseResult.processedTemplate = rootEl.innerHTML;
59
198
 
60
- // 5. Generate the component code
199
+ // 12. Generate component
61
200
  return generateComponent(parseResult);
62
201
  }
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