Skip to content

About

High-performance library for inlining CSS into HTML 'style' attributes

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

319 stars

Watchers

3 watching

Forks

Repository files navigation

css-inline

build status crates.io docs.rs codecov.io gitter

css_inline inlines CSS into HTML style attributes. Use it to prepare HTML emails or to embed HTML into third-party web pages.

It turns this HTML:

<html>
  <head>
    <style>h1 { color:blue; }</style>
  </head>
  <body>
    <h1>Big Text</h1>
  </body>
</html>

into:

<html>
  <head></head>
  <body>
    <h1 style="color: blue;">Big Text</h1>
  </body>
</html>
  • Inlines a 4-9 KB email in under 100 µs (see Performance)
  • Parses with html5ever and cssparser from Mozilla's Servo project
  • Inlines CSS from style and link tags
  • Removes style and link tags
  • Resolves external stylesheets, including local files
  • Caches external stylesheets (optional)
  • Works on Linux, Windows, and macOS
  • Parses HTML5 and CSS3
  • Bindings for Python, Ruby, JavaScript, Java, C, PHP, and a WebAssembly module to run in browsers.
  • Elixir bindings maintained by Knock
  • Command Line Interface

Playground

Try css-inline in the WebAssembly-powered playground.

Installation

Add css-inline to the dependencies in your Cargo.toml:

[dependencies]
css-inline = "0.23"

Minimum Supported Rust Version: 1.85.

Cargo features:

Feature Default Enables
http yes Loading remote stylesheets over http(s) (pulls in reqwest)
file yes Loading stylesheets from the local filesystem
stylesheet-cache yes StylesheetCache and the cache option (pulls in lru)
cli yes The css-inline binary (pulls in lexopt)
async no Non-blocking *_async inlining functions and StylesheetResolver::retrieve_async

For library-only use, list the features you need:

[dependencies]
css-inline = { version = "0.23", default-features = false, features = ["http", "file", "stylesheet-cache"] }

Without http and file, css-inline returns an error for any link stylesheet it cannot load.

Usage

const HTML: &str = r#"<html>
<head>
<style>h1 { color:blue; }</style>
</head>
<body>
    <h1>Big Text</h1>
</body>
</html>"#;

fn main() -> css_inline::Result<()> {
    let inlined = css_inline::inline(HTML)?;
    assert_eq!(
        inlined,
        r#"<html><head>

</head>
<body>
    <h1 style="color: blue;">Big Text</h1>

</body></html>"#
    );
    Ok(())
}

inline adds missing html and body tags, so the output is a complete HTML document.

To inline CSS into an HTML fragment, call inline_fragment with the fragment and the CSS. The output stays a fragment: inline_fragment does not add <html>, <head> or <body>, and if the input contains them, it keeps only their contents. Use inline for full documents:

const FRAGMENT: &str = r#"<main>
<h1>Hello</h1>
<section>
<p>who am i</p>
</section>
</main>"#;

const CSS: &str = r#"
p {
    color: red;
}

h1 {
    color: blue;
}
"#;

fn main() -> css_inline::Result<()> {
    let inlined = css_inline::inline_fragment(FRAGMENT, CSS)?;
    assert_eq!(
        inlined,
        r#"<main>
<h1 style="color: blue;">Hello</h1>
<section>
<p style="color: red;">who am i</p>
</section>
</main>"#
    );
    Ok(())
}

inline and inline_fragment return css_inline::Result<String>, with css_inline::InlineError as the error type:

  • MissingStyleSheet: a local stylesheet file does not exist
  • Network: fetching a remote stylesheet failed (http feature)
  • IO: reading a stylesheet failed, or writing to the target of inline_to / inline_fragment_to (which write into any std::io::Write) failed
  • ParseError: a CSS syntax error that css-inline cannot skip

A stylesheet that fails to load aborts inlining with an error. Inside a style tag or stylesheet, css-inline skips rules with invalid or unsupported selectors and inlines the rest.

Configuration

Configure css-inline with the builder that CSSInliner::options() returns:

const HTML: &str = "...";

fn main() -> css_inline::Result<()> {
    let inliner = css_inline::CSSInliner::options()
        .load_remote_stylesheets(false)
        .build();
    let inlined = inliner.inline(HTML)?;
    // Do something with inlined HTML, e.g. send an email
    Ok(())
}

