wandres.dev
ECOSISTEMA DE PLUGINS · Rollup-compatible

Escribir un plugin de principio a fin

Síntesis del nivel: construimos un plugin real y útil que enseña a Vite a importar archivos YAML como datos y expone un módulo virtual que agrega todos los del proyecto. Recorremos transform para un tipo de archivo nuevo, el manejo de errores con this.error, la agregación por glob en resolveId más load, la reactividad con addWatchFile y handleHotUpdate, y el empaquetado final con la convención de nombre, peerDependencies y los tipos del módulo virtual.

⏱ 22 min

Es hora de juntarlo todo. En esta lección construimos un plugin real, de los que de verdad querrías usar: uno que le enseña a Vite a importar archivos .yaml como si fueran objetos JavaScript, y que además expone un módulo virtual con todos los datos del proyecto agregados. Cada pieza que tocamos —transform para un tipo de archivo nuevo, el par resolveId más load para inyectar datos, addWatchFile y handleHotUpdate para la reactividad, y el empaquetado final— es una de las que estudiamos por separado. Verlas colaborar en un mismo plugin es la mejor forma de fijar el nivel entero.

🎯 Al terminar esta lección sabrás
  • Enseñar a Vite un tipo de archivo nuevo transformándolo con transform.
  • Manejar errores de parseo con this.error sin romper el build en silencio.
  • Inyectar datos agregados por glob mediante un módulo virtual reactivo.
  • Empaquetar el plugin con su nombre, sus peerDependencies y sus tipos.

El caso: importar YAML como datos

Vite entiende JavaScript, TypeScript, JSON, CSS y assets, pero no YAML. Si escribes import config from './app.yaml', el build falla porque nadie sabe qué hacer con ese contenido. Nuestro plugin resuelve exactamente eso: interceptar los archivos con extensión .yaml o .yml, parsearlos, y devolver un módulo que exporta el objeto resultante. Es el caso arquetípico de enseñarle al bundler un formato que no conocía.

El hook para esto es transform, y el plugin debe llevar enforce: 'pre'. La razón es de orden: si no nos adelantamos al núcleo de Vite, su transformación por defecto —pensada para JavaScript— intentará procesar el YAML antes que nosotros y fallará. Al marcarnos pre, transformamos el fuente crudo primero, y lo que entregamos ya es JavaScript válido que el resto de la tubería acepta sin problema.

import { parse } from 'yaml'
import type { Plugin } from 'vite'

const RE_YAML = /\.ya?ml$/

export default function yaml(): Plugin {
  return {
    name: 'vite-plugin-yaml',
    enforce: 'pre',
    transform(code, id) {
      if (!RE_YAML.test(id)) return null
      const datos = parse(code)
      return {
        code: `export default ${JSON.stringify(datos)}`,
        map: { mappings: '' },
      }
    },
  }
}

Dos detalles finos ya justifican el diseño. El return null cuando el id no encaja respeta la naturaleza secuencial del hook: cedemos el turno para todo lo que no sea YAML. Y el map con mappings vacío es la forma idiomática de decir “esta transformación reemplaza el módulo entero, no hay correspondencia de líneas con el original”, que evita que Vite emita un aviso por un sourcemap ausente sin mentir sobre posiciones que no existen.

Manejar los errores como es debido

Un YAML mal escrito no debe reventar el build con un error incomprensible ni, peor, colar un módulo vacío en silencio. El contexto this de los hooks trae this.error, que lanza un diagnóstico con la ubicación correcta, integrado en el informe de Vite, y detiene el build de forma limpia. Envolvemos el parseo para transformar cualquier fallo de sintaxis en un mensaje que apunte al archivo culpable.

transform(code, id) {
  if (!RE_YAML.test(id)) return null
  try {
    const datos = parse(code)
    return {
      code: `export default ${JSON.stringify(datos)}`,
      map: { mappings: '' },
    }
  } catch (e) {
    this.error(`YAML invalido en ${id}: ${(e as Error).message}`)
  }
}
⚠️
Un throw crudo pierde la ubicación

La tentación es escribir throw new Error(...) dentro del hook, pero eso produce un error sin la información de contexto que Vite sabe presentar: el archivo, la posición, el plugin responsable. this.error enriquece el diagnóstico con todo eso y lo formatea igual que los errores internos, de modo que quien lo lea sepa de un vistazo qué archivo tiene el YAML roto. La diferencia entre un plugin amable y uno hostil suele estar aquí, en cómo falla, no en cómo acierta.

Un módulo virtual que agrega todo

Transformar archivos uno a uno está bien, pero la funcionalidad estrella es agregar: exponer un único import con todos los datos YAML del proyecto reunidos. Aquí entran el módulo virtual y su par de hooks. resolveId reconoce el id público virtual:yaml-index; load escanea el proyecto con un glob, parsea cada archivo, y devuelve un objeto que los reúne todos. Guardamos la configuración resuelta en configResolved para saber cuál es la raíz del proyecto.

import { parse } from 'yaml'
import { readFile } from 'node:fs/promises'
import fg from 'fast-glob'
import type { Plugin, ResolvedConfig } from 'vite'

const ID_INDICE = 'virtual:yaml-index'
const ID_RESUELTO = '\0' + ID_INDICE

