Un tema de EmDash es un sitio Astro completo — páginas, layouts, componentes, estilos — que también incluye un archivo seed para inicializar el modelo de contenido. Construye uno para compartir tu diseño con otros, o para estandarizar la creación de sitios para tu agencia.
Conceptos Clave
- Un tema es un proyecto Astro funcional. No hay API de temas ni capa de abstracción. Un tema es un sitio entregado como plantilla. El archivo seed le dice a EmDash qué colecciones, campos, menús, redirecciones y taxonomías crear en la primera ejecución.
- El archivo seed declara el modelo de contenido. Lista exactamente qué campos necesita cada colección. Construye sobre las colecciones estándar posts y pages y agrega campos y taxonomías según lo requiera el diseño, en lugar de inventar tipos de contenido completamente nuevos.
- Las páginas de contenido del tema deben renderizarse en el servidor. En un tema, el contenido cambia en tiempo de ejecución a través de la UI de administración, por lo que las páginas que muestran contenido de EmDash no deben ser prerenderizadas. No uses
getStaticPaths()en las rutas de contenido del tema. (Las construcciones de sitios estáticos que usan EmDash como fuente de datos en tiempo de construcción pueden usargetStaticPaths, pero los temas siempre son SSR.) - Sin contenido codificado. El título del sitio, el lema, la navegación y otro contenido dinámico provienen del CMS a través de llamadas API — no de cadenas de plantilla.
Estructura del Proyecto
Un tema utiliza la siguiente estructura:
my-emdash-theme/
├── package.json # Metadatos del tema
├── astro.config.mjs # Configuración de Astro + EmDash
├── src/
│ ├── live.config.ts # Configuración de Live Collections
│ ├── pages/
│ │ ├── index.astro # Página de inicio
│ │ ├── [...slug].astro # Páginas (catch-all)
│ │ ├── posts/
│ │ │ ├── index.astro # Archivo de posts
│ │ │ └── [slug].astro # Post individual
│ │ ├── categories/
│ │ │ └── [slug].astro # Archivo de categorías
│ │ ├── tags/
│ │ │ └── [slug].astro # Archivo de etiquetas
│ │ ├── search.astro # Página de búsqueda
│ │ └── 404.astro # No encontrado
│ ├── layouts/
│ │ └── Base.astro # Layout base
│ └── components/ # Tus componentes
├── .emdash/
│ ├── seed.json # Schema y contenido de ejemplo
│ └── uploads/ # Archivos de medios locales opcionales
└── public/ # Assets estáticos
Las páginas están en la raíz como una ruta catch-all ([...slug].astro), por lo que una página con slug about se renderiza en /about. Posts, categorías y etiquetas obtienen sus propios directorios. El directorio .emdash/ contiene el archivo seed y cualquier archivo de medios local utilizado en el contenido de ejemplo.
Configuración de package.json
Agrega el campo emdash a tu package.json:
{
"name": "@your-org/emdash-theme-blog",
"version": "1.0.0",
"description": "A minimal blog theme for EmDash",
"keywords": ["astro-template", "emdash", "blog"],
"emdash": {
"label": "Minimal Blog",
"description": "A clean, minimal blog with posts, pages, and categories",
"seed": ".emdash/seed.json",
"preview": "https://your-theme-demo.pages.dev"
}
}
| Campo | Descripción |
|---|---|
emdash.label | Nombre mostrado en los selectores de temas |
emdash.description | Breve descripción del tema |
emdash.seed | Ruta al archivo seed |
emdash.preview | URL a una demo en vivo (opcional) |
El Modelo de Contenido Predeterminado
La mayoría de los temas necesitan dos tipos de colecciones: posts y pages. Los posts son entradas con marca de tiempo con extractos e imágenes destacadas que aparecen en feeds y archivos. Las páginas son contenido independiente en URLs de nivel superior.
Este es el punto de partida recomendado. Agrega más colecciones, taxonomías o campos según los necesite tu tema, pero comienza aquí.
Archivo Seed
El archivo seed le dice a EmDash qué crear en la primera ejecución. Crea .emdash/seed.json:
{
"$schema": "https://emdashcms.com/seed.schema.json",
"version": "1",
"meta": {
"name": "Minimal Blog",
"description": "A clean blog with posts and pages",
"author": "Your Name"
},
"settings": {
"title": "My Blog",
"tagline": "Thoughts and ideas",
"postsPerPage": 10
},
"collections": [
{
"slug": "posts",
"label": "Posts",
"labelSingular": "Post",
"supports": ["drafts", "revisions"],
"fields": [
{ "slug": "title", "label": "Title", "type": "string", "required": true },
{ "slug": "content", "label": "Content", "type": "portableText" },
{ "slug": "excerpt", "label": "Excerpt", "type": "text" },
{ "slug": "featured_image", "label": "Featured Image", "type": "image" }
]
},
{
"slug": "pages",
"label": "Pages",
"labelSingular": "Page",
"supports": ["drafts", "revisions"],
"fields": [
{ "slug": "title", "label": "Title", "type": "string", "required": true },
{ "slug": "content", "label": "Content", "type": "portableText" }
]
}
],
"taxonomies": [
{
"name": "category",
"label": "Categories",
"labelSingular": "Category",
"hierarchical": true,
"collections": ["posts"],
"terms": [
{ "slug": "news", "label": "News" },
{ "slug": "tutorials", "label": "Tutorials" }
]
}
],
"menus": [
{
"name": "primary",
"label": "Primary Navigation",
"items": [
{ "type": "custom", "label": "Home", "url": "/" },
{ "type": "custom", "label": "Blog", "url": "/posts" }
]
}
],
"redirects": [
{ "source": "/category/news", "destination": "/categories/news" },
{ "source": "/old-about", "destination": "/about" }
]
}
Los posts obtienen excerpt y featured_image porque aparecen en listas y feeds. Las páginas no los necesitan — son contenido independiente. Agrega campos a cualquier colección según los requiera tu tema.
Consulta Formato del Archivo Seed para la especificación completa, incluyendo secciones, áreas de widgets y referencias de medios.
Construcción de Páginas
Todas las páginas que muestran contenido de EmDash se renderizan en el servidor. Usa Astro.params para obtener el slug de la URL y consultar el contenido en tiempo de solicitud.
Página de Inicio
---
import { getEmDashCollection, getSiteSettings } from "emdash";
import Base from "../layouts/Base.astro";
const settings = await getSiteSettings();
const { entries: posts } = await getEmDashCollection("posts", {
where: { status: "published" },
orderBy: { publishedAt: "desc" },
limit: settings.postsPerPage ?? 10,
});
---
<Base title="Home">
<h1>Latest Posts</h1>
{posts.map((post) => (
<article>
<h2><a href={`/posts/${post.slug}`}>{post.data.title}</a></h2>
<p>{post.data.excerpt}</p>
</article>
))}
</Base>
Post Individual
---
import { getEmDashEntry, getEntryTerms } from "emdash";
import { PortableText } from "emdash/ui";
import Base from "../../layouts/Base.astro";
const { slug } = Astro.params;
const { entry: post } = await getEmDashEntry("posts", slug!);
if (!post) {
return Astro.redirect("/404");
}
const categories = await getEntryTerms("posts", post.id, "categories");
---
<Base title={post.data.title}>
<article>
<h1>{post.data.title}</h1>
<PortableText value={post.data.content} />
<div class="post-meta">
{categories.map((cat) => (
<a href={`/categories/${cat.slug}`}>{cat.label}</a>
))}
</div>
</article>
</Base>
Pages
Las páginas usan una ruta catch-all en la raíz para que sus slugs se mapeen directamente a URLs de nivel superior — una página con slug about se renderiza en /about:
---
import { getEmDashEntry } from "emdash";
import { PortableText } from "emdash/ui";
import Base from "../layouts/Base.astro";
const { slug } = Astro.params;
const { entry: page } = await getEmDashEntry("pages", slug!);
if (!page) {
return Astro.redirect("/404");
}
---
<Base title={page.data.title}>
<article>
<h1>{page.data.title}</h1>
<PortableText value={page.data.content} />
</article>
</Base>
Dado que esta es una ruta catch-all, solo coincide con URLs que no tienen una ruta más específica. /posts/hello-world aún llega a posts/[slug].astro, no a este archivo.
Archivo de Categorías
---
import { getTerm, getEntriesByTerm } from "emdash";
import Base from "../../layouts/Base.astro";
const { slug } = Astro.params;
const category = await getTerm("categories", slug!);
const posts = await getEntriesByTerm("posts", "categories", slug!);
if (!category) {
return Astro.redirect("/404");
}
---
<Base title={category.label}>
<h1>{category.label}</h1>
{posts.map((post) => (
<article>
<h2><a href={`/posts/${post.slug}`}>{post.data.title}</a></h2>
</article>
))}
</Base>
Uso de Imágenes
Los campos de imagen son objetos con propiedades src y alt, no cadenas. Usa el componente Image de emdash/ui para renderizado optimizado de imágenes:
---
import { Image } from "emdash/ui";
const { post } = Astro.props;
---
<article>
{post.data.featured_image?.src && (
<Image
image={post.data.featured_image}
alt={post.data.featured_image.alt || post.data.title}
width={800}
height={450}
/>
)}
<h2><a href={`/posts/${post.slug}`}>{post.data.title}</a></h2>
<p>{post.data.excerpt}</p>
</article>
Uso de Menús
Consulta los menús definidos por el administrador en tus layouts. Nunca codifiques enlaces de navegación:
---
import { getMenu, getSiteSettings } from "emdash";
const settings = await getSiteSettings();
const primaryMenu = await getMenu("primary");
---
<html>
<head>
<title>{Astro.props.title} | {settings.title}</title>
</head>
<body>
<header>
{settings.logo ? (
<img src={settings.logo.url} alt={settings.title} />
) : (
<span>{settings.title}</span>
)}
<nav>
{primaryMenu?.items.map((item) => (
<a href={item.url}>{item.label}</a>
))}
</nav>
</header>
<main>
<slot />
</main>
</body>
</html>
Plantillas de Página
Los temas a menudo necesitan múltiples layouts de página — un layout predeterminado, un layout de ancho completo, un layout de página de destino. En EmDash, agrega un campo select template a la colección de páginas y mapéalo a componentes de layout en tu ruta catch-all.
Agrega el campo a la colección de páginas en el archivo seed:
{
"slug": "template",
"label": "Page Template",
"type": "string",
"widget": "select",
"options": {
"choices": [
{ "value": "default", "label": "Default" },
{ "value": "full-width", "label": "Full Width" },
{ "value": "landing", "label": "Landing Page" }
]
},
"defaultValue": "default"
}
Luego mapea el valor a componentes de layout en la ruta catch-all:
---
import { getEmDashEntry } from "emdash";
import PageDefault from "../layouts/PageDefault.astro";
import PageFullWidth from "../layouts/PageFullWidth.astro";
import PageLanding from "../layouts/PageLanding.astro";
const { slug } = Astro.params;
const { entry: page } = await getEmDashEntry("pages", slug!);
if (!page) {
return Astro.redirect("/404");
}
const layouts = {
"default": PageDefault,
"full-width": PageFullWidth,
"landing": PageLanding,
};
const Layout = layouts[page.data.template as keyof typeof layouts] ?? PageDefault;
---
<Layout page={page} />
Los editores eligen la plantilla de un menú desplegable en la UI de administración al editar una página.
Agregar Secciones
Las secciones son bloques de contenido reutilizables que los editores pueden insertar en cualquier campo de Portable Text usando el comando de barra /section. Si tu tema tiene patrones de contenido comunes (banners hero, CTAs, grillas de características), defínelos como secciones en el archivo seed:
{
"sections": [
{
"slug": "hero-centered",
"title": "Centered Hero",
"description": "Full-width hero with centered heading and CTA",
"keywords": ["hero", "banner", "header", "landing"],
"content": [
{
"_type": "block",
"style": "h1",
"children": [{ "_type": "span", "text": "Welcome to Our Site" }]
},
{
"_type": "block",
"children": [
{ "_type": "span", "text": "Your compelling tagline goes here." }
]
}
]
},
{
"slug": "newsletter-cta",
"title": "Newsletter Signup",
"keywords": ["newsletter", "subscribe", "email"],
"content": [
{
"_type": "block",
"style": "h3",
"children": [{ "_type": "span", "text": "Subscribe to our newsletter" }]
},
{
"_type": "block",
"children": [
{
"_type": "span",
"text": "Get the latest updates delivered to your inbox."
}
]
}
]
}
]
}
Las secciones creadas desde el archivo seed están marcadas con source: "theme". Los editores también pueden crear sus propias secciones (marcadas con source: "user"), pero las secciones proporcionadas por el tema no se pueden eliminar de la UI de administración.
Agregar Contenido de Ejemplo
Incluye contenido de ejemplo en el archivo seed para demostrar el diseño de tu tema:
{
"content": {
"posts": [
{
"id": "hello-world",
"slug": "hello-world",
"status": "published",
"data": {
"title": "Hello World",
"content": [
{
"_type": "block",
"style": "normal",
"children": [{ "_type": "span", "text": "Welcome to your new blog!" }]
}
],
"excerpt": "Your first post on EmDash."
},
"taxonomies": {
"category": ["news"]
}
}
]
}
}
Incluir Medios
Referencia imágenes en el contenido de ejemplo usando la sintaxis $media. Una imagen remota se referencia por URL:
{
"data": {
"featured_image": {
"$media": {
"url": "https://images.unsplash.com/photo-xxx",
"alt": "A descriptive alt text",
"filename": "hero.jpg"
}
}
}
}
Para imágenes locales, coloca los archivos en .emdash/uploads/ y referencialos por nombre de archivo:
{
"data": {
"featured_image": {
"$media": {
"file": "hero.jpg",
"alt": "A descriptive alt text"
}
}
}
}
Durante el seeding, los archivos de medios se descargan (o se leen localmente) y se cargan en el almacenamiento.
Búsqueda
Si tu tema incluye una página de búsqueda, usa el componente LiveSearch para resultados instantáneos:
---
import LiveSearch from "emdash/ui/search";
import Base from "../layouts/Base.astro";
---
<Base title="Search">
<h1>Search</h1>
<LiveSearch
placeholder="Search posts and pages..."
collections={["posts", "pages"]}
/>
</Base>
LiveSearch proporciona búsqueda instantánea con retraso con coincidencia de prefijos, stemming de Porter y fragmentos de resultados resaltados. La búsqueda debe estar habilitada por colección en la UI de administración (Content Types > Edit > Features > Search).
Probar tu Tema
-
Crea un proyecto de prueba desde tu tema:
npm create astro@latest -- --template ./path/to/my-theme -
Instala dependencias e inicia el servidor de desarrollo:
cd test-site npm install npm run dev -
Completa el Asistente de Configuración en
http://localhost:4321/_emdash/admin -
Verifica que las colecciones, menús, redirecciones y contenido se crearon correctamente
-
Prueba que todas las plantillas de página se renderizan correctamente
-
Crea nuevo contenido a través de la UI de administración para verificar que todos los campos funcionen
Publicar tu Tema
Publica en npm para distribución:
npm publish --access public
Los usuarios pueden entonces instalar tu tema:
npm create astro@latest -- --template @your-org/emdash-theme-blog
Un tema alojado en GitHub se instala con el prefijo de plantilla github::
npm create astro@latest -- --template github:your-org/emdash-theme-blog
Bloques Personalizados de Portable Text
Los temas pueden definir tipos de bloques personalizados de Portable Text para contenido especializado. Esto es útil para páginas de marketing, páginas de destino o cualquier contenido que necesite componentes estructurados más allá del texto enriquecido estándar.
Definir Bloques Personalizados en Contenido Seed
Usa un _type con espacio de nombres en el contenido de Portable Text de tu archivo seed:
{
"content": {
"pages": [
{
"id": "home",
"slug": "home",
"status": "published",
"data": {
"title": "Home",
"content": [
{
"_type": "marketing.hero",
"headline": "Build something amazing",
"subheadline": "The all-in-one platform for modern teams.",
"primaryCta": { "label": "Get Started", "url": "/signup" }
},
{
"_type": "marketing.features",
"_key": "features",
"headline": "Everything you need",
"features": [
{
"icon": "zap",
"title": "Lightning fast",
"description": "Built for speed."
}
]
}
]
}
}
]
}
}
Crear Componentes de Bloques
Crea componentes Astro para cada tipo de bloque personalizado:
---
interface Props {
value: {
headline: string;
subheadline?: string;
primaryCta?: { label: string; url: string };
};
}
const { value } = Astro.props;
---
<section class="hero">
<h1>{value.headline}</h1>
{value.subheadline && <p>{value.subheadline}</p>}
{value.primaryCta && (
<a href={value.primaryCta.url} class="btn">
{value.primaryCta.label}
</a>
)}
</section>
Renderizar Bloques Personalizados
Pasa tus componentes de bloques personalizados al componente PortableText:
---
import { PortableText } from "emdash/ui";
import Hero from "./blocks/Hero.astro";
import Features from "./blocks/Features.astro";
interface Props {
value: unknown[];
}
const { value } = Astro.props;
const marketingTypes = {
"marketing.hero": Hero,
"marketing.features": Features,
};
---
<PortableText value={value} components={{ types: marketingTypes }} />
Renderiza el componente envolvente en una página:
---
import { getEmDashEntry } from "emdash";
import MarketingBlocks from "../components/MarketingBlocks.astro";
const { entry: page } = await getEmDashEntry("pages", "home");
---
<MarketingBlocks value={page.data.content} />
IDs de Ancla para Navegación
Agrega _key a los bloques que deben ser enlazables:
{
"_type": "marketing.features",
"_key": "features",
"headline": "Features"
}
Usa el valor _key como ancla en el componente del bloque:
<section id={value._key}>
<!-- content -->
</section>
Esto habilita enlaces de navegación como /#features.
Lista de Verificación del Tema
Antes de publicar, verifica que tu tema incluya:
-
package.jsoncon campoemdash(etiqueta, descripción, ruta del seed) -
.emdash/seed.jsoncon schema válido - Todas las colecciones referenciadas en las páginas existen en el seed
- Los menús usados en los layouts están definidos en el seed
- El contenido de ejemplo demuestra el diseño del tema
-
astro.config.mjscon configuración de base de datos y almacenamiento -
src/live.config.tscon cargador de EmDash - Sin
getStaticPaths()en páginas de contenido - Sin título del sitio, lema o navegación codificados
- Los campos de imagen se acceden como objetos (
image.src), no como cadenas - README con instrucciones de configuración
- Componentes de bloques personalizados para cualquier tipo de Portable Text no estándar
Próximos Pasos
- Formato del Archivo Seed — Referencia completa para archivos seed
- Descripción General de Temas — Cómo funcionan los temas en EmDash
- Portar Temas de WordPress — Convertir temas de WordPress existentes