CSSInliner is Send + Sync: build it once and reuse it across calls and threads.

  • inline_style_tags. Inline CSS from style tags. With false, css-inline ignores their CSS and still removes the tags unless keep_style_tags is true. Default: true
  • keep_style_tags. Keep style tags after inlining. Default: false
  • keep_link_tags. Keep link tags after inlining. Default: false
  • keep_at_rules. Keep at-rules (starting with @) after inlining. Default: false
  • minify_css. Remove trailing semicolons and spaces between properties and values. Default: false
  • base_url. Base URL for resolving relative URLs. Use the file:// scheme to load stylesheets from the filesystem. Default: None
  • load_remote_stylesheets. Load stylesheets from link tags, over the network or from file:// paths. Default: true
  • cache. Cache for external stylesheets. Default: None
  • extra_css. Extra CSS to inline. Default: None
  • preallocate_node_capacity. Advanced. Number of HTML nodes to preallocate during parsing. Set it to your expected node count to avoid reallocations. Default: 32
  • remove_inlined_selectors. Remove inlined selectors from <style> blocks and keep the blocks for rules that could not be inlined (e.g. p:hover), even when keep_style_tags is false. Default: false
  • apply_width_attributes. Add width HTML attributes from CSS width properties on table, td, th and img. Default: false
  • apply_height_attributes. Add height HTML attributes from CSS height properties on table, td, th and img. Default: false
  • resolve_css_variables. Replace var() references with the values of the custom properties they refer to and drop the --* declarations. Default: false

Add data-css-inline="ignore" to a tag to skip inlining styles into it:

<head>
  <style>h1 { color:blue; }</style>
</head>
<body>
  <!-- The tag below won't receive additional styles -->
  <h1 data-css-inline="ignore">Big Text</h1>
</body>

On a link or style tag, the same attribute excludes its styles from inlining:

<head>
  <!-- Styles below are ignored -->
  <style data-css-inline="ignore">h1 { color:blue; }</style>
</head>
<body>
  <h1>Big Text</h1>
</body>

Add data-css-inline="keep" to a style tag to keep it in the output, for example to preserve @media queries for responsive emails. The tag stays even when keep_style_tags is false.

<head>
  <!-- Styles below are not removed -->
  <style data-css-inline="keep">h1 { color:blue; }</style>
</head>
<body>
  <h1>Big Text</h1>
</body>

A style attribute cannot hold at-rules, so css-inline drops them by default. Set keep_at_rules to true to inline regular rules and keep at-rules such as @media in a style tag. That tag stays even when keep_style_tags is false. @layer is the exception: rules inside cascade layers are inlined with layer precedence, and at-rules nested in a layer are kept wrapped in it.

<head>
  <!-- With keep_at_rules=true "color:blue" will get inlined into <h1> but @media will be kept in <style> -->
  <style>h1 { color: blue; } @media (max-width: 600px) { h1 { font-size: 18px; } }</style>
</head>
<body>
  <h1>Big Text</h1>
</body>

Set minify_css to true to drop trailing semicolons and spaces between properties and values in inlined styles:

<head>
  <!-- With minify_css=true, the <h1> will have `style="color:blue;font-weight:bold"` -->
  <style>h1 { color: blue; font-weight: bold; }</style>
</head>
<body>
  <h1>Big Text</h1>
</body>

Set resolve_css_variables to true for CSS that uses custom properties (e.g. Tailwind v4 or design tokens), which email clients do not support. var() references are replaced with the values the properties have on each element, with fallbacks for undefined or cyclic ones, and the --* declarations are dropped:

