Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 61 additions & 6 deletions lib/css_inline.ex
Original file line number Diff line number Diff line change
Expand Up @@ -21,21 +21,68 @@ defmodule CSSInline do
iex> result =~ "color"
true

## Options

The following options can be passed to `inline/2` and `inline!/2`:

* `:inline_style_tags` - Whether to inline CSS from `<style>` tags. Defaults to `true`.
* `:keep_style_tags` - Whether to keep `<style>` tags after inlining. Defaults to `false`.
* `:keep_link_tags` - Whether to keep `<link>` tags after processing. Defaults to `false`.
* `:load_remote_stylesheets` - Whether to load remote stylesheets referenced in `<link>` tags.
Defaults to `true`. Set to `false` to skip external stylesheets.
* `:minify_css` - Whether to minify the inlined CSS. Defaults to `true`.

## Performance

The Rust NIF runs on a dirty CPU scheduler to avoid blocking the BEAM's main schedulers,
as CSS inlining can take several milliseconds for large documents. The implementation
uses zero-copy input handling and direct buffer writes for optimal performance.
"""

defmodule Options do
@moduledoc """
Options struct for CSS inlining configuration.
"""
defstruct inline_style_tags: true,
keep_style_tags: false,
keep_link_tags: false,
load_remote_stylesheets: true,
minify_css: true

@type t :: %__MODULE__{
inline_style_tags: boolean(),
keep_style_tags: boolean(),
keep_link_tags: boolean(),
load_remote_stylesheets: boolean(),
minify_css: boolean()
}
end

@type option ::
{:inline_style_tags, boolean()}
| {:keep_style_tags, boolean()}
| {:keep_link_tags, boolean()}
| {:load_remote_stylesheets, boolean()}
| {:minify_css, boolean()}

@doc """
Inlines CSS from `<style>` tags into element `style` attributes.

Returns `{:ok, inlined_html}` on success, or `{:error, reason}` on failure.

## Options

* `:inline_style_tags` - Whether to inline CSS from `<style>` tags. Defaults to `true`.
* `:keep_style_tags` - Whether to keep `<style>` tags after inlining. Defaults to `false`.
* `:keep_link_tags` - Whether to keep `<link>` tags after processing. Defaults to `false`.
* `:load_remote_stylesheets` - Whether to load remote stylesheets. Defaults to `true`.
* `:minify_css` - Whether to minify the inlined CSS. Defaults to `true`.
"""
@spec inline(String.t()) :: {:ok, String.t()} | {:error, term()}
def inline(html) when is_binary(html) do
case CSSInline.Native.inline_css(html) do
@spec inline(String.t(), [option()]) :: {:ok, String.t()} | {:error, term()}
def inline(html, opts \\ []) when is_binary(html) and is_list(opts) do
options = struct(Options, opts)

case CSSInline.Native.inline_css(html, options) do
result when is_binary(result) or is_list(result) ->
{:ok, IO.iodata_to_binary(result)}

Expand All @@ -50,10 +97,18 @@ defmodule CSSInline do
Inlines CSS from `<style>` tags into element `style` attributes.

Returns the inlined HTML on success, or raises an exception on failure.

## Options

* `:inline_style_tags` - Whether to inline CSS from `<style>` tags. Defaults to `true`.
* `:keep_style_tags` - Whether to keep `<style>` tags after inlining. Defaults to `false`.
* `:keep_link_tags` - Whether to keep `<link>` tags after processing. Defaults to `false`.
* `:load_remote_stylesheets` - Whether to load remote stylesheets. Defaults to `true`.
* `:minify_css` - Whether to minify the inlined CSS. Defaults to `true`.
"""
@spec inline!(String.t()) :: String.t()
def inline!(html) when is_binary(html) do
case inline(html) do
@spec inline!(String.t(), [option()]) :: String.t()
def inline!(html, opts \\ []) when is_binary(html) and is_list(opts) do
case inline(html, opts) do
{:ok, result} -> result
{:error, reason} -> raise "CSS inlining failed: #{inspect(reason)}"
end
Expand Down
2 changes: 1 addition & 1 deletion lib/css_inline/native.ex
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,5 @@ defmodule CSSInline.Native do

# NIF function - will be replaced at runtime by the Rust implementation.
# This stub is only used if the NIF fails to load.
def inline_css(_html), do: :erlang.nif_error(:nif_not_loaded)
def inline_css(_html, _opts), do: :erlang.nif_error(:nif_not_loaded)
end
2 changes: 1 addition & 1 deletion mix.exs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
defmodule CSSInline.MixProject do
use Mix.Project

@version "0.1.1"
@version "0.1.2"

