CSS modules: webpack/css-loader-compatible scoped-name hashing#1237
CSS modules: webpack/css-loader-compatible scoped-name hashing#1237sciyoshi wants to merge 14 commits into
Conversation
Not necessarily opposed to adding these, but isn't the whole point of CSS modules that the scoped names are not predictable? |
Indeed. However, in the real world there are situations where things outside of one's control depend on these classnames. In our case specifically, we have an extensive automated test suite which relies on these classnames to select elements. While we're doing work in parallel to remove that reliance on these mangled names, this approach is more straightforward and unblocks us from switching to LightningCSS. |
| * standard-base64 digests like `[md4:hash:base64:5]` to produce valid CSS | ||
| * identifiers. Default: false. | ||
| */ | ||
| escapeScopedNames?: boolean |
There was a problem hiding this comment.
Why is this an option? Won't false produce invalid CSS? Shouldn't it default to true?
There was a problem hiding this comment.
Agreed. I removed escapeScopedNames as a public option instead of just flipping the default.
The post-process now runs automatically whenever the pattern uses the extended legacy webpack/css-loader-compatible hash syntax, e.g. [md4:hash:base64:5]. Bare [hash] still keeps Lightning CSS’s default hash path unchanged.
Added a regression test asserting extended/base64 hash patterns are escaped automatically. Done in baffdb0.
| }, | ||
| ) | ||
| .unwrap(); | ||
| let name = self.maybe_escape(body); |
There was a problem hiding this comment.
Can we keep this logic inside the or_insert_with so we only do it if the entry doesn't already exist?
There was a problem hiding this comment.
Good catch, fixed in d112c8d.
Looking at this again, the printer had already rendered the scoped name it writes to CSS, and then add_local rendered the same name again for the exports map. I changed add_local to take the printer’s already-computed scoped name and use or_insert_with, so the helper no longer repeats the pattern rendering work.
| }, | ||
| ) | ||
| .unwrap(); | ||
| let name = format!("--{}", self.maybe_escape(body)); |
There was a problem hiding this comment.
Fixed this one in the same commit, d112c8d.
write_dashed_ident now passes the already-computed scoped custom-property name into add_dashed, and add_dashed uses or_insert_with. While I was there, I also cleaned up the local reference_dashed path so it checks for an existing entry before rendering a new scoped name.
| /// rules: replace any char outside `[a-zA-Z0-9\-_]` (plus the latin-1+ unicode range | ||
| /// `U+00A0..=U+FFFF`) with `-`, then prefix `_` when the result starts with `-?[0-9]` | ||
| /// or `--` so the output remains a valid CSS identifier. | ||
| pub(crate) fn escape_scoped_name(s: &str) -> String { |
There was a problem hiding this comment.
The Printer already uses serialize_identifier / serialize_name in its write_ident method, which is what calls css_module.config.pattern.write. Those functions already escape the name, so I don't think we need to implement a custom escaping function here as well?
There was a problem hiding this comment.
These are doing different things, but I should have called that out. Added a doc comment in ea0243f.
serialize_identifier / serialize_name make a string valid CSS syntax by backslash-escaping at print time. escape_scoped_name rewrites the literal scoped name to match legacy css-loader/postcss-modules output.
For example, serializer escaping would make foo+bar printable as foo\+bar in CSS, but the JS exports map would still have the literal foo+bar. The compatibility post-process instead rewrites both sides to the same literal name, e.g. foo-bar. The printer still serializes the rewritten name afterward for general CSS safety.
| * Required to match css-loader/postcss-modules' per-(file, local) hashing. | ||
| * Default: false (lightningcss hashes once per file). | ||
| */ | ||
| hashLocalName?: boolean, |
There was a problem hiding this comment.
Lightningcss already supports [contenthash] which does this. Seems confusing to have two ways of doing the same thing?
There was a problem hiding this comment.
I see why these look related, but they’re solving different problems. I clarified the docs in 4f76fcc.
[content-hash] is derived from file contents, so every export in a given module sees the same content hash. hashLocalName changes the input to the [hash] segment to include the local export name: <prefix><path>\0<local>. That means each export in the same file can get a different hash, which is what css-loader/postcss-modules do.
These will back new HashAlgorithm options in the next commit, enabling webpack/css-loader-compatible scoped-name hashes.
…tions helper Introduces a parameterized hash-and-encode helper for CSS module names, with md4 and xxhash64 algorithms and standard-base64 / hex digests. The default [hash] / [content-hash] code path is unchanged; this commit only adds the new types and helper. Pattern parsing and Segment wiring follow in subsequent commits. Tested against ground-truth output captured from a Vite/postcss-modules build to confirm md4+base64+truncate behaves identically to webpack's loader-utils.getHashDigest.
…nd length Segment::Hash becomes a struct variant carrying optional HashAlgorithm, DigestType, and length fields. When all are None the legacy siphash + custom-base64 path is preserved (existing test snapshots unchanged); when any is Some, hashing dispatches through hash_with_options. Hash computation moves from CssModule::new to Pattern::write so each segment can apply its own options. CssModule::hashes is renamed to hash_inputs and now stores the raw source-relative path string instead of a precomputed hash. Pattern::write computes the hash per segment from this raw input. Segment::ContentHash remains a unit variant in this commit; per-segment options on [content-hash] would require additional plumbing through the bundler's content-hash precomputation and is left for a follow-up.
…] syntax The parser now recognizes webpack-compatible scoped-name hash placeholders. Each of algo, digest, and length is optional; the position of the literal \`hash\` keyword distinguishes algo (before) from digest+length (after). Examples: [hash] -> legacy lightningcss hash, unchanged [hash:base64] -> default algo, base64 digest, full length [hash:5] -> default algo, hex digest, 5 chars [md4:hash:base64:5] -> md4 + base64 + 5 chars (matches webpack) [xxhash64:hash:hex:12] -> xxhash64 + hex + 12 chars Bare \`[hash]\` keeps the legacy code path so existing snapshots remain byte- identical. Recognized algorithms: md4, xxhash64. Recognized digests: hex, base64. Parsing is case-insensitive. Unknown algos/digests, missing \`hash\` keyword, and extra parts before \`hash\` all return UnknownPlaceholder. Tests cover each form, case-insensitivity, rejection of bad input, and a round-trip integration test against ground-truth output captured from a Vite/postcss-modules build (digest only; the css-loader content composition and post-processing pipeline live downstream of this commit).
Adds a hash_prefix field to css_modules::Config that prepends a string to every hash input. This matches Vite/postcss-modules' hashPrefix option and, when set to '\x00\x00\x00\x00', reproduces css-loader's tier-0 salt input byte-for-byte — a necessary piece for Vite-to-webpack scoped-name parity. Default is None, leaving lightningcss output unchanged for existing users. Per-local hash composition and post-processing (also required for full Vite parity) follow in subsequent commits.
Adds a hash_local_name flag to css_modules::Config. When true, the local class/ident name is appended to the hash input separated by a NUL byte: <prefix><relative-path>\0<local>. This matches the per-local hashing done by css-loader and postcss-modules — without it every export from a given file shares one hash. Composition happens via a new CssModule::hash_input_for helper, called from each Pattern::write call site (add_local, add_dashed, reference, reference_dashed, handle_composes, plus printer's write_ident and write_dashed_ident). Each call allocates a small string when hash_local_name is enabled; otherwise it returns Cow::Borrowed against the existing buffer. Default is false, preserving lightningcss's per-file hashing for users who don't opt in. Combined with hash_prefix from the previous commit, the bytes fed to the hash now match css-loader's input bytes exactly. A test asserts that Md4+Base64+5 over the composed input reproduces the captured Vite digest 'YTbdH' for src/styles/Alpha.module.css#foo.
Adds an escape_scoped_names config flag and an escape_scoped_name helper that mirrors css-loader/postcss-modules' genericNames post-processing: s.replace(/[^a-zA-Z0-9\\-_\\u00A0-\\uFFFF]/g, '-') .replace(/^((-?[0-9])|--)/, '_\$1') The pipeline runs after Pattern::write_to_string at every scoped-name call site (CssModule::add_local/add_dashed/reference/reference_dashed/handle_composes plus Printer::write_ident/write_dashed_ident). For dashed (custom property) idents the leading '--' is excluded — the rendered pattern is escaped without the prefix and '--' is prepended afterwards, so '--' on a custom property never triggers the leading-double-dash rule. write_to_string is also promoted from private to pub(crate) so the printer can call it directly (replacing the previous streaming Pattern::write + serialize_identifier/serialize_name closure pattern). Output is unchanged for the legacy (no-escape) path: serialize_identifier on a full string produces the same bytes as serialize_identifier on the head + serialize_name on the tail. Eight unit tests cover the four post-process branches captured from the real Vite/postcss-modules build (digit, +, /, leading -digit, leading --, leading -letter unchanged, clean input, unicode above U+00A0).
Integrates the four hashing features (md4/xxhash64 algos, hash_prefix, hash_local_name, escape_scoped_names) and asserts the rendered scoped names match a Vite/postcss-modules build's output byte-for-byte, using the generateScopedName='[name]__[local]__[md4:hash:base64:5]' and hashPrefix='\\0\\0\\0\\0' configuration captured from a real project. Twelve cases across two source files cover every post-processing branch: clean digest, +/-/-cleanup-mid-name, leading-digit prefix on rendered output, leading -digit prefix, leading -- prefix, and leading -letter (no prefix). If this test breaks, lightningcss has diverged from webpack/css-loader hashing and Vite migrations relying on hash stability will produce different class names than they did pre-migration.
…to JS Threads the three Fellow-fork CssModulesConfig fields through the napi binding so they're reachable from the @fellowapp/lightningcss JS API: - napi::CssModulesConfig gains hashPrefix, hashLocalName, escapeScopedNames (camelCase via serde rename_all on the parent struct). - Both compile() and bundle() Config initializations now wire these through to lightningcss::css_modules::Config. - node/index.d.ts CSSModulesConfig type extended with the same three optional fields, with rustdoc-equivalent JSDoc explaining each. Without this commit lightningcss-napi fails to compile against the new lightningcss::css_modules::Config struct (E0063 missing fields). Verified locally with cargo build -p lightningcss-napi --release.
095d5b9 to
4f76fcc
Compare
|
Thanks so much for taking the time to review this; much appreciated, especially given the size of the diff. I pushed a few small follow-up commits addressing the inline comments:
I also rebased this on the current master branch to fix the merge conflict around CSS module pattern ownership (master moved Separately: full webpack/css-loader parity was the bar we wanted for our own migration. I completely understand if that scope is more than you want to maintain long-term. If any of this feels like too much new config or compatibility behavior to maintain, we’re happy to keep it in a fork for our internal use and you can take only the pieces that feel useful to LightningCSS, or none of it. No pressure to merge as-is; your call, and thanks again for the thoughtful review. |
Adds opt-in support for producing CSS-modules scoped names byte-identical to webpack/css-loader and Vite-via-postcss-modules output. Default
[hash]behavior is unchanged.Reverse-engineered the algorithm from
loader-utils.getHashDigest+loader-utils.interpolateName+genericNamesagainst ground-truth output captured from a real Vite/postcss-modules build. End-to-end byte-parity verified across 12 captured class names exercising every code path (including the leading-digit / leading--digit/ leading---prefix branches).Closes #503, #636, #660, #351. Partially addresses #491 (
[hash]truncation only).New pattern syntax
Webpack-style
[<algo>:hash:<digest>:<length>]placeholders. Each part is independently optional; the position of the literalhashkeyword distinguishes algo (before) from digest+length (after):[hash][hash:base64][hash:5][md4:hash:base64:5][xxhash64:hash:hex:12]Bare
[hash]parses to all-Noneand stays on the legacy code path — existing snapshots are byte-identical. Parsing is case-insensitive. Unknown algo, unknown digest, missinghashkeyword, and extra parts beforehashall returnPatternParseError::UnknownPlaceholder.Segment::ContentHashis intentionally left as a unit variant in this PR — same options on[content-hash]would require plumbing through the bundler's content-hash precomputation and is a follow-up.New
ConfigfieldsThree new opt-in fields on
css_modules::Config, all defaulting to no-op so existing output is unchanged:hash_prefix: Option<Cow<'i, str>>None"\x00\x00\x00\x00"reproduces css-loader's tier-0 salt input byte-for-byte.hash_local_name: boolfalseescape_scoped_names: boolfalseloader-utils'genericNamesrules: replace any char outside[a-zA-Z0-9\-_ -�]with-, then prefix_when the result starts with-?[0-9]or--. Required for standard-base64 digests like[md4:hash:base64:5]to produce valid CSS identifiers.Each is independently meaningful, but for css-loader byte-parity they travel together with a webpack-style pattern. The byte-parity end-to-end test in
tests::css_modules_webpack_parityexercises all four together against the captured Vite output.Public API additions
pub enum HashAlgorithm { Md4, Xxhash64 }pub enum DigestType { Hex, Base64 }Segment::Hashbecomes a struct variant:{ algo: Option<HashAlgorithm>, digest: Option<DigestType>, length: Option<usize> }Config { hash_prefix, hash_local_name, escape_scoped_names }pub(crate) fn hash_with_options(input: &[u8], algo, digest, length) -> String(used by both the new pattern path and the byte-parity test)JS API additions
CSSModulesConfiginnode/index.d.tsandnapi::CssModulesConfiggain three optional camelCase fields (hashPrefix,hashLocalName,escapeScopedNames) that thread through to the RustConfig. Defaults preserve existing JS-side behavior.This is what makes the feature reachable from Vite via
css.lightningcss.cssModules.Why
CSS-modules ecosystem hashing — used by webpack/css-loader, postcss-modules, and Vite-with-PostCSS — is genuinely opinionated, but it's also the de facto convention. Lightningcss's existing
[hash]is more lightweight (per-file, custom alphabet, no salt, no post-processing) which is fine for a fresh project, but doesn't let anyone migrate from PostCSS to lightningcss without changing every scoped name in the build. That's a hard sell for production codebases.The four pieces here are deliberately independent flags rather than a single
compat_modebundle, on the principle that the API should be honest about which knob does what. A user who only needs salting can enablehash_prefixwithout buying into per-local hashing or escaping. For full webpack parity all four go on at once.Backward compatibility
[hash]is unchanged. The legacy code path is preserved whenalgo,digest, andlengthare allNone.Configfields default toNone/false. No existing user sees different output until they opt in.cargo test --no-default-featuresshows only the pre-existing platform-dependenttests::test_dependenciesSipHash snapshot failure, unrelated to this work).Test plan
cargo test --no-default-features— 135 passtests::css_modules_webpack_parity) reproduces 12 captured Vite/postcss-modules class names exactly. Cases cover every post-process branch:Alpha-module__foo__YTbdH)+and/cleanup (Alpha-module__cls_4__LOY-5,Alpha-module__cls_48__-ta-0)_2kP0r__…)-digitprefix (digest+0F5/→-0F5-→_-0F5-__…)--prefix (digest//GdO→--GdO→_--GdO__…)-letter(no prefix)css_modules::testscovering parser forms, rejection cases, hash dispatch, and each post-process branch in isolationCommits
chore(css-modules): add md4 and xxhash-rust dependenciesfeat(css-modules): add HashAlgorithm and DigestType with hash_with_options helperfeat(css-modules): extend Segment::Hash with optional algo, digest, and lengthfeat(css-modules): parse webpack-style [<algo>:hash:<digest>:<length>] syntaxfeat(css-modules): add Config::hash_prefix for hash input saltingfeat(css-modules): add Config::hash_local_name for per-local hashingfeat(css-modules): add Config::escape_scoped_names post-process pipelinetest(css-modules): add Vite scoped-name byte-parity end-to-end testfeat(napi): expose hash_prefix, hash_local_name, escape_scoped_names to JS