@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.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.
@@ -1148,11 +1167,24 @@ const createRuntimeRecipe = (ctx) => {
1148
1167
  }
1149
1168
  }
1150
1169
  });
1151
- const recipeStyles = isConstantSlot ? { [className]: "__ignore__" } : {
1170
+ const declaredValues = config.variants ?? {};
1171
+ /**
1172
+ * The same filter the generated `createRecipe` applies: only a value the config declares
1173
+ * names a class.
1174
+ *
1175
+ * Scalars only — a conditional or responsive value is an object of leaves, and the leaves
1176
+ * are what name classes when `createCss` walks them.
1177
+ */
1178
+ const onlyDeclared = (styles) => Object.fromEntries(Object.entries(styles).filter(([prop, value]) => {
1179
+ if (prop === className) return true;
1180
+ if (value === null || typeof value === "object") return true;
1181
+ return Object.hasOwn(declaredValues, prop) && Object.hasOwn(declaredValues[prop] ?? {}, String(value));
1182
+ }));
1183
+ const recipeStyles = isConstantSlot ? { [className]: "__ignore__" } : onlyDeclared({
1152
1184
  [className]: "__ignore__",
1153
1185
  ...defaultVariants,
1154
1186
  ...compact(variants)
1155
- };
1187
+ });
1156
1188
  if (compoundVariants.length > 0 && Object.keys(recipeStyles).some((prop) => typeof variants[prop] === "object")) return;
1157
1189
  if (isSlotRecipe) {
1158
1190
  const evaluated = anchors.length > 0 ? anchors : config.slots;
@@ -1192,6 +1224,22 @@ const literalsIn = (expression) => [...expression.matchAll(/"((?:[^"\\]|\\.)*)"/
1192
1224
  */
1193
1225
  const UNFOLDABLE_TYPES = new Set(["cva", "sva"]);
1194
1226
  /**
1227
+ * The skip reasons that leave a `css()`-family call in the output.
1228
+ *
1229
+ * `overlapping` is handled by the enclosing fold, and `not-imported` is somebody else's
1230
+ * function of the same name — neither leaves a call of ours. `not-foldable` is a `cva`/`sva`
1231
+ * definition, which keeps the recipe runtime rather than the css engine; see `strict`.
1232
+ */
1233
+ const SURVIVES_TO_RUNTIME = new Set([
1234
+ "dynamic",
1235
+ "runtime-binding",
1236
+ "raw-call",
1237
+ "unsupported-kind",
1238
+ "no-call-expression",
1239
+ "empty",
1240
+ "unresolved-token"
1241
+ ]);
1242
+ /**
1195
1243
  * A call of a recipe the file bound itself: `const badge = cva(...)`, then `badge({ tone })`.
1196
1244
  *
1197
1245
  * Folded when the whole selection resolves, reported under this reason when it does not.
@@ -1204,6 +1252,65 @@ const UNFOLDABLE_TYPES = new Set(["cva", "sva"]);
1204
1252
  */
1205
1253
  const RECIPE_CALL_TYPE = "cva-call";
