Elixir
Elixir-specific Lumis API including highlight/2, Phoenix, LiveView, custom themes, validation, and multi-theme integration.
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
# The parser is not a dependency yet; `:package` is what to add to mix.exs.
{:error, %Lumis.ParserError{reason: :not_installed, package: package}} ->
handle_missing_parser(package)
{:error, error} ->
handle_error(error)
endThere is no default theme. Without :theme, :html_inline and :terminal emit
uncolored output.
highlight/2 returns {:ok, output} or {:error, exception}. The exception is
a Lumis.ParserError when a
language's parser could not be loaded and a
Lumis.RenderError
otherwise. Both carry a :reason atom to match on, so the case a deployment has
to act on is a clause rather than a substring; Exception.message/1 renders
either one for a log.
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
A parser is an ordinary dependency. Declare the languages you highlight in
mix.exs, and Lumis.highlight/2 loads the ones a document turns out to
name, including languages injected inside it, then keeps them for every
later request:
def deps do
[
{:lumis, "~> 0.10"},
{:lumis_wasm_markdown, "~> 0.26.0"},
{:lumis_wasm_elixir, "~> 0.26.0"}
]
end# 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 no dependency supplies leaves its own block plain rather than failing
the document, and is never fetched. Only the document's own language failing is
an error, a Lumis.ParserError whose :package names the dependency to add.
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
Parser bytes ship with the lumis_wasm_* dependency, so a cold parser costs a
Wasmtime compile and nothing else. Warm the languages your application needs
from its start/2 so a request never pays it:
def start(_type, _args) do
Lumis.Languages.async_load(["markdown", "elixir", "json"])
Supervisor.start_link(children(),
strategy: :one_for_one,
name: MyApp.Supervisor
)
endasync_load/1 returns before the compile starts, so the boot never waits on
it, 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.
Warm the languages you serve rather than :bundle_full. Every language in a
process shares one 128 MB Tree-sitter Wasm store, and
loading the full set can exhaust it.
:bundle_full is capped at half that store, so it leaves out the largest
parsers of the less common languages, such as haskell and perl. Add
{:lumis_wasm_haskell, "~> 0.26.0"} and name it if you need it; the
full list is there.
Compiled modules land under config :lumis, data_dir:, LUMIS_DATA_DIR, or
Lumis's priv/lumis directory by default, so a restart or a new pod using the
same persistent directory reuses them instead of compiling again.
Where parsers come from
A parser is an ordinary dependency. mix deps.get delivers the bytes, Lumis
reads them from the priv/parsers of each installed lumis_wasm_* application,
and a language no dependency supplies answers :not_installed rather than being
fetched. Nothing in the Elixir runtime reaches the network, at boot or at render.
The data directory is where Wasmtime persists compiled modules. It defaults to
LUMIS_DATA_DIR, and configuration takes precedence, which is what a release
usually wants:
config :lumis, data_dir: "/app/lumis"Hex checks every package it fetches against the checksum in mix.lock, so
Lumis loads parsers as mix deps.get delivered them and does not hash them
again.
For upgrades from 0.6 or to 0.9, see Elixir upgrades.
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: :cloudflareLUMIS_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.Languages.available()
Lumis.Theme.available()
Lumis.Languages.loaded()
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
Generate a JSON file with the Neovim colorscheme recipe, then load it as a theme struct:
{: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.