EJS Template Support for Neovim
ejs.nvim is a free, open source Neovim plugin that brings first-class EJS (Embedded JavaScript) template support to Neovim. It is available now on GitHub and dotfyle.
The State of EJS Tooling in Neovim
EJS is widely used as the templating layer in Express.js applications, but Neovim has no built-in filetype definition or Tree-sitter parser for it. Out of the box, .ejs files open as plaintext. Aliasing the filetype to HTML is the most common workaround, but it drops all JavaScript highlighting inside EJS tags. The htmldjango parser is another rough substitute, but it does not understand EJS tag syntax and produces incorrect tokenization at tag boundaries.
ejs.nvim does not ship a custom parser. Instead, it wires up the existing tree-sitter-embedded-template grammar, which is purpose-built for the <% %> tag structure shared by EJS and ERB. Combined with Neovim’s language injection system, this gives .ejs files accurate, parser-backed highlighting for both their HTML and JavaScript content without duplicating work the ecosystem has already done.
Features
Tree-sitter Syntax Highlighting
ejs.nvim registers embedded_template as the Tree-sitter parser for the ejs filetype and ships an injection query file at queries/ejs/injections.scm. The injection query injects html into (content) nodes (the text outside <% %> tags) and javascript into (code) nodes that are children of (directive) or (output_directive) nodes. The result is that HTML receives full HTML highlighting and EJS tag content receives full JavaScript highlighting, both driven by their respective parsers.
The HTML injection uses #set! injection.combined, which merges all (content) fragments into a single virtual HTML document before parsing. Without this, each content fragment is parsed independently. Fragments that begin mid-document, such as those following a closing </script> or </style> tag, cause the HTML parser to enter error recovery and produce only ERROR nodes. With injection.combined the HTML parser sees a coherent document, produces proper element nodes, 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 JavaScript fragment, since concatenating disconnected scriptlet blocks rarely produces valid JavaScript.
LSP Attachment
On FileType ejs, ejs.nvim starts two language servers:
| Server | Handles |
|---|---|
vscode-html-language-server (html-lsp) | HTML content between EJS tags |
typescript-language-server (ts_ls) | JavaScript content inside EJS tags |
Both clients include duplicate-attachment guards that call vim.lsp.get_clients() before starting, so reopening an EJS buffer does not spawn redundant server processes.
ts_ls is attached with an on_attach callback that disables the documentHighlightProvider capability for the client. Without this, Neovim sends textDocument/documentHighlight requests 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 sends the request only when it is, clearing stale highlights when the cursor moves back into HTML content.
Root directory resolution uses vim.fs.root(bufnr, { 'package.json', '.git' }), falling back to vim.fn.getcwd() when no root marker is found.
LuaSnip Snippets
Snippets are registered for the ejs filetype and loaded only if LuaSnip is installed. The snippets module is wrapped in pcalland returns silently if LuaSnip is absent, producing no errors. All snippet triggers use tab stops for jumping between editable fields.
| Trigger | Expands to |
|---|---|
<%= | <%= expression %> |
<%- | <%- expression %> |
<% | <% code %> |
<%# | <%# comment %> |
ejsinclude | <%- include('path/to/partial', { }) %> |
ejsfor | forEach loop scaffold with opening and closing scriptlets |
ejsif | if / else block scaffold |
ejspage | Full HTML5 document scaffold with <html>, <head>, and <body> |
Health Checks
:checkhealth ejs verifies the full setup and reports the status of each component:
| 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 |
Warnings mean the corresponding feature is unavailable but the rest of the plugin still works. Errors indicate a required component is missing.
Graceful Degradation
All dependencies beyond Neovim itself are optional. A minimal installation with no tree-sitter, no language servers, and no LuaSnip still registers the ejs filetype and provides a foundation for any additional tools the user already has configured. Each feature module checks for its dependency before activating and skips silently if it is absent.
How It Works
Filetype detection is handled by ftdetect/ejs.lua, which calls vim.filetype.add({ extension = { ejs = 'ejs' } }) at startup. This runs before any plugin initialization and ensures the ejs filetype is registered even if the plugin itself is loaded lazily.
lua/ejs/treesitter.lua calls vim.treesitter.language.register('embedded_template', 'ejs'), which tells Neovim’s Tree-sitter integration to use the embedded_template parser for any buffer with filetype ejs. The injection queries in queries/ejs/injections.scm are picked up automatically by Neovim’s query resolution once the parser is registered.
The plugin exposes require('ejs').setup(opts) as its single entry point. With lazy.nvim, passing opts = {} calls setup()automatically.
Installation
lazy.nvim
{
"connorontheweb/ejs.nvim",
ft = "ejs",
dependencies = {
"nvim-treesitter/nvim-treesitter", -- optional, recommended
"neovim/nvim-lspconfig", -- optional
"L3MON4D3/LuaSnip", -- optional
},
opts = {},
}
After installation, run :TSInstall embedded_template html css inside Neovim to install the required Tree-sitter parsers. This only needs to be done once.
Other plugin managers are also supported. See the full installation documentation for vim-plug, packer.nvim, mini.deps, and the built-in packages approach.
LSP support requires html-lsp and typescript-language-server on $PATH. Both can be installed via npm:
npm install -g vscode-langservers-extracted typescript typescript-language-server
Source code is available at github.com/ConnorOnTheWeb/ejs.nvim.