Skip to content

Instantly share code, notes, and snippets.

@barnabasJ
Last active September 9, 2026 04:34
Show Gist options
  • Select an option

  • Save barnabasJ/05c2b0c7fbcbab1e7c99bf7e5be18103 to your computer and use it in GitHub Desktop.

Select an option

Save barnabasJ/05c2b0c7fbcbab1e7c99bf7e5be18103 to your computer and use it in GitHub Desktop.
Colocated JS/CSS in Phoenix LiveView — full Neovim support (highlighting, otter LSP, formatting)

Colocated JS/CSS in Phoenix LiveView — full Neovim support

How to get syntax highlighting, LSP completion (otter), and formatting for JavaScript/CSS written inside .ex files — i.e. Phoenix LiveView colocated hooks (Phoenix.LiveView.ColocatedHook / ColocatedJS, v1.1+) and colocated CSS (ColocatedCSS, v1.2+), where JS/CSS lives in <script> / <style> tags inside a ~H HEEx sigil:

def hook(assigns) do
  ~H"""
  <div id="el" phx-hook=".Sha256">…</div>
  <script :type={Phoenix.LiveView.ColocatedHook} name=".Sha256">
    export default { mounted() { this.el.innerHTML = sha256(this.el.innerHTML) } }
  </script>
  <style :type={MyAppWeb.ColocatedCSS}>
    .btn { color: var(--color-primary); }
  </style>
  """
end