1206
1254
  /**
1255
+ * An identifier that actually reads the binding.
1256
+ *
1257
+ * `getDescendantsOfKind(Identifier)` yields every name in the file, and most of them bind or
1258
+ * label rather than read: a JSX tag (`<button/>` against a recipe called `button`), an object
1259
+ * key, a property name, a declaration. Counting those failed builds on modules that had
1260
+ * folded completely — and `button`, `input`, `label`, `select`, `table`, `dialog` and `form`
1261
+ * are all ordinary recipe names as well as intrinsic elements.
1262
+ *
1263
+ * A type position is excluded for a different reason: it is erased, and with it the import.
1264
+ */
1265
+ const isValueReference = (identifier) => {
1266
+ const parent = identifier.getParent();
1267
+ if (!parent) return false;
1268
+ if (Node.isImportSpecifier(parent) || Node.isExportSpecifier(parent)) return false;
1269
+ if (Node.isImportClause(parent) || Node.isNamespaceImport(parent)) return false;
1270
+ if (Node.isPropertyAccessExpression(parent) && parent.getNameNode() === identifier) return false;
1271
+ if (Node.isQualifiedName(parent) && parent.getRight() === identifier) return false;
1272
+ if (Node.isPropertyAssignment(parent) && parent.getNameNode() === identifier) return false;
1273
+ if (Node.isMethodDeclaration(parent) || Node.isPropertyDeclaration(parent) || Node.isGetAccessorDeclaration(parent) || Node.isSetAccessorDeclaration(parent) || Node.isMethodSignature(parent) || Node.isPropertySignature(parent) || Node.isEnumMember(parent)) {
1274
+ if (parent.getNameNode() === identifier) return false;
1275
+ }
1276
+ if (Node.isLabeledStatement(parent) || Node.isBreakStatement(parent) || Node.isContinueStatement(parent)) return false;
1277
+ if (Node.isJsxOpeningElement(parent) || Node.isJsxSelfClosingElement(parent) || Node.isJsxClosingElement(parent)) {
1278
+ if (parent.getTagNameNode() === identifier) return identifier.getText()[0] === identifier.getText()[0]?.toUpperCase();
1279
+ }
1280
+ if (Node.isJsxAttribute(parent)) return false;
1281
+ if (Node.isBindingElement(parent) && parent.getPropertyNameNode() === identifier) return false;
1282
+ if ((Node.isVariableDeclaration(parent) || Node.isParameterDeclaration(parent) || Node.isBindingElement(parent) || Node.isFunctionDeclaration(parent) || Node.isClassDeclaration(parent)) && parent.getNameNode() === identifier) return false;
1283
+ return !identifier.getFirstAncestor((ancestor) => Node.isTypeNode(ancestor) || Node.isTypeAliasDeclaration(ancestor) || Node.isInterfaceDeclaration(ancestor));
1284
+ };
1285
+ /**
1286
+ * Imports a surviving reference to is not a failure.
1287
+ *
1288
+ * The first four are what the fold itself writes; all live in `cx` and pull no engine, so a
1289
+ * reference to one is the fold having worked.
1290
+ *
1291
+ * `cva` and `sva` are there for the reason `SURVIVES_TO_RUNTIME` omits `not-foldable`: a
1292
+ * recipe *definition* cannot fold to a class string and never could, and what it keeps is the
1293
+ * recipe runtime rather than the css engine — which `strict` accepts. Their unfoldable
1294
+ * invocations are reported separately, as `recipe-call`.
1295
+ */
1296
+ const PERMITTED_BINDINGS = new Set([
1297
+ "cx",
1298
+ "cva",
1299
+ "sva",
1300
+ RECIPE_PICK_HELPER,
1301
+ SPLIT_PROPS_HELPER,
1302
+ LEAF_HELPER
1303
+ ]);
1304
+ /**
1305
+ * The pieces `trim` reduces a module specifier by, hoisted because a regex literal
1306
+ * constructs a new object every time it is evaluated and `trim` runs per specifier per
1307
+ * import declaration per module.
1308
+ */
1309
+ const LEADING_RELATIVE = /^(?:\.\.?\/)+/;
1310
+ const TRAILING_SLASH = /\/$/;
1311
+ const MODULE_EXTENSION = /\.[mc]?[jt]sx?$/;
1312
+ const TRAILING_INDEX = /\/index$/;
1313
+ /**
1207
1314
  * An argument that cannot run anything when it is evaluated.
1208
1315
  *
1209
1316
  * `token(path, fallback)` evaluates both arguments before the call, so a fold that drops
@@ -1419,7 +1526,7 @@ const argumentsAccountedFor = (call, boxNode) => {
1419
1526
  return accountsForSource(args[0], boxNode);
1420
1527
  };
1421
1528
  const foldSource = (options) => {
1422
- const { ctx, code, parserResult, partial: partial_ = true, runtimeCss = createRuntimeCss(ctx) } = options;
1529
+ const { ctx, code, parserResult, partial: partial_ = true, runtimeCss = createRuntimeCss(ctx), parseModule, recipeConfigCache, reportSurvivors, sourceFile: ownSourceFile } = options;
1423
1530
  /**
1424
1531
  * Recover the static half of a call the whole-call path gave up on. Only a
1425
1532
  * single-argument `css()` qualifies: a pattern or recipe call takes props rather than a
@@ -1471,7 +1578,8 @@ const foldSource = (options) => {
1471
1578
  className: plan.className,
1472
1579
  classNames: [plan.className, ...plan.finite.filter((entry) => entry.emitsLiterals).flatMap((entry) => literalsIn(entry.expression))].filter(Boolean),
1473
1580
  replacement: `${cx.name}(${parts.join(", ")})`,
1474
- insert: cx.insert
1581
+ insert: cx.insert,
1582
+ runtimeCallee: runtimePart ? callee : void 0
1475
1583
  };
1476
1584
  };
1477
1585
  const runtimeRecipe = createRuntimeRecipe(ctx);
@@ -1501,7 +1609,25 @@ const foldSource = (options) => {
1501
1609
  */
