Ir al contenido

Integración de Astro

La única pieza que instalas. Monta el admin, la API REST y Astro.locals.cms a partir de tu cms.config.ts.

astro.config.mjs
import cloudflare from '@astrojs/cloudflare'
import react from '@astrojs/react'
import cms from '@kevolution-co/cms/astro'
import { defineConfig } from 'astro/config'
import cmsConfig from './cms.config'
export default defineConfig({
output: 'server',
adapter: cloudflare(),
integrations: [react(), cms(cmsConfig)],
})

El config se importa y se pasa. La integración lo usa para las rutas y el codegen, y sus propias rutas lo vuelven a importar del fichero, así que el objeto que le pases y el que lea el worker tienen que ser el mismo: importa tu cms.config.ts y no construyas otro ahí mismo.

function cms(config: ResolvedConfig, options?: IntegrationOptions): AstroIntegration
interface IntegrationOptions {
bindings?: { db?: string; bucket?: string; kv?: string; email?: string }
cors?: string[]
skipCodegen?: boolean
config?: string
}
Opción Por defecto Qué hace
bindings.db 'DB' Nombre del binding de D1 del que sale el contenido
bindings.bucket 'R2' Binding de R2 donde se guardan los ficheros de las colecciones con upload
bindings.kv 'KV' Binding de KV, donde viven los ajustes del sitio bajo el prefijo settings: y cada global bajo global:
bindings.email 'EMAIL' Binding de Email Sending, del que salen los correos de verificación y restablecimiento. Ver Email
cors sin CORS Orígenes permitidos en la API REST. Ver CORS
skipCodegen false No regenera .cms/ al arrancar. Exige que ya exista
config busca cms.config.{ts,js,mjs} en la raíz Ruta explícita al config

Se hacen en astro:config:setup, antes de cablear nada: un error al arrancar es infinitamente mejor que un undefined a mitad de una petición. Cada fallo dice qué instalar y qué línea añadir.

Requisito Por qué
@astrojs/cloudflare Los bindings solo existen en el runtime de Workers
@astrojs/react El admin son islas de React
output: 'server' El admin y la API se renderizan bajo demanda

Que el tsconfig.json incluya ./.cms/types.d.ts es un aviso, no un error: sin él todo funciona, solo pierdes el tipado de Astro.locals.cms.

Patrón Qué sirve
${admin.path}/[...path] El panel del admin
${api.path}/[...path] Los seis endpoints de la API REST, más las rutas de ficheros, las dos de cada global y la de ajustes
/api/auth/[...all] La autenticación

Los dos primeros patrones salen de tu config, así que mover el admin a /panel es cambiar una línea de cms.config.ts. La de autenticación es fija. Las tres declaran prerender = false por su cuenta: tus páginas estáticas siguen siendo estáticas.

/admin sirve un documento HTML entero, así que la hoja de estilos de tu sitio no llega sola. admin.css en cms.config.ts es una ruta —relativa a la raíz de tu proyecto de Astro— que el panel importa después de la suya:

export default defineConfig({
collections: [posts],
admin: { css: './src/styles/app.css' },
})

El panel define sus colores dentro de @layer base, así que cualquier declaración tuya sin capa le gana por la regla de capas de la cascada, y si tú también usas @layer base ganas por llegar después. Si defines las variables de shadcn —--background, --primary, --radius, las de --sidebar-*…— el panel se pinta con las tuyas sin que tengas que tocar nada más.

Sus utilidades, en cambio, no son tuyas para pisar: viven en una capa propia, kevin-cms, declarada después de utilities. Si no fuera así, tu hoja —que se importa la segunda— ganaría cualquier colisión de nombre, incluidas las que nunca pediste: bastaría con que escribieras hidden en cualquier fichero para que tu .hidden llegara después del @media de md:block del panel y le tumbara la barra lateral. Lo que sí es un punto de extensión son los componentes de campo.

No hace falta que instales Tailwind ni que toques su config: dist/admin.css se publica ya compilado. Si la ruta no existe, la integración falla al arrancar con ADMIN_CSS_NOT_FOUND en lugar de dejarte un error de Vite en la primera petición.

Si no declaras admin.css, el panel la busca solo. Lee el components.json de la raíz de tu proyecto —el que deja shadcn— y toma su tailwind.css. Si esa ruta existe, la usa igual que si la hubieras escrito a mano, así que un proyecto con shadcn ve su tema desde el primer arranque sin configurar nada. Solo mira ahí: sin components.json no hay detección, y no se prueban rutas convencionales.

