From 201c4dba3fc0c1d09c239a757c50f2e7be6b7460 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=80=E4=B8=9D?= Date: Mon, 9 Mar 2026 10:33:20 +0800 Subject: [PATCH 1/5] Convert the percentage in the `scale` property or `scale()` to a number (#1174) - Use shared scale component parsing so individual `scale` and transform scale functions normalize percentages into numbers consistently. - Keep serialization canonical by converting scale values through numeric output paths, including `scaleX/Y/Z`. - Expand transform tests to mirror the individual `scale` percentage cases one-for-one, and add direct AST serialization coverage for both `scale` and `transform`. --- src/lib.rs | 127 +++++++++++++++++++++++++++++++++++- src/properties/transform.rs | 42 ++++++++---- 2 files changed, 154 insertions(+), 15 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 2a41655e..3c144165 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -12714,6 +12714,57 @@ mod tests { minify_test(".foo { transform: scale3d(1, 2, 1)", ".foo{transform:scaleY(2)}"); minify_test(".foo { transform: scale3d(1, 1, 2)", ".foo{transform:scaleZ(2)}"); minify_test(".foo { transform: scale3d(2, 2, 1)", ".foo{transform:scale(2)}"); + + // transform: scale(), Convert to + test( + ".foo { transform: scale3d(50%, 1, 200%) }", + indoc! {r#" + .foo { + transform: scale3d(.5, 1, 2); + } + "#}, + ); + minify_test(".foo { transform: scale(1%) }", ".foo{transform:scale(.01)}"); + minify_test(".foo { transform: scale(0%) }", ".foo{transform:scale(0)}"); + minify_test(".foo { transform: scale(0.0%) }", ".foo{transform:scale(0)}"); + minify_test(".foo { transform: scale(-0%) }", ".foo{transform:scale(0)}"); + minify_test(".foo { transform: scale(-0) }", ".foo{transform:scale(0)}"); + minify_test(".foo { transform: scale(-0.0) }", ".foo{transform:scale(0)}"); + minify_test(".foo { transform: scale(100%) }", ".foo{transform:scale(1)}"); + minify_test(".foo { transform: scale(-100%) }", ".foo{transform:scale(-1)}"); + minify_test(".foo { transform: scale(68%) }", ".foo{transform:scale(.68)}"); + minify_test(".foo { transform: scale(5.96%) }", ".foo{transform:scale(.0596)}"); + // Match WPT coverage for repeated and multi-value percentages. + minify_test(".foo { transform: scale(100%, 100%) }", ".foo{transform:scale(1)}"); + minify_test(".foo { transform: scale3d(100%, 100%, 1) }", ".foo{transform:scale(1)}"); + minify_test(".foo { transform: scale(-100%, -100%) }", ".foo{transform:scale(-1)}"); + minify_test(".foo { transform: scale3d(-100%, -100%, 1) }", ".foo{transform:scale(-1)}"); + minify_test(".foo { transform: scale(100%, 200%) }", ".foo{transform:scaleY(2)}"); + minify_test(".foo { transform: scale3d(100%, 200%, 1) }", ".foo{transform:scaleY(2)}"); + minify_test(".foo { transform: scale3d(100%, 100%, 0%) }", ".foo{transform:scaleZ(0)}"); + minify_test(".foo { transform: scale3d(100%, 100%, 100%) }", ".foo{transform:scale(1)}"); + minify_test(".foo { transform: scale3d(-0%, -0%, -0%) }", ".foo{transform:scale3d(0,0,0)}"); + // Additional edge cases: mixed inputs and computed percentages. + minify_test(".foo { transform: scale(2, 100%) }", ".foo{transform:scaleX(2)}"); + minify_test(".foo { transform: scale(2, -50%) }", ".foo{transform:scale(2,-.5)}"); + minify_test(".foo { transform: scale(-90%, -1) }", ".foo{transform:scale(-.9,-1)}"); + minify_test(".foo { transform: scale(calc(10% + 20%)) }", ".foo{transform:scale(.3)}"); + minify_test(".foo { transform: scale(calc(150% - 50%), 200%) }", ".foo{transform:scaleY(2)}"); + minify_test(".foo { transform: scale(200%, calc(50% - 80%)) }", ".foo{transform:scale(2,-.3)}"); + // TODO: For infinite decimals, please do not attempt to resolve calc + // Expected: calc(1 / 3) + // https://github.com/parcel-bundler/lightningcss/issues/12 + minify_test(".foo { transform: scale(calc(100% / 3)) }", ".foo{transform:scale(.333333)}"); + // Transform::ScaleX/Y/Z + minify_test(".foo { transform: scaleX(10%) }", ".foo{transform:scaleX(.1)}"); + minify_test(".foo { transform: scaleY(20%) }", ".foo{transform:scaleY(.2)}"); + minify_test(".foo { transform: scaleZ(30%) }", ".foo{transform:scaleZ(.3)}"); + minify_test(".foo { transform: scaleX(0%) }", ".foo{transform:scaleX(0)}"); + minify_test(".foo { transform: scaleX(-0%) }", ".foo{transform:scaleX(0)}"); + minify_test(".foo { transform: scaleX(calc(10% + 20%)) }", ".foo{transform:scaleX(.3)}"); + minify_test(".foo { transform: scaleX(calc(180% - 20%)) }", ".foo{transform:scaleX(1.6)}"); + minify_test(".foo { transform: scaleX(calc(50% - 80%)) }", ".foo{transform:scaleX(-.3)}"); + minify_test(".foo { transform: rotate(20deg)", ".foo{transform:rotate(20deg)}"); minify_test(".foo { transform: rotateX(20deg)", ".foo{transform:rotateX(20deg)}"); minify_test(".foo { transform: rotateY(20deg)", ".foo{transform:rotateY(20deg)}"); @@ -12851,7 +12902,6 @@ mod tests { ".foo{transform:rotate(calc(10deg + var(--test)))}", ".foo{transform:rotate(calc(10deg + var(--test)))}", ); - minify_test(".foo { transform: scale(calc(10% + 20%))", ".foo{transform:scale(.3)}"); minify_test(".foo { transform: scale(calc(.1 + .2))", ".foo{transform:scale(.3)}"); minify_test( @@ -12897,6 +12947,81 @@ mod tests { minify_test(".foo { scale: 1 0 1 }", ".foo{scale:1 0}"); minify_test(".foo { scale: 1 0 0 }", ".foo{scale:1 0 0}"); + // scale, Convert to + test( + ".foo { scale: 50% 1 200% }", + indoc! {r#" + .foo { + scale: .5 1 2; + } + "#}, + ); + minify_test(".foo { scale: 1% }", ".foo{scale:.01}"); + minify_test(".foo { scale: 0% }", ".foo{scale:0}"); + minify_test(".foo { scale: 0.0% }", ".foo{scale:0}"); + minify_test(".foo { scale: -0% }", ".foo{scale:0}"); + minify_test(".foo { scale: -0 }", ".foo{scale:0}"); + minify_test(".foo { scale: -0.0 }", ".foo{scale:0}"); + minify_test(".foo { scale: 100% }", ".foo{scale:1}"); + minify_test(".foo { scale: -100% }", ".foo{scale:-1}"); + minify_test(".foo { scale: 68% }", ".foo{scale:.68}"); + minify_test(".foo { scale: 5.96% }", ".foo{scale:.0596}"); + // Match WPT coverage for repeated and multi-value percentages. + minify_test(".foo { scale: 100% 100% }", ".foo{scale:1}"); + minify_test(".foo { scale: 100% 100% 1 }", ".foo{scale:1}"); + minify_test(".foo { scale: -100% -100% }", ".foo{scale:-1}"); + minify_test(".foo { scale: -100% -100% 1 }", ".foo{scale:-1}"); + minify_test(".foo { scale: 100% 200% }", ".foo{scale:1 2}"); + minify_test(".foo { scale: 100% 200% 1 }", ".foo{scale:1 2}"); + minify_test(".foo { scale: 100% 100% 0% }", ".foo{scale:1 1 0}"); + minify_test(".foo { scale: 100% 100% 100% }", ".foo{scale:1}"); + minify_test(".foo { scale: -0% -0% -0% }", ".foo{scale:0 0 0}"); + // Additional edge cases: mixed inputs and computed percentages. + minify_test(".foo { scale: 2 100% }", ".foo{scale:2 1}"); + minify_test(".foo { scale: 2 -50% }", ".foo{scale:2 -.5}"); + minify_test(".foo { scale: -90% -1 }", ".foo{scale:-.9 -1}"); + minify_test(".foo { scale: calc(10% + 20%) }", ".foo{scale:.3}"); + minify_test(".foo { scale: calc(150% - 50%) 200% }", ".foo{scale:1 2}"); + minify_test(".foo { scale: 200% calc(50% - 80%) }", ".foo{scale:2 -.3}"); + // TODO: For infinite decimals, please do not attempt to resolve calc + // Expected: calc(1 / 3) + // https://github.com/parcel-bundler/lightningcss/issues/12 + minify_test(".foo { scale: calc(100% / 3) }", ".foo{scale:.333333}"); + + assert_eq!( + Property::Scale(crate::properties::transform::Scale::XYZ { + x: crate::values::percentage::NumberOrPercentage::Percentage(crate::values::percentage::Percentage(0.5)), + y: crate::values::percentage::NumberOrPercentage::Percentage(crate::values::percentage::Percentage(2.0)), + z: crate::values::percentage::NumberOrPercentage::Percentage(crate::values::percentage::Percentage(1.0)), + }) + .to_css_string( + false, + PrinterOptions { + minify: true, + ..PrinterOptions::default() + }, + ) + .unwrap(), + "scale:.5 2" + ); + assert_eq!( + Property::Transform( + crate::properties::transform::TransformList(vec![crate::properties::transform::Transform::ScaleX( + crate::values::percentage::NumberOrPercentage::Percentage(crate::values::percentage::Percentage(0.1)), + )]), + VendorPrefix::None, + ) + .to_css_string( + false, + PrinterOptions { + minify: true, + ..PrinterOptions::default() + }, + ) + .unwrap(), + "transform:scaleX(.1)" + ); + // TODO: Re-enable with a better solution // See: https://github.com/parcel-bundler/lightningcss/issues/288 // minify_test(".foo { transform: scale(3); scale: 0.5 }", ".foo{transform:scale(1.5)}"); diff --git a/src/properties/transform.rs b/src/properties/transform.rs index cc00e578..255beeeb 100644 --- a/src/properties/transform.rs +++ b/src/properties/transform.rs @@ -938,32 +938,32 @@ impl<'i> Parse<'i> for Transform { Ok(Transform::Translate3d(x, y, z)) }, "scale" => { - let x = NumberOrPercentage::parse(input)?; + let x = convert_percentage_to_number(input)?; if input.try_parse(|input| input.expect_comma()).is_ok() { - let y = NumberOrPercentage::parse(input)?; + let y = convert_percentage_to_number(input)?; Ok(Transform::Scale(x, y)) } else { Ok(Transform::Scale(x.clone(), x)) } }, "scalex" => { - let x = NumberOrPercentage::parse(input)?; + let x = convert_percentage_to_number(input)?; Ok(Transform::ScaleX(x)) }, "scaley" => { - let y = NumberOrPercentage::parse(input)?; + let y = convert_percentage_to_number(input)?; Ok(Transform::ScaleY(y)) }, "scalez" => { - let z = NumberOrPercentage::parse(input)?; + let z = convert_percentage_to_number(input)?; Ok(Transform::ScaleZ(z)) }, "scale3d" => { - let x = NumberOrPercentage::parse(input)?; + let x = convert_percentage_to_number(input)?; input.expect_comma()?; - let y = NumberOrPercentage::parse(input)?; + let y = convert_percentage_to_number(input)?; input.expect_comma()?; - let z = NumberOrPercentage::parse(input)?; + let z = convert_percentage_to_number(input)?; Ok(Transform::Scale3d(x, y, z)) }, "rotate" => { @@ -1102,16 +1102,19 @@ impl ToCss for Transform { dest.write_char(')') } ScaleX(x) => { + let x: f32 = x.into(); dest.write_str("scaleX(")?; x.to_css(dest)?; dest.write_char(')') } ScaleY(y) => { + let y: f32 = y.into(); dest.write_str("scaleY(")?; y.to_css(dest)?; dest.write_char(')') } ScaleZ(z) => { + let z: f32 = z.into(); dest.write_str("scaleZ(")?; z.to_css(dest)?; dest.write_char(')') @@ -1643,16 +1646,25 @@ pub enum Scale { }, } +fn convert_percentage_to_number<'i, 't>( + input: &mut Parser<'i, 't>, +) -> Result>> { + Ok(match NumberOrPercentage::parse(input)? { + NumberOrPercentage::Number(number) => NumberOrPercentage::Number(number), + NumberOrPercentage::Percentage(percent) => NumberOrPercentage::Number(percent.0), + }) +} + impl<'i> Parse<'i> for Scale { fn parse<'t>(input: &mut Parser<'i, 't>) -> Result>> { if input.try_parse(|i| i.expect_ident_matching("none")).is_ok() { return Ok(Scale::None); } - let x = NumberOrPercentage::parse(input)?; - let y = input.try_parse(NumberOrPercentage::parse); + let x = convert_percentage_to_number(input)?; + let y = input.try_parse(convert_percentage_to_number); let z = if y.is_ok() { - input.try_parse(NumberOrPercentage::parse).ok() + input.try_parse(convert_percentage_to_number).ok() } else { None }; @@ -1675,12 +1687,14 @@ impl ToCss for Scale { dest.write_str("none")?; } Scale::XYZ { x, y, z } => { + let x: f32 = x.into(); + let y: f32 = y.into(); + let z: f32 = z.into(); x.to_css(dest)?; - let zv: f32 = z.into(); - if y != x || zv != 1.0 { + if y != x || z != 1.0 { dest.write_char(' ')?; y.to_css(dest)?; - if zv != 1.0 { + if z != 1.0 { dest.write_char(' ')?; z.to_css(dest)?; } From 5ee613d3cc72882228e827c74af66e5d94a241b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=80=E4=B8=9D?= Date: Mon, 9 Mar 2026 11:42:48 +0800 Subject: [PATCH 2/5] test: remove `unwrap()` in test helpers and print explicit error line context (#1173) Replace `unwrap()` based test helper failures with explicit error handling so failures show actionable diagnostics instead of generic panic messages. - Extract shared test error formatting into src/test_helpers.rs - Print stage-specific failures (parse/minify/print) - Include caller file:line with #[track_caller] - Render CSS error line/column with nearby source lines and caret indicator --- src/lib.rs | 247 ++++++++++++++++++++++++++++++-------------- src/test_helpers.rs | 62 +++++++++++ 2 files changed, 229 insertions(+), 80 deletions(-) create mode 100644 src/test_helpers.rs diff --git a/src/lib.rs b/src/lib.rs index 3c144165..75e8eef4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -47,6 +47,9 @@ pub mod visitor; #[cfg(feature = "serde")] mod serialization; +#[cfg(test)] +mod test_helpers; + #[cfg(test)] mod tests { use crate::css_modules::{CssModuleExport, CssModuleExports, CssModuleReference, CssModuleReferences}; @@ -58,6 +61,7 @@ mod tests { use crate::rules::CssRule; use crate::rules::Location; use crate::stylesheet::*; + use crate::test_helpers::panic_with_test_error; use crate::targets::{Browsers, Features, Targets}; use crate::traits::{Parse, ToCss}; use crate::values::color::CssColor; @@ -68,86 +72,130 @@ mod tests { use std::collections::HashMap; use std::sync::{Arc, RwLock}; + #[track_caller] fn test(source: &str, expected: &str) { test_with_options(source, expected, ParserOptions::default()) } + #[track_caller] fn test_with_options<'i, 'o>(source: &'i str, expected: &'i str, options: ParserOptions<'o, 'i>) { - let mut stylesheet = StyleSheet::parse(&source, options).unwrap(); - stylesheet.minify(MinifyOptions::default()).unwrap(); - let res = stylesheet.to_css(PrinterOptions::default()).unwrap(); + let mut stylesheet = match StyleSheet::parse(&source, options) { + Ok(stylesheet) => stylesheet, + Err(e) => panic_with_test_error("test_with_options", "parse", source, e), + }; + if let Err(e) = stylesheet.minify(MinifyOptions::default()) { + panic_with_test_error("test_with_options", "minify", source, e); + } + let res = match stylesheet.to_css(PrinterOptions::default()) { + Ok(res) => res, + Err(e) => panic_with_test_error("test_with_options", "print", source, e), + }; assert_eq!(res.code, expected); } + #[track_caller] fn test_with_printer_options<'i, 'o>(source: &'i str, expected: &'i str, options: PrinterOptions<'o>) { - let mut stylesheet = StyleSheet::parse(&source, ParserOptions::default()).unwrap(); - stylesheet.minify(MinifyOptions::default()).unwrap(); - let res = stylesheet.to_css(options).unwrap(); + let mut stylesheet = match StyleSheet::parse(&source, ParserOptions::default()) { + Ok(stylesheet) => stylesheet, + Err(e) => panic_with_test_error("test_with_printer_options", "parse", source, e), + }; + if let Err(e) = stylesheet.minify(MinifyOptions::default()) { + panic_with_test_error("test_with_printer_options", "minify", source, e); + } + let res = match stylesheet.to_css(options) { + Ok(res) => res, + Err(e) => panic_with_test_error("test_with_printer_options", "print", source, e), + }; assert_eq!(res.code, expected); } + #[track_caller] fn minify_test(source: &str, expected: &str) { minify_test_with_options(source, expected, ParserOptions::default()) } #[track_caller] fn minify_test_with_options<'i, 'o>(source: &'i str, expected: &'i str, options: ParserOptions<'o, 'i>) { - let mut stylesheet = StyleSheet::parse(&source, options.clone()).unwrap(); - stylesheet.minify(MinifyOptions::default()).unwrap(); - let res = stylesheet - .to_css(PrinterOptions { - minify: true, - ..PrinterOptions::default() - }) - .unwrap(); + let mut stylesheet = match StyleSheet::parse(&source, options) { + Ok(stylesheet) => stylesheet, + Err(e) => panic_with_test_error("minify_test_with_options", "parse", source, e), + }; + if let Err(e) = stylesheet.minify(MinifyOptions::default()) { + panic_with_test_error("minify_test_with_options", "minify", source, e); + } + let res = match stylesheet.to_css(PrinterOptions { + minify: true, + ..PrinterOptions::default() + }) { + Ok(res) => res, + Err(e) => panic_with_test_error("minify_test_with_options", "print", source, e), + }; assert_eq!(res.code, expected); } + #[track_caller] fn minify_error_test_with_options<'i, 'o>( source: &'i str, error: MinifyErrorKind, options: ParserOptions<'o, 'i>, ) { - let mut stylesheet = StyleSheet::parse(&source, options.clone()).unwrap(); + let mut stylesheet = match StyleSheet::parse(&source, options) { + Ok(stylesheet) => stylesheet, + Err(e) => panic_with_test_error("minify_error_test_with_options", "parse", source, e), + }; match stylesheet.minify(MinifyOptions::default()) { Err(e) => assert_eq!(e.kind, error), - _ => unreachable!(), + Ok(()) => panic!( + "minify_error_test_with_options: expected minify error {:?}, but minification succeeded.\nsource:\n{source}", + error + ), } } + #[track_caller] fn prefix_test(source: &str, expected: &str, targets: Browsers) { - let mut stylesheet = StyleSheet::parse(&source, ParserOptions::default()).unwrap(); - stylesheet - .minify(MinifyOptions { - targets: targets.into(), - ..MinifyOptions::default() - }) - .unwrap(); - let res = stylesheet - .to_css(PrinterOptions { - targets: targets.into(), - ..PrinterOptions::default() - }) - .unwrap(); + let mut stylesheet = match StyleSheet::parse(&source, ParserOptions::default()) { + Ok(stylesheet) => stylesheet, + Err(e) => panic_with_test_error("prefix_test", "parse", source, e), + }; + if let Err(e) = stylesheet.minify(MinifyOptions { + targets: targets.into(), + ..MinifyOptions::default() + }) { + panic_with_test_error("prefix_test", "minify", source, e); + } + let res = match stylesheet.to_css(PrinterOptions { + targets: targets.into(), + ..PrinterOptions::default() + }) { + Ok(res) => res, + Err(e) => panic_with_test_error("prefix_test", "print", source, e), + }; assert_eq!(res.code, expected); } + #[track_caller] fn attr_test(source: &str, expected: &str, minify: bool, targets: Option) { - let mut attr = StyleAttribute::parse(source, ParserOptions::default()).unwrap(); + let mut attr = match StyleAttribute::parse(source, ParserOptions::default()) { + Ok(attr) => attr, + Err(e) => panic_with_test_error("attr_test", "parse", source, e), + }; attr.minify(MinifyOptions { targets: targets.into(), ..MinifyOptions::default() }); - let res = attr - .to_css(PrinterOptions { - targets: targets.into(), - minify, - ..PrinterOptions::default() - }) - .unwrap(); + let res = match attr.to_css(PrinterOptions { + targets: targets.into(), + minify, + ..PrinterOptions::default() + }) { + Ok(res) => res, + Err(e) => panic_with_test_error("attr_test", "print", source, e), + }; assert_eq!(res.code, expected); } + #[track_caller] fn nesting_test(source: &str, expected: &str) { nesting_test_with_targets( source, @@ -160,30 +208,45 @@ mod tests { ); } + #[track_caller] fn nesting_test_with_targets(source: &str, expected: &str, targets: Targets) { - let mut stylesheet = StyleSheet::parse(&source, ParserOptions::default()).unwrap(); - stylesheet - .minify(MinifyOptions { - targets, - ..MinifyOptions::default() - }) - .unwrap(); - let res = stylesheet - .to_css(PrinterOptions { - targets, - ..PrinterOptions::default() - }) - .unwrap(); + let mut stylesheet = match StyleSheet::parse(&source, ParserOptions::default()) { + Ok(stylesheet) => stylesheet, + Err(e) => panic_with_test_error("nesting_test_with_targets", "parse", source, e), + }; + if let Err(e) = stylesheet.minify(MinifyOptions { + targets, + ..MinifyOptions::default() + }) { + panic_with_test_error("nesting_test_with_targets", "minify", source, e); + } + let res = match stylesheet.to_css(PrinterOptions { + targets, + ..PrinterOptions::default() + }) { + Ok(res) => res, + Err(e) => panic_with_test_error("nesting_test_with_targets", "print", source, e), + }; assert_eq!(res.code, expected); } + #[track_caller] fn nesting_test_no_targets(source: &str, expected: &str) { - let mut stylesheet = StyleSheet::parse(&source, ParserOptions::default()).unwrap(); - stylesheet.minify(MinifyOptions::default()).unwrap(); - let res = stylesheet.to_css(PrinterOptions::default()).unwrap(); + let mut stylesheet = match StyleSheet::parse(&source, ParserOptions::default()) { + Ok(stylesheet) => stylesheet, + Err(e) => panic_with_test_error("nesting_test_no_targets", "parse", source, e), + }; + if let Err(e) = stylesheet.minify(MinifyOptions::default()) { + panic_with_test_error("nesting_test_no_targets", "minify", source, e); + } + let res = match stylesheet.to_css(PrinterOptions::default()) { + Ok(res) => res, + Err(e) => panic_with_test_error("nesting_test_no_targets", "print", source, e), + }; assert_eq!(res.code, expected); } + #[track_caller] fn css_modules_test<'i>( source: &'i str, expected: &str, @@ -192,47 +255,64 @@ mod tests { config: crate::css_modules::Config<'i>, minify: bool, ) { - let mut stylesheet = StyleSheet::parse( + let mut stylesheet = match StyleSheet::parse( &source, ParserOptions { filename: "test.css".into(), css_modules: Some(config), ..ParserOptions::default() }, - ) - .unwrap(); - stylesheet.minify(MinifyOptions::default()).unwrap(); - let res = stylesheet - .to_css(PrinterOptions { - minify, - ..Default::default() - }) - .unwrap(); + ) { + Ok(stylesheet) => stylesheet, + Err(e) => panic_with_test_error("css_modules_test", "parse", source, e), + }; + if let Err(e) = stylesheet.minify(MinifyOptions::default()) { + panic_with_test_error("css_modules_test", "minify", source, e); + } + let res = match stylesheet.to_css(PrinterOptions { + minify, + ..Default::default() + }) { + Ok(res) => res, + Err(e) => panic_with_test_error("css_modules_test", "print", source, e), + }; assert_eq!(res.code, expected); - assert_eq!(res.exports.unwrap(), expected_exports); - assert_eq!(res.references.unwrap(), expected_references); + match res.exports { + Some(exports) => assert_eq!(exports, expected_exports), + None => panic!("css_modules_test: expected CSS module exports, but got None.\nsource:\n{source}"), + } + match res.references { + Some(references) => assert_eq!(references, expected_references), + None => panic!("css_modules_test: expected CSS module references, but got None.\nsource:\n{source}"), + } } + #[track_caller] fn custom_media_test(source: &str, expected: &str) { - let mut stylesheet = StyleSheet::parse( + let mut stylesheet = match StyleSheet::parse( &source, ParserOptions { flags: ParserFlags::CUSTOM_MEDIA, ..ParserOptions::default() }, - ) - .unwrap(); - stylesheet - .minify(MinifyOptions { - targets: Browsers { - chrome: Some(95 << 16), - ..Browsers::default() - } - .into(), - ..MinifyOptions::default() - }) - .unwrap(); - let res = stylesheet.to_css(PrinterOptions::default()).unwrap(); + ) { + Ok(stylesheet) => stylesheet, + Err(e) => panic_with_test_error("custom_media_test", "parse", source, e), + }; + if let Err(e) = stylesheet.minify(MinifyOptions { + targets: Browsers { + chrome: Some(95 << 16), + ..Browsers::default() + } + .into(), + ..MinifyOptions::default() + }) { + panic_with_test_error("custom_media_test", "minify", source, e); + } + let res = match stylesheet.to_css(PrinterOptions::default()) { + Ok(res) => res, + Err(e) => panic_with_test_error("custom_media_test", "print", source, e), + }; assert_eq!(res.code, expected); } @@ -260,7 +340,14 @@ mod tests { Err(e) => unreachable!("parser error should be recovered, but got {e:?}"), } } - Arc::into_inner(warnings).unwrap().into_inner().unwrap() + let warnings = match Arc::into_inner(warnings) { + Some(warnings) => warnings, + None => panic!("error_recovery_test: expected a single Arc owner for warnings"), + }; + match warnings.into_inner() { + Ok(warnings) => warnings, + Err(e) => panic!("error_recovery_test: warnings lock is poisoned: {e}"), + } } fn css_modules_error_test(source: &str, error: ParserError) { diff --git a/src/test_helpers.rs b/src/test_helpers.rs new file mode 100644 index 00000000..ad5df9ec --- /dev/null +++ b/src/test_helpers.rs @@ -0,0 +1,62 @@ +use crate::error::{Error, ErrorLocation}; + +pub(crate) fn format_source_location_context(source: &str, loc: &ErrorLocation) -> String { + let lines: Vec<&str> = source.lines().collect(); + let line_idx = loc.line as usize; + let display_line = line_idx + 1; + let display_column = if loc.column == 0 { 1 } else { loc.column as usize }; + + let mut output = format!("css location: line {display_line}, column {display_column}"); + + if lines.is_empty() { + output.push_str("\n (source is empty)"); + return output; + } + + if line_idx >= lines.len() { + output.push_str(&format!("\n (line is out of range; source has {} line(s))", lines.len())); + return output; + } + + let start = line_idx.saturating_sub(1); + let end = usize::min(line_idx + 1, lines.len() - 1); + output.push_str("\ncontext:"); + + for i in start..=end { + let line = lines[i]; + output.push_str(&format!("\n{:>6} | {}", i + 1, line)); + if i == line_idx { + let caret_pos = display_column.saturating_sub(1); + let line_char_count = line.chars().count(); + let mut marker = String::with_capacity(caret_pos.max(line_char_count)); + for ch in line.chars().take(caret_pos) { + marker.push(if ch == '\t' { '\t' } else { ' ' }); + } + if caret_pos > line_char_count { + marker.push_str(&" ".repeat(caret_pos - line_char_count)); + } + output.push_str(&format!("\n | {}^", marker)); + } + } + + output +} + +#[track_caller] +pub(crate) fn panic_with_test_error( + helper: &str, + stage: &str, + source: &str, + error: Error, +) -> ! { + let caller = std::panic::Location::caller(); + let location = match error.loc.as_ref() { + Some(loc) => format_source_location_context(source, loc), + None => "css location: ".to_string(), + }; + panic!( + "{helper}: {stage} failed\ncaller: {}:{}\nerror: {error}\nerror(debug): {error:?}\n{location}\nsource:\n{source}", + caller.file(), + caller.line(), + ); +} From 836741b5ecc4ae70289b2a26ce265df6c8486357 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Sun, 8 Mar 2026 21:02:36 -0700 Subject: [PATCH 3/5] update compat data --- package.json | 6 +++--- src/compat.rs | 51 +++++++++++++++++++++++---------------------------- yarn.lock | 25 +++++++++++++++---------- 3 files changed, 41 insertions(+), 41 deletions(-) diff --git a/package.json b/package.json index af17c0a5..1aa7cd3e 100644 --- a/package.json +++ b/package.json @@ -48,10 +48,10 @@ "@codemirror/lang-javascript": "^6.1.2", "@codemirror/lint": "^6.1.0", "@codemirror/theme-one-dark": "^6.1.0", - "@mdn/browser-compat-data": "~7.2.4", + "@mdn/browser-compat-data": "~7.3.6", "@napi-rs/cli": "^2.14.0", - "autoprefixer": "^10.4.23", - "caniuse-lite": "^1.0.30001765", + "autoprefixer": "^10.4.27", + "caniuse-lite": "^1.0.30001777", "codemirror": "^6.0.1", "cssnano": "^7.0.6", "esbuild": "^0.19.8", diff --git a/src/compat.rs b/src/compat.rs index f0c828d7..0f07c00e 100644 --- a/src/compat.rs +++ b/src/compat.rs @@ -452,7 +452,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9371648 { + if version < 9502720 { return false; } } @@ -544,7 +544,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9371648 { + if version < 9502720 { return false; } } @@ -589,7 +589,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9371648 { + if version < 9502720 { return false; } } @@ -634,7 +634,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9371648 { + if version < 9502720 { return false; } } @@ -679,7 +679,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9371648 { + if version < 9502720 { return false; } } @@ -724,7 +724,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9371648 { + if version < 9502720 { return false; } } @@ -769,7 +769,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9371648 { + if version < 9502720 { return false; } } @@ -814,7 +814,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9371648 { + if version < 9502720 { return false; } } @@ -906,7 +906,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9371648 { + if version < 9502720 { return false; } } @@ -951,7 +951,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9371648 { + if version < 9502720 { return false; } } @@ -1021,7 +1021,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9371648 { + if version < 9502720 { return false; } } @@ -1066,7 +1066,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9371648 { + if version < 9502720 { return false; } } @@ -1156,7 +1156,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9371648 { + if version < 9502720 { return false; } } @@ -1201,7 +1201,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9371648 { + if version < 9502720 { return false; } } @@ -1225,11 +1225,6 @@ impl Feature { return false; } } - if let Some(version) = browsers.firefox { - if version < 5636096 { - return false; - } - } if let Some(version) = browsers.opera { if version < 6291456 { return false; @@ -1251,11 +1246,11 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9371648 { + if version < 9502720 { return false; } } - if browsers.ie.is_some() { + if browsers.firefox.is_some() || browsers.ie.is_some() { return false; } } @@ -1338,7 +1333,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9371648 { + if version < 9502720 { return false; } } @@ -1383,7 +1378,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9371648 { + if version < 9502720 { return false; } } @@ -1428,7 +1423,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9371648 { + if version < 9502720 { return false; } } @@ -1473,7 +1468,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9371648 { + if version < 9502720 { return false; } } @@ -1518,7 +1513,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9371648 { + if version < 9502720 { return false; } } @@ -1563,7 +1558,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9371648 { + if version < 9502720 { return false; } } @@ -1630,7 +1625,7 @@ impl Feature { } } if let Some(version) = browsers.android { - if version < 9371648 { + if version < 9502720 { return false; } } diff --git a/yarn.lock b/yarn.lock index 2fe9dbf6..b273df78 100644 --- a/yarn.lock +++ b/yarn.lock @@ -570,10 +570,10 @@ resolved "https://registry.yarnpkg.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz#775374306116d51c0c500b8c4face0f9a04752d8" integrity sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g== -"@mdn/browser-compat-data@~7.2.4": - version "7.2.4" - resolved "https://registry.yarnpkg.com/@mdn/browser-compat-data/-/browser-compat-data-7.2.4.tgz#f14464789fc03ba07a19953ec5154b903fcfaa98" - integrity sha512-qlZKXL9qvrxn2UNnlgjupk1sjz0X59oRvGBBaPqYtIxiM0q4m2D5ZF9P/T0qWm0gZYzb5jMZ1TpUViZZVv7cMg== +"@mdn/browser-compat-data@~7.3.6": + version "7.3.6" + resolved "https://registry.yarnpkg.com/@mdn/browser-compat-data/-/browser-compat-data-7.3.6.tgz#8038dc301e5b9dc7112448698ad2e632461d9ddc" + integrity sha512-eLTmNSxv2DaDO1Hq7C9lwQbThlF5+vMMUQfIl6xRPSC2q6EcFXhim4Mc9uxHNxcPvDFCdB3qviMFDdAzQVhYcw== "@mischnic/json-sourcemap@^0.1.0": version "0.1.1" @@ -1505,13 +1505,13 @@ at-least-node@^1.0.0: resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== -autoprefixer@^10.4.23: - version "10.4.23" - resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.23.tgz#c6aa6db8e7376fcd900f9fd79d143ceebad8c4e6" - integrity sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA== +autoprefixer@^10.4.27: + version "10.4.27" + resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.27.tgz#51ea301a5c3c5f8642f8e564759c4f573be486f2" + integrity sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA== dependencies: browserslist "^4.28.1" - caniuse-lite "^1.0.30001760" + caniuse-lite "^1.0.30001774" fraction.js "^5.3.4" picocolors "^1.1.1" postcss-value-parser "^4.2.0" @@ -1659,11 +1659,16 @@ caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001688: resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001690.tgz#f2d15e3aaf8e18f76b2b8c1481abde063b8104c8" integrity sha512-5ExiE3qQN6oF8Clf8ifIDcMRCRE/dMGcETG/XGMD8/XiXm6HXQgQTh1yZYLXXpSOsEUlJm1Xr7kGULZTuGtP/w== -caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001760, caniuse-lite@^1.0.30001765: +caniuse-lite@^1.0.30001759: version "1.0.30001765" resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001765.tgz#4a78d8a797fd4124ebaab2043df942eb091648ee" integrity sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ== +caniuse-lite@^1.0.30001774, caniuse-lite@^1.0.30001777: + version "1.0.30001777" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001777.tgz#028f21e4b2718d138b55e692583e6810ccf60691" + integrity sha512-tmN+fJxroPndC74efCdp12j+0rk0RHwV5Jwa1zWaFVyw2ZxAuPeG8ZgWC3Wz7uSjT3qMRQ5XHZ4COgQmsCMJAQ== + chalk@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" From 7f8a861bdee476fe90c89a8badeb3fd33a99c51a Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Sun, 8 Mar 2026 21:06:27 -0700 Subject: [PATCH 4/5] v1.32.0 --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- napi/Cargo.toml | 4 ++-- node/Cargo.toml | 2 +- package.json | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bb4bb284..a338b9be 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -754,7 +754,7 @@ dependencies = [ [[package]] name = "lightningcss" -version = "1.0.0-alpha.70" +version = "1.0.0-alpha.71" dependencies = [ "ahash 0.8.12", "assert_cmd", @@ -802,7 +802,7 @@ dependencies = [ [[package]] name = "lightningcss-napi" -version = "0.4.7" +version = "0.4.8" dependencies = [ "crossbeam-channel", "cssparser", diff --git a/Cargo.toml b/Cargo.toml index c113a392..ef0b3488 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ members = [ [package] authors = ["Devon Govett "] name = "lightningcss" -version = "1.0.0-alpha.70" +version = "1.0.0-alpha.71" description = "A CSS parser, transformer, and minifier" license = "MPL-2.0" edition = "2021" diff --git a/napi/Cargo.toml b/napi/Cargo.toml index 789062ea..37492595 100644 --- a/napi/Cargo.toml +++ b/napi/Cargo.toml @@ -1,7 +1,7 @@ [package] authors = ["Devon Govett "] name = "lightningcss-napi" -version = "0.4.7" +version = "0.4.8" description = "Node-API bindings for Lightning CSS" license = "MPL-2.0" repository = "https://github.com/parcel-bundler/lightningcss" @@ -17,7 +17,7 @@ serde = { version = "1.0.201", features = ["derive"] } serde-content = { version = "0.1.2", features = ["serde"] } serde_bytes = "0.11.5" cssparser = "0.33.0" -lightningcss = { version = "1.0.0-alpha.70", path = "../", features = [ +lightningcss = { version = "1.0.0-alpha.71", path = "../", features = [ "nodejs", "serde", ] } diff --git a/node/Cargo.toml b/node/Cargo.toml index b5c7505c..2e809cfe 100644 --- a/node/Cargo.toml +++ b/node/Cargo.toml @@ -9,7 +9,7 @@ publish = false crate-type = ["cdylib"] [dependencies] -lightningcss-napi = { version = "0.4.7", path = "../napi", features = [ +lightningcss-napi = { version = "0.4.8", path = "../napi", features = [ "bundler", "visitor", ] } diff --git a/package.json b/package.json index 1aa7cd3e..02b427aa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "lightningcss", - "version": "1.31.1", + "version": "1.32.0", "license": "MPL-2.0", "description": "A CSS parser, transformer, and minifier written in Rust", "main": "node/index.js", From 3023317bec82e2f1f3f5c206c3261f4307578e49 Mon Sep 17 00:00:00 2001 From: Devon Govett Date: Sun, 8 Mar 2026 21:43:19 -0700 Subject: [PATCH 5/5] Add docs for external resolvers --- website/pages/bundling.md | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/website/pages/bundling.md b/website/pages/bundling.md index 07e74e06..b06a02ba 100644 --- a/website/pages/bundling.md +++ b/website/pages/bundling.md @@ -157,6 +157,10 @@ body { background: green } The `bundleAsync` API is an asynchronous version of `bundle`, which also accepts a custom `resolver` object. This allows you to provide custom JavaScript functions for resolving `@import` specifiers to file paths, and reading files from the file system (or another source). The `read` and `resolve` functions are both optional, and may either return a string synchronously, or a Promise for asynchronous resolution. +`resolve` may also return a `{external: string}` object to mark an `@import` as external. This will preserve the `@import` in the output instead of bundling it. The string provided to the `external` property represents the target URL to import, which may be the original specifier or a different value. + +Note that using a custom resolver can slow down bundling significantly, especially when reading files asynchronously. Use `readFileSync` rather than `readFile` if possible for better performance, or omit either of the methods if you don't need to override the default behavior. + ```js import { bundleAsync } from 'lightningcss'; @@ -168,10 +172,27 @@ let { code, map } = await bundleAsync({ return fs.readFileSync(filePath, 'utf8'); }, resolve(specifier, from) { + if (/^https?:/.test(specifier)) { + return {external: specifier}; + } return path.resolve(path.dirname(from), specifier); } } }); ``` -Note that using a custom resolver can slow down bundling significantly, especially when reading files asynchronously. Use `readFileSync` rather than `readFile` if possible for better performance, or omit either of the methods if you don't need to override the default behavior. +
+ +**Note:** External imports must be placed before all bundled imports in the source code. CSS does not support interleaving `@import` rules with other rules, so this is required to preserve the behavior of the source code. + +```css +@import "bundled.css"; +@import "https://example.com/external.css"; /* ❌ */ +``` + +```css +@import "https://example.com/external.css"; /* ✅ */ +@import "bundled.css"; +``` + +