LumisDocs
Usage

JavaScript / TypeScript

Lumis JavaScript and TypeScript API including highlighters, bundles, lazy loading, auto-detection, injected languages, and runtime metadata.

Use the same @lumis-sh/lumis package in JavaScript and TypeScript projects. Its exports include TypeScript declarations; both use the same runtime on each host.

Each language you highlight is its own package, and the examples below assume it is installed:

npm install @lumis-sh/lumis @lumis-sh/wasm-javascript

To highlight TypeScript source, install @lumis-sh/wasm-typescript or @lumis-sh/wasm-tsx. The language you highlight is independent of whether your app uses JavaScript or TypeScript. Where parsers come from has the details.

For one-off highlighting

Use highlight() when you just need one async highlight call and do not need to keep a highlighter instance around.

import { highlight } from "@lumis-sh/lumis";
import { htmlInline } from "@lumis-sh/lumis/formatters";
import javascript from "@lumis-sh/wasm-javascript";
import frappe from "@lumis-sh/themes/catppuccin_frappe";

const html: string = await highlight(
  "const x = 1",
  htmlInline({ language: javascript, theme: frappe }),
);

highlight() uses a shared default runtime, so loaded languages and global WASM resolver changes are process-wide.

For a theme outside the bundled collection, follow Generate a theme from a Neovim colorscheme and import the resulting JSON as the formatter's theme object.

For reusable highlighters

Use createHighlighter() when you want instance isolation, explicit setup, lazy loading, or repeated synchronous hl.highlight() calls after initialization.

import { createHighlighter } from "@lumis-sh/lumis";
import { htmlInline } from "@lumis-sh/lumis/formatters";
import javascript from "@lumis-sh/wasm-javascript";
import frappe from "@lumis-sh/themes/catppuccin_frappe";

const hl = await createHighlighter({
  languages: [javascript],
});

const html = hl.highlight(
  "const x = 1",
  htmlInline({ language: javascript, theme: frappe }),
);

This creates an isolated highlighter unless you intentionally rely on the global resolver.

Bundles

A bundle package depends on a set of language packages and registers all of them lazily.

BundlePurpose
@lumis-sh/wasm-bundle-webHTML, CSS, JS, TS, JSON, Markdown, SQL, Svelte, Vue, Astro, and other web-focused languages
@lumis-sh/wasm-bundle-web-extraframework and template languages that complement the web bundle
@lumis-sh/wasm-bundle-systemC, C++, Rust, Go, Zig, LLVM, Make, CMake, and related system languages
@lumis-sh/wasm-bundle-backendpopular backend languages plus common API, config, and infra formats
@lumis-sh/wasm-bundle-fullevery language but the largest less common parsers, capped at 64 MB of Wasm memory
import { createHighlighter } from "@lumis-sh/lumis";
import web from "@lumis-sh/wasm-bundle-web";

const hl = await createHighlighter({
  languages: [web],
});

// registered, including lazy entries
hl.registeredLanguages;

// loaded right now, usually just plaintext at first
hl.languages;

Lazy loading

You can register a language now and load it only when needed.

import { createHighlighter } from "@lumis-sh/lumis";
import { htmlInline } from "@lumis-sh/lumis/formatters";
import frappe from "@lumis-sh/themes/catppuccin_frappe";
import web from "@lumis-sh/wasm-bundle-web";

const hl = await createHighlighter({
  languages: [web],
});

await hl.loadLanguage(web.javascript);

const html = hl.highlight(
  "const x = 1",
  htmlInline({
    language: web.javascript,
    theme: frappe,
  }),
);

createHighlighter({ languages }) accepts:

  • eager Language objects
  • a bundle object
  • dynamic import promises
  • lazy import functions

Repeated highlighting

Load languages once with createHighlighter(), then call hl.highlight() synchronously for repeated work.

import { createHighlighter } from "@lumis-sh/lumis";
import { htmlInline } from "@lumis-sh/lumis/formatters";
import javascript from "@lumis-sh/wasm-javascript";
import frappe from "@lumis-sh/themes/catppuccin_frappe";

const hl = await createHighlighter({
  languages: [javascript],
});

const html = hl.highlight(
  "const x = 1",
  htmlInline({ language: javascript, theme: frappe }),
);

Language references

These are equivalent at highlight time:

htmlInline({ language: json, theme });
htmlInline({ language: web.json, theme });
htmlInline({ language: "json", theme });

Auto-detection and plaintext fallback

If you omit language, Lumis tries to detect it and falls back to plaintext when it can't.

