@bamboocss/vite 1.23.0 → 1.25.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
@@ -795,6 +795,8 @@ const collectRecipeConfigs = (parserResult) => {
795
795
  };
796
796
  /** The generated binding a lowered dynamic axis calls. Lives in `cx`, which pulls no engine. */
797
797
  const RECIPE_PICK_HELPER = "cvaPick";
798
+ /** What `recipe.splitVariantProps` calls, reached directly once the call is lowered. */
799
+ const SPLIT_PROPS_HELPER = "splitProps";
798
800
  const HELPER = RECIPE_PICK_HELPER;
799
801
  /** Marker for a binding the fold must never resolve — declared twice, or unresolvable. */
800
802
  const AMBIGUOUS = Object.freeze({
@@ -802,6 +804,9 @@ const AMBIGUOUS = Object.freeze({
802
804
  name: "",
803
805
  box: void 0
804
806
  });
807
+ /** `.size`, or `["x-large"]` when the variant is not a valid identifier. */
808
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
809
+ const propertyAccess = (key) => IDENTIFIER.test(key) ? `.${key}` : `[${JSON.stringify(key)}]`;
805
810
  const LITERAL_KINDS = new Set([
806
811
  ts_morph.SyntaxKind.StringLiteral,
807
812
  ts_morph.SyntaxKind.NoSubstitutionTemplateLiteral,
@@ -845,7 +850,7 @@ const propertyKey = (nameNode) => {
845
850
  * there is nothing to match — the host here is any import of the generated css module, which
846
851
  * a file defining a recipe necessarily has, since `cva` came from it.
847
852
  */
848
- const ensureRecipeHelperImport = (call, isBambooCssModule, isGeneratedCssModule, isShadowed) => {
853
+ const ensureRecipeHelperImport = (imported, call, isBambooCssModule, isGeneratedCssModule, isShadowed) => {
849
854
  const sourceFile = call.getSourceFile();
850
855
  let host;
851
856
  for (const declaration of sourceFile.getImportDeclarations()) {
@@ -853,7 +858,7 @@ const ensureRecipeHelperImport = (call, isBambooCssModule, isGeneratedCssModule,
853
858
  if (declaration.isTypeOnly()) continue;
854
859
  for (const named of declaration.getNamedImports()) {
855
860
  if (named.isTypeOnly()) continue;
856
- if (named.getNameNode().getText() === "cvaPick") {
861
+ if (named.getNameNode().getText() === imported) {
857
862
  if (!isBambooCssModule(mod)) return void 0;
858
863
  const local = (named.getAliasNode() ?? named.getNameNode()).getText();
859
864
  return isShadowed(call, local) ? void 0 : { name: local };
@@ -862,15 +867,15 @@ const ensureRecipeHelperImport = (call, isBambooCssModule, isGeneratedCssModule,
862
867
  if (!host && isGeneratedCssModule(mod) && declaration.getNamedImports().length > 0) host = declaration;
863
868
  }
864
869
  if (!host) return void 0;
865
- if (declaredAtModuleScope(sourceFile).has("cvaPick")) return void 0;
866
- if (isShadowed(call, "cvaPick")) return void 0;
870
+ if (declaredAtModuleScope(sourceFile).has(imported)) return void 0;
871
+ if (isShadowed(call, imported)) return void 0;
867
872
  const last = host.getNamedImports().at(-1);
868
873
  if (!last) return void 0;
869
874
  return {
870
- name: RECIPE_PICK_HELPER,
875
+ name: imported,
871
876
  insert: {
872
877
  pos: last.getEnd(),
873
- names: [RECIPE_PICK_HELPER]
878
+ names: [imported]
874
879
  }
875
880
  };
876
881
  };
@@ -881,7 +886,7 @@ const ensureRecipeHelperImport = (call, isBambooCssModule, isGeneratedCssModule,
881
886
  * an unresolved variant does not merely omit a class, it can change which of several the
882
887
  * recipe applies — so a partially-known selection is not foldable at all.
883
888
  */
884
- const lowerRecipeCall = (call, entry, ctx, resolvedSelection) => {
889
+ const lowerRecipeCall = (call, entry, ctx, isInert, resolvedSelection) => {
885
890
  if (!entry || entry === AMBIGUOUS) return {
886
891
  kind: "decline",
887
892
  reason: "unknown-recipe"
@@ -907,13 +912,37 @@ const lowerRecipeCall = (call, entry, ctx, resolvedSelection) => {
907
912
  const selection = {};
908
913
  /** Variant → the source expression selecting it, for axes that stay runtime decisions. */
909
914
  const dynamicAxes = /* @__PURE__ */ new Map();
915
+ /**
916
+ * Variants whose expression could run something, in the order the source evaluates them.
917
+ *
918
+ * The text is kept, not just the key: a later property writing the same key replaces the
919
+ * entry in `dynamicAxes`, and the expression recorded here would then never be emitted.
920
+ */
921
+ const effectful = [];
910
922
  if (args.length === 1) {
911
923
  const arg = args[0];
912
- if (!arg || !ts_morph.Node.isObjectLiteralExpression(arg)) return {
924
+ if (!arg) return {
913
925
  kind: "decline",
914
926
  reason: "dynamic"
915
927
  };
916
- for (const property of arg.getProperties()) {
928
+ /**
929
+ * `input(variantProps)` — a selection the build cannot see inside.
930
+ *
931
+ * The classes are still knowable: a recipe emits one per declared variant, so the call is
932
+ * one term per variant reading that binding. This is the shape a wrapper component takes,
933
+ * where the variants are the component's public API and cannot be literals by definition.
934
+ *
935
+ * An identifier only. Each variant reads the binding again, and re-reading anything else —
936
+ * a call, a property access — would evaluate it once per axis instead of once.
937
+ */
938
+ if (ts_morph.Node.isIdentifier(arg)) {
939
+ const binding = arg.getText();
940
+ for (const key of Object.keys(config.variants ?? {})) dynamicAxes.set(key, `${binding}${propertyAccess(key)}`);
941
+ } else if (!ts_morph.Node.isObjectLiteralExpression(arg)) return {
942
+ kind: "decline",
943
+ reason: "dynamic"
944
+ };
945
+ else for (const property of arg.getProperties()) {
917
946
  if (ts_morph.Node.isSpreadAssignment(property)) return {
918
947
  kind: "decline",
919
948
  reason: "dynamic"
@@ -937,14 +966,27 @@ const lowerRecipeCall = (call, entry, ctx, resolvedSelection) => {
937
966
  kind: "decline",
938
967
  reason: "dynamic"
939
968
  };
940
- const literal = literalValue(property.getInitializer());
969
+ const initializer = property.getInitializer();
970
+ if (initializer && !isInert(initializer)) {
971
+ if (!Object.hasOwn(config.variants ?? {}, key)) return {
972
+ kind: "decline",
973
+ reason: "dynamic"
974
+ };
975
+ effectful.push({
976
+ key,
977
+ text: initializer.getText()
978
+ });
979
+ dynamicAxes.set(key, initializer.getText());
980
+ delete selection[key];
981
+ continue;
982
+ }
983
+ const literal = literalValue(initializer);
941
984
  if (literal !== void 0) {
942
985
  selection[key] = literal;
943
986
  dynamicAxes.delete(key);
944
987
  continue;
945
988
  }
946
989
  if (!resolvedSelection || !Object.hasOwn(resolvedSelection, key)) {
947
- const initializer = property.getInitializer();
948
990
  if (!initializer) return {
949
991
  kind: "decline",
950
992
  reason: "dynamic"
@@ -962,24 +1004,47 @@ const lowerRecipeCall = (call, entry, ctx, resolvedSelection) => {
962
1004
  dynamicAxes.delete(key);
963
1005
  }
964
1006
  }
1007
+ /**
1008
+ * Every expression that could run something has to reach the output carrying its own text.
1009
+ *
1010
+ * A later property writing the same key replaces it in `dynamicAxes` — `badge({ tone: a(),
1011
+ * tone: 'b' })` is last-wins for the *value*, but `a()` still runs, and emitting only the
1012
+ * literal would delete it. Duplicate keys are a type error in TypeScript; the fold does not
1013
+ * typecheck and does transform `.js`, so this is reachable.
1014
+ */
1015
+ const everyEffectSurvives = () => effectful.every(({ key, text }) => dynamicAxes.get(key) === text);
965
1016
  const merged = {
966
1017
  ...config.defaultVariants ?? {},
967
1018
  ...(0, _bamboocss_shared.compact)(selection)
968
1019
  };
969
1020
  const format = (0, _bamboocss_core.classFormatter)(ctx);
970
- if (dynamicAxes.size === 0) return {
971
- kind: "class",
972
- className: (0, _bamboocss_shared.getRecipeClassNames)(name, config.variants, merged, ctx.utility.separator, format)
973
- };
974
- for (const key of [...dynamicAxes.keys()]) if (!config.variants?.[key]) dynamicAxes.delete(key);
975
1021
  if (dynamicAxes.size === 0) {
976
- const staticOnly = { ...merged };
977
- for (const key of dynamicAxes.keys()) delete staticOnly[key];
1022
+ if (!everyEffectSurvives()) return {
1023
+ kind: "decline",
1024
+ reason: "dynamic"
1025
+ };
978
1026
  return {
979
1027
  kind: "class",
980
- className: (0, _bamboocss_shared.getRecipeClassNames)(name, config.variants, staticOnly, ctx.utility.separator, format)
1028
+ className: (0, _bamboocss_shared.getRecipeClassNames)(name, config.variants, merged, ctx.utility.separator, format)
1029
+ };
1030
+ }
1031
+ if (!everyEffectSurvives()) return {
1032
+ kind: "decline",
1033
+ reason: "dynamic"
1034
+ };
1035
+ if (effectful.length > 1) {
1036
+ const variantOrder = Object.keys(config.variants ?? {});
1037
+ const keys = effectful.map((entry) => entry.key);
1038
+ if ([...keys].sort((a, b) => variantOrder.indexOf(a) - variantOrder.indexOf(b)).join("\0") !== keys.join("\0")) return {
1039
+ kind: "decline",
1040
+ reason: "dynamic"
981
1041
  };
982
1042
  }
1043
+ for (const key of [...dynamicAxes.keys()]) if (!Object.hasOwn(config.variants ?? {}, key)) dynamicAxes.delete(key);
1044
+ if (dynamicAxes.size === 0) return {
1045
+ kind: "class",
1046
+ className: (0, _bamboocss_shared.getRecipeClassNames)(name, config.variants, merged, ctx.utility.separator, format)
1047
+ };
983
1048
  const ownClass = format(name);
984
1049
  const parts = [JSON.stringify(ownClass)];
985
1050
  const classNames = [ownClass];
@@ -988,7 +1053,8 @@ const lowerRecipeCall = (call, entry, ctx, resolvedSelection) => {
988
1053
  if (expression === void 0) {
989
1054
  const value = merged[key];
990
1055
  if (value == null) continue;
991
- if (config.variants?.[key]?.[value] == null) continue;
1056
+ const declared = config.variants?.[key];
1057
+ if (!declared || !Object.hasOwn(declared, value) || declared[value] == null) continue;
992
1058
  const className = format(`${name}--${key}${ctx.utility.separator}${(0, _bamboocss_shared.withoutSpace)(value)}`);
993
1059
  parts.push(JSON.stringify(` ${className}`));
994
1060
  classNames.push(className);
@@ -1371,7 +1437,7 @@ const hasStyles = (data) => data.length > 0 && data.every((entry) => entry != nu
1371
1437
  const argumentsAccountedFor = (call, boxNode) => {
1372
1438
  if (!ts_morph.Node.isCallExpression(call)) return false;
1373
1439
  const args = call.getArguments();
1374
- if (args.length === 0) return false;
1440
+ if (args.length === 0) return true;
1375
1441
  if (_bamboocss_extractor.box.isArray(boxNode) && boxNode.getNode() === call) {
1376
1442
  if (boxNode.value.length !== args.length) return false;
1377
1443
  return args.every((arg, index) => accountsForSource(arg, boxNode.value[index]));
@@ -1496,6 +1562,8 @@ const foldSource = (options) => {
1496
1562
  * saving real, and it is only correct to claim it once nothing reads the binding.
1497
1563
  */
1498
1564
  const recipeCalls = /* @__PURE__ */ new Map();
1565
+ /** Bindings whose `splitVariantProps` was rewritten, so that access no longer reads them. */
1566
+ const loweredSplitProps = /* @__PURE__ */ new Set();
1499
1567
  /** Ranges already reported as declined, so one call is never counted twice. */
1500
1568
  const reportedRanges = /* @__PURE__ */ new Set();
1501
1569
  const importCache = /* @__PURE__ */ new Map();
@@ -1635,12 +1703,9 @@ const foldSource = (options) => {
1635
1703
  recipeCalls.set(name, tally);
1636
1704
  const resolvedSelection = item.data?.length === 1 ? item.data[0] : void 0;
1637
1705
  const entry = recipeConfigs.get(name);
1638
- const lowered = ts_morph.Node.isCallExpression(call) && call.getArguments().every(isInertExpression) ? lowerRecipeCall(call, entry, ctx, resolvedSelection) : {
1639
- kind: "decline",
1640
- reason: "dynamic"
1641
- };
1706
+ const lowered = lowerRecipeCall(call, entry, ctx, isInertExpression, resolvedSelection);
1642
1707
  if (lowered.kind === "expression") {
1643
- const helper = ensureRecipeHelperImport(call, isBambooCssModule, isGeneratedCssModule, isShadowed);
1708
+ const helper = ensureRecipeHelperImport(RECIPE_PICK_HELPER, call, isBambooCssModule, isGeneratedCssModule, isShadowed);
1644
1709
  if (helper) {
1645
1710
  tally.lowered++;
1646
1711
  candidates.push({
@@ -1751,7 +1816,7 @@ const foldSource = (options) => {
1751
1816
  });
1752
1817
  continue;
1753
1818
  }
1754
- if (!isStaticBox(item.box) || !hasStyles(item.data) || !argumentsAccountedFor(call, item.box)) {
1819
+ if (!(ts_morph.Node.isCallExpression(call) && call.getArguments().length === 0 && item.data.length === 1) && !isStaticBox(item.box) || !hasStyles(item.data) || !argumentsAccountedFor(call, item.box)) {
1755
1820
  const partial = partial_ ? tryPartial(item, call, rootName) : void 0;
1756
1821
  if (partial) {
1757
1822
  candidates.push({
@@ -1891,6 +1956,30 @@ const foldSource = (options) => {
1891
1956
  });
1892
1957
  collectSourceFiles(item.box, dependencyScan);
1893
1958
  }
1959
+ const recipeSourceFile = recipeConfigs?.size ? [...recipeConfigs.values()].find((entry) => entry.box)?.box?.getNode?.()?.getSourceFile() : void 0;
1960
+ if (recipeSourceFile) for (const access of recipeSourceFile.getDescendantsOfKind(ts_morph.SyntaxKind.PropertyAccessExpression)) {
1961
+ if (access.getName() !== "splitVariantProps") continue;
1962
+ const target = access.getExpression();
1963
+ if (!ts_morph.Node.isIdentifier(target)) continue;
1964
+ const entry = recipeConfigs?.get(target.getText());
1965
+ if (!entry || entry === AMBIGUOUS) continue;
1966
+ if (isShadowed(access, target.getText())) continue;
1967
+ const call = access.getParent();
1968
+ if (!ts_morph.Node.isCallExpression(call) || call.getExpression() !== access) continue;
1969
+ const args = call.getArguments();
1970
+ if (args.length !== 1) continue;
1971
+ const start = call.getStart();
1972
+ const end = call.getEnd();
1973
+ if (code.slice(start, end) !== call.getText()) continue;
1974
+ if (collides([[start, end]])) continue;
1975
+ const helper = ensureRecipeHelperImport(SPLIT_PROPS_HELPER, call, isBambooCssModule, isGeneratedCssModule, isShadowed);
1976
+ if (!helper) continue;
1977
+ const keys = Object.keys(entry.config.variants ?? {});
1978
+ magic.overwrite(start, end, `${helper.name}(${args[0].getText()}, ${JSON.stringify(keys)})`);
1979
+ applyInsert(helper.insert);
1980
+ applied.push([start, end]);
1981
+ loweredSplitProps.add(target.getText());
1982
+ }
1894
1983
  for (const [binding, tally] of recipeCalls) {
1895
1984
  if (!tally.seen || tally.lowered !== tally.seen) continue;
1896
1985
  const definition = recipeConfigs?.get(binding)?.box?.getNode?.();
package/dist/index.d.cts CHANGED
@@ -2,7 +2,6 @@ import { Plugin } from "vite";
2
2
  import { Context } from "@bamboocss/core";
3
3
  import { Dict, ParserResultInterface } from "@bamboocss/types";
4
4
  import MagicString from "magic-string";
5
-
6
5
  //#region src/css.d.ts
7
6
  /**
8
7
  * What a project imports to get the stylesheet.
package/dist/index.d.mts CHANGED
@@ -1,4 +1,5 @@
1
1
  import MagicString from "magic-string";
2
+ import { Node } from "ts-morph";
2
3
  import { Context } from "@bamboocss/core";
3
4
  import { Plugin } from "vite";
4
5
  import { Dict, ParserResultInterface } from "@bamboocss/types";
package/dist/index.mjs CHANGED
@@ -768,6 +768,8 @@ const collectRecipeConfigs = (parserResult) => {
768
768
  };
769
769
  /** The generated binding a lowered dynamic axis calls. Lives in `cx`, which pulls no engine. */
770
770
  const RECIPE_PICK_HELPER = "cvaPick";
771
+ /** What `recipe.splitVariantProps` calls, reached directly once the call is lowered. */
772
+ const SPLIT_PROPS_HELPER = "splitProps";
771
773
  const HELPER = RECIPE_PICK_HELPER;
772
774
  /** Marker for a binding the fold must never resolve — declared twice, or unresolvable. */
773
775
  const AMBIGUOUS = Object.freeze({
@@ -775,6 +777,9 @@ const AMBIGUOUS = Object.freeze({
775
777
  name: "",
776
778
  box: void 0
777
779
  });
780
+ /** `.size`, or `["x-large"]` when the variant is not a valid identifier. */
781
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
782
+ const propertyAccess = (key) => IDENTIFIER.test(key) ? `.${key}` : `[${JSON.stringify(key)}]`;
778
783
  const LITERAL_KINDS = new Set([
779
784
  SyntaxKind.StringLiteral,
780
785
  SyntaxKind.NoSubstitutionTemplateLiteral,
@@ -818,7 +823,7 @@ const propertyKey = (nameNode) => {
818
823
  * there is nothing to match — the host here is any import of the generated css module, which
819
824
  * a file defining a recipe necessarily has, since `cva` came from it.
820
825
  */
821
- const ensureRecipeHelperImport = (call, isBambooCssModule, isGeneratedCssModule, isShadowed) => {
826
+ const ensureRecipeHelperImport = (imported, call, isBambooCssModule, isGeneratedCssModule, isShadowed) => {
822
827
  const sourceFile = call.getSourceFile();
823
828
  let host;
824
829
  for (const declaration of sourceFile.getImportDeclarations()) {
@@ -826,7 +831,7 @@ const ensureRecipeHelperImport = (call, isBambooCssModule, isGeneratedCssModule,
826
831
  if (declaration.isTypeOnly()) continue;
827
832
  for (const named of declaration.getNamedImports()) {
828
833
  if (named.isTypeOnly()) continue;
829
- if (named.getNameNode().getText() === "cvaPick") {
834
+ if (named.getNameNode().getText() === imported) {
830
835
  if (!isBambooCssModule(mod)) return void 0;
831
836
  const local = (named.getAliasNode() ?? named.getNameNode()).getText();
832
837
  return isShadowed(call, local) ? void 0 : { name: local };
@@ -835,15 +840,15 @@ const ensureRecipeHelperImport = (call, isBambooCssModule, isGeneratedCssModule,
835
840
  if (!host && isGeneratedCssModule(mod) && declaration.getNamedImports().length > 0) host = declaration;
836
841
  }
837
842
  if (!host) return void 0;
838
- if (declaredAtModuleScope(sourceFile).has("cvaPick")) return void 0;
839
- if (isShadowed(call, "cvaPick")) return void 0;
843
+ if (declaredAtModuleScope(sourceFile).has(imported)) return void 0;
844
+ if (isShadowed(call, imported)) return void 0;
840
845
  const last = host.getNamedImports().at(-1);
841
846
  if (!last) return void 0;
842
847
  return {
843
- name: RECIPE_PICK_HELPER,
848
+ name: imported,
844
849
  insert: {
845
850
  pos: last.getEnd(),
846
- names: [RECIPE_PICK_HELPER]
851
+ names: [imported]
847
852
  }
848
853
  };
849
854
  };
@@ -854,7 +859,7 @@ const ensureRecipeHelperImport = (call, isBambooCssModule, isGeneratedCssModule,
854
859
  * an unresolved variant does not merely omit a class, it can change which of several the
855
860
  * recipe applies — so a partially-known selection is not foldable at all.
856
861
  */
857
- const lowerRecipeCall = (call, entry, ctx, resolvedSelection) => {
862
+ const lowerRecipeCall = (call, entry, ctx, isInert, resolvedSelection) => {
858
863
  if (!entry || entry === AMBIGUOUS) return {
859
864
  kind: "decline",
860
865
  reason: "unknown-recipe"
@@ -880,13 +885,37 @@ const lowerRecipeCall = (call, entry, ctx, resolvedSelection) => {
880
885
  const selection = {};
881
886
  /** Variant → the source expression selecting it, for axes that stay runtime decisions. */
882
887
  const dynamicAxes = /* @__PURE__ */ new Map();
888
+ /**
889
+ * Variants whose expression could run something, in the order the source evaluates them.
890
+ *
891
+ * The text is kept, not just the key: a later property writing the same key replaces the
892
+ * entry in `dynamicAxes`, and the expression recorded here would then never be emitted.
893
+ */
894
+ const effectful = [];
883
895
  if (args.length === 1) {
884
896
  const arg = args[0];
885
- if (!arg || !Node.isObjectLiteralExpression(arg)) return {
897
+ if (!arg) return {
886
898
  kind: "decline",
887
899
  reason: "dynamic"
888
900
  };
889
- for (const property of arg.getProperties()) {
901
+ /**
902
+ * `input(variantProps)` — a selection the build cannot see inside.
903
+ *
904
+ * The classes are still knowable: a recipe emits one per declared variant, so the call is
905
+ * one term per variant reading that binding. This is the shape a wrapper component takes,
906
+ * where the variants are the component's public API and cannot be literals by definition.
907
+ *
908
+ * An identifier only. Each variant reads the binding again, and re-reading anything else —
909
+ * a call, a property access — would evaluate it once per axis instead of once.
910
+ */
911
+ if (Node.isIdentifier(arg)) {
912
+ const binding = arg.getText();
913
+ for (const key of Object.keys(config.variants ?? {})) dynamicAxes.set(key, `${binding}${propertyAccess(key)}`);
914
+ } else if (!Node.isObjectLiteralExpression(arg)) return {
915
+ kind: "decline",
916
+ reason: "dynamic"
917
+ };
918
+ else for (const property of arg.getProperties()) {
890
919
  if (Node.isSpreadAssignment(property)) return {
891
920
  kind: "decline",
892
921
  reason: "dynamic"
@@ -910,14 +939,27 @@ const lowerRecipeCall = (call, entry, ctx, resolvedSelection) => {
910
939
  kind: "decline",
911
940
  reason: "dynamic"
912
941
  };
913
- const literal = literalValue(property.getInitializer());
942
+ const initializer = property.getInitializer();
943
+ if (initializer && !isInert(initializer)) {
944
+ if (!Object.hasOwn(config.variants ?? {}, key)) return {
945
+ kind: "decline",
946
+ reason: "dynamic"
947
+ };
948
+ effectful.push({
949
+ key,
950
+ text: initializer.getText()
951
+ });
952
+ dynamicAxes.set(key, initializer.getText());
953
+ delete selection[key];
954
+ continue;
955
+ }
956
+ const literal = literalValue(initializer);
914
957
  if (literal !== void 0) {
915
958
  selection[key] = literal;
916
959
  dynamicAxes.delete(key);
917
960
  continue;
918
961
  }
919
962
  if (!resolvedSelection || !Object.hasOwn(resolvedSelection, key)) {
920
- const initializer = property.getInitializer();
921
963
  if (!initializer) return {
922
964
  kind: "decline",
923
965
  reason: "dynamic"
@@ -935,24 +977,47 @@ const lowerRecipeCall = (call, entry, ctx, resolvedSelection) => {
935
977
  dynamicAxes.delete(key);
936
978
  }
937
979
  }
980
+ /**
981
+ * Every expression that could run something has to reach the output carrying its own text.
982
+ *
983
+ * A later property writing the same key replaces it in `dynamicAxes` — `badge({ tone: a(),
984
+ * tone: 'b' })` is last-wins for the *value*, but `a()` still runs, and emitting only the
985
+ * literal would delete it. Duplicate keys are a type error in TypeScript; the fold does not
986
+ * typecheck and does transform `.js`, so this is reachable.
987
+ */
988
+ const everyEffectSurvives = () => effectful.every(({ key, text }) => dynamicAxes.get(key) === text);
938
989
  const merged = {
939
990
  ...config.defaultVariants ?? {},
940
991
  ...compact(selection)
941
992
  };
942
993
  const format = classFormatter(ctx);
943
- if (dynamicAxes.size === 0) return {
944
- kind: "class",
945
- className: getRecipeClassNames(name, config.variants, merged, ctx.utility.separator, format)
946
- };
947
- for (const key of [...dynamicAxes.keys()]) if (!config.variants?.[key]) dynamicAxes.delete(key);
948
994
  if (dynamicAxes.size === 0) {
949
- const staticOnly = { ...merged };
950
- for (const key of dynamicAxes.keys()) delete staticOnly[key];
995
+ if (!everyEffectSurvives()) return {
996
+ kind: "decline",
997
+ reason: "dynamic"
998
+ };
951
999
  return {
952
1000
  kind: "class",
953
- className: getRecipeClassNames(name, config.variants, staticOnly, ctx.utility.separator, format)
1001
+ className: getRecipeClassNames(name, config.variants, merged, ctx.utility.separator, format)
1002
+ };
1003
+ }
1004
+ if (!everyEffectSurvives()) return {
1005
+ kind: "decline",
1006
+ reason: "dynamic"
1007
+ };
1008
+ if (effectful.length > 1) {
1009
+ const variantOrder = Object.keys(config.variants ?? {});
1010
+ const keys = effectful.map((entry) => entry.key);
1011
+ if ([...keys].sort((a, b) => variantOrder.indexOf(a) - variantOrder.indexOf(b)).join("\0") !== keys.join("\0")) return {
1012
+ kind: "decline",
1013
+ reason: "dynamic"
954
1014
  };
955
1015
  }
1016
+ for (const key of [...dynamicAxes.keys()]) if (!Object.hasOwn(config.variants ?? {}, key)) dynamicAxes.delete(key);
1017
+ if (dynamicAxes.size === 0) return {
1018
+ kind: "class",
1019
+ className: getRecipeClassNames(name, config.variants, merged, ctx.utility.separator, format)
1020
+ };
956
1021
  const ownClass = format(name);
957
1022
  const parts = [JSON.stringify(ownClass)];
958
1023
  const classNames = [ownClass];
@@ -961,7 +1026,8 @@ const lowerRecipeCall = (call, entry, ctx, resolvedSelection) => {
961
1026
  if (expression === void 0) {
962
1027
  const value = merged[key];
963
1028
  if (value == null) continue;
964
- if (config.variants?.[key]?.[value] == null) continue;
1029
+ const declared = config.variants?.[key];
1030
+ if (!declared || !Object.hasOwn(declared, value) || declared[value] == null) continue;
965
1031
  const className = format(`${name}--${key}${ctx.utility.separator}${withoutSpace(value)}`);
966
1032
  parts.push(JSON.stringify(` ${className}`));
967
1033
  classNames.push(className);
@@ -1344,7 +1410,7 @@ const hasStyles = (data) => data.length > 0 && data.every((entry) => entry != nu
1344
1410
  const argumentsAccountedFor = (call, boxNode) => {
1345
1411
  if (!Node.isCallExpression(call)) return false;
1346
1412
  const args = call.getArguments();
1347
- if (args.length === 0) return false;
1413
+ if (args.length === 0) return true;
1348
1414
  if (box.isArray(boxNode) && boxNode.getNode() === call) {
1349
1415
  if (boxNode.value.length !== args.length) return false;
1350
1416
  return args.every((arg, index) => accountsForSource(arg, boxNode.value[index]));
@@ -1469,6 +1535,8 @@ const foldSource = (options) => {
1469
1535
  * saving real, and it is only correct to claim it once nothing reads the binding.
1470
1536
  */
1471
1537
  const recipeCalls = /* @__PURE__ */ new Map();
1538
+ /** Bindings whose `splitVariantProps` was rewritten, so that access no longer reads them. */
1539
+ const loweredSplitProps = /* @__PURE__ */ new Set();
1472
1540
  /** Ranges already reported as declined, so one call is never counted twice. */
1473
1541
  const reportedRanges = /* @__PURE__ */ new Set();
1474
1542
  const importCache = /* @__PURE__ */ new Map();
@@ -1608,12 +1676,9 @@ const foldSource = (options) => {
1608
1676
  recipeCalls.set(name, tally);
1609
1677
  const resolvedSelection = item.data?.length === 1 ? item.data[0] : void 0;
1610
1678
  const entry = recipeConfigs.get(name);
1611
- const lowered = Node.isCallExpression(call) && call.getArguments().every(isInertExpression) ? lowerRecipeCall(call, entry, ctx, resolvedSelection) : {
1612
- kind: "decline",
1613
- reason: "dynamic"
1614
- };
1679
+ const lowered = lowerRecipeCall(call, entry, ctx, isInertExpression, resolvedSelection);
1615
1680
  if (lowered.kind === "expression") {
1616
- const helper = ensureRecipeHelperImport(call, isBambooCssModule, isGeneratedCssModule, isShadowed);
1681
+ const helper = ensureRecipeHelperImport(RECIPE_PICK_HELPER, call, isBambooCssModule, isGeneratedCssModule, isShadowed);
1617
1682
  if (helper) {
1618
1683
  tally.lowered++;
1619
1684
  candidates.push({
@@ -1724,7 +1789,7 @@ const foldSource = (options) => {
1724
1789
  });
1725
1790
  continue;
1726
1791
  }
1727
- if (!isStaticBox(item.box) || !hasStyles(item.data) || !argumentsAccountedFor(call, item.box)) {
1792
+ if (!(Node.isCallExpression(call) && call.getArguments().length === 0 && item.data.length === 1) && !isStaticBox(item.box) || !hasStyles(item.data) || !argumentsAccountedFor(call, item.box)) {
1728
1793
  const partial = partial_ ? tryPartial(item, call, rootName) : void 0;
1729
1794
  if (partial) {
1730
1795
  candidates.push({
@@ -1864,6 +1929,30 @@ const foldSource = (options) => {
1864
1929
  });
1865
1930
  collectSourceFiles(item.box, dependencyScan);
1866
1931
  }
1932
+ const recipeSourceFile = recipeConfigs?.size ? [...recipeConfigs.values()].find((entry) => entry.box)?.box?.getNode?.()?.getSourceFile() : void 0;
1933
+ if (recipeSourceFile) for (const access of recipeSourceFile.getDescendantsOfKind(SyntaxKind.PropertyAccessExpression)) {
1934
+ if (access.getName() !== "splitVariantProps") continue;
1935
+ const target = access.getExpression();
1936
+ if (!Node.isIdentifier(target)) continue;
1937
+ const entry = recipeConfigs?.get(target.getText());
1938
+ if (!entry || entry === AMBIGUOUS) continue;
1939
+ if (isShadowed(access, target.getText())) continue;
1940
+ const call = access.getParent();
1941
+ if (!Node.isCallExpression(call) || call.getExpression() !== access) continue;
1942
+ const args = call.getArguments();
1943
+ if (args.length !== 1) continue;
1944
+ const start = call.getStart();
1945
+ const end = call.getEnd();
1946
+ if (code.slice(start, end) !== call.getText()) continue;
1947
+ if (collides([[start, end]])) continue;
1948
+ const helper = ensureRecipeHelperImport(SPLIT_PROPS_HELPER, call, isBambooCssModule, isGeneratedCssModule, isShadowed);
1949
+ if (!helper) continue;
1950
+ const keys = Object.keys(entry.config.variants ?? {});
1951
+ magic.overwrite(start, end, `${helper.name}(${args[0].getText()}, ${JSON.stringify(keys)})`);
1952
+ applyInsert(helper.insert);
1953
+ applied.push([start, end]);
1954
+ loweredSplitProps.add(target.getText());
1955
+ }
1867
1956
  for (const [binding, tally] of recipeCalls) {
1868
1957
  if (!tally.seen || tally.lowered !== tally.seen) continue;
1869
1958
  const definition = recipeConfigs?.get(binding)?.box?.getNode?.();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bamboocss/vite",
3
- "version": "1.23.0",
3
+ "version": "1.25.0",
4
4
  "description": "Vite integration for Bamboo CSS",
5
5
  "homepage": "https://bamboocss.com",
6
6
  "license": "MIT",
@@ -37,18 +37,18 @@
37
37
  "dependencies": {
38
38
  "magic-string": "0.30.21",
39
39
  "ts-morph": "28.0.0",
40
- "@bamboocss/core": "1.23.0",
41
- "@bamboocss/extractor": "1.23.0",
42
- "@bamboocss/config": "1.23.0",
43
- "@bamboocss/node": "1.23.0",
44
- "@bamboocss/logger": "1.23.0",
45
- "@bamboocss/shared": "1.23.0",
46
- "@bamboocss/types": "1.23.0"
40
+ "@bamboocss/config": "1.25.0",
41
+ "@bamboocss/core": "1.25.0",
42
+ "@bamboocss/extractor": "1.25.0",
43
+ "@bamboocss/logger": "1.25.0",
44
+ "@bamboocss/node": "1.25.0",
45
+ "@bamboocss/types": "1.25.0",
46
+ "@bamboocss/shared": "1.25.0"
47
47
  },
48
48
  "devDependencies": {
49
49
  "@jridgewell/trace-mapping": "^0.3.31",
50
50
  "vite": "7.2.6",
51
- "@bamboocss/fixture": "1.23.0"
51
+ "@bamboocss/fixture": "1.25.0"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "vite": ">=5"