@bamboocss/vite 1.21.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
@@ -24,6 +24,8 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  enumerable: true
25
25
  }) : target, mod));
26
26
  //#endregion
27
+ let _bamboocss_node = require("@bamboocss/node");
28
+ let _bamboocss_logger = require("@bamboocss/logger");
27
29
  let _bamboocss_config_ts_path = require("@bamboocss/config/ts-path");
28
30
  let _bamboocss_extractor = require("@bamboocss/extractor");
29
31
  let magic_string = require("magic-string");
@@ -32,8 +34,90 @@ let ts_morph = require("ts-morph");
32
34
  let _bamboocss_core = require("@bamboocss/core");
33
35
  let _bamboocss_shared = require("@bamboocss/shared");
34
36
  let node_path = require("node:path");
35
- let _bamboocss_logger = require("@bamboocss/logger");
36
- let _bamboocss_node = require("@bamboocss/node");
37
+ //#region src/css.ts
38
+ /**
39
+ * What a project imports to get the stylesheet.
40
+ *
41
+ * Spelled with a `.css` extension because that is how vite decides what a module is: the
42
+ * id is all it has for a module with no file behind it, so `virtual:bamboo` would be
43
+ * bundled as javascript and injected as a script.
44
+ */
45
+ const VIRTUAL_CSS_ID = "virtual:bamboo.css";
46
+ /**
47
+ * Rollup's convention for a module with no file: a leading NUL tells every other plugin
48
+ * not to try reading it off disk.
49
+ */
50
+ const RESOLVED_ID = `\0${VIRTUAL_CSS_ID}`;
51
+ /**
52
+ * Serve bamboo's stylesheet as a virtual module, in dev and in build.
53
+ *
54
+ * This is the integration itself, not an optimisation: without it nothing emits css and
55
+ * the generated `styled-system` runtime names classes no rule exists for.
56
+ *
57
+ * A virtual module rather than a file written to disk, because vite already owns the two
58
+ * things a file would have to reimplement. In dev it injects css over the websocket and
59
+ * replaces it in place, so an edit repaints without reloading; in build it hashes the
60
+ * content into the asset graph and lets the bundler decide where it lands. Writing
61
+ * `styles.css` and asking the project to import it means the build reads a file the same
62
+ * process just wrote, which is a race on any watch rebuild.
63
+ */
64
+ const bamboocssCss = (options = {}) => {
65
+ const { configPath, cwd } = options;
66
+ const builder = new _bamboocss_node.Builder();
67
+ let server;
68
+ /**
69
+ * Serialised, because both `load` and the watcher can reach it and `Builder` keeps one
70
+ * context. Two overlapping passes would extract into the same encoder and emit the
71
+ * stylesheet twice over.
72
+ */
73
+ let pending;
74
+ const build = async () => {
75
+ await builder.setup({
76
+ configPath,
77
+ cwd
78
+ });
79
+ await builder.emit();
80
+ builder.extract();
81
+ return builder.toCss({ layerParams: true });
82
+ };
83
+ const generate = () => {
84
+ pending = Promise.resolve(pending).catch(() => void 0).then(build);
85
+ return pending;
86
+ };
87
+ return {
88
+ name: "bamboocss:css",
89
+ resolveId(id) {
90
+ if (id === "virtual:bamboo.css") return RESOLVED_ID;
91
+ return null;
92
+ },
93
+ async load(id) {
94
+ if (id !== RESOLVED_ID) return null;
95
+ const css = await generate();
96
+ if (this.addWatchFile) for (const file of builder.context?.getFiles() ?? []) this.addWatchFile(builder.context.runtime.path.abs(builder.context.config.cwd, file));
97
+ return css;
98
+ },
99
+ configureServer(devServer) {
100
+ server = devServer;
101
+ const invalidate = (file) => {
102
+ const ctx = builder.context;
103
+ if (!ctx) return;
104
+ if (!ctx.getFiles().some((f) => ctx.runtime.path.abs(ctx.config.cwd, f) === file)) return;
105
+ const mod = server?.moduleGraph.getModuleById(RESOLVED_ID);
106
+ if (!mod) return;
107
+ server?.moduleGraph.invalidateModule(mod);
108
+ server?.ws.send({
109
+ type: "update",
110
+ updates: []
111
+ });
112
+ _bamboocss_logger.logger.debug("vite", `styles invalidated by ${file}`);
113
+ };
114
+ devServer.watcher.on("change", invalidate);
115
+ devServer.watcher.on("add", invalidate);
116
+ devServer.watcher.on("unlink", invalidate);
117
+ }
118
+ };
119
+ };
120
+ //#endregion
37
121
  //#region src/fold-partial.ts
