From f641e465bb5768c32daec75fc406a718a1753dec Mon Sep 17 00:00:00 2001 From: Samuel Cormier-Iijima Date: Mon, 4 May 2026 15:10:32 -0400 Subject: [PATCH 01/10] chore(css-modules): add md4 and xxhash-rust dependencies These will back new HashAlgorithm options in the next commit, enabling webpack/css-loader-compatible scoped-name hashes. --- Cargo.lock | 67 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ Cargo.toml | 2 ++ 2 files changed, 69 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index a338b9be..bb5be2b1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -141,6 +141,15 @@ dependencies = [ "wyz 0.5.1", ] +[[package]] +name = "block-buffer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +dependencies = [ + "hybrid-array", +] + [[package]] name = "browserslist-data" version = "0.1.1" @@ -298,6 +307,12 @@ dependencies = [ "os_str_bytes", ] +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "const-str" version = "0.3.2" @@ -367,6 +382,15 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crypto-common" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +dependencies = [ + "hybrid-array", +] + [[package]] name = "cssparser" version = "0.33.0" @@ -450,6 +474,17 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer", + "const-oid", + "crypto-common", +] + [[package]] name = "doc-comment" version = "0.3.3" @@ -609,6 +644,15 @@ dependencies = [ "libc", ] +[[package]] +name = "hybrid-array" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5" +dependencies = [ + "typenum", +] + [[package]] name = "iana-time-zone" version = "0.1.61" @@ -775,6 +819,7 @@ dependencies = [ "jemallocator", "lazy_static", "lightningcss-derive", + "md4", "parcel_selectors", "parcel_sourcemap", "pastey", @@ -788,6 +833,7 @@ dependencies = [ "serde_json", "smallvec", "static-self", + "xxhash-rust", ] [[package]] @@ -866,6 +912,15 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "md4" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd76fb0fd6b2e4be62a73f8e0858ca97f81babcb1af322dcaca196f735f17f80" +dependencies = [ + "digest", +] + [[package]] name = "memchr" version = "2.7.4" @@ -1655,6 +1710,12 @@ dependencies = [ "serde", ] +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + [[package]] name = "unicode-ident" version = "1.0.14" @@ -1910,6 +1971,12 @@ dependencies = [ "tap", ] +[[package]] +name = "xxhash-rust" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" + [[package]] name = "yansi" version = "1.0.1" diff --git a/Cargo.toml b/Cargo.toml index ef0b3488..bf6cf29d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,6 +74,8 @@ lazy_static = "1.4.0" const-str = "0.3.1" pathdiff = "0.2.1" ahash = "0.8.7" +md4 = "0.11" +xxhash-rust = { version = "0.8", features = ["xxh64"] } pastey = "0.1.0" indexmap = { version = "2.2.6", features = ["serde"] } # CLI deps From 2cb0522db3ff2237330355a869992a8bd2823020 Mon Sep 17 00:00:00 2001 From: Samuel Cormier-Iijima Date: Mon, 4 May 2026 15:12:37 -0400 Subject: [PATCH 02/10] feat(css-modules): add HashAlgorithm and DigestType with hash_with_options 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. --- src/css_modules.rs | 116 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 115 insertions(+), 1 deletion(-) diff --git a/src/css_modules.rs b/src/css_modules.rs index ce7008df..f5d11fb1 100644 --- a/src/css_modules.rs +++ b/src/css_modules.rs @@ -11,8 +11,9 @@ use crate::error::PrinterErrorKind; use crate::properties::css_modules::{Composes, Specifier}; use crate::selector::SelectorList; -use data_encoding::{Encoding, Specification}; +use data_encoding::{Encoding, Specification, BASE64_NOPAD}; use lazy_static::lazy_static; +use md4::{Digest as Md4Digest, Md4}; use pathdiff::diff_paths; #[cfg(any(feature = "serde", feature = "nodejs"))] use serde::Serialize; @@ -23,6 +24,7 @@ use std::collections::HashMap; use std::fmt::Write; use std::hash::{Hash, Hasher}; use std::path::Path; +use xxhash_rust::xxh64::xxh64; /// Configuration for CSS modules. #[derive(Clone, Debug)] @@ -546,3 +548,115 @@ pub(crate) fn hash(s: &str, at_start: bool) -> String { hash } } + +/// The algorithm used to hash a CSS module name input. +/// +/// Used in [Segment::Hash](Segment::Hash) and [Segment::ContentHash](Segment::ContentHash) to +/// override the default lightningcss hash algorithm. When unspecified, the default algorithm +/// (an internal SipHash variant) is used, which preserves byte compatibility with previous +/// lightningcss output. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(any(feature = "serde", feature = "nodejs"), derive(Serialize, serde::Deserialize))] +#[cfg_attr(any(feature = "serde", feature = "nodejs"), serde(rename_all = "lowercase"))] +pub enum HashAlgorithm { + /// MD4. Matches webpack's `md4` hash function for css-loader/postcss-modules parity. + Md4, + /// xxHash64. Matches webpack's default `xxhash64` hash function. + Xxhash64, +} + +/// The digest encoding used when stringifying a hash for inclusion in a CSS module name. +#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(any(feature = "serde", feature = "nodejs"), derive(Serialize, serde::Deserialize))] +#[cfg_attr(any(feature = "serde", feature = "nodejs"), serde(rename_all = "lowercase"))] +pub enum DigestType { + /// Hexadecimal (lowercase, `[0-9a-f]`). + Hex, + /// Standard base64 alphabet (`[A-Za-z0-9+/]`, no padding). Matches Node's `hash.digest("base64")` + /// output without the trailing `=`. Use this for css-loader/postcss-modules parity. + Base64, +} + +/// Compute the hash of `input` using `algo`, encode it with `digest`, and truncate to `length` +/// bytes (UTF-8) if specified. The output is suitable for inclusion in a scoped CSS module name. +/// +/// `length` truncates the encoded string, not the raw digest, matching webpack's +/// `loader-utils.getHashDigest(content, algo, digest, maxLength)`. +pub(crate) fn hash_with_options( + input: &[u8], + algo: HashAlgorithm, + digest: DigestType, + length: Option, +) -> String { + let raw: Vec = match algo { + HashAlgorithm::Md4 => Md4::digest(input).to_vec(), + HashAlgorithm::Xxhash64 => xxh64(input, 0).to_be_bytes().to_vec(), + }; + let encoded = match digest { + DigestType::Hex => { + let mut s = String::with_capacity(raw.len() * 2); + for b in &raw { + let _ = write!(s, "{:02x}", b); + } + s + } + DigestType::Base64 => BASE64_NOPAD.encode(&raw), + }; + match length { + Some(n) if n < encoded.len() => encoded[..n].to_string(), + _ => encoded, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn md4_base64_truncated_matches_webpack() { + // Reproduces `loader-utils.getHashDigest(content, "md4", "base64", 5)` from a captured + // Vite/postcss-modules build with hashPrefix="\0\0\0\0", file="src/styles/Alpha.module.css", + // local="foo". + let input = b"\x00\x00\x00\x00src/styles/Alpha.module.css\x00foo"; + let got = hash_with_options(input, HashAlgorithm::Md4, DigestType::Base64, Some(5)); + assert_eq!(got, "YTbdH"); + } + + #[test] + fn md4_base64_truncated_matches_webpack_with_slash_in_digest() { + let input = b"\x00\x00\x00\x00src/styles/Alpha.module.css\x00cls_4"; + let got = hash_with_options(input, HashAlgorithm::Md4, DigestType::Base64, Some(5)); + // Note: raw digest contains `/` (standard base64 alphabet); post-processing of `/` -> `-` + // happens later (in the fork). Here we only verify the digest itself. + assert_eq!(got, "LOY/5"); + } + + #[test] + fn md4_hex_full() { + let got = hash_with_options(b"abc", HashAlgorithm::Md4, DigestType::Hex, None); + assert_eq!(got, "a448017aaf21d8525fc10ae87aa6729d"); + } + + #[test] + fn xxhash64_hex() { + // xxh64 of "abc" with seed 0 = 0x44bc2cf5ad770999 + let got = hash_with_options(b"abc", HashAlgorithm::Xxhash64, DigestType::Hex, None); + assert_eq!(got, "44bc2cf5ad770999"); + } + + #[test] + fn length_truncates_encoded_not_raw() { + let full = hash_with_options(b"abc", HashAlgorithm::Md4, DigestType::Hex, None); + let trunc = hash_with_options(b"abc", HashAlgorithm::Md4, DigestType::Hex, Some(8)); + assert_eq!(trunc, &full[..8]); + } + + #[test] + fn base64_uses_standard_alphabet() { + // Inputs picked to make the digest contain both `+` and `/`, confirming the standard + // base64 alphabet (post-processing happens later in the css-loader-compat layer). + let input = b"\x00\x00\x00\x00src/styles/Alpha.module.css\x00cls_48"; + let got = hash_with_options(input, HashAlgorithm::Md4, DigestType::Base64, Some(5)); + assert_eq!(got, "/ta+0"); + } +} From f89aa6129e5e3d38ca4f6fec3c67861b13f3b28a Mon Sep 17 00:00:00 2001 From: Samuel Cormier-Iijima Date: Mon, 4 May 2026 15:22:22 -0400 Subject: [PATCH 03/10] feat(css-modules): extend Segment::Hash with optional algo, digest, and 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. --- src/css_modules.rs | 94 +++++++++++++++++++++++++++++++++------------- src/printer.rs | 4 +- 2 files changed, 70 insertions(+), 28 deletions(-) diff --git a/src/css_modules.rs b/src/css_modules.rs index f5d11fb1..f28851dd 100644 --- a/src/css_modules.rs +++ b/src/css_modules.rs @@ -74,7 +74,15 @@ pub struct Pattern<'i> { impl<'i> Default for Pattern<'i> { fn default() -> Self { Pattern { - segments: smallvec![Segment::Hash, Segment::Literal("_"), Segment::Local], + segments: smallvec![ + Segment::Hash { + algo: None, + digest: None, + length: None, + }, + Segment::Literal("_"), + Segment::Local + ], } } } @@ -115,7 +123,11 @@ impl<'i> Pattern<'i> { let segment = match &input[0..=end_idx] { "[name]" => Segment::Name, "[local]" => Segment::Local, - "[hash]" => Segment::Hash, + "[hash]" => Segment::Hash { + algo: None, + digest: None, + length: None, + }, "[content-hash]" => Segment::ContentHash, s => return Err(PatternParseError::UnknownPlaceholder(s.into(), start_idx)), }; @@ -142,9 +154,15 @@ impl<'i> Pattern<'i> { } /// Write the substituted pattern to a destination. + /// + /// `hash_input` is the raw string used as input to compute hashes for `[hash]` segments + /// (typically the project-root-relative source path). For legacy `[hash]` segments (no + /// algo/digest/length specified) the existing siphash + custom-base64 algorithm is used, + /// preserving byte compatibility with previous lightningcss output. Segments with any + /// option specified use [hash_with_options](hash_with_options). pub fn write( &self, - hash: &str, + hash_input: &str, path: &Path, local: &str, content_hash: &str, @@ -153,7 +171,7 @@ impl<'i> Pattern<'i> { where W: FnMut(&str) -> Result<(), E>, { - for segment in &self.segments { + for (idx, segment) in self.segments.iter().enumerate() { match segment { Segment::Literal(s) => { write(s)?; @@ -169,8 +187,19 @@ impl<'i> Pattern<'i> { Segment::Local => { write(local)?; } - Segment::Hash => { - write(hash)?; + Segment::Hash { algo, digest, length } => { + if algo.is_none() && digest.is_none() && length.is_none() { + let h = hash(hash_input, idx == 0); + write(&h)?; + } else { + let h = hash_with_options( + hash_input.as_bytes(), + algo.unwrap_or(HashAlgorithm::Xxhash64), + digest.unwrap_or(DigestType::Hex), + *length, + ); + write(&h)?; + } } Segment::ContentHash => { write(content_hash)?; @@ -184,12 +213,12 @@ impl<'i> Pattern<'i> { fn write_to_string( &self, mut res: String, - hash: &str, + hash_input: &str, path: &Path, local: &str, content_hash: &str, ) -> Result { - self.write(hash, path, local, content_hash, |s| res.write_str(s))?; + self.write(hash_input, path, local, content_hash, |s| res.write_str(s))?; Ok(res) } } @@ -206,7 +235,20 @@ pub enum Segment<'i> { /// The original class name. Local, /// A hash of the file name. - Hash, + /// + /// When all of `algo`, `digest`, and `length` are `None`, the legacy lightningcss + /// hash (siphash + custom base64) is used and the result is prefixed with `_` if + /// it starts with a digit and the segment is at the start of the pattern. When any + /// is `Some`, [hash_with_options](hash_with_options) is used with `Xxhash64` as the + /// default algorithm and `Hex` as the default digest. + Hash { + /// The hash algorithm to use, or `None` for the legacy default. + algo: Option, + /// The digest encoding, or `None` to default to `Hex` when any option is set. + digest: Option, + /// The maximum encoded length in characters, or `None` for full digest. + length: Option, + }, /// A hash of the file contents. ContentHash, } @@ -272,7 +314,10 @@ lazy_static! { pub(crate) struct CssModule<'a, 'b, 'c> { pub config: &'a Config<'b>, pub sources: Vec<&'c Path>, - pub hashes: Vec, + /// Raw input strings used to compute `[hash]` segments. One per source, holding the + /// project-root-relative path (or the raw path when no project_root is set). Hashing + /// happens at write time inside [Pattern::write] so per-segment options can apply. + pub hash_inputs: Vec, pub content_hashes: &'a Option>, pub exports_by_source_index: Vec, pub references: &'a mut HashMap, @@ -288,7 +333,7 @@ impl<'a, 'b, 'c> CssModule<'a, 'b, 'c> { ) -> Self { let project_root = project_root.map(|p| Path::new(p)); let sources: Vec<&Path> = sources.iter().map(|filename| Path::new(filename)).collect(); - let hashes = sources + let hash_inputs = sources .iter() .map(|path| { // Make paths relative to project root so hashes are stable. @@ -298,17 +343,14 @@ impl<'a, 'b, 'c> CssModule<'a, 'b, 'c> { } _ => Cow::Borrowed(*path), }; - hash( - &source.to_string_lossy(), - matches!(config.pattern.segments[0], Segment::Hash), - ) + source.to_string_lossy().into_owned() }) .collect(); Self { config, exports_by_source_index: sources.iter().map(|_| HashMap::new()).collect(), sources, - hashes, + hash_inputs, content_hashes, references, } @@ -323,7 +365,7 @@ impl<'a, 'b, 'c> CssModule<'a, 'b, 'c> { .pattern .write_to_string( String::new(), - &self.hashes[source_index as usize], + &self.hash_inputs[source_index as usize], &self.sources[source_index as usize], local, if let Some(content_hashes) = &self.content_hashes { @@ -347,7 +389,7 @@ impl<'a, 'b, 'c> CssModule<'a, 'b, 'c> { .pattern .write_to_string( "--".into(), - &self.hashes[source_index as usize], + &self.hash_inputs[source_index as usize], &self.sources[source_index as usize], &local[2..], if let Some(content_hashes) = &self.content_hashes { @@ -374,7 +416,7 @@ impl<'a, 'b, 'c> CssModule<'a, 'b, 'c> { .pattern .write_to_string( String::new(), - &self.hashes[source_index as usize], + &self.hash_inputs[source_index as usize], &self.sources[source_index as usize], name, if let Some(content_hashes) = &self.content_hashes { @@ -408,7 +450,7 @@ impl<'a, 'b, 'c> CssModule<'a, 'b, 'c> { .pattern .write_to_string( String::new(), - &self.hashes[*source_index as usize], + &self.hash_inputs[*source_index as usize], &self.sources[*source_index as usize], &name[2..], if let Some(content_hashes) = &self.content_hashes { @@ -433,7 +475,7 @@ impl<'a, 'b, 'c> CssModule<'a, 'b, 'c> { .pattern .write_to_string( "--".into(), - &self.hashes[source_index as usize], + &self.hash_inputs[source_index as usize], &self.sources[source_index as usize], &name[2..], if let Some(content_hashes) = &self.content_hashes { @@ -452,10 +494,10 @@ impl<'a, 'b, 'c> CssModule<'a, 'b, 'c> { } }; - let hash = hash( - &format!("{}_{}_{}", self.hashes[source_index as usize], name, key), - false, - ); + // Reuse the legacy filename hash as a stable short id here, preserving + // backward-compatible output for dashed (custom property) cross-file references. + let source_id = hash(&self.hash_inputs[source_index as usize], false); + let hash = hash(&format!("{}_{}_{}", source_id, name, key), false); let name = format!("--{}", hash); self.references.insert(name.clone(), reference); @@ -480,7 +522,7 @@ impl<'a, 'b, 'c> CssModule<'a, 'b, 'c> { .pattern .write_to_string( String::new(), - &self.hashes[source_index as usize], + &self.hash_inputs[source_index as usize], &self.sources[source_index as usize], name.0.as_ref(), if let Some(content_hashes) = &self.content_hashes { diff --git a/src/printer.rs b/src/printer.rs index b231fbec..ce24ce44 100644 --- a/src/printer.rs +++ b/src/printer.rs @@ -293,7 +293,7 @@ impl<'a, 'b, 'c, W: std::fmt::Write + Sized> Printer<'a, 'b, 'c, W> { let dest = &mut self.dest; let mut first = true; css_module.config.pattern.write( - &css_module.hashes[self.loc.source_index as usize], + &css_module.hash_inputs[self.loc.source_index as usize], &css_module.sources[self.loc.source_index as usize], ident, if let Some(content_hashes) = &css_module.content_hashes { @@ -328,7 +328,7 @@ impl<'a, 'b, 'c, W: std::fmt::Write + Sized> Printer<'a, 'b, 'c, W> { Some(css_module) if css_module.config.dashed_idents => { let dest = &mut self.dest; css_module.config.pattern.write( - &css_module.hashes[self.loc.source_index as usize], + &css_module.hash_inputs[self.loc.source_index as usize], &css_module.sources[self.loc.source_index as usize], &ident[2..], if let Some(content_hashes) = &css_module.content_hashes { From 56d45678da9e67a74a6f02cd979a9755204bdfd6 Mon Sep 17 00:00:00 2001 From: Samuel Cormier-Iijima Date: Mon, 4 May 2026 15:24:18 -0400 Subject: [PATCH 04/10] feat(css-modules): parse webpack-style [:hash::] 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). --- src/css_modules.rs | 172 +++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 165 insertions(+), 7 deletions(-) diff --git a/src/css_modules.rs b/src/css_modules.rs index f28851dd..9d0e4a62 100644 --- a/src/css_modules.rs +++ b/src/css_modules.rs @@ -114,22 +114,26 @@ impl std::error::Error for PatternParseError {} impl<'i> Pattern<'i> { /// Parse a pattern from a string. + /// + /// Supported placeholders are: + /// - `[name]`, `[local]`, `[content-hash]`, `[hash]` + /// - `[:hash::]` (webpack-compatible). Any of `algo`, `digest`, + /// and `length` can be omitted, e.g. `[hash:base64:5]`, `[md4:hash]`, `[hash:8]`. + /// Recognized algorithms: `md4`, `xxhash64`. Recognized digests: `hex`, `base64`. + /// When the `hash` keyword is bare (`[hash]`) the legacy lightningcss hash is used; + /// otherwise [hash_with_options](hash_with_options) applies. pub fn parse(mut input: &'i str) -> Result { let mut segments = SmallVec::new(); let mut start_idx: usize = 0; while !input.is_empty() { if input.starts_with('[') { if let Some(end_idx) = input.find(']') { - let segment = match &input[0..=end_idx] { + let raw = &input[0..=end_idx]; + let segment = match raw { "[name]" => Segment::Name, "[local]" => Segment::Local, - "[hash]" => Segment::Hash { - algo: None, - digest: None, - length: None, - }, "[content-hash]" => Segment::ContentHash, - s => return Err(PatternParseError::UnknownPlaceholder(s.into(), start_idx)), + _ => Self::parse_hash_segment(raw, start_idx)?, }; segments.push(segment); start_idx += end_idx + 1; @@ -148,6 +152,58 @@ impl<'i> Pattern<'i> { Ok(Pattern { segments }) } + /// Parse a `[hash]` placeholder, including webpack-style `[:hash::]`. + /// `raw` includes the surrounding brackets. Returns an error for any other placeholder. + fn parse_hash_segment(raw: &str, start_idx: usize) -> Result, PatternParseError> { + let inner = &raw[1..raw.len() - 1]; + let parts: Vec<&str> = inner.split(':').collect(); + let unknown = || PatternParseError::UnknownPlaceholder(raw.into(), start_idx); + let hash_pos = parts + .iter() + .position(|p| p.eq_ignore_ascii_case("hash")) + .ok_or_else(unknown)?; + if hash_pos > 1 { + // At most one part may precede `hash` (the algo). + return Err(unknown()); + } + let algo = if hash_pos == 1 { + Some(match parts[0].to_ascii_lowercase().as_str() { + "md4" => HashAlgorithm::Md4, + "xxhash64" => HashAlgorithm::Xxhash64, + _ => return Err(unknown()), + }) + } else { + None + }; + let after = &parts[hash_pos + 1..]; + let (digest, length) = match after { + [] => (None, None), + [a] => { + if let Ok(n) = a.parse::() { + (None, Some(n)) + } else { + (Some(parse_digest(a).ok_or_else(unknown)?), None) + } + } + [a, b] => { + let d = parse_digest(a).ok_or_else(unknown)?; + let n = b.parse::().map_err(|_| unknown())?; + (Some(d), Some(n)) + } + _ => return Err(unknown()), + }; + if algo.is_none() && digest.is_none() && length.is_none() { + // Bare `[hash]` keeps the legacy code path. + Ok(Segment::Hash { + algo: None, + digest: None, + length: None, + }) + } else { + Ok(Segment::Hash { algo, digest, length }) + } + } + /// Whether the pattern contains any `[content-hash]` segments. pub fn has_content_hash(&self) -> bool { self.segments.iter().any(|s| matches!(s, Segment::ContentHash)) @@ -607,6 +663,14 @@ pub enum HashAlgorithm { Xxhash64, } +fn parse_digest(s: &str) -> Option { + match s.to_ascii_lowercase().as_str() { + "hex" => Some(DigestType::Hex), + "base64" => Some(DigestType::Base64), + _ => None, + } +} + /// The digest encoding used when stringifying a hash for inclusion in a CSS module name. #[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)] #[cfg_attr(any(feature = "serde", feature = "nodejs"), derive(Serialize, serde::Deserialize))] @@ -701,4 +765,98 @@ mod tests { let got = hash_with_options(input, HashAlgorithm::Md4, DigestType::Base64, Some(5)); assert_eq!(got, "/ta+0"); } + + fn first_hash<'a, 'i>(p: &'a Pattern<'i>) -> &'a Segment<'i> { + p.segments + .iter() + .find(|s| matches!(s, Segment::Hash { .. })) + .unwrap() + } + + fn parse_hash(s: &str) -> (Option, Option, Option) { + let p = Pattern::parse(s).unwrap(); + match first_hash(&p) { + Segment::Hash { algo, digest, length } => (*algo, *digest, *length), + _ => unreachable!(), + } + } + + #[test] + fn parse_bare_hash_keeps_legacy() { + let p = Pattern::parse("[hash]").unwrap(); + assert!(matches!( + first_hash(&p), + Segment::Hash { + algo: None, + digest: None, + length: None + } + )); + } + + #[test] + fn parse_full_webpack_pattern() { + assert_eq!( + parse_hash("[md4:hash:base64:5]"), + (Some(HashAlgorithm::Md4), Some(DigestType::Base64), Some(5)) + ); + assert_eq!( + parse_hash("[xxhash64:hash:hex:12]"), + (Some(HashAlgorithm::Xxhash64), Some(DigestType::Hex), Some(12)) + ); + } + + #[test] + fn parse_omitted_fields() { + assert_eq!(parse_hash("[hash:base64]"), (None, Some(DigestType::Base64), None)); + assert_eq!(parse_hash("[hash:5]"), (None, None, Some(5))); + assert_eq!(parse_hash("[hash:base64:5]"), (None, Some(DigestType::Base64), Some(5))); + assert_eq!(parse_hash("[md4:hash]"), (Some(HashAlgorithm::Md4), None, None)); + assert_eq!(parse_hash("[md4:hash:5]"), (Some(HashAlgorithm::Md4), None, Some(5))); + assert_eq!( + parse_hash("[md4:hash:base64]"), + (Some(HashAlgorithm::Md4), Some(DigestType::Base64), None) + ); + } + + #[test] + fn parse_is_case_insensitive() { + assert_eq!( + parse_hash("[MD4:HASH:BASE64:5]"), + (Some(HashAlgorithm::Md4), Some(DigestType::Base64), Some(5)) + ); + } + + #[test] + fn parse_rejects_unknown_algo_and_digest() { + assert!(Pattern::parse("[sha1:hash:hex:8]").is_err()); + assert!(Pattern::parse("[md4:hash:base32:5]").is_err()); + // Two parts before "hash": + assert!(Pattern::parse("[md4:extra:hash:5]").is_err()); + // No "hash" keyword: + assert!(Pattern::parse("[md4:base64:5]").is_err()); + } + + #[test] + fn write_uses_options_to_match_webpack_output() { + // Reproduce the [name]__[local]__[md4:hash:base64:5] pattern from a captured Vite build, + // using the digest only (no css-loader content composition / post-process). The hash + // segment alone must produce "YTbdH" given the same input bytes. + let pattern = Pattern::parse("[md4:hash:base64:5]").unwrap(); + let mut out = String::new(); + let path = std::path::Path::new("src/styles/Alpha.module.css"); + pattern + .write( + "\x00\x00\x00\x00src/styles/Alpha.module.css\x00foo", + path, + "foo", + "", + |s| { + out.push_str(s); + Ok::<_, std::fmt::Error>(()) + }, + ) + .unwrap(); + assert_eq!(out, "YTbdH"); + } } From f0fff5895cb4eee43080b38964cc125be207b71c Mon Sep 17 00:00:00 2001 From: Samuel Cormier-Iijima Date: Mon, 4 May 2026 16:49:10 -0400 Subject: [PATCH 05/10] feat(css-modules): add Config::hash_prefix for hash input salting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/css_modules.rs | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/src/css_modules.rs b/src/css_modules.rs index 9d0e4a62..1d45c55e 100644 --- a/src/css_modules.rs +++ b/src/css_modules.rs @@ -48,6 +48,15 @@ pub struct Config<'i> { pub container: bool, /// Whether to check for pure CSS modules. pub pure: bool, + /// A prefix prepended to the hash input for every source. Matches Vite/postcss-modules' + /// `hashPrefix` option and css-loader's `hashSalt`; setting it to `"\x00\x00\x00\x00"` + /// makes the hashed bytes match css-loader's tier-0 salt input, which is how a Vite or + /// postcss-modules build can produce the same scoped-name hashes as webpack/css-loader. + /// + /// Applies to both legacy `[hash]` and `[:hash::]` segments. Has + /// no effect on `[content-hash]`. Default is `None` (no prefix; lightningcss output is + /// unchanged from prior versions). + pub hash_prefix: Option>, } impl<'i> Default for Config<'i> { @@ -60,6 +69,7 @@ impl<'i> Default for Config<'i> { container: true, custom_idents: true, pure: false, + hash_prefix: None, } } } @@ -389,6 +399,7 @@ impl<'a, 'b, 'c> CssModule<'a, 'b, 'c> { ) -> Self { let project_root = project_root.map(|p| Path::new(p)); let sources: Vec<&Path> = sources.iter().map(|filename| Path::new(filename)).collect(); + let prefix = config.hash_prefix.as_deref().unwrap_or(""); let hash_inputs = sources .iter() .map(|path| { @@ -399,7 +410,12 @@ impl<'a, 'b, 'c> CssModule<'a, 'b, 'c> { } _ => Cow::Borrowed(*path), }; - source.to_string_lossy().into_owned() + let rel = source.to_string_lossy(); + if prefix.is_empty() { + rel.into_owned() + } else { + format!("{}{}", prefix, rel) + } }) .collect(); Self { @@ -837,6 +853,25 @@ mod tests { assert!(Pattern::parse("[md4:base64:5]").is_err()); } + #[test] + fn hash_prefix_is_prepended_to_hash_inputs() { + let mut refs = HashMap::new(); + let sources = vec!["src/styles/Alpha.module.css".to_string()]; + let mut config = Config::default(); + config.hash_prefix = Some(std::borrow::Cow::Borrowed("\x00\x00\x00\x00")); + let m = CssModule::new(&config, &sources, None, &mut refs, &None); + assert_eq!(m.hash_inputs[0], "\x00\x00\x00\x00src/styles/Alpha.module.css"); + } + + #[test] + fn hash_prefix_default_none_leaves_input_unchanged() { + let mut refs = HashMap::new(); + let sources = vec!["src/styles/Alpha.module.css".to_string()]; + let config = Config::default(); + let m = CssModule::new(&config, &sources, None, &mut refs, &None); + assert_eq!(m.hash_inputs[0], "src/styles/Alpha.module.css"); + } + #[test] fn write_uses_options_to_match_webpack_output() { // Reproduce the [name]__[local]__[md4:hash:base64:5] pattern from a captured Vite build, From 1e4a69b57cadb8974be0fac92f6110ebe6082e6e Mon Sep 17 00:00:00 2001 From: Samuel Cormier-Iijima Date: Mon, 4 May 2026 16:52:29 -0400 Subject: [PATCH 06/10] feat(css-modules): add Config::hash_local_name for per-local hashing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: \0. 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. --- src/css_modules.rs | 107 ++++++++++++++++++++++++++++++++++++--------- src/printer.rs | 6 ++- 2 files changed, 90 insertions(+), 23 deletions(-) diff --git a/src/css_modules.rs b/src/css_modules.rs index 1d45c55e..170cb91c 100644 --- a/src/css_modules.rs +++ b/src/css_modules.rs @@ -57,6 +57,13 @@ pub struct Config<'i> { /// no effect on `[content-hash]`. Default is `None` (no prefix; lightningcss output is /// unchanged from prior versions). pub hash_prefix: Option>, + /// When `true`, the local class/ident name is appended to the hash input separated by + /// a NUL byte: `\0`. This matches the per-local hashing + /// done by css-loader and postcss-modules — without it, every export from a given file + /// shares one hash. Required for css-loader/postcss-modules byte parity. + /// + /// Default is `false` (preserves lightningcss's per-file hashing behavior). + pub hash_local_name: bool, } impl<'i> Default for Config<'i> { @@ -70,6 +77,7 @@ impl<'i> Default for Config<'i> { custom_idents: true, pure: false, hash_prefix: None, + hash_local_name: false, } } } @@ -428,7 +436,19 @@ impl<'a, 'b, 'c> CssModule<'a, 'b, 'c> { } } + /// Build the hash input for a `[hash]` segment, optionally appending the local class + /// name when [Config::hash_local_name] is enabled. + pub(crate) fn hash_input_for(&self, source_index: u32, local: &str) -> Cow<'_, str> { + let base = &self.hash_inputs[source_index as usize]; + if self.config.hash_local_name { + Cow::Owned(format!("{}\x00{}", base, local)) + } else { + Cow::Borrowed(base) + } + } + pub fn add_local(&mut self, exported: &str, local: &str, source_index: u32) { + let hash_input = self.hash_input_for(source_index, local).into_owned(); self.exports_by_source_index[source_index as usize] .entry(exported.into()) .or_insert_with(|| CssModuleExport { @@ -437,7 +457,7 @@ impl<'a, 'b, 'c> CssModule<'a, 'b, 'c> { .pattern .write_to_string( String::new(), - &self.hash_inputs[source_index as usize], + &hash_input, &self.sources[source_index as usize], local, if let Some(content_hashes) = &self.content_hashes { @@ -453,6 +473,7 @@ impl<'a, 'b, 'c> CssModule<'a, 'b, 'c> { } pub fn add_dashed(&mut self, local: &str, source_index: u32) { + let hash_input = self.hash_input_for(source_index, &local[2..]).into_owned(); self.exports_by_source_index[source_index as usize] .entry(local.into()) .or_insert_with(|| CssModuleExport { @@ -461,7 +482,7 @@ impl<'a, 'b, 'c> CssModule<'a, 'b, 'c> { .pattern .write_to_string( "--".into(), - &self.hash_inputs[source_index as usize], + &hash_input, &self.sources[source_index as usize], &local[2..], if let Some(content_hashes) = &self.content_hashes { @@ -477,6 +498,7 @@ impl<'a, 'b, 'c> CssModule<'a, 'b, 'c> { } pub fn reference(&mut self, name: &str, source_index: u32) { + let hash_input = self.hash_input_for(source_index, name).into_owned(); match self.exports_by_source_index[source_index as usize].entry(name.into()) { std::collections::hash_map::Entry::Occupied(mut entry) => { entry.get_mut().is_referenced = true; @@ -488,7 +510,7 @@ impl<'a, 'b, 'c> CssModule<'a, 'b, 'c> { .pattern .write_to_string( String::new(), - &self.hash_inputs[source_index as usize], + &hash_input, &self.sources[source_index as usize], name, if let Some(content_hashes) = &self.content_hashes { @@ -516,13 +538,14 @@ impl<'a, 'b, 'c> CssModule<'a, 'b, 'c> { file.as_ref(), ), Some(Specifier::SourceIndex(source_index)) => { + let hash_input = self.hash_input_for(*source_index, &name[2..]).into_owned(); return Some( self .config .pattern .write_to_string( String::new(), - &self.hash_inputs[*source_index as usize], + &hash_input, &self.sources[*source_index as usize], &name[2..], if let Some(content_hashes) = &self.content_hashes { @@ -536,6 +559,7 @@ impl<'a, 'b, 'c> CssModule<'a, 'b, 'c> { } None => { // Local export. Mark as used. + let hash_input = self.hash_input_for(source_index, &name[2..]).into_owned(); match self.exports_by_source_index[source_index as usize].entry(name.into()) { std::collections::hash_map::Entry::Occupied(mut entry) => { entry.get_mut().is_referenced = true; @@ -547,7 +571,7 @@ impl<'a, 'b, 'c> CssModule<'a, 'b, 'c> { .pattern .write_to_string( "--".into(), - &self.hash_inputs[source_index as usize], + &hash_input, &self.sources[source_index as usize], &name[2..], if let Some(content_hashes) = &self.content_hashes { @@ -588,22 +612,25 @@ impl<'a, 'b, 'c> CssModule<'a, 'b, 'c> { parcel_selectors::parser::Component::Class(ref id) => { for name in &composes.names { let reference = match &composes.from { - None => CssModuleReference::Local { - name: self - .config - .pattern - .write_to_string( - String::new(), - &self.hash_inputs[source_index as usize], - &self.sources[source_index as usize], - name.0.as_ref(), - if let Some(content_hashes) = &self.content_hashes { - &content_hashes[source_index as usize] - } else { - "" - }, - ) - .unwrap(), + None => { + let hash_input = self.hash_input_for(source_index, name.0.as_ref()).into_owned(); + CssModuleReference::Local { + name: self + .config + .pattern + .write_to_string( + String::new(), + &hash_input, + &self.sources[source_index as usize], + name.0.as_ref(), + if let Some(content_hashes) = &self.content_hashes { + &content_hashes[source_index as usize] + } else { + "" + }, + ) + .unwrap(), + } }, Some(Specifier::SourceIndex(dep_source_index)) => { if let Some(entry) = @@ -872,6 +899,44 @@ mod tests { assert_eq!(m.hash_inputs[0], "src/styles/Alpha.module.css"); } + #[test] + fn hash_local_name_appends_local_after_nul() { + let mut refs = HashMap::new(); + let sources = vec!["src/styles/Alpha.module.css".to_string()]; + let mut config = Config::default(); + config.hash_prefix = Some(std::borrow::Cow::Borrowed("\x00\x00\x00\x00")); + config.hash_local_name = true; + let m = CssModule::new(&config, &sources, None, &mut refs, &None); + let hi = m.hash_input_for(0, "foo"); + assert_eq!(&*hi, "\x00\x00\x00\x00src/styles/Alpha.module.css\x00foo"); + } + + #[test] + fn hash_local_name_disabled_returns_path_only() { + let mut refs = HashMap::new(); + let sources = vec!["src/styles/Alpha.module.css".to_string()]; + let config = Config::default(); + let m = CssModule::new(&config, &sources, None, &mut refs, &None); + let hi = m.hash_input_for(0, "foo"); + assert_eq!(&*hi, "src/styles/Alpha.module.css"); + } + + #[test] + fn parity_full_input_matches_webpack_digest() { + // End-to-end: with hash_prefix + hash_local_name set, the bytes hashed for + // (path="src/styles/Alpha.module.css", local="foo") match Vite's captured md4 base64 + // truncated digest "YTbdH". + let mut refs = HashMap::new(); + let sources = vec!["src/styles/Alpha.module.css".to_string()]; + let mut config = Config::default(); + config.hash_prefix = Some(std::borrow::Cow::Borrowed("\x00\x00\x00\x00")); + config.hash_local_name = true; + let m = CssModule::new(&config, &sources, None, &mut refs, &None); + let hi = m.hash_input_for(0, "foo"); + let digest = hash_with_options(hi.as_bytes(), HashAlgorithm::Md4, DigestType::Base64, Some(5)); + assert_eq!(digest, "YTbdH"); + } + #[test] fn write_uses_options_to_match_webpack_output() { // Reproduce the [name]__[local]__[md4:hash:base64:5] pattern from a captured Vite build, diff --git a/src/printer.rs b/src/printer.rs index ce24ce44..fe3f6cb9 100644 --- a/src/printer.rs +++ b/src/printer.rs @@ -292,8 +292,9 @@ impl<'a, 'b, 'c, W: std::fmt::Write + Sized> Printer<'a, 'b, 'c, W> { if let Some(css_module) = &mut self.css_module { let dest = &mut self.dest; let mut first = true; + let hash_input = css_module.hash_input_for(self.loc.source_index, ident).into_owned(); css_module.config.pattern.write( - &css_module.hash_inputs[self.loc.source_index as usize], + &hash_input, &css_module.sources[self.loc.source_index as usize], ident, if let Some(content_hashes) = &css_module.content_hashes { @@ -327,8 +328,9 @@ impl<'a, 'b, 'c, W: std::fmt::Write + Sized> Printer<'a, 'b, 'c, W> { match &mut self.css_module { Some(css_module) if css_module.config.dashed_idents => { let dest = &mut self.dest; + let hash_input = css_module.hash_input_for(self.loc.source_index, &ident[2..]).into_owned(); css_module.config.pattern.write( - &css_module.hash_inputs[self.loc.source_index as usize], + &hash_input, &css_module.sources[self.loc.source_index as usize], &ident[2..], if let Some(content_hashes) = &css_module.content_hashes { From 5353a3802ddb55c4a9a7f9cae311d98e161c8bcc Mon Sep 17 00:00:00 2001 From: Samuel Cormier-Iijima Date: Mon, 4 May 2026 16:56:44 -0400 Subject: [PATCH 07/10] feat(css-modules): add Config::escape_scoped_names post-process pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/css_modules.rs | 294 ++++++++++++++++++++++++++++++--------------- src/printer.rs | 83 +++++++------ 2 files changed, 242 insertions(+), 135 deletions(-) diff --git a/src/css_modules.rs b/src/css_modules.rs index 170cb91c..5c1ea626 100644 --- a/src/css_modules.rs +++ b/src/css_modules.rs @@ -64,6 +64,19 @@ pub struct Config<'i> { /// /// Default is `false` (preserves lightningcss's per-file hashing behavior). pub hash_local_name: bool, + /// When `true`, every rendered scoped name is post-processed using the same rules as + /// css-loader/postcss-modules' `genericNames`: + /// + /// - Characters outside `[a-zA-Z0-9\-_ -￿]` are replaced with `-`. Notably, + /// the `+` and `/` chars produced by standard base64 digests become `-`. + /// - A leading `-?[0-9]` or `--` is prefixed with `_` so the output is a valid CSS + /// identifier even when the rendered name starts with a digit. + /// + /// The `--` prefix on dashed (custom property) idents is excluded from this processing + /// — it's prepended after the rendered pattern is escaped. + /// + /// Default is `false`. + pub escape_scoped_names: bool, } impl<'i> Default for Config<'i> { @@ -78,6 +91,7 @@ impl<'i> Default for Config<'i> { pure: false, hash_prefix: None, hash_local_name: false, + escape_scoped_names: false, } } } @@ -284,7 +298,7 @@ impl<'i> Pattern<'i> { } #[inline] - fn write_to_string( + pub(crate) fn write_to_string( &self, mut res: String, hash_input: &str, @@ -447,26 +461,38 @@ impl<'a, 'b, 'c> CssModule<'a, 'b, 'c> { } } + /// Apply the css-loader/postcss-modules post-processing pipeline to a rendered scoped + /// name when [Config::escape_scoped_names] is set; otherwise return `s` unchanged. + pub(crate) fn maybe_escape(&self, s: String) -> String { + if self.config.escape_scoped_names { + escape_scoped_name(&s) + } else { + s + } + } + pub fn add_local(&mut self, exported: &str, local: &str, source_index: u32) { let hash_input = self.hash_input_for(source_index, local).into_owned(); + let body = self + .config + .pattern + .write_to_string( + String::new(), + &hash_input, + &self.sources[source_index as usize], + local, + if let Some(content_hashes) = &self.content_hashes { + &content_hashes[source_index as usize] + } else { + "" + }, + ) + .unwrap(); + let name = self.maybe_escape(body); self.exports_by_source_index[source_index as usize] .entry(exported.into()) - .or_insert_with(|| CssModuleExport { - name: self - .config - .pattern - .write_to_string( - String::new(), - &hash_input, - &self.sources[source_index as usize], - local, - if let Some(content_hashes) = &self.content_hashes { - &content_hashes[source_index as usize] - } else { - "" - }, - ) - .unwrap(), + .or_insert(CssModuleExport { + name, composes: vec![], is_referenced: false, }); @@ -474,24 +500,26 @@ impl<'a, 'b, 'c> CssModule<'a, 'b, 'c> { pub fn add_dashed(&mut self, local: &str, source_index: u32) { let hash_input = self.hash_input_for(source_index, &local[2..]).into_owned(); + let body = self + .config + .pattern + .write_to_string( + String::new(), + &hash_input, + &self.sources[source_index as usize], + &local[2..], + if let Some(content_hashes) = &self.content_hashes { + &content_hashes[source_index as usize] + } else { + "" + }, + ) + .unwrap(); + let name = format!("--{}", self.maybe_escape(body)); self.exports_by_source_index[source_index as usize] .entry(local.into()) - .or_insert_with(|| CssModuleExport { - name: self - .config - .pattern - .write_to_string( - "--".into(), - &hash_input, - &self.sources[source_index as usize], - &local[2..], - if let Some(content_hashes) = &self.content_hashes { - &content_hashes[source_index as usize] - } else { - "" - }, - ) - .unwrap(), + .or_insert(CssModuleExport { + name, composes: vec![], is_referenced: false, }); @@ -504,22 +532,28 @@ impl<'a, 'b, 'c> CssModule<'a, 'b, 'c> { entry.get_mut().is_referenced = true; } std::collections::hash_map::Entry::Vacant(entry) => { + let body = self + .config + .pattern + .write_to_string( + String::new(), + &hash_input, + &self.sources[source_index as usize], + name, + if let Some(content_hashes) = &self.content_hashes { + &content_hashes[source_index as usize] + } else { + "" + }, + ) + .unwrap(); + let escaped = if self.config.escape_scoped_names { + escape_scoped_name(&body) + } else { + body + }; entry.insert(CssModuleExport { - name: self - .config - .pattern - .write_to_string( - String::new(), - &hash_input, - &self.sources[source_index as usize], - name, - if let Some(content_hashes) = &self.content_hashes { - &content_hashes[source_index as usize] - } else { - "" - }, - ) - .unwrap(), + name: escaped, composes: vec![], is_referenced: true, }); @@ -539,48 +573,49 @@ impl<'a, 'b, 'c> CssModule<'a, 'b, 'c> { ), Some(Specifier::SourceIndex(source_index)) => { let hash_input = self.hash_input_for(*source_index, &name[2..]).into_owned(); - return Some( - self - .config - .pattern - .write_to_string( - String::new(), - &hash_input, - &self.sources[*source_index as usize], - &name[2..], - if let Some(content_hashes) = &self.content_hashes { - &content_hashes[*source_index as usize] - } else { - "" - }, - ) - .unwrap(), - ) + let body = self + .config + .pattern + .write_to_string( + String::new(), + &hash_input, + &self.sources[*source_index as usize], + &name[2..], + if let Some(content_hashes) = &self.content_hashes { + &content_hashes[*source_index as usize] + } else { + "" + }, + ) + .unwrap(); + return Some(self.maybe_escape(body)); } None => { // Local export. Mark as used. let hash_input = self.hash_input_for(source_index, &name[2..]).into_owned(); + let body = self + .config + .pattern + .write_to_string( + String::new(), + &hash_input, + &self.sources[source_index as usize], + &name[2..], + if let Some(content_hashes) = &self.content_hashes { + &content_hashes[source_index as usize] + } else { + "" + }, + ) + .unwrap(); + let scoped = format!("--{}", self.maybe_escape(body)); match self.exports_by_source_index[source_index as usize].entry(name.into()) { std::collections::hash_map::Entry::Occupied(mut entry) => { entry.get_mut().is_referenced = true; } std::collections::hash_map::Entry::Vacant(entry) => { entry.insert(CssModuleExport { - name: self - .config - .pattern - .write_to_string( - "--".into(), - &hash_input, - &self.sources[source_index as usize], - &name[2..], - if let Some(content_hashes) = &self.content_hashes { - &content_hashes[source_index as usize] - } else { - "" - }, - ) - .unwrap(), + name: scoped, composes: vec![], is_referenced: true, }); @@ -614,22 +649,23 @@ impl<'a, 'b, 'c> CssModule<'a, 'b, 'c> { let reference = match &composes.from { None => { let hash_input = self.hash_input_for(source_index, name.0.as_ref()).into_owned(); + let body = self + .config + .pattern + .write_to_string( + String::new(), + &hash_input, + &self.sources[source_index as usize], + name.0.as_ref(), + if let Some(content_hashes) = &self.content_hashes { + &content_hashes[source_index as usize] + } else { + "" + }, + ) + .unwrap(); CssModuleReference::Local { - name: self - .config - .pattern - .write_to_string( - String::new(), - &hash_input, - &self.sources[source_index as usize], - name.0.as_ref(), - if let Some(content_hashes) = &self.content_hashes { - &content_hashes[source_index as usize] - } else { - "" - }, - ) - .unwrap(), + name: self.maybe_escape(body), } }, Some(Specifier::SourceIndex(dep_source_index)) => { @@ -706,6 +742,33 @@ pub enum HashAlgorithm { Xxhash64, } +/// Post-process a rendered scoped name using css-loader/postcss-modules' `genericNames` +/// 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 { + let mut out = String::with_capacity(s.len() + 1); + for ch in s.chars() { + let keep = + ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || (ch as u32 >= 0x00A0 && (ch as u32) <= 0xFFFF); + out.push(if keep { ch } else { '-' }); + } + // /^((-?[0-9])|--)/ -> "_$1" + let bytes = out.as_bytes(); + let needs_prefix = matches!( + (bytes.first(), bytes.get(1)), + (Some(b'0'..=b'9'), _) | (Some(b'-'), Some(b'0'..=b'9' | b'-')) + ); + if needs_prefix { + let mut prefixed = String::with_capacity(out.len() + 1); + prefixed.push('_'); + prefixed.push_str(&out); + prefixed + } else { + out + } +} + fn parse_digest(s: &str) -> Option { match s.to_ascii_lowercase().as_str() { "hex" => Some(DigestType::Hex), @@ -937,6 +1000,47 @@ mod tests { assert_eq!(digest, "YTbdH"); } + #[test] + fn escape_replaces_invalid_chars_with_dash() { + // Standard base64 alphabet contains `+/`; both should become `-`. + assert_eq!(escape_scoped_name("LOY/5"), "LOY-5"); + assert_eq!(escape_scoped_name("/ta+0"), "-ta-0"); + } + + #[test] + fn escape_prefixes_leading_digit() { + assert_eq!(escape_scoped_name("2kP0r"), "_2kP0r"); + assert_eq!(escape_scoped_name("2lUK7"), "_2lUK7"); + } + + #[test] + fn escape_prefixes_leading_dash_digit() { + // `-0F5/` -> after invalid-char replace: `-0F5-` -> leading -? followed by digit -> `_-0F5-`. + assert_eq!(escape_scoped_name("-0F5-"), "_-0F5-"); + } + + #[test] + fn escape_prefixes_leading_double_dash() { + // `//GdO` -> `--GdO` -> leading `--` -> `_--GdO`. + assert_eq!(escape_scoped_name("--GdO"), "_--GdO"); + } + + #[test] + fn escape_keeps_dash_letter_unprefixed() { + // `/rXwJ` -> `-rXwJ` -> leading `-` followed by letter -> unchanged. + assert_eq!(escape_scoped_name("-rXwJ"), "-rXwJ"); + } + + #[test] + fn escape_keeps_clean_input_unchanged() { + assert_eq!(escape_scoped_name("Alpha-module__foo__YTbdH"), "Alpha-module__foo__YTbdH"); + } + + #[test] + fn escape_keeps_unicode_above_a0() { + assert_eq!(escape_scoped_name("Café"), "Café"); + } + #[test] fn write_uses_options_to_match_webpack_output() { // Reproduce the [name]__[local]__[md4:hash:base64:5] pattern from a captured Vite build, diff --git a/src/printer.rs b/src/printer.rs index fe3f6cb9..dbdcb521 100644 --- a/src/printer.rs +++ b/src/printer.rs @@ -290,30 +290,28 @@ impl<'a, 'b, 'c, W: std::fmt::Write + Sized> Printer<'a, 'b, 'c, W> { pub fn write_ident(&mut self, ident: &str, handle_css_module: bool) -> Result<(), PrinterError> { if handle_css_module { if let Some(css_module) = &mut self.css_module { - let dest = &mut self.dest; - let mut first = true; - let hash_input = css_module.hash_input_for(self.loc.source_index, ident).into_owned(); - css_module.config.pattern.write( - &hash_input, - &css_module.sources[self.loc.source_index as usize], - ident, - if let Some(content_hashes) = &css_module.content_hashes { - &content_hashes[self.loc.source_index as usize] - } else { - "" - }, - |s| { - self.col += s.len() as u32; - if first { - first = false; - serialize_identifier(s, dest) + let source_index = self.loc.source_index; + let hash_input = css_module.hash_input_for(source_index, ident).into_owned(); + let body = css_module + .config + .pattern + .write_to_string( + String::new(), + &hash_input, + &css_module.sources[source_index as usize], + ident, + if let Some(content_hashes) = &css_module.content_hashes { + &content_hashes[source_index as usize] } else { - serialize_name(s, dest) - } - }, - )?; - - css_module.add_local(&ident, &ident, self.loc.source_index); + "" + }, + ) + .unwrap(); + let scoped = css_module.maybe_escape(body); + css_module.add_local(&ident, &ident, source_index); + + self.col += scoped.len() as u32; + serialize_identifier(&scoped, &mut self.dest)?; return Ok(()); } } @@ -327,26 +325,31 @@ impl<'a, 'b, 'c, W: std::fmt::Write + Sized> Printer<'a, 'b, 'c, W> { match &mut self.css_module { Some(css_module) if css_module.config.dashed_idents => { - let dest = &mut self.dest; - let hash_input = css_module.hash_input_for(self.loc.source_index, &ident[2..]).into_owned(); - css_module.config.pattern.write( - &hash_input, - &css_module.sources[self.loc.source_index as usize], - &ident[2..], - if let Some(content_hashes) = &css_module.content_hashes { - &content_hashes[self.loc.source_index as usize] - } else { - "" - }, - |s| { - self.col += s.len() as u32; - serialize_name(s, dest) - }, - )?; + let source_index = self.loc.source_index; + let hash_input = css_module.hash_input_for(source_index, &ident[2..]).into_owned(); + let body = css_module + .config + .pattern + .write_to_string( + String::new(), + &hash_input, + &css_module.sources[source_index as usize], + &ident[2..], + if let Some(content_hashes) = &css_module.content_hashes { + &content_hashes[source_index as usize] + } else { + "" + }, + ) + .unwrap(); + let scoped = css_module.maybe_escape(body); if is_declaration { - css_module.add_dashed(ident, self.loc.source_index); + css_module.add_dashed(ident, source_index); } + + self.col += scoped.len() as u32; + serialize_name(&scoped, &mut self.dest)?; } _ => { serialize_name(&ident[2..], self)?; From 9fb127b383a1bbd5607d54c61129fc32a4bfe94b Mon Sep 17 00:00:00 2001 From: Samuel Cormier-Iijima Date: Mon, 4 May 2026 16:58:18 -0400 Subject: [PATCH 08/10] test(css-modules): add Vite scoped-name byte-parity end-to-end test 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. --- src/lib.rs | 113 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/src/lib.rs b/src/lib.rs index 75e8eef4..2f9132e8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25695,6 +25695,119 @@ mod tests { "); } + /// End-to-end byte-parity test against ground truth captured from a Vite/postcss-modules + /// build using: + /// + /// ```js + /// css.modules = { + /// generateScopedName: "[name]__[local]__[md4:hash:base64:5]", + /// hashPrefix: "\0\0\0\0", + /// } + /// ``` + /// + /// Every captured class name must be reproduced exactly. Cases cover all post-processing + /// branches: clean digest, +/-/-mid-cleanup, leading-digit prefix, leading -digit prefix, + /// leading -- prefix, leading -letter (no prefix). + #[test] + fn css_modules_webpack_parity() { + use crate::css_modules::{Config as CssModulesConfig, Pattern}; + use std::borrow::Cow; + + fn parity_for(filename: &str, source: &str) -> CssModuleExports { + let pattern = Pattern::parse("[name]__[local]__[md4:hash:base64:5]").unwrap(); + let config = CssModulesConfig { + pattern, + hash_prefix: Some(Cow::Borrowed("\x00\x00\x00\x00")), + hash_local_name: true, + escape_scoped_names: true, + ..CssModulesConfig::default() + }; + let mut stylesheet = StyleSheet::parse( + source, + ParserOptions { + filename: filename.into(), + css_modules: Some(config), + ..ParserOptions::default() + }, + ) + .unwrap(); + stylesheet.minify(MinifyOptions::default()).unwrap(); + let res = stylesheet + .to_css(PrinterOptions { + minify: false, + ..PrinterOptions::default() + }) + .unwrap(); + res.exports.unwrap() + } + + let exports_a = parity_for( + "src/styles/Alpha.module.css", + r#" +.foo { color: red; } +.bar-baz { color: blue; } +.qux { color: green; } +.cls_3 { color: cyan; } +.cls_4 { color: magenta; } +.cls_48 { color: yellow; } +.cls_9 { color: pink; } +.cls_90 { color: orange; } +.cls_533 { color: purple; } +"#, + ); + + let cases_a: &[(&str, &str)] = &[ + ("foo", "Alpha-module__foo__YTbdH"), + ("bar-baz", "Alpha-module__bar-baz__Wu8mb"), + ("qux", "Alpha-module__qux__pVaUd"), + ("cls_3", "Alpha-module__cls_3__2kP0r"), + // digest LOY/5 -> escape -> LOY-5 + ("cls_4", "Alpha-module__cls_4__LOY-5"), + // digest /ta+0 -> escape -> -ta-0 + ("cls_48", "Alpha-module__cls_48__-ta-0"), + // digest /rXwJ -> escape -> -rXwJ (leading -letter; no prefix added because the rendered + // name starts with `Alpha`, not `-`) + ("cls_9", "Alpha-module__cls_9__-rXwJ"), + // digest +0F5/ -> escape -> -0F5- + ("cls_90", "Alpha-module__cls_90__-0F5-"), + // digest //GdO -> escape -> --GdO + ("cls_533", "Alpha-module__cls_533__--GdO"), + ]; + + for (local, expected) in cases_a { + let got = exports_a.get(*local).unwrap_or_else(|| panic!("missing export {local}")); + assert_eq!( + got.name, *expected, + "mismatch for Alpha.module.css#{local}: got {} want {expected}", + got.name + ); + } + + let exports_b = parity_for( + "src/nested/dir/Beta.module.css", + r#" +.foo { color: red; } +.hello { color: blue; } +.world123 { color: green; } +"#, + ); + + let cases_b: &[(&str, &str)] = &[ + ("foo", "Beta-module__foo__EvqJr"), + ("hello", "Beta-module__hello__2lUK7"), + ("world123", "Beta-module__world123__28h2N"), + ]; + + for (local, expected) in cases_b { + let got = exports_b.get(*local).unwrap_or_else(|| panic!("missing export {local}")); + assert_eq!( + got.name, *expected, + "mismatch for Beta.module.css#{local}: got {} want {expected}", + got.name + ); + } + } + #[test] fn test_css_modules() { css_modules_test( From 7e6f0830263f00b7c5eaf306f172b26880fc72ac Mon Sep 17 00:00:00 2001 From: Samuel Cormier-Iijima Date: Mon, 4 May 2026 20:46:41 -0400 Subject: [PATCH 09/10] chore(npm): rename package to @fellowapp/lightningcss for GH Packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub Packages requires scoped names matching the publishing org. Rename the top-level package and the eleven platform-specific binary packages from 'lightningcss[-platform]' to '@fellowapp/lightningcss[-platform]', update node/index.js to require the scoped platform package at runtime, and point publishConfig.registry at https://npm.pkg.github.com. Drop CLI packaging from build-npm.js — the fork is consumed as a library (via Vite/lightningcss-napi), not as a CLI, and the per-platform CLI bundles are dead weight. Version bumped to 1.32.0-fellow.0 so the fork doesn't shadow upstream. --- node/index.js | 2 +- package.json | 13 +++++----- scripts/build-npm.js | 59 +++----------------------------------------- 3 files changed, 11 insertions(+), 63 deletions(-) diff --git a/node/index.js b/node/index.js index 6fe25aef..a17f56e0 100644 --- a/node/index.js +++ b/node/index.js @@ -15,7 +15,7 @@ if (process.platform === 'linux') { let native; try { - native = require(`lightningcss-${parts.join('-')}`); + native = require(`@fellowapp/lightningcss-${parts.join('-')}`); } catch (err) { native = require(`../lightningcss.${parts.join('-')}.node`); } diff --git a/package.json b/package.json index 02b427aa..25bc02bf 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,8 @@ { - "name": "lightningcss", - "version": "1.32.0", + "name": "@fellowapp/lightningcss", + "version": "1.32.0-fellow.0", "license": "MPL-2.0", - "description": "A CSS parser, transformer, and minifier written in Rust", + "description": "Fellow fork of lightningcss — a CSS parser, transformer, and minifier written in Rust, with css-loader/postcss-modules-compatible scoped-name hashing.", "main": "node/index.js", "types": "node/index.d.ts", "exports": { @@ -16,7 +16,8 @@ "types": false }, "publishConfig": { - "access": "public" + "access": "public", + "registry": "https://npm.pkg.github.com" }, "funding": { "type": "opencollective", @@ -24,7 +25,7 @@ }, "repository": { "type": "git", - "url": "https://github.com/parcel-bundler/lightningcss.git" + "url": "https://github.com/fellowapp/lightningcss.git" }, "engines": { "node": ">= 12.0.0" @@ -79,7 +80,7 @@ "uvu": "^0.5.6" }, "resolutions": { - "lightningcss": "link:." + "@fellowapp/lightningcss": "link:." }, "scripts": { "prepare": "patch-package", diff --git a/scripts/build-npm.js b/scripts/build-npm.js index ef447a7e..ade96520 100644 --- a/scripts/build-npm.js +++ b/scripts/build-npm.js @@ -58,7 +58,6 @@ const sysToNodePlatform = { }; let optionalDependencies = {}; -let cliOptionalDependencies = {}; try { fs.mkdirSync(dir + '/npm'); @@ -78,38 +77,17 @@ for (let triple of triples) { } buildNode(triple.name, cpu, os, libc, t); - buildCLI(triple.name, cpu, os, libc, t); } pkg.optionalDependencies = optionalDependencies; fs.writeFileSync(`${dir}/package.json`, JSON.stringify(pkg, false, 2) + '\n'); -let cliPkg = { ...pkg }; -cliPkg.name += '-cli'; -cliPkg.bin = { - 'lightningcss': 'lightningcss' -}; -delete cliPkg.main; -delete cliPkg.napi; -delete cliPkg.exports; -delete cliPkg.devDependencies; -delete cliPkg.targets; -delete cliPkg.types; -cliPkg.files = ['lightningcss', 'postinstall.js']; -cliPkg.optionalDependencies = cliOptionalDependencies; -cliPkg.scripts = { - postinstall: 'node postinstall.js' -}; - -fs.writeFileSync(`${dir}/cli/package.json`, JSON.stringify(cliPkg, false, 2) + '\n'); -fs.copyFileSync(`${dir}/README.md`, `${dir}/cli/README.md`); -fs.copyFileSync(`${dir}/LICENSE`, `${dir}/cli/LICENSE`); - function buildNode(triple, cpu, os, libc, t) { let name = `lightningcss.${t}.node`; let pkg2 = { ...pkg }; - pkg2.name += '-' + t; + // Each platform package is published as @fellowapp/lightningcss-. + pkg2.name = '@fellowapp/lightningcss-' + t; pkg2.os = [os]; pkg2.cpu = [cpu]; if (libc) { @@ -133,38 +111,7 @@ function buildNode(triple, cpu, os, libc, t) { } catch (err) { } fs.writeFileSync(`${dir}/npm/node-${t}/package.json`, JSON.stringify(pkg2, false, 2) + '\n'); fs.copyFileSync(`${dir}/artifacts/bindings-${triple}/${name}`, `${dir}/npm/node-${t}/${name}`); - fs.writeFileSync(`${dir}/npm/node-${t}/README.md`, `This is the ${triple} build of lightningcss. See https://github.com/parcel-bundler/lightningcss for details.`); + fs.writeFileSync(`${dir}/npm/node-${t}/README.md`, `This is the ${triple} build of @fellowapp/lightningcss. See https://github.com/fellowapp/lightningcss for details.`); fs.copyFileSync(`${dir}/LICENSE`, `${dir}/npm/node-${t}/LICENSE`); } -function buildCLI(triple, cpu, os, libc, t) { - let binary = os === 'win32' ? 'lightningcss.exe' : 'lightningcss'; - let pkg2 = { ...pkg }; - pkg2.name += '-cli-' + t; - pkg2.os = [os]; - pkg2.cpu = [cpu]; - pkg2.files = [binary]; - if (libc) { - pkg2.libc = [libc]; - } - delete pkg2.main; - delete pkg2.exports; - delete pkg2.napi; - delete pkg2.devDependencies; - delete pkg2.dependencies; - delete pkg2.optionalDependencies; - delete pkg2.targets; - delete pkg2.scripts; - delete pkg2.types; - - cliOptionalDependencies[pkg2.name] = pkg.version; - - try { - fs.mkdirSync(dir + '/npm/cli-' + t); - } catch (err) { } - fs.writeFileSync(`${dir}/npm/cli-${t}/package.json`, JSON.stringify(pkg2, false, 2) + '\n'); - fs.copyFileSync(`${dir}/artifacts/bindings-${triple}/${binary}`, `${dir}/npm/cli-${t}/${binary}`); - fs.chmodSync(`${dir}/npm/cli-${t}/${binary}`, 0o755); // Ensure execute bit is set. - fs.writeFileSync(`${dir}/npm/cli-${t}/README.md`, `This is the ${triple} build of lightningcss-cli. See https://github.com/parcel-bundler/lightningcss for details.`); - fs.copyFileSync(`${dir}/LICENSE`, `${dir}/npm/cli-${t}/LICENSE`); -} From a559fac10591fd2ad910588c3d97006dab890200 Mon Sep 17 00:00:00 2001 From: Samuel Cormier-Iijima Date: Mon, 4 May 2026 20:47:58 -0400 Subject: [PATCH 10/10] ci: add release-fellowapp workflow to publish to GitHub Packages Adapted from .github/workflows/release.yml. Builds the same matrix of platform-specific .node binaries (10 targets: macOS x64/arm64, Windows x64/arm64, Linux x64/arm64 gnu+musl, Linux armv7, Android arm64) and publishes them to GitHub Packages as @fellowapp/lightningcss-, plus the main @fellowapp/lightningcss package. Differences from the upstream workflow: - Triggered on workflow_dispatch only (no automated tag-based release). - Drops FreeBSD and wasm targets (not needed for the Vite-via-napi use case). - Drops CLI build/publish (the fork is consumed as a library). - Authenticates with GITHUB_TOKEN against npm.pkg.github.com via setup-node's registry-url + scope inputs. - Requires permissions: { packages: write, contents: read } on the release job. --- .github/workflows/release-fellowapp.yml | 166 ++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 .github/workflows/release-fellowapp.yml diff --git a/.github/workflows/release-fellowapp.yml b/.github/workflows/release-fellowapp.yml new file mode 100644 index 00000000..911f34dc --- /dev/null +++ b/.github/workflows/release-fellowapp.yml @@ -0,0 +1,166 @@ +name: release-fellowapp +# Build platform-specific .node binaries and publish the Fellow fork to +# GitHub Packages as @fellowapp/lightningcss + @fellowapp/lightningcss-. +# Triggered manually via workflow_dispatch. +on: + workflow_dispatch: + +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - os: windows-latest + target: x86_64-pc-windows-msvc + - os: windows-latest + target: aarch64-pc-windows-msvc + - os: macos-latest + target: x86_64-apple-darwin + strip: strip -x + + name: build-${{ matrix.target }} + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 18 + - uses: dtolnay/rust-toolchain@stable + - run: rustup target add ${{ matrix.target }} + - uses: bahmutov/npm-install@v1.8.32 + - name: Build release + run: yarn build-release + env: + RUST_TARGET: ${{ matrix.target }} + - name: Strip debug symbols + if: ${{ matrix.strip }} + run: ${{ matrix.strip }} *.node + - uses: actions/upload-artifact@v4 + with: + name: bindings-${{ matrix.target }} + path: '*.node' + + build-apple-silicon: + name: build-apple-silicon + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 18 + - uses: dtolnay/rust-toolchain@stable + - run: rustup target add aarch64-apple-darwin + - uses: bahmutov/npm-install@v1.8.32 + - name: Build release + run: yarn build-release + env: + RUST_TARGET: aarch64-apple-darwin + JEMALLOC_SYS_WITH_LG_PAGE: 14 + - name: Strip debug symbols + run: strip -x *.node + - uses: actions/upload-artifact@v4 + with: + name: bindings-aarch64-apple-darwin + path: '*.node' + + build-linux: + strategy: + fail-fast: false + matrix: + include: + - target: x86_64-unknown-linux-gnu + strip: strip + image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian + setup: npm install --global yarn@1 + - target: aarch64-unknown-linux-gnu + strip: llvm-strip + image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-aarch64 + - target: aarch64-linux-android + strip: llvm-strip + image: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-aarch64 + - target: armv7-unknown-linux-gnueabihf + strip: llvm-strip + image: ghcr.io/napi-rs/napi-rs/nodejs-rust@sha256:c22284b2d79092d3e885f64ede00f6afdeb2ccef7e2b6e78be52e7909091cd57 + - target: aarch64-unknown-linux-musl + image: ghcr.io/napi-rs/napi-rs/nodejs-rust@sha256:78c9ab1f117f8c535b93c4b91a2f19063dda6e4dba48a6187df49810625992c1 + strip: aarch64-linux-musl-strip + - target: x86_64-unknown-linux-musl + image: ghcr.io/napi-rs/napi-rs/nodejs-rust@sha256:78c9ab1f117f8c535b93c4b91a2f19063dda6e4dba48a6187df49810625992c1 + strip: strip + + name: build-${{ matrix.target }} + runs-on: ubuntu-latest + container: + image: ${{ matrix.image }} + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 18 + - uses: dtolnay/rust-toolchain@stable + - name: Setup Android NDK + if: ${{ matrix.target == 'aarch64-linux-android' }} + run: | + sudo apt update && sudo apt install unzip -y + cd /tmp + wget -q https://dl.google.com/android/repository/android-ndk-r28-linux.zip -O /tmp/ndk.zip + unzip ndk.zip + - name: Setup cross compile toolchain + if: ${{ matrix.setup }} + run: ${{ matrix.setup }} + - run: rustup target add ${{ matrix.target }} + - uses: bahmutov/npm-install@v1.8.32 + - name: Build release + run: yarn build-release + env: + ANDROID_NDK_LATEST_HOME: /tmp/android-ndk-r28 + RUST_TARGET: ${{ matrix.target }} + - name: Strip debug symbols + if: ${{ matrix.strip }} + run: ${{ matrix.strip }} *.node + - uses: actions/upload-artifact@v4 + with: + name: bindings-${{ matrix.target }} + path: '*.node' + + release: + runs-on: ubuntu-latest + name: Publish to GitHub Packages + needs: + - build + - build-linux + - build-apple-silicon + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 18 + registry-url: https://npm.pkg.github.com + scope: '@fellowapp' + - uses: bahmutov/npm-install@v1.8.32 + - uses: actions/download-artifact@v4 + with: + path: artifacts + - name: Show artifacts + run: ls -R artifacts + - name: Build npm packages + run: node scripts/build-npm.js + - name: Publish platform packages + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + for pkg in npm/*; do + echo "Publishing $pkg..." + (cd "$pkg" && npm publish) + done + - name: Publish main package + env: + NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + echo "Publishing @fellowapp/lightningcss..." + npm publish