@bamboocss/vite 1.34.1 → 1.35.1

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/dist/index.mjs CHANGED
@@ -1,12 +1,84 @@
1
1
  import { Builder, loadConfigAndCreateContext } from "@bamboocss/node";
2
2
  import { logger } from "@bamboocss/logger";
3
- import { resolveTsPathPattern } from "@bamboocss/config/ts-path";
4
- import { box, maybeBoxNode, unbox } from "@bamboocss/extractor";
3
+ import { compact, createCssUncached, createMergeCss, esc, memo, toHash, truncateList, viewTransitionClassName } from "@bamboocss/shared";
4
+ import remapping from "@ampproject/remapping";
5
5
  import MagicString from "magic-string";
6
+ import postcss from "postcss";
7
+ import selectorParser from "postcss-selector-parser";
6
8
  import { dirname, relative, resolve } from "node:path";
9
+ import { resolveTsPathPattern } from "@bamboocss/config/ts-path";
10
+ import { box, maybeBoxNode } from "@bamboocss/extractor";
7
11
  import { Node, SyntaxKind, VariableDeclarationKind } from "ts-morph";
8
- import { Recipes, classFormatter } from "@bamboocss/core";
9
- import { compact, createCssUncached, createMergeCss, getRecipeClassNames, getRecipeIdentity, getSlotCompoundVariant, memo, truncateList, withoutSpace } from "@bamboocss/shared";
12
+ //#region src/prune-static-css.ts
13
+ /** The generated declaration that identifies a Bamboo stylesheet after minification. */
14
+ const SENTINEL = "--made-with-bamboo";
15
+ /**
16
+ * Remove source-graph atoms no transformed module can emit.
17
+ *
18
+ * `prunableClasses` contains only atoms extracted from the source graph. Explicit `staticCss`
19
+ * additions are absent and survive as a safelist; graph atoms are governed by the transformed
20
+ * module reachability set, regardless of whether they originated in `css()` or a recipe.
21
+ */
22
+ const pruneStaticCss = (css, session, { prune = true } = {}) => {
23
+ if (!css.includes(SENTINEL)) return css;
24
+ const root = postcss.parse(css);
25
+ const isUtilityRule = (rule) => {
26
+ let parent = rule.parent;
27
+ while (parent) {
28
+ if (parent.type === "atrule") {
29
+ const atRule = parent;
30
+ if (atRule.name === "layer" && atRule.params === session.utilityLayer) return true;
31
+ }
32
+ parent = parent.parent;
33
+ }
34
+ return false;
35
+ };
36
+ if (prune) root.walkRules((rule) => {
37
+ if (!isUtilityRule(rule)) return;
38
+ const classes = /* @__PURE__ */ new Set();
39
+ try {
40
+ selectorParser((selectors) => {
41
+ selectors.walkClasses((classNode) => {
42
+ classes.add(classNode.toString().slice(1));
43
+ });
44
+ }).processSync(rule.selector);
45
+ } catch {
46
+ return;
47
+ }
48
+ if (classes.size !== 1) return;
49
+ const [className] = classes;
50
+ if (!className || !session.prunableClasses.has(className) || session.usedClasses.has(className)) return;
51
+ rule.remove();
52
+ });
53
+ let removed = true;
54
+ while (removed) {
55
+ removed = false;
56
+ root.walkAtRules((rule) => {
57
+ if (rule.nodes?.length !== 0) return;
58
+ rule.remove();
59
+ removed = true;
60
+ });
61
+ }
62
+ if (session.denseClassNames) root.walkRules((rule) => {
63
+ if (!isUtilityRule(rule)) return;
64
+ const transitionClasses = /* @__PURE__ */ new Set();
65
+ try {
66
+ rule.selector = selectorParser((selectors) => {
67
+ selectors.walkClasses((classNode) => {
68
+ if (!session.prunableClasses.has(classNode.toString().slice(1))) return;
69
+ if (session.viewTransitionClasses.has(classNode.value)) transitionClasses.add(classNode.value);
70
+ classNode.value = session.allocateClassString(classNode.value);
71
+ });
72
+ }).processSync(rule.selector);
73
+ } catch {}
74
+ if (transitionClasses.size) rule.walkDecls("view-transition-class", (declaration) => {
75
+ if (!transitionClasses.has(declaration.value)) return;
76
+ declaration.value = session.allocateClassString(declaration.value);
77
+ });
78
+ });
79
+ return root.toString();
80
+ };
81
+ //#endregion
10
82
  //#region src/css.ts
11
83
  /**
12
84
  * What a project imports to get the stylesheet.
@@ -21,6 +93,77 @@ const VIRTUAL_CSS_ID = "virtual:bamboo.css";
21
93
  * not to try reading it off disk.
22
94
  */
23
95
  const RESOLVED_ID = `\0${VIRTUAL_CSS_ID}`;
96
+ const INLINE_SOURCE_MAP = /\n?\/\/# sourceMappingURL=data:application\/json[^\n]*$/;
97
+ /** Rewrite one generated chunk without invalidating all mappings after the changed string. */
98
+ const replaceChunkReference = (chunk, bundle, previous, next, sourcemap) => {
99
+ if (!chunk.code.includes(previous)) return;
100
+ const magic = new MagicString(chunk.code);
101
+ let index = chunk.code.indexOf(previous);
102
+ while (index !== -1) {
103
+ magic.overwrite(index, index + previous.length, next);
104
+ index = chunk.code.indexOf(previous, index + previous.length);
105
+ }
106
+ chunk.code = magic.toString();
107
+ if (!chunk.map) return;
108
+ const file = chunk.map.file;
109
+ const debugId = chunk.map.debugId;
110
+ const combined = remapping([magic.generateMap({
111
+ source: chunk.fileName,
112
+ hires: "boundary"
113
+ }), chunk.map], () => null);
114
+ if (file) combined.file = file;
115
+ if (debugId) combined.debugId = debugId;
116
+ const rollupMap = combined;
117
+ rollupMap.toUrl = () => `data:application/json;charset=utf-8;base64,${Buffer.from(combined.toString()).toString("base64")}`;
118
+ chunk.map = rollupMap;
119
+ if (sourcemap === "inline") {
120
+ chunk.code = chunk.code.replace(INLINE_SOURCE_MAP, "");
121
+ chunk.code += `\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,${Buffer.from(combined.toString()).toString("base64")}`;
122
+ return;
123
+ }
124
+ const mapAsset = bundle[`${chunk.fileName}.map`];
125
+ if (mapAsset?.type === "asset") mapAsset.source = combined.toString();
126
+ };
127
+ /** Replace an emitted filename wherever Vite or Rollup has already recorded it. */
128
+ const replaceAssetReferences = (bundle, previous, next, sourcemap) => {
129
+ const replace = (value) => value.replaceAll(previous, next);
130
+ for (const output of Object.values(bundle)) {
131
+ if (output.type === "asset") {
132
+ if (typeof output.source === "string") output.source = replace(output.source);
133
+ continue;
134
+ }
135
+ replaceChunkReference(output, bundle, previous, next, sourcemap);
136
+ const referencedFiles = output.referencedFiles;
137
+ if (referencedFiles) output.referencedFiles = referencedFiles.map(replace);
138
+ const importedCss = output.viteMetadata?.importedCss;
139
+ if (importedCss?.delete(previous)) importedCss.add(next);
140
+ }
141
+ };
142
+ /**
143
+ * Prune and rename compiler-owned CSS, then give changed assets a hash of their final bytes.
144
+ *
145
+ * Rollup has already expanded `[hash]` when `generateBundle` runs. Mutating only `source`
146
+ * would therefore leave two different reachable subsets under one CDN key. The extra final
147
+ * hash is not cosmetic: it makes late graph reachability cache-safe.
148
+ */
149
+ const optimizeStaticCssAssets = (bundle, session) => {
150
+ for (const [bundleName, output] of Object.entries(bundle)) {
151
+ if (output.type !== "asset") continue;
152
+ const source = typeof output.source === "string" ? output.source : Buffer.from(output.source).toString();
153
+ if (!source.includes("--made-with-bamboo")) continue;
154
+ const optimized = pruneStaticCss(source, session);
155
+ output.source = optimized;
156
+ if (optimized === source) continue;
157
+ const nextName = output.fileName.replace(/\.css$/, `.b-${toHash(optimized)}.css`);
158
+ if (nextName === output.fileName) continue;
159
+ if (bundle[nextName] && bundle[nextName] !== output) throw new Error(`bamboocss: final CSS asset name collision at ${JSON.stringify(nextName)}.`);
160
+ const previous = output.fileName;
161
+ output.fileName = nextName;
162
+ replaceAssetReferences(bundle, previous, nextName, session.sourcemap);
163
+ delete bundle[bundleName];
164
+ bundle[nextName] = output;
165
+ }
166
+ };
24
167
  /**
25
168
  * Serve bamboo's stylesheet as a virtual module, in dev and in build.
26
169
  *
@@ -34,10 +177,11 @@ const RESOLVED_ID = `\0${VIRTUAL_CSS_ID}`;
34
177
  * `styles.css` and asking the project to import it means the build reads a file the same
35
178
  * process just wrote, which is a race on any watch rebuild.
36
179
  */
