@sola-air-ui/compiler 1.4.1 → 1.5.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/README.md CHANGED
@@ -12,9 +12,12 @@ npm install @sola-air-ui/compiler
12
12
 
13
13
  ```js
14
14
  import { compile } from '@sola-air-ui/compiler';
15
+ import { checkSource, checkFiles } from '@sola-air-ui/compiler/check';
15
16
  import { readFileSync } from 'node:fs';
16
17
 
17
18
  const source = readFileSync('App.sola', 'utf-8');
19
+ const diagnostics = checkSource(source, 'App.sola');
20
+
18
21
  const result = compile(source, {
19
22
  target: 'esm', // or 'iife'
20
23
  exportName: 'App', // used for iife builds
@@ -23,26 +26,30 @@ const result = compile(source, {
23
26
 
24
27
  console.log(result.code); // compiled JS
25
28
  console.log(result.css); // scoped CSS
29
+ console.log(result.dts); // generated .d.ts
26
30
  ```
27
31
 
28
32
  ## CLI
29
33
 
30
34
  ```bash
31
35
  npx sola build App.sola
36
+ npx sola-check src/
32
37
  ```
33
38
 
39
+ `sola-check` validates `.sola` files on disk (pass `--json` for machine-readable output), reporting syntax errors (`SOLA000`) and unbalanced or malformed `{#if}` / `{#each}` / `{:else}` blocks (`SOLA001`).
40
+
34
41
  ## What a `.sola` file looks like
35
42
 