Environment: Neovim 0.12, nvim-treesitter main, blink.cmp, conform.nvim, otter.nvim, mason. (The author's dotfiles use chezmoi, but nothing here requires it — the parser install step below offers a manual build, a portable script, and an nvim-treesitter option.)


The core problem

tree-sitter-heex parses the body of <script>/<style> as generic HEEx nodes: every { opens an interpolation expression (parsed as Elixir), every < starts a tag, and the rest fragments into whitespace-split text nodes. So the JS/CSS gets shredded — there is no single, contiguous node for language injection to attach to. This is the blocker: it kills highlighting, otter (it discovers regions from the treesitter tree), and any treesitter-based formatting.

There is no after/queries fix, because the grammar never produces an injectable region in the first place. The fix has to be in the grammar.

The working stack is three layers deep — elixir → heex (~H sigil) → js/css — so the parser has to expose the innermost region cleanly.


Part 1 — Fork tree-sitter-heex to add a raw_text node

Fork: barnabasJ/tree-sitter-heex, branch feat/raw-text-script-style. Two changes to grammar.js plus a small external scanner.

grammar.js

// 1. Declare an external token for the opaque script/style body, plus an
//    error_sentinel (only ever "valid" during error recovery — lets the
//    scanner bail so it doesn't reshape error trees).
externals: ($) => [$.raw_text, $.error_sentinel],

// 2. A <script>/<style> tag is a start tag, an opaque raw_text body, end tag.
//    `_raw_start_tag` is aliased to `start_tag` so the node shape stays
//    identical to every other tag (existing highlight queries keep working).
tag: ($) =>
  choice(
    seq($.start_tag, repeat($._node), $.end_tag),
    seq(alias($._raw_start_tag, $.start_tag), optional($.raw_text), $.end_tag),
    $.self_closing_tag
  ),

_raw_start_tag: ($) =>
  seq(
    "<",
    alias($._raw_tag_name, $.tag_name),
    repeat(choice($.attribute, $.expression, $.special_attribute)),
    ">"
  ),

// Higher token precedence makes the lexer prefer this over the generic
// tag_name regex for exactly "script"/"style" (so <scripts> stays a normal tag).
_raw_tag_name: ($) => token(prec(1, choice("script", "style"))),

// 3. BONUS FIX: colocated tags always carry :type={...}. The grammar's special
//    attribute list was hardcoded (:let/:for/:key/:stream/:if) and could not
//    parse :type at all. Generalise it to any :-prefixed name.
special_attribute_name: ($) => token(seq(":", /[a-zA-Z][a-zA-Z0-9_-]*/)),

src/scanner.c (the external scanner)

Scans from just after the start tag's > until the matching </script/</style (case-insensitive, browser-faithful — a bare < or a </div> string inside the JS does not end it), emitting it all as one raw_text token.

#include "tree_sitter/parser.h"
#include <stdbool.h>

enum TokenType { RAW_TEXT, ERROR_SENTINEL };

void *tree_sitter_heex_external_scanner_create(void) { return NULL; }
void tree_sitter_heex_external_scanner_destroy(void *p) {}
unsigned tree_sitter_heex_external_scanner_serialize(void *p, char *b) { return 0; }
void tree_sitter_heex_external_scanner_deserialize(void *p, const char *b, unsigned n) {}

static inline bool is_lower_alpha(int32_t c) { return c >= 'a' && c <= 'z'; }
static inline int32_t to_lower(int32_t c) { return (c >= 'A' && c <= 'Z') ? c + 32 : c; }
static inline bool is_name_char(int32_t c) {
  return is_lower_alpha(to_lower(c)) || (c >= '0' && c <= '9') || c == '-' || c == '_';
}

bool tree_sitter_heex_external_scanner_scan(void *payload, TSLexer *lexer,
                                            const bool *valid_symbols) {
  if (valid_symbols[ERROR_SENTINEL]) return false;  // in error recovery: bail
  if (!valid_symbols[RAW_TEXT]) return false;

  bool advanced = false;
  while (lexer->lookahead != 0) {
    if (lexer->lookahead != '<') {
      lexer->advance(lexer, false); lexer->mark_end(lexer); advanced = true; continue;
    }
    lexer->advance(lexer, false);                 // consume '<' (boundary still before it)
    if (lexer->lookahead != '/') { lexer->mark_end(lexer); advanced = true; continue; }
    lexer->advance(lexer, false);                 // consume '/'

    char name[7]; int n = 0;
    while (n < 6 && is_lower_alpha(to_lower(lexer->lookahead))) {
      name[n++] = (char)to_lower(lexer->lookahead); lexer->advance(lexer, false);
    }
    name[n] = '\0';
    bool is_close =
        ((n == 6 && name[0]=='s'&&name[1]=='c'&&name[2]=='r'&&name[3]=='i'&&name[4]=='p'&&name[5]=='t') ||
         (n == 5 && name[0]=='s'&&name[1]=='t'&&name[2]=='y'&&name[3]=='l'&&name[4]=='e'));
    if (is_close && is_name_char(lexer->lookahead)) is_close = false;  // </scripts> isn't a close

    if (is_close) { lexer->result_symbol = RAW_TEXT; return advanced; }  // leave </script> for end_tag
    lexer->mark_end(lexer); advanced = true;       // '<' '/' letters were content
  }
  lexer->mark_end(lexer); lexer->result_symbol = RAW_TEXT; return advanced;  // EOF, malformed
}

Regenerate + test: tree-sitter generate && tree-sitter test — all corpus tests pass (the _raw_start_tag → start_tag alias keeps existing trees unchanged; the error_sentinel keeps error-recovery trees unchanged). Commit the generated src/parser.c + src/scanner.c so consumers can build with just cc.

Install the patched parser

Neovim loads the parser from an explicit path (language.add(..., { path }) in Part 2a), so all you need is a heex.so at a known location. This guide uses ~/.local/share/nvim/heex-fork/parser/heex.so ($XDG_DATA_HOME/nvim/...) — use any path you like, just keep it in sync with the heex_so path in 2a. The fork commits the generated src/parser.c, so you only need a C compiler (cc), not the tree-sitter CLI. Pick whichever install method fits your setup; none of them assume a particular dotfile manager.

Option 1 — one-time manual build (works anywhere).

git clone --depth 1 --branch feat/raw-text-script-style \
  https://github.com/barnabasJ/tree-sitter-heex /tmp/ts-heex
mkdir -p ~/.local/share/nvim/heex-fork/parser

# Linux:
cc -shared -fPIC -Os -I /tmp/ts-heex/src \
   /tmp/ts-heex/src/parser.c /tmp/ts-heex/src/scanner.c \
   -o ~/.local/share/nvim/heex-fork/parser/heex.so

# macOS: swap `-shared` for `-bundle -undefined dynamic_lookup`

(If you fork further and edit grammar.js, run tree-sitter generate first to regenerate src/parser.c.)

Option 2 — a portable install/update script (idempotent, SHA-pinned, degrades gracefully if offline or no compiler). Run it once, on a schedule, or on every update — it no-ops when already built at the pinned SHA. Drop it into whatever you use: a lazy.nvim plugin build step, a Makefile, an Ansible task, a chezmoi run_onchange_*.sh, or just bash install-heex-parser.sh.

#!/usr/bin/env bash
set -euo pipefail
SHA="<pinned-commit>"; BRANCH="feat/raw-text-script-style"
REPO="https://github.com/barnabasJ/tree-sitter-heex.git"
DATA="${XDG_DATA_HOME:-$HOME/.local/share}/nvim/heex-fork"
OUT="$DATA/parser/heex.so"; STAMP="$DATA/.built-sha"

command -v cc  >/dev/null || { echo "no C compiler; using stock heex parser" >&2; exit 0; }
command -v git >/dev/null || { echo "no git" >&2; exit 0; }
[ -f "$OUT" ] && [ "$(cat "$STAMP" 2>/dev/null||true)" = "$SHA" ] && exit 0

TMP=$(mktemp -d); trap 'rm -rf "$TMP"' EXIT
build() {
  git clone -q --depth 1 --branch "$BRANCH" "$REPO" "$TMP/src" || return 1
  mkdir -p "$DATA/parser"
  case "$(uname -s)" in Darwin) f=(-bundle -undefined dynamic_lookup);; *) f=(-shared);; esac
  cc "${f[@]}" -fPIC -Os -I "$TMP/src/src" "$TMP/src/src/parser.c" "$TMP/src/src/scanner.c" -o "$OUT" || return 1
}
if build; then echo "$SHA" >"$STAMP"; echo "built -> $OUT"; else echo "build failed; keeping stock parser" >&2; fi

