LumisDocs
Usage

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)
end

There 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:

mix.exs
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:

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 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/runtime.exs
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/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.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.

On this page