@bamboocss/vite 1.53.0 → 1.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,9 +1,9 @@
1
1
  import { t as bare } from "./class-name.mjs";
2
+ import { dirname, resolve } from "node:path";
2
3
  import { logger } from "@bamboocss/logger";
3
4
  import { esc, truncateList } from "@bamboocss/shared";
4
5
  import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
5
6
  import { readFileSync } from "node:fs";
6
- import { resolve } from "node:path";
7
7
  import { markStaticCompilerActive } from "@bamboocss/node/static-compiler";
8
8
  //#region src/lazy-modules.ts
9
9
  /**
@@ -59,6 +59,7 @@ const createCompilationHost = (options = {}) => {
59
59
  const { configPath, cwd } = options;
60
60
  const loadBuilder = options.loadBuilder ?? createLazyBuilder();
61
61
  let command = "build";
62
+ let devSourcemap = false;
62
63
  let builder;
63
64
  let generation;
64
65
  let nextGenerationId = 0;
@@ -121,6 +122,7 @@ const createCompilationHost = (options = {}) => {
121
122
  configPath,
122
123
  cwd,
123
124
  dev: command === "serve",
125
+ atomOrigins: command === "serve" && devSourcemap,
124
126
  ...command === "serve" ? { sourceChanges } : {}
125
127
  });
126
128
  return publish();
@@ -153,6 +155,12 @@ const createCompilationHost = (options = {}) => {
153
155
  setCommand(next) {
154
156
  command = next;
155
157
  },
158
+ setDevSourcemap(enabled) {
159
+ devSourcemap = enabled;
160
+ },
161
+ isSourceFile(filePath) {
162
+ return builder?.context ? builder.isPotentialSourceFile(filePath) : true;
163
+ },
156
164
  current: () => generation,
157
165
  async ensureGeneration() {
158
166
  if (cssPass) await settled(cssPass);
@@ -211,6 +219,11 @@ const createStaticCompilationSession = () => {
211
219
  participatingEnvironments: /* @__PURE__ */ new Set(),
212
220
  completedEnvironments: /* @__PURE__ */ new Set(),
213
221
  prunedClasses: /* @__PURE__ */ new Set(),
