Guía de productos · La colección

La colección: cómo nace un producto

Crear un producto en este sitio no es tocar código: es escribir un archivo Markdown. Cada .md es un producto; un esquema Zod .strict() valida su frontmatter en build-time, y el catálogo, la ficha y el schema se generan solos.

Esta es la pieza-raíz de la guía. Antes de hablar de categorías, imágenes, precio, ficha o schema, conviene fijar qué es un producto como DATO: una entrada en una Content Collection, no markup dibujado a mano. Esa decisión —contenido en colecciones, nunca hardcodeado— es la regla D1 del sistema y la que hace que todo lo demás se genere solo.

Con una consecuencia que conviene marcar de entrada: el contenido y el diseño viven separados. El .md dice QUÉ es el producto; el componente dice CÓMO se ve. Quien edita el catálogo escribe texto y Zod lo valida; quien mantiene el diseño toca un componente, una vez, para todos los productos. Cero duplicación, cero markup repetido.

Definición

¿Qué es la colección de productos?

La carpeta src/content/productos/ donde cada archivo Markdown es un producto. Astro la carga como Content Collection y valida su frontmatter contra un esquema Zod .strict() en build-time.

La colección de productos es el conjunto de archivos .md que viven en src/content/productos/. Cada archivo es un producto: su frontmatter (la cabecera entre ---) declara los datos —título, descripción, categoría, imagen, precio…— y su cuerpo Markdown es la descripción larga que se renderiza en la ficha. Astro 6 los carga con un loader glob y los expone con getCollection.

Lo que la vuelve una colección y no «unos archivos sueltos» es el esquema: un objeto Zod .strict() definido en src/content.config.ts que valida CADA producto cuando se compila el sitio. El esquema es el contrato —qué campos existen, de qué tipo, qué rango— y .strict() es la garantía de que nadie cuela un campo inventado. Si un .md no cumple, el build falla con un mensaje claro.

Función e importancia

¿Para qué sirve?

Hace tres trabajos a la vez: baja la barrera de crear un producto a «saber escribir», valida el contenido en build (no en producción) y convierte un solo .md en la card, la ficha, el grid y el schema.

Su función es separar el contenido del código. En un sitio sin colecciones, agregar un producto significa copiar y pegar markup, con el riesgo de romper el diseño en cada copia. Con la colección, agregar un producto es crear un .md: el grid del catálogo y la ficha de detalle se regeneran solos. Quien escribe no necesita saber Astro; el sistema se encarga del resto.

Y por eso es la pieza-raíz. El mismo archivo alimenta cuatro salidas —la card del catálogo, la ficha L4, el ItemList del grid y el Product+Offer del schema— sin duplicar el dato. La validación Zod, además, atrapa los errores cuando se compila el sitio, no cuando un cliente abre una ficha rota: el costo de un typo se paga en build, no en producción.

Crear ≠ programar

Para sumar un producto escribes un archivo Markdown, no editas un .astro. Quien mantiene el catálogo trabaja con texto; quien mantiene el diseño toca el componente una sola vez para todos los productos. La barrera de entrada baja de «saber Astro» a «saber escribir».

Validación en build, no en producción

El esquema Zod .strict() revisa cada .md cuando se compila el sitio: campos faltantes, tipos equivocados, categorías inventadas o un "hero_image:" mal escrito rompen el build con un mensaje claro —no se publican en silencio—. El error se ve antes de que lo vea un cliente.

Una fuente, muchas salidas

El mismo .md alimenta la card del catálogo, la ficha de detalle (L4), el ItemList del grid y el Product+Offer del schema. No se duplica el dato en cuatro sitios: se escribe una vez y cada vista lo lee. Cambiar el título cambia todo a la vez.

Anatomía

¿Qué lleva el frontmatter?

Cuatro grupos de campos: identidad (title + description), clasificación (category + order + draft), medios (image + gallery) y comercial/SEO (price, sku, faqs, related, seoFields). Cuatro son obligatorios; el resto, opcionales.

El frontmatter es la cabecera del .md entre dos líneas de tres guiones. Cada campo cumple un papel y Zod lo vigila: los cuatro obligatorios (title, description, category, image) garantizan que ningún producto entre incompleto al catálogo; los opcionales (price, sku, gallery, faqs, related, seoFields) enriquecen la ficha solo si vienen.

Abajo, el ejemplo en vivo —un frontmatter real anotado—. Cada punto numerado se desglosa en su tarjeta: qué resuelve y qué regla de Zod lo respalda. Es exactamente la forma que valida src/content.config.ts.

1

Identidad — title + description

