@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.
@@ -10,142 +10,199 @@ export function computeHash(filePath: string): string {
10
10
  }
11
11
 
12
12
  /**
13
- * Scope CSS by rewriting selectors with hash.
14
- * :global(.selector) escapes scoping (passed through unchanged).
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 result = "";
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
- // Skip comments
22
- if (css[i] === "/" && css[i + 1] === "*") {
30
+ const c = css[i];
31
+ if (c === "/" && css[i + 1] === "*") {
23
32
  const end = css.indexOf("*/", i + 2);
24
- if (end === -1) {
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
- // Skip string literals
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
- // Find next { or end of string
47
- if (css[i] === "{") {
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
- const selector = css.slice(selectorStart, selectorEnd).trim();
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
- if (selector) {
86
- // Rewrite selector with hash
87
- result += css.slice(0, selectorStart);
88
- result += rewriteSelector(selector, hash);
89
- result += " {";
90
- result += scopedBlock;
91
- result += "}";
92
- } else {
93
- // No selector found, just output the block
94
- result += css.slice(0, i);
95
- result += " {";
96
- result += scopedBlock;
97
- result += "}";
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
- i = j;
101
- continue;
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
- return result;
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 (or comma-separated list) by appending hash to each part.
113
- * Handles :global() by passing through unchanged.
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
- // First, extract all :global() blocks and replace with placeholders
117
- const globals: string[] = [];
118
- let processed = selector;
119
-
120
- // Find :global(...) with nested parens
121
- while (processed.includes(":global(")) {
122
- const start = processed.indexOf(":global(");
123
- let depth = 1;
124
- let j = start + 7; // Skip ':global('
125
- while (j < processed.length && depth > 0) {
126
- if (processed[j] === "(") depth++;
127
- else if (processed[j] === ")") depth--;
128
- j++;
129
- }
130
- const globalContent = processed.slice(start + 7, j - 1); // Content inside :global()
131
- const placeholder = `__GLOBAL_${globals.length}__`;
132
- globals.push(globalContent);
133
- processed = processed.slice(0, start) + placeholder + processed.slice(j);
134
- }
135
-
136
- // Now split by comma (safe - no commas inside :global())
137
- const parts = processed.split(",").map((part) => {
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
- // Check for placeholder
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
- return `${trimmed}.${hash}`;
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
- return parts.join(", ");
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 { computeHash, scopeCSS } from "./babel/util/css.ts";
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
- const SIGIL_MACRO_RE = /\$(?:state|store|derived|effect)\s*\(/;
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
- let scopedCSS = "";
64
- code = code.replace(STYLE_RE, (_, css) => {
65
- scopedCSS = scopeCSS(css.trim(), hash);
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
- [sigilPlugin, { hash, mode: options?.mode, dev: options?.dev }],
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 && options?.mode !== "ssr") {
80
- out = `
81
- if (typeof document !== 'undefined' && !document.getElementById('sigil-${hash}')) {
82
- const __style = document.createElement('style');
83
- __style.id = 'sigil-${hash}';
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: ["@babel/plugin-syntax-jsx", [sigilPlugin, { hash, mode }]],
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;