VS Code language support for EJS templates: real TypeScript-powered
intelligence inside <% %> tags, on top of standard HTML tooling for everything else.
-
Syntax highlighting for EJS tags (
<% %>,<%= %>,<%- %>,<%# %>, and the whitespace-trimming<%_,-%>,_%>) that survives nesting. HTML inside a block —<% if (…) { %>…<% } %>,forEachbodies, any depth of either — stays HTML, and tags are highlighted wherever they appear: in attribute values, in place of attributes, inside<script>andstyle="…". Non-HTML templates are handled too:sitemap.xml.ejsis coloured as markup, andtheme.css.ejsas a stylesheet. See Highlighting. -
Semantic highlighting — the type checker colours identifiers inside tags, so a callback parameter, an inferred local, and a method call are told apart.
-
Completions, hover, go-to-definition, and signature help inside scriptlets and expressions, powered by the real TypeScript language service.
-
Diagnostics — type errors inside
<% %>blocks are reported as you type. -
Inferred
locals— no configuration and no per-template directive. The server scans your workspace forres.render()calls and infers each template's locals from the object you pass:// routes/payments.ts res.render("payments.ejs", { products: payments, // Payment[] site, // Site page: "Payment Listing", // string });
<%# views/payments.ejs — products, site and page are typed here %> <h1><%= page %></h1> <% for (const p of products) { %><li><%= p.amount %></li><% } %>
Values assigned to
res.localsandapp.localsare picked up too. Cross-file types resolve properly, soproducts[0].completes against your realPaymentinterface. -
Middleware locals — locals injected at runtime (
app.use(i18n.init)) never appear as an assignment, but they are usually declared. AugmentExpress.Localsonce and they are in scope in every template:declare global { namespace Express { interface Locals extends i18nAPI { } } }
Only explicitly declared members are used, so an un-augmented project is unaffected.
ejs.renderFile("templates/default/invoice.xml.ejs", { … })works the same way. -
Partials inherit their locals — a template that is only ever
included is typed from the templates that include it, since EJS passes the parent's locals down:<%# views/page.ejs — rendered with { user, site } %> <%- include('layout/header.ejs', { active: 'home' }) %>
layout/header.ejsgetsuser,siteandactivewithout being rendered directly. A key every includer provides is required; one only some provide is optional. Ctrl+click an include path to open it, and get path completion inside the quotes. -
HTML editing in the surrounding markup, delegated to the same language service VS Code uses for
.html— completion, hover, Emmet, and the editing features a template would otherwise go without: auto-closing tags, linked editing (rename an opening tag and its closing tag follows), tag folding, outline and breadcrumbs, matching-tag highlight, expand-selection, and clickablehref/srclinks.These matter here because VS Code's built-in HTML support activates on
htmlandhandlebarsonly, so none of it reaches a.ejsbuffer. They work through EJS control flow —<ul>and</ul>still pair when an<% if %>sits between them — and they stay out of the way inside a tag: the>in<% if (a > b) { %>does not insert a closing tag. See Highlighting for how. -
JavaScript and CSS inside the page —
<script>bodies andon*handlers get completion, hover, go-to-definition and type checking against the browser libs, and<style>blocks andstyle="…"attributes get CSS completion, hover, validation and colour swatches. The EJS tags woven through them do not produce false errors. See Embedded script and style. -
Stylesheet templates — a
.css.ejsfile is CSS all the way through, so it gets the CSS language service over the whole document: highlighting, completion, hover, validation, colour swatches, outline, folding, expand-selection and go-to-definition on custom properties. Everything inside<% %>works exactly as it does in a.ejsfile, inferredlocalsincluded. See Stylesheet templates.
- Node.js 20+
- VS Code 1.85+
Colouring comes from two layers, and the split is deliberate — the obvious single-layer approach does not work.
A template is not always HTML, and the three cases are not handled the same way:
| File | Language id | Grammar root |
|---|---|---|
page.ejs |
ejs |
text.html.derivative |
sitemap.xml.ejs |
ejs |
text.html.derivative |
theme.css.ejs |
ejs-css |
source.css |
.xml.ejs needs nothing of its own. ejs.tmLanguage.json includes text.html.derivative
rather than text.html.basic precisely so that element names outside the HTML vocabulary —
<urlset>, <loc>, any custom element — are not scoped
invalid.illegal.unrecognized-tag.html and painted red. That is VS Code's own answer to the
problem, used by PHP and Handlebars, and XML is close enough to HTML in shape that one
grammar and one language service serve both.
A stylesheet is not markup at any level, so .css.ejs gets its own grammar root, which means
its own scope name, which means its own language id. The EJS tag rules are shared: the
injection names both roots, and one set of rules can serve both because nothing inside a tag
can push a rule frame that outlives its %> — see below.
The grammar (syntaxes/) owns structure. A TextMate grammar is a stack machine that
only ever tests the top of its stack, so a grammar that embeds VS Code's source.js
inside <% … %> breaks the moment a tag opens a block: { pushes a JavaScript block
frame, the tag's own %> rule is never reached again, and the rest of the file is
tokenized as JavaScript — as JSX, in fact, since source.js is JSX-capable. That is why
ejs-injection.tmLanguage.json uses a curated subset of JavaScript in which nothing can
push a frame that outlives the tag, and why it is an injection: rules listed in the base
grammar are unreachable once HTML has pushed a rule of its own, which is what left
title="<%= x %>" uncoloured. Both files carry the full reasoning in their headers.
The trade-off is that the grammar can only colour by shape. It does not resolve names, so every bare identifier looks alike to it.
Semantic tokens (packages/language-server/src/semantic-tokens.ts) supply what the
grammar gave up. TypeScript classifies the virtual document, and each span is mapped back
to its place in the template — so log in forEach(log => …) is a parameter, auditLogs
an inferred local, moment(…) a function call. Themes that opt out of semantic
highlighting still get the grammar's colouring; nothing depends on the server being up.
packages/vscode-extension/tests/grammar.test.ts and grammar-css.test.ts run the real
grammars through the same tokenizer VS Code uses, so a regression in any of this fails in CI
rather than in an editor. tests/brackets.test.ts covers the other half of colouring — the
bracket pairing that decides whether a { renders gold or red, which is language
configuration rather than grammar — and runs over both configurations, since both make the
same trade.
The HTML editing features run against a virtual copy of the template in which every EJS
tag is overwritten with spaces (generateVirtualHtml). Newlines survive, and so does
every offset — the blanked document is exactly as long as the original — which is why
ranges come back from the HTML service usable as they are, with no mapping step.
Blanking is what makes the features safe inside a tag as well as outside it. <% if (a > b) { %> is whitespace in the virtual document, so the scanner finds no markup there and
tag completion declines instead of guessing. The one place this needs help is document
links: href="/users/<%= id %>/" blanks to a path that exists in no checkout, so links
overlapping a tag are dropped rather than offered as links that always fail to open.
The same trick, twice more. Two further documents are built from the virtual HTML, so
the EJS tags are already blanked: one holding the template's JavaScript (every <script>
body and every on* handler, together, because they share one browser global scope) and
one holding its CSS (every <style> body and every style="…" attribute). Everything
outside the target language is blanked to spaces, so these documents are the same length
as the template and every range still comes back unmapped.
Where a value needs wrapping, the wrapper is written into the blanked space around it
rather than inserted. style="color: red" becomes __{color: red} — __{
overwrites e=" and } overwrites the closing quote — and onclick="f()" becomes
;(()=>{f()}). Nothing moves, so there is still no mapping code anywhere. An attribute
whose name is too short to hold its prefix is skipped rather than shifted.
Tags inside the code need two different answers, because JavaScript has a universal filler and CSS does not:
- JavaScript: an expression tag becomes the identifier
__ejs, padded to the tag's own width. Five characters is exactly the narrowest tag (<%=%>), so it always fits, andvar rows = <%- JSON.stringify(x) %>;parses instead of collapsing tovar rows = ;. Scriptlets stay blank — dropping both<% if (a) { %>and its<% } %>is what keeps the braces balanced. - CSS: a tag standing where a value goes gets the same
__ejsfiller, becausebackground-color: <%= brand %>blanked tobackground-color:is a parse error the CSS parser reports on the next line — usually the closing}, which carries no tag and so survives the filter below. Anywhere else a tag stays blank: as a whole declaration (.a { <%= extraCss %> }) or a selector it parses cleanly blank and would become an error as a bare identifier. What still sits next to a tag is dropped: any diagnostic sharing a line with one goes, the same judgement document links already make abouthref="/users/<%= id %>/".
<script> is checked by a second TypeScript service with its own lib: a template renders
on the server and must not see document, while an inline script is browser code and is
meaningless without it. Its virtual file is .js, so const total: Number reports "type
annotations can only be used in TypeScript files" — the right thing to say about a browser
script. Unknown names are never reported there, because inline scripts routinely reach for
$, for globals defined in another <script src>, and for analytics shims.
Known limits:
- A tag pair that splits a declaration across branches
(
<% if (a) { %>var x=1<% } else { %>var x=2<% } %>) emits both, so TypeScript reports a duplicate declaration. <script type="module">is checked as a script, so a top-levelimportreports an error.- A
<style>block assembled from tags gets no useful validation — the line filter suppresses it rather than reporting nonsense. - A genuine typo on a browser global goes unflagged, which is the cost of the line above.
- The client service inherits the workspace's
@typesthe same way the template service does, soprocessresolves inside a browser<script>. That is the permissive direction;types: []would also cut off browser typings the project legitimately installs.
A .css.ejs file is the same trick with the host language removed. There is no markup to
extract the CSS from, so the virtual CSS is simply the template with every EJS tag blanked
— the same document generateVirtualHtml builds for a .ejs file, handed to the CSS
service instead of the HTML one. Offsets are identical, so nothing is mapped here either.
The server decides which it is from the file name rather than from the client's language id,
and models a stylesheet as a single embedded CSS region spanning the document. That is why
the change is small: the dispatch that already sends a <style> block to the CSS service
sends the whole file to it, unchanged. The markup-only features step aside — there is no
href to link and no tag pair to keep in step — and typing > inserts nothing.
Everything inside <% %> is untouched by any of this. The virtual TypeScript document is
built from the tags alone, so completions, hover, diagnostics, semantic tokens, include()
navigation and inferred locals behave exactly as they do in a .ejs file:
res.render("styles/theme.css.ejs", { brand }) types brand inside the stylesheet.
Known limits:
- CSS validation is suppressed on any line carrying a tag, since what the validator sees
there is our rewriting rather than the template. A typo on a tag-free line still reports —
including a genuinely missing
;after a declaration whose value is a tag, which is reported on the line that follows it. - A tag concatenated onto a selector fragment (
.a<%= suffix %>) is mis-coloured. VS Code's own CSS grammar scopes an identifier followed by<as a bad identifier, and the tag comes apart inside it. The damage stops at the end of the line, and a tag that is the whole selector (<%= sel %> { … }) is fine. - A ruleset split across
<% if %>branches — an opening{in one arm and its}in another — highlights and folds as the unbalanced CSS it literally is. .scss.ejsand.less.ejsare not handled; they open as plain.ejs.
This is an npm workspaces monorepo:
packages/core— EJS parsing and the virtual TypeScript document generator.packages/language-server— the LSP server (completions, hover, diagnostics), the workspace index that findsres.render()calls, and the locals type inference.packages/vscode-extension— the VS Code client, grammar, and language configuration.
| Setting | Default | Purpose |
|---|---|---|
ejs.languageServer.enable |
true |
Enable the language server. |
ejs.autoClosingTags |
true |
Insert the closing tag when you finish typing an opening HTML tag. |
ejs.inference.enable |
true |
Infer template locals from res.render() / res.locals / app.locals. |
ejs.inference.exclude |
node_modules, dist, build, out, coverage, .git |
Globs skipped when scanning for render calls. |
ejs.inference.maxFiles |
3000 |
Upper bound on files scanned. |
ejs.viewsDirs |
[] |
Extra views-root directory names beyond views/, view/ and templates/. |
res.render("admin/users") matches any template whose path ends with
admin/users.ejs, so no views directory needs to be configured. Matching never peels
past a views root, which keeps res.render("users") from matching
views/admin/users.ejs.
When several routes render the same template, keys present in every call are
required and keys present in only some are optional. app.locals keys are required;
res.locals keys are optional, since which middleware ran for a given route cannot be
determined statically.
Templates that nothing renders (dynamic view names, other frameworks) simply get no locals — and their unknown identifiers are not reported as errors, so they stay quiet rather than turning red.
npm install # from the repo root, installs all workspaces
npm run typecheck # tsc --noEmit across every package
npm test # core, language-server and grammar test suitesTo try the extension itself:
- Open
packages/vscode-extensionas your VS Code workspace folder. - Press F5 to launch an Extension Development Host.
- Open
test/views/example.ejsand try completions/hover inside a<% %>block. Its locals come from theres.render()call intest/routes/example.ts— edit that object and the template updates on save.test/pagination.ejsis the probe for<script>/<style>, andtest/theme.css.ejsthe one for stylesheet templates — its status bar must read EJS CSS, which is the only place the file-extension binding can be observed.
While iterating, run the bundler in watch mode so the Development Host picks up changes on reload:
npm run watch --workspace=packages/vscode-extensionnpm run package --workspace=packages/vscode-extensionThis type-checks, produces a production build (dist/extension.js + the bundled
language server in dist/server.js), and packages everything into
packages/vscode-extension/dist/ejs-language-support.vsix.
To install the result into your local VS Code:
npm run code:install --workspace=packages/vscode-extensionReleases are also built automatically in CI — pushing a version bump in
packages/vscode-extension/package.json to main produces a GitHub Release with the
.vsix attached (see .github/workflows/release.yml).