---
title: "Cómo crear un tema personalizado de EmDash: tokens, bloques y esquema seed"
description: "Parte 3 de la serie EmDash: cada capa de un tema personalizado (design tokens, colecciones de seed.json, tipos de bloque, rutas de página, menús, widgets y contenido semilla) con un ejemplo real en producción."
date: 2026-09-30
categories: ["web-development"]
tags: ["emdash","astro","themes"]
---

import Button from "@components/widgets/Button.astro";
import Notice from "@components/widgets/Notice.astro";
import ListCheck from "@components/widgets/ListCheck.astro";
import Accordion from "@components/widgets/Accordion.astro";

Esta es la Parte 3 de la serie EmDash. La [Parte 1 reseñó el CMS en su 1.0](/emdash-cms-review/) y la [Parte 2 dejó un sitio corriendo en local](/emdash-cms-tutorial/) sobre Node.js y SQLite. Esta parte trata de la capa que de verdad es tuya: el tema. Cubre cómo se arma un tema de EmDash y cómo construir el tuyo propio: design tokens, el esquema seed, tipos de bloque, rutas, menús y contenido semilla.

El ejemplo que recorre el artículo es real: [emdashhq.com](https://emdashhq.com/), el sitio hub de EmDash, arrancó desde la plantilla marketing de serie y se reestilizó a un aspecto de hoja de plano blueprint con seis tipos de bloque personalizados. El despliegue queda deliberadamente fuera de alcance; la siguiente parte cubre Cloudflare Workers, D1 y R2.

![El tema personalizado terminado: portada de emdashhq.com con fondo de rejilla blueprint, hero estilo ficha de especificaciones, tarjeta con diagrama de arquitectura e índice de tutoriales](@assets/images/26/09/emdash-home.webp)

<Button text="Lee la Parte 2: el tutorial" link="/emdash-cms-tutorial/" variant="outline" color="blue" size="md" icon="arrow-right" />

## Qué es realmente un tema de EmDash

<YouTubeEmbed
  url="https://www.youtube.com/embed/6puJmWg8e3Y"
  label="EMDash Build Theme"
/>

No hay tienda de temas, ni jerarquía de temas padre/hijo, ni runtime de temas. Según la [documentación oficial de temas](https://docs.emdashcms.com/themes/creating-themes/), un tema de EmDash es un proyecto Astro completo: rutas, layouts, componentes, estilos y un `seed/seed.json` que declara el modelo de contenido que las plantillas esperan. Tras el scaffolding, cada archivo es tuyo para editarlo directamente.

Eso divide un tema personalizado en dos mitades:

- **La apariencia**: design tokens en `src/styles/tokens.css`, tus overrides en `src/styles/theme.css`, fuentes en `astro.config.mjs` y un componente Astro por cada bloque de página.
- **El modelo**: `seed/seed.json` declara colecciones, campos, tipos de bloque, taxonomías, menús, áreas de widgets y contenido de ejemplo. El asistente de setup lo aplica una sola vez, sobre una base de datos vacía.

Ambas mitades viven en tu repo. El contenido vive en la base de datos y nunca está en el repo.

## Paso 1: elige la plantilla base adecuada

`create-emdash` trae cinco plantillas, cada una con una variante Node.js y una variante Cloudflare. Parte de la más cercana a tu objetivo. Conservas sus rutas y convenciones de seed en lugar de reconstruirlas:

| Plantilla | Modelo de contenido | Úsala cuando |
|---|---|---|
| `blank` | Seed por defecto integrado | Quieres cero opiniones y lo escribirás todo |
| `starter` | Páginas básicas | Un punto de partida delgado para un diseño propio |
| `blog` | Posts, páginas, categorías, etiquetas, widgets | Un sitio de contenido con un archivo de posts estándar |
| `portfolio` | Proyectos + páginas | Un sitio escaparate |
| `marketing` | Páginas compuestas con bloques versionados | Sitios tipo landing donde los editores arman las páginas |

La plantilla marketing es la base más instructiva: trae el editor de campos `blocks`, cinco tipos de bloque (hero, features, testimonials, pricing, FAQ), una ruta catch-all de páginas y el sistema de tokens en dos capas. Todos los patrones de bloque de abajo vienen de ella.

```bash
npm create emdash@latest
# Project name: my-site
# Where will you deploy? Node.js     (swap later; the theme is adapter-agnostic)
# Which template? Marketing
```

## Paso 2: el mapa de archivos del tema

Antes de estilar nada, conviene saber qué archivo es dueño de qué:

| Archivo | Es dueño de |
|---|---|
| `astro.config.mjs` | Adapter, integraciones, opciones de `emdash()`, fuentes, lista include de iconos |
| `src/live.config.ts` | La colección live `_emdash`. Boilerplate; no lo toques |
| `src/styles/tokens.css` | Valores por defecto de los design tokens dentro de `@layer base` |
| `src/styles/theme.css` | Tus overrides. Sin capa, así que siempre ganan |
| `seed/seed.json` | Modelo de contenido + contenido de ejemplo + menús + ajustes |
| `emdash-env.d.ts` | Tipos generados para cada colección y bloque. Regenera, nunca edites |
| `src/layouts/Base.astro` | Shell del documento: ajustes, menús, `<EmDashHead>`, fuentes, toggle de tema |
| `src/pages/[...slug].astro` | Una ruta que renderiza cada entrada de `pages` |
| `src/components/blocks/*.astro` | Un componente por tipo de bloque |

`package.json#emdash.seed` apunta a la ruta del seed; mantenlo si mueves el archivo.

## Paso 3: reestiliza con design tokens

El rebrand más rápido toca un solo archivo. `tokens.css` declara cada valor por defecto dentro de `@layer base`; `theme.css` va sin capa, así que una declaración ahí gana al default sin juegos de especificidad. Los colores usan `light-dark(light, dark)`, que lleva ambos modos en un solo token:

```css
/* src/styles/theme.css */
:root {
	/* Paper and ink */
	--color-bg: light-dark(#f6f8fb, #0a192f);
	--color-text: light-dark(#0a1c38, #e2edfd);
	--color-muted: light-dark(#475c7e, #8ea6cc);
	--color-border: light-dark(#d3dfef, #1e3a66);
	--color-surface: light-dark(#ffffff, #0f274a);

	/* Brand */
	--color-brand: light-dark(#1d4ed8, #60a5fa);
	--color-brand-strong: light-dark(#1e40af, #3b82f6);
	--color-accent: light-dark(#0284c7, #22d3ee);
	--radius: 8px;
}
```

La superficie de tokens cubre todo lo que un tema necesita:

| Grupo | Tokens |
|---|---|
| Colores | `--color-bg`, `--color-surface`, `--color-text`, `--color-muted`, `--color-border`, `--color-brand(-strong/-soft/-ring)`, `--color-accent`, `--color-on-brand`, `--color-success/-warning/-danger` |
| Gradientes | `--gradient-brand(-strong/-soft)`, `--gradient-headline`; derivan de los colores de marca, o usa colores sólidos para un look plano |
| Tipografía | `--font-body`, `--font-heading`, `--font-mono`, `--font-weight-heading/-display`, `--font-size-xs` hasta `--font-size-6xl`, `--leading-*`, `--tracking-*` |
| Layout | `--spacing-xs`–`--spacing-5xl`, `--max-width`, `--wide-width`, `--radius-*` |
| Detalle | `--line-hair`, `--line-ink`, `--transition-fast/-base/-slow`, `--shadow-sm` hasta `--shadow-xl` |

Dos reglas mantienen honesto el sistema de doble modo. Primera, sobrescribe un token `light-dark()` con `light-dark()`. Un valor plano repinta ambos modos a la vez, que a veces es lo que quieres y a menudo no. Segunda, mantén el bloque fallback `@supports not (color: light-dark(#000,#fff))` en `theme.css` reflejando tus valores claros para navegadores antiguos.

Las fuentes vienen del pipeline de fuentes de Astro en `astro.config.mjs`, no de imports CSS:

```js
fonts: [
	{
		provider: fontProviders.google(),
		name: "Archivo",
		cssVariable: "--font-body",
		weights: [400, 500, 600, 700, 800],
		fallbacks: ["system-ui", "sans-serif"],
	},
	{
		provider: fontProviders.google(),
		name: "IBM Plex Mono",
		cssVariable: "--font-mono",
		weights: [400, 500, 600],
		fallbacks: ["ui-monospace", "monospace"],
	},
],
```

El layout emite cada cara con `<Font cssVariable="--font-body" preload />` de `astro:assets`, que se encarga de la descarga, la variable CSS y el preload. Cambia el `name`, conserva el nombre de la variable, y todo el sitio lo sigue. Mantén `--font-heading` apuntando a `--font-body` para una sola voz, o vincula una segunda cara para los encabezados.

## Paso 4: declara el modelo de contenido en seed.json

`seed/seed.json` es el esquema que tu tema asume. Claves de primer nivel: `$schema`, `version`, `meta`, `settings`, `blockTypes`, `collections`, `taxonomies`, `menus`, `widgetAreas`, `sections`, `bylines`, `content`.

### Colecciones

Cada colección se convierte en una entrada del sidebar del admin y en una tabla de base de datos:

```json
{
	"slug": "tutorials",
	"label": "Tutorials",
	"labelSingular": "Tutorial",
	"supports": ["drafts", "revisions", "search"],
	"group": "Content hub",
	"sortOrder": 10,
	"fields": [
		{ "slug": "title", "label": "Title", "type": "string", "required": true, "searchable": true },
		{ "slug": "summary", "label": "Summary", "type": "text", "searchable": true },
		{ "slug": "url", "label": "External URL", "type": "url", "required": true },
		{ "slug": "image", "label": "Cover", "type": "image" },
		{ "slug": "topic", "label": "Topic", "type": "select",
			"validation": { "options": ["install", "theme", "deploy", "plugins"] } },
		{ "slug": "featured", "label": "Featured", "type": "boolean", "defaultValue": false }
	]
}
```

Cosas que conviene saber:

- `supports` activa flujos de trabajo por colección: `drafts`, `revisions`, `preview`, `scheduling`, `search`, `seo`, además de `commentsEnabled` para el sistema de comentarios integrado.
- `group` pliega colecciones relacionadas bajo una sección de admin colapsable; `sortOrder` controla el orden del sidebar.
- `urlPattern` (p. ej. `/posts/{slug}`) define permalinks para colecciones ruteables; hazlo coincidir con tus rutas Astro.
- `searchable: true` en un campo alimenta el índice de texto completo. Junto con el support `search`, es obligatorio antes de que `LiveSearch` devuelva nada.

Tipos de campo: `string`, `text`, `url`, `number`, `integer`, `boolean`, `datetime`, `select`, `multiSelect`, `image`, `file`, `reference`, `slug`, `repeater`, `portableText`, `blocks`, `json`. Dos formas importan más que el resto: `image` es un objeto `{ id, src?, alt?, width?, height? }`, nunca un string, y `blocks` es la composición ordenada de página que cubrimos a continuación.

### Taxonomías, menús, áreas de widgets, secciones y ajustes

Las claves de seed más pequeñas mapean a funciones del admin:

- **`taxonomies`** adjunta sistemas de términos a colecciones: `{ "name": "category", "hierarchical": true, "collections": ["posts"], "terms": [...] }`. Consúltalas con `getTaxonomyTerms("category")`. El argumento name debe coincidir exactamente con el `name` del seed, o obtienes resultados vacíos sin error.
- **`menus`** siembra menús con nombre (`primary`, `header_cta`, columnas de footer). Los items son `{ "type": "custom", "label": "Tutorials", "url": "/tutorials/" }`; los editores los reordenan y extienden sin código.
- **`widgetAreas`** siembra regiones con nombre (como un `sidebar`) que contienen widgets `content` (rich text), `menu` o `component`; los componentes core incluyen `core:search`, `core:categories`, `core:tags`, `core:recent-posts`, `core:archives`.
- **`sections`** siembra snippets reutilizables de Portable Text que los editores insertan vía el slash command `/section`. Práctico para un patrón de CTA o aviso repetido.
- **`settings`** siembra la identidad del sitio: `title`, `tagline`, `logo`, `favicon`, `social`, `timezone`, `dateFormat`.

## Paso 5: tipos de bloque, la mitad maquetadora del tema

Esta es la parte que hace que EmDash se sienta como un page builder mientras sigue dirigido por código. Un campo `blocks` en una colección guarda una lista ordenada de bloques tipados; cada `_type` mapea a un componente Astro.

### Campo blocks frente a portableText

Usa un **campo `blocks`** cuando el valor es una composición a nivel de página: una pila ordenada de secciones que un editor añade, quita y reordena. Usa **`portableText`** para un documento de texto enriquecido (párrafos, encabezados, listas, imágenes) donde los objetos personalizados viven *dentro* del flujo, renderizados vía `<PortableText components={{ type: customMap }} />`. La colección `pages` de emdashhq es toda `blocks`; el cuerpo de un post de blog sería `portableText`.

```json
{
	"slug": "content",
	"label": "Content",
	"type": "blocks",
	"validation": {
		"allowedTypes": ["marketing_hero", "marketing_features", "site_cta"],
		"maxItems": 30
	}
}
```

### Declarar un tipo de bloque

Los tipos de bloque viven en `blockTypes` y están versionados. Uno real, recortado:

```json
{
	"slug": "site_cta",
	"label": "Call to action",
	"description": "A banner with one or two buttons",
	"category": "Content hub",
	"currentVersion": 1,
	"versions": [
		{
			"version": 1,
			"fields": [
				{ "slug": "anchor_id", "label": "Anchor ID", "type": "string",
					"validation": { "pattern": "^(?:$|[a-z][a-z0-9_-]*)$" } },
				{ "slug": "headline", "label": "Headline", "type": "string", "required": true },
				{ "slug": "subheadline", "label": "Subheadline", "type": "text" },
				{ "slug": "primary_cta_label", "label": "Button label", "type": "string" },
				{ "slug": "primary_cta_url", "label": "Button URL", "type": "string" },
				{ "slug": "style", "label": "Style", "type": "select",
					"validation": { "options": ["gradient", "plain"] }, "defaultValue": "gradient" }
			]
		}
	]
}
```

La `category` agrupa bloques en el picker del admin. Los repeaters añaden filas estructuradas dentro de un bloque; el hero de emdashhq usa uno para su tira de specs:

```json
{
	"slug": "specs",
	"label": "Architecture highlights",
	"type": "repeater",
	"validation": {
		"maxItems": 4,
		"subFields": [
			{ "slug": "icon", "label": "Icon", "type": "select", "required": true,
				"options": ["zap", "database", "code", "cloud"] },
			{ "slug": "label", "label": "Label", "type": "string", "required": true }
		]
	}
}
```

Dos límites duros con los que diseñar: los campos de bloque **no pueden** contener references, `json`, `slug`, `blocks` anidados ni repeaters anidados, y un `repeater` no puede contener otro repeater. Mantén los esquemas de bloque planos — si necesitas anidado, eso es una colección, no un bloque.

### Reglas de versionado

Cada bloque almacenado lleva `_type`, `_version` y un `_key` estable. Cuando los campos cambian:

- **Cambios compatibles** (nuevo campo opcional, retoque de label) enmiendan la versión activa sobre la marcha.
- **Cambios rompientes** (renombrar o quitar un campo, cambiar su tipo) necesitan una nueva entrada en `versions`; la versión antigua se queda para que los bloques ya almacenados sigan renderizando. Entrega renderers que manejen ambas antes de activar la nueva versión.
- **Quitar un tipo** de `allowedTypes` lo mueve a una lista `retiredTypes` en lugar de borrar los datos almacenados.
- Añadir un campo `blocks` **required** a una colección que ya tiene entradas no es aditivo; rellena las entradas primero.

## Paso 6: componentes de bloque

Cada tipo de bloque mapea a un componente Astro. El wrapper que trae la plantilla (`MarketingBlocks.astro` en emdashhq) es un diccionario tipado:

```astro
---
import { Blocks, defineBlockComponents } from "emdash/ui";
import type { PageContentBlock } from "../../emdash-env";
import Hero from "./blocks/Hero.astro";
import SiteCta from "./blocks/SiteCta.astro";

const components = defineBlockComponents<PageContentBlock>({
	marketing_hero: Hero,
	site_cta: SiteCta,
	// ... one entry per allowed type
});
---

<Blocks value={Astro.props.value} components={components} />
```

`defineBlockComponents<PageContentBlock>` está tipado sobre la unión generada en `emdash-env.d.ts`. Tras editar el seed, reinicia el dev server y la unión se regenera, así que un `_type` ausente o mal escrito falla el typecheck en lugar de fallar en silencio. Un tipo de bloque sin mapear renderiza un placeholder visible en dev y nada (o tu componente `fallback`) en producción.

Un componente de bloque recibe `{ value, index, blockKey }`; `value` son los campos almacenados más `_type`/`_version`/`_key`, tipados estrechando la unión:

```astro
---
// src/components/blocks/SiteCta.astro
import type { BlockComponentProps } from "emdash/ui";
import type { PageContentBlock } from "../../emdash-env";

type CtaBlock = Extract<PageContentBlock, { _type: "site_cta" }>;
type Props = BlockComponentProps<CtaBlock>;

const { value } = Astro.props;
---

<section class="section" id={value.anchor_id || undefined}>
	<h2>{value.headline}</h2>
	{value.subheadline && <p>{value.subheadline}</p>}
	{value.primary_cta_url && (
		<a href={value.primary_cta_url} class="btn btn-primary">
			{value.primary_cta_label}
		</a>
	)}
</section>
```

`Blocks` en sí no hace queries; recorre el array almacenado y llama a los componentes. Pero los componentes pueden consultar cualquier cosa; son frontmatter Astro ordinario:

```astro
---
// inside a block component: cards fed by a collection
import { getEmDashCollection } from "emdash";

const { entries } = await getEmDashCollection("tutorials", {
	status: "published",
	orderBy: { published_at: "desc" },
	limit: 100,
});

let items = value.featured_only ? entries.filter((e) => e.data.featured) : entries;
if (value.limit > 0) items = items.slice(0, value.limit);
---
```

Ese patrón (colección en el seed, opciones de bloque como campos, `getEmDashCollection` dentro del componente) es cómo funciona cada sección de tarjetas en emdashhq. Partials compartidos (`SectionHeader`, `FilterBar`, `SectionLink`) mantienen consistente el chrome de sección entre bloques, y clases compartidas (`.card-grid`, `.hub-card`, `.badge`) en `theme.css` mantienen el look unificado sin una librería de componentes.

## Paso 7: rutas y layout

### Una ruta para cada página

El `src/pages/[...slug].astro` de la plantilla marketing renderiza cada entrada de `pages`: el slug es la URL, y una página sembrada con slug `home` sirve `/`:

```astro
---
import { getEmDashEntry, getSeoMeta } from "emdash";
import MarketingBlocks from "../components/MarketingBlocks.astro";
import Base from "../layouts/Base.astro";

const requested = Astro.params.slug;
if (requested === "home") return Astro.redirect("/", 301);

const slug = requested ?? "home";
const { entry: page, cacheHint } = await getEmDashEntry("pages", slug);

if (Astro.cache?.enabled) Astro.cache.set(cacheHint);
if (!page && slug !== "home") return Astro.rewrite("/404");

const seo = page && getSeoMeta(page, {
	siteUrl: Astro.url.origin,
	path: slug === "home" ? "/" : `/${slug}/`,
});
---

<Base title={seo?.title} description={seo?.description} image={seo?.ogImage}
	canonical={seo?.canonical} robots={seo?.robots}
	content={page ? { collection: "pages", id: page.data.id, slug: page.id } : undefined}
>
	<div {...page?.edit.content}>
		<MarketingBlocks value={page?.data.content} />
	</div>
</Base>
```

Las páginas nuevas no necesitan código: crea una entrada en el admin, obtén una URL. Otras colecciones obtienen sus propias rutas del mismo modo (`getEmDashCollection` para archivos, `getEmDashEntry` para páginas de detalle).

### El layout cablea el chrome gestionado por el admin

`Base.astro` es donde el tema se encuentra con el admin. Las piezas que cargan el peso:

```astro
---
import { getMenuWithCacheHint, getSiteSettingsWithCacheHint } from "emdash";
import { EmDashBodyEnd, EmDashBodyStart, EmDashHead } from "emdash/ui";
import { createPublicPageContext } from "emdash/page";
import { Font } from "astro:assets";

const [settings, primary, headerCta, footerLearn] = await Promise.all([
	getSiteSettingsWithCacheHint(),
	getMenuWithCacheHint("primary"),
	getMenuWithCacheHint("header_cta"),
	getMenuWithCacheHint("footer_learn"),
]);
if (Astro.cache?.enabled)
	[settings, primary, headerCta, footerLearn].forEach((r) => Astro.cache.set(r.cacheHint));

const pageCtx = createPublicPageContext({ Astro, kind: "content", pageType: "website", /* title, description, image, content */ });
---
<head>
	<Font cssVariable="--font-body" preload />
	<Font cssVariable="--font-mono" preload />
	<EmDashHead page={pageCtx} />
</head>
<body>
	<EmDashBodyStart page={pageCtx} />
	<!-- header, nav from getMenu(), slot, footer -->
	<EmDashBodyEnd page={pageCtx} />
</body>
```

Tres cosas que esto te compra:

- **Identidad y navegación gestionadas por el admin.** `getSiteSettings()` devuelve título, tagline, logo, favicon, URLs sociales; `getMenu("primary")` devuelve items que los editores controlan (cada uno con `label`, `url` resuelta, `target`, `children` para dropdowns). Los editores cambian la navegación sin un deploy.
- **Contribuciones de plugins a la página.** `EmDashHead`/`EmDashBodyStart`/`EmDashBodyEnd` renderizan lo que los plugins aportan a esos slots: los snippets de analítica del plugin site-scripts en emdashhq, por ejemplo. Déjalos incluso sin plugins instalados; añadir uno luego no requiere cambio de layout.
- **Overlay SEO automático.** En páginas renderizadas en servidor que fetchean una entrada y renderizan `<EmDashHead>` con un contexto `content`, la description, imagen, canonical y noindex del panel SEO se aplican sin query extra. `<EmDashHead>` no puede establecer `<title>`; para eso está `getSeoMeta()`.

### Edición con un clic

Cada entrada lleva un proxy `edit`. Esparce `entry.edit.<campo>` sobre el elemento que renderiza ese campo y un admin logueado obtiene edición con un clic en la página pública:

```astro
<h1 {...post.edit.title}>{post.data.title}</h1>
<div {...page.edit.content}><MarketingBlocks value={page.data.content} /></div>
```

Barato de añadir, invisible para los visitantes, y hace que el tema se sienta integrado en lugar de atornillado encima.

## Paso 8: siembra contenido para que el tema se demuestre solo

Un tema que se instala vacío es un tema que nadie evalúa. La clave `content` siembra entradas por colección; la página home siembra sus bloques como valores literales:

```json
"content": {
	"pages": [
		{
			"id": "home",
			"slug": "home",
			"status": "published",
			"data": {
				"title": "Home",
				"content": [
					{
						"_type": "marketing_hero",
						"_version": 1,
						"_key": "home-hero",
						"headline": "EmDash tutorials, themes and plugins in one place",
						"specs": [{ "icon": "zap", "label": "Cloudflare Workers SSR" }]
					},
					{ "_type": "site_tutorials", "_version": 1, "_key": "home-tutorials",
						"headline": "Latest tutorials", "limit": 3 }
				]
			}
		}
	]
}
```

Reglas de contenido seed que ahorran tiempo de depuración:

- Dale a cada bloque y span un `_key` estable; es obligatorio, y las claves duplicadas producen comportamiento de edición roto.
- Los campos de imagen toman `{ "$media": { "url": "...", "alt": "...", "filename": "..." } }`. EmDash descarga y guarda el archivo. Un string de URL pelado se salta la descarga pero renderiza.
- Referencia otras entradas sembradas con `"$ref:seed-id"`, nunca IDs en crudo.
- `"status": "draft"` siembra contenido no publicado para probar vistas de borrador.
- `npx emdash export-seed --with-content` vuelca un sitio funcionando de vuelta a un archivo seed. La forma más rápida de escribir el `content` es construir las páginas en el admin y luego exportar.

El seed se aplica una vez, en la primera petición a una base de datos vacía antes de que termine el setup; nunca sobrescribe datos existentes. Los cambios de esquema sobre una base de datos viva pasan por la [evolución de esquema](https://docs.emdashcms.com/deployment/schema-evolution/), no por re-sembrar.

## Paso 9: las convenciones que mantienen un tema coherente

Cosas pequeñas, pero son donde los temas DIY derivan:

- **Iconos.** Mantén un vocabulario de iconos fijo. En emdashhq las opciones de un campo `select` mapean a través de `src/lib/icons.ts` (`ICON_MAP`) a nombres de Phosphor, y cada icono también está listado en la config `include` de `astro-iconset`. Son tres sitios por icono nuevo: opciones del seed, el mapa, la lista de build. Olvida la lista de build y el icono no se publica, en silencio.
- **URLs almacenadas.** Todo lo que un editor puede teclear en un campo `url`/`string` pasa por `sanitizeHref()` antes del `href`. Un helper `linkAttrs()` lo envuelve y añade `target="_blank" rel="noopener noreferrer"` a los enlaces web absolutos.
- **Imágenes.** Siempre `<Image image={post.data.featured_image} />` de `emdash/ui`. `<img src={field}>` sobre un objeto renderiza `[object Object]`, el bug de plantilla EmDash más común con diferencia.
- **Búsqueda.** `LiveSearch` de `emdash/ui/search` se inserta en el header; solo indexa colecciones con el support `search` y campos marcados `searchable`, y se estila con variables CSS `--emdash-search-*` para que combine con el tema.
- **Modo oscuro.** El patrón cookie-toggle: un pequeño script `is:inline` en `<head>` añade `.light`/`.dark` (o nada = seguir al SO) antes del paint, y los tokens `light-dark()` hacen el resto. En emdashhq, `html:not(.light):not(.dark) { color-scheme: light dark }` sobrescribe el pin `color-scheme: light` del stylesheet del admin en modo sistema. Copia ese fix o el modo oscuro se quedará claro.

<Notice type="warning" title="Los cinco errores que comete todo tema nuevo de EmDash">
Campos de imagen renderizados como strings (`<img src={field}>`; usa `<Image>`). Consultar una taxonomía por el nombre en plural (`"categories"` cuando el seed dice `"category"` da resultados vacíos sin error). Pasar `entry.id` (el slug) donde se requiere `data.id` (el ULID), como en `getEntryTerms` y `Comments`. `getStaticPaths` en rutas de CMS: el contenido es dinámico, así que quédate en `output: "server"`. Y olvidar `Astro.cache.set(cacheHint)` en rutas cacheadas, que hace que los eventos de publicación no invaliden la página.
</Notice>

## Verifica que el tema funciona

<ListCheck>
<ul>
<li><code>npm run dev</code> regenera <code>emdash-env.d.ts</code> con tus nuevos tipos de colección y bloque</li>
<li>El picker de bloques del admin muestra tus tipos de bloque bajo sus categorías, con los campos que declaraste</li>
<li>Una página compuesta de bloques sembrados renderiza en <code>/</code>; una página nueva creada en el admin funciona sin cambio de código</li>
<li>Los menús, el título del sitio y los enlaces sociales editados en el admin aparecen en el sitio público</li>
<li>La edición con un clic funciona para un admin logueado en campos renderizados</li>
<li>Ambos modos de color renderizan; no hay colores hard-coded solo-claros en los componentes</li>
<li><code>/definitely-missing</code> golpea tu ruta 404, no una página en blanco</li>
</ul>
</ListCheck>

## Distribuir el tema

Un tema terminado es solo un repo. Para hacerlo scaffoldable como los oficiales, mantén `package.json#emdash.seed` configurado, conserva las rutas de plantilla (`src/pages`, `src/layouts`, `src/components`, `seed/`), y otra persona puede correr `npm create astro@latest -- --template tu-org/tu-repo`. Los paquetes oficiales `@emdash-cms/template-*` en npm funcionan igual. Lo que se entrega es el código más el seed; el contenido siempre empieza vacío en la base de datos del nuevo dueño.

## FAQ: temas de EmDash

<Accordion label="¿Campo blocks o portableText: cuál uso?" group="faq" expanded="true">
Usa `blocks` para composición a nivel de página: una pila ordenada de secciones tipadas (hero, features, CTA) que los editores añaden y reordenan. Usa `portableText` para contenido de documento: el cuerpo de un post, una página about, cualquier cosa de flujo de párrafos. Los objetos personalizados dentro del rich text se renderizan a través del mapa `components.type` del componente `PortableText` y necesitan un plugin para una UI de editor personalizada; los bloques a nivel de página obtienen su UI de edición solo de la declaración del seed.
</Accordion>

<Accordion label="¿Puedo cambiar los campos de un bloque después de que el contenido lo use?" group="faq">
Sí, con cuidado. Añadir campos opcionales es compatible; enmienda la versión activa. Renombrar, quitar o retipar un campo es rompiente: añade una entrada nueva a `versions`, conserva la versión antigua para que los bloques almacenados sigan renderizando, y actualiza tu componente para manejar ambas formas antes de activarla. La unión generada `PageContentBlock` mantiene tipada cada versión retenida.
</Accordion>

<Accordion label="¿El tema toca alguna vez mi contenido?" group="faq">
No. El tema es código del repo más `seed/seed.json`. El seed se aplica una vez sobre una base de datos vacía; el contenido vive en D1/SQLite y los medios en R2/disco a partir de ahí. Redesplegar o editar archivos del tema nunca sobrescribe entradas, y los cambios de los editores nunca tocan tu repo.
</Accordion>

## Siguiente: publícalo

Ahora tienes la superficie del tema de extremo a extremo: tokens y fuentes para la apariencia, seed.json para el modelo, tipos de bloque para la experiencia del editor, rutas y un layout para el cableado, y contenido sembrado para la impresión del primer arranque. Para ver cada patrón de este artículo corriendo en producción, navega [emdashhq.com](https://emdashhq.com/), donde los tokens blueprint, los tipos de bloque personalizados en el editor de páginas y la única ruta catch-all están todos en vivo.

Lo único que queda es la infraestructura: la Parte 4 despliega este stack exacto a Cloudflare Workers, D1 y R2.