Skip to content

Commit 899670f

Browse files
committed
feat: External stylesheet caching
Ref: #314
1 parent ee538f2 commit 899670f

14 files changed

+165
-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

+58-1
Original file line numberDiff line numberDiff line change
@@ -35,14 +35,24 @@ mod resolver;
3535

3636
pub use error::InlineError;
3737
use indexmap::IndexMap;
38-
use std::{borrow::Cow, fmt::Formatter, hash::BuildHasherDefault, io::Write, sync::Arc};
38+
use lru::{DefaultHasher, LruCache};
39+
use std::{
40+
borrow::Cow,
41+
fmt::Formatter,
42+
hash::BuildHasherDefault,
43+
io::Write,
44+
sync::{Arc, Mutex},
45+
};
3946

4047
use crate::html::ElementStyleMap;
4148
use hasher::BuildNoHashHasher;
4249
use html::Document;
4350
pub use resolver::{DefaultStylesheetResolver, StylesheetResolver};
4451
pub use url::{ParseError, Url};
4552

53+
/// An LRU Cache for external stylesheets.
54+
pub type StylesheetCache<S = DefaultHasher> = LruCache<String, String, S>;
55+
4656
/// Configuration options for CSS inlining process.
4757
#[allow(clippy::struct_excessive_bools)]
4858
pub struct InlineOptions<'a> {
@@ -59,6 +69,8 @@ pub struct InlineOptions<'a> {
5969
pub base_url: Option<Url>,
6070
/// Whether remote stylesheets should be loaded or not.
6171
pub load_remote_stylesheets: bool,
72+
/// External stylesheet cache.
73+
pub cache: Option<Mutex<StylesheetCache>>,
6274
// The point of using `Cow` here is Python bindings, where it is problematic to pass a reference
6375
// without dealing with memory leaks & unsafe. With `Cow` we can use moved values as `String` in
6476
// Python wrapper for `CSSInliner` and `&str` in Rust & simple functions on the Python side
@@ -79,6 +91,7 @@ impl<'a> std::fmt::Debug for InlineOptions<'a> {
7991
.field("keep_link_tags", &self.keep_link_tags)
8092
.field("base_url", &self.base_url)
8193
.field("load_remote_stylesheets", &self.load_remote_stylesheets)
94+
.field("cache", &self.cache)
8295
.field("extra_css", &self.extra_css)
8396
.field("preallocate_node_capacity", &self.preallocate_node_capacity)
8497
.finish_non_exhaustive()
@@ -121,6 +134,17 @@ impl<'a> InlineOptions<'a> {
121134
self
122135
}
123136

137+
/// Set external stylesheet cache size.
138+
#[must_use]
139+
pub fn cache(mut self, cache: impl Into<Option<StylesheetCache>>) -> Self {
140+
if let Some(cache) = cache.into() {
141+
self.cache = Some(Mutex::new(cache));
142+
} else {
143+
self.cache = None;
144+
}
145+
self
146+
}
147+
124148
/// Set additional CSS to inline.
125149
#[must_use]
126150
pub fn extra_css(mut self, extra_css: Option<Cow<'a, str>>) -> Self {
@@ -158,6 +182,7 @@ impl Default for InlineOptions<'_> {
158182
keep_link_tags: false,
159183
base_url: None,
160184
load_remote_stylesheets: true,
185+
cache: None,
161186
extra_css: None,
162187
preallocate_node_capacity: 32,
163188
resolver: Arc::new(DefaultStylesheetResolver),
@@ -247,7 +272,13 @@ impl<'a> CSSInliner<'a> {
247272
/// - Remote stylesheet is not available;
248273
/// - IO errors;
249274
/// - Internal CSS selector parsing error;
275+
///
276+
/// # Panics
277+
///
278+
/// This function may panic if external stylesheet cache lock is poisoned, i.e. another thread
279+
/// using the same inliner panicked while resolving external stylesheets.
250280
#[inline]
281+
#[allow(clippy::too_many_lines)]
251282
pub fn inline_to<W: Write>(&self, html: &str, target: &mut W) -> Result<()> {
252283
let document =
253284
Document::parse_with_options(html.as_bytes(), self.options.preallocate_node_capacity);
@@ -285,9 +316,23 @@ impl<'a> CSSInliner<'a> {
285316
links.dedup();
286317
for href in &links {
287318
let url = self.get_full_url(href);
319+
if let Some(lock) = self.options.cache.as_ref() {
320+
let mut cache = lock.lock().expect("Cache lock is poisoned");
321+
if let Some(cached) = cache.get(url.as_ref()) {
322+
raw_styles.push_str(cached);
323+
raw_styles.push('\n');
324+
continue;
325+
}
326+
}
327+
288328
let css = self.options.resolver.retrieve(url.as_ref())?;
289329
raw_styles.push_str(&css);
290330
raw_styles.push('\n');
331+
332+
if let Some(lock) = self.options.cache.as_ref() {
333+
let mut cache = lock.lock().expect("Cache lock is poisoned");
334+
cache.put(url.into_owned(), css);
335+
}
291336
}
292337
}
293338
if let Some(extra_css) = &self.options.extra_css {
@@ -415,3 +460,15 @@ pub fn inline(html: &str) -> Result<String> {
415460
pub fn inline_to<W: Write>(html: &str, target: &mut W) -> Result<()> {
416461
CSSInliner::default().inline_to(html, target)
417462
}
463+
464+
#[cfg(test)]
465+
mod tests {
466+
use crate::{CSSInliner, InlineOptions};
467+
468+
#[test]
469+
fn test_inliner_sync_send() {
470+
fn assert_send<T: Send + Sync>() {}
471+
assert_send::<CSSInliner<'_>>();
472+
assert_send::<InlineOptions<'_>>();
473+
}
474+
}

css-inline/src/main.rs

+2
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,8 @@ OPTIONS:
132132
keep_link_tags: args.keep_link_tags,
133133
base_url,
134134
load_remote_stylesheets: args.load_remote_stylesheets,
135+
// TODO: Customize
136+
cache: None,
135137
extra_css: args.extra_css.as_deref().map(Cow::Borrowed),
136138
preallocate_node_capacity: 32,
137139
resolver: Arc::new(DefaultStylesheetResolver),

css-inline/tests/test_inlining.rs

+57-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
#[macro_use]
22
mod utils;
3-
use css_inline::{inline, CSSInliner, InlineOptions, Url};
3+
4+
use css_inline::{inline, CSSInliner, InlineOptions, StylesheetCache, Url};
5+
use std::{
6+
num::NonZeroUsize,
7+
sync::{Arc, RwLock},
8+
};
49
use test_case::test_case;
510

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

0 commit comments

Comments
 (0)