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

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

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

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. 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 alt es opcional; puedes rellenarlo ahí para toda la tanda, o después, en el panel del fichero. 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 { 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.

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