Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/letstri/druk/llms.txt

Use this file to discover all available pages before exploring further.

Druk’s language system is a single registry: every file type Druk highlights is described by one entry in LANGUAGES. Adding a language means dropping a grammar wasm and a highlight query into place, registering them statically so the compiled binary can embed them, and adding a row to the table. Nothing else in the codebase needs to change — the parser registration, the status bar label, and the comment-toggle all derive from the same entry.

Prerequisites

  • A tree-sitter grammar wasm. Most languages are covered by the tree-sitter-wasms npm package, which ships pre-compiled .wasm files ready to use. Check node_modules/tree-sitter-wasms/out/ for a file named tree-sitter-<language>.wasm.
  • A highlight query (.scm file). This is a tree-sitter query that maps grammar node names to capture groups (@keyword, @string, @function, @type, @comment, and so on). Many grammars ship their own queries — the ones vendored by Druk live in src/languages/queries/.
Languages that OpenTUI already bundles (JavaScript, TypeScript, Markdown, Zig) need neither — see Bundled languages below.

Steps

1

Confirm a grammar wasm exists

Check whether tree-sitter-wasms already includes your language:
ls node_modules/tree-sitter-wasms/out/ | grep <language>
If a file appears, that wasm is your grammar source. If not, you may need to compile one from the language’s tree-sitter repository — the tree-sitter CLI can emit a .wasm from any grammar.js. Either way, Druk expects the wasm to be importable as a static path at build time.
Druk ships on Windows as well as Unix. Both directory separators work in import paths, but always use the / form to stay portable across platforms.
2

Write a highlight query at src/languages/queries/<id>.scm

Create src/languages/queries/<id>.scm. The file is a tree-sitter query: each clause matches a grammar node and assigns it to a capture group that the active theme styles.The scopes the built-in themes define are: keyword, keyword.operator, operator, string, escape, number, boolean, constant, function, function.method, function.builtin, constructor, type, type.builtin, namespace, variable, variable.builtin, variable.member, property, attribute, tag, label, punctuation, punctuation.bracket, punctuation.delimiter, punctuation.special, comment, embedded, error, and the markup.* family for prose formats.A minimal Python-style query looks like this:
src/languages/queries/mylang.scm
["if" "else" "while" "for" "return" "fn"] @keyword

(function_definition
  name: (identifier) @function)

(call_expression
  function: (identifier) @function)

(type_identifier) @type
(identifier) @variable

[(string) (string_content)] @string
(escape_sequence) @escape
[(integer) (float)] @number
[(true) (false)] @boolean
(comment) @comment

["{" "}" "(" ")" "[" "]"] @punctuation.bracket
["," ";" ":" "."] @punctuation.delimiter
Druk’s actual Python query follows exactly this shape:
src/languages/queries/python.scm
["and" "as" "assert" "async" "await" "break" "case" "class" "continue" "def"
 "elif" "else" "finally" "for" "from" "global" "if" "import" "in" "is"
 "lambda" "match" "nonlocal" "not" "or" "pass" "print" "raise" "return"
 "try" "type" "while" "with" "yield"] @keyword
[(true) (false)] @boolean
(comment) @comment
[(string) (string_content)] @string
(escape_sequence) @escape
[(integer) (float)] @number
(none) @constant.builtin
["{" "}" "(" ")" "[" "]"] @punctuation.bracket
["," ";" ":" "."] @punctuation.delimiter
Highlight queries fail silently when a node name doesn’t exist in the grammar — the capture simply matches nothing. One invalid pattern is different: it stops the entire parser from loading, leaving the file unstyled. Compile your query against its grammar before committing it, and write an assertion in test/languages.test.ts that a representative sample actually produces highlights.
3

Register the wasm and query in src/languages/grammars.ts

Add two static imports and an entry to GRAMMARS. Both imports must carry with { type: 'file' }.
src/languages/grammars.ts
import mylangWasm from 'tree-sitter-wasms/out/tree-sitter-mylang.wasm' with { type: 'file' }
import mylangQuery from './queries/mylang.scm' with { type: 'file' }

// ... existing imports ...

export const GRAMMARS = {
  // ... existing entries ...
  mylang: { wasm: mylangWasm, query: mylangQuery },
} satisfies Record<string, Grammar>
The full file already follows this pattern for every vendored language — Python is a representative example:
src/languages/grammars.ts
import pythonWasm from 'tree-sitter-wasms/out/tree-sitter-python.wasm' with { type: 'file' }
import pythonQuery from './queries/python.scm' with { type: 'file' }

// ...

export const GRAMMARS = {
  // ...
  python: { wasm: pythonWasm, query: pythonQuery },
} satisfies Record<string, Grammar>
Both imports must be static and must carry with { type: 'file' }. That annotation is what tells bun build --compile to embed the wasm and query file inside the standalone binary. A path constructed at runtime (e.g. `tree-sitter-wasms/out/tree-sitter-${name}.wasm`) resolves to nothing inside the compiled binary, and every file of that type renders unstyled.
4

Add an entry to LANGUAGES in src/languages/index.ts

Spread your grammar entry into a new row. The id must match the filetype name OpenTUI uses — typically the lowercase language name, or the form pathToFiletype returns for that extension.
src/languages/index.ts
import { GRAMMARS } from './grammars'

