Logotipo de Bitdoze

Cómo crear un tema personalizado de EmDash: tokens, bloques y esquema seed

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.

Dragos

53 min de lectura

Cómo crear un tema personalizado de EmDash: tokens, bloques y esquema seed

Esta es la Parte 3 de la serie EmDash. La Parte 1 reseñó el CMS en su 1.0 y la Parte 2 dejó un sitio corriendo en local 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, 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

Lee la Parte 2: el tutorial

Qué es realmente un tema de EmDash

No hay tienda de temas, ni jerarquía de temas padre/hijo, ni runtime de temas. Según la documentación oficial de temas, 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, 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.

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.

Verifica que el tema funciona

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

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

¿Campo blocks o portableText: cuál uso?

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.

¿Puedo cambiar los campos de un bloque después de que el contenido lo use?

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.

¿El tema toca alguna vez mi contenido?

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.

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