@sola-air-ui/compiler 1.1.0 → 1.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/index.js +139 -13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sola-air-ui/compiler",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
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/index.js CHANGED
@@ -32,18 +32,81 @@ function hashString(str) {
32
32
  return 'sola-' + Math.abs(hash).toString(36);
33
33
  }
34
34
 
35
+ // Split on `sep` characters that are not inside (), [] or a string, so
36
+ // `:is(a, b)` and `:global(.x, .y)` are not torn apart at their commas.
37
+ function splitTopLevel(text, isSep) {
38
+ const parts = [];
39
+ let depth = 0, quote = null, start = 0;
40
+ for (let i = 0; i < text.length; i++) {
41
+ const c = text[i];
42
+ if (quote) { if (c === '\\') i++; else if (c === quote) quote = null; continue; }
43
+ if (c === '"' || c === "'") { quote = c; continue; }
44
+ if (c === '(' || c === '[') depth++;
45
+ else if (c === ')' || c === ']') depth--;
46
+ else if (depth === 0 && isSep(c)) { parts.push(text.slice(start, i)); start = i + 1; }
47
+ }
48
+ parts.push(text.slice(start));
49
+ return parts;
50
+ }
51
+
52
+ // Put the scope class on one compound selector, before any pseudo-class or
53
+ // pseudo-element (button:hover → button.hash:hover).
54
+ function addScopeToCompound(compound, scopeHash) {
55
+ const pseudoIdx = compound.search(/::?[\w-]/);
56
+ if (pseudoIdx > 0) {
57
+ return `${compound.slice(0, pseudoIdx)}.${scopeHash}${compound.slice(pseudoIdx)}`;
58
+ }
59
+ return `${compound}.${scopeHash}`;
60
+ }
61
+
62
+ // `:global(sel)` opts a piece of a selector out of scoping, so a scoped
63
+ // component can style markup it did not create (e.g. the output of {@html}):
64
+ // .msg :global(table) → .msg.hash table
65
+ // :global(body) → body
66
+ // The scope class always goes on the last compound that is not global.
67
+ function scopeSelector(selector, scopeHash) {
68
+ const tokens = []; // alternating compounds and combinators
69
+ let depth = 0, cur = '', comb = '';
70
+ const flushCompound = () => { if (cur) { tokens.push({ compound: cur }); cur = ''; } };
71
+ for (let i = 0; i < selector.length; i++) {
72
+ const c = selector[i];
73
+ if (c === '(' || c === '[') depth++;
74
+ else if (c === ')' || c === ']') depth--;
75
+ if (depth === 0 && /[\s>+~]/.test(c)) {
76
+ flushCompound();
77
+ comb += c;
78
+ continue;
79
+ }
80
+ if (comb) { tokens.push({ comb }); comb = ''; }
81
+ cur += c;
82
+ }
83
+ flushCompound();
84
+ if (comb) tokens.push({ comb });
85
+
86
+ let last = -1;
87
+ for (let i = 0; i < tokens.length; i++) {
88
+ const t = tokens[i];
89
+ if (t.compound === undefined) continue;
90
+ const m = t.compound.match(/^:global\(([\s\S]*)\)$/);
91
+ // A comma list must stay grouped, or `.a :global(.x, .y)` would turn into
92
+ // two selectors and leave `.y` outside `.a`.
93
+ const group = (inner) => (splitTopLevel(inner, c => c === ',').length > 1 ? `:is(${inner})` : inner);
94
+ if (m) { t.compound = group(m[1]); t.global = true; }
95
+ else if (t.compound.includes(':global(')) {
96
+ t.compound = t.compound.replace(/:global\(([\s\S]*?)\)/g, (_, inner) => group(inner));
97
+ t.global = true;
98
+ } else { last = i; }
99
+ }
100
+ if (last >= 0) tokens[last].compound = addScopeToCompound(tokens[last].compound, scopeHash);
101
+ return tokens.map(t => t.compound ?? t.comb).join('');
102
+ }
103
+
35
104
  function scopeSelectorList(selector, scopeHash) {
36
- return selector
37
- .split(',')
105
+ return splitTopLevel(selector, c => c === ',')
38
106
  .map(part => {
39
107
  const trimmed = part.trim();
40
108
  if (!trimmed) return part;
41
- // Insert scope hash before pseudo-class/element (e.g. button:hover → button.hash:hover)
42
- const pseudoIdx = trimmed.search(/::?[\w-]/);
43
- if (pseudoIdx > 0) {
44
- return `${trimmed.slice(0, pseudoIdx)}.${scopeHash}${trimmed.slice(pseudoIdx)}`;
45
- }
46
- return `${trimmed}.${scopeHash}`;
109
+ return scopeSelector(trimmed, scopeHash);
47
110
  })
48
111
  .join(', ');
49
112
  }