def project do
[
Expand Down
39 changes: 11 additions & 28 deletions native/css_inline_nif/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions native/css_inline_nif/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "css_inline_nif"
version = "0.1.0"
version = "0.1.2"
edition = "2021"
authors = ["Knock Labs"]
publish = false
Expand All @@ -17,4 +17,4 @@ nif_version_2_17 = ["rustler/nif_version_2_17"]

[dependencies]
rustler = "0.37"
css-inline = "0.19"
css-inline = "0.19.1"
23 changes: 20 additions & 3 deletions native/css_inline_nif/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
use css_inline::CSSInliner;
use rustler::Error as RustlerError;
use rustler::{Error as RustlerError, NifStruct};

/// Options for CSS inlining, mapped from Elixir struct.
#[derive(Debug, NifStruct)]
#[module = "CSSInline.Options"]
struct Options {
inline_style_tags: bool,
keep_style_tags: bool,
keep_link_tags: bool,
load_remote_stylesheets: bool,
minify_css: bool,
}

/// Inlines CSS from `<style>` tags into element `style` attributes.
///
Expand All @@ -21,10 +32,16 @@ use rustler::Error as RustlerError;
/// - **Output copy**: The final copy from Rust heap to BEAM heap is unavoidable
/// since BEAM-managed data must live on the BEAM heap.
#[rustler::nif(schedule = "DirtyCpu")]
fn inline_css(html: &str) -> Result<Vec<u8>, RustlerError> {
fn inline_css(html: &str, opts: Options) -> Result<Vec<u8>, RustlerError> {
let estimated_size = (html.len() as f64 * 1.5) as usize;
let mut buffer: Vec<u8> = Vec::with_capacity(estimated_size);
let inliner = CSSInliner::options().minify_css(true).build();
let inliner = CSSInliner::options()
.inline_style_tags(opts.inline_style_tags)
.keep_style_tags(opts.keep_style_tags)
.keep_link_tags(opts.keep_link_tags)
.load_remote_stylesheets(opts.load_remote_stylesheets)
.minify_css(opts.minify_css)
.build();

inliner
.inline_to(html, &mut buffer)
Expand Down
125 changes: 124 additions & 1 deletion test/css_inline_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,116 @@ defmodule CSSInlineTest do
end
end

describe "inline!/1" do
describe "inline/2 with options" do
test "keep_style_tags: true preserves style tags" do
html = """
<html>
<head>
<style>p { color: red; }</style>
</head>
<body><p>Hello</p></body>
</html>
"""

assert {:ok, result} = CSSInline.inline(html, keep_style_tags: true)
assert result =~ "<style>"
assert result =~ ~r/<p[^>]*style="[^"]*color: ?red/
end

test "keep_style_tags: false removes style tags (default)" do
html = """
<html>
<head>
<style>p { color: red; }</style>
</head>
<body><p>Hello</p></body>
</html>
"""

assert {:ok, result} = CSSInline.inline(html, keep_style_tags: false)
refute result =~ "<style>"
end

test "inline_style_tags: false skips inlining from style tags" do
html = """
<html>
<head>
<style>p { color: red; }</style>
</head>
<body><p>Hello</p></body>
</html>
"""

assert {:ok, result} = CSSInline.inline(html, inline_style_tags: false)
# The style should NOT be inlined
refute result =~ ~r/<p[^>]*style=/
end

test "minify_css: false preserves whitespace in CSS" do
html = """
<html>
<head>
<style>p { color: red; margin: 10px; }</style>
</head>
<body><p>Hello</p></body>
</html>
"""

assert {:ok, result} = CSSInline.inline(html, minify_css: false)
# With minify_css: false, there should be spaces around colons
assert result =~ "color: red"
end

test "keep_link_tags: true preserves link tags" do
html = """
<html>
<head>
<link rel="stylesheet" href="https://example.com/style.css">
<style>p { color: red; }</style>
</head>
<body><p>Hello</p></body>
</html>
"""

assert {:ok, result} =
CSSInline.inline(html, keep_link_tags: true, load_remote_stylesheets: false)

assert result =~ "<link"
end

test "load_remote_stylesheets: false skips external stylesheets" do
html = """
<html>
<head>
<link rel="stylesheet" href="https://example.com/nonexistent.css">
<style>p { color: red; }</style>
</head>
<body><p>Hello</p></body>
</html>
"""

# With load_remote_stylesheets: false, this should succeed without trying to fetch
assert {:ok, result} = CSSInline.inline(html, load_remote_stylesheets: false)
assert result =~ ~r/<p[^>]*style="[^"]*color: ?red/
end

test "multiple options can be combined" do
html = """
<html>
<head>
<style>p { color: red; }</style>
</head>
<body><p>Hello</p></body>
</html>
"""

assert {:ok, result} = CSSInline.inline(html, keep_style_tags: true, minify_css: false)
assert result =~ "<style>"
assert result =~ "color: red"
end
end

describe "inline!/2 with options" do
test "returns inlined HTML on success" do
html = """
<html>
Expand All @@ -99,6 +208,20 @@ defmodule CSSInlineTest do
assert result =~ ~r/<p[^>]*style="[^"]*color: ?blue/
end

test "accepts options" do
html = """
<html>
<head>
<style>p { color: blue; }</style>
</head>
<body><p>Hello</p></body>
</html>
"""

result = CSSInline.inline!(html, keep_style_tags: true)
assert result =~ "<style>"
end

test "raises on error" do
html = """
<html>
Expand Down