36
43
  ```html
37
44
  <script>
38
45
  let count = $state(0);
39
- let doubled = $derived(() => count() * 2);
46
+ let doubled = $derived(count * 2);
40
47
  </script>
41
48
 
42
49
  <div>
43
- <h1>Count: {count()}</h1>
44
- <p>Doubled: {doubled()}</p>
45
- <button onclick={() => count(count() + 1)}>+1</button>
50
+ <h1>Count: {count}</h1>
51
+ <p>Doubled: {doubled}</p>
52
+ <button onclick={() => count++}>+1</button>
46
53
  </div>
47
54
 
48
55
  <style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sola-air-ui/compiler",
3
- "version": "1.4.1",
3
+ "version": "1.5.1",
4
4
  "description": "Compiler for Sola single-file components (.sola) \u2014 transforms template + script + style to ES modules",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/check.js CHANGED
@@ -28,6 +28,23 @@ function position(source, index) {
28
28
  return { line, column: index - before.lastIndexOf('\n') };
29
29
  }
30
30
 
31
+ function matchBrace(text, openPos) {
32
+ let depth = 0;
33
+ let inStr = null;
34
+ for (let i = openPos; i < text.length; i++) {
35
+ const ch = text[i];
36
+ if (inStr) {
37
+ if (ch === '\\') { i++; continue; }
38
+ if (ch === inStr) inStr = null;
39
+ continue;
40
+ }
41
+ if (ch === '"' || ch === "'" || ch === '`') { inStr = ch; continue; }
42
+ if (ch === '{') depth++;
43
+ else if (ch === '}') { if (--depth === 0) return i; }
44
+ }
45
+ return -1;
46
+ }
47
+
31
48
  /**
32
49
  * Check one component's source. Returns diagnostics:
33
50
  * `{ file, line, column, severity: 'error' | 'warning', code, message }`.
@@ -58,10 +75,40 @@ export function checkSource(source, filename = 'component.sola') {
58
75
  while ((match = tag.exec(template))) {
59
76
  const kind = match[1];
60
77
  const at = match.index;
61
- if (kind === '#if' || kind === '#each') {
62
- stack.push({ kind: kind.slice(1), at });
78
+ const closeIdx = matchBrace(template, at);
79
+ if (kind === '#if') {
80
+ stack.push({ kind: 'if', at });
81
+ if (closeIdx !== -1) {
82
+ const cond = template.slice(at + 4, closeIdx).trim();
83
+ if (!cond) {
84
+ add(at, 'error', 'SOLA001', '`{#if}` is missing a condition. It renders as literal text.');
85
+ }
86
+ }
87
+ } else if (kind === '#each') {
88
+ stack.push({ kind: 'each', at });
89
+ if (closeIdx !== -1) {
90
+ const head = template.slice(at, closeIdx + 1);
91
+ const parsed = /^\{#each\s+([\s\S]*?)\s+as\s+([\w$]+)(?:\s*,\s*([\w$]+))?(?:\s*\(([\s\S]+)\))?\}$/.exec(head);
92
+ if (!parsed || !parsed[1].trim()) {
93
+ add(at, 'error', 'SOLA001', '`{#each}` is missing `as <item>` (expected `{#each list as item}`). It renders as literal text.');
94
+ }
95
+ }
63
96
  } else if (kind === ':else') {
64
- if (stack.at(-1)?.kind !== 'if') add(at, 'error', 'SOLA001', '`{:else}` outside an `{#if}` block. It renders as literal text.');
97
+ if (stack.at(-1)?.kind !== 'if') {
98
+ add(at, 'error', 'SOLA001', '`{:else}` outside an `{#if}` block. It renders as literal text.');
99
+ } else if (closeIdx !== -1) {
100
+ const inner = template.slice(at + 6, closeIdx);
101
+ if (inner.trim() !== '') {
102
+ if (/^\s+if\b/.test(inner)) {
103
+ const cond = inner.replace(/^\s+if\b/, '').trim();
104
+ if (!cond) {
105
+ add(at, 'error', 'SOLA001', '`{:else if}` is missing a condition. It renders as literal text.');
106
+ }
107
+ } else {
108
+ add(at, 'error', 'SOLA001', '`{:else}` unexpected token (use `{:else}` or `{:else if <condition>}`). It renders as literal text.');
109
+ }
110
+ }
111
+ }
65
112
  } else {
66
113
  const want = kind.slice(1);
67
114
  const open = stack.pop();
package/src/index.js CHANGED
@@ -398,7 +398,7 @@ function desugarElseIf(template) {
398
398
  if (text.startsWith(OPEN, i)) { depth++; i += OPEN.length; continue; }
399
399
  if (text.startsWith(CLOSE, i)) { depth--; i += CLOSE.length; continue; }
400
400
  if (depth === 0 && text.startsWith('{:else', i)) {
401
- const end = text.indexOf('}', i);
401
+ const end = matchBrace(text, i);
402
402
  if (end === -1) return null;
403
403
  const inner = text.slice(i + '{:else'.length, end);
404
404
  if (/^\s+if\s+/.test(inner)) {
@@ -481,7 +481,7 @@ function transformBlocks(template) {
481
481
  let i = 0;
482
482
  while (i < text.length) {
483
483
  if (text.startsWith('{#if', i)) {
484
- const condEnd = text.indexOf('}', i);
484
+ const condEnd = matchBrace(text, i);
485
485
  if (condEnd === -1) { out += text[i++]; continue; }
486
486
  const condition = text.slice(i + 4, condEnd);
487
487
  const end = matchEnd(text, i, '{#if', '{/if}');
@@ -500,10 +500,10 @@ function transformBlocks(template) {
500
500
  }
501
501
 
502
502
  if (text.startsWith('{#each', i)) {
503
- const headEnd = text.indexOf('}', i);
503
+ const headEnd = matchBrace(text, i);
504
504
  if (headEnd === -1) { out += text[i++]; continue; }
505
505
  const head = text.slice(i, headEnd + 1);
506
- const parsed = /^{#each\s+([\s\S]*?)\s+as\s+([\w$]+)(?:\s*,\s*([\w$]+))?(?:\s*\(([^)]+)\))?}$/.exec(head);
506
+ const parsed = /^{#each\s+([\s\S]*?)\s+as\s+([\w$]+)(?:\s*,\s*([\w$]+))?(?:\s*\(([\s\S]+)\))?}$/.exec(head);
507
507
  const end = matchEnd(text, i, '{#each', '{/each}');
508
508
  if (!parsed || end === -1) { out += text[i++]; continue; }
509
509
 
@@ -555,14 +555,189 @@ function extractExpressions(text) {
555
555
  // script blocks written in TS. Covers the common patterns: type annotations,
556
556
  // interface/type declarations, angle-bracket generics, and `as` casts.
557
557
  function stripTypeScript(code) {
558
- // Remove interface and type alias declarations
559
- code = code.replace(/^\s*(export\s+)?(interface|type)\s+\w[\s\S]*?(?=\n(?:export|const|let|var|function|class|\/\/|$))/gm, '');
560
- // Remove inline type annotations: `: Type` before = , ) , ; , , or end of line
561
- code = code.replace(/:\s*[\w<>\[\]|&{},\s.?]+(?=[=,);{}\n])/g, '');
562
- // Remove `as Type` casts
563
- code = code.replace(/\s+as\s+[\w<>\[\]|&{}.?]+/g, '');
564
- // Remove generic type parameters from function/class declarations
565
- code = code.replace(/<[^>()=]+>(?=\s*\()/g, '');
558
+ const blankKeepNewlines = (str) => str.replace(/[^\n]/g, ' ');
559
+
560
+ // 1. Remove interface declarations using balanced brace matching so statements
561
+ // following an interface ($effect, onMount, etc.) are never swallowed.
562
+ const ifaceRe = /^\s*(?:export\s+)?interface\s+[A-Za-z_$][\w$]*(?:\s*<[^>]*>)?(?:\s+extends\s+[^{]+)?\s*\{/gm;
563
+ let m;
564
+ while ((m = ifaceRe.exec(code)) !== null) {
565
+ const braceStart = m.index + m[0].lastIndexOf('{');
566
+ const braceEnd = matchBrace(code, braceStart);
567
+ if (braceEnd === -1) break;
568
+ let end = braceEnd + 1;
569
+ if (code[end] === ';') end++;
570
+ code = code.slice(0, m.index) + blankKeepNewlines(code.slice(m.index, end)) + code.slice(end);
571
+ ifaceRe.lastIndex = end;
572
+ }
573
+
574
+ // 2. Remove type alias declarations (respecting balanced {}, (), [], <>)
575
+ const typeRe = /^\s*(?:export\s+)?type\s+[A-Za-z_$][\w$]*(?:\s*<[^>]*>)?\s*=\s*/gm;
576
+ while ((m = typeRe.exec(code)) !== null) {
577
+ let i = m.index + m[0].length;
578
+ let depth = 0;
579
+ let inStr = null;
580
+ while (i < code.length) {
581
+ const ch = code[i];
582
+ if (inStr) {
583
+ if (ch === '\\') { i += 2; continue; }
584
+ if (ch === inStr) inStr = null;
585
+ i++;
586
+ continue;
587
+ }
588
+ if (ch === '"' || ch === "'" || ch === '`') { inStr = ch; i++; continue; }
589
+ if (ch === '{' || ch === '(' || ch === '[') depth++;
590
+ else if (ch === '}' || ch === ')' || ch === ']') depth = Math.max(0, depth - 1);
591
+ else if (depth === 0 && ch === ';') { i++; break; }
592
+ else if (depth === 0 && ch === '\n') {
593
+ const rest = code.slice(i + 1).match(/^\s*([|&])/);
594
+ if (!rest) break;
595
+ }
596
+ i++;
597
+ }
598
+ code = code.slice(0, m.index) + blankKeepNewlines(code.slice(m.index, i)) + code.slice(i);
599
+ typeRe.lastIndex = i;
600
+ }
601
+
602
+ // Helper to scan a type expression until a top-level delimiter in stopChars
603
+ function scanTypeEnd(str, start, stopChars) {
604
+ let i = start;
605
+ let depth = 0;
606
+ let angleDepth = 0;
607
+ let inStr = null;
608
+ while (i < str.length) {
609
+ const ch = str[i];
610
+ if (inStr) {
611
+ if (ch === '\\') { i += 2; continue; }
612
+ if (ch === inStr) inStr = null;
613
+ i++;
614
+ continue;
615
+ }
616
+ if (ch === '"' || ch === "'" || ch === '`') { inStr = ch; i++; continue; }
617
+ if (ch === '=' && str[i + 1] === '>') { i += 2; continue; }
618
+ if (ch === '<') { angleDepth++; i++; continue; }
619
+ if (ch === '>' && angleDepth > 0) { angleDepth--; i++; continue; }
620
+ if (ch === '{' || ch === '(' || ch === '[') { depth++; i++; continue; }
621
+ if (ch === '}' || ch === ')' || ch === ']') {
622
+ if (depth === 0 && angleDepth === 0 && stopChars.has(ch)) return i;
623
+ depth = Math.max(0, depth - 1);
624
+ i++;
625
+ continue;
626
+ }
627
+ if (depth === 0 && angleDepth === 0 && stopChars.has(ch)) return i;
628
+ i++;
629
+ }
630
+ return i;
631
+ }
632
+
633
+ // 3. Remove variable declaration type annotations: (const|let|var) id: Type
634
+ // Never touches object literals or ternaries on the RHS of '='.
635
+ const varRe = /\b(const|let|var)(\s+[A-Za-z_$][\w$]*)(\??\s*:\s*)/g;
636
+ const varStops = new Set(['=', ';', '\n']);
637
+ while ((m = varRe.exec(code)) !== null) {
638
+ const colonStart = m.index + m[1].length + m[2].length;
639
+ const typeStart = colonStart + m[3].length;
640
+ const typeEnd = scanTypeEnd(code, typeStart, varStops);
641
+ code = code.slice(0, colonStart) + blankKeepNewlines(code.slice(colonStart, typeEnd)) + code.slice(typeEnd);
642
+ varRe.lastIndex = colonStart;
643
+ }
644
+
645
+ // 4. Remove function / arrow parameter & return type annotations.
646
+ // Walk delimiter stack so we only strip 'param?: Type' when the innermost
647
+ // enclosing delimiter is '(' (never inside '{ a: 1, b: true }' object literals).
648
+ let out = '';
649
+ const delimStack = [];
650
+ let i = 0;
651
+ let inStr = null;
652
+ let inLineComment = false;
653
+ let inBlockComment = false;
654
+ const paramStops = new Set([',', ')', '=']);
655
+
656
+ while (i < code.length) {
657
+ const ch = code[i];
658
+ const next = code[i + 1];
659
+
660
+ if (inLineComment) {
661
+ out += ch;
662
+ if (ch === '\n') inLineComment = false;
663
+ i++;
664
+ continue;
665
+ }
666
+ if (inBlockComment) {
667
+ out += ch;
668
+ if (ch === '*' && next === '/') { out += '/'; i += 2; inBlockComment = false; continue; }
669
+ i++;
670
+ continue;
671
+ }
672
+ if (inStr) {
673
+ out += ch;
674
+ if (ch === '\\' && next !== undefined) { out += next; i += 2; continue; }
675
+ if (ch === inStr) inStr = null;
676
+ i++;
677
+ continue;
678
+ }
679
+ if (ch === '/' && next === '/') { inLineComment = true; out += '//'; i += 2; continue; }
680
+ if (ch === '/' && next === '*') { inBlockComment = true; out += '/*'; i += 2; continue; }
681
+ if (ch === '"' || ch === "'" || ch === '`') { inStr = ch; out += ch; i++; continue; }
682
+
683
+ if (ch === '(' || ch === '{' || ch === '[') {
684
+ delimStack.push(ch);
685
+ out += ch;
686
+ i++;
687
+ continue;
688
+ }
689
+ if (ch === ')' || ch === '}' || ch === ']') {
690
+ delimStack.pop();
691
+ out += ch;
692
+ i++;
693
+ // Check for return type annotation after ')': '): ReturnType {' or '): ReturnType =>'
694
+ if (ch === ')') {
695
+ const retMatch = /^(\s*:\s*)/.exec(code.slice(i));
696
+ if (retMatch) {
697
+ const typeStart = i + retMatch[1].length;
698
+ const typeEnd = scanTypeEnd(code, typeStart, new Set(['{', '=', ';', '\n']));
699
+ const after = code.slice(typeEnd);
700
+ // Only strip if followed by function body '{' or arrow '=>' (not a ternary branch)
701
+ if (/^\s*(\{|=>)/.test(after) && code.slice(typeStart, typeEnd).trim().length > 0) {
702
+ out += blankKeepNewlines(code.slice(i, typeEnd));
703
+ i = typeEnd;
704
+ }
705
+ }
706
+ }
707
+ continue;
708
+ }
709
+
710
+ // Inside '(...)', check for parameter type annotation after '(' or ','
711
+ if (delimStack[delimStack.length - 1] === '(') {
712
+ const prevNonWs = out.trimEnd().slice(-1);
713
+ if (prevNonWs === '(' || prevNonWs === ',') {
714
+ const paramMatch = /^(\s*(?:\.\.\.)?[A-Za-z_$][\w$]*)(\??\s*:\s*)/.exec(code.slice(i));
715
+ if (paramMatch) {
716
+ out += paramMatch[1];
717
+ const colonStart = i + paramMatch[1].length;
718
+ const typeStart = colonStart + paramMatch[2].length;
719
+ const typeEnd = scanTypeEnd(code, typeStart, paramStops);
720
+ out += blankKeepNewlines(code.slice(colonStart, typeEnd));
721
+ i = typeEnd;
722
+ continue;
723
+ }
724
+ }
725
+ }
726
+
727
+ out += ch;
728
+ i++;
729
+ }
730
+ code = out;
731
+
732
+ // 5. Remove 'as Type' casts outside import/export specifiers
733
+ code = code.replace(
734
+ /(\b(?:import|export)\s*\{[^}]*\})|\s+as\s+(?:const\b|[A-Za-z_$][\w$.<>\[\]|&?]*(?:\s*[|&]\s*[A-Za-z_$][\w$.<>\[\]|&?]*)*)/g,
735
+ (full, impExp) => impExp ? impExp : blankKeepNewlines(full)
736
+ );
737
+
738
+ // 6. Remove generic type parameters from function/class/call expressions: foo<T>(
739
+ code = code.replace(/(?<=[\w$])<[^>()=;]+>(?=\s*\()/g, (m) => blankKeepNewlines(m));
740
+
566
741
  return code;
567
742
  }
