@bamboocss/vite 1.22.0 → 1.23.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
@@ -754,6 +754,269 @@ const ensureCxImport = (call, calleeRoot, isBambooCssModule, isGeneratedCssModul
754
754
  };
755
755
  };
756
756
  //#endregion
757
+ //#region src/fold-recipe.ts
758
+ /**
759
+ * Binding name → the config it was declared with.
760
+ *
761
+ * Built from the definitions the parser already recorded, walking each one to the declaration
762
+ * that names it. The parser records a definition under the name it was *imported* as (`cva`),
763
+ * and a call under the name the file *bound* (`badge`); this is what joins the two.
764
+ *
765
+ * Reads `cva` and not `sva`, which is load-bearing rather than an omission. The parser records
766
+ * a call of *either* as a recipe call, but an `sva` invocation returns one class per slot — an
767
+ * object, not a string — so there is no literal to substitute. Leaving slot recipes out of this
768
+ * map is what makes them decline as `unknown-recipe` instead of folding to a string that would
769
+ * break every consumer reading `.root` off it.
770
+ */
771
+ const collectRecipeConfigs = (parserResult) => {
772
+ const configs = /* @__PURE__ */ new Map();
773
+ for (const definition of parserResult.cva) {
774
+ const node = definition.box?.getNode?.();
775
+ if (!node) continue;
776
+ const nameNode = ((ts_morph.Node.isCallExpression(node) ? node : node.getFirstAncestorByKind(ts_morph.SyntaxKind.CallExpression))?.getFirstAncestorByKind(ts_morph.SyntaxKind.VariableDeclaration))?.getNameNode();
777
+ if (!nameNode || !ts_morph.Node.isIdentifier(nameNode)) continue;
778
+ if (definition.data?.length !== 1) {
779
+ configs.set(nameNode.getText(), AMBIGUOUS);
780
+ continue;
781
+ }
782
+ const config = definition.data[0];
783
+ if (!config || typeof config !== "object") continue;
784
+ if (configs.has(nameNode.getText())) {
785
+ configs.set(nameNode.getText(), AMBIGUOUS);
786
+ continue;
787
+ }
788
+ configs.set(nameNode.getText(), {
789
+ config,
790
+ name: (0, _bamboocss_shared.getRecipeIdentity)(config),
791
+ box: definition.box
792
+ });
793
+ }
794
+ return configs;
795
+ };
796
+ /** The generated binding a lowered dynamic axis calls. Lives in `cx`, which pulls no engine. */
797
+ const RECIPE_PICK_HELPER = "cvaPick";
798
+ const HELPER = RECIPE_PICK_HELPER;
799
+ /** Marker for a binding the fold must never resolve — declared twice, or unresolvable. */
800
+ const AMBIGUOUS = Object.freeze({
801
+ config: {},
802
+ name: "",
803
+ box: void 0
804
+ });
805
+ const LITERAL_KINDS = new Set([
806
+ ts_morph.SyntaxKind.StringLiteral,
807
+ ts_morph.SyntaxKind.NoSubstitutionTemplateLiteral,
808
+ ts_morph.SyntaxKind.NumericLiteral,
809
+ ts_morph.SyntaxKind.TrueKeyword,
810
+ ts_morph.SyntaxKind.FalseKeyword
811
+ ]);
812
+ /**
813
+ * The value a literal node denotes, or `undefined` for anything else.
814
+ *
815
+ * Read off the node rather than from the extractor's resolved data, because that data is lossy
816
+ * in the direction that matters: a property it could not resolve is *dropped*, so `badge({ tone })`
817
+ * and `badge({})` are identical there. Folding the first as if it were the second emits a class
818
+ * string missing the variant — the element renders, wrongly, with no report.
819
+ */
820
+ const literalValue = (node) => {
821
+ if (!node || !LITERAL_KINDS.has(node.getKind())) return void 0;
822
+ if (ts_morph.Node.isStringLiteral(node) || ts_morph.Node.isNoSubstitutionTemplateLiteral(node)) return node.getLiteralValue();
823
+ if (ts_morph.Node.isNumericLiteral(node)) return node.getLiteralValue();
824
+ if (node.getKind() === ts_morph.SyntaxKind.TrueKeyword) return true;
825
+ if (node.getKind() === ts_morph.SyntaxKind.FalseKeyword) return false;
826
+ };
827
+ /**
828
+ * The property name a key node denotes.
829
+ *
830
+ * Read off the node rather than unquoted from its text. `{ '\\u0074one': 'a' }` names the
831
+ * variant `tone`, and stripping the surrounding quotes leaves the escape uninterpreted — so
832
+ * the variant did not match, its class was dropped, and the element rendered without it. A
833
+ * numeric key normalises the same way: `{ 0x10: 'a' }` is the key `16`.
834
+ */
835
+ const propertyKey = (nameNode) => {
836
+ if (ts_morph.Node.isIdentifier(nameNode)) return nameNode.getText();
837
+ if (ts_morph.Node.isStringLiteral(nameNode) || ts_morph.Node.isNoSubstitutionTemplateLiteral(nameNode)) return nameNode.getLiteralValue();
838
+ if (ts_morph.Node.isNumericLiteral(nameNode)) return String(nameNode.getLiteralValue());
839
+ };
840
+ /**
841
+ * Make `cvaPick` callable at this call site, by whatever name the file gives it.
842
+ *
843
+ * Not `ensureCxImport`: that one resolves `cx` and finds the declaration to extend by
844
+ * matching the *callee* against an import. An inline recipe's callee is a local binding, so
845
+ * there is nothing to match — the host here is any import of the generated css module, which
846
+ * a file defining a recipe necessarily has, since `cva` came from it.
847
+ */
848
+ const ensureRecipeHelperImport = (call, isBambooCssModule, isGeneratedCssModule, isShadowed) => {
849
+ const sourceFile = call.getSourceFile();
850
+ let host;
851
+ for (const declaration of sourceFile.getImportDeclarations()) {
852
+ const mod = declaration.getModuleSpecifierValue();
853
+ if (declaration.isTypeOnly()) continue;
854
+ for (const named of declaration.getNamedImports()) {
855
+ if (named.isTypeOnly()) continue;
856
+ if (named.getNameNode().getText() === "cvaPick") {
857
+ if (!isBambooCssModule(mod)) return void 0;
858
+ const local = (named.getAliasNode() ?? named.getNameNode()).getText();
859
+ return isShadowed(call, local) ? void 0 : { name: local };
860
+ }
861
+ }
862
+ if (!host && isGeneratedCssModule(mod) && declaration.getNamedImports().length > 0) host = declaration;
863
+ }
864
+ if (!host) return void 0;
865
+ if (declaredAtModuleScope(sourceFile).has("cvaPick")) return void 0;
866
+ if (isShadowed(call, "cvaPick")) return void 0;
867
+ const last = host.getNamedImports().at(-1);
868
+ if (!last) return void 0;
869
+ return {
870
+ name: RECIPE_PICK_HELPER,
871
+ insert: {
872
+ pos: last.getEnd(),
873
+ names: [RECIPE_PICK_HELPER]
874
+ }
875
+ };
876
+ };
877
+ /**
878
+ * Lower one invocation, or say why not.
879
+ *
880
+ * Every property written at the call site has to be a literal. A selection is not additive —
881
+ * an unresolved variant does not merely omit a class, it can change which of several the
882
+ * recipe applies — so a partially-known selection is not foldable at all.
883
+ */
884
+ const lowerRecipeCall = (call, entry, ctx, resolvedSelection) => {
885
+ if (!entry || entry === AMBIGUOUS) return {
886
+ kind: "decline",
887
+ reason: "unknown-recipe"
888
+ };
889
+ const { config, name } = entry;
890
+ if (config.slots !== void 0) return {
891
+ kind: "decline",
892
+ reason: "unsupported-shape"
893
+ };
894
+ if (!config.base && !config.variants && !config.className) return {
895
+ kind: "decline",
896
+ reason: "unknown-recipe"
897
+ };
898
+ if (!ts_morph.Node.isCallExpression(call)) return {
899
+ kind: "decline",
900
+ reason: "unsupported-shape"
901
+ };
902
+ const args = call.getArguments();
903
+ if (args.length > 1) return {
904
+ kind: "decline",
905
+ reason: "unsupported-shape"
906
+ };
907
+ const selection = {};
908
+ /** Variant → the source expression selecting it, for axes that stay runtime decisions. */
909
+ const dynamicAxes = /* @__PURE__ */ new Map();
910
+ if (args.length === 1) {
911
+ const arg = args[0];
912
+ if (!arg || !ts_morph.Node.isObjectLiteralExpression(arg)) return {
913
+ kind: "decline",
914
+ reason: "dynamic"
915
+ };
916
+ for (const property of arg.getProperties()) {
917
+ if (ts_morph.Node.isSpreadAssignment(property)) return {
918
+ kind: "decline",
919
+ reason: "dynamic"
920
+ };
921
+ if (ts_morph.Node.isShorthandPropertyAssignment(property)) {
922
+ dynamicAxes.set(property.getName(), property.getName());
923
+ delete selection[property.getName()];
924
+ continue;
925
+ }
926
+ if (!ts_morph.Node.isPropertyAssignment(property)) return {
927
+ kind: "decline",
928
+ reason: "dynamic"
929
+ };
930
+ const nameNode = property.getNameNode();
931
+ if (ts_morph.Node.isComputedPropertyName(nameNode)) return {
932
+ kind: "decline",
933
+ reason: "dynamic"
934
+ };
935
+ const key = propertyKey(nameNode);
936
+ if (key === void 0) return {
937
+ kind: "decline",
938
+ reason: "dynamic"
939
+ };
940
+ const literal = literalValue(property.getInitializer());
941
+ if (literal !== void 0) {
942
+ selection[key] = literal;
943
+ dynamicAxes.delete(key);
944
+ continue;
945
+ }
946
+ if (!resolvedSelection || !Object.hasOwn(resolvedSelection, key)) {
947
+ const initializer = property.getInitializer();
948
+ if (!initializer) return {
949
+ kind: "decline",
950
+ reason: "dynamic"
951
+ };
952
+ dynamicAxes.set(key, initializer.getText());
953
+ delete selection[key];
954
+ continue;
955
+ }
956
+ const value = resolvedSelection[key];
957
+ if (value !== null && typeof value === "object") return {
958
+ kind: "decline",
959
+ reason: "dynamic"
960
+ };
961
+ selection[key] = value;
962
+ dynamicAxes.delete(key);
963
+ }
964
+ }
965
+ const merged = {
966
+ ...config.defaultVariants ?? {},
967
+ ...(0, _bamboocss_shared.compact)(selection)
968
+ };
969
+ 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
+ if (dynamicAxes.size === 0) {
976
+ const staticOnly = { ...merged };
977
+ for (const key of dynamicAxes.keys()) delete staticOnly[key];
978
+ return {
979
+ kind: "class",
980
+ className: (0, _bamboocss_shared.getRecipeClassNames)(name, config.variants, staticOnly, ctx.utility.separator, format)
981
+ };
982
+ }
983
+ const ownClass = format(name);
984
+ const parts = [JSON.stringify(ownClass)];
985
+ const classNames = [ownClass];
986
+ for (const key of Object.keys(config.variants ?? {})) {
987
+ const expression = dynamicAxes.get(key);
988
+ if (expression === void 0) {
989
+ const value = merged[key];
990
+ if (value == null) continue;
991
+ if (config.variants?.[key]?.[value] == null) continue;
992
+ const className = format(`${name}--${key}${ctx.utility.separator}${(0, _bamboocss_shared.withoutSpace)(value)}`);
993
+ parts.push(JSON.stringify(` ${className}`));
994
+ classNames.push(className);
995
+ continue;
996
+ }
997
+ const values = config.variants[key];
998
+ const table = {};
999
+ for (const value of Object.keys(values)) {
1000
+ const className = format(`${name}--${key}${ctx.utility.separator}${(0, _bamboocss_shared.withoutSpace)(value)}`);
1001
+ table[value] = ` ${className}`;
1002
+ classNames.push(className);
1003
+ }
1004
+ const fallbackValue = config.defaultVariants?.[key];
1005
+ const fallback = fallbackValue != null && values[fallbackValue] != null ? ` ${format(`${name}--${key}${ctx.utility.separator}${(0, _bamboocss_shared.withoutSpace)(fallbackValue)}`)}` : void 0;
1006
+ parts.push(`${HELPER}(${expression}, ${JSON.stringify(table)}${fallback === void 0 ? "" : `, ${JSON.stringify(fallback)}`})`);
1007
+ }
1008
+ if (parts.length === 1) return {
1009
+ kind: "class",
1010
+ className: ownClass
1011
+ };
1012
+ return {
1013
+ kind: "expression",
1014
+ expression: parts.join(" + "),
1015
+ classNames,
1016
+ staticClasses: ownClass
1017
+ };
1018
+ };
1019
+ //#endregion
757
1020
  //#region src/runtime-css.ts