38
122
  /**
39
123
  * Statically resolvable means: every box in the tree carries a known value.
@@ -480,9 +564,6 @@ const planPartialFold = (argument, boxNode, styles, deps) => {
480
564
  if (!partition) return void 0;
481
565
  const className = deps.runtimeCss(partition.staticStyles);
482
566
  if (!className && !partition.finite.length) return void 0;
483
- if (deps.ctx.config.cssMode === "grouped") {
484
- if ((className ? 1 : 0) + partition.finite.length + (partition.dynamicText.length ? 1 : 0) > 1) return void 0;
485
- }
486
567
  return {
487
568
  className,
488
569
  dynamicText: partition.dynamicText.length ? `{ ${partition.dynamicText.join(", ")} }` : void 0,
@@ -673,10 +754,272 @@ const ensureCxImport = (call, calleeRoot, isBambooCssModule, isGeneratedCssModul
673
754
  };
674
755
  };
675
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
676
1020
  //#region src/runtime-css.ts
677
1021
  /** The shape `createCss` and `createMergeCss` both take, derived from a resolved context. */
678
1022
  const createCssContext = (ctx) => ({
679
- grouped: ctx.config.cssMode === "grouped",
680
1023
  hash: Boolean(ctx.hash.className),
681
1024
  conditions: {
682
1025
  shift: ctx.conditions.shift,
@@ -782,22 +1125,13 @@ const createRuntimeRecipe = (ctx) => {
782
1125
  //#endregion
783
1126
  //#region src/fold.ts
784
1127
  /**
785
- * `cva`/`sva` return a function, so neither can collapse to a class string. Their
786
- * *invocations* could, but those are separate call sites the parser does not record as
787
- * such. `token` also resolves to no class, but it does resolve to a literal, so it folds
788
- * through its own path rather than being declined outright.
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.
789
1131
  *
790
- * Folding an invocation is now *possible* in a way it was not: a recipe's classes are named
791
- * semantically, so the build knows every class a call can produce from the config alone.
792
- * What is missing is upstream the parser matches calls by imported name, so a local
793
- * `button()` from `const button = cva(...)` is never recorded, and tracking those bindings
794
- * is a change to the extractor rather than to this set.
795
- *
796
- * Worth knowing before taking that on: semantic naming already took most of the prize.
797
- * `cvaFn` used to run `mergeCss` and name a class per property on every call; it is now a
798
- * memoized loop over `variantKeys` doing string concatenation. That is an inspection of the
799
- * two implementations, not a measurement — benchmark it before deciding it is worth the
800
- * 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.
801
1135
  */
802
1136
  const FOLDABLE_TYPES = new Set([
803
1137
  "css",
@@ -819,6 +1153,18 @@ const literalsIn = (expression) => [...expression.matchAll(/"((?:[^"\\]|\\.)*)"/
819
1153
  */
820
1154
  const UNFOLDABLE_TYPES = new Set(["cva", "sva"]);
821
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
+ /**
822
1168
  * An argument that cannot run anything when it is evaluated.
823
1169
  *
824
1170
  * `token(path, fallback)` evaluates both arguments before the call, so a fold that drops
@@ -1137,6 +1483,21 @@ const foldSource = (options) => {
1137
1483
  const skipped = [];
1138
1484
  const candidates = [];
1139
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();
1140
1501
  const importCache = /* @__PURE__ */ new Map();
1141
1502
  const importsFor = (sourceFile) => {
1142
1503
  let names = importCache.get(sourceFile);
@@ -1250,6 +1611,83 @@ const foldSource = (options) => {
1250
1611
  start: call.getStart(),
1251
1612
  end: call.getEnd()
1252
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
+ }
1253
1691
  continue;
1254
1692
  }
1255
1693
  if (!call) {
@@ -1404,6 +1842,7 @@ const foldSource = (options) => {
1404
1842
  end
1405
1843
  });
1406
1844
  collectSourceFiles(item.box, dependencyScan);
1845
+ if (candidate.configBox) collectSourceFiles(candidate.configBox, dependencyScan);
1407
1846
  continue;
1408
1847
  }
1409
1848
  let className;
@@ -1452,6 +1891,16 @@ const foldSource = (options) => {
1452
1891
  });
1453
1892
  collectSourceFiles(item.box, dependencyScan);
1454
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
+ }
1455
1904
  if (folded.length === 0) return {
1456
1905
  code,
1457
1906
  map: null,
@@ -1502,6 +1951,23 @@ const isGeneratedOutput = (filePath, ctx) => {
1502
1951
  const file = slashed(filePath);
1503
1952
  return file === root || file.startsWith(`${root}/`);
1504
1953
  };
1954
+ /**
1955
+ * The skip reasons that leave a `css()`-family call in the output.
1956
+ *
1957
+ * `overlapping` is handled by the enclosing fold, and `not-imported` is somebody else's
1958
+ * function of the same name — neither leaves a call of ours. `not-foldable` is a `cva`/`sva`
1959
+ * definition, which keeps the recipe runtime rather than the css engine; see `strict`.
1960
+ */
1961
+ const SURVIVES_TO_RUNTIME = new Set([
1962
+ "dynamic",
1963
+ "raw-call",
1964
+ "unsupported-kind",
1965
+ "no-call-expression",
1966
+ "empty",
1967
+ "unresolved-token"
1968
+ ]);
1969
+ /** 1-indexed line of a source offset, for an error a user can navigate to. */
1970
+ const lineAt = (code, offset) => code.slice(0, offset).split("\n").length;
1505
1971
  const formatSkipped = (id, skipped) => {
1506
1972
  const counts = /* @__PURE__ */ new Map();
1507
1973
  for (const entry of skipped) counts.set(entry.reason, (counts.get(entry.reason) ?? 0) + 1);
@@ -1510,16 +1976,17 @@ const formatSkipped = (id, skipped) => {
1510
1976
  /**
1511
1977
  * Vite integration for Bamboo CSS.
1512
1978
  *
1513
- * This plugin does not emit CSS keep your existing PostCSS setup for that. Its only
1514
- * job is the optional build-time fold.
1979
+ * Two plugins, because they do unrelated jobs on different schedules. The first emits the
1980
+ * stylesheet as a virtual module and runs in dev and build alike — that is the integration,
1981
+ * and nothing styles without it. The second is the optional build-time fold.
1515
1982
  *
1516
- * It runs with `enforce: 'pre'` so it sees module source as close as possible to what
1983
+ * The fold runs with `enforce: 'pre'` so it sees module source as close as possible to what
1517
1984
  * the CSS extractor reads off disk. A plugin that rewrites style calls before bamboo
1518
1985
  * sees them would otherwise make the two disagree, and a folded class could end up
1519
1986
  * with no matching rule.
1520
1987
  */
1521
1988
  const bamboocss = (options = {}) => {
1522
- const { transform = true, partial, configPath, cwd, reportSkipped = false, reportSummary = true } = options;
1989
+ const { transform = true, partial, configPath, cwd, reportSkipped = false, reportSummary = true, strict = false } = options;
1523
1990
  /** Totals across the build, for the summary. */
1524
1991
  const totals = {
1525
1992
  folded: 0,
@@ -1527,6 +1994,8 @@ const bamboocss = (options = {}) => {
1527
1994
  filesWithFolds: 0,
1528
1995
  skipped: /* @__PURE__ */ new Map()
1529
1996
  };
1997
+ /** Under `strict`, every call that would still reach the runtime. */
1998
+ const survivors = [];
1530
1999
  let ctx;
1531
2000
  let runtimeCss;
1532
2001
  let setup;
@@ -1540,8 +2009,11 @@ const bamboocss = (options = {}) => {
1540
2009
  });
1541
2010
  await setup;
1542
2011
  };
1543
- return {
1544
- name: "bamboocss",
2012
+ return [bamboocssCss({
2013
+ configPath,
2014
+ cwd
2015
+ }), {
2016
+ name: "bamboocss:fold",
1545
2017
  enforce: "pre",
1546
2018
  apply: "build",
1547
2019
  async buildStart() {
@@ -1550,6 +2022,7 @@ const bamboocss = (options = {}) => {
1550
2022
  totals.files = 0;
1551
2023
  totals.filesWithFolds = 0;
1552
2024
  totals.skipped.clear();
2025
+ survivors.length = 0;
1553
2026
  await ensureContext();
1554
2027
  },
1555
2028
  /**
@@ -1611,6 +2084,23 @@ const bamboocss = (options = {}) => {
1611
2084
  totals.folded += result.folded.length;
1612
2085
  if (result.folded.length) totals.filesWithFolds++;
1613
2086
  for (const entry of result.skipped) totals.skipped.set(entry.reason, (totals.skipped.get(entry.reason) ?? 0) + 1);
2087
+ if (strict) {
2088
+ for (const entry of result.skipped) {
2089
+ if (!SURVIVES_TO_RUNTIME.has(entry.reason)) continue;
2090
+ survivors.push({
2091
+ file: filePath,
2092
+ line: lineAt(code, entry.start),
2093
+ name: entry.name,
2094
+ reason: entry.reason
2095
+ });
2096
+ }
2097
+ if (result.code.includes("cssLeaf(")) survivors.push({
2098
+ file: filePath,
2099
+ line: lineAt(result.code, result.code.indexOf("cssLeaf(")),
2100
+ name: "cssLeaf",
2101
+ reason: "lowered-leaf"
2102
+ });
2103
+ }
1614
2104
  if (reportSkipped && result.skipped.length) _bamboocss_logger.logger.info("vite:transform", formatSkipped(filePath, result.skipped));
1615
2105
  for (const dependency of result.dependencies) this.addWatchFile?.(dependency);
1616
2106
  if (!result.folded.length) return null;
@@ -1621,6 +2111,16 @@ const bamboocss = (options = {}) => {
1621
2111
  };
1622
2112
  },
1623
2113
  buildEnd() {
2114
+ if (strict && survivors.length) {
2115
+ const byFile = /* @__PURE__ */ new Map();
2116
+ for (const entry of survivors) {
2117
+ const list = byFile.get(entry.file) ?? [];
2118
+ list.push(entry);
2119
+ byFile.set(entry.file, list);
2120
+ }
2121
+ const detail = Array.from(byFile.entries()).map(([file, entries]) => [` ${file}`, ...entries.map((e) => ` ${e.line}: ${e.name}() — ${e.reason}`)].join("\n")).join("\n");
2122
+ 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.`);
2123
+ }
1624
2124
  if (!transform || !reportSummary) return;
1625
2125
  const declined = Array.from(totals.skipped.values()).reduce((sum, count) => sum + count, 0);
1626
2126
  const total = totals.folded + declined;
@@ -1629,10 +2129,12 @@ const bamboocss = (options = {}) => {
1629
2129
  const reasons = Array.from(totals.skipped.entries()).sort((a, b) => b[1] - a[1]).map(([reason, count]) => `${reason}=${count}`).join(" ");
1630
2130
  _bamboocss_logger.logger.info("vite:transform", `Folded ${totals.folded}/${total} (${share}%) across ${totals.filesWithFolds}/${totals.files} files` + (reasons ? ` — declined: ${reasons}` : ""));
1631
2131
  }
1632
- };
2132
+ }];
1633
2133
  };
1634
2134
  //#endregion
2135
+ exports.VIRTUAL_CSS_ID = VIRTUAL_CSS_ID;
1635
2136
  exports.bamboocss = bamboocss;
2137
+ exports.bamboocssCss = bamboocssCss;
1636
2138
  exports.createRuntimeCss = createRuntimeCss;
1637
2139
  exports.default = bamboocss;
1638
2140
  exports.foldSource = foldSource;