568
743
 
@@ -709,7 +884,10 @@ function emitReactiveEdits(rootNode, src, vars, edits, initialShadows) {
709
884
  const isMemberProp = p.type === 'MemberExpression' && p.property === node && !p.computed;
710
885
  const isCallee = (p.type === 'CallExpression' || p.type === 'NewExpression') && p.callee === node;
711
886
  const isAssignTarget = (p.type === 'AssignmentExpression' && p.left === node) || p.type === 'UpdateExpression';
712
- const isPropertyKey = p.type === 'Property' && p.key === node && !p.computed;
887
+ const isPropertyKey =
888
+ (p.type === 'Property' || p.type === 'MethodDefinition' || p.type === 'PropertyDefinition') &&
889
+ p.key === node &&
890
+ !p.computed;
713
891
  const isShorthand = p.type === 'Property' && p.shorthand && p.value === node;
714
892
  const isMemberObject = p.type === 'MemberExpression' && p.object === node;
715
893
 
@@ -836,9 +1014,13 @@ export function compile(source, options = {}) {
836
1014
  const hasStyles = styleContent.trim().length > 0;
837
1015
  const scopedCss = hasStyles ? scopeStyles(styleContent, scopeHash) : '';
838
1016
 
1017
+ // Strip HTML comments first so commented-out blocks or literal braces inside
1018
+ // comments never trigger block transforms or expression errors.
1019
+ let rawTemplate = rawTemplateSource.replace(/<!--[\s\S]*?-->/g, '');
1020
+
839
1021
  // Flatten else-if chains into nested if/else before anything else looks at
840
1022
  // the template, so the block handling below only ever sees the simple form.
841
- let rawTemplate = desugarElseIf(rawTemplateSource);
1023
+ rawTemplate = desugarElseIf(rawTemplate);
842
1024
 
843
1025
  // Pre-process attribute-bound expressions BEFORE extractExpressions using
844
1026
  // balanced-brace matching (handles nested object literals, ternaries, etc.)
@@ -1126,7 +1308,7 @@ export function compile(source, options = {}) {
1126
1308
  const rewrite = (expr) => transformTemplateExpression(expr, reactiveVars, locals);
1127
1309
 
1128
1310
  if (node.type === 'text') {
1129
- const text = node.data.replace(/\\/g, '\\\\').replace(/`/g, '\\`');
1311
+ const text = escapeTemplateLiteral(node.data);
1130
1312
  if (text.trim().length > 0) {
1131
1313
  const id = `t${uid++}`;
1132
1314
  domCode += ` const ${id} = document.createTextNode(\`${text}\`);\n`;
@@ -1400,10 +1582,16 @@ export function compile(source, options = {}) {
1400
1582
  } else if (key.startsWith('bind:')) {
1401
1583
  const prop = key.slice(5);
1402
1584
  // bind:value={name} names a signal; the marker resolves back to it.
1403
- const bound = resolveHandler(val);
1585
+ const bound = resolveHandler(val).replace(/^\{|\}$/g, '').trim();
1404
1586
  if (prop === 'value') {
1405
1587
  domCode += ` ${id}.addEventListener('input', (e) => { set_${bound}(e.target.value); });\n`;
1406
1588
  domCode += ` createEffect(() => { ${id}.value = ${bound}() ?? ''; });\n`;
1589
+ } else if (prop === 'this') {
1590
+ if (stateVars.has(bound)) {
1591
+ domCode += ` set_${bound}(${id});\n`;
1592
+ } else {
1593
+ domCode += ` ${bound} = ${id};\n`;
1594
+ }
1407
1595
  }
1408
1596
  } else if (key === 'class' || key === 'className') {
1409
1597
  // Static values: emit as a plain string literal. A template literal here
package/types/index.d.ts CHANGED
@@ -13,6 +13,8 @@ export interface CompileOptions {
13
13
  filename?: string;
14
14
  /** Emit a source map mapping generated JS back to the .sola file. Default true. */
15
15
  sourcemap?: boolean;
16
+ /** Also export `css` and `scopeHash` constants from ESM output for Shadow DOM hosts. */
17
+ exportStyles?: boolean;
16
18
  }
17
19
 
18
20
  export interface CompileResult {
@@ -24,6 +26,8 @@ export interface CompileResult {
24
26
  css: string;
25
27
  /** Source map for `code`, or null when `sourcemap` is false. */
26
28
  map: SourceMap | null;
29
+ /** Generated TypeScript declaration (.d.ts) for the compiled component. */
30
+ dts: string;
27
31
  }
28
32
 
29
33
  export interface SourceMap {
@@ -40,6 +44,6 @@ export function compile(source: string, options?: CompileOptions): CompileResult
40
44
 
41
45
  /** Mount function produced by compiling a .sola component. */
42
46
  export type SolaComponent<P = Record<string, unknown>> = (
43
- target: HTMLElement,
47
+ target: Element | ShadowRoot,
44
48
  props?: P
45
49
  ) => () => void;