@sola-air-ui/compiler 1.0.0 → 1.0.2

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.js +225 -41
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sola-air-ui/compiler",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Compiler for Sola single-file components (.sola) — transforms template + script + style to ES modules",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/index.js CHANGED
@@ -25,6 +25,11 @@ function scopeStyles(css, scopeHash) {
25
25
  .map(part => {
26
26
  const trimmed = part.trim();
27
27
  if (!trimmed) return part;
28
+ // Insert scope hash before pseudo-class/element (e.g. button:hover → button.hash:hover)
29
+ const pseudoIdx = trimmed.search(/::?[\w-]/);
30
+ if (pseudoIdx > 0) {
31
+ return `${trimmed.slice(0, pseudoIdx)}.${scopeHash}${trimmed.slice(pseudoIdx)}`;
32
+ }
28
33
  return `${trimmed}.${scopeHash}`;
29
34
  })
30
35
  .join(', ');
@@ -52,14 +57,138 @@ function extractScriptAndStyle(source) {
52
57
  return { script, style, template };
53
58
  }
54
59
 
60
+ // Returns the index of the closing } that matches the { at openPos,
61
+ // respecting string literals and nested braces. Returns -1 if unbalanced.
62
+ function matchBrace(text, openPos) {
63
+ let depth = 0;
64
+ let inStr = null;
65
+ for (let i = openPos; i < text.length; i++) {
66
+ const ch = text[i];
67
+ if (inStr) {
68
+ if (ch === '\\') { i++; continue; }
69
+ if (ch === inStr) inStr = null;
70
+ continue;
71
+ }
72
+ if (ch === '"' || ch === "'" || ch === '`') { inStr = ch; continue; }
73
+ if (ch === '{') depth++;
74
+ else if (ch === '}') { if (--depth === 0) return i; }
75
+ }
76
+ return -1;
77
+ }
78
+
79
+ // Walk the template and resolve {expr} in attribute values using balanced-brace
80
+ // matching, storing dynamic expressions in dynAttrs. Event/bind handlers are
81
+ // stored as plain strings; other expressions become __soladyn_N__ markers.
82
+ function preprocessTemplate(template, dynAttrs) {
83
+ let result = '';
84
+ let i = 0;
85
+ while (i < template.length) {
86
+ // Opening tag (not closing, not comment)
87
+ if (template[i] === '<' && template[i + 1] !== '/' && template[i + 1] !== '!') {
88
+ result += '<';
89
+ i++;
90
+ // Tag name
91
+ while (i < template.length && /[^\s>\/]/.test(template[i])) result += template[i++];
92
+ // Attributes
93
+ while (i < template.length && template[i] !== '>' && !(template[i] === '/' && template[i + 1] === '>')) {
94
+ if (/\s/.test(template[i])) { result += template[i++]; continue; }
95
+ // Attribute name
96
+ let attrName = '';
97
+ while (i < template.length && /[^\s=\/>]/.test(template[i])) attrName += template[i++];
98
+ if (!attrName) { result += template[i++]; continue; }
99
+ if (template[i] !== '=') { result += attrName; continue; }
100
+ i++; // skip =
101
+ if (template[i] === '{') {
102
+ // Unquoted brace expression: attr={expr}
103
+ const closeIdx = matchBrace(template, i);
104
+ if (closeIdx !== -1) {
105
+ const expr = template.slice(i + 1, closeIdx).trim();
106
+ if (!expr.startsWith('#') && !expr.startsWith('/') && !expr.startsWith(':')) {
107
+ if (/^on:[\w]+$/.test(attrName) || /^bind:[\w]+$/.test(attrName)) {
108
+ result += `${attrName}="${expr}"`;
109
+ } else {
110
+ dynAttrs.push(expr);
111
+ result += `${attrName}="__soladyn_${dynAttrs.length - 1}__"`;
112
+ }
113
+ i = closeIdx + 1;
114
+ continue;
115
+ }
116
+ }
117
+ result += `${attrName}={`; i++;
118
+ } else if (template[i] === '"' || template[i] === "'") {
119
+ // Quoted attribute value — scan for {expr} interpolations
120
+ const quote = template[i++];
121
+ let inner = ''; let hasDyn = false;
122
+ while (i < template.length && template[i] !== quote) {
123
+ if (template[i] === '{') {
124
+ const closeIdx = matchBrace(template, i);
125
+ if (closeIdx !== -1) {
126
+ const expr = template.slice(i + 1, closeIdx).trim();
127
+ if (!expr.startsWith('#') && !expr.startsWith('/') && !expr.startsWith(':')) {
128
+ hasDyn = true;
129
+ inner += `\${${expr}}`; i = closeIdx + 1; continue;
130
+ }
131
+ }
132
+ }
133
+ inner += template[i++];
134
+ }
135
+ if (i < template.length) i++; // skip closing quote
136
+ if (hasDyn) {
137
+ dynAttrs.push('`' + inner + '`');
138
+ result += `${attrName}="__soladyn_${dynAttrs.length - 1}__"`;
139
+ } else {
140
+ result += `${attrName}="${inner}"`;
141
+ }
142
+ } else {
143
+ result += `${attrName}=`;
144
+ }
145
+ continue;
146
+ }
147
+ if (i < template.length && template[i] === '/') { result += '/'; i++; }
148
+ if (i < template.length && template[i] === '>') { result += '>'; i++; }
149
+ continue;
150
+ }
151
+ result += template[i++];
152
+ }
153
+ return result;
154
+ }
155
+
156
+ // Replace {expr} in text content with <sola-expr> tags, using balanced-brace
157
+ // matching so expressions like {fn({key: val})} work correctly.
55
158
  function extractExpressions(text) {
56
- return text.replace(/{([^{}]+)}/g, (match, expr) => {
57
- const trimmed = expr.trim();
58
- if (trimmed.startsWith('#') || trimmed.startsWith('/') || trimmed.startsWith(':')) {
59
- return match;
159
+ let result = '';
160
+ let i = 0;
161
+ while (i < text.length) {
162
+ if (text[i] === '{') {
163
+ const closeIdx = matchBrace(text, i);
164
+ if (closeIdx === -1) { result += text[i++]; continue; }
165
+ const inner = text.slice(i + 1, closeIdx).trim();
166
+ if (inner.startsWith('#') || inner.startsWith('/') || inner.startsWith(':')) {
167
+ result += text.slice(i, closeIdx + 1); i = closeIdx + 1;
168
+ } else {
169
+ result += `<sola-expr expr="${inner.replace(/"/g, '&quot;')}"></sola-expr>`;
170
+ i = closeIdx + 1;
171
+ }
172
+ } else {
173
+ result += text[i++];
60
174
  }
61
- return `<sola-expr expr="${trimmed.replace(/"/g, '&quot;')}"></sola-expr>`;
62
- });
175
+ }
176
+ return result;
177
+ }
178
+
179
+ // Strip TypeScript type annotations so Acorn (plain JS parser) can handle
180
+ // script blocks written in TS. Covers the common patterns: type annotations,
181
+ // interface/type declarations, angle-bracket generics, and `as` casts.
182
+ function stripTypeScript(code) {
183
+ // Remove interface and type alias declarations
184
+ code = code.replace(/^\s*(export\s+)?(interface|type)\s+\w[\s\S]*?(?=\n(?:export|const|let|var|function|class|\/\/|$))/gm, '');
185
+ // Remove inline type annotations: `: Type` before = , ) , ; , , or end of line
186
+ code = code.replace(/:\s*[\w<>\[\]|&{},\s.?]+(?=[=,);{}\n])/g, '');
187
+ // Remove `as Type` casts
188
+ code = code.replace(/\s+as\s+[\w<>\[\]|&{}.?]+/g, '');
189
+ // Remove generic type parameters from function/class declarations
190
+ code = code.replace(/<[^>()=]+>(?=\s*\()/g, '');
191
+ return code;
63
192
  }
64
193
 
65
194
  export function compile(source, options = {}) {
@@ -69,6 +198,11 @@ export function compile(source, options = {}) {
69
198
  const scopedCss = hasStyles ? scopeStyles(styleContent, scopeHash) : '';
70
199
 
71
200
  let rawTemplate = rawTemplateSource;
201
+
202
+ // Pre-process attribute-bound expressions BEFORE extractExpressions using
203
+ // balanced-brace matching (handles nested object literals, ternaries, etc.)
204
+ const dynAttrs = [];
205
+ rawTemplate = preprocessTemplate(rawTemplate, dynAttrs);
72
206
  rawTemplate = extractExpressions(rawTemplate);
73
207
 
74
208
  // Pre-process logic blocks: structured if-then-else
@@ -76,8 +210,8 @@ export function compile(source, options = {}) {
76
210
  rawTemplate = rawTemplate
77
211
  .replace(/{#if\s+([\s\S]*?)}([\s\S]*?){:else}([\s\S]*?){\/if}/g, '<sola-if condition="$1"><sola-then>$2</sola-then><sola-else>$3</sola-else></sola-if>')
78
212
  .replace(/{#if\s+([\s\S]*?)}([\s\S]*?){\/if}/g, '<sola-if condition="$1"><sola-then>$2</sola-then></sola-if>')
79
- .replace(/{#each\s+([\s\S]*?)\s+as\s+([\w]+)(?:\s*,\s*([\w]+))?}/g,
80
- (_, arr, item, index) => `<sola-each array="${arr}" item="${item}"${index ? ` index="${index}"` : ''}>`)
213
+ .replace(/{#each\s+([\s\S]*?)\s+as\s+([\w]+)(?:\s*,\s*([\w]+))?(?:\s*\(([^)]+)\))?}/g,
214
+ (_, arr, item, index, key) => `<sola-each array="${arr}" item="${item}"${index ? ` index="${index}"` : ''}${key ? ` key="${key.trim()}"` : ''}>`)
81
215
  .replace(/{\/each}/g, '</sola-each>');
82
216
 
83
217
  // Parse HTML AST
@@ -109,14 +243,16 @@ export function compile(source, options = {}) {
109
243
  parser.end();
110
244
 
111
245
  // Parse JS AST with Acorn
112
- let jsOutput = scriptContent;
246
+ // Strip TS first — AST positions and all slice() calls operate on the stripped source
247
+ const strippedScript = scriptContent.trim() ? stripTypeScript(scriptContent) : scriptContent;
248
+ let jsOutput = strippedScript;
113
249
  const stateVars = new Set();
114
250
  const exportedProps = [];
115
251
  const componentImports = [];
116
252
 
117
- if (scriptContent.trim()) {
253
+ if (strippedScript.trim()) {
118
254
  try {
119
- const ast = acorn.parse(scriptContent, { ecmaVersion: 'latest', sourceType: 'module' });
255
+ const ast = acorn.parse(strippedScript, { ecmaVersion: 'latest', sourceType: 'module' });
120
256
  const edits = [];
121
257
 
122
258
  function walkAST(node, parent) {
@@ -143,12 +279,12 @@ export function compile(source, options = {}) {
143
279
  node.declaration.declarations.forEach(decl => {
144
280
  const name = decl.id.name;
145
281
  const defaultVal = decl.init
146
- ? scriptContent.slice(decl.init.start, decl.init.end)
282
+ ? strippedScript.slice(decl.init.start, decl.init.end)
147
283
  : 'undefined';
148
284
  exportedProps.push({ name, default: defaultVal });
149
285
  edits.push({
150
286
  start: node.start,
151
- end: node.start + 7,
287
+ end: node.end,
152
288
  replacement: ''
153
289
  });
154
290
  });
@@ -163,7 +299,7 @@ export function compile(source, options = {}) {
163
299
  const varName = decl.id.name;
164
300
  stateVars.add(varName);
165
301
  const initArg = decl.init.arguments[0];
166
- const initVal = initArg ? scriptContent.slice(initArg.start, initArg.end) : 'undefined';
302
+ const initVal = initArg ? strippedScript.slice(initArg.start, initArg.end) : 'undefined';
167
303
  edits.push({
168
304
  start: node.start,
169
305
  end: node.end,
@@ -172,15 +308,18 @@ export function compile(source, options = {}) {
172
308
  } else if (callee.type === 'Identifier' && callee.name === '$derived') {
173
309
  const varName = decl.id.name;
174
310
  const expr = decl.init.arguments[0];
175
- const exprSrc = expr ? scriptContent.slice(expr.start, expr.end) : 'undefined';
311
+ const exprSrc = expr ? strippedScript.slice(expr.start, expr.end) : 'undefined';
312
+ const isFn = expr && (expr.type === 'ArrowFunctionExpression' || expr.type === 'FunctionExpression');
176
313
  edits.push({
177
314
  start: node.start,
178
315
  end: node.end,
179
- replacement: `const ${varName} = createDerived(() => ${exprSrc});`
316
+ replacement: isFn
317
+ ? `const ${varName} = createDerived(${exprSrc});`
318
+ : `const ${varName} = createDerived(() => ${exprSrc});`
180
319
  });
181
320
  } else if (callee.type === 'Identifier' && callee.name === '$effect') {
182
321
  const body = decl.init.arguments[0];
183
- const bodySrc = body ? scriptContent.slice(body.start, body.end) : '() => {}';
322
+ const bodySrc = body ? strippedScript.slice(body.start, body.end) : '() => {}';
184
323
  edits.push({
185
324
  start: node.start,
186
325
  end: node.end,
@@ -188,10 +327,10 @@ export function compile(source, options = {}) {
188
327
  });
189
328
  } else if (callee.type === 'Identifier' && callee.name === '$intent') {
190
329
  const promptArg = decl.init.arguments[0]
191
- ? scriptContent.slice(decl.init.arguments[0].start, decl.init.arguments[0].end)
330
+ ? strippedScript.slice(decl.init.arguments[0].start, decl.init.arguments[0].end)
192
331
  : "''";
193
332
  const fallbackArg = decl.init.arguments[1]
194
- ? scriptContent.slice(decl.init.arguments[1].start, decl.init.arguments[1].end)
333
+ ? strippedScript.slice(decl.init.arguments[1].start, decl.init.arguments[1].end)
195
334
  : 'null';
196
335
  edits.push({
197
336
  start: node.start,
@@ -200,10 +339,10 @@ export function compile(source, options = {}) {
200
339
  });
201
340
  } else if (callee.type === 'Identifier' && callee.name === '$data') {
202
341
  const sourceArg = decl.init.arguments[0]
203
- ? scriptContent.slice(decl.init.arguments[0].start, decl.init.arguments[0].end)
342
+ ? strippedScript.slice(decl.init.arguments[0].start, decl.init.arguments[0].end)
204
343
  : "''";
205
344
  const optsArg = decl.init.arguments[1]
206
- ? ', ' + scriptContent.slice(decl.init.arguments[1].start, decl.init.arguments[1].end)
345
+ ? ', ' + strippedScript.slice(decl.init.arguments[1].start, decl.init.arguments[1].end)
207
346
  : '';
208
347
  edits.push({
209
348
  start: node.start,
@@ -220,7 +359,7 @@ export function compile(source, options = {}) {
220
359
  node.left.type === 'Identifier' &&
221
360
  stateVars.has(node.left.name)) {
222
361
  const name = node.left.name;
223
- const rightSource = scriptContent.slice(node.right.start, node.right.end);
362
+ const rightSource = strippedScript.slice(node.right.start, node.right.end);
224
363
  if (node.operator === '=') {
225
364
  edits.push({ start: node.start, end: node.end, replacement: `set_${name}(${rightSource})` });
226
365
  } else {
@@ -260,7 +399,9 @@ export function compile(source, options = {}) {
260
399
  jsOutput = jsOutput.slice(0, edit.start) + edit.replacement + jsOutput.slice(edit.end);
261
400
  }
262
401
  } catch (err) {
263
- console.warn('[sola compiler] Acorn parse warning:', err.message);
402
+ const loc = err.loc ? ` (line ${err.loc.line}, col ${err.loc.column})` : '';
403
+ const filePath = typeof options === 'string' ? options : (options.filename || '<script>');
404
+ throw new Error(`[sola compiler] Parse error in ${filePath}${loc}:\n ${err.message}`);
264
405
  }
265
406
  }
266
407
 
@@ -336,26 +477,53 @@ export function compile(source, options = {}) {
336
477
  return;
337
478
  }
338
479
 
339
- // {#each} (Rendering in correct ascending order)
480
+ // {#each} with optional keyed diffing: {#each items as item (item.id)}
340
481
  if (node.name === 'sola-each') {
341
482
  const eid = `e${uid++}`;
483
+ const keyExpr = node.attribs.key || null;
342
484
  domCode += ` const ${eid}_a = document.createComment('each');\n`;
343
485
  domCode += ` ${parentVar}.appendChild(${eid}_a);\n`;
344
- domCode += ` let ${eid}_els = [];\n`;
345
- domCode += ` createEffect(() => {\n`;
346
- domCode += ` ${eid}_els.forEach(e => e.remove()); ${eid}_els = [];\n`;
347
- domCode += ` const _items = ${node.attribs.array} || [];\n`;
348
- domCode += ` const f = document.createDocumentFragment();\n`;
349
- domCode += ` for (let _i = 0; _i < _items.length; _i++) {\n`;
350
- domCode += ` const ${node.attribs.item} = _items[_i];\n`;
351
- if (node.attribs.index) {
352
- domCode += ` const ${node.attribs.index} = _i;\n`;
486
+ if (keyExpr) {
487
+ // Keyed: maintain a Map<key, Node[]> and reconcile on each run
488
+ domCode += ` let ${eid}_keyMap = new Map();\n`;
489
+ domCode += ` createEffect(() => {\n`;
490
+ domCode += ` const _items = ${node.attribs.array} || [];\n`;
491
+ domCode += ` const _nextKeys = _items.map((${node.attribs.item}, _i) => String(${keyExpr}));\n`;
492
+ domCode += ` // Remove stale keys\n`;
493
+ domCode += ` for (const [_k, _nodes] of ${eid}_keyMap) {\n`;
494
+ domCode += ` if (!_nextKeys.includes(_k)) { _nodes.forEach(n => n.remove()); ${eid}_keyMap.delete(_k); }\n`;
495
+ domCode += ` }\n`;
496
+ domCode += ` // Insert/reorder items\n`;
497
+ domCode += ` let _anchor = ${eid}_a.nextSibling;\n`;
498
+ domCode += ` for (let _i = 0; _i < _items.length; _i++) {\n`;
499
+ domCode += ` const ${node.attribs.item} = _items[_i];\n`;
500
+ if (node.attribs.index) domCode += ` const ${node.attribs.index} = _i;\n`;
501
+ domCode += ` const _key = String(${keyExpr});\n`;
502
+ domCode += ` if (!${eid}_keyMap.has(_key)) {\n`;
503
+ domCode += ` const f = document.createDocumentFragment();\n`;
504
+ node.children.forEach(child => emitNode(child, 'f'));
505
+ domCode += ` const _nodes = Array.from(f.childNodes);\n`;
506
+ domCode += ` ${eid}_keyMap.set(_key, _nodes);\n`;
507
+ domCode += ` ${eid}_a.parentNode.insertBefore(f, _anchor);\n`;
508
+ domCode += ` }\n`;
509
+ domCode += ` }\n`;
510
+ domCode += ` });\n`;
511
+ } else {
512
+ // Unkeyed: full teardown + rebuild
513
+ domCode += ` let ${eid}_els = [];\n`;
514
+ domCode += ` createEffect(() => {\n`;
515
+ domCode += ` ${eid}_els.forEach(e => e.remove()); ${eid}_els = [];\n`;
516
+ domCode += ` const _items = ${node.attribs.array} || [];\n`;
517
+ domCode += ` const f = document.createDocumentFragment();\n`;
518
+ domCode += ` for (let _i = 0; _i < _items.length; _i++) {\n`;
519
+ domCode += ` const ${node.attribs.item} = _items[_i];\n`;
520
+ if (node.attribs.index) domCode += ` const ${node.attribs.index} = _i;\n`;
521
+ node.children.forEach(child => emitNode(child, 'f'));
522
+ domCode += ` }\n`;
523
+ domCode += ` Array.from(f.childNodes).forEach(n => ${eid}_els.push(n));\n`;
524
+ domCode += ` ${eid}_a.parentNode.insertBefore(f, ${eid}_a.nextSibling);\n`;
525
+ domCode += ` });\n`;
353
526
  }
354
- node.children.forEach(child => emitNode(child, 'f'));
355
- domCode += ` }\n`;
356
- domCode += ` Array.from(f.childNodes).forEach(n => ${eid}_els.push(n));\n`;
357
- domCode += ` ${eid}_a.parentNode.insertBefore(f, ${eid}_a.nextSibling);\n`;
358
- domCode += ` });\n`;
359
527
  return;
360
528
  }
361
529
 
@@ -367,15 +535,31 @@ export function compile(source, options = {}) {
367
535
  domCode += ` ${id}.classList.add('${scopeHash}');\n`;
368
536
  }
369
537
 
538
+ const BOOLEAN_ATTRS = new Set(['disabled', 'checked', 'selected', 'readonly', 'required', 'multiple', 'open']);
370
539
  for (const [key, val] of Object.entries(node.attribs || {})) {
540
+ // Dynamic attribute pre-processed from {expr} in attribute values
541
+ const dynMatch = /^__soladyn_(\d+)__$/.exec(val);
542
+ if (dynMatch) {
543
+ const expr = dynAttrs[parseInt(dynMatch[1])];
544
+ if (BOOLEAN_ATTRS.has(key)) {
545
+ domCode += ` createEffect(() => { const _v = !!(${expr}); if (_v) ${id}.setAttribute('${key}', ''); else ${id}.removeAttribute('${key}'); });\n`;
546
+ } else if (key === 'class' || key === 'className') {
547
+ const suffix = hasStyles ? ` + ' ${scopeHash}'` : '';
548
+ domCode += ` createEffect(() => { ${id}.className = (${expr})${suffix}; });\n`;
549
+ } else {
550
+ domCode += ` createEffect(() => { ${id}.setAttribute('${key}', String(${expr})); });\n`;
551
+ }
552
+ continue;
553
+ }
371
554
  if (key.startsWith('on:')) {
372
555
  const eventName = key.slice(3);
373
556
  if (!/^[a-zA-Z][a-zA-Z0-9]*$/.test(eventName)) continue;
374
- domCode += ` ${id}.addEventListener('${eventName}', (e) => { ${val} });\n`;
557
+ // val is the handler expression (pre-processed from {expr}), pass directly
558
+ domCode += ` ${id}.addEventListener('${eventName}', ${val});\n`;
375
559
  } else if (key.startsWith('on')) {
376
560
  const eventName = key.slice(2).toLowerCase();
377
561
  if (!/^[a-zA-Z][a-zA-Z0-9]*$/.test(eventName)) continue;
378
- domCode += ` ${id}.addEventListener('${eventName}', (e) => { ${val} });\n`;
562
+ domCode += ` ${id}.addEventListener('${eventName}', ${val});\n`;
379
563
  } else if (key.startsWith('bind:')) {
380
564
  const prop = key.slice(5);
381
565
  if (prop === 'value') {
@@ -402,7 +586,7 @@ export function compile(source, options = {}) {
402
586
 
403
587
  // Assemble final output module
404
588
  let output = '';
405
- output += `// Compiled by @sola-air-ui/compiler v1.0.0\n`;
589
+ output += `// Compiled by @sola-air-ui/compiler v1.0.2\n`;
406
590
  output += `import { createSignal, createDerived, createEffect, createIntent, createData, onMount, onDestroy, pushContext, popContext, __flush_mounts, __flush_destroys } from '@sola-air-ui/core';\n`;
407
591
 
408
592
  for (const imp of componentImports) {