diff --git a/Cargo.lock b/Cargo.lock index bb4bb2840..a338b9be4 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 c113a3921..ef0b3488b 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 789062ea6..37492595e 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/napi/src/lib.rs b/napi/src/lib.rs index dff488056..f43a8d46e 100644 --- a/napi/src/lib.rs +++ b/napi/src/lib.rs @@ -121,8 +121,8 @@ pub fn transform_style_attribute(ctx: CallContext) -> napi::Result { mod bundle { use super::*; use crossbeam_channel::{self, Receiver, Sender}; - use lightningcss::bundler::FileProvider; - use napi::{Env, JsFunction, JsString, NapiRaw}; + use lightningcss::bundler::{FileProvider, ResolveResult}; + use napi::{Env, JsBoolean, JsFunction, JsString, NapiRaw}; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::sync::Mutex; @@ -169,6 +169,7 @@ mod bundle { // Allocate a single channel per thread to communicate with the JS thread. thread_local! { static CHANNEL: (Sender>, Receiver>) = crossbeam_channel::unbounded(); + static RESOLVER_CHANNEL: (Sender>, Receiver>) = crossbeam_channel::unbounded(); } impl SourceProvider for JsSourceProvider { @@ -203,9 +204,9 @@ mod bundle { } } - fn resolve(&self, specifier: &str, originating_file: &Path) -> Result { + fn resolve(&self, specifier: &str, originating_file: &Path) -> Result { if let Some(resolve) = &self.resolve { - return CHANNEL.with(|channel| { + return RESOLVER_CHANNEL.with(|channel| { let message = ResolveMessage { specifier: specifier.to_owned(), originating_file: originating_file.to_str().unwrap().to_owned(), @@ -213,22 +214,18 @@ mod bundle { }; resolve.call(message, ThreadsafeFunctionCallMode::Blocking); - let result = channel.1.recv().unwrap(); - match result { - Ok(result) => Ok(PathBuf::from_str(&result).unwrap()), - Err(e) => Err(e), - } + channel.1.recv().unwrap() }); } - Ok(originating_file.with_file_name(specifier)) + Ok(originating_file.with_file_name(specifier).into()) } } struct ResolveMessage { specifier: String, originating_file: String, - tx: Sender>, + tx: Sender>, } struct ReadMessage { @@ -241,7 +238,11 @@ mod bundle { tx: Sender>, } - fn await_promise(env: Env, result: JsUnknown, tx: Sender>) -> napi::Result<()> { + fn await_promise(env: Env, result: JsUnknown, tx: Sender>, parse: Cb) -> napi::Result<()> + where + T: 'static, + Cb: 'static + Fn(JsUnknown) -> Result, + { // If the result is a promise, wait for it to resolve, and send the result to the channel. // Otherwise, send the result immediately. if result.is_promise()? { @@ -249,9 +250,8 @@ mod bundle { let then: JsFunction = get_named_property(&result, "then")?; let tx2 = tx.clone(); let cb = env.create_function_from_closure("callback", move |ctx| { - let res = ctx.get::(0)?.into_utf8()?; - let s = res.into_owned()?; - tx.send(Ok(s)).unwrap(); + let res = parse(ctx.get::(0)?)?; + tx.send(Ok(res)).unwrap(); ctx.env.get_undefined() })?; let eb = env.create_function_from_closure("error_callback", move |ctx| { @@ -261,10 +261,8 @@ mod bundle { })?; then.call(Some(&result), &[cb, eb])?; } else { - let result: JsString = result.try_into()?; - let utf8 = result.into_utf8()?; - let s = utf8.into_owned()?; - tx.send(Ok(s)).unwrap(); + let result = parse(result)?; + tx.send(Ok(result)).unwrap(); } Ok(()) @@ -274,10 +272,12 @@ mod bundle { let specifier = ctx.env.create_string(&ctx.value.specifier)?; let originating_file = ctx.env.create_string(&ctx.value.originating_file)?; let result = ctx.callback.unwrap().call(None, &[specifier, originating_file])?; - await_promise(ctx.env, result, ctx.value.tx) + await_promise(ctx.env, result, ctx.value.tx, move |unknown| { + ctx.env.from_js_value(unknown) + }) } - fn handle_error(tx: Sender>, res: napi::Result<()>) -> napi::Result<()> { + fn handle_error(tx: Sender>, res: napi::Result<()>) -> napi::Result<()> { match res { Ok(_) => Ok(()), Err(e) => { @@ -295,7 +295,9 @@ mod bundle { fn read_on_js_thread(ctx: ThreadSafeCallContext) -> napi::Result<()> { let file = ctx.env.create_string(&ctx.value.file)?; let result = ctx.callback.unwrap().call(None, &[file])?; - await_promise(ctx.env, result, ctx.value.tx) + await_promise(ctx.env, result, ctx.value.tx, |unknown| { + JsString::try_from(unknown)?.into_utf8()?.into_owned() + }) } fn read_on_js_thread_wrapper(ctx: ThreadSafeCallContext) -> napi::Result<()> { @@ -421,10 +423,10 @@ mod bundle { #[cfg(target_arch = "wasm32")] mod bundle { use super::*; + use lightningcss::bundler::ResolveResult; use napi::{Env, JsFunction, JsString, NapiRaw, NapiValue, Ref}; use std::cell::UnsafeCell; - use std::path::{Path, PathBuf}; - use std::str::FromStr; + use std::path::Path; pub fn bundle(ctx: CallContext) -> napi::Result { let opts = ctx.get::(0)?; @@ -497,7 +499,7 @@ mod bundle { ); } - fn get_result(env: Env, mut value: JsUnknown) -> napi::Result { + fn get_result(env: Env, mut value: JsUnknown) -> napi::Result { if value.is_promise()? { let mut result = std::ptr::null_mut(); let mut error = std::ptr::null_mut(); @@ -513,7 +515,7 @@ mod bundle { value = unsafe { JsUnknown::from_raw(env.raw(), result)? }; } - value.try_into() + Ok(value) } impl SourceProvider for JsSourceProvider { @@ -523,7 +525,9 @@ mod bundle { let read: JsFunction = self.env.get_reference_value_unchecked(&self.read)?; let file = self.env.create_string(file.to_str().unwrap())?; let source: JsUnknown = read.call(None, &[file])?; - let source = get_result(self.env, source)?.into_utf8()?.into_owned()?; + let source = get_result(self.env, source)?; + let source: JsString = source.try_into()?; + let source = source.into_utf8()?.into_owned()?; // cache the result let ptr = Box::into_raw(Box::new(source)); @@ -535,16 +539,17 @@ mod bundle { Ok(unsafe { &*ptr }) } - fn resolve(&self, specifier: &str, originating_file: &Path) -> Result { + fn resolve(&self, specifier: &str, originating_file: &Path) -> Result { if let Some(resolve) = &self.resolve { let resolve: JsFunction = self.env.get_reference_value_unchecked(resolve)?; let specifier = self.env.create_string(specifier)?; let originating_file = self.env.create_string(originating_file.to_str().unwrap())?; let result: JsUnknown = resolve.call(None, &[specifier, originating_file])?; - let result = get_result(self.env, result)?.into_utf8()?; - Ok(PathBuf::from_str(result.as_str()?).unwrap()) + let result = get_result(self.env, result)?; + let result = self.env.from_js_value(result)?; + Ok(result) } else { - Ok(originating_file.with_file_name(specifier)) + Ok(ResolveResult::File(originating_file.with_file_name(specifier))) } } } diff --git a/node/Cargo.toml b/node/Cargo.toml index b5c7505c3..2e809cfe4 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/node/composeVisitors.js b/node/composeVisitors.js index 9d5796e3b..f29934905 100644 --- a/node/composeVisitors.js +++ b/node/composeVisitors.js @@ -1,15 +1,23 @@ // @ts-check /** @typedef {import('./index').Visitor} Visitor */ +/** @typedef {import('./index').VisitorFunction} VisitorFunction */ /** * Composes multiple visitor objects into a single one. - * @param {Visitor[]} visitors - * @return {Visitor} + * @param {(Visitor | VisitorFunction)[]} visitors + * @return {Visitor | VisitorFunction} */ function composeVisitors(visitors) { if (visitors.length === 1) { return visitors[0]; } + + if (visitors.some(v => typeof v === 'function')) { + return (opts) => { + let v = visitors.map(v => typeof v === 'function' ? v(opts) : v); + return composeVisitors(v); + }; + } /** @type Visitor */ let res = {}; @@ -366,7 +374,7 @@ function createArrayVisitor(visitors, apply) { // For each value, call all visitors. If a visitor returns a new value, // we start over, but skip the visitor that generated the value or saw // it before (to avoid cycles). This way, visitors can be composed in any order. - for (let v = 0; v < visitors.length;) { + for (let v = 0; v < visitors.length && i < arr.length;) { if (seen.get(v)) { v++; continue; diff --git a/node/index.d.ts b/node/index.d.ts index 76d405729..6d727d75a 100644 --- a/node/index.d.ts +++ b/node/index.d.ts @@ -63,7 +63,7 @@ export interface TransformOptions { * For optimal performance, visitors should be as specific as possible about what types of values * they care about so that JavaScript has to be called as little as possible. */ - visitor?: Visitor, + visitor?: Visitor | VisitorFunction, /** * Defines how to parse custom CSS at-rules. Each at-rule can have a prelude, defined using a CSS * [syntax string](https://drafts.css-houdini.org/css-properties-values-api/#syntax-strings), and @@ -213,6 +213,13 @@ export interface Visitor { EnvironmentVariableExit?: EnvironmentVariableVisitor | EnvironmentVariableVisitors; } +export type VisitorDependency = FileDependency | GlobDependency; +export interface VisitorOptions { + addDependency: (dep: VisitorDependency) => void +} + +export type VisitorFunction = (options: VisitorOptions) => Visitor; + export interface CustomAtRules { [name: string]: CustomAtRuleDefinition } @@ -358,7 +365,7 @@ export interface DependencyCSSModuleReference { specifier: string } -export type Dependency = ImportDependency | UrlDependency; +export type Dependency = ImportDependency | UrlDependency | FileDependency | GlobDependency; export interface ImportDependency { type: 'import', @@ -384,6 +391,16 @@ export interface UrlDependency { placeholder: string } +export interface FileDependency { + type: 'file', + filePath: string +} + +export interface GlobDependency { + type: 'glob', + glob: string +} + export interface SourceLocation { /** The file path in which the dependency exists. */ filePath: string, @@ -438,7 +455,7 @@ export interface TransformAttributeOptions { * For optimal performance, visitors should be as specific as possible about what types of values * they care about so that JavaScript has to be called as little as possible. */ - visitor?: Visitor + visitor?: Visitor | VisitorFunction } export interface TransformAttributeResult { @@ -474,4 +491,4 @@ export declare function bundleAsync(options: BundleAsyn /** * Composes multiple visitor objects into a single one. */ -export declare function composeVisitors(visitors: Visitor[]): Visitor; +export declare function composeVisitors(visitors: (Visitor | VisitorFunction)[]): Visitor | VisitorFunction; diff --git a/node/index.js b/node/index.js index 011d04b45..6fe25aef4 100644 --- a/node/index.js +++ b/node/index.js @@ -13,16 +13,47 @@ if (process.platform === 'linux') { parts.push('msvc'); } -if (process.env.CSS_TRANSFORMER_WASM) { - module.exports = require(`../pkg`); -} else { - try { - module.exports = require(`lightningcss-${parts.join('-')}`); - } catch (err) { - module.exports = require(`../lightningcss.${parts.join('-')}.node`); - } +let native; +try { + native = require(`lightningcss-${parts.join('-')}`); +} catch (err) { + native = require(`../lightningcss.${parts.join('-')}.node`); } +module.exports.transform = wrap(native.transform); +module.exports.transformStyleAttribute = wrap(native.transformStyleAttribute); +module.exports.bundle = wrap(native.bundle); +module.exports.bundleAsync = wrap(native.bundleAsync); module.exports.browserslistToTargets = require('./browserslistToTargets'); module.exports.composeVisitors = require('./composeVisitors'); module.exports.Features = require('./flags').Features; + +function wrap(call) { + return (options) => { + if (typeof options.visitor === 'function') { + let deps = []; + options.visitor = options.visitor({ + addDependency(dep) { + deps.push(dep); + } + }); + + let result = call(options); + if (result instanceof Promise) { + result = result.then(res => { + if (deps.length) { + res.dependencies ??= []; + res.dependencies.push(...deps); + } + return res; + }); + } else if (deps.length) { + result.dependencies ??= []; + result.dependencies.push(...deps); + } + return result; + } else { + return call(options); + } + }; +} diff --git a/node/src/lib.rs b/node/src/lib.rs index e429b0f2e..822944124 100644 --- a/node/src/lib.rs +++ b/node/src/lib.rs @@ -3,7 +3,7 @@ static GLOBAL: jemallocator::Jemalloc = jemallocator::Jemalloc; use napi::{CallContext, JsObject, JsUnknown}; -use napi_derive::{js_function, module_exports}; +use napi_derive::js_function; #[js_function(1)] fn transform(ctx: CallContext) -> napi::Result { @@ -26,7 +26,7 @@ pub fn bundle_async(ctx: CallContext) -> napi::Result { lightningcss_napi::bundle_async(ctx) } -#[cfg_attr(not(target_arch = "wasm32"), module_exports)] +#[cfg_attr(not(target_arch = "wasm32"), napi_derive::module_exports)] fn init(mut exports: JsObject) -> napi::Result<()> { exports.create_named_method("transform", transform)?; exports.create_named_method("transformStyleAttribute", transform_style_attribute)?; @@ -45,7 +45,6 @@ pub fn register_module() { unsafe fn register(raw_env: napi::sys::napi_env, raw_exports: napi::sys::napi_value) -> napi::Result<()> { use napi::{Env, JsObject, NapiValue}; - let env = Env::from_raw(raw_env); let exports = JsObject::from_raw_unchecked(raw_env, raw_exports); init(exports) } diff --git a/node/test/bundle.test.mjs b/node/test/bundle.test.mjs index 50d113b57..4279e51c8 100644 --- a/node/test/bundle.test.mjs +++ b/node/test/bundle.test.mjs @@ -365,7 +365,7 @@ test('resolve return non-string', async () => { } if (!error) throw new Error(`\`testResolveReturnNonString()\` failed. Expected \`bundleAsync()\` to throw, but it did not.`); - assert.equal(error.message, 'expect String, got: Number'); + assert.equal(error.message, 'data did not match any variant of untagged enum ResolveResult'); assert.equal(error.fileName, 'tests/testdata/foo.css'); assert.equal(error.loc, { line: 1, @@ -414,4 +414,29 @@ test('should support throwing in visitors', async () => { assert.equal(error.message, 'Some error'); }); +test('external import', async () => { + const { code: buffer } = await bundleAsync(/** @type {import('../index').BundleAsyncOptions} */ ({ + filename: 'tests/testdata/has_external.css', + resolver: { + resolve(specifier, originatingFile) { + if (specifier === './does_not_exist.css' || specifier.startsWith('https:')) { + return {external: specifier}; + } + return path.resolve(path.dirname(originatingFile), specifier); + } + } + })); + const code = buffer.toString('utf-8').trim(); + + const expected = ` +@import "https://fonts.googleapis.com/css2?family=Roboto&display=swap"; +@import "./does_not_exist.css"; + +.b { + height: calc(100vh - 64px); +} + `.trim(); + if (code !== expected) throw new Error(`\`testResolver()\` failed. Expected:\n${expected}\n\nGot:\n${code}`); +}); + test.run(); diff --git a/node/test/composeVisitors.test.mjs b/node/test/composeVisitors.test.mjs index 7718ec067..4379cf481 100644 --- a/node/test/composeVisitors.test.mjs +++ b/node/test/composeVisitors.test.mjs @@ -800,4 +800,61 @@ test('StyleSheet', () => { assert.equal(styleSheetExitCalledCount, 2); }); +test('visitor function', () => { + let res = transform({ + filename: 'test.css', + minify: true, + code: Buffer.from(` + @dep "foo.js"; + @dep2 "bar.js"; + + .foo { + width: 32px; + } + `), + visitor: composeVisitors([ + ({addDependency}) => ({ + Rule: { + unknown: { + dep(rule) { + let file = rule.prelude[0].value.value; + addDependency({ + type: 'file', + filePath: file + }); + return []; + } + } + } + }), + ({addDependency}) => ({ + Rule: { + unknown: { + dep2(rule) { + let file = rule.prelude[0].value.value; + addDependency({ + type: 'file', + filePath: file + }); + return []; + } + } + } + }) + ]) + }); + + assert.equal(res.code.toString(), '.foo{width:32px}'); + assert.equal(res.dependencies, [ + { + type: 'file', + filePath: 'foo.js' + }, + { + type: 'file', + filePath: 'bar.js' + } + ]); +}); + test.run(); diff --git a/node/test/visitor.test.mjs b/node/test/visitor.test.mjs index c763b8409..149825b7d 100644 --- a/node/test/visitor.test.mjs +++ b/node/test/visitor.test.mjs @@ -1170,4 +1170,119 @@ test('visit stylesheet', () => { assert.equal(res.code.toString(), '.bar{width:80px}.foo{width:32px}'); }); +test('visitor function', () => { + let res = transform({ + filename: 'test.css', + minify: true, + code: Buffer.from(` + @dep "foo.js"; + + .foo { + width: 32px; + } + `), + visitor: ({addDependency}) => ({ + Rule: { + unknown: { + dep(rule) { + let file = rule.prelude[0].value.value; + addDependency({ + type: 'file', + filePath: file + }); + return []; + } + } + } + }) + }); + + assert.equal(res.code.toString(), '.foo{width:32px}'); + assert.equal(res.dependencies, [{ + type: 'file', + filePath: 'foo.js' + }]); +}); + +test('visitor function works with style attributes', () => { + let res = transformStyleAttribute({ + filename: 'test.css', + minify: true, + code: Buffer.from('height: 12px'), + visitor: ({addDependency}) => ({ + Length() { + addDependency({ + type: 'file', + filePath: 'test.json' + }); + } + }) + }); + + assert.equal(res.dependencies, [{ + type: 'file', + filePath: 'test.json' + }]); +}); + +test('visitor function works with bundler', () => { + let res = bundle({ + filename: 'tests/testdata/a.css', + minify: true, + visitor: ({addDependency}) => ({ + Length() { + addDependency({ + type: 'file', + filePath: 'test.json' + }); + } + }) + }); + + assert.equal(res.dependencies, [ + { + type: 'file', + filePath: 'test.json' + }, + { + type: 'file', + filePath: 'test.json' + }, + { + type: 'file', + filePath: 'test.json' + } + ]); +}); + +test('works with async bundler', async () => { + let res = await bundleAsync({ + filename: 'tests/testdata/a.css', + minify: true, + visitor: ({addDependency}) => ({ + Length() { + addDependency({ + type: 'file', + filePath: 'test.json' + }); + } + }) + }); + + assert.equal(res.dependencies, [ + { + type: 'file', + filePath: 'test.json' + }, + { + type: 'file', + filePath: 'test.json' + }, + { + type: 'file', + filePath: 'test.json' + } + ]); +}); + test.run(); diff --git a/package.json b/package.json index af17c0a58..02b427aa3 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", @@ -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/bundler.rs b/src/bundler.rs index 00988487c..e5009a863 100644 --- a/src/bundler.rs +++ b/src/bundler.rs @@ -79,7 +79,7 @@ enum AtRuleParserValue<'a, T> { struct BundleStyleSheet<'i, 'o, T> { stylesheet: Option>, - dependencies: Vec, + dependencies: Vec, css_modules_deps: Vec, parent_source_index: u32, parent_dep_index: u32, @@ -89,6 +89,33 @@ struct BundleStyleSheet<'i, 'o, T> { loc: Location, } +#[derive(Debug, Clone)] +enum Dependency { + File(u32), + External(String), +} + +/// The result of [SourceProvider::resolve]. +#[derive(Debug)] +#[cfg_attr( + any(feature = "serde", feature = "nodejs"), + derive(serde::Deserialize), + serde(rename_all = "lowercase") +)] +pub enum ResolveResult { + /// An external URL. + External(String), + /// A file path. + #[serde(untagged)] + File(PathBuf), +} + +impl From for ResolveResult { + fn from(path: PathBuf) -> Self { + ResolveResult::File(path) + } +} + /// A trait to provide the contents of files to a Bundler. /// /// See [FileProvider](FileProvider) for an implementation that uses the @@ -102,7 +129,7 @@ pub trait SourceProvider: Send + Sync { /// Resolves the given import specifier to a file path given the file /// which the import originated from. - fn resolve(&self, specifier: &str, originating_file: &Path) -> Result; + fn resolve(&self, specifier: &str, originating_file: &Path) -> Result; } /// Provides an implementation of [SourceProvider](SourceProvider) @@ -136,9 +163,9 @@ impl SourceProvider for FileProvider { Ok(unsafe { &*ptr }) } - fn resolve(&self, specifier: &str, originating_file: &Path) -> Result { + fn resolve(&self, specifier: &str, originating_file: &Path) -> Result { // Assume the specifier is a relative file path and join it with current path. - Ok(originating_file.with_file_name(specifier)) + Ok(originating_file.with_file_name(specifier).into()) } } @@ -162,6 +189,11 @@ pub enum BundleErrorKind<'i, T: std::error::Error> { UnsupportedLayerCombination, /// Unsupported media query boolean logic was encountered. UnsupportedMediaBooleanLogic, + /// An external module was referenced with a CSS module "from" clause. + ReferencedExternalModuleWithCssModuleFrom, + /// An external `@import` was found after a bundled `@import`. + /// This may result in unintended selector order. + ExternalImportAfterBundledImport, /// A custom resolver error. ResolverError(#[cfg_attr(any(feature = "serde", feature = "nodejs"), serde(skip))] T), } @@ -183,6 +215,13 @@ impl<'i, T: std::error::Error> std::fmt::Display for BundleErrorKind<'i, T> { UnsupportedImportCondition => write!(f, "Unsupported import condition"), UnsupportedLayerCombination => write!(f, "Unsupported layer combination in @import"), UnsupportedMediaBooleanLogic => write!(f, "Unsupported boolean logic in @import media query"), + ReferencedExternalModuleWithCssModuleFrom => { + write!(f, "Referenced external module with CSS module \"from\" clause") + } + ExternalImportAfterBundledImport => write!( + f, + "An external `@import` was found after a bundled `@import`. This may result in unintended selector order." + ), ResolverError(err) => std::fmt::Display::fmt(&err, f), } } @@ -265,7 +304,7 @@ where // Phase 3: concatenate. let mut rules: Vec> = Vec::new(); - self.inline(&mut rules); + self.inline(&mut rules)?; let sources = self .stylesheets @@ -428,7 +467,7 @@ where } // Collect and load dependencies for this stylesheet in parallel. - let dependencies: Result, _> = stylesheet + let dependencies: Result, _> = stylesheet .rules .0 .par_iter_mut() @@ -484,16 +523,19 @@ where }; let result = match self.fs.resolve(&specifier, file) { - Ok(path) => self.load_file( - &path, - ImportRule { - layer, - media, - supports: combine_supports(rule.supports.clone(), &import.supports), - url: "".into(), - loc: import.loc, - }, - ), + Ok(ResolveResult::File(path)) => self + .load_file( + &path, + ImportRule { + layer, + media, + supports: combine_supports(rule.supports.clone(), &import.supports), + url: "".into(), + loc: import.loc, + }, + ) + .map(Dependency::File), + Ok(ResolveResult::External(url)) => Ok(Dependency::External(url)), Err(err) => Err(Error { kind: BundleErrorKind::ResolverError(err), loc: Some(ErrorLocation::new( @@ -580,7 +622,7 @@ where ) -> Option>>> { if let Some(Specifier::File(f)) = specifier { let result = match self.fs.resolve(&f, file) { - Ok(path) => { + Ok(ResolveResult::File(path)) => { let res = self.load_file( &path, ImportRule { @@ -602,6 +644,13 @@ where res } + Ok(ResolveResult::External(_)) => Err(Error { + kind: BundleErrorKind::ReferencedExternalModuleWithCssModuleFrom, + loc: Some(ErrorLocation::new( + style_loc, + self.find_filename(style_loc.source_index), + )), + }), Err(err) => Err(Error { kind: BundleErrorKind::ResolverError(err), loc: Some(ErrorLocation::new( @@ -646,7 +695,9 @@ where } for i in 0..stylesheets[source_index as usize].dependencies.len() { - let dep_source_index = stylesheets[source_index as usize].dependencies[i]; + let Dependency::File(dep_source_index) = stylesheets[source_index as usize].dependencies[i] else { + continue; + }; let resolved = &mut stylesheets[dep_source_index as usize]; // In browsers, every instance of an @import is evaluated, so we preserve the last. @@ -659,14 +710,16 @@ where } } - fn inline(&mut self, dest: &mut Vec>) { - process(self.stylesheets.get_mut().unwrap(), 0, dest); - - fn process<'a, T>( + fn inline( + &mut self, + dest: &mut Vec>, + ) -> Result<(), Error>> { + fn process<'a, T, E: std::error::Error>( stylesheets: &mut Vec>, source_index: u32, dest: &mut Vec>, - ) { + filename: &String, + ) -> Result<(), Error>> { let stylesheet = &mut stylesheets[source_index as usize]; let mut rules = std::mem::take(&mut stylesheet.stylesheet.as_mut().unwrap().rules.0); @@ -678,26 +731,47 @@ where // Include the dependency if this is the first instance as computed earlier. if resolved.parent_source_index == source_index && resolved.parent_dep_index == dep_index as u32 { - process(stylesheets, dep_source_index, dest); + process(stylesheets, dep_source_index, dest, filename)?; } dep_index += 1; } let mut import_index = 0; + let mut has_bundled_import = false; for rule in &mut rules { match rule { - CssRule::Import(_) => { - let dep_source_index = stylesheets[source_index as usize].dependencies[import_index]; - let resolved = &stylesheets[dep_source_index as usize]; - - // Include the dependency if this is the last instance as computed earlier. - if resolved.parent_source_index == source_index && resolved.parent_dep_index == dep_index { - process(stylesheets, dep_source_index, dest); + CssRule::Import(import_rule) => { + let dep_source = &stylesheets[source_index as usize].dependencies[import_index]; + match dep_source { + Dependency::File(dep_source_index) => { + let resolved = &stylesheets[*dep_source_index as usize]; + + // Include the dependency if this is the last instance as computed earlier. + if resolved.parent_source_index == source_index && resolved.parent_dep_index == dep_index { + has_bundled_import = true; + process(stylesheets, *dep_source_index, dest, filename)?; + } + + *rule = CssRule::Ignored; + dep_index += 1; + } + Dependency::External(url) => { + if has_bundled_import { + return Err(Error { + kind: BundleErrorKind::ExternalImportAfterBundledImport, + loc: Some(ErrorLocation { + filename: filename.clone(), + line: import_rule.loc.line, + column: import_rule.loc.column, + }), + }); + } + import_rule.url = url.to_owned().into(); + let imp = std::mem::replace(rule, CssRule::Ignored); + dest.push(imp); + } } - - *rule = CssRule::Ignored; - dep_index += 1; import_index += 1; } CssRule::LayerStatement(_) => { @@ -739,7 +813,10 @@ where } dest.extend(rules); + Ok(()) } + + process(self.stylesheets.get_mut().unwrap(), 0, dest, &self.options.filename) } } @@ -823,8 +900,12 @@ mod tests { Ok(self.map.get(file).unwrap()) } - fn resolve(&self, specifier: &str, originating_file: &Path) -> Result { - Ok(originating_file.with_file_name(specifier)) + fn resolve(&self, specifier: &str, originating_file: &Path) -> Result { + if specifier.starts_with("https:") { + Ok(ResolveResult::External(specifier.to_owned())) + } else { + Ok(originating_file.with_file_name(specifier).into()) + } } } @@ -843,9 +924,9 @@ mod tests { /// Resolve by stripping a `foo:` prefix off any import. Specifiers without /// this prefix fail with an error. - fn resolve(&self, specifier: &str, _originating_file: &Path) -> Result { + fn resolve(&self, specifier: &str, _originating_file: &Path) -> Result { if specifier.starts_with("foo:") { - Ok(Path::new(&specifier["foo:".len()..]).to_path_buf()) + Ok(Path::new(&specifier["foo:".len()..]).to_path_buf().into()) } else { let err = std::io::Error::new( std::io::ErrorKind::NotFound, @@ -1548,6 +1629,49 @@ mod tests { "#} ); + let res = bundle( + TestProvider { + map: fs! { + "/a.css": r#" + @import url('https://fonts.googleapis.com/css2?family=Roboto&display=swap'); + @import './b.css'; + "#, + "/b.css": r#" + .b { color: green } + "# + }, + }, + "/a.css", + ); + assert_eq!( + res, + indoc! { r#" + @import "https://fonts.googleapis.com/css2?family=Roboto&display=swap"; + + .b { + color: green; + } + "#} + ); + + error_test( + TestProvider { + map: fs! { + "/a.css": r#" + @import './b.css'; + @import url('https://fonts.googleapis.com/css2?family=Roboto&display=swap'); + "#, + "/b.css": r#" + .b { color: green } + "# + }, + }, + "/a.css", + Some(Box::new(|err| { + assert!(matches!(err, BundleErrorKind::ExternalImportAfterBundledImport)); + })), + ); + error_test( TestProvider { map: fs! { diff --git a/src/compat.rs b/src/compat.rs index f0c828d75..0f07c00ea 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/src/lib.rs b/src/lib.rs index 86f3d0df0..75e8eef4b 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) { @@ -8281,13 +8368,13 @@ mod tests { minify_test(".foo { rotate: acos(cos(45deg))", ".foo{rotate:45deg}"); minify_test(".foo { rotate: acos(-1)", ".foo{rotate:180deg}"); minify_test(".foo { rotate: acos(0)", ".foo{rotate:90deg}"); - minify_test(".foo { rotate: acos(1)", ".foo{rotate:none}"); + minify_test(".foo { rotate: acos(1)", ".foo{rotate:0deg}"); minify_test(".foo { rotate: acos(45deg)", ".foo{rotate:acos(45deg)}"); // invalid minify_test(".foo { rotate: acos(-20)", ".foo{rotate:acos(-20)}"); // evaluates to NaN minify_test(".foo { rotate: atan(tan(45deg))", ".foo{rotate:45deg}"); minify_test(".foo { rotate: atan(1)", ".foo{rotate:45deg}"); - minify_test(".foo { rotate: atan(0)", ".foo{rotate:none}"); + minify_test(".foo { rotate: atan(0)", ".foo{rotate:0deg}"); minify_test(".foo { rotate: atan(45deg)", ".foo{rotate:atan(45deg)}"); // invalid minify_test(".foo { rotate: atan2(1px, -1px)", ".foo{rotate:135deg}"); @@ -8301,6 +8388,9 @@ mod tests { minify_test(".foo { rotate: atan2(-1, 1)", ".foo{rotate:-45deg}"); // incompatible units minify_test(".foo { rotate: atan2(1px, -1vw)", ".foo{rotate:atan2(1px, -1vw)}"); + + minify_test(".foo { transform: rotate(acos(1)) }", ".foo{transform:rotate(0)}"); + minify_test(".foo { transform: rotate(atan(0)) }", ".foo{transform:rotate(0)}"); } #[test] @@ -12630,6 +12720,35 @@ mod tests { #[test] fn test_transform() { + test( + ".foo { transform: perspective(500px)translate3d(10px, 0, 20px)rotateY(30deg) }", + indoc! {r#" + .foo { + transform: perspective(500px) translate3d(10px, 0, 20px) rotateY(30deg); + } + "#}, + ); + test( + ".foo { transform: translate3d(12px,50%,3em)scale(2,.5) }", + indoc! {r#" + .foo { + transform: translate3d(12px, 50%, 3em) scale(2, .5); + } + "#}, + ); + test( + ".foo { transform:matrix(1,2,-1,1,80,80) }", + indoc! {r#" + .foo { + transform: matrix(1, 2, -1, 1, 80, 80); + } + "#}, + ); + + minify_test( + ".foo { transform: scale( 0.5 )translateX(10px ) }", + ".foo{transform:scale(.5)translate(10px)}", + ); minify_test( ".foo { transform: translate(2px, 3px)", ".foo{transform:translate(2px,3px)}", @@ -12682,6 +12801,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)}"); @@ -12819,7 +12989,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( @@ -12832,16 +13001,31 @@ mod tests { minify_test(".foo { translate: 1px 2px 0px }", ".foo{translate:1px 2px}"); minify_test(".foo { translate: 1px 0px 2px }", ".foo{translate:1px 0 2px}"); minify_test(".foo { translate: none }", ".foo{translate:none}"); + minify_test(".foo { rotate: none }", ".foo{rotate:none}"); + minify_test(".foo { rotate: 0deg }", ".foo{rotate:0deg}"); + minify_test(".foo { rotate: -0deg }", ".foo{rotate:0deg}"); minify_test(".foo { rotate: 10deg }", ".foo{rotate:10deg}"); minify_test(".foo { rotate: z 10deg }", ".foo{rotate:10deg}"); minify_test(".foo { rotate: 0 0 1 10deg }", ".foo{rotate:10deg}"); minify_test(".foo { rotate: x 10deg }", ".foo{rotate:x 10deg}"); minify_test(".foo { rotate: 1 0 0 10deg }", ".foo{rotate:x 10deg}"); - minify_test(".foo { rotate: y 10deg }", ".foo{rotate:y 10deg}"); + minify_test(".foo { rotate: 2 0 0 10deg }", ".foo{rotate:x 10deg}"); + minify_test(".foo { rotate: 0 2 0 10deg }", ".foo{rotate:y 10deg}"); + minify_test(".foo { rotate: 0 0 2 10deg }", ".foo{rotate:10deg}"); + minify_test(".foo { rotate: 0 0 5.3 10deg }", ".foo{rotate:10deg}"); + minify_test(".foo { rotate: 0 0 1 0deg }", ".foo{rotate:0deg}"); + minify_test(".foo { rotate: 10deg 0 0 -1 }", ".foo{rotate:-10deg}"); + minify_test(".foo { rotate: 10deg 0 0 -233 }", ".foo{rotate:-10deg}"); + minify_test(".foo { rotate: -1 0 0 0deg }", ".foo{rotate:x 0deg}"); + minify_test(".foo { rotate: 0deg 0 0 1 }", ".foo{rotate:0deg}"); + minify_test(".foo { rotate: 0deg 0 0 -1 }", ".foo{rotate:0deg}"); minify_test(".foo { rotate: 0 1 0 10deg }", ".foo{rotate:y 10deg}"); + minify_test(".foo { rotate: x 0rad }", ".foo{rotate:x 0deg}"); + // TODO: In minify mode, convert units to the shortest form. + // minify_test(".foo { rotate: y 0turn }", ".foo{rotate:y 0deg}"); + minify_test(".foo { rotate: z 0deg }", ".foo{rotate:0deg}"); + minify_test(".foo { rotate: 10deg y }", ".foo{rotate:y 10deg}"); minify_test(".foo { rotate: 1 1 1 10deg }", ".foo{rotate:1 1 1 10deg}"); - minify_test(".foo { rotate: 0 0 1 0deg }", ".foo{rotate:none}"); - minify_test(".foo { rotate: none }", ".foo{rotate:none}"); minify_test(".foo { scale: 1 }", ".foo{scale:1}"); minify_test(".foo { scale: 1 1 }", ".foo{scale:1}"); minify_test(".foo { scale: 1 1 1 }", ".foo{scale:1}"); @@ -12850,6 +13034,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)}"); @@ -23106,6 +23365,22 @@ mod tests { minify_test(".foo { --test: .5s; }", ".foo{--test:.5s}"); minify_test(".foo { --theme-sizes-1\\/12: 2 }", ".foo{--theme-sizes-1\\/12:2}"); minify_test(".foo { --test: 0px; }", ".foo{--test:0px}"); + test( + ".foo { transform: var(--bar, ) }", + indoc! {r#" + .foo { + transform: var(--bar, ); + } + "#}, + ); + test( + ".foo { transform: env(--bar, ) }", + indoc! {r#" + .foo { + transform: env(--bar, ); + } + "#}, + ); // Test attr() function with type() syntax - minified minify_test( @@ -27858,6 +28133,82 @@ mod tests { ); } + #[test] + fn test_mix_blend_mode() { + minify_test( + ".foo { mix-blend-mode: normal }", + ".foo{mix-blend-mode:normal}", + ); + minify_test( + ".foo { mix-blend-mode: multiply }", + ".foo{mix-blend-mode:multiply}", + ); + minify_test( + ".foo { mix-blend-mode: screen }", + ".foo{mix-blend-mode:screen}", + ); + minify_test( + ".foo { mix-blend-mode: overlay }", + ".foo{mix-blend-mode:overlay}", + ); + minify_test( + ".foo { mix-blend-mode: darken }", + ".foo{mix-blend-mode:darken}", + ); + minify_test( + ".foo { mix-blend-mode: lighten }", + ".foo{mix-blend-mode:lighten}", + ); + minify_test( + ".foo { mix-blend-mode: color-dodge }", + ".foo{mix-blend-mode:color-dodge}", + ); + minify_test( + ".foo { mix-blend-mode: color-burn }", + ".foo{mix-blend-mode:color-burn}", + ); + minify_test( + ".foo { mix-blend-mode: hard-light }", + ".foo{mix-blend-mode:hard-light}", + ); + minify_test( + ".foo { mix-blend-mode: soft-light }", + ".foo{mix-blend-mode:soft-light}", + ); + minify_test( + ".foo { mix-blend-mode: difference }", + ".foo{mix-blend-mode:difference}", + ); + minify_test( + ".foo { mix-blend-mode: exclusion }", + ".foo{mix-blend-mode:exclusion}", + ); + minify_test( + ".foo { mix-blend-mode: hue }", + ".foo{mix-blend-mode:hue}", + ); + minify_test( + ".foo { mix-blend-mode: saturation }", + ".foo{mix-blend-mode:saturation}", + ); + minify_test( + ".foo { mix-blend-mode: color }", + ".foo{mix-blend-mode:color}", + ); + minify_test( + ".foo { mix-blend-mode: luminosity }", + ".foo{mix-blend-mode:luminosity}", + ); + minify_test( + ".foo { mix-blend-mode: plus-darker }", + ".foo{mix-blend-mode:plus-darker}", + ); + minify_test( + ".foo { mix-blend-mode: plus-lighter }", + ".foo{mix-blend-mode:plus-lighter}", + ); + } + #[test] fn test_viewport() { minify_test( @@ -30379,14 +30730,20 @@ mod tests { minify_test(".foo { color-scheme: dark light; }", ".foo{color-scheme:light dark}"); minify_test(".foo { color-scheme: only light; }", ".foo{color-scheme:light only}"); minify_test(".foo { color-scheme: only dark; }", ".foo{color-scheme:dark only}"); + minify_test(".foo { color-scheme: inherit; }", ".foo{color-scheme:inherit}"); + minify_test(":root { color-scheme: unset; }", ":root{color-scheme:unset}"); + minify_test(".foo { color-scheme: unknow; }", ".foo{color-scheme:unknow}"); + minify_test(".foo { color-scheme: only; }", ".foo{color-scheme:only}"); + minify_test(".foo { color-scheme: dark foo; }", ".foo{color-scheme:dark foo}"); + minify_test(".foo { color-scheme: normal dark; }", ".foo{color-scheme:normal dark}"); minify_test( ".foo { color-scheme: dark light only; }", ".foo{color-scheme:light dark only}", ); - minify_test(".foo { color-scheme: foo bar light; }", ".foo{color-scheme:light}"); + minify_test(".foo { color-scheme: foo bar light; }", ".foo{color-scheme:foo bar light}"); minify_test( ".foo { color-scheme: only foo dark bar; }", - ".foo{color-scheme:dark only}", + ".foo{color-scheme:only foo dark bar}", ); prefix_test( ".foo { color-scheme: dark; }", diff --git a/src/properties/custom.rs b/src/properties/custom.rs index 46a3858db..26da05708 100644 --- a/src/properties/custom.rs +++ b/src/properties/custom.rs @@ -1215,7 +1215,10 @@ impl<'i> Variable<'i> { dest.write_str("var(")?; self.name.to_css(dest)?; if let Some(fallback) = &self.fallback { - dest.delim(',', false)?; + dest.write_char(',')?; + if !fallback.starts_with_whitespace() { + dest.whitespace()?; + } fallback.to_css(dest, is_custom_property)?; } dest.write_char(')') @@ -1389,7 +1392,10 @@ impl<'i> EnvironmentVariable<'i> { } if let Some(fallback) = &self.fallback { - dest.delim(',', false)?; + dest.write_char(',')?; + if !fallback.starts_with_whitespace() { + dest.whitespace()?; + } fallback.to_css(dest, is_custom_property)?; } dest.write_char(')') diff --git a/src/properties/effects.rs b/src/properties/effects.rs index 1ba69f6a6..95d403d43 100644 --- a/src/properties/effects.rs +++ b/src/properties/effects.rs @@ -1,5 +1,6 @@ //! CSS properties related to filters and effects. +use crate::macros::enum_property; use crate::error::{ParserError, PrinterError}; use crate::printer::Printer; use crate::targets::{Browsers, Targets}; @@ -410,3 +411,45 @@ impl IsCompatible for FilterList<'_> { true } } + +enum_property! { + /// A [``](https://www.w3.org/TR/compositing-1/#ltblendmodegt) value. + pub enum BlendMode { + /// The default blend mode; the top layer is drawn over the bottom layer. + Normal, + /// The source and destination are multiplied. + Multiply, + /// Multiplies the complements of the backdrop and source, then complements the result. + Screen, + /// Multiplies or screens, depending on the backdrop color. + Overlay, + /// Selects the darker of the backdrop and source. + Darken, + /// Selects the lighter of the backdrop and source. + Lighten, + /// Brightens the backdrop to reflect the source. + ColorDodge, + /// Darkens the backdrop to reflect the source. + ColorBurn, + /// Multiplies or screens, depending on the source color. + HardLight, + /// Darkens or lightens, depending on the source color. + SoftLight, + /// Subtracts the darker from the lighter. + Difference, + /// Similar to difference, but with lower contrast. + Exclusion, + /// The hue of the source with the saturation and luminosity of the backdrop. + Hue, + /// The saturation of the source with the hue and luminosity of the backdrop. + Saturation, + /// The hue and saturation of the source with the luminosity of the backdrop. + Color, + /// The luminosity of the source with the hue and saturation of the backdrop. + Luminosity, + /// Adds the source to the backdrop, producing a darker result. + PlusDarker, + /// Adds the source to the backdrop, producing a lighter result. + PlusLighter, + } +} diff --git a/src/properties/mod.rs b/src/properties/mod.rs index 54c47548a..c39b618ba 100644 --- a/src/properties/mod.rs +++ b/src/properties/mod.rs @@ -1600,6 +1600,9 @@ define_properties! { "filter": Filter(FilterList<'i>, VendorPrefix) / WebKit, "backdrop-filter": BackdropFilter(FilterList<'i>, VendorPrefix) / WebKit, + // https://www.w3.org/TR/compositing-1/ + "mix-blend-mode": MixBlendMode(BlendMode), + // https://drafts.csswg.org/css2/ "z-index": ZIndex(position::ZIndex), diff --git a/src/properties/transform.rs b/src/properties/transform.rs index c8f5a7f72..255beeeb8 100644 --- a/src/properties/transform.rs +++ b/src/properties/transform.rs @@ -7,7 +7,6 @@ use crate::error::{ParserError, PrinterError}; use crate::macros::enum_property; use crate::prefixes::Feature; use crate::printer::Printer; -use crate::stylesheet::PrinterOptions; use crate::traits::{Parse, PropertyHandler, ToCss, Zero}; use crate::values::{ angle::Angle, @@ -59,20 +58,6 @@ impl ToCss for TransformList { // TODO: Re-enable with a better solution // See: https://github.com/parcel-bundler/lightningcss/issues/288 - if dest.minify { - let mut base = String::new(); - self.to_css_base(&mut Printer::new( - &mut base, - PrinterOptions { - minify: true, - ..PrinterOptions::default() - }, - ))?; - - dest.write_str(&base)?; - - return Ok(()); - } // if dest.minify { // // Combine transforms into a single matrix. // if let Some(matrix) = self.to_matrix() { @@ -141,7 +126,13 @@ impl TransformList { where W: std::fmt::Write, { + let mut first = true; for item in &self.0 { + if first { + first = false; + } else { + dest.whitespace()?; + } item.to_css(dest)?; } Ok(()) @@ -947,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" => { @@ -1111,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(')') @@ -1518,29 +1512,39 @@ impl Translate { /// A value for the [rotate](https://drafts.csswg.org/css-transforms-2/#propdef-rotate) property. #[derive(Debug, Clone, PartialEq)] #[cfg_attr(feature = "visitor", derive(Visit))] -#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[cfg_attr( + feature = "serde", + derive(serde::Serialize, serde::Deserialize), + serde(rename_all = "lowercase") +)] #[cfg_attr(feature = "jsonschema", derive(schemars::JsonSchema))] #[cfg_attr(feature = "into_owned", derive(static_self::IntoOwned))] -pub struct Rotate { - /// Rotation around the x axis. - pub x: f32, - /// Rotation around the y axis. - pub y: f32, - /// Rotation around the z axis. - pub z: f32, - /// The angle of rotation. - pub angle: Angle, +pub enum Rotate { + /// The `none` keyword. + None, + + /// Rotation on the x, y, and z axes. + #[cfg_attr(feature = "serde", serde(untagged))] + XYZ { + /// Rotation around the x axis. + x: f32, + /// Rotation around the y axis. + y: f32, + /// Rotation around the z axis. + z: f32, + /// The angle of rotation. + angle: Angle, + }, } impl<'i> Parse<'i> for Rotate { fn parse<'t>(input: &mut Parser<'i, 't>) -> Result>> { + // CSS Transforms 2 §5.1: + // "It must serialize as the keyword none if and only if none was originally specified." + // Keep `none` explicit so identity rotations (e.g. `0deg`) do not round-trip to `none`. + // https://drafts.csswg.org/css-transforms-2/#individual-transforms if input.try_parse(|i| i.expect_ident_matching("none")).is_ok() { - return Ok(Rotate { - x: 0.0, - y: 0.0, - z: 1.0, - angle: Angle::Deg(0.0), - }); + return Ok(Rotate::None); } let angle = input.try_parse(Angle::parse); @@ -1564,7 +1568,7 @@ impl<'i> Parse<'i> for Rotate { ) .unwrap_or((0.0, 0.0, 1.0)); let angle = angle.or_else(|_| Angle::parse(input))?; - Ok(Rotate { x, y, z, angle }) + Ok(Rotate::XYZ { x, y, z, angle }) } } @@ -1573,32 +1577,46 @@ impl ToCss for Rotate { where W: std::fmt::Write, { - if self.x == 0.0 && self.y == 0.0 && self.z == 1.0 && self.angle.is_zero() { - dest.write_str("none")?; - return Ok(()); - } - - if self.x == 1.0 && self.y == 0.0 && self.z == 0.0 { - dest.write_str("x ")?; - } else if self.x == 0.0 && self.y == 1.0 && self.z == 0.0 { - dest.write_str("y ")?; - } else if !(self.x == 0.0 && self.y == 0.0 && self.z == 1.0) { - self.x.to_css(dest)?; - dest.write_char(' ')?; - self.y.to_css(dest)?; - dest.write_char(' ')?; - self.z.to_css(dest)?; - dest.write_char(' ')?; + match self { + Rotate::None => dest.write_str("none"), + Rotate::XYZ { x, y, z, angle } => { + // CSS Transforms 2 §5.1: + // "If the axis is parallel with the x or y axes, it must serialize as the appropriate keyword." + // "If a rotation about the z axis ... must serialize as just an ." + // Normalize parallel vectors (including non-unit vectors); flip the angle for negative axis directions. + // https://drafts.csswg.org/css-transforms-2/#individual-transforms + if *y == 0.0 && *z == 0.0 && *x != 0.0 { + let angle = if *x < 0.0 { angle.clone() * -1.0 } else { angle.clone() }; + dest.write_str("x ")?; + angle.to_css(dest) + } else if *x == 0.0 && *z == 0.0 && *y != 0.0 { + let angle = if *y < 0.0 { angle.clone() * -1.0 } else { angle.clone() }; + dest.write_str("y ")?; + angle.to_css(dest) + } else if *x == 0.0 && *y == 0.0 && *z != 0.0 { + let angle = if *z < 0.0 { angle.clone() * -1.0 } else { angle.clone() }; + angle.to_css(dest) + } else { + x.to_css(dest)?; + dest.write_char(' ')?; + y.to_css(dest)?; + dest.write_char(' ')?; + z.to_css(dest)?; + dest.write_char(' ')?; + angle.to_css(dest) + } + } } - - self.angle.to_css(dest) } } impl Rotate { /// Converts the rotation to a transform function. pub fn to_transform(&self) -> Transform { - Transform::Rotate3d(self.x, self.y, self.z, self.angle.clone()) + match self { + Rotate::None => Transform::Rotate3d(0.0, 0.0, 1.0, Angle::Deg(0.0)), + Rotate::XYZ { x, y, z, angle } => Transform::Rotate3d(*x, *y, *z, angle.clone()), + } } } @@ -1628,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 }; @@ -1660,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)?; } diff --git a/src/properties/ui.rs b/src/properties/ui.rs index 06b83ab99..e0802c500 100644 --- a/src/properties/ui.rs +++ b/src/properties/ui.rs @@ -433,33 +433,44 @@ bitflags! { impl<'i> Parse<'i> for ColorScheme { fn parse<'t>(input: &mut Parser<'i, 't>) -> Result>> { let mut res = ColorScheme::empty(); - let ident = input.expect_ident()?; - match_ignore_ascii_case! { &ident, - "normal" => return Ok(res), - "only" => res |= ColorScheme::Only, - "light" => res |= ColorScheme::Light, - "dark" => res |= ColorScheme::Dark, - _ => {} - }; + let mut has_any = false; - while let Ok(ident) = input.try_parse(|input| input.expect_ident_cloned()) { - match_ignore_ascii_case! { &ident, - "normal" => return Err(input.new_custom_error(ParserError::InvalidValue)), - "only" => { - // Only must be at the start or the end, not in the middle. - if res.contains(ColorScheme::Only) { - return Err(input.new_custom_error(ParserError::InvalidValue)); - } - res |= ColorScheme::Only; - return Ok(res); - }, - "light" => res |= ColorScheme::Light, - "dark" => res |= ColorScheme::Dark, - _ => {} - }; + if input.try_parse(|input| input.expect_ident_matching("normal")).is_ok() { + return Ok(res); + } + + if input.try_parse(|input| input.expect_ident_matching("only")).is_ok() { + res |= ColorScheme::Only; + has_any = true; } - Ok(res) + loop { + if input.try_parse(|input| input.expect_ident_matching("light")).is_ok() { + res |= ColorScheme::Light; + has_any = true; + continue; + } + + if input.try_parse(|input| input.expect_ident_matching("dark")).is_ok() { + res |= ColorScheme::Dark; + has_any = true; + continue; + } + + break; + } + + // Only is allowed at the start or the end. + if !res.contains(ColorScheme::Only) && input.try_parse(|input| input.expect_ident_matching("only")).is_ok() { + res |= ColorScheme::Only; + has_any = true; + } + + if has_any { + return Ok(res); + } + + Err(input.new_custom_error(ParserError::InvalidValue)) } } @@ -484,6 +495,10 @@ impl ToCss for ColorScheme { } if self.contains(ColorScheme::Only) { + // Avoid parsing `color-scheme: only` as `color-scheme: only` + if !self.intersects(ColorScheme::Light | ColorScheme::Dark) { + return dest.write_str("only"); + } dest.write_str(" only")?; } diff --git a/src/test_helpers.rs b/src/test_helpers.rs new file mode 100644 index 000000000..ad5df9ec3 --- /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(), + ); +} diff --git a/src/values/angle.rs b/src/values/angle.rs index dff23a287..b7fb62329 100644 --- a/src/values/angle.rs +++ b/src/values/angle.rs @@ -121,6 +121,8 @@ impl ToCss for Angle { } Angle::Turn(val) => (*val, "turn"), }; + // Canonicalize negative zero so serialization is stable (`0deg` instead of `-0deg`). + let value = if value == 0.0 { 0.0 } else { value }; serialize_dimension(value, unit, dest) } diff --git a/tests/testdata/has_external.css b/tests/testdata/has_external.css new file mode 100644 index 000000000..191ac502b --- /dev/null +++ b/tests/testdata/has_external.css @@ -0,0 +1,3 @@ +@import url('https://fonts.googleapis.com/css2?family=Roboto&display=swap'); +@import './does_not_exist.css'; +@import './b.css'; diff --git a/wasm/index.mjs b/wasm/index.mjs index e7d4dc69a..b74898fb8 100644 --- a/wasm/index.mjs +++ b/wasm/index.mjs @@ -38,19 +38,47 @@ export default async function init(input) { } export function transform(options) { - return wasm.transform(options); + return wrap(wasm.transform, options); } export function transformStyleAttribute(options) { - return wasm.transformStyleAttribute(options); + return wrap(wasm.transformStyleAttribute, options); } export function bundle(options) { - return wasm.bundle(options); + return wrap(wasm.bundle, options); } export function bundleAsync(options) { - return bundleAsyncInternal(options); + return wrap(bundleAsyncInternal, options); +} + +function wrap(call, options) { + if (typeof options.visitor === 'function') { + let deps = []; + options.visitor = options.visitor({ + addDependency(dep) { + deps.push(dep); + } + }); + + let result = call(options); + if (result instanceof Promise) { + result = result.then(res => { + if (deps.length) { + res.dependencies ??= []; + res.dependencies.push(...deps); + } + return res; + }); + } else if (deps.length) { + result.dependencies ??= []; + result.dependencies.push(...deps); + } + return result; + } else { + return call(options); + } } export { browserslistToTargets } from './browserslistToTargets.js'; diff --git a/wasm/wasm-node.mjs b/wasm/wasm-node.mjs index 52014444a..93c05afd6 100644 --- a/wasm/wasm-node.mjs +++ b/wasm/wasm-node.mjs @@ -25,15 +25,15 @@ export default async function init() { } export function transform(options) { - return wasm.transform(options); + return wrap(wasm.transform, options); } export function transformStyleAttribute(options) { - return wasm.transformStyleAttribute(options); + return wrap(wasm.transformStyleAttribute, options); } export function bundle(options) { - return wasm.bundle({ + return wrap(wasm.bundle, { ...options, resolver: { read: (filePath) => fs.readFileSync(filePath, 'utf8') @@ -49,7 +49,35 @@ export async function bundleAsync(options) { }; } - return bundleAsyncInternal(options); + return wrap(bundleAsyncInternal, options); +} + +function wrap(call, options) { + if (typeof options.visitor === 'function') { + let deps = []; + options.visitor = options.visitor({ + addDependency(dep) { + deps.push(dep); + } + }); + + let result = call(options); + if (result instanceof Promise) { + result = result.then(res => { + if (deps.length) { + res.dependencies ??= []; + res.dependencies.push(...deps); + } + return res; + }); + } else if (deps.length) { + result.dependencies ??= []; + result.dependencies.push(...deps); + } + return result; + } else { + return call(options); + } } export { browserslistToTargets } from './browserslistToTargets.js' diff --git a/website/pages/bundling.md b/website/pages/bundling.md index 07e74e06d..b06a02bad 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"; +``` + +
diff --git a/website/pages/transforms.md b/website/pages/transforms.md index 7441cb5d8..0a36f13a8 100644 --- a/website/pages/transforms.md +++ b/website/pages/transforms.md @@ -353,6 +353,61 @@ let res = transform({ assert.equal(res.code.toString(), '.foo{color:red}.foo.bar{color:#ff0}'); ``` +## Dependencies + +Visitors can emit dependencies so the caller (e.g. bundler) knows to re-run the transformation or invalidate a cache when those files change. These are returned as part of the result's `dependencies` property (along with other dependencies when the `analyzeDependencies` option is enabled). + +By passing a function to the `visitor` option instead of an object, you get access to the `addDependency` function. This accepts a dependency object with `type: 'file'` or `type: 'glob'`. File dependencies invalidate the transformation whenever the `filePath` changes (created, updated, or deleted). Glob dependencies invalidate whenever any file matched by the glob changes. `composeVisitors` also supports function visitors. + +By default, Lightning CSS does not do anything with these dependencies except return them to the caller. It's the caller's responsibility to implement file watching and cache invalidation accordingly. + +```js +let res = transform({ + filename: 'test.css', + code: Buffer.from(` + @dep "foo.js"; + @glob "**/*.json"; + + .foo { + width: 32px; + } + `), + visitor: ({addDependency}) => ({ + Rule: { + unknown: { + dep(rule) { + let file = rule.prelude[0].value.value; + addDependency({ + type: 'file', + filePath: file + }); + return []; + }, + glob(rule) { + let glob = rule.prelude[0].value.value; + addDependency({ + type: 'glob', + glob + }); + return []; + } + } + } + }) +}); + +assert.equal(res.dependencies, [ + { + type: 'file', + filePath: 'foo.js' + }, + { + type: 'glob', + filePath: '**/*.json' + } +]); +``` + ## Examples For examples of visitors that perform a variety of real world tasks, see the Lightning CSS [visitor tests](https://github.com/parcel-bundler/lightningcss/blob/master/node/test/visitor.test.mjs). diff --git a/yarn.lock b/yarn.lock index 2fe9dbf67..b273df78d 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"