El nombre de venta y la descripción corta del producto. No son libres: Zod exige title de 10–110 caracteres y description de 70–280. Ese rango fuerza títulos escaneables y descripciones que dicen algo, sin frases de una palabra ni párrafos enteros que rompan la card.

Dato title: z.string().min(10).max(110) · description: 70–280

2

Clasificación — category + order + draft

Dónde encaja y cómo se ordena. category es un enum CERRADO (equipos · accesorios · general); order define la posición en el grid; draft:true lo oculta del sitio (borrador). featured marca destacados. Son los campos que controlan el catálogo sin tocar la página.

Dato category: z.enum(PRODUCT_CATEGORIES) · order · draft · featured

3

Medios — image + gallery

La foto. image es OBLIGATORIA y se valida con una regex: ruta absoluta bajo /images/. gallery[] añade vistas extra para la ficha (opcional). Sin imagen válida, el build falla antes de publicar —no hay producto sin foto en el catálogo—.

Dato image: imagePath (regex ^/images/) · gallery: imagePath[]

4

Comercial + SEO — price, sku, faqs, related…

Todo lo opcional que enriquece la ficha: price (string libre o nada → «bajo cotización»), sku, brand, faqs (FAQPage), relatedProducts/relatedServices (interlinking tipado) y los seoFields (seoTitle, seoDescription, keywords). Se pintan solo si vienen.

Dato price? · sku? · brand? · faqs? · relatedProducts? · seoTitle?…

Variantes

Configuraciones del frontmatter

Un producto puede ir desde lo mínimo (cuatro campos) hasta una ficha rica con galería, FAQs y relacionados. Todas son configuraciones REALES del esquema actual: se obtienen poniendo o quitando campos opcionales, sin tocar content.config.ts.

No hay un único molde: hay un esquema con cuatro campos obligatorios y un puñado de opcionales que cada producto activa según lo que necesite. Un repuesto menor va al mínimo; un equipo estrella lleva galería, FAQs y relacionados. El esquema es el mismo; cambia qué campos trae el .md.

Abajo, seis configuraciones —todas válidas hoy, sin extender el esquema—. Cada una con el tipo de producto donde rinde mejor.

  • Mínimo viable (solo obligatorios)

    Producto simple · Arranque

    Los cuatro campos que Zod exige: title, description, category e image. Nada más. Genera una card y una ficha válidas con precio «bajo cotización». Es el punto de partida de cualquier producto; lo demás se añade después.

  • Con precio público

    Retail · B2C

    Suma price (string libre: «Desde $450 MXN», «$1,299»). La card y la ficha lo muestran y el Offer del schema lleva la cifra. Para catálogos con tarifa pública. Omitirlo activa el modelo cotización.

  • Con galería

    Producto visual · Equipos

    Añade gallery[] con rutas /images extra. La ficha (L4) pinta una imagen grande + miniaturas; la card sigue usando solo image. Para productos que se entienden mejor con varias vistas.

  • Con FAQs (FAQPage)

    Producto con dudas · Soporte

    faqs[] de pregunta/respuesta. La ficha pinta el acordeón y el schema emite FAQPage. Resuelve objeciones en la propia ficha y baja la carga de soporte. Misma forma {question, answer} en todo el sitio.

  • Con relacionados (interlinking)

    Cross-sell · Catálogo grande

    relatedProducts/relatedServices con reference() tipado: enlaza a otras fichas por slug, validado en build (un slug roto falla el build). Reparte autoridad interna y sugiere el siguiente paso.

  • Borrador (draft: true)

    En redacción · Roadmap

    draft:true mantiene el producto FUERA del sitio: no entra al grid, no genera ficha, no aparece en el schema. Útil para escribir a medias sin publicar. Quitar el draft (o false) lo activa.

Responsive y móvil

El mismo .md, en cualquier pantalla

El contenido no tiene breakpoints. Un producto es un dato; la card, la ficha y el schema lo leen y se adaptan solos. No existe un .md de escritorio y otro de móvil: hay uno, y el responsive vive en los componentes.

La gran ventaja de separar contenido y diseño se nota justo aquí: el frontmatter es agnóstico de la pantalla. El teléfono y el escritorio reciben el mismo producto —mismo título, misma categoría, misma foto— y cada vista decide cómo presentarlo. El .md nunca duplica datos «para móvil».

Y el filtrado y el orden (draft, order) viven en getCollection, antes de pintar: el teléfono recibe exactamente los productos publicados y en el mismo orden. Abajo, los dos principios con su vista en el teléfono y su receta.

1 · Una fuente, muchas vistas

