I wanted a proper "reader mode" for the articles I link to on this blog: fetch a page, strip the navigation, ads, and cookie banners, and keep only the article. In Rust, there's a crate for this called readex, which does exactly that and can even output Markdown.
My app is Elixir/Phoenix, so the obvious question was: how do I use a Rust crate from Elixir? The answer is Rustler, which lets you write NIFs (Native Implemented Functions) in Rust with safe, ergonomic bindings.
In this post, I'll walk through the whole process, from mix.exs to a green GitHub Actions pipeline, including the mistakes I made along the way.
Prerequisites: Rust and Rustler
Rustler compiles your Rust code as part of mix compile, so you need a Rust toolchain on every machine that builds the project. The easiest way is rustup:
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
rustc --version
cargo --version
Then add Rustler to your dependencies in mix.exs:
defp deps do
[
# ...
{:rustler, "~> 0.38.0", runtime: false}
]
end
runtime: false is the right choice here: Rustler is only needed at compile time. The compiled library is loaded by your own module, not by the Rustler application.
mix deps.get
Scaffolding the NIF with mix rustler.new
Rustler ships with a generator that creates the Cargo project for you:
mix rustler.new
It asks for the name of the Elixir module that will host the NIF and the name of the crate. I used YellowduckBe.ArticleExtractor.Native and article_extractor.
This generates the following:
native/article_extractor/
├── Cargo.toml
├── README.md
└── src/
└── lib.rs
The generated Cargo.toml is where we add the readex crate:
[package]
name = "article_extractor"
version = "0.1.0"
authors = []
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
rustler = "0.39.0"
readex = "0.19"
crate-type = ["cdylib"] makes Cargo produce a shared library that the BEAM can load.
You also don't want build artifacts in Git, so add this to .gitignore:
# Rust / Rustler
/target/
/native/**/target/
/priv/native/
priv/native/ is where Rustler copies the compiled library so that it ends up in your release.
Implementing extract_article in Rust
The first version of the NIF was a straightforward mapping of everything readex returns. Here's the core idea:
use readex::{extract_with, Options};
use rustler::NifStruct;
#[derive(NifStruct)]
#[module = "YellowduckBe.ArticleExtractor.Article"]
struct Article {
title: Option<String>,
text: String,
html: Option<String>,
word_count: usize,
language: Option<String>,
byline: Option<String>,
excerpt: Option<String>,
site_name: Option<String>,
published_time: Option<String>,
}
#[rustler::nif(schedule = "DirtyCpu")]
fn extract_article(html: String, url: String) -> Result<Article, String> {
let options = Options {
include_html: true,
..Options::default()
};
let result = extract_with(&html, Some(&url), &options)
.map_err(|error| format!("{error:?}"))?;
Ok(Article {
title: result.title,
text: result.text,
html: result.html,
word_count: result.word_count,
language: result.language,
byline: result.byline,
excerpt: result.excerpt,
site_name: result.site_name,
published_time: result.published_time,
})
}
rustler::init!("Elixir.YellowduckBe.ArticleExtractor.Native");
A few details are worth noting:
#[derive(NifStruct)]with#[module = "..."]converts the Rust struct into an Elixir struct of that module. No manual encoding or decoding is needed.Option<String>becomes either a string ornilon the Elixir side.Result<Article, String>becomes{:ok, %Article{}}or{:error, "reason"}, so it works withwithandcaseas you'd expect.schedule = "DirtyCpu"is important. Parsing and scoring an HTML document can take a while. A normal NIF that runs for more than about a millisecond blocks a scheduler and hurts the whole VM's latency. Marking it as a dirty CPU NIF runs it on a separate scheduler pool.rustler::init!registers the module the NIF is loaded into. TheElixir.prefix is how Elixir module names are represented in Erlang.
Exposing it to Elixir
On the Elixir side, you need three small pieces. First, the struct that NifStruct maps to:
defmodule YellowduckBe.ArticleExtractor.Article do
defstruct [
:title,
:text,
:html,
:word_count,
:language,
:byline,
:excerpt,
:site_name,
:published_time
]
end
Second, the module that loads the native library and declares the stubs:
defmodule YellowduckBe.ArticleExtractor.Native do
use Rustler,
otp_app: :yellowduck_be,
crate: "article_extractor"
# Replaced by the NIF once it's loaded.
def extract_article(_html, _url),
do: :erlang.nif_error(:nif_not_loaded)
end
The stub is overridden when the library loads. If you ever see :nif_not_loaded, the library didn't compile or load.
And third, a small public module, so the rest of the app doesn't need to know there's a NIF involved:
defmodule YellowduckBe.ArticleExtractor do
alias YellowduckBe.ArticleExtractor.Native
def extract(html, url) do
Native.extract_article(html, url)
end
end
If you want to swap out the implementation or mock it in tests, this is the seam to do it at.
Using it from Elixir
Using it from a LiveView is a matter of fetching the HTML with Req and passing it along:
defp load(url) do
with {:ok, %{body: html}} <- Req.get(url),
{:ok, article} <- ArticleExtractor.extract(html, url) do
{:ok, %{page: article}}
end
end
Or in an iex -S mix session:
iex> {:ok, %{body: html}} = Req.get("https://example.com/some-article")
iex> {:ok, article} = YellowduckBe.ArticleExtractor.extract(html, "https://example.com/some-article")
iex> article.title
"Some article"
iex> article.word_count
1234
Passing the URL is useful: readex uses it to resolve relative links and images.
Compiling it in CI/CD
Everything worked locally, so I pushed it, and the pipeline immediately failed. The GitHub runner didn't know it had to build Rust.
The fix is to install a Rust toolchain before the Elixir build. I use dtolnay/rust-toolchain:
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
My first attempt added this to the wrong job. My workflow has two jobs that compile the app (one to run the tests, one to build and deploy), and both need Rust. I added it to one, moved it to the other, and finally had to put it in both before the pipeline went green.
The lesson: every job that runs mix compile needs the Rust toolchain. Remember this when you add a job later.
Caching the Rust build
A cold build of a Rust crate with its dependencies takes minutes, far longer than the rest of my pipeline. Swatinem/rust-cache caches the Cargo registry and the target directory, keyed on your Cargo.lock:
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Setup Rust cache
uses: Swatinem/rust-cache@v2
with:
workspaces: native/article_extractor
The workspaces option matters: by default, the action looks for Cargo.toml in the repository root. Since the crate lives in native/article_extractor, I have to tell it where to look.
While I was there, I also added restore-keys to the existing caches for _build and deps. Without them, a changed mix.lock means a full cache miss. With them, the most recent cache with the same prefix is restored, and only what changed gets rebuilt:
- name: Cache compiled build
uses: actions/cache@v4
with:
path: _build
key: ${{ runner.os }}-mix-build-${{ hashFiles('**/mix.lock') }}-${{ hashFiles('**/.tool-versions') }}
restore-keys: |
${{ runner.os }}-mix-build-
I also replaced the UBUNTU_VERSION variable in the cache keys with runner.os, the built-in way to key a cache by platform.
The gotcha: force a recompile so the NIF is rebuilt
This one cost me the most time. The pipeline was green, the caches worked, and then the app crashed at runtime with :nif_not_loaded.
Here's what happens:
- Rustler compiles the crate and copies the library into
priv/native/. priv/native/is in.gitignore, so it isn't in the checkout._buildis restored from cache, including the compiled.beamfile forYellowduckBe.ArticleExtractor.Native.- Mix sees that nothing changed in the Elixir sources, concludes the app is up to date, and skips compiling it. The Rust compiler never runs, so
priv/native/stays empty.
The cache gave me stale Elixir bytecode without the native library it needs. The fix is to delete the compiled output of just my app (not the dependencies, which are the expensive part) before compiling:
- name: Force recompile of the app (rebuilds the Rust NIF into priv/native)
run: rm -rf _build/${{ env.MIX_ENV }}/lib/yellowduck_be
Now Mix has to recompile the app, which invokes the Rustler compiler. The Rust build is already cached by rust-cache, and Rustler copies the library into priv/native/. You get the caching benefits and a correct build.
Adding Markdown support
Once everything was running, I considered what I actually wanted to display. Plain text loses all the structure, and raw HTML needs sanitising. readex has extract_to_markdown, which returns the clean article as Markdown, making it easy to render with an Elixir Markdown library.
I also simplified the struct: I dropped the fields I wasn't using (canonical_url, image, categories, tags), replaced the optional html with the original_html I was handed, and added markdown:
use readex::{extract_to_markdown, extract_with, Options};
use rustler::NifStruct;
#[derive(NifStruct)]
#[module = "YellowduckBe.ArticleExtractor.Article"]
struct Article {
title: Option<String>,
original_html: String,
markdown: String,
text: String,
word_count: usize,
language: Option<String>,
byline: Option<String>,
excerpt: Option<String>,
site_name: Option<String>,
published_time: Option<String>,
}
#[rustler::nif(schedule = "DirtyCpu")]
fn extract_article(html: String, url: String) -> Result<Article, String> {
let options = Options::default();
let extracted = extract_with(&html, Some(&url), &options)
.map_err(|error| format!("{error:?}"))?;
let markdown = extract_to_markdown(&html, Some(&url), &options)
.map_err(|error| format!("{error:?}"))?;
Ok(Article {
title: extracted.title,
original_html: html,
markdown,
text: extracted.text,
word_count: extracted.word_count,
language: extracted.language,
byline: extracted.byline,
excerpt: extracted.excerpt,
site_name: extracted.site_name,
published_time: extracted.published_time,
})
}
rustler::init!("Elixir.YellowduckBe.ArticleExtractor.Native");
The Elixir struct must match, since NifStruct expects the fields to match:
defmodule YellowduckBe.ArticleExtractor.Article do
defstruct [
:title,
:original_html,
:markdown,
:text,
:word_count,
:language,
:byline,
:excerpt,
:site_name,
:published_time
]
end
In the LiveView template, I can render it with MDEx:
<div id="readability-content" class="prose max-w-none dark:prose-invert">
{safe_html(page.markdown |> MDEx.to_html!())}
</div>
Note that this extracts the article twice (once for the metadata, once for the Markdown). For my use case, the cost is negligible, and the code stays simple. If this ever shows up in a profile, it's the first thing I'd look at.
The Rust part was the easy bit. Rustler's derive macros make the boundary between Rust and Elixir nearly invisible, and dirty schedulers keep long-running work from hurting the VM.
The real work was in the pipeline:
- Every job that compiles the app needs a Rust toolchain.
- Use
rust-cachewithworkspacespointing at your crate. - Add
restore-keysto your Mix caches so a small change doesn't trigger a full rebuild. - When you cache
_build, force a recompile of your own app, or the NIF never gets built intopriv/native.
And the result is a dependable article extractor in about 40 lines of Rust, running inside my Phoenix app with no extra services to run.
If this post was enjoyable or useful for you, please share it! If you have comments, questions, or feedback, you can email my personal email. To get new posts, subscribe use the RSS feed.