Criando Temas

Nesta página

Um tema EmDash é um site Astro completo — páginas, layouts, componentes, estilos — que também inclui um arquivo seed para inicializar o modelo de conteúdo. Crie um para compartilhar seu design com outras pessoas ou para padronizar a criação de sites para sua agência.

Conceitos-Chave

  • Um tema é um projeto Astro funcional. Não há API de tema nem camada de abstração. Um tema é um site fornecido como modelo. O arquivo seed informa ao EmDash quais coleções, campos, menus, redirecionamentos e taxonomias criar na primeira execução.
  • O arquivo seed declara o modelo de conteúdo. Ele lista exatamente quais campos cada coleção precisa. Construa sobre as coleções padrão posts e pages e adicione campos e taxonomias conforme o design exigir, em vez de inventar tipos de conteúdo completamente novos.
  • As páginas de conteúdo do tema devem ser renderizadas no servidor. Em um tema, o conteúdo muda em tempo de execução através da interface de administração, portanto as páginas que exibem conteúdo EmDash não devem ser pré-renderizadas. Não use getStaticPaths() nas rotas de conteúdo do tema. (Builds de sites estáticos usando EmDash como fonte de dados em tempo de build podem usar getStaticPaths, mas os temas são sempre SSR.)
  • Sem conteúdo codificado. Título do site, slogan, navegação e outro conteúdo dinâmico vêm do CMS via chamadas de API — não de strings de template.

Estrutura do Projeto

Um tema usa a seguinte estrutura:

my-emdash-theme/
├── package.json              # Metadados do tema
├── astro.config.mjs          # Configuração Astro + EmDash
├── src/
│   ├── live.config.ts        # Configuração Live Collections
│   ├── pages/
│   │   ├── index.astro       # Página inicial
│   │   ├── [...slug].astro   # Páginas (catch-all)
│   │   ├── posts/
│   │   │   ├── index.astro   # Arquivo de posts
│   │   │   └── [slug].astro  # Post individual
│   │   ├── categories/
│   │   │   └── [slug].astro  # Arquivo de categorias
│   │   ├── tags/
│   │   │   └── [slug].astro  # Arquivo de tags
│   │   ├── search.astro      # Página de busca
│   │   └── 404.astro         # Não encontrado
│   ├── layouts/
│   │   └── Base.astro        # Layout base
│   └── components/           # Seus componentes
├── .emdash/
│   ├── seed.json             # Schema e conteúdo de exemplo
│   └── uploads/              # Arquivos de mídia locais opcionais
└── public/                   # Assets estáticos

As páginas ficam na raiz como uma rota catch-all ([...slug].astro), então uma página com slug about é renderizada em /about. Posts, categorias e tags obtêm seus próprios diretórios. O diretório .emdash/ contém o arquivo seed e quaisquer arquivos de mídia locais usados no conteúdo de exemplo.

Configurando package.json

Adicione o campo emdash ao seu 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"
	}
}
CampoDescrição
emdash.labelNome exibido nos seletores de tema
emdash.descriptionBreve descrição do tema
emdash.seedCaminho para o arquivo seed
emdash.previewURL para uma demonstração ao vivo (opcional)

O Modelo de Conteúdo Padrão

A maioria dos temas precisa de dois tipos de coleções: posts e pages. Posts são entradas com timestamp com trechos e imagens em destaque que aparecem em feeds e arquivos. Páginas são conteúdo autônomo em URLs de nível superior.

Este é o ponto de partida recomendado. Adicione mais coleções, taxonomias ou campos conforme seu tema precisar, mas comece aqui.

Arquivo Seed

O arquivo seed informa ao EmDash o que criar na primeira execução. Crie .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" }
	]
}

Posts obtêm excerpt e featured_image porque aparecem em listas e feeds. Páginas não precisam deles — são conteúdo autônomo. Adicione campos a qualquer coleção conforme seu tema precisar.

Veja Formato do Arquivo Seed para a especificação completa, incluindo seções, áreas de widgets e referências de mídia.

Construindo Páginas

Todas as páginas que exibem conteúdo EmDash são renderizadas no servidor. Use Astro.params para obter o slug da URL e consultar o conteúdo no momento da solicitação.

Página Inicial

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

As páginas usam uma rota catch-all na raiz para que seus slugs sejam mapeados diretamente para URLs de nível superior — uma página com slug about é renderizada em /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 esta é uma rota catch-all, ela corresponde apenas a URLs que não têm uma rota mais específica. /posts/hello-world ainda atinge posts/[slug].astro, não este arquivo.

Arquivo de Categorias

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

Usando Imagens

Campos de imagem são objetos com propriedades src e alt, não strings. Use o componente Image de emdash/ui para renderização otimizada de imagens:

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

Usando Menus

