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