Skip to content

Commit b5474a2

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

14 files changed

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

+66-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,19 @@ 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
86+
.debug_struct("InlineOptions");
87+
debug
7788
.field("inline_style_tags", &self.inline_style_tags)
7889
.field("keep_style_tags", &self.keep_style_tags)
7990
.field("keep_link_tags", &self.keep_link_tags)
8091
.field("base_url", &self.base_url)
81-
.field("load_remote_stylesheets", &self.load_remote_stylesheets)
92+
.field("load_remote_stylesheets", &self.load_remote_stylesheets);
93+
#[cfg(feature = "stylesheet-cache")]
94+
{
95+
debug.field("cache", &self.cache);
96+
}
97+
debug
8298
.field("extra_css", &self.extra_css)
8399
.field("preallocate_node_capacity", &self.preallocate_node_capacity)
84100
.finish_non_exhaustive()
@@ -121,6 +137,18 @@ impl<'a> InlineOptions<'a> {
121137
self
122138
}
123139

140+
/// Set external stylesheet cache.
141+
#[must_use]
142+
#[cfg(feature = "stylesheet-cache")]
143+
pub fn cache(mut self, cache: impl Into<Option<StylesheetCache>>) -> Self {
144+
if let Some(cache) = cache.into() {
145+
self.cache = Some(std::sync::Mutex::new(cache));
146+
} else {
147+
self.cache = None;
148+
}
149+
self
150+
}
151+
124152
/// Set additional CSS to inline.
125153
#[must_use]
126154
pub fn extra_css(mut self, extra_css: Option<Cow<'a, str>>) -> Self {
@@ -158,6 +186,8 @@ impl Default for InlineOptions<'_> {
158186
keep_link_tags: false,
159187
base_url: None,
160188
load_remote_stylesheets: true,
189+
#[cfg(feature = "stylesheet-cache")]
190+
cache: None,
161191
extra_css: None,
162192
preallocate_node_capacity: 32,
163193
resolver: Arc::new(DefaultStylesheetResolver),
@@ -247,7 +277,13 @@ impl<'a> CSSInliner<'a> {
247277
/// - Remote stylesheet is not available;
248278
/// - IO errors;
249279
/// - Internal CSS selector parsing error;
280+
///
281+
/// # Panics
282+
///
283+
/// This function may panic if external stylesheet cache lock is poisoned, i.e. another thread
284+
/// using the same inliner panicked while resolving external stylesheets.
250285
#[inline]
286+
#[allow(clippy::too_many_lines)]
251287
pub fn inline_to<W: Write>(&self, html: &str, target: &mut W) -> Result<()> {
252288
let document =
253289
Document::parse_with_options(html.as_bytes(), self.options.preallocate_node_capacity);
@@ -285,9 +321,25 @@ impl<'a> CSSInliner<'a> {
285321
links.dedup();
286322
for href in &links {
287323
let url = self.get_full_url(href);
324+
#[cfg(feature = "stylesheet-cache")]
325+
if let Some(lock) = self.options.cache.as_ref() {
326+
let mut cache = lock.lock().expect("Cache lock is poisoned");
327+
if let Some(cached) = cache.get(url.as_ref()) {
328+
raw_styles.push_str(cached);
329+
raw_styles.push('\n');
330+
continue;
331+
}
332+
}
333+
288334
let css = self.options.resolver.retrieve(url.as_ref())?;
289335
raw_styles.push_str(&css);
290336
raw_styles.push('\n');
337+
338+
#[cfg(feature = "stylesheet-cache")]
339+
if let Some(lock) = self.options.cache.as_ref() {
340+
let mut cache = lock.lock().expect("Cache lock is poisoned");
341+
cache.put(url.into_owned(), css);
342+
}
291343
}
292344
}
293345
if let Some(extra_css) = &self.options.extra_css {
@@ -415,3 +467,15 @@ pub fn inline(html: &str) -> Result<String> {
415467
pub fn inline_to<W: Write>(html: &str, target: &mut W) -> Result<()> {
416468
CSSInliner::default().inline_to(html, target)
417469
}
470+
471+
#[cfg(test)]
472+
mod tests {
473+
use crate::{CSSInliner, InlineOptions};
474+
475+
#[test]
476+
fn test_inliner_sync_send() {
477+
fn assert_send<T: Send + Sync>() {}
478+
assert_send::<CSSInliner<'_>>();
479+
assert_send::<InlineOptions<'_>>();
480+
}
481+
}

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

+58
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,60 @@ fn nth_child_selector() {
927928
</body></html>"#
928929
);
929930
}
931+
932+
#[test]
933+
#[cfg(feature = "stylesheet-cache")]
934+
fn test_cache() {
935+
use std::num::NonZeroUsize;
936+
use std::sync::{Arc, Mutex};
937+
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<Mutex<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.lock().expect("Lock is poisoned");
960+
*hits += 1;
961+
Ok("h1 { color: blue; }".to_string())
962+
}
963+
}
964+
965+
let hits = Arc::new(Mutex::new(0));
966+
967+
let inliner = CSSInliner::options()
968+
.resolver(Arc::new(CustomStylesheetResolver { hits: hits.clone() }))
969+
.cache(css_inline::StylesheetCache::new(
970+
NonZeroUsize::new(3).unwrap(),
971+
))
972+
.build();
973+
for _ in 0..5 {
974+
let inlined = inliner.inline(html);
975+
assert_http(
976+
inlined,
977+
r#"<body>
978+
<h1 style="color: blue;">Big Text</h1>
979+
<h2 style="color: red;">Smaller Text</h2>
980+
981+
</body></html>"#,
982+
);
983+
}
984+
985+
let hits = hits.lock().expect("Lock is poisoned");
986+
assert_eq!(*hits, 1);
987+
}

0 commit comments

Comments
 (0)