@bamboocss/vite 1.26.0 → 1.28.0

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
@@ -30,10 +30,10 @@ let _bamboocss_config_ts_path = require("@bamboocss/config/ts-path");
30
30
  let _bamboocss_extractor = require("@bamboocss/extractor");
31
31
  let magic_string = require("magic-string");
32
32
  magic_string = __toESM(magic_string);
33
+ let node_path = require("node:path");
33
34
  let ts_morph = require("ts-morph");
34
35
  let _bamboocss_core = require("@bamboocss/core");
35
36
  let _bamboocss_shared = require("@bamboocss/shared");
36
- let node_path = require("node:path");
37
37
  //#region src/css.ts
38
38
  /**
39
39
  * What a project imports to get the stylesheet.
@@ -850,7 +850,7 @@ const propertyKey = (nameNode) => {
850
850
  * there is nothing to match — the host here is any import of the generated css module, which
851
851
  * a file defining a recipe necessarily has, since `cva` came from it.
852
852
  */
853
- const ensureRecipeHelperImport = (imported, call, isBambooCssModule, isGeneratedCssModule, isShadowed) => {
853
+ const ensureRecipeHelperImport = (imported, call, isBambooCssModule, isGeneratedCssModule, isShadowed, newImportModule) => {
854
854
  const sourceFile = call.getSourceFile();
855
855
  let host;
856
856
  for (const declaration of sourceFile.getImportDeclarations()) {
@@ -866,9 +866,21 @@ const ensureRecipeHelperImport = (imported, call, isBambooCssModule, isGenerated
866
866
  }
867
867
  if (!host && isGeneratedCssModule(mod) && declaration.getNamedImports().length > 0) host = declaration;
868
868
  }
869
- if (!host) return void 0;
869
+ if (!host && !newImportModule) return void 0;
870
870
  if (declaredAtModuleScope(sourceFile).has(imported)) return void 0;
871
871
  if (isShadowed(call, imported)) return void 0;
872
+ if (!host) {
873
+ const anchor = sourceFile.getImportDeclarations().at(-1);
874
+ if (!anchor) return void 0;
875
+ return {
876
+ name: imported,
877
+ insert: {
878
+ pos: anchor.getEnd(),
879
+ names: [imported],
880
+ module: newImportModule
881
+ }
882
+ };
883
+ }
872
884
  const last = host.getNamedImports().at(-1);
873
885
  if (!last) return void 0;
874
886
  return {
@@ -928,6 +940,13 @@ const lowerRecipeCall = (call, entry, ctx, isInert, resolvedSelection) => {
928
940
  /**
929
941
  * `input(variantProps)` — a selection the build cannot see inside.
930
942
  *
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.
949
+ *
931
950
  * The classes are still knowable: a recipe emits one per declared variant, so the call is
932
951
  * one term per variant reading that binding. This is the shape a wrapper component takes,
933
952
  * where the variants are the component's public API and cannot be literals by definition.
@@ -1175,11 +1194,24 @@ const createRuntimeRecipe = (ctx) => {
1175
1194
  }
1176
1195
  }
1177
1196
  });
1178
- const recipeStyles = isConstantSlot ? { [className]: "__ignore__" } : {
1197
+ const declaredValues = config.variants ?? {};
1198
+ /**
1199
+ * The same filter the generated `createRecipe` applies: only a value the config declares
1200
+ * names a class.
1201
+ *
1202
+ * Scalars only — a conditional or responsive value is an object of leaves, and the leaves
1203
+ * are what name classes when `createCss` walks them.
1204
+ */
1205
+ const onlyDeclared = (styles) => Object.fromEntries(Object.entries(styles).filter(([prop, value]) => {
1206
+ if (prop === className) return true;
1207
+ if (value === null || typeof value === "object") return true;
1208
+ return Object.hasOwn(declaredValues, prop) && Object.hasOwn(declaredValues[prop] ?? {}, String(value));
1209
+ }));
1210
+ const recipeStyles = isConstantSlot ? { [className]: "__ignore__" } : onlyDeclared({
1179
1211
  [className]: "__ignore__",
1180
1212
  ...defaultVariants,
1181
1213
  ...(0, _bamboocss_shared.compact)(variants)
1182
- };
1214
+ });
1183
1215
  if (compoundVariants.length > 0 && Object.keys(recipeStyles).some((prop) => typeof variants[prop] === "object")) return;
1184
1216
  if (isSlotRecipe) {
1185
1217
  const evaluated = anchors.length > 0 ? anchors : config.slots;
@@ -1219,6 +1251,22 @@ const literalsIn = (expression) => [...expression.matchAll(/"((?:[^"\\]|\\.)*)"/
1219
1251
  */
1220
1252
  const UNFOLDABLE_TYPES = new Set(["cva", "sva"]);
1221
1253
  /**
1254
+ * The skip reasons that leave a `css()`-family call in the output.
1255
+ *
1256
+ * `overlapping` is handled by the enclosing fold, and `not-imported` is somebody else's
1257
+ * function of the same name — neither leaves a call of ours. `not-foldable` is a `cva`/`sva`
1258
+ * definition, which keeps the recipe runtime rather than the css engine; see `strict`.
1259
+ */
1260
+ const SURVIVES_TO_RUNTIME = new Set([
1261
+ "dynamic",
1262
+ "runtime-binding",
1263
+ "raw-call",
1264
+ "unsupported-kind",
1265
+ "no-call-expression",
1266
+ "empty",
1267
+ "unresolved-token"
1268
+ ]);
1269
+ /**
1222
1270
  * A call of a recipe the file bound itself: `const badge = cva(...)`, then `badge({ tone })`.
1223
1271
  *
1224
1272
  * Folded when the whole selection resolves, reported under this reason when it does not.
@@ -1231,6 +1279,65 @@ const UNFOLDABLE_TYPES = new Set(["cva", "sva"]);
1231
1279
  */
1232
1280
  const RECIPE_CALL_TYPE = "cva-call";
1233
1281
  /**
1282
+ * An identifier that actually reads the binding.
1283
+ *
1284
+ * `getDescendantsOfKind(Identifier)` yields every name in the file, and most of them bind or
1285
+ * label rather than read: a JSX tag (`<button/>` against a recipe called `button`), an object
1286
+ * key, a property name, a declaration. Counting those failed builds on modules that had
1287
+ * folded completely — and `button`, `input`, `label`, `select`, `table`, `dialog` and `form`
1288
+ * are all ordinary recipe names as well as intrinsic elements.
1289
+ *
1290
+ * A type position is excluded for a different reason: it is erased, and with it the import.
1291
+ */
1292
+ const isValueReference = (identifier) => {
1293
+ const parent = identifier.getParent();
1294
+ if (!parent) return false;
1295
+ if (ts_morph.Node.isImportSpecifier(parent) || ts_morph.Node.isExportSpecifier(parent)) return false;
1296
+ if (ts_morph.Node.isImportClause(parent) || ts_morph.Node.isNamespaceImport(parent)) return false;
1297
+ if (ts_morph.Node.isPropertyAccessExpression(parent) && parent.getNameNode() === identifier) return false;
1298
+ if (ts_morph.Node.isQualifiedName(parent) && parent.getRight() === identifier) return false;
1299
+ if (ts_morph.Node.isPropertyAssignment(parent) && parent.getNameNode() === identifier) return false;
1300
+ if (ts_morph.Node.isMethodDeclaration(parent) || ts_morph.Node.isPropertyDeclaration(parent) || ts_morph.Node.isGetAccessorDeclaration(parent) || ts_morph.Node.isSetAccessorDeclaration(parent) || ts_morph.Node.isMethodSignature(parent) || ts_morph.Node.isPropertySignature(parent) || ts_morph.Node.isEnumMember(parent)) {
1301
+ if (parent.getNameNode() === identifier) return false;
1302
+ }
1303
+ if (ts_morph.Node.isLabeledStatement(parent) || ts_morph.Node.isBreakStatement(parent) || ts_morph.Node.isContinueStatement(parent)) return false;
1304
+ if (ts_morph.Node.isJsxOpeningElement(parent) || ts_morph.Node.isJsxSelfClosingElement(parent) || ts_morph.Node.isJsxClosingElement(parent)) {
1305
+ if (parent.getTagNameNode() === identifier) return identifier.getText()[0] === identifier.getText()[0]?.toUpperCase();
1306
+ }
1307
+ if (ts_morph.Node.isJsxAttribute(parent)) return false;
1308
+ if (ts_morph.Node.isBindingElement(parent) && parent.getPropertyNameNode() === identifier) return false;
1309
+ if ((ts_morph.Node.isVariableDeclaration(parent) || ts_morph.Node.isParameterDeclaration(parent) || ts_morph.Node.isBindingElement(parent) || ts_morph.Node.isFunctionDeclaration(parent) || ts_morph.Node.isClassDeclaration(parent)) && parent.getNameNode() === identifier) return false;
1310
+ return !identifier.getFirstAncestor((ancestor) => ts_morph.Node.isTypeNode(ancestor) || ts_morph.Node.isTypeAliasDeclaration(ancestor) || ts_morph.Node.isInterfaceDeclaration(ancestor));
1311
+ };
1312
+ /**
1313
+ * Imports a surviving reference to is not a failure.
1314
+ *
1315
+ * The first four are what the fold itself writes; all live in `cx` and pull no engine, so a
1316
+ * reference to one is the fold having worked.
1317
+ *
1318
+ * `cva` and `sva` are there for the reason `SURVIVES_TO_RUNTIME` omits `not-foldable`: a
1319
+ * recipe *definition* cannot fold to a class string and never could, and what it keeps is the
1320
+ * recipe runtime rather than the css engine — which `strict` accepts. Their unfoldable
1321
+ * invocations are reported separately, as `recipe-call`.
1322
+ */
1323
+ const PERMITTED_BINDINGS = new Set([
1324
+ "cx",
1325
+ "cva",
1326
+ "sva",
1327
+ RECIPE_PICK_HELPER,
1328
+ SPLIT_PROPS_HELPER,
1329
+ LEAF_HELPER
1330
+ ]);
1331
+ /**
1332
+ * The pieces `trim` reduces a module specifier by, hoisted because a regex literal
1333
+ * constructs a new object every time it is evaluated and `trim` runs per specifier per
1334
+ * import declaration per module.
1335
+ */
1336
+ const LEADING_RELATIVE = /^(?:\.\.?\/)+/;
1337
+ const TRAILING_SLASH = /\/$/;
1338
+ const MODULE_EXTENSION = /\.[mc]?[jt]sx?$/;
1339
+ const TRAILING_INDEX = /\/index$/;
1340
+ /**
1234
1341
  * An argument that cannot run anything when it is evaluated.
1235
1342
  *
1236
1343
  * `token(path, fallback)` evaluates both arguments before the call, so a fold that drops
@@ -1446,7 +1553,7 @@ const argumentsAccountedFor = (call, boxNode) => {
1446
1553
  return accountsForSource(args[0], boxNode);
1447
1554
  };
1448
1555
  const foldSource = (options) => {
1449
- const { ctx, code, parserResult, partial: partial_ = true, runtimeCss = createRuntimeCss(ctx) } = options;
1556
+ const { ctx, code, parserResult, partial: partial_ = true, runtimeCss = createRuntimeCss(ctx), parseModule, recipeConfigCache, reportSurvivors, sourceFile: ownSourceFile } = options;
1450
1557
  /**
1451
1558
  * Recover the static half of a call the whole-call path gave up on. Only a
1452
1559
  * single-argument `css()` qualifies: a pattern or recipe call takes props rather than a
@@ -1498,7 +1605,8 @@ const foldSource = (options) => {
1498
1605
  className: plan.className,
1499
1606
  classNames: [plan.className, ...plan.finite.filter((entry) => entry.emitsLiterals).flatMap((entry) => literalsIn(entry.expression))].filter(Boolean),
1500
1607
  replacement: `${cx.name}(${parts.join(", ")})`,
1501
- insert: cx.insert
1608
+ insert: cx.insert,
1609
+ runtimeCallee: runtimePart ? callee : void 0
1502
1610
  };
1503
1611
  };
1504
1612
  const runtimeRecipe = createRuntimeRecipe(ctx);
@@ -1528,7 +1636,25 @@ const foldSource = (options) => {
1528
1636
  */
1529
1637
  const generatedCssModule = [ctx.imports.outdir, "css"].join("/");
1530
1638
  const pathMappings = ctx.conf.tsOptions?.pathMappings;
1531
- const trim = (value) => value.replaceAll("\\", "/").replace(/^(?:\.\.?\/)+/, "").replace(/\/$/, "");
1639
+ /**
1640
+ * The spelling reduced to the module it names.
1641
+ *
1642
+ * The extension and `/index` are stripped because bamboo's own output makes a file
1643
+ * import them: `outExtension: 'js'` under NodeNext resolution is written
1644
+ * `styled-system/css/index.js`, which is neither equal to `styled-system/css` nor a
1645
+ * tail of it. Extraction admitted such a file anyway — `ImportMap.match` is
1646
+ * substring-based — so the call was folded while the *insert* was refused, and the
1647
+ * result was reported as `dynamic`: the same silent downgrade the alias case above
1648
+ * describes, reached through the extension instead.
1649
+ *
1650
+ * This does not weaken the equality the comment above insists on. `styled-system/css/css`
1651
+ * still names neither, because only a trailing `/index` is a module's own directory.
1652
+ *
1653
+ * `.d.ts` is deliberately not stripped. A declaration file exports no runtime binding, so
1654
+ * matching one would authorise inserting an import that resolves to nothing — and a value
1655
+ * import cannot name one anyway, which is what makes leaving it out free.
1656
+ */
1657
+ const trim = (value) => value.replaceAll("\\", "/").replace(LEADING_RELATIVE, "").replace(TRAILING_SLASH, "").replace(MODULE_EXTENSION, "").replace(TRAILING_INDEX, "");
1532
1658
  const matchesModule = (mod, entries) => {
1533
1659
  const candidates = [mod];
1534
1660
  if (pathMappings) {
@@ -1545,6 +1671,100 @@ const foldSource = (options) => {
1545
1671
  };
1546
1672
  const isBambooCssModule = (mod) => matchesModule(mod, cssModules);
1547
1673
  const isGeneratedCssModule = (mod) => matchesModule(mod, [generatedCssModule]);
1674
+ /**
1675
+ * How *this* module would have to spell the css module, learnt from one that already does.
1676
+ *
1677
+ * A file calling an imported recipe need not import the css module at all, so when the
1678
+ * lowering needs `cvaPick` there is no spelling in the file to copy. The declaring module
1679
+ * necessarily has one — `cva` came from it — and that is the spelling reused here.
1680
+ *
1681
+ * A bare or aliased specifier resolves identically from any file, so it is taken as
1682
+ * written. A relative one is re-based: resolved against the module that wrote it, then
1683
+ * expressed from the module being folded.
1684
+ */
1685
+ const cssModuleSpecifierFrom = (declaring) => {
1686
+ for (const declaration of declaring.getImportDeclarations()) {
1687
+ if (declaration.isTypeOnly()) continue;
1688
+ const mod = declaration.getModuleSpecifierValue();
1689
+ if (isGeneratedCssModule(mod)) return mod;
1690
+ }
1691
+ };
1692
+ /**
1693
+ * That spelling, said from the module being folded.
1694
+ *
1695
+ * A bare or aliased specifier resolves identically from any file, so it is taken as
1696
+ * written. A relative one is re-based: resolved against the module that wrote it, then
1697
+ * expressed from the module being folded. Pure path arithmetic, so it holds a string
1698
+ * rather than a node — a cached node does not survive the next `addSourceFile`, which
1699
+ * ts-morph implements by forgetting the file's whole tree.
1700
+ */
1701
+ const rebaseSpecifier = (specifier, declaringPath, consumingPath) => {
1702
+ if (!specifier.startsWith(".")) return specifier;
1703
+ const absolute = (0, node_path.resolve)((0, node_path.dirname)(declaringPath), specifier);
1704
+ const rebased = (0, node_path.relative)((0, node_path.dirname)(consumingPath), absolute).replaceAll("\\", "/");
1705
+ if (!rebased) return void 0;
1706
+ return rebased.startsWith(".") ? rebased : `./${rebased}`;
1707
+ };
1708
+ /**
1709
+ * Configs of one foreign module, parsed once however many of its recipes are called.
1710
+ *
1711
+ * Falls back to a per-call map when the caller supplies none, so the fold stays correct
1712
+ * standalone — only repeated, which is what the shared cache exists to avoid.
1713
+ */
1714
+ const configsByModule = recipeConfigCache ?? /* @__PURE__ */ new Map();
1715
+ /** The specifier each imported recipe's module used for the css module, when it needs one. */
1716
+ const helperModules = /* @__PURE__ */ new Map();
1717
+ /** Declaring modules a fold read, recorded as paths because their nodes do not persist. */
1718
+ const foreignDependencies = /* @__PURE__ */ new Set();
1719
+ /** Resolutions for this module's own call sites, keyed by the name the call site writes. */
1720
+ const importedRecipes = /* @__PURE__ */ new Map();
1721
+ /**
1722
+ * The config of a recipe this module imports.
1723
+ *
1724
+ * The binding is followed with ts-morph's symbol aliasing rather than by re-reading import
1725
+ * declarations, because that is what already understands the shapes these are reached
1726
+ * through: `export { badge } from './styles'`, `export * from './styles'`, and an alias at
1727
+ * either end. Each hop is an alias symbol, so following them to a non-alias lands on the
1728
+ * declaration wherever it lives.
1729
+ *
1730
+ * The class names do not depend on which module the call is in — `getRecipeIdentity` hashes
1731
+ * the config — so a recipe lowered here produces exactly the string its own module's call
1732
+ * sites produce, and exactly the one the runtime would have.
1733
+ */
1734
+ const resolveImportedRecipe = (call, name, origin) => {
1735
+ if (importedRecipes.has(name)) return importedRecipes.get(name);
1736
+ const resolve = () => {
1737
+ if (!parseModule) return void 0;
1738
+ const consuming = call.getSourceFile();
1739
+ if (origin.filePath === consuming.getFilePath()) return void 0;
1740
+ let foreign = configsByModule.get(origin.filePath);
1741
+ if (!foreign) {
1742
+ const result = parseModule(origin.filePath);
1743
+ if (!result) return void 0;
1744
+ const collected = collectRecipeConfigs(result);
1745
+ const declaring = [...collected.values()].find((entry) => entry.box)?.box?.getNode?.()?.getSourceFile();
1746
+ const configs = /* @__PURE__ */ new Map();
1747
+ for (const [key, entry] of collected) configs.set(key, entry === AMBIGUOUS ? entry : {
1748
+ config: entry.config,
1749
+ name: entry.name,
1750
+ box: void 0
1751
+ });
1752
+ foreign = {
1753
+ configs,
1754
+ cssSpecifier: declaring ? cssModuleSpecifierFrom(declaring) : void 0
1755
+ };
1756
+ configsByModule.set(origin.filePath, foreign);
1757
+ }
1758
+ const entry = foreign.configs.get(origin.name);
1759
+ if (!entry || entry === AMBIGUOUS) return void 0;
1760
+ foreignDependencies.add(origin.filePath);
1761
+ helperModules.set(name, foreign.cssSpecifier ? rebaseSpecifier(foreign.cssSpecifier, origin.filePath, consuming.getFilePath()) : void 0);
1762
+ return entry;
1763
+ };
1764
+ const resolved = resolve();
1765
+ importedRecipes.set(name, resolved);
1766
+ return resolved;
1767
+ };
1548
1768
  const folded = [];
1549
1769
  const skipped = [];
1550
1770
  const candidates = [];
@@ -1575,58 +1795,6 @@ const foldSource = (options) => {
1575
1795
  }
1576
1796
  return names;
1577
1797
  };
1578
- /**
1579
- * Is this binding the variant half of `<recipe>.splitVariantProps(...)`?
1580
- *
1581
- * Read off the declaration rather than the type, so it is the same recipe and the same
1582
- * destructuring position the source actually wrote.
1583
- */
1584
- const isSplitVariantPropsOf = (binding, recipe) => {
1585
- for (const declaration of binding.getSourceFile().getDescendantsOfKind(ts_morph.SyntaxKind.VariableDeclaration)) {
1586
- const nameNode = declaration.getNameNode();
1587
- if (!ts_morph.Node.isArrayBindingPattern(nameNode)) continue;
1588
- const first = nameNode.getElements()[0];
1589
- if (!first || !ts_morph.Node.isBindingElement(first)) continue;
1590
- if (first.getNameNode().getText() !== binding.getText()) continue;
1591
- const initializer = declaration.getInitializer();
1592
- if (!initializer || !ts_morph.Node.isCallExpression(initializer)) return false;
1593
- const callee = initializer.getExpression();
1594
- if (!ts_morph.Node.isPropertyAccessExpression(callee)) return false;
1595
- return callee.getName() === "splitVariantProps" && callee.getExpression().getText() === recipe;
1596
- }
1597
- return false;
1598
- };
1599
- /**
1600
- * Lower a config recipe call the same way an inline one lowers.
1601
- *
1602
- * The config lives in `ctx.recipes` rather than in the module, and the classes are named
1603
- * from it identically — so this is the same `lowerRecipeCall`, handed the config from a
1604
- * different place. Slot recipes are excluded by the caller: they resolve to one class per
1605
- * slot rather than to a string.
1606
- */
1607
- const lowerConfigRecipeCall = (call, name) => {
1608
- const config = ctx.recipes.getConfig(name);
1609
- if (!config || config.slots !== void 0) return void 0;
1610
- const className = config.className;
1611
- if (!className) return void 0;
1612
- if (!ts_morph.Node.isCallExpression(call)) return void 0;
1613
- const argument = call.getArguments()[0];
1614
- if (!argument || !ts_morph.Node.isIdentifier(argument) || !isSplitVariantPropsOf(argument, name)) return void 0;
1615
- const lowered = lowerRecipeCall(call, {
1616
- config,
1617
- name: className,
1618
- box: void 0
1619
- }, ctx, isInertExpression);
1620
- if (lowered.kind !== "expression") return void 0;
1621
- const helper = ensureRecipeHelperImport(RECIPE_PICK_HELPER, call, isBambooCssModule, isGeneratedCssModule, isShadowed);
1622
- if (!helper) return void 0;
1623
- return {
1624
- replacement: helper.name === "cvaPick" ? lowered.expression : lowered.expression.replaceAll(`${RECIPE_PICK_HELPER}(`, `${helper.name}(`),
1625
- className: lowered.staticClasses,
1626
- classNames: lowered.classNames,
1627
- insert: helper.insert
1628
- };
1629
- };
1630
1798
  for (const item of parserResult.toArray()) {
1631
1799
  const type = item.type ?? "";
1632
1800
  const name = item.name ?? type;
@@ -1747,6 +1915,10 @@ const foldSource = (options) => {
1747
1915
  continue;
1748
1916
  }
1749
1917
  recipeConfigs ??= collectRecipeConfigs(parserResult);
1918
+ if (!recipeConfigs.has(name) && item.origin) {
1919
+ const imported = resolveImportedRecipe(call, name, item.origin);
1920
+ if (imported) recipeConfigs.set(name, imported);
1921
+ }
1750
1922
  const tally = recipeCalls.get(name) ?? {
1751
1923
  seen: 0,
1752
1924
  lowered: 0
@@ -1757,7 +1929,7 @@ const foldSource = (options) => {
1757
1929
  const entry = recipeConfigs.get(name);
1758
1930
  const lowered = lowerRecipeCall(call, entry, ctx, isInertExpression, resolvedSelection);
1759
1931
  if (lowered.kind === "expression") {
1760
- const helper = ensureRecipeHelperImport(RECIPE_PICK_HELPER, call, isBambooCssModule, isGeneratedCssModule, isShadowed);
1932
+ const helper = ensureRecipeHelperImport(RECIPE_PICK_HELPER, call, isBambooCssModule, isGeneratedCssModule, isShadowed, helperModules.get(name));
1761
1933
  if (helper) {
1762
1934
  tally.lowered++;
1763
1935
  candidates.push({
@@ -1871,25 +2043,19 @@ const foldSource = (options) => {
1871
2043
  if (!(ts_morph.Node.isCallExpression(call) && call.getArguments().length === 0 && item.data.length === 1) && !isStaticBox(item.box) || !hasStyles(item.data) || !argumentsAccountedFor(call, item.box)) {
1872
2044
  const partial = partial_ ? tryPartial(item, call, rootName) : void 0;
1873
2045
  if (partial) {
1874
- candidates.push({
1875
- item,
1876
- call,
1877
- node: call,
2046
+ if (reportSurvivors && partial.runtimeCallee) skipped.push({
2047
+ name,
2048
+ reason: "runtime-binding",
1878
2049
  start,
1879
- end,
1880
- ...partial
2050
+ end
1881
2051
  });
1882
- continue;
1883
- }
1884
- const lowered = type === "recipe" && !slot ? lowerConfigRecipeCall(call, name) : void 0;
1885
- if (lowered) {
1886
2052
  candidates.push({
1887
2053
  item,
1888
2054
  call,
1889
2055
  node: call,
1890
2056
  start,
1891
2057
  end,
1892
- ...lowered
2058
+ ...partial
1893
2059
  });
1894
2060
  continue;
1895
2061
  }
@@ -1910,13 +2076,22 @@ const foldSource = (options) => {
1910
2076
  slot
1911
2077
  });
1912
2078
  }
1913
- if (candidates.length === 0) return {
1914
- code,
1915
- map: null,
1916
- folded,
1917
- skipped,
1918
- dependencies: []
1919
- };
2079
+ /**
2080
+ * Ranges the rewrite actually replaced. Declared before the early return below, because
2081
+ * that return is now also a reporting point: a module with nothing to fold is exactly the
2082
+ * shape `reportSurvivors` exists to catch.
2083
+ */
2084
+ const applied = [];
2085
+ if (candidates.length === 0) {
2086
+ if (reportSurvivors) reportRuntimeBindings();
2087
+ return {
2088
+ code,
2089
+ map: null,
2090
+ folded,
2091
+ skipped,
2092
+ dependencies: []
2093
+ };
2094
+ }
1920
2095
  const dependencyScan = createDependencyScan(candidates[0].node.getSourceFile());
1921
2096
  candidates.sort((a, b) => a.start - b.start || b.end - a.end);
1922
2097
  const magic = new magic_string.default(code);
@@ -1925,10 +2100,9 @@ const foldSource = (options) => {
1925
2100
  if (!insert) return;
1926
2101
  const missing = insert.names.filter((name) => !insertedNames.has(name));
1927
2102
  if (!missing.length) return;
1928
- magic.appendLeft(insert.pos, missing.map((name) => `, ${name}`).join(""));
2103
+ magic.appendLeft(insert.pos, insert.module ? `\nimport { ${missing.join(", ")} } from '${insert.module}'` : missing.map((name) => `, ${name}`).join(""));
1929
2104
  for (const name of missing) insertedNames.add(name);
1930
2105
  };
1931
- const applied = [];
1932
2106
  const collides = (edits) => edits.some(([start, end]) => applied.some(([from, to]) => start < to && from < end));
1933
2107
  for (const candidate of candidates) {
1934
2108
  const { item, start, end } = candidate;
@@ -2020,7 +2194,7 @@ const foldSource = (options) => {
2020
2194
  });
2021
2195
  collectSourceFiles(item.box, dependencyScan);
2022
2196
  }
2023
- const recipeSourceFile = [...recipeConfigs?.values() ?? []].find((entry) => entry.box)?.box?.getNode?.()?.getSourceFile() ?? candidates[0]?.node.getSourceFile();
2197
+ const recipeSourceFile = candidates[0]?.node.getSourceFile();
2024
2198
  if (recipeSourceFile) for (const access of recipeSourceFile.getDescendantsOfKind(ts_morph.SyntaxKind.PropertyAccessExpression)) {
2025
2199
  if (access.getName() !== "splitVariantProps") continue;
2026
2200
  const target = access.getExpression();
@@ -2042,7 +2216,7 @@ const foldSource = (options) => {
2042
2216
  const end = call.getEnd();
2043
2217
  if (code.slice(start, end) !== call.getText()) continue;
2044
2218
  if (collides([[start, end]])) continue;
2045
- const helper = ensureRecipeHelperImport(SPLIT_PROPS_HELPER, call, isBambooCssModule, isGeneratedCssModule, isShadowed);
2219
+ const helper = ensureRecipeHelperImport(SPLIT_PROPS_HELPER, call, isBambooCssModule, isGeneratedCssModule, isShadowed, helperModules.get(target.getText()));
2046
2220
  if (!helper) continue;
2047
2221
  const keys = Object.keys(entry.config.variants ?? {});
2048
2222
  magic.overwrite(start, end, `${helper.name}(${args[0].getText()}, ${JSON.stringify(keys)})`);
@@ -2060,6 +2234,104 @@ const foldSource = (options) => {
2060
2234
  if (code.slice(start, call.getEnd()) !== call.getText()) continue;
2061
2235
  magic.appendLeft(start, "/*#__PURE__*/");
2062
2236
  }
2237
+ /**
2238
+ * Bindings from a bamboo module still referenced once every rewrite is applied.
2239
+ *
2240
+ * Deliberately not driven by `parserResult`: that is the recogniser, and the point here is
2241
+ * to catch what it did not see. A namespace import called as `s.cva(...)`, a default
2242
+ * import, a specifier that resolved to nothing — each leaves a live reference and no ledger
2243
+ * entry at all, which is how `strict` came to pass a build that still shipped the engine.
2244
+ *
2245
+ * The helpers the fold itself writes are excluded: `cx`, `cvaPick`, `splitProps` and the
2246
+ * leaf helper live in `cx` and pull no engine, so a reference to one is the fold working
2247
+ * rather than failing.
2248
+ */
2249
+ function reportRuntimeBindings() {
2250
+ const sourceFile = ownSourceFile ?? candidates[0]?.node.getSourceFile();
2251
+ if (!sourceFile) return;
2252
+ const bambooModules = [
2253
+ ...cssModules,
2254
+ ...ctx.imports.matchers.recipe?.mods ?? [],
2255
+ ...ctx.imports.matchers.pattern?.mods ?? [],
2256
+ ...ctx.imports.matchers.tokens?.mods ?? []
2257
+ ];
2258
+ /** Local name -> what to call it in the report. */
2259
+ const watched = /* @__PURE__ */ new Map();
2260
+ for (const declaration of sourceFile.getImportDeclarations()) {
2261
+ if (declaration.isTypeOnly()) continue;
2262
+ if (!matchesModule(declaration.getModuleSpecifierValue(), bambooModules)) continue;
2263
+ for (const named of declaration.getNamedImports()) {
2264
+ if (named.isTypeOnly()) continue;
2265
+ const imported = named.getNameNode().getText();
2266
+ if (PERMITTED_BINDINGS.has(imported)) continue;
2267
+ watched.set((named.getAliasNode() ?? named.getNameNode()).getText(), imported);
2268
+ }
2269
+ const namespace = declaration.getNamespaceImport();
2270
+ if (namespace) watched.set(namespace.getText(), `${namespace.getText()}.*`);
2271
+ const defaultImport = declaration.getDefaultImport();
2272
+ if (defaultImport) watched.set(defaultImport.getText(), defaultImport.getText());
2273
+ }
2274
+ for (const declaration of sourceFile.getExportDeclarations()) {
2275
+ if (declaration.isTypeOnly()) continue;
2276
+ if (!matchesModule(declaration.getModuleSpecifierValue() ?? "", bambooModules)) continue;
2277
+ if (declaration.isNamespaceExport()) {
2278
+ skipped.push({
2279
+ name: declaration.getNamespaceExport()?.getName() ?? "*",
2280
+ reason: "runtime-binding",
2281
+ start: declaration.getStart(),
2282
+ end: declaration.getEnd()
2283
+ });
2284
+ continue;
2285
+ }
2286
+ for (const named of declaration.getNamedExports()) {
2287
+ if (named.isTypeOnly()) continue;
2288
+ const imported = named.getNameNode().getText();
2289
+ if (PERMITTED_BINDINGS.has(imported)) continue;
2290
+ skipped.push({
2291
+ name: imported,
2292
+ reason: "runtime-binding",
2293
+ start: named.getStart(),
2294
+ end: named.getEnd()
2295
+ });
2296
+ }
2297
+ }
2298
+ for (const declaration of sourceFile.getExportDeclarations()) {
2299
+ if (declaration.isTypeOnly() || declaration.getModuleSpecifier()) continue;
2300
+ for (const named of declaration.getNamedExports()) {
2301
+ if (named.isTypeOnly()) continue;
2302
+ const imported = watched.get(named.getNameNode().getText());
2303
+ if (imported === void 0) continue;
2304
+ skipped.push({
2305
+ name: imported,
2306
+ reason: "runtime-binding",
2307
+ start: named.getStart(),
2308
+ end: named.getEnd()
2309
+ });
2310
+ }
2311
+ }
2312
+ if (watched.size === 0) return;
2313
+ const declined = skipped.filter((entry) => SURVIVES_TO_RUNTIME.has(entry.reason) && entry.end > entry.start).map((entry) => [entry.start, entry.end]);
2314
+ const reported = /* @__PURE__ */ new Set();
2315
+ for (const identifier of sourceFile.getDescendantsOfKind(ts_morph.SyntaxKind.Identifier)) {
2316
+ const local = identifier.getText();
2317
+ const imported = watched.get(local);
2318
+ if (imported === void 0 || reported.has(local)) continue;
2319
+ const start = identifier.getStart();
2320
+ if (identifier.getFirstAncestorByKind(ts_morph.SyntaxKind.ImportDeclaration)) continue;
2321
+ if (applied.some(([from, to]) => start >= from && start < to)) continue;
2322
+ if (declined.some(([from, to]) => start >= from && start < to)) continue;
2323
+ if (!isValueReference(identifier)) continue;
2324
+ if (isShadowed(identifier, local)) continue;
2325
+ reported.add(local);
2326
+ skipped.push({
2327
+ name: imported,
2328
+ reason: "runtime-binding",
2329
+ start,
2330
+ end: identifier.getEnd()
2331
+ });
2332
+ }
2333
+ }
2334
+ if (reportSurvivors) reportRuntimeBindings();
2063
2335
  if (folded.length === 0) return {
2064
2336
  code,
2065
2337
  map: null,
@@ -2076,7 +2348,7 @@ const foldSource = (options) => {
2076
2348
  }),
2077
2349
  folded,
2078
2350
  skipped,
2079
- dependencies: Array.from(dependencyScan.results)
2351
+ dependencies: [...dependencyScan.results, ...foreignDependencies]
2080
2352
  };
2081
2353
  };
2082
2354
  //#endregion
@@ -2110,21 +2382,6 @@ const isGeneratedOutput = (filePath, ctx) => {
2110
2382
  const file = slashed(filePath);
2111
2383
  return file === root || file.startsWith(`${root}/`);
2112
2384
  };
2113
- /**
2114
- * The skip reasons that leave a `css()`-family call in the output.
2115
- *
2116
- * `overlapping` is handled by the enclosing fold, and `not-imported` is somebody else's
2117
- * function of the same name — neither leaves a call of ours. `not-foldable` is a `cva`/`sva`
2118
- * definition, which keeps the recipe runtime rather than the css engine; see `strict`.
2119
- */
2120
- const SURVIVES_TO_RUNTIME = new Set([
2121
- "dynamic",
2122
- "raw-call",
2123
- "unsupported-kind",
2124
- "no-call-expression",
2125
- "empty",
2126
- "unresolved-token"
2127
- ]);
2128
2385
  /** 1-indexed line of a source offset, for an error a user can navigate to. */
2129
2386
  const lineAt = (code, offset) => code.slice(0, offset).split("\n").length;
2130
2387
  const formatSkipped = (id, skipped) => {
@@ -2155,6 +2412,13 @@ const bamboocss = (options = {}) => {
2155
2412
  };
2156
2413
  /** Under `strict`, every call that would still reach the runtime. */
2157
2414
  const survivors = [];
2415
+ /**
2416
+ * Recipe configs read out of modules other than the one being transformed.
2417
+ *
2418
+ * Per build rather than per module: a recipe declared once and imported by fifty components
2419
+ * would otherwise re-parse its module fifty times, which is the transform path.
2420
+ */
2421
+ const recipeConfigCache = /* @__PURE__ */ new Map();
2158
2422
  let ctx;
2159
2423
  let runtimeCss;
2160
2424
  let setup;
@@ -2182,6 +2446,7 @@ const bamboocss = (options = {}) => {
2182
2446
  totals.filesWithFolds = 0;
2183
2447
  totals.skipped.clear();
2184
2448
  survivors.length = 0;
2449
+ recipeConfigCache.clear();
2185
2450
  await ensureContext();
2186
2451
  },
2187
2452
  /**
@@ -2209,6 +2474,7 @@ const bamboocss = (options = {}) => {
2209
2474
  if (!shouldTransform(id)) return;
2210
2475
  const [filePath] = id.split("?");
2211
2476
  if (!filePath) return;
2477
+ recipeConfigCache.clear();
2212
2478
  if (change.event === "delete") {
2213
2479
  ctx.project.removeSourceFile(filePath);
2214
2480
  return;
@@ -2224,16 +2490,20 @@ const bamboocss = (options = {}) => {
2224
2490
  if (isGeneratedOutput(filePath, ctx)) return null;
2225
2491
  let result;
2226
2492
  try {
2227
- ctx.project.addSourceFile(filePath, code);
2493
+ const sourceFile = ctx.project.addSourceFile(filePath, code);
2228
2494
  const parserResult = ctx.project.parseSourceFile(filePath);
2229
- if (!parserResult || parserResult.isEmpty()) return null;
2495
+ if (!parserResult || parserResult.isEmpty() && !strict) return null;
2230
2496
  result = foldSource({
2231
2497
  ctx,
2232
2498
  code,
2233
2499
  parserResult,
2234
2500
  filePath,
2235
2501
  runtimeCss,
2236
- partial
2502
+ partial,
2503
+ parseModule: (path) => ctx?.project.parseSourceFile(path),
2504
+ recipeConfigCache,
2505
+ reportSurvivors: strict,
2506
+ sourceFile
2237
2507
  });
2238
2508
  } catch (error) {
2239
2509
  _bamboocss_logger.logger.caughtError("vite:transform", `Failed to fold ${filePath}`, error);
@@ -2277,7 +2547,7 @@ const bamboocss = (options = {}) => {
2277
2547
  list.push(entry);
2278
2548
  byFile.set(entry.file, list);
2279
2549
  }
2280
- const detail = Array.from(byFile.entries()).map(([file, entries]) => [` ${file}`, ...entries.map((e) => ` ${e.line}: ${e.name}() — ${e.reason}`)].join("\n")).join("\n");
2550
+ 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");
2281
2551
  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.`);
2282
2552
  }
2283
2553
  if (!transform || !reportSummary) return;