alpinejs.nvim icon

alpinejs.nvim

Alpine.js Highlighting and Completion for Neovim

alpinejs.nvim is a free, open source Neovim plugin that brings Alpine.js developer support to Neovim: directive and magic-property highlighting, completion for every major engine, hover documentation, unknown-directive diagnostics, go to definition, and VS Code-format snippets. It covers thirteen markup languages plus JSX and TSX: HTML, EJS, PHP, Twig, Nunjucks, Blade, Liquid, Jinja2, Handlebars, Astro, templ, Go html/template, and Tera. That is the same ground Alpine.js Tools covers, the VS Code extension this plugin is based on, feature for feature and language for language.

All dependencies beyond Neovim itself are optional. The plugin degrades gracefully when nvim-treesitter, a completion engine, or LuaSnip are absent.

Features

  • Directive and magic-property highlighting

    Highlighting for Alpine directives (x-data, x-on:click, :class, @click), shorthand forms (:attr, @event), .modifier.chains, and the inline JS expressions inside directive values, including magic properties ($el, $refs, $store, ...).

  • Two highlighting paths, chosen automatically per buffer

    Tree-sitter (primary): query additions in queries/html/ and queries/javascript/ layer onto whatever html/javascript parse tree is already active — including .ejs buffers whose HTML content is injected via ejs.nvim's embedded_template -> html injection, with no .ejs-specific code. Legacy :syntax regex (fallback): used on buffers where Tree-sitter highlighting isn't active, so users without nvim-treesitter still get highlighting.

  • Astro and JSX/TSX

    .astro files are treated as markup, since an Astro template is markup and Astro passes @click and :class through to the HTML — and Astro's own client:load, transition:animate, and set:html are untouched, because a : shorthand only counts when the colon starts the attribute name. JSX and TSX get their own Tree-sitter queries written against (jsx_attribute (property_identifier)) and (jsx_attribute (jsx_namespace_name)), since neither shares the html grammar's node types. Enable or opt out via the jsx_filetypes option.

  • Long forms only in JSX, verified against the TypeScript compiler

    @click and :class are TS1003 syntax errors in JSX. Running tsc against modifier chains found the same for x-on:click.prevent and x-model.lazy, which the VS Code extension had not documented. Completion therefore offers no shorthands and no modifiers in JSX, and a diagnostic explains each — tsserver reports only "Identifier expected" pointing at the @, which says nothing about Alpine or about what to write instead.

  • templ, Go templates, and Tera

    .templ files ride the templ parser, whose queries are verbatim copies of the html pair because tree-sitter-templ emits exactly the node shape the html queries are written against. The parse tree also makes the expensive half of the VS Code work unnecessary on that path: width<max && offset>0 in the surrounding Go parses as a binary expression and a Go raw string parses as a raw string literal, so neither can reach an attribute name — the region restriction is structural rather than scanned for. The text scanners did need it, though, since completion, diagnostics, and hover all run over line text: a mask now blanks everything outside a templ { } block, counting brace depth outside strings and comments so the braces in x-data="{ open: false }" neither open nor close one.

    Go's html/template and text/template come through the template filetype Neovim already uses for .tmpl, plus gohtmltmpl and gotmpl. Tera (.tera, .html.tera) covers the Rust half; Askama and MiniJinja need nothing, since their templates are .html or .jinja and both were already covered. New injection queries give the Go and Tera families a Tree-sitter path for the first time — both parsers model markup as opaque text and ship no html injection, so a combined html injection over those text nodes reassembles the fragments either side of a {{ if }} into one well-formed tree, at which point every existing html query applies inside it.

  • Handlebars, where Neovim needed the opposite of VS Code

    Handlebars is the one language that is easier upstream: in VS Code it needs nothing installed, and in Neovim it is the only supported filetype where neither path works as shipped. Its parser is glimmer, which implements Ember's strict dialect — a bare { in an attribute value is a parse error there, so x-data="{ open: false }" collapses the tag to an ERROR node that highlights nothing, and block helpers in attribute position, {{{triple}}}, and {{> partial}} fail the same way. Neovim's bundled syntax/handlebars.vim then scopes a tag as hbsComponent and never defines htmlTag, so the regex fallback had nothing to attach to either. Anchoring the fallback to containedin=htmlTag,hbsComponent restores the entire feature set.

    force_syntax_filetypes defaults to { 'handlebars' } for the same reason. The fallback is normally skipped whenever Tree-sitter highlighting is active, on the reasoning that the queries are carrying it; for Handlebars that reasoning inverts, since a user who installs glimmer would otherwise get an ERROR tree that highlights nothing and no fallback — worse than either path alone. :checkhealth alpinejs reports the situation rather than leaving it to be discovered.

  • Per-check diagnostic severities

    diagnostics is a table of per-check severities — error, warn, info, hint, or off — with hint the useful one: the check keeps running and :AlpineFix still works while the entry stays out of your location list. The two checks are separate because they fail differently: unknown_directive is the heuristic, while jsx_shorthand reports a hard TS1003 syntax error in terms that mention Alpine. extra_directives exempts third-party plugin directives from the warning and adds them to the "did you mean" candidates, accepting x-clipboard, clipboard, and x-clipboard:copy alike. workspace_exclude adds directory names to the scan's skip list. The older diagnostics = true|false spelling still works.

  • Configurable filetype list

    Defaults to the thirteen markup languages Alpine.js Tools supports, mapped to 16 filetype strings, plus a separate jsx_filetypes list for javascript, javascriptreact, and typescriptreact. Both are correctable via setup(). filetype_detection (off by default) registers the six extensions Neovim misses — .handlebars, .hjs, .gohtml, .gotmpl, .gtpl, .j2 — and it is off because deciding what language a file is belongs to your config rather than to a plugin that happens to support that language.

  • Completion for every major engine

    One engine-agnostic core feeds adapters for nvim-cmp, blink.cmp (registered automatically via its runtime API on v1.6+, appending to a per-filetype list so it cannot displace sources you configured yourself), and a complete-function covering Neovim 0.12's built-in completion, mini.completion, and coq_nvim. Directive names, modifier chains after a ., magic properties, $refs. from the document's x-ref attributes, $store. and x-data="component" from a lazy workspace index, and directive-value properties from the nearest x-data object literal. Each adapter is skipped silently when its engine is absent.

  • Hover, diagnostics, and go to definition

    K shows the type signature and docs link for the directive, shorthand, modifier, or magic property under the cursor, falling through to vim.lsp.buf.hover() or keywordprg everywhere else so it never takes the key from your language server. Unknown x-* attributes get a warning with a "did you mean" suggestion, applied by :AlpineFix. :AlpineDefinition jumps to the Alpine.data() or Alpine.store() registration, backed by a lazy workspace index that skips node_modules, dist, and vendor and never parses a file that does not contain the string Alpine.

  • Front matter is skipped

    A --- fenced block at the top of a file is front matter in every ecosystem that uses one, and it is never markup. The rule is keyed on the fence rather than the language, so Astro's TypeScript frontmatter and YAML front matter in Jekyll, Hugo, and Eleventy .html/.liquid templates are all skipped. The closing fence must be a line containing exactly ---, so a stray --- cannot blank out a file.

  • Snippets

    46 snippets shipped across all sixteen markup filetypes in the same package.json + snippets/*.json layout as rafamadriz/friendly-snippets, loaded by LuaSnip's own VS Code loader, with no LuaSnip dependency in this plugin's code at all. The same forms are also served by the completion source, so they work in every engine regardless of snippet plugin.

  • Health checks and a test suite

    :checkhealth alpinejs (or :AlpineHealth) diagnoses your setup. 160 tests run with nvim -l tests/run.lua, needing no test framework or plugin dependencies. The templ region tests are written to be non-vacuous: the suite rescans the same buffer as html and asserts the bogus range does appear without the gating, so a negative assertion cannot pass with the feature deleted.

Prerequisites

All dependencies are optional. The plugin degrades gracefully when any of them are absent.

Dependency Purpose Required
Neovim >= 0.9vim.treesitter.query.add_directive()Yes
nvim-treesitter/nvim-treesitterhtml/javascript parsers, for the primary highlighting path (plus astro, tsx for those languages)Recommended
hrsh7th/nvim-cmpCompletionOptional
saghen/blink.cmpCompletion (v1.6+ registers automatically)Optional
L3MON4D3/LuaSnipSnippet engineOptional

Installation

lazy.nvim

{
  "connorontheweb/alpinejs.nvim",
  ft = {
    "html", "ejs", "php", "twig", "blade", "liquid",
    "jinja", "htmldjango", "nunjucks", "astro",
    "templ", "template", "gohtmltmpl", "gotmpl", "tera", "handlebars",
    "javascript", "javascriptreact", "typescriptreact",
  },
  dependencies = {
    "nvim-treesitter/nvim-treesitter", -- optional, recommended
    "hrsh7th/nvim-cmp",                -- optional
    "L3MON4D3/LuaSnip",                -- optional
  },
  opts = {},
}

With opts = {}, lazy.nvim calls setup() for you. If the default filetype list already matches your setup, you can omit opts entirely — plugin/alpinejs.lua bootstraps setup() with defaults the first time any default filetype is opened.

No plugin manager (built-in packages)

mkdir -p ~/.local/share/nvim/site/pack/plugins/start
git clone https://github.com/connorontheweb/alpinejs.nvim \
  ~/.local/share/nvim/site/pack/plugins/start/alpinejs.nvim

vim-plug

Plug 'connorontheweb/alpinejs.nvim'

packer.nvim

use 'connorontheweb/alpinejs.nvim'

mini.deps

MiniDeps.add('connorontheweb/alpinejs.nvim')
require('alpinejs').setup()

Post-install: install the Tree-sitter parsers (recommended)

:TSInstall html javascript astro tsx templ

Without these, the plugin falls back to the legacy :syntax regex highlighter automatically, so nothing needs to be configured differently either way.

Configuration

All options default as shown. Pass overrides to require('alpinejs').setup(), or via opts if using lazy.nvim:

require('alpinejs').setup({
  -- markup buffers to activate Alpine support on; see config.lua for the
  -- language -> filetype-string mapping this list assumes
  filetypes = {
    'html', 'ejs', 'php', 'twig', 'blade', 'liquid',
    'jinja', 'htmldjango', 'nunjucks', 'astro',
    'templ', 'template', 'gohtmltmpl', 'gotmpl', 'tera', 'handlebars',
  },

  -- JavaScript buffers that may contain JSX; set to {} to opt out
  jsx_filetypes = { 'javascript', 'javascriptreact', 'typescriptreact' },

  highlight = true,  -- enable the :syntax regex fallback path
  hover     = true,  -- K shows docs, falling through to LSP elsewhere
  workspace = true,  -- lazy index of Alpine.data()/Alpine.store() registrations

  -- Per-check severity: 'error' | 'warn' | 'info' | 'hint' | 'off'.
  -- `hint` keeps the check running and :AlpineFix working while keeping
  -- the entry out of your location list. `diagnostics = true|false` still works.
  diagnostics = {
    unknown_directive = 'warn',
    jsx_shorthand     = 'warn',
  },

  extra_directives = {},  -- third-party plugin directives, exempt from the warning
  workspace_exclude = {}, -- directory names the workspace scan skips

  -- Buffers where the :syntax fallback runs even with Tree-sitter active
  force_syntax_filetypes = { 'handlebars' },

  filetype_detection = false, -- register the extensions Neovim misses

  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,   -- insert x-data="{ | }" rather than the bare label
  },
})

Each completion adapter is skipped silently when its engine is not installed. The pre-1.1.0 top-level cmp spelling is still honoured.

Commands

:AlpineHealth, :AlpineFix (apply the suggested directive under the cursor), :AlpineDefinition (jump to the Alpine.data() / Alpine.store() registration), and :AlpineRescan (rebuild the workspace index).

Health Checks

Run :checkhealth alpinejs (or :AlpineHealth) to diagnose your setup: Neovim version, active filetype list, whether the html Tree-sitter parser is available, whether your completion engines and LuaSnip are installed, and, for every configured filetype other than HTML, which of the two highlighting paths will actually activate for it. Reaching the workspace file cap is reported here rather than quietly returning partial results.

The per-filetype probe used to open a scratch buffer, set its filetype, and read synID() back — which works in isolation and is unreliable in a loop, because syntax state leaks between successive probes, so from the third filetype onward it reported whatever the second one had loaded. It now uses a measured table plus deterministic runtimepath checks, with the anchors measured one process per filetype, which is the only way to rule that leakage out. Two corrections fell out of measuring rather than assuming: astro does have a regex fallback (Neovim ships syntax/astro.vim), and jinja ships a syntax file that never sources html.vim, so that filetype has no working fallback. Both are now reported honestly, and filetypes with neither a syntax script nor a parser are collected into one line instead of a warning each — taking a default install from thirteen warnings to two.

How It Works

The Tree-sitter path is purely declarative: query files Neovim's loader merges across the whole runtimepath whenever a buffer already has Tree-sitter highlighting active for html or javascript. Every styled capture (@alpinejs.directive, @alpinejs.modifier, @alpinejs.shorthand, @alpinejs.magic) sets an explicit priority of 110 via (#set! @capture "priority" 110), and the unstyled anchor captures set 90. This isn't decorative: when multiple captures tie on Tree-sitter's default priority of 100 at the exact same range, Neovim renders whichever capture comes last in the merged query text — an order that depends on runtimepath plugin load order and can't be controlled or predicted. Without the explicit priorities, unstyled anchor captures could end up ordered after the styled ones and silently blank them out instead of layering underneath, which is exactly what happened before the 1.0.1 fix.

Because .ejs buffers parsed via ejs.nvim's embedded_template parser inject a genuine, separate html language tree for their HTML content, these same query files apply there too automatically, with no .ejs-specific code anywhere in this plugin.

The legacy :syntax fallback layers :syntax match/region rules into the existing htmlTag region via containedin=, reusing the real syntax/javascript.vim grammar for directive values via :syntax include. It's only applied to a buffer if Tree-sitter highlighting isn't (and doesn't become) active for that buffer. containedin=htmlTag is a group-name hook, not a filetype-specific one, so it activates for any filetype whose syntax happens to define htmlTag, with no per-language code in this plugin — verified directly for PHP, htmldjango, and liquid.

Three classes of bug the VS Code extension had to fix cannot arise on the highlighting paths, and the test suite asserts each rather than assuming it. Livewire's wire:model being read as the : shorthand, TextMate grammar scope targeting, and unknown-directive warnings on ordinary English are all consequences of matching Alpine syntax with regexes across an entire document. Both highlighting paths here are anchored to attribute positions — Tree-sitter matches (attribute (attribute_name)) nodes and the regex fallback is containedin=htmlTag,hbsComponent — so wire:model fails the ^[:@] test on its first character, and prose can never reach an attribute node at all. Diagnostics, completion, and hover run over a text scan rather than the tree, so they get the guarantee by construction instead: candidates come from the tag scan, which puts hyphenated prose, translate-x-1/2, const diff = x-y > 0, and typos inside <!-- --> out of scope. Raw-text element bodies are part of that scan's remit and were the one gap — <script>if (a<b) { let x-y = 1 }</script> opened a bogus tag on the < and reported x-y, in every filetype. script, style, textarea, and title bodies are now skipped, while attributes on the opening tag itself still count, so <script x-data="{}" defer> reads both.

Completion dispatches on language family, because JSX needs its own scanner: < is also the less-than operator and a generic-argument delimiter, so the markup scan would report "inside a tag" across large stretches of ordinary TypeScript. The JSX scan accepts a < only when an element name follows and everything up to the cursor could appear in an opening tag, rejecting new Map<string, number>() on the , and i.n < 5 && i.n > 1 on the &. Tree-sitter is deliberately not used for JSX completion despite being available: an unterminated <div x- collapses the whole statement to a bare ERROR node, indistinguishable from const diff = x-. It is used for JSX diagnostics, which run over a complete document where the tree is exact.

Related