@bamboocss/vite 1.14.0 → 1.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -172,8 +172,11 @@ const accountsForSource = (node, boxNode) => {
172
172
  if (!box.isMap(boxNode)) return false;
173
173
  for (const property of unwrapped.getProperties()) {
174
174
  if (Node.isSpreadAssignment(property)) {
175
- const expression = property.getExpression();
176
- if (!Node.isObjectLiteralExpression(expression)) return false;
175
+ const expression = unwrapExpression(property.getExpression());
176
+ if (Node.isObjectLiteralExpression(expression)) continue;
177
+ const walked = boxNode.resolvedSpreads?.find((entry) => entry.node === expression);
178
+ if (!walked) return false;
179
+ if (!accountsForSource(walked.box.getNode(), walked.box)) return false;
177
180
  continue;
178
181
  }
179
182
  if (Node.isMethodDeclaration(property) || Node.isGetAccessorDeclaration(property) || Node.isSetAccessorDeclaration(property)) return false;
@@ -410,7 +413,6 @@ const LEAF_SENTINEL = "bamboo0leaf0sentinel0";
410
413
  * carry the same path or the declined shape resolves without its condition.
411
414
  */
412
415
  const leafPrefix = (key, ctx, runtimeCss) => {
413
- if (ctx.isTemplateLiteralSyntax) return void 0;
414
416
  if (ctx.conditions.isCondition(key)) return void 0;
415
417
  let resolved;
416
418
  try {
@@ -450,6 +452,9 @@ const planPartialFold = (argument, boxNode, styles, deps) => {
450
452
  if (!partition) return void 0;
451
453
  const className = deps.runtimeCss(partition.staticStyles);
452
454
  if (!className && !partition.finite.length) return void 0;
455
+ if (deps.ctx.config.cssMode === "grouped") {
456
+ if ((className ? 1 : 0) + partition.finite.length + (partition.dynamicText.length ? 1 : 0) > 1) return void 0;
457
+ }
453
458
  return {
454
459
  className,
455
460
  dynamicText: partition.dynamicText.length ? `{ ${partition.dynamicText.join(", ")} }` : void 0,
@@ -639,464 +644,6 @@ const ensureCxImport = (call, calleeRoot, isBambooCssModule, isGeneratedCssModul
639
644
  }
640
645
  };
641
646
  };
642
- /**
643
- * The `css` and `cx` bindings a partially folded JSX element needs.
644
- *
645
- * Splitting an element sends its dynamic style props to a `css()` call, so unlike the
646
- * call-site split this needs *two* bindings rather than one. Both are taken from an
647
- * existing bamboo `css` import: writing a new import declaration would mean guessing a
648
- * module specifier, and the spelling varies with `importMap`, path aliases and how the
649
- * project reaches its outdir. An element in a file that does not already import `css` is
650
- * left alone instead.
651
- */
652
- const resolveCssHelpers = (node, isBambooCssModule, isGeneratedCssModule, isShadowed, wantLeaf = false) => {
653
- const sourceFile = node.getSourceFile();
654
- if (!importsAnything(sourceFile, isBambooCssModule)) return void 0;
655
- for (const declaration of sourceFile.getImportDeclarations()) {
656
- const mod = declaration.getModuleSpecifierValue();
657
- if (declaration.isTypeOnly() || !isBambooCssModule(mod)) continue;
658
- const named = declaration.getNamedImports();
659
- const cssImport = named.find((entry) => entry.getNameNode().getText() === "css" && !entry.isTypeOnly());
660
- if (!cssImport) continue;
661
- const cssName = (cssImport.getAliasNode() ?? cssImport.getNameNode()).getText();
662
- if (isShadowed(node, cssName)) return void 0;
663
- const wanted = wantLeaf ? ["cx", LEAF_HELPER] : ["cx"];
664
- const resolved = {};
665
- const missing = [];
666
- for (const want of wanted) {
667
- const existing = named.find((entry) => entry.getNameNode().getText() === want && !entry.isTypeOnly());
668
- if (existing) {
669
- const local = (existing.getAliasNode() ?? existing.getNameNode()).getText();
670
- if (isShadowed(node, local)) return void 0;
671
- resolved[want] = local;
672
- continue;
673
- }
674
- missing.push(want);
675
- }
676
- if (!missing.length) return {
677
- css: cssName,
678
- cx: resolved.cx,
679
- leaf: resolved[LEAF_HELPER]
680
- };
681
- if (!isGeneratedCssModule(mod)) return void 0;
682
- const declared = declaredAtModuleScope(sourceFile);
683
- for (const name of missing) {
684
- if (isShadowed(node, name) || declared.has(name)) return void 0;
685
- resolved[name] = name;
686
- }
687
- const last = named.at(-1);
688
- if (!last) return void 0;
689
- return {
690
- css: cssName,
691
- cx: resolved.cx,
692
- leaf: resolved[LEAF_HELPER],
693
- insert: {
694
- pos: last.getEnd(),
695
- names: missing
696
- }
697
- };
698
- }
699
- };
700
- //#endregion
701
- //#region src/fold-jsx.ts
702
- const RANK = {
703
- constant: 0,
704
- reads: 1,
705
- unknown: 2
706
- };
707
- const worst = (a, b) => RANK[a] >= RANK[b] ? a : b;
708
- const purityOf = (node) => {
709
- if (Node.isIdentifier(node)) return "reads";
710
- if (Node.isStringLiteral(node) || Node.isNumericLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node) || Node.isTrueLiteral(node) || Node.isFalseLiteral(node) || node.getKind() === SyntaxKind.NullKeyword) return "constant";
711
- if (Node.isPrefixUnaryExpression(node) && Node.isNumericLiteral(node.getOperand())) return "constant";
712
- if (Node.isObjectLiteralExpression(node)) return node.getProperties().reduce((acc, property) => {
713
- if (!Node.isPropertyAssignment(property)) return "unknown";
714
- if (Node.isComputedPropertyName(property.getNameNode())) return "unknown";
715
- const value = property.getInitializer();
716
- return worst(acc, value ? purityOf(value) : "unknown");
717
- }, "constant");
718
- if (Node.isArrayLiteralExpression(node)) return node.getElements().reduce((acc, element) => worst(acc, purityOf(element)), "constant");
719
- return "unknown";
720
- };
721
- /**
722
- * The same question for a whole attribute.
723
- *
724
- * A `JsxElement` or `JsxFragment` initializer — `title=<Tag x={f()} />` — is legal, holds
725
- * arbitrary expressions, and is not a `JsxExpression`, so this asks what an initializer
726
- * *is* rather than listing the kinds that carry code.
727
- */
728
- const attributePurity = (attribute) => {
729
- const initializer = attribute.getInitializer();
730
- if (!initializer) return "constant";
731
- if (Node.isStringLiteral(initializer)) return "constant";
732
- if (!Node.isJsxExpression(initializer)) return "unknown";
733
- const expression = initializer.getExpression();
734
- return expression ? purityOf(expression) : "constant";
735
- };
736
- /**
737
- * Props the factory gives extra *styling* meaning to, which is why they block a fold.
738
- *
739
- * `unstyled` skips the recipe and `css` merges at a higher precedence than the style
740
- * props, so neither is expressible as a class the fold can compute alongside the rest.
741
- *
742
- * `ref`, `key` and `children` used to be here too, and do not belong: none of them
743
- * changes what the element is styled with. The factory takes `ref` through `forwardRef`
744
- * and hands it straight to `createElement`, so an intrinsic tag — or whatever `as` names
745
- * — receives the identical prop. `key` never reaches the component at all; React consumes
746
- * it. And `children ?? combinedProps.children` mirrors `createElement`'s own rule that
747
- * the third argument beats `props.children`. Each is a passthrough, and travels as one.
748
- */
749
- const RESERVED_PROPS = new Set(["unstyled", "css"]);
750
- /**
751
- * Props that carry no styling, and so block a fold only where the framework gives them a
752
- * meaning the rewrite would change.
753
- */
754
- const MECHANICAL_PROPS = new Set([
755
- "ref",
756
- "key",
757
- "children"
758
- ]);
759
- /**
760
- * Where a mechanical prop may travel as an ordinary passthrough.
761
- *
762
- * React only, and measured rather than reasoned about. Its factory forwards the ref to
763
- * the element it renders, so moving the ref onto that element changes nothing.
764
- *
765
- * Preact was in this list on the strength of `forwardRef` appearing in its factory too,
766
- * and that inference was wrong: under this repo's compat setup an unfolded
767
- * `<styled.div ref={r}>` binds the *component instance* and the folded `<div ref={r}>`
768
- * binds the DOM node. A hand-written `forwardRef` behaves the same way, so it is Preact's
769
- * own handling rather than anything the factory does. Vue diverges for the plain reason —
770
- * a ref on a component is the instance, on an element it is the node.
771
- *
772
- * An allowlist rather than a denylist, because the failure is silent and the list of
773
- * runtimes is open. Every other framework keeps the behaviour it had before.
774
- */
775
- const MECHANICAL_FRAMEWORKS = new Set(["react"]);
776
- /**
777
- * Is this a tag JSX reads as an intrinsic element?
778
- *
779
- * Anchored and dot-free, because both halves matter. `Section` is a variable reference,
780
- * and `foo.bar` is a member expression — a property read off something in scope — where
781
- * the runtime would have created an element named literally that.
782
- */
783
- const isIntrinsicTag = (tag) => /^[a-z][\w-]*$/.test(tag);
784
- /**
785
- * The tag an `as` prop names, when it names one statically.
786
- *
787
- * The factory destructures `{ as: Element = __base__ }` and hands `Element` to
788
- * `createElement`, so a static `as` is simply a different tag with the same class and
789
- * the same forwarded props — `splitProps` keys off the factory's own config, not off
790
- * what `as` points at, so the split is unchanged.
791
- *
792
- * Casing is load-bearing, because JSX and `createElement` disagree about it. JSX reads a
793
- * lowercase tag as an intrinsic element and a capitalised one as a variable, while
794
- * `createElement` takes a string as intrinsic and anything else as a component. So the
795
- * two forms only survive the rewrite when their casing already agrees:
796
- *
797
- * - `as="section"` -> `<section>`, intrinsic both ways.
798
- * - `as={Link}` -> `<Link>`, a component reference both ways.
799
- *
800
- * The mismatched pair render something else entirely. `as={thing}` would fold to
801
- * `<thing>`, a DOM element named `thing` rather than the component; `as="Section"` would
802
- * fold to `<Section>`, a variable reference rather than the intrinsic the factory would
803
- * have created. Both bail.
804
- *
805
- * A dot is the same hazard spelled differently, and it survives lowercasing. `<foo.bar>`
806
- * is a JSX member expression — `createElement(foo.bar)`, a property read off a variable
807
- * in scope — where the factory would have created an intrinsic element named literally
808
- * `foo.bar`. So a dotted value bails even though its casing agrees.
809
- */
810
- const asTag = (attribute) => {
811
- const initializer = attribute.getInitializer();
812
- if (!initializer) return void 0;
813
- if (Node.isStringLiteral(initializer)) {
814
- const value = initializer.getLiteralValue();
815
- return /^[a-z][\w-]*$/.test(value) ? value : void 0;
816
- }
817
- if (!Node.isJsxExpression(initializer)) return void 0;
818
- const expression = initializer.getExpression();
819
- if (!expression || !Node.isIdentifier(expression)) return void 0;
820
- const name = expression.getText();
821
- return /^[A-Z]/.test(name) ? name : void 0;
822
- };
823
- /**
824
- * `normalizeHTMLProps` renames these on the way to the DOM (`htmlSize` -> `size`).
825
- * Reproducing the rename is easy; noticing that it exists is the hard part, so they bail.
826
- */
827
- const HTML_PROPS = new Set([
828
- "htmlSize",
829
- "htmlTranslate",
830
- "htmlWidth",
831
- "htmlHeight"
832
- ]);
833
- /**
834
- * The intrinsic tag a factory expression names, if it names one statically.
835
- *
836
- * Only `styled.div` and friends fold. `styled(Component)` and `styled('div')` are call
837
- * expressions whose result is bound elsewhere, and a capitalised tag is a component
838
- * rather than an intrinsic element.
839
- */
840
- const intrinsicTag = (tagName, factoryName) => {
841
- const prefix = `${factoryName}.`;
842
- if (!tagName.startsWith(prefix)) return void 0;
843
- const tag = tagName.slice(prefix.length);
844
- if (!/^[a-z][a-z0-9-]*$/.test(tag)) return void 0;
845
- return tag;
846
- };
847
- /**
848
- * Collapse a pattern element (`<Stack gap="4">`) to the tag it renders.
849
- *
850
- * A pattern component is a second layer on top of the factory: it splits its own props
851
- * out, runs them through the pattern's transform, and hands the result to
852
- * `styled.<jsxElement>`, which then does everything described above. Folding one removes
853
- * both layers.
854
- *
855
- * The class is computed through `patterns.transform`, the same call the encoder makes
856
- * when it decides what css to emit — so a folded pattern class is backed by a rule by
857
- * construction, and the render-parity test is what confirms it also matches the runtime.
858
- *
859
- * Only the default `jsxStyleProps: 'all'` folds. Under `minimal` and `none` the pattern's
860
- * styles reach the factory through the `css` prop instead of being spread, which reverses
861
- * which side wins when a prop is set in both places.
862
- */
863
- const planPatternFold = (item, ctx, runtimeCss) => {
864
- const node = item.box?.getNode?.();
865
- if (!node || !Node.isJsxOpeningElement(node) && !Node.isJsxSelfClosingElement(node)) return { reason: "unsupported-kind" };
866
- if (ctx.jsx.styleProps !== "all") return { reason: "unsupported-kind" };
867
- const jsxName = node.getTagNameNode().getText();
868
- const detail = ctx.patterns.details.find((entry) => entry.jsxName === jsxName);
869
- if (!detail) return { reason: "unsupported-kind" };
870
- const styles = item.data?.[0] ?? {};
871
- const passthrough = [];
872
- let staticClassName = "";
873
- let sawChildrenProp = false;
874
- let tag = detail.config.jsxElement ?? "div";
875
- let tagFromAs = false;
876
- for (const attribute of node.getAttributes()) {
877
- if (!Node.isJsxAttribute(attribute)) return { reason: "dynamic" };
878
- const name = attribute.getNameNode().getText();
879
- if (name === "className") {
880
- const initializer = attribute.getInitializer();
881
- if (!initializer || !Node.isStringLiteral(initializer)) return { reason: "dynamic" };
882
- staticClassName = initializer.getLiteralValue();
883
- continue;
884
- }
885
- if (name === "as") {
886
- const resolved = asTag(attribute);
887
- if (!resolved) return { reason: "dynamic" };
888
- tag = resolved;
889
- tagFromAs = true;
890
- continue;
891
- }
892
- if (RESERVED_PROPS.has(name) || HTML_PROPS.has(name)) return { reason: "dynamic" };
893
- if (MECHANICAL_PROPS.has(name) && !MECHANICAL_FRAMEWORKS.has(ctx.jsx.framework ?? "")) return { reason: "dynamic" };
894
- if (name === "children") sawChildrenProp = true;
895
- if (detail.props.includes(name) || ctx.isValidProperty(name)) {
896
- if (!(name in styles)) return { reason: "dynamic" };
897
- continue;
898
- }
899
- passthrough.push(attribute.getText());
900
- }
901
- if (!tagFromAs && !isIntrinsicTag(tag)) return { reason: "unsupported-kind" };
902
- if (sawChildrenProp && !isIntrinsicTag(tag)) return { reason: "dynamic" };
903
- let resolved;
904
- try {
905
- resolved = runtimeCss(ctx.patterns.transform(detail.baseName, styles));
906
- } catch {
907
- return { reason: "dynamic" };
908
- }
909
- const className = [resolved, staticClassName].filter(Boolean).join(" ");
910
- if (!className) return { reason: "dynamic" };
911
- return buildEdits(node, tag, passthrough, className);
912
- };
913
- const planJsxFold = (item, ctx, runtimeCss, deps) => {
914
- const node = item.box?.getNode?.();
915
- if (!node || !Node.isJsxOpeningElement(node) && !Node.isJsxSelfClosingElement(node)) return { reason: "unsupported-kind" };
916
- const baseTag = intrinsicTag(node.getTagNameNode().getText(), ctx.jsx.factoryName);
917
- if (!baseTag) return { reason: "unsupported-kind" };
918
- let tag = baseTag;
919
- const styles = item.data?.[0] ?? {};
920
- const propBoxes = box.isMap(item.box) ? item.box.value : void 0;
921
- const passthrough = [];
922
- const staticProps = [];
923
- const dynamicProps = [];
924
- let staticClassName = "";
925
- let dynamicClassName = "";
926
- let sawClassName = false;
927
- let sawChildrenProp = false;
928
- /**
929
- * Where a dynamic `className` was written, and where the attributes that outlive the
930
- * fold were.
931
- *
932
- * The factory appends `className` after the styles, so a folded one is emitted last and
933
- * anything written after it runs before it instead. That covers more than the style
934
- * props — a passthrough keeps its own place among the attributes — but only where the
935
- * expression survives at all. A static style prop is not among them: it is *deleted*,
936
- * its value having been resolved at build time. That is a larger change than reordering
937
- * and a separate pre-existing gap — `<styled.div color={counted()} />` folds and never
938
- * calls it, with or without this — rather than a reason the reordering is moot.
939
- */
940
- let classNameIndex = -1;
941
- let classNamePurity = "constant";
942
- const survivors = [];
943
- let index = -1;
944
- for (const attribute of node.getAttributes()) {
945
- if (!Node.isJsxAttribute(attribute)) return { reason: "dynamic" };
946
- index += 1;
947
- const name = attribute.getNameNode().getText();
948
- if (name === "className") {
949
- const initializer = attribute.getInitializer();
950
- if (!initializer) return { reason: "dynamic" };
951
- if (sawClassName && (dynamicClassName || !Node.isStringLiteral(initializer))) return { reason: "dynamic" };
952
- sawClassName = true;
953
- if (Node.isStringLiteral(initializer)) {
954
- staticClassName = initializer.getLiteralValue();
955
- continue;
956
- }
957
- const expression = Node.isJsxExpression(initializer) ? initializer.getExpression() : void 0;
958
- if (!expression) return { reason: "dynamic" };
959
- dynamicClassName = expression.getText();
960
- classNameIndex = index;
961
- classNamePurity = purityOf(expression);
962
- continue;
963
- }
964
- if (name === "as") {
965
- const resolved = asTag(attribute);
966
- if (!resolved) return { reason: "dynamic" };
967
- survivors.push({
968
- index,
969
- purity: attributePurity(attribute)
970
- });
971
- tag = resolved;
972
- continue;
973
- }
974
- if (RESERVED_PROPS.has(name) || HTML_PROPS.has(name)) return { reason: "dynamic" };
975
- if (MECHANICAL_PROPS.has(name) && !MECHANICAL_FRAMEWORKS.has(ctx.jsx.framework ?? "")) return { reason: "dynamic" };
976
- if (name === "children") sawChildrenProp = true;
977
- if (ctx.isValidProperty(name)) {
978
- const attributeValue = attribute.getInitializer();
979
- const valueExpression = attributeValue && Node.isJsxExpression(attributeValue) ? attributeValue.getExpression() : void 0;
980
- const propBox = propBoxes?.get(name);
981
- if (name in styles && isStaticBox(propBox) && accountsForSource(valueExpression, propBox)) {
982
- staticProps.push(name);
983
- continue;
984
- }
985
- if (!valueExpression) return { reason: "dynamic" };
986
- const expression = valueExpression;
987
- survivors.push({
988
- index,
989
- purity: attributePurity(attribute)
990
- });
991
- dynamicProps.push({
992
- name,
993
- text: expression.getText(),
994
- expression
995
- });
996
- continue;
997
- }
998
- survivors.push({
999
- index,
1000
- purity: attributePurity(attribute)
1001
- });
1002
- passthrough.push(attribute.getText());
1003
- }
1004
- if (sawChildrenProp && !isIntrinsicTag(tag)) return { reason: "dynamic" };
1005
- /**
1006
- * Would emitting the className last move it past something that can observe it?
1007
- *
1008
- * A constant survivor commutes with anything. One that only reads commutes only while
1009
- * the className expression cannot write — `className={cn} onClick={h}` is safe, and
1010
- * `className={assigns()} bg={tone}` is not, because moving the read after the write
1011
- * hands it the other value.
1012
- *
1013
- * This answers for the className and nothing else. `buildEdits` emits
1014
- * `[...passthrough, className={cx(…)}]`, so a passthrough is also hoisted ahead of every
1015
- * dynamic style prop's expression — `<styled.div bg={writes()} data-x={reads} />`
1016
- * reorders those two with no className present at all. That is pre-existing and
1017
- * reproduces on an unchanged tree; folding a dynamic className only makes it reachable
1018
- * for more elements. Closing it means comparing every survivor against everything it
1019
- * crosses rather than against one attribute, which is a different change.
1020
- */
1021
- const reordered = () => classNameIndex >= 0 && survivors.some((entry) => entry.index > classNameIndex && entry.purity !== "constant" && !(entry.purity === "reads" && classNamePurity !== "unknown"));
1022
- if (dynamicProps.length) {
1023
- if (!deps || item.data.length !== 1) return { reason: "dynamic" };
1024
- if (collides(staticProps, dynamicProps.map((prop) => prop.name), ctx)) return { reason: "dynamic" };
1025
- const resolveProp = (name) => ctx.utility.hasShorthand ? ctx.utility.resolveShorthand(name) : name;
1026
- const claimed = /* @__PURE__ */ new Map();
1027
- for (const prop of dynamicProps) claimed.set(resolveProp(prop.name), (claimed.get(resolveProp(prop.name)) ?? 0) + 1);
1028
- const prefixes = /* @__PURE__ */ new Map();
1029
- for (const prop of dynamicProps) {
1030
- if (claimed.get(resolveProp(prop.name)) !== 1) continue;
1031
- if (isWrittenAsCollection(prop.expression)) continue;
1032
- const prefix = leafPrefix(prop.name, ctx, runtimeCss);
1033
- if (prefix !== void 0) prefixes.set(prop.name, prefix);
1034
- }
1035
- const kinds = dynamicProps.map((prop) => prefixes.has(prop.name) ? "l" : "r").join("");
1036
- if (!/^l*r*$/.test(kinds) && !/^r*l*$/.test(kinds)) prefixes.clear();
1037
- if (reordered()) return { reason: "dynamic" };
1038
- const helpers = resolveCssHelpers(node, deps.isBambooCssModule, deps.isGeneratedCssModule, deps.isShadowed, prefixes.size > 0);
1039
- if (!helpers) return { reason: "dynamic" };
1040
- if (!helpers.leaf) prefixes.clear();
1041
- const staticStyles = {};
1042
- for (const name of staticProps) staticStyles[name] = styles[name];
1043
- const resolved = [runtimeCss(staticStyles), staticClassName].filter(Boolean).join(" ");
1044
- const lowered = dynamicProps.filter((prop) => prefixes.has(prop.name)).map((prop) => leafCall(prefixes.get(prop.name), prop.name, prop.text, helpers.leaf));
1045
- const residue = dynamicProps.filter((prop) => !prefixes.has(prop.name));
1046
- const runtime = residue.length ? [`${helpers.css}({ ${residue.map((prop) => `${prop.name}: ${prop.text}`).join(", ")} })`] : [];
1047
- if (!resolved && !lowered.length) return { reason: "dynamic" };
1048
- const ordered = kinds.startsWith("r") ? [...runtime, ...lowered] : [...lowered, ...runtime];
1049
- const parts = dynamicClassName ? [...ordered, dynamicClassName] : ordered;
1050
- const plan = buildEdits(node, tag, passthrough, resolved, `${helpers.cx}(${[...resolved ? [JSON.stringify(resolved)] : [], ...parts].join(", ")})`);
1051
- return "reason" in plan ? plan : {
1052
- ...plan,
1053
- insert: helpers.insert
1054
- };
1055
- }
1056
- if (item.data.length !== 1) return { reason: "dynamic" };
1057
- const className = [runtimeCss(styles), staticClassName].filter(Boolean).join(" ");
1058
- if (dynamicClassName) {
1059
- if (reordered()) return { reason: "dynamic" };
1060
- const helpers = deps && resolveCssHelpers(node, deps.isBambooCssModule, deps.isGeneratedCssModule, deps.isShadowed);
1061
- if (!helpers) return { reason: "dynamic" };
1062
- const args = [...className ? [JSON.stringify(className)] : [], dynamicClassName];
1063
- const plan = buildEdits(node, tag, passthrough, className, `${helpers.cx}(${args.join(", ")})`);
1064
- return "reason" in plan ? plan : {
1065
- ...plan,
1066
- insert: helpers.insert
1067
- };
1068
- }
1069
- if (!className) return { reason: "dynamic" };
1070
- return buildEdits(node, tag, passthrough, className);
1071
- };
1072
- /** Rewrite the opening element, and the closing one when there is a pair. */
1073
- const buildEdits = (node, tag, passthrough, className, classExpression) => {
1074
- const attributes = [...passthrough, `className={${classExpression ?? JSON.stringify(className)}}`].join(" ");
1075
- const selfClosing = Node.isJsxSelfClosingElement(node);
1076
- const edits = [{
1077
- start: node.getStart(),
1078
- end: node.getEnd(),
1079
- text: `<${tag} ${attributes}${selfClosing ? " />" : ">"}`
1080
- }];
1081
- let end = node.getEnd();
1082
- if (!selfClosing) {
1083
- const parent = node.getParent();
1084
- if (!Node.isJsxElement(parent)) return { reason: "unsupported-kind" };
1085
- const closing = parent.getClosingElement();
1086
- edits.push({
1087
- start: closing.getStart(),
1088
- end: closing.getEnd(),
1089
- text: `</${tag}>`
1090
- });
1091
- end = closing.getEnd();
1092
- }
1093
- return {
1094
- edits,
1095
- className,
1096
- start: node.getStart(),
1097
- end
1098
- };
1099
- };
1100
647
  //#endregion
1101
648
  //#region src/runtime-css.ts
1102
649
  /** The shape `createCss` and `createMergeCss` both take, derived from a resolved context. */
@@ -1122,9 +669,30 @@ const createRuntimeCss = (ctx) => {
1122
669
  const { mergeCss } = createMergeCss(cssContext);
1123
670
  return (...styles) => cssFn(mergeCss(...styles));
1124
671
  };
1125
- const createRuntimeRecipe = (ctx, runtimeCss) => {
672
+ /**
673
+ * The map is every token in the project, so it is built once per context and shared by
674
+ * every module in the build — not once per `foldSource`, which would price a whole token
675
+ * table into each of the overwhelming majority of modules that call `token()` zero times.
676
+ * Keyed weakly so a context that goes out of scope takes its table with it.
677
+ */
678
+ const tokenValues = /* @__PURE__ */ new WeakMap();
679
+ const tokenValuesFor = (ctx) => {
680
+ let values = tokenValues.get(ctx);
681
+ if (values) return values;
682
+ values = /* @__PURE__ */ new Map();
683
+ for (const token of ctx.tokens.allTokens) {
684
+ const { varRef, isVirtual, condition } = token.extensions;
685
+ values.set(token.name, isVirtual || condition !== "base" ? varRef : token.value);
686
+ }
687
+ tokenValues.set(ctx, values);
688
+ return values;
689
+ };
690
+ const createRuntimeToken = (ctx) => (path) => {
691
+ const value = tokenValuesFor(ctx).get(path);
692
+ return typeof value === "string" ? value : void 0;
693
+ };
694
+ const createRuntimeRecipe = (ctx) => {
1126
695
  const separator = ctx.utility.separator;
1127
- const { mergeCss } = createMergeCss(createCssContext(ctx));
1128
696
  return (name, variants) => {
1129
697
  const config = ctx.recipes.getConfig(name);
1130
698
  const node = ctx.recipes.getRecipe(name);
@@ -1156,40 +724,28 @@ const createRuntimeRecipe = (ctx, runtimeCss) => {
1156
724
  ...compact(variants)
1157
725
  };
1158
726
  if (compoundVariants.length > 0 && Object.keys(recipeStyles).some((prop) => typeof variants[prop] === "object")) return;
1159
- const compoundStyles = getCompoundVariantCss(compoundVariants, recipeStyles, mergeCss);
1160
- return [recipeCss(recipeStyles), runtimeCss(compoundStyles)].filter(Boolean).join(" ");
727
+ return recipeCss(recipeStyles);
1161
728
  };
1162
729
  };
1163
- /**
1164
- * Mirrors the function of the same name in the generated `cva` artifact, down to the
1165
- * `mergeCss` it accumulates with.
1166
- *
1167
- * That merge has to be the deep one. More than one compound variant can match a single
1168
- * selection, and their `css` objects then combine rather than replace: `_hover` set by
1169
- * one and `_hover` set by another have to end up as a single condition holding both
1170
- * declarations. `Object.assign` drops everything the earlier match contributed under a
1171
- * shared key, which produces a shorter class list and no error at all.
1172
- *
1173
- * Taking `mergeCss` as an argument rather than importing one keeps it the same instance
1174
- * the rest of the fold resolves through, built from the same context.
1175
- */
1176
- const getCompoundVariantCss = (compoundVariants, variantMap, mergeCss) => {
1177
- let result = {};
1178
- for (const compoundVariant of compoundVariants) {
1179
- if (!compoundVariant) continue;
1180
- if (Object.entries(compoundVariant).every(([key, value]) => {
1181
- if (key === "css") return true;
1182
- return (Array.isArray(value) ? value : [value]).some((entry) => variantMap[key] === entry);
1183
- })) result = mergeCss(result, compoundVariant.css);
1184
- }
1185
- return result;
1186
- };
1187
730
  //#endregion
1188
731
  //#region src/fold.ts
1189
732
  /**
1190
- * `cva`/`sva` return a function and `token` returns a value, so none of them can
1191
- * collapse to a class string. Their *invocations* could, but those are separate call
1192
- * sites the parser does not record as such.
733
+ * `cva`/`sva` return a function, so neither can collapse to a class string. Their
734
+ * *invocations* could, but those are separate call sites the parser does not record as
735
+ * such. `token` also resolves to no class, but it does resolve to a literal, so it folds
736
+ * through its own path rather than being declined outright.
737
+ *
738
+ * Folding an invocation is now *possible* in a way it was not: a recipe's classes are named
739
+ * semantically, so the build knows every class a call can produce from the config alone.
740
+ * What is missing is upstream — the parser matches calls by imported name, so a local
741
+ * `button()` from `const button = cva(...)` is never recorded, and tracking those bindings
742
+ * is a change to the extractor rather than to this set.
743
+ *
744
+ * Worth knowing before taking that on: semantic naming already took most of the prize.
745
+ * `cvaFn` used to run `mergeCss` and name a class per property on every call; it is now a
746
+ * memoized loop over `variantKeys` doing string concatenation. That is an inspection of the
747
+ * two implementations, not a measurement — benchmark it before deciding it is worth the
748
+ * extractor work.
1193
749
  */
1194
750
  const FOLDABLE_TYPES = new Set([
1195
751
  "css",
@@ -1209,13 +765,16 @@ const literalsIn = (expression) => [...expression.matchAll(/"((?:[^"\\]|\\.)*)"/
1209
765
  * phase — hence separate from `unsupported-kind`, where a slot recipe lands because it
1210
766
  * resolves to one class per slot rather than to a single string.
1211
767
  */
1212
- const UNFOLDABLE_TYPES = new Set([
1213
- "cva",
1214
- "sva",
1215
- "token"
1216
- ]);
1217
- /** Element surfaces `foldJsx` handles, as opposed to call sites. */
1218
- const JSX_TYPES = new Set(["jsx-factory", "jsx-pattern"]);
768
+ const UNFOLDABLE_TYPES = new Set(["cva", "sva"]);
769
+ /**
770
+ * An argument that cannot run anything when it is evaluated.
771
+ *
772
+ * `token(path, fallback)` evaluates both arguments before the call, so a fold that drops
773
+ * the fallback also drops whatever evaluating it would have done. `token('x', compute())`
774
+ * is pathological, but the fold's contract is behaviour preservation and a literal is the
775
+ * cheap way to prove it: no call, no property read, no getter.
776
+ */
777
+ const isInertArgument = (node) => Node.isStringLiteral(node) || Node.isNumericLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node) || node.getKind() === SyntaxKind.TrueKeyword || node.getKind() === SyntaxKind.FalseKeyword || node.getKind() === SyntaxKind.NullKeyword || Node.isIdentifier(node) && node.getText() === "undefined";
1219
778
  /**
1220
779
  * Source files a box tree reaches, other than the one being folded.
1221
780
  *
@@ -1289,17 +848,6 @@ const calleeRootName = (call) => {
1289
848
  return Node.isIdentifier(current) ? current.getText() : void 0;
1290
849
  };
1291
850
  /**
1292
- * The identifier a JSX tag is rooted at: `styled` for `<styled.div>`, `bamboo` for
1293
- * `<bamboo.styled.div>`. The same question `calleeRootName` answers for a call, and it
1294
- * feeds the same import and shadowing checks.
1295
- */
1296
- const tagRootName = (element) => {
1297
- if (!Node.isJsxOpeningElement(element) && !Node.isJsxSelfClosingElement(element)) return void 0;
1298
- let current = element.getTagNameNode();
1299
- while (Node.isPropertyAccessExpression(current)) current = current.getExpression();
1300
- return Node.isIdentifier(current) ? current.getText() : void 0;
1301
- };
1302
- /**
1303
851
  * Local names a module binds to an import of bamboo's own generated system.
1304
852
  *
1305
853
  * The parser matches by name and asks neither question this does — deliberately, since
@@ -1401,7 +949,7 @@ const argumentsAccountedFor = (call, boxNode) => {
1401
949
  return accountsForSource(args[0], boxNode);
1402
950
  };
1403
951
  const foldSource = (options) => {
1404
- const { ctx, code, parserResult, jsx = true, partial: partial_ = true, runtimeCss = createRuntimeCss(ctx) } = options;
952
+ const { ctx, code, parserResult, partial: partial_ = true, runtimeCss = createRuntimeCss(ctx) } = options;
1405
953
  /**
1406
954
  * Recover the static half of a call the whole-call path gave up on. Only a
1407
955
  * single-argument `css()` qualifies: a pattern or recipe call takes props rather than a
@@ -1456,7 +1004,8 @@ const foldSource = (options) => {
1456
1004
  insert: cx.insert
1457
1005
  };
1458
1006
  };
1459
- const runtimeRecipe = createRuntimeRecipe(ctx, runtimeCss);
1007
+ const runtimeRecipe = createRuntimeRecipe(ctx);
1008
+ const runtimeToken = createRuntimeToken(ctx);
1460
1009
  /**
1461
1010
  * Does this specifier name a module that exports the css API, exactly?
1462
1011
  *
@@ -1498,11 +1047,6 @@ const foldSource = (options) => {
1498
1047
  };
1499
1048
  const isBambooCssModule = (mod) => matchesModule(mod, cssModules);
1500
1049
  const isGeneratedCssModule = (mod) => matchesModule(mod, [generatedCssModule]);
1501
- const jsxDeps = {
1502
- isBambooCssModule,
1503
- isGeneratedCssModule,
1504
- isShadowed
1505
- };
1506
1050
  const folded = [];
1507
1051
  const skipped = [];
1508
1052
  const candidates = [];
@@ -1521,12 +1065,8 @@ const foldSource = (options) => {
1521
1065
  const name = item.name ?? type;
1522
1066
  if (!item.box) continue;
1523
1067
  const call = findCallExpression(item.box);
1524
- if (jsx && JSX_TYPES.has(type)) {
1525
- const element = item.box.getNode?.();
1526
- if (!element) continue;
1527
- const elementStart = element.getStart();
1528
- const elementEnd = element.getEnd();
1529
- if (code.slice(elementStart, elementEnd) !== element.getText()) {
1068
+ if (type === "token") {
1069
+ if (!call) {
1530
1070
  skipped.push({
1531
1071
  name,
1532
1072
  reason: "no-call-expression",
@@ -1535,34 +1075,85 @@ const foldSource = (options) => {
1535
1075
  });
1536
1076
  continue;
1537
1077
  }
1538
- const rootName = tagRootName(element);
1539
- if (!rootName || !importsFor(element.getSourceFile()).has(rootName) || isShadowed(element, rootName)) {
1078
+ const start = call.getStart();
1079
+ const end = call.getEnd();
1080
+ if (code.slice(start, end) !== call.getText()) {
1081
+ skipped.push({
1082
+ name,
1083
+ reason: "no-call-expression",
1084
+ start: 0,
1085
+ end: 0
1086
+ });
1087
+ continue;
1088
+ }
1089
+ const rangeKey = `${start}:${end}`;
1090
+ if (seenRanges.has(rangeKey)) continue;
1091
+ seenRanges.add(rangeKey);
1092
+ const rootName = calleeRootName(call);
1093
+ if (!rootName || !importsFor(call.getSourceFile()).has(rootName) || isShadowed(call, rootName)) {
1540
1094
  skipped.push({
1541
1095
  name,
1542
1096
  reason: "not-imported",
1543
- start: elementStart,
1544
- end: elementEnd
1097
+ start,
1098
+ end
1099
+ });
1100
+ continue;
1101
+ }
1102
+ const callee = Node.isCallExpression(call) ? call.getExpression() : void 0;
1103
+ if (Node.isPropertyAccessExpression(callee) && !ctx.imports.matchers.tokens.match(callee.getNameNode().getText())) {
1104
+ skipped.push({
1105
+ name,
1106
+ reason: "unsupported-kind",
1107
+ start,
1108
+ end
1545
1109
  });
1546
1110
  continue;
1547
1111
  }
1548
- const plan = type === "jsx-pattern" ? planPatternFold(item, ctx, runtimeCss) : planJsxFold(item, ctx, runtimeCss, partial_ ? jsxDeps : void 0);
1549
- if ("reason" in plan) {
1112
+ if (!isStaticBox(item.box) || item.data.length !== 1) {
1550
1113
  skipped.push({
1551
1114
  name,
1552
- reason: plan.reason,
1553
- start: elementStart,
1554
- end: elementEnd
1115
+ reason: "dynamic",
1116
+ start,
1117
+ end
1118
+ });
1119
+ continue;
1120
+ }
1121
+ const path = item.data[0];
1122
+ if (typeof path !== "string") {
1123
+ skipped.push({
1124
+ name,
1125
+ reason: "dynamic",
1126
+ start,
1127
+ end
1128
+ });
1129
+ continue;
1130
+ }
1131
+ if (!(Node.isCallExpression(call) ? call.getArguments().slice(1) : []).every(isInertArgument)) {
1132
+ skipped.push({
1133
+ name,
1134
+ reason: "dynamic",
1135
+ start,
1136
+ end
1137
+ });
1138
+ continue;
1139
+ }
1140
+ const value = runtimeToken(path);
1141
+ if (!value) {
1142
+ skipped.push({
1143
+ name,
1144
+ reason: "unresolved-token",
1145
+ start,
1146
+ end
1555
1147
  });
1556
1148
  continue;
1557
1149
  }
1558
1150
  candidates.push({
1559
1151
  item,
1560
- node: element,
1561
- edits: plan.edits,
1562
- className: plan.className,
1563
- insert: plan.insert,
1564
- start: plan.start,
1565
- end: plan.end
1152
+ call,
1153
+ node: call,
1154
+ start,
1155
+ end,
1156
+ value
1566
1157
  });
1567
1158
  continue;
1568
1159
  }
@@ -1669,7 +1260,7 @@ const foldSource = (options) => {
1669
1260
  for (const candidate of candidates) {
1670
1261
  const { item, start, end } = candidate;
1671
1262
  const name = item.name ?? item.type ?? "";
1672
- const ranges = candidate.edits ? candidate.edits.map((edit) => [edit.start, edit.end]) : [[start, end]];
1263
+ const ranges = [[start, end]];
1673
1264
  if (collides(ranges)) {
1674
1265
  skipped.push({
1675
1266
  name,
@@ -1679,26 +1270,28 @@ const foldSource = (options) => {
1679
1270
  });
1680
1271
  continue;
1681
1272
  }
1682
- if (candidate.replacement) {
1683
- magic.overwrite(start, end, candidate.replacement);
1684
- applyInsert(candidate.insert);
1273
+ if (candidate.value !== void 0) {
1274
+ magic.overwrite(start, end, JSON.stringify(candidate.value));
1685
1275
  applied.push(...ranges);
1686
1276
  folded.push({
1687
1277
  name,
1688
- className: candidate.className,
1689
- classNames: (candidate.classNames ?? [candidate.className]).filter(Boolean),
1278
+ kind: "value",
1279
+ className: "",
1280
+ classNames: [],
1281
+ value: candidate.value,
1690
1282
  start,
1691
1283
  end
1692
1284
  });
1693
1285
  collectSourceFiles(item.box, dependencyScan);
1694
1286
  continue;
1695
1287
  }
1696
- if (candidate.edits) {
1697
- for (const edit of candidate.edits) magic.overwrite(edit.start, edit.end, edit.text);
1288
+ if (candidate.replacement) {
1289
+ magic.overwrite(start, end, candidate.replacement);
1698
1290
  applyInsert(candidate.insert);
1699
1291
  applied.push(...ranges);
1700
1292
  folded.push({
1701
1293
  name,
1294
+ kind: "class",
1702
1295
  className: candidate.className,
1703
1296
  classNames: (candidate.classNames ?? [candidate.className]).filter(Boolean),
1704
1297
  start,
@@ -1745,6 +1338,7 @@ const foldSource = (options) => {
1745
1338
  applied.push(...ranges);
1746
1339
  folded.push({
1747
1340
  name,
1341
+ kind: "class",
1748
1342
  className,
1749
1343
  classNames: [className],
1750
1344
  start,
@@ -1819,7 +1413,7 @@ const formatSkipped = (id, skipped) => {
1819
1413
  * with no matching rule.
1820
1414
  */
1821
1415
  const bamboocss = (options = {}) => {
1822
- const { transform = false, jsx, partial, configPath, cwd, reportSkipped = false, reportSummary = true } = options;
1416
+ const { transform = false, partial, configPath, cwd, reportSkipped = false, reportSummary = true } = options;
1823
1417
  /** Totals across the build, for the summary. */
1824
1418
  const totals = {
1825
1419
  folded: 0,
@@ -1852,6 +1446,37 @@ const bamboocss = (options = {}) => {
1852
1446
  totals.skipped.clear();
1853
1447
  await ensureContext();
1854
1448
  },
1449
+ /**
1450
+ * Take a changed module out of the parser's hands before the rebuild reads it.
1451
+ *
1452
+ * `addWatchFile` below registers the modules a fold read, so editing one re-transforms
1453
+ * its consumers. That is only half of it. The consumer is transformed *before* the
1454
+ * module it imports — that is how a bundler discovers imports at all — so by the time
1455
+ * the changed module's own `transform` refreshes it in the ts-morph project, the fold
1456
+ * that reads it has already run against the previous contents and baked a stale class
1457
+ * into the bundle. Rollup calls this hook before any of that, which is the only point
1458
+ * where refreshing is early enough.
1459
+ *
1460
+ * Both entry points clear the box-node cache, which is the part that matters: a
1461
+ * resolution memoized against the old contents outlives the file itself.
1462
+ *
1463
+ * A created file is handled as an edit. `reloadSourceFile` cannot re-read one the
1464
+ * parser has never held, and does not need to — it clears the cache, and the extractor
1465
+ * adds a newly-reachable module from disk on next use. What the shared path *is* needed
1466
+ * for is an editor's atomic save, which arrives as a delete followed by a create while
1467
+ * the parser still holds the file.
1468
+ */
1469
+ watchChange(id, change) {
1470
+ if (!transform || !ctx) return;
1471
+ if (!shouldTransform(id)) return;
1472
+ const [filePath] = id.split("?");
1473
+ if (!filePath) return;
1474
+ if (change.event === "delete") {
1475
+ ctx.project.removeSourceFile(filePath);
1476
+ return;
1477
+ }
1478
+ ctx.project.reloadSourceFile(filePath);
1479
+ },
1855
1480
  async transform(code, id) {
1856
1481
  if (!transform) return null;
1857
1482
  if (!shouldTransform(id)) return null;
@@ -1870,7 +1495,6 @@ const bamboocss = (options = {}) => {
1870
1495
  parserResult,
1871
1496
  filePath,
1872
1497
  runtimeCss,
1873
- jsx,
1874
1498
  partial
1875
1499
  });
1876
1500
  } catch (error) {