ejs.nvim
Neovim Plugin for EJS Template Support
ejs.nvim is a free, open source Neovim plugin that brings first-class EJS (Embedded JavaScript) template support to Neovim. Rather than reinventing a parser, it wires up the existing tree-sitter-embedded-template grammar with language injection, LSP configuration, template navigation, diagnostics, folding, and snippets. It is at feature parity with EJS Colorizer, the VS Code extension this plugin mirrors.
All dependencies beyond Neovim itself are optional. The plugin degrades gracefully when nvim-treesitter, language servers, a completion engine, or LuaSnip are absent, and requires zero manual configuration beyond installation.
Three of the extension's features are deliberately left out, because in Neovim they belong to other tools: Emmet expansion, Prettier formatting (conform.nvim or formatprg), and the Outline provider (LSP symbols or aerial.nvim). The extension's joined-program JavaScript syntax check is also skipped — ts_ls is already attached to <% %> regions and reports real syntax errors with better positions than that heuristic can.
Features
-
Tree-sitter syntax highlighting
HTML outside
<% %>tags, JavaScript inside them, via Neovim's native Tree-sitter highlighter. -
Language injection
Registers
embedded_templateas the parser for theejsfiletype, and shipsqueries/embedded_template/injections.scmto injecthtmlinto content nodes andjavascriptinto directive/output-directive code nodes. -
LSP attachment
html-lspattaches for the HTML portions andts_lsattaches for the JavaScript portions, both onFileType ejs, with duplicate-attachment guards. -
include() path completion and navigation
Completion inside the quotes of an
include()call, with directories offered with a trailing slash and re-triggering so navigating into one keeps completing. Paths are offered without the.ejsextension, the form that actually appears in templates. Plaingfand:EjsDefinitionopen the target:ftplugin/ejs.luasetssuffixesaddand anincludeexpr, soinclude('partials/head')resolves toviews/partials/head.ejs— supplying the missing extension and searching the views root, neither of whichgfcan do on its own. -
Include resolution that handles both conventions
File-relative first, matching EJS's own runtime and EJS Colorizer's
includeResolver.ts, then the views root — the nearest ancestor directory namedviews, else<project root>/views, mirroring how Express Map derives it fromapp.set('views', ...). A leading/resolves against the views root only..ejs,.html, and.htmare all tried, andinclude('partials')findspartials/index.ejs. -
Diagnostics
An
include()path matching no file is reported, with a message naming the directories that were searched. A<%# %>comment that ends early is also flagged — the case where the commented-out text itself contains a tag, so EJS's scan to the first%>closes the comment there and the remainder leaks back into the template as markup. Include detection is scoped to the Tree-sittercoderegions, soinclude()written in prose, in an HTML comment, or in a<script>body is left alone; without theembedded_templateparser it falls back to a text scan for<% %>spans rather than reporting nothing. A commented-outinclude()raises no warning, but:EjsDefinitionand completion still follow it. -
Hover documentation on K
Documentation for the delimiter under the cursor. The distinctions worth documenting are the ones not guessable from the syntax:
<%=escapes its output and<%-does not, which is the XSS-relevant difference, and%>,-%>, and_%>differ in what whitespace they consume. Openers are matched longest-first so<%#is never read as<%with a stray#.Kin an EJS buffer already belongs to html-lsp or ts_ls, so the mapping answers only on a delimiter and hands off everywhere else. -
Completion for every major engine
EJS tag delimiters, block scaffolds (
ejsif,ejsfor,ejsinclude,ejspage), andinclude()paths, served to nvim-cmp, blink.cmp (registered automatically via its runtime API on v1.6+), and a complete-function for Neovim 0.12's built-in completion, mini.completion, and coq_nvim — all from one core. This includes the v6<%%literal escape, which was missing from the snippets entirely, and makes the scaffolds available to anyone using a different snippet engine or none. -
Folding for control-flow blocks
Tree-sitter cannot express these:
<% if (x) { %>and<% } %>are two independentdirectivenodes with the HTML between them belonging to neither, so no single node spans the block. Brace depth inside the code regions is what actually delimits it, counted with string, comment, and<%%-escape awareness so<% const s = "}" %>does not unbalance the file. Folds start open and per-line depths are cached perchangedtick. -
Region-aware commentstring
.ejshad nocommentstringat all, sogcreported an empty string rather than commenting anything.ftplugin/ejs.luanow sets<%# %s %>; because Neovim resolves the comment string from the deepest Tree-sitter tree at the cursor, and this plugin already injectshtmlandjavascript,gcproduces<!-- -->in markup and//inside<% %>with no further work — deliberately region-aware rather than always emitting<%# %>, because that is how Neovim behaves in every other embedded language. -
LuaSnip snippets
Snippets for common EJS patterns, registered for the
ejsfiletype, loaded only if LuaSnip is installed. Now includes the closing delimiters-%>,_%>, and the v6%%>literal escape, which were missing from the plugin entirely. LuaSnip is genuinely optional: the completion source offers the same tags and scaffolds independently. -
Health checks and a test suite
:checkhealth ejsverifies your Neovim version, installed Tree-sitter parsers, and language server availability. 54 tests run withnvim -l tests/run.lua, covering completion contexts, include resolution against a real fixture tree, code-region scoping under both backends, diagnostics, and folding — needing no test framework or plugin dependencies.
Prerequisites
All dependencies are optional. The plugin degrades gracefully when any of them are absent.
| Dependency | Purpose | Required |
|---|---|---|
| Neovim >= 0.10 | vim.fs.root() API | Yes |
nvim-treesitter/nvim-treesitter | Parser management; required for CSS highlighting in <style> blocks | Recommended |
html-lsp (vscode-html-language-server) | HTML language server | Optional |
typescript-language-server | JavaScript/TypeScript language server | Optional |
hrsh7th/nvim-cmp | Completion | Optional |
saghen/blink.cmp | Completion (v1.6+ registers automatically) | Optional |
L3MON4D3/LuaSnip | Snippet engine | Optional |
Installation
lazy.nvim
{
"connorontheweb/ejs.nvim",
ft = "ejs",
dependencies = {
"nvim-treesitter/nvim-treesitter", -- optional, recommended
"neovim/nvim-lspconfig", -- optional
"L3MON4D3/LuaSnip", -- optional
},
opts = {},
}
With opts = {}, lazy.nvim calls setup() for you.
vim-plug
Plug 'connorontheweb/ejs.nvim'
lua require('ejs').setup()
packer.nvim
use 'connorontheweb/ejs.nvim'
mini.deps
MiniDeps.add('connorontheweb/ejs.nvim')
require('ejs').setup()
No plugin manager (built-in packages)
mkdir -p ~/.local/share/nvim/site/pack/plugins/start
git clone https://github.com/connorontheweb/ejs.nvim \
~/.local/share/nvim/site/pack/plugins/start/ejs.nvim
Then add require('ejs').setup() to your init.lua (or lua require('ejs').setup() in init.vim).
Post-install: install the Tree-sitter parsers
With nvim-treesitter installed, run inside Neovim:
:TSInstall embedded_template html css
embedded_template parses the EJS structure, html handles content between EJS tags, and css handles CSS inside <style> blocks injected via the HTML parser's own injection queries.
Configuration
All options default as shown. Pass overrides to require('ejs').setup(), or via opts if using lazy.nvim:
require('ejs').setup({
treesitter = true, -- register the embedded_template parser for .ejs files
lsp = true, -- attach html-lsp and ts_ls on FileType ejs
snippets = true, -- load LuaSnip snippets (silently skipped if LuaSnip absent)
diagnostics = true, -- unresolved include() paths and early-closing comments
folding = true, -- fold control-flow blocks (folds start open)
hover = true, -- K documents the delimiter under the cursor
completion = {
cmp = true, -- register the nvim-cmp source
blink = true, -- register the blink.cmp source (v1.6+)
omni = false, -- complete-function for built-in/mini.completion/coq_nvim
snippets = true, -- accepting `<%=` inserts `<%= | %>`
},
})
snippets now controls LuaSnip only; the completion table controls the completion source, which offers the same tags and scaffolds independently. Each completion adapter is skipped silently when its engine is absent.
Commands
:EjsHealth and :EjsDefinition (open the include() target under the cursor; plain gf works too).
Health Checks
Run :checkhealth ejs to diagnose your setup:
| Check | Pass condition | Failure level |
|---|---|---|
| Neovim version | >= 0.10 | Error |
embedded_template parser | Installed via :TSInstall | Error |
html parser | Installed via :TSInstall | Warning |
css parser | Installed via :TSInstall | Warning |
vscode-html-language-server | Found on $PATH | Warning |
typescript-language-server | Found on $PATH | Warning |
| LuaSnip | Installed and loadable | Warning |
How It Works
queries/embedded_template/injections.scm injects html into (content) nodes (text outside <% %> tags) and javascript into (code) nodes that are children of (directive) or (output_directive) nodes.
The HTML injection uses #set! injection.combined so all (content) fragments are merged into a single virtual HTML document before parsing. This is required for correctness: without it, each fragment is parsed independently, and fragments that start mid-document (after a </script> tag, for example) cause the HTML parser to immediately enter error recovery and produce only ERROR nodes. With injection.combined the HTML parser sees a coherent document, produces proper element nodes including style_element, and its own injection queries fire correctly to inject CSS inside <style> blocks. JavaScript injections do not use injection.combined: each (code) block is parsed as an independent JS fragment, since concatenating disconnected scriptlet blocks rarely produces valid JavaScript.
On the LSP side, ts_ls is attached with an on_attach callback that disables the documentHighlightProvider capability for the client. Without this, Neovim sends textDocument/documentHighlight to ts_ls whenever the cursor rests on an HTML node, which the server cannot handle and returns a -32603 error. A buffer-local CursorHold autocommand replaces the default dispatch: it uses vim.treesitter.get_node() to check whether the cursor is inside a (code) node and only sends the request to ts_ls when it is, clearing stale highlights when the cursor moves back into HTML content.