@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.cjs CHANGED
@@ -26,14 +26,89 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
26
26
  //#endregion
27
27
  let _bamboocss_node = require("@bamboocss/node");
28
28
  let _bamboocss_logger = require("@bamboocss/logger");
29
- let _bamboocss_config_ts_path = require("@bamboocss/config/ts-path");
30
- let _bamboocss_extractor = require("@bamboocss/extractor");
29
+ let _bamboocss_shared = require("@bamboocss/shared");
30
+ let _ampproject_remapping = require("@ampproject/remapping");
31
+ _ampproject_remapping = __toESM(_ampproject_remapping);
31
32
  let magic_string = require("magic-string");
32
33
  magic_string = __toESM(magic_string);
34
+ let postcss = require("postcss");
35
+ postcss = __toESM(postcss);
36
+ let postcss_selector_parser = require("postcss-selector-parser");
37
+ postcss_selector_parser = __toESM(postcss_selector_parser);
33
38
  let node_path = require("node:path");
39
+ let _bamboocss_config_ts_path = require("@bamboocss/config/ts-path");
40
+ let _bamboocss_extractor = require("@bamboocss/extractor");
34
41
  let ts_morph = require("ts-morph");
35
- let _bamboocss_core = require("@bamboocss/core");
36
- let _bamboocss_shared = require("@bamboocss/shared");
42
+ //#region src/prune-static-css.ts
43
+ /** The generated declaration that identifies a Bamboo stylesheet after minification. */
44
+ const SENTINEL = "--made-with-bamboo";
45
+ /**
46
+ * Remove source-graph atoms no transformed module can emit.
47
+ *
48
+ * `prunableClasses` contains only atoms extracted from the source graph. Explicit `staticCss`
49
+ * additions are absent and survive as a safelist; graph atoms are governed by the transformed
50
+ * module reachability set, regardless of whether they originated in `css()` or a recipe.
51
+ */
52
+ const pruneStaticCss = (css, session, { prune = true } = {}) => {
53
+ if (!css.includes(SENTINEL)) return css;
54
+ const root = postcss.default.parse(css);
55
+ const isUtilityRule = (rule) => {
56
+ let parent = rule.parent;
57
+ while (parent) {
58
+ if (parent.type === "atrule") {
59
+ const atRule = parent;
60
+ if (atRule.name === "layer" && atRule.params === session.utilityLayer) return true;
61
+ }
62
+ parent = parent.parent;
63
+ }
64
+ return false;
65
+ };
66
+ if (prune) root.walkRules((rule) => {
67
+ if (!isUtilityRule(rule)) return;
68
+ const classes = /* @__PURE__ */ new Set();
69
+ try {
70
+ (0, postcss_selector_parser.default)((selectors) => {
71
+ selectors.walkClasses((classNode) => {
72
+ classes.add(classNode.toString().slice(1));
73
+ });
74
+ }).processSync(rule.selector);
75
+ } catch {
76
+ return;
77
+ }
78
+ if (classes.size !== 1) return;
79
+ const [className] = classes;
80
+ if (!className || !session.prunableClasses.has(className) || session.usedClasses.has(className)) return;
81
+ rule.remove();
82
+ });
83
+ let removed = true;
84
+ while (removed) {
85
+ removed = false;
86
+ root.walkAtRules((rule) => {
87
+ if (rule.nodes?.length !== 0) return;
88
+ rule.remove();
89
+ removed = true;
90
+ });
91
+ }
92
+ if (session.denseClassNames) root.walkRules((rule) => {
93
+ if (!isUtilityRule(rule)) return;
94
+ const transitionClasses = /* @__PURE__ */ new Set();
95
+ try {
96
+ rule.selector = (0, postcss_selector_parser.default)((selectors) => {
97
+ selectors.walkClasses((classNode) => {
98
+ if (!session.prunableClasses.has(classNode.toString().slice(1))) return;
99
+ if (session.viewTransitionClasses.has(classNode.value)) transitionClasses.add(classNode.value);
100
+ classNode.value = session.allocateClassString(classNode.value);
101
+ });
102
+ }).processSync(rule.selector);
103
+ } catch {}
104
+ if (transitionClasses.size) rule.walkDecls("view-transition-class", (declaration) => {
105
+ if (!transitionClasses.has(declaration.value)) return;
106
+ declaration.value = session.allocateClassString(declaration.value);
107
+ });
108
+ });
109
+ return root.toString();
110
+ };
111
+ //#endregion
37
112
  //#region src/css.ts
38
113
  /**
39
114
  * What a project imports to get the stylesheet.
@@ -48,6 +123,77 @@ const VIRTUAL_CSS_ID = "virtual:bamboo.css";
48
123
  * not to try reading it off disk.
49
124
  */
50
125
  const RESOLVED_ID = `\0${VIRTUAL_CSS_ID}`;
126
+ const INLINE_SOURCE_MAP = /\n?\/\/# sourceMappingURL=data:application\/json[^\n]*$/;
127
+ /** Rewrite one generated chunk without invalidating all mappings after the changed string. */
128
+ const replaceChunkReference = (chunk, bundle, previous, next, sourcemap) => {
129
+ if (!chunk.code.includes(previous)) return;
130
+ const magic = new magic_string.default(chunk.code);
131
+ let index = chunk.code.indexOf(previous);
132
+ while (index !== -1) {
133
+ magic.overwrite(index, index + previous.length, next);
134
+ index = chunk.code.indexOf(previous, index + previous.length);
135
+ }
136
+ chunk.code = magic.toString();
137
+ if (!chunk.map) return;
138
+ const file = chunk.map.file;
139
+ const debugId = chunk.map.debugId;
140
+ const combined = (0, _ampproject_remapping.default)([magic.generateMap({
141
+ source: chunk.fileName,
142
+ hires: "boundary"
143
+ }), chunk.map], () => null);
144
+ if (file) combined.file = file;
145
+ if (debugId) combined.debugId = debugId;
146
+ const rollupMap = combined;
147
+ rollupMap.toUrl = () => `data:application/json;charset=utf-8;base64,${Buffer.from(combined.toString()).toString("base64")}`;
148
+ chunk.map = rollupMap;
149
+ if (sourcemap === "inline") {
150
+ chunk.code = chunk.code.replace(INLINE_SOURCE_MAP, "");
151
+ chunk.code += `\n//# sourceMappingURL=data:application/json;charset=utf-8;base64,${Buffer.from(combined.toString()).toString("base64")}`;
152
+ return;
153
+ }
154
+ const mapAsset = bundle[`${chunk.fileName}.map`];
155
+ if (mapAsset?.type === "asset") mapAsset.source = combined.toString();
156
+ };
157
+ /** Replace an emitted filename wherever Vite or Rollup has already recorded it. */
158
+ const replaceAssetReferences = (bundle, previous, next, sourcemap) => {
159
+ const replace = (value) => value.replaceAll(previous, next);
160
+ for (const output of Object.values(bundle)) {
161
+ if (output.type === "asset") {
162
+ if (typeof output.source === "string") output.source = replace(output.source);
163
+ continue;
164
+ }
165
+ replaceChunkReference(output, bundle, previous, next, sourcemap);
166
+ const referencedFiles = output.referencedFiles;
167
+ if (referencedFiles) output.referencedFiles = referencedFiles.map(replace);
168
+ const importedCss = output.viteMetadata?.importedCss;
169
+ if (importedCss?.delete(previous)) importedCss.add(next);
170
+ }
171
+ };
172
+ /**
173
+ * Prune and rename compiler-owned CSS, then give changed assets a hash of their final bytes.
174
+ *
175
+ * Rollup has already expanded `[hash]` when `generateBundle` runs. Mutating only `source`
176
+ * would therefore leave two different reachable subsets under one CDN key. The extra final
177
+ * hash is not cosmetic: it makes late graph reachability cache-safe.
178
+ */
179
+ const optimizeStaticCssAssets = (bundle, session) => {
180
+ for (const [bundleName, output] of Object.entries(bundle)) {
181
+ if (output.type !== "asset") continue;
182
+ const source = typeof output.source === "string" ? output.source : Buffer.from(output.source).toString();
183
+ if (!source.includes("--made-with-bamboo")) continue;
184
+ const optimized = pruneStaticCss(source, session);
185
+ output.source = optimized;
186
+ if (optimized === source) continue;
187
+ const nextName = output.fileName.replace(/\.css$/, `.b-${(0, _bamboocss_shared.toHash)(optimized)}.css`);
188
+ if (nextName === output.fileName) continue;
189
+ if (bundle[nextName] && bundle[nextName] !== output) throw new Error(`bamboocss: final CSS asset name collision at ${JSON.stringify(nextName)}.`);
190
+ const previous = output.fileName;
191
+ output.fileName = nextName;
192
+ replaceAssetReferences(bundle, previous, nextName, session.sourcemap);
193
+ delete bundle[bundleName];
194
+ bundle[nextName] = output;
195
+ }
196
+ };
51
197
  /**
52
198
  * Serve bamboo's stylesheet as a virtual module, in dev and in build.
53
199
  *
@@ -61,10 +207,11 @@ const RESOLVED_ID = `\0${VIRTUAL_CSS_ID}`;
61
207
  * `styles.css` and asking the project to import it means the build reads a file the same
62
208
  * process just wrote, which is a race on any watch rebuild.
63
209
  */
