@bamboocss/vite 1.37.12 → 1.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -94,6 +94,7 @@ const pruneStaticCss = (css, session, { prune = true } = {}) => {
94
94
  if (!className || !prunable.has(className) || used.has(className)) return;
95
95
  candidate.remove();
96
96
  removedAny = true;
97
+ session.prunedClasses.add(className);
97
98
  });
98
99
  }).processSync(rule.selector);
99
100
  } catch {
@@ -154,6 +155,56 @@ const pruneStaticCss = (css, session, { prune = true } = {}) => {
154
155
  return root.toString();
155
156
  };
156
157
  //#endregion
158
+ //#region src/static-session.ts
159
+ const createStaticCompilationSession = () => {
160
+ const session = {
161
+ utilityLayer: "utilities",
162
+ sourcemap: false,
163
+ cssLoaded: false,
164
+ transformedFiles: /* @__PURE__ */ new Set(),
165
+ extractedFiles: /* @__PURE__ */ new Set(),
166
+ prunableClasses: /* @__PURE__ */ new Set(),
167
+ viewTransitionClasses: /* @__PURE__ */ new Set(),
168
+ usedClasses: /* @__PURE__ */ new Set(),
169
+ expectedEnvironments: void 0,
170
+ startedEnvironments: /* @__PURE__ */ new Set(),
171
+ prunedClasses: /* @__PURE__ */ new Set(),
172
+ markClassUsed(className) {
173
+ for (const token of className.split(" ")) {
174
+ if (!token) continue;
175
+ session.usedClasses.add(token.includes("\\") ? token : (0, _bamboocss_shared.esc)(token));
176
+ }
177
+ }
178
+ };
179
+ return session;
180
+ };
181
+ /**
182
+ * Environments this run intends to build that have not been compiled yet.
183
+ *
184
+ * Empty means everything the run will contribute has been contributed, which is the condition
185
+ * every whole-run judgement here waits for: pruning the stylesheet against reachability, and
186
+ * the two guards that ask whether the compiled modules and the extraction graph agree. Each of
187
+ * those is false about a build in progress and true only about a finished one.
188
+ *
189
+ * Empty is also the answer for a single-environment build, where nothing announced an
190
+ * environment list because there is only ever one — so that path is unchanged.
191
+ *
192
+ * An environment a run declares and then never builds leaves this permanently non-empty, and
193
+ * those judgements are skipped for the run. Every one of them errs towards shipping more CSS
194
+ * or asserting less, so that is the safe direction to be wrong in.
195
+ */
196
+ const remainingEnvironments = (session) => [...session.expectedEnvironments ?? []].filter((name) => !session.startedEnvironments.has(name));
197
+ const resetStaticCompilationSession = (session) => {
198
+ session.cssLoaded = false;
199
+ session.transformedFiles.clear();
200
+ session.extractedFiles.clear();
201
+ session.prunableClasses.clear();
202
+ session.viewTransitionClasses.clear();
203
+ session.usedClasses.clear();
204
+ session.startedEnvironments.clear();
205
+ session.prunedClasses.clear();
206
+ };
207
+ //#endregion
157
208
  //#region src/css.ts
158
209
  /**
159
210
  * What a project imports to get the stylesheet.
@@ -255,32 +306,37 @@ const replaceAssetReferences = (bundle, previous, next, sourcemap) => {
255
306
  */
256
307
  const carriesGeneratedCss = (output) => output.type === "asset" && output.fileName.endsWith(".css");
257
308
  /**
258
- * Prune and rename compiler-owned CSS, then give changed assets a hash of their final bytes.
309
+ * Prune compiler-owned CSS, then give any sheet whose bytes changed a hash of those bytes.
259
310
  *
260
311
  * Rollup has already expanded `[hash]` when `generateBundle` runs. Mutating only `source`
261
312
  * would therefore leave two different reachable subsets under one CDN key. The extra final
262
313
  * hash is not cosmetic: it makes late graph reachability cache-safe.
314
+ *
315
+ * Renaming is therefore not a choice this takes. Pruned bytes under the unpruned sheet's name
316
+ * is the one outcome that must never be reachable, and a sheet nothing was removed from keeps
317
+ * its name because its bytes are unchanged — so "rename" is a consequence of "the bytes moved",
318
+ * not a second option. `prune` is the only knob.
263
319
  */
264
320
  const optimizeStaticCssAssets = (bundle, session, options = {}) => {
265
- const { rename = true } = options;
321
+ const { prune = true, sourcemap = session.sourcemap } = options;
322
+ /** Assets in this bundle that carry the generated stylesheet, pruned or not. */
323
+ let sheets = 0;
266
324
  for (const output of Object.values(bundle)) {
267
325
  if (!carriesGeneratedCss(output)) continue;
268
326
  const source = typeof output.source === "string" ? output.source : Buffer.from(output.source).toString();
269
327
  if (!source.includes("--made-with-bamboo")) continue;
328
+ sheets++;
329
+ if (!prune) continue;
270
330
  const optimized = pruneStaticCss(source, session);
271
331
  output.source = optimized;
272
332
  if (optimized === source) continue;
273
- if (!rename) {
274
- output.source = source;
275
- continue;
276
- }
277
333
  const nextName = output.fileName.replace(/\.css$/, `.b-${(0, _bamboocss_shared.toHash)(optimized)}.css`);
278
- if (nextName === output.fileName) continue;
279
334
  if (bundle[nextName] && bundle[nextName] !== output) throw new Error(`bamboocss: final CSS asset name collision at ${JSON.stringify(nextName)}.`);
280
335
  const previous = output.fileName;
281
336
  output.fileName = nextName;
282
- replaceAssetReferences(bundle, previous, nextName, session.sourcemap);
337
+ replaceAssetReferences(bundle, previous, nextName, sourcemap);
283
338
  }
339
+ return { sheets };
284
340
  };
285
341
  /**
286
342
  * Serve bamboo's stylesheet as a virtual module, in dev and in build.
@@ -296,7 +352,7 @@ const optimizeStaticCssAssets = (bundle, session, options = {}) => {
296
352
  * process just wrote, which is a race on any watch rebuild.
297
353
  */
298
354
  const bamboocssCss = (options) => {
299
- const { configPath, cwd, session, renameCssAsset = true } = options;
355
+ const { configPath, cwd, session, pruneCss = true } = options;
300
356
  /**
301
357
  * Environments whose `load` served the virtual stylesheet.
302
358
  *
@@ -356,9 +412,32 @@ const bamboocssCss = (options) => {
356
412
  };
357
413
  return {
358
414
  name: "bamboocss:css",
415
+ /**
416
+ * One instance for every environment of a build, rather than one per environment.
417
+ *
418
+ * Vite re-reads the config file once per environment, so a project that lists this plugin
419
+ * in `vite.config.ts` — every project — got a *fresh* instance per environment, each with
420
+ * its own compilation session, context and ts-morph project. Nothing an environment
421
+ * established could then be seen by the next one, which is the premise the reachability
422
+ * accounting below is built on, and it also meant the whole config load and extraction
423
+ * happened once per environment.
424
+ */
425
+ sharedDuringBuild: true,
359
426
  configResolved(config) {
360
427
  command = config.command;
361
428
  session.sourcemap = config.build.sourcemap;
429
+ if (config.builder && config.environments) session.expectedEnvironments = new Set(Object.keys(config.environments));
430
+ },
431
+ /**
432
+ * The definitive environment list, for a run that reaches `builder.buildApp()` without
433
+ * configuring `builder` — the shape `vite build` itself takes, where exactly one
434
+ * environment is set up and pruning is therefore safe.
435
+ */
436
+ buildApp: {
437
+ order: "pre",
438
+ async handler(builder) {
439
+ session.expectedEnvironments = new Set(Object.keys(builder.environments));
440
+ }
362
441
  },
363
442
  resolveId(id) {
364
443
  const query = queryOf(id);
@@ -398,8 +477,34 @@ const bamboocssCss = (options) => {
398
477
  generateBundle: {
399
478
  order: "post",
400
479
  handler(_, bundle) {
401
- optimizeStaticCssAssets(bundle, session, { rename: renameCssAsset });
402
- if (!servedEnvironments.has(this.environment?.name ?? "default")) return;
480
+ const environment = this.environment;
481
+ /**
482
+ * Every environment of this run has already had its modules compiled.
483
+ *
484
+ * Reachability is what pruning removes rules against, and it is only complete once
485
+ * nothing is left to contribute to it. The stylesheet is emitted and finalized by the
486
+ * environment that *imports* it, which in an SSR app is the client — and the client
487
+ * builds first, before the server environment has transformed a single module. Pruning
488
+ * there deletes every rule for a class only the server graph reaches, and the pages
489
+ * link the pruned copy: one project lost 39% of its atoms that way, presenting as
490
+ * rarely-used classes such as `md:{display:inline-block}` silently not applying.
491
+ *
492
+ * So the full extracted stylesheet ships instead, which is what `pruneCss: false` asks
493
+ * for by hand. Being the last environment is not the common case — frameworks build the
494
+ * client first — but it is the only one where the answer is knowable, and a framework
495
+ * that builds its server bundle first does get pruned output.
496
+ */
497
+ const pending = remainingEnvironments(session);
498
+ const { sheets } = optimizeStaticCssAssets(bundle, session, {
499
+ prune: pruneCss && pending.length === 0,
500
+ sourcemap: environment?.config?.build?.sourcemap
501
+ });
502
+ if (sheets && !pruneCss) _bamboocss_logger.logger.info("vite", "Reachability pruning is off (`pruneCss: false`). The full extracted stylesheet ships.");
503
+ else if (sheets && pending.length) _bamboocss_logger.logger.info("vite", `Reachability pruning skipped: the stylesheet is emitted by the ${JSON.stringify(environment?.name ?? "default")} environment, and ${(0, _bamboocss_shared.truncateList)(pending, {
504
+ unit: "environment",
505
+ separator: ", "
506
+ })} ${pending.length === 1 ? "has" : "have"} not been compiled in this run. The full extracted stylesheet ships — nothing is missing from it.`);
507
+ if (!servedEnvironments.has(environment?.name ?? "default")) return;
403
508
  if (!session.transformedFiles.size) return;
404
509
  if (!Object.values(bundle).some((output) => {
405
510
  if (!carriesGeneratedCss(output)) return false;
@@ -412,6 +517,78 @@ const bamboocssCss = (options) => {
412
517
  //#endregion
413
518
  //#region src/fold-analysis.ts
414
519
  /**
520
+ * Nodes that *compose* a value out of their children rather than computing one.
521
+ *
522
+ * The boundary of the walk below, and the whole of its precision. Climbing through these
523
+ * keeps a value's provenance: `'red.300'` inside `{ color: 'red.300' }` inside a default is
524
+ * still the default's. Anything else — a call, a function body, a JSX element — produces its
525
+ * value by being evaluated, so what is written inside it is an argument to that evaluation
526
+ * and not the enclosing default's value.
527
+ *
528
+ * Without the boundary, `({ cls = css({ color: 'red.300' }) }) => cls` is rejected: the call's
529
+ * own literal argument is syntactically inside a default, so an unbounded walk calls it one.
530
+ * That is correct code, and rejecting it fails the build.
531
+ */
532
+ const composesValue = (node) => ts_morph.Node.isObjectLiteralExpression(node) || ts_morph.Node.isArrayLiteralExpression(node) || ts_morph.Node.isPropertyAssignment(node) || ts_morph.Node.isShorthandPropertyAssignment(node) || ts_morph.Node.isSpreadAssignment(node) || ts_morph.Node.isSpreadElement(node) || ts_morph.Node.isAsExpression(node) || ts_morph.Node.isParenthesizedExpression(node) || ts_morph.Node.isNonNullExpression(node) || ts_morph.Node.isTypeAssertion(node) || ts_morph.Node.isSatisfiesExpression(node);
533
+ /** Is `inner` written within `outer`? Positions rather than a walk, so it is O(1). */
534
+ const contains = (outer, inner) => outer.getSourceFile() === inner.getSourceFile() && outer.getStart() <= inner.getStart() && inner.getEnd() <= outer.getEnd();
535
+ /**
536
+ * Did this value come from the `= …` of a destructuring binding?
537
+ *
538
+ * `const { tone = 'red.300' } = source` boxes as the literal `'red.300'`: the extractor's
539
+ * `maybeDefinitionValue` tests for an initializer first and returns the boxed default, never
540
+ * reaching the branch that would read `source`. So the default is reported as the value whether
541
+ * or not it is the one that applies.
542
+ *
543
+ * For extraction that is merely optimistic, and deliberately so: a CLI or PostCSS build ships a
544
+ * runtime `css()`, where the default genuinely does apply when the caller omits the key, and it
545
+ * needs a rule behind it. Folding is where the same resolution turns into a wrong answer,
546
+ * because the call is *replaced* by that value.
547
+ *
548
+ * Stops at the first non-composing parent, so a call written inside a default keeps its own
549
+ * provenance, and checks that the binding element was reached through its initializer, so
550
+ * `{ tone = X }`'s name node is not mistaken for its default.
551
+ */
552
+ const isBindingElementDefault = (node) => {
553
+ if (node && ts_morph.Node.isCallExpression(node)) return false;
554
+ let current = node;
555
+ while (current) {
556
+ const parent = current.getParent();
557
+ if (!parent) return false;
558
+ if (ts_morph.Node.isBindingElement(parent)) return parent.getInitializer() === current;
559
+ if (!composesValue(parent)) return false;
560
+ current = parent;
561
+ }
562
+ return false;
563
+ };
564
+ /**
565
+ * The same question asked of a whole box, including how it was resolved.
566
+ *
567
+ * The node a box reports is not always the one its value came from — an empty `{}` default
568
+ * boxes against the call rather than against the `{}` — so the resolution stack is consulted
569
+ * too. A binding element reaches that stack by having been resolved *through*, which is the
570
+ * signal the extractor itself reads when one of these is a conditional's test.
571
+ *
572
+ * Only the entries that are binding elements are examined. Walking up from every other entry
573
+ * was tried and never once changed a verdict across the default spellings or the sandbox's own
574
+ * modules, while accounting for most of the parent hops this does — the stack carries nodes
575
+ * that were never resolved through, including a call's own arguments, so walking from them is
576
+ * both the expensive half and the one that reaches conclusions it has no basis for.
577
+ *
578
+ * A binding element without an initializer carries no default to mistrust: `const { tone } =
579
+ * source` either resolves from `source` or does not resolve at all.
580
+ */
581
+ const isFromBindingDefault = (node) => {
582
+ const own = node.getNode?.();
583
+ if (isBindingElementDefault(own)) return true;
584
+ for (const entry of node.getStack?.() ?? []) {
585
+ if (!ts_morph.Node.isBindingElement(entry) || !entry.getInitializer()) continue;
586
+ if (own && contains(entry, own)) continue;
587
+ return true;
588
+ }
589
+ return false;
590
+ };
591
+ /**
415
592
  * Statically resolvable means: every box in the tree carries a known value.
416
593
  *
417
594
  * `unresolvable` is the extractor saying it could not evaluate a node.
@@ -425,6 +602,7 @@ const isStaticBox = (node, seen = /* @__PURE__ */ new Set()) => {
425
602
  seen.add(node);
426
603
  if (_bamboocss_extractor.box.isUnresolvable(node) || _bamboocss_extractor.box.isConditional(node)) return false;
427
604
  if (!("type" in node) || node.type == null) return false;
605
+ if (isFromBindingDefault(node)) return false;
428
606
  if (_bamboocss_extractor.box.isLiteral(node) && node.value === void 0) {
429
607
  const source = node.getNode?.();
430
608
  return Boolean(source && ts_morph.Node.isIdentifier(source) && source.getText() === "undefined");
@@ -2784,40 +2962,50 @@ const createStaticStyleSetCompiler = (ctx, runtimeCss, allocateClassString = (cl
2784
2962
  };
2785
2963
  };
2786
2964
  //#endregion
2787
- //#region src/static-session.ts
2788
- const createStaticCompilationSession = () => {
2789
- const session = {
2790
- utilityLayer: "utilities",
2791
- sourcemap: false,
2792
- cssLoaded: false,
2793
- transformedFiles: /* @__PURE__ */ new Set(),
2794
- extractedFiles: /* @__PURE__ */ new Set(),
2795
- prunableClasses: /* @__PURE__ */ new Set(),
2796
- viewTransitionClasses: /* @__PURE__ */ new Set(),
2797
- usedClasses: /* @__PURE__ */ new Set(),
2798
- markClassUsed(className) {
2799
- for (const token of className.split(" ")) {
2800
- if (!token) continue;
2801
- session.usedClasses.add(token.includes("\\") ? token : (0, _bamboocss_shared.esc)(token));
2802
- }
2803
- }
2804
- };
2805
- return session;
2806
- };
2807
- const resetStaticCompilationSession = (session) => {
2808
- session.cssLoaded = false;
2809
- session.transformedFiles.clear();
2810
- session.extractedFiles.clear();
2811
- session.prunableClasses.clear();
2812
- session.viewTransitionClasses.clear();
2813
- session.usedClasses.clear();
2814
- };
2815
- //#endregion
2816
2965
  //#region src/plugin.ts
2817
2966
  const DEFAULT_EXTENSIONS = /\.(?:[cm]?[jt]sx?)$/;
2818
2967
  const NODE_MODULES = /node_modules/;
2968
+ /**
2969
+ * Queries that make Vite serve something other than the module's own source.
2970
+ *
2971
+ * `./theme.tsx?raw` is a module whose text is `export default "…"`, and `?url`, `?worker` and
2972
+ * `?sharedworker` are wrappers of the same kind. The query has to be stripped before the
2973
+ * extension is tested — otherwise nothing matches `.tsx` — and stripping it is what made these
2974
+ * look like the file itself. The transform then handed the wrapper's text to ts-morph *under
2975
+ * the real file's path*, overwriting the parsed module every fold reads for that path.
2976
+ *
2977
+ * That is not theoretical: a module folding `css(shared)` against a sibling the entry also
2978
+ * imported as `?raw` failed the build with "1 call(s) could not be compiled" — the compiler
2979
+ * had read `export default "…"` and found no `shared` to resolve. The advice it prints, to
2980
+ * make the value statically analyzable, is unfollowable, because the source already was.
2981
+ *
2982
+ * Whether it bites depends on which of the two ids Rollup transforms last, so the same project
2983
+ * can build and then stop building because an import moved.
2984
+ *
2985
+ * A deny list rather than an allow list of benign queries: dev ids carry `?t=` after an edit
2986
+ * and `?import` when a dynamic import is rewritten, and rejecting an unrecognised one of those
2987
+ * would silently stop folding a module rather than loudly refuse it.
2988
+ *
2989
+ * Exactly these four, matching Vite's own `SPECIAL_QUERY_RE`. The list was drafted wider —
2990
+ * `?inline`, `?no-inline`, `?worklet`, `?init` — and every one of those was wrong: Vite has no
2991
+ * `worklet` query at all, `?init` is `.wasm` only and that extension is already rejected below,
2992
+ * and `inline`/`no-inline` merely pick base64-versus-file for something that *already* matched
2993
+ * `raw`/`url`, so `./a.tsx?inline` is served as the module's own source. Rejecting an id that
2994
+ * carries real source is the expensive direction: the transform declines, its atoms never reach
2995
+ * the reachability set, pruning removes their rules, and the runtime still returns the class
2996
+ * names — unstyled elements, no error. Only names verified against Vite belong here.
2997
+ *
2998
+ * Note `?worker_file`, which is how dev serves a worker's *real* source, is deliberately absent
2999
+ * and must stay absent. It contains "worker" and is the obvious next entry; adding it would
3000
+ * stop folding every worker module in dev, silently, by the mechanism above.
3001
+ *
3002
+ * Tested against the whole id rather than a split-off query, so it cannot disagree with
3003
+ * `queryOf` in `css.ts` about where the query starts.
3004
+ */
3005
+ const WRAPPED_MODULE_QUERY = /[?&](?:raw|url|worker|sharedworker)(?:&|=|$)/;
2819
3006
  const shouldTransform = (id) => {
2820
3007
  if (id.startsWith("\0")) return false;
3008
+ if (WRAPPED_MODULE_QUERY.test(id)) return false;
2821
3009
  const [filePath] = id.split("?");
2822
3010
  if (!filePath) return false;
2823
3011
  if (NODE_MODULES.test(filePath)) return false;
@@ -2864,17 +3052,26 @@ const formatSkipped = (id, skipped) => {
2864
3052
  * with no matching rule.
2865
3053
  */
2866
3054
  const bamboocss = (options = {}) => {
2867
- const { configPath, cwd, reportSkipped = false, reportSummary = true, maxRecipeStates, renameCssAsset = true } = options;
3055
+ const { configPath, cwd, reportSkipped = false, reportSummary = true, maxRecipeStates, pruneCss = true } = options;
2868
3056
  if (maxRecipeStates !== void 0 && (!Number.isSafeInteger(maxRecipeStates) || maxRecipeStates < 1)) throw new Error("bamboocss: `maxRecipeStates` must be a positive safe integer.");
2869
- /** Environments whose `buildStart` has run in the build currently in progress. */
2870
- const seenEnvironments = /* @__PURE__ */ new Set();
2871
- /** Totals across the build, for the summary. */
2872
- const totals = {
2873
- folded: 0,
2874
- files: 0,
2875
- filesWithFolds: 0,
2876
- skipped: /* @__PURE__ */ new Map()
2877
- };
3057
+ if ("renameCssAsset" in options) throw new Error("bamboocss: `renameCssAsset` has been replaced by `pruneCss`. Use `pruneCss: false` for what `renameCssAsset: false` did — it always disabled the pruning as well, since pruned bytes under the unpruned sheet's name is what lets a CDN serve a stale stylesheet. The new name says which of the two it is really about.");
3058
+ /**
3059
+ * What each file's transform found, for the summary. Keyed by file rather than summed as it
3060
+ * goes, because a build has more than one environment and they share most of their modules.
3061
+ *
3062
+ * Running totals double-counted every shared module once per environment — a two-environment
3063
+ * build of one shared file and one entry each reported "2/2 across 2/4 files" for three
3064
+ * source modules. Coverage is a property of the source, not of how many times a bundler
3065
+ * handed the same file over. It also grew without bound in dev, where every HMR
3066
+ * re-transform of a file counted as another file.
3067
+ *
3068
+ * A second pass over a file replaces its entry rather than adding to it. Both environments
3069
+ * are assumed to compute the same answer for the same module — true of this compiler, though
3070
+ * not something the plugin can enforce, since another `pre` plugin may hand each environment
3071
+ * different code. Where they disagree the last one wins, which is a cosmetic number either
3072
+ * way.
3073
+ */
3074
+ const perFile = /* @__PURE__ */ new Map();
2878
3075
  const staticSession = createStaticCompilationSession();
2879
3076
  /**
2880
3077
  * Indexed by file, because the only bulk operation on it is "forget this one's".
@@ -2937,26 +3134,24 @@ const bamboocss = (options = {}) => {
2937
3134
  configPath,
2938
3135
  cwd,
2939
3136
  session: staticSession,
2940
- renameCssAsset
3137
+ pruneCss
2941
3138
  }), {
2942
3139
  name: "bamboocss:compiler",
2943
3140
  enforce: "pre",
3141
+ /** See the same declaration on the css plugin: one instance per build, not per environment. */
3142
+ sharedDuringBuild: true,
2944
3143
  configResolved(config) {
2945
3144
  command = config.command;
2946
3145
  },
2947
3146
  async buildStart() {
2948
3147
  const environment = this.environment?.name ?? "default";
2949
- if (seenEnvironments.has(environment)) {
2950
- seenEnvironments.clear();
2951
- totals.folded = 0;
2952
- totals.files = 0;
2953
- totals.filesWithFolds = 0;
2954
- totals.skipped.clear();
3148
+ if (staticSession.startedEnvironments.has(environment)) {
3149
+ perFile.clear();
2955
3150
  survivorsByFile.clear();
2956
3151
  recipeConfigCache.clear();
2957
3152
  resetStaticCompilationSession(staticSession);
2958
3153
  }
2959
- seenEnvironments.add(environment);
3154
+ staticSession.startedEnvironments.add(environment);
2960
3155
  try {
2961
3156
  await ensureContext();
2962
3157
  } catch (error) {
@@ -3026,8 +3221,10 @@ const bamboocss = (options = {}) => {
3026
3221
  });
3027
3222
  } catch (error) {
3028
3223
  _bamboocss_logger.logger.caughtError("vite:transform", `Failed to compile ${filePath}`, error);
3029
- totals.files++;
3030
- totals.skipped.set("compile-failed", (totals.skipped.get("compile-failed") ?? 0) + 1);
3224
+ perFile.set(filePath, {
3225
+ folded: 0,
3226
+ skipped: new Map([["compile-failed", 1]])
3227
+ });
3031
3228
  addSurvivor({
3032
3229
  file: filePath,
3033
3230
  line: 1,
@@ -3037,10 +3234,15 @@ const bamboocss = (options = {}) => {
3037
3234
  if (command === "serve") throw asError(error, `failed to compile ${filePath}`);
3038
3235
  return null;
3039
3236
  }
3040
- totals.files++;
3041
- totals.folded += result.folded.length;
3042
- if (result.folded.length) totals.filesWithFolds++;
3043
- for (const entry of result.skipped) totals.skipped.set(entry.reason, (totals.skipped.get(entry.reason) ?? 0) + 1);
3237
+ let skippedHere;
3238
+ for (const entry of result.skipped) {
3239
+ skippedHere ??= /* @__PURE__ */ new Map();
3240
+ skippedHere.set(entry.reason, (skippedHere.get(entry.reason) ?? 0) + 1);
3241
+ }
3242
+ perFile.set(filePath, {
3243
+ folded: result.folded.length,
3244
+ skipped: skippedHere
3245
+ });
3044
3246
  if (result.folded.some((entry) => entry.kind === "class" || entry.kind === "slots")) staticSession.transformedFiles.add((0, node_path.resolve)(filePath));
3045
3247
  for (const entry of result.folded) for (const className of entry.classNames) staticSession.markClassUsed(className);
3046
3248
  for (const entry of result.skipped) {
@@ -3067,7 +3269,15 @@ const bamboocss = (options = {}) => {
3067
3269
  buildEnd() {
3068
3270
  const survivors = allSurvivors();
3069
3271
  if (survivors.length) throw createSurvivorError(survivors);
3070
- if (typeof this.getModuleInfo === "function") {
3272
+ const lost = [...staticSession.usedClasses].filter((className) => staticSession.prunedClasses.has(bare(className)));
3273
+ if (lost.length) {
3274
+ const environment = this.environment?.name ?? "default";
3275
+ throw new Error(`bamboocss: ${lost.length} class(es) compiled in the ${JSON.stringify(environment)} environment were already pruned out of a stylesheet emitted by an earlier one. Elements carrying them would render unstyled.\n\n${(0, _bamboocss_shared.truncateList)(lost.map((className) => ` ${className}`), {
3276
+ unit: "class",
3277
+ separator: "\n"
3278
+ })}\n\nThe stylesheet is finalized by the environment that imports it, so pruning it is only safe once every environment has been compiled. This build did not say how many there would be: it called \`builder.build(environment)\` directly. Run it through \`vite build\`, call \`builder.buildApp()\`, or set \`builder: {}\` in the Vite config so the environments are known before the first one builds. \`bamboocss({ pruneCss: false })\` also turns pruning off entirely.`);
3279
+ }
3280
+ if (typeof this.getModuleInfo === "function" && !remainingEnvironments(staticSession).length) {
3071
3281
  if (!staticSession.cssLoaded) throw new Error(`bamboocss: compiled class values were produced, but ${JSON.stringify(VIRTUAL_CSS_ID)} was not imported. Add \`import ${JSON.stringify(VIRTUAL_CSS_ID)}\` once, from a JavaScript or TypeScript module in the application entry graph.\n\nIt has to be a JS import. \`@import\` from a stylesheet does not reach it: the id names a virtual module resolved by this plugin, and Vite resolves CSS \`@import\` before plugin resolution, so it fails as an unresolvable path. A project that ships one preloaded stylesheet imports this from its entry module instead, and lets Vite emit the CSS asset.`);
3072
3282
  const outsideExtraction = [...staticSession.transformedFiles].filter((file) => !staticSession.extractedFiles.has(file));
3073
3283
  if (outsideExtraction.length) throw new Error(`bamboocss: ${outsideExtraction.length} statically compiled module(s) are outside the CSS extraction graph:\n\n${(0, _bamboocss_shared.truncateList)(outsideExtraction.map((file) => ` ${file}`), {
@@ -3076,12 +3286,21 @@ const bamboocss = (options = {}) => {
3076
3286
  })}\n\nAdd them to \`include\` in bamboo.config, or no CSS rule can back their emitted classes.`);
3077
3287
  }
3078
3288
  if (!reportSummary) return;
3079
- const declined = Array.from(totals.skipped.values()).reduce((sum, count) => sum + count, 0);
3080
- const total = totals.folded + declined;
3289
+ if (command === "build" && remainingEnvironments(staticSession).length) return;
3290
+ let folded = 0;
3291
+ let filesWithFolds = 0;
3292
+ const skipped = /* @__PURE__ */ new Map();
3293
+ for (const entry of perFile.values()) {
3294
+ folded += entry.folded;
3295
+ if (entry.folded) filesWithFolds++;
3296
+ for (const [reason, count] of entry.skipped ?? []) skipped.set(reason, (skipped.get(reason) ?? 0) + count);
3297
+ }
3298
+ const declined = Array.from(skipped.values()).reduce((sum, count) => sum + count, 0);
3299
+ const total = folded + declined;
3081
3300
  if (!total) return;
3082
- const share = Math.round(totals.folded / total * 100);
3083
- const reasons = Array.from(totals.skipped.entries()).sort((a, b) => b[1] - a[1]).map(([reason, count]) => `${reason}=${count}`).join(" ");
3084
- _bamboocss_logger.logger.info("vite:transform", `Compiled ${totals.folded}/${total} (${share}%) across ${totals.filesWithFolds}/${totals.files} files` + (reasons ? ` — declined: ${reasons}` : ""));
3301
+ const share = Math.round(folded / total * 100);
3302
+ const reasons = Array.from(skipped.entries()).sort((a, b) => b[1] - a[1]).map(([reason, count]) => `${reason}=${count}`).join(" ");
3303
+ _bamboocss_logger.logger.info("vite:transform", `Compiled ${folded}/${total} (${share}%) across ${filesWithFolds}/${perFile.size} files` + (reasons ? ` — declined: ${reasons}` : ""));
3085
3304
  }
3086
3305
  }];
3087
3306
  };
package/dist/index.d.cts CHANGED
@@ -37,21 +37,32 @@ interface BambooVitePluginOptions {
37
37
  */
38
38
  maxRecipeStates?: number;
39
39
  /**
40
- * Give the pruned stylesheet a final name derived from its own bytes.
40
+ * Remove rules for atoms no compiled module can emit. Builds only; dev never prunes.
41
41
  *
42
- * Rollup and Rolldown both expand `[hash]` before `generateBundle`, so pruning after it can
43
- * leave two different reachable subsets under one CDN key. Renaming the pruned stylesheet to
44
- * a hash of its own bytes closes that.
42
+ * Off ships the whole extracted stylesheet: every rule the source graph produced, including
43
+ * ones nothing reaches. Larger, and never wrong *by pruning* it also stands down the
44
+ * assertion that every compiled class has a rule, since that check exists to catch this pass
45
+ * removing too much. So this is a true escape hatch: it cannot fail a build over reachability.
45
46
  *
46
- * Turning it off does not merely skip the rename it skips the pruning with it. The two are
47
- * one operation: pruned bytes under a name describing the unpruned ones is how a stale
48
- * stylesheet outlives a deploy, which is worse than shipping a larger sheet. Reach for this
49
- * only when something downstream cannot follow a renamed asset, and expect the full
50
- * extracted stylesheet when you do.
47
+ * The pruned sheet is also renamed to a hash of its own bytes, and that is not a separate
48
+ * setting because it cannot safely be one. Rollup and Rolldown expand `[hash]` before
49
+ * `generateBundle`, where pruning has to run, so the name Vite assigned describes the sheet
50
+ * as it was *before* pruning. Leaving that name on pruned bytes is how a stale stylesheet
51
+ * outlives a deploy a change to reachability alone, which is what upgrading Bamboo is,
52
+ * leaves identical source CSS under an identical name with different content, and a CDN
53
+ * holding that key keeps serving the old one. So the bytes and the name move together or
54
+ * neither does.
55
+ *
56
+ * Reach for this if something downstream derives an artifact from the stylesheet's *content*
57
+ * during `generateBundle` before Bamboo runs — subresource integrity is the clear case, since
58
+ * an `integrity` attribute is a digest of the bytes and no amount of reference rewriting can
59
+ * carry it across an edit — or to rule pruning out while diagnosing a missing rule. Where the
60
+ * consumer can be moved after Bamboo instead (`order: 'post'`, `writeBundle`, `closeBundle`),
61
+ * do that and keep the pruning.
51
62
  *
52
63
  * @default true
53
64
  */
54
- renameCssAsset?: boolean;
65
+ pruneCss?: boolean;
55
66
  }
56
67
  /**
57
68
  * Vite integration for Bamboo CSS.
package/dist/index.d.mts CHANGED
@@ -37,21 +37,32 @@ interface BambooVitePluginOptions {
37
37
  */
38
38
  maxRecipeStates?: number;
39
39
  /**
40
- * Give the pruned stylesheet a final name derived from its own bytes.
40
+ * Remove rules for atoms no compiled module can emit. Builds only; dev never prunes.
41
41
  *
42
- * Rollup and Rolldown both expand `[hash]` before `generateBundle`, so pruning after it can
43
- * leave two different reachable subsets under one CDN key. Renaming the pruned stylesheet to
44
- * a hash of its own bytes closes that.
42
+ * Off ships the whole extracted stylesheet: every rule the source graph produced, including
43
+ * ones nothing reaches. Larger, and never wrong *by pruning* it also stands down the
44
+ * assertion that every compiled class has a rule, since that check exists to catch this pass
45
+ * removing too much. So this is a true escape hatch: it cannot fail a build over reachability.
45
46
  *
46
- * Turning it off does not merely skip the rename it skips the pruning with it. The two are
47
- * one operation: pruned bytes under a name describing the unpruned ones is how a stale
48
- * stylesheet outlives a deploy, which is worse than shipping a larger sheet. Reach for this
49
- * only when something downstream cannot follow a renamed asset, and expect the full
50
- * extracted stylesheet when you do.
47
+ * The pruned sheet is also renamed to a hash of its own bytes, and that is not a separate
48
+ * setting because it cannot safely be one. Rollup and Rolldown expand `[hash]` before
49
+ * `generateBundle`, where pruning has to run, so the name Vite assigned describes the sheet
50
+ * as it was *before* pruning. Leaving that name on pruned bytes is how a stale stylesheet
51
+ * outlives a deploy a change to reachability alone, which is what upgrading Bamboo is,
52
+ * leaves identical source CSS under an identical name with different content, and a CDN
53
+ * holding that key keeps serving the old one. So the bytes and the name move together or
54
+ * neither does.
55
+ *
56
+ * Reach for this if something downstream derives an artifact from the stylesheet's *content*
57
+ * during `generateBundle` before Bamboo runs — subresource integrity is the clear case, since
58
+ * an `integrity` attribute is a digest of the bytes and no amount of reference rewriting can
59
+ * carry it across an edit — or to rule pruning out while diagnosing a missing rule. Where the
60
+ * consumer can be moved after Bamboo instead (`order: 'post'`, `writeBundle`, `closeBundle`),
61
+ * do that and keep the pruning.
51
62
  *
52
63
  * @default true
53
64
  */
54
- renameCssAsset?: boolean;
65
+ pruneCss?: boolean;
55
66
  }
56
67
  /**
57
68
  * Vite integration for Bamboo CSS.
package/dist/index.mjs CHANGED
@@ -64,6 +64,7 @@ const pruneStaticCss = (css, session, { prune = true } = {}) => {
64
64
  if (!className || !prunable.has(className) || used.has(className)) return;
65
65
  candidate.remove();
66
66
  removedAny = true;
67
+ session.prunedClasses.add(className);
67
68
  });
68
69
  }).processSync(rule.selector);
69
70
  } catch {
@@ -124,6 +125,56 @@ const pruneStaticCss = (css, session, { prune = true } = {}) => {
124
125
  return root.toString();
125
126
  };
126
127
  //#endregion
128
+ //#region src/static-session.ts
129
+ const createStaticCompilationSession = () => {
130
+ const session = {
131
+ utilityLayer: "utilities",
132
+ sourcemap: false,
133
+ cssLoaded: false,
134
+ transformedFiles: /* @__PURE__ */ new Set(),
135
+ extractedFiles: /* @__PURE__ */ new Set(),
136
+ prunableClasses: /* @__PURE__ */ new Set(),
137
+ viewTransitionClasses: /* @__PURE__ */ new Set(),
138
+ usedClasses: /* @__PURE__ */ new Set(),
139
+ expectedEnvironments: void 0,
140
+ startedEnvironments: /* @__PURE__ */ new Set(),
141
+ prunedClasses: /* @__PURE__ */ new Set(),
142
+ markClassUsed(className) {
143
+ for (const token of className.split(" ")) {
144
+ if (!token) continue;
145
+ session.usedClasses.add(token.includes("\\") ? token : esc(token));
146
+ }
147
+ }
148
+ };
149
+ return session;
150
+ };
151
+ /**
152
+ * Environments this run intends to build that have not been compiled yet.
153
+ *
154
+ * Empty means everything the run will contribute has been contributed, which is the condition
155
+ * every whole-run judgement here waits for: pruning the stylesheet against reachability, and
156
+ * the two guards that ask whether the compiled modules and the extraction graph agree. Each of
157
+ * those is false about a build in progress and true only about a finished one.
158
+ *
159
+ * Empty is also the answer for a single-environment build, where nothing announced an
160
+ * environment list because there is only ever one — so that path is unchanged.
161
+ *
162
+ * An environment a run declares and then never builds leaves this permanently non-empty, and
163
+ * those judgements are skipped for the run. Every one of them errs towards shipping more CSS
164
+ * or asserting less, so that is the safe direction to be wrong in.
165
+ */
166
+ const remainingEnvironments = (session) => [...session.expectedEnvironments ?? []].filter((name) => !session.startedEnvironments.has(name));
167
+ const resetStaticCompilationSession = (session) => {
168
+ session.cssLoaded = false;
169
+ session.transformedFiles.clear();
170
+ session.extractedFiles.clear();
171
+ session.prunableClasses.clear();
172
+ session.viewTransitionClasses.clear();
173
+ session.usedClasses.clear();
174
+ session.startedEnvironments.clear();
175
+ session.prunedClasses.clear();
176
+ };
177
+ //#endregion
127
178
  //#region src/css.ts
128
179
  /**
129
180
  * What a project imports to get the stylesheet.
@@ -225,32 +276,37 @@ const replaceAssetReferences = (bundle, previous, next, sourcemap) => {
225
276
  */
226
277
  const carriesGeneratedCss = (output) => output.type === "asset" && output.fileName.endsWith(".css");
227
278
  /**
228
- * Prune and rename compiler-owned CSS, then give changed assets a hash of their final bytes.
279
+ * Prune compiler-owned CSS, then give any sheet whose bytes changed a hash of those bytes.
229
280
  *
230
281
  * Rollup has already expanded `[hash]` when `generateBundle` runs. Mutating only `source`
231
282
  * would therefore leave two different reachable subsets under one CDN key. The extra final
232
283
  * hash is not cosmetic: it makes late graph reachability cache-safe.
284
+ *
285
+ * Renaming is therefore not a choice this takes. Pruned bytes under the unpruned sheet's name
286
+ * is the one outcome that must never be reachable, and a sheet nothing was removed from keeps
287
+ * its name because its bytes are unchanged — so "rename" is a consequence of "the bytes moved",
288
+ * not a second option. `prune` is the only knob.
233
289
  */
234
290
  const optimizeStaticCssAssets = (bundle, session, options = {}) => {
235
- const { rename = true } = options;
291
+ const { prune = true, sourcemap = session.sourcemap } = options;
292
+ /** Assets in this bundle that carry the generated stylesheet, pruned or not. */
293
+ let sheets = 0;
236
294
  for (const output of Object.values(bundle)) {
237
295
  if (!carriesGeneratedCss(output)) continue;
238
296
  const source = typeof output.source === "string" ? output.source : Buffer.from(output.source).toString();
239
297
  if (!source.includes("--made-with-bamboo")) continue;
298
+ sheets++;
299
+ if (!prune) continue;
240
300
  const optimized = pruneStaticCss(source, session);
241
301
  output.source = optimized;
242
302
  if (optimized === source) continue;
243
- if (!rename) {
244
- output.source = source;
245
- continue;
246
- }
247
303
  const nextName = output.fileName.replace(/\.css$/, `.b-${toHash(optimized)}.css`);
248
- if (nextName === output.fileName) continue;
249
304
  if (bundle[nextName] && bundle[nextName] !== output) throw new Error(`bamboocss: final CSS asset name collision at ${JSON.stringify(nextName)}.`);
250
305
  const previous = output.fileName;
251
306
  output.fileName = nextName;
252
- replaceAssetReferences(bundle, previous, nextName, session.sourcemap);
307
+ replaceAssetReferences(bundle, previous, nextName, sourcemap);
253
308
  }
309
+ return { sheets };
254
310
  };
255
311
  /**
256
312
  * Serve bamboo's stylesheet as a virtual module, in dev and in build.
@@ -266,7 +322,7 @@ const optimizeStaticCssAssets = (bundle, session, options = {}) => {
266
322
  * process just wrote, which is a race on any watch rebuild.
267
323
  */
268
324
  const bamboocssCss = (options) => {
269
- const { configPath, cwd, session, renameCssAsset = true } = options;
325
+ const { configPath, cwd, session, pruneCss = true } = options;
270
326
  /**
271
327
  * Environments whose `load` served the virtual stylesheet.
272
328
  *
@@ -326,9 +382,32 @@ const bamboocssCss = (options) => {
326
382
  };
327
383
  return {
328
384
  name: "bamboocss:css",
385
+ /**
386
+ * One instance for every environment of a build, rather than one per environment.
387
+ *
388
+ * Vite re-reads the config file once per environment, so a project that lists this plugin
389
+ * in `vite.config.ts` — every project — got a *fresh* instance per environment, each with
390
+ * its own compilation session, context and ts-morph project. Nothing an environment
391
+ * established could then be seen by the next one, which is the premise the reachability
392
+ * accounting below is built on, and it also meant the whole config load and extraction
393
+ * happened once per environment.
394
+ */
395
+ sharedDuringBuild: true,
329
396
  configResolved(config) {
330
397
  command = config.command;
331
398
  session.sourcemap = config.build.sourcemap;
399
+ if (config.builder && config.environments) session.expectedEnvironments = new Set(Object.keys(config.environments));
400
+ },
401
+ /**
402
+ * The definitive environment list, for a run that reaches `builder.buildApp()` without
403
+ * configuring `builder` — the shape `vite build` itself takes, where exactly one
404
+ * environment is set up and pruning is therefore safe.
405
+ */
406
+ buildApp: {
407
+ order: "pre",
408
+ async handler(builder) {
409
+ session.expectedEnvironments = new Set(Object.keys(builder.environments));
410
+ }
332
411
  },
333
412
  resolveId(id) {
334
413
  const query = queryOf(id);
@@ -368,8 +447,34 @@ const bamboocssCss = (options) => {
368
447
  generateBundle: {
369
448
  order: "post",
370
449
  handler(_, bundle) {
371
- optimizeStaticCssAssets(bundle, session, { rename: renameCssAsset });
372
- if (!servedEnvironments.has(this.environment?.name ?? "default")) return;
450
+ const environment = this.environment;
451
+ /**
452
+ * Every environment of this run has already had its modules compiled.
453
+ *
454
+ * Reachability is what pruning removes rules against, and it is only complete once
455
+ * nothing is left to contribute to it. The stylesheet is emitted and finalized by the
456
+ * environment that *imports* it, which in an SSR app is the client — and the client
457
+ * builds first, before the server environment has transformed a single module. Pruning
458
+ * there deletes every rule for a class only the server graph reaches, and the pages
459
+ * link the pruned copy: one project lost 39% of its atoms that way, presenting as
460
+ * rarely-used classes such as `md:{display:inline-block}` silently not applying.
461
+ *
462
+ * So the full extracted stylesheet ships instead, which is what `pruneCss: false` asks
463
+ * for by hand. Being the last environment is not the common case — frameworks build the
464
+ * client first — but it is the only one where the answer is knowable, and a framework
465
+ * that builds its server bundle first does get pruned output.
466
+ */
467
+ const pending = remainingEnvironments(session);
468
+ const { sheets } = optimizeStaticCssAssets(bundle, session, {
469
+ prune: pruneCss && pending.length === 0,
470
+ sourcemap: environment?.config?.build?.sourcemap
471
+ });
472
+ if (sheets && !pruneCss) logger.info("vite", "Reachability pruning is off (`pruneCss: false`). The full extracted stylesheet ships.");
473
+ else if (sheets && pending.length) logger.info("vite", `Reachability pruning skipped: the stylesheet is emitted by the ${JSON.stringify(environment?.name ?? "default")} environment, and ${truncateList(pending, {
474
+ unit: "environment",
475
+ separator: ", "
476
+ })} ${pending.length === 1 ? "has" : "have"} not been compiled in this run. The full extracted stylesheet ships — nothing is missing from it.`);
477
+ if (!servedEnvironments.has(environment?.name ?? "default")) return;
373
478
  if (!session.transformedFiles.size) return;
374
479
  if (!Object.values(bundle).some((output) => {
375
480
  if (!carriesGeneratedCss(output)) return false;
@@ -382,6 +487,78 @@ const bamboocssCss = (options) => {
382
487
  //#endregion
383
488
  //#region src/fold-analysis.ts
384
489
  /**
490
+ * Nodes that *compose* a value out of their children rather than computing one.
491
+ *
492
+ * The boundary of the walk below, and the whole of its precision. Climbing through these
493
+ * keeps a value's provenance: `'red.300'` inside `{ color: 'red.300' }` inside a default is
494
+ * still the default's. Anything else — a call, a function body, a JSX element — produces its
495
+ * value by being evaluated, so what is written inside it is an argument to that evaluation
496
+ * and not the enclosing default's value.
497
+ *
498
+ * Without the boundary, `({ cls = css({ color: 'red.300' }) }) => cls` is rejected: the call's
499
+ * own literal argument is syntactically inside a default, so an unbounded walk calls it one.
500
+ * That is correct code, and rejecting it fails the build.
501
+ */
502
+ const composesValue = (node) => Node.isObjectLiteralExpression(node) || Node.isArrayLiteralExpression(node) || Node.isPropertyAssignment(node) || Node.isShorthandPropertyAssignment(node) || Node.isSpreadAssignment(node) || Node.isSpreadElement(node) || Node.isAsExpression(node) || Node.isParenthesizedExpression(node) || Node.isNonNullExpression(node) || Node.isTypeAssertion(node) || Node.isSatisfiesExpression(node);
503
+ /** Is `inner` written within `outer`? Positions rather than a walk, so it is O(1). */
504
+ const contains = (outer, inner) => outer.getSourceFile() === inner.getSourceFile() && outer.getStart() <= inner.getStart() && inner.getEnd() <= outer.getEnd();
505
+ /**
506
+ * Did this value come from the `= …` of a destructuring binding?
507
+ *
508
+ * `const { tone = 'red.300' } = source` boxes as the literal `'red.300'`: the extractor's
509
+ * `maybeDefinitionValue` tests for an initializer first and returns the boxed default, never
510
+ * reaching the branch that would read `source`. So the default is reported as the value whether
511
+ * or not it is the one that applies.
512
+ *
513
+ * For extraction that is merely optimistic, and deliberately so: a CLI or PostCSS build ships a
514
+ * runtime `css()`, where the default genuinely does apply when the caller omits the key, and it
515
+ * needs a rule behind it. Folding is where the same resolution turns into a wrong answer,
516
+ * because the call is *replaced* by that value.
517
+ *
518
+ * Stops at the first non-composing parent, so a call written inside a default keeps its own
519
+ * provenance, and checks that the binding element was reached through its initializer, so
520
+ * `{ tone = X }`'s name node is not mistaken for its default.
521
+ */
522
+ const isBindingElementDefault = (node) => {
523
+ if (node && Node.isCallExpression(node)) return false;
524
+ let current = node;
525
+ while (current) {
526
+ const parent = current.getParent();
527
+ if (!parent) return false;
528
+ if (Node.isBindingElement(parent)) return parent.getInitializer() === current;
529
+ if (!composesValue(parent)) return false;
530
+ current = parent;
531
+ }
532
+ return false;
533
+ };
534
+ /**
535
+ * The same question asked of a whole box, including how it was resolved.
536
+ *
537
+ * The node a box reports is not always the one its value came from — an empty `{}` default
538
+ * boxes against the call rather than against the `{}` — so the resolution stack is consulted
539
+ * too. A binding element reaches that stack by having been resolved *through*, which is the
540
+ * signal the extractor itself reads when one of these is a conditional's test.
541
+ *
542
+ * Only the entries that are binding elements are examined. Walking up from every other entry
543
+ * was tried and never once changed a verdict across the default spellings or the sandbox's own
544
+ * modules, while accounting for most of the parent hops this does — the stack carries nodes
545
+ * that were never resolved through, including a call's own arguments, so walking from them is
546
+ * both the expensive half and the one that reaches conclusions it has no basis for.
547
+ *
548
+ * A binding element without an initializer carries no default to mistrust: `const { tone } =
549
+ * source` either resolves from `source` or does not resolve at all.
550
+ */
551
+ const isFromBindingDefault = (node) => {
552
+ const own = node.getNode?.();
553
+ if (isBindingElementDefault(own)) return true;
554
+ for (const entry of node.getStack?.() ?? []) {
555
+ if (!Node.isBindingElement(entry) || !entry.getInitializer()) continue;
556
+ if (own && contains(entry, own)) continue;
557
+ return true;
558
+ }
559
+ return false;
560
+ };
561
+ /**
385
562
  * Statically resolvable means: every box in the tree carries a known value.
386
563
  *
387
564
  * `unresolvable` is the extractor saying it could not evaluate a node.
@@ -395,6 +572,7 @@ const isStaticBox = (node, seen = /* @__PURE__ */ new Set()) => {
395
572
  seen.add(node);
396
573
  if (box.isUnresolvable(node) || box.isConditional(node)) return false;
397
574
  if (!("type" in node) || node.type == null) return false;
575
+ if (isFromBindingDefault(node)) return false;
398
576
  if (box.isLiteral(node) && node.value === void 0) {
399
577
  const source = node.getNode?.();
400
578
  return Boolean(source && Node.isIdentifier(source) && source.getText() === "undefined");
@@ -2754,40 +2932,50 @@ const createStaticStyleSetCompiler = (ctx, runtimeCss, allocateClassString = (cl
2754
2932
  };
2755
2933
  };
2756
2934
  //#endregion
2757
- //#region src/static-session.ts
2758
- const createStaticCompilationSession = () => {
2759
- const session = {
2760
- utilityLayer: "utilities",
2761
- sourcemap: false,
2762
- cssLoaded: false,
2763
- transformedFiles: /* @__PURE__ */ new Set(),
2764
- extractedFiles: /* @__PURE__ */ new Set(),
2765
- prunableClasses: /* @__PURE__ */ new Set(),
2766
- viewTransitionClasses: /* @__PURE__ */ new Set(),
2767
- usedClasses: /* @__PURE__ */ new Set(),
2768
- markClassUsed(className) {
2769
- for (const token of className.split(" ")) {
2770
- if (!token) continue;
2771
- session.usedClasses.add(token.includes("\\") ? token : esc(token));
2772
- }
2773
- }
2774
- };
2775
- return session;
2776
- };
2777
- const resetStaticCompilationSession = (session) => {
2778
- session.cssLoaded = false;
2779
- session.transformedFiles.clear();
2780
- session.extractedFiles.clear();
2781
- session.prunableClasses.clear();
2782
- session.viewTransitionClasses.clear();
2783
- session.usedClasses.clear();
2784
- };
2785
- //#endregion
2786
2935
  //#region src/plugin.ts
2787
2936
  const DEFAULT_EXTENSIONS = /\.(?:[cm]?[jt]sx?)$/;
2788
2937
  const NODE_MODULES = /node_modules/;
2938
+ /**
2939
+ * Queries that make Vite serve something other than the module's own source.
2940
+ *
2941
+ * `./theme.tsx?raw` is a module whose text is `export default "…"`, and `?url`, `?worker` and
2942
+ * `?sharedworker` are wrappers of the same kind. The query has to be stripped before the
2943
+ * extension is tested — otherwise nothing matches `.tsx` — and stripping it is what made these
2944
+ * look like the file itself. The transform then handed the wrapper's text to ts-morph *under
2945
+ * the real file's path*, overwriting the parsed module every fold reads for that path.
2946
+ *
2947
+ * That is not theoretical: a module folding `css(shared)` against a sibling the entry also
2948
+ * imported as `?raw` failed the build with "1 call(s) could not be compiled" — the compiler
2949
+ * had read `export default "…"` and found no `shared` to resolve. The advice it prints, to
2950
+ * make the value statically analyzable, is unfollowable, because the source already was.
2951
+ *
2952
+ * Whether it bites depends on which of the two ids Rollup transforms last, so the same project
2953
+ * can build and then stop building because an import moved.
2954
+ *
2955
+ * A deny list rather than an allow list of benign queries: dev ids carry `?t=` after an edit
2956
+ * and `?import` when a dynamic import is rewritten, and rejecting an unrecognised one of those
2957
+ * would silently stop folding a module rather than loudly refuse it.
2958
+ *
2959
+ * Exactly these four, matching Vite's own `SPECIAL_QUERY_RE`. The list was drafted wider —
2960
+ * `?inline`, `?no-inline`, `?worklet`, `?init` — and every one of those was wrong: Vite has no
2961
+ * `worklet` query at all, `?init` is `.wasm` only and that extension is already rejected below,
2962
+ * and `inline`/`no-inline` merely pick base64-versus-file for something that *already* matched
2963
+ * `raw`/`url`, so `./a.tsx?inline` is served as the module's own source. Rejecting an id that
2964
+ * carries real source is the expensive direction: the transform declines, its atoms never reach
2965
+ * the reachability set, pruning removes their rules, and the runtime still returns the class
2966
+ * names — unstyled elements, no error. Only names verified against Vite belong here.
2967
+ *
2968
+ * Note `?worker_file`, which is how dev serves a worker's *real* source, is deliberately absent
2969
+ * and must stay absent. It contains "worker" and is the obvious next entry; adding it would
2970
+ * stop folding every worker module in dev, silently, by the mechanism above.
2971
+ *
2972
+ * Tested against the whole id rather than a split-off query, so it cannot disagree with
2973
+ * `queryOf` in `css.ts` about where the query starts.
2974
+ */
2975
+ const WRAPPED_MODULE_QUERY = /[?&](?:raw|url|worker|sharedworker)(?:&|=|$)/;
2789
2976
  const shouldTransform = (id) => {
2790
2977
  if (id.startsWith("\0")) return false;
2978
+ if (WRAPPED_MODULE_QUERY.test(id)) return false;
2791
2979
  const [filePath] = id.split("?");
2792
2980
  if (!filePath) return false;
2793
2981
  if (NODE_MODULES.test(filePath)) return false;
@@ -2834,17 +3022,26 @@ const formatSkipped = (id, skipped) => {
2834
3022
  * with no matching rule.
2835
3023
  */
2836
3024
  const bamboocss = (options = {}) => {
2837
- const { configPath, cwd, reportSkipped = false, reportSummary = true, maxRecipeStates, renameCssAsset = true } = options;
3025
+ const { configPath, cwd, reportSkipped = false, reportSummary = true, maxRecipeStates, pruneCss = true } = options;
2838
3026
  if (maxRecipeStates !== void 0 && (!Number.isSafeInteger(maxRecipeStates) || maxRecipeStates < 1)) throw new Error("bamboocss: `maxRecipeStates` must be a positive safe integer.");
2839
- /** Environments whose `buildStart` has run in the build currently in progress. */
2840
- const seenEnvironments = /* @__PURE__ */ new Set();
2841
- /** Totals across the build, for the summary. */
2842
- const totals = {
2843
- folded: 0,
2844
- files: 0,
2845
- filesWithFolds: 0,
2846
- skipped: /* @__PURE__ */ new Map()
2847
- };
3027
+ if ("renameCssAsset" in options) throw new Error("bamboocss: `renameCssAsset` has been replaced by `pruneCss`. Use `pruneCss: false` for what `renameCssAsset: false` did — it always disabled the pruning as well, since pruned bytes under the unpruned sheet's name is what lets a CDN serve a stale stylesheet. The new name says which of the two it is really about.");
3028
+ /**
3029
+ * What each file's transform found, for the summary. Keyed by file rather than summed as it
3030
+ * goes, because a build has more than one environment and they share most of their modules.
3031
+ *
3032
+ * Running totals double-counted every shared module once per environment — a two-environment
3033
+ * build of one shared file and one entry each reported "2/2 across 2/4 files" for three
3034
+ * source modules. Coverage is a property of the source, not of how many times a bundler
3035
+ * handed the same file over. It also grew without bound in dev, where every HMR
3036
+ * re-transform of a file counted as another file.
3037
+ *
3038
+ * A second pass over a file replaces its entry rather than adding to it. Both environments
3039
+ * are assumed to compute the same answer for the same module — true of this compiler, though
3040
+ * not something the plugin can enforce, since another `pre` plugin may hand each environment
3041
+ * different code. Where they disagree the last one wins, which is a cosmetic number either
3042
+ * way.
3043
+ */
3044
+ const perFile = /* @__PURE__ */ new Map();
2848
3045
  const staticSession = createStaticCompilationSession();
2849
3046
  /**
2850
3047
  * Indexed by file, because the only bulk operation on it is "forget this one's".
@@ -2907,26 +3104,24 @@ const bamboocss = (options = {}) => {
2907
3104
  configPath,
2908
3105
  cwd,
2909
3106
  session: staticSession,
2910
- renameCssAsset
3107
+ pruneCss
2911
3108
  }), {
2912
3109
  name: "bamboocss:compiler",
2913
3110
  enforce: "pre",
3111
+ /** See the same declaration on the css plugin: one instance per build, not per environment. */
3112
+ sharedDuringBuild: true,
2914
3113
  configResolved(config) {
2915
3114
  command = config.command;
2916
3115
  },
2917
3116
  async buildStart() {
2918
3117
  const environment = this.environment?.name ?? "default";
2919
- if (seenEnvironments.has(environment)) {
2920
- seenEnvironments.clear();
2921
- totals.folded = 0;
2922
- totals.files = 0;
2923
- totals.filesWithFolds = 0;
2924
- totals.skipped.clear();
3118
+ if (staticSession.startedEnvironments.has(environment)) {
3119
+ perFile.clear();
2925
3120
  survivorsByFile.clear();
2926
3121
  recipeConfigCache.clear();
2927
3122
  resetStaticCompilationSession(staticSession);
2928
3123
  }
2929
- seenEnvironments.add(environment);
3124
+ staticSession.startedEnvironments.add(environment);
2930
3125
  try {
2931
3126
  await ensureContext();
2932
3127
  } catch (error) {
@@ -2996,8 +3191,10 @@ const bamboocss = (options = {}) => {
2996
3191
  });
2997
3192
  } catch (error) {
2998
3193
  logger.caughtError("vite:transform", `Failed to compile ${filePath}`, error);
2999
- totals.files++;
3000
- totals.skipped.set("compile-failed", (totals.skipped.get("compile-failed") ?? 0) + 1);
3194
+ perFile.set(filePath, {
3195
+ folded: 0,
3196
+ skipped: new Map([["compile-failed", 1]])
3197
+ });
3001
3198
  addSurvivor({
3002
3199
  file: filePath,
3003
3200
  line: 1,
@@ -3007,10 +3204,15 @@ const bamboocss = (options = {}) => {
3007
3204
  if (command === "serve") throw asError(error, `failed to compile ${filePath}`);
3008
3205
  return null;
3009
3206
  }
3010
- totals.files++;
3011
- totals.folded += result.folded.length;
3012
- if (result.folded.length) totals.filesWithFolds++;
3013
- for (const entry of result.skipped) totals.skipped.set(entry.reason, (totals.skipped.get(entry.reason) ?? 0) + 1);
3207
+ let skippedHere;
3208
+ for (const entry of result.skipped) {
3209
+ skippedHere ??= /* @__PURE__ */ new Map();
3210
+ skippedHere.set(entry.reason, (skippedHere.get(entry.reason) ?? 0) + 1);
3211
+ }
3212
+ perFile.set(filePath, {
3213
+ folded: result.folded.length,
3214
+ skipped: skippedHere
3215
+ });
3014
3216
  if (result.folded.some((entry) => entry.kind === "class" || entry.kind === "slots")) staticSession.transformedFiles.add(resolve(filePath));
3015
3217
  for (const entry of result.folded) for (const className of entry.classNames) staticSession.markClassUsed(className);
3016
3218
  for (const entry of result.skipped) {
@@ -3037,7 +3239,15 @@ const bamboocss = (options = {}) => {
3037
3239
  buildEnd() {
3038
3240
  const survivors = allSurvivors();
3039
3241
  if (survivors.length) throw createSurvivorError(survivors);
3040
- if (typeof this.getModuleInfo === "function") {
3242
+ const lost = [...staticSession.usedClasses].filter((className) => staticSession.prunedClasses.has(bare(className)));
3243
+ if (lost.length) {
3244
+ const environment = this.environment?.name ?? "default";
3245
+ throw new Error(`bamboocss: ${lost.length} class(es) compiled in the ${JSON.stringify(environment)} environment were already pruned out of a stylesheet emitted by an earlier one. Elements carrying them would render unstyled.\n\n${truncateList(lost.map((className) => ` ${className}`), {
3246
+ unit: "class",
3247
+ separator: "\n"
3248
+ })}\n\nThe stylesheet is finalized by the environment that imports it, so pruning it is only safe once every environment has been compiled. This build did not say how many there would be: it called \`builder.build(environment)\` directly. Run it through \`vite build\`, call \`builder.buildApp()\`, or set \`builder: {}\` in the Vite config so the environments are known before the first one builds. \`bamboocss({ pruneCss: false })\` also turns pruning off entirely.`);
3249
+ }
3250
+ if (typeof this.getModuleInfo === "function" && !remainingEnvironments(staticSession).length) {
3041
3251
  if (!staticSession.cssLoaded) throw new Error(`bamboocss: compiled class values were produced, but ${JSON.stringify(VIRTUAL_CSS_ID)} was not imported. Add \`import ${JSON.stringify(VIRTUAL_CSS_ID)}\` once, from a JavaScript or TypeScript module in the application entry graph.\n\nIt has to be a JS import. \`@import\` from a stylesheet does not reach it: the id names a virtual module resolved by this plugin, and Vite resolves CSS \`@import\` before plugin resolution, so it fails as an unresolvable path. A project that ships one preloaded stylesheet imports this from its entry module instead, and lets Vite emit the CSS asset.`);
3042
3252
  const outsideExtraction = [...staticSession.transformedFiles].filter((file) => !staticSession.extractedFiles.has(file));
3043
3253
  if (outsideExtraction.length) throw new Error(`bamboocss: ${outsideExtraction.length} statically compiled module(s) are outside the CSS extraction graph:\n\n${truncateList(outsideExtraction.map((file) => ` ${file}`), {
@@ -3046,12 +3256,21 @@ const bamboocss = (options = {}) => {
3046
3256
  })}\n\nAdd them to \`include\` in bamboo.config, or no CSS rule can back their emitted classes.`);
3047
3257
  }
3048
3258
  if (!reportSummary) return;
3049
- const declined = Array.from(totals.skipped.values()).reduce((sum, count) => sum + count, 0);
3050
- const total = totals.folded + declined;
3259
+ if (command === "build" && remainingEnvironments(staticSession).length) return;
3260
+ let folded = 0;
3261
+ let filesWithFolds = 0;
3262
+ const skipped = /* @__PURE__ */ new Map();
3263
+ for (const entry of perFile.values()) {
3264
+ folded += entry.folded;
3265
+ if (entry.folded) filesWithFolds++;
3266
+ for (const [reason, count] of entry.skipped ?? []) skipped.set(reason, (skipped.get(reason) ?? 0) + count);
3267
+ }
3268
+ const declined = Array.from(skipped.values()).reduce((sum, count) => sum + count, 0);
3269
+ const total = folded + declined;
3051
3270
  if (!total) return;
3052
- const share = Math.round(totals.folded / total * 100);
3053
- const reasons = Array.from(totals.skipped.entries()).sort((a, b) => b[1] - a[1]).map(([reason, count]) => `${reason}=${count}`).join(" ");
3054
- logger.info("vite:transform", `Compiled ${totals.folded}/${total} (${share}%) across ${totals.filesWithFolds}/${totals.files} files` + (reasons ? ` — declined: ${reasons}` : ""));
3271
+ const share = Math.round(folded / total * 100);
3272
+ const reasons = Array.from(skipped.entries()).sort((a, b) => b[1] - a[1]).map(([reason, count]) => `${reason}=${count}`).join(" ");
3273
+ logger.info("vite:transform", `Compiled ${folded}/${total} (${share}%) across ${filesWithFolds}/${perFile.size} files` + (reasons ? ` — declined: ${reasons}` : ""));
3055
3274
  }
3056
3275
  }];
3057
3276
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bamboocss/vite",
3
- "version": "1.37.12",
3
+ "version": "1.38.0",
4
4
  "description": "Vite integration for Bamboo CSS",
5
5
  "homepage": "https://bamboocss.com",
6
6
  "license": "MIT",
@@ -40,18 +40,18 @@
40
40
  "postcss": "8.5.26",
41
41
  "postcss-selector-parser": "7.1.5",
42
42
  "ts-morph": "28.0.0",
43
- "@bamboocss/config": "1.37.12",
44
- "@bamboocss/core": "1.37.12",
45
- "@bamboocss/extractor": "1.37.12",
46
- "@bamboocss/logger": "1.37.12",
47
- "@bamboocss/node": "1.37.12",
48
- "@bamboocss/shared": "1.37.12",
49
- "@bamboocss/types": "1.37.12"
43
+ "@bamboocss/config": "1.38.0",
44
+ "@bamboocss/core": "1.38.0",
45
+ "@bamboocss/logger": "1.38.0",
46
+ "@bamboocss/node": "1.38.0",
47
+ "@bamboocss/extractor": "1.38.0",
48
+ "@bamboocss/shared": "1.38.0",
49
+ "@bamboocss/types": "1.38.0"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@jridgewell/trace-mapping": "^0.3.31",
53
53
  "vite": "7.2.6",
54
- "@bamboocss/fixture": "1.37.12"
54
+ "@bamboocss/fixture": "1.38.0"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "vite": ">=5"