222
+ deferredSheets: [],
223
+ writtenOutputs: [],
224
+ prunedAssets: /* @__PURE__ */ new WeakSet(),
225
+ prunedSheetNames: /* @__PURE__ */ new WeakMap(),
226
+ splitCss: true,
214
227
  beginOutputProjection(_environment, _outputOptions, _bundle, replacesGeneratedStylesheet) {
215
228
  if (replacesGeneratedStylesheet) session.prunedClasses.clear();
216
229
  const prunable = new Set([...session.prunableClasses].map((className) => className.replaceAll("\\", "")));
@@ -300,6 +313,173 @@ const queryOf = (id) => {
300
313
  */
301
314
  const asError = (error, context) => error instanceof Error ? error : new Error(`bamboocss: ${context}: ${String(error)}`, { cause: error });
302
315
  /**
316
+ * Which lazily loaded chunk each atom exclusive to one belongs to.
317
+ *
318
+ * An atom belongs to a chunk when every module that emits it is in that chunk, and the chunk
319
+ * is not loaded with an entry anyway — an entry, or anything an entry statically imports,
320
+ * would put the atom in the entry sheet's own company either way. An atom two chunks share,
321
+ * or one no compiled module emits — `staticCss` — has no owner and stays where every route
322
+ * finds it. Loading with an entry is the static import closure of every entry, which is what
323
+ * the browser fetches before the first render.
324
+ */
325
+ const chunkOwnership = (bundle, environment, session) => {
326
+ const classNamesOf = session.classNamesOf;
327
+ const ownership = /* @__PURE__ */ new Map();
328
+ if (!classNamesOf) return ownership;
329
+ const chunks = Object.values(bundle).filter((output) => output.type === "chunk");
330
+ const byFileName = new Map(chunks.map((chunk) => [chunk.fileName, chunk]));
331
+ const eager = /* @__PURE__ */ new Set();
332
+ const visit = (fileName) => {
333
+ if (eager.has(fileName)) return;
334
+ eager.add(fileName);
335
+ for (const imported of byFileName.get(fileName)?.imports ?? []) visit(imported);
336
+ };
337
+ for (const chunk of chunks) if (chunk.isEntry) visit(chunk.fileName);
338
+ const owners = /* @__PURE__ */ new Map();
339
+ for (const chunk of chunks) {
340
+ const owner = eager.has(chunk.fileName) ? null : chunk.fileName;
341
+ for (const moduleId of Object.keys(chunk.modules)) for (const classNames of classNamesOf(environment, moduleId) ?? []) for (const token of classNames.split(" ")) {
342
+ if (!token) continue;
343
+ const className = bare(token);
344
+ const previous = owners.get(className);
345
+ if (previous === void 0) owners.set(className, owner);
346
+ else if (previous !== owner) owners.set(className, null);
347
+ }
348
+ }
349
+ for (const [className, owner] of owners) if (owner !== null) ownership.set(className, owner);
350
+ logger.debug("vite", `Split: ${chunks.length} chunk(s), ${eager.size} loaded with an entry, ${owners.size} atom(s) seen, ${ownership.size} owned by a lazy chunk.`);
351
+ return ownership;
352
+ };
353
+ /**
354
+ * Prune every generated sheet in `bundle` this generation has not pruned yet.
355
+ *
356
+ * Reached twice per output. The early hook, ordered `pre`, reaches a sheet Vite emitted while
357
+ * rendering chunks — every `cssCodeSplit: true` build — before any other plugin's
358
+ * `generateBundle` reads its name, so a framework recording asset names records the final one.
359
+ * That was not a courtesy: `@vitejs/plugin-rsc` snapshots the server build's stylesheet name in
360
+ * a normal-order hook and writes it into a manifest at the end of the run, so a rename in a
361
+ * `post` hook left every server-rendered page linking a stylesheet that no longer existed. The
362
+ * late hook, ordered `post`, reaches what Vite emits from its own `generateBundle` — the single
363
+ * `style.css` of a `cssCodeSplit: false` build. Exactly one of the two opens a projection for a
364
+ * given sheet.
365
+ *
366
+ * Pruning waits for no one; finalizing does. The stylesheet is emitted by the environment that
367
+ * *imports* it, which in an SSR app is the client — and the client builds first, before the
368
+ * server environment has transformed a single module. Two answers were tried before this one.
369
+ * Holding the sheet back until every environment had contributed meant never pruning in any SSR
370
+ * framework, since the client's output is on disk before the server starts. Pruning against the
371
+ * client alone, with a guard that failed the build when a later environment reached a rule it had
372
+ * removed, made a styled component that renders only on the server a build failure — and under
373
+ * React Server Components most components never reach the client graph at all.
374
+ *
375
+ * So while environments remain, the sheet is pruned against what the run knows so far and
376
+ * written under a name hashed from those bytes, and it is recorded as deferred with its unpruned
377
+ * source. When the last environment writes its output, `bamboocss:output-write-observer` prunes
378
+ * that source again against the union of every environment's reachability. Usually the result is
379
+ * the bytes already on disk, and nothing moves. When a later environment restored a rule, the
380
+ * final bytes go under a new name, every written reference moves with them, and so does any copy
381
+ * of the provisional sheet another output carries. A single-environment build, a run whose
382
+ * sheet-carrying environment builds last, and an in-memory build take the direct path, where the
383
+ * guard in `buildEnd` still fails a later environment that reaches a rule the sheet lost.
384
+ */
385
+ const pruneEmittedSheets = async (context, session, outputOptions, bundle, isWrite, pruneCss, splitCss) => {
386
+ const { containsGeneratedCssAsset, optimizeStaticCssAssets } = await loadCssOutputModule();
387
+ let handledNames = session.prunedSheetNames.get(outputOptions);
388
+ if (!handledNames) {
389
+ handledNames = /* @__PURE__ */ new Set();
390
+ session.prunedSheetNames.set(outputOptions, handledNames);
391
+ }
392
+ const carriesSheet = containsGeneratedCssAsset(bundle, session.prunedAssets, handledNames);
393
+ const environmentName = context.environment?.name ?? "default";
394
+ const pending = remainingEnvironments(session, environmentName);
395
+ const completesRun = !pending.length && session.deferredSheets.length > 0;
396
+ if (!carriesSheet && !completesRun) return;
397
+ const outputProjection = session.beginOutputProjection(environmentName, outputOptions, bundle, carriesSheet);
398
+ try {
399
+ const outputDir = outputOptions.dir ?? (outputOptions.file ? dirname(outputOptions.file) : void 0);
400
+ const deferred = pruneCss && isWrite === true && outputDir !== void 0 && pending.length > 0;
401
+ const remaining = truncateList(pending, {
402
+ unit: "environment",
403
+ separator: ", "
404
+ });
405
+ const sourcemap = context.environment?.config?.build?.sourcemap ?? session.sourcemap;
406
+ if (completesRun) await session.finalizeDeferred?.({
407
+ environment: environmentName,
408
+ bundle,
409
+ sourcemap
410
+ });
411
+ if (!carriesSheet) return;
412
+ const cssCodeSplit = context.environment?.config?.build?.cssCodeSplit ?? session.cssCodeSplit ?? true;
413
+ const split = splitCss && session.splitCss && pruneCss && cssCodeSplit && context.emitFile && context.getFileName ? {
414
+ ownership: chunkOwnership(bundle, environmentName, session),
415
+ emit: (chunkFileName, css) => {
416
+ const chunk = bundle[chunkFileName];
417
+ const referenceId = context.emitFile({
418
+ type: "asset",
419
+ name: `${chunk?.name ?? "chunk"}.css`,
420
+ source: css
421
+ });
422
+ const fileName = context.getFileName(referenceId);
423
+ chunk?.viteMetadata?.importedCss?.add(fileName);
424
+ const emitted = bundle[fileName];
425
+ if (emitted) session.prunedAssets.add(emitted);
426
+ }
427
+ } : void 0;
428
+ const { sheets, results } = optimizeStaticCssAssets(bundle, session, {
429
+ environment: environmentName,
430
+ prune: pruneCss,
431
+ requiredClasses: outputProjection.requiredClasses,
432
+ sourcemap: context.environment?.config?.build?.sourcemap,
433
+ handled: session.prunedAssets,
434
+ handledNames,
435
+ split
436
+ });
437
+ if (sheets && !pruneCss) logger.info("vite", "Reachability pruning is off (`pruneCss: false`). The full extracted stylesheet ships.");
438
+ if (!sheets || !pruneCss || !pending.length) return;
439
+ if (deferred) {
440
+ for (const result of results) session.deferredSheets.push({
441
+ environment: environmentName,
442
+ dir: resolve(outputDir),
443
+ originalFileName: result.original,
444
+ fileName: result.fileName,
445
+ source: result.source,
446
+ provisional: result.optimized,
447
+ sourcemap,
448
+ asset: result.asset,
449
+ bundle,
450
+ moved: result.moved
451
+ });
452
+ logger.debug("vite", `Pruned the stylesheet against what the run knows, with ${remaining} still to compile. It is pruned again from source once the last environment has written its output, and renamed only if that restores a rule.`);
453
+ } else logger.debug("vite", `Pruning against the ${JSON.stringify(environmentName)} environment with ${remaining} still to compile. An in-memory build has no file to finalize, so a class only those reach fails the build rather than shipping without its rule.`);
454
+ } finally {
455
+ outputProjection.restore();
456
+ }
457
+ };
458
+ /**
459
+ * The early half of the stylesheet's output lifecycle. @see `pruneEmittedSheets`
460
+ *
461
+ * A plugin of its own because one plugin carries one `generateBundle`, and this one has to be
462
+ * ordered `pre` while the checks in `bamboocssCss` have to see the finished bundle.
463
+ */
464
+ const bamboocssCssEarly = (options) => ({
465
+ name: "bamboocss:css-early",
466
+ sharedDuringBuild: true,
467
+ /**
468
+ * A watch rebuild renders into the same output options object, so the names pruned by the
469
+ * previous build would otherwise still read as handled, and the sheet a rebuild re-emits
470
+ * under an unchanged name would ship unpruned.
471
+ */
472
+ renderStart(outputOptions) {
473
+ options.session.prunedSheetNames.delete(outputOptions);
474
+ },
475
+ generateBundle: {
476
+ order: "pre",
477
+ async handler(outputOptions, bundle, isWrite) {
478
+ await pruneEmittedSheets(this, options.session, outputOptions, bundle, isWrite, options.pruneCss ?? true, true);
479
+ }
480
+ }
481
+ });
482
+ /**
303
483
  * Serve bamboo's stylesheet as a virtual module, in dev and in build.
304
484
  *
305
485
  * This is the integration itself, not an optimisation: without it nothing emits css and
@@ -358,6 +538,13 @@ const bamboocssCss = (options) => {
358
538
  let pendingGeneration = -1;
359
539
  let servedCss;
360
540
  /**
541
+ * Whether the served stylesheet carries a source map: a dev server with Vite's
542
+ * `css.devSourcemap` on. Off, and the extraction pass records no call sites at all.
543
+ */
544
+ let devSourcemap = false;
545
+ /** Each atom's first call site, by class name, from the last pass. @see `Builder.getAtomOrigins` */
546
+ let atomOrigins;
547
+ /**
361
548
  * Held by the host for its whole length, rather than only around each mutation.
362
549
  *
363
550
  * Extraction fills the encoder this sheet is emitted from and `toCss` reads it back, with a
@@ -383,6 +570,7 @@ const bamboocssCss = (options) => {
383
570
  graphAtomHashes = new Set(activeBuilder.context.encoder.atomic);
384
571
  }
385
572
  const css = activeBuilder.toCss({ layerParams: true });
573
+ atomOrigins = devSourcemap ? activeBuilder.getAtomOrigins?.() : void 0;
386
574
  session.prunableClasses.clear();
387
575
  session.viewTransitionClasses.clear();
388
576
  if (graphAtomHashes && activeBuilder.context) {
@@ -475,7 +663,10 @@ const bamboocssCss = (options) => {
475
663
  async configResolved(config) {
476
664
  command = config.command;
477
665
  host.setCommand(config.command);
666
+ devSourcemap = config.command === "serve" && Boolean(config.css?.devSourcemap);
667
+ host.setDevSourcemap(devSourcemap);
478
668
  session.sourcemap = config.build.sourcemap;
669
+ session.cssCodeSplit = config.build.cssCodeSplit;
479
670
  ssrBuildOptions = {
480
671
  ssr: config.build.ssr,
481
672
  ssrEmitAssets: config.build.ssrEmitAssets
@@ -551,24 +742,34 @@ const bamboocssCss = (options) => {
551
742
  const generationAtStart = changeGeneration;
552
743
  if (command === "serve" && servedCss?.generation === generationAtStart) {
553
744
  if (this.addWatchFile) for (const file of session.extractedFiles) this.addWatchFile(file);
554
- return servedCss.css;
745
+ return servedCss.map ? {
746
+ code: servedCss.css,
747
+ map: servedCss.map
748
+ } : servedCss.css;
555
749
  }
556
750
  let css;
751
+ let map;
557
752
  try {
558
- const validateDevCss = command === "serve" ? (await loadCssOutput()).pruneStaticCss : void 0;
753
+ const cssOutput = command === "serve" ? await loadCssOutput() : void 0;
754
+ const validateDevCss = cssOutput?.pruneStaticCss;
559
755
  const first = prebuilt;
560
756
  prebuilt = void 0;
561
757
  css = await (first ?? generate());
562
758
  if (validateDevCss) css = validateDevCss(css, session, { prune: false });
759
+ if (devSourcemap && atomOrigins?.size) map = cssOutput?.cssSourceMap?.(css, atomOrigins);
563
760
  } catch (error) {
564
761
  throw asError(error, `failed to generate ${VIRTUAL_CSS_ID}`);
565
762
  }
566
763
  if (command === "serve" && generationAtStart === changeGeneration) servedCss = {
567
764
  generation: generationAtStart,
568
- css
765
+ css,
766
+ map
569
767
  };
570
768
  if (this.addWatchFile) for (const file of session.extractedFiles) this.addWatchFile(file);
571
- return css;
769
+ return map ? {
770
+ code: css,
771
+ map
772
+ } : css;
572
773
  },
573
774
  configureServer(devServer) {
574
775
  server = devServer;
@@ -610,49 +811,15 @@ const bamboocssCss = (options) => {
610
811
  },
611
812
  generateBundle: {
612
813
  order: "post",
613
- async handler(outputOptions, bundle) {
614
- const { containsGeneratedCssAsset, optimizeStaticCssAssets } = await loadCssOutputModule();
615
- const environment = this.environment;
814
+ async handler(outputOptions, bundle, isWrite) {
815
+ const context = this;
816
+ await pruneEmittedSheets(context, session, outputOptions, bundle, isWrite, pruneCss, false);
817
+ const { containsGeneratedCssAsset } = await loadCssOutputModule();
818
+ const environment = context.environment;
616
819
  const environmentName = environment?.name ?? "default";
617
820
  const replacesGeneratedStylesheet = containsGeneratedCssAsset(bundle);
618
- const outputProjection = session.beginOutputProjection(environmentName, outputOptions, bundle, replacesGeneratedStylesheet);
821
+ const outputProjection = session.beginOutputProjection(environmentName, outputOptions, bundle, false);
619
822
  try {
620
- /**
621
- * Pruned against what this environment compiled, without waiting for the rest.
622
- *
623
- * The stylesheet is emitted and finalized by the environment that *imports* it, which
624
- * in an SSR app is the client — and the client builds first, before the server
625
- * environment has transformed a single module. Waiting for a complete answer therefore
626
- * meant never pruning at all in any SSR framework: react-router, Remix, Nuxt, SvelteKit
627
- * and Qwik all build the client first, and the client's output is on disk before the
628
- * server environment starts. That is most production apps, and the feature was inert in
629
- * every one of them — silently, since a build with nothing to prune looks identical.
630
- *
631
- * The reason for waiting was real: a class only the server graph reaches is not in this
632
- * environment's reachability set, so pruning here removes rules the server-rendered
633
- * markup still names. What makes it safe to prune anyway is that the mistake is
634
- * *detectable* rather than silent — `buildEnd` in `plugin.ts` intersects every later
635
- * environment's compiled classes against `prunedClasses` and fails the build naming
636
- * them. A styled component that only ever renders on the server is the shape that
637
- * trips it, and `pruneCss: false` is the answer when it does.
638
- *
639
- * So the trade is deliberate: a loud build failure in the rare case, in exchange for
640
- * the feature working at all in the common one. It is the same reasoning as the
641
- * unimported-`virtual:bamboo.css` check — a class with no rule behind it must never
642
- * leave the build quietly.
643
- */
644
- const pending = remainingEnvironments(session);
645
- if (pending.length) logger.debug("vite", `Pruning against the ${JSON.stringify(environment?.name ?? "default")} environment with ${truncateList(pending, {
646
- unit: "environment",
647
- separator: ", "
648
- })} still to compile. A class only those reach fails the build rather than shipping without its rule.`);
649
- const { sheets } = optimizeStaticCssAssets(bundle, session, {
650
- environment: environmentName,
651
- prune: pruneCss,
652
- requiredClasses: outputProjection.requiredClasses,
653
- sourcemap: environment?.config?.build?.sourcemap
654
- });
655
- if (sheets && !pruneCss) logger.info("vite", "Reachability pruning is off (`pruneCss: false`). The full extracted stylesheet ships.");
656
823
  if (!outputProjection.cssLoaded) return;
657
824
  if (!session.transformedFiles.size) return;
658
825
  /**
@@ -754,6 +921,64 @@ const shouldTransform = (id) => {
754
921
  * Returns `null` when the module is still a raw SFC and must be left to the framework plugin.
755
922
  * Astro frontmatter is `---`, not `<script>`, so a tag check alone would parse the template.
756
923
  */
924
+ /** The module specifiers that reach bamboo — the `styled-system` paths and any `importMap` — per context. */
925
+ const entrypointNeedles = /* @__PURE__ */ new WeakMap();
926
+ /**
927
+ * Whether a module's text names a bamboo entrypoint at all.
928
+ *
929
+ * A textual test on purpose: it runs before the module is parsed, on modules outside the
930
+ * extraction inventory, to decide whether parsing is worth it. The outdir's own name is among
931
+ * the needles, so a relative import of the generated `styled-system` counts too.
932
+ */
933
+ const namesEntrypoint = (ctx, code) => {
934
+ let needles = entrypointNeedles.get(ctx);
935
+ if (!needles) {
936
+ const outdirName = ctx.imports.outdir.split("/").filter(Boolean).at(-1);
937
+ needles = [...new Set([outdirName ?? "", ...Object.values(ctx.imports.value).flat()].filter((needle) => needle.length > 0))];
938
+ entrypointNeedles.set(ctx, needles);
939
+ }
940
+ return needles.some((needle) => code.includes(needle));
941
+ };
942
+ /** A module specifier in an import, export-from, dynamic import or require. */
943
+ const MODULE_SPECIFIER = /\b(?:from|import|require)\s*\(?\s*['"]([^'"]+)['"]/g;
944
+ /** How a relative specifier may name a file, in the order the bundler tries them. */
945
+ const SPECIFIER_SUFFIXES = [
946
+ "",
947
+ ".ts",
948
+ ".tsx",
949
+ ".mts",
950
+ ".cts",
951
+ ".js",
952
+ ".jsx",
953
+ ".mjs",
954
+ ".cjs",
955
+ "/index.ts",
956
+ "/index.tsx",
957
+ "/index.js"
958
+ ];
959
+ /**
960
+ * Whether a module imports a module the shared project holds.
961
+ *
962
+ * Answered from project membership alone — no parse, no disk, no round trip to the compiler —
963
+ * which is what makes it affordable to ask of every module outside the extraction inventory.
964
+ * A relative specifier is tried against the file names a bundler would; a bare one against
965
+ * where the parser last resolved that package to, since a package the project holds a source
966
+ * of is one an included file imports a recipe from.
967
+ */
968
+ const importsProjectModule = (ctx, filePath, code) => {
969
+ const directory = dirname(filePath);
970
+ for (const match of code.matchAll(MODULE_SPECIFIER)) {
971
+ const specifier = match[1];
972
+ if (specifier.startsWith(".") || specifier.startsWith("/")) {
973
+ const base = specifier.startsWith("/") ? specifier : resolve(directory, specifier);
974
+ for (const suffix of SPECIFIER_SUFFIXES) if (ctx.project.hasSourceFile(base + suffix)) return true;
975
+ continue;
976
+ }
977
+ const target = ctx.project.bareSpecifierTarget(specifier);
978
+ if (target && ctx.project.hasSourceFile(target)) return true;
979
+ }
980
+ return false;
981
+ };
757
982
  const compilerParsePath = (id, code) => {
758
983
  const [filePath, query = ""] = id.split("?");
759
984
  if (!filePath) return null;
@@ -832,11 +1057,12 @@ const formatSkipped = (id, skipped) => {
832
1057
  * therefore fold in the post plugin, after the framework has extracted them.
833
1058
  */
834
1059
  const bamboocss = (options = {}) => {
835
- const { configPath, cwd, reportSkipped = false, reportSummary = true, maxRecipeStates, pruneCss = true } = options;
1060
+ const { configPath, cwd, reportSkipped = false, reportSummary = true, maxRecipeStates, pruneCss = true, splitCss = true } = options;
836
1061
  markStaticCompilerActive();
837
1062
  if (maxRecipeStates !== void 0 && (!Number.isSafeInteger(maxRecipeStates) || maxRecipeStates < 1)) throw new Error("bamboocss: `maxRecipeStates` must be a positive safe integer.");
838
1063
  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.");
839
1064
  const staticSession = createStaticCompilationSession();
1065
+ staticSession.splitCss = splitCss;
840
1066
  /**
841
1067
  * One Builder, one resolved config, one context and one ts-morph project for the run.
842
1068
  *
@@ -1266,6 +1492,42 @@ const bamboocss = (options = {}) => {
1266
1492
  staticSession.prunedClasses.clear();
1267
1493
  for (const slots of liveOutputSlotsByEnvironment.values()) for (const slot of slots.values()) for (const className of slot.prunedClasses ?? []) staticSession.prunedClasses.add(className);
1268
1494
  };
1495
+ /**
1496
+ * Prune the sheets the run wrote whole, now that every environment has contributed.
1497
+ *
1498
+ * Reached from the write hook of whichever environment completes the run, which is the first
1499
+ * point at which the union of every environment's reachability exists and every reference to
1500
+ * the sheet is on disk. A run that never completes — an environment declared and never built
1501
+ * — leaves the sheets whole, and says so as the process exits.
1502
+ */
1503
+ const finalizeDeferredSheetsIfComplete = async (candidate, bundle, sourcemap) => {
1504
+ if (!staticSession.deferredSheets.length) return;
1505
+ if (remainingEnvironments(staticSession, candidate).length) return;
1506
+ const sheets = staticSession.deferredSheets.splice(0);
1507
+ const { finalizeDeferredSheets } = await loadCssOutputModule();
1508
+ const committed = staticSession.prunedClasses;
1509
+ staticSession.prunedClasses = /* @__PURE__ */ new Set();
1510
+ let finalized;
1511
+ let lost;
1512
+ try {
1513
+ finalized = finalizeDeferredSheets(sheets, staticSession, {
1514
+ prune: pruneCss,
1515
+ requiredClasses: currentRequiredClasses(),
1516
+ outputs: staticSession.writtenOutputs,
1517
+ bundle,
1518
+ sourcemap
1519
+ });
1520
+ } finally {
1521
+ lost = staticSession.prunedClasses;
1522
+ staticSession.prunedClasses = committed;
1523
+ }
1524
+ for (const sheet of finalized) for (const slot of liveOutputSlotsByEnvironment.get(sheet.environment)?.values() ?? []) slot.prunedClasses = new Set(lost);
1525
+ rebuildLivePrunedClasses();
1526
+ for (const sheet of finalized) {
1527
+ if (!sheet.renamed) continue;
1528
+ logger.info("vite", `Pruned ${sheet.originalFileName} against every environment once the last had written, which restored a rule the earlier prune removed: ${sheet.before} → ${sheet.after} bytes, now ${sheet.renamed}.`);
1529
+ }
1530
+ };
1269
1531
  const observeEnvironmentBuildStart = (environment) => {
1270
1532
  const serial = (nextBuildSerialByEnvironment.get(environment) ?? 0) + 1;
1271
1533
  nextBuildSerialByEnvironment.set(environment, serial);
@@ -1385,6 +1647,8 @@ const bamboocss = (options = {}) => {
1385
1647
  rebuildStaticTransformContributions();
1386
1648
  rebuildLivePrunedClasses();
1387
1649
  };
1650
+ staticSession.finalizeDeferred = ({ environment, bundle, sourcemap }) => finalizeDeferredSheetsIfComplete(environment, bundle, sourcemap);
1651
+ staticSession.classNamesOf = (environment, moduleId) => transformStateByEnvironment.get(environment)?.transformArtifactsByModule.get(moduleId)?.classNames;
1388
1652
  staticSession.beginOutputProjection = (environment, outputOptions, bundle, replacesGeneratedStylesheet) => {
1389
1653
  const generation = preparedGenerations.get(environment);
1390
1654
  if (!generation || transformStateByEnvironment.get(environment) !== generation.state) {
@@ -1407,9 +1671,13 @@ const bamboocss = (options = {}) => {
1407
1671
  restore() {
1408
1672
  if (restored) return;
1409
1673
  restored = true;
1674
+ const previous = outputStageByBundle.get(bundle) ?? outputStageByOptions.get(outputOptions);
1410
1675
  const stage = replacesGeneratedStylesheet ? {
1411
1676
  cssDigest: bambooCssDigest(bundle),
1412
- prunedClasses: new Set(staticSession.prunedClasses)
1677
+ prunedClasses: new Set([...previous?.prunedClasses ?? [], ...staticSession.prunedClasses])
1678
+ } : previous ? {
1679
+ cssDigest: bambooCssDigest(bundle),
1680
+ prunedClasses: new Set(previous.prunedClasses ?? [])
1413
1681
  } : {};
1414
1682
  outputStageByBundle.set(bundle, stage);
1415
1683
  outputStageByOptions.set(outputOptions, stage);
@@ -1751,6 +2019,7 @@ const bamboocss = (options = {}) => {
1751
2019
  let styleCompiler;
1752
2020
  let command = "build";
1753
2021
  let defaultEmitAssets = true;
2022
+ let exitWarningInstalled = false;
1754
2023
  /**
1755
2024
  * Expand semantic leaf reads through the Project's exact resolution paths.
1756
2025
  *
@@ -1993,6 +2262,16 @@ const bamboocss = (options = {}) => {
1993
2262
  command = config.command;
1994
2263
  host.setCommand(config.command);
1995
2264
  defaultEmitAssets = config.build?.emitAssets ?? (!config.build?.ssr || config.build?.ssrEmitAssets === true);
2265
+ if (config.command === "build" && !exitWarningInstalled) {
2266
+ exitWarningInstalled = true;
2267
+ process.once("beforeExit", () => {
2268
+ if (!staticSession.deferredSheets.length) return;
2269
+ logger.warn("vite", `The stylesheet was pruned against an incomplete run: ${truncateList(remainingEnvironments(staticSession), {
2270
+ unit: "environment",
2271
+ separator: ", "
2272
+ })} never completed, so a rule only those reach was never restored. Build every declared environment, or set \`bamboocss({ pruneCss: false })\` to ship the full extracted stylesheet.`);
2273
+ });
2274
+ }
1996
2275
  const plugins = config.plugins;
1997
2276
  if (plugins) {
1998
2277
  for (const finalizer of [outputWriteObserver, memoryOutputCommitter]) {
@@ -2177,11 +2456,11 @@ const bamboocss = (options = {}) => {
2177
2456
  rebuildStaticTransformContributions(environment, state);
2178
2457
  const survivors = allSurvivors(states);
2179
2458
  if (survivors.length) throw createSurvivorError(survivors);
2180
- const lost = currentWillEmitCss ? [] : [...staticSession.usedClasses].filter((className) => staticSession.prunedClasses.has(bare(className)));
2459
+ const lost = currentWillEmitCss || staticSession.deferredSheets.length ? [] : [...staticSession.usedClasses].filter((className) => staticSession.prunedClasses.has(bare(className)));
2181
2460
  if (lost.length) 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}`), {
2182
2461
  unit: "class",
2183
2462
  separator: "\n"
2184
- })}\n\nThe stylesheet is finalized by the environment that imports it — the client, which builds first — so it is pruned against what that environment compiled. These classes are reached only from here, so no rule for them survived.\n\nThat usually means a styled component which renders only on the server. Either give the client a path to it, or set \`bamboocss({ pruneCss: false })\` to ship the whole extracted stylesheet.`);
2463
+ })}\n\nThe stylesheet was pruned before this environment compiled. A run that announces its environments \`builder\` in the Vite config, which every framework building more than one sets holds pruning back until the last one has written, so this is a run that built environments one at a time without saying so, or a rebuild of this environment alone after the sheet was finalized. These classes are reached only from here, so no rule for them survived.\n\nConfigure \`builder\` so the run announces its environments, rebuild every environment together, or set \`bamboocss({ pruneCss: false })\` to ship the whole extracted stylesheet.`);
2185
2464
  const remaining = remainingEnvironments(staticSession, environment);
2186
2465
  if (typeof this.getModuleInfo === "function" && !remaining.length) {
2187
2466
  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.`);
@@ -2226,6 +2505,10 @@ const bamboocss = (options = {}) => {
2226
2505
  if (!ctx || !foldSourceImpl || !runtimeCss || !styleCompiler) return null;
2227
2506
  const [filePath] = id.split("?");
2228
2507
  if (isGeneratedOutput(filePath, ctx)) return null;
2508
+ if (!(ctx.project.hasSourceFile(filePath) && ctx.project.getSourceFile(filePath)?.getFullText() === code) && !namesEntrypoint(ctx, code) && !importsProjectModule(ctx, filePath, code)) {
2509
+ logger.debug("vite:transform", `Skipped ${filePath}: ${host.isSourceFile(filePath) ? "rewritten before bamboo" : "outside `include`"}, and it reaches nothing bamboo`);
2510
+ return null;
2511
+ }
2229
2512
  const requestedParsePath = compilerParsePath(id, code);
2230
2513
  if (requestedParsePath === null) return null;
2231
2514
  const state = environmentState(this);
@@ -2388,9 +2671,17 @@ const bamboocss = (options = {}) => {
2388
2671
  writeBundle: {
2389
2672
  order: "pre",
2390
2673
  sequential: true,
2391
- handler(outputOptions, bundle) {
2674
+ async handler(outputOptions, bundle) {
2675
+ const environment = environmentName(this);
2676
+ const outputDir = outputOptions.dir ?? (outputOptions.file ? dirname(outputOptions.file) : void 0);
2677
+ if (outputDir) staticSession.writtenOutputs.push({
2678
+ environment,
2679
+ dir: resolve(outputDir),
2680
+ files: Object.values(bundle).map((output) => output.fileName)
2681
+ });
2392
2682
  const identity = outputIdentityByBundle.get(bundle) ?? outputIdentityByOptions.get(outputOptions);
2393
- if (identity?.environment === environmentName(this)) publishPreparedOutput(identity.environment, identity.outputToken, identity.outputSlot, true);
2683
+ if (identity?.environment === environment) publishPreparedOutput(identity.environment, identity.outputToken, identity.outputSlot, true);
2684
+ await finalizeDeferredSheetsIfComplete();
2394
2685
  }
2395
2686
  }
2396
2687
  };
@@ -2414,6 +2705,10 @@ const bamboocss = (options = {}) => {
2414
2705
  session: staticSession,
2415
2706
  pruneCss
2416
2707
  }),
2708
+ bamboocssCssEarly({
2709
+ session: staticSession,
2710
+ pruneCss
2711
+ }),
2417
2712
  compiler,
2418
2713
  compilerSfc
2419
2714
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bamboocss/vite",
3
- "version": "1.53.0",
3
+ "version": "1.54.0",
4
4
  "description": "Vite integration for Bamboo CSS",
5
5
  "homepage": "https://bamboocss.com",
6
6
  "license": "MIT",
@@ -38,23 +38,24 @@
38
38
  },
39
39
  "dependencies": {
40
40
  "@ampproject/remapping": "2.3.0",
41
+ "@jridgewell/gen-mapping": "0.3.13",
41
42
  "magic-string": "0.30.21",
42
43
  "postcss": "8.5.26",
43
44
  "postcss-selector-parser": "7.1.5",
44
- "@bamboocss/config": "1.53.0",
45
- "@bamboocss/core": "1.53.0",
46
- "@bamboocss/node": "1.53.0",
47
- "@bamboocss/extractor": "1.53.0",
48
- "@bamboocss/shared": "1.53.0",
49
- "@bamboocss/logger": "1.53.0",
50
- "@bamboocss/ts-ast": "1.53.0",
51
- "@bamboocss/types": "1.53.0"
45
+ "@bamboocss/config": "1.54.0",
46
+ "@bamboocss/core": "1.54.0",
47
+ "@bamboocss/extractor": "1.54.0",
48
+ "@bamboocss/node": "1.54.0",
49
+ "@bamboocss/logger": "1.54.0",
50
+ "@bamboocss/shared": "1.54.0",
51
+ "@bamboocss/ts-ast": "1.54.0",
52
+ "@bamboocss/types": "1.54.0"
52
53
  },
53
54
  "devDependencies": {
54
55
  "@jridgewell/trace-mapping": "^0.3.31",
55
56
  "@typescript/api": "npm:typescript@7.1.0-dev.20260826.1",
56
57
  "vite": "7.2.6",
57
- "@bamboocss/fixture": "1.53.0"
58
+ "@bamboocss/fixture": "1.54.0"
58
59
  },
59
60
  "peerDependencies": {
60
61
  "vite": ">=5"