64
- const bamboocssCss = (options = {}) => {
65
- const { configPath, cwd } = options;
210
+ const bamboocssCss = (options) => {
211
+ const { configPath, cwd, session } = options;
66
212
  const builder = new _bamboocss_node.Builder();
67
213
  let server;
214
+ let command = "build";
68
215
  /**
69
216
  * Serialised, because both `load` and the watcher can reach it and `Builder` keeps one
70
217
  * context. Two overlapping passes would extract into the same encoder and emit the
@@ -78,7 +225,33 @@ const bamboocssCss = (options = {}) => {
78
225
  });
79
226
  await builder.emit();
80
227
  builder.extract();
81
- return builder.toCss({ layerParams: true });
228
+ 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.");
229
+ if (builder.context) {
230
+ session.cssLoaded = true;
231
+ session.utilityLayer = builder.context.config.layers?.utilities ?? "utilities";
232
+ session.extractedFiles.clear();
233
+ for (const file of builder.context.getFiles()) session.extractedFiles.add(builder.context.runtime.path.abs(builder.context.config.cwd, file));
234
+ }
235
+ let graphAtomHashes;
236
+ if (builder.context) {
237
+ builder.context.encoder.atomizeObservedRecipes();
238
+ graphAtomHashes = new Set(builder.context.encoder.atomic);
239
+ }
240
+ const css = builder.toCss({
241
+ layerParams: true,
242
+ includeRecipes: false
243
+ });
244
+ session.prunableClasses.clear();
245
+ session.viewTransitionClasses.clear();
246
+ if (graphAtomHashes && builder.context) {
247
+ const decoder = builder.context.decoder.collect(builder.context.encoder);
248
+ for (const atom of decoder.atomic) if (graphAtomHashes.has(atom.hash)) session.prunableClasses.add(atom.className);
249
+ for (const transition of decoder.view_transitions) {
250
+ session.viewTransitionClasses.add(transition.className);
251
+ session.prunableClasses.add((0, _bamboocss_shared.esc)(transition.className));
252
+ }
253
+ }
254
+ return command === "serve" ? pruneStaticCss(css, session, { prune: false }) : css;
82
255
  };
83
256
  const generate = () => {
84
257
  pending = Promise.resolve(pending).catch(() => void 0).then(build);
@@ -86,6 +259,10 @@ const bamboocssCss = (options = {}) => {
86
259
  };
87
260
  return {
88
261
  name: "bamboocss:css",
262
+ configResolved(config) {
263
+ command = config.command;
264
+ session.sourcemap = config.build.sourcemap;
265
+ },
89
266
  resolveId(id) {
90
267
  if (id === "virtual:bamboo.css") return RESOLVED_ID;
91
268
  return null;
@@ -105,20 +282,23 @@ const bamboocssCss = (options = {}) => {
105
282
  const mod = server?.moduleGraph.getModuleById(RESOLVED_ID);
106
283
  if (!mod) return;
107
284
  server?.moduleGraph.invalidateModule(mod);
108
- server?.ws.send({
109
- type: "update",
110
- updates: []
111
- });
285
+ server?.reloadModule(mod);
112
286
  _bamboocss_logger.logger.debug("vite", `styles invalidated by ${file}`);
113
287
  };
114
288
  devServer.watcher.on("change", invalidate);
115
289
  devServer.watcher.on("add", invalidate);
116
290
  devServer.watcher.on("unlink", invalidate);
291
+ },
292
+ generateBundle: {
293
+ order: "post",
294
+ handler(_, bundle) {
295
+ optimizeStaticCssAssets(bundle, session);
296
+ }
117
297
  }
118
298
  };
119
299
  };
120
300
  //#endregion
121
- //#region src/fold-partial.ts
301
+ //#region src/fold-analysis.ts
122
302
  /**
123
303
  * Statically resolvable means: every box in the tree carries a known value.
124
304
  *
@@ -133,7 +313,10 @@ const isStaticBox = (node, seen = /* @__PURE__ */ new Set()) => {
133
313
  seen.add(node);
134
314
  if (_bamboocss_extractor.box.isUnresolvable(node) || _bamboocss_extractor.box.isConditional(node)) return false;
135
315
  if (!("type" in node) || node.type == null) return false;
136
- if (_bamboocss_extractor.box.isLiteral(node) && node.value === void 0) return false;
316
+ if (_bamboocss_extractor.box.isLiteral(node) && node.value === void 0) {
317
+ const source = node.getNode?.();
318
+ return Boolean(source && ts_morph.Node.isIdentifier(source) && source.getText() === "undefined");
319
+ }
137
320
  if (_bamboocss_extractor.box.isMap(node)) {
138
321
  for (const child of node.value.values()) if (!isStaticBox(child, seen)) return false;
139
322
  return true;
@@ -258,8 +441,8 @@ const isCollapsedBinary = (node) => ts_morph.Node.isBinaryExpression(node) && CO
258
441
  * Spreads are the conservative case. `{ ...base }` where `base` is a static local
259
442
  * object *is* resolved by the extractor, but a resolved spread and an unresolved one
260
443
  * are indistinguishable once flattened into the map — both just contribute keys, or
261
- * fail to. Rather than guess, phase 1 declines them. Partial folding is where this
262
- * gets revisited.
444
+ * fail to. Rather than guess and erase evaluation the compiler cannot reproduce, the call
445
+ * is rejected.
263
446
  */
264
447
  /**
265
448
  * What a property's value is written as. A shorthand names it, so the name *is* the
@@ -304,37 +487,6 @@ const accountsForSource = (node, boxNode) => {
304
487
  return true;
305
488
  };
306
489
  /**
307
- * A property whose value is a ternary is not dynamic, it is *finite*: both branches are
308
- * known, so each can be resolved now and the choice left to a ternary between two
309
- * literals. That removes the `css()` call without needing to know which branch runs.
310
- *
311
- * Independent conditionals stay linear rather than multiplying, because each property
312
- * contributes its own ternary. Two conditionals give two ternaries, not four
313
- * combinations — which is only sound because `collides()` already rules out two
314
- * properties resolving to the same class, so no combination can interact with another.
315
- */
316
- const finiteBranches = (key, value, boxNode, deps) => {
317
- if (!_bamboocss_extractor.box.isConditional(boxNode)) return void 0;
318
- const node = boxNode.getNode();
319
- if (!ts_morph.Node.isConditionalExpression(node)) return void 0;
320
- if (!value || unwrapExpression(value) !== node) return void 0;
321
- const [whenTrue, whenFalse] = [[node.getWhenTrue(), boxNode.whenTrue], [node.getWhenFalse(), boxNode.whenFalse]].map(([source, branch]) => {
322
- if (!deps.isStatic(branch)) return void 0;
323
- if (!deps.isAccounted(source, branch)) return void 0;
324
- const value = (0, _bamboocss_extractor.unbox)(branch).raw;
325
- try {
326
- return deps.runtimeCss({ [key]: value });
327
- } catch {
328
- return;
329
- }
330
- });
331
- if (whenTrue === void 0 || whenFalse === void 0) return void 0;
332
- if (!whenTrue && !whenFalse) return void 0;
333
- return `${node.getCondition().getText()} ? ${JSON.stringify(whenTrue)} : ${JSON.stringify(whenFalse)}`;
334
- };
335
- /** The binding the leaf fold calls, exported by the generated css module. */
336
- const LEAF_HELPER = "cssLeaf";
337
- /**
338
490
  * Memo keyed on a file, thrown away when its text is replaced.
339
491
  *
340
492
  * A plain `WeakMap<SourceFile, …>` is wrong here: ts-morph reuses the wrapper when a path
@@ -355,18 +507,6 @@ const byText = (cache, sourceFile, compute) => {
355
507
  return value;
356
508
  };
357
509
  /**
358
- * The modules this file imports from, as specifiers.
359
- *
360
- * Only strings are cached. A ts-morph *node* cannot be: re-adding a path forgets the old
361
- * nodes even when the text is identical, so the memo would hand back wrappers that throw
362
- * on access. Strings outlive that, and answer the one question worth asking before the
363
- * helper resolution walks the declarations — whether this file imports from bamboo at
364
- * all. On a module of many elements that never fold, that walk was the entire cost of
365
- * trying.
366
- */
367
- const specifierCache = /* @__PURE__ */ new WeakMap();
368
- const importsAnything = (sourceFile, matches) => byText(specifierCache, sourceFile, () => sourceFile.getImportDeclarations().map((declaration) => declaration.getModuleSpecifierValue())).some(matches);
369
- /**
370
510
  * Every name declared at module scope, which is what an added import could collide with.
371
511
  *
372
512
  * This replaced `sourceFile.getLocals()`. That was precise, but it goes through the
@@ -477,282 +617,6 @@ const collectModuleScopeNames = (sourceFile) => {
477
617
  }
478
618
  return names;
479
619
  };
480
- /**
481
- * The local name an already-imported bamboo binding goes by.
482
- *
483
- * `ensureCxImport` answers this too, but it also decides whether a *missing* binding can
484
- * be added, which needs `getLocals()` — the compiler's binder over the whole module. This
485
- * runs for every candidate whether it folds or not, so it stops at what the import
486
- * declarations already say and never forces that.
487
- */
488
- const findBambooBinding = (call, imported, isBambooCssModule, isShadowed) => {
489
- if (!importsAnything(call.getSourceFile(), isBambooCssModule)) return void 0;
490
- for (const declaration of call.getSourceFile().getImportDeclarations()) {
491
- if (declaration.isTypeOnly() || !isBambooCssModule(declaration.getModuleSpecifierValue())) continue;
492
- for (const named of declaration.getNamedImports()) {
493
- if (named.isTypeOnly() || named.getNameNode().getText() !== imported) continue;
494
- const local = (named.getAliasNode() ?? named.getNameNode()).getText();
495
- return isShadowed(call, local) ? void 0 : local;
496
- }
497
- }
498
- };
499
- /**
500
- * A value that survives the class pipeline unchanged, so the class built around it is the
501
- * prefix and nothing else: no whitespace for `sanitize` to collapse, no `!` for the
502
- * important regex, no space for `withoutSpace`, and nothing a token or condition could
503
- * plausibly be named.
504
- */
505
- const LEAF_SENTINEL = "bamboo0leaf0sentinel0";
506
- /**
507
- * A property the extractor could not resolve is *open-ended* rather than finite — but its
508
- * class is still `prefix + value`, and the prefix is known now.
509
- *
510
- * `utility.transform` is string construction over a table fixed at build time, and
511
- * nothing consults which rules were emitted. So `css({ color: tone })` already returns
512
- * `c_<tone>` for a value the extractor never saw, with no CSS behind it. Emitting that
513
- * string directly cannot be less correct than the call it replaces.
514
- *
515
- * The prefix is read off the real implementation rather than rebuilt: resolving a
516
- * sentinel through `runtimeCss` applies the shorthand table and the utility's class name
517
- * in one step. It also self-gates — a hashed or grouped class does not contain the
518
- * sentinel, so both modes decline here without this having to read the config.
519
- *
520
- * Shared with the element surface, which asks the same question about a JSX style prop.
521
- *
522
- * Top level only, like the finite lowering beside it. A nested leaf's class carries its
523
- * condition path, which the prefix would describe correctly — but the helper's fallback
524
- * rebuilds `{ [prop]: value }` to hand back to `css()`, and that reconstruction has to
525
- * carry the same path or the declined shape resolves without its condition.
526
- */
527
- const leafPrefix = (key, ctx, runtimeCss) => {
528
- if (ctx.conditions.isCondition(key)) return void 0;
529
- let resolved;
530
- try {
531
- resolved = runtimeCss({ [key]: LEAF_SENTINEL });
532
- } catch {
533
- return;
534
- }
535
- if (!resolved.endsWith(LEAF_SENTINEL)) return void 0;
536
- const prefix = resolved.slice(0, -21);
537
- return !prefix || prefix.includes(" ") ? void 0 : prefix;
538
- };
539
- /**
540
- * Written as an object or an array, this is a condition block or a responsive list: one
541
- * class per entry rather than one class. `leafClass` declines both at runtime and falls
542
- * back, so lowering one is not wrong — it is a guaranteed round trip through the fallback,
543
- * which is the same reason a condition key is declined.
544
- */
545
- const isWrittenAsCollection = (value) => {
546
- const inner = unwrapExpression(value);
547
- return ts_morph.Node.isObjectLiteralExpression(inner) || ts_morph.Node.isArrayLiteralExpression(inner);
548
- };
549
- /** The call this surface emits in place of the property. */
550
- const leafCall = (prefix, key, valueText, name = LEAF_HELPER) => `${name}(${JSON.stringify(prefix)}, ${JSON.stringify(key)}, ${valueText})`;
551
- const dynamicLeaf = (key, value, deps) => {
552
- if (!deps.allowLeaf || !value) return void 0;
553
- if (isWrittenAsCollection(value)) return void 0;
554
- const prefix = leafPrefix(key, deps.ctx, deps.runtimeCss);
555
- return prefix === void 0 ? void 0 : leafCall(prefix, key, value.getText(), deps.leafName ?? "cssLeaf");
556
- };
557
- /**
558
- * Properties are partitioned whole rather than recursed into. A top-level property is
559
- * either entirely static or entirely dynamic, which keeps the reconstructed object a
560
- * verbatim slice of the source and avoids rebuilding nested conditions by hand.
561
- */
562
- const planPartialFold = (argument, boxNode, styles, deps) => {
563
- const partition = partitionObject(argument, boxNode, styles, deps);
564
- if (!partition) return void 0;
565
- const className = deps.runtimeCss(partition.staticStyles);
566
- if (!className && !partition.finite.length) return void 0;
567
- return {
568
- className,
569
- dynamicText: partition.dynamicText.length ? `{ ${partition.dynamicText.join(", ")} }` : void 0,
570
- finite: partition.finite,
571
- finiteFirst: partition.finiteFirst
572
- };
573
- };
574
- /**
575
- * Split one object level, recursing into a block that is part static and part dynamic.
576
- *
577
- * Without the recursion a single dynamic leaf sends its whole block to the runtime:
578
- * `{ _hover: { color: 'red.300', bg: p } }` loses the resolved `color` even though
579
- * nothing about it depends on `p`. That is a precision loss rather than a wrong answer,
580
- * but it costs exactly the calls a component re-renders most.
581
- *
582
- * A class is identified by its condition path *and* its property, so `_hover.color` in
583
- * one half and `_hover.bg` in the other cannot collide, and neither can `color` against
584
- * `_hover.color`. Collision is therefore checked per level, among siblings.
585
- *
586
- * The static subtree is read from the extracted data rather than rebuilt: the extractor
587
- * has already dropped the unresolvable leaves, so `styles[key]` for a mixed block is
588
- * exactly the resolvable part. The dynamic side is taken from source text, so nothing
589
- * depends on that pruning being complete.
590
- */
591
- const partitionObject = (node, boxNode, styles, deps, topLevel = true) => {
592
- if (!_bamboocss_extractor.box.isMap(boxNode)) return void 0;
593
- const { ctx, isAccounted, isStatic } = deps;
594
- const staticKeys = [];
595
- const staticStyles = {};
596
- const seenKeys = /* @__PURE__ */ new Set();
597
- /**
598
- * Everything not resolved outright, in source order. Kept as one list because a ternary
599
- * that cannot be lowered has to become a runtime property *in its original position*,
600
- * and because whether the two kinds interleave is a property of that order.
601
- */
602
- const slots = [];
603
- for (const property of node.getProperties()) {
604
- if (!ts_morph.Node.isPropertyAssignment(property) && !ts_morph.Node.isShorthandPropertyAssignment(property)) return void 0;
605
- const nameNode = property.getNameNode();
606
- if (ts_morph.Node.isComputedPropertyName(nameNode)) return void 0;
607
- const key = ts_morph.Node.isStringLiteral(nameNode) || ts_morph.Node.isNumericLiteral(nameNode) ? String(nameNode.getLiteralValue()) : nameNode.getText();
608
- if (seenKeys.has(key)) return void 0;
609
- seenKeys.add(key);
610
- const value = valueOf(property);
611
- const valueBox = boxNode.value.get(key);
612
- if (key in styles && isStatic(valueBox) && isAccounted(value, valueBox)) {
613
- staticKeys.push(key);
614
- staticStyles[key] = styles[key];
615
- continue;
616
- }
617
- if (topLevel) {
618
- const branches = finiteBranches(key, value, valueBox, deps);
619
- if (branches) {
620
- slots.push({
621
- key,
622
- kind: "finite",
623
- lowered: {
624
- expression: branches,
625
- emitsLiterals: true
626
- },
627
- text: property.getText()
628
- });
629
- continue;
630
- }
631
- const leaf = dynamicLeaf(key, value, deps);
632
- if (leaf) {
633
- slots.push({
634
- key,
635
- kind: "finite",
636
- lowered: {
637
- expression: leaf,
638
- emitsLiterals: false
639
- },
640
- text: property.getText()
641
- });
642
- continue;
643
- }
644
- }
645
- const nested = value && ts_morph.Node.isObjectLiteralExpression(value) && isAccounted(value, valueBox) ? partitionObject(value, valueBox, styles[key] ?? {}, deps, false) : void 0;
646
- if (nested && Object.keys(nested.staticStyles).length && nested.dynamicText.length) {
647
- staticKeys.push(key);
648
- staticStyles[key] = nested.staticStyles;
649
- slots.push({
650
- key,
651
- kind: "dynamic",
652
- split: true,
653
- text: `${property.getNameNode().getText()}: { ${nested.dynamicText.join(", ")} }`
654
- });
655
- continue;
656
- }
657
- slots.push({
658
- key,
659
- kind: "dynamic",
660
- text: property.getText()
661
- });
662
- }
663
- const demote = (slot) => {
664
- slot.kind = "dynamic";
665
- slot.lowered = void 0;
666
- };
667
- const contested = () => slots.filter((slot) => slot.kind === "dynamic" && !slot.split).map((slot) => slot.key);
668
- for (;;) {
669
- const lowered = slots.filter((slot) => slot.kind === "finite");
670
- const dynamic = contested();
671
- const offender = lowered.find((slot, index) => collides([slot.key], [
672
- ...staticKeys,
673
- ...dynamic,
674
- ...lowered.slice(0, index).map((kept) => kept.key)
675
- ], ctx));
676
- if (!offender) break;
677
- demote(offender);
678
- }
679
- const written = () => slots.map((slot) => slot.kind === "finite" ? "f" : "d").join("");
680
- while (!/^f*d*$/.test(written()) && !/^d*f*$/.test(written())) demote(slots.findLast((slot) => slot.kind === "finite"));
681
- const dynamicText = slots.filter((slot) => slot.kind === "dynamic").map((slot) => slot.text);
682
- const finite = slots.filter((slot) => slot.kind === "finite").map((slot) => slot.lowered);
683
- if (!staticKeys.length && !finite.length) return void 0;
684
- if (!dynamicText.length && !finite.length) return void 0;
685
- if (collides(staticKeys, contested(), ctx)) return void 0;
686
- return {
687
- staticStyles,
688
- dynamicText,
689
- finite,
690
- finiteFirst: !written().startsWith("d")
691
- };
692
- };
693
- /**
694
- * Do the two halves resolve to a shared property?
695
- *
696
- * Compared after shorthand resolution, since that is where distinct keys become the same
697
- * property. An unrecognised key resolves to itself, so two distinct unknown keys are read
698
- * as distinct — which is right for atomic output, where one class is emitted per key.
699
- */
700
- const collides = (staticKeys, dynamicKeys, ctx) => {
701
- if (staticKeys.includes("base") || dynamicKeys.includes("base")) return true;
702
- const resolve = (key) => ctx.utility.hasShorthand ? ctx.utility.resolveShorthand(key) : key;
703
- const resolvedStatic = new Set(staticKeys.map(resolve));
704
- return dynamicKeys.some((key) => resolvedStatic.has(resolve(key)));
705
- };
706
- /**
707
- * The `cx` binding to call, adding it to the import that already brings in the style
708
- * helper when it is not there yet.
709
- *
710
- * Reusing that declaration rather than writing a new one avoids having to guess the
711
- * module specifier, which varies with `importMap`, path aliases and how the project
712
- * spells its outdir.
713
- */
714
- const ensureCxImport = (call, calleeRoot, isBambooCssModule, isGeneratedCssModule, isShadowed, extra = []) => {
715
- const sourceFile = call.getSourceFile();
716
- const wanted = ["cx", ...extra];
717
- const resolved = {};
718
- let host;
719
- for (const declaration of sourceFile.getImportDeclarations()) {
720
- const mod = declaration.getModuleSpecifierValue();
721
- for (const named of declaration.getNamedImports()) {
722
- const local = (named.getAliasNode() ?? named.getNameNode()).getText();
723
- const imported = named.getNameNode().getText();
724
- if (wanted.includes(imported) && !(imported in resolved)) {
725
- if (declaration.isTypeOnly() || named.isTypeOnly()) return void 0;
726
- if (!isBambooCssModule(mod)) return void 0;
727
- if (isShadowed(call, local)) return void 0;
728
- resolved[imported] = local;
729
- }
730
- if (local === calleeRoot) host = declaration;
731
- }
732
- }
733
- const missing = wanted.filter((name) => !(name in resolved));
734
- if (!missing.length) return {
735
- name: resolved.cx,
736
- names: resolved
737
- };
738
- if (!host) return void 0;
739
- if (!isGeneratedCssModule(host.getModuleSpecifierValue())) return void 0;
740
- const declared = declaredAtModuleScope(sourceFile);
741
- for (const name of missing) {
742
- if (declared.has(name) || isShadowed(call, name)) return void 0;
743
- resolved[name] = name;
744
- }
745
- const last = host.getNamedImports().at(-1);
746
- if (!last) return void 0;
747
- return {
748
- name: resolved.cx,
749
- names: resolved,
750
- insert: {
751
- pos: last.getEnd(),
752
- names: missing
753
- }
754
- };
755
- };
756
620
  //#endregion
757
621
  //#region src/fold-recipe.ts
758
622
  /**
@@ -762,15 +626,12 @@ const ensureCxImport = (call, calleeRoot, isBambooCssModule, isGeneratedCssModul
762
626
  * that names it. The parser records a definition under the name it was *imported* as (`cva`),
763
627
  * and a call under the name the file *bound* (`badge`); this is what joins the two.
764
628
  *
765
- * Reads `cva` and not `sva`, which is load-bearing rather than an omission. The parser records
766
- * a call of *either* as a recipe call, but an `sva` invocation returns one class per slot — an
767
- * object, not a string — so there is no literal to substitute. Leaving slot recipes out of this
768
- * map is what makes them decline as `unknown-recipe` instead of folding to a string that would
769
- * break every consumer reading `.root` off it.
629
+ * Slot and ordinary recipes share one representation.
770
630
  */
771
631
  const collectRecipeConfigs = (parserResult) => {
772
632
  const configs = /* @__PURE__ */ new Map();
773
- for (const definition of parserResult.cva) {
633
+ const definitions = [...parserResult.cva, ...parserResult.sva];
634
+ for (const definition of definitions) {
774
635
  const node = definition.box?.getNode?.();
775
636
  if (!node) continue;
776
637
  const nameNode = ((ts_morph.Node.isCallExpression(node) ? node : node.getFirstAncestorByKind(ts_morph.SyntaxKind.CallExpression))?.getFirstAncestorByKind(ts_morph.SyntaxKind.VariableDeclaration))?.getNameNode();
@@ -787,21 +648,20 @@ const collectRecipeConfigs = (parserResult) => {
787
648
  }
788
649
  configs.set(nameNode.getText(), {
789
650
  config,
790
- name: (0, _bamboocss_shared.getRecipeIdentity)(config),
791
651
  box: definition.box
792
652
  });
793
653
  }
794
654
  return configs;
795
655
  };
796
- /** The generated binding a lowered dynamic axis calls. Lives in `cx`, which pulls no engine. */
797
- const RECIPE_PICK_HELPER = "cvaPick";
656
+ /** Pick a complete precompiled StyleSet for one or more runtime recipe axes. */
657
+ const RECIPE_MAP_HELPER = "cvaMap";
658
+ /** Guard the exact compiler against accidentally materialising an enormous Cartesian product. */
659
+ const DEFAULT_MAX_RECIPE_STATES = 65536;
798
660
  /** What `recipe.splitVariantProps` calls, reached directly once the call is lowered. */
799
661
  const SPLIT_PROPS_HELPER = "splitProps";
800
- const HELPER = RECIPE_PICK_HELPER;
801
662
  /** Marker for a binding the fold must never resolve — declared twice, or unresolvable. */
802
663
  const AMBIGUOUS = Object.freeze({
803
664
  config: {},
804
- name: "",
805
665
  box: void 0
806
666
  });
807
667
  /** `.size`, or `["x-large"]` when the variant is not a valid identifier. */
@@ -843,10 +703,9 @@ const propertyKey = (nameNode) => {
843
703
  if (ts_morph.Node.isNumericLiteral(nameNode)) return String(nameNode.getLiteralValue());
844
704
  };
845
705
  /**
846
- * Make `cvaPick` callable at this call site, by whatever name the file gives it.
706
+ * Make a generated compile helper callable at this call site, by whatever name the file gives it.
847
707
  *
848
- * Not `ensureCxImport`: that one resolves `cx` and finds the declaration to extend by
849
- * matching the *callee* against an import. An inline recipe's callee is a local binding, so
708
+ * Unlike `cx`, an inline recipe's callee is a local binding, so
850
709
  * there is nothing to match — the host here is any import of the generated css module, which
851
710
  * a file defining a recipe necessarily has, since `cva` came from it.
852
711
  */
@@ -898,13 +757,18 @@ const ensureRecipeHelperImport = (imported, call, isBambooCssModule, isGenerated
898
757
  * an unresolved variant does not merely omit a class, it can change which of several the
899
758
  * recipe applies — so a partially-known selection is not foldable at all.
900
759
  */
901
- const lowerRecipeCall = (call, entry, ctx, isInert, resolvedSelection) => {
760
+ const lowerRecipeCall = (call, entry, styleCompiler, isInert, resolvedSelection, slot, maxRecipeStates = DEFAULT_MAX_RECIPE_STATES) => {
902
761
  if (!entry || entry === AMBIGUOUS) return {
903
762
  kind: "decline",
904
763
  reason: "unknown-recipe"
905
764
  };
906
- const { config, name } = entry;
907
- if (config.slots !== void 0) return {
765
+ const { config } = entry;
766
+ if (config.slots !== void 0) {
767
+ if (!Array.isArray(config.slots) || slot !== void 0 && !config.slots.includes(slot)) return {
768
+ kind: "decline",
769
+ reason: "unsupported-shape"
770
+ };
771
+ } else if (slot) return {
908
772
  kind: "decline",
909
773
  reason: "unsupported-shape"
910
774
  };
@@ -940,16 +804,13 @@ const lowerRecipeCall = (call, entry, ctx, isInert, resolvedSelection) => {
940
804
  /**
941
805
  * `input(variantProps)` — a selection the build cannot see inside.
942
806
  *
943
- * Inline recipes only. `cva` resolves a selection with `getRecipeClassNames`, which reads
944
- * a variant value as a key and so cannot take a conditional — a `{ base, md }` object finds
945
- * no entry and names no class, exactly as `cvaPick` does. A **config** recipe routes its
946
- * selection through `createCss`, which *expands* conditions into one class per breakpoint,
947
- * so a scalar lookup silently drops them. That is why this lowering is not applied to
948
- * config recipes: for a dynamic axis the build cannot know which kind of value arrives.
807
+ * The compiled recipe contract accepts scalar declared variant values. A conditional
808
+ * object is not a finite selection value; responsiveness belongs inside a variant's style
809
+ * declaration, where the compiler can materialize its conditions ahead of time.
949
810
  *
950
- * The classes are still knowable: a recipe emits one per declared variant, so the call is
951
- * one term per variant reading that binding. This is the shape a wrapper component takes,
952
- * where the variants are the component's public API and cannot be literals by definition.
811
+ * The complete StyleSets are knowable: the config declares every scalar value each axis
812
+ * accepts. This is the shape a wrapper component takes, where variants are its public API
813
+ * and therefore cannot be literals by definition.
953
814
  *
954
815
  * An identifier only. Each variant reads the binding again, and re-reading anything else —
955
816
  * a call, a property access — would evaluate it once per axis instead of once.
@@ -1032,19 +893,51 @@ const lowerRecipeCall = (call, entry, ctx, isInert, resolvedSelection) => {
1032
893
  * typecheck and does transform `.js`, so this is reachable.
1033
894
  */
1034
895
  const everyEffectSurvives = () => effectful.every(({ key, text }) => dynamicAxes.get(key) === text);
1035
- const merged = {
1036
- ...config.defaultVariants ?? {},
1037
- ...(0, _bamboocss_shared.compact)(selection)
896
+ const compiledSelection = (selected) => {
897
+ if (Array.isArray(config.slots) && slot === void 0) {
898
+ const slots = {};
899
+ const classNames = /* @__PURE__ */ new Set();
900
+ for (const slotName of config.slots) {
901
+ const styles = styleCompiler.resolveRecipe(config, selected, slotName);
902
+ if (!styles) return void 0;
903
+ const className = styleCompiler.className(styles);
904
+ slots[slotName] = className;
905
+ for (const token of className.split(" ")) if (token) classNames.add(token);
906
+ }
907
+ return {
908
+ value: slots,
909
+ classNames: [...classNames]
910
+ };
911
+ }
912
+ const styles = styleCompiler.resolveRecipe(config, selected, slot);
913
+ if (!styles) return void 0;
914
+ const className = styleCompiler.className(styles);
915
+ return {
916
+ value: className,
917
+ classNames: className.split(" ").filter(Boolean),
918
+ styles
919
+ };
1038
920
  };
1039
- const format = (0, _bamboocss_core.classFormatter)(ctx);
1040
921
  if (dynamicAxes.size === 0) {
1041
922
  if (!everyEffectSurvives()) return {
1042
923
  kind: "decline",
1043
924
  reason: "dynamic"
1044
925
  };
1045
- return {
926
+ const compiled = compiledSelection(selection);
927
+ if (!compiled) return {
928
+ kind: "decline",
929
+ reason: "dynamic"
930
+ };
931
+ if (typeof compiled.value === "string") return {
1046
932
  kind: "class",
1047
- className: (0, _bamboocss_shared.getRecipeClassNames)(name, config.variants, merged, ctx.utility.separator, format)
933
+ className: compiled.value,
934
+ styles: compiled.styles
935
+ };
936
+ return {
937
+ kind: "slots",
938
+ expression: JSON.stringify(compiled.value),
939
+ classNames: compiled.classNames,
940
+ dynamic: false
1048
941
  };
1049
942
  }
1050
943
  if (!everyEffectSurvives()) return {
@@ -1060,45 +953,126 @@ const lowerRecipeCall = (call, entry, ctx, isInert, resolvedSelection) => {
1060
953
  };
1061
954
  }
1062
955
  for (const key of [...dynamicAxes.keys()]) if (!Object.hasOwn(config.variants ?? {}, key)) dynamicAxes.delete(key);
1063
- if (dynamicAxes.size === 0) return {
1064
- kind: "class",
1065
- className: (0, _bamboocss_shared.getRecipeClassNames)(name, config.variants, merged, ctx.utility.separator, format)
1066
- };
1067
- const ownClass = format(name);
1068
- const parts = [JSON.stringify(ownClass)];
1069
- const classNames = [ownClass];
1070
- for (const key of Object.keys(config.variants ?? {})) {
1071
- const expression = dynamicAxes.get(key);
1072
- if (expression === void 0) {
1073
- const value = merged[key];
1074
- if (value == null) continue;
1075
- const declared = config.variants?.[key];
1076
- if (!declared || !Object.hasOwn(declared, value) || declared[value] == null) continue;
1077
- const className = format(`${name}--${key}${ctx.utility.separator}${(0, _bamboocss_shared.withoutSpace)(value)}`);
1078
- parts.push(JSON.stringify(` ${className}`));
1079
- classNames.push(className);
1080
- continue;
1081
- }
1082
- const values = config.variants[key];
1083
- const table = {};
1084
- for (const value of Object.keys(values)) {
1085
- const className = format(`${name}--${key}${ctx.utility.separator}${(0, _bamboocss_shared.withoutSpace)(value)}`);
1086
- table[value] = ` ${className}`;
1087
- classNames.push(className);
1088
- }
1089
- const fallbackValue = config.defaultVariants?.[key];
1090
- const fallback = fallbackValue != null && values[fallbackValue] != null ? ` ${format(`${name}--${key}${ctx.utility.separator}${(0, _bamboocss_shared.withoutSpace)(fallbackValue)}`)}` : void 0;
1091
- parts.push(`${HELPER}(${expression}, ${JSON.stringify(table)}${fallback === void 0 ? "" : `, ${JSON.stringify(fallback)}`})`);
956
+ if (dynamicAxes.size === 0) {
957
+ const compiled = compiledSelection(selection);
958
+ if (!compiled) return {
959
+ kind: "decline",
960
+ reason: "dynamic"
961
+ };
962
+ if (typeof compiled.value === "string") return {
963
+ kind: "class",
964
+ className: compiled.value,
965
+ styles: compiled.styles
966
+ };
967
+ return {
968
+ kind: "slots",
969
+ expression: JSON.stringify(compiled.value),
970
+ classNames: compiled.classNames,
971
+ dynamic: false
972
+ };
1092
973
  }
1093
- if (parts.length === 1) return {
1094
- kind: "class",
1095
- className: ownClass
1096
- };
974
+ /**
975
+ * Compile the finite recipe state space into a reduced decision table.
976
+ *
977
+ * Each leaf is a *complete* final StyleSet. This matters for declarations overridden by
978
+ * variants and compounds: selecting independent per-axis atoms would put both values in
979
+ * the utility layer and let stylesheet order, rather than the recipe's merge order, pick
980
+ * the winner. Complete leaves retain the same precedence while sharing their atoms with
981
+ * every `css()` and recipe in the build.
982
+ *
983
+ * `undefined` is its own edge because it restores a default variant. `null` and any
984
+ * undeclared value take the miss edge and explicitly suppress that default. Declared
985
+ * values use string keys, matching JavaScript's property-key coercion in the recipe
986
+ * runtime. A flat alternating key/value array avoids the special `__proto__` semantics
987
+ * of an object literal.
988
+ */
989
+ const axes = Object.keys(config.variants ?? {}).filter((key) => dynamicAxes.has(key));
990
+ const stateCount = axes.reduce((product, axis) => product * (Object.keys(config.variants?.[axis] ?? {}).length + 2), 1);
991
+ 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.`);
992
+ const expressions = axes.map((axis) => dynamicAxes.get(axis));
993
+ const wholeSlots = Array.isArray(config.slots) && slot === void 0;
1097
994
  return {
1098
- kind: "expression",
1099
- expression: parts.join(" + "),
1100
- classNames,
1101
- staticClasses: ownClass
995
+ kind: "dynamic-style",
996
+ map: {
997
+ outputKind: wholeSlots ? "slots" : "class",
998
+ compile(before = [], after = []) {
999
+ const nodes = [];
1000
+ const nodeByShape = /* @__PURE__ */ new Map();
1001
+ const leaves = [];
1002
+ const leafByShape = /* @__PURE__ */ new Map();
1003
+ const emittedClasses = /* @__PURE__ */ new Set();
1004
+ const leaf = (dynamicSelection) => {
1005
+ const selected = {
1006
+ ...selection,
1007
+ ...dynamicSelection
1008
+ };
1009
+ if (wholeSlots) {
1010
+ const compiled = compiledSelection(selected);
1011
+ if (!compiled || typeof compiled.value === "string") return internLeaf("");
1012
+ for (const token of compiled.classNames) emittedClasses.add(token);
1013
+ return internLeaf(compiled.value);
1014
+ }
1015
+ const styles = styleCompiler.resolveRecipe(config, selected, slot);
1016
+ if (!styles) return internLeaf("");
1017
+ const className = styleCompiler.className(styleCompiler.compose(...before, styles, ...after));
1018
+ for (const token of className.split(" ")) if (token) emittedClasses.add(token);
1019
+ return internLeaf(className);
1020
+ };
1021
+ function internLeaf(value) {
1022
+ const shape = JSON.stringify(value);
1023
+ const known = leafByShape.get(shape);
1024
+ if (known !== void 0) return ~known;
1025
+ const id = leaves.length;
1026
+ leaves.push(value);
1027
+ leafByShape.set(shape, id);
1028
+ return ~id;
1029
+ }
1030
+ const buildNode = (index, dynamicSelection) => {
1031
+ if (index === axes.length) return leaf(dynamicSelection);
1032
+ const axis = axes[index];
1033
+ const values = Object.keys(config.variants?.[axis] ?? {});
1034
+ const miss = buildNode(index + 1, {
1035
+ ...dynamicSelection,
1036
+ [axis]: null
1037
+ });
1038
+ const absentSelection = { ...dynamicSelection };
1039
+ delete absentSelection[axis];
1040
+ const absent = buildNode(index + 1, absentSelection);
1041
+ const byValue = [];
1042
+ for (const value of values) byValue.push(value, buildNode(index + 1, {
1043
+ ...dynamicSelection,
1044
+ [axis]: value
1045
+ }));
1046
+ const refs = [
1047
+ miss,
1048
+ absent,
1049
+ ...byValue.filter((_, valueIndex) => valueIndex % 2 === 1)
1050
+ ];
1051
+ if (refs.every((ref) => ref === refs[0])) return refs[0];
1052
+ const node = [
1053
+ miss,
1054
+ absent,
1055
+ byValue
1056
+ ];
1057
+ const shape = JSON.stringify(node);
1058
+ const known = nodeByShape.get(shape);
1059
+ if (known !== void 0) return known;
1060
+ const id = nodes.length;
1061
+ nodes.push(node);
1062
+ nodeByShape.set(shape, id);
1063
+ return id;
1064
+ };
1065
+ const root = buildNode(0, {});
1066
+ const staticLeaf = root < 0 ? leaves[~root] : void 0;
1067
+ return {
1068
+ expression: root < 0 && effectful.length === 0 ? JSON.stringify(staticLeaf) : `${RECIPE_MAP_HELPER}([${expressions.join(", ")}], ${JSON.stringify(nodes)}, ${JSON.stringify(leaves)}, ${root})`,
1069
+ classNames: [...emittedClasses],
1070
+ staticClasses: typeof staticLeaf === "string" ? staticLeaf : "",
1071
+ outputKind: wholeSlots ? "slots" : "class",
1072
+ usesHelper: !(root < 0 && effectful.length === 0)
1073
+ };
1074
+ }
1075
+ }
1102
1076
  };
1103
1077
  };
1104
1078
  //#endregion
@@ -1162,114 +1136,29 @@ const createRuntimeTokenValue = (ctx) => (path) => {
1162
1136
  * the default form the trivially foldable one.
1163
1137
  */
1164
1138
  const createRuntimeToken = (ctx) => (path) => tokenValuesFor(ctx).get(path)?.variable || void 0;
1165
- /**
1166
- * Whether a slot's class is independent of the variant props.
1167
- *
1168
- * A scoped slot recipe delivers variants through `@scope` rules anchored on an enclosing
1169
- * slot, so every *other* slot carries a constant class — the same string whatever the props
1170
- * are. That is what makes `recipe(anything).slot` foldable even when the variant is fully
1171
- * dynamic, which was not true before scoping existed.
1172
- */
1173
- const createConstantSlotCheck = (ctx) => (name, slot) => {
1174
- const config = ctx.recipes.getConfig(name);
1175
- if (!config || !("slots" in config)) return false;
1176
- if (!config.slots.includes(slot)) return false;
1177
- const anchors = _bamboocss_core.Recipes.getScopeRoots(config);
1178
- return anchors.length > 0 && !anchors.includes(slot);
1179
- };
1180
- const createRuntimeRecipe = (ctx) => {
1181
- const separator = ctx.utility.separator;
1182
- return (name, variants, slot) => {
1183
- const config = ctx.recipes.getConfig(name);
1184
- const node = ctx.recipes.getRecipe(name);
1185
- if (!config || !node) return void 0;
1186
- const isSlotRecipe = "slots" in config;
1187
- if (isSlotRecipe !== Boolean(slot)) return void 0;
1188
- if (slot && !config.slots.includes(slot)) return void 0;
1189
- const anchors = isSlotRecipe ? _bamboocss_core.Recipes.getScopeRoots(config) : [];
1190
- const isConstantSlot = Boolean(slot) && anchors.length > 0 && !anchors.includes(slot);
1191
- const className = slot ? ctx.recipes.getSlotKey(node.className, slot) : node.className;
1192
- const { defaultVariants = {} } = config;
1193
- const compoundVariants = slot ? (0, _bamboocss_shared.getSlotCompoundVariant)(config.compoundVariants ?? [], slot) : config.compoundVariants ?? [];
1194
- const recipeCss = (0, _bamboocss_shared.createCssUncached)({
1195
- hash: Boolean(ctx.hash.className),
1196
- conditions: {
1197
- shift: ctx.conditions.shift,
1198
- finalize: ctx.conditions.finalize
1199
- },
1200
- utility: {
1201
- prefix: ctx.utility.prefix,
1202
- hasShorthand: false,
1203
- resolveShorthand: (prop) => prop,
1204
- toHash: ctx.utility.toHash.bind(ctx.utility),
1205
- transform: (prop, value) => {
1206
- if (value === "__ignore__") return { className };
1207
- return { className: `${className}--${prop}${separator}${(0, _bamboocss_shared.withoutSpace)(value)}` };
1208
- }
1209
- }
1210
- });
1211
- const declaredValues = config.variants ?? {};
1212
- /**
1213
- * The same filter the generated `createRecipe` applies: only a value the config declares
1214
- * names a class.
1215
- *
1216
- * Scalars only — a conditional or responsive value is an object of leaves, and the leaves
1217
- * are what name classes when `createCss` walks them.
1218
- */
1219
- const onlyDeclared = (styles) => Object.fromEntries(Object.entries(styles).filter(([prop, value]) => {
1220
- if (prop === className) return true;
1221
- if (value === null || typeof value === "object") return true;
1222
- return Object.hasOwn(declaredValues, prop) && Object.hasOwn(declaredValues[prop] ?? {}, String(value));
1223
- }));
1224
- const recipeStyles = isConstantSlot ? { [className]: "__ignore__" } : onlyDeclared({
1225
- [className]: "__ignore__",
1226
- ...defaultVariants,
1227
- ...(0, _bamboocss_shared.compact)(variants)
1228
- });
1229
- if (compoundVariants.length > 0 && Object.keys(recipeStyles).some((prop) => typeof variants[prop] === "object")) return;
1230
- if (isSlotRecipe) {
1231
- const evaluated = anchors.length > 0 ? anchors : config.slots;
1232
- if (Object.values(variants).some((value) => typeof value === "object" && value !== null) && evaluated.some((slotName) => (0, _bamboocss_shared.getSlotCompoundVariant)(config.compoundVariants ?? [], slotName).length > 0)) return void 0;
1233
- }
1234
- return recipeCss(recipeStyles);
1235
- };
1236
- };
1237
1139
  //#endregion
1238
1140
  //#region src/fold.ts
1239
1141
  /**
1240
- * `cva`/`sva` return a function, so neither *definition* can collapse to a class string.
1241
- * `token` also resolves to no class, but it does resolve to a literal, so it folds through
1242
- * its own path rather than being declined outright.
1142
+ * `cva`/`sva` return a function, so their definitions are compile-time declarations rather
1143
+ * than class-producing calls; once their uses are lowered, the factory calls are erased.
1144
+ * `token` also resolves to no class, but it does resolve to a literal, so it compiles through
1145
+ * its own path rather than being declined outright. A static `viewTransition` bag resolves to
1146
+ * its extracted class and uses the ordinary class candidate path.
1243
1147
  *
1244
- * Their invocations are a different matter and do fold — `cva`'s through `fold-recipe`,
1245
- * which is a separate set because the call is recorded under the name the file bound rather
1246
- * than the name it imported. `sva`'s do not: a slot recipe resolves to one class per slot.
1148
+ * Recipe invocations compile through `fold-recipe`. Inline calls
1149
+ * are recorded under the name the file bound; config calls arrive as `recipe`. Routing both
1150
+ * through one exact finite-state lowering keeps their selection contract identical.
1247
1151
  */
1248
1152
  const FOLDABLE_TYPES = new Set([
1249
1153
  "css",
1250
1154
  "pattern",
1251
- "recipe"
1155
+ "viewTransition"
1252
1156
  ]);
1253
1157
  /**
1254
- * The class strings inside a lowered ternary — `e ? "c_red" : "c_blue"` gives both arms.
1255
- *
1256
- * They are read back out of the emitted text rather than threaded through the planner,
1257
- * because the planner's product *is* that text: anything it did not write cannot appear
1258
- * here, and anything it did cannot be missed.
1259
- */
1260
- const literalsIn = (expression) => [...expression.matchAll(/"((?:[^"\\]|\\.)*)"/g)].flatMap((match) => JSON.parse(match[0]).split(" ")).filter(Boolean);
1261
- /**
1262
- * The kinds reported as `not-foldable`, which is permanent rather than a limit of this
1263
- * phase — hence separate from `unsupported-kind`, where a slot recipe lands because it
1264
- * resolves to one class per slot rather than to a single string.
1265
- */
1266
- const UNFOLDABLE_TYPES = new Set(["cva", "sva"]);
1267
- /**
1268
1158
  * The skip reasons that leave a `css()`-family call in the output.
1269
1159
  *
1270
1160
  * `overlapping` is handled by the enclosing fold, and `not-imported` is somebody else's
1271
- * function of the same name — neither leaves a call of ours. `not-foldable` is a `cva`/`sva`
1272
- * definition, which keeps the recipe runtime rather than the css engine; see `failOnUnfolded`.
1161
+ * function of the same name — neither leaves a call of ours.
1273
1162
  */
1274
1163
  const SURVIVES_TO_RUNTIME = new Set([
1275
1164
  "dynamic",
@@ -1277,9 +1166,8 @@ const SURVIVES_TO_RUNTIME = new Set([
1277
1166
  "raw-call",
1278
1167
  "unsupported-kind",
1279
1168
  "no-call-expression",
1280
- "empty",
1281
1169
  "unresolved-token",
1282
- "fold-failed"
1170
+ "compile-failed"
1283
1171
  ]);
1284
1172
  /**
1285
1173
  * A call of a recipe the file bound itself: `const badge = cva(...)`, then `badge({ tone })`.
@@ -1327,21 +1215,13 @@ const isValueReference = (identifier) => {
1327
1215
  /**
1328
1216
  * Imports a surviving reference to is not a failure.
1329
1217
  *
1330
- * The first four are what the fold itself writes; all live in `cx` and pull no engine, so a
1218
+ * These are what the compiler itself writes; all live in `cx` and pull no engine, so a
1331
1219
  * reference to one is the fold having worked.
1332
- *
1333
- * `cva` and `sva` are there for the reason `SURVIVES_TO_RUNTIME` omits `not-foldable`: a
1334
- * recipe *definition* cannot fold to a class string and never could, and what it keeps is the
1335
- * recipe runtime rather than the css engine — which `failOnUnfolded` accepts. Their unfoldable
1336
- * invocations are reported separately, as `recipe-call`.
1337
1220
  */
1338
1221
  const PERMITTED_BINDINGS = new Set([
1339
1222
  "cx",
1340
- "cva",
1341
- "sva",
1342
- RECIPE_PICK_HELPER,
1343
- SPLIT_PROPS_HELPER,
1344
- LEAF_HELPER
1223
+ RECIPE_MAP_HELPER,
1224
+ SPLIT_PROPS_HELPER
1345
1225
  ]);
1346
1226
  /**
1347
1227
  * The pieces `trim` reduces a module specifier by, hoisted because a regex literal
@@ -1569,64 +1449,7 @@ const argumentsAccountedFor = (call, boxNode) => {
1569
1449
  return accountsForSource(args[0], boxNode);
1570
1450
  };
1571
1451
  const foldSource = (options) => {
1572
- const { ctx, code, parserResult, partial: partial_ = true, runtimeCss = createRuntimeCss(ctx), parseModule, recipeConfigCache, reportSurvivors, sourceFile: ownSourceFile } = options;
1573
- /**
1574
- * Recover the static half of a call the whole-call path gave up on. Only a
1575
- * single-argument `css()` qualifies: a pattern or recipe call takes props rather than a
1576
- * style object, and a multi-argument `css` is later-wins across the whole object.
1577
- */
1578
- const tryPartial = (item, call, rootName) => {
1579
- if (item.type !== "css" || !rootName) return void 0;
1580
- if (!ts_morph.Node.isCallExpression(call)) return void 0;
1581
- const args = call.getArguments();
1582
- if (args.length !== 1) return void 0;
1583
- const unboxed = _bamboocss_extractor.box.isMap(item.box) ? (0, _bamboocss_extractor.unbox)(item.box) : void 0;
1584
- if (!unboxed?.raw) return void 0;
1585
- const raw = unboxed.raw;
1586
- if (unboxed.spreadConditions?.length) return void 0;
1587
- const argument = args[0];
1588
- if (!argument || !ts_morph.Node.isObjectLiteralExpression(argument)) return void 0;
1589
- const leafName = findBambooBinding(call, LEAF_HELPER, isBambooCssModule, isShadowed);
1590
- const plan_ = (allowLeaf) => {
1591
- try {
1592
- return planPartialFold(argument, item.box, raw, {
1593
- ctx,
1594
- runtimeCss,
1595
- isAccounted: accountsForSource,
1596
- isStatic: (boxNode) => isStaticBox(boxNode),
1597
- allowLeaf,
1598
- leafName: leafName ?? "cssLeaf"
1599
- });
1600
- } catch {
1601
- return;
1602
- }
1603
- };
1604
- let plan = plan_(true);
1605
- if (!plan) return void 0;
1606
- const usesLeaf = () => plan.finite.some((entry) => !entry.emitsLiterals);
1607
- let cx = ensureCxImport(call, rootName, isBambooCssModule, isGeneratedCssModule, isShadowed, usesLeaf() ? [LEAF_HELPER] : []);
1608
- if (!cx && usesLeaf()) {
1609
- plan = plan_(false);
1610
- if (!plan) return void 0;
1611
- cx = ensureCxImport(call, rootName, isBambooCssModule, isGeneratedCssModule, isShadowed);
1612
- }
1613
- if (!cx) return void 0;
1614
- const callee = call.getExpression().getText();
1615
- const runtimePart = plan.dynamicText ? `${callee}(${plan.dynamicText})` : void 0;
1616
- const runtimeParts = runtimePart ? [runtimePart] : [];
1617
- const lowered = plan.finite.map((entry) => entry.expression);
1618
- const parts = [...plan.className ? [JSON.stringify(plan.className)] : [], ...plan.finiteFirst ? [...lowered, ...runtimeParts] : [...runtimeParts, ...lowered]];
1619
- if (!parts.length || parts.length === 1 && runtimePart) return void 0;
1620
- return {
1621
- className: plan.className,
1622
- classNames: [plan.className, ...plan.finite.filter((entry) => entry.emitsLiterals).flatMap((entry) => literalsIn(entry.expression))].filter(Boolean),
1623
- replacement: `${cx.name}(${parts.join(", ")})`,
1624
- insert: cx.insert,
1625
- runtimeCallee: runtimePart ? callee : void 0
1626
- };
1627
- };
1628
- const runtimeRecipe = createRuntimeRecipe(ctx);
1629
- const isConstantSlot = createConstantSlotCheck(ctx);
1452
+ const { ctx, code, parserResult, runtimeCss = createRuntimeCss(ctx), styleCompiler, maxRecipeStates, parseModule, recipeConfigCache, reportSurvivors, sourceFile: ownSourceFile } = options;
1630
1453
  const runtimeToken = createRuntimeToken(ctx);
1631
1454
  const runtimeTokenValue = createRuntimeTokenValue(ctx);
1632
1455
  /**
@@ -1639,7 +1462,7 @@ const foldSource = (options) => {
1639
1462
  *
1640
1463
  * A tsconfig path alias is resolved first, the same way `ImportMap.match` does. Without
1641
1464
  * that, `@site/styled-system/css` — the spelling this repo's own website uses — fails
1642
- * the check and silently loses partial folding, which is indistinguishable in the
1465
+ * the check and silently loses helper lowering, which is indistinguishable in the
1643
1466
  * diagnostics from a genuinely dynamic call.
1644
1467
  */
1645
1468
  const cssModules = ctx.imports.matchers.css?.mods ?? [];
@@ -1689,10 +1512,30 @@ const foldSource = (options) => {
1689
1512
  const isBambooCssModule = (mod) => matchesModule(mod, cssModules);
1690
1513
  const isGeneratedCssModule = (mod) => matchesModule(mod, [generatedCssModule]);
1691
1514
  /**
1515
+ * The generated css entry as spelled beside an imported config recipe.
1516
+ *
1517
+ * A decision table needs only `cvaMap`, but a module importing a config recipe often has
1518
+ * no css import to extend. Preserve a relative/aliased styled-system spelling by replacing
1519
+ * its `/recipes` suffix; falling back to the configured generated entry covers bare imports.
1520
+ */
1521
+ const configRecipeCssSpecifier = (call, binding) => {
1522
+ for (const declaration of call.getSourceFile().getImportDeclarations()) {
1523
+ if (declaration.isTypeOnly()) continue;
1524
+ if (!declaration.getNamedImports().some((named) => {
1525
+ if (named.isTypeOnly()) return false;
1526
+ return (named.getAliasNode() ?? named.getNameNode()).getText() === binding;
1527
+ })) continue;
1528
+ const mod = declaration.getModuleSpecifierValue().replaceAll("\\", "/");
1529
+ const at = mod.lastIndexOf("/recipes");
1530
+ if (at >= 0) return `${mod.slice(0, at)}/css`;
1531
+ }
1532
+ return generatedCssModule;
1533
+ };
1534
+ /**
1692
1535
  * How *this* module would have to spell the css module, learnt from one that already does.
1693
1536
  *
1694
1537
  * A file calling an imported recipe need not import the css module at all, so when the
1695
- * lowering needs `cvaPick` there is no spelling in the file to copy. The declaring module
1538
+ * lowering needs a decision-table helper there is no spelling in the file to copy. The declaring module
1696
1539
  * necessarily has one — `cva` came from it — and that is the spelling reused here.
1697
1540
  *
1698
1541
  * A bare or aliased specifier resolves identically from any file, so it is taken as
@@ -1744,9 +1587,8 @@ const foldSource = (options) => {
1744
1587
  * either end. Each hop is an alias symbol, so following them to a non-alias lands on the
1745
1588
  * declaration wherever it lives.
1746
1589
  *
1747
- * The class names do not depend on which module the call is in `getRecipeIdentity` hashes
1748
- * the config so a recipe lowered here produces exactly the string its own module's call
1749
- * sites produce, and exactly the one the runtime would have.
1590
+ * The selected declarations do not depend on which module the call is in. A recipe lowered
1591
+ * here therefore reaches the same globally shared atoms as a call in its declaring module.
1750
1592
  */
1751
1593
  const resolveImportedRecipe = (call, name, origin) => {
1752
1594
  if (importedRecipes.has(name)) return importedRecipes.get(name);
@@ -1763,7 +1605,6 @@ const foldSource = (options) => {
1763
1605
  const configs = /* @__PURE__ */ new Map();
1764
1606
  for (const [key, entry] of collected) configs.set(key, entry === AMBIGUOUS ? entry : {
1765
1607
  config: entry.config,
1766
- name: entry.name,
1767
1608
  box: void 0
1768
1609
  });
1769
1610
  foreign = {
@@ -1786,21 +1627,19 @@ const foldSource = (options) => {
1786
1627
  const skipped = [];
1787
1628
  const candidates = [];
1788
1629
  const seenRanges = /* @__PURE__ */ new Set();
1789
- /** Built on first use: most modules declare no inline recipe. */
1790
- let recipeConfigs;
1791
- /**
1792
- * Per inline recipe binding: calls seen, calls lowered.
1793
- *
1794
- * A binding whose every call lowered is no longer read, so its `cva({ … })` config can leave
1795
- * the bundle which is the whole point, the config being far larger than the runtime. But a
1796
- * bundler will not drop the call on its own: `cva` closes over the config and builds an
1797
- * object, and Rollup cannot prove that is side-effect free, so it keeps the expression and
1798
- * the module ends up *larger* than before folding. The annotation below is what makes the
1799
- * saving real, and it is only correct to claim it once nothing reads the binding.
1800
- */
1801
- const recipeCalls = /* @__PURE__ */ new Map();
1802
- /** Bindings whose `splitVariantProps` was rewritten, so that access no longer reads them. */
1803
- const loweredSplitProps = /* @__PURE__ */ new Set();
1630
+ const recipeConfigs = collectRecipeConfigs(parserResult);
1631
+ const recipeDefinitions = [];
1632
+ for (const [name, entry] of recipeConfigs) {
1633
+ if (entry === AMBIGUOUS) continue;
1634
+ const definition = entry.box?.getNode?.();
1635
+ if (!definition) continue;
1636
+ const call = ts_morph.Node.isCallExpression(definition) ? definition : definition.getFirstAncestorByKind(ts_morph.SyntaxKind.CallExpression);
1637
+ if (!call || code.slice(call.getStart(), call.getEnd()) !== call.getText()) continue;
1638
+ recipeDefinitions.push({
1639
+ name,
1640
+ call
1641
+ });
1642
+ }
1804
1643
  /** Ranges already reported as declined, so one call is never counted twice. */
1805
1644
  const reportedRanges = /* @__PURE__ */ new Set();
1806
1645
  const importCache = /* @__PURE__ */ new Map();
@@ -1921,13 +1760,7 @@ const foldSource = (options) => {
1921
1760
  continue;
1922
1761
  }
1923
1762
  if (!FOLDABLE_TYPES.has(type)) {
1924
- if (call && UNFOLDABLE_TYPES.has(type)) skipped.push({
1925
- name,
1926
- reason: "not-foldable",
1927
- start: call.getStart(),
1928
- end: call.getEnd()
1929
- });
1930
- if (call && type === RECIPE_CALL_TYPE && !isShadowed(call, name)) {
1763
+ if (call && (type === RECIPE_CALL_TYPE || type === "recipe") && !isShadowed(call, name)) {
1931
1764
  const start = call.getStart();
1932
1765
  const end = call.getEnd();
1933
1766
  const rangeKey = `${start}:${end}`;
@@ -1942,35 +1775,95 @@ const foldSource = (options) => {
1942
1775
  });
1943
1776
  continue;
1944
1777
  }
1945
- recipeConfigs ??= collectRecipeConfigs(parserResult);
1946
- if (!recipeConfigs.has(name) && item.origin) {
1947
- const imported = resolveImportedRecipe(call, name, item.origin);
1948
- if (imported) recipeConfigs.set(name, imported);
1778
+ if (isRawCall(call)) {
1779
+ skipped.push({
1780
+ name,
1781
+ reason: "raw-call",
1782
+ start,
1783
+ end
1784
+ });
1785
+ continue;
1786
+ }
1787
+ if (!recipeConfigs.has(name)) {
1788
+ if (type === "recipe") {
1789
+ const config = ctx.recipes.getConfig(name);
1790
+ if (config) {
1791
+ recipeConfigs.set(name, {
1792
+ config,
1793
+ box: void 0
1794
+ });
1795
+ helperModules.set(name, configRecipeCssSpecifier(call, name));
1796
+ }
1797
+ } else if (item.origin) {
1798
+ const imported = resolveImportedRecipe(call, name, item.origin);
1799
+ if (imported) recipeConfigs.set(name, imported);
1800
+ }
1949
1801
  }
1950
- const tally = recipeCalls.get(name) ?? {
1951
- seen: 0,
1952
- lowered: 0
1953
- };
1954
- tally.seen++;
1955
- recipeCalls.set(name, tally);
1956
1802
  const resolvedSelection = item.data?.length === 1 ? item.data[0] : void 0;
1957
1803
  const entry = recipeConfigs.get(name);
1958
- const lowered = lowerRecipeCall(call, entry, ctx, isInertExpression, resolvedSelection);
1959
- if (lowered.kind === "expression") {
1960
- const helper = ensureRecipeHelperImport(RECIPE_PICK_HELPER, call, isBambooCssModule, isGeneratedCssModule, isShadowed, helperModules.get(name));
1804
+ let inlineSlot;
1805
+ let inlineEnd = end;
1806
+ if (Array.isArray(entry?.config.slots)) {
1807
+ const parent = call.getParent();
1808
+ if (ts_morph.Node.isPropertyAccessExpression(parent) && parent.getExpression() === call) {
1809
+ const accessed = parent.getName();
1810
+ if (entry.config.slots.includes(accessed)) {
1811
+ inlineSlot = accessed;
1812
+ inlineEnd = parent.getEnd();
1813
+ }
1814
+ } else if (ts_morph.Node.isElementAccessExpression(parent) && parent.getExpression() === call) {
1815
+ const argument = parent.getArgumentExpression();
1816
+ const accessed = argument && (ts_morph.Node.isStringLiteral(argument) || ts_morph.Node.isNoSubstitutionTemplateLiteral(argument)) ? argument.getLiteralValue() : void 0;
1817
+ if (typeof accessed === "string" && entry.config.slots.includes(accessed)) {
1818
+ inlineSlot = accessed;
1819
+ inlineEnd = parent.getEnd();
1820
+ }
1821
+ }
1822
+ }
1823
+ const lowered = lowerRecipeCall(call, entry, styleCompiler, isInertExpression, resolvedSelection, inlineSlot, maxRecipeStates);
1824
+ if (lowered.kind === "dynamic-style") {
1825
+ const helper = ensureRecipeHelperImport(RECIPE_MAP_HELPER, call, isBambooCssModule, isGeneratedCssModule, isShadowed, helperModules.get(name));
1961
1826
  if (helper) {
1962
- tally.lowered++;
1963
1827
  candidates.push({
1964
1828
  item,
1965
1829
  call,
1966
1830
  node: call,
1967
1831
  start,
1968
- end,
1969
- replacement: helper.name === "cvaPick" ? lowered.expression : lowered.expression.replaceAll(`${RECIPE_PICK_HELPER}(`, `${helper.name}(`),
1970
- className: lowered.staticClasses,
1971
- classNames: lowered.classNames,
1832
+ end: inlineEnd,
1833
+ className: "",
1834
+ classNames: [],
1835
+ styleMap: lowered.map,
1836
+ mapHelperName: helper.name,
1972
1837
  insert: helper.insert,
1973
- configBox: entry?.box
1838
+ configBox: entry?.box,
1839
+ outputKind: lowered.map.outputKind === "slots" ? "slots" : void 0
1840
+ });
1841
+ continue;
1842
+ }
1843
+ skipped.push({
1844
+ name,
1845
+ reason: "recipe-call",
1846
+ start,
1847
+ end
1848
+ });
1849
+ continue;
1850
+ }
1851
+ if (lowered.kind === "slots") {
1852
+ const helper = lowered.helper ? ensureRecipeHelperImport(lowered.helper, call, isBambooCssModule, isGeneratedCssModule, isShadowed, helperModules.get(name)) : void 0;
1853
+ if (!lowered.helper || helper) {
1854
+ const replacement = lowered.helper && helper && helper.name !== lowered.helper ? lowered.expression.replaceAll(`${lowered.helper}(`, `${helper.name}(`) : lowered.expression;
1855
+ candidates.push({
1856
+ item,
1857
+ call,
1858
+ node: call,
1859
+ start,
1860
+ end: inlineEnd,
1861
+ replacement,
1862
+ className: "",
1863
+ classNames: lowered.classNames,
1864
+ insert: helper?.insert,
1865
+ configBox: entry?.box,
1866
+ outputKind: "slots"
1974
1867
  });
1975
1868
  continue;
1976
1869
  }
@@ -1983,16 +1876,16 @@ const foldSource = (options) => {
1983
1876
  continue;
1984
1877
  }
1985
1878
  if (lowered.kind === "class") {
1986
- tally.lowered++;
1987
1879
  candidates.push({
1988
1880
  item,
1989
1881
  call,
1990
1882
  node: call,
1991
1883
  start,
1992
- end,
1884
+ end: inlineEnd,
1993
1885
  replacement: JSON.stringify(lowered.className),
1994
1886
  className: lowered.className,
1995
1887
  classNames: lowered.className.split(" ").filter(Boolean),
1888
+ styleSet: lowered.styles,
1996
1889
  configBox: entry?.box
1997
1890
  });
1998
1891
  continue;
@@ -2056,37 +1949,7 @@ const foldSource = (options) => {
2056
1949
  });
2057
1950
  continue;
2058
1951
  }
2059
- if (slot && isConstantSlot(name, slot) && ts_morph.Node.isCallExpression(call) && call.getArguments().every(isInertExpression)) {
2060
- candidates.push({
2061
- item,
2062
- call,
2063
- node: call,
2064
- start,
2065
- end: foldEnd,
2066
- slot,
2067
- constantSlot: true
2068
- });
2069
- continue;
2070
- }
2071
1952
  if (!(ts_morph.Node.isCallExpression(call) && call.getArguments().length === 0 && item.data.length === 1) && !isStaticBox(item.box) || !hasStyles(item.data) || !argumentsAccountedFor(call, item.box)) {
2072
- const partial = partial_ ? tryPartial(item, call, rootName) : void 0;
2073
- if (partial) {
2074
- if (reportSurvivors && partial.runtimeCallee) skipped.push({
2075
- name,
2076
- reason: "runtime-binding",
2077
- start,
2078
- end
2079
- });
2080
- candidates.push({
2081
- item,
2082
- call,
2083
- node: call,
2084
- start,
2085
- end,
2086
- ...partial
2087
- });
2088
- continue;
2089
- }
2090
1953
  skipped.push({
2091
1954
  name,
2092
1955
  reason: "dynamic",
@@ -2105,12 +1968,220 @@ const foldSource = (options) => {
2105
1968
  });
2106
1969
  }
2107
1970
  /**
1971
+ * Resolve every fully static candidate to symbolic declarations before allocating a class.
1972
+ *
1973
+ * The normal fold can wait until the rewrite loop to compute a class string. Semantic
1974
+ * composition cannot: an enclosing `cx()` needs the declarations of its arguments so it can
1975
+ * discard overridden values before any string exists.
1976
+ */
1977
+ {
1978
+ for (const candidate of candidates) {
1979
+ if (candidate.styleSet || candidate.value !== void 0 || candidate.replacement) continue;
1980
+ const { item } = candidate;
1981
+ if (item.type === "css") {
1982
+ candidate.styleSet = styleCompiler.compose(...item.data);
1983
+ continue;
1984
+ }
1985
+ if (item.type === "pattern") {
1986
+ candidate.styleSet = styleCompiler.compose(...item.data.map((entry) => ctx.patterns.transform(item.name ?? "", entry)));
1987
+ continue;
1988
+ }
1989
+ if (item.type === "viewTransition") {
1990
+ const semantic = (0, _bamboocss_shared.viewTransitionClassName)(item.data[0], ctx.utility.prefix);
1991
+ candidate.className = styleCompiler.allocateClassString(semantic);
1992
+ candidate.classNames = [candidate.className];
1993
+ candidate.replacement = JSON.stringify(candidate.className);
1994
+ continue;
1995
+ }
1996
+ }
1997
+ const sourceFile = ownSourceFile ?? candidates[0]?.node.getSourceFile();
1998
+ if (sourceFile) {
1999
+ const cxBindings = /* @__PURE__ */ new Set();
2000
+ for (const declaration of sourceFile.getImportDeclarations()) {
2001
+ if (declaration.isTypeOnly() || !isBambooCssModule(declaration.getModuleSpecifierValue())) continue;
2002
+ for (const named of declaration.getNamedImports()) {
2003
+ if (named.isTypeOnly() || named.getNameNode().getText() !== "cx") continue;
2004
+ cxBindings.add((named.getAliasNode() ?? named.getNameNode()).getText());
2005
+ }
2006
+ }
2007
+ const byRange = new Map(candidates.map((candidate) => [`${candidate.start}:${candidate.end}`, candidate]));
2008
+ for (const call of sourceFile.getDescendantsOfKind(ts_morph.SyntaxKind.CallExpression)) {
2009
+ const callee = call.getExpression();
2010
+ if (!ts_morph.Node.isIdentifier(callee) || !cxBindings.has(callee.getText()) || isShadowed(call, callee.getText())) continue;
2011
+ const matched = [];
2012
+ const parts = [];
2013
+ const dynamic = [];
2014
+ const constantCandidates = [];
2015
+ let supported = true;
2016
+ const take = (arg) => {
2017
+ const candidate = byRange.get(`${arg.getStart()}:${arg.getEnd()}`);
2018
+ if (candidate?.styleMap?.outputKind === "class") {
2019
+ dynamic.push(candidate);
2020
+ parts.push({
2021
+ kind: "dynamic",
2022
+ candidate
2023
+ });
2024
+ return true;
2025
+ }
2026
+ if (candidate?.styleSet) {
2027
+ matched.push(candidate);
2028
+ parts.push({
2029
+ kind: "style",
2030
+ candidate
2031
+ });
2032
+ return true;
2033
+ }
2034
+ if (candidate?.item.type === "viewTransition" && candidate.replacement && candidate.className) {
2035
+ constantCandidates.push(candidate);
2036
+ parts.push({
2037
+ kind: "class",
2038
+ value: candidate.className,
2039
+ candidate
2040
+ });
2041
+ return true;
2042
+ }
2043
+ if (ts_morph.Node.isStringLiteral(arg) || ts_morph.Node.isNoSubstitutionTemplateLiteral(arg)) {
2044
+ parts.push({
2045
+ kind: "class",
2046
+ value: arg.getLiteralValue()
2047
+ });
2048
+ return true;
2049
+ }
2050
+ if (ts_morph.Node.isArrayLiteralExpression(arg)) {
2051
+ for (const element of arg.getElements()) if (ts_morph.Node.isSpreadElement(element) || !take(element)) return false;
2052
+ return true;
2053
+ }
2054
+ if (arg.getKind() === ts_morph.SyntaxKind.FalseKeyword || arg.getKind() === ts_morph.SyntaxKind.TrueKeyword || ts_morph.Node.isNumericLiteral(arg) || arg.getKind() === ts_morph.SyntaxKind.NullKeyword || ts_morph.Node.isIdentifier(arg) && arg.getText() === "undefined") return true;
2055
+ return false;
2056
+ };
2057
+ for (const arg of call.getArguments()) {
2058
+ if (take(arg)) continue;
2059
+ supported = false;
2060
+ break;
2061
+ }
2062
+ if (dynamic.length > 1) {
2063
+ skipped.push({
2064
+ name: "cx",
2065
+ reason: "dynamic",
2066
+ start: call.getStart(),
2067
+ end: call.getEnd()
2068
+ });
2069
+ continue;
2070
+ }
2071
+ if (!supported) {
2072
+ skipped.push({
2073
+ name: "cx",
2074
+ reason: "dynamic",
2075
+ start: call.getStart(),
2076
+ end: call.getEnd()
2077
+ });
2078
+ continue;
2079
+ }
2080
+ if (dynamic.length === 1 && matched.length > 0) {
2081
+ const dynamicCandidate = dynamic[0];
2082
+ const styleParts = parts.filter((part) => part.kind !== "class");
2083
+ const dynamicIndex = styleParts.findIndex((part) => part.kind === "dynamic");
2084
+ const before = styleParts.slice(0, dynamicIndex).filter((part) => part.kind === "style").map((part) => part.candidate.styleSet);
2085
+ const after = styleParts.slice(dynamicIndex + 1).filter((part) => part.kind === "style").map((part) => part.candidate.styleSet);
2086
+ const compiled = dynamicCandidate.styleMap.compile(before, after);
2087
+ const expression = compiled.usesHelper && dynamicCandidate.mapHelperName && dynamicCandidate.mapHelperName !== "cvaMap" ? compiled.expression.replaceAll(`${RECIPE_MAP_HELPER}(`, `${dynamicCandidate.mapHelperName}(`) : compiled.expression;
2088
+ const arguments_ = [];
2089
+ let wroteCompiled = false;
2090
+ for (const part of parts) {
2091
+ if (part.kind === "class") {
2092
+ if (part.value) arguments_.push(JSON.stringify(part.value));
2093
+ continue;
2094
+ }
2095
+ if (!wroteCompiled) {
2096
+ arguments_.push(expression);
2097
+ wroteCompiled = true;
2098
+ }
2099
+ }
2100
+ dynamicCandidate.subsumed = true;
2101
+ const first = styleParts[0].candidate;
2102
+ candidates.push({
2103
+ ...first,
2104
+ call,
2105
+ node: call,
2106
+ start: call.getStart(),
2107
+ end: call.getEnd(),
2108
+ displayName: "cx",
2109
+ replacement: arguments_.length === 1 ? arguments_[0] : `${callee.getText()}(${arguments_.join(", ")})`,
2110
+ className: "",
2111
+ classNames: [...compiled.classNames, ...parts.filter((part) => part.kind === "class").flatMap((part) => part.value.split(" "))].filter(Boolean),
2112
+ styleSet: void 0,
2113
+ styleMap: void 0,
2114
+ outputKind: void 0,
2115
+ insert: compiled.usesHelper ? dynamicCandidate.insert : void 0,
2116
+ sourceBoxes: styleParts.flatMap((part) => [part.candidate.item.box, part.candidate.configBox]).concat(constantCandidates.map((candidate) => candidate.item.box)).filter(Boolean)
2117
+ });
2118
+ continue;
2119
+ }
2120
+ if (matched.length === 0) {
2121
+ if (constantCandidates.length === 0) continue;
2122
+ const className = parts.filter((part) => part.kind === "class").map((part) => part.value).filter(Boolean).join(" ");
2123
+ const first = constantCandidates[0];
2124
+ candidates.push({
2125
+ ...first,
2126
+ call,
2127
+ node: call,
2128
+ start: call.getStart(),
2129
+ end: call.getEnd(),
2130
+ displayName: "cx",
2131
+ replacement: JSON.stringify(className),
2132
+ className,
2133
+ classNames: className.split(" ").filter(Boolean),
2134
+ sourceBoxes: constantCandidates.map((candidate) => candidate.item.box).filter(Boolean)
2135
+ });
2136
+ continue;
2137
+ }
2138
+ const merged = styleCompiler.compose(...matched.map((candidate) => candidate.styleSet));
2139
+ const compiled = styleCompiler.className(merged);
2140
+ const classParts = [];
2141
+ let wroteCompiled = false;
2142
+ for (const part of parts) {
2143
+ if (part.kind === "class") {
2144
+ if (part.value) classParts.push(part.value);
2145
+ continue;
2146
+ }
2147
+ if (!wroteCompiled && compiled) {
2148
+ classParts.push(compiled);
2149
+ wroteCompiled = true;
2150
+ }
2151
+ }
2152
+ const first = matched[0];
2153
+ candidates.push({
2154
+ ...first,
2155
+ call,
2156
+ node: call,
2157
+ start: call.getStart(),
2158
+ end: call.getEnd(),
2159
+ displayName: "cx",
2160
+ replacement: JSON.stringify(classParts.join(" ")),
2161
+ className: classParts.join(" "),
2162
+ classNames: classParts.flatMap((part) => part.split(" ")).filter(Boolean),
2163
+ styleSet: merged,
2164
+ sourceBoxes: [...matched.flatMap((candidate) => [candidate.item.box, candidate.configBox]), ...constantCandidates.map((candidate) => candidate.item.box)].filter(Boolean)
2165
+ });
2166
+ }
2167
+ }
2168
+ for (const candidate of candidates) {
2169
+ if (!candidate.styleMap || candidate.subsumed || candidate.replacement) continue;
2170
+ const compiled = candidate.styleMap.compile();
2171
+ candidate.replacement = compiled.usesHelper && candidate.mapHelperName && candidate.mapHelperName !== "cvaMap" ? compiled.expression.replaceAll(`${RECIPE_MAP_HELPER}(`, `${candidate.mapHelperName}(`) : compiled.expression;
2172
+ if (!compiled.usesHelper) candidate.insert = void 0;
2173
+ candidate.className = compiled.staticClasses;
2174
+ candidate.classNames = compiled.classNames;
2175
+ candidate.outputKind = compiled.outputKind === "slots" ? "slots" : void 0;
2176
+ }
2177
+ }
2178
+ /**
2108
2179
  * Ranges the rewrite actually replaced. Declared before the early return below, because
2109
2180
  * that return is now also a reporting point: a module with nothing to fold is exactly the
2110
2181
  * shape `reportSurvivors` exists to catch.
2111
2182
  */
2112
2183
  const applied = [];
2113
- if (candidates.length === 0) {
2184
+ if (candidates.length === 0 && recipeDefinitions.length === 0) {
2114
2185
  if (reportSurvivors) reportRuntimeBindings();
2115
2186
  return {
2116
2187
  code,
@@ -2120,7 +2191,15 @@ const foldSource = (options) => {
2120
2191
  dependencies: []
2121
2192
  };
2122
2193
  }
2123
- const dependencyScan = createDependencyScan(candidates[0].node.getSourceFile());
2194
+ const rewriteSourceFile = ownSourceFile ?? candidates[0]?.node.getSourceFile() ?? recipeDefinitions[0]?.call.getSourceFile();
2195
+ if (!rewriteSourceFile) return {
2196
+ code,
2197
+ map: null,
2198
+ folded,
2199
+ skipped,
2200
+ dependencies: []
2201
+ };
2202
+ const dependencyScan = createDependencyScan(rewriteSourceFile);
2124
2203
  candidates.sort((a, b) => a.start - b.start || b.end - a.end);
2125
2204
  const magic = new magic_string.default(code);
2126
2205
  const insertedNames = /* @__PURE__ */ new Set();
@@ -2134,7 +2213,7 @@ const foldSource = (options) => {
2134
2213
  const collides = (edits) => edits.some(([start, end]) => applied.some(([from, to]) => start < to && from < end));
2135
2214
  for (const candidate of candidates) {
2136
2215
  const { item, start, end } = candidate;
2137
- const name = item.name ?? item.type ?? "";
2216
+ const name = candidate.displayName ?? item.name ?? item.type ?? "";
2138
2217
  const ranges = [[start, end]];
2139
2218
  if (collides(ranges)) {
2140
2219
  skipped.push({
@@ -2166,7 +2245,7 @@ const foldSource = (options) => {
2166
2245
  applied.push(...ranges);
2167
2246
  folded.push({
2168
2247
  name,
2169
- kind: "class",
2248
+ kind: candidate.outputKind ?? "class",
2170
2249
  className: candidate.className,
2171
2250
  classNames: (candidate.classNames ?? [candidate.className]).filter(Boolean),
2172
2251
  start,
@@ -2174,24 +2253,13 @@ const foldSource = (options) => {
2174
2253
  });
2175
2254
  collectSourceFiles(item.box, dependencyScan);
2176
2255
  if (candidate.configBox) collectSourceFiles(candidate.configBox, dependencyScan);
2256
+ for (const box of candidate.sourceBoxes ?? []) collectSourceFiles(box, dependencyScan);
2177
2257
  continue;
2178
2258
  }
2179
2259
  let className;
2180
2260
  try {
2181
2261
  if (item.type === "pattern") className = runtimeCss(...item.data.map((entry) => ctx.patterns.transform(name, entry)));
2182
- else if (item.type === "recipe") {
2183
- const resolved = candidate.constantSlot ? runtimeRecipe(name, {}, candidate.slot) : item.data.length === 1 ? runtimeRecipe(name, item.data[0], candidate.slot) : void 0;
2184
- if (resolved == null) {
2185
- skipped.push({
2186
- name,
2187
- reason: "unsupported-kind",
2188
- start,
2189
- end
2190
- });
2191
- continue;
2192
- }
2193
- className = resolved;
2194
- } else className = runtimeCss(...item.data);
2262
+ else className = runtimeCss(...item.data);
2195
2263
  } catch {
2196
2264
  skipped.push({
2197
2265
  name,
@@ -2201,22 +2269,13 @@ const foldSource = (options) => {
2201
2269
  });
2202
2270
  continue;
2203
2271
  }
2204
- if (!className) {
2205
- skipped.push({
2206
- name,
2207
- reason: "empty",
2208
- start,
2209
- end
2210
- });
2211
- continue;
2212
- }
2213
2272
  magic.overwrite(start, end, JSON.stringify(className));
2214
2273
  applied.push(...ranges);
2215
2274
  folded.push({
2216
2275
  name,
2217
2276
  kind: "class",
2218
2277
  className,
2219
- classNames: [className],
2278
+ classNames: className ? [className] : [],
2220
2279
  start,
2221
2280
  end
2222
2281
  });
@@ -2232,7 +2291,6 @@ const foldSource = (options) => {
2232
2291
  const importedConfig = !local && importsFor(recipeSourceFile).has(target.getText()) ? ctx.recipes.getConfig(target.getText()) : void 0;
2233
2292
  const entry = local && local !== AMBIGUOUS ? local : importedConfig ? {
2234
2293
  config: importedConfig,
2235
- name: "",
2236
2294
  box: void 0
2237
2295
  } : void 0;
2238
2296
  if (!entry) continue;
@@ -2250,17 +2308,21 @@ const foldSource = (options) => {
2250
2308
  magic.overwrite(start, end, `${helper.name}(${args[0].getText()}, ${JSON.stringify(keys)})`);
2251
2309
  applyInsert(helper.insert);
2252
2310
  applied.push([start, end]);
2253
- loweredSplitProps.add(target.getText());
2254
2311
  }
2255
- for (const [binding, tally] of recipeCalls) {
2256
- if (!tally.seen || tally.lowered !== tally.seen) continue;
2257
- const definition = recipeConfigs?.get(binding)?.box?.getNode?.();
2258
- if (!definition) continue;
2259
- const call = ts_morph.Node.isCallExpression(definition) ? definition : definition.getFirstAncestorByKind(ts_morph.SyntaxKind.CallExpression);
2260
- if (!call) continue;
2312
+ for (const { name, call } of recipeDefinitions) {
2261
2313
  const start = call.getStart();
2262
- if (code.slice(start, call.getEnd()) !== call.getText()) continue;
2263
- magic.appendLeft(start, "/*#__PURE__*/");
2314
+ const end = call.getEnd();
2315
+ if (collides([[start, end]])) continue;
2316
+ magic.overwrite(start, end, "undefined");
2317
+ applied.push([start, end]);
2318
+ folded.push({
2319
+ name,
2320
+ kind: "definition",
2321
+ className: "",
2322
+ classNames: [],
2323
+ start,
2324
+ end
2325
+ });
2264
2326
  }
2265
2327
  /**
2266
2328
  * Bindings from a bamboo module still referenced once every rewrite is applied.
@@ -2268,21 +2330,65 @@ const foldSource = (options) => {
2268
2330
  * Deliberately not driven by `parserResult`: that is the recogniser, and the point here is
2269
2331
  * to catch what it did not see. A namespace import called as `s.cva(...)`, a default
2270
2332
  * import, a specifier that resolved to nothing — each leaves a live reference and no ledger
2271
- * entry at all, which is how `failOnUnfolded` came to pass a build that still shipped the engine.
2333
+ * entry at all, which used to let a build silently ship the engine.
2272
2334
  *
2273
- * The helpers the fold itself writes are excluded: `cx`, `cvaPick`, `splitProps` and the
2274
- * leaf helper live in `cx` and pull no engine, so a reference to one is the fold working
2275
- * rather than failing.
2335
+ * The helpers the compiler writes are excluded because they pull no style engine. `cx` is
2336
+ * also allowed to remain when it joins an arbitrary external class; only fully analyzable
2337
+ * arguments receive Bamboo's semantic composition guarantee.
2276
2338
  */
2277
2339
  function reportRuntimeBindings() {
2278
- const sourceFile = ownSourceFile ?? candidates[0]?.node.getSourceFile();
2340
+ const sourceFile = ownSourceFile ?? candidates[0]?.node.getSourceFile() ?? recipeDefinitions[0]?.call.getSourceFile();
2279
2341
  if (!sourceFile) return;
2342
+ for (const [binding, entry] of recipeConfigs) {
2343
+ if (entry === AMBIGUOUS) continue;
2344
+ const definition = entry.box?.getNode?.();
2345
+ if (!definition || definition.getSourceFile() !== sourceFile) continue;
2346
+ const nameNode = definition.getFirstAncestorByKind(ts_morph.SyntaxKind.VariableDeclaration)?.getNameNode();
2347
+ if (!nameNode || !ts_morph.Node.isIdentifier(nameNode)) continue;
2348
+ 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;
2349
+ const survivor = nameNode.findReferencesAsNodes().find((ref) => !applied.some(([from, to]) => ref.getStart() >= from && ref.getStart() < to));
2350
+ if (!survivor) continue;
2351
+ skipped.push({
2352
+ name: binding,
2353
+ reason: "runtime-binding",
2354
+ start: survivor.getStart(),
2355
+ end: survivor.getEnd()
2356
+ });
2357
+ }
2280
2358
  const bambooModules = [
2281
2359
  ...cssModules,
2282
2360
  ...ctx.imports.matchers.recipe?.mods ?? [],
2283
2361
  ...ctx.imports.matchers.pattern?.mods ?? [],
2284
2362
  ...ctx.imports.matchers.tokens?.mods ?? []
2285
2363
  ];
2364
+ for (const call of sourceFile.getDescendantsOfKind(ts_morph.SyntaxKind.CallExpression)) {
2365
+ const callee = call.getExpression();
2366
+ const argument = call.getArguments()[0];
2367
+ if (!argument || !ts_morph.Node.isStringLiteral(argument) && !ts_morph.Node.isNoSubstitutionTemplateLiteral(argument)) continue;
2368
+ if (!matchesModule(argument.getLiteralValue(), bambooModules)) continue;
2369
+ const isDynamicImport = callee.getKind() === ts_morph.SyntaxKind.ImportKeyword;
2370
+ const isRequire = ts_morph.Node.isIdentifier(callee) && callee.getText() === "require" && !isShadowed(call, "require");
2371
+ if (!isDynamicImport && !isRequire) continue;
2372
+ skipped.push({
2373
+ name: isDynamicImport ? "import" : "require",
2374
+ reason: "runtime-binding",
2375
+ start: call.getStart(),
2376
+ end: call.getEnd()
2377
+ });
2378
+ }
2379
+ for (const declaration of sourceFile.getDescendantsOfKind(ts_morph.SyntaxKind.ImportEqualsDeclaration)) {
2380
+ if (declaration.isTypeOnly()) continue;
2381
+ const reference = declaration.getModuleReference();
2382
+ if (!ts_morph.Node.isExternalModuleReference(reference)) continue;
2383
+ const expression = reference.getExpression();
2384
+ if (!expression || !ts_morph.Node.isStringLiteral(expression) || !matchesModule(expression.getLiteralValue(), bambooModules)) continue;
2385
+ skipped.push({
2386
+ name: declaration.getName(),
2387
+ reason: "runtime-binding",
2388
+ start: declaration.getStart(),
2389
+ end: declaration.getEnd()
2390
+ });
2391
+ }
2286
2392
  /** Local name -> what to call it in the report. */
2287
2393
  const watched = /* @__PURE__ */ new Map();
2288
2394
  for (const declaration of sourceFile.getImportDeclarations()) {
@@ -2380,6 +2486,122 @@ const foldSource = (options) => {
2380
2486
  };
2381
2487
  };
2382
2488
  //#endregion
2489
+ //#region src/style-set.ts
2490
+ const isRecord = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
2491
+ /** A compound selector matches only through variant classes the recipe actually emits. */
2492
+ const matchesCompound = (compound, selection, variants) => {
2493
+ for (const [key, expected] of Object.entries(compound)) {
2494
+ if (key === "css") continue;
2495
+ const declared = variants?.[key];
2496
+ const selected = selection[key];
2497
+ if (selected == null || !declared || !Object.hasOwn(declared, String(selected))) return false;
2498
+ if (!(Array.isArray(expected) ? expected : [expected]).some((value) => value != null && String(selected) === String(value))) return false;
2499
+ }
2500
+ return true;
2501
+ };
2502
+ /**
2503
+ * Resolve the style fragments one recipe call contributes, in emitted-rule precedence.
2504
+ *
2505
+ * This intentionally rejects conditional variant *selections*. A scalar selects a style
2506
+ * object; an object such as `{ base: 'sm', md: 'lg' }` selects several objects under
2507
+ * conditions and needs a separate lowering. Returning `undefined` rejects that call instead
2508
+ * of silently compiling only one branch.
2509
+ */
2510
+ const createStaticStyleSetCompiler = (ctx, runtimeCss, allocateClassString = (className) => className) => {
2511
+ const { mergeCssUncached } = (0, _bamboocss_shared.createMergeCss)(createCssContext(ctx));
2512
+ const compose = (...styles) => mergeCssUncached(...styles);
2513
+ const resolveRecipe = (config, input = {}, slot) => {
2514
+ const slots = Array.isArray(config.slots) ? config.slots : void 0;
2515
+ if (Boolean(slots) !== Boolean(slot)) return void 0;
2516
+ if (slot && !slots?.includes(slot)) return void 0;
2517
+ const selection = {
2518
+ ...config.defaultVariants ?? {},
2519
+ ...(0, _bamboocss_shared.compact)(input)
2520
+ };
2521
+ if (Object.values(selection).some((value) => isRecord(value))) return void 0;
2522
+ const fragments = [];
2523
+ const take = (candidate) => {
2524
+ if (!isRecord(candidate)) return;
2525
+ const styles = slot ? candidate[slot] : candidate;
2526
+ if (isRecord(styles)) fragments.push(styles);
2527
+ };
2528
+ take(config.base);
2529
+ for (const variant of Object.keys(config.variants ?? {})) {
2530
+ const value = selection[variant];
2531
+ if (value == null) continue;
2532
+ take(config.variants?.[variant]?.[String(value)]);
2533
+ }
2534
+ for (const compound of config.compoundVariants ?? []) {
2535
+ if (!isRecord(compound) || !matchesCompound(compound, selection, config.variants)) continue;
2536
+ take(compound.css);
2537
+ }
2538
+ return compose(...fragments);
2539
+ };
2540
+ return {
2541
+ compose,
2542
+ resolveRecipe,
2543
+ className: (...styles) => runtimeCss(...styles),
2544
+ allocateClassString
2545
+ };
2546
+ };
2547
+ //#endregion
2548
+ //#region src/static-session.ts
2549
+ const ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
2550
+ const denseName = (index) => {
2551
+ let value = index;
2552
+ let result = "";
2553
+ do {
2554
+ result = ALPHABET[value % 52] + result;
2555
+ value = Math.floor(value / 52) - 1;
2556
+ } while (value >= 0);
2557
+ return `_${result}`;
2558
+ };
2559
+ const createStaticCompilationSession = (denseClassNames = true) => {
2560
+ const mode = denseClassNames === true ? "stable" : denseClassNames;
2561
+ const session = {
2562
+ utilityLayer: "utilities",
2563
+ sourcemap: false,
2564
+ cssLoaded: false,
2565
+ transformedFiles: /* @__PURE__ */ new Set(),
2566
+ extractedFiles: /* @__PURE__ */ new Set(),
2567
+ prunableClasses: /* @__PURE__ */ new Set(),
2568
+ viewTransitionClasses: /* @__PURE__ */ new Set(),
2569
+ usedClasses: /* @__PURE__ */ new Set(),
2570
+ denseClasses: /* @__PURE__ */ new Map(),
2571
+ semanticClasses: /* @__PURE__ */ new Map(),
2572
+ denseClassNames: Boolean(mode),
2573
+ allocateClassString(className) {
2574
+ if (!mode) return className;
2575
+ return className.split(" ").filter(Boolean).map((semantic) => {
2576
+ let dense = session.denseClasses.get(semantic);
2577
+ if (!dense) {
2578
+ dense = mode === "local" ? denseName(session.denseClasses.size) : `_${(0, _bamboocss_shared.toHash)(semantic)}`;
2579
+ const collision = session.semanticClasses.get(dense);
2580
+ if (collision && collision !== semantic) throw new Error(`Bamboo compact class collision between ${JSON.stringify(collision)} and ${JSON.stringify(semantic)}. Disable \`denseClassNames\` for this build.`);
2581
+ session.denseClasses.set(semantic, dense);
2582
+ session.semanticClasses.set(dense, semantic);
2583
+ }
2584
+ return dense;
2585
+ }).join(" ");
2586
+ },
2587
+ markClassUsed(className) {
2588
+ const semantic = session.semanticClasses.get(className) ?? className;
2589
+ session.usedClasses.add((0, _bamboocss_shared.esc)(semantic));
2590
+ }
2591
+ };
2592
+ return session;
2593
+ };
2594
+ const resetStaticCompilationSession = (session) => {
2595
+ session.cssLoaded = false;
2596
+ session.transformedFiles.clear();
2597
+ session.extractedFiles.clear();
2598
+ session.prunableClasses.clear();
2599
+ session.viewTransitionClasses.clear();
2600
+ session.usedClasses.clear();
2601
+ session.denseClasses.clear();
2602
+ session.semanticClasses.clear();
2603
+ };
2604
+ //#endregion
2383
2605
  //#region src/plugin.ts
2384
2606
  const DEFAULT_EXTENSIONS = /\.(?:[cm]?[jt]sx?)$/;
2385
2607
  const NODE_MODULES = /node_modules/;
@@ -2422,15 +2644,17 @@ const formatSkipped = (id, skipped) => {
2422
2644
  *
2423
2645
  * Two plugins, because they do unrelated jobs on different schedules. The first emits the
2424
2646
  * stylesheet as a virtual module and runs in dev and build alike — that is the integration,
2425
- * and nothing styles without it. The second is the optional build-time fold.
2647
+ * and nothing styles without it. The second compiles every Bamboo source call in both dev
2648
+ * and build; there is no runtime styling fallback.
2426
2649
  *
2427
- * The fold runs with `enforce: 'pre'` so it sees module source as close as possible to what
2650
+ * The compiler runs with `enforce: 'pre'` so it sees module source as close as possible to what
2428
2651
  * the CSS extractor reads off disk. A plugin that rewrites style calls before bamboo
2429
2652
  * sees them would otherwise make the two disagree, and a folded class could end up
2430
2653
  * with no matching rule.
2431
2654
  */
2432
2655
  const bamboocss = (options = {}) => {
2433
- const { transform = true, partial, configPath, cwd, reportSkipped = false, reportSummary = true, failOnUnfolded = false } = options;
2656
+ const { configPath, cwd, reportSkipped = false, reportSummary = true, denseClassNames = true, maxRecipeStates } = options;
2657
+ if (maxRecipeStates !== void 0 && (!Number.isSafeInteger(maxRecipeStates) || maxRecipeStates < 1)) throw new Error("bamboocss: `maxRecipeStates` must be a positive safe integer.");
2434
2658
  /** Totals across the build, for the summary. */
2435
2659
  const totals = {
2436
2660
  folded: 0,
@@ -2438,8 +2662,35 @@ const bamboocss = (options = {}) => {
2438
2662
  filesWithFolds: 0,
2439
2663
  skipped: /* @__PURE__ */ new Map()
2440
2664
  };
2441
- /** Under `failOnUnfolded`, every call that would still reach the runtime. */
2665
+ const staticSession = createStaticCompilationSession(denseClassNames);
2442
2666
  const survivors = [];
2667
+ const survivorKeys = /* @__PURE__ */ new Set();
2668
+ const addSurvivor = (entry) => {
2669
+ const key = `${entry.file}:${entry.line}:${entry.name}:${entry.reason}`;
2670
+ if (survivorKeys.has(key)) return;
2671
+ survivorKeys.add(key);
2672
+ survivors.push(entry);
2673
+ };
2674
+ const clearSurvivorsFor = (file) => {
2675
+ for (let index = survivors.length - 1; index >= 0; index--) if (survivors[index]?.file === file) survivors.splice(index, 1);
2676
+ survivorKeys.clear();
2677
+ for (const entry of survivors) survivorKeys.add(`${entry.file}:${entry.line}:${entry.name}:${entry.reason}`);
2678
+ };
2679
+ const createSurvivorError = (entries) => {
2680
+ const byFile = /* @__PURE__ */ new Map();
2681
+ for (const entry of entries) {
2682
+ const list = byFile.get(entry.file) ?? [];
2683
+ list.push(entry);
2684
+ byFile.set(entry.file, list);
2685
+ }
2686
+ const named = (entry) => entry.reason === "runtime-binding" || entry.reason === "compile-failed" ? entry.name : `${entry.name}()`;
2687
+ const detail = (0, _bamboocss_shared.truncateList)(Array.from(byFile.entries(), ([file, fileEntries]) => [` ${file}`, ...fileEntries.map((entry) => ` ${entry.line}: ${named(entry)} — ${entry.reason}`)].join("\n")), {
2688
+ unit: "file",
2689
+ separator: "\n"
2690
+ });
2691
+ const threw = entries.some((entry) => entry.reason === "compile-failed");
2692
+ 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`.");
2693
+ };
2443
2694
  /**
2444
2695
  * Recipe configs read out of modules other than the one being transformed.
2445
2696
  *
@@ -2449,6 +2700,8 @@ const bamboocss = (options = {}) => {
2449
2700
  const recipeConfigCache = /* @__PURE__ */ new Map();
2450
2701
  let ctx;
2451
2702
  let runtimeCss;
2703
+ let styleCompiler;
2704
+ let command = "build";
2452
2705
  let setup;
2453
2706
  const ensureContext = async () => {
2454
2707
  if (!setup) setup = (0, _bamboocss_node.loadConfigAndCreateContext)({
@@ -2456,25 +2709,31 @@ const bamboocss = (options = {}) => {
2456
2709
  cwd
2457
2710
  }).then((loaded) => {
2458
2711
  ctx = loaded;
2459
- runtimeCss = createRuntimeCss(loaded);
2712
+ const semanticCss = createRuntimeCss(loaded);
2713
+ runtimeCss = (...styles) => staticSession.allocateClassString(semanticCss(...styles));
2714
+ styleCompiler = createStaticStyleSetCompiler(loaded, runtimeCss, staticSession.allocateClassString);
2460
2715
  });
2461
2716
  await setup;
2462
2717
  };
2463
2718
  return [bamboocssCss({
2464
2719
  configPath,
2465
- cwd
2720
+ cwd,
2721
+ session: staticSession
2466
2722
  }), {
2467
- name: "bamboocss:fold",
2723
+ name: "bamboocss:compiler",
2468
2724
  enforce: "pre",
2469
- apply: "build",
2725
+ configResolved(config) {
2726
+ command = config.command;
2727
+ },
2470
2728
  async buildStart() {
2471
- if (!transform) return;
2472
2729
  totals.folded = 0;
2473
2730
  totals.files = 0;
2474
2731
  totals.filesWithFolds = 0;
2475
2732
  totals.skipped.clear();
2476
2733
  survivors.length = 0;
2734
+ survivorKeys.clear();
2477
2735
  recipeConfigCache.clear();
2736
+ resetStaticCompilationSession(staticSession);
2478
2737
  await ensureContext();
2479
2738
  },
2480
2739
  /**
@@ -2498,7 +2757,7 @@ const bamboocss = (options = {}) => {
2498
2757
  * the parser still holds the file.
2499
2758
  */
2500
2759
  watchChange(id, change) {
2501
- if (!transform || !ctx) return;
2760
+ if (!ctx) return;
2502
2761
  if (!shouldTransform(id)) return;
2503
2762
  const [filePath] = id.split("?");
2504
2763
  if (!filePath) return;
@@ -2510,101 +2769,90 @@ const bamboocss = (options = {}) => {
2510
2769
  ctx.project.reloadSourceFile(filePath);
2511
2770
  },
2512
2771
  async transform(code, id) {
2513
- if (!transform) return null;
2514
2772
  if (!shouldTransform(id)) return null;
2515
2773
  await ensureContext();
2516
- if (!ctx || !runtimeCss) return null;
2774
+ if (!ctx || !runtimeCss || !styleCompiler) return null;
2517
2775
  const [filePath] = id.split("?");
2776
+ clearSurvivorsFor(filePath);
2518
2777
  if (isGeneratedOutput(filePath, ctx)) return null;
2519
2778
  let result;
2520
2779
  try {
2521
2780
  const sourceFile = ctx.project.addSourceFile(filePath, code);
2522
2781
  const parserResult = ctx.project.parseSourceFile(filePath);
2523
- if (!parserResult || parserResult.isEmpty() && !failOnUnfolded) return null;
2782
+ if (!parserResult) return null;
2524
2783
  result = foldSource({
2525
2784
  ctx,
2526
2785
  code,
2527
2786
  parserResult,
2528
2787
  filePath,
2529
2788
  runtimeCss,
2530
- partial,
2789
+ styleCompiler,
2790
+ maxRecipeStates,
2531
2791
  parseModule: (path) => ctx?.project.parseSourceFile(path),
2532
2792
  recipeConfigCache,
2533
- reportSurvivors: failOnUnfolded,
2793
+ reportSurvivors: true,
2534
2794
  sourceFile
2535
2795
  });
2536
2796
  } catch (error) {
2537
- _bamboocss_logger.logger.caughtError("vite:transform", `Failed to fold ${filePath}`, error);
2797
+ _bamboocss_logger.logger.caughtError("vite:transform", `Failed to compile ${filePath}`, error);
2538
2798
  totals.files++;
2539
- totals.skipped.set("fold-failed", (totals.skipped.get("fold-failed") ?? 0) + 1);
2540
- if (failOnUnfolded) survivors.push({
2799
+ totals.skipped.set("compile-failed", (totals.skipped.get("compile-failed") ?? 0) + 1);
2800
+ addSurvivor({
2541
2801
  file: filePath,
2542
2802
  line: 1,
2543
- name: "fold",
2544
- reason: "fold-failed"
2803
+ name: "compiler",
2804
+ reason: "compile-failed"
2545
2805
  });
2806
+ if (command === "serve") throw error;
2546
2807
  return null;
2547
2808
  }
2548
2809
  totals.files++;
2549
2810
  totals.folded += result.folded.length;
2550
2811
  if (result.folded.length) totals.filesWithFolds++;
2551
2812
  for (const entry of result.skipped) totals.skipped.set(entry.reason, (totals.skipped.get(entry.reason) ?? 0) + 1);
2552
- if (failOnUnfolded) {
2553
- for (const entry of result.skipped) {
2554
- if (!SURVIVES_TO_RUNTIME.has(entry.reason)) continue;
2555
- survivors.push({
2556
- file: filePath,
2557
- line: lineAt(code, entry.start),
2558
- name: entry.name,
2559
- reason: entry.reason
2560
- });
2561
- }
2562
- if ((ctx.config.leafFallback ?? true) && result.code.includes("cssLeaf(")) survivors.push({
2813
+ if (result.folded.some((entry) => entry.kind === "class" || entry.kind === "slots")) staticSession.transformedFiles.add((0, node_path.resolve)(filePath));
2814
+ for (const entry of result.folded) for (const className of entry.classNames) staticSession.markClassUsed(className);
2815
+ for (const entry of result.skipped) {
2816
+ if (entry.reason === "not-imported" || entry.reason === "overlapping") continue;
2817
+ if (entry.name === "cx" && entry.reason === "dynamic") continue;
2818
+ addSurvivor({
2563
2819
  file: filePath,
2564
- line: lineAt(result.code, result.code.indexOf("cssLeaf(")),
2565
- name: "cssLeaf",
2566
- reason: "lowered-leaf"
2820
+ line: lineAt(code, entry.start),
2821
+ name: entry.name,
2822
+ reason: entry.reason
2567
2823
  });
2568
2824
  }
2569
2825
  if (reportSkipped && result.skipped.length) _bamboocss_logger.logger.info("vite:transform", formatSkipped(filePath, result.skipped));
2570
2826
  for (const dependency of result.dependencies) this.addWatchFile?.(dependency);
2827
+ if (command === "serve" && survivors.some((entry) => entry.file === filePath)) throw createSurvivorError(survivors.filter((entry) => entry.file === filePath));
2571
2828
  if (!result.folded.length) return null;
2572
- _bamboocss_logger.logger.debug("vite:transform", `Folded ${result.folded.length} call(s) in ${filePath}`);
2829
+ _bamboocss_logger.logger.debug("vite:transform", `Compiled ${result.folded.length} call(s) in ${filePath}`);
2573
2830
  return {
2574
2831
  code: result.code,
2575
2832
  map: result.map
2576
2833
  };
2577
2834
  },
2578
2835
  buildEnd() {
2579
- if (failOnUnfolded && survivors.length) {
2580
- const byFile = /* @__PURE__ */ new Map();
2581
- for (const entry of survivors) {
2582
- const list = byFile.get(entry.file) ?? [];
2583
- list.push(entry);
2584
- byFile.set(entry.file, list);
2585
- }
2586
- const named = (e) => e.reason === "runtime-binding" || e.reason === "fold-failed" ? e.name : `${e.name}()`;
2587
- const detail = (0, _bamboocss_shared.truncateList)(Array.from(byFile.entries(), ([file, entries]) => [` ${file}`, ...entries.map((e) => ` ${e.line}: ${named(e)} — ${e.reason}`)].join("\n")), {
2836
+ if (survivors.length) throw createSurvivorError(survivors);
2837
+ if (typeof this.getModuleInfo === "function") {
2838
+ 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.`);
2839
+ const outsideExtraction = [...staticSession.transformedFiles].filter((file) => !staticSession.extractedFiles.has(file));
2840
+ if (outsideExtraction.length) throw new Error(`bamboocss: ${outsideExtraction.length} statically compiled module(s) are outside the CSS extraction graph:\n\n${(0, _bamboocss_shared.truncateList)(outsideExtraction.map((file) => ` ${file}`), {
2588
2841
  unit: "file",
2589
2842
  separator: "\n"
2590
- });
2591
- const threw = survivors.some((entry) => entry.reason === "fold-failed");
2592
- 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.");
2843
+ })}\n\nAdd them to \`include\` in bamboo.config, or no CSS rule can back their emitted classes.`);
2593
2844
  }
2594
- if (!transform || !reportSummary) return;
2845
+ if (!reportSummary) return;
2595
2846
  const declined = Array.from(totals.skipped.values()).reduce((sum, count) => sum + count, 0);
2596
2847
  const total = totals.folded + declined;
2597
2848
  if (!total) return;
2598
2849
  const share = Math.round(totals.folded / total * 100);
2599
2850
  const reasons = Array.from(totals.skipped.entries()).sort((a, b) => b[1] - a[1]).map(([reason, count]) => `${reason}=${count}`).join(" ");
2600
- _bamboocss_logger.logger.info("vite:transform", `Folded ${totals.folded}/${total} (${share}%) across ${totals.filesWithFolds}/${totals.files} files` + (reasons ? ` — declined: ${reasons}` : ""));
2851
+ _bamboocss_logger.logger.info("vite:transform", `Compiled ${totals.folded}/${total} (${share}%) across ${totals.filesWithFolds}/${totals.files} files` + (reasons ? ` — declined: ${reasons}` : ""));
2601
2852
  }
2602
2853
  }];
2603
2854
  };
2604
2855
  //#endregion
2605
2856
  exports.VIRTUAL_CSS_ID = VIRTUAL_CSS_ID;
2606
2857
  exports.bamboocss = bamboocss;
2607
- exports.bamboocssCss = bamboocssCss;
2608
- exports.createRuntimeCss = createRuntimeCss;
2609
2858
  exports.default = bamboocss;
2610
- exports.foldSource = foldSource;