wandres.dev
NINJA · IDE completo

Frontend: React, TypeScript, Tailwind, CSS/HTML

El stack web completo en Neovim 0.12: TypeScript con vtsls o ts_ls declarados en la carpeta lsp, Tailwind con previews de color, CSS/HTML/Emmet, ESLint, Prettier y parsers de Treesitter con la API viva.

⏱ 18 min

El frontend es donde más servidores conviven: TypeScript, Tailwind, CSS, HTML, Emmet y ESLint, todos a la vez sobre el mismo archivo .tsx. Bien montado, Neovim iguala o supera a VS Code para React — y en 0.12 montarlo son seis ficheros pequeños y una línea que los enciende.

🎯 Al terminar esta lección sabrás
  • Elegir servidor de TypeScript: vtsls o ts_ls, nunca los dos.
  • Declarar los seis servidores del stack web en la carpeta lsp/.
  • Tailwind con completado de clases en helpers y previews de color.
  • Parsers de Treesitter, ESLint y Prettier sin pisar teclas ajenas.

Un fichero por servidor

En Neovim 0.12 no hay que registrar nada en ningún plugin: cada servidor es un fichero lsp/nombre.lua que devuelve una tabla, y una sola llamada decide cuáles se activan. Ese fichero solo se lee cuando el servidor hace falta, así que tener seis declarados no cuesta nada al arrancar.

vim.lsp.enable({
  "vtsls",                   -- o "ts_ls", nunca los dos
  "tailwindcss",
  "cssls",
  "html",
  "emmet_language_server",
  "eslint",
})

Los binarios los consigues por npm (npm i -g @vtsls/language-server vscode-langservers-extracted @tailwindcss/language-server @olrtg/emmet-language-server) o con Mason, que existe justo para eso: instalar ejecutables y ponerlos en el camino de búsqueda. Ni declara ni activa nada; si prefieres npm, no lo necesitas.

TypeScript: vtsls o ts_ls

🟦

ts_ls

typescript-language-server, la envoltura clásica de tsserver. Sólida y estándar. El binario se instala con npm i -g typescript typescript-language-server.

🚀

vtsls

Envuelve la extensión de TypeScript de VS Code entera, así que acepta sus mismos ajustes y expone sus comandos y refactors. Soporta monorepos con un solo proceso. La opción “pro”.

🛑
Uno u otro, nunca los dos

Sus propios autores lo dicen: no es recomendable activar vtsls y ts_ls a la vez. Los dos hablan con tsserver, así que tendrías dos procesos analizando el mismo proyecto, diagnósticos duplicados y el doble de memoria. Elige uno y bórralo del vim.lsp.enable.

return {
  cmd = { "vtsls", "--stdio" },
  filetypes = { "javascript", "javascriptreact", "typescript", "typescriptreact" },
  root_markers = { "tsconfig.json", "jsconfig.json", "package.json", ".git" },
  init_options = { hostInfo = "neovim" },
  settings = {
    typescript = {
      inlayHints = {
        parameterNames = { enabled = "literals" },  -- none, literals o all
        variableTypes = { enabled = true },
        functionLikeReturnTypes = { enabled = true },
      },
    },
  },
}

Si prefieres ts_ls, la ficha es casi idéntica cambiando el cmd por { "typescript-language-server", "--stdio" }. Los inlay hints los enciende el autocomando LspAttach de la lección 2.1 cuando el servidor los soporta, y los alternas con <leader>th.

💡
Los comandos que trae el catálogo

Si instalas nvim-lspconfig (lección 2.5), su ficha de ts_ls añade dos comandos de buffer que Neovim no trae: :LspTypescriptSourceAction, para las acciones de archivo completo —organizar imports, borrar código sin usar—, y :LspTypescriptGoToSourceDefinition, que salta a la implementación real en vez de a la declaración de tipos. Eso es lo que aporta hoy ese plugin: fichas mantenidas por gente que ha averiguado estos detalles, no maquinaria.

Tailwind: clases, helpers y colores

return {
  cmd = { "tailwindcss-language-server", "--stdio" },
  filetypes = {
    "html", "css", "scss", "less", "astro", "svelte", "vue",
    "javascript", "javascriptreact", "typescript", "typescriptreact",
  },
  root_markers = {
    "tailwind.config.js", "tailwind.config.ts",
    "postcss.config.js", "package.json", ".git",
  },
  settings = {
    tailwindCSS = {
      -- Funciones en las que también quieres completado de clases
      classFunctions = { "tw", "clsx", "cva", "cn" },
      -- Atributos que contienen clases
      classAttributes = { "class", "className", "class:list", "classList" },
    },
  },
}