Consulte menus definidos pelo administrador em seus layouts. Nunca codifique links de navegação:

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

Modelos de Página

Os temas frequentemente precisam de vários layouts de página — um layout padrão, um layout de largura total, um layout de página de destino. No EmDash, adicione um campo select template à coleção pages e mapeie-o para componentes de layout em sua rota catch-all.

Adicione o campo à coleção pages no arquivo 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"
}

Em seguida, mapeie o valor para componentes de layout na rota 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} />

Os editores escolhem o modelo de um menu suspenso na interface de administração ao editar uma página.

Adicionando Seções

Seções são blocos de conteúdo reutilizáveis que os editores podem inserir em qualquer campo Portable Text usando o comando de barra /section. Se seu tema tiver padrões de conteúdo comuns (banners hero, CTAs, grades de recursos), defina-os como seções no arquivo 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."
						}
					]
				}
			]
		}
	]
}

Seções criadas a partir do arquivo seed são marcadas com source: "theme". Os editores também podem criar suas próprias seções (marcadas com source: "user"), mas as seções fornecidas pelo tema não podem ser excluídas da interface de administração.

Adicionando Conteúdo de Exemplo

Inclua conteúdo de exemplo no arquivo seed para demonstrar o design do seu 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"]
				}
			}
		]
	}
}

Incluindo Mídia

Referencie imagens no conteúdo de exemplo usando a sintaxe $media. Uma imagem remota é referenciada por URL:

{
	"data": {
		"featured_image": {
			"$media": {
				"url": "https://images.unsplash.com/photo-xxx",
				"alt": "A descriptive alt text",
				"filename": "hero.jpg"
			}
		}
	}
}

Para imagens locais, coloque os arquivos em .emdash/uploads/ e referencie-os por nome de arquivo:

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

Durante o seeding, os arquivos de mídia são baixados (ou lidos localmente) e carregados para o armazenamento.

Busca

Se seu tema incluir uma página de busca, use o 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 fornece busca instantânea com debounce com correspondência de prefixo, lematização Porter e trechos de resultados destacados. A busca deve ser habilitada por coleção na interface de administração (Content Types > Edit > Features > Search).

Testando Seu Tema

  1. Crie um projeto de teste a partir do seu tema:

    npm create astro@latest -- --template ./path/to/my-theme
  2. Instale dependências e inicie o servidor de desenvolvimento:

    cd test-site
    npm install
    npm run dev
  3. Complete o Assistente de Configuração em http://localhost:4321/_emdash/admin

  4. Verifique se coleções, menus, redirecionamentos e conteúdo foram criados corretamente

  5. Teste se todos os modelos de página renderizam adequadamente

  6. Crie novo conteúdo através da interface de administração para verificar se todos os campos funcionam

Publicando Seu Tema

Publique no npm para distribuição:

npm publish --access public

Os usuários podem então instalar seu tema:

npm create astro@latest -- --template @your-org/emdash-theme-blog

Um tema hospedado no GitHub é instalado com o prefixo de modelo github::

npm create astro@latest -- --template github:your-org/emdash-theme-blog

Blocos Portable Text Personalizados

Os temas podem definir tipos de blocos Portable Text personalizados para conteúdo especializado. Isso é útil para páginas de marketing, páginas de destino ou qualquer conteúdo que precise de componentes estruturados além do rich text padrão.

Definindo Blocos Personalizados em Conteúdo Seed

Use um _type com namespace no conteúdo Portable Text do seu arquivo 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."
								}
							]
						}
					]
				}
			}
		]
	}
}

Criando Componentes de Bloco

Crie componentes Astro para cada tipo de bloco 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>

Renderizando Blocos Personalizados

Passe seus componentes de bloco personalizados para o 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 }} />

Renderize o componente wrapper em uma 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 Âncora para Navegação

Adicione _key a blocos que devem ser vinculáveis:

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

Use o valor _key como uma âncora no componente do bloco:

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

Isso habilita links de navegação como /#features.

Lista de Verificação do Tema

Antes de publicar, verifique se seu tema inclui:

  • package.json com campo emdash (rótulo, descrição, caminho do seed)
  • .emdash/seed.json com schema válido
  • Todas as coleções referenciadas nas páginas existem no seed
  • Os menus usados nos layouts estão definidos no seed
  • O conteúdo de exemplo demonstra o design do tema
  • astro.config.mjs com configuração de banco de dados e armazenamento
  • src/live.config.ts com carregador EmDash
  • Sem getStaticPaths() em páginas de conteúdo
  • Sem título do site, slogan ou navegação codificados
  • Campos de imagem acessados como objetos (image.src), não strings
  • README com instruções de configuração
  • Componentes de bloco personalizados para quaisquer tipos Portable Text não padrão

Próximos Passos