Skip to content
 
 

Repository files navigation

EJS Language Support

VS Code language support for EJS templates: real TypeScript-powered intelligence inside <% %> tags, on top of standard HTML tooling for everything else.

Features

  • Syntax highlighting for EJS tags (<% %>, <%= %>, <%- %>, <%# %>, and the whitespace-trimming <%_, -%>, _%>) that survives nesting. HTML inside a block — <% if (…) { %><% } %>, forEach bodies, any depth of either — stays HTML, and tags are highlighted wherever they appear: in attribute values, in place of attributes, inside <script> and style="…". Non-HTML templates are handled too: sitemap.xml.ejs is coloured as markup, and theme.css.ejs as 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 for res.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.locals and app.locals are picked up too. Cross-file types resolve properly, so products[0]. completes against your real Payment interface.

  • Middleware locals — locals injected at runtime (app.use(i18n.init)) never appear as an assignment, but they are usually declared. Augment Express.Locals once 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.ejs gets user, site and active without 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 clickable href/src links.

    These matter here because VS Code's built-in HTML support activates on html and handlebars only, so none of it reaches a .ejs buffer. 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 and on* handlers get completion, hover, go-to-definition and type checking against the browser libs, and <style> blocks and style="…" 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.ejs file 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 .ejs file, inferred locals included. See Stylesheet templates.

Requirements

  • Node.js 20+
  • VS Code 1.85+

Highlighting

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.

HTML features and the virtual document

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.

Embedded script and style

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, and var rows = <%- JSON.stringify(x) %>; parses instead of collapsing to var 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 __ejs filler, because background-color: <%= brand %> blanked to background-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 about href="/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-level import reports 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 @types the same way the template service does, so process resolves inside a browser <script>. That is the permissive direction; types: [] would also cut off browser typings the project legitimately installs.

Stylesheet templates

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.ejs and .less.ejs are not handled; they open as plain .ejs.

Project layout

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 finds res.render() calls, and the locals type inference.
  • packages/vscode-extension — the VS Code client, grammar, and language configuration.

Settings

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/.

How a template is matched to a render call

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.

Development

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 suites

To try the extension itself:

  1. Open packages/vscode-extension as your VS Code workspace folder.
  2. Press F5 to launch an Extension Development Host.
  3. Open test/views/example.ejs and try completions/hover inside a <% %> block. Its locals come from the res.render() call in test/routes/example.ts — edit that object and the template updates on save. test/pagination.ejs is the probe for <script>/<style>, and test/theme.css.ejs the 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-extension

Building a VSIX

npm run package --workspace=packages/vscode-extension

This 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-extension

Releases 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).

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages