@bamboocss/vite 1.53.1 → 1.54.1

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
@@ -4,11 +4,11 @@ Object.defineProperties(exports, {
4
4
  });
5
5
  require("./chunk.cjs");
6
6
  const require_class_name = require("./class-name.cjs");
7
+ let node_path = require("node:path");
7
8
  let _bamboocss_logger = require("@bamboocss/logger");
8
9
  let _bamboocss_shared = require("@bamboocss/shared");
9
10
  let node_crypto = require("node:crypto");
10
11
  let node_fs = require("node:fs");
11
- let node_path = require("node:path");
12
12
  let _bamboocss_node_static_compiler = require("@bamboocss/node/static-compiler");
13
13
  //#region src/lazy-modules.ts
14
14
  /**
@@ -64,6 +64,7 @@ const createCompilationHost = (options = {}) => {
64
64
  const { configPath, cwd } = options;
65
65
  const loadBuilder = options.loadBuilder ?? createLazyBuilder();
66
66
  let command = "build";
67
+ let devSourcemap = false;
67
68
  let builder;
68
69
  let generation;
69
70
  let nextGenerationId = 0;
@@ -126,6 +127,7 @@ const createCompilationHost = (options = {}) => {
126
127
  configPath,
127
128
  cwd,
128
129
  dev: command === "serve",
130
+ atomOrigins: command === "serve" && devSourcemap,
129
131
  ...command === "serve" ? { sourceChanges } : {}
130
132
  });
131
133
  return publish();
@@ -158,6 +160,12 @@ const createCompilationHost = (options = {}) => {
158
160
  setCommand(next) {
159
161
  command = next;
160
162
  },
163
+ setDevSourcemap(enabled) {
164
+ devSourcemap = enabled;
165
+ },
166
+ isSourceFile(filePath) {
167
+ return builder?.context ? builder.isPotentialSourceFile(filePath) : true;
168
+ },
161
169
  current: () => generation,
162
170
  async ensureGeneration() {
163
171
  if (cssPass) await settled(cssPass);
@@ -216,6 +224,11 @@ const createStaticCompilationSession = () => {
216
224
  participatingEnvironments: /* @__PURE__ */ new Set(),
217
225
  completedEnvironments: /* @__PURE__ */ new Set(),
218
226
  prunedClasses: /* @__PURE__ */ new Set(),