Detection methods:

  • file extensions (.rs, .js, .ex)
  • filenames (Makefile, Dockerfile)
  • shebangs (#!/usr/bin/env python)
  • Emacs mode lines
const html = await highlight(
  source,
  htmlInline({ theme: frappe }),
);

Useful when rendering user-provided content or pasted snippets.

Metadata APIs

Language helpers live in @lumis-sh/lumis/languages, and theme helpers live in @lumis-sh/lumis/themes.

Useful for dropdowns, config validation, and docs tooling:

  • languages.available()
  • languages.get() — metadata by name, alias, extension, or path; empty input returns plaintext, unknown input returns undefined
  • languages.loaded() — ids ready to highlight without a download
  • themes.available(), themes.get(name), and themes.closest(colors) from @lumis-sh/lumis/themes
  • sanitizeThemeName() from @lumis-sh/lumis/formatters/html

Runtime notes

  • every runtime loads the same per-language parser WASM; what differs is the engine that runs it
  • on Node, @lumis-sh/lumis runs those parsers and the highlighting walk under Wasmtime; built-in formatters normally render there too, while custom formatters assemble events in JavaScript and an async call that may need a JavaScript resolver stays on the main thread
  • browsers, and any platform with no prebuilt addon, run them under the host's own WebAssembly through web-tree-sitter, and walk and format in JavaScript
  • the addon is prebuilt for macOS arm64 and x64, Linux arm64 and x64 against both glibc and musl, and Windows arm64 and x64; npm installs the one matching the host, so Alpine and other musl images get the same runtime as a glibc host
  • runtimeKind() returns 'native' or 'wasm' if you need to confirm which one an install resolved
  • the addon carries no parsers and loads each from the @lumis-sh/wasm-* package the project installed, so it costs about 4.7 MB of download on top of the Wasm runtime rather than one binary per language
  • the package uses conditional exports so Node and web-standard runtimes get the right entry point automatically
  • every language package contains matching queries plus an exact parser version and SHA-256 digest; parser bytes a resolver downloads are checked against that digest
  • Node keeps compiled parser modules, and anything a custom resolver fetched, in the platform user data directory; LUMIS_DATA_DIR overrides it
  • browsers load only the parser packages you import, and fetch nothing unless you configure a resolver; parser bytes a resolver fetched are kept in CacheStorage, with an IndexedDB fallback
  • Node loads installed parser packages such as @lumis-sh/wasm-elixir or bundle packages such as @lumis-sh/wasm-bundle-web, and nothing else unless you configure a resolver
  • ordinary Node highlighters share one Wasmtime runtime; a highlighter that supplies parser bytes, queries, or resolver callbacks gets an isolated Tree-sitter Wasm store so its definitions are released with that highlighter, while the Wasmtime engine and persistent caches remain shared
  • Bun and Deno are also supported
  • edge and worker hosts such as Supabase Edge Functions, Cloudflare Workers and Vercel are supported too; see Edge / Workers

Where parsers come from

Every language is a package, named after its parser: @lumis-sh/wasm-rust holds the Rust parser, the queries written for it, and the parser's SHA-256 digest. Install it and import the language it exports. The same code runs in Node and in a browser:

npm install @lumis-sh/lumis @lumis-sh/wasm-rust
import { createHighlighter } from "@lumis-sh/lumis";
import rust from "@lumis-sh/wasm-rust";

const hl = await createHighlighter({ languages: [rust] });

In Node, Lumis reads the parser out of node_modules, next to its lumis.json. In a browser, your bundler ships it with your code, and Lumis checks the bytes against the package's digest when it fetches them. Either way nothing comes from a CDN, and a parser runs with the queries it was released with.

A bundle package exports a whole set, and loads each language the first time it is used:

import web from "@lumis-sh/wasm-bundle-web";

const hl = await createHighlighter({ languages: [web] });

One parser can serve more than one language, so a few packages export more than one, by name:

import markdown, { mdx } from "@lumis-sh/wasm-markdown";
import { ejs, erb } from "@lumis-sh/wasm-embedded-template";

A grammar split in two comes along on its own: importing markdown also loads markdown_inline.

A language whose package the project didn't install or import is refused, and so is a package too old for this Lumis or outside the supported version range; update it. Declaring parsers explains why. To fetch parsers at run time instead, configure a resolver.

In Node, @lumis-sh/lumis/langs/rust, @lumis-sh/lumis/bundles/web, and a plain "rust" work too: they name the package, and Node reads the installed one. A browser has no node_modules to read, so it needs the import.

Node searches from the language and bundle packages passed to createHighlighter, including their parser dependencies, with the working directory as a fallback. An imported bundle therefore works even when a CLI or editor runs from another directory. These packages remain available to later highlighters in the same process. Update older @lumis-sh/wasm-bundle-* packages if their injected languages still depend on the working directory.

A string ID or a @lumis-sh/lumis/langs/* handle alone has no installation location. If the parsers are installed elsewhere, pass the imported language or bundle instead.

Without a bundler, import the package from jsDelivr, which serves the parser file next to the module:

import rust from "https://cdn.jsdelivr.net/npm/@lumis-sh/wasm-rust/+esm";

Vite 8 needs no configuration. The dev server of Vite 7 and older, VitePress 1 included, pre-bundles a package away from its parser file, so leave the parser packages you import out of pre-bundling. The languages a bundle loads and the grammars a language requires come along without being listed:

vite.config.ts
import { defineConfig } from "vite";

export default defineConfig({
  optimizeDeps: {
    exclude: ["@lumis-sh/wasm-rust", "@lumis-sh/wasm-bundle-web"],
  },
});

Custom resolvers

A resolver tells Lumis where to find package metadata and parser files, such as a CDN or a directory you serve. It is the only way Lumis fetches a parser at run time, and configuring one is a declaration of its own: it answers for every language you didn't import, ahead of installed packages on Node.

Every highlighter
import * as languages from "@lumis-sh/lumis/languages";

languages.configurePackageResolver(
  (packageName, versionRange) =>
    `https://unpkg.com/${packageName}@${versionRange}/lumis.json`,
);

languages.configureWasmResolver(
  (_language, wasm) =>
    `https://unpkg.com/${wasm.packageName}@${wasm.version}/${wasm.name}.wasm`,
);
One highlighter
import { createHighlighter } from "@lumis-sh/lumis";
import rust from "@lumis-sh/lumis/langs/rust";

const hl = await createHighlighter({
  languages: [rust],
  languagePackageResolver: (packageName) =>
    `/wasm/${encodeURIComponent(packageName)}/lumis.json`,
  wasmResolver: (_language, wasm) => `/wasm/${wasm.name}.wasm`,
});

Whatever a resolver returns is still checked against the package's digest. On Node, resolvers also answer for languages found inside a document; the next section covers what that walk can read.

Injected languages

On Node, highlighting loads a language injected inside a document during the same pass that finds it, so a Markdown file with a fenced Rust block highlights that block without Rust being named anywhere in your code. It loads from the installed package, so install the languages a document can inject too. One that isn't installed leaves its own block plain rather than failing the document, and Lumis warns once naming it.

Configured languages.configurePackageResolver() and languages.configureWasmResolver() callbacks participate in that same Node native path. They apply to a language first discovered mid-document as well as to the root language, without a preload or second pass.

The native walk has to read a newly discovered resolver result synchronously. Local paths plus file:, data:, http:, and https: URLs work there. A URL owned by JavaScript itself, such as a blob: URL, cannot be opened from Rust; include that language in createHighlighter({languages: [...]}) or call loadLanguage() first so JavaScript resolves it before the synchronous walk.

In a browser, a parser cannot be downloaded in the middle of highlighting, so import and load every participating language yourself:

import css from "@lumis-sh/wasm-css";
import html from "@lumis-sh/wasm-html";
import javascript from "@lumis-sh/wasm-javascript";

const hl = await createHighlighter({
  languages: [html, css, javascript],
});

const htmlOutput = hl.highlight(
  source,
  htmlInline({ language: html, theme: frappe }),
);

Languages from a bundle package such as @lumis-sh/wasm-bundle-web are registered but not loaded. Call hl.loadLanguage() for each one the document uses before highlighting it.

A language only has to be loaded once per page. A highlighter without wasmResolver or languagePackageResolver of its own also highlights an injected block in any language another highlighter on the page loaded from its package, so a page that loads rust for one highlighter gets Rust fences in a Markdown-only highlighter as well. One that sets a resolver uses only what it loaded itself, and so does every highlighter once a global resolver is set with languages.configureWasmResolver() or languages.configureLanguagePackageResolver().

See On demand.

Preload languages during application startup

Highlighting loads on demand, so preloading only decides when a language is paid for. See Languages.

Start serving, then load in the background:

bootstrap.ts
import * as languages from "@lumis-sh/lumis/languages";

await startServer();

// Not awaited, so a slow compile delays no request. The
// `.catch()` is required: an unhandled rejection
// terminates the process.
languages.load(["javascript", "html", "css"]).catch(
  (error) => {
    logger.warn(
      { error },
      "Lumis preload failed; languages load on demand",
    );
  },
);

languages.load() warms the same default runtime highlight() uses, so nothing has to be threaded through the application. createHighlighter({languages}) is the same load into an instance you keep yourself.

Awaiting a preload before startServer() puts every compile on the boot path, so the process accepts no request until the slowest one finishes.

The API accepts bundle-* names such as bundle-web, covering the same languages as the matching @lumis-sh/wasm-bundle-* package. Lumis loads a parser as your package manager installed it. A parser downloaded through a resolver is checked against its package's SHA-256 first.

When the native Node addon is available, the API also validates each parser and its queries and persists the compiled Wasmtime module in the data directory. The portable fallback can't: web-tree-sitter does not persist its compilation across processes.

To compile in an image build or a prestart step instead, run the same languages.load() there and await it, with LUMIS_DATA_DIR pointing at the directory the server will use. The server then reuses those compiled modules. Being a separate process is the point: failing there fails the build rather than a request. Languages has the command, and why lumis languages download is not the build step for a Node project.

On this page