El .md es el dato; no sabe de breakpoints. La card, la ficha y el schema lo leen y cada uno se adapta: el responsive vive en el componente, no en el contenido.

Contenido agnóstico · responsive en el componente
/* UNA FUENTE → MUCHAS VISTAS (el mismo .md, en cualquier pantalla)
   No hay un .md «de escritorio» y otro «de móvil». El frontmatter es
   el dato; cada vista (card, ficha, schema) lo lee y se adapta sola.
   El responsive vive en los componentes, NO en el contenido. */

// El .md NO sabe de breakpoints. La card sí:
.grid { grid-template-columns: 1fr; }                 /* móvil */
@media (min-width: 768px) {
  .grid { grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); }
}

2 · draft y order se respetan en todas partes

El filtro de borradores y el orden viven en getCollection, antes de pintar. El teléfono recibe los mismos productos publicados y en el mismo orden que el escritorio: cero divergencia.

getCollection · filtro + orden, una sola vez
/* draft Y order SE RESPETAN EN TODA PANTALLA
   El filtro de borradores y el orden viven en getCollection, antes
   de pintar: el teléfono recibe exactamente los mismos productos
   publicados y en el mismo orden que el escritorio. Cero divergencia. */

const productos = (await getCollection('productos', ({ data }) => !data.draft))
  .sort((a, b) => a.data.order - b.data.order)

Posición

¿Dónde vive?

Un archivo .md por producto en src/content/productos/. El nombre del archivo es el slug de la URL. El esquema que lo valida vive en src/content.config.ts; ambos se sincronizan con TAXONOMY en site.ts.

Cada producto es un archivo en src/content/productos/<slug>.md, y ese <slug> es exactamente la URL de su ficha (/productos/<slug>). Para crear un producto, creas un archivo; para borrarlo, lo borras; para renombrarlo, cambias el nombre del archivo (y los enlaces que apunten a él). No hay un índice que mantener a mano: la carpeta ES el catálogo.

El esquema que valida estos archivos vive aparte, en src/content.config.ts, y los slugs de su enum de categorías deben coincidir con TAXONOMY en site.ts —esa es la sincronía que mantiene alineados el menú, las migas y las rutas con el contenido real—. La pieza «Las categorías» de esta guía profundiza justo en eso.

Implementación

Cómo se construye

Tres piezas: el esquema Zod .strict() (content.config.ts), un archivo de producto (.md con frontmatter + cuerpo) y la lectura con getCollection en cualquier página. El loader glob conecta la carpeta con la colección.

El esquema se define una vez con defineCollection: un loader glob que apunta a src/content/productos/**/*.md y un schema Zod .strict(). Cada campo declara su tipo y su validación —strings con min/max, el enum de categorías, la regex de imagen, las referencias tipadas—. Astro corre esa validación en build y tipa el resultado, así getCollection devuelve datos ya seguros.

A partir de ahí, crear un producto es escribir el .md, y consumirlo es llamar getCollection('productos'), filtrar drafts, ordenar por order y mapear. La página no sabe cuántos productos hay ni cuáles: lee la colección. Abajo, las tres recetas reales del sistema.

content.config.ts · el esquema Zod .strict()
// src/content.config.ts — el esquema que valida CADA .md (Zod .strict()).
import { defineCollection, reference, z } from 'astro:content'
import { glob } from 'astro/loaders'

