Skip to content

Commit 78a4227

Browse files
feat(css): make the printer's mode reachable and support beautifying (#21660)
* test(css): benchmark the printer, not just the parser `css-parser-tailwind-unit` stops at the parse, so nothing measured serialization or the source map it resolves — the half this branch changes. This covers both paths a nested block can take (streamed as its children finish, or buffered and printed whole), the shapes they cost differently on, and the two pieces only a streamed block reaches: a declaration a later one overrides, and an opener held back so an empty block can still be dropped. Against this branch's merge base: tailwind 345.6 -> 257.0 ms streamed block 113.1 -> 82.1 ms buffered blocks 94.6 -> 83.4 ms flat rules 109.0 -> 104.0 ms deep nesting 10.5 -> 11.1 ms retracted declarations 99.1 -> 77.4 ms dropped empty blocks 34.1 -> 21.8 ms Deep nesting is the run-to-run spread, not a cost: alternating the two arms interleaves them (before 10.80/11.17/11.11, after 10.96/10.85/11.06). * test(css): benchmark each printer fixture unminimized too A build that is not minimizing still parses and walks, and that pass is the floor the printer sits on. Running every fixture both ways makes the pair the measurement: the difference is what printing and the source map cost on that shape, which neither number shows alone. It is a far bigger share here than in HTML — the CSS walk skips most of what the printer then has to serialize. fixture walk minify tailwind 36.1 -> 266.7 ms streamed block 9.7 -> 87.7 ms buffered blocks 8.5 -> 84.4 ms flat rules 11.1 -> 112.6 ms deep nesting 2.3 -> 12.2 ms retracted declarations 10.1 -> 90.3 ms dropped empty blocks 6.7 -> 23.4 ms The fixtures move to a table so the two modes cannot drift apart; the `minify (...)` names are unchanged, so their history carries over. Their absolute values shift against the previous commit because the walk arms now share the process — compare within a run, not across. * feat(css): make the printer's mode reachable and support beautifying `PrintOptions.mode` has always been typed `"minify" | "beautify"` and both printers branch on it, but `process()` hardcoded `"minify"`, so half of it was unreachable. `process(src, { mode })` now selects it; `minimize: true` keeps working as the shorthand for `mode: "minify"` it reads as. Beautifying re-serializes without the transforms — the authored `#ff0000`, `1px 2px 1px 2px` and comment placement stay as written, one declaration per line. It is deliberately still ugly in places (no indentation, top-level items run together); what it must not be is lossy, and two things were: - kept comments (`/*!`, `@license`, `@preserve`) were only collected when minifying, so beautifying dropped every license banner; - a custom property's value prints straight from source, comments included, but only the minifying path claimed them from the writer, so beautifying emitted each one twice. Minify output is unchanged, byte-for-byte, over the 840-file css/html fixture corpus and 24,000 random inputs. On the same corpus `minify(beautify(x)) === minify(x)` holds everywhere minify is itself idempotent. * chore: add a changeset for the print modes
1 parent 829fc32 commit 78a4227

6 files changed

Lines changed: 274 additions & 34 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"webpack": minor
3+
---
4+
5+
Support printing CSS and HTML beautified as well as minified.

