Languages
Let Lumis load parsers on demand, or preload the languages you know you need.
A language in Lumis is a Tree-sitter parser: a WebAssembly module that Lumis downloads, verifies, compiles, and loads. You have two ways to get one, and you can use both.
| On demand | Preloaded | |
|---|---|---|
| What you write | nothing | a list of languages, once |
| First document in a language | pays the download and the compile | already loaded |
| Every document after | full speed | full speed |
| A language you did not expect | works | works, on demand |
On demand is the default and needs no setup. Highlight a document and Lumis loads whatever that document turns out to name. Preloading is the same work, moved off your users' first request — it is the "pick a language" step done ahead of time instead of during a request.
Both end in the same place. Preloading only decides when you pay.
On demand
Nothing to declare. Lumis resolves, downloads, verifies, and loads a language the first time a document names it, then reuses it for every later document.
That includes languages injected inside another one, in the same pass: a
Markdown file with a fenced Rust block highlights that block, and an HTML page
highlights its <style> and <script> contents, without any of them appearing
in your code.
A language that cannot be fetched costs its own block, not the document. A thousand-line file still highlights when one fenced block names something unpublished; that block stays plain.
The cost is one download and then a Wasmtime compile, and the compile is the larger half: seven parsers already on disk take about 8 s to compile, against 1.3 s once their compiled forms are stored. Registering an already-compiled parser is 3-15 ms, and about 0.3 ms after that. So the penalty is real but it is paid once per language, by whichever request arrives first.
That is fine for a CLI, a script, a build step, or anything where one slow first render does not matter. Preload when it does.
:::note
Browsers are the exception. web-tree-sitter loads asynchronously, so a parser
cannot be fetched inside a synchronous walk: in a browser, preload an injected
language before highlighting the document that mentions it, or use a bundle.
Node does not have this limit — it highlights through a native addon.
:::
Preloaded
Name the languages you want. Which call depends on whether the process doing the work is the one that will serve:
| Runtime | In the process that serves | In a process that exits |
|---|---|---|
| JavaScript / TypeScript | loadLanguages([...]), not awaited | cacheLanguages([...]) |
| Elixir | Lumis.Languages.async_load([...]) in start/2 | Lumis.Languages.cache/2 |
| Browsers / CDN | createHighlighter({languages}), or loadLanguages() | — nothing to prepare ahead |
| CLI | — the process exits, so there is no later to hold for | lumis languages cache <names> |
| Rust | — parsers are Cargo features compiled into the binary | — |
| Java | construct Lumis at startup and retain it | — parsers ship inside lumis4j |
The columns are the two verbs, spelled the same way everywhere. load caches the language and keeps it in this runtime, so nothing loads it again. cache only puts it on disk, where it survives restarts and is shared by every native runtime pointed at the same directory.
Load is cache plus keeping it, so a process that will serve wants a load. Cache is for a process that is not serving — an image build, a release task, a CLI run — which has nothing to hold on to. Caching on a native runtime compiles as well, so a directory prepared that way saves the serving process both halves.
:::note Preloading is an optimization, never a prerequisite. An application that starts cold serves correctly the whole time it is loading, so preloading must never delay a boot or fail one — every example below keeps both true. :::
Which languages to name
Name your root languages and anything they can inject. Markdown may need the languages used in fenced code blocks, and HTML may inject CSS and JavaScript. Prefer a focused list or a bundle over every parser unless your application genuinely accepts every supported language. Anything you miss still loads on demand.
Language catalog has every name, and the package each one resolves to.
All of these operations are safe to repeat. A normal run reuses valid files;
force: true or --force is an explicit update that resolves the compatible
package range again.
Preloading, per runtime
Start serving, then load in the background:
import { loadLanguages } from "@lumis-sh/lumis";
await startServer();
// Not awaited, so a slow CDN delays no request. The
// `.catch()` is required: an unhandled rejection
// terminates the process, which is exactly the failure
// loading in the background is meant to avoid.
loadLanguages(["javascript", "html", "css"]).catch(
(error) => {
logger.warn(
{ error },
"Lumis preload failed; languages load on demand",
);
},
);
loadLanguages() warms the same default runtime the module-level highlight()
uses, so nothing has to be threaded through your application. It accepts catalog
names, aliases, and bundle names such as bundle-web. Every name is attempted;
if any fail it rejects with an AggregateError naming each one, after the rest
have loaded.
createHighlighter({languages}) is the same load into an instance you keep
yourself, and is what browsers use.
Await cacheLanguages() instead when a build or prestart step is preparing a
directory the serving process will read — failing is the point of a preparation
step:
await cacheLanguages(["bundle-web"], { force: true });
{force: true} resolves the compatible package range again and replaces valid
cached files. On the native Node addon, caching also validates the parser and
queries and persists the compiled Wasmtime module. Bun and Deno, or a Node
installation without the addon, can call the same API, but the portable
web-tree-sitter fallback cannot persist compiled modules across processes.
Point every process at one directory
The CLI, Elixir NIF, and native Node addon share one store, so a parser one of them downloads is already there for the others. Name it explicitly when one process prepares files for another:
| Host | Explicit setting | Environment fallback | Default |
|---|---|---|---|
| CLI | --data-dir /app/lumis | LUMIS_DATA_DIR | platform user data directory |
| JavaScript / TypeScript cache API | {directory: "/app/lumis"} | LUMIS_DATA_DIR | platform user data directory |
| Elixir | config :lumis, data_dir: "/app/lumis" | LUMIS_DATA_DIR | the Lumis application priv/lumis directory |
Elixir configuration takes precedence over the environment. The npm directory
option controls where cacheLanguages() writes; also set LUMIS_DATA_DIR for
the deployed highlighter when it must read that explicit directory.
WASM and CDN has the directory layout, integrity checks, custom resolvers, and offline images.