From 01b1b662546d8c278efa64ee8b5b6731d056f3d3 Mon Sep 17 00:00:00 2001 From: Marco Roth Date: Wed, 14 Oct 2026 15:04:10 +0900 Subject: [PATCH] Accept minor and patch versions in `targets` --- README.md | 14 ++++++- rust/src/lib.rs | 6 +-- rust/src/options.rs | 55 ++++++++++++++++++++++++--- sig/lightningcss/types.rbs | 22 ++++++----- test/lightningcss/targets_test.rb | 62 +++++++++++++++++++++++++++++++ 5 files changed, 140 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 2490ade..cc1e078 100644 --- a/README.md +++ b/README.md @@ -57,13 +57,25 @@ LightningCSS.minify(".a { color: #ff0000 }") #### Targeting browsers -Give the browsers you support, each as its major version. Anything they cannot read is lowered, with the original kept after it so that a browser which understands it still wins. +Give the browsers you support, each with the oldest version you support. Anything they cannot read is lowered, with the original kept after it so that a browser which understands it still wins. ```ruby LightningCSS.transform(".a { color: lab(50% 40 59) }", targets: { chrome: 80 }, minify: true).code #=> ".a{color:#bf5702;color:lab(50% 40 59)}" ``` +A version is an `Integer` for a major version, or a `Float` or `String` for a minor or patch release. Several features first shipped in a minor Safari release, so the minor version can change the output. + +```ruby +LightningCSS.transform(".a { color: oklch(60% .2 30) }", targets: { safari: 15 }, minify: true).code +#=> ".a{color:lab(51.6908% 61.9679 48.0969)}" + +LightningCSS.transform(".a { color: oklch(60% .2 30) }", targets: { safari: "15.4" }, minify: true).code +#=> ".a{color:oklch(60% .2 30)}" +``` + +Prefer a `String` for a minor version above 9, since `16.10` as a `Float` is `16.1`. + #### Bundling `bundle` resolves the `@import` statements a stylesheet was written with by reading the files they name, so it takes a path on disk to the entry stylesheet. diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 64a4cc5..2cf295b 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -172,7 +172,7 @@ fn transform_source(code: &str, options: &TransformOptions) -> Result Result Result, pub minify: bool, pub error_recovery: bool, - pub targets: Option>, + pub targets: Option>, pub css_modules: Option, pub scope: Option, } @@ -60,16 +60,61 @@ impl CssModulesOptions { } } +#[derive(Debug, Deserialize)] +#[serde(untagged)] +pub enum Version { + Major(u32), + Float(f64), + String(String), +} + +impl Version { + /// Encodes a version the way Lightning CSS reads it, as `major << 16 | minor << 8 | patch`. + fn encode(&self) -> Option { + match self { + Version::Major(major) => (*major <= 0xffff).then(|| major << 16), + Version::Float(float) => Self::parse(&float.to_string()), + Version::String(string) => Self::parse(string), + } + } + + fn parse(version: &str) -> Option { + let mut parts = version.trim().split('.'); + + let major: u16 = parts.next()?.parse().ok()?; + let minor: u8 = parts.next().map_or(Some(0), |part| part.parse().ok())?; + let patch: u8 = parts.next().map_or(Some(0), |part| part.parse().ok())?; + + if parts.next().is_some() { + return None; + } + + Some(u32::from(major) << 16 | u32::from(minor) << 8 | u32::from(patch)) + } + + fn describe(&self) -> String { + match self { + Version::Major(major) => major.to_string(), + Version::Float(float) => float.to_string(), + Version::String(string) => format!("{string:?}"), + } + } +} + impl TransformOptions { - pub fn to_targets(&self) -> Targets { + pub fn to_targets(&self) -> Result { let Some(versions) = &self.targets else { - return Targets::default(); + return Ok(Targets::default()); }; let mut browsers = Browsers::default(); for (name, version) in versions { - let encoded = Some(version << 16); + let encoded = Some( + version + .encode() + .ok_or_else(|| format!("Invalid version {} for {name} in targets", version.describe()))?, + ); match name.as_str() { "android" => browsers.android = encoded, @@ -85,7 +130,7 @@ impl TransformOptions { } } - Targets::from(browsers) + Ok(Targets::from(browsers)) } } diff --git a/sig/lightningcss/types.rbs b/sig/lightningcss/types.rbs index 07ba911..f60fed8 100644 --- a/sig/lightningcss/types.rbs +++ b/sig/lightningcss/types.rbs @@ -1,17 +1,19 @@ # Note: This file is hand written, `rbs-inline` does not generate this file. module LightningCSS + type version = Integer | Float | String + type browsers = { - ?android: Integer, - ?chrome: Integer, - ?edge: Integer, - ?firefox: Integer, - ?ie: Integer, - ?ios: Integer, - ?ios_saf: Integer, - ?opera: Integer, - ?safari: Integer, - ?samsung: Integer + ?android: version, + ?chrome: version, + ?edge: version, + ?firefox: version, + ?ie: version, + ?ios: version, + ?ios_saf: version, + ?opera: version, + ?safari: version, + ?samsung: version } type css_modules = bool | { diff --git a/test/lightningcss/targets_test.rb b/test/lightningcss/targets_test.rb index 9ca3a8a..f484832 100644 --- a/test/lightningcss/targets_test.rb +++ b/test/lightningcss/targets_test.rb @@ -35,6 +35,68 @@ class TargetsTest < Minitest::Spec assert_equal ".a {\n -webkit-user-select: none;\n user-select: none;\n}\n", code end + OKLCH = ".a { color: oklch(60% .2 30) }" + + test "reads an integer as a major version" do + code = LightningCSS.transform(OKLCH, targets: { safari: 15 }, minify: true).code + + assert_equal ".a{color:lab(51.6908% 61.9679 48.0969)}", code + end + + test "reads a float as a major and minor version" do + code = LightningCSS.transform(OKLCH, targets: { safari: 15.4 }, minify: true).code + + assert_equal ".a{color:oklch(60% .2 30)}", code + end + + test "reads a string as a major and minor version" do + code = LightningCSS.transform(OKLCH, targets: { safari: "15.4" }, minify: true).code + + assert_equal ".a{color:oklch(60% .2 30)}", code + end + + test "reads a string with a patch version" do + code = LightningCSS.transform(OKLCH, targets: { ios_saf: "15.4.1" }, minify: true).code + + assert_equal ".a{color:oklch(60% .2 30)}", code + end + + test "reads a minor version for a property it would otherwise prefix" do + assert_equal ".a{-webkit-appearance:none;appearance:none}", LightningCSS.transform(".a { appearance: none }", targets: { safari: 15 }, minify: true).code + assert_equal ".a{appearance:none}", LightningCSS.transform(".a { appearance: none }", targets: { safari: "15.4" }, minify: true).code + end + + test "reads a minor version for a shorthand it would otherwise expand" do + assert_equal ".a{top:0;bottom:0;left:0;right:0}", LightningCSS.transform(".a { inset: 0 }", targets: { safari: 14 }, minify: true).code + assert_equal ".a{inset:0}", LightningCSS.transform(".a { inset: 0 }", targets: { safari: 14.1 }, minify: true).code + end + + test "applies a minor version to a style attribute" do + code = LightningCSS.transform_style_attribute("appearance: none", targets: { safari: "15.4" }, minify: true).code + + assert_equal "appearance:none", code + end + + ["15.a", "15.4.1.2", "", "-1", "15.256", "65536"].each do |version| + test "raises an option error for the version #{version.inspect}" do + error = assert_raises(OptionError) { LightningCSS.transform(".a{}", targets: { safari: version }) } + + assert_equal "Invalid version #{version.inspect} for safari in targets", error.message + end + end + + test "raises an option error for a negative version" do + error = assert_raises(OptionError) { LightningCSS.transform(".a{}", targets: { safari: -1 }) } + + assert_equal "Invalid version -1 for safari in targets", error.message + end + + test "raises an option error for a major version too large to encode" do + error = assert_raises(OptionError) { LightningCSS.transform(".a{}", targets: { safari: 65_536 }) } + + assert_equal "Invalid version 65536 for safari in targets", error.message + end + test "applies its targets to a bundle it was not asked to minify" do Dir.mktmpdir do |dir| path = File.join(dir, "a.css")