227
+ deferredSheets: [],
228
+ writtenOutputs: [],
229
+ prunedAssets: /* @__PURE__ */ new WeakSet(),
230
+ prunedSheetNames: /* @__PURE__ */ new WeakMap(),
231
+ splitCss: true,
219
232
  beginOutputProjection(_environment, _outputOptions, _bundle, replacesGeneratedStylesheet) {
220
233
  if (replacesGeneratedStylesheet) session.prunedClasses.clear();
221
234
  const prunable = new Set([...session.prunableClasses].map((className) => className.replaceAll("\\", "")));
@@ -305,6 +318,173 @@ const queryOf = (id) => {
305
318
  */
306
319
  const asError = (error, context) => error instanceof Error ? error : new Error(`bamboocss: ${context}: ${String(error)}`, { cause: error });
307
320
  /**
321
+ * Which lazily loaded chunk each atom exclusive to one belongs to.
322
+ *
323
+ * An atom belongs to a chunk when every module that emits it is in that chunk, and the chunk
324
+ * is not loaded with an entry anyway — an entry, or anything an entry statically imports,
325
+ * would put the atom in the entry sheet's own company either way. An atom two chunks share,
326
+ * or one no compiled module emits — `staticCss` — has no owner and stays where every route
327
+ * finds it. Loading with an entry is the static import closure of every entry, which is what
328
+ * the browser fetches before the first render.
329
+ */
330
+ const chunkOwnership = (bundle, environment, session) => {
331
+ const classNamesOf = session.classNamesOf;
332
+ const ownership = /* @__PURE__ */ new Map();
333
+ if (!classNamesOf) return ownership;
334
+ const chunks = Object.values(bundle).filter((output) => output.type === "chunk");
335
+ const byFileName = new Map(chunks.map((chunk) => [chunk.fileName, chunk]));
336
+ const eager = /* @__PURE__ */ new Set();
337
+ const visit = (fileName) => {
338
+ if (eager.has(fileName)) return;
339
+ eager.add(fileName);
340
+ for (const imported of byFileName.get(fileName)?.imports ?? []) visit(imported);
341
+ };
342
+ for (const chunk of chunks) if (chunk.isEntry) visit(chunk.fileName);
343
+ const owners = /* @__PURE__ */ new Map();
344
+ for (const chunk of chunks) {
345
+ const owner = eager.has(chunk.fileName) ? null : chunk.fileName;
346
+ for (const moduleId of Object.keys(chunk.modules)) for (const classNames of classNamesOf(environment, moduleId) ?? []) for (const token of classNames.split(" ")) {
347
+ if (!token) continue;
348
+ const className = require_class_name.bare(token);
349
+ const previous = owners.get(className);
350
+ if (previous === void 0) owners.set(className, owner);
351
+ else if (previous !== owner) owners.set(className, null);
352
+ }
353
+ }
354
+ for (const [className, owner] of owners) if (owner !== null) ownership.set(className, owner);
355
+ _bamboocss_logger.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.`);
356
+ return ownership;
357
+ };
358
+ /**
359
+ * Prune every generated sheet in `bundle` this generation has not pruned yet.
360
+ *
361
+ * Reached twice per output. The early hook, ordered `pre`, reaches a sheet Vite emitted while
362
+ * rendering chunks — every `cssCodeSplit: true` build — before any other plugin's
363
+ * `generateBundle` reads its name, so a framework recording asset names records the final one.
364
+ * That was not a courtesy: `@vitejs/plugin-rsc` snapshots the server build's stylesheet name in
365
+ * a normal-order hook and writes it into a manifest at the end of the run, so a rename in a
366
+ * `post` hook left every server-rendered page linking a stylesheet that no longer existed. The
367
+ * late hook, ordered `post`, reaches what Vite emits from its own `generateBundle` — the single
368
+ * `style.css` of a `cssCodeSplit: false` build. Exactly one of the two opens a projection for a
369
+ * given sheet.
370
+ *
371
+ * Pruning waits for no one; finalizing does. The stylesheet is emitted by the environment that
372
+ * *imports* it, which in an SSR app is the client — and the client builds first, before the
373
+ * server environment has transformed a single module. Two answers were tried before this one.
374
+ * Holding the sheet back until every environment had contributed meant never pruning in any SSR
375
+ * framework, since the client's output is on disk before the server starts. Pruning against the
376
+ * client alone, with a guard that failed the build when a later environment reached a rule it had
377
+ * removed, made a styled component that renders only on the server a build failure — and under
378
+ * React Server Components most components never reach the client graph at all.
379
+ *
380
+ * So while environments remain, the sheet is pruned against what the run knows so far and
381
+ * written under a name hashed from those bytes, and it is recorded as deferred with its unpruned
382
+ * source. When the last environment writes its output, `bamboocss:output-write-observer` prunes
383
+ * that source again against the union of every environment's reachability. Usually the result is
384
+ * the bytes already on disk, and nothing moves. When a later environment restored a rule, the
385
+ * final bytes go under a new name, every written reference moves with them, and so does any copy
386
+ * of the provisional sheet another output carries. A single-environment build, a run whose
387
+ * sheet-carrying environment builds last, and an in-memory build take the direct path, where the
388
+ * guard in `buildEnd` still fails a later environment that reaches a rule the sheet lost.
389
+ */
390
+ const pruneEmittedSheets = async (context, session, outputOptions, bundle, isWrite, pruneCss, splitCss) => {
391
+ const { containsGeneratedCssAsset, optimizeStaticCssAssets } = await loadCssOutputModule();
392
+ let handledNames = session.prunedSheetNames.get(outputOptions);
393
+ if (!handledNames) {
394
+ handledNames = /* @__PURE__ */ new Set();
395
+ session.prunedSheetNames.set(outputOptions, handledNames);
396
+ }
397
+ const carriesSheet = containsGeneratedCssAsset(bundle, session.prunedAssets, handledNames);
398
+ const environmentName = context.environment?.name ?? "default";
399
+ const pending = remainingEnvironments(session, environmentName);
400
+ const completesRun = !pending.length && session.deferredSheets.length > 0;
401
+ if (!carriesSheet && !completesRun) return;
402
+ const outputProjection = session.beginOutputProjection(environmentName, outputOptions, bundle, carriesSheet);
403
+ try {
404
+ const outputDir = outputOptions.dir ?? (outputOptions.file ? (0, node_path.dirname)(outputOptions.file) : void 0);
405
+ const deferred = pruneCss && isWrite === true && outputDir !== void 0 && pending.length > 0;
406
+ const remaining = (0, _bamboocss_shared.truncateList)(pending, {
407
+ unit: "environment",
408
+ separator: ", "
409
+ });
410
+ const sourcemap = context.environment?.config?.build?.sourcemap ?? session.sourcemap;
411
+ if (completesRun) await session.finalizeDeferred?.({
412
+ environment: environmentName,
413
+ bundle,
414
+ sourcemap
415
+ });
416
+ if (!carriesSheet) return;
417
+ const cssCodeSplit = context.environment?.config?.build?.cssCodeSplit ?? session.cssCodeSplit ?? true;
418
+ const split = splitCss && session.splitCss && pruneCss && cssCodeSplit && context.emitFile && context.getFileName ? {
419
+ ownership: chunkOwnership(bundle, environmentName, session),
420
+ emit: (chunkFileName, css) => {
421
+ const chunk = bundle[chunkFileName];
422
+ const referenceId = context.emitFile({
423
+ type: "asset",
424
+ name: `${chunk?.name ?? "chunk"}.css`,
425
+ source: css
426
+ });
427
+ const fileName = context.getFileName(referenceId);
428
+ chunk?.viteMetadata?.importedCss?.add(fileName);
429
+ const emitted = bundle[fileName];
430
+ if (emitted) session.prunedAssets.add(emitted);
431
+ }
432
+ } : void 0;
433
+ const { sheets, results } = optimizeStaticCssAssets(bundle, session, {
434
+ environment: environmentName,
435
+ prune: pruneCss,
436
+ requiredClasses: outputProjection.requiredClasses,
437
+ sourcemap: context.environment?.config?.build?.sourcemap,
438
+ handled: session.prunedAssets,
439
+ handledNames,
440
+ split
441
+ });
442
+ if (sheets && !pruneCss) _bamboocss_logger.logger.info("vite", "Reachability pruning is off (`pruneCss: false`). The full extracted stylesheet ships.");
443
+ if (!sheets || !pruneCss || !pending.length) return;
444
+ if (deferred) {
445
+ for (const result of results) session.deferredSheets.push({
446
+ environment: environmentName,
447
+ dir: (0, node_path.resolve)(outputDir),
448
+ originalFileName: result.original,
449
+ fileName: result.fileName,
450
+ source: result.source,
451
+ provisional: result.optimized,
452
+ sourcemap,
453
+ asset: result.asset,
454
+ bundle,
455
+ moved: result.moved
456
+ });
457
+ _bamboocss_logger.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.`);
458
+ } else _bamboocss_logger.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.`);
459
+ } finally {
460
+ outputProjection.restore();
461
+ }
462
+ };
463
+ /**
464
+ * The early half of the stylesheet's output lifecycle. @see `pruneEmittedSheets`
465
+ *
466
+ * A plugin of its own because one plugin carries one `generateBundle`, and this one has to be
467
+ * ordered `pre` while the checks in `bamboocssCss` have to see the finished bundle.
468
+ */
469
+ const bamboocssCssEarly = (options) => ({
470
+ name: "bamboocss:css-early",
471
+ sharedDuringBuild: true,
472
+ /**
473
+ * A watch rebuild renders into the same output options object, so the names pruned by the
474
+ * previous build would otherwise still read as handled, and the sheet a rebuild re-emits
475
+ * under an unchanged name would ship unpruned.
476
+ */
477
+ renderStart(outputOptions) {
478
+ options.session.prunedSheetNames.delete(outputOptions);
479
+ },
480
+ generateBundle: {
481
+ order: "pre",
482
+ async handler(outputOptions, bundle, isWrite) {
483
+ await pruneEmittedSheets(this, options.session, outputOptions, bundle, isWrite, options.pruneCss ?? true, true);
484
+ }
485
+ }
486
+ });
487
+ /**
308
488
  * Serve bamboo's stylesheet as a virtual module, in dev and in build.
309
489
  *
310
490
  * This is the integration itself, not an optimisation: without it nothing emits css and
@@ -363,6 +543,13 @@ const bamboocssCss = (options) => {
363
543
  let pendingGeneration = -1;
364
544
  let servedCss;
365
545
  /**
546
+ * Whether the served stylesheet carries a source map: a dev server with Vite's
547
+ * `css.devSourcemap` on. Off, and the extraction pass records no call sites at all.
548
+ */
549
+ let devSourcemap = false;
550
+ /** Each atom's first call site, by class name, from the last pass. @see `Builder.getAtomOrigins` */
551
+ let atomOrigins;
552
+ /**
366
553
  * Held by the host for its whole length, rather than only around each mutation.
367
554
  *
368
555
  * Extraction fills the encoder this sheet is emitted from and `toCss` reads it back, with a
@@ -388,6 +575,7 @@ const bamboocssCss = (options) => {
388
575
  graphAtomHashes = new Set(activeBuilder.context.encoder.atomic);
389
576
  }
390
577
  const css = activeBuilder.toCss({ layerParams: true });
578
+ atomOrigins = devSourcemap ? activeBuilder.getAtomOrigins?.() : void 0;
391
579
  session.prunableClasses.clear();
392
580
  session.viewTransitionClasses.clear();
393
581
  if (graphAtomHashes && activeBuilder.context) {
@@ -480,7 +668,10 @@ const bamboocssCss = (options) => {
480
668
  async configResolved(config) {
481
669
  command = config.command;
482
670
  host.setCommand(config.command);
671
+ devSourcemap = config.command === "serve" && Boolean(config.css?.devSourcemap);
672
+ host.setDevSourcemap(devSourcemap);
483
673
  session.sourcemap = config.build.sourcemap;
674
+ session.cssCodeSplit = config.build.cssCodeSplit;
484
675
  ssrBuildOptions = {
485
676
  ssr: config.build.ssr,
486
677
  ssrEmitAssets: config.build.ssrEmitAssets
@@ -556,24 +747,34 @@ const bamboocssCss = (options) => {
556
747
  const generationAtStart = changeGeneration;
557
748
  if (command === "serve" && servedCss?.generation === generationAtStart) {
558
749
  if (this.addWatchFile) for (const file of session.extractedFiles) this.addWatchFile(file);
559
- return servedCss.css;
750
+ return servedCss.map ? {
751
+ code: servedCss.css,
752
+ map: servedCss.map
753
+ } : servedCss.css;
560
754
  }
561
755
  let css;
756
+ let map;
562
757
  try {
563
- const validateDevCss = command === "serve" ? (await loadCssOutput()).pruneStaticCss : void 0;
758
+ const cssOutput = command === "serve" ? await loadCssOutput() : void 0;
759
+ const validateDevCss = cssOutput?.pruneStaticCss;
564
760
  const first = prebuilt;
565
761
  prebuilt = void 0;
566
762
  css = await (first ?? generate());
567
763
  if (validateDevCss) css = validateDevCss(css, session, { prune: false });
764
+ if (devSourcemap && atomOrigins?.size) map = cssOutput?.cssSourceMap?.(css, atomOrigins);
568
765
  } catch (error) {
569
766
  throw asError(error, `failed to generate ${VIRTUAL_CSS_ID}`);
570
767
  }
571
768
  if (command === "serve" && generationAtStart === changeGeneration) servedCss = {
572
769
  generation: generationAtStart,
573
- css
770
+ css,
771
+ map
574
772
  };
575
773
  if (this.addWatchFile) for (const file of session.extractedFiles) this.addWatchFile(file);
576
- return css;
774
+ return map ? {
775
+ code: css,
776
+ map
777
+ } : css;
577
778
  },
578
779
  configureServer(devServer) {
579
780
  server = devServer;
@@ -615,49 +816,15 @@ const bamboocssCss = (options) => {
615
816
  },
616
817
  generateBundle: {
617
818
  order: "post",
618
- async handler(outputOptions, bundle) {
619
- const { containsGeneratedCssAsset, optimizeStaticCssAssets } = await loadCssOutputModule();
620
- const environment = this.environment;
819
+ async handler(outputOptions, bundle, isWrite) {
820
+ const context = this;
821
+ await pruneEmittedSheets(context, session, outputOptions, bundle, isWrite, pruneCss, false);
822
+ const { containsGeneratedCssAsset } = await loadCssOutputModule();
823
+ const environment = context.environment;
621
824
  const environmentName = environment?.name ?? "default";
622
825
  const replacesGeneratedStylesheet = containsGeneratedCssAsset(bundle);
623
- const outputProjection = session.beginOutputProjection(environmentName, outputOptions, bundle, replacesGeneratedStylesheet);
826
+ const outputProjection = session.beginOutputProjection(environmentName, outputOptions, bundle, false);
624
827
  try {
625
- /**
626
- * Pruned against what this environment compiled, without waiting for the rest.
627
- *
628
- * The stylesheet is emitted and finalized by the environment that *imports* it, which
629
- * in an SSR app is the client — and the client builds first, before the server
630
- * environment has transformed a single module. Waiting for a complete answer therefore
631
- * meant never pruning at all in any SSR framework: react-router, Remix, Nuxt, SvelteKit
632
- * and Qwik all build the client first, and the client's output is on disk before the
633
- * server environment starts. That is most production apps, and the feature was inert in
634
- * every one of them — silently, since a build with nothing to prune looks identical.
635
- *
636
- * The reason for waiting was real: a class only the server graph reaches is not in this
637
- * environment's reachability set, so pruning here removes rules the server-rendered
638
- * markup still names. What makes it safe to prune anyway is that the mistake is
639
- * *detectable* rather than silent — `buildEnd` in `plugin.ts` intersects every later
640
- * environment's compiled classes against `prunedClasses` and fails the build naming
641
- * them. A styled component that only ever renders on the server is the shape that
642
- * trips it, and `pruneCss: false` is the answer when it does.
643
- *
644
- * So the trade is deliberate: a loud build failure in the rare case, in exchange for
645
- * the feature working at all in the common one. It is the same reasoning as the
646
- * unimported-`virtual:bamboo.css` check — a class with no rule behind it must never
647
- * leave the build quietly.
648
- */
649
- const pending = remainingEnvironments(session);
650
- if (pending.length) _bamboocss_logger.logger.debug("vite", `Pruning against the ${JSON.stringify(environment?.name ?? "default")} environment with ${(0, _bamboocss_shared.truncateList)(pending, {
651
- unit: "environment",
652
- separator: ", "
653
- })} still to compile. A class only those reach fails the build rather than shipping without its rule.`);
654
- const { sheets } = optimizeStaticCssAssets(bundle, session, {
655
- environment: environmentName,
656
- prune: pruneCss,
657
- requiredClasses: outputProjection.requiredClasses,
658
- sourcemap: environment?.config?.build?.sourcemap
659
- });
660
- if (sheets && !pruneCss) _bamboocss_logger.logger.info("vite", "Reachability pruning is off (`pruneCss: false`). The full extracted stylesheet ships.");
661
828
  if (!outputProjection.cssLoaded) return;
662
829
  if (!session.transformedFiles.size) return;
663
830
  /**
@@ -759,6 +926,64 @@ const shouldTransform = (id) => {
759
926
  * Returns `null` when the module is still a raw SFC and must be left to the framework plugin.
760
927
  * Astro frontmatter is `---`, not `<script>`, so a tag check alone would parse the template.
761
928
  */
929
+ /** The module specifiers that reach bamboo — the `styled-system` paths and any `importMap` — per context. */
930
+ const entrypointNeedles = /* @__PURE__ */ new WeakMap();
931
+ /**
932
+ * Whether a module's text names a bamboo entrypoint at all.
933
+ *
934
+ * A textual test on purpose: it runs before the module is parsed, on modules outside the
935
+ * extraction inventory, to decide whether parsing is worth it. The outdir's own name is among
936
+ * the needles, so a relative import of the generated `styled-system` counts too.
937
+ */
938
+ const namesEntrypoint = (ctx, code) => {
939
+ let needles = entrypointNeedles.get(ctx);
940
+ if (!needles) {
941
+ const outdirName = ctx.imports.outdir.split("/").filter(Boolean).at(-1);
942
+ needles = [...new Set([outdirName ?? "", ...Object.values(ctx.imports.value).flat()].filter((needle) => needle.length > 0))];
943
+ entrypointNeedles.set(ctx, needles);
944
+ }
945
+ return needles.some((needle) => code.includes(needle));
946
+ };
947
+ /** A module specifier in an import, export-from, dynamic import or require. */
948
+ const MODULE_SPECIFIER = /\b(?:from|import|require)\s*\(?\s*['"]([^'"]+)['"]/g;
949
+ /** How a relative specifier may name a file, in the order the bundler tries them. */
950
+ const SPECIFIER_SUFFIXES = [
951
+ "",
952
+ ".ts",
953
+ ".tsx",
954
+ ".mts",
955
+ ".cts",
956
+ ".js",
957
+ ".jsx",
958
+ ".mjs",
959
+ ".cjs",
960
+ "/index.ts",
961
+ "/index.tsx",
962
+ "/index.js"
963
+ ];
964
+ /**
965
+ * Whether a module imports a module the shared project holds.
966
+ *
967
+ * Answered from project membership alone — no parse, no disk, no round trip to the compiler —
968
+ * which is what makes it affordable to ask of every module outside the extraction inventory.
969
+ * A relative specifier is tried against the file names a bundler would; a bare one against
970
+ * where the parser last resolved that package to, since a package the project holds a source
971
+ * of is one an included file imports a recipe from.
972
+ */
973
+ const importsProjectModule = (ctx, filePath, code) => {
974
+ const directory = (0, node_path.dirname)(filePath);
975
+ for (const match of code.matchAll(MODULE_SPECIFIER)) {
976
+ const specifier = match[1];
977
+ if (specifier.startsWith(".") || specifier.startsWith("/")) {
978
+ const base = specifier.startsWith("/") ? specifier : (0, node_path.resolve)(directory, specifier);
979
+ for (const suffix of SPECIFIER_SUFFIXES) if (ctx.project.hasSourceFile(base + suffix)) return true;
980
+ continue;
981
+ }
982
+ const target = ctx.project.bareSpecifierTarget(specifier);
983
+ if (target && ctx.project.hasSourceFile(target)) return true;
984
+ }
985
+ return false;
986
+ };
762
987
  const compilerParsePath = (id, code) => {
763
988
  const [filePath, query = ""] = id.split("?");
764
989
  if (!filePath) return null;
@@ -837,11 +1062,12 @@ const formatSkipped = (id, skipped) => {
837
1062
  * therefore fold in the post plugin, after the framework has extracted them.
838
1063
  */
839
1064
  const bamboocss = (options = {}) => {
840
- const { configPath, cwd, reportSkipped = false, reportSummary = true, maxRecipeStates, pruneCss = true } = options;
1065
+ const { configPath, cwd, reportSkipped = false, reportSummary = true, maxRecipeStates, pruneCss = true, splitCss = true } = options;
841
1066
  (0, _bamboocss_node_static_compiler.markStaticCompilerActive)();
842
1067
  if (maxRecipeStates !== void 0 && (!Number.isSafeInteger(maxRecipeStates) || maxRecipeStates < 1)) throw new Error("bamboocss: `maxRecipeStates` must be a positive safe integer.");
843
1068
  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.");
844
1069
  const staticSession = createStaticCompilationSession();
1070
+ staticSession.splitCss = splitCss;
845
1071
  /**
846
1072
  * One Builder, one resolved config, one context and one ts-morph project for the run.
847
1073
  *
@@ -1271,6 +1497,42 @@ const bamboocss = (options = {}) => {
1271
1497
  staticSession.prunedClasses.clear();
1272
1498
  for (const slots of liveOutputSlotsByEnvironment.values()) for (const slot of slots.values()) for (const className of slot.prunedClasses ?? []) staticSession.prunedClasses.add(className);
1273
1499
  };
1500
+ /**
1501
+ * Prune the sheets the run wrote whole, now that every environment has contributed.
1502
+ *
1503
+ * Reached from the write hook of whichever environment completes the run, which is the first
1504
+ * point at which the union of every environment's reachability exists and every reference to
1505
+ * the sheet is on disk. A run that never completes — an environment declared and never built
1506
+ * — leaves the sheets whole, and says so as the process exits.
1507
+ */
1508
+ const finalizeDeferredSheetsIfComplete = async (candidate, bundle, sourcemap) => {
1509
+ if (!staticSession.deferredSheets.length) return;
1510
+ if (remainingEnvironments(staticSession, candidate).length) return;
1511
+ const sheets = staticSession.deferredSheets.splice(0);
1512
+ const { finalizeDeferredSheets } = await loadCssOutputModule();
1513
+ const committed = staticSession.prunedClasses;
1514
+ staticSession.prunedClasses = /* @__PURE__ */ new Set();
1515
+ let finalized;
1516
+ let lost;
1517
+ try {
1518
+ finalized = finalizeDeferredSheets(sheets, staticSession, {
1519
+ prune: pruneCss,
1520
+ requiredClasses: currentRequiredClasses(),
1521
+ outputs: staticSession.writtenOutputs,
1522
+ bundle,
1523
+ sourcemap
1524
+ });
1525
+ } finally {
1526
+ lost = staticSession.prunedClasses;
1527
+ staticSession.prunedClasses = committed;
1528
+ }
1529
+ for (const sheet of finalized) for (const slot of liveOutputSlotsByEnvironment.get(sheet.environment)?.values() ?? []) slot.prunedClasses = new Set(lost);
1530
+ rebuildLivePrunedClasses();
1531
+ for (const sheet of finalized) {
1532
+ if (!sheet.renamed) continue;
1533
+ _bamboocss_logger.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}.`);
1534
+ }
1535
+ };
1274
1536
  const observeEnvironmentBuildStart = (environment) => {
1275
1537
  const serial = (nextBuildSerialByEnvironment.get(environment) ?? 0) + 1;
1276
1538
  nextBuildSerialByEnvironment.set(environment, serial);
@@ -1390,6 +1652,8 @@ const bamboocss = (options = {}) => {
1390
1652
  rebuildStaticTransformContributions();
1391
1653
  rebuildLivePrunedClasses();
1392
1654
  };
1655
+ staticSession.finalizeDeferred = ({ environment, bundle, sourcemap }) => finalizeDeferredSheetsIfComplete(environment, bundle, sourcemap);
1656
+ staticSession.classNamesOf = (environment, moduleId) => transformStateByEnvironment.get(environment)?.transformArtifactsByModule.get(moduleId)?.classNames;
1393
1657
  staticSession.beginOutputProjection = (environment, outputOptions, bundle, replacesGeneratedStylesheet) => {
1394
1658
  const generation = preparedGenerations.get(environment);
1395
1659
  if (!generation || transformStateByEnvironment.get(environment) !== generation.state) {
@@ -1412,9 +1676,13 @@ const bamboocss = (options = {}) => {
1412
1676
  restore() {
1413
1677
  if (restored) return;
1414
1678
  restored = true;
1679
+ const previous = outputStageByBundle.get(bundle) ?? outputStageByOptions.get(outputOptions);
1415
1680
  const stage = replacesGeneratedStylesheet ? {
1416
1681
  cssDigest: bambooCssDigest(bundle),
1417
- prunedClasses: new Set(staticSession.prunedClasses)
1682
+ prunedClasses: new Set([...previous?.prunedClasses ?? [], ...staticSession.prunedClasses])
1683
+ } : previous ? {
1684
+ cssDigest: bambooCssDigest(bundle),
1685
+ prunedClasses: new Set(previous.prunedClasses ?? [])
1418
1686
  } : {};
1419
1687
  outputStageByBundle.set(bundle, stage);
1420
1688
  outputStageByOptions.set(outputOptions, stage);
@@ -1756,6 +2024,7 @@ const bamboocss = (options = {}) => {
1756
2024
  let styleCompiler;
1757
2025
  let command = "build";
1758
2026
  let defaultEmitAssets = true;
2027
+ let exitWarningInstalled = false;
1759
2028
  /**
1760
2029
  * Expand semantic leaf reads through the Project's exact resolution paths.
1761
2030
  *
@@ -1998,6 +2267,16 @@ const bamboocss = (options = {}) => {
1998
2267
  command = config.command;
1999
2268
  host.setCommand(config.command);
2000
2269
  defaultEmitAssets = config.build?.emitAssets ?? (!config.build?.ssr || config.build?.ssrEmitAssets === true);
2270
+ if (config.command === "build" && !exitWarningInstalled) {
2271
+ exitWarningInstalled = true;
2272
+ process.once("beforeExit", () => {
2273
+ if (!staticSession.deferredSheets.length) return;
2274
+ _bamboocss_logger.logger.warn("vite", `The stylesheet was pruned against an incomplete run: ${(0, _bamboocss_shared.truncateList)(remainingEnvironments(staticSession), {
2275
+ unit: "environment",
2276
+ separator: ", "
2277
+ })} 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.`);
2278
+ });
2279
+ }
2001
2280
  const plugins = config.plugins;
2002
2281
  if (plugins) {
2003
2282
  for (const finalizer of [outputWriteObserver, memoryOutputCommitter]) {
@@ -2182,11 +2461,11 @@ const bamboocss = (options = {}) => {
2182
2461
  rebuildStaticTransformContributions(environment, state);
2183
2462
  const survivors = allSurvivors(states);
2184
2463
  if (survivors.length) throw createSurvivorError(survivors);
2185
- const lost = currentWillEmitCss ? [] : [...staticSession.usedClasses].filter((className) => staticSession.prunedClasses.has(require_class_name.bare(className)));
2464
+ const lost = currentWillEmitCss || staticSession.deferredSheets.length ? [] : [...staticSession.usedClasses].filter((className) => staticSession.prunedClasses.has(require_class_name.bare(className)));
2186
2465
  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${(0, _bamboocss_shared.truncateList)(lost.map((className) => ` ${className}`), {
2187
2466
  unit: "class",
2188
2467
  separator: "\n"
2189
- })}\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.`);
2468
+ })}\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.`);
2190
2469
  const remaining = remainingEnvironments(staticSession, environment);
2191
2470
  if (typeof this.getModuleInfo === "function" && !remaining.length) {
2192
2471
  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.`);
@@ -2231,6 +2510,10 @@ const bamboocss = (options = {}) => {
2231
2510
  if (!ctx || !foldSourceImpl || !runtimeCss || !styleCompiler) return null;
2232
2511
  const [filePath] = id.split("?");
2233
2512
  if (isGeneratedOutput(filePath, ctx)) return null;
2513
+ if (!(ctx.project.hasSourceFile(filePath) && ctx.project.getSourceFile(filePath)?.getFullText() === code) && !namesEntrypoint(ctx, code) && !importsProjectModule(ctx, filePath, code)) {
2514
+ _bamboocss_logger.logger.debug("vite:transform", `Skipped ${filePath}: ${host.isSourceFile(filePath) ? "rewritten before bamboo" : "outside `include`"}, and it reaches nothing bamboo`);
2515
+ return null;
2516
+ }
2234
2517
  const requestedParsePath = compilerParsePath(id, code);
2235
2518
  if (requestedParsePath === null) return null;
2236
2519
  const state = environmentState(this);
@@ -2393,9 +2676,17 @@ const bamboocss = (options = {}) => {
2393
2676
  writeBundle: {
2394
2677
  order: "pre",
2395
2678
  sequential: true,
2396
- handler(outputOptions, bundle) {
2679
+ async handler(outputOptions, bundle) {
2680
+ const environment = environmentName(this);
2681
+ const outputDir = outputOptions.dir ?? (outputOptions.file ? (0, node_path.dirname)(outputOptions.file) : void 0);
2682
+ if (outputDir) staticSession.writtenOutputs.push({
2683
+ environment,
2684
+ dir: (0, node_path.resolve)(outputDir),
2685
+ files: Object.values(bundle).map((output) => output.fileName)
2686
+ });
2397
2687
  const identity = outputIdentityByBundle.get(bundle) ?? outputIdentityByOptions.get(outputOptions);
2398
- if (identity?.environment === environmentName(this)) publishPreparedOutput(identity.environment, identity.outputToken, identity.outputSlot, true);
2688
+ if (identity?.environment === environment) publishPreparedOutput(identity.environment, identity.outputToken, identity.outputSlot, true);
2689
+ await finalizeDeferredSheetsIfComplete();
2399
2690
  }
2400
2691
  }
2401
2692
  };
@@ -2419,6 +2710,10 @@ const bamboocss = (options = {}) => {
2419
2710
  session: staticSession,
2420
2711
  pruneCss
2421
2712
  }),
2713
+ bamboocssCssEarly({
2714
+ session: staticSession,
2715
+ pruneCss
2716
+ }),
2422
2717
  compiler,
2423
2718
  compilerSfc
2424
2719
  ];
package/dist/index.d.cts CHANGED
@@ -62,6 +62,17 @@ interface BambooVitePluginOptions {
62
62
  * @default true
63
63
  */
64
64
  pruneCss?: boolean;
65
+ /**
66
+ * Give each lazily loaded chunk a stylesheet of the utilities only it uses, and keep the
67
+ * rest in the entry sheet. Builds only, and only where Vite's own `build.cssCodeSplit` is on.
68
+ *
69
+ * An atom two chunks use stays in the entry sheet, so nothing is ever downloaded twice; a
70
+ * route that is the only user of a style downloads that style with the route. Precedence
71
+ * does not depend on which sheet a rule is in, since it lives in the cascade sublayers.
72
+ *
73
+ * @default true
74
+ */
75
+ splitCss?: boolean;
65
76
  }
66
77
  /**
67
78
  * Vite integration for Bamboo CSS.
package/dist/index.d.mts CHANGED
@@ -64,6 +64,17 @@ interface BambooVitePluginOptions {
64
64
  * @default true
65
65
  */
66
66
  pruneCss?: boolean;
67
+ /**
68
+ * Give each lazily loaded chunk a stylesheet of the utilities only it uses, and keep the
69
+ * rest in the entry sheet. Builds only, and only where Vite's own `build.cssCodeSplit` is on.
70
+ *
71
+ * An atom two chunks use stays in the entry sheet, so nothing is ever downloaded twice; a
72
+ * route that is the only user of a style downloads that style with the route. Precedence
73
+ * does not depend on which sheet a rule is in, since it lives in the cascade sublayers.
74
+ *
75
+ * @default true
76
+ */
77
+ splitCss?: boolean;
67
78
  }
68
79
  /**
69
80
  * Vite integration for Bamboo CSS.