export const LANGUAGES: Language[] = [
  // ... existing entries ...
  { id: 'mylang', ...GRAMMARS.mylang },
]
The real Python and Go entries in the registry look like this:
src/languages/index.ts
{ id: 'python', ...GRAMMARS.python },
{ id: 'go', ...GRAMMARS.go },
That’s everything needed for a new grammar-backed language. Restart Druk and open a file with the matching extension — it will be highlighted.

Bundled languages

JavaScript, TypeScript, Markdown, and Zig are bundled by OpenTUI itself. They need no wasm or query from Druk — only a bundled: true flag:
src/languages/index.ts
{ id: 'javascript', label: 'js', bundled: true },
{ id: 'typescript', label: 'ts', bundled: true },
{ id: 'markdown',   label: 'md', bundled: true },
{ id: 'zig',                     bundled: true },
Parser registration and highlighting both read from this same table, so bundled: true is the full declaration.

Dialect and grammar reuse

A dialect that is close enough to an existing language can spread its grammar entry directly. javascriptreact and tsrx (Octane’s template dialect) both reuse the tsx grammar:
src/languages/index.ts
{ id: 'typescriptreact', label: 'tsx', ...GRAMMARS.tsx },
{ id: 'javascriptreact', label: 'jsx', ...GRAMMARS.tsx },
{ id: 'tsrx',            ...GRAMMARS.tsx, patterns: [ /* overlay */ ] },
The scss, sass, and less entries similarly all spread GRAMMARS.css.

Custom filetype mapping

OpenTUI maps file extensions to filetype names via its internal pathToFiletype. For files whose type lives in the file’s name rather than its extension — .env, .env.local, bun.lock — you need to add a case to filetypeForPath in src/languages/highlight.ts:
src/languages/highlight.ts
/** Files whose name says what they are while their extension does not. */
const BY_NAME: Record<string, string> = {
  'bun.lock': 'json',
}

const DOTENV = /^\.env(?:\.[\w.-]+)?$|\.env$/

export function filetypeForPath(path: string): string | undefined {
  const name = path.slice(Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + 1)
  if (BY_NAME[name]) return BY_NAME[name]
  if (DOTENV.test(name)) return 'dotenv'
  if (name.endsWith('.tsrx')) return 'tsrx'
  return pathToFiletype(path) ?? undefined
}
Add your own BY_NAME entry or regex before the final pathToFiletype fallback.

Status bar label

The status bar shows the id by default. Add a label only where OpenTUI’s filetype name isn’t what a person would call the file:
src/languages/index.ts
// OpenTUI calls it "typescriptreact"; the status bar should show "tsx".
{ id: 'typescriptreact', label: 'tsx', ...GRAMMARS.tsx },

// dotenv's internal id is fine as-is — show a shorter label instead.
{ id: 'dotenv', label: 'env', patterns: [ /* ... */ ] },
If label is omitted, id is shown as-is.

Regex-only languages

When no usable grammar exists — tree-sitter-yaml needs an external scanner that OpenTUI’s worker cannot link, and several formats have no wasm at all — declare patterns instead of a wasm and query. Patterns are { group, re } pairs painted in order; later entries win the characters they overlap:
src/languages/index.ts
{
  id: 'dotenv',
  label: 'env',
  patterns: [
    { group: 'punctuation', re: /=/g },
    { group: 'number',      re: /\b\d+(?:\.\d+)?\b/g },
    { group: 'boolean',     re: /\b(?:true|false|null)\b/gi },
    { group: 'string',      re: /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g },
    { group: 'variable',    re: /\$\{[\w.]+\}|\$[A-Za-z_]\w*/g },
    { group: 'property',    re: /^[ \t]*(?:export[ \t]+)?[\w.]+(?=[ \t]*=)/gm },
    { group: 'keyword',     re: /^[ \t]*export\b/gm },
    { group: 'comment',     re: /^[ \t]*#.*/gm },
  ],
},
Patterns work well for line-oriented config formats. They need no wasm and no query file — only the patterns array in the LANGUAGES entry.

Patterns overlay (grammar + patterns)

patterns beside a grammar means something different: an overlay for tokens the grammar cannot parse. .tsrx files are TypeScript/TSX plus Octane directive syntax (@if, @for, @{) that lands in tree-sitter ERROR regions. A highlight query cannot reach inside an ERROR node, so those tokens are matched by regex instead:
src/languages/index.ts
{
  id: 'tsrx',
  ...GRAMMARS.tsx,
  patterns: [
    {
      group: 'keyword.directive',
      re: /@(?:if|else|for|empty|switch|case|default|try|pending|catch)\b|@(?=\{)|(?<=;[ \t]*)key\b(?=[ \t]+[\w$.\[\]]+[ \t]*\))/g,
    },
  ],
},
The overlay runs after the parse. outsideProse in highlight.ts then drops any regex match that a comment or string capture already covers — so directive syntax inside a string literal stays a string, not a keyword. Everywhere else the overlay wins, because the grammar mis-attributes the token (tsx reads @catch as a call expression and marks catch as function) rather than missing it.
A language with only patterns and no wasm or bundled flag must never reach tree-sitter. computeHighlights in highlight.ts checks for this and short-circuits before calling the client. Do not add a bundled: true or a wasm to a patterns-only entry by mistake — it would pass the regex output to the query engine, which can hang on formats like YAML.

Testing

Add an assertion in test/languages.test.ts that a representative source snippet produces at least one highlight segment for your language. This guards against two common mistakes: a wasm that fails to load (the language stays unstyled with no console output) and a query that compiles but matches nothing (every node name in the query is wrong).

Build docs developers (and LLMs) love