@sola-air-ui/compiler 1.0.1 → 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 +196 -65
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sola-air-ui/compiler",
3
- "version": "1.0.1",
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
@@ -57,14 +57,138 @@ function extractScriptAndStyle(source) {
57
57
  return { script, style, template };
58
58
  }
59
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.
60
158
  function extractExpressions(text) {
61
- return text.replace(/{([^{}]+)}/g, (match, expr) => {
62
- const trimmed = expr.trim();
63
- if (trimmed.startsWith('#') || trimmed.startsWith('/') || trimmed.startsWith(':')) {
64
- 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++];
65
174
  }
66
- return `<sola-expr expr="${trimmed.replace(/"/g, '&quot;')}"></sola-expr>`;
67
- });
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;
68
192
  }
69
193
 
70
194
  export function compile(source, options = {}) {
@@ -75,34 +199,10 @@ export function compile(source, options = {}) {
75
199
 
76
200
  let rawTemplate = rawTemplateSource;
77
201
 
78
- // Pre-process attribute-bound expressions BEFORE extractExpressions.
79
- // extractExpressions replaces {expr} with <sola-expr> HTML tags, which breaks
80
- // htmlparser2 when those tags land inside attribute values.
81
- const dynAttrs = []; // expressions for __soladyn_N__ markers
82
-
83
- // Event handlers: on:event={expr} → on:event="expr"
84
- rawTemplate = rawTemplate.replace(/\bon:([\w]+)=\{([^}]+)\}/g, 'on:$1="$2"');
85
- // Bind: bind:prop={expr} → bind:prop="expr"
86
- rawTemplate = rawTemplate.replace(/\bbind:([\w]+)=\{([^}]+)\}/g, 'bind:$1="$2"');
87
- // Quoted string interpolation: attr="text {expr} text" → attr="__soladyn_N__"
88
- // Run iteratively to handle multiple {expr} per value.
89
- let _prev;
90
- do {
91
- _prev = rawTemplate;
92
- rawTemplate = rawTemplate.replace(/([\w:-]+)="([^"]*\{[^{}]+\}[^"]*)"/g, (_, attr, val) => {
93
- const jsExpr = '`' + val.replace(/\{([^{}]+)\}/g, (__, e) => `\${${e.trim()}}`) + '`';
94
- const i = dynAttrs.length;
95
- dynAttrs.push(jsExpr);
96
- return `${attr}="__soladyn_${i}__"`;
97
- });
98
- } while (rawTemplate !== _prev);
99
- // Unquoted attr expressions: attr={expr} → attr="__soladyn_N__"
100
- rawTemplate = rawTemplate.replace(/([\w:-]+)=\{([^{}]+)\}/g, (_, attr, expr) => {
101
- const i = dynAttrs.length;
102
- dynAttrs.push(expr.trim());
103
- return `${attr}="__soladyn_${i}__"`;
104
- });
105
-
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);
106
206
  rawTemplate = extractExpressions(rawTemplate);
107
207
 
108
208
  // Pre-process logic blocks: structured if-then-else
