@bamboocss/config 1.30.1 → 1.32.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
@@ -3,11 +3,11 @@ import { BAMBOO_CONFIG_NAME, BambooError, assign, dashCase, isObject, isString,
3
3
  import { bundleNRequire } from "bundle-n-require";
4
4
  import findUp from "escalade/sync";
5
5
  import path, { posix, resolve, sep } from "path";
6
+ import { preset as presetBase } from "@bamboocss/preset-base";
7
+ import { preset as presetBamboo } from "@bamboocss/preset-bamboo";
6
8
  import microdiff from "microdiff";
7
9
  import fs from "fs";
8
10
  import ts from "typescript";
9
- import { preset as presetBase } from "@bamboocss/preset-base";
10
- import { preset as presetBamboo } from "@bamboocss/preset-bamboo";
11
11
  //#region src/is-bamboo-config.ts
12
12
  const configName = "bamboo";
13
13
  const bambooConfigFiles = new Set([
@@ -58,6 +58,25 @@ async function bundleConfig(options) {
58
58
  };
59
59
  }
60
60
  //#endregion
61
+ //#region src/bundled-preset.ts
62
+ const bundledPresets = {
63
+ "@bamboocss/preset-base": presetBase,
64
+ "@bamboocss/preset-bamboo": presetBamboo,
65
+ "@bamboocss/dev/presets": presetBamboo
66
+ };
67
+ const bundledPresetsNames = Object.keys(bundledPresets);
68
+ const isBundledPreset = (preset) => bundledPresetsNames.includes(preset);
69
+ const getBundledPreset = (preset) => {
70
+ return typeof preset === "string" && isBundledPreset(preset) ? bundledPresets[preset] : void 0;
71
+ };
72
+ /**
73
+ * What `presets` loads when a config does not list any.
74
+ *
75
+ * Exported so a config that adds a preset can keep them without restating them:
76
+ * `presets: [...defaultPresets, myPreset]`. Spread it — the array is shared.
77
+ */
78
+ const defaultPresets = [presetBase, presetBamboo];
79
+ //#endregion
61
80
  //#region src/create-matcher.ts
62
81
  /**
63
82
  * Acts like a .gitignore matcher
@@ -85,14 +104,12 @@ function createMatcher(id, patterns) {
85
104
  const all = [
86
105
  "clean",
87
106
  "cwd",
88
- "eject",
89
107
  "outdir",
90
108
  "forceConsistentTypeExtension",
91
109
  "outExtension",
92
110
  "emitTokensOnly",
93
111
  "presets",
94
- "plugins",
95
- "hooks"
112
+ "plugins"
96
113
  ];
97
114
  const format = [
98
115
  "hash",
@@ -137,7 +154,7 @@ const artifactConfigDeps = {
137
154
  "types-entry": [],
138
155
  "types-gen": [],
139
156
  "types-gen-system": [],
140
- themes: ["themes"].concat(tokens),
157
+ themes: ["theme.variants"].concat(tokens),
141
158
  "static-css": [
142
159
  "staticCss",
143
160
  "patterns",
@@ -151,6 +168,7 @@ const artifactMatchers = Object.entries(artifactConfigDeps).map(([key, paths]) =
151
168
  if (!paths.length) return () => void 0;
152
169
  return createMatcher(key, paths.concat(all));
153
170
  });
171
+ Array.from(new Set([...all, ...Object.values(artifactConfigDeps).flat()]));
154
172
  //#endregion
155
173
  //#region src/diff-config.ts
156
174
  const runIfFn = (fn) => typeof fn === "function" ? fn() : fn;
@@ -477,18 +495,35 @@ const tryCatch = (name, fn) => {
477
495
  };
478
496
  //#endregion
479
497
  //#region src/validation/utils.ts
480
- const REFERENCE_REGEX = /({([^}]*)})/g;
481
- const curlyBracketRegex = /[{}]/g;
498
+ /**
499
+ * A reference to another token: `token(colors.red.300)`.
500
+ *
501
+ * Deliberately a copy of the regex in `@bamboocss/token-dictionary`, which this package does not
502
+ * depend on. The two must agree: validation is what reports a missing or circular reference, so a
503
+ * spelling only the dictionary understands is one this never checks — which is silence, not an
504
+ * error, and exactly what a spelling change here is most likely to cause.
505
+ */
506
+ const REFERENCE_REGEX = /token\(([^(),]+)\)/g;
482
507
  const isValidToken = (token) => isObject(token) && Object.hasOwnProperty.call(token, "value");
483
- const isTokenReference = (value) => typeof value === "string" && REFERENCE_REGEX.test(value);
508
+ const isTokenReference = (value) => typeof value === "string" && getReferences(value).length > 0;
509
+ /**
510
+ * The retired curly reference — `{colors.red.300}`, or `{$spacing-2}` under a custom
511
+ * `formatTokenName`. A copy of the regex in `@bamboocss/token-dictionary`, which this package
512
+ * does not depend on.
513
+ *
514
+ * Reported here as well as there because a *token* value carrying one is the worse case: the
515
+ * text is emitted into the stylesheet rather than dropped, and validation is the only thing that
516
+ * can name which token it came from.
517
+ */
518
+ const CURLY_REFERENCE = /\{[^{}\s:;"']+\}/;
519
+ const findCurlyReference = (value) => value.includes("{") ? CURLY_REFERENCE.exec(value)?.[0] ?? void 0 : void 0;
520
+ /** The retired `token(path, fallback)` form. See `findCurlyReference` for why these fail. */
521
+ const FALLBACK_REFERENCE = /token\([^(),]+,[^()]*\)/;
522
+ const findFallbackReference = (value) => value.includes("token(") ? FALLBACK_REFERENCE.exec(value)?.[0] ?? void 0 : void 0;
484
523
  const formatPath = (path) => path;
485
524
  function getReferences(value) {
486
525
  if (typeof value !== "string") return [];
487
- const matches = value.match(REFERENCE_REGEX);
488
- if (!matches) return [];
489
- return matches.map((match) => match.replace(curlyBracketRegex, "")).map((value) => {
490
- return value.trim().split("/")[0];
491
- });
526
+ return [...value.matchAll(REFERENCE_REGEX)].map((match) => match[1].trim().split("/")[0]).filter(Boolean);
492
527
  }
493
528
  const serializeTokenValue = (value) => {
494
529
  if (isString(value)) return value;
@@ -545,28 +580,71 @@ const tokenKeys = [
545
580
  "deprecated"
546
581
  ];
547
582
  /**
583
+ * Options whose scalar form is shorthand for setting every member of their object form.
584
+ *
585
+ * `hash: true` says both `cssVar` and `className`; `prefix: 'bb'` says both; `preflight: true`
586
+ * says "on, with the defaults". Expanding them is what lets the object forms compose: a preset
587
+ * that sets `prefix.className` and an app that sets `prefix.cssVar` should end up with both,
588
+ * and before this the app's object replaced the preset's wholesale — silently, since the two
589
+ * name different members. `hash`'s members are optional, so writing the partial form that
590
+ * triggered it is the natural thing to do.
591
+ *
592
+ * `preflight: false` has no object form — there is no member meaning "off" — so it stays a
593
+ * scalar and wins outright when it is the value the winning config states.
594
+ */
595
+ const SCALAR_SHORTHANDS = {
596
+ hash: (value) => typeof value === "boolean" ? {
597
+ cssVar: value,
598
+ className: value
599
+ } : value,
600
+ prefix: (value) => typeof value === "string" ? {
601
+ cssVar: value,
602
+ className: value
603
+ } : value,
604
+ preflight: (value) => value === true ? {} : value
605
+ };
606
+ /**
607
+ * Merge one of those, winner-first per member.
608
+ *
609
+ * `records` arrives in precedence order — the user's config, then each preset — which is the
610
+ * order `assign` wants, since it only fills keys the target does not already have.
611
+ */
612
+ function mergeScalarShorthand(key, records) {
613
+ const normalize = SCALAR_SHORTHANDS[key];
614
+ const values = records.map((record) => record[key]).filter((value) => value !== void 0);
615
+ if (!values.length) return void 0;
616
+ if (values[0] === false) return false;
617
+ const objects = values.map(normalize).filter((value) => value !== null && typeof value === "object");
618
+ if (!objects.length) return values[0];
619
+ const merged = objects.reduce((acc, object) => assign(acc, object), {});
620
+ return isEmptyObject(merged) ? values[0] : merged;
621
+ }
622
+ /**
548
623
  * Merge all configs into a single config
549
624
  */
550
625
  function mergeConfigs(configs) {
551
- const userConfig = configs.at(-1);
552
- const pluginHooks = userConfig.plugins ?? [];
553
- if (userConfig.hooks) pluginHooks.push({
554
- name: BAMBOO_CONFIG_NAME,
555
- hooks: userConfig.hooks
556
- });
557
626
  const reversed = Array.from(configs).reverse();
627
+ const theme = mergeExtensions(reversed.map((config) => config.theme ?? {}));
628
+ const themeVariants = mergeExtensions(reversed.map((config) => config.theme?.variants ?? {}));
629
+ if (isEmptyObject(themeVariants)) delete theme.variants;
630
+ else theme.variants = themeVariants;
631
+ const global = compact({
632
+ css: mergeExtensions(reversed.map((config) => config.global?.css ?? {})),
633
+ vars: mergeExtensions(reversed.map((config) => config.global?.vars ?? {})),
634
+ fontface: mergeExtensions(reversed.map((config) => config.global?.fontface ?? {})),
635
+ positionTry: mergeExtensions(reversed.map((config) => config.global?.positionTry ?? {}))
636
+ });
558
637
  const withoutEmpty = compact(assign({
559
638
  conditions: mergeExtensions(reversed.map((config) => config.conditions ?? {})),
560
- theme: mergeExtensions(reversed.map((config) => config.theme ?? {})),
639
+ theme,
561
640
  patterns: mergeExtensions(reversed.map((config) => config.patterns ?? {})),
562
641
  utilities: mergeExtensions(reversed.map((config) => config.utilities ?? {})),
563
- globalCss: mergeExtensions(reversed.map((config) => config.globalCss ?? {})),
564
- globalVars: mergeExtensions(reversed.map((config) => config.globalVars ?? {})),
565
- globalFontface: mergeExtensions(reversed.map((config) => config.globalFontface ?? {})),
566
- globalPositionTry: mergeExtensions(reversed.map((config) => config.globalPositionTry ?? {})),
642
+ global,
567
643
  staticCss: mergeExtensions(reversed.map((config) => config.staticCss ?? {})),
568
- themes: mergeExtensions(reversed.map((config) => config.themes ?? {})),
569
- hooks: mergeHooks(pluginHooks)
644
+ prune: mergeExtensions(reversed.map((config) => config.prune ?? {})),
645
+ hash: mergeScalarShorthand("hash", reversed),
646
+ prefix: mergeScalarShorthand("prefix", reversed),
647
+ preflight: mergeScalarShorthand("preflight", reversed)
570
648
  }, ...reversed));
571
649
  /**
572
650
  * Properly merge tokens between flat/nested forms by setting the flat form as the default
@@ -651,18 +729,6 @@ async function getResolvedConfig(config, cwd, hooks) {
651
729
  return merged;
652
730
  }
653
731
  //#endregion
654
- //#region src/bundled-preset.ts
655
- const bundledPresets = {
656
- "@bamboocss/preset-base": presetBase,
657
- "@bamboocss/preset-bamboo": presetBamboo,
658
- "@bamboocss/dev/presets": presetBamboo
659
- };
660
- const bundledPresetsNames = Object.keys(bundledPresets);
661
- const isBundledPreset = (preset) => bundledPresetsNames.includes(preset);
662
- const getBundledPreset = (preset) => {
663
- return typeof preset === "string" && isBundledPreset(preset) ? bundledPresets[preset] : void 0;
664
- };
665
- //#endregion
666
732
  //#region src/validation/validate-artifact.ts
667
733
  const validateArtifactNames = (names, addError) => {
668
734
  names.recipes.forEach((recipeName) => {
@@ -757,6 +823,145 @@ const validateRecipes = (options) => {
757
823
  return artifacts;
758
824
  };
759
825
  //#endregion
826
+ //#region src/validation/validate-removed.ts
827
+ /**
828
+ * Config options that no longer exist, and what replaced them.
829
+ *
830
+ * An unknown key is otherwise *silently ignored* — nothing walks the config for keys it does not
831
+ * recognise. So removing an option without this leaves the worst possible upgrade: the build
832
+ * reverts to the default and says nothing, and an assertion the user asked for simply stops being
833
+ * enforced. That is exactly the shape a renamed prune flag would have taken.
834
+ *
835
+ * Keyed by the removed name so the message can say what to write instead, rather than reporting a
836
+ * bare "unknown option". Entries can be dropped a release or two after removal, once nobody is
837
+ * upgrading across them.
838
+ */
839
+ const REMOVED = {
840
+ pruneUnusedTokens: (value) => value === "strict" ? `\`pruneUnusedTokens: 'strict'\` is now \`prune: { tokens: 'accounted', unresolvedPath: 'error' }\`.` : `\`pruneUnusedTokens\` is now \`prune: { tokens: '${value === false ? "off" : "reachable"}' }\`.`,
841
+ pruneUnusedKeyframes: (value) => `\`pruneUnusedKeyframes\` is now \`prune: { keyframes: ${value === false ? "false" : "true"} }\`.`,
842
+ prunePreflight: (value) => `\`prunePreflight\` is now \`prune: { preflight: ${value === false ? "false" : "true"} }\`.`,
843
+ globalCss: () => `\`globalCss\` is now \`global: { css }\`.`,
844
+ globalFontface: () => `\`globalFontface\` is now \`global: { fontface }\`.`,
845
+ globalPositionTry: () => `\`globalPositionTry\` is now \`global: { positionTry }\`.`,
846
+ globalVars: () => `\`globalVars\` is now \`global: { vars }\`.`,
847
+ themes: () => `\`themes\` is now \`theme.variants\`. One character from \`theme\`, both spellings valid, so the typo resolved to a different feature instead of an error.`,
848
+ eject: (value) => value ? `\`eject: true\` is now \`presets: []\`. \`presets\` is the complete list — an unset \`presets\` loads \`defaultPresets\`, and listing your own no longer keeps a default underneath it.` : `\`eject: false\` was the default and no longer exists — delete it. \`presets\` is now the complete list.`,
849
+ hooks: () => `\`hooks\` is now a plugin: \`plugins: [{ name: 'my-app', hooks: { ... } }]\`. One mechanism had two spellings, and the nameless one left every diagnostic about a hook with nothing to print. Ordering is now just the order of the array, rather than "plugins in sequence, then the config's own last".`,
850
+ lightningcss: (value) => value ? `\`lightningcss: true\` is now \`plugins: [pluginLightningcss()]\` from \`@bamboocss/plugin-lightningcss\`, which you install yourself. The flag forced a static import, so every project carried the native binary whether or not it was on.` : `\`lightningcss: false\` was the default and no longer exists — delete it.`
851
+ };
852
+ /** Values that no longer exist for an option that does. Keyed by option, then by old value. */
853
+ const RETIRED_VALUES = { validation: { none: `\`validation: 'none'\` is now \`validation: 'off'\`, matching \`prune.unresolvedPath\`.` } };
854
+ /** Removed keys nested one level down, keyed by their parent. */
855
+ const COMPOSITION_MOVED = (old, prop) => () => `\`theme.${old}\` is now \`theme.mixins\`, applied through \`css({ mixin: '…' })\` rather than \`css({ ${prop}: '…' })\`. The three keys ran through one registration and differed only in which properties the value could set — an arbitrary partition that cost a bundle spanning two of them a second key and a second application.`;
856
+ const REMOVED_NESTED = {
857
+ prune: { unresolved: (value) => `\`prune.unresolved\` is now \`prune.unresolvedPath\`, and the accounting pass it used to switch on is now \`prune.tokens: 'accounted'\` — write \`prune: { tokens: 'accounted', unresolvedPath: '${value === "error" ? "error" : "warn"}' }\`. They are separate because \`'off'\` used to mean two things at once: no accounting, and no report.` },
858
+ theme: {
859
+ textStyles: COMPOSITION_MOVED("textStyles", "textStyle"),
860
+ layerStyles: COMPOSITION_MOVED("layerStyles", "layerStyle"),
861
+ animationStyles: COMPOSITION_MOVED("animationStyles", "animationStyle")
862
+ }
863
+ };
864
+ /**
865
+ * Options a config still sets that no longer exist — a hard error, and not silenceable.
866
+ *
867
+ * Runs ahead of `validation` and ignores it, for the reason `assertNoRetiredSyntax` does. The
868
+ * rest of `validateConfig` reports opinions about a config that will still build; this reports a
869
+ * config that is *provably* not the one being read. An unknown key might be forward-compatible —
870
+ * a setting for a version you have not installed yet — but a key on this list is only reachable
871
+ * by a config written against a version that is behind the one running it.
872
+ *
873
+ * It used to warn, which is the wrong severity for exactly the upgrade this exists to catch. A
874
+ * warning scrolls past in CI, and a removed option is silent in every other way: the build
875
+ * reverts to the default and the assertion the user asked for stops being enforced. These
876
+ * removals ship in minor versions, so a Renovate auto-merge sails through a warning without a
877
+ * person ever reading it — the one case where nothing else can catch it.
878
+ *
879
+ * Every occurrence is collected before throwing, because the point is to fix a config once
880
+ * rather than to be told about it one key at a time. Delete this a release or two after removal,
881
+ * along with `validate-retired-syntax.ts`.
882
+ */
883
+ function assertNoRemovedOptions(config) {
884
+ const found = [];
885
+ validateRemovedOptions(config, (scope, message) => {
886
+ found.push(`- [${scope}] ${message}`);
887
+ });
888
+ if (!found.length) return;
889
+ throw new BambooError("CONFIG_ERROR", `${found.length} config option(s) no longer exist:\n\n${found.join("\n")}\n\nNothing walks a config for keys it does not recognise, so an option left in place here is reported nowhere else — the build reverts to the default in silence, and any assertion the option asked for stops being enforced. Make the edits above and the build proceeds. This is not governed by \`validation\`, which grades a config that still builds.\n\nThis reads the config after presets are merged, so a key you cannot find in your own file came from one of them — upgrade that preset, or drop the key with a \`config:resolved\` hook.`);
890
+ }
891
+ function validateRemovedOptions(config, addError) {
892
+ const dict = config;
893
+ for (const [name, describe] of Object.entries(REMOVED)) {
894
+ if (!Object.hasOwn(config, name)) continue;
895
+ addError("config", describe(dict[name]));
896
+ }
897
+ for (const [parent, removed] of Object.entries(REMOVED_NESTED)) {
898
+ const value = dict[parent];
899
+ if (value == null || typeof value !== "object") continue;
900
+ for (const [name, describe] of Object.entries(removed)) {
901
+ if (!Object.hasOwn(value, name)) continue;
902
+ addError("config", describe(value[name]));
903
+ }
904
+ }
905
+ for (const [name, retired] of Object.entries(RETIRED_VALUES)) {
906
+ const value = dict[name];
907
+ if (typeof value !== "string") continue;
908
+ const message = retired[value];
909
+ if (message) addError("config", message);
910
+ }
911
+ if (typeof dict.prune?.tokens === "boolean") addError("config", `\`prune.tokens\` takes a strategy now, not a boolean — write \`'${dict.prune.tokens ? "reachable" : "off"}'\`. \`'accounted'\` is the new one: keeps computed from the token paths in your source rather than from what the css reaches.`);
912
+ for (const [name, pattern] of Object.entries(config.patterns ?? {})) {
913
+ const dictPattern = pattern;
914
+ if (!dictPattern) continue;
915
+ if (Object.hasOwn(dictPattern, "strict")) addError("patterns", `\`${name}.strict\` is now \`${name}.cssProps: '${dictPattern.strict ? "none" : "all"}'\`.`);
916
+ if (Object.hasOwn(dictPattern, "blocklist")) addError("patterns", `\`${name}.blocklist\` is now \`${name}.cssProps: { except: [...] }\`, which is no longer experimental and no longer silently dropped when the pattern also set \`strict\`.`);
917
+ }
918
+ }
919
+ //#endregion
920
+ //#region src/validation/validate-retired-syntax.ts
921
+ /**
922
+ * Token values still written in a retired reference syntax.
923
+ *
924
+ * A hard error, and one that runs ahead of `validation` rather than under it. The rest of
925
+ * `validateConfig` reports opinions about a config that will still build; this reports a spelling
926
+ * that no longer means anything, whose output is broken either way — in a token value the text is
927
+ * emitted into the stylesheet as-is, and nothing downstream reports it. `validation: 'off'` is an
928
+ * opt-out of opinions, not of that.
929
+ *
930
+ * Safe to throw on because the spelling was never available for anything else: until it was
931
+ * removed, `{…}` in a value was consumed unconditionally — braces stripped, unresolved paths
932
+ * emitted bare — so no literal `{a.b}` could have survived to mean itself.
933
+ *
934
+ * Every occurrence is collected before throwing, because the point is to fix a config once rather
935
+ * than to be told about it one token at a time. Delete this a release or two after removal, along
936
+ * with `validate-removed.ts`.
937
+ */
938
+ function assertNoRetiredSyntax(config) {
939
+ const found = [];
940
+ const collect = (source, label) => {
941
+ if (!source) return;
942
+ walkObject(source, (token, path) => {
943
+ if (!isValidToken(token)) return;
944
+ const value = serializeTokenValue(token.value ?? token);
945
+ const at = `${label}.${formatPath(path.join("."))}`;
946
+ const curly = findCurlyReference(value);
947
+ if (curly) found.push(`- \`${at}\`: \`${curly}\` → \`token(${curly.slice(1, -1)})\``);
948
+ const fallback = findFallbackReference(value);
949
+ if (fallback) {
950
+ const path = fallback.slice(6, fallback.lastIndexOf(",")).trim();
951
+ found.push(`- \`${at}\`: \`${fallback}\` → \`token(${path})\``);
952
+ }
953
+ }, { stop: isValidToken });
954
+ };
955
+ collect(config.theme?.tokens, "theme.tokens");
956
+ collect(config.theme?.semanticTokens, "theme.semanticTokens");
957
+ for (const [name, variant] of Object.entries(config.theme?.variants ?? {})) {
958
+ collect(variant?.tokens, `theme.variants.${name}.tokens`);
959
+ collect(variant?.semanticTokens, `theme.variants.${name}.semanticTokens`);
960
+ }
961
+ if (!found.length) return;
962
+ throw new BambooError("CONFIG_ERROR", `${found.length} token value(s) use a retired reference syntax:\n\n${found.join("\n")}\n\n\`{colors.red.300}\` and \`token(colors.red.300, fallback)\` were both removed so a token is referenced one way. Neither is ignored quietly — the text is emitted into the stylesheet as-is.`);
963
+ }
964
+ //#endregion
760
965
  //#region src/validation/validate-token-references.ts
761
966
  const validateTokenReferences = (props) => {
762
967
  const { valueAtPath, refsByPath, addError, typeByPath } = props;
@@ -877,13 +1082,29 @@ const validateTokens = (options) => {
877
1082
  * - Check for missing tokens references
878
1083
  * - Check for conditions selectors (must contain '&')
879
1084
  * - Check for breakpoints units (must be the same)
1085
+ * - Throw on options that have been removed, which are otherwise ignored in silence
1086
+ * - Throw on token values still written in the retired curly reference syntax
1087
+ *
1088
+ * The two throwing checks run first and answer to nothing below them. `validation` grades
1089
+ * *opinions about a config that still builds*; those two are evidence that the config is not
1090
+ * the one being read, which is a different question and not one a severity setting should
1091
+ * decide.
880
1092
  */
881
1093
  const validateConfig = (config) => {
882
- if (config.validation === "none") return;
1094
+ assertNoRetiredSyntax(config);
1095
+ assertNoRemovedOptions(config);
883
1096
  const warnings = /* @__PURE__ */ new Set();
884
1097
  const addError = (scope, message) => {
885
1098
  warnings.add(`[${scope}] ` + message);
886
1099
  };
1100
+ const report = () => {
1101
+ if (!warnings.size) return;
1102
+ const errors = `⚠️ Invalid config:\n${Array.from(warnings).map((err) => "- " + err).join("\n")}\n`;
1103
+ if (config.validation === "error") throw new BambooError("CONFIG_ERROR", errors);
1104
+ logger.warn("config", errors);
1105
+ return warnings;
1106
+ };
1107
+ if (config.validation === "off") return report();
887
1108
  validateBreakpoints(config.theme?.breakpoints, addError);
888
1109
  validateConditions(config.conditions, addError);
889
1110
  const artifacts = {
@@ -913,12 +1134,7 @@ const validateConfig = (config) => {
913
1134
  }
914
1135
  validatePatterns(config.patterns, artifacts);
915
1136
  validateArtifactNames(artifacts, addError);
916
- if (warnings.size) {
917
- const errors = `⚠️ Invalid config:\n${Array.from(warnings).map((err) => "- " + err).join("\n")}\n`;
918
- if (config.validation === "error") throw new BambooError("CONFIG_ERROR", errors);
919
- logger.warn("config", errors);
920
- return warnings;
921
- }
1137
+ return report();
922
1138
  };
923
1139
  //#endregion
924
1140
  //#region src/resolve-config.ts
@@ -928,28 +1144,41 @@ const hookUtils = {
928
1144
  traverse
929
1145
  };
930
1146
  /**
931
- * Resolve the final config (including presets)
932
- * @bamboocss/preset-base: ALWAYS included if NOT using eject: true
933
- * @bamboocss/preset-bamboo: only included by default if no presets
1147
+ * The one way this rename can break a config without saying so.
1148
+ *
1149
+ * `presets` still exists and still takes a list, so nothing in `validate-removed` notices
1150
+ * that its meaning changed. A config that listed `[myPreset]` used to get `preset-base`
1151
+ * underneath it and now does not — and what `preset-base` carries is the utility table, so
1152
+ * the failure is every class name silently changing (`c_red_300` becomes `color_red_300`)
1153
+ * rather than an error. That is the shape this codebase treats as the worst upgrade there
1154
+ * is, so it gets a message.
1155
+ *
1156
+ * Skipped for an empty list, which is a deliberate eject and the replacement for
1157
+ * `eject: true`. Drop this a release or two after the rename.
1158
+ */
1159
+ function warnIfBaseDropped(listed, resolved) {
1160
+ if (!listed?.length) return;
1161
+ if (resolved.some((preset) => preset?.name === "@bamboocss/preset-base")) return;
1162
+ logger.warn("config", "`presets` is now the complete list, and this one does not include `@bamboocss/preset-base` — so its utilities, conditions and patterns are not loaded, and generated class names change. Listing a preset used to keep `preset-base` underneath it.\n\n import { defaultPresets } from '@bamboocss/dev/presets'\n presets: [...defaultPresets, yourPreset]\n\nIf dropping it is deliberate, this is the intended behaviour and the warning goes away once `preset-base` is listed explicitly.");
1163
+ }
1164
+ /**
1165
+ * Resolve the final config (including presets).
1166
+ *
1167
+ * `presets` is authoritative: what the config lists is what is loaded, and an unset
1168
+ * `presets` loads `defaultPresets`. There is no implicit preset a listed one sits on top
1169
+ * of — `eject` used to control that, badly. Under it, listing any preset kept
1170
+ * `@bamboocss/preset-base` and silently dropped `@bamboocss/preset-bamboo`, so `presets`
1171
+ * was neither additive nor replacing, and `presets: []` meant "base only" rather than
1172
+ * "none". Both of those had to be discovered by reading this function.
934
1173
  */
935
1174
  async function resolveConfig(result, cwd) {
936
- const presets = /* @__PURE__ */ new Set();
937
- if (!result.config.eject) presets.add(presetBase);
938
- if (result.config.presets) result.config.presets.forEach((preset) => {
939
- presets.add(getBundledPreset(preset) ?? preset);
940
- });
941
- else if (!result.config.eject) presets.add(presetBamboo);
942
- result.config.presets = Array.from(presets);
943
- const userConfig = result.config;
944
- const pluginHooks = userConfig.plugins ?? [];
945
- if (userConfig.hooks) pluginHooks.push({
946
- name: BAMBOO_CONFIG_NAME,
947
- hooks: userConfig.hooks
948
- });
949
- const earlyHooks = mergeHooks(pluginHooks);
950
- const mergedConfig = await getResolvedConfig(result.config, cwd, earlyHooks);
951
- const hooks = mergedConfig.hooks ?? {};
1175
+ const listed = result.config.presets;
1176
+ result.config.presets = listed ? Array.from(new Set(listed.map((preset) => getBundledPreset(preset) ?? preset))) : [...defaultPresets];
1177
+ warnIfBaseDropped(listed, result.config.presets);
1178
+ const hooks = mergeHooks(result.config.plugins ?? []);
1179
+ const mergedConfig = await getResolvedConfig(result.config, cwd, hooks);
952
1180
  if (mergedConfig.logLevel) logger.level = mergedConfig.logLevel;
1181
+ if (mergedConfig.logFilter) logger.filter = mergedConfig.logFilter;
953
1182
  validateConfig(mergedConfig);
954
1183
  const loadConfigResult = {
955
1184
  ...result,
@@ -985,4 +1214,4 @@ async function loadConfig(options) {
985
1214
  return resolveConfig(await bundleConfig(options), options.cwd);
986
1215
  }
987
1216
  //#endregion
988
- export { bundleConfig, convertTsPathsToRegexes, diffConfigs, findConfig, getConfigDependencies, getResolvedConfig, loadConfig, mergeConfigs, mergeHooks, resolveConfig };
1217
+ export { bundleConfig, convertTsPathsToRegexes, defaultPresets, diffConfigs, findConfig, getConfigDependencies, getResolvedConfig, loadConfig, mergeConfigs, mergeHooks, presetBamboo, presetBase, resolveConfig };
@@ -172,28 +172,71 @@ const tokenKeys = [
172
172
  "deprecated"
173
173
  ];
174
174
  /**
175
+ * Options whose scalar form is shorthand for setting every member of their object form.
176
+ *
177
+ * `hash: true` says both `cssVar` and `className`; `prefix: 'bb'` says both; `preflight: true`
178
+ * says "on, with the defaults". Expanding them is what lets the object forms compose: a preset
179
+ * that sets `prefix.className` and an app that sets `prefix.cssVar` should end up with both,
180
+ * and before this the app's object replaced the preset's wholesale — silently, since the two
181
+ * name different members. `hash`'s members are optional, so writing the partial form that
182
+ * triggered it is the natural thing to do.
183
+ *
184
+ * `preflight: false` has no object form — there is no member meaning "off" — so it stays a
185
+ * scalar and wins outright when it is the value the winning config states.
186
+ */
187
+ const SCALAR_SHORTHANDS = {
188
+ hash: (value) => typeof value === "boolean" ? {
189
+ cssVar: value,
190
+ className: value
191
+ } : value,
192
+ prefix: (value) => typeof value === "string" ? {
193
+ cssVar: value,
194
+ className: value
195
+ } : value,
196
+ preflight: (value) => value === true ? {} : value
197
+ };
198
+ /**
199
+ * Merge one of those, winner-first per member.
200
+ *
201
+ * `records` arrives in precedence order — the user's config, then each preset — which is the
202
+ * order `assign` wants, since it only fills keys the target does not already have.
203
+ */
204
+ function mergeScalarShorthand(key, records) {
205
+ const normalize = SCALAR_SHORTHANDS[key];
206
+ const values = records.map((record) => record[key]).filter((value) => value !== void 0);
207
+ if (!values.length) return void 0;
208
+ if (values[0] === false) return false;
209
+ const objects = values.map(normalize).filter((value) => value !== null && typeof value === "object");
210
+ if (!objects.length) return values[0];
211
+ const merged = objects.reduce((acc, object) => (0, _bamboocss_shared.assign)(acc, object), {});
212
+ return isEmptyObject(merged) ? values[0] : merged;
213
+ }
214
+ /**
175
215
  * Merge all configs into a single config
176
216
  */
177
217
  function mergeConfigs(configs) {
178
- const userConfig = configs.at(-1);
179
- const pluginHooks = userConfig.plugins ?? [];
180
- if (userConfig.hooks) pluginHooks.push({
181
- name: _bamboocss_shared.BAMBOO_CONFIG_NAME,
182
- hooks: userConfig.hooks
183
- });
184
218
  const reversed = Array.from(configs).reverse();
219
+ const theme = mergeExtensions(reversed.map((config) => config.theme ?? {}));
220
+ const themeVariants = mergeExtensions(reversed.map((config) => config.theme?.variants ?? {}));
221
+ if (isEmptyObject(themeVariants)) delete theme.variants;
222
+ else theme.variants = themeVariants;
223
+ const global = compact({
224
+ css: mergeExtensions(reversed.map((config) => config.global?.css ?? {})),
225
+ vars: mergeExtensions(reversed.map((config) => config.global?.vars ?? {})),
226
+ fontface: mergeExtensions(reversed.map((config) => config.global?.fontface ?? {})),
227
+ positionTry: mergeExtensions(reversed.map((config) => config.global?.positionTry ?? {}))
228
+ });
185
229
  const withoutEmpty = compact((0, _bamboocss_shared.assign)({
186
230
  conditions: mergeExtensions(reversed.map((config) => config.conditions ?? {})),
187
- theme: mergeExtensions(reversed.map((config) => config.theme ?? {})),
231
+ theme,
188
232
  patterns: mergeExtensions(reversed.map((config) => config.patterns ?? {})),
189
233
  utilities: mergeExtensions(reversed.map((config) => config.utilities ?? {})),
190
- globalCss: mergeExtensions(reversed.map((config) => config.globalCss ?? {})),
191
- globalVars: mergeExtensions(reversed.map((config) => config.globalVars ?? {})),
192
- globalFontface: mergeExtensions(reversed.map((config) => config.globalFontface ?? {})),
193
- globalPositionTry: mergeExtensions(reversed.map((config) => config.globalPositionTry ?? {})),
234
+ global,
194
235
  staticCss: mergeExtensions(reversed.map((config) => config.staticCss ?? {})),
195
- themes: mergeExtensions(reversed.map((config) => config.themes ?? {})),
196
- hooks: mergeHooks(pluginHooks)
236
+ prune: mergeExtensions(reversed.map((config) => config.prune ?? {})),
237
+ hash: mergeScalarShorthand("hash", reversed),
238
+ prefix: mergeScalarShorthand("prefix", reversed),
239
+ preflight: mergeScalarShorthand("preflight", reversed)
197
240
  }, ...reversed));
198
241
  /**
199
242
  * Properly merge tokens between flat/nested forms by setting the flat form as the default
@@ -1,4 +1,4 @@
1
- import { BAMBOO_CONFIG_NAME, assign, isObject, mergeAndConcat, mergeWith, walkObject } from "@bamboocss/shared";
1
+ import { assign, isObject, mergeAndConcat, mergeWith, walkObject } from "@bamboocss/shared";
2
2
  import { logger } from "@bamboocss/logger";
3
3
  //#region src/merge-hooks.ts
4
4
  const mergeHooks = (plugins) => {
@@ -171,28 +171,71 @@ const tokenKeys = [
171
171
  "deprecated"
172
172
  ];
173
173
  /**
174
+ * Options whose scalar form is shorthand for setting every member of their object form.
175
+ *
176
+ * `hash: true` says both `cssVar` and `className`; `prefix: 'bb'` says both; `preflight: true`
177
+ * says "on, with the defaults". Expanding them is what lets the object forms compose: a preset
178
+ * that sets `prefix.className` and an app that sets `prefix.cssVar` should end up with both,
179
+ * and before this the app's object replaced the preset's wholesale — silently, since the two
180
+ * name different members. `hash`'s members are optional, so writing the partial form that
181
+ * triggered it is the natural thing to do.
182
+ *
183
+ * `preflight: false` has no object form — there is no member meaning "off" — so it stays a
184
+ * scalar and wins outright when it is the value the winning config states.
185
+ */
186
+ const SCALAR_SHORTHANDS = {
187
+ hash: (value) => typeof value === "boolean" ? {
188
+ cssVar: value,
189
+ className: value
190
+ } : value,
191
+ prefix: (value) => typeof value === "string" ? {
192
+ cssVar: value,
193
+ className: value
194
+ } : value,
195
+ preflight: (value) => value === true ? {} : value
196
+ };
197
+ /**
198
+ * Merge one of those, winner-first per member.
199
+ *
200
+ * `records` arrives in precedence order — the user's config, then each preset — which is the
201
+ * order `assign` wants, since it only fills keys the target does not already have.
202
+ */
203
+ function mergeScalarShorthand(key, records) {
204
+ const normalize = SCALAR_SHORTHANDS[key];
205
+ const values = records.map((record) => record[key]).filter((value) => value !== void 0);
206
+ if (!values.length) return void 0;
207
+ if (values[0] === false) return false;
208
+ const objects = values.map(normalize).filter((value) => value !== null && typeof value === "object");
209
+ if (!objects.length) return values[0];
210
+ const merged = objects.reduce((acc, object) => assign(acc, object), {});
211
+ return isEmptyObject(merged) ? values[0] : merged;
212
+ }
213
+ /**
174
214
  * Merge all configs into a single config
175
215
  */
176
216
  function mergeConfigs(configs) {
177
- const userConfig = configs.at(-1);
178
- const pluginHooks = userConfig.plugins ?? [];
179
- if (userConfig.hooks) pluginHooks.push({
180
- name: BAMBOO_CONFIG_NAME,
181
- hooks: userConfig.hooks
182
- });
183
217
  const reversed = Array.from(configs).reverse();
218
+ const theme = mergeExtensions(reversed.map((config) => config.theme ?? {}));
219
+ const themeVariants = mergeExtensions(reversed.map((config) => config.theme?.variants ?? {}));
220
+ if (isEmptyObject(themeVariants)) delete theme.variants;
221
+ else theme.variants = themeVariants;
222
+ const global = compact({
223
+ css: mergeExtensions(reversed.map((config) => config.global?.css ?? {})),
224
+ vars: mergeExtensions(reversed.map((config) => config.global?.vars ?? {})),
225
+ fontface: mergeExtensions(reversed.map((config) => config.global?.fontface ?? {})),
226
+ positionTry: mergeExtensions(reversed.map((config) => config.global?.positionTry ?? {}))
227
+ });
184
228
  const withoutEmpty = compact(assign({
185
229
  conditions: mergeExtensions(reversed.map((config) => config.conditions ?? {})),
186
- theme: mergeExtensions(reversed.map((config) => config.theme ?? {})),
230
+ theme,
187
231
  patterns: mergeExtensions(reversed.map((config) => config.patterns ?? {})),
188
232
  utilities: mergeExtensions(reversed.map((config) => config.utilities ?? {})),
189
- globalCss: mergeExtensions(reversed.map((config) => config.globalCss ?? {})),
190
- globalVars: mergeExtensions(reversed.map((config) => config.globalVars ?? {})),
191
- globalFontface: mergeExtensions(reversed.map((config) => config.globalFontface ?? {})),
192
- globalPositionTry: mergeExtensions(reversed.map((config) => config.globalPositionTry ?? {})),
233
+ global,
193
234
  staticCss: mergeExtensions(reversed.map((config) => config.staticCss ?? {})),
194
- themes: mergeExtensions(reversed.map((config) => config.themes ?? {})),
195
- hooks: mergeHooks(pluginHooks)
235
+ prune: mergeExtensions(reversed.map((config) => config.prune ?? {})),
236
+ hash: mergeScalarShorthand("hash", reversed),
237
+ prefix: mergeScalarShorthand("prefix", reversed),
238
+ preflight: mergeScalarShorthand("preflight", reversed)
196
239
  }, ...reversed));
197
240
  /**
198
241
  * Properly merge tokens between flat/nested forms by setting the flat form as the default