Option 3 — let nvim-treesitter build it (only on the master branch). If you're still on nvim-treesitter master (not main), register the fork as the heex parser source and :TSInstall heex:

require("nvim-treesitter.parsers").get_parser_configs().heex.install_info = {
  url = "https://github.com/barnabasJ/tree-sitter-heex",
  branch = "feat/raw-text-script-style",
  files = { "src/parser.c", "src/scanner.c" },
}
-- :TSInstall heex   (or :TSUpdate heex)

With this option nvim-treesitter installs to its own parser dir and wins the registration itself, so you can drop the explicit language.add line in 2a and keep only the query.set part. (On main, nvim-treesitter has no equivalent install_info override, which is why Options 1–2 build to an explicit path and 2a force-loads it.)


Part 2 — Neovim wiring

2a. Load the patched parser + inject JS/CSS (treesitter.lua)

The first language.add for a language wins, so register ours before nvim-treesitter touches heex (at startup, before any FileType handler). Then set the injections query at runtime — not as an after/queries file, because a static query referencing raw_text hard-errors when the stock parser is active. Guarded: if the parser isn't built, keep the stock parser + stock injections.

local heex_so = vim.fn.stdpath("data") .. "/heex-fork/parser/heex.so"
if (vim.uv or vim.loop).fs_stat(heex_so)
   and pcall(vim.treesitter.language.add, "heex", { path = heex_so }) then
  -- query.set REPLACES the injections query, so inline nvim-treesitter's heex
  -- base verbatim, then add the script/style rules over our raw_text node.
  vim.treesitter.query.set("heex", "injections", [==[
(directive
  [ (partial_expression_value) (ending_expression_value) ] @injection.content
  (#set! injection.language "elixir")
  (#set! injection.include-children)
  (#set! injection.combined))
((directive (expression_value) @injection.content) (#set! injection.language "elixir"))
(expression (expression_value) @injection.content (#set! injection.language "elixir"))
((comment) @injection.content (#set! injection.language "comment"))

((tag (start_tag (tag_name) @_tag) (raw_text) @injection.content)
 (#eq? @_tag "script") (#set! injection.language "javascript"))
((tag (start_tag (tag_name) @_tag) (raw_text) @injection.content)
 (#eq? @_tag "style")  (#set! injection.language "css"))
]==])
end

That alone gives syntax highlighting for colocated JS/CSS (verified through the full elixir → heex → javascript/css chain).

2b. LSP via otter.nvim (otter.lua)

otter creates a hidden per-language buffer from each injected region and proxies LSP to the real servers; completion surfaces through blink's normal lsp source.

return {
  "jmbuhr/otter.nvim",
  dependencies = { "nvim-treesitter/nvim-treesitter" },
  ft = { "elixir", "heex", "eelixir" },
  opts = {
    lsp = { diagnostic_update_events = { "BufWritePost" } },
    buffers = { set_filetype = true, write_to_disk = false },
    handle_leading_whitespace = true,
  },
  config = function(_, opts)
    require("otter").setup(opts)

    -- *** Critical bug fix ***
    -- otter-ls forwards to the hidden buffer with vim.lsp.buf_request, whose
    -- callback fires once PER client and replies on the FIRST to answer. With
    -- cssls + css_variables both attached, the one returning nothing at the
    -- cursor (e.g. css_variables on a plain property) answers first and shadows
    -- the other → ZERO completions. Upstream is still first-wins. Wrap
    -- buf_request to AGGREGATE: query all completion clients on the otter
    -- buffer and reply once with merged items. (Use client:request, not
    -- buf_request_all — that's built on buf_request and would recurse.)
    if not vim.g.__otter_completion_aggregation then
      vim.g.__otter_completion_aggregation = true
      local ms = vim.lsp.protocol.Methods
      local orig = vim.lsp.buf_request
      local function is_otter_buf(b)
        local ok, keeper = pcall(require, "otter.keeper"); if not ok then return false end
        for _, raft in pairs(keeper.rafts or {}) do
          for _, n in pairs(raft.buffers or {}) do if n == b then return true end end
        end
        return false
      end
      vim.lsp.buf_request = function(bufnr, method, params, handler)
        if method ~= ms.textDocument_completion or not is_otter_buf(bufnr) then
          return orig(bufnr, method, params, handler)
        end
        local clients = vim.lsp.get_clients({ bufnr = bufnr, method = method })
        local items, remaining, replied = {}, #clients, false
        local function reply()
          if not replied then replied = true
            handler(nil, { isIncomplete = true, items = items },
              { bufnr = bufnr, method = method, params = params }) end
        end
        if remaining == 0 then reply(); return {} end
        local function on_one(result)
          if result then for _, it in ipairs(result.items or result) do items[#items+1] = it end end
          remaining = remaining - 1; if remaining == 0 then reply() end
        end
        for _, c in ipairs(clients) do
          local ok = c:request(method, params, function(_, r) on_one(r) end, bufnr)
          if not ok then on_one(nil) end
        end
        return {}
      end
    end

    -- Activate on each elixir/heex buffer (otter is graceful when there's none).
    local grp = vim.api.nvim_create_augroup("otter-elixir-heex", { clear = true })
    vim.api.nvim_create_autocmd("FileType", {
      group = grp, pattern = { "elixir", "heex", "eelixir" },
      callback = function() pcall(require("otter").activate, { "javascript", "css" }, true, true) end,
    })
  end,
}

2c. The LSP servers (mason.lua)

ts_ls = {},                 -- JS (hooks). Real .js/.ts files + otter buffers.
cssls = {},                 -- general CSS (properties, values, at-rules, same-file vars)
css_variables = {           -- cross-file var(--…); indexes the WORKSPACE itself,
  settings = {              -- so it works inside the hidden otter buffer too.
    cssVariables = {
      lookupFiles = { "**/*.css", "**/*.scss", "**/*.less", "assets/**/*.css" },
    },
  },
},

You need both cssls (general completion) and css_variables (cross-file variables) — that's exactly why the aggregation fix in 2b is required.

2d. Formatting via mix format (conform.nvim)

There is no treesitter/LSP path to format the embedded content — but mix format is a compile-time pass and sidesteps that entirely. LiveView 1.2.0-rc.0+ ships a Phoenix.LiveView.HTMLFormatter.TagFormatter behaviour that hands <script>/<style> bodies to prettier. So just run the project's mix format over stdin:

formatters = {
  mix_format = {
    command = "mix",
    args = function(_, ctx) return { "format", "--stdin-filename", ctx.filename, "-" } end,
    stdin = true,
    cwd = function(_, ctx) return vim.fs.root(ctx.dirname, { "mix.exs", ".formatter.exs" }) end,
    require_cwd = true,   -- outside a mix project, fall back to LSP formatting
  },
},
formatters_by_ft = { elixir = { "mix_format" }, heex = { "mix_format" } },
-- mix cold-starts the BEAM (~1–2s); bump format-on-save timeout for elixir/heex
-- to ~3000ms or it silently times out at the default 500ms.

2e. Completion ordering (blink.cmp)

providers = {
  lsp = {
    score_offset = 10,            -- LSP above path/snippets/buffer
    transform_items = function(_, items)  -- boost embedded css/js (otter-ls)
      for _, item in ipairs(items) do     -- above Elixir's `expert` inside ~H
        if type(item.client_name) == "string" and item.client_name:match("^otter%-ls") then
          item.score_offset = (item.score_offset or 0) + 5
        end
      end
      return items
    end,
  },
},

Part 3 — Per-project config (in each Phoenix app)

These can't live in dotfiles. Needs LiveView ~> 1.2.0-rc and npx prettier.

.formatter.exs — formats colocated JS/CSS with prettier

defmodule PrettierFormatter do
  @moduledoc false
  @behaviour Phoenix.LiveView.HTMLFormatter.TagFormatter
  require Logger

  @impl true
  def render_tag({tag, _attrs, content}, _opts) when tag in ["script", "style"] do
    ext = if tag == "style", do: "css", else: "js"
    tmp = Path.join(System.tmp_dir!(), "phx_prettier_#{System.unique_integer([:positive])}.#{ext}")
    try do
      File.write!(tmp, content)
      case System.cmd("npx", ["prettier", tmp], stderr_to_stdout: true) do
        {out, 0} -> {:ok, String.trim(out)}
        {err, _} -> Logger.error("prettier failed: #{err}"); :skip
      end
    after
      File.rm(tmp)
    end
  end

  def render_tag(_tag, _opts), do: :skip
end

[
  plugins: [Phoenix.LiveView.HTMLFormatter],
  tag_formatters: %{script: PrettierFormatter, style: PrettierFormatter},
  inputs: ["*.{heex,ex,exs}", "{config,lib,test}/**/*.{heex,ex,exs}", "priv/*/seeds.exs"]
]

jsconfig.json — let ts_ls resolve package imports in hooks

The hidden otter buffer is anchored next to the .ex in lib/, but JS deps live in assets/node_modules. Point TS at them:

{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": { "*": ["assets/node_modules/*"] }
  }
}

What works / what doesn't

Works: highlighting; CSS completion (general + cross-file var(--…)); JS completion; go-to-def within the block; formatting of colocated JS/CSS via mix format.

Out of reach (inherent to the hidden-buffer model):

  • Cross-file links to your other JS modules, and inbound references — but colocated hooks don't need these.
  • Bare package imports need the jsconfig.json shim above (Phoenix's lib/ vs assets/node_modules split).
  • cssls vs css_variables can't be split in blink (both arrive merged under otter-ls) — but fuzzy matching surfaces the right one by context.

Verify

  1. Highlighting: open a .ex with a colocated <script>/<style>; JS/CSS should be colored.
  2. Completion: in <style> type a property (colo) and var(--; in <script> type JS.
  3. Formatting: <leader>f / save reflows the embedded JS/CSS.
  4. Sanity: :checkhealth otter, and :lua =vim.tbl_map(function(c) return c.name end, vim.lsp.get_clients()) with the cursor in a <style> block should list cssls, css_variables, otter-ls.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment