Skip to content

Commit 70fd340

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

14 files changed

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

+22
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+
- Optionally caches 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.
@@ -99,6 +100,7 @@ fn main() -> css_inline::Result<()> {
99100
- `keep_link_tags`. Specifies whether to keep "link" tags after inlining. Default: `false`
100101
- `base_url`. The base URL used to resolve relative URLs. If you'd like to load stylesheets from your filesystem, use the `file://` scheme. Default: `None`
101102
- `load_remote_stylesheets`. Specifies whether remote stylesheets should be loaded. Default: `true`
103+
- `cache`. Specifies cache for external stylesheets. Default: `None`
102104
- `extra_css`. Extra CSS to be inlined. Default: `None`
103105
- `preallocate_node_capacity`. **Advanced**. Preallocates capacity for HTML nodes during parsing. This can improve performance when you have an estimate of the number of nodes in your HTML document. Default: `32`
104106

@@ -177,6 +179,26 @@ fn main() -> css_inline::Result<()> {
177179
}
178180
```
179181

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

182204
`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

+2
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+
- Optionally caches external stylesheets
3940
- Works on Linux, Windows, and macOS
4041
- Supports HTML5 & CSS3
4142

@@ -112,6 +113,7 @@ Possible configurations:
112113
- `keep_link_tags`. Specifies whether to keep "link" tags after inlining. Default: `false`
113114
- `base_url`. The base URL used to resolve relative URLs. If you'd like to load stylesheets from your filesystem, use the `file://` scheme. Default: `NULL`
114115
- `load_remote_stylesheets`. Specifies whether remote stylesheets should be loaded. Default: `true`
116+
- `cache`. Specifies caching options for external stylesheets. Default: `NULL` - TODO
115117
- `extra_css`. Extra CSS to be inlined. Default: `NULL`
116118
- `preallocate_node_capacity`. **Advanced**. Preallocates capacity for HTML nodes during parsing. This can improve performance when you have an estimate of the number of nodes in your HTML document. Default: `32`
117119

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

+2
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+
- Optionally caches external stylesheets
4041
- Works on Linux, Windows, and macOS
4142
- Supports HTML5 & CSS3
4243
- Tested on Node.js 18 & 20.
@@ -82,6 +83,7 @@ var inlined = inline(
8283
- `keepLinkTags`. Specifies whether to keep "link" tags after inlining. Default: `false`
8384
- `baseUrl`. The base URL used to resolve relative URLs. If you'd like to load stylesheets from your filesystem, use the `file://` scheme. Default: `null`
8485
- `loadRemoteStylesheets`. Specifies whether remote stylesheets should be loaded. Default: `true`
86+
- `cache`. Specifies caching options for external stylesheets (for example, `{size: 5}`). Default: `null`
8587
- `extraCss`. Extra CSS to be inlined. Default: `null`
8688
- `preallocateNodeCapacity`. **Advanced**. Preallocates capacity for HTML nodes during parsing. This can improve performance when you have an estimate of the number of nodes in your HTML document. Default: `32`
8789

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

+2
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+
- Optionally caches external stylesheets
4344
- Can process multiple documents in parallel
4445
- Works on Linux, Windows, and macOS
4546
- Supports HTML5 & CSS3
@@ -117,6 +118,7 @@ inliner.inline("...")
117118
- `keep_link_tags`. Specifies whether to keep "link" tags after inlining. Default: `False`
118119
- `base_url`. The base URL used to resolve relative URLs. If you'd like to load stylesheets from your filesystem, use the `file://` scheme. Default: `None`
119120
- `load_remote_stylesheets`. Specifies whether remote stylesheets should be loaded. Default: `True`
121+
- `cache`. Specifies caching options for external stylesheets (for example, `{"size": 5}`). Default: `null`
120122
- `extra_css`. Extra CSS to be inlined. Default: `None`
121123
- `preallocate_node_capacity`. **Advanced**. Preallocates capacity for HTML nodes during parsing. This can improve performance when you have an estimate of the number of nodes in your HTML document. Default: `32`
122124

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

+2
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+
- Optionally caches external stylesheets
4041
- Can process multiple documents in parallel
4142
- Works on Linux, Windows, and macOS
4243
- Supports HTML5 & CSS3
@@ -98,6 +99,7 @@ inliner.inline("...")
9899
- `keep_link_tags`. Specifies whether to keep "link" tags after inlining. Default: `False`
99100
- `base_url`. The base URL used to resolve relative URLs. If you'd like to load stylesheets from your filesystem, use the `file://` scheme. Default: `nil`
100101
- `load_remote_stylesheets`. Specifies whether remote stylesheets should be loaded. Default: `True`
102+
- `cache`. Specifies caching options for external stylesheets (for example, `{size => 5}`). Default: `null`
101103
- `extra_css`. Extra CSS to be inlined. Default: `nil`
102104
- `preallocate_node_capacity`. **Advanced**. Preallocates capacity for HTML nodes during parsing. This can improve performance when you have an estimate of the number of nodes in your HTML document. Default: `32`
103105

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),

0 commit comments

Comments
 (0)