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. Y la portada necesita un sitio donde vivir:
una colección con upload, que es lo que guarda los bytes en R2.
import { defineCollection, defineConfig } from '@kevolution-co/cms'
const media = defineCollection({ slug: 'media', upload: { mimeTypes: ['image/*'], maxFileSize: 10 * 1024 * 1024 }, access: { read: 'public' }, fields: [{ name: 'alt', type: 'text', required: true }],})
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: 'media', accept: ['image/*'], admin: { position: 'sidebar' }, }, { name: 'author', type: 'relationship', to: 'users', admin: { position: 'sidebar' }, }, ],})
export default defineConfig({ collections: [media, posts], email: { from: 'noreply@tudominio.com', siteName: 'Mi sitio' },})Lo que hace cada pieza nueva:
| Pieza | Qué consigue |
|---|---|
upload en media |
Convierte la colección en almacén de ficheros: los bytes a R2, los metadatos a D1 |
mimeTypes y maxFileSize |
Qué entra en media. Un PDF o un fichero de 20 MB se rechazan al subir |
alt con required |
Ninguna imagen entra en la biblioteca sin texto alternativo |
to: 'media' en cover |
El campo guarda el id de un documento de media |
accept en cover |
Estrecha aún más: 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: media, posts y la users que el CMS añade sola. 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: son cinco
columnas nuevas en posts y una tabla media, y 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, y se sube desde otra pantalla. Abre Medios en otra pestaña, arrastra ahí tu imagen y rellena el
altquemediadeclararequired—esa pantalla pinta los campos de la colección una vez para toda la tanda—. 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 { media, 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={media.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 media.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 { media, 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={media.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/media/<id>/<filename>.
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.