Skip to main content

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:

lib/my_app/application.ex
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/runtime.exs
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:

RemovedUse instead
config :lumis, :bundled_languagescall Lumis.Languages.async_load/1 from your start/2
config :lumis, :wasm_resolverconfig :lumis, data_dir: "/app/lumis"
config :lumis, :language_package_resolverconfig :lumis, data_dir: "/app/lumis"
release-local priv/wasmconfig :lumis, data_dir: "/app/lumis", or LUMIS_DATA_DIR
config :lumis, :wasm_pathone 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/config.exs
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.