@@ -540,7 +603,7 @@ function collectReactiveVars(ast) {
540
603
  decl.init.callee.type === 'Identifier' && decl.id.type === 'Identifier') {
541
604
  const macro = decl.init.callee.name;
542
605
  if (macro === '$state' || macro === '$persist') vars.state.add(decl.id.name);
543
- else if (macro === '$derived') vars.derived.add(decl.id.name);
606
+ else if (macro === '$derived' || macro === '$lastEvent') vars.derived.add(decl.id.name);
544
607
  else if (macro === '$intent') vars.intent.add(decl.id.name);
545
608
  else if (macro === '$data') vars.data.add(decl.id.name);
546
609
  }
@@ -638,7 +701,7 @@ function emitReactiveEdits(rootNode, src, vars, edits, initialShadows) {
638
701
  const isMemberProp = p.type === 'MemberExpression' && p.property === node && !p.computed;
639
702
  const isCallee = (p.type === 'CallExpression' || p.type === 'NewExpression') && p.callee === node;
640
703
  const isAssignTarget = (p.type === 'AssignmentExpression' && p.left === node) || p.type === 'UpdateExpression';
641
- const isPropertyKey = p.type === 'Property' && p.key === node && !p.computed && !p.shorthand;
704
+ const isPropertyKey = p.type === 'Property' && p.key === node && !p.computed;
642
705
  const isShorthand = p.type === 'Property' && p.shorthand && p.value === node;
643
706
  const isMemberObject = p.type === 'MemberExpression' && p.object === node;
644
707
 
@@ -874,6 +937,16 @@ export function compile(source, options = {}) {
874
937
  removedRanges.push([node.start, node.end]);
875
938
  }
876
939
 
940
+ // $on('type', handler): subscribe to a bus event for the life of the
941
+ // component. Compiles to onDestroy(__solaBus.on(type, handler)). The handler
942
+ // stays in place so signals read inside it are rewritten like any other.
943
+ if (node.type === 'ExpressionStatement' && node.expression.type === 'CallExpression' &&
944
+ node.expression.callee.type === 'Identifier' && node.expression.callee.name === '$on') {
945
+ const call = node.expression;
946
+ edits.push({ start: call.callee.start, end: call.callee.end, replacement: 'onDestroy(__solaBus.on' });
947
+ edits.push({ start: call.end, end: call.end, replacement: ')' });
948
+ }
949
+
877
950
  // $state()/$derived()/$effect()/$intent()/$data() macro declarations.
878
951
  // Bracket edits (head + tail) leave the argument expression in place so
879
952
  // the reactive rewrite pass below can still auto-call signals inside it.
@@ -907,6 +980,16 @@ export function compile(source, options = {}) {
907
980
  } else {
908
981
  edits.push({ start: node.start, end: node.end, replacement: `const ${varName} = createDerived(() => undefined);` });
909
982
  }
983
+ } else if (macro === '$lastEvent') {
984
+ // let ctx = $lastEvent('type'): the latest payload of a bus event as a
985
+ // reactive accessor, released when the component is destroyed.
986
+ const varName = decl.id.name;
987
+ if (arg) {
988
+ edits.push({ start: node.start, end: arg.start, replacement: `const ${varName} = __solaBus.signal(` });
989
+ edits.push({ start: lastArg.end, end: node.end, replacement: `); onDestroy(${varName}.dispose);` });
990
+ } else {
991
+ edits.push({ start: node.start, end: node.end, replacement: `const ${varName} = () => undefined;` });
992
+ }
910
993
  } else if (macro === '$effect') {
911
994
  if (arg) {
912
995
  edits.push({ start: node.start, end: arg.start, replacement: `createEffect(` });
@@ -1274,20 +1357,24 @@ export function compile(source, options = {}) {
1274
1357
 
1275
1358
  // Assemble final output module
1276
1359
  const CORE_EXPORTS = 'createSignal, createDerived, createEffect, createIntent, createData, createPersistentSignal, onMount, onDestroy, pushContext, popContext, __flush_mounts, __flush_destroys';
1360
+ // The default bus is imported under an alias so a user variable named `bus`
1361
+ // cannot shadow what $on / $lastEvent compile to.
1362
+ const ESM_CORE_IMPORTS = CORE_EXPORTS + ', bus as __solaBus';
1363
+ const IIFE_CORE_PULL = CORE_EXPORTS + ', bus: __solaBus';
1277
1364
  let output = '';
1278
1365
  output += `// Compiled by @sola-air-ui/compiler v${COMPILER_VERSION}\n`;
1279
1366
 
1280
1367
  if (target === 'iife') {
1281
1368
  // ServiceNow / no-bundler mode: pull from window.SolaCore global
1282
1369
  output += `(function() {\n`;
1283
- output += `const { ${CORE_EXPORTS} } = window.SolaCore;\n`;
1370
+ output += `const { ${IIFE_CORE_PULL} } = window.SolaCore;\n`;
1284
1371
  for (const imp of componentImports) {
1285
1372
  output += `// Note: sub-component ${imp.localName} must be pre-loaded before this script\n`;
1286
1373
  }
1287
1374
  output += `\n`;
1288
1375
  output += `window['${options.exportName || 'SolaComponent'}'] = function mount(__target, props = {}) {\n`;
1289
1376
  } else {
1290
- output += `import { ${CORE_EXPORTS} } from '@sola-air-ui/core';\n`;
1377
+ output += `import { ${ESM_CORE_IMPORTS} } from '@sola-air-ui/core';\n`;
1291
1378
  for (const imp of componentImports) {
1292
1379
  output += `import ${imp.localName} from '${imp.path}';\n`;
1293
1380
  }
@@ -1336,6 +1423,13 @@ export function compile(source, options = {}) {
1336
1423
  output += `};\n`;
1337
1424
  if (target === 'iife') {
1338
1425
  output += `})();\n`;
1426
+ } else if (options.exportStyles) {
1427
+ // Opt-in: the stylesheet and its scope class as module exports, for hosts
1428
+ // that mount into a Shadow Root and adopt the CSS themselves (e.g. via
1429
+ // adoptedStyleSheets). Off by default so existing build scripts that append
1430
+ // their own `css` / `scopeHash` exports, or strip exports, keep working.
1431
+ output += `\nexport const scopeHash = ${JSON.stringify(scopeHash)};\n`;
1432
+ output += `export const css = \`${escapeTemplateLiteral(scopedCss || '')}\`;\n`;
1339
1433
  }
1340
1434
 
1341
1435
  // The reactive rewrite can add or remove characters within a line but never
@@ -1355,6 +1449,38 @@ export function compile(source, options = {}) {
1355
1449
  code: output,
1356
1450
  scopeHash,
1357
1451
  css: scopedCss,
1358
- map
1452
+ map,
1453
+ dts: buildDeclarations(exportedProps, !!options.exportStyles)
1359
1454
  };
1360
1455
  }
1456
+
1457
+ // Best-effort TypeScript type for a prop, from the literal its default is
1458
+ // declared with. Anything it cannot read confidently is `unknown`, which stays
1459
+ // safe for callers rather than claiming a precision it does not have.
1460
+ function inferPropType(defaultSrc) {
1461
+ const d = String(defaultSrc ?? '').trim();
1462
+ if (/^(['"`])[\s\S]*\1$/.test(d)) return 'string';
1463
+ if (/^-?\d[\d_]*(\.\d+)?([eE][+-]?\d+)?$/.test(d)) return 'number';
1464
+ if (d === 'true' || d === 'false') return 'boolean';
1465
+ if (d.startsWith('[')) return 'unknown[]';
1466
+ if (d.startsWith('{')) return 'Record<string, unknown>';
1467
+ if (/^(\(.*\)|\w+)\s*=>|^function\b/.test(d)) return '(...args: any[]) => any';
1468
+ return 'unknown';
1469
+ }
1470
+
1471
+ // A `.d.ts` for a compiled component, so a TypeScript host can import the
1472
+ // compiled module with its props checked. Every prop is optional because each
1473
+ // has a default.
1474
+ function buildDeclarations(props, exportStyles) {
1475
+ const fields = props
1476
+ .map(p => ` ${/^[A-Za-z_$][\w$]*$/.test(p.name) ? p.name : JSON.stringify(p.name)}?: ${inferPropType(p.default)};`)
1477
+ .join('\n');
1478
+ return [
1479
+ `export interface Props {${fields ? '\n' + fields + '\n' : ''}}`,
1480
+ `/** Mounts the component into \`target\` and returns a teardown function. */`,
1481
+ `declare function mount(target: Element | ShadowRoot, props?: Props): () => void;`,
1482
+ `export default mount;`,
1483
+ ...(exportStyles ? [`export declare const scopeHash: string;`, `export declare const css: string;`] : []),
1484
+ ``
1485
+ ].join('\n');
1486
+ }