@@ -110,8 +210,8 @@ export function compile(source, options = {}) {
110
210
  rawTemplate = rawTemplate
111
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>')
112
212
  .replace(/{#if\s+([\s\S]*?)}([\s\S]*?){\/if}/g, '<sola-if condition="$1"><sola-then>$2</sola-then></sola-if>')
113
- .replace(/{#each\s+([\s\S]*?)\s+as\s+([\w]+)(?:\s*,\s*([\w]+))?}/g,
114
- (_, 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()}"` : ''}>`)
115
215
  .replace(/{\/each}/g, '</sola-each>');
116
216
 
117
217
  // Parse HTML AST
@@ -143,14 +243,16 @@ export function compile(source, options = {}) {
143
243
  parser.end();
144
244
 
145
245
  // Parse JS AST with Acorn
146
- 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;
147
249
  const stateVars = new Set();
148
250
  const exportedProps = [];
149
251
  const componentImports = [];
150
252
 
151
- if (scriptContent.trim()) {
253
+ if (strippedScript.trim()) {
152
254
  try {
153
- const ast = acorn.parse(scriptContent, { ecmaVersion: 'latest', sourceType: 'module' });
255
+ const ast = acorn.parse(strippedScript, { ecmaVersion: 'latest', sourceType: 'module' });
154
256
  const edits = [];
155
257
 
156
258
  function walkAST(node, parent) {
@@ -177,7 +279,7 @@ export function compile(source, options = {}) {
177
279
  node.declaration.declarations.forEach(decl => {
178
280
  const name = decl.id.name;
179
281
  const defaultVal = decl.init
180
- ? scriptContent.slice(decl.init.start, decl.init.end)
282
+ ? strippedScript.slice(decl.init.start, decl.init.end)
181
283
  : 'undefined';
182
284
  exportedProps.push({ name, default: defaultVal });
183
285
  edits.push({
@@ -197,7 +299,7 @@ export function compile(source, options = {}) {
197
299
  const varName = decl.id.name;
198
300
  stateVars.add(varName);
199
301
  const initArg = decl.init.arguments[0];
200
- const initVal = initArg ? scriptContent.slice(initArg.start, initArg.end) : 'undefined';
302
+ const initVal = initArg ? strippedScript.slice(initArg.start, initArg.end) : 'undefined';
201
303
  edits.push({
202
304
  start: node.start,
203
305
  end: node.end,
@@ -206,7 +308,7 @@ export function compile(source, options = {}) {
206
308
  } else if (callee.type === 'Identifier' && callee.name === '$derived') {
207
309
  const varName = decl.id.name;
208
310
  const expr = decl.init.arguments[0];
209
- const exprSrc = expr ? scriptContent.slice(expr.start, expr.end) : 'undefined';
311
+ const exprSrc = expr ? strippedScript.slice(expr.start, expr.end) : 'undefined';
210
312
  const isFn = expr && (expr.type === 'ArrowFunctionExpression' || expr.type === 'FunctionExpression');
211
313
  edits.push({
212
314
  start: node.start,
@@ -217,7 +319,7 @@ export function compile(source, options = {}) {
217
319
  });
218
320
  } else if (callee.type === 'Identifier' && callee.name === '$effect') {
219
321
  const body = decl.init.arguments[0];
220
- const bodySrc = body ? scriptContent.slice(body.start, body.end) : '() => {}';
322
+ const bodySrc = body ? strippedScript.slice(body.start, body.end) : '() => {}';
221
323
  edits.push({
222
324
  start: node.start,
223
325
  end: node.end,
@@ -225,10 +327,10 @@ export function compile(source, options = {}) {
225
327
  });
226
328
  } else if (callee.type === 'Identifier' && callee.name === '$intent') {
227
329
  const promptArg = decl.init.arguments[0]
228
- ? 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)
229
331
  : "''";
230
332
  const fallbackArg = decl.init.arguments[1]
231
- ? 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)
232
334
  : 'null';
233
335
  edits.push({
234
336
  start: node.start,
@@ -237,10 +339,10 @@ export function compile(source, options = {}) {
237
339
  });
238
340
  } else if (callee.type === 'Identifier' && callee.name === '$data') {
239
341
  const sourceArg = decl.init.arguments[0]
240
- ? 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)
241
343
  : "''";
242
344
  const optsArg = decl.init.arguments[1]
243
- ? ', ' + 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)
244
346
  : '';
245
347
  edits.push({
246
348
  start: node.start,
@@ -257,7 +359,7 @@ export function compile(source, options = {}) {
257
359
  node.left.type === 'Identifier' &&
258
360
  stateVars.has(node.left.name)) {
259
361
  const name = node.left.name;
260
- const rightSource = scriptContent.slice(node.right.start, node.right.end);
362
+ const rightSource = strippedScript.slice(node.right.start, node.right.end);
261
363
  if (node.operator === '=') {
262
364
  edits.push({ start: node.start, end: node.end, replacement: `set_${name}(${rightSource})` });
263
365
  } else {
@@ -297,7 +399,9 @@ export function compile(source, options = {}) {
297
399
  jsOutput = jsOutput.slice(0, edit.start) + edit.replacement + jsOutput.slice(edit.end);
298
400
  }
299
401
  } catch (err) {
300
- 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}`);
301
405
  }
302
406
  }
303
407
 
@@ -373,26 +477,53 @@ export function compile(source, options = {}) {
373
477
  return;
374
478
  }
375
479
 
376
- // {#each} (Rendering in correct ascending order)
480
+ // {#each} with optional keyed diffing: {#each items as item (item.id)}
377
481
  if (node.name === 'sola-each') {
378
482
  const eid = `e${uid++}`;
483
+ const keyExpr = node.attribs.key || null;
379
484
  domCode += ` const ${eid}_a = document.createComment('each');\n`;
380
485
  domCode += ` ${parentVar}.appendChild(${eid}_a);\n`;
381
- domCode += ` let ${eid}_els = [];\n`;
382
- domCode += ` createEffect(() => {\n`;
383
- domCode += ` ${eid}_els.forEach(e => e.remove()); ${eid}_els = [];\n`;
384
- domCode += ` const _items = ${node.attribs.array} || [];\n`;
385
- domCode += ` const f = document.createDocumentFragment();\n`;
386
- domCode += ` for (let _i = 0; _i < _items.length; _i++) {\n`;
387
- domCode += ` const ${node.attribs.item} = _items[_i];\n`;
388
- if (node.attribs.index) {
389
- 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`;
390
526
  }
391
- node.children.forEach(child => emitNode(child, 'f'));
392
- domCode += ` }\n`;
393
- domCode += ` Array.from(f.childNodes).forEach(n => ${eid}_els.push(n));\n`;
394
- domCode += ` ${eid}_a.parentNode.insertBefore(f, ${eid}_a.nextSibling);\n`;
395
- domCode += ` });\n`;
396
527
  return;
397
528
  }
398
529
 
@@ -455,7 +586,7 @@ export function compile(source, options = {}) {
455
586
 
456
587
  // Assemble final output module
457
588
  let output = '';
458
- output += `// Compiled by @sola-air-ui/compiler v1.0.1\n`;
589
+ output += `// Compiled by @sola-air-ui/compiler v1.0.2\n`;
459
590
  output += `import { createSignal, createDerived, createEffect, createIntent, createData, onMount, onDestroy, pushContext, popContext, __flush_mounts, __flush_destroys } from '@sola-air-ui/core';\n`;
460
591
 
461
592
  for (const imp of componentImports) {