// La imagen DEBE ser ruta absoluta bajo /images/ (si no, falla el build).
const imagePath = z.string().regex(/^\/images\//)

export const PRODUCT_CATEGORIES = ['equipos', 'accesorios', 'general'] as const

const productos = defineCollection({
  loader: glob({ pattern: '**/*.md', base: './src/content/productos' }),
  schema: z.object({
    title:       z.string().min(10).max(110),
    description: z.string().min(70).max(280),
    category:    z.enum(PRODUCT_CATEGORIES),   // enum CERRADO, no string libre
    image:       imagePath,                    // OBLIGATORIA
    price:       z.string().optional(),        // string libre; omitir = cotización
    sku:         z.string().optional(),
    brand:       z.string().optional(),
    gallery:     z.array(imagePath).optional(),
    relatedProducts: z.array(reference('productos')).optional(),
    faqs:        z.array(z.object({ question: z.string(), answer: z.string() })).optional(),
    featured:    z.boolean().default(false),
    order:       z.number().default(0),
    draft:       z.boolean().default(false),
  }).strict(),   // ← rechaza cualquier campo desconocido
})

export const collections = { productos }
src/content/productos/casco-nom-115.md · un producto
---
# src/content/productos/casco-nom-115.md  → URL: /productos/casco-nom-115
title: "Casco de seguridad industrial NOM-115"
description: "Casco homologado para industria pesada, ligero y con barboquejo
  ajustable. Stock para entrega 24 h en CDMX y zona metropolitana."
category: "equipos"            # enum cerrado: equipos | accesorios | general
image: "/images/productos/casco-seguridad-industrial.avif"
price: "Desde $450 MXN"        # opcional → sin él, CTA "bajo cotización"
sku: "EQ-CASCO-115"
brand: "EJEMPLOS"
order: 1                        # posición en el grid (menor = primero)
featured: true
faqs:
  - question: "¿El casco cumple la NOM-115-STPS?"
    answer: "Sí, está homologado y se entrega con su certificado."
---

## Sobre este producto

Cuerpo en **Markdown**: se renderiza en la ficha de detalle. Aquí va la
descripción larga, listas de beneficios, tablas de especificaciones, etc.
Astro · leer la colección con getCollection
---
// Cualquier página lee la colección con getCollection. Una sola fuente.
import { getCollection } from 'astro:content'
import ProductCard from '@components/ProductCard.astro'

// Filtra borradores (draft) y ordena por 'order'.
const productos = (await getCollection('productos', ({ data }) => !data.draft))
  .sort((a, b) => a.data.order - b.data.order)
---

<div class="grid">
  {productos.map((p, i) => (
    <ProductCard
      title={p.data.title}
      href={`/productos/${p.id}`}
      image={p.data.image}
      badge={p.data.category}
      description={p.data.description}
      index={i}
    />
  ))}
</div>

En concreto: defineCollection recibe un loader: glob(...) que carga todos los .md de la carpeta y un schema que es un z.object(...) cerrado con .strict(). Astro valida cada archivo en build; un fallo (campo faltante, tipo erróneo, categoría fuera del enum, imagen sin /images/) detiene la compilación con la ruta del archivo y el campo culpable.

Al consumir, getCollection('productos', filtro) devuelve un array tipado donde cada entrada trae id (el slug, = nombre del archivo) y data (el frontmatter validado). El patrón del sitio es filtrar !data.draft, ordenar por data.order y mapear a ProductCard en el grid o renderizar el cuerpo con render() en la ficha. El cuerpo Markdown se vuelve HTML automáticamente.

Buenas prácticas

Qué hacer y qué evitar

Casi todo se sostiene solo cuando se respeta la colección: el esquema valida, el slug sale del nombre del archivo y el draft oculta lo incompleto. El detalle a vigilar: no inventar campos ni categorías fuera del esquema.

Ninguno de estos hábitos es capricho: salen del diseño de la colección y del esquema Zod. La regla de oro es dejar que el esquema mande —si rechaza algo, casi siempre el .md está mal, no el esquema—. Mantener el contenido en la colección (nunca en .astro) es lo que conserva el grid, la ficha y el schema sincronizados.

La buena noticia es que el sistema avisa: un campo mal escrito, una categoría inventada o una imagen fuera de /images/ rompen el build con un mensaje claro, antes de publicar. Abajo, lo que conviene y lo que conviene evitar, enfrentados.

Sí conviene

  • Crea UN archivo .md por producto en src/content/productos/. El nombre del archivo es el slug de la URL (/productos/<slug>).
  • Respeta los rangos de Zod: title 10–110, description 70–280. Si el build se queja, ajusta el texto —no relajes el esquema sin razón—.
  • Usa draft:true mientras escribes un producto incompleto: queda fuera del sitio hasta que lo publiques (quita el draft o ponlo en false).
  • Ordena con el campo order (número). Productos sin order caen al final; reserva 1, 2, 3… para los que van primero en el grid.
  • Apóyate en el .strict(): si Zod rechaza un campo, es que no existe en el esquema —revisa la ortografía antes de añadirlo a content.config.ts—.

Mejor evita

  • NO hardcodees un producto dentro de un .astro: rompe la regla D1 (toda entidad repetible vive en una colección) y se queda fuera del schema y del grid.
  • NO inventes campos en el frontmatter: con .strict() un campo desconocido (p. ej. "imagen:" en vez de "image:") falla el build, no se ignora en silencio.
  • NO uses una categoría fuera del enum: Zod la rechaza. Si necesitas una nueva, añádela al enum en content.config.ts Y a TAXONOMY en site.ts.
  • NO dejes la imagen apuntando a una ruta relativa o fuera de /images/: la regex la rechaza. Siempre ruta absoluta bajo /images/.
  • NO publiques los .md DEMO: reemplázalos por productos reales antes de lanzar. Son ejemplos para ver el esquema, no contenido de cliente.
¿Necesitas ayuda?