Skip to content

Commit 313b594

Browse files
committed
feat: Loading external stylesheets
Ref: #8
1 parent b381282 commit 313b594

File tree

11 files changed

+199
-39
lines changed

11 files changed

+199
-39
lines changed

.github/workflows/build.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,12 @@ jobs:
5858
with:
5959
python-version: 3.7
6060

61+
- name: Start background server
62+
run: |
63+
python -m pip install flask
64+
# Starts the server in background
65+
python ./tests/server.py &
66+
6167
- uses: actions-rs/toolchain@v1
6268
with:
6369
profile: minimal

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,17 @@
99
- `CSSInliner::compact()` constructor for producing smaller HTML output.
1010
- `CSSInliner.inline_to` that writes the output to a generic writer. [#24](https://github.com/Stranger6667/css-inline/issues/24)
1111
- Implement `Error` for `InlineError`.
12+
- Loading external stylesheets. [#8](https://github.com/Stranger6667/css-inline/issues/8)
1213

1314
### Changed
1415

1516
- Improved error messages. [#27](https://github.com/Stranger6667/css-inline/issues/27)
17+
- Skip selectors, that can't be parsed.
1618

1719
### Fixed
1820

1921
- Ignore `@media` queries, since they can not be inlined. [#7](https://github.com/Stranger6667/css-inline/issues/7)
22+
- Panic in cases when styles are applied to the currently processed "link" tags.
2023

2124
### Performance
2225

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ categories = ["web-programming"]
1616
[dependencies]
1717
cssparser = "0"
1818
kuchiki = "0.8"
19+
attohttpc = "0"
1920

2021
[dev-dependencies]
2122
criterion = ">= 0.1"

python/CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@
22

33
## [Unreleased]
44

5+
### Added
6+
7+
- Loading external stylesheets. [#8](https://github.com/Stranger6667/css-inline/issues/8)
8+
9+
### Changed
10+
11+
- Skip selectors, that can't be parsed.
12+
13+
### Fixed
14+
15+
- Panic in cases when styles are applied to the currently processed "link" tags.
16+
517
## 0.1.0 - 2020-06-24
618

719
- Initial public release

python/README.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Fast CSS inlining for Python implemented in Rust.
88
Features:
99

1010
- Removing ``style`` tags after inlining;
11+
- Resolving external stylesheets (including local files);
1112
- ... more features will be available soon
1213

1314
**NOTE**. This library is in active development and provides a small number of features at the moment, see ``Limitations`` sections below for more information.
@@ -89,7 +90,6 @@ Limitations
8990

9091
Currently (as of ``0.1.0``) there are the following notable limitations:
9192

92-
- External stylesheets are not resolved (`#8 <https://github.com/Stranger6667/css-inline/issues/8>`_)
9393
- Inlined CSS is not minimized (`#12 <https://github.com/Stranger6667/css-inline/issues/12>`_)
9494
- `class` and `id` attributes are not removed (`#13 <https://github.com/Stranger6667/css-inline/issues/13>`_)
9595

python/src/lib.rs

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -10,23 +10,25 @@ create_exception!(css_inline, InlineError, exceptions::ValueError);
1010
fn to_pyerr(error: rust_inline::InlineError) -> PyErr {
1111
match error {
1212
rust_inline::InlineError::IO(error) => InlineError::py_err(format!("{}", error)),
13+
rust_inline::InlineError::Network(error) => InlineError::py_err(format!("{}", error)),
1314
rust_inline::InlineError::ParseError(message) => InlineError::py_err(message),
1415
}
1516
}
1617

1718
/// Customizable CSS inliner.
1819
#[pyclass]
19-
#[text_signature = "(remove_style_tags=False)"]
20+
#[text_signature = "(remove_style_tags=False, base_url=None)"]
2021
struct CSSInliner {
2122
inner: rust_inline::CSSInliner,
2223
}
2324

2425
#[pymethods]
2526
impl CSSInliner {
2627
#[new]
27-
fn new(remove_style_tags: Option<bool>) -> Self {
28+
fn new(remove_style_tags: Option<bool>, base_url: Option<String>) -> Self {
2829
let options = rust_inline::InlineOptions {
2930
remove_style_tags: remove_style_tags.unwrap_or(false),
31+
base_url,
3032
};
3133
CSSInliner {
3234
inner: rust_inline::CSSInliner::new(options),
@@ -50,27 +52,37 @@ impl CSSInliner {
5052
}
5153
}
5254

53-
/// inline(html, remove_style_tags=False)
55+
/// inline(html, remove_style_tags=False, base_url=None)
5456
///
5557
/// Inline CSS in the given HTML document
5658
#[pyfunction]
57-
#[text_signature = "(html, remove_style_tags=False)"]
58-
fn inline(html: &str, remove_style_tags: Option<bool>) -> PyResult<String> {
59+
#[text_signature = "(html, remove_style_tags=False, base_url=None)"]
60+
fn inline(
61+
html: &str,
62+
remove_style_tags: Option<bool>,
63+
base_url: Option<String>,
64+
) -> PyResult<String> {
5965
let options = rust_inline::InlineOptions {
6066
remove_style_tags: remove_style_tags.unwrap_or(false),
67+
base_url,
6168
};
6269
let inliner = rust_inline::CSSInliner::new(options);
6370
Ok(inliner.inline(html).map_err(to_pyerr)?)
6471
}
6572

66-
/// inline_many(htmls, remove_style_tags=False)
73+
/// inline_many(htmls, remove_style_tags=False, base_url=None)
6774
///
6875
/// Inline CSS in multiple HTML documents
6976
#[pyfunction]
70-
#[text_signature = "(htmls, remove_style_tags=False)"]
71-
fn inline_many(htmls: &PyList, remove_style_tags: Option<bool>) -> PyResult<Vec<String>> {
77+
#[text_signature = "(htmls, remove_style_tags=False, base_url=None)"]
78+
fn inline_many(
79+
htmls: &PyList,
80+
remove_style_tags: Option<bool>,
81+
base_url: Option<String>,
82+
) -> PyResult<Vec<String>> {
7283
let options = rust_inline::InlineOptions {
7384
remove_style_tags: remove_style_tags.unwrap_or(false),
85+
base_url,
7486
};
7587
let inliner = rust_inline::CSSInliner::new(options);
7688
inline_many_impl(&inliner, htmls)

src/error.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ use std::{fmt, io};
99
pub enum InlineError {
1010
/// Input-output error. May happen during writing the resulting HTML.
1111
IO(io::Error),
12+
/// Network-related problem. E.g. resource is not available.
13+
Network(attohttpc::Error),
1214
/// Syntax errors or unsupported selectors.
1315
ParseError(String),
1416
}
@@ -18,13 +20,19 @@ impl From<io::Error> for InlineError {
1820
InlineError::IO(error)
1921
}
2022
}
23+
impl From<attohttpc::Error> for InlineError {
24+
fn from(error: attohttpc::Error) -> Self {
25+
InlineError::Network(error)
26+
}
27+
}
2128

2229
impl Error for InlineError {}
2330

2431
impl Display for InlineError {
2532
fn fmt(&self, f: &mut Formatter) -> fmt::Result {
2633
match self {
2734
InlineError::IO(error) => write!(f, "{}", error),
35+
InlineError::Network(error) => write!(f, "{}", error),
2836
InlineError::ParseError(error) => write!(f, "{}", error),
2937
}
3038
}

src/lib.rs

Lines changed: 80 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -127,14 +127,15 @@
127127
variant_size_differences
128128
)]
129129
use kuchiki::traits::TendrilSink;
130-
use kuchiki::{parse_html, Selectors};
130+
use kuchiki::{parse_html, NodeRef, Selectors};
131131

132132
pub mod error;
133133
mod parser;
134134

135135
pub use error::InlineError;
136136
use std::collections::HashMap;
137-
use std::io::Write;
137+
use std::fs::File;
138+
use std::io::{Read, Write};
138139

139140
#[derive(Debug)]
140141
struct Rule<'i> {
@@ -159,6 +160,8 @@ impl<'i> Rule<'i> {
159160
pub struct InlineOptions {
160161
/// Remove "style" tags after inlining
161162
pub remove_style_tags: bool,
163+
/// Used for loading external stylesheets via relative URLs
164+
pub base_url: Option<String>,
162165
}
163166

164167
impl InlineOptions {
@@ -167,6 +170,7 @@ impl InlineOptions {
167170
pub fn compact() -> Self {
168171
InlineOptions {
169172
remove_style_tags: true,
173+
base_url: None,
170174
}
171175
}
172176
}
@@ -176,6 +180,7 @@ impl Default for InlineOptions {
176180
fn default() -> Self {
177181
InlineOptions {
178182
remove_style_tags: false,
183+
base_url: None,
179184
}
180185
}
181186
}
@@ -222,43 +227,88 @@ impl CSSInliner {
222227
{
223228
if let Some(first_child) = style_tag.as_node().first_child() {
224229
if let Some(css_cell) = first_child.as_text() {
225-
let css = css_cell.borrow();
226-
let mut parse_input = cssparser::ParserInput::new(css.as_str());
227-
let mut parser = parser::CSSParser::new(&mut parse_input);
228-
for parsed in parser.parse() {
229-
if let Ok((selector, declarations)) = parsed {
230-
let rule = Rule::new(selector, declarations).map_err(|_| {
231-
error::InlineError::ParseError("Unknown error".to_string())
232-
})?;
233-
let matching_elements = document
234-
.inclusive_descendants()
235-
.filter_map(|node| node.into_element_ref())
236-
.filter(|element| rule.selectors.matches(element));
237-
for matching_element in matching_elements {
238-
let mut attributes = matching_element.attributes.borrow_mut();
239-
let style = if let Some(existing_style) = attributes.get("style") {
240-
merge_styles(existing_style, &rule.declarations)?
241-
} else {
242-
rule.declarations
243-
.iter()
244-
.map(|&(ref key, value)| format!("{}:{};", key, value))
245-
.collect()
246-
};
247-
attributes.insert("style", style);
248-
}
249-
}
250-
// Ignore not parsable entries. E.g. there is no parser for @media queries
251-
// Which means that they will fall into this category and will be ignored
252-
}
230+
process_css(&document, css_cell.borrow().as_str())?;
253231
}
254232
}
255233
if self.options.remove_style_tags {
256234
style_tag.as_node().detach()
257235
}
258236
}
237+
for link_tag in document
238+
.select("link[rel~=stylesheet]")
239+
.map_err(|_| error::InlineError::ParseError("Unknown error".to_string()))?
240+
{
241+
if let Some(href) = &link_tag.attributes.borrow().get("href") {
242+
let url = self.get_full_url(href);
243+
let css = self.load_external(url.as_str())?;
244+
process_css(&document, css.as_str())?;
245+
}
246+
}
259247
document.serialize(target)?;
260248
Ok(())
261249
}
250+
251+
fn get_full_url(&self, href: &str) -> String {
252+
if href.starts_with("//") {
253+
if let Some(base_url) = &self.options.base_url {
254+
if base_url.starts_with("https://") {
255+
format!("https:{}", href)
256+
} else {
257+
format!("http:{}", href)
258+
}
259+
} else {
260+
format!("http:{}", href)
261+
}
262+
} else {
263+
href.to_string()
264+
}
265+
}
266+
267+
fn load_external(&self, url: &str) -> Result<String, InlineError> {
268+
if url.starts_with("http") | url.starts_with("https") {
269+
let response = attohttpc::get(url).send()?;
270+
Ok(response.text()?)
271+
} else {
272+
let mut file = File::open(url)?;
273+
let mut css = String::new();
274+
file.read_to_string(&mut css)?;
275+
Ok(css)
276+
}
277+
}
278+
}
279+
280+
fn process_css(document: &NodeRef, css: &str) -> Result<(), InlineError> {
281+
let mut parse_input = cssparser::ParserInput::new(css);
282+
let mut parser = parser::CSSParser::new(&mut parse_input);
283+
for parsed in parser.parse() {
284+
if let Ok((selector, declarations)) = parsed {
285+
if let Ok(rule) = Rule::new(selector, declarations) {
286+
let matching_elements = document
287+
.inclusive_descendants()
288+
.filter_map(|node| node.into_element_ref())
289+
.filter(|element| rule.selectors.matches(element));
290+
for matching_element in matching_elements {
291+
// It can be borrowed if the current selector matches <link> tag, that is
292+
// already borrowed in `inline_to`. We can ignore such matches
293+
if let Ok(mut attributes) = matching_element.attributes.try_borrow_mut() {
294+
let style = if let Some(existing_style) = attributes.get("style") {
295+
merge_styles(existing_style, &rule.declarations)?
296+
} else {
297+
rule.declarations
298+
.iter()
299+
.map(|&(ref key, value)| format!("{}:{};", key, value))
300+
.collect()
301+
};
302+
attributes.insert("style", style);
303+
}
304+
}
305+
}
306+
// Skip selectors that can't be parsed
307+
}
308+
// Ignore not parsable entries. E.g. there is no parser for @media queries
309+
// Which means that they will fall into this category and will be ignored
310+
}
311+
Ok(())
262312
}
263313

264314
impl Default for CSSInliner {

tests/external.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
h1 { color: blue; }

tests/server.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from flask import Flask
2+
3+
app = Flask(__name__)
4+
5+
with open("tests/external.css") as fd:
6+
STYLESHEET = fd.read()
7+
8+
9+
@app.route("/external.css")
10+
def stylesheet():
11+
return STYLESHEET
12+
13+
14+
if __name__ == "__main__":
15+
app.run()

0 commit comments

Comments
 (0)