classFunctions es la opción que resuelve el problema clásico de React: las clases escritas dentro de clsx(...), cva(...) o una plantilla etiquetada tw no se completaban porque el servidor no sabía que ahí dentro hay clases. Cada entrada se trata como una expresión regular que casa con el nombre de la función, así que tw\\.[a-z-]+ cubre también cosas como tw.div.

⚠️
Ctrl-Espacio y Tab tienen dueño

El completado de clases aparece con el autocompletado, y el autocompletado es el dueño de Ctrl-Space y de Tab en todo este track — tanto si usas el nativo de 0.12 (vim.o.autocomplete más vim.opt.completeopt) como blink.cmp de la lección 2.2. Ningún plugin de esta lección ni de las siguientes puede reclamar esas dos teclas. Y s y S son de flash.nvim, con mini.surround en el prefijo gs (lección 2.3).

Previews de color

El servidor de Tailwind ya te enseña el CSS que genera cada clase al hacer hover. Para ver el color real junto a la clase, añade un plugin de resaltado de colores:

brenoprata10/nvim-highlight-colors
return {
  "brenoprata10/nvim-highlight-colors",
  event = { "BufReadPre", "BufNewFile" },
  opts = {
    render = "virtual",
    enable_tailwind = true,
  },
}
vim.pack.add({
  { src = "https://github.com/brenoprata10/nvim-highlight-colors" },
})

vim.opt.termguicolors = true
require("nvim-highlight-colors").setup({
  render = "virtual",
  enable_tailwind = true,
})

Con render virtual pinta un cuadradito junto al valor; si prefieres teñir el fondo, usa render background. Necesita termguicolors.

Ahora bg-red-500 o #89b4fa se muestran con su color al lado, y con :HighlightColors Toggle lo apagas cuando estorbe. Además reconoce el textDocument/documentColor que envían servidores como el de Tailwind, así que los colores que solo el servidor conoce también aparecen.

CSS, HTML y Emmet

Tres fichas cortas y muy parecidas. Las tres primeras órdenes vienen del paquete vscode-langservers-extracted:

return {
  cmd = { "vscode-css-language-server", "--stdio" },
  filetypes = { "css", "scss", "less" },
  root_markers = { "package.json", ".git" },
  init_options = { provideFormatter = true },
  settings = {
    css = { validate = true },
    scss = { validate = true },
    less = { validate = true },
  },
}
return {
  cmd = { "vscode-html-language-server", "--stdio" },
  filetypes = { "html" },
  root_markers = { "package.json", ".git" },
  init_options = {
    provideFormatter = true,
    embeddedLanguages = { css = true, javascript = true },
  },
}
return {
  cmd = { "emmet-language-server", "--stdio" },
  filetypes = {
    "html", "css", "scss", "less", "astro", "svelte", "vue",
    "javascriptreact", "typescriptreact",
  },
  root_markers = { ".git" },
}

Con Emmet escribes div.card>ul>li*3 y el completado lo expande al HTML entero. Funciona igual dentro de JSX.

ℹ️
Sin snippets, cssls y html casi no completan

Los servidores de CSS y HTML solo ofrecen completado si el cliente declara que soporta snippets, y esa capacidad no viene activada de serie. Se arregla una vez, para todos los servidores, con la entrada comodín:

local caps = vim.lsp.protocol.make_client_capabilities()
caps.textDocument.completion.completionItem.snippetSupport = true

vim.lsp.config("*", { capabilities = caps })

Si en la lección 2.1 ya pusiste ahí las capabilities de blink.cmp, añade esta línea a esa misma tabla en lugar de declarar el comodín dos veces: la fusión es superficial y la segunda declaración pisaría la primera.

ESLint y Prettier

Para ESLint tienes dos caminos y no hacen falta los dos:

  1. El servidor (vscode-eslint-language-server, del mismo paquete que CSS y HTML): diagnósticos en vivo y arreglo automático por code action. Su ficha es peculiar porque solo se adjunta si encuentra un fichero de configuración de ESLint por encima del archivo.
  2. eslint_d por nvim-lint (lección 2.4): más ligero, corre el linter al guardar y ya lo tienes montado.

Si eliges el servidor y quieres arreglo al guardar, el catálogo de nvim-lspconfig aporta aquí algo concreto: su ficha crea el comando :LspEslintFixAll. Encadenarlo al guardado se hace respetando el on_attach que ya trae:

local base_on_attach = vim.lsp.config.eslint.on_attach

vim.lsp.config("eslint", {
  on_attach = function(client, bufnr)
    if not base_on_attach then return end
    base_on_attach(client, bufnr)
    vim.api.nvim_create_autocmd("BufWritePre", {
      buffer = bufnr,
      command = "LspEslintFixAll",
    })
  end,
})