‎lib/css/syntax.js‎

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4656,14 +4656,15 @@ const grammar = (input, visitors, writer, options) => {
46564656
_commentBucket = visitors[T_COMMENT];
46574657

46584658
// Comment sink: fire the `Comment` visitor bucket (if registered) and, when
4659-
// minifying, keep license/important comments (`/*!`, `@license`, `@preserve`) —
4659+
// printing, keep license/important comments (`/*!`, `@license`, `@preserve`) —
46604660
// the ecosystem default (cssnano / clean-css / csso keep `/*!`; terser adds the
46614661
// annotations). A kept comment is handed to the writer, which re-emits it before
4662-
// the next top-level node. No comment visitor and no minify printing => no
4663-
// callback (comments are skipped at zero cost).
4662+
// the next top-level node. Both print modes keep the same ones, so beautifying
4663+
// and minifying the same stylesheet carry the same banners. No comment visitor
4664+
// and no printing => no callback (comments are skipped at zero cost).
46644665
/** @type {((input: string, start: number, end: number) => number) | undefined} */
46654666
let onComment;
4666-
if (writer !== undefined && writer.options.mode === "minify") {
4667+
if (writer !== undefined) {
46674668
const w = writer;
46684669
onComment = (src, start, end) => {
46694670
if (_commentBucket !== undefined) _grammarOnComment(src, start, end);
@@ -8771,9 +8772,15 @@ const printer = (path, writer) => {
87718772
// minify, down to the boundary they stand for.
87728773
const from = path.start(children[0]);
87738774
const to = path.end(children[children.length - 1]);
8774-
value = minify
8775-
? _customPropertyValue(path, children, writer, from, to)
8776-
: _input.slice(from, to);
8775+
if (minify) {
8776+
value = _customPropertyValue(path, children, writer, from, to);
8777+
} else {
8778+
// Straight from source, so the kept comments in it are already
8779+
// there — claim them, or the writer emits them a second time
8780+
// ahead of the next top-level node.
8781+
writer.takeInserts(from, to);
8782+
value = _input.slice(from, to);
8783+
}
87778784
} else if (property === "unicode-range") {
87788785
// `U+…` tokenizes as numbers, so the generic numeric normalization
87798786
// would corrupt it; each range is shortened as the urange it is.

‎lib/util/SourceProcessor.js‎

Lines changed: 19 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -633,17 +633,18 @@ class SourceProcessor {
633633
}
634634

635635
/**
636-
* Parse `input` once and fire the visitors in source order. With `minimize`
637-
* (and a printer supplied at construction) the same walk also prints — a
636+
* Parse `input` once and fire the visitors in source order. Asking for output
637+
* — `mode`, or `minimize: true` for the `"minify"` it is shorthand for — makes
638+
* the same walk print, given a printer supplied at construction: a
638639
* {@link PrintContext} is created, each node's printer fires into it as the node
639640
* finishes, and the result is returned as `{ code, map }`: the serialized output
640641
* and its input->output source map, always, independent of the pipeline's own
641-
* source-map setting (`source` / `content` name the map's input). Without
642-
* `minimize` it only walks and returns `undefined`. A single parse — printing
642+
* source-map setting (`source` / `content` name the map's input). Asking for
643+
* none of it only walks and returns `undefined`. A single parse — printing
643644
* never re-parses; all configuration is per-call.
644645
* @overload
645646
* @param {string} input
646-
* @param {TProcessOptions & { minimize: true, source?: string, content?: string }} options
647+
* @param {TProcessOptions & ({ minimize: true } | { mode: PrintOptions["mode"] }) & { source?: string, content?: string }} options
647648
* @returns {{ code: string, map: SourceMap }}
648649
*/
649650
/**
@@ -654,28 +655,28 @@ class SourceProcessor {
654655
*/
655656
/**
656657
* @param {string} input source text
657-
* @param {TProcessOptions=} options grammar-specific options (`skip`, …) plus `minimize` and, for the map, `source` / `content`
658+
* @param {TProcessOptions=} options grammar-specific options (`skip`, …) plus `mode` / `minimize` and, for the map, `source` / `content`
658659
* @returns {EXPECTED_ANY} `{ code, map }` when printing, else `undefined` — see the overloads
659660
*/
660661
process(input, options) {
661662
const opts = options || /** @type {TProcessOptions} */ ({});
662-
const printing =
663-
this._printer !== undefined &&
664-
/** @type {{ minimize?: boolean }} */ (opts).minimize === true;
665-
if (!printing) {
663+
const printOpts =
664+
/** @type {{ mode?: PrintOptions["mode"], minimize?: boolean, environment?: Readonly<Record<string, boolean>>, convertLengthUnits?: boolean }} */ (
665+
opts
666+
);
667+
// `minimize: true` is the shorthand `optimization.minimize` reads as, so it
668+
// names the mode it asks for rather than a second way to switch printing on.
669+
const asked =
670+
printOpts.mode || (printOpts.minimize === true ? "minify" : undefined);
671+
if (this._printer === undefined || asked === undefined) {
666672
this._grammar(input, this._visitors, undefined, opts);
667673
return undefined;
668674
}
669675
const ctx = new PrintContext(
670676
{
671-
mode: "minify",
672-
environment:
673-
/** @type {{ environment?: Readonly<Record<string, boolean>> }} */ (
674-
opts
675-
).environment,
676-
convertLengthUnits: /** @type {{ convertLengthUnits?: boolean }} */ (
677-
opts
678-
).convertLengthUnits
677+
mode: asked,
678+
environment: printOpts.environment,
679+
convertLengthUnits: printOpts.convertLengthUnits
679680
},
680681
/** @type {NodePrinter<TPath, TNode>} */ (this._printer)
681682
);

‎test/CssSyntax.unittest.js‎

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2183,6 +2183,76 @@ describe("CssSyntax — SourceProcessor without visitors", () => {
21832183
});
21842184
});
21852185

2186+
describe("CssSyntax — print modes", () => {
2187+
/**
2188+
* @param {string} src css source
2189+
* @param {import("../lib/util/SourceProcessor").PrintOptions["mode"]} mode print mode
2190+
* @returns {string} its serialization
2191+
*/
2192+
const print = (src, mode) =>
2193+
new SourceProcessor().process(src, { mode }).code;
2194+
2195+
it("prints nothing unless output is asked for", () => {
2196+
expect(new SourceProcessor().process(".a{color:red}")).toBeUndefined();
2197+
});
2198+
2199+
it("reads `minimize: true` as the `minify` mode it is shorthand for", () => {
2200+
const src = ".a{color:#ff0000;margin:1px 2px 1px 2px}";
2201+
expect(new SourceProcessor().process(src, { minimize: true }).code).toBe(
2202+
print(src, "minify")
2203+
);
2204+
});
2205+
2206+
it("beautifies without the transforms minifying applies", () => {
2207+
// Ugly is allowed — unindented, and top-level items still run together —
2208+
// but nothing the author wrote may be rewritten.
2209+
expect(
2210+
print(
2211+
"@media screen{.a{color:#ff0000;margin:1px 2px 1px 2px}}",
2212+
"beautify"
2213+
)
2214+
).toBe(
2215+
"@media screen {\n.a {\ncolor: #ff0000;\nmargin: 1px 2px 1px 2px;\n}\n}"
2216+
);
2217+
expect(
2218+
print("@media screen{.a{color:#ff0000;margin:1px 2px 1px 2px}}", "minify")
2219+
).toBe("@media screen{.a{color:red;margin:1px 2px}}");
2220+
});
2221+
2222+
it("keeps the same comments in both modes", () => {
2223+
// A license banner survives minifying, so it has to survive beautifying —
2224+
// otherwise the two modes disagree about what the stylesheet says.
2225+
expect(print("/*!keep*/.a{color:red}/* drop */", "beautify")).toBe(
2226+
"/*!keep*/.a {\ncolor: red;\n}"
2227+
);
2228+
expect(print("/*!keep*/.a{color:red}/* drop */", "minify")).toBe(
2229+
"/*!keep*/.a{color:red}"
2230+
);
2231+
});
2232+
2233+
it("does not emit a custom property's kept comment twice", () => {
2234+
// The value prints straight from source, comments and all, so beautifying
2235+
// has to claim them the way minifying does or they land again before the
2236+
// next top-level rule.
2237+
expect(print(".a{--x:1px /*!k*/ 2px}.b{color:red}", "beautify")).toBe(
2238+
".a {\n--x: 1px /*!k*/ 2px;\n}.b {\ncolor: red;\n}"
2239+
);
2240+
});
2241+
2242+
it("beautifies to something that minifies back the same", () => {
2243+
for (const src of [
2244+
"/*!k*/.a{color:#ff0000}",
2245+
"@media screen{.a{margin:1px 2px 1px 2px}.b{color:red}}",
2246+
".a{--x:1px /*!k*/ 2px}.b{content:'y'}",
2247+
"@supports (a:b){.a{&:hover{color:red}}}",
2248+
".a{transition:all 500ms}@import url(x.css);"
2249+
]) {
2250+
const minified = print(src, "minify");
2251+
expect(print(print(src, "beautify"), "minify")).toBe(minified);
2252+
}
2253+
});
2254+
});
2255+
21862256
describe("CssSyntax minify — the value transforms' rejection paths", () => {
21872257
/** @typedef {import("../lib/css/syntax").CssEnvironment} CssEnvironment */
21882258

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// cspell:ignore tailwind rgba
2+
import fs from "fs";
3+
import { createRequire } from "module";
4+
import { fileURLToPath } from "url";
5+
6+
const require = createRequire(import.meta.url);
7+
8+
/** @type {typeof import("../../../lib/css/syntax")} */
9+
const cssSyntax = require("../../../lib/css/syntax.js");
10+
11+
const { SourceProcessor } = cssSyntax;
12+
13+
// Printing is the other half of `css-parser-tailwind-unit`: the same walk, but
14+
// every node also serializes and the input->output source map is resolved.
15+
// A nested block that grows past the streaming threshold hands its children back
16+
// as they finish and prints them into the output there; one that stays small is
17+
// materialized and printed whole. Both paths are covered here, along with the
18+
// pieces only a streamed block reaches — a declaration a later one overrides
19+
// (emitted so that it can be taken back, and taken back), and an opener held
20+
// back so an empty block can still be dropped.
21+
//
22+
// Every fixture runs in all three modes the processor has. `walk` is what a
23+
// build that is not printing pays — parse and walk, no output; `minify` and
24+
// `beautify` are the same pass with the printer on, the first applying the value
25+
// and shorthand transforms and the second only re-serializing. No visitors are
26+
// registered, so `walk` is the floor all three share: `minify - walk` is what
27+
// printing and the map cost on that shape and `minify - beautify` is what the
28+
// transforms cost on top of serializing. Visitor cost is
29+
// `css-parser-tailwind-unit`'s.
30+
31+
// Real-world ~1.9 MiB minified stylesheet (Tailwind), shared with the
32+
// `css/large` configCase and the parser benchmark.
33+
const TAILWIND = fs.readFileSync(
34+
fileURLToPath(
35+
new URL("../../configCases/css/large/tailwind.min.css", import.meta.url)
36+
),
37+
"utf8"
38+
);
39+
40+
/** @type {[string, string][]} name, source */
41+
const FIXTURES = [
42+
["tailwind", TAILWIND],
43+
// One block big enough to stream, and many that are not: the same rules
44+
// either way, so the pair isolates what streaming a block costs against
45+
// buffering it.
46+
[
47+
"streamed block",
48+
(() => {
49+
let s = "@media (min-width:1px){";
50+
for (let i = 0; i < 5000; i++) {
51+
s += `.n-${i}{color:#0000ff;margin:1px 2px 3px 4px;padding:0}`;
52+
}
53+
return `${s}}`;
54+
})()
55+
],
56+
[
57+
"buffered blocks",
58+
(() => {
59+
let s = "";
60+
for (let i = 0; i < 200; i++) {
61+
s += `@media (min-width:${i}px){`;
62+
for (let j = 0; j < 25; j++) {
63+
s += `.b-${i}-${j}{color:#0000ff;margin:1px 2px 3px 4px;padding:0}`;
64+
}
65+
s += "}";
66+
}
67+
return s;
68+
})()
69+
],
70+
// One long run of top-level rules — no block frame at all, so this is the
71+
// per-rule cost on its own.
72+
[
73+
"flat rules",
74+
(() => {
75+
let s = "";
76+
for (let i = 0; i < 6000; i++) {
77+
s += `.f-${i}{color:#0000ff;margin:1px 2px 3px 4px;padding:0}`;
78+
}
79+
return s;
80+
})()
81+
],
82+
// Eight-deep at-rule nesting: one frame per open block, so depth is the axis
83+
// `flat rules` holds flat.
84+
[
85+
"deep nesting",
86+
(() => {
87+
let s = "";
88+
for (let i = 0; i < 400; i++) {
89+
for (let j = 0; j < 8; j++) s += `@media (min-width:${j}px){`;
90+
s += `.d-${i}{color:red}`;
91+
s += "}".repeat(8);
92+
}
93+
return s;
94+
})()
95+
],
96+
// CSS nesting: declarations interleaved with nested rules inside one streamed
97+
// block, so each declaration is emitted so that it can be taken back, and is
98+
// when a later one overrides it.
99+
[
100+
"retracted declarations",
101+
(() => {
102+
let s = ".outer{";
103+
for (let i = 0; i < 4000; i++) {
104+
s += `color:rgb(${i % 256} 0 0);.n-${i}{margin:1px 2px 3px 4px;padding:0}`;
105+
}
106+
return `${s}}`;
107+
})()
108+
],
109+
// Every rule prints to nothing, so a streamed block's held-back opener is
110+
// dropped rather than flushed — the whole stylesheet serializes to "".
111+
[
112+
"dropped empty blocks",
113+
(() => {
114+
let s = "@media (min-width:1px){";
115+
for (let i = 0; i < 20000; i++) s += `.e-${i}{}`;
116+
return `${s}}`;
117+
})()
118+
]
119+
];
120+
121+
/**
122+
* @param {import("tinybench").Bench} bench bench
123+
* @returns {void}
124+
*/
125+
export default (bench) => {
126+
for (const [name, source] of FIXTURES) {
127+
bench.add(
128+
`unit benchmark "css-printer-tailwind-unit", walk (${name})`,
129+
() => {
130+
new SourceProcessor().process(source);
131+
}
132+
);
133+
bench.add(
134+
`unit benchmark "css-printer-tailwind-unit", beautify (${name})`,
135+
() => {
136+
new SourceProcessor().process(source, {
137+
mode: "beautify",
138+
source: "in.css"
139+
});
140+
}
141+
);
142+
bench.add(
143+
`unit benchmark "css-printer-tailwind-unit", minify (${name})`,
144+
() => {
145+
new SourceProcessor().process(source, {
146+
minimize: true,
147+
source: "in.css"
148+
});
149+
}
150+
);
151+
}
152+
};

‎types.d.ts‎

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -26031,22 +26031,27 @@ declare abstract class SourceProcessorClass<
2603126031
): SourceProcessorClass<TPath, TNode, TProcessOptions>;
2603226032

2603326033
/**
26034-
* Parse `input` once and fire the visitors in source order. With `minimize`
26035-
* (and a printer supplied at construction) the same walk also prints — a
26034+
* Parse `input` once and fire the visitors in source order. Asking for output
26035+
* — `mode`, or `minimize: true` for the `"minify"` it is shorthand for — makes
26036+
* the same walk print, given a printer supplied at construction: a
2603626037
* {@link PrintContext} is created, each node's printer fires into it as the node
2603726038
* finishes, and the result is returned as `{ code, map }`: the serialized output
2603826039
* and its input->output source map, always, independent of the pipeline's own
26039-
* source-map setting (`source` / `content` name the map's input). Without
26040-
* `minimize` it only walks and returns `undefined`. A single parse — printing
26040+
* source-map setting (`source` / `content` name the map's input). Asking for
26041+
* none of it only walks and returns `undefined`. A single parse — printing
2604126042
* never re-parses; all configuration is per-call.
2604226043
*/
2604326044
process(
2604426045
input: string,
26045-
options: TProcessOptions & {
26046-
minimize: true;
26047-
source?: string;
26048-
content?: string;
26049-
}
26046+
options:
26047+
| (TProcessOptions & { minimize: true } & {
26048+
source?: string;
26049+
content?: string;
26050+
})
26051+
| (TProcessOptions & { mode: "minify" | "beautify" } & {
26052+
source?: string;
26053+
content?: string;
26054+
})
2605026055
): { code: string; map: SourceMap };
2605126056
process(input: string, options?: TProcessOptions): undefined;
2605226057
}

0 commit comments

Comments
 (0)
Sponsor
SponsoredKunjungi sekarang
Promo