Elixir
When you want explicit error handling
Use highlight/2 when invalid user input, formatter options, or language detection should stay in the normal control flow.
case Lumis.highlight(source, formatter: {:html_inline, language: "elixir", theme: "catppuccin_latte"}) do
{:ok, html} -> html
{:error, reason} -> handle_error(reason)
end
There is no default theme. Without :theme, :html_inline and :terminal emit
uncolored output.
highlight/2 returns {:ok, output} or {:error, reason}.
When you want the highlighted output directly
Use highlight!/2 when failures should raise immediately, such as in scripts, trusted internal code, or examples.
html = Lumis.highlight!(source, formatter: {:html_inline, language: "elixir", theme: "catppuccin_latte"})
highlight!/2 returns the highlighted output or raises.
Language selection
Elixir accepts several forms:
Lumis.highlight!(code, formatter: {:html_inline, language: "elixir"})
Lumis.highlight!(code, formatter: {:html_inline, language: ".ex"})
Lumis.highlight!(code, formatter: {:html_inline, language: "lib/my_app.ex"})
Lumis.highlight!(code)
Omitting language from the formatter enables auto-detection.
Parser loading
Lumis.highlight/2 downloads, verifies and loads whatever a document turns out
to name, including languages injected inside it, and caches them for every later
request. Nothing has to be declared up front:
# Loads markdown, then elixir for the fenced block, in one pass.
Lumis.highlight!("""
# Title
```elixir
defmodule Demo do
end
```
""", formatter: {:html_inline, language: "markdown", theme: "catppuccin_latte"})
A language that cannot be fetched leaves its own block plain rather than failing the document. Only the document's own language failing raises.
Loading is global to the VM. One runtime lives in the NIF, so the first process to need a language pays for it and every process after it does not.
Warming parsers during application startup
A cold parser costs a download and then a Wasmtime compile, and the compile is
the larger half. Warm the languages your application needs from its start/2
so a request never pays either:
def start(_type, _args) do
Lumis.Languages.async_load(["markdown", "elixir", "json"])
Supervisor.start_link(children(), strategy: :one_for_one, name: MyApp.Supervisor)
end
async_load/1 returns before the download starts, so the boot never waits on
the network, and its result is deliberately not matched. The work runs under a
:temporary child of Lumis's supervisor: a failure is logged, never retried,
and cannot stop the application from starting. Since highlighting loads on
demand regardless, the worst case is paying on the first request rather than an
outage. Use Lumis.Languages.load/1 when the caller does want the result.
A :bundle_* atom names the same languages as the @lumis-sh/wasm-bundle-* package
of that name — :bundle_web, :bundle_web_extra, :bundle_system, :bundle_backend
and :bundle_full — so a bundle means the same thing in every runtime.
Lumis.Languages.bundles/0 returns the membership.
Parsers land under config :lumis, data_dir:, LUMIS_DATA_DIR, or Lumis's
priv/lumis directory by default. The result is self-sufficient — parser bytes,
their metadata, and the compiled modules — so a restart or new pod using the
same persistent directory reuses them.
Preparing a directory for a process that has not started
Lumis.Languages.cache/2 downloads, validates and compiles without loading
anything, and lumis languages cache does the same from the CLI. Both are for
filling a directory another process will read — an image build, a release task,
a migration step. Pass force: true or --force to resolve the compatible
package range again and replace files that already verify.
Inside a running application, prefer async_load/1. It does the same download
and compile and keeps the result, where cache/2 discards it and leaves the
first request to load the language again.
Where parsers come from
One directory holds them all, and it is checked before the CDN. Parsers you build or vendor yourself go in the same place as downloaded ones, so a release that stages them at build time never reaches the network.
It defaults to LUMIS_DATA_DIR, and configuration takes precedence, which is
what a release usually wants:
config :lumis, data_dir: "/app/lumis"
See WASM and CDN for the layout and for how a compatible range becomes an exact cached package.
Bytes are checked against the size and SHA-256 their package declares before use, and anything that fails is discarded rather than trusted, so a corrupted cache repairs itself.
Upgrading from 0.6
Highlighting used to raise for a language that had not been loaded, and left
injected languages unhighlighted. It now loads them, so Lumis.Languages.load/1
becomes an optimization rather than a requirement.
Four things were removed along with the Elixir-side download code, which now lives in the shared Rust store:
| Removed | Use instead |
|---|---|
config :lumis, :bundled_languages | call Lumis.Languages.async_load/1 from your start/2 |
config :lumis, :wasm_resolver | config :lumis, data_dir: "/app/lumis" |
config :lumis, :language_package_resolver | config :lumis, data_dir: "/app/lumis" |
release-local priv/wasm | config :lumis, data_dir: "/app/lumis", or LUMIS_DATA_DIR |
config :lumis, :wasm_path | one directory now: config :lumis, data_dir: |
async_load/1 takes language names or a :bundle_* name rather than reading
configuration, so the warm set lives beside the code that needs it.
Where the precompiled NIF comes from
The NIF ships precompiled, and it is downloaded once while the lumis dependency
compiles. GitHub Releases serves it by default. The same artifacts are mirrored
to Cloudflare R2, which you can opt into when GitHub is unreachable:
config :lumis, artifact_source: :cloudflare
LUMIS_ARTIFACT_SOURCE=cloudflare does the same thing, and is easier to reach
inside a Dockerfile or a CI job than a config file that has already been baked
into an image. Configuration wins when both are set. Valid values are :github
and :cloudflare.
This is a switch, not a fallback. The source you choose is the only one tried, so
a failed download does not reach for the other one. Both serve byte-identical
files, and either way the download is checked against the SHA-256 in
checksum-Elixir.Lumis.Native.exs from the Hex package before it is used, so the
mirror cannot hand you a different NIF.
Two other escape hatches sit alongside it: LUMIS_BUILD=1 builds the NIF from
source instead of downloading anything, and LUMIS_USE_LEGACY_ARTIFACTS=1 takes
the legacy-CPU variant on a machine without the newer instruction sets.
Discover supported languages and themes
Lumis.available_languages()
Lumis.available_themes()
Lumis.loaded_languages()
Lumis.Languages.get("js")
Lumis.Theme.get("catppuccin_latte")
Lumis.Languages.get/2 and Lumis.Theme.get/2 look one up by name, returning
nil (or a default you pass) when there is no such language or theme.
See Lumis, Lumis.Languages and Lumis.Theme on HexDocs.
Phoenix and LiveView
Highlighted HTML must be rendered as raw HTML in templates.
code = Lumis.highlight!(source, formatter: {:html_inline, language: "elixir", theme: "catppuccin_latte"})
{Phoenix.HTML.raw(@code)}
For linked CSS output, also mount a theme stylesheet through Plug.Static as shown in CSS Theme Files.
Light/dark mode
See Light/Dark Mode for three approaches to theme switching (CSS light-dark(), media queries, and manual toggle).
Custom themes
{:ok, theme} = Lumis.Theme.from_file("/path/to/theme.json")
{:ok, theme} = Lumis.Theme.from_json(theme_json)
Then pass the resulting theme struct into the formatter options.
Validation
# validate options before passing to highlight
Lumis.validate_options!(formatter: {:html_inline, language: "elixir", theme: "catppuccin_frappe"})
# inspect defaults
Lumis.default_options()
Use validate_options!/1 when your app exposes formatter options to users (e.g., config files, admin panels). It raises on invalid values.