37
- const bamboocssCss = (options = {}) => {
38
- const { configPath, cwd } = options;
180
+ const bamboocssCss = (options) => {
181
+ const { configPath, cwd, session } = options;
39
182
  const builder = new Builder();
40
183
  let server;
184
+ let command = "build";
41
185
  /**
42
186
  * Serialised, because both `load` and the watcher can reach it and `Builder` keeps one
43
187
  * context. Two overlapping passes would extract into the same encoder and emit the
@@ -51,7 +195,33 @@ const bamboocssCss = (options = {}) => {
51
195
  });
52
196
  await builder.emit();
53
197
  builder.extract();
54
- return builder.toCss({ layerParams: true });
198
+ if (builder.context?.config.polyfill) throw new Error("bamboocss: the cascade-layer polyfill is incompatible with compiled atomic styles. The polyfill removes the utility-layer boundary required for safe atom reachability and renaming.");
199
+ if (builder.context) {
200
+ session.cssLoaded = true;
201
+ session.utilityLayer = builder.context.config.layers?.utilities ?? "utilities";
202
+ session.extractedFiles.clear();
203
+ for (const file of builder.context.getFiles()) session.extractedFiles.add(builder.context.runtime.path.abs(builder.context.config.cwd, file));
204
+ }
205
+ let graphAtomHashes;
206
+ if (builder.context) {
207
+ builder.context.encoder.atomizeObservedRecipes();
208
+ graphAtomHashes = new Set(builder.context.encoder.atomic);
209
+ }
210
+ const css = builder.toCss({
211
+ layerParams: true,
212
+ includeRecipes: false
213
+ });
214
+ session.prunableClasses.clear();
215
+ session.viewTransitionClasses.clear();
216
+ if (graphAtomHashes && builder.context) {
217
+ const decoder = builder.context.decoder.collect(builder.context.encoder);
218
+ for (const atom of decoder.atomic) if (graphAtomHashes.has(atom.hash)) session.prunableClasses.add(atom.className);
219
+ for (const transition of decoder.view_transitions) {
220
+ session.viewTransitionClasses.add(transition.className);
221
+ session.prunableClasses.add(esc(transition.className));
222
+ }
223
+ }
224
+ return command === "serve" ? pruneStaticCss(css, session, { prune: false }) : css;
55
225
  };
56
226
  const generate = () => {
57
227
  pending = Promise.resolve(pending).catch(() => void 0).then(build);
@@ -59,6 +229,10 @@ const bamboocssCss = (options = {}) => {
59
229
  };
60
230
  return {
61
231
  name: "bamboocss:css",
232
+ configResolved(config) {
233
+ command = config.command;
234
+ session.sourcemap = config.build.sourcemap;
235
+ },
62
236
  resolveId(id) {
63
237
  if (id === "virtual:bamboo.css") return RESOLVED_ID;
64
238
  return null;
@@ -78,20 +252,23 @@ const bamboocssCss = (options = {}) => {
78
252
  const mod = server?.moduleGraph.getModuleById(RESOLVED_ID);
79
253
  if (!mod) return;
80
254
  server?.moduleGraph.invalidateModule(mod);
81
- server?.ws.send({
82
- type: "update",
83
- updates: []
84
- });
255
+ server?.reloadModule(mod);
85
256
  logger.debug("vite", `styles invalidated by ${file}`);
86
257
  };
87
258
  devServer.watcher.on("change", invalidate);
88
259
  devServer.watcher.on("add", invalidate);
89
260
  devServer.watcher.on("unlink", invalidate);
261
+ },
262
+ generateBundle: {
263
+ order: "post",
264
+ handler(_, bundle) {
265
+ optimizeStaticCssAssets(bundle, session);
266
+ }
90
267
  }
91
268
  };
92
269
  };
93
270
  //#endregion
94
- //#region src/fold-partial.ts
271
+ //#region src/fold-analysis.ts
95
272
  /**
96
273
  * Statically resolvable means: every box in the tree carries a known value.
97
274
  *
@@ -106,7 +283,10 @@ const isStaticBox = (node, seen = /* @__PURE__ */ new Set()) => {
106
283
  seen.add(node);
107
284
  if (box.isUnresolvable(node) || box.isConditional(node)) return false;
108
285
  if (!("type" in node) || node.type == null) return false;
109
- if (box.isLiteral(node) && node.value === void 0) return false;
286
+ if (box.isLiteral(node) && node.value === void 0) {
287
+ const source = node.getNode?.();
288
+ return Boolean(source && Node.isIdentifier(source) && source.getText() === "undefined");
289
+ }
110
290
  if (box.isMap(node)) {
111
291
  for (const child of node.value.values()) if (!isStaticBox(child, seen)) return false;
112
292
  return true;
@@ -231,8 +411,8 @@ const isCollapsedBinary = (node) => Node.isBinaryExpression(node) && COLLAPSED_B
231
411
  * Spreads are the conservative case. `{ ...base }` where `base` is a static local
232
412
  * object *is* resolved by the extractor, but a resolved spread and an unresolved one
233
413
  * are indistinguishable once flattened into the map — both just contribute keys, or
234
- * fail to. Rather than guess, phase 1 declines them. Partial folding is where this
235
- * gets revisited.
414
+ * fail to. Rather than guess and erase evaluation the compiler cannot reproduce, the call
415
+ * is rejected.
236
416
  */
237
417
  /**
238
418
  * What a property's value is written as. A shorthand names it, so the name *is* the
@@ -277,37 +457,6 @@ const accountsForSource = (node, boxNode) => {
277
457
  return true;
278
458
  };
279
459
  /**
280
- * A property whose value is a ternary is not dynamic, it is *finite*: both branches are
281
- * known, so each can be resolved now and the choice left to a ternary between two
282
- * literals. That removes the `css()` call without needing to know which branch runs.
283
- *
284
- * Independent conditionals stay linear rather than multiplying, because each property
285
- * contributes its own ternary. Two conditionals give two ternaries, not four
286
- * combinations — which is only sound because `collides()` already rules out two
287
- * properties resolving to the same class, so no combination can interact with another.
288
- */
289
- const finiteBranches = (key, value, boxNode, deps) => {
290
- if (!box.isConditional(boxNode)) return void 0;
291
- const node = boxNode.getNode();
292
- if (!Node.isConditionalExpression(node)) return void 0;
293
- if (!value || unwrapExpression(value) !== node) return void 0;
294
- const [whenTrue, whenFalse] = [[node.getWhenTrue(), boxNode.whenTrue], [node.getWhenFalse(), boxNode.whenFalse]].map(([source, branch]) => {
295
- if (!deps.isStatic(branch)) return void 0;
296
- if (!deps.isAccounted(source, branch)) return void 0;
297
- const value = unbox(branch).raw;
298
- try {
299
- return deps.runtimeCss({ [key]: value });
300
- } catch {
301
- return;
302
- }
303
- });
304
- if (whenTrue === void 0 || whenFalse === void 0) return void 0;
305
- if (!whenTrue && !whenFalse) return void 0;
306
- return `${node.getCondition().getText()} ? ${JSON.stringify(whenTrue)} : ${JSON.stringify(whenFalse)}`;
307
- };
308
- /** The binding the leaf fold calls, exported by the generated css module. */
309
- const LEAF_HELPER = "cssLeaf";
310
- /**
311
460
  * Memo keyed on a file, thrown away when its text is replaced.
312
461
  *
313
462
  * A plain `WeakMap<SourceFile, …>` is wrong here: ts-morph reuses the wrapper when a path
@@ -328,18 +477,6 @@ const byText = (cache, sourceFile, compute) => {
328
477
  return value;
329
478
  };
330
479
  /**
331
- * The modules this file imports from, as specifiers.
332
- *
333
- * Only strings are cached. A ts-morph *node* cannot be: re-adding a path forgets the old
334
- * nodes even when the text is identical, so the memo would hand back wrappers that throw
335
- * on access. Strings outlive that, and answer the one question worth asking before the
336
- * helper resolution walks the declarations — whether this file imports from bamboo at
337
- * all. On a module of many elements that never fold, that walk was the entire cost of
338
- * trying.
339
- */
340
- const specifierCache = /* @__PURE__ */ new WeakMap();
341
- const importsAnything = (sourceFile, matches) => byText(specifierCache, sourceFile, () => sourceFile.getImportDeclarations().map((declaration) => declaration.getModuleSpecifierValue())).some(matches);
342
- /**
343
480
  * Every name declared at module scope, which is what an added import could collide with.
344
481
  *
345
482
  * This replaced `sourceFile.getLocals()`. That was precise, but it goes through the
@@ -450,282 +587,6 @@ const collectModuleScopeNames = (sourceFile) => {
450
587
  }
451
588
  return names;
452
589
  };
453
- /**
454
- * The local name an already-imported bamboo binding goes by.
455
- *
456
- * `ensureCxImport` answers this too, but it also decides whether a *missing* binding can
457
- * be added, which needs `getLocals()` — the compiler's binder over the whole module. This
458
- * runs for every candidate whether it folds or not, so it stops at what the import
459
- * declarations already say and never forces that.
460
- */
461
- const findBambooBinding = (call, imported, isBambooCssModule, isShadowed) => {
462
- if (!importsAnything(call.getSourceFile(), isBambooCssModule)) return void 0;
463
- for (const declaration of call.getSourceFile().getImportDeclarations()) {
464
- if (declaration.isTypeOnly() || !isBambooCssModule(declaration.getModuleSpecifierValue())) continue;
465
- for (const named of declaration.getNamedImports()) {
466
- if (named.isTypeOnly() || named.getNameNode().getText() !== imported) continue;
467
- const local = (named.getAliasNode() ?? named.getNameNode()).getText();
468
- return isShadowed(call, local) ? void 0 : local;
469
- }
470
- }
471
- };
472
- /**
473
- * A value that survives the class pipeline unchanged, so the class built around it is the
474
- * prefix and nothing else: no whitespace for `sanitize` to collapse, no `!` for the
475
- * important regex, no space for `withoutSpace`, and nothing a token or condition could
476
- * plausibly be named.
477
- */
478
- const LEAF_SENTINEL = "bamboo0leaf0sentinel0";
479
- /**
480
- * A property the extractor could not resolve is *open-ended* rather than finite — but its
481
- * class is still `prefix + value`, and the prefix is known now.
482
- *
483
- * `utility.transform` is string construction over a table fixed at build time, and
484
- * nothing consults which rules were emitted. So `css({ color: tone })` already returns
485
- * `c_<tone>` for a value the extractor never saw, with no CSS behind it. Emitting that
486
- * string directly cannot be less correct than the call it replaces.
487
- *
488
- * The prefix is read off the real implementation rather than rebuilt: resolving a
489
- * sentinel through `runtimeCss` applies the shorthand table and the utility's class name
490
- * in one step. It also self-gates — a hashed or grouped class does not contain the
491
- * sentinel, so both modes decline here without this having to read the config.
492
- *
493
- * Shared with the element surface, which asks the same question about a JSX style prop.
494
- *
495
- * Top level only, like the finite lowering beside it. A nested leaf's class carries its
496
- * condition path, which the prefix would describe correctly — but the helper's fallback
497
- * rebuilds `{ [prop]: value }` to hand back to `css()`, and that reconstruction has to
498
- * carry the same path or the declined shape resolves without its condition.
499
- */
500
- const leafPrefix = (key, ctx, runtimeCss) => {
501
- if (ctx.conditions.isCondition(key)) return void 0;
502
- let resolved;
503
- try {
504
- resolved = runtimeCss({ [key]: LEAF_SENTINEL });
505
- } catch {
506
- return;
507
- }
508
- if (!resolved.endsWith(LEAF_SENTINEL)) return void 0;
509
- const prefix = resolved.slice(0, -21);
510
- return !prefix || prefix.includes(" ") ? void 0 : prefix;
511
- };
512
- /**
513
- * Written as an object or an array, this is a condition block or a responsive list: one
514
- * class per entry rather than one class. `leafClass` declines both at runtime and falls
515
- * back, so lowering one is not wrong — it is a guaranteed round trip through the fallback,
516
- * which is the same reason a condition key is declined.
517
- */
518
- const isWrittenAsCollection = (value) => {
519
- const inner = unwrapExpression(value);
520
- return Node.isObjectLiteralExpression(inner) || Node.isArrayLiteralExpression(inner);
521
- };
522
- /** The call this surface emits in place of the property. */
523
- const leafCall = (prefix, key, valueText, name = LEAF_HELPER) => `${name}(${JSON.stringify(prefix)}, ${JSON.stringify(key)}, ${valueText})`;
524
- const dynamicLeaf = (key, value, deps) => {
525
- if (!deps.allowLeaf || !value) return void 0;
526
- if (isWrittenAsCollection(value)) return void 0;
527
- const prefix = leafPrefix(key, deps.ctx, deps.runtimeCss);
528
- return prefix === void 0 ? void 0 : leafCall(prefix, key, value.getText(), deps.leafName ?? "cssLeaf");
529
- };
530
- /**
531
- * Properties are partitioned whole rather than recursed into. A top-level property is
532
- * either entirely static or entirely dynamic, which keeps the reconstructed object a
533
- * verbatim slice of the source and avoids rebuilding nested conditions by hand.
534
- */
535
- const planPartialFold = (argument, boxNode, styles, deps) => {
536
- const partition = partitionObject(argument, boxNode, styles, deps);
537
- if (!partition) return void 0;
538
- const className = deps.runtimeCss(partition.staticStyles);
539
- if (!className && !partition.finite.length) return void 0;
540
- return {
541
- className,
542
- dynamicText: partition.dynamicText.length ? `{ ${partition.dynamicText.join(", ")} }` : void 0,
543
- finite: partition.finite,
544
- finiteFirst: partition.finiteFirst
545
- };
546
- };
547
- /**
548
- * Split one object level, recursing into a block that is part static and part dynamic.
549
- *
550
- * Without the recursion a single dynamic leaf sends its whole block to the runtime:
551
- * `{ _hover: { color: 'red.300', bg: p } }` loses the resolved `color` even though
552
- * nothing about it depends on `p`. That is a precision loss rather than a wrong answer,
553
- * but it costs exactly the calls a component re-renders most.
554
- *
555
- * A class is identified by its condition path *and* its property, so `_hover.color` in
556
- * one half and `_hover.bg` in the other cannot collide, and neither can `color` against
557
- * `_hover.color`. Collision is therefore checked per level, among siblings.
558
- *
559
- * The static subtree is read from the extracted data rather than rebuilt: the extractor
560
- * has already dropped the unresolvable leaves, so `styles[key]` for a mixed block is
561
- * exactly the resolvable part. The dynamic side is taken from source text, so nothing
562
- * depends on that pruning being complete.
563
- */
564
- const partitionObject = (node, boxNode, styles, deps, topLevel = true) => {
565
- if (!box.isMap(boxNode)) return void 0;
566
- const { ctx, isAccounted, isStatic } = deps;
567
- const staticKeys = [];
568
- const staticStyles = {};
569
- const seenKeys = /* @__PURE__ */ new Set();
570
- /**
571
- * Everything not resolved outright, in source order. Kept as one list because a ternary
572
- * that cannot be lowered has to become a runtime property *in its original position*,
573
- * and because whether the two kinds interleave is a property of that order.
574
- */
575
- const slots = [];
576
- for (const property of node.getProperties()) {
577
- if (!Node.isPropertyAssignment(property) && !Node.isShorthandPropertyAssignment(property)) return void 0;
578
- const nameNode = property.getNameNode();
579
- if (Node.isComputedPropertyName(nameNode)) return void 0;
580
- const key = Node.isStringLiteral(nameNode) || Node.isNumericLiteral(nameNode) ? String(nameNode.getLiteralValue()) : nameNode.getText();
581
- if (seenKeys.has(key)) return void 0;
582
- seenKeys.add(key);
583
- const value = valueOf(property);
584
- const valueBox = boxNode.value.get(key);
585
- if (key in styles && isStatic(valueBox) && isAccounted(value, valueBox)) {
586
- staticKeys.push(key);
587
- staticStyles[key] = styles[key];
588
- continue;
589
- }
590
- if (topLevel) {
591
- const branches = finiteBranches(key, value, valueBox, deps);
592
- if (branches) {
593
- slots.push({
594
- key,
595
- kind: "finite",
596
- lowered: {
597
- expression: branches,
598
- emitsLiterals: true
599
- },
600
- text: property.getText()
601
- });
602
- continue;
603
- }
604
- const leaf = dynamicLeaf(key, value, deps);
605
- if (leaf) {
606
- slots.push({
607
- key,
608
- kind: "finite",
609
- lowered: {
610
- expression: leaf,
611
- emitsLiterals: false
612
- },
613
- text: property.getText()
614
- });
615
- continue;
616
- }
617
- }
618
- const nested = value && Node.isObjectLiteralExpression(value) && isAccounted(value, valueBox) ? partitionObject(value, valueBox, styles[key] ?? {}, deps, false) : void 0;
619
- if (nested && Object.keys(nested.staticStyles).length && nested.dynamicText.length) {
620
- staticKeys.push(key);
621
- staticStyles[key] = nested.staticStyles;
622
- slots.push({
623
- key,
624
- kind: "dynamic",
625
- split: true,
626
- text: `${property.getNameNode().getText()}: { ${nested.dynamicText.join(", ")} }`
627
- });
628
- continue;
629
- }
630
- slots.push({
631
- key,
632
- kind: "dynamic",
633
- text: property.getText()
634
- });
635
- }
636
- const demote = (slot) => {
637
- slot.kind = "dynamic";
638
- slot.lowered = void 0;
639
- };
640
- const contested = () => slots.filter((slot) => slot.kind === "dynamic" && !slot.split).map((slot) => slot.key);
641
- for (;;) {
642
- const lowered = slots.filter((slot) => slot.kind === "finite");
643
- const dynamic = contested();
644
- const offender = lowered.find((slot, index) => collides([slot.key], [
645
- ...staticKeys,
646
- ...dynamic,
647
- ...lowered.slice(0, index).map((kept) => kept.key)
648
- ], ctx));
649
- if (!offender) break;
650
- demote(offender);
651
- }
652
- const written = () => slots.map((slot) => slot.kind === "finite" ? "f" : "d").join("");
653
- while (!/^f*d*$/.test(written()) && !/^d*f*$/.test(written())) demote(slots.findLast((slot) => slot.kind === "finite"));
654
- const dynamicText = slots.filter((slot) => slot.kind === "dynamic").map((slot) => slot.text);
655
- const finite = slots.filter((slot) => slot.kind === "finite").map((slot) => slot.lowered);
656
- if (!staticKeys.length && !finite.length) return void 0;
657
- if (!dynamicText.length && !finite.length) return void 0;
658
- if (collides(staticKeys, contested(), ctx)) return void 0;
659
- return {
660
- staticStyles,
661
- dynamicText,
662
- finite,
663
- finiteFirst: !written().startsWith("d")
664
- };
665
- };
666
- /**
667
- * Do the two halves resolve to a shared property?
668
- *
669
- * Compared after shorthand resolution, since that is where distinct keys become the same
670
- * property. An unrecognised key resolves to itself, so two distinct unknown keys are read
671
- * as distinct — which is right for atomic output, where one class is emitted per key.
672
- */
673
- const collides = (staticKeys, dynamicKeys, ctx) => {
674
- if (staticKeys.includes("base") || dynamicKeys.includes("base")) return true;
675
- const resolve = (key) => ctx.utility.hasShorthand ? ctx.utility.resolveShorthand(key) : key;
676
- const resolvedStatic = new Set(staticKeys.map(resolve));
677
- return dynamicKeys.some((key) => resolvedStatic.has(resolve(key)));
678
- };
679
- /**
680
- * The `cx` binding to call, adding it to the import that already brings in the style
681
- * helper when it is not there yet.
682
- *
683
- * Reusing that declaration rather than writing a new one avoids having to guess the
684
- * module specifier, which varies with `importMap`, path aliases and how the project
685
- * spells its outdir.
686
- */
687
- const ensureCxImport = (call, calleeRoot, isBambooCssModule, isGeneratedCssModule, isShadowed, extra = []) => {
688
- const sourceFile = call.getSourceFile();
689
- const wanted = ["cx", ...extra];
690
- const resolved = {};
691
- let host;
692
- for (const declaration of sourceFile.getImportDeclarations()) {
693
- const mod = declaration.getModuleSpecifierValue();
694
- for (const named of declaration.getNamedImports()) {
695
- const local = (named.getAliasNode() ?? named.getNameNode()).getText();
696
- const imported = named.getNameNode().getText();
697
- if (wanted.includes(imported) && !(imported in resolved)) {
698
- if (declaration.isTypeOnly() || named.isTypeOnly()) return void 0;
699
- if (!isBambooCssModule(mod)) return void 0;
700
- if (isShadowed(call, local)) return void 0;
701
- resolved[imported] = local;
702
- }
703
- if (local === calleeRoot) host = declaration;
704
- }
705
- }
706
- const missing = wanted.filter((name) => !(name in resolved));
707
- if (!missing.length) return {
708
- name: resolved.cx,
709
- names: resolved
710
- };
711
- if (!host) return void 0;
712
- if (!isGeneratedCssModule(host.getModuleSpecifierValue())) return void 0;
713
- const declared = declaredAtModuleScope(sourceFile);
714
- for (const name of missing) {
715
- if (declared.has(name) || isShadowed(call, name)) return void 0;
716
- resolved[name] = name;
717
- }
718
- const last = host.getNamedImports().at(-1);
719
- if (!last) return void 0;
720
- return {
721
- name: resolved.cx,
722
- names: resolved,
723
- insert: {
724
- pos: last.getEnd(),
725
- names: missing
726
- }
727
- };
728
- };
729
590
  //#endregion
730
591
  //#region src/fold-recipe.ts
731
592
  /**
@@ -735,15 +596,12 @@ const ensureCxImport = (call, calleeRoot, isBambooCssModule, isGeneratedCssModul
735
596
  * that names it. The parser records a definition under the name it was *imported* as (`cva`),
736
597
  * and a call under the name the file *bound* (`badge`); this is what joins the two.
737
598
  *
738
- * Reads `cva` and not `sva`, which is load-bearing rather than an omission. The parser records
739
- * a call of *either* as a recipe call, but an `sva` invocation returns one class per slot — an
740
- * object, not a string — so there is no literal to substitute. Leaving slot recipes out of this
741
- * map is what makes them decline as `unknown-recipe` instead of folding to a string that would
742
- * break every consumer reading `.root` off it.
599
+ * Slot and ordinary recipes share one representation.
743
600
  */
744
601
  const collectRecipeConfigs = (parserResult) => {
745
602
  const configs = /* @__PURE__ */ new Map();
746
- for (const definition of parserResult.cva) {
603
+ const definitions = [...parserResult.cva, ...parserResult.sva];
604
+ for (const definition of definitions) {
747
605
  const node = definition.box?.getNode?.();
748
606
  if (!node) continue;
749
607
  const nameNode = ((Node.isCallExpression(node) ? node : node.getFirstAncestorByKind(SyntaxKind.CallExpression))?.getFirstAncestorByKind(SyntaxKind.VariableDeclaration))?.getNameNode();
@@ -760,21 +618,20 @@ const collectRecipeConfigs = (parserResult) => {
760
618
  }
761
619
  configs.set(nameNode.getText(), {
762
620
  config,
763
- name: getRecipeIdentity(config),
764
621
  box: definition.box
765
622
  });
766
623
  }
767
624
  return configs;
768
625
  };
769
- /** The generated binding a lowered dynamic axis calls. Lives in `cx`, which pulls no engine. */
770
- const RECIPE_PICK_HELPER = "cvaPick";
626
+ /** Pick a complete precompiled StyleSet for one or more runtime recipe axes. */
627
+ const RECIPE_MAP_HELPER = "cvaMap";
628
+ /** Guard the exact compiler against accidentally materialising an enormous Cartesian product. */
629
+ const DEFAULT_MAX_RECIPE_STATES = 65536;
771
630
  /** What `recipe.splitVariantProps` calls, reached directly once the call is lowered. */
772
631
  const SPLIT_PROPS_HELPER = "splitProps";
773
- const HELPER = RECIPE_PICK_HELPER;
774
632
  /** Marker for a binding the fold must never resolve — declared twice, or unresolvable. */
775
633
  const AMBIGUOUS = Object.freeze({
776
634
  config: {},
777
- name: "",
778
635
  box: void 0
779
636
  });
780
637
  /** `.size`, or `["x-large"]` when the variant is not a valid identifier. */
@@ -816,10 +673,9 @@ const propertyKey = (nameNode) => {
816
673
  if (Node.isNumericLiteral(nameNode)) return String(nameNode.getLiteralValue());
817
674
  };
818
675
  /**
819
- * Make `cvaPick` callable at this call site, by whatever name the file gives it.
676
+ * Make a generated compile helper callable at this call site, by whatever name the file gives it.
820
677
  *
821
- * Not `ensureCxImport`: that one resolves `cx` and finds the declaration to extend by
822
- * matching the *callee* against an import. An inline recipe's callee is a local binding, so
678
+ * Unlike `cx`, an inline recipe's callee is a local binding, so
823
679
  * there is nothing to match — the host here is any import of the generated css module, which
824
680
  * a file defining a recipe necessarily has, since `cva` came from it.
825
681
  */
@@ -871,13 +727,18 @@ const ensureRecipeHelperImport = (imported, call, isBambooCssModule, isGenerated
871
727
  * an unresolved variant does not merely omit a class, it can change which of several the
872
728
  * recipe applies — so a partially-known selection is not foldable at all.
873
729
  */
874
- const lowerRecipeCall = (call, entry, ctx, isInert, resolvedSelection) => {
730
+ const lowerRecipeCall = (call, entry, styleCompiler, isInert, resolvedSelection, slot, maxRecipeStates = DEFAULT_MAX_RECIPE_STATES) => {
875
731
  if (!entry || entry === AMBIGUOUS) return {
876
732
  kind: "decline",
877
733
  reason: "unknown-recipe"
878
734
  };
879
- const { config, name } = entry;
880
- if (config.slots !== void 0) return {
735
+ const { config } = entry;
736
+ if (config.slots !== void 0) {
737
+ if (!Array.isArray(config.slots) || slot !== void 0 && !config.slots.includes(slot)) return {
738
+ kind: "decline",
739
+ reason: "unsupported-shape"
740
+ };
741
+ } else if (slot) return {
881
742
  kind: "decline",
882
743
  reason: "unsupported-shape"
883
744
  };
@@ -913,16 +774,13 @@ const lowerRecipeCall = (call, entry, ctx, isInert, resolvedSelection) => {
913
774
  /**
914
775
  * `input(variantProps)` — a selection the build cannot see inside.
915
776
  *
916
- * Inline recipes only. `cva` resolves a selection with `getRecipeClassNames`, which reads
917
- * a variant value as a key and so cannot take a conditional — a `{ base, md }` object finds
918
- * no entry and names no class, exactly as `cvaPick` does. A **config** recipe routes its
919
- * selection through `createCss`, which *expands* conditions into one class per breakpoint,
920
- * so a scalar lookup silently drops them. That is why this lowering is not applied to
921
- * config recipes: for a dynamic axis the build cannot know which kind of value arrives.
777
+ * The compiled recipe contract accepts scalar declared variant values. A conditional
778
+ * object is not a finite selection value; responsiveness belongs inside a variant's style
779
+ * declaration, where the compiler can materialize its conditions ahead of time.
922
780
  *
923
- * The classes are still knowable: a recipe emits one per declared variant, so the call is
924
- * one term per variant reading that binding. This is the shape a wrapper component takes,
925
- * where the variants are the component's public API and cannot be literals by definition.
781
+ * The complete StyleSets are knowable: the config declares every scalar value each axis
782
+ * accepts. This is the shape a wrapper component takes, where variants are its public API
783
+ * and therefore cannot be literals by definition.
926
784
  *
927
785
  * An identifier only. Each variant reads the binding again, and re-reading anything else —
928
786
  * a call, a property access — would evaluate it once per axis instead of once.
@@ -1005,19 +863,51 @@ const lowerRecipeCall = (call, entry, ctx, isInert, resolvedSelection) => {
1005
863
  * typecheck and does transform `.js`, so this is reachable.
1006
864
  */
1007
865
  const everyEffectSurvives = () => effectful.every(({ key, text }) => dynamicAxes.get(key) === text);
1008
- const merged = {
1009
- ...config.defaultVariants ?? {},
1010
- ...compact(selection)
866
+ const compiledSelection = (selected) => {
867
+ if (Array.isArray(config.slots) && slot === void 0) {
868
+ const slots = {};
869
+ const classNames = /* @__PURE__ */ new Set();
870
+ for (const slotName of config.slots) {
871
+ const styles = styleCompiler.resolveRecipe(config, selected, slotName);
872
+ if (!styles) return void 0;
873
+ const className = styleCompiler.className(styles);
874
+ slots[slotName] = className;
875
+ for (const token of className.split(" ")) if (token) classNames.add(token);
876
+ }
877
+ return {
878
+ value: slots,
879
+ classNames: [...classNames]
880
+ };
881
+ }
882
+ const styles = styleCompiler.resolveRecipe(config, selected, slot);
883
+ if (!styles) return void 0;
884
+ const className = styleCompiler.className(styles);
885
+ return {
886
+ value: className,
887
+ classNames: className.split(" ").filter(Boolean),
888
+ styles
889
+ };
1011
890
  };
1012
- const format = classFormatter(ctx);
1013
891
  if (dynamicAxes.size === 0) {
1014
892
  if (!everyEffectSurvives()) return {
1015
893
  kind: "decline",
1016
894
  reason: "dynamic"
1017
895
  };
1018
- return {
896
+ const compiled = compiledSelection(selection);
897
+ if (!compiled) return {
898
+ kind: "decline",
899
+ reason: "dynamic"
900
+ };
901
+ if (typeof compiled.value === "string") return {
1019
902
  kind: "class",
1020
- className: getRecipeClassNames(name, config.variants, merged, ctx.utility.separator, format)
903
+ className: compiled.value,
904
+ styles: compiled.styles
905
+ };
906
+ return {
907
+ kind: "slots",
908
+ expression: JSON.stringify(compiled.value),
909
+ classNames: compiled.classNames,
910
+ dynamic: false
1021
911
  };
1022
912
  }
1023
913
  if (!everyEffectSurvives()) return {
@@ -1033,45 +923,126 @@ const lowerRecipeCall = (call, entry, ctx, isInert, resolvedSelection) => {
1033
923
  };
1034
924
  }
1035
925
  for (const key of [...dynamicAxes.keys()]) if (!Object.hasOwn(config.variants ?? {}, key)) dynamicAxes.delete(key);
1036
- if (dynamicAxes.size === 0) return {
1037
- kind: "class",
1038
- className: getRecipeClassNames(name, config.variants, merged, ctx.utility.separator, format)
1039
- };
1040
- const ownClass = format(name);
1041
- const parts = [JSON.stringify(ownClass)];
1042
- const classNames = [ownClass];
1043
- for (const key of Object.keys(config.variants ?? {})) {
1044
- const expression = dynamicAxes.get(key);
1045
- if (expression === void 0) {
1046
- const value = merged[key];
1047
- if (value == null) continue;
1048
- const declared = config.variants?.[key];
1049
- if (!declared || !Object.hasOwn(declared, value) || declared[value] == null) continue;
1050
- const className = format(`${name}--${key}${ctx.utility.separator}${withoutSpace(value)}`);
1051
- parts.push(JSON.stringify(` ${className}`));
1052
- classNames.push(className);
1053
- continue;
1054
- }
1055
- const values = config.variants[key];
1056
- const table = {};
1057
- for (const value of Object.keys(values)) {
1058
- const className = format(`${name}--${key}${ctx.utility.separator}${withoutSpace(value)}`);
1059
- table[value] = ` ${className}`;
1060
- classNames.push(className);
1061
- }
1062
- const fallbackValue = config.defaultVariants?.[key];
1063
- const fallback = fallbackValue != null && values[fallbackValue] != null ? ` ${format(`${name}--${key}${ctx.utility.separator}${withoutSpace(fallbackValue)}`)}` : void 0;
1064
- parts.push(`${HELPER}(${expression}, ${JSON.stringify(table)}${fallback === void 0 ? "" : `, ${JSON.stringify(fallback)}`})`);
926
+ if (dynamicAxes.size === 0) {
927
+ const compiled = compiledSelection(selection);
928
+ if (!compiled) return {
929
+ kind: "decline",
930
+ reason: "dynamic"
931
+ };
932
+ if (typeof compiled.value === "string") return {
933
+ kind: "class",
934
+ className: compiled.value,
935
+ styles: compiled.styles
936
+ };
937
+ return {
938
+ kind: "slots",
939
+ expression: JSON.stringify(compiled.value),
940
+ classNames: compiled.classNames,
941
+ dynamic: false
942
+ };
1065
943
  }
1066
- if (parts.length === 1) return {
1067
- kind: "class",
1068
- className: ownClass
1069
- };
944
+ /**
945
+ * Compile the finite recipe state space into a reduced decision table.
946
+ *
947
+ * Each leaf is a *complete* final StyleSet. This matters for declarations overridden by
948
+ * variants and compounds: selecting independent per-axis atoms would put both values in
949
+ * the utility layer and let stylesheet order, rather than the recipe's merge order, pick
950
+ * the winner. Complete leaves retain the same precedence while sharing their atoms with
951
+ * every `css()` and recipe in the build.
952
+ *
953
+ * `undefined` is its own edge because it restores a default variant. `null` and any
954
+ * undeclared value take the miss edge and explicitly suppress that default. Declared
955
+ * values use string keys, matching JavaScript's property-key coercion in the recipe
956
+ * runtime. A flat alternating key/value array avoids the special `__proto__` semantics
957
+ * of an object literal.
958
+ */
959
+ const axes = Object.keys(config.variants ?? {}).filter((key) => dynamicAxes.has(key));
960
+ const stateCount = axes.reduce((product, axis) => product * (Object.keys(config.variants?.[axis] ?? {}).length + 2), 1);
961
+ if (stateCount > maxRecipeStates) throw new Error(`Static recipe compilation would inspect ${stateCount.toLocaleString("en-US")} selections across ${axes.length} runtime variant axes, above maxRecipeStates=${maxRecipeStates.toLocaleString("en-US")}. Make one or more axes statically known, split the recipe, or raise the limit explicitly.`);
962
+ const expressions = axes.map((axis) => dynamicAxes.get(axis));
963
+ const wholeSlots = Array.isArray(config.slots) && slot === void 0;
1070
964
  return {
1071
- kind: "expression",
1072
- expression: parts.join(" + "),
1073
- classNames,
1074
- staticClasses: ownClass
965
+ kind: "dynamic-style",
966
+ map: {
967
+ outputKind: wholeSlots ? "slots" : "class",
968
+ compile(before = [], after = []) {
969
+ const nodes = [];
970
+ const nodeByShape = /* @__PURE__ */ new Map();
971
+ const leaves = [];
972
+ const leafByShape = /* @__PURE__ */ new Map();
973
+ const emittedClasses = /* @__PURE__ */ new Set();
974
+ const leaf = (dynamicSelection) => {
975
+ const selected = {
976
+ ...selection,
977
+ ...dynamicSelection
978
+ };
979
+ if (wholeSlots) {
980
+ const compiled = compiledSelection(selected);
981
+ if (!compiled || typeof compiled.value === "string") return internLeaf("");
982
+ for (const token of compiled.classNames) emittedClasses.add(token);
983
+ return internLeaf(compiled.value);
984
+ }
985
+ const styles = styleCompiler.resolveRecipe(config, selected, slot);
986
+ if (!styles) return internLeaf("");
987
+ const className = styleCompiler.className(styleCompiler.compose(...before, styles, ...after));
988
+ for (const token of className.split(" ")) if (token) emittedClasses.add(token);
989
+ return internLeaf(className);
990
+ };
991
+ function internLeaf(value) {
992
+ const shape = JSON.stringify(value);
993
+ const known = leafByShape.get(shape);
994
+ if (known !== void 0) return ~known;
995
+ const id = leaves.length;
996
+ leaves.push(value);
997
+ leafByShape.set(shape, id);
998
+ return ~id;
999
+ }
1000
+ const buildNode = (index, dynamicSelection) => {
1001
+ if (index === axes.length) return leaf(dynamicSelection);
1002
+ const axis = axes[index];
1003
+ const values = Object.keys(config.variants?.[axis] ?? {});
1004
+ const miss = buildNode(index + 1, {
1005
+ ...dynamicSelection,
1006
+ [axis]: null
1007
+ });
1008
+ const absentSelection = { ...dynamicSelection };
1009
+ delete absentSelection[axis];
1010
+ const absent = buildNode(index + 1, absentSelection);
1011
+ const byValue = [];
1012
+ for (const value of values) byValue.push(value, buildNode(index + 1, {
1013
+ ...dynamicSelection,
1014
+ [axis]: value
1015
+ }));
1016
+ const refs = [
1017
+ miss,
1018
+ absent,
1019
+ ...byValue.filter((_, valueIndex) => valueIndex % 2 === 1)
1020
+ ];
1021
+ if (refs.every((ref) => ref === refs[0])) return refs[0];
1022
+ const node = [
1023
+ miss,
1024
+ absent,
1025
+ byValue
1026
+ ];
1027
+ const shape = JSON.stringify(node);
1028
+ const known = nodeByShape.get(shape);
1029
+ if (known !== void 0) return known;
1030
+ const id = nodes.length;
1031
+ nodes.push(node);
1032
+ nodeByShape.set(shape, id);
1033
+ return id;
1034
+ };
1035
+ const root = buildNode(0, {});
1036
+ const staticLeaf = root < 0 ? leaves[~root] : void 0;
1037
+ return {
1038
+ expression: root < 0 && effectful.length === 0 ? JSON.stringify(staticLeaf) : `${RECIPE_MAP_HELPER}([${expressions.join(", ")}], ${JSON.stringify(nodes)}, ${JSON.stringify(leaves)}, ${root})`,
1039
+ classNames: [...emittedClasses],
1040
+ staticClasses: typeof staticLeaf === "string" ? staticLeaf : "",
1041
+ outputKind: wholeSlots ? "slots" : "class",
1042
+ usesHelper: !(root < 0 && effectful.length === 0)
1043
+ };
1044
+ }
1045
+ }
1075
1046
  };
1076
1047
  };
1077
1048
  //#endregion
@@ -1135,114 +1106,29 @@ const createRuntimeTokenValue = (ctx) => (path) => {
1135
1106
  * the default form the trivially foldable one.
1136
1107
  */
1137
1108
  const createRuntimeToken = (ctx) => (path) => tokenValuesFor(ctx).get(path)?.variable || void 0;
1138
- /**
1139
- * Whether a slot's class is independent of the variant props.
1140
- *
1141
- * A scoped slot recipe delivers variants through `@scope` rules anchored on an enclosing
1142
- * slot, so every *other* slot carries a constant class — the same string whatever the props
1143
- * are. That is what makes `recipe(anything).slot` foldable even when the variant is fully
1144
- * dynamic, which was not true before scoping existed.
1145
- */
1146
- const createConstantSlotCheck = (ctx) => (name, slot) => {
1147
- const config = ctx.recipes.getConfig(name);
1148
- if (!config || !("slots" in config)) return false;
1149
- if (!config.slots.includes(slot)) return false;
1150
- const anchors = Recipes.getScopeRoots(config);
1151
- return anchors.length > 0 && !anchors.includes(slot);
1152
- };
1153
- const createRuntimeRecipe = (ctx) => {
1154
- const separator = ctx.utility.separator;
1155
- return (name, variants, slot) => {
1156
- const config = ctx.recipes.getConfig(name);
1157
- const node = ctx.recipes.getRecipe(name);
1158
- if (!config || !node) return void 0;
1159
- const isSlotRecipe = "slots" in config;
1160
- if (isSlotRecipe !== Boolean(slot)) return void 0;
1161
- if (slot && !config.slots.includes(slot)) return void 0;
1162
- const anchors = isSlotRecipe ? Recipes.getScopeRoots(config) : [];
1163
- const isConstantSlot = Boolean(slot) && anchors.length > 0 && !anchors.includes(slot);
1164
- const className = slot ? ctx.recipes.getSlotKey(node.className, slot) : node.className;
1165
- const { defaultVariants = {} } = config;
1166
- const compoundVariants = slot ? getSlotCompoundVariant(config.compoundVariants ?? [], slot) : config.compoundVariants ?? [];
1167
- const recipeCss = createCssUncached({
1168
- hash: Boolean(ctx.hash.className),
1169
- conditions: {
1170
- shift: ctx.conditions.shift,
1171
- finalize: ctx.conditions.finalize
1172
- },
1173
- utility: {
1174
- prefix: ctx.utility.prefix,
1175
- hasShorthand: false,
1176
- resolveShorthand: (prop) => prop,
1177
- toHash: ctx.utility.toHash.bind(ctx.utility),
1178
- transform: (prop, value) => {
1179
- if (value === "__ignore__") return { className };
1180
- return { className: `${className}--${prop}${separator}${withoutSpace(value)}` };
1181
- }
1182
- }
1183
- });
1184
- const declaredValues = config.variants ?? {};
1185
- /**
1186
- * The same filter the generated `createRecipe` applies: only a value the config declares
1187
- * names a class.
1188
- *
1189
- * Scalars only — a conditional or responsive value is an object of leaves, and the leaves
1190
- * are what name classes when `createCss` walks them.
1191
- */
1192
- const onlyDeclared = (styles) => Object.fromEntries(Object.entries(styles).filter(([prop, value]) => {
1193
- if (prop === className) return true;
1194
- if (value === null || typeof value === "object") return true;
1195
- return Object.hasOwn(declaredValues, prop) && Object.hasOwn(declaredValues[prop] ?? {}, String(value));
1196
- }));
1197
- const recipeStyles = isConstantSlot ? { [className]: "__ignore__" } : onlyDeclared({
1198
- [className]: "__ignore__",
1199
- ...defaultVariants,
1200
- ...compact(variants)
1201
- });
1202
- if (compoundVariants.length > 0 && Object.keys(recipeStyles).some((prop) => typeof variants[prop] === "object")) return;
1203
- if (isSlotRecipe) {
1204
- const evaluated = anchors.length > 0 ? anchors : config.slots;
1205
- if (Object.values(variants).some((value) => typeof value === "object" && value !== null) && evaluated.some((slotName) => getSlotCompoundVariant(config.compoundVariants ?? [], slotName).length > 0)) return void 0;
1206
- }
1207
- return recipeCss(recipeStyles);
1208
- };
1209
- };
1210
1109
  //#endregion
1211
1110
  //#region src/fold.ts
1212
1111
  /**
1213
- * `cva`/`sva` return a function, so neither *definition* can collapse to a class string.
1214
- * `token` also resolves to no class, but it does resolve to a literal, so it folds through
1215
- * its own path rather than being declined outright.
1112
+ * `cva`/`sva` return a function, so their definitions are compile-time declarations rather
1113
+ * than class-producing calls; once their uses are lowered, the factory calls are erased.
1114
+ * `token` also resolves to no class, but it does resolve to a literal, so it compiles through
1115
+ * its own path rather than being declined outright. A static `viewTransition` bag resolves to
1116
+ * its extracted class and uses the ordinary class candidate path.
1216
1117
  *
1217
- * Their invocations are a different matter and do fold — `cva`'s through `fold-recipe`,
1218
- * which is a separate set because the call is recorded under the name the file bound rather
1219
- * than the name it imported. `sva`'s do not: a slot recipe resolves to one class per slot.
1118
+ * Recipe invocations compile through `fold-recipe`. Inline calls
1119
+ * are recorded under the name the file bound; config calls arrive as `recipe`. Routing both
1120
+ * through one exact finite-state lowering keeps their selection contract identical.
1220
1121
  */
1221
1122
  const FOLDABLE_TYPES = new Set([
1222
1123
  "css",
1223
1124
  "pattern",
1224
- "recipe"
1125
+ "viewTransition"
1225
1126
  ]);
1226
1127
  /**
1227
- * The class strings inside a lowered ternary — `e ? "c_red" : "c_blue"` gives both arms.
1228
- *
1229
- * They are read back out of the emitted text rather than threaded through the planner,
1230
- * because the planner's product *is* that text: anything it did not write cannot appear
1231
- * here, and anything it did cannot be missed.
1232
- */
1233
- const literalsIn = (expression) => [...expression.matchAll(/"((?:[^"\\]|\\.)*)"/g)].flatMap((match) => JSON.parse(match[0]).split(" ")).filter(Boolean);
1234
- /**
1235
- * The kinds reported as `not-foldable`, which is permanent rather than a limit of this
1236
- * phase — hence separate from `unsupported-kind`, where a slot recipe lands because it
1237
- * resolves to one class per slot rather than to a single string.
1238
- */
1239
- const UNFOLDABLE_TYPES = new Set(["cva", "sva"]);
1240
- /**
1241
1128
  * The skip reasons that leave a `css()`-family call in the output.
1242
1129
  *
1243
1130
  * `overlapping` is handled by the enclosing fold, and `not-imported` is somebody else's
1244
- * function of the same name — neither leaves a call of ours. `not-foldable` is a `cva`/`sva`
1245
- * definition, which keeps the recipe runtime rather than the css engine; see `failOnUnfolded`.
1131
+ * function of the same name — neither leaves a call of ours.
1246
1132
  */
1247
1133
  const SURVIVES_TO_RUNTIME = new Set([
1248
1134
  "dynamic",
@@ -1250,9 +1136,8 @@ const SURVIVES_TO_RUNTIME = new Set([
1250
1136
  "raw-call",
1251
1137
  "unsupported-kind",
1252
1138
  "no-call-expression",
1253
- "empty",
1254
1139
  "unresolved-token",
1255
- "fold-failed"
1140
+ "compile-failed"
1256
1141
  ]);
1257
1142
  /**
1258
1143
  * A call of a recipe the file bound itself: `const badge = cva(...)`, then `badge({ tone })`.
@@ -1300,21 +1185,13 @@ const isValueReference = (identifier) => {
1300
1185
  /**
1301
1186
  * Imports a surviving reference to is not a failure.
1302
1187
  *
1303
- * The first four are what the fold itself writes; all live in `cx` and pull no engine, so a
1188
+ * These are what the compiler itself writes; all live in `cx` and pull no engine, so a
1304
1189
  * reference to one is the fold having worked.
1305
- *
1306
- * `cva` and `sva` are there for the reason `SURVIVES_TO_RUNTIME` omits `not-foldable`: a
1307
- * recipe *definition* cannot fold to a class string and never could, and what it keeps is the
1308
- * recipe runtime rather than the css engine — which `failOnUnfolded` accepts. Their unfoldable
1309
- * invocations are reported separately, as `recipe-call`.
1310
1190
  */
1311
1191
  const PERMITTED_BINDINGS = new Set([
1312
1192
  "cx",
1313
- "cva",
1314
- "sva",
1315
- RECIPE_PICK_HELPER,
1316
- SPLIT_PROPS_HELPER,
1317
- LEAF_HELPER
1193
+ RECIPE_MAP_HELPER,
1194
+ SPLIT_PROPS_HELPER
1318
1195
  ]);
1319
1196
  /**
1320
1197
  * The pieces `trim` reduces a module specifier by, hoisted because a regex literal
@@ -1542,64 +1419,7 @@ const argumentsAccountedFor = (call, boxNode) => {
1542
1419
  return accountsForSource(args[0], boxNode);
1543
1420
  };
1544
1421
  const foldSource = (options) => {
1545
- const { ctx, code, parserResult, partial: partial_ = true, runtimeCss = createRuntimeCss(ctx), parseModule, recipeConfigCache, reportSurvivors, sourceFile: ownSourceFile } = options;
1546
- /**
1547
- * Recover the static half of a call the whole-call path gave up on. Only a
1548
- * single-argument `css()` qualifies: a pattern or recipe call takes props rather than a
1549
- * style object, and a multi-argument `css` is later-wins across the whole object.
1550
- */
1551
- const tryPartial = (item, call, rootName) => {
1552
- if (item.type !== "css" || !rootName) return void 0;
1553
- if (!Node.isCallExpression(call)) return void 0;
1554
- const args = call.getArguments();
1555
- if (args.length !== 1) return void 0;
1556
- const unboxed = box.isMap(item.box) ? unbox(item.box) : void 0;
1557
- if (!unboxed?.raw) return void 0;
1558
- const raw = unboxed.raw;
1559
- if (unboxed.spreadConditions?.length) return void 0;
1560
- const argument = args[0];
1561
- if (!argument || !Node.isObjectLiteralExpression(argument)) return void 0;
1562
- const leafName = findBambooBinding(call, LEAF_HELPER, isBambooCssModule, isShadowed);
1563
- const plan_ = (allowLeaf) => {
1564
- try {
1565
- return planPartialFold(argument, item.box, raw, {
1566
- ctx,
1567
- runtimeCss,
1568
- isAccounted: accountsForSource,
1569
- isStatic: (boxNode) => isStaticBox(boxNode),
1570
- allowLeaf,
1571
- leafName: leafName ?? "cssLeaf"
1572
- });
1573
- } catch {
1574
- return;
1575
- }
1576
- };
1577
- let plan = plan_(true);
1578
- if (!plan) return void 0;
1579
- const usesLeaf = () => plan.finite.some((entry) => !entry.emitsLiterals);
1580
- let cx = ensureCxImport(call, rootName, isBambooCssModule, isGeneratedCssModule, isShadowed, usesLeaf() ? [LEAF_HELPER] : []);
1581
- if (!cx && usesLeaf()) {
1582
- plan = plan_(false);
1583
- if (!plan) return void 0;
1584
- cx = ensureCxImport(call, rootName, isBambooCssModule, isGeneratedCssModule, isShadowed);
1585
- }
1586
- if (!cx) return void 0;
1587
- const callee = call.getExpression().getText();
1588
- const runtimePart = plan.dynamicText ? `${callee}(${plan.dynamicText})` : void 0;
1589
- const runtimeParts = runtimePart ? [runtimePart] : [];
1590
- const lowered = plan.finite.map((entry) => entry.expression);
1591
- const parts = [...plan.className ? [JSON.stringify(plan.className)] : [], ...plan.finiteFirst ? [...lowered, ...runtimeParts] : [...runtimeParts, ...lowered]];
1592
- if (!parts.length || parts.length === 1 && runtimePart) return void 0;
1593
- return {
1594
- className: plan.className,
1595
- classNames: [plan.className, ...plan.finite.filter((entry) => entry.emitsLiterals).flatMap((entry) => literalsIn(entry.expression))].filter(Boolean),
1596
- replacement: `${cx.name}(${parts.join(", ")})`,
1597
- insert: cx.insert,
1598
- runtimeCallee: runtimePart ? callee : void 0
1599
- };
1600
- };
1601
- const runtimeRecipe = createRuntimeRecipe(ctx);
1602
- const isConstantSlot = createConstantSlotCheck(ctx);
1422
+ const { ctx, code, parserResult, runtimeCss = createRuntimeCss(ctx), styleCompiler, maxRecipeStates, parseModule, recipeConfigCache, reportSurvivors, sourceFile: ownSourceFile } = options;
1603
1423
  const runtimeToken = createRuntimeToken(ctx);
1604
1424
  const runtimeTokenValue = createRuntimeTokenValue(ctx);
1605
1425
  /**
@@ -1612,7 +1432,7 @@ const foldSource = (options) => {
1612
1432
  *
1613
1433
  * A tsconfig path alias is resolved first, the same way `ImportMap.match` does. Without
1614
1434
  * that, `@site/styled-system/css` — the spelling this repo's own website uses — fails
1615
- * the check and silently loses partial folding, which is indistinguishable in the
1435
+ * the check and silently loses helper lowering, which is indistinguishable in the
1616
1436
  * diagnostics from a genuinely dynamic call.
1617
1437
  */
1618
1438
  const cssModules = ctx.imports.matchers.css?.mods ?? [];
@@ -1662,10 +1482,30 @@ const foldSource = (options) => {
1662
1482
  const isBambooCssModule = (mod) => matchesModule(mod, cssModules);
1663
1483
  const isGeneratedCssModule = (mod) => matchesModule(mod, [generatedCssModule]);
1664
1484
  /**
1485
+ * The generated css entry as spelled beside an imported config recipe.
1486
+ *
1487
+ * A decision table needs only `cvaMap`, but a module importing a config recipe often has
1488
+ * no css import to extend. Preserve a relative/aliased styled-system spelling by replacing
1489
+ * its `/recipes` suffix; falling back to the configured generated entry covers bare imports.
1490
+ */
1491
+ const configRecipeCssSpecifier = (call, binding) => {
1492
+ for (const declaration of call.getSourceFile().getImportDeclarations()) {
1493
+ if (declaration.isTypeOnly()) continue;
1494
+ if (!declaration.getNamedImports().some((named) => {
1495
+ if (named.isTypeOnly()) return false;
1496
+ return (named.getAliasNode() ?? named.getNameNode()).getText() === binding;
1497
+ })) continue;
1498
+ const mod = declaration.getModuleSpecifierValue().replaceAll("\\", "/");
1499
+ const at = mod.lastIndexOf("/recipes");
1500
+ if (at >= 0) return `${mod.slice(0, at)}/css`;
1501
+ }
1502
+ return generatedCssModule;
1503
+ };
1504
+ /**
1665
1505
  * How *this* module would have to spell the css module, learnt from one that already does.
1666
1506
  *
1667
1507
  * A file calling an imported recipe need not import the css module at all, so when the
1668
- * lowering needs `cvaPick` there is no spelling in the file to copy. The declaring module
1508
+ * lowering needs a decision-table helper there is no spelling in the file to copy. The declaring module
1669
1509
  * necessarily has one — `cva` came from it — and that is the spelling reused here.
1670
1510
  *
1671
1511
  * A bare or aliased specifier resolves identically from any file, so it is taken as
@@ -1717,9 +1557,8 @@ const foldSource = (options) => {
1717
1557
  * either end. Each hop is an alias symbol, so following them to a non-alias lands on the
1718
1558
  * declaration wherever it lives.
1719
1559
  *
1720
- * The class names do not depend on which module the call is in `getRecipeIdentity` hashes
1721
- * the config so a recipe lowered here produces exactly the string its own module's call
1722
- * sites produce, and exactly the one the runtime would have.
1560
+ * The selected declarations do not depend on which module the call is in. A recipe lowered
1561
+ * here therefore reaches the same globally shared atoms as a call in its declaring module.
1723
1562
  */
1724
1563
  const resolveImportedRecipe = (call, name, origin) => {
1725
1564
  if (importedRecipes.has(name)) return importedRecipes.get(name);
@@ -1736,7 +1575,6 @@ const foldSource = (options) => {
1736
1575
  const configs = /* @__PURE__ */ new Map();
1737
1576
  for (const [key, entry] of collected) configs.set(key, entry === AMBIGUOUS ? entry : {
1738
1577
  config: entry.config,
1739
- name: entry.name,
1740
1578
  box: void 0
1741
1579
  });
1742
1580
  foreign = {
@@ -1759,21 +1597,19 @@ const foldSource = (options) => {
1759
1597
  const skipped = [];
1760
1598
  const candidates = [];
1761
1599
  const seenRanges = /* @__PURE__ */ new Set();
1762
- /** Built on first use: most modules declare no inline recipe. */
1763
- let recipeConfigs;
1764
- /**
1765
- * Per inline recipe binding: calls seen, calls lowered.
1766
- *
1767
- * A binding whose every call lowered is no longer read, so its `cva({ … })` config can leave
1768
- * the bundle which is the whole point, the config being far larger than the runtime. But a
1769
- * bundler will not drop the call on its own: `cva` closes over the config and builds an
1770
- * object, and Rollup cannot prove that is side-effect free, so it keeps the expression and
1771
- * the module ends up *larger* than before folding. The annotation below is what makes the
1772
- * saving real, and it is only correct to claim it once nothing reads the binding.
1773
- */
1774
- const recipeCalls = /* @__PURE__ */ new Map();
1775
- /** Bindings whose `splitVariantProps` was rewritten, so that access no longer reads them. */
1776
- const loweredSplitProps = /* @__PURE__ */ new Set();
1600
+ const recipeConfigs = collectRecipeConfigs(parserResult);
1601
+ const recipeDefinitions = [];
1602
+ for (const [name, entry] of recipeConfigs) {
1603
+ if (entry === AMBIGUOUS) continue;
1604
+ const definition = entry.box?.getNode?.();
1605
+ if (!definition) continue;
1606
+ const call = Node.isCallExpression(definition) ? definition : definition.getFirstAncestorByKind(SyntaxKind.CallExpression);
1607
+ if (!call || code.slice(call.getStart(), call.getEnd()) !== call.getText()) continue;
1608
+ recipeDefinitions.push({
1609
+ name,
1610
+ call
1611
+ });
1612
+ }
1777
1613
  /** Ranges already reported as declined, so one call is never counted twice. */
1778
1614
  const reportedRanges = /* @__PURE__ */ new Set();
1779
1615
  const importCache = /* @__PURE__ */ new Map();
@@ -1894,13 +1730,7 @@ const foldSource = (options) => {
1894
1730
  continue;
1895
1731
  }
1896
1732
  if (!FOLDABLE_TYPES.has(type)) {
1897
- if (call && UNFOLDABLE_TYPES.has(type)) skipped.push({
1898
- name,
1899
- reason: "not-foldable",
1900
- start: call.getStart(),
1901
- end: call.getEnd()
1902
- });
1903
- if (call && type === RECIPE_CALL_TYPE && !isShadowed(call, name)) {
1733
+ if (call && (type === RECIPE_CALL_TYPE || type === "recipe") && !isShadowed(call, name)) {
1904
1734
  const start = call.getStart();
1905
1735
  const end = call.getEnd();
1906
1736
  const rangeKey = `${start}:${end}`;
@@ -1915,35 +1745,95 @@ const foldSource = (options) => {
1915
1745
  });
1916
1746
  continue;
1917
1747
  }
1918
- recipeConfigs ??= collectRecipeConfigs(parserResult);
1919
- if (!recipeConfigs.has(name) && item.origin) {
1920
- const imported = resolveImportedRecipe(call, name, item.origin);
1921
- if (imported) recipeConfigs.set(name, imported);
1748
+ if (isRawCall(call)) {
1749
+ skipped.push({
1750
+ name,
1751
+ reason: "raw-call",
1752
+ start,
1753
+ end
1754
+ });
1755
+ continue;
1756
+ }
1757
+ if (!recipeConfigs.has(name)) {
1758
+ if (type === "recipe") {
1759
+ const config = ctx.recipes.getConfig(name);
1760
+ if (config) {
1761
+ recipeConfigs.set(name, {
1762
+ config,
1763
+ box: void 0
1764
+ });
1765
+ helperModules.set(name, configRecipeCssSpecifier(call, name));
1766
+ }
1767
+ } else if (item.origin) {
1768
+ const imported = resolveImportedRecipe(call, name, item.origin);
1769
+ if (imported) recipeConfigs.set(name, imported);
1770
+ }
1922
1771
  }
1923
- const tally = recipeCalls.get(name) ?? {
1924
- seen: 0,
1925
- lowered: 0
1926
- };
1927
- tally.seen++;
1928
- recipeCalls.set(name, tally);
1929
1772
  const resolvedSelection = item.data?.length === 1 ? item.data[0] : void 0;
1930
1773
  const entry = recipeConfigs.get(name);
1931
- const lowered = lowerRecipeCall(call, entry, ctx, isInertExpression, resolvedSelection);
1932
- if (lowered.kind === "expression") {
1933
- const helper = ensureRecipeHelperImport(RECIPE_PICK_HELPER, call, isBambooCssModule, isGeneratedCssModule, isShadowed, helperModules.get(name));
1774
+ let inlineSlot;
1775
+ let inlineEnd = end;
1776
+ if (Array.isArray(entry?.config.slots)) {
1777
+ const parent = call.getParent();
1778
+ if (Node.isPropertyAccessExpression(parent) && parent.getExpression() === call) {
1779
+ const accessed = parent.getName();
1780
+ if (entry.config.slots.includes(accessed)) {
1781
+ inlineSlot = accessed;
1782
+ inlineEnd = parent.getEnd();
1783
+ }
1784
+ } else if (Node.isElementAccessExpression(parent) && parent.getExpression() === call) {
1785
+ const argument = parent.getArgumentExpression();
1786
+ const accessed = argument && (Node.isStringLiteral(argument) || Node.isNoSubstitutionTemplateLiteral(argument)) ? argument.getLiteralValue() : void 0;
1787
+ if (typeof accessed === "string" && entry.config.slots.includes(accessed)) {
1788
+ inlineSlot = accessed;
1789
+ inlineEnd = parent.getEnd();
1790
+ }
1791
+ }
1792
+ }
1793
+ const lowered = lowerRecipeCall(call, entry, styleCompiler, isInertExpression, resolvedSelection, inlineSlot, maxRecipeStates);
1794
+ if (lowered.kind === "dynamic-style") {
1795
+ const helper = ensureRecipeHelperImport(RECIPE_MAP_HELPER, call, isBambooCssModule, isGeneratedCssModule, isShadowed, helperModules.get(name));
1934
1796
  if (helper) {
1935
- tally.lowered++;
1936
1797
  candidates.push({
1937
1798
  item,
1938
1799
  call,
1939
1800
  node: call,
1940
1801
  start,
1941
- end,
1942
- replacement: helper.name === "cvaPick" ? lowered.expression : lowered.expression.replaceAll(`${RECIPE_PICK_HELPER}(`, `${helper.name}(`),
1943
- className: lowered.staticClasses,
1944
- classNames: lowered.classNames,
1802
+ end: inlineEnd,
1803
+ className: "",
1804
+ classNames: [],
1805
+ styleMap: lowered.map,
1806
+ mapHelperName: helper.name,
1945
1807
  insert: helper.insert,
1946
- configBox: entry?.box
1808
+ configBox: entry?.box,
1809
+ outputKind: lowered.map.outputKind === "slots" ? "slots" : void 0
1810
+ });
1811
+ continue;
1812
+ }
1813
+ skipped.push({
1814
+ name,
1815
+ reason: "recipe-call",
1816
+ start,
1817
+ end
1818
+ });
1819
+ continue;
1820
+ }
1821
+ if (lowered.kind === "slots") {
1822
+ const helper = lowered.helper ? ensureRecipeHelperImport(lowered.helper, call, isBambooCssModule, isGeneratedCssModule, isShadowed, helperModules.get(name)) : void 0;
1823
+ if (!lowered.helper || helper) {
1824
+ const replacement = lowered.helper && helper && helper.name !== lowered.helper ? lowered.expression.replaceAll(`${lowered.helper}(`, `${helper.name}(`) : lowered.expression;
1825
+ candidates.push({
1826
+ item,
1827
+ call,
1828
+ node: call,
1829
+ start,
1830
+ end: inlineEnd,
1831
+ replacement,
1832
+ className: "",
1833
+ classNames: lowered.classNames,
1834
+ insert: helper?.insert,
1835
+ configBox: entry?.box,
1836
+ outputKind: "slots"
1947
1837
  });
1948
1838
  continue;
1949
1839
  }
@@ -1956,16 +1846,16 @@ const foldSource = (options) => {
1956
1846
  continue;
1957
1847
  }
1958
1848
  if (lowered.kind === "class") {
1959
- tally.lowered++;
1960
1849
  candidates.push({
1961
1850
  item,
1962
1851
  call,
1963
1852
  node: call,
1964
1853
  start,
1965
- end,
1854
+ end: inlineEnd,
1966
1855
  replacement: JSON.stringify(lowered.className),
1967
1856
  className: lowered.className,
1968
1857
  classNames: lowered.className.split(" ").filter(Boolean),
1858
+ styleSet: lowered.styles,
1969
1859
  configBox: entry?.box
1970
1860
  });
1971
1861
  continue;
@@ -2029,37 +1919,7 @@ const foldSource = (options) => {
2029
1919
  });
2030
1920
  continue;
2031
1921
  }
2032
- if (slot && isConstantSlot(name, slot) && Node.isCallExpression(call) && call.getArguments().every(isInertExpression)) {
2033
- candidates.push({
2034
- item,
2035
- call,
2036
- node: call,
2037
- start,
2038
- end: foldEnd,
2039
- slot,
2040
- constantSlot: true
2041
- });
2042
- continue;
2043
- }
2044
1922
  if (!(Node.isCallExpression(call) && call.getArguments().length === 0 && item.data.length === 1) && !isStaticBox(item.box) || !hasStyles(item.data) || !argumentsAccountedFor(call, item.box)) {
2045
- const partial = partial_ ? tryPartial(item, call, rootName) : void 0;
2046
- if (partial) {
2047
- if (reportSurvivors && partial.runtimeCallee) skipped.push({
2048
- name,
2049
- reason: "runtime-binding",
2050
- start,
2051
- end
2052
- });
2053
- candidates.push({
2054
- item,
2055
- call,
2056
- node: call,
2057
- start,
2058
- end,
2059
- ...partial
2060
- });
2061
- continue;
2062
- }
2063
1923
  skipped.push({
2064
1924
  name,
2065
1925
  reason: "dynamic",
@@ -2078,12 +1938,220 @@ const foldSource = (options) => {
2078
1938
  });
2079
1939
  }
2080
1940
  /**
1941
+ * Resolve every fully static candidate to symbolic declarations before allocating a class.
1942
+ *
1943
+ * The normal fold can wait until the rewrite loop to compute a class string. Semantic
1944
+ * composition cannot: an enclosing `cx()` needs the declarations of its arguments so it can
1945
+ * discard overridden values before any string exists.
1946
+ */
1947
+ {
1948
+ for (const candidate of candidates) {
1949
+ if (candidate.styleSet || candidate.value !== void 0 || candidate.replacement) continue;
1950
+ const { item } = candidate;
1951
+ if (item.type === "css") {
1952
+ candidate.styleSet = styleCompiler.compose(...item.data);
1953
+ continue;
1954
+ }
1955
+ if (item.type === "pattern") {
1956
+ candidate.styleSet = styleCompiler.compose(...item.data.map((entry) => ctx.patterns.transform(item.name ?? "", entry)));
1957
+ continue;
1958
+ }
1959
+ if (item.type === "viewTransition") {
1960
+ const semantic = viewTransitionClassName(item.data[0], ctx.utility.prefix);
1961
+ candidate.className = styleCompiler.allocateClassString(semantic);
1962
+ candidate.classNames = [candidate.className];
1963
+ candidate.replacement = JSON.stringify(candidate.className);
1964
+ continue;
1965
+ }
1966
+ }
1967
+ const sourceFile = ownSourceFile ?? candidates[0]?.node.getSourceFile();
1968
+ if (sourceFile) {
1969
+ const cxBindings = /* @__PURE__ */ new Set();
1970
+ for (const declaration of sourceFile.getImportDeclarations()) {
1971
+ if (declaration.isTypeOnly() || !isBambooCssModule(declaration.getModuleSpecifierValue())) continue;
1972
+ for (const named of declaration.getNamedImports()) {
1973
+ if (named.isTypeOnly() || named.getNameNode().getText() !== "cx") continue;
1974
+ cxBindings.add((named.getAliasNode() ?? named.getNameNode()).getText());
1975
+ }
1976
+ }
1977
+ const byRange = new Map(candidates.map((candidate) => [`${candidate.start}:${candidate.end}`, candidate]));
1978
+ for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
1979
+ const callee = call.getExpression();
1980
+ if (!Node.isIdentifier(callee) || !cxBindings.has(callee.getText()) || isShadowed(call, callee.getText())) continue;
1981
+ const matched = [];
1982
+ const parts = [];
1983
+ const dynamic = [];
1984
+ const constantCandidates = [];
1985
+ let supported = true;
1986
+ const take = (arg) => {
1987
+ const candidate = byRange.get(`${arg.getStart()}:${arg.getEnd()}`);
1988
+ if (candidate?.styleMap?.outputKind === "class") {
1989
+ dynamic.push(candidate);
1990
+ parts.push({
1991
+ kind: "dynamic",
1992
+ candidate
1993
+ });
1994
+ return true;
1995
+ }
1996
+ if (candidate?.styleSet) {
1997
+ matched.push(candidate);
1998
+ parts.push({
1999
+ kind: "style",
2000
+ candidate
2001
+ });
2002
+ return true;
2003
+ }
2004
+ if (candidate?.item.type === "viewTransition" && candidate.replacement && candidate.className) {
2005
+ constantCandidates.push(candidate);
2006
+ parts.push({
2007
+ kind: "class",
2008
+ value: candidate.className,
2009
+ candidate
2010
+ });
2011
+ return true;
2012
+ }
2013
+ if (Node.isStringLiteral(arg) || Node.isNoSubstitutionTemplateLiteral(arg)) {
2014
+ parts.push({
2015
+ kind: "class",
2016
+ value: arg.getLiteralValue()
2017
+ });
2018
+ return true;
2019
+ }
2020
+ if (Node.isArrayLiteralExpression(arg)) {
2021
+ for (const element of arg.getElements()) if (Node.isSpreadElement(element) || !take(element)) return false;
2022
+ return true;
2023
+ }
2024
+ if (arg.getKind() === SyntaxKind.FalseKeyword || arg.getKind() === SyntaxKind.TrueKeyword || Node.isNumericLiteral(arg) || arg.getKind() === SyntaxKind.NullKeyword || Node.isIdentifier(arg) && arg.getText() === "undefined") return true;
2025
+ return false;
2026
+ };
2027
+ for (const arg of call.getArguments()) {
2028
+ if (take(arg)) continue;
2029
+ supported = false;
2030
+ break;
2031
+ }
2032
+ if (dynamic.length > 1) {
2033
+ skipped.push({
2034
+ name: "cx",
2035
+ reason: "dynamic",
2036
+ start: call.getStart(),
2037
+ end: call.getEnd()
2038
+ });
2039
+ continue;
2040
+ }
2041
+ if (!supported) {
2042
+ skipped.push({
2043
+ name: "cx",
2044
+ reason: "dynamic",
2045
+ start: call.getStart(),
2046
+ end: call.getEnd()
2047
+ });
2048
+ continue;
2049
+ }
2050
+ if (dynamic.length === 1 && matched.length > 0) {
2051
+ const dynamicCandidate = dynamic[0];
2052
+ const styleParts = parts.filter((part) => part.kind !== "class");
2053
+ const dynamicIndex = styleParts.findIndex((part) => part.kind === "dynamic");
2054
+ const before = styleParts.slice(0, dynamicIndex).filter((part) => part.kind === "style").map((part) => part.candidate.styleSet);
2055
+ const after = styleParts.slice(dynamicIndex + 1).filter((part) => part.kind === "style").map((part) => part.candidate.styleSet);
2056
+ const compiled = dynamicCandidate.styleMap.compile(before, after);
2057
+ const expression = compiled.usesHelper && dynamicCandidate.mapHelperName && dynamicCandidate.mapHelperName !== "cvaMap" ? compiled.expression.replaceAll(`${RECIPE_MAP_HELPER}(`, `${dynamicCandidate.mapHelperName}(`) : compiled.expression;
2058
+ const arguments_ = [];
2059
+ let wroteCompiled = false;
2060
+ for (const part of parts) {
2061
+ if (part.kind === "class") {
2062
+ if (part.value) arguments_.push(JSON.stringify(part.value));
2063
+ continue;
2064
+ }
2065
+ if (!wroteCompiled) {
2066
+ arguments_.push(expression);
2067
+ wroteCompiled = true;
2068
+ }
2069
+ }
2070
+ dynamicCandidate.subsumed = true;
2071
+ const first = styleParts[0].candidate;
2072
+ candidates.push({
2073
+ ...first,
2074
+ call,
2075
+ node: call,
2076
+ start: call.getStart(),
2077
+ end: call.getEnd(),
2078
+ displayName: "cx",
2079
+ replacement: arguments_.length === 1 ? arguments_[0] : `${callee.getText()}(${arguments_.join(", ")})`,
2080
+ className: "",
2081
+ classNames: [...compiled.classNames, ...parts.filter((part) => part.kind === "class").flatMap((part) => part.value.split(" "))].filter(Boolean),
2082
+ styleSet: void 0,
2083
+ styleMap: void 0,
2084
+ outputKind: void 0,
2085
+ insert: compiled.usesHelper ? dynamicCandidate.insert : void 0,
2086
+ sourceBoxes: styleParts.flatMap((part) => [part.candidate.item.box, part.candidate.configBox]).concat(constantCandidates.map((candidate) => candidate.item.box)).filter(Boolean)
2087
+ });
2088
+ continue;
2089
+ }
2090
+ if (matched.length === 0) {
2091
+ if (constantCandidates.length === 0) continue;
2092
+ const className = parts.filter((part) => part.kind === "class").map((part) => part.value).filter(Boolean).join(" ");
2093
+ const first = constantCandidates[0];
2094
+ candidates.push({
2095
+ ...first,
2096
+ call,
2097
+ node: call,
2098
+ start: call.getStart(),
2099
+ end: call.getEnd(),
2100
+ displayName: "cx",
2101
+ replacement: JSON.stringify(className),
2102
+ className,
2103
+ classNames: className.split(" ").filter(Boolean),
2104
+ sourceBoxes: constantCandidates.map((candidate) => candidate.item.box).filter(Boolean)
2105
+ });
2106
+ continue;
2107
+ }
2108
+ const merged = styleCompiler.compose(...matched.map((candidate) => candidate.styleSet));
2109
+ const compiled = styleCompiler.className(merged);
2110
+ const classParts = [];
2111
+ let wroteCompiled = false;
2112
+ for (const part of parts) {
2113
+ if (part.kind === "class") {
2114
+ if (part.value) classParts.push(part.value);
2115
+ continue;
2116
+ }
2117
+ if (!wroteCompiled && compiled) {
2118
+ classParts.push(compiled);
2119
+ wroteCompiled = true;
2120
+ }
2121
+ }
2122
+ const first = matched[0];
2123
+ candidates.push({
2124
+ ...first,
2125
+ call,
2126
+ node: call,
2127
+ start: call.getStart(),
2128
+ end: call.getEnd(),
2129
+ displayName: "cx",
2130
+ replacement: JSON.stringify(classParts.join(" ")),
2131
+ className: classParts.join(" "),
2132
+ classNames: classParts.flatMap((part) => part.split(" ")).filter(Boolean),
2133
+ styleSet: merged,
2134
+ sourceBoxes: [...matched.flatMap((candidate) => [candidate.item.box, candidate.configBox]), ...constantCandidates.map((candidate) => candidate.item.box)].filter(Boolean)
2135
+ });
2136
+ }
2137
+ }
2138
+ for (const candidate of candidates) {
2139
+ if (!candidate.styleMap || candidate.subsumed || candidate.replacement) continue;
2140
+ const compiled = candidate.styleMap.compile();
2141
+ candidate.replacement = compiled.usesHelper && candidate.mapHelperName && candidate.mapHelperName !== "cvaMap" ? compiled.expression.replaceAll(`${RECIPE_MAP_HELPER}(`, `${candidate.mapHelperName}(`) : compiled.expression;
2142
+ if (!compiled.usesHelper) candidate.insert = void 0;
2143
+ candidate.className = compiled.staticClasses;
2144
+ candidate.classNames = compiled.classNames;
2145
+ candidate.outputKind = compiled.outputKind === "slots" ? "slots" : void 0;
2146
+ }
2147
+ }
2148
+ /**
2081
2149
  * Ranges the rewrite actually replaced. Declared before the early return below, because
2082
2150
  * that return is now also a reporting point: a module with nothing to fold is exactly the
2083
2151
  * shape `reportSurvivors` exists to catch.
2084
2152
  */
2085
2153
  const applied = [];
2086
- if (candidates.length === 0) {
2154
+ if (candidates.length === 0 && recipeDefinitions.length === 0) {
2087
2155
  if (reportSurvivors) reportRuntimeBindings();
2088
2156
  return {
2089
2157
  code,
@@ -2093,7 +2161,15 @@ const foldSource = (options) => {
2093
2161
  dependencies: []
2094
2162
  };
2095
2163
  }
2096
- const dependencyScan = createDependencyScan(candidates[0].node.getSourceFile());
2164
+ const rewriteSourceFile = ownSourceFile ?? candidates[0]?.node.getSourceFile() ?? recipeDefinitions[0]?.call.getSourceFile();
2165
+ if (!rewriteSourceFile) return {
2166
+ code,
2167
+ map: null,
2168
+ folded,
2169
+ skipped,
2170
+ dependencies: []
2171
+ };
2172
+ const dependencyScan = createDependencyScan(rewriteSourceFile);
2097
2173
  candidates.sort((a, b) => a.start - b.start || b.end - a.end);
2098
2174
  const magic = new MagicString(code);
2099
2175
  const insertedNames = /* @__PURE__ */ new Set();
@@ -2107,7 +2183,7 @@ const foldSource = (options) => {
2107
2183
  const collides = (edits) => edits.some(([start, end]) => applied.some(([from, to]) => start < to && from < end));
2108
2184
  for (const candidate of candidates) {
2109
2185
  const { item, start, end } = candidate;
2110
- const name = item.name ?? item.type ?? "";
2186
+ const name = candidate.displayName ?? item.name ?? item.type ?? "";
2111
2187
  const ranges = [[start, end]];
2112
2188
  if (collides(ranges)) {
2113
2189
  skipped.push({
@@ -2139,7 +2215,7 @@ const foldSource = (options) => {
2139
2215
  applied.push(...ranges);
2140
2216
  folded.push({
2141
2217
  name,
2142
- kind: "class",
2218
+ kind: candidate.outputKind ?? "class",
2143
2219
  className: candidate.className,
2144
2220
  classNames: (candidate.classNames ?? [candidate.className]).filter(Boolean),
2145
2221
  start,
@@ -2147,24 +2223,13 @@ const foldSource = (options) => {
2147
2223
  });
2148
2224
  collectSourceFiles(item.box, dependencyScan);
2149
2225
  if (candidate.configBox) collectSourceFiles(candidate.configBox, dependencyScan);
2226
+ for (const box of candidate.sourceBoxes ?? []) collectSourceFiles(box, dependencyScan);
2150
2227
  continue;
2151
2228
  }
2152
2229
  let className;
2153
2230
  try {
2154
2231
  if (item.type === "pattern") className = runtimeCss(...item.data.map((entry) => ctx.patterns.transform(name, entry)));
2155
- else if (item.type === "recipe") {
2156
- const resolved = candidate.constantSlot ? runtimeRecipe(name, {}, candidate.slot) : item.data.length === 1 ? runtimeRecipe(name, item.data[0], candidate.slot) : void 0;
2157
- if (resolved == null) {
2158
- skipped.push({
2159
- name,
2160
- reason: "unsupported-kind",
2161
- start,
2162
- end
2163
- });
2164
- continue;
2165
- }
2166
- className = resolved;
2167
- } else className = runtimeCss(...item.data);
2232
+ else className = runtimeCss(...item.data);
2168
2233
  } catch {
2169
2234
  skipped.push({
2170
2235
  name,
@@ -2174,22 +2239,13 @@ const foldSource = (options) => {
2174
2239
  });
2175
2240
  continue;
2176
2241
  }
2177
- if (!className) {
2178
- skipped.push({
2179
- name,
2180
- reason: "empty",
2181
- start,
2182
- end
2183
- });
2184
- continue;
2185
- }
2186
2242
  magic.overwrite(start, end, JSON.stringify(className));
2187
2243
  applied.push(...ranges);
2188
2244
  folded.push({
2189
2245
  name,
2190
2246
  kind: "class",
2191
2247
  className,
2192
- classNames: [className],
2248
+ classNames: className ? [className] : [],
2193
2249
  start,
2194
2250
  end
2195
2251
  });
@@ -2205,7 +2261,6 @@ const foldSource = (options) => {
2205
2261
  const importedConfig = !local && importsFor(recipeSourceFile).has(target.getText()) ? ctx.recipes.getConfig(target.getText()) : void 0;
2206
2262
  const entry = local && local !== AMBIGUOUS ? local : importedConfig ? {
2207
2263
  config: importedConfig,
2208
- name: "",
2209
2264
  box: void 0
2210
2265
  } : void 0;
2211
2266
  if (!entry) continue;
@@ -2223,17 +2278,21 @@ const foldSource = (options) => {
2223
2278
  magic.overwrite(start, end, `${helper.name}(${args[0].getText()}, ${JSON.stringify(keys)})`);
2224
2279
  applyInsert(helper.insert);
2225
2280
  applied.push([start, end]);
2226
- loweredSplitProps.add(target.getText());
2227
2281
  }
2228
- for (const [binding, tally] of recipeCalls) {
2229
- if (!tally.seen || tally.lowered !== tally.seen) continue;
2230
- const definition = recipeConfigs?.get(binding)?.box?.getNode?.();
2231
- if (!definition) continue;
2232
- const call = Node.isCallExpression(definition) ? definition : definition.getFirstAncestorByKind(SyntaxKind.CallExpression);
2233
- if (!call) continue;
2282
+ for (const { name, call } of recipeDefinitions) {
2234
2283
  const start = call.getStart();
2235
- if (code.slice(start, call.getEnd()) !== call.getText()) continue;
2236
- magic.appendLeft(start, "/*#__PURE__*/");
2284
+ const end = call.getEnd();
2285
+ if (collides([[start, end]])) continue;
2286
+ magic.overwrite(start, end, "undefined");
2287
+ applied.push([start, end]);
2288
+ folded.push({
2289
+ name,
2290
+ kind: "definition",
2291
+ className: "",
2292
+ classNames: [],
2293
+ start,
2294
+ end
2295
+ });
2237
2296
  }
2238
2297
  /**
2239
2298
  * Bindings from a bamboo module still referenced once every rewrite is applied.
@@ -2241,21 +2300,65 @@ const foldSource = (options) => {
2241
2300
  * Deliberately not driven by `parserResult`: that is the recogniser, and the point here is
2242
2301
  * to catch what it did not see. A namespace import called as `s.cva(...)`, a default
2243
2302
  * import, a specifier that resolved to nothing — each leaves a live reference and no ledger
2244
- * entry at all, which is how `failOnUnfolded` came to pass a build that still shipped the engine.
2303
+ * entry at all, which used to let a build silently ship the engine.
2245
2304
  *
2246
- * The helpers the fold itself writes are excluded: `cx`, `cvaPick`, `splitProps` and the
2247
- * leaf helper live in `cx` and pull no engine, so a reference to one is the fold working
2248
- * rather than failing.
2305
+ * The helpers the compiler writes are excluded because they pull no style engine. `cx` is
2306
+ * also allowed to remain when it joins an arbitrary external class; only fully analyzable
2307
+ * arguments receive Bamboo's semantic composition guarantee.
2249
2308
  */
2250
2309
  function reportRuntimeBindings() {
2251
- const sourceFile = ownSourceFile ?? candidates[0]?.node.getSourceFile();
2310
+ const sourceFile = ownSourceFile ?? candidates[0]?.node.getSourceFile() ?? recipeDefinitions[0]?.call.getSourceFile();
2252
2311
  if (!sourceFile) return;
2312
+ for (const [binding, entry] of recipeConfigs) {
2313
+ if (entry === AMBIGUOUS) continue;
2314
+ const definition = entry.box?.getNode?.();
2315
+ if (!definition || definition.getSourceFile() !== sourceFile) continue;
2316
+ const nameNode = definition.getFirstAncestorByKind(SyntaxKind.VariableDeclaration)?.getNameNode();
2317
+ if (!nameNode || !Node.isIdentifier(nameNode)) continue;
2318
+ if (skipped.filter((item) => SURVIVES_TO_RUNTIME.has(item.reason) && item.end > item.start).some((item) => nameNode.findReferencesAsNodes().some((ref) => ref.getStart() >= item.start && ref.getStart() < item.end))) continue;
2319
+ const survivor = nameNode.findReferencesAsNodes().find((ref) => !applied.some(([from, to]) => ref.getStart() >= from && ref.getStart() < to));
2320
+ if (!survivor) continue;
2321
+ skipped.push({
2322
+ name: binding,
2323
+ reason: "runtime-binding",
2324
+ start: survivor.getStart(),
2325
+ end: survivor.getEnd()
2326
+ });
2327
+ }
2253
2328
  const bambooModules = [
2254
2329
  ...cssModules,
2255
2330
  ...ctx.imports.matchers.recipe?.mods ?? [],
2256
2331
  ...ctx.imports.matchers.pattern?.mods ?? [],
2257
2332
  ...ctx.imports.matchers.tokens?.mods ?? []
2258
2333
  ];
2334
+ for (const call of sourceFile.getDescendantsOfKind(SyntaxKind.CallExpression)) {
2335
+ const callee = call.getExpression();
2336
+ const argument = call.getArguments()[0];
2337
+ if (!argument || !Node.isStringLiteral(argument) && !Node.isNoSubstitutionTemplateLiteral(argument)) continue;
2338
+ if (!matchesModule(argument.getLiteralValue(), bambooModules)) continue;
2339
+ const isDynamicImport = callee.getKind() === SyntaxKind.ImportKeyword;
2340
+ const isRequire = Node.isIdentifier(callee) && callee.getText() === "require" && !isShadowed(call, "require");
2341
+ if (!isDynamicImport && !isRequire) continue;
2342
+ skipped.push({
2343
+ name: isDynamicImport ? "import" : "require",
2344
+ reason: "runtime-binding",
2345
+ start: call.getStart(),
2346
+ end: call.getEnd()
2347
+ });
2348
+ }
2349
+ for (const declaration of sourceFile.getDescendantsOfKind(SyntaxKind.ImportEqualsDeclaration)) {
2350
+ if (declaration.isTypeOnly()) continue;
2351
+ const reference = declaration.getModuleReference();
2352
+ if (!Node.isExternalModuleReference(reference)) continue;
2353
+ const expression = reference.getExpression();
2354
+ if (!expression || !Node.isStringLiteral(expression) || !matchesModule(expression.getLiteralValue(), bambooModules)) continue;
2355
+ skipped.push({
2356
+ name: declaration.getName(),
2357
+ reason: "runtime-binding",
2358
+ start: declaration.getStart(),
2359
+ end: declaration.getEnd()
2360
+ });
2361
+ }
2259
2362
  /** Local name -> what to call it in the report. */
2260
2363
  const watched = /* @__PURE__ */ new Map();
2261
2364
  for (const declaration of sourceFile.getImportDeclarations()) {
@@ -2353,6 +2456,122 @@ const foldSource = (options) => {
2353
2456
  };
2354
2457
  };
2355
2458
  //#endregion
2459
+ //#region src/style-set.ts
2460
+ const isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
2461
+ /** A compound selector matches only through variant classes the recipe actually emits. */
2462
+ const matchesCompound = (compound, selection, variants) => {
2463
+ for (const [key, expected] of Object.entries(compound)) {
2464
+ if (key === "css") continue;
2465
+ const declared = variants?.[key];
2466
+ const selected = selection[key];
2467
+ if (selected == null || !declared || !Object.hasOwn(declared, String(selected))) return false;
2468
+ if (!(Array.isArray(expected) ? expected : [expected]).some((value) => value != null && String(selected) === String(value))) return false;
2469
+ }
2470
+ return true;
2471
+ };
2472
+ /**
2473
+ * Resolve the style fragments one recipe call contributes, in emitted-rule precedence.
2474
+ *
2475
+ * This intentionally rejects conditional variant *selections*. A scalar selects a style
2476
+ * object; an object such as `{ base: 'sm', md: 'lg' }` selects several objects under
2477
+ * conditions and needs a separate lowering. Returning `undefined` rejects that call instead
2478
+ * of silently compiling only one branch.
2479
+ */
2480
+ const createStaticStyleSetCompiler = (ctx, runtimeCss, allocateClassString = (className) => className) => {
2481
+ const { mergeCssUncached } = createMergeCss(createCssContext(ctx));
2482
+ const compose = (...styles) => mergeCssUncached(...styles);
2483
+ const resolveRecipe = (config, input = {}, slot) => {
2484
+ const slots = Array.isArray(config.slots) ? config.slots : void 0;
2485
+ if (Boolean(slots) !== Boolean(slot)) return void 0;
2486
+ if (slot && !slots?.includes(slot)) return void 0;
2487
+ const selection = {
2488
+ ...config.defaultVariants ?? {},
2489
+ ...compact(input)
2490
+ };
2491
+ if (Object.values(selection).some((value) => isRecord(value))) return void 0;
2492
+ const fragments = [];
2493
+ const take = (candidate) => {
2494
+ if (!isRecord(candidate)) return;
2495
+ const styles = slot ? candidate[slot] : candidate;
2496
+ if (isRecord(styles)) fragments.push(styles);
2497
+ };
2498
+ take(config.base);
2499
+ for (const variant of Object.keys(config.variants ?? {})) {
2500
+ const value = selection[variant];
2501
+ if (value == null) continue;
2502
+ take(config.variants?.[variant]?.[String(value)]);
2503
+ }
2504
+ for (const compound of config.compoundVariants ?? []) {
2505
+ if (!isRecord(compound) || !matchesCompound(compound, selection, config.variants)) continue;
2506
+ take(compound.css);
2507
+ }
2508
+ return compose(...fragments);
2509
+ };
2510
+ return {
2511
+ compose,
2512
+ resolveRecipe,
2513
+ className: (...styles) => runtimeCss(...styles),
2514
+ allocateClassString
2515
+ };
2516
+ };
2517
+ //#endregion
2518
+ //#region src/static-session.ts
2519
+ const ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
2520
+ const denseName = (index) => {
2521
+ let value = index;
2522
+ let result = "";
2523
+ do {
2524
+ result = ALPHABET[value % 52] + result;
2525
+ value = Math.floor(value / 52) - 1;
2526
+ } while (value >= 0);
2527
+ return `_${result}`;
2528
+ };
2529
+ const createStaticCompilationSession = (denseClassNames = true) => {
2530
+ const mode = denseClassNames === true ? "stable" : denseClassNames;
2531
+ const session = {
2532
+ utilityLayer: "utilities",
2533
+ sourcemap: false,
2534
+ cssLoaded: false,
2535
+ transformedFiles: /* @__PURE__ */ new Set(),
2536
+ extractedFiles: /* @__PURE__ */ new Set(),
2537
+ prunableClasses: /* @__PURE__ */ new Set(),
2538
+ viewTransitionClasses: /* @__PURE__ */ new Set(),
2539
+ usedClasses: /* @__PURE__ */ new Set(),
2540
+ denseClasses: /* @__PURE__ */ new Map(),
2541
+ semanticClasses: /* @__PURE__ */ new Map(),
2542
+ denseClassNames: Boolean(mode),
2543
+ allocateClassString(className) {
2544
+ if (!mode) return className;
2545
+ return className.split(" ").filter(Boolean).map((semantic) => {
2546
+ let dense = session.denseClasses.get(semantic);
2547
+ if (!dense) {
2548
+ dense = mode === "local" ? denseName(session.denseClasses.size) : `_${toHash(semantic)}`;
2549
+ const collision = session.semanticClasses.get(dense);
2550
+ if (collision && collision !== semantic) throw new Error(`Bamboo compact class collision between ${JSON.stringify(collision)} and ${JSON.stringify(semantic)}. Disable \`denseClassNames\` for this build.`);
2551
+ session.denseClasses.set(semantic, dense);
2552
+ session.semanticClasses.set(dense, semantic);
2553
+ }
2554
+ return dense;
2555
+ }).join(" ");
2556
+ },
2557
+ markClassUsed(className) {
2558
+ const semantic = session.semanticClasses.get(className) ?? className;
2559
+ session.usedClasses.add(esc(semantic));
2560
+ }
2561
+ };
2562
+ return session;
2563
+ };
2564
+ const resetStaticCompilationSession = (session) => {
2565
+ session.cssLoaded = false;
2566
+ session.transformedFiles.clear();
2567
+ session.extractedFiles.clear();
2568
+ session.prunableClasses.clear();
2569
+ session.viewTransitionClasses.clear();
2570
+ session.usedClasses.clear();
2571
+ session.denseClasses.clear();
2572
+ session.semanticClasses.clear();
2573
+ };
2574
+ //#endregion
2356
2575
  //#region src/plugin.ts
2357
2576
  const DEFAULT_EXTENSIONS = /\.(?:[cm]?[jt]sx?)$/;
2358
2577
  const NODE_MODULES = /node_modules/;
@@ -2395,15 +2614,17 @@ const formatSkipped = (id, skipped) => {
2395
2614
  *
2396
2615
  * Two plugins, because they do unrelated jobs on different schedules. The first emits the
2397
2616
  * stylesheet as a virtual module and runs in dev and build alike — that is the integration,
2398
- * and nothing styles without it. The second is the optional build-time fold.
2617
+ * and nothing styles without it. The second compiles every Bamboo source call in both dev
2618
+ * and build; there is no runtime styling fallback.
2399
2619
  *
2400
- * The fold runs with `enforce: 'pre'` so it sees module source as close as possible to what
2620
+ * The compiler runs with `enforce: 'pre'` so it sees module source as close as possible to what
2401
2621
  * the CSS extractor reads off disk. A plugin that rewrites style calls before bamboo
2402
2622
  * sees them would otherwise make the two disagree, and a folded class could end up
2403
2623
  * with no matching rule.
2404
2624
  */
2405
2625
  const bamboocss = (options = {}) => {
2406
- const { transform = true, partial, configPath, cwd, reportSkipped = false, reportSummary = true, failOnUnfolded = false } = options;
2626
+ const { configPath, cwd, reportSkipped = false, reportSummary = true, denseClassNames = true, maxRecipeStates } = options;
2627
+ if (maxRecipeStates !== void 0 && (!Number.isSafeInteger(maxRecipeStates) || maxRecipeStates < 1)) throw new Error("bamboocss: `maxRecipeStates` must be a positive safe integer.");
2407
2628
  /** Totals across the build, for the summary. */
2408
2629
  const totals = {
2409
2630
  folded: 0,
@@ -2411,8 +2632,35 @@ const bamboocss = (options = {}) => {
2411
2632
  filesWithFolds: 0,
2412
2633
  skipped: /* @__PURE__ */ new Map()
2413
2634
  };
2414
- /** Under `failOnUnfolded`, every call that would still reach the runtime. */
2635
+ const staticSession = createStaticCompilationSession(denseClassNames);
2415
2636
  const survivors = [];
2637
+ const survivorKeys = /* @__PURE__ */ new Set();
2638
+ const addSurvivor = (entry) => {
2639
+ const key = `${entry.file}:${entry.line}:${entry.name}:${entry.reason}`;
2640
+ if (survivorKeys.has(key)) return;
2641
+ survivorKeys.add(key);
2642
+ survivors.push(entry);
2643
+ };
2644
+ const clearSurvivorsFor = (file) => {
2645
+ for (let index = survivors.length - 1; index >= 0; index--) if (survivors[index]?.file === file) survivors.splice(index, 1);
2646
+ survivorKeys.clear();
2647
+ for (const entry of survivors) survivorKeys.add(`${entry.file}:${entry.line}:${entry.name}:${entry.reason}`);
2648
+ };
2649
+ const createSurvivorError = (entries) => {
2650
+ const byFile = /* @__PURE__ */ new Map();
2651
+ for (const entry of entries) {
2652
+ const list = byFile.get(entry.file) ?? [];
2653
+ list.push(entry);
2654
+ byFile.set(entry.file, list);
2655
+ }
2656
+ const named = (entry) => entry.reason === "runtime-binding" || entry.reason === "compile-failed" ? entry.name : `${entry.name}()`;
2657
+ const detail = truncateList(Array.from(byFile.entries(), ([file, fileEntries]) => [` ${file}`, ...fileEntries.map((entry) => ` ${entry.line}: ${named(entry)} — ${entry.reason}`)].join("\n")), {
2658
+ unit: "file",
2659
+ separator: "\n"
2660
+ });
2661
+ const threw = entries.some((entry) => entry.reason === "compile-failed");
2662
+ return /* @__PURE__ */ new Error(`bamboocss: ${entries.length} call(s) could not be compiled.\n\n${detail}\n\n` + (threw ? "`compile-failed` is a module the compiler threw on — see the error logged for it above. Nothing was established about its calls either way.\n\n" : "") + "Bamboo emits no runtime styling fallback or recipe layer. Make the values finite and statically analyzable, move variation into declared recipe variants, or safelist intentional dynamic classes with `staticCss`.");
2663
+ };
2416
2664
  /**
2417
2665
  * Recipe configs read out of modules other than the one being transformed.
2418
2666
  *
@@ -2422,6 +2670,8 @@ const bamboocss = (options = {}) => {
2422
2670
  const recipeConfigCache = /* @__PURE__ */ new Map();
2423
2671
  let ctx;
2424
2672
  let runtimeCss;
2673
+ let styleCompiler;
2674
+ let command = "build";
2425
2675
  let setup;
2426
2676
  const ensureContext = async () => {
2427
2677
  if (!setup) setup = loadConfigAndCreateContext({
@@ -2429,25 +2679,31 @@ const bamboocss = (options = {}) => {
2429
2679
  cwd
2430
2680
  }).then((loaded) => {
2431
2681
  ctx = loaded;
2432
- runtimeCss = createRuntimeCss(loaded);
2682
+ const semanticCss = createRuntimeCss(loaded);
2683
+ runtimeCss = (...styles) => staticSession.allocateClassString(semanticCss(...styles));
2684
+ styleCompiler = createStaticStyleSetCompiler(loaded, runtimeCss, staticSession.allocateClassString);
2433
2685
  });
2434
2686
  await setup;
2435
2687
  };
2436
2688
  return [bamboocssCss({
2437
2689
  configPath,
2438
- cwd
2690
+ cwd,
2691
+ session: staticSession
2439
2692
  }), {
2440
- name: "bamboocss:fold",
2693
+ name: "bamboocss:compiler",
2441
2694
  enforce: "pre",
2442
- apply: "build",
2695
+ configResolved(config) {
2696
+ command = config.command;
2697
+ },
2443
2698
  async buildStart() {
2444
- if (!transform) return;
2445
2699
  totals.folded = 0;
2446
2700
  totals.files = 0;
2447
2701
  totals.filesWithFolds = 0;
2448
2702
  totals.skipped.clear();
2449
2703
  survivors.length = 0;
2704
+ survivorKeys.clear();
2450
2705
  recipeConfigCache.clear();
2706
+ resetStaticCompilationSession(staticSession);
2451
2707
  await ensureContext();
2452
2708
  },
2453
2709
  /**
@@ -2471,7 +2727,7 @@ const bamboocss = (options = {}) => {
2471
2727
  * the parser still holds the file.
2472
2728
  */
2473
2729
  watchChange(id, change) {
2474
- if (!transform || !ctx) return;
2730
+ if (!ctx) return;
2475
2731
  if (!shouldTransform(id)) return;
2476
2732
  const [filePath] = id.split("?");
2477
2733
  if (!filePath) return;
@@ -2483,96 +2739,88 @@ const bamboocss = (options = {}) => {
2483
2739
  ctx.project.reloadSourceFile(filePath);
2484
2740
  },
2485
2741
  async transform(code, id) {
2486
- if (!transform) return null;
2487
2742
  if (!shouldTransform(id)) return null;
2488
2743
  await ensureContext();
2489
- if (!ctx || !runtimeCss) return null;
2744
+ if (!ctx || !runtimeCss || !styleCompiler) return null;
2490
2745
  const [filePath] = id.split("?");
2746
+ clearSurvivorsFor(filePath);
2491
2747
  if (isGeneratedOutput(filePath, ctx)) return null;
2492
2748
  let result;
2493
2749
  try {
2494
2750
  const sourceFile = ctx.project.addSourceFile(filePath, code);
2495
2751
  const parserResult = ctx.project.parseSourceFile(filePath);
2496
- if (!parserResult || parserResult.isEmpty() && !failOnUnfolded) return null;
2752
+ if (!parserResult) return null;
2497
2753
  result = foldSource({
2498
2754
  ctx,
2499
2755
  code,
2500
2756
  parserResult,
2501
2757
  filePath,
2502
2758
  runtimeCss,
2503
- partial,
2759
+ styleCompiler,
2760
+ maxRecipeStates,
2504
2761
  parseModule: (path) => ctx?.project.parseSourceFile(path),
2505
2762
  recipeConfigCache,
2506
- reportSurvivors: failOnUnfolded,
2763
+ reportSurvivors: true,
2507
2764
  sourceFile
2508
2765
  });
2509
2766
  } catch (error) {
2510
- logger.caughtError("vite:transform", `Failed to fold ${filePath}`, error);
2767
+ logger.caughtError("vite:transform", `Failed to compile ${filePath}`, error);
2511
2768
  totals.files++;
2512
- totals.skipped.set("fold-failed", (totals.skipped.get("fold-failed") ?? 0) + 1);
2513
- if (failOnUnfolded) survivors.push({
2769
+ totals.skipped.set("compile-failed", (totals.skipped.get("compile-failed") ?? 0) + 1);
2770
+ addSurvivor({
2514
2771
  file: filePath,
2515
2772
  line: 1,
2516
- name: "fold",
2517
- reason: "fold-failed"
2773
+ name: "compiler",
2774
+ reason: "compile-failed"
2518
2775
  });
2776
+ if (command === "serve") throw error;
2519
2777
  return null;
2520
2778
  }
2521
2779
  totals.files++;
2522
2780
  totals.folded += result.folded.length;
2523
2781
  if (result.folded.length) totals.filesWithFolds++;
2524
2782
  for (const entry of result.skipped) totals.skipped.set(entry.reason, (totals.skipped.get(entry.reason) ?? 0) + 1);
2525
- if (failOnUnfolded) {
2526
- for (const entry of result.skipped) {
2527
- if (!SURVIVES_TO_RUNTIME.has(entry.reason)) continue;
2528
- survivors.push({
2529
- file: filePath,
2530
- line: lineAt(code, entry.start),
2531
- name: entry.name,
2532
- reason: entry.reason
2533
- });
2534
- }
2535
- if ((ctx.config.leafFallback ?? true) && result.code.includes("cssLeaf(")) survivors.push({
2783
+ if (result.folded.some((entry) => entry.kind === "class" || entry.kind === "slots")) staticSession.transformedFiles.add(resolve(filePath));
2784
+ for (const entry of result.folded) for (const className of entry.classNames) staticSession.markClassUsed(className);
2785
+ for (const entry of result.skipped) {
2786
+ if (entry.reason === "not-imported" || entry.reason === "overlapping") continue;
2787
+ if (entry.name === "cx" && entry.reason === "dynamic") continue;
2788
+ addSurvivor({
2536
2789
  file: filePath,
2537
- line: lineAt(result.code, result.code.indexOf("cssLeaf(")),
2538
- name: "cssLeaf",
2539
- reason: "lowered-leaf"
2790
+ line: lineAt(code, entry.start),
2791
+ name: entry.name,
2792
+ reason: entry.reason
2540
2793
  });
2541
2794
  }
2542
2795
  if (reportSkipped && result.skipped.length) logger.info("vite:transform", formatSkipped(filePath, result.skipped));
2543
2796
  for (const dependency of result.dependencies) this.addWatchFile?.(dependency);
2797
+ if (command === "serve" && survivors.some((entry) => entry.file === filePath)) throw createSurvivorError(survivors.filter((entry) => entry.file === filePath));
2544
2798
  if (!result.folded.length) return null;
2545
- logger.debug("vite:transform", `Folded ${result.folded.length} call(s) in ${filePath}`);
2799
+ logger.debug("vite:transform", `Compiled ${result.folded.length} call(s) in ${filePath}`);
2546
2800
  return {
2547
2801
  code: result.code,
2548
2802
  map: result.map
2549
2803
  };
2550
2804
  },
2551
2805
  buildEnd() {
2552
- if (failOnUnfolded && survivors.length) {
2553
- const byFile = /* @__PURE__ */ new Map();
2554
- for (const entry of survivors) {
2555
- const list = byFile.get(entry.file) ?? [];
2556
- list.push(entry);
2557
- byFile.set(entry.file, list);
2558
- }
2559
- const named = (e) => e.reason === "runtime-binding" || e.reason === "fold-failed" ? e.name : `${e.name}()`;
2560
- const detail = truncateList(Array.from(byFile.entries(), ([file, entries]) => [` ${file}`, ...entries.map((e) => ` ${e.line}: ${named(e)} — ${e.reason}`)].join("\n")), {
2806
+ if (survivors.length) throw createSurvivorError(survivors);
2807
+ if (typeof this.getModuleInfo === "function") {
2808
+ if (!staticSession.cssLoaded) throw new Error(`bamboocss: compiled class values were produced, but ${JSON.stringify(VIRTUAL_CSS_ID)} was not imported. Import it once from the application entry so the compiled rules are emitted.`);
2809
+ const outsideExtraction = [...staticSession.transformedFiles].filter((file) => !staticSession.extractedFiles.has(file));
2810
+ if (outsideExtraction.length) throw new Error(`bamboocss: ${outsideExtraction.length} statically compiled module(s) are outside the CSS extraction graph:\n\n${truncateList(outsideExtraction.map((file) => ` ${file}`), {
2561
2811
  unit: "file",
2562
2812
  separator: "\n"
2563
- });
2564
- const threw = survivors.some((entry) => entry.reason === "fold-failed");
2565
- throw new Error(`bamboocss: ${survivors.length} call(s) could not be folded, and \`failOnUnfolded\` is on.\n\n${detail}\n\n` + (threw ? "`fold-failed` is a module the fold threw on — see the error logged for it above. Nothing was established about its calls either way, so it cannot support the guarantee this option makes.\n\n" : "") + "Each one keeps `styled-system/css` in the bundle, so the engine cannot be dropped however many other calls folded. Make the values static, move the variation into a `cva` variant, or generate them with `staticCss` — or set `failOnUnfolded: false` to accept the runtime.");
2813
+ })}\n\nAdd them to \`include\` in bamboo.config, or no CSS rule can back their emitted classes.`);
2566
2814
  }
2567
- if (!transform || !reportSummary) return;
2815
+ if (!reportSummary) return;
2568
2816
  const declined = Array.from(totals.skipped.values()).reduce((sum, count) => sum + count, 0);
2569
2817
  const total = totals.folded + declined;
2570
2818
  if (!total) return;
2571
2819
  const share = Math.round(totals.folded / total * 100);
2572
2820
  const reasons = Array.from(totals.skipped.entries()).sort((a, b) => b[1] - a[1]).map(([reason, count]) => `${reason}=${count}`).join(" ");
2573
- logger.info("vite:transform", `Folded ${totals.folded}/${total} (${share}%) across ${totals.filesWithFolds}/${totals.files} files` + (reasons ? ` — declined: ${reasons}` : ""));
2821
+ logger.info("vite:transform", `Compiled ${totals.folded}/${total} (${share}%) across ${totals.filesWithFolds}/${totals.files} files` + (reasons ? ` — declined: ${reasons}` : ""));
2574
2822
  }
2575
2823
  }];
2576
2824
  };
2577
2825
  //#endregion
2578
- export { VIRTUAL_CSS_ID, bamboocss, bamboocss as default, bamboocssCss, createRuntimeCss, foldSource };
2826
+ export { VIRTUAL_CSS_ID, bamboocss, bamboocss as default };