Skip to content

Commit 9a61b9e

Browse files
committed
feat: External stylesheet caching
Ref: #314 Signed-off-by: Dmitry Dygalo <[email protected]>
1 parent ee538f2 commit 9a61b9e

14 files changed

+190
-3
lines changed

CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## [Unreleased]
44

5+
### Added
6+
7+
- External stylesheet caching. [#314](https://github.com/Stranger6667/css-inline/issues/314)
8+
59
### Changed
610

711
- Update `html5ever` to `0.27`.

README.md

+21
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ into:
3838
- Inlines CSS from `style` and `link` tags
3939
- Removes `style` and `link` tags
4040
- Resolves external stylesheets (including local files)
41+
- Caching for external stylesheets
4142
- Works on Linux, Windows, and macOS
4243
- Supports HTML5 & CSS3
4344
- Bindings for [Python](https://github.com/Stranger6667/css-inline/tree/master/bindings/python), [Ruby](https://github.com/Stranger6667/css-inline/tree/master/bindings/ruby), [JavaScript](https://github.com/Stranger6667/css-inline/tree/master/bindings/javascript), [C](https://github.com/Stranger6667/css-inline/tree/master/bindings/c), and a [WebAssembly](https://github.com/Stranger6667/css-inline/tree/master/bindings/javascript/wasm) module to run in browsers.
@@ -177,6 +178,26 @@ fn main() -> css_inline::Result<()> {
177178
}
178179
```
179180

181+
You can also cache external stylesheets to avoid excessive network requests:
182+
183+
```rust
184+
use std::num::NonZeroUsize;
185+
186+
fn main() -> css_inline::Result<()> {
187+
let inliner = css_inline::CSSInliner::options()
188+
.cache(
189+
// This is an LRU cache
190+
css_inline::StylesheetCache::new(
191+
NonZeroUsize::new(5).expect("Invalid cache size")
192+
)
193+
)
194+
.build();
195+
Ok(())
196+
}
197+
```
198+
199+
Caching is disabled by default.
200+
180201
## Performance
181202

182203
`css-inline` typically inlines HTML emails within hundreds of microseconds, though results may vary with input complexity.

bindings/c/CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## [Unreleased]
44

5+
### Added
6+
7+
- External stylesheet caching. [#314](https://github.com/Stranger6667/css-inline/issues/314)
8+
59
### Changed
610

711
- Update `html5ever` to `0.27`.

bindings/c/README.md

+1
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ into:
3636
- Inlines CSS from `style` and `link` tags
3737
- Removes `style` and `link` tags
3838
- Resolves external stylesheets (including local files)
39+
- Caching for external stylesheets
3940
- Works on Linux, Windows, and macOS
4041
- Supports HTML5 & CSS3
4142

bindings/javascript/CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## [Unreleased]
44

5+
### Added
6+
7+
- External stylesheet caching. [#314](https://github.com/Stranger6667/css-inline/issues/314)
8+
59
## [0.13.2] - 2024-03-25
610

711
### Changed

bindings/javascript/README.md

+1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ into:
3737
- Inlines CSS from `style` and `link` tags
3838
- Removes `style` and `link` tags
3939
- Resolves external stylesheets (including local files)
40+
- Caching for external stylesheets
4041
- Works on Linux, Windows, and macOS
4142
- Supports HTML5 & CSS3
4243
- Tested on Node.js 18 & 20.

bindings/python/CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## [Unreleased]
44

5+
### Added
6+
7+
- External stylesheet caching. [#314](https://github.com/Stranger6667/css-inline/issues/314)
8+
59
### Changed
610

711
- Update `html5ever` to `0.27`.

bindings/python/README.md

+1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ into:
4040
- Inlines CSS from `style` and `link` tags
4141
- Removes `style` and `link` tags
4242
- Resolves external stylesheets (including local files)
43+
- Caching for external stylesheets
4344
- Can process multiple documents in parallel
4445
- Works on Linux, Windows, and macOS
4546
- Supports HTML5 & CSS3

bindings/ruby/CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
## [Unreleased]
44

5+
### Added
6+
7+
- External stylesheet caching. [#314](https://github.com/Stranger6667/css-inline/issues/314)
8+
59
### Changed
610

711
- Update `html5ever` to `0.27`.

bindings/ruby/README.md

+1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ into:
3737
- Inlines CSS from `style` and `link` tags
3838
- Removes `style` and `link` tags
3939
- Resolves external stylesheets (including local files)
40+
- Caching for external stylesheets
4041
- Can process multiple documents in parallel
4142
- Works on Linux, Windows, and macOS
4243
- Supports HTML5 & CSS3

css-inline/Cargo.toml

+3-1
Original file line numberDiff line numberDiff line change
@@ -22,15 +22,17 @@ rust-version = "1.65"
2222
name = "css-inline"
2323

2424
[features]
25-
default = ["cli", "http", "file"]
25+
default = ["cli", "http", "file", "stylesheet-cache"]
2626
cli = ["pico-args", "rayon"]
2727
http = ["reqwest"]
2828
file = []
29+
stylesheet-cache = ["lru"]
2930

3031
[dependencies]
3132
cssparser = "0.31.2"
3233
html5ever = "0.27.0"
3334
indexmap = "2.1"
35+
lru = { version = "0.12.3", optional = true }
3436
pico-args = { version = "0.3", optional = true }
3537
rayon = { version = "1.10", optional = true }
3638
reqwest = { version = "0.12.0", optional = true, default-features = false, features = ["rustls-tls", "blocking"] }

css-inline/src/lib.rs

+65-2
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ mod resolver;
3535

3636
pub use error::InlineError;
3737
use indexmap::IndexMap;
38+
#[cfg(feature = "stylesheet-cache")]
39+
use lru::{DefaultHasher, LruCache};
3840
use std::{borrow::Cow, fmt::Formatter, hash::BuildHasherDefault, io::Write, sync::Arc};
3941

4042
use crate::html::ElementStyleMap;
@@ -43,6 +45,10 @@ use html::Document;
4345
pub use resolver::{DefaultStylesheetResolver, StylesheetResolver};
4446
pub use url::{ParseError, Url};
4547

48+
/// An LRU Cache for external stylesheets.
49+
#[cfg(feature = "stylesheet-cache")]
50+
pub type StylesheetCache<S = DefaultHasher> = LruCache<String, String, S>;
51+
4652
/// Configuration options for CSS inlining process.
4753
#[allow(clippy::struct_excessive_bools)]
4854
pub struct InlineOptions<'a> {
@@ -59,6 +65,9 @@ pub struct InlineOptions<'a> {
5965
pub base_url: Option<Url>,
6066
/// Whether remote stylesheets should be loaded or not.
6167
pub load_remote_stylesheets: bool,
68+
/// External stylesheet cache.
69+
#[cfg(feature = "stylesheet-cache")]
70+
pub cache: Option<std::sync::Mutex<StylesheetCache>>,
6271
// The point of using `Cow` here is Python bindings, where it is problematic to pass a reference
6372
// without dealing with memory leaks & unsafe. With `Cow` we can use moved values as `String` in
6473
// Python wrapper for `CSSInliner` and `&str` in Rust & simple functions on the Python side
@@ -73,12 +82,18 @@ pub struct InlineOptions<'a> {
7382

7483
impl<'a> std::fmt::Debug for InlineOptions<'a> {
7584
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
76-
f.debug_struct("InlineOptions")
85+
let mut debug = f.debug_struct("InlineOptions");
86+
debug
7787
.field("inline_style_tags", &self.inline_style_tags)
7888
.field("keep_style_tags", &self.keep_style_tags)
7989
.field("keep_link_tags", &self.keep_link_tags)
8090
.field("base_url", &self.base_url)
81-
.field("load_remote_stylesheets", &self.load_remote_stylesheets)
91+
.field("load_remote_stylesheets", &self.load_remote_stylesheets);
92+
#[cfg(feature = "stylesheet-cache")]
93+
{
94+
debug.field("cache", &self.cache);
95+
}
96+
debug
8297
.field("extra_css", &self.extra_css)
8398
.field("preallocate_node_capacity", &self.preallocate_node_capacity)
8499
.finish_non_exhaustive()
@@ -121,6 +136,18 @@ impl<'a> InlineOptions<'a> {
121136
self
122137
}
123138

139+
/// Set external stylesheet cache.
140+
#[must_use]
141+
#[cfg(feature = "stylesheet-cache")]
142+
pub fn cache(mut self, cache: impl Into<Option<StylesheetCache>>) -> Self {
143+
if let Some(cache) = cache.into() {
144+
self.cache = Some(std::sync::Mutex::new(cache));
145+
} else {
146+
self.cache = None;
147+
}
148+
self
149+
}
150+
124151
/// Set additional CSS to inline.
125152
#[must_use]
126153
pub fn extra_css(mut self, extra_css: Option<Cow<'a, str>>) -> Self {
@@ -158,6 +185,8 @@ impl Default for InlineOptions<'_> {
158185
keep_link_tags: false,
159186
base_url: None,
160187
load_remote_stylesheets: true,
188+
#[cfg(feature = "stylesheet-cache")]
189+
cache: None,
161190
extra_css: None,
162191
preallocate_node_capacity: 32,
163192
resolver: Arc::new(DefaultStylesheetResolver),
@@ -247,7 +276,13 @@ impl<'a> CSSInliner<'a> {
247276
/// - Remote stylesheet is not available;
248277
/// - IO errors;
249278
/// - Internal CSS selector parsing error;
279+
///
280+
/// # Panics
281+
///
282+
/// This function may panic if external stylesheet cache lock is poisoned, i.e. another thread
283+
/// using the same inliner panicked while resolving external stylesheets.
250284
#[inline]
285+
#[allow(clippy::too_many_lines)]
251286
pub fn inline_to<W: Write>(&self, html: &str, target: &mut W) -> Result<()> {
252287
let document =
253288
Document::parse_with_options(html.as_bytes(), self.options.preallocate_node_capacity);
@@ -285,9 +320,25 @@ impl<'a> CSSInliner<'a> {
285320
links.dedup();
286321
for href in &links {
287322
let url = self.get_full_url(href);
323+
#[cfg(feature = "stylesheet-cache")]
324+
if let Some(lock) = self.options.cache.as_ref() {
325+
let mut cache = lock.lock().expect("Cache lock is poisoned");
326+
if let Some(cached) = cache.get(url.as_ref()) {
327+
raw_styles.push_str(cached);
328+
raw_styles.push('\n');
329+
continue;
330+
}
331+
}
332+
288333
let css = self.options.resolver.retrieve(url.as_ref())?;
289334
raw_styles.push_str(&css);
290335
raw_styles.push('\n');
336+
337+
#[cfg(feature = "stylesheet-cache")]
338+
if let Some(lock) = self.options.cache.as_ref() {
339+
let mut cache = lock.lock().expect("Cache lock is poisoned");
340+
cache.put(url.into_owned(), css);
341+
}
291342
}
292343
}
293344
if let Some(extra_css) = &self.options.extra_css {
@@ -415,3 +466,15 @@ pub fn inline(html: &str) -> Result<String> {
415466
pub fn inline_to<W: Write>(html: &str, target: &mut W) -> Result<()> {
416467
CSSInliner::default().inline_to(html, target)
417468
}
469+
470+
#[cfg(test)]
471+
mod tests {
472+
use crate::{CSSInliner, InlineOptions};
473+
474+
#[test]
475+
fn test_inliner_sync_send() {
476+
fn assert_send<T: Send + Sync>() {}
477+
assert_send::<CSSInliner<'_>>();
478+
assert_send::<InlineOptions<'_>>();
479+
}
480+
}

css-inline/src/main.rs

+17
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@ OPTIONS:
6262
--load-remote-stylesheets
6363
Whether remote stylesheets should be loaded or not.
6464
65+
--no-cache
66+
Disable caching of remote stylesheets.
67+
6568
--extra-css
6669
Additional CSS to inline.
6770
@@ -79,6 +82,8 @@ OPTIONS:
7982
extra_css: Option<String>,
8083
output_filename_prefix: Option<OsString>,
8184
load_remote_stylesheets: bool,
85+
#[cfg(feature = "stylesheet-cache")]
86+
no_cache: bool,
8287
files: Vec<String>,
8388
}
8489

@@ -117,6 +122,8 @@ OPTIONS:
117122
extra_css: args.opt_value_from_str("--extra-css")?,
118123
output_filename_prefix: args.opt_value_from_str("--output-filename-prefix")?,
119124
load_remote_stylesheets: args.contains("--load-remote-stylesheets"),
125+
#[cfg(feature = "stylesheet-cache")]
126+
no_cache: args.contains("--no-cache"),
120127
files: args.free()?,
121128
};
122129
let base_url = match parse_url(args.base_url) {
@@ -132,6 +139,16 @@ OPTIONS:
132139
keep_link_tags: args.keep_link_tags,
133140
base_url,
134141
load_remote_stylesheets: args.load_remote_stylesheets,
142+
#[cfg(feature = "stylesheet-cache")]
143+
cache: {
144+
if args.no_cache {
145+
None
146+
} else {
147+
Some(std::sync::Mutex::new(css_inline::StylesheetCache::new(
148+
std::num::NonZeroUsize::new(5).expect("Invalid cache size"),
149+
)))
150+
}
151+
},
135152
extra_css: args.extra_css.as_deref().map(Cow::Borrowed),
136153
preallocate_node_capacity: 32,
137154
resolver: Arc::new(DefaultStylesheetResolver),

css-inline/tests/test_inlining.rs

+60
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
#[macro_use]
22
mod utils;
3+
34
use css_inline::{inline, CSSInliner, InlineOptions, Url};
45
use test_case::test_case;
56

@@ -927,3 +928,62 @@ fn nth_child_selector() {
927928
</body></html>"#
928929
);
929930
}
931+
932+
#[test]
933+
#[cfg(feature = "stylesheet-cache")]
934+
fn test_cache() {
935+
use std::{
936+
num::NonZeroUsize,
937+
sync::{Arc, Mutex},
938+
};
939+
940+
let html = r#"
941+
<html>
942+
<head>
943+
<link href="http://127.0.0.1:1234/external.css" rel="stylesheet">
944+
<style>
945+
h2 { color: red; }
946+
</style>
947+
</head>
948+
<body>
949+
<h1>Big Text</h1>
950+
<h2>Smaller Text</h2>
951+
</body>
952+
</html>"#;
953+
954+
#[derive(Debug, Default)]
955+
pub struct CustomStylesheetResolver {
956+
hits: Arc<Mutex<usize>>,
957+
}
958+
959+
impl css_inline::StylesheetResolver for CustomStylesheetResolver {
960+
fn retrieve(&self, _: &str) -> css_inline::Result<String> {
961+
let mut hits = self.hits.lock().expect("Lock is poisoned");
962+
*hits += 1;
963+
Ok("h1 { color: blue; }".to_string())
964+
}
965+
}
966+
967+
let hits = Arc::new(Mutex::new(0));
968+
969+
let inliner = CSSInliner::options()
970+
.resolver(Arc::new(CustomStylesheetResolver { hits: hits.clone() }))
971+
.cache(css_inline::StylesheetCache::new(
972+
NonZeroUsize::new(3).unwrap(),
973+
))
974+
.build();
975+
for _ in 0..5 {
976+
let inlined = inliner.inline(html);
977+
assert_http(
978+
inlined,
979+
r#"<body>
980+
<h1 style="color: blue;">Big Text</h1>
981+
<h2 style="color: red;">Smaller Text</h2>
982+
983+
</body></html>"#,
984+
);
985+
}
986+
987+
let hits = hits.lock().expect("Lock is poisoned");
988+
assert_eq!(*hits, 1);
989+
}

0 commit comments

Comments
 (0)