export function indice(): Partial<Plugin> {
  let config: ResolvedConfig
  return {
    configResolved(resuelta) {
      config = resuelta
    },
    resolveId(source) {
      if (source === ID_INDICE) return ID_RESUELTO
      return null
    },
    async load(id) {
      if (id !== ID_RESUELTO) return null
      const rutas = await fg('**/*.{yaml,yml}', { cwd: config.root, absolute: true })
      const pares = await Promise.all(
        rutas.map(async (ruta) => {
          this.addWatchFile(ruta)
          return [ruta, parse(await readFile(ruta, 'utf8'))]
        }),
      )
      return `export default ${JSON.stringify(Object.fromEntries(pares))}`
    },
  }
}

Cada pieza tiene su porqué. El prefijo \0 del id resuelto marca el módulo como no-archivo, para que nadie lo busque en disco. El this.addWatchFile por cada YAML declara la dependencia: sin él, el dev server no sabría que el índice depende de esos archivos. Y el Object.fromEntries convierte la lista de pares en el objeto final que se exporta.

Falta cerrar el círculo de la reactividad. Cuando editas un YAML, el módulo virtual que lo agrega debe regenerarse. handleHotUpdate intercepta el cambio, localiza el módulo del índice por su id resuelto y lo invalida, forzando que load vuelva a correr con los datos nuevos.

handleHotUpdate({ file, server }) {
  if (!/\.ya?ml$/.test(file)) return
  const mod = server.moduleGraph.getModuleById('\0virtual:yaml-index')
  if (mod) {
    server.moduleGraph.invalidateModule(mod)
    server.ws.send({ type: 'full-reload' })
  }
}
flowchart TD
A[import de un archivo yaml] --> B[transform lo reescribe a objeto]
C[import de virtual yaml-index] --> D[resolveId mas load agregan todos]
B --> E[grafo de modulos]
D --> E
F[editar un yaml] --> G[handleHotUpdate invalida el indice]
G --> D

Empaquetar, tipar y publicar

Un plugin no está terminado hasta que otra persona puede instalarlo. La convención de nombre del ecosistema es vite-plugin-nombre, o @scope/vite-plugin-nombre si va con scope; esa forma permite que las herramientas lo descubran y que quien lo busca lo encuentre. En el package.json, Vite va en peerDependencies, no en dependencies: tu plugin no trae su propia copia de Vite, usa la del proyecto que lo instala, evitando la trampa de la doble instancia.

{
  "name": "vite-plugin-yaml",
  "type": "module",
  "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
  "peerDependencies": { "vite": "^8.0.0" },
  "dependencies": { "yaml": "^2.0.0", "fast-glob": "^3.0.0" }
}

Queda un último detalle que separa un plugin correcto de uno excelente: los tipos del módulo virtual. Sin ellos, import datos from 'virtual:yaml-index' no autocompleta y TypeScript se queja de que el módulo no existe. La solución es publicar un archivo de declaraciones que el usuario referencia desde su proyecto, para que el editor sepa qué exporta el import inventado.

// client.d.ts, referenciado por el usuario
declare module 'virtual:yaml-index' {
  const datos: Record<string, unknown>
  export default datos
}
💡
Publica los tipos del módulo virtual

El error más común al distribuir un plugin con módulos virtuales es olvidar los tipos. El plugin funciona en tiempo de ejecución, pero el editor marca el import en rojo y no ofrece autocompletado, y esa fricción basta para que la gente desconfíe. Envía un archivo de declaraciones y documenta en tu README que el usuario lo añada a su configuración de TypeScript. Un módulo virtual sin tipos está a medio hacer: da datos, pero no da la seguridad de tipos que es la mitad del valor de importarlos como módulo.

Un plugin es la suma de contratos pequeños que ya dominas

Mira el plugin terminado y verás que no hay ni una idea nueva: todo lo que hace es combinar los contratos que fuiste aprendiendo lección a lección. El enforce: 'pre' de la primera lección lo pone antes del núcleo para poder transformar un formato crudo. El transform de la segunda le enseña a Vite un tipo de archivo que no conocía, cediendo el turno con null para todo lo demás y cuidando el sourcemap. El resolveId más load de la cuarta inventan un módulo virtual que no existe en disco, marcado con el byte nulo para que nadie lo confunda con un archivo. El configResolved y el handleHotUpdate de la tercera leen la configuración y mantienen todo reactivo cuando editas. Y el empaquetado con peerDependencies y tipos aplica lo que sabes de publicar paquetes. Esa es la revelación del nivel: escribir un plugin no es dominar una API gigantesca, sino saber qué contrato pequeño resuelve cada necesidad y componerlos con criterio. El bundler no es una caja negra que hay que aplacar con configuración copiada de internet; es un motor de grafos con puntos de enganche precisos, y tú acabas de enganchar cinco de ellos para crear una capacidad que antes no existía. Cuando interiorizas que cualquier plugin del ecosistema —por sofisticado que parezca— es esta misma clase de composición, dejas de ser alguien que usa el toolchain y te vuelves alguien que lo extiende. Ese es el poder que abre este nivel, y el que te acompaña el resto del track.

⚔️ Construye tu plugin completo
  1. Escribe el transform que convierte .yaml y .yml en módulos, con enforce: 'pre' y el map de mappings vacío.
  2. Envuelve el parseo en this.error y comprueba que un YAML roto detiene el build con un mensaje que nombra el archivo.
  3. Añade el módulo virtual virtual:yaml-index que agrega todos los YAML del proyecto con un glob en load.
  4. Declara cada archivo con this.addWatchFile e invalida el índice en handleHotUpdate al editar un YAML.
  5. Prepara el package.json con la convención de nombre, vite en peerDependencies y publica un client.d.ts con los tipos del módulo virtual.