@bamboocss/vite 1.26.0 → 1.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -3,10 +3,10 @@ import { logger } from "@bamboocss/logger";
3
3
  import { resolveTsPathPattern } from "@bamboocss/config/ts-path";
4
4
  import { box, maybeBoxNode, unbox } from "@bamboocss/extractor";
5
5
  import MagicString from "magic-string";
6
+ import { dirname, relative, resolve } from "node:path";
6
7
  import { Node, SyntaxKind, VariableDeclarationKind } from "ts-morph";
7
8
  import { Recipes, classFormatter } from "@bamboocss/core";
8
9
  import { compact, createCssUncached, createMergeCss, getRecipeClassNames, getRecipeIdentity, getSlotCompoundVariant, memo, withoutSpace } from "@bamboocss/shared";
9
- import { resolve } from "node:path";
10
10
  //#region src/css.ts
11
11
  /**
12
12
  * What a project imports to get the stylesheet.
@@ -823,7 +823,7 @@ const propertyKey = (nameNode) => {
823
823
  * there is nothing to match — the host here is any import of the generated css module, which
824
824
  * a file defining a recipe necessarily has, since `cva` came from it.
825
825
  */
826
- const ensureRecipeHelperImport = (imported, call, isBambooCssModule, isGeneratedCssModule, isShadowed) => {
826
+ const ensureRecipeHelperImport = (imported, call, isBambooCssModule, isGeneratedCssModule, isShadowed, newImportModule) => {
827
827
  const sourceFile = call.getSourceFile();
828
828
  let host;
829
829
  for (const declaration of sourceFile.getImportDeclarations()) {
@@ -839,9 +839,21 @@ const ensureRecipeHelperImport = (imported, call, isBambooCssModule, isGenerated
839
839
  }
840
840
  if (!host && isGeneratedCssModule(mod) && declaration.getNamedImports().length > 0) host = declaration;
841
841
  }
842
- if (!host) return void 0;
842
+ if (!host && !newImportModule) return void 0;
843
843
  if (declaredAtModuleScope(sourceFile).has(imported)) return void 0;
844
844
  if (isShadowed(call, imported)) return void 0;
845
+ if (!host) {
846
+ const anchor = sourceFile.getImportDeclarations().at(-1);
847
+ if (!anchor) return void 0;
848
+ return {
849
+ name: imported,
850
+ insert: {
851
+ pos: anchor.getEnd(),
852
+ names: [imported],
853
+ module: newImportModule
854
+ }
855
+ };
856
+ }
845
857
  const last = host.getNamedImports().at(-1);
846
858
  if (!last) return void 0;
847
859
  return {
@@ -901,6 +913,13 @@ const lowerRecipeCall = (call, entry, ctx, isInert, resolvedSelection) => {
901
913
  /**
902
914
  * `input(variantProps)` — a selection the build cannot see inside.
903
915
  *
916
+ * Inline recipes only. `cva` resolves a selection with `getRecipeClassNames`, which reads
917
+ * a variant value as a key and so cannot take a conditional — a `{ base, md }` object finds
918
+ * no entry and names no class, exactly as `cvaPick` does. A **config** recipe routes its
919
+ * selection through `createCss`, which *expands* conditions into one class per breakpoint,
920
+ * so a scalar lookup silently drops them. That is why this lowering is not applied to
921
+ * config recipes: for a dynamic axis the build cannot know which kind of value arrives.
922
+ *
904
923
  * The classes are still knowable: a recipe emits one per declared variant, so the call is
905
924
  * one term per variant reading that binding. This is the shape a wrapper component takes,
906
925
  * where the variants are the component's public API and cannot be literals by definition.
@@ -1084,6 +1103,9 @@ const createRuntimeCss = (ctx) => {
1084
1103
  * every module in the build — not once per `foldSource`, which would price a whole token
1085
1104
  * table into each of the overwhelming majority of modules that call `token()` zero times.
1086
1105
  * Keyed weakly so a context that goes out of scope takes its table with it.
1106
+ *
1107
+ * Both halves of the generated entry are stored, because `token()` and `token.var()` read
1108
+ * different ones and building a second table would pay the same per-project cost twice.
1087
1109
  */
1088
1110
  const tokenValues = /* @__PURE__ */ new WeakMap();
1089
1111
  const tokenValuesFor = (ctx) => {
@@ -1092,16 +1114,29 @@ const tokenValuesFor = (ctx) => {
1092
1114
  values = /* @__PURE__ */ new Map();
1093
1115
  for (const token of ctx.tokens.allTokens) {
1094
1116
  const { varRef, isVirtual, condition } = token.extensions;
1095
- values.set(token.name, isVirtual || condition !== "base" ? varRef : token.value);
1117
+ values.set(token.name, {
1118
+ value: isVirtual || condition !== "base" ? varRef : token.value,
1119
+ variable: varRef
1120
+ });
1096
1121
  }
1097
1122
  tokenValues.set(ctx, values);
1098
1123
  return values;
1099
1124
  };
1100
1125
  const createRuntimeToken = (ctx) => (path) => {
1101
- const value = tokenValuesFor(ctx).get(path);
1126
+ const value = tokenValuesFor(ctx).get(path)?.value;
1102
1127
  return typeof value === "string" ? value : void 0;
1103
1128
  };
1104
1129
  /**
1130
+ * The generated runtime's `token.var`, rebuilt in-process.
1131
+ *
1132
+ * Mirrors `tokenVar` in `generateTokenJs`, which reads the `variable` half of the same
1133
+ * entry `token()` reads the `value` half of. That half is `varRef` for every token
1134
+ * regardless of condition, so unlike `createRuntimeToken` there is no split to get wrong
1135
+ * and no non-string case to decline: a `var()` reference is a string or the token does not
1136
+ * exist. Which is what makes this the more foldable of the two.
1137
+ */
1138
+ const createRuntimeTokenVar = (ctx) => (path) => tokenValuesFor(ctx).get(path)?.variable || void 0;
1139
+ /**
1105
1140
  * Whether a slot's class is independent of the variant props.
1106
1141
  *
1107
1142
  * A scoped slot recipe delivers variants through `@scope` rules anchored on an enclosing
@@ -1148,11 +1183,24 @@ const createRuntimeRecipe = (ctx) => {
1148
1183
  }
1149
1184
  }
1150
1185
  });
1151
- const recipeStyles = isConstantSlot ? { [className]: "__ignore__" } : {
1186
+ const declaredValues = config.variants ?? {};
1187
+ /**
1188
+ * The same filter the generated `createRecipe` applies: only a value the config declares
1189
+ * names a class.
1190
+ *
1191
+ * Scalars only — a conditional or responsive value is an object of leaves, and the leaves
1192
+ * are what name classes when `createCss` walks them.
1193
+ */
1194
+ const onlyDeclared = (styles) => Object.fromEntries(Object.entries(styles).filter(([prop, value]) => {
1195
+ if (prop === className) return true;
1196
+ if (value === null || typeof value === "object") return true;
1197
+ return Object.hasOwn(declaredValues, prop) && Object.hasOwn(declaredValues[prop] ?? {}, String(value));
1198
+ }));
1199
+ const recipeStyles = isConstantSlot ? { [className]: "__ignore__" } : onlyDeclared({
1152
1200
  [className]: "__ignore__",
1153
1201
  ...defaultVariants,
1154
1202
  ...compact(variants)
1155
- };
1203
+ });
1156
1204
  if (compoundVariants.length > 0 && Object.keys(recipeStyles).some((prop) => typeof variants[prop] === "object")) return;
1157
1205
  if (isSlotRecipe) {
1158
1206
  const evaluated = anchors.length > 0 ? anchors : config.slots;
@@ -1192,6 +1240,22 @@ const literalsIn = (expression) => [...expression.matchAll(/"((?:[^"\\]|\\.)*)"/
1192
1240
  */
1193
1241
  const UNFOLDABLE_TYPES = new Set(["cva", "sva"]);
1194
1242
  /**
1243
+ * The skip reasons that leave a `css()`-family call in the output.
1244
+ *
1245
+ * `overlapping` is handled by the enclosing fold, and `not-imported` is somebody else's
1246
+ * function of the same name — neither leaves a call of ours. `not-foldable` is a `cva`/`sva`
1247
+ * definition, which keeps the recipe runtime rather than the css engine; see `strict`.
1248
+ */
1249
+ const SURVIVES_TO_RUNTIME = new Set([
1250
+ "dynamic",
1251
+ "runtime-binding",
1252
+ "raw-call",
1253
+ "unsupported-kind",
1254
+ "no-call-expression",
1255
+ "empty",
1256
+ "unresolved-token"
1257
+ ]);
1258
+ /**
1195
1259
  * A call of a recipe the file bound itself: `const badge = cva(...)`, then `badge({ tone })`.
1196
1260
  *
1197
1261
  * Folded when the whole selection resolves, reported under this reason when it does not.
@@ -1204,6 +1268,65 @@ const UNFOLDABLE_TYPES = new Set(["cva", "sva"]);
1204
1268
  */
1205
1269
  const RECIPE_CALL_TYPE = "cva-call";
1206
1270
  /**
1271
+ * An identifier that actually reads the binding.
1272
+ *
1273
+ * `getDescendantsOfKind(Identifier)` yields every name in the file, and most of them bind or
1274
+ * label rather than read: a JSX tag (`<button/>` against a recipe called `button`), an object
1275
+ * key, a property name, a declaration. Counting those failed builds on modules that had
1276
+ * folded completely — and `button`, `input`, `label`, `select`, `table`, `dialog` and `form`
1277
+ * are all ordinary recipe names as well as intrinsic elements.
1278
+ *
1279
+ * A type position is excluded for a different reason: it is erased, and with it the import.
1280
+ */
1281
+ const isValueReference = (identifier) => {
1282
+ const parent = identifier.getParent();
1283
+ if (!parent) return false;
1284
+ if (Node.isImportSpecifier(parent) || Node.isExportSpecifier(parent)) return false;
1285
+ if (Node.isImportClause(parent) || Node.isNamespaceImport(parent)) return false;
1286
+ if (Node.isPropertyAccessExpression(parent) && parent.getNameNode() === identifier) return false;
1287
+ if (Node.isQualifiedName(parent) && parent.getRight() === identifier) return false;
1288
+ if (Node.isPropertyAssignment(parent) && parent.getNameNode() === identifier) return false;
1289
+ if (Node.isMethodDeclaration(parent) || Node.isPropertyDeclaration(parent) || Node.isGetAccessorDeclaration(parent) || Node.isSetAccessorDeclaration(parent) || Node.isMethodSignature(parent) || Node.isPropertySignature(parent) || Node.isEnumMember(parent)) {
1290
+ if (parent.getNameNode() === identifier) return false;
1291
+ }
1292
+ if (Node.isLabeledStatement(parent) || Node.isBreakStatement(parent) || Node.isContinueStatement(parent)) return false;
1293
+ if (Node.isJsxOpeningElement(parent) || Node.isJsxSelfClosingElement(parent) || Node.isJsxClosingElement(parent)) {
1294
+ if (parent.getTagNameNode() === identifier) return identifier.getText()[0] === identifier.getText()[0]?.toUpperCase();
1295
+ }
1296
+ if (Node.isJsxAttribute(parent)) return false;
1297
+ if (Node.isBindingElement(parent) && parent.getPropertyNameNode() === identifier) return false;
1298
+ if ((Node.isVariableDeclaration(parent) || Node.isParameterDeclaration(parent) || Node.isBindingElement(parent) || Node.isFunctionDeclaration(parent) || Node.isClassDeclaration(parent)) && parent.getNameNode() === identifier) return false;
1299
+ return !identifier.getFirstAncestor((ancestor) => Node.isTypeNode(ancestor) || Node.isTypeAliasDeclaration(ancestor) || Node.isInterfaceDeclaration(ancestor));
1300
+ };
1301
+ /**
1302
+ * Imports a surviving reference to is not a failure.
1303
+ *
1304
+ * The first four are what the fold itself writes; all live in `cx` and pull no engine, so a
1305
+ * reference to one is the fold having worked.
1306
+ *
1307
+ * `cva` and `sva` are there for the reason `SURVIVES_TO_RUNTIME` omits `not-foldable`: a
1308
+ * recipe *definition* cannot fold to a class string and never could, and what it keeps is the
1309
+ * recipe runtime rather than the css engine — which `strict` accepts. Their unfoldable
1310
+ * invocations are reported separately, as `recipe-call`.
1311
+ */
1312
+ const PERMITTED_BINDINGS = new Set([
1313
+ "cx",
1314
+ "cva",
1315
+ "sva",
1316
+ RECIPE_PICK_HELPER,
1317
+ SPLIT_PROPS_HELPER,
1318
+ LEAF_HELPER
1319
+ ]);
1320
+ /**
1321
+ * The pieces `trim` reduces a module specifier by, hoisted because a regex literal
1322
+ * constructs a new object every time it is evaluated and `trim` runs per specifier per
1323
+ * import declaration per module.
1324
+ */
1325
+ const LEADING_RELATIVE = /^(?:\.\.?\/)+/;
1326
+ const TRAILING_SLASH = /\/$/;
1327
+ const MODULE_EXTENSION = /\.[mc]?[jt]sx?$/;
1328
+ const TRAILING_INDEX = /\/index$/;
1329
+ /**
1207
1330
  * An argument that cannot run anything when it is evaluated.
1208
1331
  *
1209
1332
  * `token(path, fallback)` evaluates both arguments before the call, so a fold that drops
@@ -1419,7 +1542,7 @@ const argumentsAccountedFor = (call, boxNode) => {
1419
1542
  return accountsForSource(args[0], boxNode);
1420
1543
  };
1421
1544
  const foldSource = (options) => {
1422
- const { ctx, code, parserResult, partial: partial_ = true, runtimeCss = createRuntimeCss(ctx) } = options;
1545
+ const { ctx, code, parserResult, partial: partial_ = true, runtimeCss = createRuntimeCss(ctx), parseModule, recipeConfigCache, reportSurvivors, sourceFile: ownSourceFile } = options;
1423
1546
  /**
1424
1547
  * Recover the static half of a call the whole-call path gave up on. Only a
1425
1548
  * single-argument `css()` qualifies: a pattern or recipe call takes props rather than a
@@ -1471,12 +1594,14 @@ const foldSource = (options) => {
1471
1594
  className: plan.className,
1472
1595
  classNames: [plan.className, ...plan.finite.filter((entry) => entry.emitsLiterals).flatMap((entry) => literalsIn(entry.expression))].filter(Boolean),
1473
1596
  replacement: `${cx.name}(${parts.join(", ")})`,
1474
- insert: cx.insert
1597
+ insert: cx.insert,
1598
+ runtimeCallee: runtimePart ? callee : void 0
1475
1599
  };
1476
1600
  };
1477
1601
  const runtimeRecipe = createRuntimeRecipe(ctx);
1478
1602
  const isConstantSlot = createConstantSlotCheck(ctx);
1479
1603
  const runtimeToken = createRuntimeToken(ctx);
1604
+ const runtimeTokenVar = createRuntimeTokenVar(ctx);
1480
1605
  /**
1481
1606
  * Does this specifier name a module that exports the css API, exactly?
1482
1607
  *
@@ -1501,7 +1626,25 @@ const foldSource = (options) => {
1501
1626
  */
1502
1627
  const generatedCssModule = [ctx.imports.outdir, "css"].join("/");
1503
1628
  const pathMappings = ctx.conf.tsOptions?.pathMappings;
1504
- const trim = (value) => value.replaceAll("\\", "/").replace(/^(?:\.\.?\/)+/, "").replace(/\/$/, "");
1629
+ /**
1630
+ * The spelling reduced to the module it names.
1631
+ *
1632
+ * The extension and `/index` are stripped because bamboo's own output makes a file
1633
+ * import them: `outExtension: 'js'` under NodeNext resolution is written
1634
+ * `styled-system/css/index.js`, which is neither equal to `styled-system/css` nor a
1635
+ * tail of it. Extraction admitted such a file anyway — `ImportMap.match` is
1636
+ * substring-based — so the call was folded while the *insert* was refused, and the
1637
+ * result was reported as `dynamic`: the same silent downgrade the alias case above
1638
+ * describes, reached through the extension instead.
1639
+ *
1640
+ * This does not weaken the equality the comment above insists on. `styled-system/css/css`
1641
+ * still names neither, because only a trailing `/index` is a module's own directory.
1642
+ *
1643
+ * `.d.ts` is deliberately not stripped. A declaration file exports no runtime binding, so
1644
+ * matching one would authorise inserting an import that resolves to nothing — and a value
1645
+ * import cannot name one anyway, which is what makes leaving it out free.
1646
+ */
1647
+ const trim = (value) => value.replaceAll("\\", "/").replace(LEADING_RELATIVE, "").replace(TRAILING_SLASH, "").replace(MODULE_EXTENSION, "").replace(TRAILING_INDEX, "");
1505
1648
  const matchesModule = (mod, entries) => {
1506
1649
  const candidates = [mod];
1507
1650
  if (pathMappings) {
@@ -1518,6 +1661,100 @@ const foldSource = (options) => {
1518
1661
  };
1519
1662
  const isBambooCssModule = (mod) => matchesModule(mod, cssModules);
1520
1663
  const isGeneratedCssModule = (mod) => matchesModule(mod, [generatedCssModule]);
1664
+ /**
1665
+ * How *this* module would have to spell the css module, learnt from one that already does.
1666
+ *
1667
+ * A file calling an imported recipe need not import the css module at all, so when the
1668
+ * lowering needs `cvaPick` there is no spelling in the file to copy. The declaring module
1669
+ * necessarily has one — `cva` came from it — and that is the spelling reused here.
1670
+ *
1671
+ * A bare or aliased specifier resolves identically from any file, so it is taken as
1672
+ * written. A relative one is re-based: resolved against the module that wrote it, then
1673
+ * expressed from the module being folded.
1674
+ */
1675
+ const cssModuleSpecifierFrom = (declaring) => {
1676
+ for (const declaration of declaring.getImportDeclarations()) {
1677
+ if (declaration.isTypeOnly()) continue;
1678
+ const mod = declaration.getModuleSpecifierValue();
1679
+ if (isGeneratedCssModule(mod)) return mod;
1680
+ }
1681
+ };
1682
+ /**
1683
+ * That spelling, said from the module being folded.
1684
+ *
1685
+ * A bare or aliased specifier resolves identically from any file, so it is taken as
1686
+ * written. A relative one is re-based: resolved against the module that wrote it, then
1687
+ * expressed from the module being folded. Pure path arithmetic, so it holds a string
1688
+ * rather than a node — a cached node does not survive the next `addSourceFile`, which
1689
+ * ts-morph implements by forgetting the file's whole tree.
1690
+ */
1691
+ const rebaseSpecifier = (specifier, declaringPath, consumingPath) => {
1692
+ if (!specifier.startsWith(".")) return specifier;
1693
+ const absolute = resolve(dirname(declaringPath), specifier);
1694
+ const rebased = relative(dirname(consumingPath), absolute).replaceAll("\\", "/");
1695
+ if (!rebased) return void 0;
1696
+ return rebased.startsWith(".") ? rebased : `./${rebased}`;
1697
+ };
1698
+ /**
1699
+ * Configs of one foreign module, parsed once however many of its recipes are called.
1700
+ *
1701
+ * Falls back to a per-call map when the caller supplies none, so the fold stays correct
1702
+ * standalone — only repeated, which is what the shared cache exists to avoid.
1703
+ */
1704
+ const configsByModule = recipeConfigCache ?? /* @__PURE__ */ new Map();
1705
+ /** The specifier each imported recipe's module used for the css module, when it needs one. */
1706
+ const helperModules = /* @__PURE__ */ new Map();
1707
+ /** Declaring modules a fold read, recorded as paths because their nodes do not persist. */
1708
+ const foreignDependencies = /* @__PURE__ */ new Set();
1709
+ /** Resolutions for this module's own call sites, keyed by the name the call site writes. */
1710
+ const importedRecipes = /* @__PURE__ */ new Map();
1711
+ /**
1712
+ * The config of a recipe this module imports.
1713
+ *
1714
+ * The binding is followed with ts-morph's symbol aliasing rather than by re-reading import
1715
+ * declarations, because that is what already understands the shapes these are reached
1716
+ * through: `export { badge } from './styles'`, `export * from './styles'`, and an alias at
1717
+ * either end. Each hop is an alias symbol, so following them to a non-alias lands on the
1718
+ * declaration wherever it lives.
1719
+ *
1720
+ * The class names do not depend on which module the call is in — `getRecipeIdentity` hashes
1721
+ * the config — so a recipe lowered here produces exactly the string its own module's call
1722
+ * sites produce, and exactly the one the runtime would have.
1723
+ */
1724
+ const resolveImportedRecipe = (call, name, origin) => {
1725
+ if (importedRecipes.has(name)) return importedRecipes.get(name);
1726
+ const resolve = () => {
1727
+ if (!parseModule) return void 0;
1728
+ const consuming = call.getSourceFile();
1729
+ if (origin.filePath === consuming.getFilePath()) return void 0;
1730
+ let foreign = configsByModule.get(origin.filePath);
1731
+ if (!foreign) {
1732
+ const result = parseModule(origin.filePath);
1733
+ if (!result) return void 0;
1734
+ const collected = collectRecipeConfigs(result);
1735
+ const declaring = [...collected.values()].find((entry) => entry.box)?.box?.getNode?.()?.getSourceFile();
1736
+ const configs = /* @__PURE__ */ new Map();
1737
+ for (const [key, entry] of collected) configs.set(key, entry === AMBIGUOUS ? entry : {
1738
+ config: entry.config,
1739
+ name: entry.name,
1740
+ box: void 0
1741
+ });
1742
+ foreign = {
1743
+ configs,
1744
+ cssSpecifier: declaring ? cssModuleSpecifierFrom(declaring) : void 0
1745
+ };
1746
+ configsByModule.set(origin.filePath, foreign);
1747
+ }
1748
+ const entry = foreign.configs.get(origin.name);
1749
+ if (!entry || entry === AMBIGUOUS) return void 0;
1750
+ foreignDependencies.add(origin.filePath);
1751
+ helperModules.set(name, foreign.cssSpecifier ? rebaseSpecifier(foreign.cssSpecifier, origin.filePath, consuming.getFilePath()) : void 0);
1752
+ return entry;
1753
+ };
1754
+ const resolved = resolve();
1755
+ importedRecipes.set(name, resolved);
1756
+ return resolved;
1757
+ };
1521
1758
  const folded = [];
1522
1759
  const skipped = [];
1523
1760
  const candidates = [];
@@ -1548,64 +1785,12 @@ const foldSource = (options) => {
1548
1785
  }
1549
1786
  return names;
1550
1787
  };
1551
- /**
1552
- * Is this binding the variant half of `<recipe>.splitVariantProps(...)`?
1553
- *
1554
- * Read off the declaration rather than the type, so it is the same recipe and the same
1555
- * destructuring position the source actually wrote.
1556
- */
1557
- const isSplitVariantPropsOf = (binding, recipe) => {
1558
- for (const declaration of binding.getSourceFile().getDescendantsOfKind(SyntaxKind.VariableDeclaration)) {
1559
- const nameNode = declaration.getNameNode();
1560
- if (!Node.isArrayBindingPattern(nameNode)) continue;
1561
- const first = nameNode.getElements()[0];
1562
- if (!first || !Node.isBindingElement(first)) continue;
1563
- if (first.getNameNode().getText() !== binding.getText()) continue;
1564
- const initializer = declaration.getInitializer();
1565
- if (!initializer || !Node.isCallExpression(initializer)) return false;
1566
- const callee = initializer.getExpression();
1567
- if (!Node.isPropertyAccessExpression(callee)) return false;
1568
- return callee.getName() === "splitVariantProps" && callee.getExpression().getText() === recipe;
1569
- }
1570
- return false;
1571
- };
1572
- /**
1573
- * Lower a config recipe call the same way an inline one lowers.
1574
- *
1575
- * The config lives in `ctx.recipes` rather than in the module, and the classes are named
1576
- * from it identically — so this is the same `lowerRecipeCall`, handed the config from a
1577
- * different place. Slot recipes are excluded by the caller: they resolve to one class per
1578
- * slot rather than to a string.
1579
- */
1580
- const lowerConfigRecipeCall = (call, name) => {
1581
- const config = ctx.recipes.getConfig(name);
1582
- if (!config || config.slots !== void 0) return void 0;
1583
- const className = config.className;
1584
- if (!className) return void 0;
1585
- if (!Node.isCallExpression(call)) return void 0;
1586
- const argument = call.getArguments()[0];
1587
- if (!argument || !Node.isIdentifier(argument) || !isSplitVariantPropsOf(argument, name)) return void 0;
1588
- const lowered = lowerRecipeCall(call, {
1589
- config,
1590
- name: className,
1591
- box: void 0
1592
- }, ctx, isInertExpression);
1593
- if (lowered.kind !== "expression") return void 0;
1594
- const helper = ensureRecipeHelperImport(RECIPE_PICK_HELPER, call, isBambooCssModule, isGeneratedCssModule, isShadowed);
1595
- if (!helper) return void 0;
1596
- return {
1597
- replacement: helper.name === "cvaPick" ? lowered.expression : lowered.expression.replaceAll(`${RECIPE_PICK_HELPER}(`, `${helper.name}(`),
1598
- className: lowered.staticClasses,
1599
- classNames: lowered.classNames,
1600
- insert: helper.insert
1601
- };
1602
- };
1603
1788
  for (const item of parserResult.toArray()) {
1604
1789
  const type = item.type ?? "";
1605
1790
  const name = item.name ?? type;
1606
1791
  if (!item.box) continue;
1607
1792
  const call = findCallExpression(item.box);
1608
- if (type === "token") {
1793
+ if (type === "token" || type === "tokenVar") {
1609
1794
  if (!call) {
1610
1795
  skipped.push({
1611
1796
  name,
@@ -1640,7 +1825,18 @@ const foldSource = (options) => {
1640
1825
  continue;
1641
1826
  }
1642
1827
  const callee = Node.isCallExpression(call) ? call.getExpression() : void 0;
1643
- if (Node.isPropertyAccessExpression(callee) && !ctx.imports.matchers.tokens.match(callee.getNameNode().getText())) {
1828
+ const propertyName = Node.isPropertyAccessExpression(callee) ? callee.getNameNode().getText() : void 0;
1829
+ const wantsVar = type === "tokenVar";
1830
+ if (wantsVar !== (propertyName === "var")) {
1831
+ skipped.push({
1832
+ name,
1833
+ reason: "unsupported-kind",
1834
+ start,
1835
+ end
1836
+ });
1837
+ continue;
1838
+ }
1839
+ if (!wantsVar && propertyName !== void 0 && !ctx.imports.matchers.tokens.match(propertyName)) {
1644
1840
  skipped.push({
1645
1841
  name,
1646
1842
  reason: "unsupported-kind",
@@ -1677,7 +1873,7 @@ const foldSource = (options) => {
1677
1873
  });
1678
1874
  continue;
1679
1875
  }
1680
- const value = runtimeToken(path);
1876
+ const value = wantsVar ? runtimeTokenVar(path) : runtimeToken(path);
1681
1877
  if (!value) {
1682
1878
  skipped.push({
1683
1879
  name,
@@ -1720,6 +1916,10 @@ const foldSource = (options) => {
1720
1916
  continue;
1721
1917
  }
1722
1918
  recipeConfigs ??= collectRecipeConfigs(parserResult);
1919
+ if (!recipeConfigs.has(name) && item.origin) {
1920
+ const imported = resolveImportedRecipe(call, name, item.origin);
1921
+ if (imported) recipeConfigs.set(name, imported);
1922
+ }
1723
1923
  const tally = recipeCalls.get(name) ?? {
1724
1924
  seen: 0,
1725
1925
  lowered: 0
@@ -1730,7 +1930,7 @@ const foldSource = (options) => {
1730
1930
  const entry = recipeConfigs.get(name);
1731
1931
  const lowered = lowerRecipeCall(call, entry, ctx, isInertExpression, resolvedSelection);
1732
1932
  if (lowered.kind === "expression") {
1733
- const helper = ensureRecipeHelperImport(RECIPE_PICK_HELPER, call, isBambooCssModule, isGeneratedCssModule, isShadowed);
1933
+ const helper = ensureRecipeHelperImport(RECIPE_PICK_HELPER, call, isBambooCssModule, isGeneratedCssModule, isShadowed, helperModules.get(name));
1734
1934
  if (helper) {
1735
1935
  tally.lowered++;
1736
1936
  candidates.push({
@@ -1844,25 +2044,19 @@ const foldSource = (options) => {
1844
2044
  if (!(Node.isCallExpression(call) && call.getArguments().length === 0 && item.data.length === 1) && !isStaticBox(item.box) || !hasStyles(item.data) || !argumentsAccountedFor(call, item.box)) {
1845
2045
  const partial = partial_ ? tryPartial(item, call, rootName) : void 0;
1846
2046
  if (partial) {
1847
- candidates.push({
1848
- item,
1849
- call,
1850
- node: call,
2047
+ if (reportSurvivors && partial.runtimeCallee) skipped.push({
2048
+ name,
2049
+ reason: "runtime-binding",
1851
2050
  start,
1852
- end,
1853
- ...partial
2051
+ end
1854
2052
  });
1855
- continue;
1856
- }
1857
- const lowered = type === "recipe" && !slot ? lowerConfigRecipeCall(call, name) : void 0;
1858
- if (lowered) {
1859
2053
  candidates.push({
1860
2054
  item,
1861
2055
  call,
1862
2056
  node: call,
1863
2057
  start,
1864
2058
  end,
1865
- ...lowered
2059
+ ...partial
1866
2060
  });
1867
2061
  continue;
1868
2062
  }
@@ -1883,13 +2077,22 @@ const foldSource = (options) => {
1883
2077
  slot
1884
2078
  });
1885
2079
  }
1886
- if (candidates.length === 0) return {
1887
- code,
1888
- map: null,
1889
- folded,
1890
- skipped,
1891
- dependencies: []
1892
- };
2080
+ /**
2081
+ * Ranges the rewrite actually replaced. Declared before the early return below, because
2082
+ * that return is now also a reporting point: a module with nothing to fold is exactly the
2083
+ * shape `reportSurvivors` exists to catch.
2084
+ */
2085
+ const applied = [];
2086
+ if (candidates.length === 0) {
2087
+ if (reportSurvivors) reportRuntimeBindings();
2088
+ return {
2089
+ code,
2090
+ map: null,
2091
+ folded,
2092
+ skipped,
2093
+ dependencies: []
2094
+ };
2095
+ }
1893
2096
  const dependencyScan = createDependencyScan(candidates[0].node.getSourceFile());
1894
2097
  candidates.sort((a, b) => a.start - b.start || b.end - a.end);
1895
2098
  const magic = new MagicString(code);
@@ -1898,10 +2101,9 @@ const foldSource = (options) => {
1898
2101
  if (!insert) return;
1899
2102
  const missing = insert.names.filter((name) => !insertedNames.has(name));
1900
2103
  if (!missing.length) return;
1901
- magic.appendLeft(insert.pos, missing.map((name) => `, ${name}`).join(""));
2104
+ magic.appendLeft(insert.pos, insert.module ? `\nimport { ${missing.join(", ")} } from '${insert.module}'` : missing.map((name) => `, ${name}`).join(""));
1902
2105
  for (const name of missing) insertedNames.add(name);
1903
2106
  };
1904
- const applied = [];
1905
2107
  const collides = (edits) => edits.some(([start, end]) => applied.some(([from, to]) => start < to && from < end));
1906
2108
  for (const candidate of candidates) {
1907
2109
  const { item, start, end } = candidate;
@@ -1993,7 +2195,7 @@ const foldSource = (options) => {
1993
2195
  });
1994
2196
  collectSourceFiles(item.box, dependencyScan);
1995
2197
  }
1996
- const recipeSourceFile = [...recipeConfigs?.values() ?? []].find((entry) => entry.box)?.box?.getNode?.()?.getSourceFile() ?? candidates[0]?.node.getSourceFile();
2198
+ const recipeSourceFile = candidates[0]?.node.getSourceFile();
1997
2199
  if (recipeSourceFile) for (const access of recipeSourceFile.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
1998
2200
  if (access.getName() !== "splitVariantProps") continue;
1999
2201
  const target = access.getExpression();
@@ -2015,7 +2217,7 @@ const foldSource = (options) => {
2015
2217
  const end = call.getEnd();
2016
2218
  if (code.slice(start, end) !== call.getText()) continue;
2017
2219
  if (collides([[start, end]])) continue;
2018
- const helper = ensureRecipeHelperImport(SPLIT_PROPS_HELPER, call, isBambooCssModule, isGeneratedCssModule, isShadowed);
2220
+ const helper = ensureRecipeHelperImport(SPLIT_PROPS_HELPER, call, isBambooCssModule, isGeneratedCssModule, isShadowed, helperModules.get(target.getText()));
2019
2221
  if (!helper) continue;
2020
2222
  const keys = Object.keys(entry.config.variants ?? {});
2021
2223
  magic.overwrite(start, end, `${helper.name}(${args[0].getText()}, ${JSON.stringify(keys)})`);
@@ -2033,6 +2235,104 @@ const foldSource = (options) => {
2033
2235
  if (code.slice(start, call.getEnd()) !== call.getText()) continue;
2034
2236
  magic.appendLeft(start, "/*#__PURE__*/");
2035
2237
  }
2238
+ /**
2239
+ * Bindings from a bamboo module still referenced once every rewrite is applied.
2240
+ *
2241
+ * Deliberately not driven by `parserResult`: that is the recogniser, and the point here is
2242
+ * to catch what it did not see. A namespace import called as `s.cva(...)`, a default
2243
+ * import, a specifier that resolved to nothing — each leaves a live reference and no ledger
2244
+ * entry at all, which is how `strict` came to pass a build that still shipped the engine.
2245
+ *
2246
+ * The helpers the fold itself writes are excluded: `cx`, `cvaPick`, `splitProps` and the
2247
+ * leaf helper live in `cx` and pull no engine, so a reference to one is the fold working
2248
+ * rather than failing.
2249
+ */
2250
+ function reportRuntimeBindings() {
2251
+ const sourceFile = ownSourceFile ?? candidates[0]?.node.getSourceFile();
2252
+ if (!sourceFile) return;
2253
+ const bambooModules = [
2254
+ ...cssModules,
2255
+ ...ctx.imports.matchers.recipe?.mods ?? [],
2256
+ ...ctx.imports.matchers.pattern?.mods ?? [],
2257
+ ...ctx.imports.matchers.tokens?.mods ?? []
2258
+ ];
2259
+ /** Local name -> what to call it in the report. */
2260
+ const watched = /* @__PURE__ */ new Map();
2261
+ for (const declaration of sourceFile.getImportDeclarations()) {
2262
+ if (declaration.isTypeOnly()) continue;
2263
+ if (!matchesModule(declaration.getModuleSpecifierValue(), bambooModules)) continue;
2264
+ for (const named of declaration.getNamedImports()) {
2265
+ if (named.isTypeOnly()) continue;
2266
+ const imported = named.getNameNode().getText();
2267
+ if (PERMITTED_BINDINGS.has(imported)) continue;
2268
+ watched.set((named.getAliasNode() ?? named.getNameNode()).getText(), imported);
2269
+ }
2270
+ const namespace = declaration.getNamespaceImport();
2271
+ if (namespace) watched.set(namespace.getText(), `${namespace.getText()}.*`);
2272
+ const defaultImport = declaration.getDefaultImport();
2273
+ if (defaultImport) watched.set(defaultImport.getText(), defaultImport.getText());
2274
+ }
2275
+ for (const declaration of sourceFile.getExportDeclarations()) {
2276
+ if (declaration.isTypeOnly()) continue;
2277
+ if (!matchesModule(declaration.getModuleSpecifierValue() ?? "", bambooModules)) continue;
2278
+ if (declaration.isNamespaceExport()) {
2279
+ skipped.push({
2280
+ name: declaration.getNamespaceExport()?.getName() ?? "*",
2281
+ reason: "runtime-binding",
2282
+ start: declaration.getStart(),
2283
+ end: declaration.getEnd()
2284
+ });
2285
+ continue;
2286
+ }
2287
+ for (const named of declaration.getNamedExports()) {
2288
+ if (named.isTypeOnly()) continue;
2289
+ const imported = named.getNameNode().getText();
2290
+ if (PERMITTED_BINDINGS.has(imported)) continue;
2291
+ skipped.push({
2292
+ name: imported,
2293
+ reason: "runtime-binding",
2294
+ start: named.getStart(),
2295
+ end: named.getEnd()
2296
+ });
2297
+ }
2298
+ }
2299
+ for (const declaration of sourceFile.getExportDeclarations()) {
2300
+ if (declaration.isTypeOnly() || declaration.getModuleSpecifier()) continue;
2301
+ for (const named of declaration.getNamedExports()) {
2302
+ if (named.isTypeOnly()) continue;
2303
+ const imported = watched.get(named.getNameNode().getText());
2304
+ if (imported === void 0) continue;
2305
+ skipped.push({
2306
+ name: imported,
2307
+ reason: "runtime-binding",
2308
+ start: named.getStart(),
2309
+ end: named.getEnd()
2310
+ });
2311
+ }
2312
+ }
2313
+ if (watched.size === 0) return;
2314
+ const declined = skipped.filter((entry) => SURVIVES_TO_RUNTIME.has(entry.reason) && entry.end > entry.start).map((entry) => [entry.start, entry.end]);
2315
+ const reported = /* @__PURE__ */ new Set();
2316
+ for (const identifier of sourceFile.getDescendantsOfKind(SyntaxKind.Identifier)) {
2317
+ const local = identifier.getText();
2318
+ const imported = watched.get(local);
2319
+ if (imported === void 0 || reported.has(local)) continue;
2320
+ const start = identifier.getStart();
2321
+ if (identifier.getFirstAncestorByKind(SyntaxKind.ImportDeclaration)) continue;
2322
+ if (applied.some(([from, to]) => start >= from && start < to)) continue;
2323
+ if (declined.some(([from, to]) => start >= from && start < to)) continue;
2324
+ if (!isValueReference(identifier)) continue;
2325
+ if (isShadowed(identifier, local)) continue;
2326
+ reported.add(local);
2327
+ skipped.push({
2328
+ name: imported,
2329
+ reason: "runtime-binding",
2330
+ start,
2331
+ end: identifier.getEnd()
2332
+ });
2333
+ }
2334
+ }
2335
+ if (reportSurvivors) reportRuntimeBindings();
2036
2336
  if (folded.length === 0) return {
2037
2337
  code,
2038
2338
  map: null,
@@ -2049,7 +2349,7 @@ const foldSource = (options) => {
2049
2349
  }),
2050
2350
  folded,
2051
2351
  skipped,
2052
- dependencies: Array.from(dependencyScan.results)
2352
+ dependencies: [...dependencyScan.results, ...foreignDependencies]
2053
2353
  };
2054
2354
  };
2055
2355
  //#endregion
@@ -2083,21 +2383,6 @@ const isGeneratedOutput = (filePath, ctx) => {
2083
2383
  const file = slashed(filePath);
2084
2384
  return file === root || file.startsWith(`${root}/`);
2085
2385
  };
2086
- /**
2087
- * The skip reasons that leave a `css()`-family call in the output.
2088
- *
2089
- * `overlapping` is handled by the enclosing fold, and `not-imported` is somebody else's
2090
- * function of the same name — neither leaves a call of ours. `not-foldable` is a `cva`/`sva`
2091
- * definition, which keeps the recipe runtime rather than the css engine; see `strict`.
2092
- */
2093
- const SURVIVES_TO_RUNTIME = new Set([
2094
- "dynamic",
2095
- "raw-call",
2096
- "unsupported-kind",
2097
- "no-call-expression",
2098
- "empty",
2099
- "unresolved-token"
2100
- ]);
2101
2386
  /** 1-indexed line of a source offset, for an error a user can navigate to. */
2102
2387
  const lineAt = (code, offset) => code.slice(0, offset).split("\n").length;
2103
2388
  const formatSkipped = (id, skipped) => {
@@ -2128,6 +2413,13 @@ const bamboocss = (options = {}) => {
2128
2413
  };
2129
2414
  /** Under `strict`, every call that would still reach the runtime. */
2130
2415
  const survivors = [];
2416
+ /**
2417
+ * Recipe configs read out of modules other than the one being transformed.
2418
+ *
2419
+ * Per build rather than per module: a recipe declared once and imported by fifty components
2420
+ * would otherwise re-parse its module fifty times, which is the transform path.
2421
+ */
2422
+ const recipeConfigCache = /* @__PURE__ */ new Map();
2131
2423
  let ctx;
2132
2424
  let runtimeCss;
2133
2425
  let setup;
@@ -2155,6 +2447,7 @@ const bamboocss = (options = {}) => {
2155
2447
  totals.filesWithFolds = 0;
2156
2448
  totals.skipped.clear();
2157
2449
  survivors.length = 0;
2450
+ recipeConfigCache.clear();
2158
2451
  await ensureContext();
2159
2452
  },
2160
2453
  /**
@@ -2182,6 +2475,7 @@ const bamboocss = (options = {}) => {
2182
2475
  if (!shouldTransform(id)) return;
2183
2476
  const [filePath] = id.split("?");
2184
2477
  if (!filePath) return;
2478
+ recipeConfigCache.clear();
2185
2479
  if (change.event === "delete") {
2186
2480
  ctx.project.removeSourceFile(filePath);
2187
2481
  return;
@@ -2197,16 +2491,20 @@ const bamboocss = (options = {}) => {
2197
2491
  if (isGeneratedOutput(filePath, ctx)) return null;
2198
2492
  let result;
2199
2493
  try {
2200
- ctx.project.addSourceFile(filePath, code);
2494
+ const sourceFile = ctx.project.addSourceFile(filePath, code);
2201
2495
  const parserResult = ctx.project.parseSourceFile(filePath);
2202
- if (!parserResult || parserResult.isEmpty()) return null;
2496
+ if (!parserResult || parserResult.isEmpty() && !strict) return null;
2203
2497
  result = foldSource({
2204
2498
  ctx,
2205
2499
  code,
2206
2500
  parserResult,
2207
2501
  filePath,
2208
2502
  runtimeCss,
2209
- partial
2503
+ partial,
2504
+ parseModule: (path) => ctx?.project.parseSourceFile(path),
2505
+ recipeConfigCache,
2506
+ reportSurvivors: strict,
2507
+ sourceFile
2210
2508
  });
2211
2509
  } catch (error) {
2212
2510
  logger.caughtError("vite:transform", `Failed to fold ${filePath}`, error);
@@ -2250,7 +2548,7 @@ const bamboocss = (options = {}) => {
2250
2548
  list.push(entry);
2251
2549
  byFile.set(entry.file, list);
2252
2550
  }
2253
- const detail = Array.from(byFile.entries()).map(([file, entries]) => [` ${file}`, ...entries.map((e) => ` ${e.line}: ${e.name}() — ${e.reason}`)].join("\n")).join("\n");
2551
+ const detail = Array.from(byFile.entries()).map(([file, entries]) => [` ${file}`, ...entries.map((e) => ` ${e.line}: ${e.name}${e.reason === "runtime-binding" ? "" : "()"} — ${e.reason}`)].join("\n")).join("\n");
2254
2552
  throw new Error(`bamboocss: ${survivors.length} call(s) could not be folded, and \`strict\` is on.\n\n${detail}\n\nEach 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 \`strict: false\` to accept the runtime.`);
2255
2553
  }
2256
2554
  if (!transform || !reportSummary) return;