Crear Temas

En esta página

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. Crea 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 empaquetado 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 añade campos y taxonomías según requiera el diseño, en lugar de inventar tipos de contenido completamente nuevos.
  • Las páginas de contenido del tema deben ser renderizadas en 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 rutas de contenido del tema. (Las builds de sitios estáticos que usan EmDash como fuente de datos en tiempo de build pueden usar getStaticPaths, pero los temas siempre son SSR.)
  • Sin contenido codificado. El título del sitio, tagline, 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 usa la siguiente estructura:

my-emdash-theme/
├── package.json              # Metadatos del tema
├── astro.config.mjs          # Configuración Astro + EmDash
├── src/
│   ├── live.config.ts        # Configuración de Live Collections
│   ├── pages/
│   │   ├── index.astro       # Página principal
│   │   ├── [...slug].astro   # Páginas (catch-all)
│   │   ├── posts/
│   │   │   ├── index.astro   # Archivo de posts
│   │   │   └── [slug].astro  # Post individual
│   │   ├── categories/
│   │   │   └── [slug].astro  # Archivo de categoría
│   │   ├── 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             # Esquema y contenido de ejemplo
│   └── uploads/              # Archivos multimedia locales opcionales
└── public/                   # Assets estáticos

Las páginas se ubican en la raíz como ruta catch-all ([...slug].astro), así 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 multimedia local usado en el contenido de ejemplo.

Configurar package.json

Añade 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"
	}
}
CampoDescripción
emdash.labelNombre para mostrar en selectores de temas
emdash.descriptionBreve descripción del tema
emdash.seedRuta al archivo seed
emdash.previewURL a una demo en vivo (opcional)

El modelo de contenido predeterminado

La mayoría de los temas necesitan dos tipos de colección: posts y pages. Los posts son entradas con marca de tiempo con extractos e imágenes destacadas que aparecen en feeds y archivos. Las pages son contenido independiente en URLs de nivel superior.

Este es el punto de partida recomendado. Añade más colecciones, taxonomías o campos según tu tema los necesite, pero empieza 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 pages no los necesitan — son contenido independiente. Añade campos a cualquier colección según requiera tu tema.

Consulta Formato de archivo Seed para la especificación completa, incluyendo secciones, áreas de widgets y referencias a medios.

Construir páginas

Todas las páginas que muestran contenido de EmDash son renderizadas en servidor. Usa Astro.params para obtener el slug de la URL y consultar contenido en tiempo de solicitud.

Página principal

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

Páginas

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>

Como 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ía

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

Usar 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}
      priority
    />
  )}
  <h2><a href={`/posts/${post.slug}`}>{post.data.title}</a></h2>
  <p>{post.data.excerpt}</p>
</article>

Usa priority solo para la imagen probablemente visible sobre el pliegue en una página, como un hero o primera imagen de tarjeta. Renderiza la imagen con loading="eager" y fetchpriority="high". loading controla si el navegador puede diferir la carga, mientras que fetchpriority indica qué tan importante es la solicitud una vez que el navegador la descubre.

Usar menús

Consulta menús definidos por el admin 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 — un layout predeterminado, uno de ancho completo, un layout de landing page. En EmDash, añade un campo select template a la colección de páginas y mapéalo a componentes de layout en tu ruta catch-all.

Añade 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 desde un desplegable en la UI de administración al editar una página.

Añadir secciones

Las secciones son bloques de contenido reutilizables que los editores pueden insertar en cualquier campo Portable Text usando el comando slash /section. Si tu tema tiene patrones de contenido comunes (banners hero, CTAs, grids 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 se marcan con source: "theme". Los editores también pueden crear sus propias secciones (marcadas source: "user"), pero las secciones proporcionadas por el tema no pueden ser eliminadas desde la UI de administración.

Añadir 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 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 archivos en .emdash/uploads/ y referéncialos por nombre de archivo:

{
	"data": {
		"featured_image": {
			"$media": {
				"file": "hero.jpg",
				"alt": "A descriptive alt text"
			}
		}
	}
}

Durante el seeding, los archivos multimedia se descargan (o leen localmente) y se suben al 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 debounce, coincidencia de prefijos, stemming Porter y snippets de resultados resaltados. La búsqueda debe habilitarse por colección en la UI de administración (Content Types > Edit > Features > Search).

Probar tu tema

  1. Crea un proyecto de prueba desde tu tema:

    npm create astro@latest -- --template ./path/to/my-theme
  2. Instala dependencias e inicia el servidor de desarrollo:

    cd test-site
    npm install
    npm run dev
  3. Completa el Asistente de Configuración en http://localhost:4321/_emdash/admin

  4. Verifica que colecciones, menús, redirecciones y contenido se crearon correctamente

  5. Prueba que todas las plantillas de página se renderizan correctamente

  6. Crea nuevo contenido a través del admin para verificar que todos los campos funcionan

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 Portable Text personalizados

Los temas pueden definir tipos de bloques Portable Text personalizados para contenido especializado. Esto es útil para páginas de marketing, landing pages o cualquier contenido que necesite componentes estructurados más allá del rich text estándar.

Definir bloques personalizados en contenido seed

Usa un _type con namespace en el contenido 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 bloque

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 bloque 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 wrapper 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

Añade _key a bloques que deban ser enlazables:

{
	"_type": "marketing.features",
	"_key": "features",
	"headline": "Features"
}

Usa el valor _key como ancla en el componente de bloque:

<section id={value._key}>
  <!-- contenido -->
</section>

Esto permite enlaces de navegación como /#features.

Lista de verificación del tema

Antes de publicar, verifica que tu tema incluye:

  • package.json con campo emdash (label, description, ruta seed)
  • .emdash/seed.json con esquema válido
  • Todas las colecciones referenciadas en páginas existen en el seed
  • Los menús usados en layouts están definidos en el seed
  • El contenido de ejemplo demuestra el diseño del tema
  • astro.config.mjs con configuración de base de datos y almacenamiento
  • src/live.config.ts con loader de EmDash
  • Sin getStaticPaths() en páginas de contenido
  • Sin título de sitio, tagline o navegación codificados
  • Campos de imagen accedidos como objetos (image.src), no cadenas
  • README con instrucciones de configuración
  • Componentes de bloque personalizados para tipos Portable Text no estándar

Próximos pasos