Tu primera colección
Al terminar esta página tendrás un post escrito desde /admin —con su portada subida a R2 y su autor
elegido de la colección users— pintándose en /posts/<slug>, y un listado en la portada de tu sitio
que lo enlaza.
Partimos de un proyecto que ya ha pasado por Instalación: el paquete
instalado, los bindings de Cloudflare declarados, drizzle-kit configurado y la colección posts de
tres campos ya migrada. Aquí no se vuelve a crear nada de eso.
Haz crecer el modelo
Sección titulada «Haz crecer el modelo»Tres campos bastan para comprobar que la instalación funciona, pero no para un blog. Un blog necesita
un resumen, una fecha, un estado, una portada y un autor. La portada no necesita ninguna colección
nueva: el paquete trae la suya, ssd, que es la biblioteca de medios del panel y existe desde que
instalas. Basta con apuntarle.
import { defineCollection, defineConfig } from '@kevolution-co/cms'
const posts = defineCollection({ slug: 'posts', access: { read: 'public' }, admin: { useAsTitle: 'title', defaultColumns: ['title', 'status', 'publishedAt'], }, fields: [ { name: 'title', type: 'text', required: true }, { name: 'slug', type: 'text', required: true, unique: true, index: true }, { name: 'excerpt', type: 'textarea', admin: { description: 'Resumen para los listados' }, }, { name: 'body', type: 'textarea', admin: { description: 'Markdown' } }, { name: 'status', type: 'select', required: true, defaultValue: 'draft', options: [ { label: 'Borrador', value: 'draft' }, { label: 'Publicado', value: 'published' }, ], admin: { position: 'sidebar' }, }, { name: 'publishedAt', type: 'date', admin: { position: 'sidebar' } }, { name: 'cover', type: 'upload', to: 'ssd', accept: ['image/*'], admin: { position: 'sidebar' }, }, { name: 'author', type: 'relationship', to: 'users', admin: { position: 'sidebar' }, }, ],})
export default defineConfig({ collections: [posts], email: { from: 'noreply@tudominio.com', siteName: 'Mi sitio' },})Lo que hace cada pieza nueva:
| Pieza | Qué consigue |
|---|---|
to: 'ssd' en cover |
El campo guarda el id de un fichero de la biblioteca, que el CMS aporta sin declararla |
accept en cover |
Estrecha lo que ese campo admite: en la portada solo cabe una imagen |
to: 'users' en author |
Apunta a la colección de usuarios, que el CMS añade sola |
admin.position: 'sidebar' |
Manda ese campo a la columna derecha del editor, junto a las acciones |
access.read: 'public' |
Abre las lecturas de la API REST. Escribir sigue exigiendo sesión |
Los detalles de cada tipo están en Campos, y los de las colecciones con ficheros en Ficheros.
El config no crea tablas: genera SQL que tú lees antes de aplicar.
pnpm cms db:migrate✔ Cargando config✔ Emitiendo .cms/3 colecciones desde /mi-sitio/cms.config.ts /mi-sitio/.cms/schema.ts /mi-sitio/.cms/types.d.tsTres, y solo has declarado una: posts, la users de la autenticación y la ssd de la biblioteca,
que el CMS añade solas. Son las mismas tres de la instalación, así que aquí no aparece ninguna tabla
nueva. Detrás de esas líneas verás las de drizzle-kit, que es quien escribe el SQL y habla por su
cuenta.
Abre el migration.sql que acaba de aparecer en migrations/ y léelo antes de seguir: están
las cinco columnas nuevas de posts y, detrás, el rodeo que SQLite obliga a dar para añadir el
CHECK del select —copia la tabla en __new_posts, se lleva las filas, tira la vieja con un
DROP TABLE y renombra la copia—. Es la última oportunidad de verlo antes de que exista.
pnpm cms db:applyAplicando en local sobre el binding DBNo hace falta decirle cuál es la base: la saca del binding de tu wrangler.jsonc. Detrás de esa línea
habla wrangler, que cuenta cuántos comandos aplicó.
Si el SQL no te convence, pnpm cms db:pop borra esa migración y vuelves a editar el config. El
ciclo entero está en Schema y migraciones.
Escribe el post en /admin
Sección titulada «Escribe el post en /admin»pnpm dev-
Abre
http://localhost:4321/admin. Con la tablausersvacía no hay login que enseñar, así que cualquier ruta del panel te lleva al asistente en/admin/setup. -
El asistente pide dos cosas a la vez: los ajustes del sitio —nombre, URL pública, remitente del correo y dirección de respuesta— y tu cuenta —nombre, email y contraseña—. Al enviarlo se guardan los ajustes, se crea tu cuenta ya verificada y entras con la sesión abierta. A partir de ahí el registro queda cerrado:
/admin/setupresponde404. -
En la barra lateral verás Posts arriba,
Usersbajo Autenticación y Medios abajo. Entra en Posts y pulsa crear. -
Rellena
title,slug,excerptybody. Elbodyes Markdown en untextarea: encabezados, listas y enlaces se escriben tal cual y se convierten al pintarlos. -
A la derecha, en la columna que te ha dado
admin.position: 'sidebar', estánstatus,publishedAt,coveryauthor. Ponstatusen Publicado y elige una fecha enpublishedAt. -
La portada se sube antes de elegirla. Abre Medios en otra pestaña y arrastra ahí tu imagen: cae en la carpeta que estés viendo —la raíz, si no has entrado en ninguna— y si escribes un nombre en Carpeta, esa carpeta nace con este primer fichero. El
altes opcional; puedes rellenarlo ahí para toda la tanda, o después, en el panel del fichero. Pulsa Subir. -
Vuelve a la pestaña del post y pulsa Elegir en
cover. El diálogo lista tu biblioteca filtrada por elacceptdel campo, con la imagen que acabas de subir. Selecciónala. -
En
author, escribe las primeras letras de tu correo. El buscador consultauserspor suuseAsTitle, que esemail, y te devuelve las primeras coincidencias. Elige la tuya. -
Guarda.
⌘SoCtrl+Stambién valen.
Píntalo en tu sitio
Sección titulada «Píntalo en tu sitio»Falta una dependencia: el body es Markdown y alguien tiene que convertirlo en HTML.
pnpm add markdown-itUna relación se tipa como string | Doc | null, porque depth decide en tiempo de ejecución si llega
el id o el documento entero. Las dos páginas necesitan estrecharla, así que el estrechamiento va en un
sitio y no en dos:
/** El documento que hay tras una relación, o `null` si llegó como id o no hay ninguno */export function resolved<T>(value: string | T | null): T | null { if (value === null || typeof value === 'string') return null
return value}El listado
Sección titulada «El listado»---import { resolved } from '../lib/content'
export const prerender = false
const { ssd, posts } = Astro.locals.cms.collections
const { docs } = await posts.find({ where: { status: { equals: 'published' } }, sort: '-publishedAt', limit: 10, depth: 1,})---
<ul> { docs.map((post) => { const cover = resolved(post.cover)
return ( <li> {cover !== null && <img src={ssd.url(cover)} alt={cover.alt ?? ''} width="320" />} <h2><a href={`/posts/${post.slug}`}>{post.title}</a></h2> {post.excerpt !== null && <p>{post.excerpt}</p>} </li> ) }) }</ul>where deja fuera los borradores, sort los ordena por fecha descendente y depth: 1 convierte
cover de un id en el documento entero, que es lo que ssd.url() necesita para construir la ruta
canónica del fichero. Una sola llamada. Todo lo que admite find está en
Leer y escribir contenido.
La página del post
Sección titulada «La página del post»---import MarkdownIt from 'markdown-it'import { resolved } from '../../lib/content'
export const prerender = false
const { slug } = Astro.paramsconst { ssd, posts } = Astro.locals.cms.collections
const post = slug === undefined ? null : await posts.findOne({ where: { slug: { equals: slug } }, depth: 1 })
if (post === null) { return new Response('No existe ningún post con ese slug.', { status: 404 })}
const cover = resolved(post.cover)const author = resolved(post.author)
const md = new MarkdownIt({ html: false, linkify: true, typographer: true })const body = post.body === null || post.body.trim() === '' ? null : md.render(post.body)---
<article> <h1>{post.title}</h1>
{author !== null && <p>Por {author.name ?? author.email}</p>} { post.publishedAt !== null && ( <time datetime={post.publishedAt.toISOString()}> {post.publishedAt.toLocaleDateString('es-ES')} </time> ) }
{cover !== null && <img src={ssd.url(cover)} alt={cover.alt ?? ''} />} {post.excerpt !== null && <p>{post.excerpt}</p>} {body !== null && <div set:html={body} />}</article>html: false no es decoración: escapa el HTML crudo en vez de dejarlo pasar, así que un <script>
tecleado en el editor sale como texto y no se ejecuta en el navegador de quien lee. Todo lo que hay en
body viene de un formulario.
Abre http://localhost:4321/ y ahí está tu post, con su portada servida desde R2 en
/api/cms/cdn/ssd/<id>/<filename>. La carpeta no sale en la URL: la clave del objeto la lleva, pero
lo que identifica al fichero es su id.
Lo siguiente
Sección titulada «Lo siguiente»- Subirlo a producción, en el orden correcto: Despliegue.
- Lo que falla la primera hora, por síntoma: Solución de problemas.
- Lo que la versión 1 no hace: Limitaciones.