@sigil-dev/compiler 0.9.7 → 0.9.11
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 +3 -1
- package/src/babel/handlers/dom/state.ts +4 -5
- package/src/babel/index.ts +77 -24
- package/src/babel/jsx/children.ts +10 -2
- package/src/babel/jsx/element.ts +209 -112
- package/src/babel/jsx/fragment.ts +15 -3
- package/src/babel/jsx/keyed-list.ts +10 -2
- package/src/babel/jsx/ssr.ts +138 -238
- package/src/babel/jsx/utils.ts +47 -1
- package/src/babel/util/bind.ts +4 -0
- package/src/babel/util/css.ts +170 -113
- package/src/babel/util/props.ts +256 -0
- package/src/bun-plugin.ts +28 -17
- package/src/vite/index.ts +4 -1
- package/test/bun-plugin.test.ts +45 -0
- package/test/hydration.test.ts +3 -2
- package/test/jsx.test.ts +26 -4
- package/test/render-parity.test.ts +639 -0
- package/test/scoped-css.test.ts +39 -2
- package/test/ssr-raw-text.test.ts +68 -0
- package/test/ssr.test.ts +1 -1
- package/test/style-object.test.ts +29 -6
package/src/babel/util/css.ts
CHANGED
|
@@ -10,142 +10,199 @@ export function computeHash(filePath: string): string {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* Scope CSS by
|
|
14
|
-
*
|
|
13
|
+
* Scope CSS by adding the file's hash class to every selector.
|
|
14
|
+
*
|
|
15
|
+
* - Each selector gets the class on its last compound, before any pseudo
|
|
16
|
+
* part: `.list .item:hover` → `.list .item.sX:hover`, so the rule only
|
|
17
|
+
* matches elements this file rendered.
|
|
18
|
+
* - `:global(…)` is left unscoped.
|
|
19
|
+
* - Grouping at-rules (@media, @supports, @container, @layer, @scope) are
|
|
20
|
+
* scoped inside; others with a block (@keyframes, @font-face, …) pass
|
|
21
|
+
* through, as do statements like @import.
|
|
22
|
+
* - Nested rules are scoped too; declarations are never touched.
|
|
15
23
|
*/
|
|
16
24
|
export function scopeCSS(css: string, hash: string): string {
|
|
17
|
-
let
|
|
25
|
+
let out = "";
|
|
26
|
+
let emitted = 0; // css[0..emitted) is already in `out`
|
|
27
|
+
let start = 0; // where the current statement began
|
|
18
28
|
let i = 0;
|
|
19
|
-
|
|
20
29
|
while (i < css.length) {
|
|
21
|
-
|
|
22
|
-
if (
|
|
30
|
+
const c = css[i];
|
|
31
|
+
if (c === "/" && css[i + 1] === "*") {
|
|
23
32
|
const end = css.indexOf("*/", i + 2);
|
|
24
|
-
|
|
25
|
-
result += css.slice(i);
|
|
26
|
-
break;
|
|
27
|
-
}
|
|
28
|
-
result += css.slice(i, end + 2);
|
|
29
|
-
i = end + 2;
|
|
33
|
+
i = end === -1 ? css.length : end + 2;
|
|
30
34
|
continue;
|
|
31
35
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
if (css[i] === '"' || css[i] === "'") {
|
|
35
|
-
const quote = css[i];
|
|
36
|
-
let j = i + 1;
|
|
37
|
-
while (j < css.length && css[j] !== quote) {
|
|
38
|
-
if (css[j] === "\\") j++;
|
|
39
|
-
j++;
|
|
40
|
-
}
|
|
41
|
-
result += css.slice(i, j + 1);
|
|
42
|
-
i = j + 1;
|
|
36
|
+
if (c === '"' || c === "'") {
|
|
37
|
+
i = skipString(css, i);
|
|
43
38
|
continue;
|
|
44
39
|
}
|
|
40
|
+
if (c === ";" || c === "}") {
|
|
41
|
+
i++;
|
|
42
|
+
start = i;
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
if (c !== "{") {
|
|
46
|
+
i++;
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const close = matchingBrace(css, i);
|
|
50
|
+
const prelude = css.slice(start, i);
|
|
51
|
+
const lead = /^(?:\s|\/\*[\s\S]*?\*\/)*/.exec(prelude)![0];
|
|
52
|
+
const head = prelude.slice(lead.length).trim();
|
|
53
|
+
const body = css.slice(i + 1, close);
|
|
54
|
+
let block: string;
|
|
55
|
+
if (head.startsWith("@")) {
|
|
56
|
+
block = `${head} {${PASSTHROUGH_AT.test(head) ? body : scopeCSS(body, hash)}}`;
|
|
57
|
+
} else {
|
|
58
|
+
block = `${rewriteSelector(head, hash)} {${scopeCSS(body, hash)}}`;
|
|
59
|
+
}
|
|
60
|
+
out += css.slice(emitted, start) + lead + block;
|
|
61
|
+
i = close + 1;
|
|
62
|
+
emitted = i;
|
|
63
|
+
start = i;
|
|
64
|
+
}
|
|
65
|
+
return out + css.slice(emitted);
|
|
66
|
+
}
|
|
45
67
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
// Find the matching }
|
|
49
|
-
let depth = 1;
|
|
50
|
-
let j = i + 1;
|
|
51
|
-
while (j < css.length && depth > 0) {
|
|
52
|
-
if (css[j] === "{") depth++;
|
|
53
|
-
else if (css[j] === "}") depth--;
|
|
54
|
-
j++;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// The block content (inside { }) - recurse into it
|
|
58
|
-
const blockContent = css.slice(i + 1, j - 1);
|
|
59
|
-
const scopedBlock = scopeCSS(blockContent, hash);
|
|
60
|
-
|
|
61
|
-
// Find where the selector starts (look back from {)
|
|
62
|
-
let selectorEnd = i;
|
|
63
|
-
let k = i - 1;
|
|
64
|
-
// Skip whitespace before {
|
|
65
|
-
while (k >= 0 && css[k] === " ") k--;
|
|
66
|
-
selectorEnd = k + 1;
|
|
67
|
-
|
|
68
|
-
// Find start of selector (look back for } or ; or start of string)
|
|
69
|
-
let selectorStart = selectorEnd;
|
|
70
|
-
k = selectorEnd - 1;
|
|
71
|
-
while (k >= 0) {
|
|
72
|
-
if (css[k] === "}" || css[k] === ";") {
|
|
73
|
-
selectorStart = k + 1;
|
|
74
|
-
break;
|
|
75
|
-
}
|
|
76
|
-
if (k === 0) {
|
|
77
|
-
selectorStart = 0;
|
|
78
|
-
break;
|
|
79
|
-
}
|
|
80
|
-
k--;
|
|
81
|
-
}
|
|
68
|
+
const PASSTHROUGH_AT =
|
|
69
|
+
/^@(?:-webkit-)?(?:keyframes|font-face|page|property|counter-style|font-feature-values|font-palette-values|view-transition)\b/i;
|
|
82
70
|
|
|
83
|
-
|
|
71
|
+
function skipString(css: string, i: number): number {
|
|
72
|
+
const quote = css[i];
|
|
73
|
+
let j = i + 1;
|
|
74
|
+
while (j < css.length && css[j] !== quote) {
|
|
75
|
+
if (css[j] === "\\") j++;
|
|
76
|
+
j++;
|
|
77
|
+
}
|
|
78
|
+
return j + 1;
|
|
79
|
+
}
|
|
84
80
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
81
|
+
/** Index of the `}` that closes the `{` at `open` (or the end of input). */
|
|
82
|
+
function matchingBrace(css: string, open: number): number {
|
|
83
|
+
let depth = 0;
|
|
84
|
+
for (let j = open; j < css.length; j++) {
|
|
85
|
+
const c = css[j];
|
|
86
|
+
if (c === "/" && css[j + 1] === "*") {
|
|
87
|
+
const end = css.indexOf("*/", j + 2);
|
|
88
|
+
if (end === -1) return css.length;
|
|
89
|
+
j = end + 1;
|
|
90
|
+
} else if (c === '"' || c === "'") {
|
|
91
|
+
j = skipString(css, j) - 1;
|
|
92
|
+
} else if (c === "{") depth++;
|
|
93
|
+
else if (c === "}" && --depth === 0) return j;
|
|
94
|
+
}
|
|
95
|
+
return css.length;
|
|
96
|
+
}
|
|
99
97
|
|
|
100
|
-
|
|
101
|
-
|
|
98
|
+
/** Split on `sep` characters that sit outside (), [] and strings. */
|
|
99
|
+
function splitTop(text: string, isSep: (c: string) => boolean): string[] {
|
|
100
|
+
const parts: string[] = [];
|
|
101
|
+
let depth = 0;
|
|
102
|
+
let from = 0;
|
|
103
|
+
for (let j = 0; j < text.length; j++) {
|
|
104
|
+
const c = text[j]!;
|
|
105
|
+
if (c === "(" || c === "[") depth++;
|
|
106
|
+
else if (c === ")" || c === "]") depth--;
|
|
107
|
+
else if (c === '"' || c === "'") j = skipString(text, j) - 1;
|
|
108
|
+
else if (depth === 0 && isSep(c)) {
|
|
109
|
+
parts.push(text.slice(from, j));
|
|
110
|
+
from = j + 1;
|
|
102
111
|
}
|
|
103
|
-
|
|
104
|
-
result += css[i];
|
|
105
|
-
i++;
|
|
106
112
|
}
|
|
113
|
+
parts.push(text.slice(from));
|
|
114
|
+
return parts;
|
|
115
|
+
}
|
|
107
116
|
|
|
108
|
-
|
|
117
|
+
/** `.item:hover::after` → `.item.sX:hover::after` */
|
|
118
|
+
function scopeCompound(compound: string, hash: string): string {
|
|
119
|
+
let depth = 0;
|
|
120
|
+
for (let j = 0; j < compound.length; j++) {
|
|
121
|
+
const c = compound[j];
|
|
122
|
+
if (c === "(" || c === "[") depth++;
|
|
123
|
+
else if (c === ")" || c === "]") depth--;
|
|
124
|
+
else if (c === ":" && depth === 0) {
|
|
125
|
+
return `${compound.slice(0, j)}.${hash}${compound.slice(j)}`;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return `${compound}.${hash}`;
|
|
109
129
|
}
|
|
110
130
|
|
|
111
131
|
/**
|
|
112
|
-
* Rewrite a selector
|
|
113
|
-
*
|
|
132
|
+
* Rewrite a selector list: each selector gets the hash on its last compound
|
|
133
|
+
* that isn't `:global(…)`.
|
|
114
134
|
*/
|
|
115
135
|
function rewriteSelector(selector: string, hash: string): string {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const trimmed = part.trim();
|
|
139
|
-
if (!trimmed) return trimmed;
|
|
136
|
+
return splitTop(selector, (c) => c === ",")
|
|
137
|
+
.map((part) => {
|
|
138
|
+
const trimmed = part.trim();
|
|
139
|
+
if (!trimmed) return trimmed;
|
|
140
|
+
// compounds and the combinators between them, in order
|
|
141
|
+
const tokens = trimmed.replace(/\s*([>+~])\s*/g, " $1 ").split(/(\s+)/);
|
|
142
|
+
const isCompound = (t: string) => t.trim() !== "" && !/^[>+~]$/.test(t);
|
|
143
|
+
const isGlobal = (t: string) => /^:global\(/.test(t);
|
|
144
|
+
for (let k = tokens.length - 1; k >= 0; k--) {
|
|
145
|
+
const t = tokens[k]!;
|
|
146
|
+
if (isCompound(t) && !isGlobal(t)) {
|
|
147
|
+
tokens[k] = scopeCompound(t, hash);
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return tokens
|
|
152
|
+
.map((t) => (isGlobal(t) ? t.slice(8, -1) : t))
|
|
153
|
+
.join("")
|
|
154
|
+
.replace(/ ([>+~]) /g, " $1 ");
|
|
155
|
+
})
|
|
156
|
+
.join(", ");
|
|
157
|
+
}
|
|
140
158
|
|
|
141
|
-
|
|
142
|
-
const globalMatch = trimmed.match(/^__GLOBAL_(\d+)__$/);
|
|
143
|
-
if (globalMatch) {
|
|
144
|
-
return globals[parseInt(globalMatch[1]!, 10)];
|
|
145
|
-
}
|
|
159
|
+
const STYLE_BLOCK_RE = /<style[^>]*>([\s\S]*?)<\/style>/gi;
|
|
146
160
|
|
|
147
|
-
|
|
161
|
+
/**
|
|
162
|
+
* Take a component file's (.tsx/.jsx) `<style>` blocks out of its source. Returns the
|
|
163
|
+
* source without them (the JSX parser would read CSS braces as expressions)
|
|
164
|
+
* and the scoped CSS of all blocks, or null when the file has none.
|
|
165
|
+
*/
|
|
166
|
+
export function extractStyles(
|
|
167
|
+
source: string,
|
|
168
|
+
hash: string,
|
|
169
|
+
filename: string,
|
|
170
|
+
): { code: string; css: string | null } {
|
|
171
|
+
// only component files have style blocks; a .ts file that merely
|
|
172
|
+
// mentions a <style> tag in a string must come through untouched
|
|
173
|
+
if (!/\.[jt]sx$/.test(filename)) return { code: source, css: null };
|
|
174
|
+
const blocks: string[] = [];
|
|
175
|
+
const code = source.replace(STYLE_BLOCK_RE, (_, css: string) => {
|
|
176
|
+
blocks.push(css.trim());
|
|
177
|
+
return "";
|
|
148
178
|
});
|
|
179
|
+
return {
|
|
180
|
+
code,
|
|
181
|
+
css: blocks.length ? scopeCSS(blocks.join("\n"), hash) : null,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Browser build: add the file's CSS to <head> once, when the module first
|
|
187
|
+
* loads. A server-rendered page already has it (see serverStyleSnippet), so
|
|
188
|
+
* the id check makes this a no-op there.
|
|
189
|
+
*/
|
|
190
|
+
export function clientStyleSnippet(hash: string, css: string): string {
|
|
191
|
+
return `if (typeof document !== "undefined" && !document.getElementById("sigil-${hash}")) {
|
|
192
|
+
const __sigilStyle = document.createElement("style");
|
|
193
|
+
__sigilStyle.id = "sigil-${hash}";
|
|
194
|
+
__sigilStyle.textContent = ${JSON.stringify(css)};
|
|
195
|
+
document.head.appendChild(__sigilStyle);
|
|
196
|
+
}
|
|
197
|
+
`;
|
|
198
|
+
}
|
|
149
199
|
|
|
150
|
-
|
|
200
|
+
/**
|
|
201
|
+
* Server build: record the file's CSS by hash. The renderer puts a
|
|
202
|
+
* <style id="sigil-HASH"> in the page's <head> for every scope class the
|
|
203
|
+
* rendered HTML uses, so pages are styled before any script runs.
|
|
204
|
+
*/
|
|
205
|
+
export function serverStyleSnippet(hash: string, css: string): string {
|
|
206
|
+
return `(globalThis.__sigil_css__ ??= new Map()).set(${JSON.stringify(hash)}, ${JSON.stringify(css)});
|
|
207
|
+
`;
|
|
151
208
|
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { type NodePath, types as t } from "@babel/core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Names of props objects in the file being compiled (reset per file by the
|
|
5
|
+
* plugin). Children code uses it to tell `{title}`, a prop that may hold
|
|
6
|
+
* nodes, from `{count}`, which is always text.
|
|
7
|
+
*/
|
|
8
|
+
export const propObjects = new Set<string>();
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Values imported from the project's own modules (relative or alias paths),
|
|
12
|
+
* reset per file. `cart.items.length` where `cart` is an exported `$store`
|
|
13
|
+
* must update like local state; the store's proxy tracks the read, so the
|
|
14
|
+
* compiler only has to give the expression an effect. Components (PascalCase)
|
|
15
|
+
* and type imports aren't included, and neither are calls: `format(x)` may
|
|
16
|
+
* return nodes and keeps its old handling.
|
|
17
|
+
*/
|
|
18
|
+
export const importRoots = new Set<string>();
|
|
19
|
+
|
|
20
|
+
export function collectImportRoots(program: NodePath<t.Program>): void {
|
|
21
|
+
importRoots.clear();
|
|
22
|
+
for (const node of program.node.body) {
|
|
23
|
+
if (!t.isImportDeclaration(node) || node.importKind === "type") continue;
|
|
24
|
+
if (!/^[./$~@]/.test(node.source.value)) continue;
|
|
25
|
+
// @scope/package imports are npm packages, not project modules
|
|
26
|
+
if (
|
|
27
|
+
/^@[^/]+\/[^/]/.test(node.source.value) &&
|
|
28
|
+
!node.source.value.startsWith("@/")
|
|
29
|
+
)
|
|
30
|
+
continue;
|
|
31
|
+
for (const spec of node.specifiers) {
|
|
32
|
+
if (t.isImportSpecifier(spec) && spec.importKind === "type") continue;
|
|
33
|
+
const name = spec.local.name;
|
|
34
|
+
if (/^[A-Z]/.test(name)) continue;
|
|
35
|
+
importRoots.add(name);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function rootName(expr: t.Node): string | null {
|
|
41
|
+
let node = expr;
|
|
42
|
+
while (t.isMemberExpression(node)) node = node.object;
|
|
43
|
+
return t.isIdentifier(node) ? node.name : null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** `cart.items.length` where `cart` is imported from the project. */
|
|
47
|
+
export function isImportRead(expr: t.Node): boolean {
|
|
48
|
+
if (!t.isMemberExpression(expr)) return false;
|
|
49
|
+
const root = rootName(expr);
|
|
50
|
+
return root !== null && importRoots.has(root);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** `readStore(count)`, inserted by `rewriteImportReads`. */
|
|
54
|
+
export function isStoreRead(expr: t.Node): boolean {
|
|
55
|
+
return (
|
|
56
|
+
t.isCallExpression(expr) &&
|
|
57
|
+
t.isIdentifier(expr.callee, { name: "readStore" }) &&
|
|
58
|
+
expr.arguments.length === 1 &&
|
|
59
|
+
t.isIdentifier(expr.arguments[0]) &&
|
|
60
|
+
importRoots.has(expr.arguments[0].name)
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Wrap bare reads of project imports in `readStore(…)`.
|
|
66
|
+
*
|
|
67
|
+
* An exported `$store` holding a primitive reaches other modules as its
|
|
68
|
+
* signal getter, since the compiler only rewrites reads in the declaring
|
|
69
|
+
* file. `readStore` calls a signal and returns anything else unchanged, so
|
|
70
|
+
* `{count}` and `count * 2` see the number and track it. Callees, member
|
|
71
|
+
* objects (object stores forward member reads already), JSX tags, exports
|
|
72
|
+
* and types are left alone. Returns whether anything changed.
|
|
73
|
+
*/
|
|
74
|
+
export function rewriteImportReads(program: NodePath<t.Program>): boolean {
|
|
75
|
+
if (importRoots.size === 0) return false;
|
|
76
|
+
let changed = false;
|
|
77
|
+
program.traverse({
|
|
78
|
+
Identifier(path) {
|
|
79
|
+
const name = path.node.name;
|
|
80
|
+
if (!importRoots.has(name)) return;
|
|
81
|
+
if (!path.isReferencedIdentifier()) return;
|
|
82
|
+
const binding = path.scope.getBinding(name);
|
|
83
|
+
if (binding?.kind !== "module") return;
|
|
84
|
+
const parent = path.parent;
|
|
85
|
+
if (
|
|
86
|
+
(t.isCallExpression(parent) || t.isNewExpression(parent)) &&
|
|
87
|
+
parent.callee === path.node
|
|
88
|
+
)
|
|
89
|
+
return;
|
|
90
|
+
if (t.isMemberExpression(parent) && parent.object === path.node) return;
|
|
91
|
+
if (t.isOptionalMemberExpression(parent) && parent.object === path.node)
|
|
92
|
+
return;
|
|
93
|
+
if (t.isTaggedTemplateExpression(parent)) return;
|
|
94
|
+
if (t.isExportSpecifier(parent)) return;
|
|
95
|
+
if (path.findParent((p) => p.isTSType())) return;
|
|
96
|
+
if (t.isCallExpression(parent) && isStoreRead(parent)) return;
|
|
97
|
+
path.replaceWith(
|
|
98
|
+
t.callExpression(t.identifier("readStore"), [t.identifier(name)]),
|
|
99
|
+
);
|
|
100
|
+
path.skip();
|
|
101
|
+
changed = true;
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
return changed;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A read whose value may be text or nodes and may change: `props.title`,
|
|
109
|
+
* `_props.item.name`, or a member read on an imported store.
|
|
110
|
+
*/
|
|
111
|
+
export function isPropRead(expr: t.Node): boolean {
|
|
112
|
+
if (isStoreRead(expr)) return true;
|
|
113
|
+
const root = rootName(expr);
|
|
114
|
+
if (root === null) return false;
|
|
115
|
+
return propObjects.has(root) || isImportRead(expr);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
type Fn =
|
|
119
|
+
| t.ArrowFunctionExpression
|
|
120
|
+
| t.FunctionExpression
|
|
121
|
+
| t.FunctionDeclaration;
|
|
122
|
+
|
|
123
|
+
interface Prop {
|
|
124
|
+
key: string;
|
|
125
|
+
local: string;
|
|
126
|
+
fallback?: t.Expression;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Keep destructured props live.
|
|
131
|
+
*
|
|
132
|
+
* `({ title }) => <h1>{title}</h1>` reads `title` once, when the component is
|
|
133
|
+
* called, so a prop the caller passes as a getter (see element.ts) would
|
|
134
|
+
* freeze at its first value. This rewrites the pattern into reads through the
|
|
135
|
+
* props object, `(_props) => <h1>{_props.title}</h1>`, and registers
|
|
136
|
+
* `_props` as reactive so JSX that reads it gets an effect.
|
|
137
|
+
*
|
|
138
|
+
* A plain `(props) =>` parameter needs no rewrite, only the registration.
|
|
139
|
+
*
|
|
140
|
+
* Only simple patterns are rewritten: `{ a, b: c, d = 1 }`. Anything else (a
|
|
141
|
+
* rest element, nested patterns, computed keys, a prop reassigned in the body,
|
|
142
|
+
* a prop used as a JSX tag) is left as it was, with the old read-once
|
|
143
|
+
* behaviour.
|
|
144
|
+
*/
|
|
145
|
+
export function liftDestructuredProps(
|
|
146
|
+
program: NodePath<t.Program>,
|
|
147
|
+
reactive: Set<string>,
|
|
148
|
+
): void {
|
|
149
|
+
program.traverse({
|
|
150
|
+
"ArrowFunctionExpression|FunctionExpression|FunctionDeclaration"(
|
|
151
|
+
path: NodePath<Fn>,
|
|
152
|
+
) {
|
|
153
|
+
const param = path.node.params[0];
|
|
154
|
+
if (!param || !isComponent(path) || !returnsJsx(path)) return;
|
|
155
|
+
// `(props) => <p>{props.title}</p>` is already a live read
|
|
156
|
+
if (t.isIdentifier(param)) {
|
|
157
|
+
reactive.add(param.name);
|
|
158
|
+
propObjects.add(param.name);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (!t.isObjectPattern(param)) return;
|
|
162
|
+
|
|
163
|
+
const props: Prop[] = [];
|
|
164
|
+
for (const p of param.properties) {
|
|
165
|
+
if (!t.isObjectProperty(p) || p.computed || !t.isIdentifier(p.key))
|
|
166
|
+
return;
|
|
167
|
+
if (t.isIdentifier(p.value)) {
|
|
168
|
+
props.push({ key: p.key.name, local: p.value.name });
|
|
169
|
+
} else if (
|
|
170
|
+
t.isAssignmentPattern(p.value) &&
|
|
171
|
+
t.isIdentifier(p.value.left)
|
|
172
|
+
) {
|
|
173
|
+
props.push({
|
|
174
|
+
key: p.key.name,
|
|
175
|
+
local: p.value.left.name,
|
|
176
|
+
fallback: p.value.right,
|
|
177
|
+
});
|
|
178
|
+
} else return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const bindings = props.map((p) => path.scope.getBinding(p.local));
|
|
182
|
+
for (const b of bindings) {
|
|
183
|
+
if (!b?.constant) return;
|
|
184
|
+
if (b.referencePaths.some((r) => r.isJSXIdentifier())) return;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const propsId = path.scope.generateUidIdentifier("props");
|
|
188
|
+
if (param.typeAnnotation) propsId.typeAnnotation = param.typeAnnotation;
|
|
189
|
+
|
|
190
|
+
props.forEach((prop, i) => {
|
|
191
|
+
for (const ref of bindings[i]!.referencePaths) {
|
|
192
|
+
const read = () =>
|
|
193
|
+
t.memberExpression(
|
|
194
|
+
t.identifier(propsId.name),
|
|
195
|
+
t.identifier(prop.key),
|
|
196
|
+
);
|
|
197
|
+
const value = prop.fallback
|
|
198
|
+
? t.conditionalExpression(
|
|
199
|
+
t.binaryExpression("===", read(), t.identifier("undefined")),
|
|
200
|
+
t.cloneNode(prop.fallback),
|
|
201
|
+
read(),
|
|
202
|
+
)
|
|
203
|
+
: read();
|
|
204
|
+
const parent = ref.parentPath;
|
|
205
|
+
if (parent?.isObjectProperty() && parent.node.shorthand) {
|
|
206
|
+
parent.node.shorthand = false;
|
|
207
|
+
}
|
|
208
|
+
ref.replaceWith(value);
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
path.node.params[0] = propsId;
|
|
213
|
+
path.scope.crawl();
|
|
214
|
+
reactive.add(propsId.name);
|
|
215
|
+
propObjects.add(propsId.name);
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Components are PascalCase functions (`function Card`, `const Card = …`) or
|
|
222
|
+
* a module's default export (route components are often anonymous). A
|
|
223
|
+
* lowercase callback such as `items.map((item) => <li>…</li>)` is not one;
|
|
224
|
+
* list rows get their own item reactivity in keyed-list.ts.
|
|
225
|
+
*/
|
|
226
|
+
function isComponent(path: NodePath<Fn>): boolean {
|
|
227
|
+
const pascal = (name?: string) => !!name && /^[A-Z]/.test(name);
|
|
228
|
+
if (path.isFunctionDeclaration()) {
|
|
229
|
+
return (
|
|
230
|
+
pascal(path.node.id?.name) || path.parentPath.isExportDefaultDeclaration()
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
const parent = path.parentPath;
|
|
234
|
+
if (parent.isExportDefaultDeclaration()) return true;
|
|
235
|
+
if (parent.isVariableDeclarator() && t.isIdentifier(parent.node.id)) {
|
|
236
|
+
return pascal(parent.node.id.name);
|
|
237
|
+
}
|
|
238
|
+
if (path.isFunctionExpression() && pascal(path.node.id?.name)) return true;
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** A function whose own body (not a nested function) produces JSX. */
|
|
243
|
+
function returnsJsx(path: NodePath<Fn>): boolean {
|
|
244
|
+
let found = false;
|
|
245
|
+
path.traverse({
|
|
246
|
+
JSXElement(p) {
|
|
247
|
+
if (p.getFunctionParent() === path) found = true;
|
|
248
|
+
p.skip();
|
|
249
|
+
},
|
|
250
|
+
JSXFragment(p) {
|
|
251
|
+
if (p.getFunctionParent() === path) found = true;
|
|
252
|
+
p.skip();
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
return found;
|
|
256
|
+
}
|
package/src/bun-plugin.ts
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
import { transformSync } from "@babel/core";
|
|
2
2
|
import type { BunPlugin } from "bun";
|
|
3
3
|
import sigilPlugin from "./babel/index.ts";
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
clientStyleSnippet,
|
|
6
|
+
computeHash,
|
|
7
|
+
extractStyles,
|
|
8
|
+
serverStyleSnippet,
|
|
9
|
+
} from "./babel/util/css.ts";
|
|
5
10
|
|
|
6
|
-
const STYLE_RE = /<style[^>]*>([\s\S]*?)<\/style>/gi;
|
|
7
11
|
const STYLE_TEST_RE = /<style[^>]*>[\s\S]*?<\/style>/i;
|
|
8
12
|
const SOURCE_RE = /\.[jt]sx?$/;
|
|
9
13
|
const JSX_RE = /\.[jt]sx$/;
|
|
10
|
-
|
|
14
|
+
// any macro, including $state.raw(…), $effect.pre(…) and $store<T>(…)
|
|
15
|
+
const SIGIL_MACRO_RE = /\$(?:state|store|derived|effect|inspect|debug)\b/;
|
|
16
|
+
// a project import: reads of an imported store need the compiler too
|
|
17
|
+
const PROJECT_IMPORT_RE = /\bfrom\s*["'](?:\.{1,2}\/|\$|~\/|@\/)/;
|
|
11
18
|
|
|
12
19
|
function normalizePath(path: string): string {
|
|
13
20
|
return path.replace(/\\/g, "/");
|
|
@@ -29,6 +36,7 @@ function shouldTransformSource(path: string, code: string): boolean {
|
|
|
29
36
|
return (
|
|
30
37
|
JSX_RE.test(normalized) ||
|
|
31
38
|
SIGIL_MACRO_RE.test(code) ||
|
|
39
|
+
PROJECT_IMPORT_RE.test(code) ||
|
|
32
40
|
STYLE_TEST_RE.test(code)
|
|
33
41
|
);
|
|
34
42
|
}
|
|
@@ -60,30 +68,33 @@ export function sigil(options?: {
|
|
|
60
68
|
if (!shouldTransformSource(path, code)) return undefined;
|
|
61
69
|
// console.log("[bun-plugin] intercepting:", path);
|
|
62
70
|
const hash = computeHash(path);
|
|
63
|
-
|
|
64
|
-
code = code
|
|
65
|
-
|
|
66
|
-
return "";
|
|
67
|
-
});
|
|
71
|
+
const styles = extractStyles(code, hash, path);
|
|
72
|
+
code = styles.code;
|
|
73
|
+
const scopedCSS = styles.css;
|
|
68
74
|
const res = transformSync(code, {
|
|
69
75
|
parserOpts: {
|
|
70
76
|
//@ts-expect-error holy shut up
|
|
71
77
|
plugins: [["typescript", { isTSX: true }], "jsx"],
|
|
72
78
|
},
|
|
73
79
|
plugins: [
|
|
74
|
-
[
|
|
80
|
+
[
|
|
81
|
+
sigilPlugin,
|
|
82
|
+
// scope classes only where there is scoped CSS to match
|
|
83
|
+
{
|
|
84
|
+
hash: scopedCSS ? hash : undefined,
|
|
85
|
+
mode: options?.mode,
|
|
86
|
+
dev: options?.dev,
|
|
87
|
+
},
|
|
88
|
+
],
|
|
75
89
|
],
|
|
76
90
|
filename: path,
|
|
77
91
|
});
|
|
78
92
|
let out = transpiler.transformSync(res?.code ?? "");
|
|
79
|
-
if (scopedCSS
|
|
80
|
-
out =
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
__style.textContent = ${JSON.stringify(scopedCSS)};
|
|
85
|
-
document.head.appendChild(__style);
|
|
86
|
-
}${out}`;
|
|
93
|
+
if (scopedCSS) {
|
|
94
|
+
out =
|
|
95
|
+
(options?.mode === "ssr"
|
|
96
|
+
? serverStyleSnippet(hash, scopedCSS)
|
|
97
|
+
: clientStyleSnippet(hash, scopedCSS)) + out;
|
|
87
98
|
}
|
|
88
99
|
return { contents: out, loader: "js" };
|
|
89
100
|
});
|
package/src/vite/index.ts
CHANGED
|
@@ -30,7 +30,10 @@ export function sigil(options?: {
|
|
|
30
30
|
const hash = computeHash(id);
|
|
31
31
|
const mode = options?.hydrate ? ("hydrate" as const) : undefined;
|
|
32
32
|
const res = transformSync(codeWithoutStyle, {
|
|
33
|
-
plugins: [
|
|
33
|
+
plugins: [
|
|
34
|
+
"@babel/plugin-syntax-jsx",
|
|
35
|
+
[sigilPlugin, { hash: scopedCSS ? hash : undefined, mode }],
|
|
36
|
+
],
|
|
34
37
|
filename: id,
|
|
35
38
|
});
|
|
36
39
|
if (!res) return;
|