Para formatear, ya configuraste Prettier en conform (prettierd con recambio a prettier). Se ejecuta al guardar en JS, TS, CSS, HTML y JSON, y un .prettierrc en la raíz manda sobre el estilo.

Parsers del stack web

⚠️
Nada de nvim-treesitter.configs

require("nvim-treesitter.configs").setup(...) con ensure_installed y highlight es la configuración de la rama master, congelada y solo válida para Neovim 0.11. En la rama main —la única viva— el plugin instala parsers y poco más; el resaltado lo enciende Neovim.

require("nvim-treesitter").install({
  "javascript", "typescript", "tsx", "html", "css", "json",
})

-- Sin foldlevelstart = 99, cada archivo se abre ENTERO PLEGADO.
-- Ponlo una vez en tus opciones (leccion 2.3).
vim.o.foldlevelstart = 99

vim.api.nvim_create_autocmd("FileType", {
  pattern = { "javascript", "javascriptreact", "typescript", "typescriptreact", "html", "css" },
  callback = function()
    vim.treesitter.start()
    vim.wo[0][0].foldexpr = "v:lua.vim.treesitter.foldexpr()"
    vim.wo[0][0].foldmethod = "expr"
  end,
})

Ojo a un detalle que despista: el parser de React se llama tsx, no typescriptreact. El tipo de archivo de Neovim y el nombre del parser no tienen por qué coincidir.

El cierre y renombrado automático de etiquetas ya lo instalaste en la lección 2.3 con nvim-ts-autotag: escribes <div> y se cierra solo; renombras la etiqueta de apertura y la de cierre te sigue. En React eso son cientos de pulsaciones al día.

package.json vivo

vuki656/package-info.nvim
return {
  "vuki656/package-info.nvim",
  dependencies = { "MunifTanjim/nui.nvim" },
  event = { "BufRead package.json" },
  config = function()
    require("package-info").setup()
    vim.keymap.set("n", "<leader>ns", "<cmd>PackageInfoShow<cr>",   { desc = "npm: ver versiones" })
    vim.keymap.set("n", "<leader>nu", "<cmd>PackageInfoUpdate<cr>", { desc = "npm: actualizar dependencia" })
    vim.keymap.set("n", "<leader>nd", "<cmd>PackageInfoDelete<cr>", { desc = "npm: borrar dependencia" })
  end,
}
vim.pack.add({
  { src = "https://github.com/MunifTanjim/nui.nvim" },
  { src = "https://github.com/vuki656/package-info.nvim" },
})

require("package-info").setup()
vim.keymap.set("n", "<leader>ns", "<cmd>PackageInfoShow<cr>",   { desc = "npm: ver versiones" })
vim.keymap.set("n", "<leader>nu", "<cmd>PackageInfoUpdate<cr>", { desc = "npm: actualizar dependencia" })
vim.keymap.set("n", "<leader>nd", "<cmd>PackageInfoDelete<cr>", { desc = "npm: borrar dependencia" })

Necesita nui.nvim y npm en el sistema. Detecta solo si el proyecto usa npm, yarn, pnpm o bun, incluso dentro de un monorepo.

Con <leader>ns ves como texto virtual qué versión de cada dependencia es la última publicada, y actualizas o borras la de la línea sin salir del buffer.

Neovim para React, de verdad

Con TypeScript, Tailwind, autotag, previews de color y Prettier al guardar tienes el bucle completo de React: completado de props y de clases, renombrado seguro de componentes con <leader>rn, imports automáticos, formateo instantáneo y JSX que se cierra solo. Súmale s de flash.nvim para saltar por componentes largos. Pero mira la lección desde arriba: seis servidores conviviendo sobre el mismo buffer y ninguno se estorba, porque cada uno declara a qué tipos de archivo se ofrece y qué raíz busca. Ese es el diseño que hace que el stack web sea manejable en un editor de texto plano — y también el sitio donde mirar cuando algo se comporta raro. Antes de tocar nada, :checkhealth vim.lsp te dice qué clientes están adjuntos a ese buffer. Si ves dos que hacen lo mismo, ya tienes tu bug.

⚔️ Monta un .tsx real
  1. Escribe las fichas de vtsls y tailwindcss y actívalas junto a las de CSS, HTML y Emmet.
  2. Abre un proyecto React con TypeScript y Tailwind y comprueba con :checkhealth vim.lsp cuántos clientes hay adjuntos al mismo .tsx.
  3. Escribe className="bg- y mira el completado de Tailwind con los colores al lado.
  4. Mete una clase dentro de clsx(...) y confirma que también completa gracias a classFunctions.
  5. Renombra un componente con <leader>rn y observa cómo se actualizan los usos.
  6. Instala los parsers, desordena el formato y guarda: Prettier lo arregla y el resaltado no parpadea.