La precedencia es estricta —lo que declares gana sobre lo detectado, y lo detectado sobre el tema del panel— y el arranque dice en el log cuál de los tres ha cogido. Que la detección exista no ablanda el error de arriba: un admin.css que apunta a un fichero inexistente sigue siendo ADMIN_CSS_NOT_FOUND, porque esa ruta la escribiste tú. Lo que no rompe nada es el otro lado: un components.json ausente, mal formado o con una tailwind.css que ya no existe deja el panel con su propio tema y sigue arrancando.

El panel lleva su propio conmutador: /admin sirve un <html> distinto del de tu sitio, así que la clase .dark que tú pongas en tus páginas no le llega. Lo que sí comparten es el localStorage del origen, y el paquete exporta la clave que usa:

import { THEME_KEY } from '@kevolution-co/cms'
export function ThemeToggle() {
return (
<button
onClick={() => {
const dark = document.documentElement.classList.toggle('dark')
localStorage.setItem(THEME_KEY, dark ? 'dark' : 'light')
}}
>
Cambiar el tema
</button>
)
}

Con eso, cambiar el tema en tu sitio cambia también el del panel la próxima vez que se abra. Si prefieres que vayan por separado, usa cualquier otra clave y no pasa nada: son dos documentos independientes.

Para que no haya un parpadeo del tema claro antes del oscuro, la lectura tiene que correr antes del primer pintado, así que va en un <script is:inline> dentro de <head> y no en una isla —cuando React hidrata, la versión clara ya se ha pintado—. Envuélvelo en try/catch: localStorage lanza directamente en un navegador con las cookies bloqueadas, y un tema no vale una página en blanco.

Un middleware con order: 'pre' —para que el middleware de tu aplicación ya lo encuentre puesto— deja en locals.cms las colecciones y los globals del config, más la autenticación:

---
export const prerender = false
const { docs } = await Astro.locals.cms.collections.posts.find({ where: { status: { equals: 'published' } } })
---

El tipado sale de .cms/types.d.ts, que augmenta App.Locals. No declaras nada.

La construcción es perezosa: locals.cms es un accesor y no se lee ningún binding hasta que algo pide una colección. Una página que solo dibuja una portada estática no paga nada.

locals.cms.auth trae getSession(), getUser() y requireUser(). Su caché de sesión es por petición, mientras que collections se construye una vez por isolate.

locals.cms.email trae send(), para mandar correo transaccional desde tus propias páginas. Ver Email.

locals.cms.globals.<slug> trae get() y update(), el documento único de cada global. Sale del mismo buildAPI que las colecciones —un solo accesor perezoso detrás de los dos— y el codegen lo tipa igual, con una interfaz por global en .cms/types.d.ts. Un global vive en KV, así que declararlos no añade ninguna tabla.

locals.cms.settings trae get() y update(), los ajustes del sitio guardados en KV. Como email, es un accesor anidado: una página que no los lee no resuelve el binding.

Al arrancar, la integración escribe .cms/schema.ts y .cms/types.d.ts junto a tu cms.config.ts, y registra ese fichero en el watcher: al guardarlo, Astro reinicia y se regeneran.

Generar o aplicar una migración nunca es automático. En dev, si no hay ninguna migración o si el config se ha movido más allá de la última, lo avisa con el comando que toca.

Las rutas y el middleware viven dentro del paquete, así que no pueden importar un fichero tuyo por ruta relativa. Cuatro módulos virtuales cierran el hueco.

virtual:kevin-cms/server lleva el config, el schema de Drizzle y los nombres de los bindings. No se resuelve en el entorno del navegador, a propósito.

virtual:kevin-cms/config es lo que consume el admin, que corre en el navegador, y por eso lleva solo la parte pública y serializable:

Va No va
admin.path, admin.auth, api.path Los nombres de los bindings
Slug, etiquetas y opciones de admin de cada colección y de cada global access, que es la política de autorización
Nombre, tipo y validaciones de cada campo Rutas absolutas de tu máquina
upload y auth de la colección Un defaultValue que sea una función
admin.css y admin.fieldComponents, que son rutas de build

virtual:kevin-cms/theme es un import de la hoja de estilos que haya ganado —la que declares en admin.css o la que se detecte desde components.json—, o export {} cuando no hay ninguna. Ver El tema del panel.

virtual:kevin-cms/field-components es un import por cada componente de campo que hayas sustituido con admin.fieldComponents, y un objeto con todos ellos; export default {} si no has sustituido ninguno. Ver Poner el tuyo.

Ese último se resuelve en el navegador, a diferencia de virtual:kevin-cms/server: su contenido son componentes de React, que es justo lo que el panel necesita allí. Las rutas se comprueban en astro:config:setup, así que una que no existe es un error al arrancar y no un fallo de resolución de Vite en la primera petición a /admin.