758
1021
  /** The shape `createCss` and `createMergeCss` both take, derived from a resolved context. */
759
1022
  const createCssContext = (ctx) => ({
@@ -862,22 +1125,13 @@ const createRuntimeRecipe = (ctx) => {
862
1125
  //#endregion
863
1126
  //#region src/fold.ts
864
1127
  /**
865
- * `cva`/`sva` return a function, so neither can collapse to a class string. Their
866
- * *invocations* could, but those are separate call sites the parser does not record as
867
- * such. `token` also resolves to no class, but it does resolve to a literal, so it folds
868
- * through its own path rather than being declined outright.
869
- *
870
- * Folding an invocation is now *possible* in a way it was not: a recipe's classes are named
871
- * semantically, so the build knows every class a call can produce from the config alone.
872
- * What is missing is upstream — the parser matches calls by imported name, so a local
873
- * `button()` from `const button = cva(...)` is never recorded, and tracking those bindings
874
- * is a change to the extractor rather than to this set.
1128
+ * `cva`/`sva` return a function, so neither *definition* can collapse to a class string.
1129
+ * `token` also resolves to no class, but it does resolve to a literal, so it folds through
1130
+ * its own path rather than being declined outright.
875
1131
  *
876
- * Worth knowing before taking that on: semantic naming already took most of the prize.
877
- * `cvaFn` used to run `mergeCss` and name a class per property on every call; it is now a
878
- * memoized loop over `variantKeys` doing string concatenation. That is an inspection of the
879
- * two implementations, not a measurement — benchmark it before deciding it is worth the
880
- * extractor work.
1132
+ * Their invocations are a different matter and do fold `cva`'s through `fold-recipe`,
1133
+ * which is a separate set because the call is recorded under the name the file bound rather
1134
+ * than the name it imported. `sva`'s do not: a slot recipe resolves to one class per slot.
881
1135
  */
882
1136
  const FOLDABLE_TYPES = new Set([
883
1137
  "css",
@@ -899,6 +1153,18 @@ const literalsIn = (expression) => [...expression.matchAll(/"((?:[^"\\]|\\.)*)"/
899
1153
  */
900
1154
  const UNFOLDABLE_TYPES = new Set(["cva", "sva"]);
901
1155
  /**
1156
+ * A call of a recipe the file bound itself: `const badge = cva(...)`, then `badge({ tone })`.
1157
+ *
1158
+ * Folded when the whole selection resolves, reported under this reason when it does not.
1159
+ * Deliberately all-or-nothing: an unresolved variant does not merely omit its own class, so a
1160
+ * partially-known selection is not foldable at all.
1161
+ *
1162
+ * Visible at all because it used to not be. The parser matched calls by imported name, so a
1163
+ * local binding was never recorded, and an unfoldable invocation looked identical to code
1164
+ * nothing had parsed.
1165
+ */
1166
+ const RECIPE_CALL_TYPE = "cva-call";
1167
+ /**
902
1168
  * An argument that cannot run anything when it is evaluated.
903
1169
  *
904
1170
  * `token(path, fallback)` evaluates both arguments before the call, so a fold that drops
@@ -1217,6 +1483,21 @@ const foldSource = (options) => {
1217
1483
  const skipped = [];
1218
1484
  const candidates = [];
1219
1485
  const seenRanges = /* @__PURE__ */ new Set();
1486
+ /** Built on first use: most modules declare no inline recipe. */
1487
+ let recipeConfigs;
1488
+ /**
1489
+ * Per inline recipe binding: calls seen, calls lowered.
1490
+ *
1491
+ * A binding whose every call lowered is no longer read, so its `cva({ … })` config can leave
1492
+ * the bundle — which is the whole point, the config being far larger than the runtime. But a
1493
+ * bundler will not drop the call on its own: `cva` closes over the config and builds an
1494
+ * object, and Rollup cannot prove that is side-effect free, so it keeps the expression and
1495
+ * the module ends up *larger* than before folding. The annotation below is what makes the
1496
+ * saving real, and it is only correct to claim it once nothing reads the binding.
1497
+ */
1498
+ const recipeCalls = /* @__PURE__ */ new Map();
1499
+ /** Ranges already reported as declined, so one call is never counted twice. */
1500
+ const reportedRanges = /* @__PURE__ */ new Set();
1220
1501
  const importCache = /* @__PURE__ */ new Map();
1221
1502
  const importsFor = (sourceFile) => {
1222
1503
  let names = importCache.get(sourceFile);
@@ -1330,6 +1611,83 @@ const foldSource = (options) => {
1330
1611
  start: call.getStart(),
1331
1612
  end: call.getEnd()
1332
1613
  });
1614
+ if (call && type === RECIPE_CALL_TYPE && !isShadowed(call, name)) {
1615
+ const start = call.getStart();
1616
+ const end = call.getEnd();
1617
+ const rangeKey = `${start}:${end}`;
1618
+ if (!reportedRanges.has(rangeKey)) {
1619
+ reportedRanges.add(rangeKey);
1620
+ if (code.slice(start, end) !== call.getText()) {
1621
+ skipped.push({
1622
+ name,
1623
+ reason: "no-call-expression",
1624
+ start: 0,
1625
+ end: 0
1626
+ });
1627
+ continue;
1628
+ }
1629
+ recipeConfigs ??= collectRecipeConfigs(parserResult);
1630
+ const tally = recipeCalls.get(name) ?? {
1631
+ seen: 0,
1632
+ lowered: 0
1633
+ };
1634
+ tally.seen++;
1635
+ recipeCalls.set(name, tally);
1636
+ const resolvedSelection = item.data?.length === 1 ? item.data[0] : void 0;
1637
+ 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
+ };
1642
+ if (lowered.kind === "expression") {
1643
+ const helper = ensureRecipeHelperImport(call, isBambooCssModule, isGeneratedCssModule, isShadowed);
1644
+ if (helper) {
1645
+ tally.lowered++;
1646
+ candidates.push({
1647
+ item,
1648
+ call,
1649
+ node: call,
1650
+ start,
1651
+ end,
1652
+ replacement: helper.name === "cvaPick" ? lowered.expression : lowered.expression.replaceAll(`${RECIPE_PICK_HELPER}(`, `${helper.name}(`),
1653
+ className: lowered.staticClasses,
1654
+ classNames: lowered.classNames,
1655
+ insert: helper.insert,
1656
+ configBox: entry?.box
1657
+ });
1658
+ continue;
1659
+ }
1660
+ skipped.push({
1661
+ name,
1662
+ reason: "recipe-call",
1663
+ start,
1664
+ end
1665
+ });
1666
+ continue;
1667
+ }
1668
+ if (lowered.kind === "class") {
1669
+ tally.lowered++;
1670
+ candidates.push({
1671
+ item,
1672
+ call,
1673
+ node: call,
1674
+ start,
1675
+ end,
1676
+ replacement: JSON.stringify(lowered.className),
1677
+ className: lowered.className,
1678
+ classNames: lowered.className.split(" ").filter(Boolean),
1679
+ configBox: entry?.box
1680
+ });
1681
+ continue;
1682
+ }
1683
+ skipped.push({
1684
+ name,
1685
+ reason: "recipe-call",
1686
+ start,
1687
+ end
1688
+ });
1689
+ }
1690
+ }
1333
1691
  continue;
1334
1692
  }
1335
1693
  if (!call) {
@@ -1484,6 +1842,7 @@ const foldSource = (options) => {
1484
1842
  end
1485
1843
  });
1486
1844
  collectSourceFiles(item.box, dependencyScan);
1845
+ if (candidate.configBox) collectSourceFiles(candidate.configBox, dependencyScan);
1487
1846
  continue;
1488
1847
  }
1489
1848
  let className;
@@ -1532,6 +1891,16 @@ const foldSource = (options) => {
1532
1891
  });
