https://github.com/marko-js/tree-sitter
Tree-sitter grammar for the Marko templating language
https://github.com/marko-js/tree-sitter
Last synced: about 1 month ago
JSON representation
Tree-sitter grammar for the Marko templating language
- Host: GitHub
- URL: https://github.com/marko-js/tree-sitter
- Owner: marko-js
- License: mit
- Created: 2026-06-13T03:19:52.000Z (about 2 months ago)
- Default Branch: main
- Last Pushed: 2026-06-13T22:06:11.000Z (about 2 months ago)
- Last Synced: 2026-06-13T23:23:29.746Z (about 2 months ago)
- Language: C
- Size: 102 KB
- Stars: 0
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# @marko/tree-sitter
A [tree-sitter](https://tree-sitter.github.io/) grammar for the
[Marko](https://markojs.com/) templating language.
It covers the full language: both authoring modes (HTML and
concise/indentation-based), `${placeholders}`, tag variables, arguments and
parameters, shorthand `#id`/`.class` and method attributes, attribute
groups, statement tags, raw-text tags, `--` content blocks, and `$`
scriptlets, all with the exact source ranges the Marko compiler sees, so the
tree is reliable for highlighting, folding, structural editing, and tooling.
## Usage
With the node bindings:
```js
const Parser = require("tree-sitter");
const Marko = require("@marko/tree-sitter");
const parser = new Parser();
parser.setLanguage(Marko);
const tree = parser.parse("${count}");
console.log(tree.rootNode.toString());
```
In the browser (or anywhere native bindings are unwanted), use the wasm
build with [web-tree-sitter](https://github.com/tree-sitter/tree-sitter/tree/master/lib/binding_web):
```js
import { Language, Parser } from "web-tree-sitter";
await Parser.init();
const Marko = await Language.load("tree-sitter-marko.wasm");
const parser = new Parser();
parser.setLanguage(Marko);
```
Building from a checkout:
```sh
npm install
npm run build # tree-sitter generate && node-gyp rebuild
npm run build:wasm # tree-sitter-marko.wasm (the CLI fetches wasi-sdk itself)
```
For editors, the grammar ships ready-to-use queries: `queries/highlights.scm`
for syntax highlighting captures and `queries/injections.scm` for
embedded-language injections (described below).
## Editors and tools
Every integration consumes the same artifacts: the generated parser
(`src/parser.c` + `src/scanner.c`), the queries in `queries/`, and, for
embedded highlighting, the `typescript`, `css`, and `scss` grammars installed
in the host tool like any other language.
### tree-sitter CLI
From this directory:
```sh
npx tree-sitter playground # interactive tree in the browser
npx tree-sitter parse file.marko # print the syntax tree
npx tree-sitter highlight file.marko # ANSI highlighting (--html for a page)
```
`highlight` resolves the injected languages through the CLI config: run
`tree-sitter init-config`, then make sure one of the `parser-directories`
in `~/.config/tree-sitter/config.json` holds clones of
`tree-sitter-typescript` (run `npm install --ignore-scripts` inside it, as
its queries reference its `tree-sitter-javascript` dependency),
`tree-sitter-css`, and `tree-sitter-scss`. Note the loader only discovers
grammars that have a `tree-sitter.json`.
### Neovim
Neovim (0.10+) needs no plugins. Compile the parser onto the runtime path
and start treesitter for the filetype:
```sh
# from this directory
mkdir -p ~/.local/share/nvim/site/parser ~/.local/share/nvim/site/queries
npx tree-sitter build -o ~/.local/share/nvim/site/parser/marko.so
cp -R queries ~/.local/share/nvim/site/queries/marko
```
```lua
-- init.lua
vim.filetype.add({ extension = { marko = "marko" } })
vim.api.nvim_create_autocmd("FileType", {
pattern = "marko",
callback = function()
vim.treesitter.start()
end,
})
```
Embedded highlighting uses whatever parsers are installed; with
nvim-treesitter that's `:TSInstall typescript css scss`.
### Helix
Register the grammar and language in `~/.config/helix/languages.toml`:
```toml
[[language]]
name = "marko"
scope = "source.marko"
file-types = ["marko"]
comment-token = "//"
block-comment-tokens = { start = "" }
injection-regex = "^marko$"
[[grammar]]
name = "marko"
source = { git = "https://github.com/marko-js/tree-sitter", rev = "" }
```
(or `source = { path = "/path/to/tree-sitter" }` for a local
checkout), then:
```sh
hx --grammar fetch && hx --grammar build
```
and copy the queries to `~/.config/helix/runtime/queries/marko/`.
### Zed
Marko support in Zed is provided by the
[marko-js/zed](https://github.com/marko-js/zed) extension, which bundles this
grammar (via its `[grammars.marko]` entry pointing here), the language config,
the editor queries, and the
[Marko language server](https://github.com/marko-js/language-server). Install
it from Zed's extension registry, or run `zed: install dev extension` on a
local checkout of that repo. Its `highlights.scm`/`injections.scm` are copies
of the `queries/` here, used as-is: Zed evaluates the
`#eq?`/`#any-of?`/`#not-any-of?` predicates, `injection.combined`, and the
dynamic `@injection.language` dialects. The Zed-specific `brackets.scm` and
`outline.scm` are maintained in that extension repo.
### VS Code
VS Code does not consume tree-sitter grammars. Marko support there remains
the tmLanguage-based
[Marko VS Code extension](https://github.com/marko-js/language-server),
which this grammar's queries are designed to match visually.
## The tree
```marko
div.panel
${count}
```
```
(document
(element
(tag_name (tag_name_fragment))
(shorthand_class (tag_name_fragment))
(concise_open_tag_end)
(element
(open_tag_start)
(tag_name (tag_name_fragment))
(attr_name)
(args)
(method_body (method_body_expr))
(open_tag_end)
(placeholder (placeholder_start) (placeholder_expr))
(close_tag (close_tag_start) (close_tag_name) (close_tag_end))
(element_end))
(element_end)))
```
Commonly queried nodes:
| node | meaning |
| ------------------------------------------------------------------------------------------ | ------------------------------------------------------------ |
| `element` | a tag in either mode, including its body and close |
| `tag_name`, `tag_name_fragment` | tag names; interpolated names contain `placeholder` children |
| `shorthand_id`, `shorthand_class` | `#id` / `.class` shorthands |
| `attr_name`, `attr_value`, `attr_bound_value`, `attr_spread` | attributes (`x=…`, `x:=…`, `...spread`) |
| `args`, `method_body` | `(…)` arguments and `{ … }` shorthand-method bodies |
| `tag_var` → `var_pattern`, `var_type` | tag variables: `/pattern` with an optional `: type` |
| `params` → `param` → `param_pattern`, `param_type`, `param_default` | `\|a: T = 1, b\|` tag parameters |
| `type_args`, `type_params` → `type_expr` | `` type arguments/parameters |
| `placeholder`, `placeholder_expr` | `${…}` and `$!{…}` |
| `scriptlet`, `scriptlet_expr`, `scriptlet_block_expr` | `$ statement` / `$ { block }` |
| `statement_expr` | the body of statement tags (`import`, `static`, `server`, …) |
| `text`, `html_comment`, `line_comment`, `block_comment`, `cdata`, `doctype`, `declaration` | content |
| `html_block` | `--` delimited / single-line content blocks in concise mode |
A few structural notes:
- `element_end` is a zero-width node marking where an element closes
implicitly (concise dedent, void tags, self-closing, end of input).
`concise_open_tag_end` is similarly zero-width (or covers a trailing `;`).
- Delimiters are visible named tokens (`args_open`/`args_close`,
`params_open`/`params_close`, `type_open`/`type_close`,
`attr_group_open`/`attr_group_close`, `method_body_open`/
`method_body_close`, `scriptlet_block_open`/`scriptlet_block_close`,
`placeholder_start`/`placeholder_end`), so bracket-matching and
punctuation queries can target them.
- Whitespace is significant in Marko, so the grammar has no `extras`; text
nodes contain their exact whitespace.
- Invalid input produces an `ERROR` node from the first error onward,
matching how Marko itself reports a single error per template rather than
guessing at recovery.
## Tag types
Tag parsing in Marko depends on the tag. This grammar follows the
[tags API](https://markojs.com/docs/reference/core-tag)'s `parseOptions` (and
the vscode tmLanguage grammar it replaces), which sorts tags into three
groups.
Void tags take no children and no closing tag: the html void elements plus
`const`, `debug`, `id`, `let`, `lifecycle`, `log`, and `return`.
Raw-text tags (`script`, `style`, `html-script`, `html-style`,
`html-comment`) have bodies of text and placeholders only.
Statement tags (`import`, `export`, `static`, `server`, `client`, plus
`class` for class-API compatibility) treat the rest of the line, and any
indented continuation, as a single embedded statement.
## Embedded languages
Marko templates embed TypeScript and CSS throughout, and
`queries/injections.scm` maps it all. TypeScript is injected into every
expression position: placeholders, attribute values/spreads, arguments,
shorthand-method bodies, scriptlets, statement-tag bodies, and the
pattern/default parts of tag variables and parameters.
Statement tags split into two groups, mirroring how the compiler consumes
them. `import`, `export`, and `class` are pass-through TypeScript, where the
keyword is part of the statement, so the whole element (keyword included) is
injected and the keyword takes its color from the TS grammar. For `static`,
`server`, and `client` the keyword is Marko syntax that the compiler strips,
so only the body is injected and `highlights.scm` captures the keyword as
`@keyword` (the tmLanguage grammar makes the same split). ``/
`<html-script>` bodies inject TypeScript and `<style>`/`<html-style>` bodies
inject CSS, including their concise `script --` / `style --` block forms,
with `injection.combined` merging chunks split by placeholders. `style`
dialect shorthands are honored the way the compiler resolves them: the last
shorthand segment names the injected language, so `<style.scss>` and
`style.module.scss --` inject `scss` while plain `<style>` falls back to
`css`. `html-style` is exempt, since its shorthand is a real class attribute,
not a dialect.
Type annotations (tag var/param types, `<T>` type args/params) are captured
as `@type` instead of injected: a bare type is not a valid TypeScript
program, so flat coloring is the accurate option (the tmLanguage grammar
makes the same approximation). One residual case: the parameters of a
shorthand method (`onClick(event) { … }`) inject as a whole TS program,
which renders typed parameters slightly off, since that position cannot be
split without lookahead the scanner doesn't have.
## Fidelity and development
Marko's parser is [htmljs-parser](https://github.com/marko-js/htmljs-parser);
this grammar's external scanner reimplements its state machine, and the test
suite (`npm test`, Node 22) asserts the tree reproduces the parser's event
stream with byte-precise ranges across the parser's full fixture suite, plus
thousands of templates from the Marko ecosystem during development.
The reference parser and its fixtures are always the same htmljs-parser
revision: they are fetched together into `.cache/` with degit, so the suite
compares the grammar against the exact parser the fixtures came from (set
`HTMLJS_FIXTURES` to point at a local checkout's fixtures instead, and the
adjacent sources are used as the parser; if this directory is dropped inside
an htmljs-parser checkout, the local parser sources are used automatically).
The published `htmljs-parser` package is only a last-resort fallback. The
grammar tracks the parser, so the suite is only expected to be green against
the htmljs-parser revision the grammar was synced with.
See `__tests__/` and `tools/` to work on the grammar itself; the scanner
internals are documented at the top of `src/scanner.c`.