<html>
  <head>
    <style>
      :root { --brand: #17bebb; --space: 4px; }
      .cta { color: var(--brand); padding: calc(var(--space) * 2); border: var(--w, 1px) solid; }
    </style>
  </head>
  <body>
    <!-- With resolve_css_variables=true, the <a> will have
         `style="color: #17bebb;padding: calc(4px * 2);border: 1px solid;"` -->
    <a class="cta">Buy</a>
  </body>
</html>

A declaration whose var() cannot be resolved and has no fallback is dropped. Custom properties defined inside @media and other at-rules are not inlined, so they do not take part.

To load stylesheets from the filesystem, set base_url with the file:// scheme. css-inline joins relative link hrefs onto it, so end the directory with /: with file://styles/email/, href="main.css" resolves to styles/email/main.css; without the slash, to styles/main.css. A standard URL parser reads styles in file://styles/email/ as a host. css-inline instead treats styles/email/ as a path relative to the current directory. Use file:///abs/path/ for an absolute path.

const HTML: &str = "...";

fn main() -> css_inline::Result<()> {
    let base_url = css_inline::Url::parse("file://styles/email/").expect("Invalid URL");
    let inliner = css_inline::CSSInliner::options()
        .base_url(Some(base_url))
        .build();
    let inlined = inliner.inline(HTML)?;
    // Do something with inlined HTML, e.g. send an email
    Ok(())
}

To control how css-inline fetches external stylesheets, implement StylesheetResolver. Override retrieve, or only retrieve_from_url / retrieve_from_path. The trait's default unsupported method builds an InlineError::IO with std::io::ErrorKind::Unsupported for locations your resolver rejects:

#[derive(Debug, Default)]
pub struct CustomStylesheetResolver;

impl css_inline::StylesheetResolver for CustomStylesheetResolver {
    fn retrieve(&self, location: &str) -> css_inline::Result<String> {
        Err(self.unsupported("External stylesheets are not supported"))
    }
}

fn main() -> css_inline::Result<()> {
    let inliner = css_inline::CSSInliner::options()
        .resolver(std::sync::Arc::new(CustomStylesheetResolver))
        .build();
    Ok(())
}

Every inlining function has a non-blocking *_async counterpart behind the async feature:

css-inline = { version = "0.23", features = ["async"] }

External stylesheets of a document are fetched concurrently, each URL once, and stylesheets already in the cache are not fetched again. The returned futures are Send. The default resolver reads local files on a separate thread and fetches remote ones with async reqwest (requires the http feature and a Tokio runtime):

const HTML: &str = "...";

#[cfg(feature = "async")]
#[tokio::main(flavor = "current_thread")]
async fn main() -> css_inline::Result<()> {
    let inlined = css_inline::inline_async(HTML).await?;
    // Or with a configured inliner
    let inliner = css_inline::CSSInliner::options()
        .keep_style_tags(true)
        .build();
    let inlined = inliner.inline_async(HTML).await?;
    Ok(())
}

// This block is here for testing purposes
#[cfg(not(feature = "async"))]
fn main() -> css_inline::Result<()> {
    Ok(())
}

StylesheetResolver::retrieve_async calls the blocking retrieve by default, so a custom resolver that implements only retrieve blocks the executor while it runs. Override retrieve_async instead, using the re-exported css_inline::async_trait attribute. Custom resolvers work with any executor:

#[derive(Debug, Default)]
pub struct CustomStylesheetResolver;

#[cfg_attr(feature = "async", css_inline::async_trait)]
impl css_inline::StylesheetResolver for CustomStylesheetResolver {
    #[cfg(feature = "async")]
    async fn retrieve_async(&self, location: &str) -> css_inline::Result<String> {
        // Fetch `location` with your async client
        Ok(String::new())
    }
}

To fetch each external stylesheet once, enable the cache (requires the stylesheet-cache feature, on by default):

use std::num::NonZeroUsize;

#[cfg(feature = "stylesheet-cache")]
fn main() -> css_inline::Result<()> {
    let inliner = css_inline::CSSInliner::options()
        .cache(
            // LRU cache keyed by resolved URL; holds up to 5 stylesheets
            css_inline::StylesheetCache::new(
                NonZeroUsize::new(5).expect("Invalid cache size")
            )
        )
        .build();
    Ok(())
}

// This block is here for testing purposes
#[cfg(not(feature = "stylesheet-cache"))]
fn main() -> css_inline::Result<()> {
    Ok(())
}

The cache option defaults to None, so css-inline fetches stylesheets on every call until you set it.

Performance

The table shows absolute timings of the Rust crate. The Python, Ruby and JavaScript READMEs compare css-inline with other inliners.

Benchmarks for css-inline==0.22.0:

Case Bench ID Time Size
Basic simple 4.10 µs 230 bytes
Realistic-1 big_email_1 74.41 µs 8.58 KB
Realistic-2 big_email_2 42.13 µs 4.30 KB
GitHub page big_page 16.51 ms 1.81 MB

Criterion mean time, measured with rustc 1.99 on a Ryzen 9 9950X.

The harness is crates/css-inline/benches/inliner.rs (Criterion); inputs are in benchmarks/benchmarks.json. Run it with cargo bench -p css-inline.

Command Line Interface

Installation

Install with cargo:

cargo install css-inline

Usage

This command inlines CSS in two documents in parallel and writes inlined.email1.html and inlined.email2.html next to the input files:

css-inline email1.html email2.html

Piped input goes to stdout: cat email.html | css-inline > out.html.

Key flags:

  • --base-url: base URL for relative stylesheet links
  • --extra-css-file <PATH>: inline extra CSS from a file (repeatable)
  • --output-filename-prefix: output prefix instead of inlined.

List all options with --help:

css-inline --help

Further reading

Articles on how this library was built and how it works:

Support

Ask questions in the gitter chat.

License

This project is licensed under the terms of the MIT license.

About

High-performance library for inlining CSS into HTML 'style' attributes

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

319 stars

Watchers

3 watching

Forks

Releases

Sponsor this project

Used by

Contributors

Languages