@sola-air-ui/compiler 1.0.8 → 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.
package/src/index.js CHANGED
@@ -1,735 +1,1486 @@
1
- import * as acorn from 'acorn';
2
- import * as htmlparser2 from 'htmlparser2';
3
- import { readFileSync } from 'node:fs';
4
-
5
- const { version: COMPILER_VERSION } = JSON.parse(
6
- readFileSync(new URL('../package.json', import.meta.url), 'utf8')
7
- );
8
-
9
- const VOID_ELEMENTS = new Set([
10
- 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
11
- 'link', 'meta', 'param', 'source', 'track', 'wbr'
12
- ]);
13
-
14
- function hashString(str) {
15
- let hash = 5381;
16
- for (let i = 0; i < str.length; i++) {
17
- hash = ((hash << 5) + hash) + str.charCodeAt(i);
18
- hash |= 0;
19
- }
20
- return 'sola-' + Math.abs(hash).toString(36);
21
- }
22
-
23
- function scopeStyles(css, scopeHash) {
24
- return css.replace(/([^\r\n,{}]+)(,(?=[^{}]*{)|\s*{)/g, (match, selector, trailing) => {
25
- if (selector.trim().startsWith('@') || selector.trim().startsWith('from') || selector.trim().startsWith('to') || /^\d+%/.test(selector.trim())) {
26
- return match;
27
- }
28
- const scopedSelector = selector
29
- .split(',')
30
- .map(part => {
31
- const trimmed = part.trim();
32
- if (!trimmed) return part;
33
- // Insert scope hash before pseudo-class/element (e.g. button:hover → button.hash:hover)
34
- const pseudoIdx = trimmed.search(/::?[\w-]/);
35
- if (pseudoIdx > 0) {
36
- return `${trimmed.slice(0, pseudoIdx)}.${scopeHash}${trimmed.slice(pseudoIdx)}`;
37
- }
38
- return `${trimmed}.${scopeHash}`;
39
- })
40
- .join(', ');
41
- return scopedSelector + trailing;
42
- });
43
- }
44
-
45
- function extractScriptAndStyle(source) {
46
- let script = '';
47
- let style = '';
48
- let template = source;
49
-
50
- let isTypeScript = false;
51
- const scriptMatch = template.match(/<script(?:\s+lang=["'](ts|js)["'])?>([\s\S]*?)<\/script>/);
52
- if (scriptMatch) {
53
- isTypeScript = scriptMatch[1] === 'ts';
54
- script = scriptMatch[2];
55
- template = template.replace(scriptMatch[0], '');
56
- }
57
-
58
- const styleMatch = template.match(/<style(?:\s+scoped)?>([\s\S]*?)<\/style>/);
59
- if (styleMatch) {
60
- style = styleMatch[1];
61
- template = template.replace(styleMatch[0], '');
62
- }
63
-
64
- return { script, style, template, isTypeScript };
65
- }
66
-
67
- // Returns the index of the closing } that matches the { at openPos,
68
- // respecting string literals and nested braces. Returns -1 if unbalanced.
69
- function matchBrace(text, openPos) {
70
- let depth = 0;
71
- let inStr = null;
72
- for (let i = openPos; i < text.length; i++) {
73
- const ch = text[i];
74
- if (inStr) {
75
- if (ch === '\\') { i++; continue; }
76
- if (ch === inStr) inStr = null;
77
- continue;
78
- }
79
- if (ch === '"' || ch === "'" || ch === '`') { inStr = ch; continue; }
80
- if (ch === '{') depth++;
81
- else if (ch === '}') { if (--depth === 0) return i; }
82
- }
83
- return -1;
84
- }
85
-
86
- // Walk the template and resolve {expr} in attribute values using balanced-brace
87
- // matching, storing dynamic expressions in dynAttrs. Event/bind handlers are
88
- // stored as plain strings; other expressions become __soladyn_N__ markers.
89
- function preprocessTemplate(template, dynAttrs) {
90
- let result = '';
91
- let i = 0;
92
- while (i < template.length) {
93
- // Opening tag (not closing, not comment)
94
- if (template[i] === '<' && template[i + 1] !== '/' && template[i + 1] !== '!') {
95
- result += '<';
96
- i++;
97
- // Tag name
98
- while (i < template.length && /[^\s>\/]/.test(template[i])) result += template[i++];
99
- // Attributes
100
- while (i < template.length && template[i] !== '>' && !(template[i] === '/' && template[i + 1] === '>')) {
101
- if (/\s/.test(template[i])) { result += template[i++]; continue; }
102
- // Attribute name
103
- let attrName = '';
104
- while (i < template.length && /[^\s=\/>]/.test(template[i])) attrName += template[i++];
105
- if (!attrName) { result += template[i++]; continue; }
106
- if (template[i] !== '=') { result += attrName; continue; }
107
- i++; // skip =
108
- if (template[i] === '{') {
109
- // Unquoted brace expression: attr={expr}
110
- const closeIdx = matchBrace(template, i);
111
- if (closeIdx !== -1) {
112
- const expr = template.slice(i + 1, closeIdx).trim();
113
- if (!expr.startsWith('#') && !expr.startsWith('/') && !expr.startsWith(':')) {
114
- if (/^on:?[\w]+$/.test(attrName) || /^bind:[\w]+$/.test(attrName)) {
115
- result += `${attrName}="${expr}"`;
116
- } else {
117
- dynAttrs.push(expr);
118
- result += `${attrName}="__soladyn_${dynAttrs.length - 1}__"`;
119
- }
120
- i = closeIdx + 1;
121
- continue;
122
- }
123
- }
124
- result += `${attrName}={`; i++;
125
- } else if (template[i] === '"' || template[i] === "'") {
126
- // Quoted attribute value — scan for {expr} interpolations
127
- const quote = template[i++];
128
- const parts = [];
129
- let currentText = '';
130
- let hasDyn = false;
131
- while (i < template.length && template[i] !== quote) {
132
- if (template[i] === '{') {
133
- const closeIdx = matchBrace(template, i);
134
- if (closeIdx !== -1) {
135
- const expr = template.slice(i + 1, closeIdx).trim();
136
- if (!expr.startsWith('#') && !expr.startsWith('/') && !expr.startsWith(':')) {
137
- hasDyn = true;
138
- parts.push({ text: currentText });
139
- currentText = '';
140
- parts.push({ expr });
141
- i = closeIdx + 1; continue;
142
- }
143
- }
144
- }
145
- currentText += template[i++];
146
- }
147
- parts.push({ text: currentText });
148
- if (i < template.length) i++; // skip closing quote
149
- if (hasDyn) {
150
- // Build a string-concatenation expression instead of a runtime backtick
151
- // template literal. Confirmed by driving a real browser against a live
152
- // ServiceNow Service Portal instance: its widget-script delivery pipeline
153
- // silently mangles `${expr}` interpolation inside a template literal down
154
- // to bare, unevaluated expression text (e.g. `background: ${x}` becomes
155
- // the literal string "background: x" — the function reference itself,
156
- // never called). Every prior "the reactive binding doesn't do anything"
157
- // report came from this exact code path. Concatenation is semantically
158
- // identical and isn't affected by whatever that pipeline does to backticks.
159
- const combined = parts
160
- .filter((p) => p.expr !== undefined || p.text !== '')
161
- .map((p) => (p.expr !== undefined ? `(${p.expr})` : JSON.stringify(p.text)))
162
- .join(' + ');
163
- dynAttrs.push(combined || `''`);
164
- result += `${attrName}="__soladyn_${dynAttrs.length - 1}__"`;
165
- } else {
166
- result += `${attrName}="${currentText}"`;
167
- }
168
- } else {
169
- result += `${attrName}=`;
170
- }
171
- continue;
172
- }
173
- if (i < template.length && template[i] === '/') { result += '/'; i++; }
174
- if (i < template.length && template[i] === '>') { result += '>'; i++; }
175
- continue;
176
- }
177
- result += template[i++];
178
- }
179
- return result;
180
- }
181
-
182
- // Replace {expr} in text content with <sola-expr> tags, using balanced-brace
183
- // matching so expressions like {fn({key: val})} work correctly.
184
- function extractExpressions(text) {
185
- let result = '';
186
- let i = 0;
187
- while (i < text.length) {
188
- if (text[i] === '{') {
189
- const closeIdx = matchBrace(text, i);
190
- if (closeIdx === -1) { result += text[i++]; continue; }
191
- const inner = text.slice(i + 1, closeIdx).trim();
192
- if (inner.startsWith('#') || inner.startsWith('/') || inner.startsWith(':')) {
193
- result += text.slice(i, closeIdx + 1); i = closeIdx + 1;
194
- } else if (inner.startsWith('@html')) {
195
- const htmlExpr = inner.slice('@html'.length).trim();
196
- result += `<sola-html expr="${htmlExpr.replace(/"/g, '&quot;')}"></sola-html>`;
197
- i = closeIdx + 1;
198
- } else {
199
- result += `<sola-expr expr="${inner.replace(/"/g, '&quot;')}"></sola-expr>`;
200
- i = closeIdx + 1;
201
- }
202
- } else {
203
- result += text[i++];
204
- }
205
- }
206
- return result;
207
- }
208
-
209
- // Strip TypeScript type annotations so Acorn (plain JS parser) can handle
210
- // script blocks written in TS. Covers the common patterns: type annotations,
211
- // interface/type declarations, angle-bracket generics, and `as` casts.
212
- function stripTypeScript(code) {
213
- // Remove interface and type alias declarations
214
- code = code.replace(/^\s*(export\s+)?(interface|type)\s+\w[\s\S]*?(?=\n(?:export|const|let|var|function|class|\/\/|$))/gm, '');
215
- // Remove inline type annotations: `: Type` before = , ) , ; , , or end of line
216
- code = code.replace(/:\s*[\w<>\[\]|&{},\s.?]+(?=[=,);{}\n])/g, '');
217
- // Remove `as Type` casts
218
- code = code.replace(/\s+as\s+[\w<>\[\]|&{}.?]+/g, '');
219
- // Remove generic type parameters from function/class declarations
220
- code = code.replace(/<[^>()=]+>(?=\s*\()/g, '');
221
- return code;
222
- }
223
-
224
- export function compile(source, options = {}) {
225
- const target = options.target || 'esm'; // 'esm' | 'iife'
226
- const { script: scriptContent, style: styleContent, template: rawTemplateSource, isTypeScript } = extractScriptAndStyle(source);
227
- const scopeHash = hashString(styleContent || rawTemplateSource);
228
- const hasStyles = styleContent.trim().length > 0;
229
- const scopedCss = hasStyles ? scopeStyles(styleContent, scopeHash) : '';
230
-
231
- let rawTemplate = rawTemplateSource;
232
-
233
- // Pre-process attribute-bound expressions BEFORE extractExpressions using
234
- // balanced-brace matching (handles nested object literals, ternaries, etc.)
235
- const dynAttrs = [];
236
- rawTemplate = preprocessTemplate(rawTemplate, dynAttrs);
237
- rawTemplate = extractExpressions(rawTemplate);
238
-
239
- // Pre-process logic blocks: structured if-then-else
240
- // Process nested or sequential if/else blocks cleanly
241
- rawTemplate = rawTemplate
242
- .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>')
243
- .replace(/{#if\s+([\s\S]*?)}([\s\S]*?){\/if}/g, '<sola-if condition="$1"><sola-then>$2</sola-then></sola-if>')
244
- .replace(/{#each\s+([\s\S]*?)\s+as\s+([\w]+)(?:\s*,\s*([\w]+))?(?:\s*\(([^)]+)\))?}/g,
245
- (_, arr, item, index, key) => `<sola-each array="${arr}" item="${item}"${index ? ` index="${index}"` : ''}${key ? ` key="${key.trim()}"` : ''}>`)
246
- .replace(/{\/each}/g, '</sola-each>');
247
-
248
- // Parse HTML AST
249
- const templateNodes = [];
250
- let root = { type: 'element', name: '__root__', attribs: {}, children: [] };
251
- templateNodes.push(root);
252
-
253
- const parser = new htmlparser2.Parser({
254
- onopentag(name, attribs) {
255
- const node = { type: 'element', name, attribs, children: [] };
256
- templateNodes[templateNodes.length - 1].children.push(node);
257
- if (!VOID_ELEMENTS.has(name)) {
258
- templateNodes.push(node);
259
- }
260
- },
261
- ontext(text) {
262
- if (text.trim().length > 0 && templateNodes.length > 0) {
263
- templateNodes[templateNodes.length - 1].children.push({ type: 'text', data: text });
264
- }
265
- },
266
- onclosetag(name) {
267
- if (templateNodes.length > 1 && !VOID_ELEMENTS.has(name)) {
268
- templateNodes.pop();
269
- }
270
- }
271
- }, { recognizeSelfClosing: true, lowerCaseTags: false, lowerCaseAttributeNames: false });
272
-
273
- parser.write(rawTemplate.trim() || '<div></div>');
274
- parser.end();
275
-
276
- // Parse JS AST with Acorn
277
- // Strip TS first — AST positions and all slice() calls operate on the stripped source.
278
- // Only run on <script lang="ts"> — the strip regex can't reliably distinguish a type
279
- // annotation's `:` from a ternary's `: falsyBranch` or an object literal's `key: value`,
280
- // so applying it to plain JS risks corrupting valid code. Scripts that don't opt into TS
281
- // are left untouched.
282
- const strippedScript = scriptContent.trim() && isTypeScript ? stripTypeScript(scriptContent) : scriptContent;
283
- let jsOutput = strippedScript;
284
- const stateVars = new Set();
285
- const exportedProps = [];
286
- const componentImports = [];
287
-
288
- if (strippedScript.trim()) {
289
- try {
290
- const ast = acorn.parse(strippedScript, { ecmaVersion: 'latest', sourceType: 'module' });
291
- const edits = [];
292
-
293
- function walkAST(node, parent) {
294
- if (!node || typeof node !== 'object') return;
295
-
296
- // Track component imports
297
- if (node.type === 'ImportDeclaration') {
298
- const src = node.source.value;
299
- if (src.endsWith('.sola')) {
300
- node.specifiers.forEach(spec => {
301
- componentImports.push({ localName: spec.local.name, path: src });
302
- });
303
- }
304
- edits.push({
305
- start: node.start,
306
- end: node.end,
307
- replacement: `// import hoisted: ${node.source.value}`
308
- });
309
- }
310
-
311
- // Exported props
312
- if (node.type === 'ExportNamedDeclaration' && node.declaration &&
313
- node.declaration.type === 'VariableDeclaration') {
314
- node.declaration.declarations.forEach(decl => {
315
- const name = decl.id.name;
316
- const defaultVal = decl.init
317
- ? strippedScript.slice(decl.init.start, decl.init.end)
318
- : 'undefined';
319
- exportedProps.push({ name, default: defaultVal });
320
- edits.push({
321
- start: node.start,
322
- end: node.end,
323
- replacement: ''
324
- });
325
- });
326
- }
327
-
328
- // $state() -> createSignal()
329
- if (node.type === 'VariableDeclaration') {
330
- node.declarations.forEach(decl => {
331
- if (decl.init && decl.init.type === 'CallExpression') {
332
- const callee = decl.init.callee;
333
- if (callee.type === 'Identifier' && callee.name === '$state') {
334
- const varName = decl.id.name;
335
- stateVars.add(varName);
336
- const initArg = decl.init.arguments[0];
337
- const initVal = initArg ? strippedScript.slice(initArg.start, initArg.end) : 'undefined';
338
- edits.push({
339
- start: node.start,
340
- end: node.end,
341
- replacement: `const [${varName}, set_${varName}] = createSignal(${initVal});`
342
- });
343
- } else if (callee.type === 'Identifier' && callee.name === '$derived') {
344
- const varName = decl.id.name;
345
- const expr = decl.init.arguments[0];
346
- const exprSrc = expr ? strippedScript.slice(expr.start, expr.end) : 'undefined';
347
- const isFn = expr && (expr.type === 'ArrowFunctionExpression' || expr.type === 'FunctionExpression');
348
- edits.push({
349
- start: node.start,
350
- end: node.end,
351
- replacement: isFn
352
- ? `const ${varName} = createDerived(${exprSrc});`
353
- : `const ${varName} = createDerived(() => ${exprSrc});`
354
- });
355
- } else if (callee.type === 'Identifier' && callee.name === '$effect') {
356
- const body = decl.init.arguments[0];
357
- const bodySrc = body ? strippedScript.slice(body.start, body.end) : '() => {}';
358
- edits.push({
359
- start: node.start,
360
- end: node.end,
361
- replacement: `createEffect(${bodySrc});`
362
- });
363
- } else if (callee.type === 'Identifier' && callee.name === '$intent') {
364
- const promptArg = decl.init.arguments[0]
365
- ? strippedScript.slice(decl.init.arguments[0].start, decl.init.arguments[0].end)
366
- : "''";
367
- const fallbackArg = decl.init.arguments[1]
368
- ? strippedScript.slice(decl.init.arguments[1].start, decl.init.arguments[1].end)
369
- : 'null';
370
- edits.push({
371
- start: node.start,
372
- end: node.end,
373
- replacement: `const ${decl.id.name} = createIntent(${promptArg}, ${fallbackArg});`
374
- });
375
- } else if (callee.type === 'Identifier' && callee.name === '$data') {
376
- const sourceArg = decl.init.arguments[0]
377
- ? strippedScript.slice(decl.init.arguments[0].start, decl.init.arguments[0].end)
378
- : "''";
379
- const optsArg = decl.init.arguments[1]
380
- ? ', ' + strippedScript.slice(decl.init.arguments[1].start, decl.init.arguments[1].end)
381
- : '';
382
- edits.push({
383
- start: node.start,
384
- end: node.end,
385
- replacement: `const ${decl.id.name} = createData(${sourceArg}${optsArg});`
386
- });
387
- }
388
- }
389
- });
390
- }
391
-
392
- // Assignment to state var: count = x -> set_count(x) (supporting all 10 assignment operators)
393
- if (node.type === 'AssignmentExpression' &&
394
- node.left.type === 'Identifier' &&
395
- stateVars.has(node.left.name)) {
396
- const name = node.left.name;
397
- const rightSource = strippedScript.slice(node.right.start, node.right.end);
398
- if (node.operator === '=') {
399
- edits.push({ start: node.start, end: node.end, replacement: `set_${name}(${rightSource})` });
400
- } else {
401
- const rawOp = node.operator.slice(0, -1);
402
- edits.push({ start: node.start, end: node.end, replacement: `set_${name}(${name}() ${rawOp} (${rightSource}))` });
403
- }
404
- }
405
-
406
- // Update expressions: count++ / count--
407
- if (node.type === 'UpdateExpression' &&
408
- node.argument.type === 'Identifier' &&
409
- stateVars.has(node.argument.name)) {
410
- const name = node.argument.name;
411
- const op = node.operator === '++' ? '+' : '-';
412
- edits.push({
413
- start: node.start,
414
- end: node.end,
415
- replacement: `set_${name}(${name}() ${op} 1)`
416
- });
417
- }
418
-
419
- for (const key of Object.keys(node)) {
420
- if (key === 'type') continue;
421
- const child = node[key];
422
- if (Array.isArray(child)) {
423
- child.forEach(c => walkAST(c, node));
424
- } else if (child && typeof child === 'object' && child.type) {
425
- walkAST(child, node);
426
- }
427
- }
428
- }
429
-
430
- walkAST(ast, null);
431
-
432
- edits.sort((a, b) => b.start - a.start);
433
- for (const edit of edits) {
434
- jsOutput = jsOutput.slice(0, edit.start) + edit.replacement + jsOutput.slice(edit.end);
435
- }
436
- } catch (err) {
437
- const loc = err.loc ? ` (line ${err.loc.line}, col ${err.loc.column})` : '';
438
- const filePath = typeof options === 'string' ? options : (options.filename || '<script>');
439
- throw new Error(`[sola compiler] Parse error in ${filePath}${loc}:\n ${err.message}`);
440
- }
441
- }
442
-
443
- // Generate DOM creation instructions
444
- let uid = 0;
445
- let domCode = '';
446
- const importedComponentSet = new Set(componentImports.map(i => i.localName));
447
-
448
- function emitNode(node, parentVar) {
449
- if (!node) return;
450
-
451
- if (node.type === 'text') {
452
- const text = node.data.replace(/\\/g, '\\\\').replace(/`/g, '\\`');
453
- if (text.trim().length > 0) {
454
- const id = `t${uid++}`;
455
- domCode += ` const ${id} = document.createTextNode(\`${text}\`);\n`;
456
- domCode += ` ${parentVar}.appendChild(${id});\n`;
457
- }
458
- return;
459
- }
460
-
461
- if (node.name === 'sola-expr') {
462
- const expr = (node.attribs.expr || '').replace(/&quot;/g, '"');
463
- const id = `e${uid++}`;
464
- domCode += ` const ${id} = document.createTextNode('');\n`;
465
- domCode += ` ${parentVar}.appendChild(${id});\n`;
466
- // Parenthesize expr before `?? ''`: JS forbids mixing ?? with || or && at the
467
- // same level without explicit grouping (e.g. `col.label || col ?? ''` is a
468
- // SyntaxError), and any {a || b}-style fallback expression — an extremely
469
- // common pattern — would otherwise produce invalid generated code.
470
- domCode += ` createEffect(() => { ${id}.textContent = String((${expr}) ?? ''); });\n`;
471
- return;
472
- }
473
-
474
- // {@html expr} — renders expr as raw HTML via innerHTML, unlike {expr} which always
475
- // goes through textContent. Wrapped in its own element so setting innerHTML can't
476
- // clobber unrelated sibling nodes. Callers are responsible for trusting/sanitizing
477
- // whatever markup they pass — same contract as innerHTML anywhere else.
478
- if (node.name === 'sola-html') {
479
- const expr = (node.attribs.expr || '').replace(/&quot;/g, '"');
480
- const id = `h${uid++}`;
481
- domCode += ` const ${id} = document.createElement('div');\n`;
482
- domCode += ` ${id}.style.display = 'contents';\n`;
483
- domCode += ` ${parentVar}.appendChild(${id});\n`;
484
- domCode += ` createEffect(() => { ${id}.innerHTML = String((${expr}) ?? ''); });\n`;
485
- return;
486
- }
487
-
488
- // Nested custom component
489
- if (importedComponentSet.has(node.name)) {
490
- const cid = `comp${uid++}`;
491
- domCode += ` const ${cid}_target = document.createElement('div');\n`;
492
- domCode += ` ${cid}_target.className = 'sola-component-root';\n`;
493
- domCode += ` ${parentVar}.appendChild(${cid}_target);\n`;
494
-
495
- // Dynamically-bound props (rows={items}) arrive here as __soladyn_N__ placeholders —
496
- // resolve them back to the real expression from dynAttrs and pass it as a raw
497
- // (unquoted) property so the child receives live data, not the placeholder text.
498
- //
499
- // `onXxx`-named props (e.g. onChange={handler}) are a special case: preprocessTemplate
500
- // strips their {expr} down to raw identifier text before this code ever sees them
501
- // (the same exception that makes real onclick/on:click DOM handlers work), so they
502
- // never arrive as a __soladyn_N__ placeholder at all. Since preprocessTemplate can't
503
- // yet tell a component apart from a native element, that exception can't distinguish
504
- // "this is a DOM event on <button>" from "this is a callback prop on <Toggle>" — so
505
- // treat any on-prefixed key here as a raw expression too, same as a real dynAttr.
506
- const propEntries = [];
507
- for (const [key, val] of Object.entries(node.attribs || {})) {
508
- const dynMatch = /^__soladyn_(\d+)__$/.exec(val);
509
- const isEventLikeProp = /^on:?[\w]+$/.test(key);
510
- const propValue = dynMatch
511
- ? `(${dynAttrs[parseInt(dynMatch[1], 10)]})`
512
- : isEventLikeProp
513
- ? `(${val})`
514
- : JSON.stringify(val);
515
- propEntries.push(`${JSON.stringify(key)}: ${propValue}`);
516
- }
517
-
518
- // Slot content: compile the tag's children into a function the child component can
519
- // call to project them wherever it renders a <slot> in its own template.
520
- if (node.children && node.children.length > 0) {
521
- const slotVar = `${cid}_slot`;
522
- domCode += ` const ${slotVar} = (__slot_target) => {\n`;
523
- node.children.forEach(child => emitNode(child, '__slot_target'));
524
- domCode += ` };\n`;
525
- propEntries.push(`children: ${slotVar}`);
526
- }
527
-
528
- domCode += ` ${node.name}(${cid}_target, { ${propEntries.join(', ')} });\n`;
529
- return;
530
- }
531
-
532
- // Slot placeholder: projects the children passed by a parent component, if any.
533
- if (node.name === 'slot') {
534
- domCode += ` if (typeof props.children === 'function') { props.children(${parentVar}); }\n`;
535
- return;
536
- }
537
-
538
- // {#if ...} {:else} ... {/if}
539
- if (node.name === 'sola-if') {
540
- const cid = `c${uid++}`;
541
- domCode += ` const ${cid}_a = document.createComment('if');\n`;
542
- domCode += ` ${parentVar}.appendChild(${cid}_a);\n`;
543
- domCode += ` let ${cid}_els = [];\n`;
544
-
545
- const thenChild = node.children.find(c => c.name === 'sola-then');
546
- const elseChild = node.children.find(c => c.name === 'sola-else');
547
-
548
- domCode += ` createEffect(() => {\n`;
549
- domCode += ` ${cid}_els.forEach(e => e.remove()); ${cid}_els = [];\n`;
550
- domCode += ` if (${node.attribs.condition}) {\n`;
551
- if (thenChild) {
552
- domCode += ` const f = document.createDocumentFragment();\n`;
553
- thenChild.children.forEach(child => emitNode(child, 'f'));
554
- domCode += ` Array.from(f.childNodes).forEach(n => ${cid}_els.push(n));\n`;
555
- domCode += ` ${cid}_a.parentNode.insertBefore(f, ${cid}_a.nextSibling);\n`;
556
- }
557
- domCode += ` } else {\n`;
558
- if (elseChild) {
559
- domCode += ` const f = document.createDocumentFragment();\n`;
560
- elseChild.children.forEach(child => emitNode(child, 'f'));
561
- domCode += ` Array.from(f.childNodes).forEach(n => ${cid}_els.push(n));\n`;
562
- domCode += ` ${cid}_a.parentNode.insertBefore(f, ${cid}_a.nextSibling);\n`;
563
- }
564
- domCode += ` }\n`;
565
- domCode += ` });\n`;
566
- return;
567
- }
568
-
569
- // {#each} with optional keyed diffing: {#each items as item (item.id)}
570
- if (node.name === 'sola-each') {
571
- const eid = `e${uid++}`;
572
- const keyExpr = node.attribs.key || null;
573
- domCode += ` const ${eid}_a = document.createComment('each');\n`;
574
- domCode += ` ${parentVar}.appendChild(${eid}_a);\n`;
575
- if (keyExpr) {
576
- // Keyed: maintain a Map<key, Node[]> and reconcile on each run
577
- domCode += ` let ${eid}_keyMap = new Map();\n`;
578
- domCode += ` createEffect(() => {\n`;
579
- domCode += ` const _items = ${node.attribs.array} || [];\n`;
580
- domCode += ` const _nextKeys = _items.map((${node.attribs.item}, _i) => String(${keyExpr}));\n`;
581
- domCode += ` // Remove stale keys\n`;
582
- domCode += ` for (const [_k, _nodes] of ${eid}_keyMap) {\n`;
583
- domCode += ` if (!_nextKeys.includes(_k)) { _nodes.forEach(n => n.remove()); ${eid}_keyMap.delete(_k); }\n`;
584
- domCode += ` }\n`;
585
- domCode += ` // Insert/reorder items\n`;
586
- domCode += ` let _anchor = ${eid}_a.nextSibling;\n`;
587
- domCode += ` for (let _i = 0; _i < _items.length; _i++) {\n`;
588
- domCode += ` const ${node.attribs.item} = _items[_i];\n`;
589
- if (node.attribs.index) domCode += ` const ${node.attribs.index} = _i;\n`;
590
- domCode += ` const _key = String(${keyExpr});\n`;
591
- domCode += ` if (!${eid}_keyMap.has(_key)) {\n`;
592
- domCode += ` const f = document.createDocumentFragment();\n`;
593
- node.children.forEach(child => emitNode(child, 'f'));
594
- domCode += ` const _nodes = Array.from(f.childNodes);\n`;
595
- domCode += ` ${eid}_keyMap.set(_key, _nodes);\n`;
596
- domCode += ` ${eid}_a.parentNode.insertBefore(f, _anchor);\n`;
597
- domCode += ` }\n`;
598
- domCode += ` }\n`;
599
- domCode += ` });\n`;
600
- } else {
601
- // Unkeyed: full teardown + rebuild
602
- domCode += ` let ${eid}_els = [];\n`;
603
- domCode += ` createEffect(() => {\n`;
604
- domCode += ` ${eid}_els.forEach(e => e.remove()); ${eid}_els = [];\n`;
605
- domCode += ` const _items = ${node.attribs.array} || [];\n`;
606
- domCode += ` const f = document.createDocumentFragment();\n`;
607
- domCode += ` for (let _i = 0; _i < _items.length; _i++) {\n`;
608
- domCode += ` const ${node.attribs.item} = _items[_i];\n`;
609
- if (node.attribs.index) domCode += ` const ${node.attribs.index} = _i;\n`;
610
- node.children.forEach(child => emitNode(child, 'f'));
611
- domCode += ` }\n`;
612
- domCode += ` Array.from(f.childNodes).forEach(n => ${eid}_els.push(n));\n`;
613
- domCode += ` ${eid}_a.parentNode.insertBefore(f, ${eid}_a.nextSibling);\n`;
614
- domCode += ` });\n`;
615
- }
616
- return;
617
- }
618
-
619
- // Normal element
620
- const id = `n${uid++}`;
621
- domCode += ` const ${id} = document.createElement('${node.name}');\n`;
622
-
623
- if (hasStyles) {
624
- domCode += ` ${id}.classList.add('${scopeHash}');\n`;
625
- }
626
-
627
- const BOOLEAN_ATTRS = new Set(['disabled', 'checked', 'selected', 'readonly', 'required', 'multiple', 'open']);
628
- for (const [key, val] of Object.entries(node.attribs || {})) {
629
- // Dynamic attribute pre-processed from {expr} in attribute values
630
- const dynMatch = /^__soladyn_(\d+)__$/.exec(val);
631
- if (dynMatch) {
632
- const expr = dynAttrs[parseInt(dynMatch[1])];
633
- if (BOOLEAN_ATTRS.has(key)) {
634
- domCode += ` createEffect(() => { const _v = !!(${expr}); if (_v) ${id}.setAttribute('${key}', ''); else ${id}.removeAttribute('${key}'); });\n`;
635
- } else if (key === 'class' || key === 'className') {
636
- const suffix = hasStyles ? ` + ' ${scopeHash}'` : '';
637
- domCode += ` createEffect(() => { ${id}.className = (${expr})${suffix}; });\n`;
638
- } else {
639
- domCode += ` createEffect(() => { ${id}.setAttribute('${key}', String(${expr})); });\n`;
640
- }
641
- continue;
642
- }
643
- if (key.startsWith('on:')) {
644
- const eventName = key.slice(3);
645
- if (!/^[a-zA-Z][a-zA-Z0-9]*$/.test(eventName)) continue;
646
- // val is the handler expression (pre-processed from {expr}), pass directly
647
- domCode += ` ${id}.addEventListener('${eventName}', ${val});\n`;
648
- } else if (key.startsWith('on')) {
649
- const eventName = key.slice(2).toLowerCase();
650
- if (!/^[a-zA-Z][a-zA-Z0-9]*$/.test(eventName)) continue;
651
- domCode += ` ${id}.addEventListener('${eventName}', ${val});\n`;
652
- } else if (key.startsWith('bind:')) {
653
- const prop = key.slice(5);
654
- if (prop === 'value') {
655
- domCode += ` ${id}.addEventListener('input', (e) => { set_${val}(e.target.value); });\n`;
656
- domCode += ` createEffect(() => { ${id}.value = ${val}() ?? ''; });\n`;
657
- }
658
- } else if (key === 'class' || key === 'className') {
659
- domCode += ` ${id}.className += ' ' + \`${val}\`;\n`;
660
- } else {
661
- domCode += ` ${id}.setAttribute('${key}', \`${val}\`);\n`;
662
- }
663
- }
664
-
665
- domCode += ` ${parentVar}.appendChild(${id});\n`;
666
-
667
- if (node.children && !VOID_ELEMENTS.has(node.name)) {
668
- for (const child of node.children) {
669
- emitNode(child, id);
670
- }
671
- }
672
- }
673
-
674
- root.children.forEach(child => emitNode(child, '__target'));
675
-
676
- // Assemble final output module
677
- const CORE_EXPORTS = 'createSignal, createDerived, createEffect, createIntent, createData, onMount, onDestroy, pushContext, popContext, __flush_mounts, __flush_destroys';
678
- let output = '';
679
- output += `// Compiled by @sola-air-ui/compiler v${COMPILER_VERSION}\n`;
680
-
681
- if (target === 'iife') {
682
- // ServiceNow / no-bundler mode: pull from window.SolaCore global
683
- output += `(function() {\n`;
684
- output += `const { ${CORE_EXPORTS} } = window.SolaCore;\n`;
685
- for (const imp of componentImports) {
686
- output += `// Note: sub-component ${imp.localName} must be pre-loaded before this script\n`;
687
- }
688
- output += `\n`;
689
- output += `window['${options.exportName || 'SolaComponent'}'] = function mount(__target, props = {}) {\n`;
690
- } else {
691
- output += `import { ${CORE_EXPORTS} } from '@sola-air-ui/core';\n`;
692
- for (const imp of componentImports) {
693
- output += `import ${imp.localName} from '${imp.path}';\n`;
694
- }
695
- output += `\n`;
696
- output += `export default function mount(__target, props = {}) {\n`;
697
- }
698
- output += ` const __ctx = pushContext();\n\n`;
699
-
700
- if (hasStyles) {
701
- output += ` if (typeof document !== 'undefined' && !window['__SOLA_STYLE_${scopeHash}']) {\n`;
702
- output += ` window['__SOLA_STYLE_${scopeHash}'] = true;\n`;
703
- output += ` const styleEl = document.createElement('style');\n`;
704
- output += ` styleEl.textContent = \`${scopedCss}\`;\n`;
705
- output += ` document.head.appendChild(styleEl);\n`;
706
- output += ` }\n\n`;
707
- }
708
-
709
- for (const prop of exportedProps) {
710
- output += ` let ${prop.name} = props.${prop.name} !== undefined ? props.${prop.name} : ${prop.default};\n`;
711
- }
712
-
713
- output += `\n // User script\n`;
714
- output += jsOutput.split('\n').map(l => ' ' + l).join('\n') + '\n\n';
715
-
716
- output += ` // Reactive DOM graph\n`;
717
- output += domCode;
718
-
719
- output += `\n __flush_mounts(__ctx);\n`;
720
- output += `\n return () => {\n`;
721
- output += ` __flush_destroys(__ctx);\n`;
722
- output += ` popContext(__ctx);\n`;
723
- output += ` __target.innerHTML = '';\n`;
724
- output += ` };\n`;
725
- output += `};\n`;
726
- if (target === 'iife') {
727
- output += `})();\n`;
728
- }
729
-
730
- return {
731
- code: output,
732
- scopeHash,
733
- css: scopedCss
734
- };
735
- }
1
+ import * as acorn from 'acorn';
2
+ import * as htmlparser2 from 'htmlparser2';
3
+ import { readFileSync } from 'node:fs';
4
+
5
+ const { version: COMPILER_VERSION } = JSON.parse(
6
+ readFileSync(new URL('../package.json', import.meta.url), 'utf8')
7
+ );
8
+
9
+ const VOID_ELEMENTS = new Set([
10
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
11
+ 'link', 'meta', 'param', 'source', 'track', 'wbr'
12
+ ]);
13
+
14
+ // Escape a string that is emitted inside a backtick template literal. Without
15
+ // this, a single backtick anywhere in a <style> block (a CSS comment quoting a
16
+ // class name, say) closes the literal early and the rest of the stylesheet is
17
+ // parsed as JavaScript — which fails at runtime, not at compile time. `${` is
18
+ // escaped for the same reason.
19
+ function escapeTemplateLiteral(str) {
20
+ return str
21
+ .replace(/\\/g, '\\\\')
22
+ .replace(/`/g, '\\`')
23
+ .replace(/\$\{/g, '\\${');
24
+ }
25
+
26
+ function hashString(str) {
27
+ let hash = 5381;
28
+ for (let i = 0; i < str.length; i++) {
29
+ hash = ((hash << 5) + hash) + str.charCodeAt(i);
30
+ hash |= 0;
31
+ }
32
+ return 'sola-' + Math.abs(hash).toString(36);
33
+ }
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
+
104
+ function scopeSelectorList(selector, scopeHash) {
105
+ return splitTopLevel(selector, c => c === ',')
106
+ .map(part => {
107
+ const trimmed = part.trim();
108
+ if (!trimmed) return part;
109
+ return scopeSelector(trimmed, scopeHash);
110
+ })
111
+ .join(', ');
112
+ }
113
+
114
+ // Scope every rule's selector to this component.
115
+ //
116
+ // Deliberately brace-depth aware rather than a single regex: a regex that treats
117
+ // any run of text before `{` as a selector also rewrites declaration values that
118
+ // happen to contain a brace (`content: "${x}"`, `grid-template-areas`, a data:
119
+ // URI), silently corrupting the stylesheet. Only text at depth 0 — or directly
120
+ // inside an at-rule block such as @media — is a selector.
121
+ function scopeStyles(css, scopeHash) {
122
+ let out = '';
123
+ let buf = ''; // pending selector text
124
+ let depth = 0; // brace nesting
125
+ let inStr = null; // quote char while inside a string
126
+ let inComment = false;
127
+ // Depths that hold declarations rather than nested rules; their contents are
128
+ // copied through untouched.
129
+ const ruleDepths = [];
130
+
131
+ for (let i = 0; i < css.length; i++) {
132
+ const ch = css[i];
133
+ const next = css[i + 1];
134
+
135
+ if (inComment) {
136
+ buf += ch;
137
+ if (ch === '*' && next === '/') { buf += next; i++; inComment = false; }
138
+ continue;
139
+ }
140
+ if (inStr) {
141
+ buf += ch;
142
+ if (ch === '\\') { if (next !== undefined) { buf += next; i++; } continue; }
143
+ if (ch === inStr) inStr = null;
144
+ continue;
145
+ }
146
+ if (ch === '/' && next === '*') { buf += ch + next; i++; inComment = true; continue; }
147
+ if (ch === '"' || ch === "'") { inStr = ch; buf += ch; continue; }
148
+
149
+ if (ch === '{') {
150
+ const isDeclarationBlock = depth > 0 && !ruleDepths.includes(depth);
151
+ if (isDeclarationBlock) {
152
+ // A brace inside a declaration value — not a rule. Pass it through.
153
+ buf += ch;
154
+ continue;
155
+ }
156
+
157
+ // Split the buffer into everything that is not the selector (comments and
158
+ // whitespace) and the selector itself. A comment sitting above a rule —
159
+ // `/* Dark mode */ @media ...` — must not be treated as part of it, or the
160
+ // at-rule is misclassified and the scope hash lands inside the comment.
161
+ const commentEnd = buf.lastIndexOf('*/');
162
+ const selectorStart = commentEnd === -1 ? 0 : commentEnd + 2;
163
+ const afterComments = buf.slice(selectorStart);
164
+ const leadingLen = selectorStart + (afterComments.length - afterComments.trimStart().length);
165
+ const leading = buf.slice(0, leadingLen);
166
+ const selector = buf.slice(leadingLen);
167
+ const trimmed = selector.trim();
168
+ const isAtRule = trimmed.startsWith('@');
169
+ const isKeyframeStop =
170
+ trimmed.startsWith('from') || trimmed.startsWith('to') || /^\d+%/.test(trimmed);
171
+
172
+ // @media/@supports wrap further rules; @keyframes stops and plain rules
173
+ // wrap declarations.
174
+ const wrapsRules = isAtRule && /^@(media|supports|container|layer|scope)\b/.test(trimmed);
175
+
176
+ out += leading;
177
+ out += (isAtRule || isKeyframeStop) ? selector : scopeSelectorList(selector, scopeHash);
178
+ out += ch;
179
+ buf = '';
180
+ depth++;
181
+ if (wrapsRules) ruleDepths.push(depth);
182
+ continue;
183
+ }
184
+
185
+ if (ch === '}') {
186
+ out += buf + ch;
187
+ buf = '';
188
+ const idx = ruleDepths.indexOf(depth);
189
+ if (idx !== -1) ruleDepths.splice(idx, 1);
190
+ depth = Math.max(0, depth - 1);
191
+ continue;
192
+ }
193
+
194
+ buf += ch;
195
+ }
196
+
197
+ return out + buf;
198
+ }
199
+
200
+ function extractScriptAndStyle(source) {
201
+ let script = '';
202
+ let style = '';
203
+ let template = source;
204
+
205
+ let isTypeScript = false;
206
+ // Zero-based line in the original .sola file where the script body starts,
207
+ // so generated output can be mapped back to it.
208
+ let scriptStartLine = 0;
209
+ const scriptMatch = template.match(/<script(?:\s+lang=["'](ts|js)["'])?>([\s\S]*?)<\/script>/);
210
+ if (scriptMatch) {
211
+ isTypeScript = scriptMatch[1] === 'ts';
212
+ script = scriptMatch[2];
213
+ const openTagEnd = scriptMatch.index + scriptMatch[0].indexOf('>') + 1;
214
+ scriptStartLine = source.slice(0, openTagEnd).split('\n').length - 1;
215
+ template = template.replace(scriptMatch[0], '');
216
+ }
217
+
218
+ const styleMatch = template.match(/<style(?:\s+scoped)?>([\s\S]*?)<\/style>/);
219
+ if (styleMatch) {
220
+ style = styleMatch[1];
221
+ template = template.replace(styleMatch[0], '');
222
+ }
223
+
224
+ return { script, style, template, isTypeScript, scriptStartLine };
225
+ }
226
+
227
+ // Returns the index of the closing } that matches the { at openPos,
228
+ // respecting string literals and nested braces. Returns -1 if unbalanced.
229
+ function matchBrace(text, openPos) {
230
+ let depth = 0;
231
+ let inStr = null;
232
+ for (let i = openPos; i < text.length; i++) {
233
+ const ch = text[i];
234
+ if (inStr) {
235
+ if (ch === '\\') { i++; continue; }
236
+ if (ch === inStr) inStr = null;
237
+ continue;
238
+ }
239
+ if (ch === '"' || ch === "'" || ch === '`') { inStr = ch; continue; }
240
+ if (ch === '{') depth++;
241
+ else if (ch === '}') { if (--depth === 0) return i; }
242
+ }
243
+ return -1;
244
+ }
245
+
246
+ // Walk the template and resolve {expr} in attribute values using balanced-brace
247
+ // matching. Both dynamic attributes and event/bind handlers are lifted into side
248
+ // tables and replaced with brace-free markers (__soladyn_N__ / __solahandler_N__).
249
+ //
250
+ // Handlers must be lifted rather than written back as literal attribute text:
251
+ // extractExpressions runs over the whole template afterwards and has no notion
252
+ // of attribute boundaries, so a block-bodied handler like
253
+ // `on:click={() => { n = n() + 1; }}` had its braces eaten and turned into a
254
+ // <sola-expr> tag, which then shredded the rest of the tag into bogus
255
+ // attributes. Markers contain no braces, so nothing downstream can see inside.
256
+ function preprocessTemplate(template, dynAttrs, handlers) {
257
+ const isHandlerAttr = (name) => /^on:?[\w]+$/.test(name) || /^bind:[\w]+$/.test(name);
258
+ let result = '';
259
+ let i = 0;
260
+ while (i < template.length) {
261
+ // Opening tag (not closing, not comment)
262
+ if (template[i] === '<' && template[i + 1] !== '/' && template[i + 1] !== '!') {
263
+ result += '<';
264
+ i++;
265
+ // Tag name
266
+ while (i < template.length && /[^\s>\/]/.test(template[i])) result += template[i++];
267
+ // Attributes
268
+ while (i < template.length && template[i] !== '>' && !(template[i] === '/' && template[i + 1] === '>')) {
269
+ if (/\s/.test(template[i])) { result += template[i++]; continue; }
270
+ // Attribute name
271
+ let attrName = '';
272
+ while (i < template.length && /[^\s=\/>]/.test(template[i])) attrName += template[i++];
273
+ if (!attrName) { result += template[i++]; continue; }
274
+ if (template[i] !== '=') { result += attrName; continue; }
275
+ i++; // skip =
276
+ if (template[i] === '{') {
277
+ // Unquoted brace expression: attr={expr}
278
+ const closeIdx = matchBrace(template, i);
279
+ if (closeIdx !== -1) {
280
+ const expr = template.slice(i + 1, closeIdx).trim();
281
+ if (!expr.startsWith('#') && !expr.startsWith('/') && !expr.startsWith(':')) {
282
+ if (isHandlerAttr(attrName)) {
283
+ handlers.push(expr);
284
+ result += `${attrName}="__solahandler_${handlers.length - 1}__"`;
285
+ } else {
286
+ dynAttrs.push(expr);
287
+ result += `${attrName}="__soladyn_${dynAttrs.length - 1}__"`;
288
+ }
289
+ i = closeIdx + 1;
290
+ continue;
291
+ }
292
+ }
293
+ result += `${attrName}={`; i++;
294
+ } else if (template[i] === '"' || template[i] === "'") {
295
+ // Quoted attribute value — scan for {expr} interpolations
296
+ const quote = template[i++];
297
+ const parts = [];
298
+ let currentText = '';
299
+ let hasDyn = false;
300
+ while (i < template.length && template[i] !== quote) {
301
+ if (template[i] === '{') {
302
+ const closeIdx = matchBrace(template, i);
303
+ if (closeIdx !== -1) {
304
+ const expr = template.slice(i + 1, closeIdx).trim();
305
+ if (!expr.startsWith('#') && !expr.startsWith('/') && !expr.startsWith(':')) {
306
+ hasDyn = true;
307
+ parts.push({ text: currentText });
308
+ currentText = '';
309
+ parts.push({ expr });
310
+ i = closeIdx + 1; continue;
311
+ }
312
+ }
313
+ }
314
+ currentText += template[i++];
315
+ }
316
+ parts.push({ text: currentText });
317
+ if (i < template.length) i++; // skip closing quote
318
+ if (hasDyn && isHandlerAttr(attrName)) {
319
+ // A handler written in the quoted form, e.g. onclick="{() => go()}".
320
+ // Concatenating it as a string would produce an attribute holding
321
+ // source text rather than a wired listener.
322
+ const exprPart = parts.find((p) => p.expr !== undefined);
323
+ handlers.push(exprPart ? exprPart.expr : '() => {}');
324
+ result += `${attrName}="__solahandler_${handlers.length - 1}__"`;
325
+ } else if (hasDyn) {
326
+ // Build a string-concatenation expression instead of a runtime backtick
327
+ // template literal. Confirmed by driving a real browser against a live
328
+ // ServiceNow Service Portal instance: its widget-script delivery pipeline
329
+ // silently mangles `${expr}` interpolation inside a template literal down
330
+ // to bare, unevaluated expression text (e.g. `background: ${x}` becomes
331
+ // the literal string "background: x" — the function reference itself,
332
+ // never called). Every prior "the reactive binding doesn't do anything"
333
+ // report came from this exact code path. Concatenation is semantically
334
+ // identical and isn't affected by whatever that pipeline does to backticks.
335
+ const combined = parts
336
+ .filter((p) => p.expr !== undefined || p.text !== '')
337
+ .map((p) => (p.expr !== undefined ? `(${p.expr})` : JSON.stringify(p.text)))
338
+ .join(' + ');
339
+ dynAttrs.push(combined || `''`);
340
+ result += `${attrName}="__soladyn_${dynAttrs.length - 1}__"`;
341
+ } else {
342
+ result += `${attrName}="${currentText}"`;
343
+ }
344
+ } else {
345
+ result += `${attrName}=`;
346
+ }
347
+ continue;
348
+ }
349
+ if (i < template.length && template[i] === '/') { result += '/'; i++; }
350
+ if (i < template.length && template[i] === '>') { result += '>'; i++; }
351
+ continue;
352
+ }
353
+ result += template[i++];
354
+ }
355
+ return result;
356
+ }
357
+
358
+ // Rewrite `{:else if C}` into `{:else}{#if C}` plus a matching `{/if}`, so an
359
+ // else-if chain becomes ordinary nested if/else blocks that the block handling
360
+ // below already understands. Without this the chain is never recognised and
361
+ // every branch's expression is evaluated regardless of the condition — an
362
+ // `{:else if x}{...x.y...}` chain throws on null the moment the first branch
363
+ // is false.
364
+ function desugarElseIf(template) {
365
+ const OPEN = '{#if';
366
+ const CLOSE = '{/if}';
367
+
368
+ // Index of the {/if} matching the {#if} that starts at openIdx.
369
+ function matchingClose(text, openIdx) {
370
+ let depth = 0;
371
+ let i = openIdx;
372
+ while (i < text.length) {
373
+ if (text.startsWith(OPEN, i)) { depth++; i += OPEN.length; continue; }
374
+ if (text.startsWith(CLOSE, i)) {
375
+ depth--;
376
+ if (depth === 0) return i;
377
+ i += CLOSE.length;
378
+ continue;
379
+ }
380
+ i++;
381
+ }
382
+ return -1;
383
+ }
384
+
385
+ // First `{:else if ...}` that belongs to this block rather than a nested one.
386
+ function findElseIf(text, from, to) {
387
+ let depth = 0;
388
+ let i = from;
389
+ while (i < to) {
390
+ if (text.startsWith(OPEN, i)) { depth++; i += OPEN.length; continue; }
391
+ if (text.startsWith(CLOSE, i)) { depth--; i += CLOSE.length; continue; }
392
+ if (depth === 0 && text.startsWith('{:else', i)) {
393
+ const end = text.indexOf('}', i);
394
+ if (end === -1) return null;
395
+ const inner = text.slice(i + '{:else'.length, end);
396
+ if (/^\s+if\s+/.test(inner)) {
397
+ return { start: i, end, condition: inner.replace(/^\s+if\s+/, '').trim() };
398
+ }
399
+ }
400
+ i++;
401
+ }
402
+ return null;
403
+ }
404
+
405
+ let result = template;
406
+ let searchFrom = 0;
407
+ while (true) {
408
+ const openIdx = result.indexOf(OPEN, searchFrom);
409
+ if (openIdx === -1) break;
410
+
411
+ const closeIdx = matchingClose(result, openIdx);
412
+ if (closeIdx === -1) { searchFrom = openIdx + OPEN.length; continue; }
413
+
414
+ const hit = findElseIf(result, openIdx + OPEN.length, closeIdx);
415
+ if (!hit) { searchFrom = openIdx + OPEN.length; continue; }
416
+
417
+ // {:else if C} → {:else}{#if C} ... {/if}, the extra close going just
418
+ // before this block's own {/if}.
419
+ result =
420
+ result.slice(0, hit.start) +
421
+ '{:else}{#if ' + hit.condition + '}' +
422
+ result.slice(hit.end + 1, closeIdx) +
423
+ CLOSE +
424
+ result.slice(closeIdx);
425
+ // Re-examine this same block: a chain may hold several else-ifs.
426
+ }
427
+
428
+ return result;
429
+ }
430
+
431
+ // Convert {#if}/{:else}/{/if} and {#each}/{/each} blocks into the sola-* tag
432
+ // form the DOM emitter understands.
433
+ //
434
+ // Written as a recursive scanner rather than the regexes this replaced: a
435
+ // non-greedy regex cannot count nesting, so `{#if a}…{:else}{#if b}…{/if}{/if}`
436
+ // captured an unbalanced fragment as the else branch and produced structurally
437
+ // wrong output. Anything deeper than two levels was silently corrupted.
438
+ function transformBlocks(template) {
439
+ const attr = (s) => String(s).trim().replace(/&/g, '&amp;').replace(/"/g, '&quot;');
440
+
441
+ // Index of the tag closing the block opened at `openIdx`, honouring nesting.
442
+ function matchEnd(text, openIdx, openTag, closeTag) {
443
+ let depth = 0;
444
+ let i = openIdx;
445
+ while (i < text.length) {
446
+ if (text.startsWith(openTag, i)) { depth++; i += openTag.length; continue; }
447
+ if (text.startsWith(closeTag, i)) {
448
+ depth--;
449
+ if (depth === 0) return i;
450
+ i += closeTag.length;
451
+ continue;
452
+ }
453
+ i++;
454
+ }
455
+ return -1;
456
+ }
457
+
458
+ // Position of this block's own {:else}, ignoring any belonging to nested blocks.
459
+ function topLevelElse(text, from, to) {
460
+ let depth = 0;
461
+ let i = from;
462
+ while (i < to) {
463
+ if (text.startsWith('{#if', i)) { depth++; i += 4; continue; }
464
+ if (text.startsWith('{/if}', i)) { depth--; i += 5; continue; }
465
+ if (depth === 0 && text.startsWith('{:else}', i)) return i;
466
+ i++;
467
+ }
468
+ return -1;
469
+ }
470
+
471
+ function walk(text) {
472
+ let out = '';
473
+ let i = 0;
474
+ while (i < text.length) {
475
+ if (text.startsWith('{#if', i)) {
476
+ const condEnd = text.indexOf('}', i);
477
+ if (condEnd === -1) { out += text[i++]; continue; }
478
+ const condition = text.slice(i + 4, condEnd);
479
+ const end = matchEnd(text, i, '{#if', '{/if}');
480
+ if (end === -1) { out += text[i++]; continue; }
481
+
482
+ const body = text.slice(condEnd + 1, end);
483
+ const elseIdx = topLevelElse(body, 0, body.length);
484
+ const thenPart = elseIdx === -1 ? body : body.slice(0, elseIdx);
485
+ const elsePart = elseIdx === -1 ? null : body.slice(elseIdx + '{:else}'.length);
486
+
487
+ out += `<sola-if condition="${attr(condition)}"><sola-then>${walk(thenPart)}</sola-then>`;
488
+ if (elsePart !== null) out += `<sola-else>${walk(elsePart)}</sola-else>`;
489
+ out += `</sola-if>`;
490
+ i = end + '{/if}'.length;
491
+ continue;
492
+ }
493
+
494
+ if (text.startsWith('{#each', i)) {
495
+ const headEnd = text.indexOf('}', i);
496
+ if (headEnd === -1) { out += text[i++]; continue; }
497
+ const head = text.slice(i, headEnd + 1);
498
+ const parsed = /^{#each\s+([\s\S]*?)\s+as\s+([\w$]+)(?:\s*,\s*([\w$]+))?(?:\s*\(([^)]+)\))?}$/.exec(head);
499
+ const end = matchEnd(text, i, '{#each', '{/each}');
500
+ if (!parsed || end === -1) { out += text[i++]; continue; }
501
+
502
+ const [, arr, item, index, key] = parsed;
503
+ out += `<sola-each array="${attr(arr)}" item="${attr(item)}"`;
504
+ if (index) out += ` index="${attr(index)}"`;
505
+ if (key) out += ` key="${attr(key)}"`;
506
+ out += `>${walk(text.slice(headEnd + 1, end))}</sola-each>`;
507
+ i = end + '{/each}'.length;
508
+ continue;
509
+ }
510
+
511
+ out += text[i++];
512
+ }
513
+ return out;
514
+ }
515
+
516
+ return walk(template);
517
+ }
518
+
519
+ // Replace {expr} in text content with <sola-expr> tags, using balanced-brace
520
+ // matching so expressions like {fn({key: val})} work correctly.
521
+ function extractExpressions(text) {
522
+ let result = '';
523
+ let i = 0;
524
+ while (i < text.length) {
525
+ if (text[i] === '{') {
526
+ const closeIdx = matchBrace(text, i);
527
+ if (closeIdx === -1) { result += text[i++]; continue; }
528
+ const inner = text.slice(i + 1, closeIdx).trim();
529
+ if (inner.startsWith('#') || inner.startsWith('/') || inner.startsWith(':')) {
530
+ result += text.slice(i, closeIdx + 1); i = closeIdx + 1;
531
+ } else if (inner.startsWith('@html')) {
532
+ const htmlExpr = inner.slice('@html'.length).trim();
533
+ result += `<sola-html expr="${htmlExpr.replace(/"/g, '&quot;')}"></sola-html>`;
534
+ i = closeIdx + 1;
535
+ } else {
536
+ result += `<sola-expr expr="${inner.replace(/"/g, '&quot;')}"></sola-expr>`;
537
+ i = closeIdx + 1;
538
+ }
539
+ } else {
540
+ result += text[i++];
541
+ }
542
+ }
543
+ return result;
544
+ }
545
+
546
+ // Strip TypeScript type annotations so Acorn (plain JS parser) can handle
547
+ // script blocks written in TS. Covers the common patterns: type annotations,
548
+ // interface/type declarations, angle-bracket generics, and `as` casts.
549
+ function stripTypeScript(code) {
550
+ // Remove interface and type alias declarations
551
+ code = code.replace(/^\s*(export\s+)?(interface|type)\s+\w[\s\S]*?(?=\n(?:export|const|let|var|function|class|\/\/|$))/gm, '');
552
+ // Remove inline type annotations: `: Type` before = , ) , ; , , or end of line
553
+ code = code.replace(/:\s*[\w<>\[\]|&{},\s.?]+(?=[=,);{}\n])/g, '');
554
+ // Remove `as Type` casts
555
+ code = code.replace(/\s+as\s+[\w<>\[\]|&{}.?]+/g, '');
556
+ // Remove generic type parameters from function/class declarations
557
+ code = code.replace(/<[^>()=]+>(?=\s*\()/g, '');
558
+ return code;
559
+ }
560
+
561
+ // ─── Reactive auto-call transform ───
562
+ // Makes the parenthesis-free syntax real: bare reads of $state/$derived vars
563
+ // compile to getter calls (count → count(), user.name → user().name), so
564
+ // `$derived(count * 2)` and `{doubled}` behave the way the docs show them.
565
+ // Explicit calls (count()) are left alone, keeping the README syntax valid.
566
+
567
+ function collectPatternNames(node, out) {
568
+ if (!node) return;
569
+ switch (node.type) {
570
+ case 'Identifier': out.add(node.name); break;
571
+ case 'ObjectPattern': node.properties.forEach(p => collectPatternNames(p.value || p.argument, out)); break;
572
+ case 'ArrayPattern': node.elements.forEach(el => collectPatternNames(el, out)); break;
573
+ case 'AssignmentPattern': collectPatternNames(node.left, out); break;
574
+ case 'RestElement': collectPatternNames(node.argument, out); break;
575
+ }
576
+ }
577
+
578
+ // Collect every name declared anywhere inside a function body. Deliberately
579
+ // conservative: a name declared in ANY nested scope suppresses auto-call for
580
+ // that name within the function, preferring a missed rewrite over a wrong one.
581
+ function collectDeclaredNames(node, out) {
582
+ if (!node || typeof node !== 'object' || !node.type) return;
583
+ if (node.type === 'VariableDeclaration') node.declarations.forEach(d => collectPatternNames(d.id, out));
584
+ if ((node.type === 'FunctionDeclaration' || node.type === 'ClassDeclaration') && node.id) out.add(node.id.name);
585
+ if (node.type === 'CatchClause' && node.param) collectPatternNames(node.param, out);
586
+ for (const key of Object.keys(node)) {
587
+ if (key === 'type') continue;
588
+ const child = node[key];
589
+ if (Array.isArray(child)) child.forEach(c => collectDeclaredNames(c, out));
590
+ else if (child && typeof child === 'object' && child.type) collectDeclaredNames(child, out);
591
+ }
592
+ }
593
+
594
+ // Pre-pass: find every $state/$derived/$intent/$data declaration up front so
595
+ // reads that appear earlier in the source (hoisted functions) still rewrite.
596
+ function collectReactiveVars(ast) {
597
+ const vars = { state: new Set(), derived: new Set(), intent: new Set(), data: new Set() };
598
+ (function walk(node) {
599
+ if (!node || typeof node !== 'object' || !node.type) return;
600
+ if (node.type === 'VariableDeclaration') {
601
+ for (const decl of node.declarations) {
602
+ if (decl.init && decl.init.type === 'CallExpression' &&
603
+ decl.init.callee.type === 'Identifier' && decl.id.type === 'Identifier') {
604
+ const macro = decl.init.callee.name;
605
+ if (macro === '$state' || macro === '$persist') vars.state.add(decl.id.name);
606
+ else if (macro === '$derived' || macro === '$lastEvent') vars.derived.add(decl.id.name);
607
+ else if (macro === '$intent') vars.intent.add(decl.id.name);
608
+ else if (macro === '$data') vars.data.add(decl.id.name);
609
+ }
610
+ }
611
+ }
612
+ for (const key of Object.keys(node)) {
613
+ if (key === 'type') continue;
614
+ const child = node[key];
615
+ if (Array.isArray(child)) child.forEach(walk);
616
+ else if (child && typeof child === 'object' && child.type) walk(child);
617
+ }
618
+ })(ast);
619
+ return vars;
620
+ }
621
+
622
+ const DATA_ACCESSOR_METHODS = new Set(['refetch', 'stop']);
623
+ const INTENT_SUB_SIGNALS = new Set(['loading', 'error']);
624
+
625
+ function reactiveKind(name, vars, shadowStack) {
626
+ for (const scope of shadowStack) if (scope.has(name)) return null;
627
+ if (vars.state.has(name)) return 'state';
628
+ if (vars.derived.has(name)) return 'derived';
629
+ if (vars.intent.has(name)) return 'intent';
630
+ if (vars.data.has(name)) return 'data';
631
+ return null;
632
+ }
633
+
634
+ // Walk an AST emitting position edits that implement the reactive rewrite:
635
+ // - `count = v` → `set_count(v)` (bracket edits so children still walk)
636
+ // - `count += v` → `set_count(count() + (v))`
637
+ // - `count++` → `set_count(count() + 1)`
638
+ // - bare read → `count()`; member-object read `user.name` → `user().name`
639
+ // - `{count}` shorthand in object literals → `{count: count()}`
640
+ // - $intent accessors: `x.loading` → `x.loading()` (sub-signals live on the fn)
641
+ // - $data accessors: `x.data` → `x().data`, but `x.refetch`/`x.stop` untouched
642
+ function emitReactiveEdits(rootNode, src, vars, edits, initialShadows) {
643
+ function walk(node, parent, shadows) {
644
+ if (!node || typeof node !== 'object' || !node.type) return;
645
+
646
+ if (node.type === 'FunctionDeclaration' || node.type === 'FunctionExpression' || node.type === 'ArrowFunctionExpression') {
647
+ const scope = new Set();
648
+ if (node.id) scope.add(node.id.name);
649
+ (node.params || []).forEach(p => collectPatternNames(p, scope));
650
+ collectDeclaredNames(node.body, scope);
651
+ shadows = [...shadows, scope];
652
+ }
653
+
654
+ if (node.type === 'AssignmentExpression' && node.left.type === 'Identifier' &&
655
+ reactiveKind(node.left.name, vars, shadows) === 'state') {
656
+ const name = node.left.name;
657
+ if (node.operator === '=') {
658
+ edits.push({ start: node.start, end: node.right.start, replacement: `set_${name}(` });
659
+ edits.push({ start: node.end, end: node.end, replacement: `)` });
660
+ } else {
661
+ const rawOp = node.operator.slice(0, -1);
662
+ edits.push({ start: node.start, end: node.right.start, replacement: `set_${name}(${name}() ${rawOp} (` });
663
+ edits.push({ start: node.end, end: node.end, replacement: `))` });
664
+ }
665
+ walk(node.right, node, shadows);
666
+ return;
667
+ }
668
+
669
+ if (node.type === 'UpdateExpression' && node.argument.type === 'Identifier' &&
670
+ reactiveKind(node.argument.name, vars, shadows) === 'state') {
671
+ const name = node.argument.name;
672
+ const op = node.operator === '++' ? '+' : '-';
673
+ edits.push({ start: node.start, end: node.end, replacement: `set_${name}(${name}() ${op} 1)` });
674
+ return;
675
+ }
676
+
677
+ // $intent sub-signals: x.loading / x.error are functions hung on the accessor
678
+ if (node.type === 'MemberExpression' && !node.computed &&
679
+ node.object.type === 'Identifier' && node.property.type === 'Identifier' &&
680
+ reactiveKind(node.object.name, vars, shadows) === 'intent' &&
681
+ INTENT_SUB_SIGNALS.has(node.property.name) &&
682
+ !(parent && parent.type === 'CallExpression' && parent.callee === node)) {
683
+ edits.push({ start: node.end, end: node.end, replacement: `()` });
684
+ return; // children need no further rewriting
685
+ }
686
+
687
+ if (node.type === 'Identifier') {
688
+ const kind = reactiveKind(node.name, vars, shadows);
689
+ if (kind) {
690
+ // A fragment root (e.g. the whole expression is just `count`) has no
691
+ // parent — treat it as a plain read position.
692
+ const p = parent || { type: '__fragment_root__' };
693
+ const isDeclOrBinding =
694
+ (p.type === 'VariableDeclarator' && p.id === node) ||
695
+ ((p.type === 'FunctionDeclaration' || p.type === 'FunctionExpression' || p.type === 'ClassDeclaration') && p.id === node) ||
696
+ (p.type === 'AssignmentPattern' && p.left === node) ||
697
+ p.type === 'RestElement' ||
698
+ p.type === 'ImportSpecifier' || p.type === 'ImportDefaultSpecifier' ||
699
+ p.type === 'ExportSpecifier' || p.type === 'LabeledStatement' ||
700
+ p.type === 'BreakStatement' || p.type === 'ContinueStatement';
701
+ const isMemberProp = p.type === 'MemberExpression' && p.property === node && !p.computed;
702
+ const isCallee = (p.type === 'CallExpression' || p.type === 'NewExpression') && p.callee === node;
703
+ const isAssignTarget = (p.type === 'AssignmentExpression' && p.left === node) || p.type === 'UpdateExpression';
704
+ const isPropertyKey = p.type === 'Property' && p.key === node && !p.computed;
705
+ const isShorthand = p.type === 'Property' && p.shorthand && p.value === node;
706
+ const isMemberObject = p.type === 'MemberExpression' && p.object === node;
707
+
708
+ if (!isDeclOrBinding && !isMemberProp && !isCallee && !isAssignTarget && !isPropertyKey) {
709
+ if (isShorthand) {
710
+ edits.push({ start: node.start, end: node.end, replacement: `${node.name}: ${node.name}()` });
711
+ return;
712
+ }
713
+ if (isMemberObject) {
714
+ if (kind === 'state' || kind === 'derived' ||
715
+ (kind === 'data' && !(p.property.type === 'Identifier' && !p.computed && DATA_ACCESSOR_METHODS.has(p.property.name)))) {
716
+ edits.push({ start: node.end, end: node.end, replacement: `()` });
717
+ }
718
+ // intent accessors keep their function identity for member access
719
+ return;
720
+ }
721
+ edits.push({ start: node.end, end: node.end, replacement: `()` });
722
+ return;
723
+ }
724
+ }
725
+ }
726
+
727
+ for (const key of Object.keys(node)) {
728
+ if (key === 'type') continue;
729
+ const child = node[key];
730
+ if (Array.isArray(child)) child.forEach(c => walk(c, node, shadows));
731
+ else if (child && typeof child === 'object' && child.type) walk(child, node, shadows);
732
+ }
733
+ }
734
+ walk(rootNode, null, initialShadows || []);
735
+ }
736
+
737
+ function applyEdits(src, edits) {
738
+ // Descending by start (then end) so earlier positions stay valid; zero-width
739
+ // inserts at a shared position land before same-position range replacements.
740
+ const sorted = [...edits].sort((a, b) => (b.start - a.start) || (b.end - a.end));
741
+ let out = src;
742
+ for (const edit of sorted) {
743
+ out = out.slice(0, edit.start) + edit.replacement + out.slice(edit.end);
744
+ }
745
+ return out;
746
+ }
747
+
748
+ const NO_REACTIVE_VARS = { state: new Set(), derived: new Set(), intent: new Set(), data: new Set() };
749
+
750
+ function hasReactiveVars(vars) {
751
+ return vars.state.size > 0 || vars.derived.size > 0 || vars.intent.size > 0 || vars.data.size > 0;
752
+ }
753
+
754
+ // Rewrite one template expression (text interpolation, dynamic attribute,
755
+ // if-condition, each-source, event handler) with the reactive auto-call rules.
756
+ // `locals` holds template-scope names (each-item, each-index) that shadow signals.
757
+ // Unparseable input is returned unchanged — the emitter will surface it as-is.
758
+ function transformTemplateExpression(src, vars, locals) {
759
+ if (!hasReactiveVars(vars) || !src || !src.trim()) return src;
760
+ let ast;
761
+ try {
762
+ ast = acorn.parseExpressionAt(src, 0, { ecmaVersion: 'latest' });
763
+ } catch {
764
+ return src;
765
+ }
766
+ const edits = [];
767
+ emitReactiveEdits(ast, src, vars, edits, locals && locals.size ? [locals] : []);
768
+ return edits.length > 0 ? applyEdits(src, edits) : src;
769
+ }
770
+
771
+ // ─── Source maps ───
772
+ // The generated module inlines the user's script verbatim (indented by two
773
+ // spaces) into the mount function, so every line of that block maps 1:1 back to
774
+ // its line in the .sola file. That covers the case that matters: a runtime error
775
+ // or breakpoint inside user code pointing at user code rather than at generated
776
+ // output. Emitted DOM instructions have no user-authored counterpart and are
777
+ // deliberately left unmapped.
778
+
779
+ const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
780
+
781
+ function encodeVlq(value) {
782
+ let vlq = value < 0 ? ((-value) << 1) | 1 : value << 1;
783
+ let out = '';
784
+ do {
785
+ let digit = vlq & 0b11111;
786
+ vlq >>>= 5;
787
+ if (vlq > 0) digit |= 0b100000;
788
+ out += B64[digit];
789
+ } while (vlq > 0);
790
+ return out;
791
+ }
792
+
793
+ /**
794
+ * Build a v3 source map from a list of {generatedLine, sourceLine} pairs
795
+ * (both zero-based), each mapping column 0 to column 0 of the source line.
796
+ */
797
+ function buildSourceMap(segments, filename, sourceContent, generatedLineCount) {
798
+ const byLine = new Map();
799
+ for (const seg of segments) byLine.set(seg.generatedLine, seg.sourceLine);
800
+
801
+ let prevSourceLine = 0;
802
+ const lines = [];
803
+ for (let line = 0; line < generatedLineCount; line++) {
804
+ if (!byLine.has(line)) { lines.push(''); continue; }
805
+ const sourceLine = byLine.get(line);
806
+ // [generatedColumn, sourceIndex, sourceLine, sourceColumn], all relative
807
+ // except generatedColumn which resets each line.
808
+ lines.push(
809
+ encodeVlq(0) + encodeVlq(0) + encodeVlq(sourceLine - prevSourceLine) + encodeVlq(0)
810
+ );
811
+ prevSourceLine = sourceLine;
812
+ }
813
+
814
+ return {
815
+ version: 3,
816
+ file: filename ? filename.replace(/\.sola$/, '.js') : undefined,
817
+ sources: [filename || 'component.sola'],
818
+ sourcesContent: [sourceContent],
819
+ names: [],
820
+ mappings: lines.join(';')
821
+ };
822
+ }
823
+
824
+ export function compile(source, options = {}) {
825
+ const target = options.target || 'esm'; // 'esm' | 'iife'
826
+ const { script: scriptContent, style: styleContent, template: rawTemplateSource, isTypeScript, scriptStartLine } = extractScriptAndStyle(source);
827
+ const scopeHash = hashString(styleContent || rawTemplateSource);
828
+ const hasStyles = styleContent.trim().length > 0;
829
+ const scopedCss = hasStyles ? scopeStyles(styleContent, scopeHash) : '';
830
+
831
+ // Flatten else-if chains into nested if/else before anything else looks at
832
+ // the template, so the block handling below only ever sees the simple form.
833
+ let rawTemplate = desugarElseIf(rawTemplateSource);
834
+
835
+ // Pre-process attribute-bound expressions BEFORE extractExpressions using
836
+ // balanced-brace matching (handles nested object literals, ternaries, etc.)
837
+ const dynAttrs = [];
838
+ const handlers = [];
839
+ rawTemplate = preprocessTemplate(rawTemplate, dynAttrs, handlers);
840
+
841
+ // Resolve a __solahandler_N__ marker back to its expression. Anything else is
842
+ // passed through, so a handler written as a bare identifier still works.
843
+ const resolveHandler = (val) => {
844
+ const m = /^__solahandler_(\d+)__$/.exec(val);
845
+ return m ? handlers[parseInt(m[1], 10)] : val;
846
+ };
847
+ rawTemplate = extractExpressions(rawTemplate);
848
+
849
+ // Convert {#if}/{:else}/{/if} and {#each}/{/each} into the sola-* tag form
850
+ // the emitter walks.
851
+ rawTemplate = transformBlocks(rawTemplate);
852
+
853
+ // Parse HTML AST
854
+ const templateNodes = [];
855
+ let root = { type: 'element', name: '__root__', attribs: {}, children: [] };
856
+ templateNodes.push(root);
857
+
858
+ const parser = new htmlparser2.Parser({
859
+ onopentag(name, attribs) {
860
+ const node = { type: 'element', name, attribs, children: [] };
861
+ templateNodes[templateNodes.length - 1].children.push(node);
862
+ if (!VOID_ELEMENTS.has(name)) {
863
+ templateNodes.push(node);
864
+ }
865
+ },
866
+ ontext(text) {
867
+ if (text.trim().length > 0 && templateNodes.length > 0) {
868
+ templateNodes[templateNodes.length - 1].children.push({ type: 'text', data: text });
869
+ }
870
+ },
871
+ onclosetag(name) {
872
+ if (templateNodes.length > 1 && !VOID_ELEMENTS.has(name)) {
873
+ templateNodes.pop();
874
+ }
875
+ }
876
+ }, { recognizeSelfClosing: true, lowerCaseTags: false, lowerCaseAttributeNames: false });
877
+
878
+ parser.write(rawTemplate.trim() || '<div></div>');
879
+ parser.end();
880
+
881
+ // Parse JS AST with Acorn
882
+ // Strip TS first — AST positions and all slice() calls operate on the stripped source.
883
+ // Only run on <script lang="ts"> — the strip regex can't reliably distinguish a type
884
+ // annotation's `:` from a ternary's `: falsyBranch` or an object literal's `key: value`,
885
+ // so applying it to plain JS risks corrupting valid code. Scripts that don't opt into TS
886
+ // are left untouched.
887
+ const strippedScript = scriptContent.trim() && isTypeScript ? stripTypeScript(scriptContent) : scriptContent;
888
+ let jsOutput = strippedScript;
889
+ const stateVars = new Set();
890
+ const exportedProps = [];
891
+ const componentImports = [];
892
+
893
+ let reactiveVars = NO_REACTIVE_VARS;
894
+ if (strippedScript.trim()) {
895
+ try {
896
+ const ast = acorn.parse(strippedScript, { ecmaVersion: 'latest', sourceType: 'module' });
897
+ const edits = [];
898
+ // Whole-node removals (imports, exported props): any rewrite edit that
899
+ // falls inside one of these spans must be dropped, not applied on top.
900
+ const removedRanges = [];
901
+
902
+ // Pre-pass so reads that appear before their $state declaration
903
+ // (hoisted handler functions) still rewrite correctly.
904
+ reactiveVars = collectReactiveVars(ast);
905
+ for (const name of reactiveVars.state) stateVars.add(name);
906
+
907
+ function walkAST(node, parent) {
908
+ if (!node || typeof node !== 'object') return;
909
+
910
+ // Track component imports
911
+ if (node.type === 'ImportDeclaration') {
912
+ const src = node.source.value;
913
+ if (src.endsWith('.sola')) {
914
+ node.specifiers.forEach(spec => {
915
+ componentImports.push({ localName: spec.local.name, path: src });
916
+ });
917
+ }
918
+ edits.push({
919
+ start: node.start,
920
+ end: node.end,
921
+ replacement: `// import hoisted: ${node.source.value}`
922
+ });
923
+ removedRanges.push([node.start, node.end]);
924
+ }
925
+
926
+ // Exported props
927
+ if (node.type === 'ExportNamedDeclaration' && node.declaration &&
928
+ node.declaration.type === 'VariableDeclaration') {
929
+ node.declaration.declarations.forEach(decl => {
930
+ const name = decl.id.name;
931
+ const defaultVal = decl.init
932
+ ? strippedScript.slice(decl.init.start, decl.init.end)
933
+ : 'undefined';
934
+ exportedProps.push({ name, default: defaultVal });
935
+ });
936
+ edits.push({ start: node.start, end: node.end, replacement: '' });
937
+ removedRanges.push([node.start, node.end]);
938
+ }
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
+
950
+ // $state()/$derived()/$effect()/$intent()/$data() macro declarations.
951
+ // Bracket edits (head + tail) leave the argument expression in place so
952
+ // the reactive rewrite pass below can still auto-call signals inside it.
953
+ if (node.type === 'VariableDeclaration' && node.declarations.length === 1) {
954
+ const decl = node.declarations[0];
955
+ if (decl.init && decl.init.type === 'CallExpression' && decl.init.callee.type === 'Identifier') {
956
+ const macro = decl.init.callee.name;
957
+ const arg = decl.init.arguments[0];
958
+ const lastArg = decl.init.arguments[decl.init.arguments.length - 1];
959
+ if (macro === '$state' || macro === '$persist') {
960
+ // $persist(key, initial, options) is $state backed by storage and
961
+ // synced across tabs/pages; it reads and assigns exactly like $state.
962
+ const varName = decl.id.name;
963
+ const factory = macro === '$persist' ? 'createPersistentSignal' : 'createSignal';
964
+ if (arg) {
965
+ edits.push({ start: node.start, end: arg.start, replacement: `const [${varName}, set_${varName}] = ${factory}(` });
966
+ edits.push({ start: lastArg.end, end: node.end, replacement: `);` });
967
+ } else {
968
+ edits.push({ start: node.start, end: node.end, replacement: `const [${varName}, set_${varName}] = createSignal(undefined);` });
969
+ }
970
+ } else if (macro === '$derived') {
971
+ const varName = decl.id.name;
972
+ if (arg) {
973
+ const isFn = arg.type === 'ArrowFunctionExpression' || arg.type === 'FunctionExpression';
974
+ edits.push({
975
+ start: node.start,
976
+ end: arg.start,
977
+ replacement: isFn ? `const ${varName} = createDerived(` : `const ${varName} = createDerived(() => `
978
+ });
979
+ edits.push({ start: arg.end, end: node.end, replacement: `);` });
980
+ } else {
981
+ edits.push({ start: node.start, end: node.end, replacement: `const ${varName} = createDerived(() => undefined);` });
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
+ }
993
+ } else if (macro === '$effect') {
994
+ if (arg) {
995
+ edits.push({ start: node.start, end: arg.start, replacement: `createEffect(` });
996
+ edits.push({ start: arg.end, end: node.end, replacement: `);` });
997
+ } else {
998
+ edits.push({ start: node.start, end: node.end, replacement: `createEffect(() => {});` });
999
+ }
1000
+ } else if (macro === '$intent') {
1001
+ if (arg) {
1002
+ edits.push({ start: node.start, end: arg.start, replacement: `const ${decl.id.name} = createIntent(` });
1003
+ edits.push({ start: lastArg.end, end: node.end, replacement: `);` });
1004
+ } else {
1005
+ edits.push({ start: node.start, end: node.end, replacement: `const ${decl.id.name} = createIntent('');` });
1006
+ }
1007
+ } else if (macro === '$data') {
1008
+ if (arg) {
1009
+ edits.push({ start: node.start, end: arg.start, replacement: `const ${decl.id.name} = createData(` });
1010
+ edits.push({ start: lastArg.end, end: node.end, replacement: `);` });
1011
+ } else {
1012
+ edits.push({ start: node.start, end: node.end, replacement: `const ${decl.id.name} = createData('');` });
1013
+ }
1014
+ }
1015
+ }
1016
+ }
1017
+
1018
+ for (const key of Object.keys(node)) {
1019
+ if (key === 'type') continue;
1020
+ const child = node[key];
1021
+ if (Array.isArray(child)) {
1022
+ child.forEach(c => walkAST(c, node));
1023
+ } else if (child && typeof child === 'object' && child.type) {
1024
+ walkAST(child, node);
1025
+ }
1026
+ }
1027
+ }
1028
+
1029
+ walkAST(ast, null);
1030
+
1031
+ // Reactive rewrite pass: assignments/updates to signals become setter
1032
+ // calls, bare signal reads become getter calls. Edits landing inside a
1033
+ // removed import/export span are dropped.
1034
+ const rewriteEdits = [];
1035
+ emitReactiveEdits(ast, strippedScript, reactiveVars, rewriteEdits, []);
1036
+ for (const edit of rewriteEdits) {
1037
+ if (removedRanges.some(([s, e]) => edit.start >= s && edit.end <= e)) continue;
1038
+ edits.push(edit);
1039
+ }
1040
+
1041
+ jsOutput = applyEdits(strippedScript, edits);
1042
+ } catch (err) {
1043
+ const loc = err.loc ? ` (line ${err.loc.line}, col ${err.loc.column})` : '';
1044
+ const filePath = typeof options === 'string' ? options : (options.filename || '<script>');
1045
+ throw new Error(`[sola compiler] Parse error in ${filePath}${loc}:\n ${err.message}`);
1046
+ }
1047
+ }
1048
+
1049
+ // Generate DOM creation instructions
1050
+ let uid = 0;
1051
+ let domCode = '';
1052
+ const importedComponentSet = new Set(componentImports.map(i => i.localName));
1053
+
1054
+ const sourceFile = options.filename || '<component>';
1055
+
1056
+ // Reject a template expression that is not valid JavaScript, instead of
1057
+ // emitting it and letting the bundler report a syntax error inside generated
1058
+ // code the author never wrote. The usual cause is a literal brace in markup —
1059
+ // a code sample containing `setInterval(() => {` — where `{` opens an
1060
+ // expression, so everything up to the next `}` is swallowed.
1061
+ function checkExpression(expr, whatFor) {
1062
+ try {
1063
+ const trimmed = expr.trim();
1064
+ const node = acorn.parseExpressionAt(trimmed, 0, { ecmaVersion: 'latest' });
1065
+ // parseExpressionAt stops at the first complete expression and ignores
1066
+ // whatever follows, so `{<span>…}` only fails once the whole string has
1067
+ // to be consumed.
1068
+ if (node.end !== trimmed.length) throw new Error('trailing content');
1069
+ } catch {
1070
+ const preview = expr.length > 80 ? expr.slice(0, 80) + '…' : expr;
1071
+ throw new Error(
1072
+ `[sola compiler] Invalid ${whatFor} in ${sourceFile}:\n` +
1073
+ ` {${preview}}\n` +
1074
+ ` This is not a valid JavaScript expression. If you meant a literal ` +
1075
+ `brace in your markup, escape it as &#123; and &#125;.`
1076
+ );
1077
+ }
1078
+ }
1079
+
1080
+ function emitNode(node, parentVar, locals = new Set()) {
1081
+ if (!node) return;
1082
+ // Template-expression rewrite with the current template scope (each-item /
1083
+ // each-index names shadow signals of the same name).
1084
+ const rewrite = (expr) => transformTemplateExpression(expr, reactiveVars, locals);
1085
+
1086
+ if (node.type === 'text') {
1087
+ const text = node.data.replace(/\\/g, '\\\\').replace(/`/g, '\\`');
1088
+ if (text.trim().length > 0) {
1089
+ const id = `t${uid++}`;
1090
+ domCode += ` const ${id} = document.createTextNode(\`${text}\`);\n`;
1091
+ domCode += ` ${parentVar}.appendChild(${id});\n`;
1092
+ }
1093
+ return;
1094
+ }
1095
+
1096
+ if (node.name === 'sola-expr') {
1097
+ const raw = (node.attribs.expr || '').replace(/&quot;/g, '"');
1098
+ checkExpression(raw, 'template expression');
1099
+ const expr = rewrite(raw);
1100
+ const id = `e${uid++}`;
1101
+ domCode += ` const ${id} = document.createTextNode('');\n`;
1102
+ domCode += ` ${parentVar}.appendChild(${id});\n`;
1103
+ // Parenthesize expr before `?? ''`: JS forbids mixing ?? with || or && at the
1104
+ // same level without explicit grouping (e.g. `col.label || col ?? ''` is a
1105
+ // SyntaxError), and any {a || b}-style fallback expression — an extremely
1106
+ // common pattern — would otherwise produce invalid generated code.
1107
+ domCode += ` createEffect(() => { ${id}.textContent = String((${expr}) ?? ''); });\n`;
1108
+ return;
1109
+ }
1110
+
1111
+ // {@html expr} — renders expr as raw HTML via innerHTML, unlike {expr} which always
1112
+ // goes through textContent. Wrapped in its own element so setting innerHTML can't
1113
+ // clobber unrelated sibling nodes. Callers are responsible for trusting/sanitizing
1114
+ // whatever markup they pass — same contract as innerHTML anywhere else.
1115
+ if (node.name === 'sola-html') {
1116
+ const expr = rewrite((node.attribs.expr || '').replace(/&quot;/g, '"'));
1117
+ const id = `h${uid++}`;
1118
+ domCode += ` const ${id} = document.createElement('div');\n`;
1119
+ domCode += ` ${id}.style.display = 'contents';\n`;
1120
+ domCode += ` ${parentVar}.appendChild(${id});\n`;
1121
+ domCode += ` createEffect(() => { ${id}.innerHTML = String((${expr}) ?? ''); });\n`;
1122
+ return;
1123
+ }
1124
+
1125
+ // Nested custom component
1126
+ if (importedComponentSet.has(node.name)) {
1127
+ const cid = `comp${uid++}`;
1128
+ domCode += ` const ${cid}_target = document.createElement('div');\n`;
1129
+ domCode += ` ${cid}_target.className = 'sola-component-root';\n`;
1130
+ domCode += ` ${parentVar}.appendChild(${cid}_target);\n`;
1131
+
1132
+ // Dynamically-bound props (rows={items}) arrive here as __soladyn_N__ placeholders —
1133
+ // resolve them back to the real expression from dynAttrs and pass it as a raw
1134
+ // (unquoted) property so the child receives live data, not the placeholder text.
1135
+ //
1136
+ // `onXxx`-named props (e.g. onChange={handler}) arrive as a
1137
+ // __solahandler_N__ marker, because preprocessTemplate cannot tell a
1138
+ // component apart from a native element and lifts both the same way.
1139
+ // Either way the expression is passed through as a live function.
1140
+ const propEntries = [];
1141
+ for (const [key, val] of Object.entries(node.attribs || {})) {
1142
+ const dynMatch = /^__soladyn_(\d+)__$/.exec(val);
1143
+ const handlerMatch = /^__solahandler_(\d+)__$/.exec(val);
1144
+ // Dynamic (non-event) component props are deliberately NOT auto-called:
1145
+ // passing the getter is how a child receives live data today, and
1146
+ // README-syntax components rely on it.
1147
+ const propValue = dynMatch
1148
+ ? `(${dynAttrs[parseInt(dynMatch[1], 10)]})`
1149
+ : handlerMatch
1150
+ ? `(${rewrite(handlers[parseInt(handlerMatch[1], 10)])})`
1151
+ : JSON.stringify(val);
1152
+ propEntries.push(`${JSON.stringify(key)}: ${propValue}`);
1153
+ }
1154
+
1155
+ // Slot content: compile the tag's children into a function the child component can
1156
+ // call to project them wherever it renders a <slot> in its own template.
1157
+ if (node.children && node.children.length > 0) {
1158
+ const slotVar = `${cid}_slot`;
1159
+ domCode += ` const ${slotVar} = (__slot_target) => {\n`;
1160
+ node.children.forEach(child => emitNode(child, '__slot_target', locals));
1161
+ domCode += ` };\n`;
1162
+ propEntries.push(`children: ${slotVar}`);
1163
+ }
1164
+
1165
+ domCode += ` ${node.name}(${cid}_target, { ${propEntries.join(', ')} });\n`;
1166
+ return;
1167
+ }
1168
+
1169
+ // Slot placeholder: projects the children passed by a parent component, if any.
1170
+ if (node.name === 'slot') {
1171
+ domCode += ` if (typeof props.children === 'function') { props.children(${parentVar}); }\n`;
1172
+ return;
1173
+ }
1174
+
1175
+ // {#if ...} {:else} ... {/if}
1176
+ if (node.name === 'sola-if') {
1177
+ const cid = `c${uid++}`;
1178
+ domCode += ` const ${cid}_a = document.createComment('if');\n`;
1179
+ domCode += ` ${parentVar}.appendChild(${cid}_a);\n`;
1180
+ domCode += ` let ${cid}_els = [];\n`;
1181
+
1182
+ const thenChild = node.children.find(c => c.name === 'sola-then');
1183
+ const elseChild = node.children.find(c => c.name === 'sola-else');
1184
+
1185
+ domCode += ` createEffect(() => {\n`;
1186
+ domCode += ` ${cid}_els.forEach(e => e.remove()); ${cid}_els = [];\n`;
1187
+ domCode += ` if (${rewrite(node.attribs.condition)}) {\n`;
1188
+ if (thenChild) {
1189
+ domCode += ` const f = document.createDocumentFragment();\n`;
1190
+ thenChild.children.forEach(child => emitNode(child, 'f', locals));
1191
+ domCode += ` Array.from(f.childNodes).forEach(n => ${cid}_els.push(n));\n`;
1192
+ domCode += ` ${cid}_a.parentNode.insertBefore(f, ${cid}_a.nextSibling);\n`;
1193
+ }
1194
+ domCode += ` } else {\n`;
1195
+ if (elseChild) {
1196
+ domCode += ` const f = document.createDocumentFragment();\n`;
1197
+ elseChild.children.forEach(child => emitNode(child, 'f', locals));
1198
+ domCode += ` Array.from(f.childNodes).forEach(n => ${cid}_els.push(n));\n`;
1199
+ domCode += ` ${cid}_a.parentNode.insertBefore(f, ${cid}_a.nextSibling);\n`;
1200
+ }
1201
+ domCode += ` }\n`;
1202
+ domCode += ` });\n`;
1203
+ return;
1204
+ }
1205
+
1206
+ // {#each} with optional keyed diffing: {#each items as item (item.id)}
1207
+ if (node.name === 'sola-each') {
1208
+ const eid = `e${uid++}`;
1209
+ // Item/index names shadow signals inside the loop body and key expression
1210
+ const eachLocals = new Set(locals);
1211
+ eachLocals.add(node.attribs.item);
1212
+ if (node.attribs.index) eachLocals.add(node.attribs.index);
1213
+ const arrayExpr = rewrite(node.attribs.array);
1214
+ const keyExpr = node.attribs.key
1215
+ ? transformTemplateExpression(node.attribs.key, reactiveVars, eachLocals)
1216
+ : null;
1217
+ domCode += ` const ${eid}_a = document.createComment('each');\n`;
1218
+ domCode += ` ${parentVar}.appendChild(${eid}_a);\n`;
1219
+ if (keyExpr) {
1220
+ // Keyed: maintain a Map<key, Node[]> and reconcile on each run
1221
+ // Reconcile by walking the new order and moving each key's nodes into
1222
+ // place against a cursor. The previous version only ever inserted, and
1223
+ // against an anchor it never advanced — so reordering a keyed list (a
1224
+ // sort, a drag, a reverse) left the DOM in its original order, silently.
1225
+ domCode += ` let ${eid}_keyMap = new Map();
1226
+ `;
1227
+ domCode += ` createEffect(() => {
1228
+ `;
1229
+ domCode += ` const _items = ${arrayExpr} || [];
1230
+ `;
1231
+ domCode += ` const _parent = ${eid}_a.parentNode;
1232
+ `;
1233
+ domCode += ` const _seen = new Set();
1234
+ `;
1235
+ domCode += ` let _cursor = ${eid}_a.nextSibling;
1236
+ `;
1237
+ domCode += ` for (let _i = 0; _i < _items.length; _i++) {
1238
+ `;
1239
+ domCode += ` const ${node.attribs.item} = _items[_i];
1240
+ `;
1241
+ if (node.attribs.index) domCode += ` const ${node.attribs.index} = _i;
1242
+ `;
1243
+ domCode += ` const _key = String(${keyExpr});
1244
+ `;
1245
+ domCode += ` _seen.add(_key);
1246
+ `;
1247
+ domCode += ` let _nodes = ${eid}_keyMap.get(_key);
1248
+ `;
1249
+ domCode += ` if (!_nodes) {
1250
+ `;
1251
+ domCode += ` const f = document.createDocumentFragment();
1252
+ `;
1253
+ node.children.forEach(child => emitNode(child, 'f', eachLocals));
1254
+ domCode += ` _nodes = Array.from(f.childNodes);
1255
+ `;
1256
+ domCode += ` ${eid}_keyMap.set(_key, _nodes);
1257
+ `;
1258
+ domCode += ` }
1259
+ `;
1260
+ domCode += ` for (const _n of _nodes) {
1261
+ `;
1262
+ domCode += ` if (_n === _cursor) { _cursor = _cursor.nextSibling; }
1263
+ `;
1264
+ domCode += ` else { _parent.insertBefore(_n, _cursor); }
1265
+ `;
1266
+ domCode += ` }
1267
+ `;
1268
+ domCode += ` }
1269
+ `;
1270
+ domCode += ` for (const [_k, _stale] of ${eid}_keyMap) {
1271
+ `;
1272
+ domCode += ` if (!_seen.has(_k)) { _stale.forEach(n => n.remove()); ${eid}_keyMap.delete(_k); }
1273
+ `;
1274
+ domCode += ` }
1275
+ `;
1276
+ domCode += ` });
1277
+ `;
1278
+ } else {
1279
+ // Unkeyed: full teardown + rebuild
1280
+ domCode += ` let ${eid}_els = [];\n`;
1281
+ domCode += ` createEffect(() => {\n`;
1282
+ domCode += ` ${eid}_els.forEach(e => e.remove()); ${eid}_els = [];\n`;
1283
+ domCode += ` const _items = ${arrayExpr} || [];\n`;
1284
+ domCode += ` const f = document.createDocumentFragment();\n`;
1285
+ domCode += ` for (let _i = 0; _i < _items.length; _i++) {\n`;
1286
+ domCode += ` const ${node.attribs.item} = _items[_i];\n`;
1287
+ if (node.attribs.index) domCode += ` const ${node.attribs.index} = _i;\n`;
1288
+ node.children.forEach(child => emitNode(child, 'f', eachLocals));
1289
+ domCode += ` }\n`;
1290
+ domCode += ` Array.from(f.childNodes).forEach(n => ${eid}_els.push(n));\n`;
1291
+ domCode += ` ${eid}_a.parentNode.insertBefore(f, ${eid}_a.nextSibling);\n`;
1292
+ domCode += ` });\n`;
1293
+ }
1294
+ return;
1295
+ }
1296
+
1297
+ // Normal element
1298
+ const id = `n${uid++}`;
1299
+ domCode += ` const ${id} = document.createElement('${node.name}');\n`;
1300
+
1301
+ if (hasStyles) {
1302
+ domCode += ` ${id}.classList.add('${scopeHash}');\n`;
1303
+ }
1304
+
1305
+ const BOOLEAN_ATTRS = new Set(['disabled', 'checked', 'selected', 'readonly', 'required', 'multiple', 'open']);
1306
+ for (const [key, val] of Object.entries(node.attribs || {})) {
1307
+ // Dynamic attribute pre-processed from {expr} in attribute values
1308
+ const dynMatch = /^__soladyn_(\d+)__$/.exec(val);
1309
+ if (dynMatch) {
1310
+ const expr = rewrite(dynAttrs[parseInt(dynMatch[1])]);
1311
+ if (BOOLEAN_ATTRS.has(key)) {
1312
+ domCode += ` createEffect(() => { const _v = !!(${expr}); if (_v) ${id}.setAttribute('${key}', ''); else ${id}.removeAttribute('${key}'); });\n`;
1313
+ } else if (key === 'class' || key === 'className') {
1314
+ const suffix = hasStyles ? ` + ' ${scopeHash}'` : '';
1315
+ domCode += ` createEffect(() => { ${id}.className = (${expr})${suffix}; });\n`;
1316
+ } else {
1317
+ domCode += ` createEffect(() => { ${id}.setAttribute('${key}', String(${expr})); });\n`;
1318
+ }
1319
+ continue;
1320
+ }
1321
+ if (key.startsWith('on:')) {
1322
+ const eventName = key.slice(3);
1323
+ if (!/^[a-zA-Z][a-zA-Z0-9]*$/.test(eventName)) continue;
1324
+ domCode += ` ${id}.addEventListener('${eventName}', ${rewrite(resolveHandler(val))});\n`;
1325
+ } else if (key.startsWith('on')) {
1326
+ const eventName = key.slice(2).toLowerCase();
1327
+ if (!/^[a-zA-Z][a-zA-Z0-9]*$/.test(eventName)) continue;
1328
+ domCode += ` ${id}.addEventListener('${eventName}', ${rewrite(resolveHandler(val))});\n`;
1329
+ } else if (key.startsWith('bind:')) {
1330
+ const prop = key.slice(5);
1331
+ // bind:value={name} names a signal; the marker resolves back to it.
1332
+ const bound = resolveHandler(val);
1333
+ if (prop === 'value') {
1334
+ domCode += ` ${id}.addEventListener('input', (e) => { set_${bound}(e.target.value); });\n`;
1335
+ domCode += ` createEffect(() => { ${id}.value = ${bound}() ?? ''; });\n`;
1336
+ }
1337
+ } else if (key === 'class' || key === 'className') {
1338
+ // Static values: emit as a plain string literal. A template literal here
1339
+ // would let a backtick or ${ in the markup break out into expression
1340
+ // position; JSON.stringify cannot.
1341
+ domCode += ` ${id}.className += ' ' + ${JSON.stringify(val)};\n`;
1342
+ } else {
1343
+ domCode += ` ${id}.setAttribute(${JSON.stringify(key)}, ${JSON.stringify(val)});\n`;
1344
+ }
1345
+ }
1346
+
1347
+ domCode += ` ${parentVar}.appendChild(${id});\n`;
1348
+
1349
+ if (node.children && !VOID_ELEMENTS.has(node.name)) {
1350
+ for (const child of node.children) {
1351
+ emitNode(child, id, locals);
1352
+ }
1353
+ }
1354
+ }
1355
+
1356
+ root.children.forEach(child => emitNode(child, '__target'));
1357
+
1358
+ // Assemble final output module
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';
1364
+ let output = '';
1365
+ output += `// Compiled by @sola-air-ui/compiler v${COMPILER_VERSION}\n`;
1366
+
1367
+ if (target === 'iife') {
1368
+ // ServiceNow / no-bundler mode: pull from window.SolaCore global
1369
+ output += `(function() {\n`;
1370
+ output += `const { ${IIFE_CORE_PULL} } = window.SolaCore;\n`;
1371
+ for (const imp of componentImports) {
1372
+ output += `// Note: sub-component ${imp.localName} must be pre-loaded before this script\n`;
1373
+ }
1374
+ output += `\n`;
1375
+ output += `window['${options.exportName || 'SolaComponent'}'] = function mount(__target, props = {}) {\n`;
1376
+ } else {
1377
+ output += `import { ${ESM_CORE_IMPORTS} } from '@sola-air-ui/core';\n`;
1378
+ for (const imp of componentImports) {
1379
+ output += `import ${imp.localName} from '${imp.path}';\n`;
1380
+ }
1381
+ output += `\n`;
1382
+ output += `export default function mount(__target, props = {}) {\n`;
1383
+ }
1384
+ output += ` const __ctx = pushContext();\n\n`;
1385
+
1386
+ if (hasStyles) {
1387
+ // Styles go into whichever root the component actually lives in: the
1388
+ // ShadowRoot when mounted inside one (document.head styles can't cross a
1389
+ // shadow boundary), otherwise document.head. De-duplicated per root.
1390
+ output += ` if (typeof document !== 'undefined') {\n`;
1391
+ output += ` const __rootNode = __target && __target.getRootNode ? __target.getRootNode() : document;\n`;
1392
+ output += ` const __styleHost = (typeof ShadowRoot !== 'undefined' && __rootNode instanceof ShadowRoot) ? __rootNode : document.head;\n`;
1393
+ output += ` const __seen = __styleHost.__solaStyles || (__styleHost.__solaStyles = new Set());\n`;
1394
+ output += ` if (!__seen.has('${scopeHash}')) {\n`;
1395
+ output += ` __seen.add('${scopeHash}');\n`;
1396
+ output += ` const styleEl = document.createElement('style');\n`;
1397
+ output += ` styleEl.textContent = \`${escapeTemplateLiteral(scopedCss)}\`;\n`;
1398
+ output += ` __styleHost.appendChild(styleEl);\n`;
1399
+ output += ` }\n`;
1400
+ output += ` }\n\n`;
1401
+ }
1402
+
1403
+ for (const prop of exportedProps) {
1404
+ output += ` let ${prop.name} = props.${prop.name} !== undefined ? props.${prop.name} : ${prop.default};\n`;
1405
+ }
1406
+
1407
+ output += `\n // User script\n`;
1408
+ // Record where the user's script lands in the output so each of its lines can
1409
+ // be mapped back to the same line of the .sola file.
1410
+ const userScriptGeneratedStart = output.split('\n').length - 1;
1411
+ const userScriptLines = jsOutput.split('\n');
1412
+ output += userScriptLines.map(l => ' ' + l).join('\n') + '\n\n';
1413
+
1414
+ output += ` // Reactive DOM graph\n`;
1415
+ output += domCode;
1416
+
1417
+ output += `\n __flush_mounts(__ctx);\n`;
1418
+ output += `\n return () => {\n`;
1419
+ output += ` __flush_destroys(__ctx);\n`;
1420
+ output += ` popContext(__ctx);\n`;
1421
+ output += ` __target.innerHTML = '';\n`;
1422
+ output += ` };\n`;
1423
+ output += `};\n`;
1424
+ if (target === 'iife') {
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`;
1433
+ }
1434
+
1435
+ // The reactive rewrite can add or remove characters within a line but never
1436
+ // splits one, so the user script's line count is preserved and each line maps
1437
+ // straight back to its origin.
1438
+ let map = null;
1439
+ if (options.sourcemap !== false) {
1440
+ const filename = options.filename || 'component.sola';
1441
+ const segments = userScriptLines.map((_, i) => ({
1442
+ generatedLine: userScriptGeneratedStart + i,
1443
+ sourceLine: scriptStartLine + i
1444
+ }));
1445
+ map = buildSourceMap(segments, filename, source, output.split('\n').length);
1446
+ }
1447
+
1448
+ return {
1449
+ code: output,
1450
+ scopeHash,
1451
+ css: scopedCss,
1452
+ map,
1453
+ dts: buildDeclarations(exportedProps, !!options.exportStyles)
1454
+ };
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
+ }