1533
1892
  collectSourceFiles(item.box, dependencyScan);
1534
1893
  }
1894
+ for (const [binding, tally] of recipeCalls) {
1895
+ if (!tally.seen || tally.lowered !== tally.seen) continue;
1896
+ const definition = recipeConfigs?.get(binding)?.box?.getNode?.();
1897
+ if (!definition) continue;
1898
+ const call = ts_morph.Node.isCallExpression(definition) ? definition : definition.getFirstAncestorByKind(ts_morph.SyntaxKind.CallExpression);
1899
+ if (!call) continue;
1900
+ const start = call.getStart();
1901
+ if (code.slice(start, call.getEnd()) !== call.getText()) continue;
1902
+ magic.appendLeft(start, "/*#__PURE__*/");
1903
+ }
1535
1904
  if (folded.length === 0) return {
1536
1905
  code,
1537
1906
  map: null,
package/dist/index.d.cts CHANGED
@@ -61,7 +61,7 @@ declare const createRuntimeCss: (ctx: Context) => RuntimeCss;
61
61
  * Why a call site was left alone. Surfaced through `panda`-style diagnostics so a
62
62
  * user can tell the difference between "this folded" and "this silently didn't".
63
63
  */
64
- type SkipReason = 'dynamic' | 'raw-call' | 'not-foldable' | 'unsupported-kind' | 'not-imported' | 'no-call-expression' | 'overlapping' | 'empty' | 'unresolved-token';
64
+ type SkipReason = 'dynamic' | 'raw-call' | 'not-foldable' | 'recipe-call' | 'unsupported-kind' | 'not-imported' | 'no-call-expression' | 'overlapping' | 'empty' | 'unresolved-token';
65
65
  interface FoldedCall {
66
66
  name: string;
67
67
  /**
package/dist/index.d.mts CHANGED
@@ -61,7 +61,7 @@ declare const createRuntimeCss: (ctx: Context) => RuntimeCss;
61
61
  * Why a call site was left alone. Surfaced through `panda`-style diagnostics so a
62
62
  * user can tell the difference between "this folded" and "this silently didn't".
63
63
  */
64
- type SkipReason = 'dynamic' | 'raw-call' | 'not-foldable' | 'unsupported-kind' | 'not-imported' | 'no-call-expression' | 'overlapping' | 'empty' | 'unresolved-token';
64
+ type SkipReason = 'dynamic' | 'raw-call' | 'not-foldable' | 'recipe-call' | 'unsupported-kind' | 'not-imported' | 'no-call-expression' | 'overlapping' | 'empty' | 'unresolved-token';
65
65
  interface FoldedCall {
66
66
  name: string;
67
67
  /**
package/dist/index.mjs CHANGED
@@ -4,8 +4,8 @@ import { resolveTsPathPattern } from "@bamboocss/config/ts-path";
4
4
  import { box, maybeBoxNode, unbox } from "@bamboocss/extractor";
5
5
  import MagicString from "magic-string";
6
6
  import { Node, SyntaxKind, VariableDeclarationKind } from "ts-morph";
7
- import { Recipes } from "@bamboocss/core";
8
- import { compact, createCssUncached, createMergeCss, getSlotCompoundVariant, memo, withoutSpace } from "@bamboocss/shared";
7
+ import { Recipes, classFormatter } from "@bamboocss/core";
8
+ import { compact, createCssUncached, createMergeCss, getRecipeClassNames, getRecipeIdentity, getSlotCompoundVariant, memo, withoutSpace } from "@bamboocss/shared";
9
9
  import { resolve } from "node:path";
10
10
  //#region src/css.ts
11
11
  /**
@@ -727,6 +727,269 @@ const ensureCxImport = (call, calleeRoot, isBambooCssModule, isGeneratedCssModul
727
727
  };
728
728
  };
729
729
  //#endregion
730
+ //#region src/fold-recipe.ts
731
+ /**
732
+ * Binding name → the config it was declared with.
733
+ *
734
+ * Built from the definitions the parser already recorded, walking each one to the declaration
735
+ * that names it. The parser records a definition under the name it was *imported* as (`cva`),
736
+ * and a call under the name the file *bound* (`badge`); this is what joins the two.
737
+ *
738
+ * Reads `cva` and not `sva`, which is load-bearing rather than an omission. The parser records
739
+ * a call of *either* as a recipe call, but an `sva` invocation returns one class per slot — an
740
+ * object, not a string — so there is no literal to substitute. Leaving slot recipes out of this
741
+ * map is what makes them decline as `unknown-recipe` instead of folding to a string that would
742
+ * break every consumer reading `.root` off it.
743
+ */
744
+ const collectRecipeConfigs = (parserResult) => {
745
+ const configs = /* @__PURE__ */ new Map();
746
+ for (const definition of parserResult.cva) {
747
+ const node = definition.box?.getNode?.();
748
+ if (!node) continue;
749
+ const nameNode = ((Node.isCallExpression(node) ? node : node.getFirstAncestorByKind(SyntaxKind.CallExpression))?.getFirstAncestorByKind(SyntaxKind.VariableDeclaration))?.getNameNode();
750
+ if (!nameNode || !Node.isIdentifier(nameNode)) continue;
751
+ if (definition.data?.length !== 1) {
752
+ configs.set(nameNode.getText(), AMBIGUOUS);
753
+ continue;
754
+ }
755
+ const config = definition.data[0];
756
+ if (!config || typeof config !== "object") continue;
757
+ if (configs.has(nameNode.getText())) {
758
+ configs.set(nameNode.getText(), AMBIGUOUS);
759
+ continue;
760
+ }
761
+ configs.set(nameNode.getText(), {
762
+ config,
763
+ name: getRecipeIdentity(config),
764
+ box: definition.box
765
+ });
766
+ }
767
+ return configs;
768
+ };
769
+ /** The generated binding a lowered dynamic axis calls. Lives in `cx`, which pulls no engine. */
770
+ const RECIPE_PICK_HELPER = "cvaPick";
771
+ const HELPER = RECIPE_PICK_HELPER;
772
+ /** Marker for a binding the fold must never resolve — declared twice, or unresolvable. */
773
+ const AMBIGUOUS = Object.freeze({
774
+ config: {},
775
+ name: "",
776
+ box: void 0
777
+ });
778
+ const LITERAL_KINDS = new Set([
779
+ SyntaxKind.StringLiteral,
780
+ SyntaxKind.NoSubstitutionTemplateLiteral,
781
+ SyntaxKind.NumericLiteral,
782
+ SyntaxKind.TrueKeyword,
783
+ SyntaxKind.FalseKeyword
784
+ ]);
785
+ /**
786
+ * The value a literal node denotes, or `undefined` for anything else.
787
+ *
788
+ * Read off the node rather than from the extractor's resolved data, because that data is lossy
789
+ * in the direction that matters: a property it could not resolve is *dropped*, so `badge({ tone })`
790
+ * and `badge({})` are identical there. Folding the first as if it were the second emits a class
791
+ * string missing the variant — the element renders, wrongly, with no report.
792
+ */
793
+ const literalValue = (node) => {
794
+ if (!node || !LITERAL_KINDS.has(node.getKind())) return void 0;
795
+ if (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) return node.getLiteralValue();
796
+ if (Node.isNumericLiteral(node)) return node.getLiteralValue();
797
+ if (node.getKind() === SyntaxKind.TrueKeyword) return true;
798
+ if (node.getKind() === SyntaxKind.FalseKeyword) return false;
799
+ };
800
+ /**
801
+ * The property name a key node denotes.
802
+ *
803
+ * Read off the node rather than unquoted from its text. `{ '\\u0074one': 'a' }` names the
804
+ * variant `tone`, and stripping the surrounding quotes leaves the escape uninterpreted — so
805
+ * the variant did not match, its class was dropped, and the element rendered without it. A
806
+ * numeric key normalises the same way: `{ 0x10: 'a' }` is the key `16`.
807
+ */
808
+ const propertyKey = (nameNode) => {
809
+ if (Node.isIdentifier(nameNode)) return nameNode.getText();
810
+ if (Node.isStringLiteral(nameNode) || Node.isNoSubstitutionTemplateLiteral(nameNode)) return nameNode.getLiteralValue();
811
+ if (Node.isNumericLiteral(nameNode)) return String(nameNode.getLiteralValue());
812
+ };
813
+ /**
814
+ * Make `cvaPick` callable at this call site, by whatever name the file gives it.
815
+ *
816
+ * Not `ensureCxImport`: that one resolves `cx` and finds the declaration to extend by
817
+ * matching the *callee* against an import. An inline recipe's callee is a local binding, so
818
+ * there is nothing to match — the host here is any import of the generated css module, which
819
+ * a file defining a recipe necessarily has, since `cva` came from it.
820
+ */
821
+ const ensureRecipeHelperImport = (call, isBambooCssModule, isGeneratedCssModule, isShadowed) => {
822
+ const sourceFile = call.getSourceFile();
823
+ let host;
824
+ for (const declaration of sourceFile.getImportDeclarations()) {
825
+ const mod = declaration.getModuleSpecifierValue();
826
+ if (declaration.isTypeOnly()) continue;
827
+ for (const named of declaration.getNamedImports()) {
828
+ if (named.isTypeOnly()) continue;
829
+ if (named.getNameNode().getText() === "cvaPick") {
830
+ if (!isBambooCssModule(mod)) return void 0;
831
+ const local = (named.getAliasNode() ?? named.getNameNode()).getText();
832
+ return isShadowed(call, local) ? void 0 : { name: local };
833
+ }
834
+ }
835
+ if (!host && isGeneratedCssModule(mod) && declaration.getNamedImports().length > 0) host = declaration;
836
+ }
837
+ if (!host) return void 0;
838
+ if (declaredAtModuleScope(sourceFile).has("cvaPick")) return void 0;
839
+ if (isShadowed(call, "cvaPick")) return void 0;
840
+ const last = host.getNamedImports().at(-1);
841
+ if (!last) return void 0;
842
+ return {
843
+ name: RECIPE_PICK_HELPER,
844
+ insert: {
845
+ pos: last.getEnd(),
846
+ names: [RECIPE_PICK_HELPER]
847
+ }
848
+ };
849
+ };
850
+ /**
851
+ * Lower one invocation, or say why not.
852
+ *
853
+ * Every property written at the call site has to be a literal. A selection is not additive —
854
+ * an unresolved variant does not merely omit a class, it can change which of several the
855
+ * recipe applies — so a partially-known selection is not foldable at all.
856
+ */
857
+ const lowerRecipeCall = (call, entry, ctx, resolvedSelection) => {
858
+ if (!entry || entry === AMBIGUOUS) return {
859
+ kind: "decline",
860
+ reason: "unknown-recipe"
861
+ };
862
+ const { config, name } = entry;
863
+ if (config.slots !== void 0) return {
864
+ kind: "decline",
865
+ reason: "unsupported-shape"
866
+ };
867
+ if (!config.base && !config.variants && !config.className) return {
868
+ kind: "decline",
869
+ reason: "unknown-recipe"
870
+ };
871
+ if (!Node.isCallExpression(call)) return {
872
+ kind: "decline",
873
+ reason: "unsupported-shape"
874
+ };
875
+ const args = call.getArguments();
876
+ if (args.length > 1) return {
877
+ kind: "decline",
878
+ reason: "unsupported-shape"
879
+ };
880
+ const selection = {};
881
+ /** Variant → the source expression selecting it, for axes that stay runtime decisions. */
882
+ const dynamicAxes = /* @__PURE__ */ new Map();
883
+ if (args.length === 1) {
884
+ const arg = args[0];
885
+ if (!arg || !Node.isObjectLiteralExpression(arg)) return {
886
+ kind: "decline",
887
+ reason: "dynamic"
888
+ };
889
+ for (const property of arg.getProperties()) {
890
+ if (Node.isSpreadAssignment(property)) return {
891
+ kind: "decline",
892
+ reason: "dynamic"
893
+ };
894
+ if (Node.isShorthandPropertyAssignment(property)) {
895
+ dynamicAxes.set(property.getName(), property.getName());
896
+ delete selection[property.getName()];
897
+ continue;
898
+ }
899
+ if (!Node.isPropertyAssignment(property)) return {
900
+ kind: "decline",
901
+ reason: "dynamic"
902
+ };
903
+ const nameNode = property.getNameNode();
904
+ if (Node.isComputedPropertyName(nameNode)) return {
905
+ kind: "decline",
906
+ reason: "dynamic"
907
+ };
908
+ const key = propertyKey(nameNode);
909
+ if (key === void 0) return {
910
+ kind: "decline",
911
+ reason: "dynamic"
912
+ };
913
+ const literal = literalValue(property.getInitializer());
914
+ if (literal !== void 0) {
915
+ selection[key] = literal;
916
+ dynamicAxes.delete(key);
917
+ continue;
918
+ }
919
+ if (!resolvedSelection || !Object.hasOwn(resolvedSelection, key)) {
920
+ const initializer = property.getInitializer();
921
+ if (!initializer) return {
922
+ kind: "decline",
923
+ reason: "dynamic"
924
+ };
925
+ dynamicAxes.set(key, initializer.getText());
926
+ delete selection[key];
927
+ continue;
928
+ }
929
+ const value = resolvedSelection[key];
930
+ if (value !== null && typeof value === "object") return {
931
+ kind: "decline",
932
+ reason: "dynamic"
933
+ };
934
+ selection[key] = value;
935
+ dynamicAxes.delete(key);
936
+ }
937
+ }
938
+ const merged = {
939
+ ...config.defaultVariants ?? {},
940
+ ...compact(selection)
941
+ };
942
+ 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
+ if (dynamicAxes.size === 0) {
949
+ const staticOnly = { ...merged };
950
+ for (const key of dynamicAxes.keys()) delete staticOnly[key];
951
+ return {
952
+ kind: "class",
953
+ className: getRecipeClassNames(name, config.variants, staticOnly, ctx.utility.separator, format)
954
+ };
955
+ }
956
+ const ownClass = format(name);
957
+ const parts = [JSON.stringify(ownClass)];
958
+ const classNames = [ownClass];
959
+ for (const key of Object.keys(config.variants ?? {})) {
960
+ const expression = dynamicAxes.get(key);
961
+ if (expression === void 0) {
962
+ const value = merged[key];
963
+ if (value == null) continue;
964
+ if (config.variants?.[key]?.[value] == null) continue;
965
+ const className = format(`${name}--${key}${ctx.utility.separator}${withoutSpace(value)}`);
966
+ parts.push(JSON.stringify(` ${className}`));
967
+ classNames.push(className);
968
+ continue;
969
+ }
970
+ const values = config.variants[key];
971
+ const table = {};
972
+ for (const value of Object.keys(values)) {
973
+ const className = format(`${name}--${key}${ctx.utility.separator}${withoutSpace(value)}`);
974
+ table[value] = ` ${className}`;
975
+ classNames.push(className);
976
+ }
977
+ const fallbackValue = config.defaultVariants?.[key];
978
+ const fallback = fallbackValue != null && values[fallbackValue] != null ? ` ${format(`${name}--${key}${ctx.utility.separator}${withoutSpace(fallbackValue)}`)}` : void 0;
979
+ parts.push(`${HELPER}(${expression}, ${JSON.stringify(table)}${fallback === void 0 ? "" : `, ${JSON.stringify(fallback)}`})`);
980
+ }
981
+ if (parts.length === 1) return {
982
+ kind: "class",
983
+ className: ownClass
984
+ };
985
+ return {
986
+ kind: "expression",
987
+ expression: parts.join(" + "),
988
+ classNames,
989
+ staticClasses: ownClass
990
+ };
991
+ };
992
+ //#endregion
730
993
  //#region src/runtime-css.ts
731
994
  /** The shape `createCss` and `createMergeCss` both take, derived from a resolved context. */
732
995
  const createCssContext = (ctx) => ({
@@ -835,22 +1098,13 @@ const createRuntimeRecipe = (ctx) => {
835
1098
  //#endregion
836
1099
  //#region src/fold.ts
837
1100
  /**
838
- * `cva`/`sva` return a function, so neither can collapse to a class string. Their
839
- * *invocations* could, but those are separate call sites the parser does not record as
840
- * such. `token` also resolves to no class, but it does resolve to a literal, so it folds
841
- * through its own path rather than being declined outright.
842
- *
843
- * Folding an invocation is now *possible* in a way it was not: a recipe's classes are named
844
- * semantically, so the build knows every class a call can produce from the config alone.
845
- * What is missing is upstream — the parser matches calls by imported name, so a local
846
- * `button()` from `const button = cva(...)` is never recorded, and tracking those bindings
847
- * is a change to the extractor rather than to this set.
1101
+ * `cva`/`sva` return a function, so neither *definition* can collapse to a class string.
1102
+ * `token` also resolves to no class, but it does resolve to a literal, so it folds through
1103
+ * its own path rather than being declined outright.
848
1104
  *
849
- * Worth knowing before taking that on: semantic naming already took most of the prize.
850
- * `cvaFn` used to run `mergeCss` and name a class per property on every call; it is now a
851
- * memoized loop over `variantKeys` doing string concatenation. That is an inspection of the
852
- * two implementations, not a measurement — benchmark it before deciding it is worth the
853
- * extractor work.
1105
+ * Their invocations are a different matter and do fold `cva`'s through `fold-recipe`,
1106
+ * which is a separate set because the call is recorded under the name the file bound rather
1107
+ * than the name it imported. `sva`'s do not: a slot recipe resolves to one class per slot.
854
1108
  */
855
1109
  const FOLDABLE_TYPES = new Set([
856
1110
  "css",
@@ -872,6 +1126,18 @@ const literalsIn = (expression) => [...expression.matchAll(/"((?:[^"\\]|\\.)*)"/
872
1126
  */
873
1127
  const UNFOLDABLE_TYPES = new Set(["cva", "sva"]);
874
1128
  /**
1129
+ * A call of a recipe the file bound itself: `const badge = cva(...)`, then `badge({ tone })`.
1130
+ *
1131
+ * Folded when the whole selection resolves, reported under this reason when it does not.
1132
+ * Deliberately all-or-nothing: an unresolved variant does not merely omit its own class, so a
1133
+ * partially-known selection is not foldable at all.
1134
+ *
1135
+ * Visible at all because it used to not be. The parser matched calls by imported name, so a
1136
+ * local binding was never recorded, and an unfoldable invocation looked identical to code
1137
+ * nothing had parsed.
1138
+ */
1139
+ const RECIPE_CALL_TYPE = "cva-call";
1140
+ /**
875
1141
  * An argument that cannot run anything when it is evaluated.
876
1142
  *
877
1143
  * `token(path, fallback)` evaluates both arguments before the call, so a fold that drops
@@ -1190,6 +1456,21 @@ const foldSource = (options) => {
1190
1456
  const skipped = [];
1191
1457
  const candidates = [];
1192
1458
  const seenRanges = /* @__PURE__ */ new Set();
1459
+ /** Built on first use: most modules declare no inline recipe. */
1460
+ let recipeConfigs;
1461
+ /**
1462
+ * Per inline recipe binding: calls seen, calls lowered.
1463
+ *
1464
+ * A binding whose every call lowered is no longer read, so its `cva({ … })` config can leave
1465
+ * the bundle — which is the whole point, the config being far larger than the runtime. But a
1466
+ * bundler will not drop the call on its own: `cva` closes over the config and builds an
1467
+ * object, and Rollup cannot prove that is side-effect free, so it keeps the expression and
1468
+ * the module ends up *larger* than before folding. The annotation below is what makes the
1469
+ * saving real, and it is only correct to claim it once nothing reads the binding.
1470
+ */
1471
+ const recipeCalls = /* @__PURE__ */ new Map();
1472
+ /** Ranges already reported as declined, so one call is never counted twice. */
1473
+ const reportedRanges = /* @__PURE__ */ new Set();
1193
1474
  const importCache = /* @__PURE__ */ new Map();
1194
1475
  const importsFor = (sourceFile) => {
1195
1476
  let names = importCache.get(sourceFile);
@@ -1303,6 +1584,83 @@ const foldSource = (options) => {
1303
1584
  start: call.getStart(),
1304
1585
  end: call.getEnd()
1305
1586
  });
1587
+ if (call && type === RECIPE_CALL_TYPE && !isShadowed(call, name)) {
1588
+ const start = call.getStart();
1589
+ const end = call.getEnd();
1590
+ const rangeKey = `${start}:${end}`;
1591
+ if (!reportedRanges.has(rangeKey)) {
1592
+ reportedRanges.add(rangeKey);
1593
+ if (code.slice(start, end) !== call.getText()) {
1594
+ skipped.push({
1595
+ name,
1596
+ reason: "no-call-expression",
1597
+ start: 0,
1598
+ end: 0
1599
+ });
1600
+ continue;
1601
+ }
1602
+ recipeConfigs ??= collectRecipeConfigs(parserResult);
1603
+ const tally = recipeCalls.get(name) ?? {
1604
+ seen: 0,
1605
+ lowered: 0
1606
+ };
1607
+ tally.seen++;
1608
+ recipeCalls.set(name, tally);
1609
+ const resolvedSelection = item.data?.length === 1 ? item.data[0] : void 0;
1610
+ 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
+ };
1615
+ if (lowered.kind === "expression") {
1616
+ const helper = ensureRecipeHelperImport(call, isBambooCssModule, isGeneratedCssModule, isShadowed);
1617
+ if (helper) {
1618
+ tally.lowered++;
1619
+ candidates.push({
1620
+ item,
1621
+ call,
1622
+ node: call,
1623
+ start,
1624
+ end,
1625
+ replacement: helper.name === "cvaPick" ? lowered.expression : lowered.expression.replaceAll(`${RECIPE_PICK_HELPER}(`, `${helper.name}(`),
1626
+ className: lowered.staticClasses,
1627
+ classNames: lowered.classNames,
1628
+ insert: helper.insert,
1629
+ configBox: entry?.box
1630
+ });
1631
+ continue;
1632
+ }
1633
+ skipped.push({
1634
+ name,
1635
+ reason: "recipe-call",
1636
+ start,
1637
+ end
1638
+ });
1639
+ continue;
1640
+ }
1641
+ if (lowered.kind === "class") {
1642
+ tally.lowered++;
1643
+ candidates.push({
1644
+ item,
1645
+ call,
1646
+ node: call,
1647
+ start,
1648
+ end,
1649
+ replacement: JSON.stringify(lowered.className),
1650
+ className: lowered.className,
1651
+ classNames: lowered.className.split(" ").filter(Boolean),
1652
+ configBox: entry?.box
1653
+ });
1654
+ continue;
1655
+ }
1656
+ skipped.push({
1657
+ name,
1658
+ reason: "recipe-call",
1659
+ start,
1660
+ end
1661
+ });
1662
+ }
1663
+ }
1306
1664
  continue;
1307
1665
  }
1308
1666
  if (!call) {
@@ -1457,6 +1815,7 @@ const foldSource = (options) => {
1457
1815
  end
1458
1816
  });
1459
1817
  collectSourceFiles(item.box, dependencyScan);
1818
+ if (candidate.configBox) collectSourceFiles(candidate.configBox, dependencyScan);
1460
1819
  continue;
1461
1820
  }
1462
1821
  let className;
@@ -1505,6 +1864,16 @@ const foldSource = (options) => {
1505
1864
  });
1506
1865
  collectSourceFiles(item.box, dependencyScan);
1507
1866
  }
1867
+ for (const [binding, tally] of recipeCalls) {
1868
+ if (!tally.seen || tally.lowered !== tally.seen) continue;
1869
+ const definition = recipeConfigs?.get(binding)?.box?.getNode?.();
1870
+ if (!definition) continue;
1871
+ const call = Node.isCallExpression(definition) ? definition : definition.getFirstAncestorByKind(SyntaxKind.CallExpression);
1872
+ if (!call) continue;
1873
+ const start = call.getStart();
1874
+ if (code.slice(start, call.getEnd()) !== call.getText()) continue;
1875
+ magic.appendLeft(start, "/*#__PURE__*/");
1876
+ }
1508
1877
  if (folded.length === 0) return {
1509
1878
  code,
1510
1879
  map: null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bamboocss/vite",
3
- "version": "1.22.0",
3
+ "version": "1.23.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/config": "1.22.0",
41
- "@bamboocss/core": "1.22.0",
42
- "@bamboocss/logger": "1.22.0",
43
- "@bamboocss/extractor": "1.22.0",
44
- "@bamboocss/node": "1.22.0",
45
- "@bamboocss/shared": "1.22.0",
46
- "@bamboocss/types": "1.22.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"
47
47
  },
48
48
  "devDependencies": {
49
49
  "@jridgewell/trace-mapping": "^0.3.31",
50
50
  "vite": "7.2.6",
51
- "@bamboocss/fixture": "1.22.0"
51
+ "@bamboocss/fixture": "1.23.0"
52
52
  },
53
53
  "peerDependencies": {
54
54
  "vite": ">=5"