Ir al contenido

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.

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.

cms.config.ts
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.

Ventana de terminal
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.ts

Tres: 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.

Ventana de terminal
pnpm cms db:apply
Aplicando en local sobre el binding DB

No 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.

Ventana de terminal
pnpm dev
  1. Abre http://localhost:4321/admin. Con la tabla users vacía no hay login que enseñar, así que cualquier ruta del panel te lleva al asistente en /admin/setup.

  2. 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/setup responde 404.

  3. En la barra lateral verás Posts arriba, Users bajo Autenticación y Medios abajo. Entra en Posts y pulsa crear.

  4. Rellena title, slug, excerpt y body. El body es Markdown en un textarea: encabezados, listas y enlaces se escriben tal cual y se convierten al pintarlos.

  5. A la derecha, en la columna que te ha dado admin.position: 'sidebar', están status, publishedAt, cover y author. Pon status en Publicado y elige una fecha en publishedAt.

  6. 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 alt que media declara required —esa pantalla pinta los campos de la colección una vez para toda la tanda—. Pulsa Subir.

  7. Vuelve a la pestaña del post y pulsa Elegir en cover. El diálogo lista tu biblioteca filtrada por el accept del campo, con la imagen que acabas de subir. Selecciónala.

  8. En author, escribe las primeras letras de tu correo. El buscador consulta users por su useAsTitle, que es email, y te devuelve las primeras coincidencias. Elige la tuya.

  9. Guarda. ⌘S o Ctrl+S también valen.

Falta una dependencia: el body es Markdown y alguien tiene que convertirlo en HTML.

Ventana de terminal
pnpm add markdown-it

Una 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:

src/lib/content.ts
/** 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
}
src/pages/index.astro
---
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.

src/pages/posts/[slug].astro
---
import MarkdownIt from 'markdown-it'
import { resolved } from '../../lib/content'
export const prerender = false
const { slug } = Astro.params
const { 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>.