From e4d8b14d0b17a709b0f59b0d9beff4b89ebf9bf2 Mon Sep 17 00:00:00 2001 From: Wilson Lin Date: Fri, 8 Jan 2021 00:26:02 +1100 Subject: [PATCH] CSS minification using esbuild --- Cargo.toml | 6 +-- README.md | 15 +++--- bench/README.md | 1 - bench/minifiers.js | 49 ++++++++++++------- bench/minify-html-bench/src/main.rs | 1 + bench/package.json | 1 + cli/src/main.rs | 3 ++ fuzz/src/main.rs | 1 + java/pom.xml | 2 +- .../in/wilsonl/minifyhtml/Configuration.java | 12 ++++- java/src/main/rust/lib.rs | 1 + nodejs/binding.c | 14 +++++- nodejs/index.d.ts | 6 ++- nodejs/native/src/lib.rs | 3 +- nodejs/package.json | 2 +- python/Cargo.toml | 2 +- python/src/lib.rs | 5 +- ruby/minify_html.gemspec | 2 +- ruby/src/lib.rs | 4 ++ src/cfg/mod.rs | 5 ++ src/lib.rs | 5 ++ src/proc/mod.rs | 38 +++++++------- src/tests/mod.rs | 18 +++++++ src/unit/script.rs | 7 +-- src/unit/style.rs | 45 ++++++++++++++++- src/unit/tag.rs | 2 +- 26 files changed, 187 insertions(+), 63 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 19a15ee..3dcb32e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,10 @@ [package] name = "minify-html" -description = "Fast and smart HTML + JS minifier" +description = "Extremely fast and smart HTML + JS + CSS minifier" license = "MIT" homepage = "https://github.com/wilsonzlin/minify-html" readme = "README.md" -keywords = ["html", "compress", "minifier", "minify", "minification"] +keywords = ["html", "compress", "minifier", "js", "css"] categories = ["compression", "command-line-utilities", "development-tools::build-utils", "web-programming"] repository = "https://github.com/wilsonzlin/minify-html.git" version = "0.3.12" @@ -22,6 +22,6 @@ js-esbuild = ["crossbeam", "esbuild-rs"] [dependencies] aho-corasick = "0.7" crossbeam = { version = "0.7", optional = true } -esbuild-rs = { version = "0.2.1", optional = true } +esbuild-rs = { version = "0.8.30", optional = true } lazy_static = "1.4" memchr = "2" diff --git a/README.md b/README.md index 770567e..f71bbab 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Comes with native bindings to Node.js, Python, Java, and Ruby. - Advanced minification strategy beats other minifiers with only one pass. - Uses zero memory allocations, SIMD searching, direct tries, and lookup tables. - Well tested with a large test suite and extensive [fuzzing](./fuzz). -- Natively binds to [esbuild](https://github.com/wilsonzlin/esbuild-rs) for super fast JS minification. +- Natively binds to [esbuild](https://github.com/wilsonzlin/esbuild-rs) for super fast JS and CSS minification. ## Performance @@ -46,9 +46,9 @@ minify-html --src /path/to/src.html --out /path/to/output.min.html minify-html = { version = "0.3.12", features = ["js-esbuild"] } ``` -Building with the `js-esbuild` feature requires the Go compiler to be installed as well, to build the [JS minifier](https://github.com/wilsonzlin/esbuild-rs). +Building with the `js-esbuild` feature requires the Go compiler to be installed as well, to build the [JS and CSS minifier](https://github.com/wilsonzlin/esbuild-rs). -If the `js-esbuild` feature is not enabled, `cfg.minify_js` will have no effect. +If the `js-esbuild` feature is not enabled, `cfg.minify_js` and `cfg.minify_css` will have no effect. ##### Use @@ -82,7 +82,7 @@ yarn add @minify-html/js ```js const minifyHtml = require("@minify-html/js"); -const cfg = minifyHtml.createConfiguration({ minifyJs: false }); +const cfg = minifyHtml.createConfiguration({ minifyJs: false, minifyCss: false }); const minified = minifyHtml.minify("

Hello, world!

", cfg); // Alternatively, minify in place to avoid copying. @@ -97,7 +97,7 @@ minify-html is also available for TypeScript: import * as minifyHtml from "@minify-html/js"; import * as fs from "fs"; -const cfg = minifyHtml.createConfiguration({ minifyJs: false }); +const cfg = minifyHtml.createConfiguration({ minifyJs: false, minifyCss: false }); const minified = minifyHtml.minify("

Hello, world!

", cfg); // Or alternatively: const minified = minifyHtml.minifyInPlace(fs.readFileSync("source.html"), cfg); @@ -133,6 +133,7 @@ import in.wilsonl.minifyhtml.SyntaxException; Configuration cfg = new Configuration.Builder() .setMinifyJs(false) + .setMinifyCss(false) .build(); try { @@ -165,7 +166,7 @@ Add the PyPI project as a dependency and install it using `pip` or `pipenv`. import minify_html try: - minified = minify_html.minify("

Hello, world!

", minify_js=False) + minified = minify_html.minify("

Hello, world!

", minify_js=False, minify_css=False) except SyntaxError as e: print(e) ``` @@ -188,7 +189,7 @@ Add the library as a dependency to `Gemfile` or `*.gemspec`. ```ruby require 'minify_html' -print MinifyHtml.minify("

Hello, world!

", { :minify_js => false }) +print MinifyHtml.minify("

Hello, world!

", { :minify_js => false, :minify_css => false }) ``` diff --git a/bench/README.md b/bench/README.md index e889c23..9cee574 100644 --- a/bench/README.md +++ b/bench/README.md @@ -41,7 +41,6 @@ Since speed depends on the input, speed charts show performance relative to the The settings used for each minifier can be found in [minifiers.js](./minifiers.js). Some settings to note: -- CSS minification is disabled for all, as minify-html currently does not support CSS minification (coming soon). - All minifiers are configured to use esbuild for JS minification asynchronously and in parallel, similar to how minify-html works. - `conservativeCollapse` is enabled for html-minifier as otherwise some whitespace would be unsafely removed with side effects. minify-html can safely remove whitespace with context if configured properly. diff --git a/bench/minifiers.js b/bench/minifiers.js index 292a84a..d04a5d8 100644 --- a/bench/minifiers.js +++ b/bench/minifiers.js @@ -1,9 +1,10 @@ +const cleanCss = require('clean-css'); const esbuild = require('esbuild'); const htmlMinifier = require('html-minifier'); const minifyHtml = require('@minify-html/js'); const minimize = require('minimize'); -const testJsMinification = process.env.HTML_ONLY !== '1'; +const testJsAndCssMinification = process.env.HTML_ONLY !== '1'; const jsMime = new Set([ undefined, @@ -46,7 +47,10 @@ class EsbuildAsync { } } -const minifyHtmlCfg = minifyHtml.createConfiguration({minifyJs: testJsMinification}); +const minifyHtmlCfg = minifyHtml.createConfiguration({ + minifyJs: testJsAndCssMinification, + minifyCss: testJsAndCssMinification, +}); const htmlMinifierCfg = { collapseBooleanAttributes: true, collapseInlineTagWhitespace: true, @@ -59,7 +63,8 @@ const htmlMinifierCfg = { decodeEntities: true, ignoreCustomComments: [], ignoreCustomFragments: [/<\?[\s\S]*?\?>/], - // This will be set to a function if `testJsMinification` is true. + // These will be set later if `testJsAndCssMinification` is true. + minifyCSS: false, minifyJS: false, processConditionalComments: true, removeAttributeQuotes: true, @@ -75,32 +80,42 @@ const htmlMinifierCfg = { module.exports = { '@minify-html/js': (_, buffer) => minifyHtml.minifyInPlace(Buffer.from(buffer), minifyHtmlCfg), - 'html-minifier': testJsMinification + 'html-minifier': testJsAndCssMinification ? async (content) => { const js = new EsbuildAsync(); const res = htmlMinifier.minify(content, { ...htmlMinifierCfg, + minifyCSS: true, minifyJS: code => js.queue(code), }); return js.finalise(res); } : content => htmlMinifier.minify(content, htmlMinifierCfg), - 'minimize': testJsMinification + 'minimize': testJsAndCssMinification ? async (content) => { const js = new EsbuildAsync(); - const plugins = []; - if (testJsMinification) { - plugins.push({ - id: 'esbuild', - element: (node, next) => { - if (node.type === 'text' && node.parent && node.parent.type === 'script' && jsMime.has(node.parent.attribs.type)) { - node.data = js.queue(node.data); - } - next(); + const css = new cleanCss({ + level: 2, + inline: false, + rebase: false, + }); + const res = new minimize({ + plugins: [ + { + id: 'esbuild', + element: (node, next) => { + if (node.type === 'text' && node.parent) { + if (node.parent.type === 'script' && jsMime.has(node.parent.attribs.type)) { + node.data = js.queue(node.data); + } else if (node.parent.type === 'style') { + node.data = css.minify(node.data).styles; + } + } + next(); + }, }, - }); - } - const res = new minimize({plugins}).parse(content); + ], + }).parse(content); return js.finalise(res); } : content => new minimize().parse(content), diff --git a/bench/minify-html-bench/src/main.rs b/bench/minify-html-bench/src/main.rs index 1aa67ae..46fbc5a 100644 --- a/bench/minify-html-bench/src/main.rs +++ b/bench/minify-html-bench/src/main.rs @@ -25,6 +25,7 @@ fn main() { let mut data = source.to_vec(); in_place(&mut data, &Cfg { minify_js: false, + minify_css: false, }).unwrap(); }; let elapsed = start.elapsed().as_secs_f64(); diff --git a/bench/package.json b/bench/package.json index c3eda9d..19fe34b 100644 --- a/bench/package.json +++ b/bench/package.json @@ -5,6 +5,7 @@ "benchmark": "2.1.4", "chart.js": "^2.9.3", "chartjs-node": "^1.7.1", + "clean-css": "^4.2.3", "esbuild": "^0.6.5", "html-minifier": "4.0.0", "minimize": "2.2.0", diff --git a/cli/src/main.rs b/cli/src/main.rs index e07e7cc..0ffbedf 100644 --- a/cli/src/main.rs +++ b/cli/src/main.rs @@ -13,6 +13,8 @@ struct Cli { out: Option, #[structopt(long)] js: bool, + #[structopt(long)] + css: bool, } macro_rules! io_expect { @@ -38,6 +40,7 @@ fn main() { io_expect!(src_file.read_to_end(&mut code), "could not load source code"); match with_friendly_error(&mut code, &Cfg { minify_js: args.js, + minify_css: args.css, }) { Ok(out_len) => { let mut out_file: Box = match args.out { diff --git a/fuzz/src/main.rs b/fuzz/src/main.rs index 5808443..c32e02b 100644 --- a/fuzz/src/main.rs +++ b/fuzz/src/main.rs @@ -6,6 +6,7 @@ fn main() { let mut mut_data: Vec = data.iter().map(|x| *x).collect(); let _ = in_place(&mut mut_data, &Cfg { minify_js: false, + minify_css: false, }); }); } diff --git a/java/pom.xml b/java/pom.xml index a98045f..880e448 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -9,7 +9,7 @@ 0.3.12 minify-html - Fast and smart HTML + JS minifier + Extremely fast and smart HTML + JS + CSS minifier https://github.com/wilsonzlin/minify-html diff --git a/java/src/main/java/in/wilsonl/minifyhtml/Configuration.java b/java/src/main/java/in/wilsonl/minifyhtml/Configuration.java index da8bee1..108f828 100644 --- a/java/src/main/java/in/wilsonl/minifyhtml/Configuration.java +++ b/java/src/main/java/in/wilsonl/minifyhtml/Configuration.java @@ -5,9 +5,11 @@ package in.wilsonl.minifyhtml; */ public class Configuration { private final boolean minifyJs; + private final boolean minifyCss; - public Configuration(boolean minifyJs) { + public Configuration(boolean minifyJs, boolean minifyCss) { this.minifyJs = minifyJs; + this.minifyCss = minifyCss; } /** @@ -15,14 +17,20 @@ public class Configuration { */ public static class Builder { private boolean minifyJs = false; + private boolean minifyCss = false; public Builder setMinifyJs(boolean minifyJs) { this.minifyJs = minifyJs; return this; } + public Builder setMinifyCss(boolean minifyCss) { + this.minifyCss = minifyCss; + return this; + } + public Configuration build() { - return new Configuration(this.minifyJs); + return new Configuration(this.minifyJs, this.minifyCss); } } } diff --git a/java/src/main/rust/lib.rs b/java/src/main/rust/lib.rs index d67e239..d558678 100644 --- a/java/src/main/rust/lib.rs +++ b/java/src/main/rust/lib.rs @@ -12,6 +12,7 @@ fn build_cfg( ) -> Cfg { Cfg { minify_js: env.get_field(*obj, "minifyJs", "Z").unwrap().z().unwrap(), + minify_css: env.get_field(*obj, "minifyCss", "Z").unwrap().z().unwrap(), } } diff --git a/nodejs/binding.c b/nodejs/binding.c index 33cf264..499163c 100644 --- a/nodejs/binding.c +++ b/nodejs/binding.c @@ -96,7 +96,19 @@ napi_value node_method_create_configuration(napi_env env, napi_callback_info inf return undefined; } - Cfg const* cfg = ffi_create_cfg(minify_js); + // Get `minifyCss` property. + napi_value minify_css_value; + if (napi_get_named_property(env, obj_arg, "minifyCss", &minify_css_value) != napi_ok) { + assert_ok(napi_throw_type_error(env, NULL, "Failed to get minifyCss property")); + return undefined; + } + bool minify_css; + if (napi_get_value_bool(env, minify_css_value, &minify_css) != napi_ok) { + assert_ok(napi_throw_type_error(env, NULL, "Failed to get minifyCss boolean property")); + return undefined; + } + + Cfg const* cfg = ffi_create_cfg(minify_js, minify_css); napi_value js_cfg; if (napi_create_external(env, (void*) cfg, js_cfg_finalizer, NULL, &js_cfg) != napi_ok) { diff --git a/nodejs/index.d.ts b/nodejs/index.d.ts index 4008002..79f6ec7 100644 --- a/nodejs/index.d.ts +++ b/nodejs/index.d.ts @@ -11,7 +11,11 @@ export function createConfiguration (options: { /** * If enabled, content in `"); eval_with_js_min(b"", b""); } + +#[cfg(feature = "js-esbuild")] +#[test] +fn test_css_minification() { + eval_with_css_min(b"", b""); +} diff --git a/src/unit/script.rs b/src/unit/script.rs index de8c257..091da90 100644 --- a/src/unit/script.rs +++ b/src/unit/script.rs @@ -9,7 +9,7 @@ use crate::proc::Processor; use { std::sync::Arc, esbuild_rs::{TransformOptionsBuilder, TransformOptions}, - crate::proc::JsMinSection, + crate::proc::EsbuildSection, crate::proc::checkpoint::WriteCheckpoint, }; @@ -36,14 +36,15 @@ pub fn process_script(proc: &mut Processor, cfg: &Cfg, js: bool) -> ProcessingRe proc.m(WhileNotSeq(&SCRIPT_END), Keep); // `process_tag` will require closing tag. + // TODO This is copied from style.rs. #[cfg(feature = "js-esbuild")] if js && cfg.minify_js { - let (wg, results) = proc.new_script_section(); + let (wg, results) = proc.new_esbuild_section(); let src = start.written_range(proc); unsafe { esbuild_rs::transform_direct_unmanaged(&proc[src], &TRANSFORM_OPTIONS.clone(), move |result| { let mut guard = results.lock().unwrap(); - guard.push(JsMinSection { + guard.push(EsbuildSection { src, result, }); diff --git a/src/unit/style.rs b/src/unit/style.rs index e867091..0b1d99e 100644 --- a/src/unit/style.rs +++ b/src/unit/style.rs @@ -4,16 +4,59 @@ use crate::err::ProcessingResult; use crate::proc::MatchAction::*; use crate::proc::MatchMode::*; use crate::proc::Processor; +#[cfg(feature = "js-esbuild")] +use { + std::sync::Arc, + esbuild_rs::{Loader, TransformOptionsBuilder, TransformOptions}, + crate::proc::EsbuildSection, + crate::proc::checkpoint::WriteCheckpoint, +}; +use crate::Cfg; + +#[cfg(feature = "js-esbuild")] +lazy_static! { + static ref TRANSFORM_OPTIONS: Arc = { + let mut builder = TransformOptionsBuilder::new(); + builder.loader = Loader::CSS; + builder.minify_identifiers = true; + builder.minify_syntax = true; + builder.minify_whitespace = true; + builder.build() + }; +} lazy_static! { static ref STYLE_END: AhoCorasick = AhoCorasickBuilder::new().ascii_case_insensitive(true).build(&[" ProcessingResult<()> { +pub fn process_style(proc: &mut Processor, cfg: &Cfg) -> ProcessingResult<()> { + #[cfg(feature = "js-esbuild")] + let start = WriteCheckpoint::new(proc); proc.require_not_at_end()?; proc.m(WhileNotSeq(&STYLE_END), Keep); // `process_tag` will require closing tag. + // TODO This is copied from script.rs. + #[cfg(feature = "js-esbuild")] + if cfg.minify_css { + let (wg, results) = proc.new_esbuild_section(); + let src = start.written_range(proc); + unsafe { + esbuild_rs::transform_direct_unmanaged(&proc[src], &TRANSFORM_OPTIONS.clone(), move |result| { + let mut guard = results.lock().unwrap(); + guard.push(EsbuildSection { + src, + result, + }); + // Drop Arc reference and Mutex guard before marking task as complete as it's possible proc::finish + // waiting on WaitGroup will resume before Arc/Mutex is dropped after exiting this function. + drop(guard); + drop(results); + drop(wg); + }); + }; + }; + Ok(()) } diff --git a/src/unit/tag.rs b/src/unit/tag.rs index d71187b..49308a0 100644 --- a/src/unit/tag.rs +++ b/src/unit/tag.rs @@ -211,7 +211,7 @@ pub fn process_tag( match tag_type { TagType::ScriptData => process_script(proc, cfg, false)?, TagType::ScriptJs => process_script(proc, cfg, true)?, - TagType::Style => process_style(proc)?, + TagType::Style => process_style(proc, cfg)?, _ => closing_tag_omitted = process_content(proc, cfg, child_ns, Some(tag_name))?.closing_tag_omitted, };