diff --git a/node/composeVisitors.js b/node/composeVisitors.js index 9d5796e3..f2993490 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 76d40572..6d727d75 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 011d04b4..6fe25aef 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/test/composeVisitors.test.mjs b/node/test/composeVisitors.test.mjs index 7718ec06..4379cf48 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 c763b840..149825b7 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/src/lib.rs b/src/lib.rs index 2a41655e..75e8eef4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -47,6 +47,9 @@ pub mod visitor; #[cfg(feature = "serde")] mod serialization; +#[cfg(test)] +mod test_helpers; + #[cfg(test)] mod tests { use crate::css_modules::{CssModuleExport, CssModuleExports, CssModuleReference, CssModuleReferences}; @@ -58,6 +61,7 @@ mod tests { use crate::rules::CssRule; use crate::rules::Location; use crate::stylesheet::*; + use crate::test_helpers::panic_with_test_error; use crate::targets::{Browsers, Features, Targets}; use crate::traits::{Parse, ToCss}; use crate::values::color::CssColor; @@ -68,86 +72,130 @@ mod tests { use std::collections::HashMap; use std::sync::{Arc, RwLock}; + #[track_caller] fn test(source: &str, expected: &str) { test_with_options(source, expected, ParserOptions::default()) } + #[track_caller] fn test_with_options<'i, 'o>(source: &'i str, expected: &'i str, options: ParserOptions<'o, 'i>) { - let mut stylesheet = StyleSheet::parse(&source, options).unwrap(); - stylesheet.minify(MinifyOptions::default()).unwrap(); - let res = stylesheet.to_css(PrinterOptions::default()).unwrap(); + let mut stylesheet = match StyleSheet::parse(&source, options) { + Ok(stylesheet) => stylesheet, + Err(e) => panic_with_test_error("test_with_options", "parse", source, e), + }; + if let Err(e) = stylesheet.minify(MinifyOptions::default()) { + panic_with_test_error("test_with_options", "minify", source, e); + } + let res = match stylesheet.to_css(PrinterOptions::default()) { + Ok(res) => res, + Err(e) => panic_with_test_error("test_with_options", "print", source, e), + }; assert_eq!(res.code, expected); } + #[track_caller] fn test_with_printer_options<'i, 'o>(source: &'i str, expected: &'i str, options: PrinterOptions<'o>) { - let mut stylesheet = StyleSheet::parse(&source, ParserOptions::default()).unwrap(); - stylesheet.minify(MinifyOptions::default()).unwrap(); - let res = stylesheet.to_css(options).unwrap(); + let mut stylesheet = match StyleSheet::parse(&source, ParserOptions::default()) { + Ok(stylesheet) => stylesheet, + Err(e) => panic_with_test_error("test_with_printer_options", "parse", source, e), + }; + if let Err(e) = stylesheet.minify(MinifyOptions::default()) { + panic_with_test_error("test_with_printer_options", "minify", source, e); + } + let res = match stylesheet.to_css(options) { + Ok(res) => res, + Err(e) => panic_with_test_error("test_with_printer_options", "print", source, e), + }; assert_eq!(res.code, expected); } + #[track_caller] fn minify_test(source: &str, expected: &str) { minify_test_with_options(source, expected, ParserOptions::default()) } #[track_caller] fn minify_test_with_options<'i, 'o>(source: &'i str, expected: &'i str, options: ParserOptions<'o, 'i>) { - let mut stylesheet = StyleSheet::parse(&source, options.clone()).unwrap(); - stylesheet.minify(MinifyOptions::default()).unwrap(); - let res = stylesheet - .to_css(PrinterOptions { - minify: true, - ..PrinterOptions::default() - }) - .unwrap(); + let mut stylesheet = match StyleSheet::parse(&source, options) { + Ok(stylesheet) => stylesheet, + Err(e) => panic_with_test_error("minify_test_with_options", "parse", source, e), + }; + if let Err(e) = stylesheet.minify(MinifyOptions::default()) { + panic_with_test_error("minify_test_with_options", "minify", source, e); + } + let res = match stylesheet.to_css(PrinterOptions { + minify: true, + ..PrinterOptions::default() + }) { + Ok(res) => res, + Err(e) => panic_with_test_error("minify_test_with_options", "print", source, e), + }; assert_eq!(res.code, expected); } + #[track_caller] fn minify_error_test_with_options<'i, 'o>( source: &'i str, error: MinifyErrorKind, options: ParserOptions<'o, 'i>, ) { - let mut stylesheet = StyleSheet::parse(&source, options.clone()).unwrap(); + let mut stylesheet = match StyleSheet::parse(&source, options) { + Ok(stylesheet) => stylesheet, + Err(e) => panic_with_test_error("minify_error_test_with_options", "parse", source, e), + }; match stylesheet.minify(MinifyOptions::default()) { Err(e) => assert_eq!(e.kind, error), - _ => unreachable!(), + Ok(()) => panic!( + "minify_error_test_with_options: expected minify error {:?}, but minification succeeded.\nsource:\n{source}", + error + ), } } + #[track_caller] fn prefix_test(source: &str, expected: &str, targets: Browsers) { - let mut stylesheet = StyleSheet::parse(&source, ParserOptions::default()).unwrap(); - stylesheet - .minify(MinifyOptions { - targets: targets.into(), - ..MinifyOptions::default() - }) - .unwrap(); - let res = stylesheet - .to_css(PrinterOptions { - targets: targets.into(), - ..PrinterOptions::default() - }) - .unwrap(); + let mut stylesheet = match StyleSheet::parse(&source, ParserOptions::default()) { + Ok(stylesheet) => stylesheet, + Err(e) => panic_with_test_error("prefix_test", "parse", source, e), + }; + if let Err(e) = stylesheet.minify(MinifyOptions { + targets: targets.into(), + ..MinifyOptions::default() + }) { + panic_with_test_error("prefix_test", "minify", source, e); + } + let res = match stylesheet.to_css(PrinterOptions { + targets: targets.into(), + ..PrinterOptions::default() + }) { + Ok(res) => res, + Err(e) => panic_with_test_error("prefix_test", "print", source, e), + }; assert_eq!(res.code, expected); } + #[track_caller] fn attr_test(source: &str, expected: &str, minify: bool, targets: Option) { - let mut attr = StyleAttribute::parse(source, ParserOptions::default()).unwrap(); + let mut attr = match StyleAttribute::parse(source, ParserOptions::default()) { + Ok(attr) => attr, + Err(e) => panic_with_test_error("attr_test", "parse", source, e), + }; attr.minify(MinifyOptions { targets: targets.into(), ..MinifyOptions::default() }); - let res = attr - .to_css(PrinterOptions { - targets: targets.into(), - minify, - ..PrinterOptions::default() - }) - .unwrap(); + let res = match attr.to_css(PrinterOptions { + targets: targets.into(), + minify, + ..PrinterOptions::default() + }) { + Ok(res) => res, + Err(e) => panic_with_test_error("attr_test", "print", source, e), + }; assert_eq!(res.code, expected); } + #[track_caller] fn nesting_test(source: &str, expected: &str) { nesting_test_with_targets( source, @@ -160,30 +208,45 @@ mod tests { ); } + #[track_caller] fn nesting_test_with_targets(source: &str, expected: &str, targets: Targets) { - let mut stylesheet = StyleSheet::parse(&source, ParserOptions::default()).unwrap(); - stylesheet - .minify(MinifyOptions { - targets, - ..MinifyOptions::default() - }) - .unwrap(); - let res = stylesheet - .to_css(PrinterOptions { - targets, - ..PrinterOptions::default() - }) - .unwrap(); + let mut stylesheet = match StyleSheet::parse(&source, ParserOptions::default()) { + Ok(stylesheet) => stylesheet, + Err(e) => panic_with_test_error("nesting_test_with_targets", "parse", source, e), + }; + if let Err(e) = stylesheet.minify(MinifyOptions { + targets, + ..MinifyOptions::default() + }) { + panic_with_test_error("nesting_test_with_targets", "minify", source, e); + } + let res = match stylesheet.to_css(PrinterOptions { + targets, + ..PrinterOptions::default() + }) { + Ok(res) => res, + Err(e) => panic_with_test_error("nesting_test_with_targets", "print", source, e), + }; assert_eq!(res.code, expected); } + #[track_caller] fn nesting_test_no_targets(source: &str, expected: &str) { - let mut stylesheet = StyleSheet::parse(&source, ParserOptions::default()).unwrap(); - stylesheet.minify(MinifyOptions::default()).unwrap(); - let res = stylesheet.to_css(PrinterOptions::default()).unwrap(); + let mut stylesheet = match StyleSheet::parse(&source, ParserOptions::default()) { + Ok(stylesheet) => stylesheet, + Err(e) => panic_with_test_error("nesting_test_no_targets", "parse", source, e), + }; + if let Err(e) = stylesheet.minify(MinifyOptions::default()) { + panic_with_test_error("nesting_test_no_targets", "minify", source, e); + } + let res = match stylesheet.to_css(PrinterOptions::default()) { + Ok(res) => res, + Err(e) => panic_with_test_error("nesting_test_no_targets", "print", source, e), + }; assert_eq!(res.code, expected); } + #[track_caller] fn css_modules_test<'i>( source: &'i str, expected: &str, @@ -192,47 +255,64 @@ mod tests { config: crate::css_modules::Config<'i>, minify: bool, ) { - let mut stylesheet = StyleSheet::parse( + let mut stylesheet = match StyleSheet::parse( &source, ParserOptions { filename: "test.css".into(), css_modules: Some(config), ..ParserOptions::default() }, - ) - .unwrap(); - stylesheet.minify(MinifyOptions::default()).unwrap(); - let res = stylesheet - .to_css(PrinterOptions { - minify, - ..Default::default() - }) - .unwrap(); + ) { + Ok(stylesheet) => stylesheet, + Err(e) => panic_with_test_error("css_modules_test", "parse", source, e), + }; + if let Err(e) = stylesheet.minify(MinifyOptions::default()) { + panic_with_test_error("css_modules_test", "minify", source, e); + } + let res = match stylesheet.to_css(PrinterOptions { + minify, + ..Default::default() + }) { + Ok(res) => res, + Err(e) => panic_with_test_error("css_modules_test", "print", source, e), + }; assert_eq!(res.code, expected); - assert_eq!(res.exports.unwrap(), expected_exports); - assert_eq!(res.references.unwrap(), expected_references); + match res.exports { + Some(exports) => assert_eq!(exports, expected_exports), + None => panic!("css_modules_test: expected CSS module exports, but got None.\nsource:\n{source}"), + } + match res.references { + Some(references) => assert_eq!(references, expected_references), + None => panic!("css_modules_test: expected CSS module references, but got None.\nsource:\n{source}"), + } } + #[track_caller] fn custom_media_test(source: &str, expected: &str) { - let mut stylesheet = StyleSheet::parse( + let mut stylesheet = match StyleSheet::parse( &source, ParserOptions { flags: ParserFlags::CUSTOM_MEDIA, ..ParserOptions::default() }, - ) - .unwrap(); - stylesheet - .minify(MinifyOptions { - targets: Browsers { - chrome: Some(95 << 16), - ..Browsers::default() - } - .into(), - ..MinifyOptions::default() - }) - .unwrap(); - let res = stylesheet.to_css(PrinterOptions::default()).unwrap(); + ) { + Ok(stylesheet) => stylesheet, + Err(e) => panic_with_test_error("custom_media_test", "parse", source, e), + }; + if let Err(e) = stylesheet.minify(MinifyOptions { + targets: Browsers { + chrome: Some(95 << 16), + ..Browsers::default() + } + .into(), + ..MinifyOptions::default() + }) { + panic_with_test_error("custom_media_test", "minify", source, e); + } + let res = match stylesheet.to_css(PrinterOptions::default()) { + Ok(res) => res, + Err(e) => panic_with_test_error("custom_media_test", "print", source, e), + }; assert_eq!(res.code, expected); } @@ -260,7 +340,14 @@ mod tests { Err(e) => unreachable!("parser error should be recovered, but got {e:?}"), } } - Arc::into_inner(warnings).unwrap().into_inner().unwrap() + let warnings = match Arc::into_inner(warnings) { + Some(warnings) => warnings, + None => panic!("error_recovery_test: expected a single Arc owner for warnings"), + }; + match warnings.into_inner() { + Ok(warnings) => warnings, + Err(e) => panic!("error_recovery_test: warnings lock is poisoned: {e}"), + } } fn css_modules_error_test(source: &str, error: ParserError) { @@ -12714,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)}"); @@ -12851,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( @@ -12897,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)}"); diff --git a/src/properties/transform.rs b/src/properties/transform.rs index cc00e578..255beeeb 100644 --- a/src/properties/transform.rs +++ b/src/properties/transform.rs @@ -938,32 +938,32 @@ impl<'i> Parse<'i> for Transform { Ok(Transform::Translate3d(x, y, z)) }, "scale" => { - let x = NumberOrPercentage::parse(input)?; + let x = convert_percentage_to_number(input)?; if input.try_parse(|input| input.expect_comma()).is_ok() { - let y = NumberOrPercentage::parse(input)?; + let y = convert_percentage_to_number(input)?; Ok(Transform::Scale(x, y)) } else { Ok(Transform::Scale(x.clone(), x)) } }, "scalex" => { - let x = NumberOrPercentage::parse(input)?; + let x = convert_percentage_to_number(input)?; Ok(Transform::ScaleX(x)) }, "scaley" => { - let y = NumberOrPercentage::parse(input)?; + let y = convert_percentage_to_number(input)?; Ok(Transform::ScaleY(y)) }, "scalez" => { - let z = NumberOrPercentage::parse(input)?; + let z = convert_percentage_to_number(input)?; Ok(Transform::ScaleZ(z)) }, "scale3d" => { - let x = NumberOrPercentage::parse(input)?; + let x = convert_percentage_to_number(input)?; input.expect_comma()?; - let y = NumberOrPercentage::parse(input)?; + let y = convert_percentage_to_number(input)?; input.expect_comma()?; - let z = NumberOrPercentage::parse(input)?; + let z = convert_percentage_to_number(input)?; Ok(Transform::Scale3d(x, y, z)) }, "rotate" => { @@ -1102,16 +1102,19 @@ impl ToCss for Transform { dest.write_char(')') } ScaleX(x) => { + let x: f32 = x.into(); dest.write_str("scaleX(")?; x.to_css(dest)?; dest.write_char(')') } ScaleY(y) => { + let y: f32 = y.into(); dest.write_str("scaleY(")?; y.to_css(dest)?; dest.write_char(')') } ScaleZ(z) => { + let z: f32 = z.into(); dest.write_str("scaleZ(")?; z.to_css(dest)?; dest.write_char(')') @@ -1643,16 +1646,25 @@ pub enum Scale { }, } +fn convert_percentage_to_number<'i, 't>( + input: &mut Parser<'i, 't>, +) -> Result>> { + Ok(match NumberOrPercentage::parse(input)? { + NumberOrPercentage::Number(number) => NumberOrPercentage::Number(number), + NumberOrPercentage::Percentage(percent) => NumberOrPercentage::Number(percent.0), + }) +} + impl<'i> Parse<'i> for Scale { fn parse<'t>(input: &mut Parser<'i, 't>) -> Result>> { if input.try_parse(|i| i.expect_ident_matching("none")).is_ok() { return Ok(Scale::None); } - let x = NumberOrPercentage::parse(input)?; - let y = input.try_parse(NumberOrPercentage::parse); + let x = convert_percentage_to_number(input)?; + let y = input.try_parse(convert_percentage_to_number); let z = if y.is_ok() { - input.try_parse(NumberOrPercentage::parse).ok() + input.try_parse(convert_percentage_to_number).ok() } else { None }; @@ -1675,12 +1687,14 @@ impl ToCss for Scale { dest.write_str("none")?; } Scale::XYZ { x, y, z } => { + let x: f32 = x.into(); + let y: f32 = y.into(); + let z: f32 = z.into(); x.to_css(dest)?; - let zv: f32 = z.into(); - if y != x || zv != 1.0 { + if y != x || z != 1.0 { dest.write_char(' ')?; y.to_css(dest)?; - if zv != 1.0 { + if z != 1.0 { dest.write_char(' ')?; z.to_css(dest)?; } diff --git a/src/test_helpers.rs b/src/test_helpers.rs new file mode 100644 index 00000000..ad5df9ec --- /dev/null +++ b/src/test_helpers.rs @@ -0,0 +1,62 @@ +use crate::error::{Error, ErrorLocation}; + +pub(crate) fn format_source_location_context(source: &str, loc: &ErrorLocation) -> String { + let lines: Vec<&str> = source.lines().collect(); + let line_idx = loc.line as usize; + let display_line = line_idx + 1; + let display_column = if loc.column == 0 { 1 } else { loc.column as usize }; + + let mut output = format!("css location: line {display_line}, column {display_column}"); + + if lines.is_empty() { + output.push_str("\n (source is empty)"); + return output; + } + + if line_idx >= lines.len() { + output.push_str(&format!("\n (line is out of range; source has {} line(s))", lines.len())); + return output; + } + + let start = line_idx.saturating_sub(1); + let end = usize::min(line_idx + 1, lines.len() - 1); + output.push_str("\ncontext:"); + + for i in start..=end { + let line = lines[i]; + output.push_str(&format!("\n{:>6} | {}", i + 1, line)); + if i == line_idx { + let caret_pos = display_column.saturating_sub(1); + let line_char_count = line.chars().count(); + let mut marker = String::with_capacity(caret_pos.max(line_char_count)); + for ch in line.chars().take(caret_pos) { + marker.push(if ch == '\t' { '\t' } else { ' ' }); + } + if caret_pos > line_char_count { + marker.push_str(&" ".repeat(caret_pos - line_char_count)); + } + output.push_str(&format!("\n | {}^", marker)); + } + } + + output +} + +#[track_caller] +pub(crate) fn panic_with_test_error( + helper: &str, + stage: &str, + source: &str, + error: Error, +) -> ! { + let caller = std::panic::Location::caller(); + let location = match error.loc.as_ref() { + Some(loc) => format_source_location_context(source, loc), + None => "css location: ".to_string(), + }; + panic!( + "{helper}: {stage} failed\ncaller: {}:{}\nerror: {error}\nerror(debug): {error:?}\n{location}\nsource:\n{source}", + caller.file(), + caller.line(), + ); +} diff --git a/wasm/index.mjs b/wasm/index.mjs index e7d4dc69..b74898fb 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 52014444..93c05afd 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/transforms.md b/website/pages/transforms.md index 7441cb5d..0a36f13a 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).