@bamboocss/vite 1.37.13 → 1.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +334 -40
- package/dist/index.d.cts +21 -10
- package/dist/index.d.mts +21 -10
- package/dist/index.mjs +335 -41
- package/package.json +9 -9
package/dist/index.cjs
CHANGED
|
@@ -24,6 +24,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
24
24
|
enumerable: true
|
|
25
25
|
}) : target, mod));
|
|
26
26
|
//#endregion
|
|
27
|
+
let _bamboocss_config = require("@bamboocss/config");
|
|
27
28
|
let _bamboocss_node = require("@bamboocss/node");
|
|
28
29
|
let _bamboocss_logger = require("@bamboocss/logger");
|
|
29
30
|
let _bamboocss_shared = require("@bamboocss/shared");
|
|
@@ -306,14 +307,19 @@ const replaceAssetReferences = (bundle, previous, next, sourcemap) => {
|
|
|
306
307
|
*/
|
|
307
308
|
const carriesGeneratedCss = (output) => output.type === "asset" && output.fileName.endsWith(".css");
|
|
308
309
|
/**
|
|
309
|
-
* Prune
|
|
310
|
+
* Prune compiler-owned CSS, then give any sheet whose bytes changed a hash of those bytes.
|
|
310
311
|
*
|
|
311
312
|
* Rollup has already expanded `[hash]` when `generateBundle` runs. Mutating only `source`
|
|
312
313
|
* would therefore leave two different reachable subsets under one CDN key. The extra final
|
|
313
314
|
* hash is not cosmetic: it makes late graph reachability cache-safe.
|
|
315
|
+
*
|
|
316
|
+
* Renaming is therefore not a choice this takes. Pruned bytes under the unpruned sheet's name
|
|
317
|
+
* is the one outcome that must never be reachable, and a sheet nothing was removed from keeps
|
|
318
|
+
* its name because its bytes are unchanged — so "rename" is a consequence of "the bytes moved",
|
|
319
|
+
* not a second option. `prune` is the only knob.
|
|
314
320
|
*/
|
|
315
321
|
const optimizeStaticCssAssets = (bundle, session, options = {}) => {
|
|
316
|
-
const {
|
|
322
|
+
const { prune = true, sourcemap = session.sourcemap } = options;
|
|
317
323
|
/** Assets in this bundle that carry the generated stylesheet, pruned or not. */
|
|
318
324
|
let sheets = 0;
|
|
319
325
|
for (const output of Object.values(bundle)) {
|
|
@@ -325,12 +331,7 @@ const optimizeStaticCssAssets = (bundle, session, options = {}) => {
|
|
|
325
331
|
const optimized = pruneStaticCss(source, session);
|
|
326
332
|
output.source = optimized;
|
|
327
333
|
if (optimized === source) continue;
|
|
328
|
-
if (!rename) {
|
|
329
|
-
output.source = source;
|
|
330
|
-
continue;
|
|
331
|
-
}
|
|
332
334
|
const nextName = output.fileName.replace(/\.css$/, `.b-${(0, _bamboocss_shared.toHash)(optimized)}.css`);
|
|
333
|
-
if (nextName === output.fileName) continue;
|
|
334
335
|
if (bundle[nextName] && bundle[nextName] !== output) throw new Error(`bamboocss: final CSS asset name collision at ${JSON.stringify(nextName)}.`);
|
|
335
336
|
const previous = output.fileName;
|
|
336
337
|
output.fileName = nextName;
|
|
@@ -352,7 +353,7 @@ const optimizeStaticCssAssets = (bundle, session, options = {}) => {
|
|
|
352
353
|
* process just wrote, which is a race on any watch rebuild.
|
|
353
354
|
*/
|
|
354
355
|
const bamboocssCss = (options) => {
|
|
355
|
-
const { configPath, cwd, session,
|
|
356
|
+
const { configPath, cwd, session, pruneCss = true } = options;
|
|
356
357
|
/**
|
|
357
358
|
* Environments whose `load` served the virtual stylesheet.
|
|
358
359
|
*
|
|
@@ -426,6 +427,39 @@ const bamboocssCss = (options) => {
|
|
|
426
427
|
configResolved(config) {
|
|
427
428
|
command = config.command;
|
|
428
429
|
session.sourcemap = config.build.sourcemap;
|
|
430
|
+
/**
|
|
431
|
+
* Tell Vite that `bamboo.config.ts` is a config file, so editing one restarts the server.
|
|
432
|
+
*
|
|
433
|
+
* Tokens live there, and they are what a designer iterates on most — "restart the dev
|
|
434
|
+
* server to see a colour change" is the wrong instruction for the file most likely to be
|
|
435
|
+
* edited all afternoon. Nothing watched it: `watch` is the CLI's own watcher, and a
|
|
436
|
+
* project running `vite dev` never reaches it.
|
|
437
|
+
*
|
|
438
|
+
* A restart rather than re-emitting the stylesheet, because this plugin and the compiler
|
|
439
|
+
* hold *separate* contexts and only this one reloads its config. A token *value* edit
|
|
440
|
+
* came out right on the next source change, and an edit that changes what compiles —
|
|
441
|
+
* adding a token, a condition, a utility — left the compiler naming classes from the old
|
|
442
|
+
* config against a sheet emitted from the new one. Half-updated is worse than stale.
|
|
443
|
+
*
|
|
444
|
+
* Through Vite's own list rather than a watcher of ours. Vite adds these paths to the
|
|
445
|
+
* files it watches, which is what reaches a config *outside* `root` — a monorepo with one
|
|
446
|
+
* config above `apps/web`, or a preset resolved into `node_modules`, neither of which the
|
|
447
|
+
* project watcher covers. It also means the restart is Vite's, with its own concurrency
|
|
448
|
+
* guard and its own error reporting, rather than a second implementation of both.
|
|
449
|
+
*
|
|
450
|
+
* The config's own import graph is resolved the way `Builder` resolves it, minus the
|
|
451
|
+
* tsconfig paths it has not loaded yet at this point. `dependencies` globs are not
|
|
452
|
+
* expanded here: they are declared as an escape hatch for a *config reload*, and turning
|
|
453
|
+
* every file matching one into a full server restart is not what a project asking for
|
|
454
|
+
* that meant.
|
|
455
|
+
*/
|
|
456
|
+
if (config.command === "serve") try {
|
|
457
|
+
const { deps } = (0, _bamboocss_config.getConfigDependencies)((0, _bamboocss_config.findConfig)({
|
|
458
|
+
cwd: cwd ?? config.root,
|
|
459
|
+
file: configPath
|
|
460
|
+
}));
|
|
461
|
+
config.configFileDependencies.push(...deps);
|
|
462
|
+
} catch {}
|
|
429
463
|
if (config.builder && config.environments) session.expectedEnvironments = new Set(Object.keys(config.environments));
|
|
430
464
|
},
|
|
431
465
|
/**
|
|
@@ -489,18 +523,18 @@ const bamboocssCss = (options) => {
|
|
|
489
523
|
* link the pruned copy: one project lost 39% of its atoms that way, presenting as
|
|
490
524
|
* rarely-used classes such as `md:{display:inline-block}` silently not applying.
|
|
491
525
|
*
|
|
492
|
-
* So the full extracted stylesheet ships instead,
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
526
|
+
* So the full extracted stylesheet ships instead, which is what `pruneCss: false` asks
|
|
527
|
+
* for by hand. Being the last environment is not the common case — frameworks build the
|
|
528
|
+
* client first — but it is the only one where the answer is knowable, and a framework
|
|
529
|
+
* that builds its server bundle first does get pruned output.
|
|
496
530
|
*/
|
|
497
531
|
const pending = remainingEnvironments(session);
|
|
498
532
|
const { sheets } = optimizeStaticCssAssets(bundle, session, {
|
|
499
|
-
|
|
500
|
-
prune: pending.length === 0,
|
|
533
|
+
prune: pruneCss && pending.length === 0,
|
|
501
534
|
sourcemap: environment?.config?.build?.sourcemap
|
|
502
535
|
});
|
|
503
|
-
if (sheets &&
|
|
536
|
+
if (sheets && !pruneCss) _bamboocss_logger.logger.info("vite", "Reachability pruning is off (`pruneCss: false`). The full extracted stylesheet ships.");
|
|
537
|
+
else if (sheets && pending.length) _bamboocss_logger.logger.info("vite", `Reachability pruning skipped: the stylesheet is emitted by the ${JSON.stringify(environment?.name ?? "default")} environment, and ${(0, _bamboocss_shared.truncateList)(pending, {
|
|
504
538
|
unit: "environment",
|
|
505
539
|
separator: ", "
|
|
506
540
|
})} ${pending.length === 1 ? "has" : "have"} not been compiled in this run. The full extracted stylesheet ships — nothing is missing from it.`);
|
|
@@ -517,6 +551,78 @@ const bamboocssCss = (options) => {
|
|
|
517
551
|
//#endregion
|
|
518
552
|
//#region src/fold-analysis.ts
|
|
519
553
|
/**
|
|
554
|
+
* Nodes that *compose* a value out of their children rather than computing one.
|
|
555
|
+
*
|
|
556
|
+
* The boundary of the walk below, and the whole of its precision. Climbing through these
|
|
557
|
+
* keeps a value's provenance: `'red.300'` inside `{ color: 'red.300' }` inside a default is
|
|
558
|
+
* still the default's. Anything else — a call, a function body, a JSX element — produces its
|
|
559
|
+
* value by being evaluated, so what is written inside it is an argument to that evaluation
|
|
560
|
+
* and not the enclosing default's value.
|
|
561
|
+
*
|
|
562
|
+
* Without the boundary, `({ cls = css({ color: 'red.300' }) }) => cls` is rejected: the call's
|
|
563
|
+
* own literal argument is syntactically inside a default, so an unbounded walk calls it one.
|
|
564
|
+
* That is correct code, and rejecting it fails the build.
|
|
565
|
+
*/
|
|
566
|
+
const composesValue = (node) => ts_morph.Node.isObjectLiteralExpression(node) || ts_morph.Node.isArrayLiteralExpression(node) || ts_morph.Node.isPropertyAssignment(node) || ts_morph.Node.isShorthandPropertyAssignment(node) || ts_morph.Node.isSpreadAssignment(node) || ts_morph.Node.isSpreadElement(node) || ts_morph.Node.isAsExpression(node) || ts_morph.Node.isParenthesizedExpression(node) || ts_morph.Node.isNonNullExpression(node) || ts_morph.Node.isTypeAssertion(node) || ts_morph.Node.isSatisfiesExpression(node);
|
|
567
|
+
/** Is `inner` written within `outer`? Positions rather than a walk, so it is O(1). */
|
|
568
|
+
const contains = (outer, inner) => outer.getSourceFile() === inner.getSourceFile() && outer.getStart() <= inner.getStart() && inner.getEnd() <= outer.getEnd();
|
|
569
|
+
/**
|
|
570
|
+
* Did this value come from the `= …` of a destructuring binding?
|
|
571
|
+
*
|
|
572
|
+
* `const { tone = 'red.300' } = source` boxes as the literal `'red.300'`: the extractor's
|
|
573
|
+
* `maybeDefinitionValue` tests for an initializer first and returns the boxed default, never
|
|
574
|
+
* reaching the branch that would read `source`. So the default is reported as the value whether
|
|
575
|
+
* or not it is the one that applies.
|
|
576
|
+
*
|
|
577
|
+
* For extraction that is merely optimistic, and deliberately so: a CLI or PostCSS build ships a
|
|
578
|
+
* runtime `css()`, where the default genuinely does apply when the caller omits the key, and it
|
|
579
|
+
* needs a rule behind it. Folding is where the same resolution turns into a wrong answer,
|
|
580
|
+
* because the call is *replaced* by that value.
|
|
581
|
+
*
|
|
582
|
+
* Stops at the first non-composing parent, so a call written inside a default keeps its own
|
|
583
|
+
* provenance, and checks that the binding element was reached through its initializer, so
|
|
584
|
+
* `{ tone = X }`'s name node is not mistaken for its default.
|
|
585
|
+
*/
|
|
586
|
+
const isBindingElementDefault = (node) => {
|
|
587
|
+
if (node && ts_morph.Node.isCallExpression(node)) return false;
|
|
588
|
+
let current = node;
|
|
589
|
+
while (current) {
|
|
590
|
+
const parent = current.getParent();
|
|
591
|
+
if (!parent) return false;
|
|
592
|
+
if (ts_morph.Node.isBindingElement(parent)) return parent.getInitializer() === current;
|
|
593
|
+
if (!composesValue(parent)) return false;
|
|
594
|
+
current = parent;
|
|
595
|
+
}
|
|
596
|
+
return false;
|
|
597
|
+
};
|
|
598
|
+
/**
|
|
599
|
+
* The same question asked of a whole box, including how it was resolved.
|
|
600
|
+
*
|
|
601
|
+
* The node a box reports is not always the one its value came from — an empty `{}` default
|
|
602
|
+
* boxes against the call rather than against the `{}` — so the resolution stack is consulted
|
|
603
|
+
* too. A binding element reaches that stack by having been resolved *through*, which is the
|
|
604
|
+
* signal the extractor itself reads when one of these is a conditional's test.
|
|
605
|
+
*
|
|
606
|
+
* Only the entries that are binding elements are examined. Walking up from every other entry
|
|
607
|
+
* was tried and never once changed a verdict across the default spellings or the sandbox's own
|
|
608
|
+
* modules, while accounting for most of the parent hops this does — the stack carries nodes
|
|
609
|
+
* that were never resolved through, including a call's own arguments, so walking from them is
|
|
610
|
+
* both the expensive half and the one that reaches conclusions it has no basis for.
|
|
611
|
+
*
|
|
612
|
+
* A binding element without an initializer carries no default to mistrust: `const { tone } =
|
|
613
|
+
* source` either resolves from `source` or does not resolve at all.
|
|
614
|
+
*/
|
|
615
|
+
const isFromBindingDefault = (node) => {
|
|
616
|
+
const own = node.getNode?.();
|
|
617
|
+
if (isBindingElementDefault(own)) return true;
|
|
618
|
+
for (const entry of node.getStack?.() ?? []) {
|
|
619
|
+
if (!ts_morph.Node.isBindingElement(entry) || !entry.getInitializer()) continue;
|
|
620
|
+
if (own && contains(entry, own)) continue;
|
|
621
|
+
return true;
|
|
622
|
+
}
|
|
623
|
+
return false;
|
|
624
|
+
};
|
|
625
|
+
/**
|
|
520
626
|
* Statically resolvable means: every box in the tree carries a known value.
|
|
521
627
|
*
|
|
522
628
|
* `unresolvable` is the extractor saying it could not evaluate a node.
|
|
@@ -530,6 +636,7 @@ const isStaticBox = (node, seen = /* @__PURE__ */ new Set()) => {
|
|
|
530
636
|
seen.add(node);
|
|
531
637
|
if (_bamboocss_extractor.box.isUnresolvable(node) || _bamboocss_extractor.box.isConditional(node)) return false;
|
|
532
638
|
if (!("type" in node) || node.type == null) return false;
|
|
639
|
+
if (isFromBindingDefault(node)) return false;
|
|
533
640
|
if (_bamboocss_extractor.box.isLiteral(node) && node.value === void 0) {
|
|
534
641
|
const source = node.getNode?.();
|
|
535
642
|
return Boolean(source && ts_morph.Node.isIdentifier(source) && source.getText() === "undefined");
|
|
@@ -2892,8 +2999,47 @@ const createStaticStyleSetCompiler = (ctx, runtimeCss, allocateClassString = (cl
|
|
|
2892
2999
|
//#region src/plugin.ts
|
|
2893
3000
|
const DEFAULT_EXTENSIONS = /\.(?:[cm]?[jt]sx?)$/;
|
|
2894
3001
|
const NODE_MODULES = /node_modules/;
|
|
3002
|
+
/**
|
|
3003
|
+
* Queries that make Vite serve something other than the module's own source.
|
|
3004
|
+
*
|
|
3005
|
+
* `./theme.tsx?raw` is a module whose text is `export default "…"`, and `?url`, `?worker` and
|
|
3006
|
+
* `?sharedworker` are wrappers of the same kind. The query has to be stripped before the
|
|
3007
|
+
* extension is tested — otherwise nothing matches `.tsx` — and stripping it is what made these
|
|
3008
|
+
* look like the file itself. The transform then handed the wrapper's text to ts-morph *under
|
|
3009
|
+
* the real file's path*, overwriting the parsed module every fold reads for that path.
|
|
3010
|
+
*
|
|
3011
|
+
* That is not theoretical: a module folding `css(shared)` against a sibling the entry also
|
|
3012
|
+
* imported as `?raw` failed the build with "1 call(s) could not be compiled" — the compiler
|
|
3013
|
+
* had read `export default "…"` and found no `shared` to resolve. The advice it prints, to
|
|
3014
|
+
* make the value statically analyzable, is unfollowable, because the source already was.
|
|
3015
|
+
*
|
|
3016
|
+
* Whether it bites depends on which of the two ids Rollup transforms last, so the same project
|
|
3017
|
+
* can build and then stop building because an import moved.
|
|
3018
|
+
*
|
|
3019
|
+
* A deny list rather than an allow list of benign queries: dev ids carry `?t=` after an edit
|
|
3020
|
+
* and `?import` when a dynamic import is rewritten, and rejecting an unrecognised one of those
|
|
3021
|
+
* would silently stop folding a module rather than loudly refuse it.
|
|
3022
|
+
*
|
|
3023
|
+
* Exactly these four, matching Vite's own `SPECIAL_QUERY_RE`. The list was drafted wider —
|
|
3024
|
+
* `?inline`, `?no-inline`, `?worklet`, `?init` — and every one of those was wrong: Vite has no
|
|
3025
|
+
* `worklet` query at all, `?init` is `.wasm` only and that extension is already rejected below,
|
|
3026
|
+
* and `inline`/`no-inline` merely pick base64-versus-file for something that *already* matched
|
|
3027
|
+
* `raw`/`url`, so `./a.tsx?inline` is served as the module's own source. Rejecting an id that
|
|
3028
|
+
* carries real source is the expensive direction: the transform declines, its atoms never reach
|
|
3029
|
+
* the reachability set, pruning removes their rules, and the runtime still returns the class
|
|
3030
|
+
* names — unstyled elements, no error. Only names verified against Vite belong here.
|
|
3031
|
+
*
|
|
3032
|
+
* Note `?worker_file`, which is how dev serves a worker's *real* source, is deliberately absent
|
|
3033
|
+
* and must stay absent. It contains "worker" and is the obvious next entry; adding it would
|
|
3034
|
+
* stop folding every worker module in dev, silently, by the mechanism above.
|
|
3035
|
+
*
|
|
3036
|
+
* Tested against the whole id rather than a split-off query, so it cannot disagree with
|
|
3037
|
+
* `queryOf` in `css.ts` about where the query starts.
|
|
3038
|
+
*/
|
|
3039
|
+
const WRAPPED_MODULE_QUERY = /[?&](?:raw|url|worker|sharedworker)(?:&|=|$)/;
|
|
2895
3040
|
const shouldTransform = (id) => {
|
|
2896
3041
|
if (id.startsWith("\0")) return false;
|
|
3042
|
+
if (WRAPPED_MODULE_QUERY.test(id)) return false;
|
|
2897
3043
|
const [filePath] = id.split("?");
|
|
2898
3044
|
if (!filePath) return false;
|
|
2899
3045
|
if (NODE_MODULES.test(filePath)) return false;
|
|
@@ -2921,6 +3067,18 @@ const isGeneratedOutput = (filePath, ctx) => {
|
|
|
2921
3067
|
};
|
|
2922
3068
|
/** 1-indexed line of a source offset, for an error a user can navigate to. */
|
|
2923
3069
|
const lineAt = (code, offset) => code.slice(0, offset).split("\n").length;
|
|
3070
|
+
/**
|
|
3071
|
+
* One spelling for a path used as a map key against paths from somewhere else.
|
|
3072
|
+
*
|
|
3073
|
+
* The fold reports dependencies as ts-morph sees them and Vite reports a changed file as its
|
|
3074
|
+
* watcher saw it. On Windows those differ by separator, and can differ by the case of the
|
|
3075
|
+
* drive letter alone — chokidar reports what the OS handed it, `path.resolve` preserves
|
|
3076
|
+
* whatever the cwd had. Either would make every lookup below miss and restore the exact
|
|
3077
|
+
* staleness they exist to fix, silently, since a miss is indistinguishable from a module that
|
|
3078
|
+
* folded nothing. Only the drive letter is case-folded: the rest of the path is compared as
|
|
3079
|
+
* written, because elsewhere the filesystem may well be case-sensitive.
|
|
3080
|
+
*/
|
|
3081
|
+
const normalizeFsPath = (file) => (0, node_path.resolve)(file).replaceAll("\\", "/").replace(/^[a-z]:\//, (drive) => drive.toUpperCase());
|
|
2924
3082
|
const formatSkipped = (id, skipped) => {
|
|
2925
3083
|
const counts = /* @__PURE__ */ new Map();
|
|
2926
3084
|
for (const entry of skipped) counts.set(entry.reason, (counts.get(entry.reason) ?? 0) + 1);
|
|
@@ -2940,15 +3098,27 @@ const formatSkipped = (id, skipped) => {
|
|
|
2940
3098
|
* with no matching rule.
|
|
2941
3099
|
*/
|
|
2942
3100
|
const bamboocss = (options = {}) => {
|
|
2943
|
-
const { configPath, cwd, reportSkipped = false, reportSummary = true, maxRecipeStates,
|
|
3101
|
+
const { configPath, cwd, reportSkipped = false, reportSummary = true, maxRecipeStates, pruneCss = true } = options;
|
|
3102
|
+
(0, _bamboocss_node.markStaticCompilerActive)();
|
|
2944
3103
|
if (maxRecipeStates !== void 0 && (!Number.isSafeInteger(maxRecipeStates) || maxRecipeStates < 1)) throw new Error("bamboocss: `maxRecipeStates` must be a positive safe integer.");
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
2951
|
-
|
|
3104
|
+
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.");
|
|
3105
|
+
/**
|
|
3106
|
+
* What each file's transform found, for the summary. Keyed by file rather than summed as it
|
|
3107
|
+
* goes, because a build has more than one environment and they share most of their modules.
|
|
3108
|
+
*
|
|
3109
|
+
* Running totals double-counted every shared module once per environment — a two-environment
|
|
3110
|
+
* build of one shared file and one entry each reported "2/2 across 2/4 files" for three
|
|
3111
|
+
* source modules. Coverage is a property of the source, not of how many times a bundler
|
|
3112
|
+
* handed the same file over. It also grew without bound in dev, where every HMR
|
|
3113
|
+
* re-transform of a file counted as another file.
|
|
3114
|
+
*
|
|
3115
|
+
* A second pass over a file replaces its entry rather than adding to it. Both environments
|
|
3116
|
+
* are assumed to compute the same answer for the same module — true of this compiler, though
|
|
3117
|
+
* not something the plugin can enforce, since another `pre` plugin may hand each environment
|
|
3118
|
+
* different code. Where they disagree the last one wins, which is a cosmetic number either
|
|
3119
|
+
* way.
|
|
3120
|
+
*/
|
|
3121
|
+
const perFile = /* @__PURE__ */ new Map();
|
|
2952
3122
|
const staticSession = createStaticCompilationSession();
|
|
2953
3123
|
/**
|
|
2954
3124
|
* Indexed by file, because the only bulk operation on it is "forget this one's".
|
|
@@ -2991,6 +3161,84 @@ const bamboocss = (options = {}) => {
|
|
|
2991
3161
|
* would otherwise re-parse its module fifty times, which is the transform path.
|
|
2992
3162
|
*/
|
|
2993
3163
|
const recipeConfigCache = /* @__PURE__ */ new Map();
|
|
3164
|
+
/**
|
|
3165
|
+
* Which modules folded a value read out of which other module, for dev invalidation.
|
|
3166
|
+
*
|
|
3167
|
+
* `addWatchFile` reports the same edges, and in a build that is enough — Rollup discards a
|
|
3168
|
+
* module whose watched file changed. Vite's dev server does not: a module that *statically
|
|
3169
|
+
* imports* the changed one is only **soft**-invalidated, which by design keeps its cached
|
|
3170
|
+
* transform result and rewrites nothing but the timestamps on its import specifiers. That
|
|
3171
|
+
* cached result is where the compiled class string lives, so the edit never reaches it.
|
|
3172
|
+
*
|
|
3173
|
+
* The recipe case is the one users meet, because it is the one where the class is compiled
|
|
3174
|
+
* into somebody else's module: an inline `cva` declaration is erased, and each *call site*
|
|
3175
|
+
* becomes a literal in the module that calls it. Editing the recipe then updates the class
|
|
3176
|
+
* in a module Vite has decided not to re-transform, so the browser and the SSR render keep
|
|
3177
|
+
* the old class — with no error, and with Vite and Bamboo both logging as if the edit landed.
|
|
3178
|
+
* A restart applies it, which is what makes it read as "recipes do not hot-reload".
|
|
3179
|
+
*
|
|
3180
|
+
* `css(sharedObject)` across modules fails identically; it is rarer only because a consumer
|
|
3181
|
+
* that folds *nothing but* recipe calls has its import erased, and an erased import is not a
|
|
3182
|
+
* static one, so Vite hard-invalidates it and the bug hides. Import one more value from the
|
|
3183
|
+
* same module — the shape any real `ui.ts` has — and the import survives, and so does the
|
|
3184
|
+
* stale class.
|
|
3185
|
+
*
|
|
3186
|
+
* Keyed by dependency, since "what changed" is the question asked, and tracked in the other
|
|
3187
|
+
* direction as well so a re-transform can retract edges the module no longer has.
|
|
3188
|
+
*/
|
|
3189
|
+
const dependentsByDependency = /* @__PURE__ */ new Map();
|
|
3190
|
+
const dependenciesByFile = /* @__PURE__ */ new Map();
|
|
3191
|
+
const recordFoldDependencies = (file, dependencies) => {
|
|
3192
|
+
const next = new Set(dependencies.map(normalizeFsPath).filter((dependency) => dependency !== file));
|
|
3193
|
+
const previous = dependenciesByFile.get(file);
|
|
3194
|
+
for (const dependency of previous ?? []) {
|
|
3195
|
+
if (next.has(dependency)) continue;
|
|
3196
|
+
const dependents = dependentsByDependency.get(dependency);
|
|
3197
|
+
if (!dependents?.delete(file)) continue;
|
|
3198
|
+
if (!dependents.size) dependentsByDependency.delete(dependency);
|
|
3199
|
+
}
|
|
3200
|
+
if (!next.size) {
|
|
3201
|
+
dependenciesByFile.delete(file);
|
|
3202
|
+
return;
|
|
3203
|
+
}
|
|
3204
|
+
dependenciesByFile.set(file, next);
|
|
3205
|
+
for (const dependency of next) {
|
|
3206
|
+
const dependents = dependentsByDependency.get(dependency);
|
|
3207
|
+
if (dependents) dependents.add(file);
|
|
3208
|
+
else dependentsByDependency.set(dependency, new Set([file]));
|
|
3209
|
+
}
|
|
3210
|
+
};
|
|
3211
|
+
/**
|
|
3212
|
+
* Modules to re-transform because `file` changed, hard-invalidated on the way out.
|
|
3213
|
+
*
|
|
3214
|
+
* Both halves are load-bearing. Invalidating drops the stale compiled result, which is the
|
|
3215
|
+
* defect itself; returning the modules is what makes Vite propagate an update for them, so
|
|
3216
|
+
* the client applies one rather than waiting for whatever request happens next.
|
|
3217
|
+
*
|
|
3218
|
+
* Invalidating here rather than leaving it to `updateModules` also survives another plugin
|
|
3219
|
+
* filtering the list afterwards — a framework's own `hotUpdate` decides what its route
|
|
3220
|
+
* modules do, and the stale bytes have to go either way.
|
|
3221
|
+
*
|
|
3222
|
+
* A consumer Vite already reached costs nothing: `propagateUpdate` skips a module it has
|
|
3223
|
+
* traversed, and every consumer of a *resolvable* dependency is one, since `addWatchFile`
|
|
3224
|
+
* makes it an importer whether or not the import survived the fold. What is left is the
|
|
3225
|
+
* consumer of a dependency with no module of its own, where Vite matched nothing and would
|
|
3226
|
+
* have done nothing at all. That one can end in a page reload, which is the honest outcome:
|
|
3227
|
+
* its compiled classes really did change, and a reload is what Vite does with any update
|
|
3228
|
+
* nothing accepts.
|
|
3229
|
+
*/
|
|
3230
|
+
const foldDependentModules = (file, modules, graph) => {
|
|
3231
|
+
const dependents = dependentsByDependency.get(normalizeFsPath(file));
|
|
3232
|
+
if (!dependents?.size) return;
|
|
3233
|
+
const added = [];
|
|
3234
|
+
for (const dependent of dependents) for (const module of graph.getModulesByFile(dependent) ?? []) {
|
|
3235
|
+
if (modules.includes(module) || added.includes(module)) continue;
|
|
3236
|
+
graph.invalidateModule(module);
|
|
3237
|
+
added.push(module);
|
|
3238
|
+
}
|
|
3239
|
+
if (!added.length) return;
|
|
3240
|
+
return [...modules, ...added];
|
|
3241
|
+
};
|
|
2994
3242
|
let ctx;
|
|
2995
3243
|
let runtimeCss;
|
|
2996
3244
|
let styleCompiler;
|
|
@@ -3011,7 +3259,7 @@ const bamboocss = (options = {}) => {
|
|
|
3011
3259
|
configPath,
|
|
3012
3260
|
cwd,
|
|
3013
3261
|
session: staticSession,
|
|
3014
|
-
|
|
3262
|
+
pruneCss
|
|
3015
3263
|
}), {
|
|
3016
3264
|
name: "bamboocss:compiler",
|
|
3017
3265
|
enforce: "pre",
|
|
@@ -3023,12 +3271,11 @@ const bamboocss = (options = {}) => {
|
|
|
3023
3271
|
async buildStart() {
|
|
3024
3272
|
const environment = this.environment?.name ?? "default";
|
|
3025
3273
|
if (staticSession.startedEnvironments.has(environment)) {
|
|
3026
|
-
|
|
3027
|
-
totals.files = 0;
|
|
3028
|
-
totals.filesWithFolds = 0;
|
|
3029
|
-
totals.skipped.clear();
|
|
3274
|
+
perFile.clear();
|
|
3030
3275
|
survivorsByFile.clear();
|
|
3031
3276
|
recipeConfigCache.clear();
|
|
3277
|
+
dependentsByDependency.clear();
|
|
3278
|
+
dependenciesByFile.clear();
|
|
3032
3279
|
resetStaticCompilationSession(staticSession);
|
|
3033
3280
|
}
|
|
3034
3281
|
staticSession.startedEnvironments.add(environment);
|
|
@@ -3066,10 +3313,40 @@ const bamboocss = (options = {}) => {
|
|
|
3066
3313
|
recipeConfigCache.clear();
|
|
3067
3314
|
if (change.event === "delete") {
|
|
3068
3315
|
ctx.project.removeSourceFile(filePath);
|
|
3316
|
+
recordFoldDependencies(normalizeFsPath(filePath), []);
|
|
3069
3317
|
return;
|
|
3070
3318
|
}
|
|
3071
3319
|
ctx.project.reloadSourceFile(filePath);
|
|
3072
3320
|
},
|
|
3321
|
+
/**
|
|
3322
|
+
* Re-transform whatever folded a value out of the file that just changed.
|
|
3323
|
+
*
|
|
3324
|
+
* Dev only — `hotUpdate` does not run in a build, where Rollup's own invalidation already
|
|
3325
|
+
* covers this — and additive: the modules Vite matched are returned alongside, so this
|
|
3326
|
+
* decides nothing about them.
|
|
3327
|
+
*
|
|
3328
|
+
* `handleHotUpdate` below stands in on Vite 5, which has no `hotUpdate`. Not quite the
|
|
3329
|
+
* same thing: Vite 5 calls that hook for an update and not for a file appearing or being
|
|
3330
|
+
* deleted, so a recipe file *created* while the server runs leaves its consumers stale
|
|
3331
|
+
* there. Vite 6 and up call `hotUpdate` for all three, and never call `handleHotUpdate`
|
|
3332
|
+
* when a plugin has both — including its deprecation warning — so exactly one of the two
|
|
3333
|
+
* runs on any supported version.
|
|
3334
|
+
*
|
|
3335
|
+
* `environment` optional-chained for the same reason `addWatchFile` is in `transform`:
|
|
3336
|
+
* a harness driving the hook need not supply a full plugin context, and a `TypeError`
|
|
3337
|
+
* here is swallowed into an HMR error payload that a middleware-mode server sends
|
|
3338
|
+
* nowhere.
|
|
3339
|
+
*/
|
|
3340
|
+
hotUpdate({ file, modules }) {
|
|
3341
|
+
const graph = this.environment?.moduleGraph;
|
|
3342
|
+
if (!graph) return;
|
|
3343
|
+
return foldDependentModules(file, modules, graph);
|
|
3344
|
+
},
|
|
3345
|
+
handleHotUpdate({ file, modules, server }) {
|
|
3346
|
+
const legacy = server;
|
|
3347
|
+
if (legacy.environments) return;
|
|
3348
|
+
return foldDependentModules(file, modules, legacy.moduleGraph);
|
|
3349
|
+
},
|
|
3073
3350
|
async transform(code, id) {
|
|
3074
3351
|
if (!shouldTransform(id)) return null;
|
|
3075
3352
|
try {
|
|
@@ -3101,8 +3378,10 @@ const bamboocss = (options = {}) => {
|
|
|
3101
3378
|
});
|
|
3102
3379
|
} catch (error) {
|
|
3103
3380
|
_bamboocss_logger.logger.caughtError("vite:transform", `Failed to compile ${filePath}`, error);
|
|
3104
|
-
|
|
3105
|
-
|
|
3381
|
+
perFile.set(filePath, {
|
|
3382
|
+
folded: 0,
|
|
3383
|
+
skipped: new Map([["compile-failed", 1]])
|
|
3384
|
+
});
|
|
3106
3385
|
addSurvivor({
|
|
3107
3386
|
file: filePath,
|
|
3108
3387
|
line: 1,
|
|
@@ -3112,10 +3391,15 @@ const bamboocss = (options = {}) => {
|
|
|
3112
3391
|
if (command === "serve") throw asError(error, `failed to compile ${filePath}`);
|
|
3113
3392
|
return null;
|
|
3114
3393
|
}
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3394
|
+
let skippedHere;
|
|
3395
|
+
for (const entry of result.skipped) {
|
|
3396
|
+
skippedHere ??= /* @__PURE__ */ new Map();
|
|
3397
|
+
skippedHere.set(entry.reason, (skippedHere.get(entry.reason) ?? 0) + 1);
|
|
3398
|
+
}
|
|
3399
|
+
perFile.set(filePath, {
|
|
3400
|
+
folded: result.folded.length,
|
|
3401
|
+
skipped: skippedHere
|
|
3402
|
+
});
|
|
3119
3403
|
if (result.folded.some((entry) => entry.kind === "class" || entry.kind === "slots")) staticSession.transformedFiles.add((0, node_path.resolve)(filePath));
|
|
3120
3404
|
for (const entry of result.folded) for (const className of entry.classNames) staticSession.markClassUsed(className);
|
|
3121
3405
|
for (const entry of result.skipped) {
|
|
@@ -3130,6 +3414,7 @@ const bamboocss = (options = {}) => {
|
|
|
3130
3414
|
}
|
|
3131
3415
|
if (reportSkipped && result.skipped.length) _bamboocss_logger.logger.info("vite:transform", formatSkipped(filePath, result.skipped));
|
|
3132
3416
|
for (const dependency of result.dependencies) this.addWatchFile?.(dependency);
|
|
3417
|
+
recordFoldDependencies(normalizeFsPath(filePath), result.dependencies);
|
|
3133
3418
|
const forFile = survivorsByFile.get(filePath);
|
|
3134
3419
|
if (command === "serve" && forFile?.length) throw createSurvivorError(forFile);
|
|
3135
3420
|
if (!result.folded.length) return null;
|
|
@@ -3148,7 +3433,7 @@ const bamboocss = (options = {}) => {
|
|
|
3148
3433
|
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
3434
|
unit: "class",
|
|
3150
3435
|
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({
|
|
3436
|
+
})}\n\nThe stylesheet is finalized by the environment that imports it, so pruning it is only safe once every environment has been compiled. This build did not say how many there would be: it called \`builder.build(environment)\` directly. Run it through \`vite build\`, call \`builder.buildApp()\`, or set \`builder: {}\` in the Vite config so the environments are known before the first one builds. \`bamboocss({ pruneCss: false })\` also turns pruning off entirely.`);
|
|
3152
3437
|
}
|
|
3153
3438
|
if (typeof this.getModuleInfo === "function" && !remainingEnvironments(staticSession).length) {
|
|
3154
3439
|
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.`);
|
|
@@ -3159,12 +3444,21 @@ const bamboocss = (options = {}) => {
|
|
|
3159
3444
|
})}\n\nAdd them to \`include\` in bamboo.config, or no CSS rule can back their emitted classes.`);
|
|
3160
3445
|
}
|
|
3161
3446
|
if (!reportSummary) return;
|
|
3162
|
-
|
|
3163
|
-
|
|
3447
|
+
if (command === "build" && remainingEnvironments(staticSession).length) return;
|
|
3448
|
+
let folded = 0;
|
|
3449
|
+
let filesWithFolds = 0;
|
|
3450
|
+
const skipped = /* @__PURE__ */ new Map();
|
|
3451
|
+
for (const entry of perFile.values()) {
|
|
3452
|
+
folded += entry.folded;
|
|
3453
|
+
if (entry.folded) filesWithFolds++;
|
|
3454
|
+
for (const [reason, count] of entry.skipped ?? []) skipped.set(reason, (skipped.get(reason) ?? 0) + count);
|
|
3455
|
+
}
|
|
3456
|
+
const declined = Array.from(skipped.values()).reduce((sum, count) => sum + count, 0);
|
|
3457
|
+
const total = folded + declined;
|
|
3164
3458
|
if (!total) return;
|
|
3165
|
-
const share = Math.round(
|
|
3166
|
-
const reasons = Array.from(
|
|
3167
|
-
_bamboocss_logger.logger.info("vite:transform", `Compiled ${
|
|
3459
|
+
const share = Math.round(folded / total * 100);
|
|
3460
|
+
const reasons = Array.from(skipped.entries()).sort((a, b) => b[1] - a[1]).map(([reason, count]) => `${reason}=${count}`).join(" ");
|
|
3461
|
+
_bamboocss_logger.logger.info("vite:transform", `Compiled ${folded}/${total} (${share}%) across ${filesWithFolds}/${perFile.size} files` + (reasons ? ` — declined: ${reasons}` : ""));
|
|
3168
3462
|
}
|
|
3169
3463
|
}];
|
|
3170
3464
|
};
|
package/dist/index.d.cts
CHANGED
|
@@ -37,21 +37,32 @@ interface BambooVitePluginOptions {
|
|
|
37
37
|
*/
|
|
38
38
|
maxRecipeStates?: number;
|
|
39
39
|
/**
|
|
40
|
-
*
|
|
40
|
+
* Remove rules for atoms no compiled module can emit. Builds only; dev never prunes.
|
|
41
41
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* a
|
|
42
|
+
* Off ships the whole extracted stylesheet: every rule the source graph produced, including
|
|
43
|
+
* ones nothing reaches. Larger, and never wrong *by pruning* — it also stands down the
|
|
44
|
+
* assertion that every compiled class has a rule, since that check exists to catch this pass
|
|
45
|
+
* removing too much. So this is a true escape hatch: it cannot fail a build over reachability.
|
|
45
46
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
47
|
+
* The pruned sheet is also renamed to a hash of its own bytes, and that is not a separate
|
|
48
|
+
* setting because it cannot safely be one. Rollup and Rolldown expand `[hash]` before
|
|
49
|
+
* `generateBundle`, where pruning has to run, so the name Vite assigned describes the sheet
|
|
50
|
+
* as it was *before* pruning. Leaving that name on pruned bytes is how a stale stylesheet
|
|
51
|
+
* outlives a deploy — a change to reachability alone, which is what upgrading Bamboo is,
|
|
52
|
+
* leaves identical source CSS under an identical name with different content, and a CDN
|
|
53
|
+
* holding that key keeps serving the old one. So the bytes and the name move together or
|
|
54
|
+
* neither does.
|
|
55
|
+
*
|
|
56
|
+
* Reach for this if something downstream derives an artifact from the stylesheet's *content*
|
|
57
|
+
* during `generateBundle` before Bamboo runs — subresource integrity is the clear case, since
|
|
58
|
+
* an `integrity` attribute is a digest of the bytes and no amount of reference rewriting can
|
|
59
|
+
* carry it across an edit — or to rule pruning out while diagnosing a missing rule. Where the
|
|
60
|
+
* consumer can be moved after Bamboo instead (`order: 'post'`, `writeBundle`, `closeBundle`),
|
|
61
|
+
* do that and keep the pruning.
|
|
51
62
|
*
|
|
52
63
|
* @default true
|
|
53
64
|
*/
|
|
54
|
-
|
|
65
|
+
pruneCss?: boolean;
|
|
55
66
|
}
|
|
56
67
|
/**
|
|
57
68
|
* Vite integration for Bamboo CSS.
|
package/dist/index.d.mts
CHANGED
|
@@ -37,21 +37,32 @@ interface BambooVitePluginOptions {
|
|
|
37
37
|
*/
|
|
38
38
|
maxRecipeStates?: number;
|
|
39
39
|
/**
|
|
40
|
-
*
|
|
40
|
+
* Remove rules for atoms no compiled module can emit. Builds only; dev never prunes.
|
|
41
41
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* a
|
|
42
|
+
* Off ships the whole extracted stylesheet: every rule the source graph produced, including
|
|
43
|
+
* ones nothing reaches. Larger, and never wrong *by pruning* — it also stands down the
|
|
44
|
+
* assertion that every compiled class has a rule, since that check exists to catch this pass
|
|
45
|
+
* removing too much. So this is a true escape hatch: it cannot fail a build over reachability.
|
|
45
46
|
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
47
|
+
* The pruned sheet is also renamed to a hash of its own bytes, and that is not a separate
|
|
48
|
+
* setting because it cannot safely be one. Rollup and Rolldown expand `[hash]` before
|
|
49
|
+
* `generateBundle`, where pruning has to run, so the name Vite assigned describes the sheet
|
|
50
|
+
* as it was *before* pruning. Leaving that name on pruned bytes is how a stale stylesheet
|
|
51
|
+
* outlives a deploy — a change to reachability alone, which is what upgrading Bamboo is,
|
|
52
|
+
* leaves identical source CSS under an identical name with different content, and a CDN
|
|
53
|
+
* holding that key keeps serving the old one. So the bytes and the name move together or
|
|
54
|
+
* neither does.
|
|
55
|
+
*
|
|
56
|
+
* Reach for this if something downstream derives an artifact from the stylesheet's *content*
|
|
57
|
+
* during `generateBundle` before Bamboo runs — subresource integrity is the clear case, since
|
|
58
|
+
* an `integrity` attribute is a digest of the bytes and no amount of reference rewriting can
|
|
59
|
+
* carry it across an edit — or to rule pruning out while diagnosing a missing rule. Where the
|
|
60
|
+
* consumer can be moved after Bamboo instead (`order: 'post'`, `writeBundle`, `closeBundle`),
|
|
61
|
+
* do that and keep the pruning.
|
|
51
62
|
*
|
|
52
63
|
* @default true
|
|
53
64
|
*/
|
|
54
|
-
|
|
65
|
+
pruneCss?: boolean;
|
|
55
66
|
}
|
|
56
67
|
/**
|
|
57
68
|
* Vite integration for Bamboo CSS.
|
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { findConfig, getConfigDependencies } from "@bamboocss/config";
|
|
2
|
+
import { Builder, loadConfigAndCreateContext, markStaticCompilerActive } from "@bamboocss/node";
|
|
2
3
|
import { logger } from "@bamboocss/logger";
|
|
3
4
|
import { compact, createCssUncached, createMergeCss, esc, memo, toHash, truncateList, viewTransitionClassName } from "@bamboocss/shared";
|
|
4
5
|
import remapping from "@ampproject/remapping";
|
|
@@ -276,14 +277,19 @@ const replaceAssetReferences = (bundle, previous, next, sourcemap) => {
|
|
|
276
277
|
*/
|
|
277
278
|
const carriesGeneratedCss = (output) => output.type === "asset" && output.fileName.endsWith(".css");
|
|
278
279
|
/**
|
|
279
|
-
* Prune
|
|
280
|
+
* Prune compiler-owned CSS, then give any sheet whose bytes changed a hash of those bytes.
|
|
280
281
|
*
|
|
281
282
|
* Rollup has already expanded `[hash]` when `generateBundle` runs. Mutating only `source`
|
|
282
283
|
* would therefore leave two different reachable subsets under one CDN key. The extra final
|
|
283
284
|
* hash is not cosmetic: it makes late graph reachability cache-safe.
|
|
285
|
+
*
|
|
286
|
+
* Renaming is therefore not a choice this takes. Pruned bytes under the unpruned sheet's name
|
|
287
|
+
* is the one outcome that must never be reachable, and a sheet nothing was removed from keeps
|
|
288
|
+
* its name because its bytes are unchanged — so "rename" is a consequence of "the bytes moved",
|
|
289
|
+
* not a second option. `prune` is the only knob.
|
|
284
290
|
*/
|
|
285
291
|
const optimizeStaticCssAssets = (bundle, session, options = {}) => {
|
|
286
|
-
const {
|
|
292
|
+
const { prune = true, sourcemap = session.sourcemap } = options;
|
|
287
293
|
/** Assets in this bundle that carry the generated stylesheet, pruned or not. */
|
|
288
294
|
let sheets = 0;
|
|
289
295
|
for (const output of Object.values(bundle)) {
|
|
@@ -295,12 +301,7 @@ const optimizeStaticCssAssets = (bundle, session, options = {}) => {
|
|
|
295
301
|
const optimized = pruneStaticCss(source, session);
|
|
296
302
|
output.source = optimized;
|
|
297
303
|
if (optimized === source) continue;
|
|
298
|
-
if (!rename) {
|
|
299
|
-
output.source = source;
|
|
300
|
-
continue;
|
|
301
|
-
}
|
|
302
304
|
const nextName = output.fileName.replace(/\.css$/, `.b-${toHash(optimized)}.css`);
|
|
303
|
-
if (nextName === output.fileName) continue;
|
|
304
305
|
if (bundle[nextName] && bundle[nextName] !== output) throw new Error(`bamboocss: final CSS asset name collision at ${JSON.stringify(nextName)}.`);
|
|
305
306
|
const previous = output.fileName;
|
|
306
307
|
output.fileName = nextName;
|
|
@@ -322,7 +323,7 @@ const optimizeStaticCssAssets = (bundle, session, options = {}) => {
|
|
|
322
323
|
* process just wrote, which is a race on any watch rebuild.
|
|
323
324
|
*/
|
|
324
325
|
const bamboocssCss = (options) => {
|
|
325
|
-
const { configPath, cwd, session,
|
|
326
|
+
const { configPath, cwd, session, pruneCss = true } = options;
|
|
326
327
|
/**
|
|
327
328
|
* Environments whose `load` served the virtual stylesheet.
|
|
328
329
|
*
|
|
@@ -396,6 +397,39 @@ const bamboocssCss = (options) => {
|
|
|
396
397
|
configResolved(config) {
|
|
397
398
|
command = config.command;
|
|
398
399
|
session.sourcemap = config.build.sourcemap;
|
|
400
|
+
/**
|
|
401
|
+
* Tell Vite that `bamboo.config.ts` is a config file, so editing one restarts the server.
|
|
402
|
+
*
|
|
403
|
+
* Tokens live there, and they are what a designer iterates on most — "restart the dev
|
|
404
|
+
* server to see a colour change" is the wrong instruction for the file most likely to be
|
|
405
|
+
* edited all afternoon. Nothing watched it: `watch` is the CLI's own watcher, and a
|
|
406
|
+
* project running `vite dev` never reaches it.
|
|
407
|
+
*
|
|
408
|
+
* A restart rather than re-emitting the stylesheet, because this plugin and the compiler
|
|
409
|
+
* hold *separate* contexts and only this one reloads its config. A token *value* edit
|
|
410
|
+
* came out right on the next source change, and an edit that changes what compiles —
|
|
411
|
+
* adding a token, a condition, a utility — left the compiler naming classes from the old
|
|
412
|
+
* config against a sheet emitted from the new one. Half-updated is worse than stale.
|
|
413
|
+
*
|
|
414
|
+
* Through Vite's own list rather than a watcher of ours. Vite adds these paths to the
|
|
415
|
+
* files it watches, which is what reaches a config *outside* `root` — a monorepo with one
|
|
416
|
+
* config above `apps/web`, or a preset resolved into `node_modules`, neither of which the
|
|
417
|
+
* project watcher covers. It also means the restart is Vite's, with its own concurrency
|
|
418
|
+
* guard and its own error reporting, rather than a second implementation of both.
|
|
419
|
+
*
|
|
420
|
+
* The config's own import graph is resolved the way `Builder` resolves it, minus the
|
|
421
|
+
* tsconfig paths it has not loaded yet at this point. `dependencies` globs are not
|
|
422
|
+
* expanded here: they are declared as an escape hatch for a *config reload*, and turning
|
|
423
|
+
* every file matching one into a full server restart is not what a project asking for
|
|
424
|
+
* that meant.
|
|
425
|
+
*/
|
|
426
|
+
if (config.command === "serve") try {
|
|
427
|
+
const { deps } = getConfigDependencies(findConfig({
|
|
428
|
+
cwd: cwd ?? config.root,
|
|
429
|
+
file: configPath
|
|
430
|
+
}));
|
|
431
|
+
config.configFileDependencies.push(...deps);
|
|
432
|
+
} catch {}
|
|
399
433
|
if (config.builder && config.environments) session.expectedEnvironments = new Set(Object.keys(config.environments));
|
|
400
434
|
},
|
|
401
435
|
/**
|
|
@@ -459,18 +493,18 @@ const bamboocssCss = (options) => {
|
|
|
459
493
|
* link the pruned copy: one project lost 39% of its atoms that way, presenting as
|
|
460
494
|
* rarely-used classes such as `md:{display:inline-block}` silently not applying.
|
|
461
495
|
*
|
|
462
|
-
* So the full extracted stylesheet ships instead,
|
|
463
|
-
*
|
|
464
|
-
*
|
|
465
|
-
*
|
|
496
|
+
* So the full extracted stylesheet ships instead, which is what `pruneCss: false` asks
|
|
497
|
+
* for by hand. Being the last environment is not the common case — frameworks build the
|
|
498
|
+
* client first — but it is the only one where the answer is knowable, and a framework
|
|
499
|
+
* that builds its server bundle first does get pruned output.
|
|
466
500
|
*/
|
|
467
501
|
const pending = remainingEnvironments(session);
|
|
468
502
|
const { sheets } = optimizeStaticCssAssets(bundle, session, {
|
|
469
|
-
|
|
470
|
-
prune: pending.length === 0,
|
|
503
|
+
prune: pruneCss && pending.length === 0,
|
|
471
504
|
sourcemap: environment?.config?.build?.sourcemap
|
|
472
505
|
});
|
|
473
|
-
if (sheets &&
|
|
506
|
+
if (sheets && !pruneCss) logger.info("vite", "Reachability pruning is off (`pruneCss: false`). The full extracted stylesheet ships.");
|
|
507
|
+
else if (sheets && pending.length) logger.info("vite", `Reachability pruning skipped: the stylesheet is emitted by the ${JSON.stringify(environment?.name ?? "default")} environment, and ${truncateList(pending, {
|
|
474
508
|
unit: "environment",
|
|
475
509
|
separator: ", "
|
|
476
510
|
})} ${pending.length === 1 ? "has" : "have"} not been compiled in this run. The full extracted stylesheet ships — nothing is missing from it.`);
|
|
@@ -487,6 +521,78 @@ const bamboocssCss = (options) => {
|
|
|
487
521
|
//#endregion
|
|
488
522
|
//#region src/fold-analysis.ts
|
|
489
523
|
/**
|
|
524
|
+
* Nodes that *compose* a value out of their children rather than computing one.
|
|
525
|
+
*
|
|
526
|
+
* The boundary of the walk below, and the whole of its precision. Climbing through these
|
|
527
|
+
* keeps a value's provenance: `'red.300'` inside `{ color: 'red.300' }` inside a default is
|
|
528
|
+
* still the default's. Anything else — a call, a function body, a JSX element — produces its
|
|
529
|
+
* value by being evaluated, so what is written inside it is an argument to that evaluation
|
|
530
|
+
* and not the enclosing default's value.
|
|
531
|
+
*
|
|
532
|
+
* Without the boundary, `({ cls = css({ color: 'red.300' }) }) => cls` is rejected: the call's
|
|
533
|
+
* own literal argument is syntactically inside a default, so an unbounded walk calls it one.
|
|
534
|
+
* That is correct code, and rejecting it fails the build.
|
|
535
|
+
*/
|
|
536
|
+
const composesValue = (node) => Node.isObjectLiteralExpression(node) || Node.isArrayLiteralExpression(node) || Node.isPropertyAssignment(node) || Node.isShorthandPropertyAssignment(node) || Node.isSpreadAssignment(node) || Node.isSpreadElement(node) || Node.isAsExpression(node) || Node.isParenthesizedExpression(node) || Node.isNonNullExpression(node) || Node.isTypeAssertion(node) || Node.isSatisfiesExpression(node);
|
|
537
|
+
/** Is `inner` written within `outer`? Positions rather than a walk, so it is O(1). */
|
|
538
|
+
const contains = (outer, inner) => outer.getSourceFile() === inner.getSourceFile() && outer.getStart() <= inner.getStart() && inner.getEnd() <= outer.getEnd();
|
|
539
|
+
/**
|
|
540
|
+
* Did this value come from the `= …` of a destructuring binding?
|
|
541
|
+
*
|
|
542
|
+
* `const { tone = 'red.300' } = source` boxes as the literal `'red.300'`: the extractor's
|
|
543
|
+
* `maybeDefinitionValue` tests for an initializer first and returns the boxed default, never
|
|
544
|
+
* reaching the branch that would read `source`. So the default is reported as the value whether
|
|
545
|
+
* or not it is the one that applies.
|
|
546
|
+
*
|
|
547
|
+
* For extraction that is merely optimistic, and deliberately so: a CLI or PostCSS build ships a
|
|
548
|
+
* runtime `css()`, where the default genuinely does apply when the caller omits the key, and it
|
|
549
|
+
* needs a rule behind it. Folding is where the same resolution turns into a wrong answer,
|
|
550
|
+
* because the call is *replaced* by that value.
|
|
551
|
+
*
|
|
552
|
+
* Stops at the first non-composing parent, so a call written inside a default keeps its own
|
|
553
|
+
* provenance, and checks that the binding element was reached through its initializer, so
|
|
554
|
+
* `{ tone = X }`'s name node is not mistaken for its default.
|
|
555
|
+
*/
|
|
556
|
+
const isBindingElementDefault = (node) => {
|
|
557
|
+
if (node && Node.isCallExpression(node)) return false;
|
|
558
|
+
let current = node;
|
|
559
|
+
while (current) {
|
|
560
|
+
const parent = current.getParent();
|
|
561
|
+
if (!parent) return false;
|
|
562
|
+
if (Node.isBindingElement(parent)) return parent.getInitializer() === current;
|
|
563
|
+
if (!composesValue(parent)) return false;
|
|
564
|
+
current = parent;
|
|
565
|
+
}
|
|
566
|
+
return false;
|
|
567
|
+
};
|
|
568
|
+
/**
|
|
569
|
+
* The same question asked of a whole box, including how it was resolved.
|
|
570
|
+
*
|
|
571
|
+
* The node a box reports is not always the one its value came from — an empty `{}` default
|
|
572
|
+
* boxes against the call rather than against the `{}` — so the resolution stack is consulted
|
|
573
|
+
* too. A binding element reaches that stack by having been resolved *through*, which is the
|
|
574
|
+
* signal the extractor itself reads when one of these is a conditional's test.
|
|
575
|
+
*
|
|
576
|
+
* Only the entries that are binding elements are examined. Walking up from every other entry
|
|
577
|
+
* was tried and never once changed a verdict across the default spellings or the sandbox's own
|
|
578
|
+
* modules, while accounting for most of the parent hops this does — the stack carries nodes
|
|
579
|
+
* that were never resolved through, including a call's own arguments, so walking from them is
|
|
580
|
+
* both the expensive half and the one that reaches conclusions it has no basis for.
|
|
581
|
+
*
|
|
582
|
+
* A binding element without an initializer carries no default to mistrust: `const { tone } =
|
|
583
|
+
* source` either resolves from `source` or does not resolve at all.
|
|
584
|
+
*/
|
|
585
|
+
const isFromBindingDefault = (node) => {
|
|
586
|
+
const own = node.getNode?.();
|
|
587
|
+
if (isBindingElementDefault(own)) return true;
|
|
588
|
+
for (const entry of node.getStack?.() ?? []) {
|
|
589
|
+
if (!Node.isBindingElement(entry) || !entry.getInitializer()) continue;
|
|
590
|
+
if (own && contains(entry, own)) continue;
|
|
591
|
+
return true;
|
|
592
|
+
}
|
|
593
|
+
return false;
|
|
594
|
+
};
|
|
595
|
+
/**
|
|
490
596
|
* Statically resolvable means: every box in the tree carries a known value.
|
|
491
597
|
*
|
|
492
598
|
* `unresolvable` is the extractor saying it could not evaluate a node.
|
|
@@ -500,6 +606,7 @@ const isStaticBox = (node, seen = /* @__PURE__ */ new Set()) => {
|
|
|
500
606
|
seen.add(node);
|
|
501
607
|
if (box.isUnresolvable(node) || box.isConditional(node)) return false;
|
|
502
608
|
if (!("type" in node) || node.type == null) return false;
|
|
609
|
+
if (isFromBindingDefault(node)) return false;
|
|
503
610
|
if (box.isLiteral(node) && node.value === void 0) {
|
|
504
611
|
const source = node.getNode?.();
|
|
505
612
|
return Boolean(source && Node.isIdentifier(source) && source.getText() === "undefined");
|
|
@@ -2862,8 +2969,47 @@ const createStaticStyleSetCompiler = (ctx, runtimeCss, allocateClassString = (cl
|
|
|
2862
2969
|
//#region src/plugin.ts
|
|
2863
2970
|
const DEFAULT_EXTENSIONS = /\.(?:[cm]?[jt]sx?)$/;
|
|
2864
2971
|
const NODE_MODULES = /node_modules/;
|
|
2972
|
+
/**
|
|
2973
|
+
* Queries that make Vite serve something other than the module's own source.
|
|
2974
|
+
*
|
|
2975
|
+
* `./theme.tsx?raw` is a module whose text is `export default "…"`, and `?url`, `?worker` and
|
|
2976
|
+
* `?sharedworker` are wrappers of the same kind. The query has to be stripped before the
|
|
2977
|
+
* extension is tested — otherwise nothing matches `.tsx` — and stripping it is what made these
|
|
2978
|
+
* look like the file itself. The transform then handed the wrapper's text to ts-morph *under
|
|
2979
|
+
* the real file's path*, overwriting the parsed module every fold reads for that path.
|
|
2980
|
+
*
|
|
2981
|
+
* That is not theoretical: a module folding `css(shared)` against a sibling the entry also
|
|
2982
|
+
* imported as `?raw` failed the build with "1 call(s) could not be compiled" — the compiler
|
|
2983
|
+
* had read `export default "…"` and found no `shared` to resolve. The advice it prints, to
|
|
2984
|
+
* make the value statically analyzable, is unfollowable, because the source already was.
|
|
2985
|
+
*
|
|
2986
|
+
* Whether it bites depends on which of the two ids Rollup transforms last, so the same project
|
|
2987
|
+
* can build and then stop building because an import moved.
|
|
2988
|
+
*
|
|
2989
|
+
* A deny list rather than an allow list of benign queries: dev ids carry `?t=` after an edit
|
|
2990
|
+
* and `?import` when a dynamic import is rewritten, and rejecting an unrecognised one of those
|
|
2991
|
+
* would silently stop folding a module rather than loudly refuse it.
|
|
2992
|
+
*
|
|
2993
|
+
* Exactly these four, matching Vite's own `SPECIAL_QUERY_RE`. The list was drafted wider —
|
|
2994
|
+
* `?inline`, `?no-inline`, `?worklet`, `?init` — and every one of those was wrong: Vite has no
|
|
2995
|
+
* `worklet` query at all, `?init` is `.wasm` only and that extension is already rejected below,
|
|
2996
|
+
* and `inline`/`no-inline` merely pick base64-versus-file for something that *already* matched
|
|
2997
|
+
* `raw`/`url`, so `./a.tsx?inline` is served as the module's own source. Rejecting an id that
|
|
2998
|
+
* carries real source is the expensive direction: the transform declines, its atoms never reach
|
|
2999
|
+
* the reachability set, pruning removes their rules, and the runtime still returns the class
|
|
3000
|
+
* names — unstyled elements, no error. Only names verified against Vite belong here.
|
|
3001
|
+
*
|
|
3002
|
+
* Note `?worker_file`, which is how dev serves a worker's *real* source, is deliberately absent
|
|
3003
|
+
* and must stay absent. It contains "worker" and is the obvious next entry; adding it would
|
|
3004
|
+
* stop folding every worker module in dev, silently, by the mechanism above.
|
|
3005
|
+
*
|
|
3006
|
+
* Tested against the whole id rather than a split-off query, so it cannot disagree with
|
|
3007
|
+
* `queryOf` in `css.ts` about where the query starts.
|
|
3008
|
+
*/
|
|
3009
|
+
const WRAPPED_MODULE_QUERY = /[?&](?:raw|url|worker|sharedworker)(?:&|=|$)/;
|
|
2865
3010
|
const shouldTransform = (id) => {
|
|
2866
3011
|
if (id.startsWith("\0")) return false;
|
|
3012
|
+
if (WRAPPED_MODULE_QUERY.test(id)) return false;
|
|
2867
3013
|
const [filePath] = id.split("?");
|
|
2868
3014
|
if (!filePath) return false;
|
|
2869
3015
|
if (NODE_MODULES.test(filePath)) return false;
|
|
@@ -2891,6 +3037,18 @@ const isGeneratedOutput = (filePath, ctx) => {
|
|
|
2891
3037
|
};
|
|
2892
3038
|
/** 1-indexed line of a source offset, for an error a user can navigate to. */
|
|
2893
3039
|
const lineAt = (code, offset) => code.slice(0, offset).split("\n").length;
|
|
3040
|
+
/**
|
|
3041
|
+
* One spelling for a path used as a map key against paths from somewhere else.
|
|
3042
|
+
*
|
|
3043
|
+
* The fold reports dependencies as ts-morph sees them and Vite reports a changed file as its
|
|
3044
|
+
* watcher saw it. On Windows those differ by separator, and can differ by the case of the
|
|
3045
|
+
* drive letter alone — chokidar reports what the OS handed it, `path.resolve` preserves
|
|
3046
|
+
* whatever the cwd had. Either would make every lookup below miss and restore the exact
|
|
3047
|
+
* staleness they exist to fix, silently, since a miss is indistinguishable from a module that
|
|
3048
|
+
* folded nothing. Only the drive letter is case-folded: the rest of the path is compared as
|
|
3049
|
+
* written, because elsewhere the filesystem may well be case-sensitive.
|
|
3050
|
+
*/
|
|
3051
|
+
const normalizeFsPath = (file) => resolve(file).replaceAll("\\", "/").replace(/^[a-z]:\//, (drive) => drive.toUpperCase());
|
|
2894
3052
|
const formatSkipped = (id, skipped) => {
|
|
2895
3053
|
const counts = /* @__PURE__ */ new Map();
|
|
2896
3054
|
for (const entry of skipped) counts.set(entry.reason, (counts.get(entry.reason) ?? 0) + 1);
|
|
@@ -2910,15 +3068,27 @@ const formatSkipped = (id, skipped) => {
|
|
|
2910
3068
|
* with no matching rule.
|
|
2911
3069
|
*/
|
|
2912
3070
|
const bamboocss = (options = {}) => {
|
|
2913
|
-
const { configPath, cwd, reportSkipped = false, reportSummary = true, maxRecipeStates,
|
|
3071
|
+
const { configPath, cwd, reportSkipped = false, reportSummary = true, maxRecipeStates, pruneCss = true } = options;
|
|
3072
|
+
markStaticCompilerActive();
|
|
2914
3073
|
if (maxRecipeStates !== void 0 && (!Number.isSafeInteger(maxRecipeStates) || maxRecipeStates < 1)) throw new Error("bamboocss: `maxRecipeStates` must be a positive safe integer.");
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
3074
|
+
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.");
|
|
3075
|
+
/**
|
|
3076
|
+
* What each file's transform found, for the summary. Keyed by file rather than summed as it
|
|
3077
|
+
* goes, because a build has more than one environment and they share most of their modules.
|
|
3078
|
+
*
|
|
3079
|
+
* Running totals double-counted every shared module once per environment — a two-environment
|
|
3080
|
+
* build of one shared file and one entry each reported "2/2 across 2/4 files" for three
|
|
3081
|
+
* source modules. Coverage is a property of the source, not of how many times a bundler
|
|
3082
|
+
* handed the same file over. It also grew without bound in dev, where every HMR
|
|
3083
|
+
* re-transform of a file counted as another file.
|
|
3084
|
+
*
|
|
3085
|
+
* A second pass over a file replaces its entry rather than adding to it. Both environments
|
|
3086
|
+
* are assumed to compute the same answer for the same module — true of this compiler, though
|
|
3087
|
+
* not something the plugin can enforce, since another `pre` plugin may hand each environment
|
|
3088
|
+
* different code. Where they disagree the last one wins, which is a cosmetic number either
|
|
3089
|
+
* way.
|
|
3090
|
+
*/
|
|
3091
|
+
const perFile = /* @__PURE__ */ new Map();
|
|
2922
3092
|
const staticSession = createStaticCompilationSession();
|
|
2923
3093
|
/**
|
|
2924
3094
|
* Indexed by file, because the only bulk operation on it is "forget this one's".
|
|
@@ -2961,6 +3131,84 @@ const bamboocss = (options = {}) => {
|
|
|
2961
3131
|
* would otherwise re-parse its module fifty times, which is the transform path.
|
|
2962
3132
|
*/
|
|
2963
3133
|
const recipeConfigCache = /* @__PURE__ */ new Map();
|
|
3134
|
+
/**
|
|
3135
|
+
* Which modules folded a value read out of which other module, for dev invalidation.
|
|
3136
|
+
*
|
|
3137
|
+
* `addWatchFile` reports the same edges, and in a build that is enough — Rollup discards a
|
|
3138
|
+
* module whose watched file changed. Vite's dev server does not: a module that *statically
|
|
3139
|
+
* imports* the changed one is only **soft**-invalidated, which by design keeps its cached
|
|
3140
|
+
* transform result and rewrites nothing but the timestamps on its import specifiers. That
|
|
3141
|
+
* cached result is where the compiled class string lives, so the edit never reaches it.
|
|
3142
|
+
*
|
|
3143
|
+
* The recipe case is the one users meet, because it is the one where the class is compiled
|
|
3144
|
+
* into somebody else's module: an inline `cva` declaration is erased, and each *call site*
|
|
3145
|
+
* becomes a literal in the module that calls it. Editing the recipe then updates the class
|
|
3146
|
+
* in a module Vite has decided not to re-transform, so the browser and the SSR render keep
|
|
3147
|
+
* the old class — with no error, and with Vite and Bamboo both logging as if the edit landed.
|
|
3148
|
+
* A restart applies it, which is what makes it read as "recipes do not hot-reload".
|
|
3149
|
+
*
|
|
3150
|
+
* `css(sharedObject)` across modules fails identically; it is rarer only because a consumer
|
|
3151
|
+
* that folds *nothing but* recipe calls has its import erased, and an erased import is not a
|
|
3152
|
+
* static one, so Vite hard-invalidates it and the bug hides. Import one more value from the
|
|
3153
|
+
* same module — the shape any real `ui.ts` has — and the import survives, and so does the
|
|
3154
|
+
* stale class.
|
|
3155
|
+
*
|
|
3156
|
+
* Keyed by dependency, since "what changed" is the question asked, and tracked in the other
|
|
3157
|
+
* direction as well so a re-transform can retract edges the module no longer has.
|
|
3158
|
+
*/
|
|
3159
|
+
const dependentsByDependency = /* @__PURE__ */ new Map();
|
|
3160
|
+
const dependenciesByFile = /* @__PURE__ */ new Map();
|
|
3161
|
+
const recordFoldDependencies = (file, dependencies) => {
|
|
3162
|
+
const next = new Set(dependencies.map(normalizeFsPath).filter((dependency) => dependency !== file));
|
|
3163
|
+
const previous = dependenciesByFile.get(file);
|
|
3164
|
+
for (const dependency of previous ?? []) {
|
|
3165
|
+
if (next.has(dependency)) continue;
|
|
3166
|
+
const dependents = dependentsByDependency.get(dependency);
|
|
3167
|
+
if (!dependents?.delete(file)) continue;
|
|
3168
|
+
if (!dependents.size) dependentsByDependency.delete(dependency);
|
|
3169
|
+
}
|
|
3170
|
+
if (!next.size) {
|
|
3171
|
+
dependenciesByFile.delete(file);
|
|
3172
|
+
return;
|
|
3173
|
+
}
|
|
3174
|
+
dependenciesByFile.set(file, next);
|
|
3175
|
+
for (const dependency of next) {
|
|
3176
|
+
const dependents = dependentsByDependency.get(dependency);
|
|
3177
|
+
if (dependents) dependents.add(file);
|
|
3178
|
+
else dependentsByDependency.set(dependency, new Set([file]));
|
|
3179
|
+
}
|
|
3180
|
+
};
|
|
3181
|
+
/**
|
|
3182
|
+
* Modules to re-transform because `file` changed, hard-invalidated on the way out.
|
|
3183
|
+
*
|
|
3184
|
+
* Both halves are load-bearing. Invalidating drops the stale compiled result, which is the
|
|
3185
|
+
* defect itself; returning the modules is what makes Vite propagate an update for them, so
|
|
3186
|
+
* the client applies one rather than waiting for whatever request happens next.
|
|
3187
|
+
*
|
|
3188
|
+
* Invalidating here rather than leaving it to `updateModules` also survives another plugin
|
|
3189
|
+
* filtering the list afterwards — a framework's own `hotUpdate` decides what its route
|
|
3190
|
+
* modules do, and the stale bytes have to go either way.
|
|
3191
|
+
*
|
|
3192
|
+
* A consumer Vite already reached costs nothing: `propagateUpdate` skips a module it has
|
|
3193
|
+
* traversed, and every consumer of a *resolvable* dependency is one, since `addWatchFile`
|
|
3194
|
+
* makes it an importer whether or not the import survived the fold. What is left is the
|
|
3195
|
+
* consumer of a dependency with no module of its own, where Vite matched nothing and would
|
|
3196
|
+
* have done nothing at all. That one can end in a page reload, which is the honest outcome:
|
|
3197
|
+
* its compiled classes really did change, and a reload is what Vite does with any update
|
|
3198
|
+
* nothing accepts.
|
|
3199
|
+
*/
|
|
3200
|
+
const foldDependentModules = (file, modules, graph) => {
|
|
3201
|
+
const dependents = dependentsByDependency.get(normalizeFsPath(file));
|
|
3202
|
+
if (!dependents?.size) return;
|
|
3203
|
+
const added = [];
|
|
3204
|
+
for (const dependent of dependents) for (const module of graph.getModulesByFile(dependent) ?? []) {
|
|
3205
|
+
if (modules.includes(module) || added.includes(module)) continue;
|
|
3206
|
+
graph.invalidateModule(module);
|
|
3207
|
+
added.push(module);
|
|
3208
|
+
}
|
|
3209
|
+
if (!added.length) return;
|
|
3210
|
+
return [...modules, ...added];
|
|
3211
|
+
};
|
|
2964
3212
|
let ctx;
|
|
2965
3213
|
let runtimeCss;
|
|
2966
3214
|
let styleCompiler;
|
|
@@ -2981,7 +3229,7 @@ const bamboocss = (options = {}) => {
|
|
|
2981
3229
|
configPath,
|
|
2982
3230
|
cwd,
|
|
2983
3231
|
session: staticSession,
|
|
2984
|
-
|
|
3232
|
+
pruneCss
|
|
2985
3233
|
}), {
|
|
2986
3234
|
name: "bamboocss:compiler",
|
|
2987
3235
|
enforce: "pre",
|
|
@@ -2993,12 +3241,11 @@ const bamboocss = (options = {}) => {
|
|
|
2993
3241
|
async buildStart() {
|
|
2994
3242
|
const environment = this.environment?.name ?? "default";
|
|
2995
3243
|
if (staticSession.startedEnvironments.has(environment)) {
|
|
2996
|
-
|
|
2997
|
-
totals.files = 0;
|
|
2998
|
-
totals.filesWithFolds = 0;
|
|
2999
|
-
totals.skipped.clear();
|
|
3244
|
+
perFile.clear();
|
|
3000
3245
|
survivorsByFile.clear();
|
|
3001
3246
|
recipeConfigCache.clear();
|
|
3247
|
+
dependentsByDependency.clear();
|
|
3248
|
+
dependenciesByFile.clear();
|
|
3002
3249
|
resetStaticCompilationSession(staticSession);
|
|
3003
3250
|
}
|
|
3004
3251
|
staticSession.startedEnvironments.add(environment);
|
|
@@ -3036,10 +3283,40 @@ const bamboocss = (options = {}) => {
|
|
|
3036
3283
|
recipeConfigCache.clear();
|
|
3037
3284
|
if (change.event === "delete") {
|
|
3038
3285
|
ctx.project.removeSourceFile(filePath);
|
|
3286
|
+
recordFoldDependencies(normalizeFsPath(filePath), []);
|
|
3039
3287
|
return;
|
|
3040
3288
|
}
|
|
3041
3289
|
ctx.project.reloadSourceFile(filePath);
|
|
3042
3290
|
},
|
|
3291
|
+
/**
|
|
3292
|
+
* Re-transform whatever folded a value out of the file that just changed.
|
|
3293
|
+
*
|
|
3294
|
+
* Dev only — `hotUpdate` does not run in a build, where Rollup's own invalidation already
|
|
3295
|
+
* covers this — and additive: the modules Vite matched are returned alongside, so this
|
|
3296
|
+
* decides nothing about them.
|
|
3297
|
+
*
|
|
3298
|
+
* `handleHotUpdate` below stands in on Vite 5, which has no `hotUpdate`. Not quite the
|
|
3299
|
+
* same thing: Vite 5 calls that hook for an update and not for a file appearing or being
|
|
3300
|
+
* deleted, so a recipe file *created* while the server runs leaves its consumers stale
|
|
3301
|
+
* there. Vite 6 and up call `hotUpdate` for all three, and never call `handleHotUpdate`
|
|
3302
|
+
* when a plugin has both — including its deprecation warning — so exactly one of the two
|
|
3303
|
+
* runs on any supported version.
|
|
3304
|
+
*
|
|
3305
|
+
* `environment` optional-chained for the same reason `addWatchFile` is in `transform`:
|
|
3306
|
+
* a harness driving the hook need not supply a full plugin context, and a `TypeError`
|
|
3307
|
+
* here is swallowed into an HMR error payload that a middleware-mode server sends
|
|
3308
|
+
* nowhere.
|
|
3309
|
+
*/
|
|
3310
|
+
hotUpdate({ file, modules }) {
|
|
3311
|
+
const graph = this.environment?.moduleGraph;
|
|
3312
|
+
if (!graph) return;
|
|
3313
|
+
return foldDependentModules(file, modules, graph);
|
|
3314
|
+
},
|
|
3315
|
+
handleHotUpdate({ file, modules, server }) {
|
|
3316
|
+
const legacy = server;
|
|
3317
|
+
if (legacy.environments) return;
|
|
3318
|
+
return foldDependentModules(file, modules, legacy.moduleGraph);
|
|
3319
|
+
},
|
|
3043
3320
|
async transform(code, id) {
|
|
3044
3321
|
if (!shouldTransform(id)) return null;
|
|
3045
3322
|
try {
|
|
@@ -3071,8 +3348,10 @@ const bamboocss = (options = {}) => {
|
|
|
3071
3348
|
});
|
|
3072
3349
|
} catch (error) {
|
|
3073
3350
|
logger.caughtError("vite:transform", `Failed to compile ${filePath}`, error);
|
|
3074
|
-
|
|
3075
|
-
|
|
3351
|
+
perFile.set(filePath, {
|
|
3352
|
+
folded: 0,
|
|
3353
|
+
skipped: new Map([["compile-failed", 1]])
|
|
3354
|
+
});
|
|
3076
3355
|
addSurvivor({
|
|
3077
3356
|
file: filePath,
|
|
3078
3357
|
line: 1,
|
|
@@ -3082,10 +3361,15 @@ const bamboocss = (options = {}) => {
|
|
|
3082
3361
|
if (command === "serve") throw asError(error, `failed to compile ${filePath}`);
|
|
3083
3362
|
return null;
|
|
3084
3363
|
}
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3364
|
+
let skippedHere;
|
|
3365
|
+
for (const entry of result.skipped) {
|
|
3366
|
+
skippedHere ??= /* @__PURE__ */ new Map();
|
|
3367
|
+
skippedHere.set(entry.reason, (skippedHere.get(entry.reason) ?? 0) + 1);
|
|
3368
|
+
}
|
|
3369
|
+
perFile.set(filePath, {
|
|
3370
|
+
folded: result.folded.length,
|
|
3371
|
+
skipped: skippedHere
|
|
3372
|
+
});
|
|
3089
3373
|
if (result.folded.some((entry) => entry.kind === "class" || entry.kind === "slots")) staticSession.transformedFiles.add(resolve(filePath));
|
|
3090
3374
|
for (const entry of result.folded) for (const className of entry.classNames) staticSession.markClassUsed(className);
|
|
3091
3375
|
for (const entry of result.skipped) {
|
|
@@ -3100,6 +3384,7 @@ const bamboocss = (options = {}) => {
|
|
|
3100
3384
|
}
|
|
3101
3385
|
if (reportSkipped && result.skipped.length) logger.info("vite:transform", formatSkipped(filePath, result.skipped));
|
|
3102
3386
|
for (const dependency of result.dependencies) this.addWatchFile?.(dependency);
|
|
3387
|
+
recordFoldDependencies(normalizeFsPath(filePath), result.dependencies);
|
|
3103
3388
|
const forFile = survivorsByFile.get(filePath);
|
|
3104
3389
|
if (command === "serve" && forFile?.length) throw createSurvivorError(forFile);
|
|
3105
3390
|
if (!result.folded.length) return null;
|
|
@@ -3118,7 +3403,7 @@ const bamboocss = (options = {}) => {
|
|
|
3118
3403
|
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
3404
|
unit: "class",
|
|
3120
3405
|
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({
|
|
3406
|
+
})}\n\nThe stylesheet is finalized by the environment that imports it, so pruning it is only safe once every environment has been compiled. This build did not say how many there would be: it called \`builder.build(environment)\` directly. Run it through \`vite build\`, call \`builder.buildApp()\`, or set \`builder: {}\` in the Vite config so the environments are known before the first one builds. \`bamboocss({ pruneCss: false })\` also turns pruning off entirely.`);
|
|
3122
3407
|
}
|
|
3123
3408
|
if (typeof this.getModuleInfo === "function" && !remainingEnvironments(staticSession).length) {
|
|
3124
3409
|
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.`);
|
|
@@ -3129,12 +3414,21 @@ const bamboocss = (options = {}) => {
|
|
|
3129
3414
|
})}\n\nAdd them to \`include\` in bamboo.config, or no CSS rule can back their emitted classes.`);
|
|
3130
3415
|
}
|
|
3131
3416
|
if (!reportSummary) return;
|
|
3132
|
-
|
|
3133
|
-
|
|
3417
|
+
if (command === "build" && remainingEnvironments(staticSession).length) return;
|
|
3418
|
+
let folded = 0;
|
|
3419
|
+
let filesWithFolds = 0;
|
|
3420
|
+
const skipped = /* @__PURE__ */ new Map();
|
|
3421
|
+
for (const entry of perFile.values()) {
|
|
3422
|
+
folded += entry.folded;
|
|
3423
|
+
if (entry.folded) filesWithFolds++;
|
|
3424
|
+
for (const [reason, count] of entry.skipped ?? []) skipped.set(reason, (skipped.get(reason) ?? 0) + count);
|
|
3425
|
+
}
|
|
3426
|
+
const declined = Array.from(skipped.values()).reduce((sum, count) => sum + count, 0);
|
|
3427
|
+
const total = folded + declined;
|
|
3134
3428
|
if (!total) return;
|
|
3135
|
-
const share = Math.round(
|
|
3136
|
-
const reasons = Array.from(
|
|
3137
|
-
logger.info("vite:transform", `Compiled ${
|
|
3429
|
+
const share = Math.round(folded / total * 100);
|
|
3430
|
+
const reasons = Array.from(skipped.entries()).sort((a, b) => b[1] - a[1]).map(([reason, count]) => `${reason}=${count}`).join(" ");
|
|
3431
|
+
logger.info("vite:transform", `Compiled ${folded}/${total} (${share}%) across ${filesWithFolds}/${perFile.size} files` + (reasons ? ` — declined: ${reasons}` : ""));
|
|
3138
3432
|
}
|
|
3139
3433
|
}];
|
|
3140
3434
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bamboocss/vite",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.39.0",
|
|
4
4
|
"description": "Vite integration for Bamboo CSS",
|
|
5
5
|
"homepage": "https://bamboocss.com",
|
|
6
6
|
"license": "MIT",
|
|
@@ -40,18 +40,18 @@
|
|
|
40
40
|
"postcss": "8.5.26",
|
|
41
41
|
"postcss-selector-parser": "7.1.5",
|
|
42
42
|
"ts-morph": "28.0.0",
|
|
43
|
-
"@bamboocss/
|
|
44
|
-
"@bamboocss/
|
|
45
|
-
"@bamboocss/
|
|
46
|
-
"@bamboocss/logger": "1.
|
|
47
|
-
"@bamboocss/node": "1.
|
|
48
|
-
"@bamboocss/
|
|
49
|
-
"@bamboocss/
|
|
43
|
+
"@bamboocss/core": "1.39.0",
|
|
44
|
+
"@bamboocss/extractor": "1.39.0",
|
|
45
|
+
"@bamboocss/config": "1.39.0",
|
|
46
|
+
"@bamboocss/logger": "1.39.0",
|
|
47
|
+
"@bamboocss/node": "1.39.0",
|
|
48
|
+
"@bamboocss/shared": "1.39.0",
|
|
49
|
+
"@bamboocss/types": "1.39.0"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@jridgewell/trace-mapping": "^0.3.31",
|
|
53
53
|
"vite": "7.2.6",
|
|
54
|
-
"@bamboocss/fixture": "1.
|
|
54
|
+
"@bamboocss/fixture": "1.39.0"
|
|
55
55
|
},
|
|
56
56
|
"peerDependencies": {
|
|
57
57
|
"vite": ">=5"
|