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