@bamboocss/vite 1.37.11 → 1.37.13

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.
Files changed (3) hide show
  1. package/dist/index.cjs +144 -48
  2. package/dist/index.mjs +144 -48
  3. package/package.json +9 -9
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.
@@ -262,11 +313,15 @@ const carriesGeneratedCss = (output) => output.type === "asset" && output.fileNa
262
313
  * hash is not cosmetic: it makes late graph reachability cache-safe.
263
314
  */
264
315
  const optimizeStaticCssAssets = (bundle, session, options = {}) => {
265
- const { rename = true } = options;
316
+ const { rename = true, prune = true, sourcemap = session.sourcemap } = options;
317
+ /** Assets in this bundle that carry the generated stylesheet, pruned or not. */
318
+ let sheets = 0;
266
319
  for (const output of Object.values(bundle)) {
267
320
  if (!carriesGeneratedCss(output)) continue;
268
321
  const source = typeof output.source === "string" ? output.source : Buffer.from(output.source).toString();
269
322
  if (!source.includes("--made-with-bamboo")) continue;
323
+ sheets++;
324
+ if (!prune) continue;
270
325
  const optimized = pruneStaticCss(source, session);
271
326
  output.source = optimized;
272
327
  if (optimized === source) continue;
@@ -279,8 +334,9 @@ const optimizeStaticCssAssets = (bundle, session, options = {}) => {
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.
@@ -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, exactly as `renameCssAsset: false`
493
+ * already does. Being the last environment is not the common case — frameworks build
494
+ * the client first — but it is the only one where the answer is knowable, and a
495
+ * framework that builds its server bundle first does get pruned output.
496
+ */
497
+ const pending = remainingEnvironments(session);
498
+ const { sheets } = optimizeStaticCssAssets(bundle, session, {
499
+ rename: renameCssAsset,
500
+ prune: pending.length === 0,
501
+ sourcemap: environment?.config?.build?.sourcemap
502
+ });
503
+ 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;
@@ -2562,7 +2667,21 @@ const foldSource = (options) => {
2562
2667
  const sourceFile = ownSourceFile ?? candidates[0]?.node.getSourceFile() ?? recipeDefinitions[0]?.call.getSourceFile();
2563
2668
  if (!sourceFile) return;
2564
2669
  const importedRecipeBindings = new Set(parserResult.importedRecipes?.keys() ?? []);
2565
- const identifiers = identifierIndex(sourceFile);
2670
+ /**
2671
+ * Every identifier in the module, grouped by the name it spells — built at most once per
2672
+ * pass, and only when something below has a name to look up.
2673
+ *
2674
+ * One index answers for every binding, rather than a walk per binding. Built here rather
2675
+ * than cached across passes: it holds nodes, and a node does not outlive its source file
2676
+ * being replaced.
2677
+ *
2678
+ * Deferred because most modules in an app neither declare nor import a recipe, and the
2679
+ * walk is not cheap — `getDescendantsOfKind` wraps every identifier in the file in a
2680
+ * ts-morph node to answer a question those modules never ask. It was 11% of a 6,307-file
2681
+ * build, most of it spent producing an index nothing read.
2682
+ */
2683
+ let identifiers;
2684
+ const identifiersByName = () => identifiers ??= identifierIndex(sourceFile);
2566
2685
  for (const binding of new Set([...recipeConfigs.keys(), ...importedRecipeBindings])) {
2567
2686
  const entry = recipeConfigs.get(binding);
2568
2687
  if (entry === AMBIGUOUS) continue;
@@ -2570,7 +2689,7 @@ const foldSource = (options) => {
2570
2689
  const definition = entry?.box?.getNode?.();
2571
2690
  const nameNode = definition?.getSourceFile() === sourceFile ? definition?.getFirstAncestorByKind(ts_morph.SyntaxKind.VariableDeclaration)?.getNameNode() : importSpecifierFor(sourceFile, binding);
2572
2691
  if (!nameNode || !ts_morph.Node.isIdentifier(nameNode)) continue;
2573
- const references = localReferencesTo(identifiers, binding, nameNode);
2692
+ const references = localReferencesTo(identifiersByName(), binding, nameNode);
2574
2693
  if (skipped.filter((item) => SURVIVES_TO_RUNTIME.has(item.reason) && item.end > item.start).some((item) => references.some((ref) => ref.getStart() >= item.start && ref.getStart() < item.end))) continue;
2575
2694
  const survivor = references.find((ref) => !applied.some(([from, to]) => ref.getStart() >= from && ref.getStart() < to));
2576
2695
  if (!survivor) continue;
@@ -2671,25 +2790,24 @@ const foldSource = (options) => {
2671
2790
  }
2672
2791
  if (watched.size === 0) return;
2673
2792
  const declined = skipped.filter((entry) => SURVIVES_TO_RUNTIME.has(entry.reason) && entry.end > entry.start).map((entry) => [entry.start, entry.end]);
2674
- const reported = /* @__PURE__ */ new Set();
2675
- for (const identifier of sourceFile.getDescendantsOfKind(ts_morph.SyntaxKind.Identifier)) {
2676
- const local = identifier.getText();
2677
- const imported = watched.get(local);
2678
- if (imported === void 0 || reported.has(local)) continue;
2793
+ const survivors = [];
2794
+ for (const [local, imported] of watched) for (const identifier of identifiersByName().get(local) ?? []) {
2679
2795
  const start = identifier.getStart();
2680
2796
  if (identifier.getFirstAncestorByKind(ts_morph.SyntaxKind.ImportDeclaration)) continue;
2681
2797
  if (applied.some(([from, to]) => start >= from && start < to)) continue;
2682
2798
  if (declined.some(([from, to]) => start >= from && start < to)) continue;
2683
2799
  if (!isValueReference(identifier)) continue;
2684
2800
  if (isShadowed(identifier, local)) continue;
2685
- reported.add(local);
2686
- skipped.push({
2801
+ survivors.push({
2687
2802
  name: imported,
2688
2803
  reason: "runtime-binding",
2689
2804
  start,
2690
2805
  end: identifier.getEnd()
2691
2806
  });
2807
+ break;
2692
2808
  }
2809
+ survivors.sort((a, b) => a.start - b.start);
2810
+ skipped.push(...survivors);
2693
2811
  }
2694
2812
  if (reportSurvivors) reportRuntimeBindings();
2695
2813
  if (folded.length === 0) return {
@@ -2771,35 +2889,6 @@ const createStaticStyleSetCompiler = (ctx, runtimeCss, allocateClassString = (cl
2771
2889
  };
2772
2890
  };
2773
2891
  //#endregion
2774
- //#region src/static-session.ts
2775
- const createStaticCompilationSession = () => {
2776
- const session = {
2777
- utilityLayer: "utilities",
2778
- sourcemap: false,
2779
- cssLoaded: false,
2780
- transformedFiles: /* @__PURE__ */ new Set(),
2781
- extractedFiles: /* @__PURE__ */ new Set(),
2782
- prunableClasses: /* @__PURE__ */ new Set(),
2783
- viewTransitionClasses: /* @__PURE__ */ new Set(),
2784
- usedClasses: /* @__PURE__ */ new Set(),
2785
- markClassUsed(className) {
2786
- for (const token of className.split(" ")) {
2787
- if (!token) continue;
2788
- session.usedClasses.add(token.includes("\\") ? token : (0, _bamboocss_shared.esc)(token));
2789
- }
2790
- }
2791
- };
2792
- return session;
2793
- };
2794
- const resetStaticCompilationSession = (session) => {
2795
- session.cssLoaded = false;
2796
- session.transformedFiles.clear();
2797
- session.extractedFiles.clear();
2798
- session.prunableClasses.clear();
2799
- session.viewTransitionClasses.clear();
2800
- session.usedClasses.clear();
2801
- };
2802
- //#endregion
2803
2892
  //#region src/plugin.ts
2804
2893
  const DEFAULT_EXTENSIONS = /\.(?:[cm]?[jt]sx?)$/;
2805
2894
  const NODE_MODULES = /node_modules/;
@@ -2853,8 +2942,6 @@ const formatSkipped = (id, skipped) => {
2853
2942
  const bamboocss = (options = {}) => {
2854
2943
  const { configPath, cwd, reportSkipped = false, reportSummary = true, maxRecipeStates, renameCssAsset = true } = options;
2855
2944
  if (maxRecipeStates !== void 0 && (!Number.isSafeInteger(maxRecipeStates) || maxRecipeStates < 1)) throw new Error("bamboocss: `maxRecipeStates` must be a positive safe integer.");
2856
- /** Environments whose `buildStart` has run in the build currently in progress. */
2857
- const seenEnvironments = /* @__PURE__ */ new Set();
2858
2945
  /** Totals across the build, for the summary. */
2859
2946
  const totals = {
2860
2947
  folded: 0,
@@ -2928,13 +3015,14 @@ const bamboocss = (options = {}) => {
2928
3015
  }), {
2929
3016
  name: "bamboocss:compiler",
2930
3017
  enforce: "pre",
3018
+ /** See the same declaration on the css plugin: one instance per build, not per environment. */
3019
+ sharedDuringBuild: true,
2931
3020
  configResolved(config) {
2932
3021
  command = config.command;
2933
3022
  },
2934
3023
  async buildStart() {
2935
3024
  const environment = this.environment?.name ?? "default";
2936
- if (seenEnvironments.has(environment)) {
2937
- seenEnvironments.clear();
3025
+ if (staticSession.startedEnvironments.has(environment)) {
2938
3026
  totals.folded = 0;
2939
3027
  totals.files = 0;
2940
3028
  totals.filesWithFolds = 0;
@@ -2943,7 +3031,7 @@ const bamboocss = (options = {}) => {
2943
3031
  recipeConfigCache.clear();
2944
3032
  resetStaticCompilationSession(staticSession);
2945
3033
  }
2946
- seenEnvironments.add(environment);
3034
+ staticSession.startedEnvironments.add(environment);
2947
3035
  try {
2948
3036
  await ensureContext();
2949
3037
  } catch (error) {
@@ -3054,7 +3142,15 @@ const bamboocss = (options = {}) => {
3054
3142
  buildEnd() {
3055
3143
  const survivors = allSurvivors();
3056
3144
  if (survivors.length) throw createSurvivorError(survivors);
3057
- if (typeof this.getModuleInfo === "function") {
3145
+ const lost = [...staticSession.usedClasses].filter((className) => staticSession.prunedClasses.has(bare(className)));
3146
+ if (lost.length) {
3147
+ const environment = this.environment?.name ?? "default";
3148
+ 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}`), {
3149
+ unit: "class",
3150
+ separator: "\n"
3151
+ })}\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({ renameCssAsset: false })\` also turns pruning off entirely.`);
3152
+ }
3153
+ if (typeof this.getModuleInfo === "function" && !remainingEnvironments(staticSession).length) {
3058
3154
  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.`);
3059
3155
  const outsideExtraction = [...staticSession.transformedFiles].filter((file) => !staticSession.extractedFiles.has(file));
3060
3156
  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}`), {
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.
@@ -232,11 +283,15 @@ const carriesGeneratedCss = (output) => output.type === "asset" && output.fileNa
232
283
  * hash is not cosmetic: it makes late graph reachability cache-safe.
233
284
  */
234
285
  const optimizeStaticCssAssets = (bundle, session, options = {}) => {
235
- const { rename = true } = options;
286
+ const { rename = true, prune = true, sourcemap = session.sourcemap } = options;
287
+ /** Assets in this bundle that carry the generated stylesheet, pruned or not. */
288
+ let sheets = 0;
236
289
  for (const output of Object.values(bundle)) {
237
290
  if (!carriesGeneratedCss(output)) continue;
238
291
  const source = typeof output.source === "string" ? output.source : Buffer.from(output.source).toString();
239
292
  if (!source.includes("--made-with-bamboo")) continue;
293
+ sheets++;
294
+ if (!prune) continue;
240
295
  const optimized = pruneStaticCss(source, session);
241
296
  output.source = optimized;
242
297
  if (optimized === source) continue;
@@ -249,8 +304,9 @@ const optimizeStaticCssAssets = (bundle, session, options = {}) => {
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.
@@ -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, exactly as `renameCssAsset: false`
463
+ * already does. Being the last environment is not the common case — frameworks build
464
+ * the client first — but it is the only one where the answer is knowable, and a
465
+ * framework that builds its server bundle first does get pruned output.
466
+ */
467
+ const pending = remainingEnvironments(session);
468
+ const { sheets } = optimizeStaticCssAssets(bundle, session, {
469
+ rename: renameCssAsset,
470
+ prune: pending.length === 0,
471
+ sourcemap: environment?.config?.build?.sourcemap
472
+ });
473
+ 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;
@@ -2532,7 +2637,21 @@ const foldSource = (options) => {
2532
2637
  const sourceFile = ownSourceFile ?? candidates[0]?.node.getSourceFile() ?? recipeDefinitions[0]?.call.getSourceFile();
2533
2638
  if (!sourceFile) return;
2534
2639
  const importedRecipeBindings = new Set(parserResult.importedRecipes?.keys() ?? []);
2535
- const identifiers = identifierIndex(sourceFile);
2640
+ /**
2641
+ * Every identifier in the module, grouped by the name it spells — built at most once per
2642
+ * pass, and only when something below has a name to look up.
2643
+ *
2644
+ * One index answers for every binding, rather than a walk per binding. Built here rather
2645
+ * than cached across passes: it holds nodes, and a node does not outlive its source file
2646
+ * being replaced.
2647
+ *
2648
+ * Deferred because most modules in an app neither declare nor import a recipe, and the
2649
+ * walk is not cheap — `getDescendantsOfKind` wraps every identifier in the file in a
2650
+ * ts-morph node to answer a question those modules never ask. It was 11% of a 6,307-file
2651
+ * build, most of it spent producing an index nothing read.
2652
+ */
2653
+ let identifiers;
2654
+ const identifiersByName = () => identifiers ??= identifierIndex(sourceFile);
2536
2655
  for (const binding of new Set([...recipeConfigs.keys(), ...importedRecipeBindings])) {
2537
2656
  const entry = recipeConfigs.get(binding);
2538
2657
  if (entry === AMBIGUOUS) continue;
@@ -2540,7 +2659,7 @@ const foldSource = (options) => {
2540
2659
  const definition = entry?.box?.getNode?.();
2541
2660
  const nameNode = definition?.getSourceFile() === sourceFile ? definition?.getFirstAncestorByKind(SyntaxKind.VariableDeclaration)?.getNameNode() : importSpecifierFor(sourceFile, binding);
2542
2661
  if (!nameNode || !Node.isIdentifier(nameNode)) continue;
2543
- const references = localReferencesTo(identifiers, binding, nameNode);
2662
+ const references = localReferencesTo(identifiersByName(), binding, nameNode);
2544
2663
  if (skipped.filter((item) => SURVIVES_TO_RUNTIME.has(item.reason) && item.end > item.start).some((item) => references.some((ref) => ref.getStart() >= item.start && ref.getStart() < item.end))) continue;
2545
2664
  const survivor = references.find((ref) => !applied.some(([from, to]) => ref.getStart() >= from && ref.getStart() < to));
2546
2665
  if (!survivor) continue;
@@ -2641,25 +2760,24 @@ const foldSource = (options) => {
2641
2760
  }
2642
2761
  if (watched.size === 0) return;
2643
2762
  const declined = skipped.filter((entry) => SURVIVES_TO_RUNTIME.has(entry.reason) && entry.end > entry.start).map((entry) => [entry.start, entry.end]);
2644
- const reported = /* @__PURE__ */ new Set();
2645
- for (const identifier of sourceFile.getDescendantsOfKind(SyntaxKind.Identifier)) {
2646
- const local = identifier.getText();
2647
- const imported = watched.get(local);
2648
- if (imported === void 0 || reported.has(local)) continue;
2763
+ const survivors = [];
2764
+ for (const [local, imported] of watched) for (const identifier of identifiersByName().get(local) ?? []) {
2649
2765
  const start = identifier.getStart();
2650
2766
  if (identifier.getFirstAncestorByKind(SyntaxKind.ImportDeclaration)) continue;
2651
2767
  if (applied.some(([from, to]) => start >= from && start < to)) continue;
2652
2768
  if (declined.some(([from, to]) => start >= from && start < to)) continue;
2653
2769
  if (!isValueReference(identifier)) continue;
2654
2770
  if (isShadowed(identifier, local)) continue;
2655
- reported.add(local);
2656
- skipped.push({
2771
+ survivors.push({
2657
2772
  name: imported,
2658
2773
  reason: "runtime-binding",
2659
2774
  start,
2660
2775
  end: identifier.getEnd()
2661
2776
  });
2777
+ break;
2662
2778
  }
2779
+ survivors.sort((a, b) => a.start - b.start);
2780
+ skipped.push(...survivors);
2663
2781
  }
2664
2782
  if (reportSurvivors) reportRuntimeBindings();
2665
2783
  if (folded.length === 0) return {
@@ -2741,35 +2859,6 @@ const createStaticStyleSetCompiler = (ctx, runtimeCss, allocateClassString = (cl
2741
2859
  };
2742
2860
  };
2743
2861
  //#endregion
2744
- //#region src/static-session.ts
2745
- const createStaticCompilationSession = () => {
2746
- const session = {
2747
- utilityLayer: "utilities",
2748
- sourcemap: false,
2749
- cssLoaded: false,
2750
- transformedFiles: /* @__PURE__ */ new Set(),
2751
- extractedFiles: /* @__PURE__ */ new Set(),
2752
- prunableClasses: /* @__PURE__ */ new Set(),
2753
- viewTransitionClasses: /* @__PURE__ */ new Set(),
2754
- usedClasses: /* @__PURE__ */ new Set(),
2755
- markClassUsed(className) {
2756
- for (const token of className.split(" ")) {
2757
- if (!token) continue;
2758
- session.usedClasses.add(token.includes("\\") ? token : esc(token));
2759
- }
2760
- }
2761
- };
2762
- return session;
2763
- };
2764
- const resetStaticCompilationSession = (session) => {
2765
- session.cssLoaded = false;
2766
- session.transformedFiles.clear();
2767
- session.extractedFiles.clear();
2768
- session.prunableClasses.clear();
2769
- session.viewTransitionClasses.clear();
2770
- session.usedClasses.clear();
2771
- };
2772
- //#endregion
2773
2862
  //#region src/plugin.ts
2774
2863
  const DEFAULT_EXTENSIONS = /\.(?:[cm]?[jt]sx?)$/;
2775
2864
  const NODE_MODULES = /node_modules/;
@@ -2823,8 +2912,6 @@ const formatSkipped = (id, skipped) => {
2823
2912
  const bamboocss = (options = {}) => {
2824
2913
  const { configPath, cwd, reportSkipped = false, reportSummary = true, maxRecipeStates, renameCssAsset = true } = options;
2825
2914
  if (maxRecipeStates !== void 0 && (!Number.isSafeInteger(maxRecipeStates) || maxRecipeStates < 1)) throw new Error("bamboocss: `maxRecipeStates` must be a positive safe integer.");
2826
- /** Environments whose `buildStart` has run in the build currently in progress. */
2827
- const seenEnvironments = /* @__PURE__ */ new Set();
2828
2915
  /** Totals across the build, for the summary. */
2829
2916
  const totals = {
2830
2917
  folded: 0,
@@ -2898,13 +2985,14 @@ const bamboocss = (options = {}) => {
2898
2985
  }), {
2899
2986
  name: "bamboocss:compiler",
2900
2987
  enforce: "pre",
2988
+ /** See the same declaration on the css plugin: one instance per build, not per environment. */
2989
+ sharedDuringBuild: true,
2901
2990
  configResolved(config) {
2902
2991
  command = config.command;
2903
2992
  },
2904
2993
  async buildStart() {
2905
2994
  const environment = this.environment?.name ?? "default";
2906
- if (seenEnvironments.has(environment)) {
2907
- seenEnvironments.clear();
2995
+ if (staticSession.startedEnvironments.has(environment)) {
2908
2996
  totals.folded = 0;
2909
2997
  totals.files = 0;
2910
2998
  totals.filesWithFolds = 0;
@@ -2913,7 +3001,7 @@ const bamboocss = (options = {}) => {
2913
3001
  recipeConfigCache.clear();
2914
3002
  resetStaticCompilationSession(staticSession);
2915
3003
  }
2916
- seenEnvironments.add(environment);
3004
+ staticSession.startedEnvironments.add(environment);
2917
3005
  try {
2918
3006
  await ensureContext();
2919
3007
  } catch (error) {
@@ -3024,7 +3112,15 @@ const bamboocss = (options = {}) => {
3024
3112
  buildEnd() {
3025
3113
  const survivors = allSurvivors();
3026
3114
  if (survivors.length) throw createSurvivorError(survivors);
3027
- if (typeof this.getModuleInfo === "function") {
3115
+ const lost = [...staticSession.usedClasses].filter((className) => staticSession.prunedClasses.has(bare(className)));
3116
+ if (lost.length) {
3117
+ const environment = this.environment?.name ?? "default";
3118
+ 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}`), {
3119
+ unit: "class",
3120
+ separator: "\n"
3121
+ })}\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({ renameCssAsset: false })\` also turns pruning off entirely.`);
3122
+ }
3123
+ if (typeof this.getModuleInfo === "function" && !remainingEnvironments(staticSession).length) {
3028
3124
  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.`);
3029
3125
  const outsideExtraction = [...staticSession.transformedFiles].filter((file) => !staticSession.extractedFiles.has(file));
3030
3126
  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}`), {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bamboocss/vite",
3
- "version": "1.37.11",
3
+ "version": "1.37.13",
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/logger": "1.37.11",
44
- "@bamboocss/node": "1.37.11",
45
- "@bamboocss/extractor": "1.37.11",
46
- "@bamboocss/core": "1.37.11",
47
- "@bamboocss/config": "1.37.11",
48
- "@bamboocss/shared": "1.37.11",
49
- "@bamboocss/types": "1.37.11"
43
+ "@bamboocss/config": "1.37.13",
44
+ "@bamboocss/core": "1.37.13",
45
+ "@bamboocss/extractor": "1.37.13",
46
+ "@bamboocss/logger": "1.37.13",
47
+ "@bamboocss/node": "1.37.13",
48
+ "@bamboocss/types": "1.37.13",
49
+ "@bamboocss/shared": "1.37.13"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@jridgewell/trace-mapping": "^0.3.31",
53
53
  "vite": "7.2.6",
54
- "@bamboocss/fixture": "1.37.11"
54
+ "@bamboocss/fixture": "1.37.13"
55
55
  },
56
56
  "peerDependencies": {
57
57
  "vite": ">=5"