@sola-air-ui/compiler 1.1.0 → 1.3.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.
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.3.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/cli.js CHANGED
@@ -18,7 +18,7 @@ if (args.length === 0 || args.includes('-h') || args.includes('--help')) {
18
18
  --target <esm|iife> Output format (default: esm)
19
19
  esm — ES module with import/export (default, for bundlers)
20
20
  iife — Self-contained IIFE for no-bundler environments
21
- (ServiceNow Service Portal, plain <script> tags)
21
+ (enterprise portal widgets, plain <script> tags)
22
22
  --export-name <Name> Global name for IIFE target (default: SolaComponent)
23
23
  The compiled component mounts as window['Name'](el, props)
24
24
  -v, --version Print compiler version
package/src/index.js CHANGED
@@ -6,6 +6,14 @@ const { version: COMPILER_VERSION } = JSON.parse(
6
6
  readFileSync(new URL('../package.json', import.meta.url), 'utf8')
7
7
  );
8
8
 
9
+ // Names every compiled component imports from core under their own name. A
10
+ // script that imports one of these again is not given a second binding.
11
+ const CORE_PROVIDED = new Set([
12
+ 'createSignal', 'createDerived', 'createEffect', 'createIntent', 'createData',
13
+ 'createPersistentSignal', 'onMount', 'onDestroy', 'pushContext', 'popContext',
14
+ '__flush_mounts', '__flush_destroys', '__untrack'
15
+ ]);
16
+
9
17
  const VOID_ELEMENTS = new Set([
10
18
  'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
11
19
  'link', 'meta', 'param', 'source', 'track', 'wbr'
@@ -32,18 +40,81 @@ function hashString(str) {
32
40
  return 'sola-' + Math.abs(hash).toString(36);
33
41
  }
34
42
 
43
+ // Split on `sep` characters that are not inside (), [] or a string, so
44
+ // `:is(a, b)` and `:global(.x, .y)` are not torn apart at their commas.
45
+ function splitTopLevel(text, isSep) {
46
+ const parts = [];
47
+ let depth = 0, quote = null, start = 0;
48
+ for (let i = 0; i < text.length; i++) {
49
+ const c = text[i];
50
+ if (quote) { if (c === '\\') i++; else if (c === quote) quote = null; continue; }
51
+ if (c === '"' || c === "'") { quote = c; continue; }
52
+ if (c === '(' || c === '[') depth++;
53
+ else if (c === ')' || c === ']') depth--;
54
+ else if (depth === 0 && isSep(c)) { parts.push(text.slice(start, i)); start = i + 1; }
55
+ }
56
+ parts.push(text.slice(start));
57
+ return parts;
58
+ }
59
+
60
+ // Put the scope class on one compound selector, before any pseudo-class or
61
+ // pseudo-element (button:hover → button.hash:hover).
62
+ function addScopeToCompound(compound, scopeHash) {
63
+ const pseudoIdx = compound.search(/::?[\w-]/);
64
+ if (pseudoIdx > 0) {
65
+ return `${compound.slice(0, pseudoIdx)}.${scopeHash}${compound.slice(pseudoIdx)}`;
66
+ }
67
+ return `${compound}.${scopeHash}`;
68
+ }
69
+
70
+ // `:global(sel)` opts a piece of a selector out of scoping, so a scoped
71
+ // component can style markup it did not create (e.g. the output of {@html}):
72
+ // .msg :global(table) → .msg.hash table
73
+ // :global(body) → body
74
+ // The scope class always goes on the last compound that is not global.
75
+ function scopeSelector(selector, scopeHash) {
76
+ const tokens = []; // alternating compounds and combinators
77
+ let depth = 0, cur = '', comb = '';
78
+ const flushCompound = () => { if (cur) { tokens.push({ compound: cur }); cur = ''; } };
79
+ for (let i = 0; i < selector.length; i++) {
80
+ const c = selector[i];
81
+ if (c === '(' || c === '[') depth++;
82
+ else if (c === ')' || c === ']') depth--;
83
+ if (depth === 0 && /[\s>+~]/.test(c)) {
84
+ flushCompound();
85
+ comb += c;
86
+ continue;
87
+ }
88
+ if (comb) { tokens.push({ comb }); comb = ''; }
89
+ cur += c;
90
+ }
91
+ flushCompound();
92
+ if (comb) tokens.push({ comb });
93
+
94
+ let last = -1;
95
+ for (let i = 0; i < tokens.length; i++) {
96
+ const t = tokens[i];
97
+ if (t.compound === undefined) continue;
98
+ const m = t.compound.match(/^:global\(([\s\S]*)\)$/);
99
+ // A comma list must stay grouped, or `.a :global(.x, .y)` would turn into
100
+ // two selectors and leave `.y` outside `.a`.
101
+ const group = (inner) => (splitTopLevel(inner, c => c === ',').length > 1 ? `:is(${inner})` : inner);
102
+ if (m) { t.compound = group(m[1]); t.global = true; }
103
+ else if (t.compound.includes(':global(')) {
104
+ t.compound = t.compound.replace(/:global\(([\s\S]*?)\)/g, (_, inner) => group(inner));
105
+ t.global = true;
106
+ } else { last = i; }
107
+ }
108
+ if (last >= 0) tokens[last].compound = addScopeToCompound(tokens[last].compound, scopeHash);
109
+ return tokens.map(t => t.compound ?? t.comb).join('');
110
+ }
111
+
35
112
  function scopeSelectorList(selector, scopeHash) {
36
- return selector
37
- .split(',')
113
+ return splitTopLevel(selector, c => c === ',')
38
114
  .map(part => {
39
115
  const trimmed = part.trim();
40
116
  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}`;
117
+ return scopeSelector(trimmed, scopeHash);
47
118
  })
48
119
  .join(', ');
49
120
  }
@@ -262,7 +333,7 @@ function preprocessTemplate(template, dynAttrs, handlers) {
262
333
  } else if (hasDyn) {
263
334
  // Build a string-concatenation expression instead of a runtime backtick
264
335
  // template literal. Confirmed by driving a real browser against a live
265
- // ServiceNow Service Portal instance: its widget-script delivery pipeline
336
+ // enterprise portal instance: its widget-script delivery pipeline
266
337
  // silently mangles `${expr}` interpolation inside a template literal down
267
338
  // to bare, unevaluated expression text (e.g. `background: ${x}` becomes
268
339
  // the literal string "background: x" — the function reference itself,
@@ -540,7 +611,7 @@ function collectReactiveVars(ast) {
540
611
  decl.init.callee.type === 'Identifier' && decl.id.type === 'Identifier') {
541
612
  const macro = decl.init.callee.name;
542
613
  if (macro === '$state' || macro === '$persist') vars.state.add(decl.id.name);
543
- else if (macro === '$derived') vars.derived.add(decl.id.name);
614
+ else if (macro === '$derived' || macro === '$lastEvent') vars.derived.add(decl.id.name);
544
615
  else if (macro === '$intent') vars.intent.add(decl.id.name);
545
616
  else if (macro === '$data') vars.data.add(decl.id.name);
546
617
  }
@@ -638,7 +709,7 @@ function emitReactiveEdits(rootNode, src, vars, edits, initialShadows) {
638
709
  const isMemberProp = p.type === 'MemberExpression' && p.property === node && !p.computed;
639
710
  const isCallee = (p.type === 'CallExpression' || p.type === 'NewExpression') && p.callee === node;
640
711
  const isAssignTarget = (p.type === 'AssignmentExpression' && p.left === node) || p.type === 'UpdateExpression';
641
- const isPropertyKey = p.type === 'Property' && p.key === node && !p.computed && !p.shorthand;
712
+ const isPropertyKey = p.type === 'Property' && p.key === node && !p.computed;
642
713
  const isShorthand = p.type === 'Property' && p.shorthand && p.value === node;
643
714
  const isMemberObject = p.type === 'MemberExpression' && p.object === node;
644
715
 
@@ -826,6 +897,12 @@ export function compile(source, options = {}) {
826
897
  const stateVars = new Set();
827
898
  const exportedProps = [];
828
899
  const componentImports = [];
900
+ // Every other import in the script. They used to be deleted, so a package
901
+ // import (`import DataCard from '@sola-air-ui/ui/data-card'`, or anything from
902
+ // '@sola-air-ui/core' that the compiler does not already provide) was gone from
903
+ // the output and left an undefined name behind.
904
+ const otherImports = [];
905
+ const coreImportExtras = [];
829
906
 
830
907
  let reactiveVars = NO_REACTIVE_VARS;
831
908
  if (strippedScript.trim()) {
@@ -851,6 +928,21 @@ export function compile(source, options = {}) {
851
928
  node.specifiers.forEach(spec => {
852
929
  componentImports.push({ localName: spec.local.name, path: src });
853
930
  });
931
+ } else if (src === '@sola-air-ui/core' || src === '@sola/core') {
932
+ // The compiler already imports these names under their own names.
933
+ // Importing one again would be a duplicate binding, so keep only the
934
+ // rest: names it does not provide, and aliased ones.
935
+ node.specifiers.forEach(spec => {
936
+ if (spec.type !== 'ImportSpecifier') return;
937
+ const imported = spec.imported.name ?? spec.imported.value;
938
+ if (spec.local.name === imported && CORE_PROVIDED.has(imported)) return;
939
+ coreImportExtras.push({ imported, local: spec.local.name });
940
+ });
941
+ } else {
942
+ otherImports.push({
943
+ code: strippedScript.slice(node.start, node.end).trim().replace(/;?$/, ';'),
944
+ locals: node.specifiers.map(spec => spec.local.name)
945
+ });
854
946
  }
855
947
  edits.push({
856
948
  start: node.start,
@@ -874,6 +966,16 @@ export function compile(source, options = {}) {
874
966
  removedRanges.push([node.start, node.end]);
875
967
  }
876
968
 
969
+ // $on('type', handler): subscribe to a bus event for the life of the
970
+ // component. Compiles to onDestroy(__solaBus.on(type, handler)). The handler
971
+ // stays in place so signals read inside it are rewritten like any other.
972
+ if (node.type === 'ExpressionStatement' && node.expression.type === 'CallExpression' &&
973
+ node.expression.callee.type === 'Identifier' && node.expression.callee.name === '$on') {
974
+ const call = node.expression;
975
+ edits.push({ start: call.callee.start, end: call.callee.end, replacement: 'onDestroy(__solaBus.on' });
976
+ edits.push({ start: call.end, end: call.end, replacement: ')' });
977
+ }
978
+
877
979
  // $state()/$derived()/$effect()/$intent()/$data() macro declarations.
878
980
  // Bracket edits (head + tail) leave the argument expression in place so
879
981
  // the reactive rewrite pass below can still auto-call signals inside it.
@@ -907,6 +1009,16 @@ export function compile(source, options = {}) {
907
1009
  } else {
908
1010
  edits.push({ start: node.start, end: node.end, replacement: `const ${varName} = createDerived(() => undefined);` });
909
1011
  }
1012
+ } else if (macro === '$lastEvent') {
1013
+ // let ctx = $lastEvent('type'): the latest payload of a bus event as a
1014
+ // reactive accessor, released when the component is destroyed.
1015
+ const varName = decl.id.name;
1016
+ if (arg) {
1017
+ edits.push({ start: node.start, end: arg.start, replacement: `const ${varName} = __solaBus.signal(` });
1018
+ edits.push({ start: lastArg.end, end: node.end, replacement: `); onDestroy(${varName}.dispose);` });
1019
+ } else {
1020
+ edits.push({ start: node.start, end: node.end, replacement: `const ${varName} = () => undefined;` });
1021
+ }
910
1022
  } else if (macro === '$effect') {
911
1023
  if (arg) {
912
1024
  edits.push({ start: node.start, end: arg.start, replacement: `createEffect(` });
@@ -967,6 +1079,11 @@ export function compile(source, options = {}) {
967
1079
  let uid = 0;
968
1080
  let domCode = '';
969
1081
  const importedComponentSet = new Set(componentImports.map(i => i.localName));
1082
+ // A capitalised tag that matches a name imported from a package is a component
1083
+ // too (`import DataCard from '@sola-air-ui/ui/data-card'`), not only a `.sola` path.
1084
+ for (const imp of otherImports) {
1085
+ for (const name of imp.locals) if (/^[A-Z]/.test(name)) importedComponentSet.add(name);
1086
+ }
970
1087
 
971
1088
  const sourceFile = options.filename || '<component>';
972
1089
 
@@ -1047,25 +1164,43 @@ export function compile(source, options = {}) {
1047
1164
  domCode += ` ${parentVar}.appendChild(${cid}_target);\n`;
1048
1165
 
1049
1166
  // Dynamically-bound props (rows={items}) arrive here as __soladyn_N__ placeholders —
1050
- // resolve them back to the real expression from dynAttrs and pass it as a raw
1051
- // (unquoted) property so the child receives live data, not the placeholder text.
1167
+ // resolve them back to the real expression from dynAttrs.
1052
1168
  //
1053
1169
  // `onXxx`-named props (e.g. onChange={handler}) arrive as a
1054
1170
  // __solahandler_N__ marker, because preprocessTemplate cannot tell a
1055
1171
  // component apart from a native element and lifts both the same way.
1056
1172
  // Either way the expression is passed through as a live function.
1173
+ //
1174
+ // Three kinds of dynamic prop:
1175
+ // - a bare name (rows={items}) is passed as-is. For a $state that is the
1176
+ // signal accessor, which is how a child receives live data (the shipped
1177
+ // components accept either an accessor or a plain value);
1178
+ // - an expression that reads a signal (title={ctx.title}) is evaluated
1179
+ // with the signal reads rewritten, and the child is re-mounted when
1180
+ // the value changes;
1181
+ // - an expression that reads no signal (text={msg.text} in an {#each})
1182
+ // is evaluated once, when the child is mounted.
1057
1183
  const propEntries = [];
1184
+ let reactiveProps = false;
1058
1185
  for (const [key, val] of Object.entries(node.attribs || {})) {
1059
1186
  const dynMatch = /^__soladyn_(\d+)__$/.exec(val);
1060
1187
  const handlerMatch = /^__solahandler_(\d+)__$/.exec(val);
1061
- // Dynamic (non-event) component props are deliberately NOT auto-called:
1062
- // passing the getter is how a child receives live data today, and
1063
- // README-syntax components rely on it.
1064
- const propValue = dynMatch
1065
- ? `(${dynAttrs[parseInt(dynMatch[1], 10)]})`
1066
- : handlerMatch
1067
- ? `(${rewrite(handlers[parseInt(handlerMatch[1], 10)])})`
1068
- : JSON.stringify(val);
1188
+ let propValue;
1189
+ if (dynMatch) {
1190
+ const raw = dynAttrs[parseInt(dynMatch[1], 10)];
1191
+ if (/^\s*[A-Za-z_$][\w$]*\s*$/.test(raw)) {
1192
+ propValue = `(${raw})`;
1193
+ } else {
1194
+ checkExpression(raw, 'component prop');
1195
+ const rewritten = rewrite(raw);
1196
+ if (rewritten !== raw) reactiveProps = true;
1197
+ propValue = `(${rewritten})`;
1198
+ }
1199
+ } else if (handlerMatch) {
1200
+ propValue = `(${rewrite(handlers[parseInt(handlerMatch[1], 10)])})`;
1201
+ } else {
1202
+ propValue = JSON.stringify(val);
1203
+ }
1069
1204
  propEntries.push(`${JSON.stringify(key)}: ${propValue}`);
1070
1205
  }
1071
1206
 
@@ -1079,7 +1214,21 @@ export function compile(source, options = {}) {
1079
1214
  propEntries.push(`children: ${slotVar}`);
1080
1215
  }
1081
1216
 
1082
- domCode += ` ${node.name}(${cid}_target, { ${propEntries.join(', ')} });\n`;
1217
+ // The child mounts untracked, so nothing it reads while building itself
1218
+ // can re-run the parent's effect. Its teardown is kept and registered with
1219
+ // whatever scope is being built, so it runs when that scope is destroyed.
1220
+ domCode += ` let ${cid}_unmount = null;\n`;
1221
+ domCode += ` onDestroy(() => { if (typeof ${cid}_unmount === 'function') ${cid}_unmount(); ${cid}_unmount = null; });\n`;
1222
+ if (reactiveProps) {
1223
+ domCode += ` createEffect(() => {\n`;
1224
+ domCode += ` const ${cid}_props = { ${propEntries.join(', ')} };\n`;
1225
+ domCode += ` if (typeof ${cid}_unmount === 'function') __untrack(${cid}_unmount);\n`;
1226
+ domCode += ` ${cid}_target.textContent = '';\n`;
1227
+ domCode += ` ${cid}_unmount = __untrack(() => ${node.name}(${cid}_target, ${cid}_props));\n`;
1228
+ domCode += ` });\n`;
1229
+ } else {
1230
+ domCode += ` ${cid}_unmount = __untrack(() => ${node.name}(${cid}_target, { ${propEntries.join(', ')} }));\n`;
1231
+ }
1083
1232
  return;
1084
1233
  }
1085
1234
 
@@ -1090,37 +1239,50 @@ export function compile(source, options = {}) {
1090
1239
  }
1091
1240
 
1092
1241
  // {#if ...} {:else} ... {/if}
1242
+ //
1243
+ // Each time a branch is built it gets its own scope (pushContext). Effects,
1244
+ // onDestroy callbacks and child components created inside register with that
1245
+ // scope, and switching or removing the branch destroys it, so nothing built
1246
+ // for a branch that is no longer on the page keeps running.
1093
1247
  if (node.name === 'sola-if') {
1094
1248
  const cid = `c${uid++}`;
1095
1249
  domCode += ` const ${cid}_a = document.createComment('if');\n`;
1096
1250
  domCode += ` ${parentVar}.appendChild(${cid}_a);\n`;
1097
1251
  domCode += ` let ${cid}_els = [];\n`;
1098
-
1252
+ domCode += ` let ${cid}_ctx = null;\n`;
1253
+ domCode += ` onDestroy(() => { if (${cid}_ctx) { __flush_destroys(${cid}_ctx); ${cid}_ctx = null; } });\n`;
1254
+
1099
1255
  const thenChild = node.children.find(c => c.name === 'sola-then');
1100
1256
  const elseChild = node.children.find(c => c.name === 'sola-else');
1101
1257
 
1102
- domCode += ` createEffect(() => {\n`;
1103
- domCode += ` ${cid}_els.forEach(e => e.remove()); ${cid}_els = [];\n`;
1104
- domCode += ` if (${rewrite(node.attribs.condition)}) {\n`;
1105
- if (thenChild) {
1258
+ const emitBranch = (branch) => {
1259
+ if (!branch) return;
1260
+ domCode += ` ${cid}_ctx = pushContext();\n`;
1261
+ domCode += ` try {\n`;
1106
1262
  domCode += ` const f = document.createDocumentFragment();\n`;
1107
- thenChild.children.forEach(child => emitNode(child, 'f', locals));
1263
+ branch.children.forEach(child => emitNode(child, 'f', locals));
1108
1264
  domCode += ` Array.from(f.childNodes).forEach(n => ${cid}_els.push(n));\n`;
1109
1265
  domCode += ` ${cid}_a.parentNode.insertBefore(f, ${cid}_a.nextSibling);\n`;
1110
- }
1266
+ domCode += ` } finally { popContext(${cid}_ctx); }\n`;
1267
+ };
1268
+
1269
+ domCode += ` createEffect(() => {\n`;
1270
+ domCode += ` if (${cid}_ctx) { __flush_destroys(${cid}_ctx); ${cid}_ctx = null; }\n`;
1271
+ domCode += ` ${cid}_els.forEach(e => e.remove()); ${cid}_els = [];\n`;
1272
+ domCode += ` if (${rewrite(node.attribs.condition)}) {\n`;
1273
+ emitBranch(thenChild);
1111
1274
  domCode += ` } else {\n`;
1112
- if (elseChild) {
1113
- domCode += ` const f = document.createDocumentFragment();\n`;
1114
- elseChild.children.forEach(child => emitNode(child, 'f', locals));
1115
- domCode += ` Array.from(f.childNodes).forEach(n => ${cid}_els.push(n));\n`;
1116
- domCode += ` ${cid}_a.parentNode.insertBefore(f, ${cid}_a.nextSibling);\n`;
1117
- }
1275
+ emitBranch(elseChild);
1118
1276
  domCode += ` }\n`;
1119
1277
  domCode += ` });\n`;
1120
1278
  return;
1121
1279
  }
1122
1280
 
1123
1281
  // {#each} with optional keyed diffing: {#each items as item (item.id)}
1282
+ //
1283
+ // Like {#if}, what is built for the list gets its own scope so it can be
1284
+ // destroyed: the whole list for an unkeyed each, one scope per key for a
1285
+ // keyed one (so removing an item stops that item's effects and children).
1124
1286
  if (node.name === 'sola-each') {
1125
1287
  const eid = `e${uid++}`;
1126
1288
  // Item/index names shadow signals inside the loop body and key expression
@@ -1134,70 +1296,53 @@ export function compile(source, options = {}) {
1134
1296
  domCode += ` const ${eid}_a = document.createComment('each');\n`;
1135
1297
  domCode += ` ${parentVar}.appendChild(${eid}_a);\n`;
1136
1298
  if (keyExpr) {
1137
- // Keyed: maintain a Map<key, Node[]> and reconcile on each run
1299
+ // Keyed: maintain a Map<key, { nodes, ctx }> and reconcile on each run
1138
1300
  // Reconcile by walking the new order and moving each key's nodes into
1139
1301
  // place against a cursor. The previous version only ever inserted, and
1140
1302
  // against an anchor it never advanced — so reordering a keyed list (a
1141
1303
  // sort, a drag, a reverse) left the DOM in its original order, silently.
1142
- domCode += ` let ${eid}_keyMap = new Map();
1143
- `;
1144
- domCode += ` createEffect(() => {
1145
- `;
1146
- domCode += ` const _items = ${arrayExpr} || [];
1147
- `;
1148
- domCode += ` const _parent = ${eid}_a.parentNode;
1149
- `;
1150
- domCode += ` const _seen = new Set();
1151
- `;
1152
- domCode += ` let _cursor = ${eid}_a.nextSibling;
1153
- `;
1154
- domCode += ` for (let _i = 0; _i < _items.length; _i++) {
1155
- `;
1156
- domCode += ` const ${node.attribs.item} = _items[_i];
1157
- `;
1158
- if (node.attribs.index) domCode += ` const ${node.attribs.index} = _i;
1159
- `;
1160
- domCode += ` const _key = String(${keyExpr});
1161
- `;
1162
- domCode += ` _seen.add(_key);
1163
- `;
1164
- domCode += ` let _nodes = ${eid}_keyMap.get(_key);
1165
- `;
1166
- domCode += ` if (!_nodes) {
1167
- `;
1168
- domCode += ` const f = document.createDocumentFragment();
1169
- `;
1304
+ domCode += ` let ${eid}_keyMap = new Map();\n`;
1305
+ domCode += ` onDestroy(() => { for (const _e of ${eid}_keyMap.values()) __flush_destroys(_e.ctx); ${eid}_keyMap.clear(); });\n`;
1306
+ domCode += ` createEffect(() => {\n`;
1307
+ domCode += ` const _items = ${arrayExpr} || [];\n`;
1308
+ domCode += ` const _parent = ${eid}_a.parentNode;\n`;
1309
+ domCode += ` const _seen = new Set();\n`;
1310
+ domCode += ` let _cursor = ${eid}_a.nextSibling;\n`;
1311
+ domCode += ` for (let _i = 0; _i < _items.length; _i++) {\n`;
1312
+ domCode += ` const ${node.attribs.item} = _items[_i];\n`;
1313
+ if (node.attribs.index) domCode += ` const ${node.attribs.index} = _i;\n`;
1314
+ domCode += ` const _key = String(${keyExpr});\n`;
1315
+ domCode += ` _seen.add(_key);\n`;
1316
+ domCode += ` let _entry = ${eid}_keyMap.get(_key);\n`;
1317
+ domCode += ` if (!_entry) {\n`;
1318
+ domCode += ` const _kctx = pushContext();\n`;
1319
+ domCode += ` try {\n`;
1320
+ domCode += ` const f = document.createDocumentFragment();\n`;
1170
1321
  node.children.forEach(child => emitNode(child, 'f', eachLocals));
1171
- domCode += ` _nodes = Array.from(f.childNodes);
1172
- `;
1173
- domCode += ` ${eid}_keyMap.set(_key, _nodes);
1174
- `;
1175
- domCode += ` }
1176
- `;
1177
- domCode += ` for (const _n of _nodes) {
1178
- `;
1179
- domCode += ` if (_n === _cursor) { _cursor = _cursor.nextSibling; }
1180
- `;
1181
- domCode += ` else { _parent.insertBefore(_n, _cursor); }
1182
- `;
1183
- domCode += ` }
1184
- `;
1185
- domCode += ` }
1186
- `;
1187
- domCode += ` for (const [_k, _stale] of ${eid}_keyMap) {
1188
- `;
1189
- domCode += ` if (!_seen.has(_k)) { _stale.forEach(n => n.remove()); ${eid}_keyMap.delete(_k); }
1190
- `;
1191
- domCode += ` }
1192
- `;
1193
- domCode += ` });
1194
- `;
1322
+ domCode += ` _entry = { nodes: Array.from(f.childNodes), ctx: _kctx };\n`;
1323
+ domCode += ` } finally { popContext(_kctx); }\n`;
1324
+ domCode += ` ${eid}_keyMap.set(_key, _entry);\n`;
1325
+ domCode += ` }\n`;
1326
+ domCode += ` for (const _n of _entry.nodes) {\n`;
1327
+ domCode += ` if (_n === _cursor) { _cursor = _cursor.nextSibling; }\n`;
1328
+ domCode += ` else { _parent.insertBefore(_n, _cursor); }\n`;
1329
+ domCode += ` }\n`;
1330
+ domCode += ` }\n`;
1331
+ domCode += ` for (const [_k, _stale] of ${eid}_keyMap) {\n`;
1332
+ domCode += ` if (!_seen.has(_k)) { __flush_destroys(_stale.ctx); _stale.nodes.forEach(n => n.remove()); ${eid}_keyMap.delete(_k); }\n`;
1333
+ domCode += ` }\n`;
1334
+ domCode += ` });\n`;
1195
1335
  } else {
1196
1336
  // Unkeyed: full teardown + rebuild
1197
1337
  domCode += ` let ${eid}_els = [];\n`;
1338
+ domCode += ` let ${eid}_ctx = null;\n`;
1339
+ domCode += ` onDestroy(() => { if (${eid}_ctx) { __flush_destroys(${eid}_ctx); ${eid}_ctx = null; } });\n`;
1198
1340
  domCode += ` createEffect(() => {\n`;
1341
+ domCode += ` if (${eid}_ctx) { __flush_destroys(${eid}_ctx); ${eid}_ctx = null; }\n`;
1199
1342
  domCode += ` ${eid}_els.forEach(e => e.remove()); ${eid}_els = [];\n`;
1200
1343
  domCode += ` const _items = ${arrayExpr} || [];\n`;
1344
+ domCode += ` ${eid}_ctx = pushContext();\n`;
1345
+ domCode += ` try {\n`;
1201
1346
  domCode += ` const f = document.createDocumentFragment();\n`;
1202
1347
  domCode += ` for (let _i = 0; _i < _items.length; _i++) {\n`;
1203
1348
  domCode += ` const ${node.attribs.item} = _items[_i];\n`;
@@ -1206,6 +1351,7 @@ export function compile(source, options = {}) {
1206
1351
  domCode += ` }\n`;
1207
1352
  domCode += ` Array.from(f.childNodes).forEach(n => ${eid}_els.push(n));\n`;
1208
1353
  domCode += ` ${eid}_a.parentNode.insertBefore(f, ${eid}_a.nextSibling);\n`;
1354
+ domCode += ` } finally { popContext(${eid}_ctx); }\n`;
1209
1355
  domCode += ` });\n`;
1210
1356
  }
1211
1357
  return;
@@ -1273,24 +1419,39 @@ export function compile(source, options = {}) {
1273
1419
  root.children.forEach(child => emitNode(child, '__target'));
1274
1420
 
1275
1421
  // Assemble final output module
1276
- const CORE_EXPORTS = 'createSignal, createDerived, createEffect, createIntent, createData, createPersistentSignal, onMount, onDestroy, pushContext, popContext, __flush_mounts, __flush_destroys';
1422
+ const CORE_EXPORTS = [...CORE_PROVIDED].join(', ');
1423
+ // The default bus is imported under an alias so a user variable named `bus`
1424
+ // cannot shadow what $on / $lastEvent compile to.
1425
+ const ESM_CORE_IMPORTS = CORE_EXPORTS + ', bus as __solaBus';
1426
+ const IIFE_CORE_PULL = CORE_EXPORTS + ', bus: __solaBus';
1277
1427
  let output = '';
1278
1428
  output += `// Compiled by @sola-air-ui/compiler v${COMPILER_VERSION}\n`;
1279
1429
 
1280
1430
  if (target === 'iife') {
1281
- // ServiceNow / no-bundler mode: pull from window.SolaCore global
1431
+ // No-bundler mode: pull from window.SolaCore global
1282
1432
  output += `(function() {\n`;
1283
- output += `const { ${CORE_EXPORTS} } = window.SolaCore;\n`;
1433
+ const iifeExtras = coreImportExtras.map(e => (e.local === e.imported ? e.imported : `${e.imported}: ${e.local}`));
1434
+ output += `const { ${[IIFE_CORE_PULL, ...iifeExtras].join(', ')} } = window.SolaCore;\n`;
1284
1435
  for (const imp of componentImports) {
1285
1436
  output += `// Note: sub-component ${imp.localName} must be pre-loaded before this script\n`;
1286
1437
  }
1438
+ for (const imp of otherImports) {
1439
+ output += `// Note: ${imp.locals.join(', ')} must be loaded before this script (no-bundler output cannot import)\n`;
1440
+ }
1287
1441
  output += `\n`;
1288
1442
  output += `window['${options.exportName || 'SolaComponent'}'] = function mount(__target, props = {}) {\n`;
1289
1443
  } else {
1290
- output += `import { ${CORE_EXPORTS} } from '@sola-air-ui/core';\n`;
1444
+ output += `import { ${ESM_CORE_IMPORTS} } from '@sola-air-ui/core';\n`;
1445
+ if (coreImportExtras.length) {
1446
+ const names = coreImportExtras.map(e => (e.local === e.imported ? e.imported : `${e.imported} as ${e.local}`));
1447
+ output += `import { ${names.join(', ')} } from '@sola-air-ui/core';\n`;
1448
+ }
1291
1449
  for (const imp of componentImports) {
1292
1450
  output += `import ${imp.localName} from '${imp.path}';\n`;
1293
1451
  }
1452
+ for (const imp of otherImports) {
1453
+ output += `${imp.code}\n`;
1454
+ }
1294
1455
  output += `\n`;
1295
1456
  output += `export default function mount(__target, props = {}) {\n`;
1296
1457
  }
@@ -1336,6 +1497,13 @@ export function compile(source, options = {}) {
1336
1497
  output += `};\n`;
1337
1498
  if (target === 'iife') {
1338
1499
  output += `})();\n`;
1500
+ } else if (options.exportStyles) {
1501
+ // Opt-in: the stylesheet and its scope class as module exports, for hosts
1502
+ // that mount into a Shadow Root and adopt the CSS themselves (e.g. via
1503
+ // adoptedStyleSheets). Off by default so existing build scripts that append
1504
+ // their own `css` / `scopeHash` exports, or strip exports, keep working.
1505
+ output += `\nexport const scopeHash = ${JSON.stringify(scopeHash)};\n`;
1506
+ output += `export const css = \`${escapeTemplateLiteral(scopedCss || '')}\`;\n`;
1339
1507
  }
1340
1508
 
1341
1509
  // The reactive rewrite can add or remove characters within a line but never
@@ -1355,6 +1523,41 @@ export function compile(source, options = {}) {
1355
1523
  code: output,
1356
1524
  scopeHash,
1357
1525
  css: scopedCss,
1358
- map
1526
+ map,
1527
+ dts: buildDeclarations(exportedProps, !!options.exportStyles)
1359
1528
  };
1360
1529
  }
1530
+
1531
+ // Best-effort TypeScript type for a prop, from the literal its default is
1532
+ // declared with. Anything it cannot read confidently is `unknown`, which stays
1533
+ // safe for callers rather than claiming a precision it does not have.
1534
+ function inferPropType(defaultSrc) {
1535
+ const d = String(defaultSrc ?? '').trim();
1536
+ if (/^(['"`])[\s\S]*\1$/.test(d)) return 'string';
1537
+ if (/^-?\d[\d_]*(\.\d+)?([eE][+-]?\d+)?$/.test(d)) return 'number';
1538
+ if (d === 'true' || d === 'false') return 'boolean';
1539
+ // A null or undefined default says nothing about the type. `unknown` would stop a
1540
+ // TypeScript host from reading or calling the prop; `any` lets it.
1541
+ if (d === 'null' || d === 'undefined') return 'any';
1542
+ if (d.startsWith('[')) return 'unknown[]';
1543
+ if (d.startsWith('{')) return 'Record<string, unknown>';
1544
+ if (/^(\(.*\)|\w+)\s*=>|^function\b/.test(d)) return '(...args: any[]) => any';
1545
+ return 'unknown';
1546
+ }
1547
+
1548
+ // A `.d.ts` for a compiled component, so a TypeScript host can import the
1549
+ // compiled module with its props checked. Every prop is optional because each
1550
+ // has a default.
1551
+ function buildDeclarations(props, exportStyles) {
1552
+ const fields = props
1553
+ .map(p => ` ${/^[A-Za-z_$][\w$]*$/.test(p.name) ? p.name : JSON.stringify(p.name)}?: ${inferPropType(p.default)};`)
1554
+ .join('\n');
1555
+ return [
1556
+ `export interface Props {${fields ? '\n' + fields + '\n' : ''}}`,
1557
+ `/** Mounts the component into \`target\` and returns a teardown function. */`,
1558
+ `declare function mount(target: Element | ShadowRoot, props?: Props): () => void;`,
1559
+ `export default mount;`,
1560
+ ...(exportStyles ? [`export declare const scopeHash: string;`, `export declare const css: string;`] : []),
1561
+ ``
1562
+ ].join('\n');
1563
+ }
package/types/index.d.ts CHANGED
@@ -4,7 +4,7 @@ export interface CompileOptions {
4
4
  /**
5
5
  * 'esm' (default) emits a module importing from @sola-air-ui/core.
6
6
  * 'iife' emits a self-contained bundle reading from window.SolaCore, for
7
- * ServiceNow and other no-bundler hosts.
7
+ * enterprise portals and other no-bundler hosts.
8
8
  */
9
9
  target?: 'esm' | 'iife';
10
10
  /** Global name the IIFE build registers itself under. Default 'SolaComponent'. */