1502
1610
  const generatedCssModule = [ctx.imports.outdir, "css"].join("/");
1503
1611
  const pathMappings = ctx.conf.tsOptions?.pathMappings;
1504
- const trim = (value) => value.replaceAll("\\", "/").replace(/^(?:\.\.?\/)+/, "").replace(/\/$/, "");
1612
+ /**
1613
+ * The spelling reduced to the module it names.
1614
+ *
1615
+ * The extension and `/index` are stripped because bamboo's own output makes a file
1616
+ * import them: `outExtension: 'js'` under NodeNext resolution is written
1617
+ * `styled-system/css/index.js`, which is neither equal to `styled-system/css` nor a
1618
+ * tail of it. Extraction admitted such a file anyway — `ImportMap.match` is
1619
+ * substring-based — so the call was folded while the *insert* was refused, and the
1620
+ * result was reported as `dynamic`: the same silent downgrade the alias case above
1621
+ * describes, reached through the extension instead.
1622
+ *
1623
+ * This does not weaken the equality the comment above insists on. `styled-system/css/css`
1624
+ * still names neither, because only a trailing `/index` is a module's own directory.
1625
+ *
1626
+ * `.d.ts` is deliberately not stripped. A declaration file exports no runtime binding, so
1627
+ * matching one would authorise inserting an import that resolves to nothing — and a value
1628
+ * import cannot name one anyway, which is what makes leaving it out free.
1629
+ */
1630
+ const trim = (value) => value.replaceAll("\\", "/").replace(LEADING_RELATIVE, "").replace(TRAILING_SLASH, "").replace(MODULE_EXTENSION, "").replace(TRAILING_INDEX, "");
1505
1631
  const matchesModule = (mod, entries) => {
1506
1632
  const candidates = [mod];
1507
1633
  if (pathMappings) {
@@ -1518,6 +1644,100 @@ const foldSource = (options) => {
1518
1644
  };
1519
1645
  const isBambooCssModule = (mod) => matchesModule(mod, cssModules);
1520
1646
  const isGeneratedCssModule = (mod) => matchesModule(mod, [generatedCssModule]);
1647
+ /**
1648
+ * How *this* module would have to spell the css module, learnt from one that already does.
1649
+ *
1650
+ * A file calling an imported recipe need not import the css module at all, so when the
1651
+ * lowering needs `cvaPick` there is no spelling in the file to copy. The declaring module
1652
+ * necessarily has one — `cva` came from it — and that is the spelling reused here.
1653
+ *
1654
+ * A bare or aliased specifier resolves identically from any file, so it is taken as
1655
+ * written. A relative one is re-based: resolved against the module that wrote it, then
1656
+ * expressed from the module being folded.
1657
+ */
1658
+ const cssModuleSpecifierFrom = (declaring) => {
1659
+ for (const declaration of declaring.getImportDeclarations()) {
1660
+ if (declaration.isTypeOnly()) continue;
1661
+ const mod = declaration.getModuleSpecifierValue();
1662
+ if (isGeneratedCssModule(mod)) return mod;
1663
+ }
1664
+ };
1665
+ /**
1666
+ * That spelling, said from the module being folded.
1667
+ *
1668
+ * A bare or aliased specifier resolves identically from any file, so it is taken as
1669
+ * written. A relative one is re-based: resolved against the module that wrote it, then
1670
+ * expressed from the module being folded. Pure path arithmetic, so it holds a string
1671
+ * rather than a node — a cached node does not survive the next `addSourceFile`, which
1672
+ * ts-morph implements by forgetting the file's whole tree.
1673
+ */
1674
+ const rebaseSpecifier = (specifier, declaringPath, consumingPath) => {
1675
+ if (!specifier.startsWith(".")) return specifier;
1676
+ const absolute = resolve(dirname(declaringPath), specifier);
1677
+ const rebased = relative(dirname(consumingPath), absolute).replaceAll("\\", "/");
1678
+ if (!rebased) return void 0;
1679
+ return rebased.startsWith(".") ? rebased : `./${rebased}`;
1680
+ };
1681
+ /**
1682
+ * Configs of one foreign module, parsed once however many of its recipes are called.
1683
+ *
1684
+ * Falls back to a per-call map when the caller supplies none, so the fold stays correct
1685
+ * standalone — only repeated, which is what the shared cache exists to avoid.
1686
+ */
1687
+ const configsByModule = recipeConfigCache ?? /* @__PURE__ */ new Map();
1688
+ /** The specifier each imported recipe's module used for the css module, when it needs one. */
1689
+ const helperModules = /* @__PURE__ */ new Map();
1690
+ /** Declaring modules a fold read, recorded as paths because their nodes do not persist. */
1691
+ const foreignDependencies = /* @__PURE__ */ new Set();
1692
+ /** Resolutions for this module's own call sites, keyed by the name the call site writes. */
1693
+ const importedRecipes = /* @__PURE__ */ new Map();
1694
+ /**
1695
+ * The config of a recipe this module imports.
1696
+ *
1697
+ * The binding is followed with ts-morph's symbol aliasing rather than by re-reading import
1698
+ * declarations, because that is what already understands the shapes these are reached
1699
+ * through: `export { badge } from './styles'`, `export * from './styles'`, and an alias at
1700
+ * either end. Each hop is an alias symbol, so following them to a non-alias lands on the
1701
+ * declaration wherever it lives.
1702
+ *
1703
+ * The class names do not depend on which module the call is in — `getRecipeIdentity` hashes
1704
+ * the config — so a recipe lowered here produces exactly the string its own module's call
1705
+ * sites produce, and exactly the one the runtime would have.
1706
+ */
1707
+ const resolveImportedRecipe = (call, name, origin) => {
1708
+ if (importedRecipes.has(name)) return importedRecipes.get(name);
1709
+ const resolve = () => {
1710
+ if (!parseModule) return void 0;
1711
+ const consuming = call.getSourceFile();
1712
+ if (origin.filePath === consuming.getFilePath()) return void 0;
1713
+ let foreign = configsByModule.get(origin.filePath);
1714
+ if (!foreign) {
1715
+ const result = parseModule(origin.filePath);
1716
+ if (!result) return void 0;
1717
+ const collected = collectRecipeConfigs(result);
1718
+ const declaring = [...collected.values()].find((entry) => entry.box)?.box?.getNode?.()?.getSourceFile();
1719
+ const configs = /* @__PURE__ */ new Map();
1720
+ for (const [key, entry] of collected) configs.set(key, entry === AMBIGUOUS ? entry : {
1721
+ config: entry.config,
1722
+ name: entry.name,
1723
+ box: void 0
1724
+ });
1725
+ foreign = {
1726
+ configs,
1727
+ cssSpecifier: declaring ? cssModuleSpecifierFrom(declaring) : void 0
1728
+ };
1729
+ configsByModule.set(origin.filePath, foreign);
1730
+ }
1731
+ const entry = foreign.configs.get(origin.name);
1732
+ if (!entry || entry === AMBIGUOUS) return void 0;
1733
+ foreignDependencies.add(origin.filePath);
1734
+ helperModules.set(name, foreign.cssSpecifier ? rebaseSpecifier(foreign.cssSpecifier, origin.filePath, consuming.getFilePath()) : void 0);
1735
+ return entry;
1736
+ };
1737
+ const resolved = resolve();
1738
+ importedRecipes.set(name, resolved);
1739
+ return resolved;
1740
+ };
1521
1741
  const folded = [];
1522
1742
  const skipped = [];
1523
1743
  const candidates = [];
@@ -1548,58 +1768,6 @@ const foldSource = (options) => {
1548
1768
  }
1549
1769
  return names;
1550
1770
  };
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
1771
  for (const item of parserResult.toArray()) {
1604
1772
  const type = item.type ?? "";
1605
1773
  const name = item.name ?? type;
@@ -1720,6 +1888,10 @@ const foldSource = (options) => {
1720
1888
  continue;
1721
1889
  }
1722
1890
  recipeConfigs ??= collectRecipeConfigs(parserResult);
1891
+ if (!recipeConfigs.has(name) && item.origin) {
1892
+ const imported = resolveImportedRecipe(call, name, item.origin);
1893
+ if (imported) recipeConfigs.set(name, imported);
1894
+ }
1723
1895
  const tally = recipeCalls.get(name) ?? {
1724
1896
  seen: 0,
1725
1897
  lowered: 0
@@ -1730,7 +1902,7 @@ const foldSource = (options) => {
1730
1902
  const entry = recipeConfigs.get(name);
1731
1903
  const lowered = lowerRecipeCall(call, entry, ctx, isInertExpression, resolvedSelection);
1732
1904
  if (lowered.kind === "expression") {
1733
- const helper = ensureRecipeHelperImport(RECIPE_PICK_HELPER, call, isBambooCssModule, isGeneratedCssModule, isShadowed);
1905
+ const helper = ensureRecipeHelperImport(RECIPE_PICK_HELPER, call, isBambooCssModule, isGeneratedCssModule, isShadowed, helperModules.get(name));
1734
1906
  if (helper) {
1735
1907
  tally.lowered++;
1736
1908
  candidates.push({
@@ -1844,25 +2016,19 @@ const foldSource = (options) => {
1844
2016
  if (!(Node.isCallExpression(call) && call.getArguments().length === 0 && item.data.length === 1) && !isStaticBox(item.box) || !hasStyles(item.data) || !argumentsAccountedFor(call, item.box)) {
1845
2017
  const partial = partial_ ? tryPartial(item, call, rootName) : void 0;
1846
2018
  if (partial) {
1847
- candidates.push({
1848
- item,
1849
- call,
1850
- node: call,
2019
+ if (reportSurvivors && partial.runtimeCallee) skipped.push({
2020
+ name,
2021
+ reason: "runtime-binding",
1851
2022
  start,
1852
- end,
1853
- ...partial
2023
+ end
1854
2024
  });
1855
- continue;
1856
- }
1857
- const lowered = type === "recipe" && !slot ? lowerConfigRecipeCall(call, name) : void 0;
1858
- if (lowered) {
1859
2025
  candidates.push({
1860
2026
  item,
1861
2027
  call,
1862
2028
  node: call,
1863
2029
  start,
1864
2030
  end,
1865
- ...lowered
2031
+ ...partial
1866
2032
  });
1867
2033
  continue;
1868
2034
  }
@@ -1883,13 +2049,22 @@ const foldSource = (options) => {
1883
2049
  slot
1884
2050
  });
1885
2051
  }
1886
- if (candidates.length === 0) return {
1887
- code,
1888
- map: null,
1889
- folded,
1890
- skipped,
1891
- dependencies: []
1892
- };
2052
+ /**
2053
+ * Ranges the rewrite actually replaced. Declared before the early return below, because
2054
+ * that return is now also a reporting point: a module with nothing to fold is exactly the
2055
+ * shape `reportSurvivors` exists to catch.
2056
+ */
2057
+ const applied = [];
2058
+ if (candidates.length === 0) {
2059
+ if (reportSurvivors) reportRuntimeBindings();
2060
+ return {
2061
+ code,
2062
+ map: null,
2063
+ folded,
2064
+ skipped,
2065
+ dependencies: []
2066
+ };
2067
+ }
1893
2068
  const dependencyScan = createDependencyScan(candidates[0].node.getSourceFile());
1894
2069
  candidates.sort((a, b) => a.start - b.start || b.end - a.end);
1895
2070
  const magic = new MagicString(code);
@@ -1898,10 +2073,9 @@ const foldSource = (options) => {
1898
2073
  if (!insert) return;
1899
2074
  const missing = insert.names.filter((name) => !insertedNames.has(name));
1900
2075
  if (!missing.length) return;
1901
- magic.appendLeft(insert.pos, missing.map((name) => `, ${name}`).join(""));
2076
+ magic.appendLeft(insert.pos, insert.module ? `\nimport { ${missing.join(", ")} } from '${insert.module}'` : missing.map((name) => `, ${name}`).join(""));
1902
2077
  for (const name of missing) insertedNames.add(name);
1903
2078
  };
1904
- const applied = [];
1905
2079
  const collides = (edits) => edits.some(([start, end]) => applied.some(([from, to]) => start < to && from < end));
1906
2080
  for (const candidate of candidates) {
1907
2081
  const { item, start, end } = candidate;
@@ -1993,7 +2167,7 @@ const foldSource = (options) => {
1993
2167
  });
1994
2168
  collectSourceFiles(item.box, dependencyScan);
1995
2169
  }
1996
- const recipeSourceFile = [...recipeConfigs?.values() ?? []].find((entry) => entry.box)?.box?.getNode?.()?.getSourceFile() ?? candidates[0]?.node.getSourceFile();
2170
+ const recipeSourceFile = candidates[0]?.node.getSourceFile();
1997
2171
  if (recipeSourceFile) for (const access of recipeSourceFile.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
1998
2172
  if (access.getName() !== "splitVariantProps") continue;
1999
2173
  const target = access.getExpression();
@@ -2015,7 +2189,7 @@ const foldSource = (options) => {
2015
2189
  const end = call.getEnd();
2016
2190
  if (code.slice(start, end) !== call.getText()) continue;
2017
2191
  if (collides([[start, end]])) continue;
2018
- const helper = ensureRecipeHelperImport(SPLIT_PROPS_HELPER, call, isBambooCssModule, isGeneratedCssModule, isShadowed);
2192
+ const helper = ensureRecipeHelperImport(SPLIT_PROPS_HELPER, call, isBambooCssModule, isGeneratedCssModule, isShadowed, helperModules.get(target.getText()));
2019
2193
  if (!helper) continue;
2020
2194
  const keys = Object.keys(entry.config.variants ?? {});
2021
2195
  magic.overwrite(start, end, `${helper.name}(${args[0].getText()}, ${JSON.stringify(keys)})`);
@@ -2033,6 +2207,104 @@ const foldSource = (options) => {
2033
2207
  if (code.slice(start, call.getEnd()) !== call.getText()) continue;
2034
2208
  magic.appendLeft(start, "/*#__PURE__*/");
2035
2209
  }
2210
+ /**
2211
+ * Bindings from a bamboo module still referenced once every rewrite is applied.
2212
+ *
2213
+ * Deliberately not driven by `parserResult`: that is the recogniser, and the point here is
2214
+ * to catch what it did not see. A namespace import called as `s.cva(...)`, a default
2215
+ * import, a specifier that resolved to nothing — each leaves a live reference and no ledger
2216
+ * entry at all, which is how `strict` came to pass a build that still shipped the engine.
2217
+ *
2218
+ * The helpers the fold itself writes are excluded: `cx`, `cvaPick`, `splitProps` and the
2219
+ * leaf helper live in `cx` and pull no engine, so a reference to one is the fold working
2220
+ * rather than failing.
2221
+ */
2222
+ function reportRuntimeBindings() {
2223
+ const sourceFile = ownSourceFile ?? candidates[0]?.node.getSourceFile();
2224
+ if (!sourceFile) return;
2225
+ const bambooModules = [
2226
+ ...cssModules,
2227
+ ...ctx.imports.matchers.recipe?.mods ?? [],
2228
+ ...ctx.imports.matchers.pattern?.mods ?? [],
2229
+ ...ctx.imports.matchers.tokens?.mods ?? []
2230
+ ];
2231
+ /** Local name -> what to call it in the report. */
2232
+ const watched = /* @__PURE__ */ new Map();
2233
+ for (const declaration of sourceFile.getImportDeclarations()) {
2234
+ if (declaration.isTypeOnly()) continue;
2235
+ if (!matchesModule(declaration.getModuleSpecifierValue(), bambooModules)) continue;
2236
+ for (const named of declaration.getNamedImports()) {
2237
+ if (named.isTypeOnly()) continue;
2238
+ const imported = named.getNameNode().getText();
2239
+ if (PERMITTED_BINDINGS.has(imported)) continue;
2240
+ watched.set((named.getAliasNode() ?? named.getNameNode()).getText(), imported);
2241
+ }
2242
+ const namespace = declaration.getNamespaceImport();
2243
+ if (namespace) watched.set(namespace.getText(), `${namespace.getText()}.*`);
2244
+ const defaultImport = declaration.getDefaultImport();
2245
+ if (defaultImport) watched.set(defaultImport.getText(), defaultImport.getText());
2246
+ }
2247
+ for (const declaration of sourceFile.getExportDeclarations()) {
2248
+ if (declaration.isTypeOnly()) continue;
2249
+ if (!matchesModule(declaration.getModuleSpecifierValue() ?? "", bambooModules)) continue;
2250
+ if (declaration.isNamespaceExport()) {
2251
+ skipped.push({
2252
+ name: declaration.getNamespaceExport()?.getName() ?? "*",
2253
+ reason: "runtime-binding",
2254
+ start: declaration.getStart(),
2255
+ end: declaration.getEnd()
2256
+ });
2257
+ continue;
2258
+ }
2259
+ for (const named of declaration.getNamedExports()) {
2260
+ if (named.isTypeOnly()) continue;
2261
+ const imported = named.getNameNode().getText();
2262
+ if (PERMITTED_BINDINGS.has(imported)) continue;
2263
+ skipped.push({
2264
+ name: imported,
2265
+ reason: "runtime-binding",
2266
+ start: named.getStart(),
2267
+ end: named.getEnd()
2268
+ });
2269
+ }
2270
+ }
2271
+ for (const declaration of sourceFile.getExportDeclarations()) {
2272
+ if (declaration.isTypeOnly() || declaration.getModuleSpecifier()) continue;
2273
+ for (const named of declaration.getNamedExports()) {
2274
+ if (named.isTypeOnly()) continue;
2275
+ const imported = watched.get(named.getNameNode().getText());
2276
+ if (imported === void 0) continue;
2277
+ skipped.push({
2278
+ name: imported,
2279
+ reason: "runtime-binding",
2280
+ start: named.getStart(),
2281
+ end: named.getEnd()
2282
+ });
2283
+ }
2284
+ }
2285
+ if (watched.size === 0) return;
2286
+ const declined = skipped.filter((entry) => SURVIVES_TO_RUNTIME.has(entry.reason) && entry.end > entry.start).map((entry) => [entry.start, entry.end]);
2287
+ const reported = /* @__PURE__ */ new Set();
2288
+ for (const identifier of sourceFile.getDescendantsOfKind(SyntaxKind.Identifier)) {
2289
+ const local = identifier.getText();
2290
+ const imported = watched.get(local);
2291
+ if (imported === void 0 || reported.has(local)) continue;
2292
+ const start = identifier.getStart();
2293
+ if (identifier.getFirstAncestorByKind(SyntaxKind.ImportDeclaration)) continue;
2294
+ if (applied.some(([from, to]) => start >= from && start < to)) continue;
2295
+ if (declined.some(([from, to]) => start >= from && start < to)) continue;
2296
+ if (!isValueReference(identifier)) continue;
2297
+ if (isShadowed(identifier, local)) continue;
2298
+ reported.add(local);
2299
+ skipped.push({
2300
+ name: imported,
2301
+ reason: "runtime-binding",
2302
+ start,
2303
+ end: identifier.getEnd()
2304
+ });
2305
+ }
2306
+ }
2307
+ if (reportSurvivors) reportRuntimeBindings();
2036
2308
  if (folded.length === 0) return {
2037
2309
  code,
2038
2310
  map: null,
@@ -2049,7 +2321,7 @@ const foldSource = (options) => {
2049
2321
  }),
2050
2322
  folded,
2051
2323
  skipped,
2052
- dependencies: Array.from(dependencyScan.results)
2324
+ dependencies: [...dependencyScan.results, ...foreignDependencies]
2053
2325
  };
2054
2326
  };
2055
2327
  //#endregion
@@ -2083,21 +2355,6 @@ const isGeneratedOutput = (filePath, ctx) => {
2083
2355
  const file = slashed(filePath);
2084
2356
  return file === root || file.startsWith(`${root}/`);
2085
2357
  };
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
2358
  /** 1-indexed line of a source offset, for an error a user can navigate to. */
2102
2359
  const lineAt = (code, offset) => code.slice(0, offset).split("\n").length;
2103
2360
  const formatSkipped = (id, skipped) => {
@@ -2128,6 +2385,13 @@ const bamboocss = (options = {}) => {
2128
2385
  };
2129
2386
  /** Under `strict`, every call that would still reach the runtime. */
2130
2387
  const survivors = [];
2388
+ /**
2389
+ * Recipe configs read out of modules other than the one being transformed.
2390
+ *
2391
+ * Per build rather than per module: a recipe declared once and imported by fifty components
2392
+ * would otherwise re-parse its module fifty times, which is the transform path.
2393
+ */
2394
+ const recipeConfigCache = /* @__PURE__ */ new Map();
2131
2395
  let ctx;
2132
2396
  let runtimeCss;
2133
2397
  let setup;
@@ -2155,6 +2419,7 @@ const bamboocss = (options = {}) => {
2155
2419
  totals.filesWithFolds = 0;
2156
2420
  totals.skipped.clear();
2157
2421
  survivors.length = 0;
2422
+ recipeConfigCache.clear();
2158
2423
  await ensureContext();
2159
2424
  },
2160
2425
  /**
@@ -2182,6 +2447,7 @@ const bamboocss = (options = {}) => {
2182
2447
  if (!shouldTransform(id)) return;
2183
2448
  const [filePath] = id.split("?");
2184
2449
  if (!filePath) return;
2450
+ recipeConfigCache.clear();
2185
2451
  if (change.event === "delete") {
2186
2452
  ctx.project.removeSourceFile(filePath);
2187
2453
  return;
@@ -2197,16 +2463,20 @@ const bamboocss = (options = {}) => {
2197
2463
  if (isGeneratedOutput(filePath, ctx)) return null;
2198
2464
  let result;
2199
2465
  try {
2200
- ctx.project.addSourceFile(filePath, code);
2466
+ const sourceFile = ctx.project.addSourceFile(filePath, code);
2201
2467
  const parserResult = ctx.project.parseSourceFile(filePath);
2202
- if (!parserResult || parserResult.isEmpty()) return null;
2468
+ if (!parserResult || parserResult.isEmpty() && !strict) return null;
2203
2469
  result = foldSource({
2204
2470
  ctx,
2205
2471
  code,
2206
2472
  parserResult,
2207
2473
  filePath,
2208
2474
  runtimeCss,
2209
- partial
2475
+ partial,
2476
+ parseModule: (path) => ctx?.project.parseSourceFile(path),
2477
+ recipeConfigCache,
2478
+ reportSurvivors: strict,
2479
+ sourceFile
2210
2480
  });
2211
2481
  } catch (error) {
2212
2482
  logger.caughtError("vite:transform", `Failed to fold ${filePath}`, error);
@@ -2250,7 +2520,7 @@ const bamboocss = (options = {}) => {
2250
2520
  list.push(entry);
2251
2521
  byFile.set(entry.file, list);
2252
2522
  }
2253
- const detail = Array.from(byFile.entries()).map(([file, entries]) => [` ${file}`, ...entries.map((e) => ` ${e.line}: ${e.name}() — ${e.reason}`)].join("\n")).join("\n");
2523
+ 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
2524
  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
2525
  }
2256
2526
  if (!transform || !reportSummary) return;