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.
53 min de lectura

EmDash CMS
Part 3 of 6
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.

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 ensrc/styles/theme.css, fuentes enastro.config.mjsy un componente Astro por cada bloque de página. - El modelo:
seed/seed.jsondeclara 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.
npm create emdash@latest
# Project name: my-site
# Where will you deploy? Node.js (swap later; the theme is adapter-agnostic)
# Which template? MarketingPaso 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:
/* 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:
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:
{
"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:
supportsactiva flujos de trabajo por colección:drafts,revisions,preview,scheduling,search,seo, además decommentsEnabledpara el sistema de comentarios integrado.grouppliega colecciones relacionadas bajo una sección de admin colapsable;sortOrdercontrola el orden del sidebar.urlPattern(p. ej./posts/{slug}) define permalinks para colecciones ruteables; hazlo coincidir con tus rutas Astro.searchable: trueen un campo alimenta el índice de texto completo. Junto con el supportsearch, es obligatorio antes de queLiveSearchdevuelva 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:
taxonomiesadjunta sistemas de términos a colecciones:{ "name": "category", "hierarchical": true, "collections": ["posts"], "terms": [...] }. Consúltalas congetTaxonomyTerms("category"). El argumento name debe coincidir exactamente con elnamedel seed, o obtienes resultados vacíos sin error.menussiembra 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.widgetAreassiembra regiones con nombre (como unsidebar) que contienen widgetscontent(rich text),menuocomponent; los componentes core incluyencore:search,core:categories,core:tags,core:recent-posts,core:archives.sectionssiembra 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.settingssiembra 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.
{
"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:
{
"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:
{
"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
allowedTypeslo mueve a una listaretiredTypesen lugar de borrar los datos almacenados. - Añadir un campo
blocksrequired 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:
---
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:
---
// 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:
---
// 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 /:
---
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:
---
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 conlabel,urlresuelta,target,childrenpara dropdowns). Los editores cambian la navegación sin un deploy. - Contribuciones de plugins a la página.
EmDashHead/EmDashBodyStart/EmDashBodyEndrenderizan 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 contextocontent, 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:
<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:
"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
_keyestable; 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-contentvuelca un sitio funcionando de vuelta a un archivo seed. La forma más rápida de escribir elcontentes 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
selectmapean a través desrc/lib/icons.ts(ICON_MAP) a nombres de Phosphor, y cada icono también está listado en la configincludedeastro-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/stringpasa porsanitizeHref()antes delhref. Un helperlinkAttrs()lo envuelve y añadetarget="_blank" rel="noopener noreferrer"a los enlaces web absolutos. - Imágenes. Siempre
<Image image={post.data.featured_image} />deemdash/ui.<img src={field}>sobre un objeto renderiza[object Object], el bug de plantilla EmDash más común con diferencia. - Búsqueda.
LiveSearchdeemdash/ui/searchse inserta en el header; solo indexa colecciones con el supportsearchy campos marcadossearchable, 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:inlineen<head>añade.light/.dark(o nada = seguir al SO) antes del paint, y los tokenslight-dark()hacen el resto. En emdashhq,html:not(.light):not(.dark) { color-scheme: light dark }sobrescribe el pincolor-scheme: lightdel 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 devregeneraemdash-env.d.tscon 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-missinggolpea 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.


