@bamboocss/vite 1.15.0 → 1.16.1

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
@@ -199,8 +199,11 @@ const accountsForSource = (node, boxNode) => {
199
199
  if (!_bamboocss_extractor.box.isMap(boxNode)) return false;
200
200
  for (const property of unwrapped.getProperties()) {
201
201
  if (ts_morph.Node.isSpreadAssignment(property)) {
202
- const expression = property.getExpression();
203
- if (!ts_morph.Node.isObjectLiteralExpression(expression)) return false;
202
+ const expression = unwrapExpression(property.getExpression());
203
+ if (ts_morph.Node.isObjectLiteralExpression(expression)) continue;
204
+ const walked = boxNode.resolvedSpreads?.find((entry) => entry.node === expression);
205
+ if (!walked) return false;
206
+ if (!accountsForSource(walked.box.getNode(), walked.box)) return false;
204
207
  continue;
205
208
  }
206
209
  if (ts_morph.Node.isMethodDeclaration(property) || ts_morph.Node.isGetAccessorDeclaration(property) || ts_morph.Node.isSetAccessorDeclaration(property)) return false;
@@ -437,7 +440,6 @@ const LEAF_SENTINEL = "bamboo0leaf0sentinel0";
437
440
  * carry the same path or the declined shape resolves without its condition.
438
441
  */
439
442
  const leafPrefix = (key, ctx, runtimeCss) => {
440
- if (ctx.isTemplateLiteralSyntax) return void 0;
441
443
  if (ctx.conditions.isCondition(key)) return void 0;
442
444
  let resolved;
443
445
  try {
@@ -477,6 +479,9 @@ const planPartialFold = (argument, boxNode, styles, deps) => {
477
479
  if (!partition) return void 0;
478
480
  const className = deps.runtimeCss(partition.staticStyles);
479
481
  if (!className && !partition.finite.length) return void 0;
482
+ if (deps.ctx.config.cssMode === "grouped") {
483
+ if ((className ? 1 : 0) + partition.finite.length + (partition.dynamicText.length ? 1 : 0) > 1) return void 0;
484
+ }
480
485
  return {
481
486
  className,
482
487
  dynamicText: partition.dynamicText.length ? `{ ${partition.dynamicText.join(", ")} }` : void 0,
@@ -666,464 +671,6 @@ const ensureCxImport = (call, calleeRoot, isBambooCssModule, isGeneratedCssModul
666
671
  }
667
672
  };
668
673
  };
669
- /**
670
- * The `css` and `cx` bindings a partially folded JSX element needs.
671
- *
672
- * Splitting an element sends its dynamic style props to a `css()` call, so unlike the
673
- * call-site split this needs *two* bindings rather than one. Both are taken from an
674
- * existing bamboo `css` import: writing a new import declaration would mean guessing a
675
- * module specifier, and the spelling varies with `importMap`, path aliases and how the
676
- * project reaches its outdir. An element in a file that does not already import `css` is
677
- * left alone instead.
678
- */
679
- const resolveCssHelpers = (node, isBambooCssModule, isGeneratedCssModule, isShadowed, wantLeaf = false) => {
680
- const sourceFile = node.getSourceFile();
681
- if (!importsAnything(sourceFile, isBambooCssModule)) return void 0;
682
- for (const declaration of sourceFile.getImportDeclarations()) {
683
- const mod = declaration.getModuleSpecifierValue();
684
- if (declaration.isTypeOnly() || !isBambooCssModule(mod)) continue;
685
- const named = declaration.getNamedImports();
686
- const cssImport = named.find((entry) => entry.getNameNode().getText() === "css" && !entry.isTypeOnly());
687
- if (!cssImport) continue;
688
- const cssName = (cssImport.getAliasNode() ?? cssImport.getNameNode()).getText();
689
- if (isShadowed(node, cssName)) return void 0;
690
- const wanted = wantLeaf ? ["cx", LEAF_HELPER] : ["cx"];
691
- const resolved = {};
692
- const missing = [];
693
- for (const want of wanted) {
694
- const existing = named.find((entry) => entry.getNameNode().getText() === want && !entry.isTypeOnly());
695
- if (existing) {
696
- const local = (existing.getAliasNode() ?? existing.getNameNode()).getText();
697
- if (isShadowed(node, local)) return void 0;
698
- resolved[want] = local;
699
- continue;
700
- }
701
- missing.push(want);
702
- }
703
- if (!missing.length) return {
704
- css: cssName,
705
- cx: resolved.cx,
706
- leaf: resolved[LEAF_HELPER]
707
- };
708
- if (!isGeneratedCssModule(mod)) return void 0;
709
- const declared = declaredAtModuleScope(sourceFile);
710
- for (const name of missing) {
711
- if (isShadowed(node, name) || declared.has(name)) return void 0;
712
- resolved[name] = name;
713
- }
714
- const last = named.at(-1);
715
- if (!last) return void 0;
716
- return {
717
- css: cssName,
718
- cx: resolved.cx,
719
- leaf: resolved[LEAF_HELPER],
720
- insert: {
721
- pos: last.getEnd(),
722
- names: missing
723
- }
724
- };
725
- }
726
- };
727
- //#endregion
728
- //#region src/fold-jsx.ts
729
- const RANK = {
730
- constant: 0,
731
- reads: 1,
732
- unknown: 2
733
- };
734
- const worst = (a, b) => RANK[a] >= RANK[b] ? a : b;
735
- const purityOf = (node) => {
736
- if (ts_morph.Node.isIdentifier(node)) return "reads";
737
- if (ts_morph.Node.isStringLiteral(node) || ts_morph.Node.isNumericLiteral(node) || ts_morph.Node.isNoSubstitutionTemplateLiteral(node) || ts_morph.Node.isTrueLiteral(node) || ts_morph.Node.isFalseLiteral(node) || node.getKind() === ts_morph.SyntaxKind.NullKeyword) return "constant";
738
- if (ts_morph.Node.isPrefixUnaryExpression(node) && ts_morph.Node.isNumericLiteral(node.getOperand())) return "constant";
739
- if (ts_morph.Node.isObjectLiteralExpression(node)) return node.getProperties().reduce((acc, property) => {
740
- if (!ts_morph.Node.isPropertyAssignment(property)) return "unknown";
741
- if (ts_morph.Node.isComputedPropertyName(property.getNameNode())) return "unknown";
742
- const value = property.getInitializer();
743
- return worst(acc, value ? purityOf(value) : "unknown");
744
- }, "constant");
745
- if (ts_morph.Node.isArrayLiteralExpression(node)) return node.getElements().reduce((acc, element) => worst(acc, purityOf(element)), "constant");
746
- return "unknown";
747
- };
748
- /**
749
- * The same question for a whole attribute.
750
- *
751
- * A `JsxElement` or `JsxFragment` initializer — `title=<Tag x={f()} />` — is legal, holds
752
- * arbitrary expressions, and is not a `JsxExpression`, so this asks what an initializer
753
- * *is* rather than listing the kinds that carry code.
754
- */
755
- const attributePurity = (attribute) => {
756
- const initializer = attribute.getInitializer();
757
- if (!initializer) return "constant";
758
- if (ts_morph.Node.isStringLiteral(initializer)) return "constant";
759
- if (!ts_morph.Node.isJsxExpression(initializer)) return "unknown";
760
- const expression = initializer.getExpression();
761
- return expression ? purityOf(expression) : "constant";
762
- };
763
- /**
764
- * Props the factory gives extra *styling* meaning to, which is why they block a fold.
765
- *
766
- * `unstyled` skips the recipe and `css` merges at a higher precedence than the style
767
- * props, so neither is expressible as a class the fold can compute alongside the rest.
768
- *
769
- * `ref`, `key` and `children` used to be here too, and do not belong: none of them
770
- * changes what the element is styled with. The factory takes `ref` through `forwardRef`
771
- * and hands it straight to `createElement`, so an intrinsic tag — or whatever `as` names
772
- * — receives the identical prop. `key` never reaches the component at all; React consumes
773
- * it. And `children ?? combinedProps.children` mirrors `createElement`'s own rule that
774
- * the third argument beats `props.children`. Each is a passthrough, and travels as one.
775
- */
776
- const RESERVED_PROPS = new Set(["unstyled", "css"]);
777
- /**
778
- * Props that carry no styling, and so block a fold only where the framework gives them a
779
- * meaning the rewrite would change.
780
- */
781
- const MECHANICAL_PROPS = new Set([
782
- "ref",
783
- "key",
784
- "children"
785
- ]);
786
- /**
787
- * Where a mechanical prop may travel as an ordinary passthrough.
788
- *
789
- * React only, and measured rather than reasoned about. Its factory forwards the ref to
790
- * the element it renders, so moving the ref onto that element changes nothing.
791
- *
792
- * Preact was in this list on the strength of `forwardRef` appearing in its factory too,
793
- * and that inference was wrong: under this repo's compat setup an unfolded
794
- * `<styled.div ref={r}>` binds the *component instance* and the folded `<div ref={r}>`
795
- * binds the DOM node. A hand-written `forwardRef` behaves the same way, so it is Preact's
796
- * own handling rather than anything the factory does. Vue diverges for the plain reason —
797
- * a ref on a component is the instance, on an element it is the node.
798
- *
799
- * An allowlist rather than a denylist, because the failure is silent and the list of
800
- * runtimes is open. Every other framework keeps the behaviour it had before.
801
- */
802
- const MECHANICAL_FRAMEWORKS = new Set(["react"]);
803
- /**
804
- * Is this a tag JSX reads as an intrinsic element?
805
- *
806
- * Anchored and dot-free, because both halves matter. `Section` is a variable reference,
807
- * and `foo.bar` is a member expression — a property read off something in scope — where
808
- * the runtime would have created an element named literally that.
809
- */
810
- const isIntrinsicTag = (tag) => /^[a-z][\w-]*$/.test(tag);
811
- /**
812
- * The tag an `as` prop names, when it names one statically.
813
- *
814
- * The factory destructures `{ as: Element = __base__ }` and hands `Element` to
815
- * `createElement`, so a static `as` is simply a different tag with the same class and
816
- * the same forwarded props — `splitProps` keys off the factory's own config, not off
817
- * what `as` points at, so the split is unchanged.
818
- *
819
- * Casing is load-bearing, because JSX and `createElement` disagree about it. JSX reads a
820
- * lowercase tag as an intrinsic element and a capitalised one as a variable, while
821
- * `createElement` takes a string as intrinsic and anything else as a component. So the
822
- * two forms only survive the rewrite when their casing already agrees:
823
- *
824
- * - `as="section"` -> `<section>`, intrinsic both ways.
825
- * - `as={Link}` -> `<Link>`, a component reference both ways.
826
- *
827
- * The mismatched pair render something else entirely. `as={thing}` would fold to
828
- * `<thing>`, a DOM element named `thing` rather than the component; `as="Section"` would
829
- * fold to `<Section>`, a variable reference rather than the intrinsic the factory would
830
- * have created. Both bail.
831
- *
832
- * A dot is the same hazard spelled differently, and it survives lowercasing. `<foo.bar>`
833
- * is a JSX member expression — `createElement(foo.bar)`, a property read off a variable
834
- * in scope — where the factory would have created an intrinsic element named literally
835
- * `foo.bar`. So a dotted value bails even though its casing agrees.
836
- */
837
- const asTag = (attribute) => {
838
- const initializer = attribute.getInitializer();
839
- if (!initializer) return void 0;
840
- if (ts_morph.Node.isStringLiteral(initializer)) {
841
- const value = initializer.getLiteralValue();
842
- return /^[a-z][\w-]*$/.test(value) ? value : void 0;
843
- }
844
- if (!ts_morph.Node.isJsxExpression(initializer)) return void 0;
845
- const expression = initializer.getExpression();
846
- if (!expression || !ts_morph.Node.isIdentifier(expression)) return void 0;
847
- const name = expression.getText();
848
- return /^[A-Z]/.test(name) ? name : void 0;
849
- };
850
- /**
851
- * `normalizeHTMLProps` renames these on the way to the DOM (`htmlSize` -> `size`).
852
- * Reproducing the rename is easy; noticing that it exists is the hard part, so they bail.
853
- */
854
- const HTML_PROPS = new Set([
855
- "htmlSize",
856
- "htmlTranslate",
857
- "htmlWidth",
858
- "htmlHeight"
859
- ]);
860
- /**
861
- * The intrinsic tag a factory expression names, if it names one statically.
862
- *
863
- * Only `styled.div` and friends fold. `styled(Component)` and `styled('div')` are call
864
- * expressions whose result is bound elsewhere, and a capitalised tag is a component
865
- * rather than an intrinsic element.
866
- */
867
- const intrinsicTag = (tagName, factoryName) => {
868
- const prefix = `${factoryName}.`;
869
- if (!tagName.startsWith(prefix)) return void 0;
870
- const tag = tagName.slice(prefix.length);
871
- if (!/^[a-z][a-z0-9-]*$/.test(tag)) return void 0;
872
- return tag;
873
- };
874
- /**
875
- * Collapse a pattern element (`<Stack gap="4">`) to the tag it renders.
876
- *
877
- * A pattern component is a second layer on top of the factory: it splits its own props
878
- * out, runs them through the pattern's transform, and hands the result to
879
- * `styled.<jsxElement>`, which then does everything described above. Folding one removes
880
- * both layers.
881
- *
882
- * The class is computed through `patterns.transform`, the same call the encoder makes
883
- * when it decides what css to emit — so a folded pattern class is backed by a rule by
884
- * construction, and the render-parity test is what confirms it also matches the runtime.
885
- *
886
- * Only the default `jsxStyleProps: 'all'` folds. Under `minimal` and `none` the pattern's
887
- * styles reach the factory through the `css` prop instead of being spread, which reverses
888
- * which side wins when a prop is set in both places.
889
- */
890
- const planPatternFold = (item, ctx, runtimeCss) => {
891
- const node = item.box?.getNode?.();
892
- if (!node || !ts_morph.Node.isJsxOpeningElement(node) && !ts_morph.Node.isJsxSelfClosingElement(node)) return { reason: "unsupported-kind" };
893
- if (ctx.jsx.styleProps !== "all") return { reason: "unsupported-kind" };
894
- const jsxName = node.getTagNameNode().getText();
895
- const detail = ctx.patterns.details.find((entry) => entry.jsxName === jsxName);
896
- if (!detail) return { reason: "unsupported-kind" };
897
- const styles = item.data?.[0] ?? {};
898
- const passthrough = [];
899
- let staticClassName = "";
900
- let sawChildrenProp = false;
901
- let tag = detail.config.jsxElement ?? "div";
902
- let tagFromAs = false;
903
- for (const attribute of node.getAttributes()) {
904
- if (!ts_morph.Node.isJsxAttribute(attribute)) return { reason: "dynamic" };
905
- const name = attribute.getNameNode().getText();
906
- if (name === "className") {
907
- const initializer = attribute.getInitializer();
908
- if (!initializer || !ts_morph.Node.isStringLiteral(initializer)) return { reason: "dynamic" };
909
- staticClassName = initializer.getLiteralValue();
910
- continue;
911
- }
912
- if (name === "as") {
913
- const resolved = asTag(attribute);
914
- if (!resolved) return { reason: "dynamic" };
915
- tag = resolved;
916
- tagFromAs = true;
917
- continue;
918
- }
919
- if (RESERVED_PROPS.has(name) || HTML_PROPS.has(name)) return { reason: "dynamic" };
920
- if (MECHANICAL_PROPS.has(name) && !MECHANICAL_FRAMEWORKS.has(ctx.jsx.framework ?? "")) return { reason: "dynamic" };
921
- if (name === "children") sawChildrenProp = true;
922
- if (detail.props.includes(name) || ctx.isValidProperty(name)) {
923
- if (!(name in styles)) return { reason: "dynamic" };
924
- continue;
925
- }
926
- passthrough.push(attribute.getText());
927
- }
928
- if (!tagFromAs && !isIntrinsicTag(tag)) return { reason: "unsupported-kind" };
929
- if (sawChildrenProp && !isIntrinsicTag(tag)) return { reason: "dynamic" };
930
- let resolved;
931
- try {
932
- resolved = runtimeCss(ctx.patterns.transform(detail.baseName, styles));
933
- } catch {
934
- return { reason: "dynamic" };
935
- }
936
- const className = [resolved, staticClassName].filter(Boolean).join(" ");
937
- if (!className) return { reason: "dynamic" };
938
- return buildEdits(node, tag, passthrough, className);
939
- };
940
- const planJsxFold = (item, ctx, runtimeCss, deps) => {
941
- const node = item.box?.getNode?.();
942
- if (!node || !ts_morph.Node.isJsxOpeningElement(node) && !ts_morph.Node.isJsxSelfClosingElement(node)) return { reason: "unsupported-kind" };
943
- const baseTag = intrinsicTag(node.getTagNameNode().getText(), ctx.jsx.factoryName);
944
- if (!baseTag) return { reason: "unsupported-kind" };
945
- let tag = baseTag;
946
- const styles = item.data?.[0] ?? {};
947
- const propBoxes = _bamboocss_extractor.box.isMap(item.box) ? item.box.value : void 0;
948
- const passthrough = [];
949
- const staticProps = [];
950
- const dynamicProps = [];
951
- let staticClassName = "";
952
- let dynamicClassName = "";
953
- let sawClassName = false;
954
- let sawChildrenProp = false;
955
- /**
956
- * Where a dynamic `className` was written, and where the attributes that outlive the
957
- * fold were.
958
- *
959
- * The factory appends `className` after the styles, so a folded one is emitted last and
960
- * anything written after it runs before it instead. That covers more than the style
961
- * props — a passthrough keeps its own place among the attributes — but only where the
962
- * expression survives at all. A static style prop is not among them: it is *deleted*,
963
- * its value having been resolved at build time. That is a larger change than reordering
964
- * and a separate pre-existing gap — `<styled.div color={counted()} />` folds and never
965
- * calls it, with or without this — rather than a reason the reordering is moot.
966
- */
967
- let classNameIndex = -1;
968
- let classNamePurity = "constant";
969
- const survivors = [];
970
- let index = -1;
971
- for (const attribute of node.getAttributes()) {
972
- if (!ts_morph.Node.isJsxAttribute(attribute)) return { reason: "dynamic" };
973
- index += 1;
974
- const name = attribute.getNameNode().getText();
975
- if (name === "className") {
976
- const initializer = attribute.getInitializer();
977
- if (!initializer) return { reason: "dynamic" };
978
- if (sawClassName && (dynamicClassName || !ts_morph.Node.isStringLiteral(initializer))) return { reason: "dynamic" };
979
- sawClassName = true;
980
- if (ts_morph.Node.isStringLiteral(initializer)) {
981
- staticClassName = initializer.getLiteralValue();
982
- continue;
983
- }
984
- const expression = ts_morph.Node.isJsxExpression(initializer) ? initializer.getExpression() : void 0;
985
- if (!expression) return { reason: "dynamic" };
986
- dynamicClassName = expression.getText();
987
- classNameIndex = index;
988
- classNamePurity = purityOf(expression);
989
- continue;
990
- }
991
- if (name === "as") {
992
- const resolved = asTag(attribute);
993
- if (!resolved) return { reason: "dynamic" };
994
- survivors.push({
995
- index,
996
- purity: attributePurity(attribute)
997
- });
998
- tag = resolved;
999
- continue;
1000
- }
1001
- if (RESERVED_PROPS.has(name) || HTML_PROPS.has(name)) return { reason: "dynamic" };
1002
- if (MECHANICAL_PROPS.has(name) && !MECHANICAL_FRAMEWORKS.has(ctx.jsx.framework ?? "")) return { reason: "dynamic" };
1003
- if (name === "children") sawChildrenProp = true;
1004
- if (ctx.isValidProperty(name)) {
1005
- const attributeValue = attribute.getInitializer();
1006
- const valueExpression = attributeValue && ts_morph.Node.isJsxExpression(attributeValue) ? attributeValue.getExpression() : void 0;
1007
- const propBox = propBoxes?.get(name);
1008
- if (name in styles && isStaticBox(propBox) && accountsForSource(valueExpression, propBox)) {
1009
- staticProps.push(name);
1010
- continue;
1011
- }
1012
- if (!valueExpression) return { reason: "dynamic" };
1013
- const expression = valueExpression;
1014
- survivors.push({
1015
- index,
1016
- purity: attributePurity(attribute)
1017
- });
1018
- dynamicProps.push({
1019
- name,
1020
- text: expression.getText(),
1021
- expression
1022
- });
1023
- continue;
1024
- }
1025
- survivors.push({
1026
- index,
1027
- purity: attributePurity(attribute)
1028
- });
1029
- passthrough.push(attribute.getText());
1030
- }
1031
- if (sawChildrenProp && !isIntrinsicTag(tag)) return { reason: "dynamic" };
1032
- /**
1033
- * Would emitting the className last move it past something that can observe it?
1034
- *
1035
- * A constant survivor commutes with anything. One that only reads commutes only while
1036
- * the className expression cannot write — `className={cn} onClick={h}` is safe, and
1037
- * `className={assigns()} bg={tone}` is not, because moving the read after the write
1038
- * hands it the other value.
1039
- *
1040
- * This answers for the className and nothing else. `buildEdits` emits
1041
- * `[...passthrough, className={cx(…)}]`, so a passthrough is also hoisted ahead of every
1042
- * dynamic style prop's expression — `<styled.div bg={writes()} data-x={reads} />`
1043
- * reorders those two with no className present at all. That is pre-existing and
1044
- * reproduces on an unchanged tree; folding a dynamic className only makes it reachable
1045
- * for more elements. Closing it means comparing every survivor against everything it
1046
- * crosses rather than against one attribute, which is a different change.
1047
- */
1048
- const reordered = () => classNameIndex >= 0 && survivors.some((entry) => entry.index > classNameIndex && entry.purity !== "constant" && !(entry.purity === "reads" && classNamePurity !== "unknown"));
1049
- if (dynamicProps.length) {
1050
- if (!deps || item.data.length !== 1) return { reason: "dynamic" };
1051
- if (collides(staticProps, dynamicProps.map((prop) => prop.name), ctx)) return { reason: "dynamic" };
1052
- const resolveProp = (name) => ctx.utility.hasShorthand ? ctx.utility.resolveShorthand(name) : name;
1053
- const claimed = /* @__PURE__ */ new Map();
1054
- for (const prop of dynamicProps) claimed.set(resolveProp(prop.name), (claimed.get(resolveProp(prop.name)) ?? 0) + 1);
1055
- const prefixes = /* @__PURE__ */ new Map();
1056
- for (const prop of dynamicProps) {
1057
- if (claimed.get(resolveProp(prop.name)) !== 1) continue;
1058
- if (isWrittenAsCollection(prop.expression)) continue;
1059
- const prefix = leafPrefix(prop.name, ctx, runtimeCss);
1060
- if (prefix !== void 0) prefixes.set(prop.name, prefix);
1061
- }
1062
- const kinds = dynamicProps.map((prop) => prefixes.has(prop.name) ? "l" : "r").join("");
1063
- if (!/^l*r*$/.test(kinds) && !/^r*l*$/.test(kinds)) prefixes.clear();
1064
- if (reordered()) return { reason: "dynamic" };
1065
- const helpers = resolveCssHelpers(node, deps.isBambooCssModule, deps.isGeneratedCssModule, deps.isShadowed, prefixes.size > 0);
1066
- if (!helpers) return { reason: "dynamic" };
1067
- if (!helpers.leaf) prefixes.clear();
1068
- const staticStyles = {};
1069
- for (const name of staticProps) staticStyles[name] = styles[name];
1070
- const resolved = [runtimeCss(staticStyles), staticClassName].filter(Boolean).join(" ");
1071
- const lowered = dynamicProps.filter((prop) => prefixes.has(prop.name)).map((prop) => leafCall(prefixes.get(prop.name), prop.name, prop.text, helpers.leaf));
1072
- const residue = dynamicProps.filter((prop) => !prefixes.has(prop.name));
1073
- const runtime = residue.length ? [`${helpers.css}({ ${residue.map((prop) => `${prop.name}: ${prop.text}`).join(", ")} })`] : [];
1074
- if (!resolved && !lowered.length) return { reason: "dynamic" };
1075
- const ordered = kinds.startsWith("r") ? [...runtime, ...lowered] : [...lowered, ...runtime];
1076
- const parts = dynamicClassName ? [...ordered, dynamicClassName] : ordered;
1077
- const plan = buildEdits(node, tag, passthrough, resolved, `${helpers.cx}(${[...resolved ? [JSON.stringify(resolved)] : [], ...parts].join(", ")})`);
1078
- return "reason" in plan ? plan : {
1079
- ...plan,
1080
- insert: helpers.insert
1081
- };
1082
- }
1083
- if (item.data.length !== 1) return { reason: "dynamic" };
1084
- const className = [runtimeCss(styles), staticClassName].filter(Boolean).join(" ");
1085
- if (dynamicClassName) {
1086
- if (reordered()) return { reason: "dynamic" };
1087
- const helpers = deps && resolveCssHelpers(node, deps.isBambooCssModule, deps.isGeneratedCssModule, deps.isShadowed);
1088
- if (!helpers) return { reason: "dynamic" };
1089
- const args = [...className ? [JSON.stringify(className)] : [], dynamicClassName];
1090
- const plan = buildEdits(node, tag, passthrough, className, `${helpers.cx}(${args.join(", ")})`);
1091
- return "reason" in plan ? plan : {
1092
- ...plan,
1093
- insert: helpers.insert
1094
- };
1095
- }
1096
- if (!className) return { reason: "dynamic" };
1097
- return buildEdits(node, tag, passthrough, className);
1098
- };
1099
- /** Rewrite the opening element, and the closing one when there is a pair. */
1100
- const buildEdits = (node, tag, passthrough, className, classExpression) => {
1101
- const attributes = [...passthrough, `className={${classExpression ?? JSON.stringify(className)}}`].join(" ");
1102
- const selfClosing = ts_morph.Node.isJsxSelfClosingElement(node);
1103
- const edits = [{
1104
- start: node.getStart(),
1105
- end: node.getEnd(),
1106
- text: `<${tag} ${attributes}${selfClosing ? " />" : ">"}`
1107
- }];
1108
- let end = node.getEnd();
1109
- if (!selfClosing) {
1110
- const parent = node.getParent();
1111
- if (!ts_morph.Node.isJsxElement(parent)) return { reason: "unsupported-kind" };
1112
- const closing = parent.getClosingElement();
1113
- edits.push({
1114
- start: closing.getStart(),
1115
- end: closing.getEnd(),
1116
- text: `</${tag}>`
1117
- });
1118
- end = closing.getEnd();
1119
- }
1120
- return {
1121
- edits,
1122
- className,
1123
- start: node.getStart(),
1124
- end
1125
- };
1126
- };
1127
674
  //#endregion
1128
675
  //#region src/runtime-css.ts
1129
676
  /** The shape `createCss` and `createMergeCss` both take, derived from a resolved context. */
@@ -1149,9 +696,30 @@ const createRuntimeCss = (ctx) => {
1149
696
  const { mergeCss } = (0, _bamboocss_shared.createMergeCss)(cssContext);
1150
697
  return (...styles) => cssFn(mergeCss(...styles));
1151
698
  };
1152
- const createRuntimeRecipe = (ctx, runtimeCss) => {
699
+ /**
700
+ * The map is every token in the project, so it is built once per context and shared by
701
+ * every module in the build — not once per `foldSource`, which would price a whole token
702
+ * table into each of the overwhelming majority of modules that call `token()` zero times.
703
+ * Keyed weakly so a context that goes out of scope takes its table with it.
704
+ */
705
+ const tokenValues = /* @__PURE__ */ new WeakMap();
706
+ const tokenValuesFor = (ctx) => {
707
+ let values = tokenValues.get(ctx);
708
+ if (values) return values;
709
+ values = /* @__PURE__ */ new Map();
710
+ for (const token of ctx.tokens.allTokens) {
711
+ const { varRef, isVirtual, condition } = token.extensions;
712
+ values.set(token.name, isVirtual || condition !== "base" ? varRef : token.value);
713
+ }
714
+ tokenValues.set(ctx, values);
715
+ return values;
716
+ };
717
+ const createRuntimeToken = (ctx) => (path) => {
718
+ const value = tokenValuesFor(ctx).get(path);
719
+ return typeof value === "string" ? value : void 0;
720
+ };
721
+ const createRuntimeRecipe = (ctx) => {
1153
722
  const separator = ctx.utility.separator;
1154
- const { mergeCss } = (0, _bamboocss_shared.createMergeCss)(createCssContext(ctx));
1155
723
  return (name, variants) => {
1156
724
  const config = ctx.recipes.getConfig(name);
1157
725
  const node = ctx.recipes.getRecipe(name);
@@ -1183,40 +751,28 @@ const createRuntimeRecipe = (ctx, runtimeCss) => {
1183
751
  ...(0, _bamboocss_shared.compact)(variants)
1184
752
  };
1185
753
  if (compoundVariants.length > 0 && Object.keys(recipeStyles).some((prop) => typeof variants[prop] === "object")) return;
1186
- const compoundStyles = getCompoundVariantCss(compoundVariants, recipeStyles, mergeCss);
1187
- return [recipeCss(recipeStyles), runtimeCss(compoundStyles)].filter(Boolean).join(" ");
754
+ return recipeCss(recipeStyles);
1188
755
  };
1189
756
  };
1190
- /**
1191
- * Mirrors the function of the same name in the generated `cva` artifact, down to the
1192
- * `mergeCss` it accumulates with.
1193
- *
1194
- * That merge has to be the deep one. More than one compound variant can match a single
1195
- * selection, and their `css` objects then combine rather than replace: `_hover` set by
1196
- * one and `_hover` set by another have to end up as a single condition holding both
1197
- * declarations. `Object.assign` drops everything the earlier match contributed under a
1198
- * shared key, which produces a shorter class list and no error at all.
1199
- *
1200
- * Taking `mergeCss` as an argument rather than importing one keeps it the same instance
1201
- * the rest of the fold resolves through, built from the same context.
1202
- */
1203
- const getCompoundVariantCss = (compoundVariants, variantMap, mergeCss) => {
1204
- let result = {};
1205
- for (const compoundVariant of compoundVariants) {
1206
- if (!compoundVariant) continue;
1207
- if (Object.entries(compoundVariant).every(([key, value]) => {
1208
- if (key === "css") return true;
1209
- return (Array.isArray(value) ? value : [value]).some((entry) => variantMap[key] === entry);
1210
- })) result = mergeCss(result, compoundVariant.css);
1211
- }
1212
- return result;
1213
- };
1214
757
  //#endregion
1215
758
  //#region src/fold.ts
1216
759
  /**
1217
- * `cva`/`sva` return a function and `token` returns a value, so none of them can
1218
- * collapse to a class string. Their *invocations* could, but those are separate call
1219
- * sites the parser does not record as such.
760
+ * `cva`/`sva` return a function, so neither can collapse to a class string. Their
761
+ * *invocations* could, but those are separate call sites the parser does not record as
762
+ * such. `token` also resolves to no class, but it does resolve to a literal, so it folds
763
+ * through its own path rather than being declined outright.
764
+ *
765
+ * Folding an invocation is now *possible* in a way it was not: a recipe's classes are named
766
+ * semantically, so the build knows every class a call can produce from the config alone.
767
+ * What is missing is upstream — the parser matches calls by imported name, so a local
768
+ * `button()` from `const button = cva(...)` is never recorded, and tracking those bindings
769
+ * is a change to the extractor rather than to this set.
770
+ *
771
+ * Worth knowing before taking that on: semantic naming already took most of the prize.
772
+ * `cvaFn` used to run `mergeCss` and name a class per property on every call; it is now a
773
+ * memoized loop over `variantKeys` doing string concatenation. That is an inspection of the
774
+ * two implementations, not a measurement — benchmark it before deciding it is worth the
775
+ * extractor work.
1220
776
  */
1221
777
  const FOLDABLE_TYPES = new Set([
1222
778
  "css",
@@ -1236,13 +792,16 @@ const literalsIn = (expression) => [...expression.matchAll(/"((?:[^"\\]|\\.)*)"/
1236
792
  * phase — hence separate from `unsupported-kind`, where a slot recipe lands because it
1237
793
  * resolves to one class per slot rather than to a single string.
1238
794
  */
1239
- const UNFOLDABLE_TYPES = new Set([
1240
- "cva",
1241
- "sva",
1242
- "token"
1243
- ]);
1244
- /** Element surfaces `foldJsx` handles, as opposed to call sites. */
1245
- const JSX_TYPES = new Set(["jsx-factory", "jsx-pattern"]);
795
+ const UNFOLDABLE_TYPES = new Set(["cva", "sva"]);
796
+ /**
797
+ * An argument that cannot run anything when it is evaluated.
798
+ *
799
+ * `token(path, fallback)` evaluates both arguments before the call, so a fold that drops
800
+ * the fallback also drops whatever evaluating it would have done. `token('x', compute())`
801
+ * is pathological, but the fold's contract is behaviour preservation and a literal is the
802
+ * cheap way to prove it: no call, no property read, no getter.
803
+ */
804
+ const isInertArgument = (node) => ts_morph.Node.isStringLiteral(node) || ts_morph.Node.isNumericLiteral(node) || ts_morph.Node.isNoSubstitutionTemplateLiteral(node) || node.getKind() === ts_morph.SyntaxKind.TrueKeyword || node.getKind() === ts_morph.SyntaxKind.FalseKeyword || node.getKind() === ts_morph.SyntaxKind.NullKeyword || ts_morph.Node.isIdentifier(node) && node.getText() === "undefined";
1246
805
  /**
1247
806
  * Source files a box tree reaches, other than the one being folded.
1248
807
  *
@@ -1316,17 +875,6 @@ const calleeRootName = (call) => {
1316
875
  return ts_morph.Node.isIdentifier(current) ? current.getText() : void 0;
1317
876
  };
1318
877
  /**
1319
- * The identifier a JSX tag is rooted at: `styled` for `<styled.div>`, `bamboo` for
1320
- * `<bamboo.styled.div>`. The same question `calleeRootName` answers for a call, and it
1321
- * feeds the same import and shadowing checks.
1322
- */
1323
- const tagRootName = (element) => {
1324
- if (!ts_morph.Node.isJsxOpeningElement(element) && !ts_morph.Node.isJsxSelfClosingElement(element)) return void 0;
1325
- let current = element.getTagNameNode();
1326
- while (ts_morph.Node.isPropertyAccessExpression(current)) current = current.getExpression();
1327
- return ts_morph.Node.isIdentifier(current) ? current.getText() : void 0;
1328
- };
1329
- /**
1330
878
  * Local names a module binds to an import of bamboo's own generated system.
1331
879
  *
1332
880
  * The parser matches by name and asks neither question this does — deliberately, since
@@ -1428,7 +976,7 @@ const argumentsAccountedFor = (call, boxNode) => {
1428
976
  return accountsForSource(args[0], boxNode);
1429
977
  };
1430
978
  const foldSource = (options) => {
1431
- const { ctx, code, parserResult, jsx = true, partial: partial_ = true, runtimeCss = createRuntimeCss(ctx) } = options;
979
+ const { ctx, code, parserResult, partial: partial_ = true, runtimeCss = createRuntimeCss(ctx) } = options;
1432
980
  /**
1433
981
  * Recover the static half of a call the whole-call path gave up on. Only a
1434
982
  * single-argument `css()` qualifies: a pattern or recipe call takes props rather than a
@@ -1483,7 +1031,8 @@ const foldSource = (options) => {
1483
1031
  insert: cx.insert
1484
1032
  };
1485
1033
  };
1486
- const runtimeRecipe = createRuntimeRecipe(ctx, runtimeCss);
1034
+ const runtimeRecipe = createRuntimeRecipe(ctx);
1035
+ const runtimeToken = createRuntimeToken(ctx);
1487
1036
  /**
1488
1037
  * Does this specifier name a module that exports the css API, exactly?
1489
1038
  *
@@ -1525,11 +1074,6 @@ const foldSource = (options) => {
1525
1074
  };
1526
1075
  const isBambooCssModule = (mod) => matchesModule(mod, cssModules);
1527
1076
  const isGeneratedCssModule = (mod) => matchesModule(mod, [generatedCssModule]);
1528
- const jsxDeps = {
1529
- isBambooCssModule,
1530
- isGeneratedCssModule,
1531
- isShadowed
1532
- };
1533
1077
  const folded = [];
1534
1078
  const skipped = [];
1535
1079
  const candidates = [];
@@ -1548,12 +1092,8 @@ const foldSource = (options) => {
1548
1092
  const name = item.name ?? type;
1549
1093
  if (!item.box) continue;
1550
1094
  const call = findCallExpression(item.box);
1551
- if (jsx && JSX_TYPES.has(type)) {
1552
- const element = item.box.getNode?.();
1553
- if (!element) continue;
1554
- const elementStart = element.getStart();
1555
- const elementEnd = element.getEnd();
1556
- if (code.slice(elementStart, elementEnd) !== element.getText()) {
1095
+ if (type === "token") {
1096
+ if (!call) {
1557
1097
  skipped.push({
1558
1098
  name,
1559
1099
  reason: "no-call-expression",
@@ -1562,34 +1102,85 @@ const foldSource = (options) => {
1562
1102
  });
1563
1103
  continue;
1564
1104
  }
1565
- const rootName = tagRootName(element);
1566
- if (!rootName || !importsFor(element.getSourceFile()).has(rootName) || isShadowed(element, rootName)) {
1105
+ const start = call.getStart();
1106
+ const end = call.getEnd();
1107
+ if (code.slice(start, end) !== call.getText()) {
1108
+ skipped.push({
1109
+ name,
1110
+ reason: "no-call-expression",
1111
+ start: 0,
1112
+ end: 0
1113
+ });
1114
+ continue;
1115
+ }
1116
+ const rangeKey = `${start}:${end}`;
1117
+ if (seenRanges.has(rangeKey)) continue;
1118
+ seenRanges.add(rangeKey);
1119
+ const rootName = calleeRootName(call);
1120
+ if (!rootName || !importsFor(call.getSourceFile()).has(rootName) || isShadowed(call, rootName)) {
1567
1121
  skipped.push({
1568
1122
  name,
1569
1123
  reason: "not-imported",
1570
- start: elementStart,
1571
- end: elementEnd
1124
+ start,
1125
+ end
1126
+ });
1127
+ continue;
1128
+ }
1129
+ const callee = ts_morph.Node.isCallExpression(call) ? call.getExpression() : void 0;
1130
+ if (ts_morph.Node.isPropertyAccessExpression(callee) && !ctx.imports.matchers.tokens.match(callee.getNameNode().getText())) {
1131
+ skipped.push({
1132
+ name,
1133
+ reason: "unsupported-kind",
1134
+ start,
1135
+ end
1572
1136
  });
1573
1137
  continue;
1574
1138
  }
1575
- const plan = type === "jsx-pattern" ? planPatternFold(item, ctx, runtimeCss) : planJsxFold(item, ctx, runtimeCss, partial_ ? jsxDeps : void 0);
1576
- if ("reason" in plan) {
1139
+ if (!isStaticBox(item.box) || item.data.length !== 1) {
1577
1140
  skipped.push({
1578
1141
  name,
1579
- reason: plan.reason,
1580
- start: elementStart,
1581
- end: elementEnd
1142
+ reason: "dynamic",
1143
+ start,
1144
+ end
1145
+ });
1146
+ continue;
1147
+ }
1148
+ const path = item.data[0];
1149
+ if (typeof path !== "string") {
1150
+ skipped.push({
1151
+ name,
1152
+ reason: "dynamic",
1153
+ start,
1154
+ end
1155
+ });
1156
+ continue;
1157
+ }
1158
+ if (!(ts_morph.Node.isCallExpression(call) ? call.getArguments().slice(1) : []).every(isInertArgument)) {
1159
+ skipped.push({
1160
+ name,
1161
+ reason: "dynamic",
1162
+ start,
1163
+ end
1164
+ });
1165
+ continue;
1166
+ }
1167
+ const value = runtimeToken(path);
1168
+ if (!value) {
1169
+ skipped.push({
1170
+ name,
1171
+ reason: "unresolved-token",
1172
+ start,
1173
+ end
1582
1174
  });
1583
1175
  continue;
1584
1176
  }
1585
1177
  candidates.push({
1586
1178
  item,
1587
- node: element,
1588
- edits: plan.edits,
1589
- className: plan.className,
1590
- insert: plan.insert,
1591
- start: plan.start,
1592
- end: plan.end
1179
+ call,
1180
+ node: call,
1181
+ start,
1182
+ end,
1183
+ value
1593
1184
  });
1594
1185
  continue;
1595
1186
  }
@@ -1696,7 +1287,7 @@ const foldSource = (options) => {
1696
1287
  for (const candidate of candidates) {
1697
1288
  const { item, start, end } = candidate;
1698
1289
  const name = item.name ?? item.type ?? "";
1699
- const ranges = candidate.edits ? candidate.edits.map((edit) => [edit.start, edit.end]) : [[start, end]];
1290
+ const ranges = [[start, end]];
1700
1291
  if (collides(ranges)) {
1701
1292
  skipped.push({
1702
1293
  name,
@@ -1706,26 +1297,28 @@ const foldSource = (options) => {
1706
1297
  });
1707
1298
  continue;
1708
1299
  }
1709
- if (candidate.replacement) {
1710
- magic.overwrite(start, end, candidate.replacement);
1711
- applyInsert(candidate.insert);
1300
+ if (candidate.value !== void 0) {
1301
+ magic.overwrite(start, end, JSON.stringify(candidate.value));
1712
1302
  applied.push(...ranges);
1713
1303
  folded.push({
1714
1304
  name,
1715
- className: candidate.className,
1716
- classNames: (candidate.classNames ?? [candidate.className]).filter(Boolean),
1305
+ kind: "value",
1306
+ className: "",
1307
+ classNames: [],
1308
+ value: candidate.value,
1717
1309
  start,
1718
1310
  end
1719
1311
  });
1720
1312
  collectSourceFiles(item.box, dependencyScan);
1721
1313
  continue;
1722
1314
  }
1723
- if (candidate.edits) {
1724
- for (const edit of candidate.edits) magic.overwrite(edit.start, edit.end, edit.text);
1315
+ if (candidate.replacement) {
1316
+ magic.overwrite(start, end, candidate.replacement);
1725
1317
  applyInsert(candidate.insert);
1726
1318
  applied.push(...ranges);
1727
1319
  folded.push({
1728
1320
  name,
1321
+ kind: "class",
1729
1322
  className: candidate.className,
1730
1323
  classNames: (candidate.classNames ?? [candidate.className]).filter(Boolean),
1731
1324
  start,
@@ -1772,6 +1365,7 @@ const foldSource = (options) => {
1772
1365
  applied.push(...ranges);
1773
1366
  folded.push({
1774
1367
  name,
1368
+ kind: "class",
1775
1369
  className,
1776
1370
  classNames: [className],
1777
1371
  start,
@@ -1846,7 +1440,7 @@ const formatSkipped = (id, skipped) => {
1846
1440
  * with no matching rule.
1847
1441
  */
1848
1442
  const bamboocss = (options = {}) => {
1849
- const { transform = false, jsx, partial, configPath, cwd, reportSkipped = false, reportSummary = true } = options;
1443
+ const { transform = false, partial, configPath, cwd, reportSkipped = false, reportSummary = true } = options;
1850
1444
  /** Totals across the build, for the summary. */
1851
1445
  const totals = {
1852
1446
  folded: 0,
@@ -1879,6 +1473,37 @@ const bamboocss = (options = {}) => {
1879
1473
  totals.skipped.clear();
1880
1474
  await ensureContext();
1881
1475
  },
1476
+ /**
1477
+ * Take a changed module out of the parser's hands before the rebuild reads it.
1478
+ *
1479
+ * `addWatchFile` below registers the modules a fold read, so editing one re-transforms
1480
+ * its consumers. That is only half of it. The consumer is transformed *before* the
1481
+ * module it imports — that is how a bundler discovers imports at all — so by the time
1482
+ * the changed module's own `transform` refreshes it in the ts-morph project, the fold
1483
+ * that reads it has already run against the previous contents and baked a stale class
1484
+ * into the bundle. Rollup calls this hook before any of that, which is the only point
1485
+ * where refreshing is early enough.
1486
+ *
1487
+ * Both entry points clear the box-node cache, which is the part that matters: a
1488
+ * resolution memoized against the old contents outlives the file itself.
1489
+ *
1490
+ * A created file is handled as an edit. `reloadSourceFile` cannot re-read one the
1491
+ * parser has never held, and does not need to — it clears the cache, and the extractor
1492
+ * adds a newly-reachable module from disk on next use. What the shared path *is* needed
1493
+ * for is an editor's atomic save, which arrives as a delete followed by a create while
1494
+ * the parser still holds the file.
1495
+ */
1496
+ watchChange(id, change) {
1497
+ if (!transform || !ctx) return;
1498
+ if (!shouldTransform(id)) return;
1499
+ const [filePath] = id.split("?");
1500
+ if (!filePath) return;
1501
+ if (change.event === "delete") {
1502
+ ctx.project.removeSourceFile(filePath);
1503
+ return;
1504
+ }
1505
+ ctx.project.reloadSourceFile(filePath);
1506
+ },
1882
1507
  async transform(code, id) {
1883
1508
  if (!transform) return null;
1884
1509
  if (!shouldTransform(id)) return null;
@@ -1897,7 +1522,6 @@ const bamboocss = (options = {}) => {
1897
1522
  parserResult,
1898
1523
  filePath,
1899
1524
  runtimeCss,
1900
- jsx,
1901
1525
  partial
1902
1526
  });
1903
1527
  } catch (error) {