Criar Temas

Nesta página

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

Conceitos Fundamentais

  • Um tema é um projeto Astro funcional. Não existe API de tema ou camada de abstração. Constrói um site e distribui-o como template. O ficheiro seed apenas diz ao EmDash quais coleções, campos, menus, redirecionamentos e taxonomias criar na primeira execução.
  • O EmDash dá-lhe mais controlo sobre o modelo de conteúdo do que o WordPress. Os temas aproveitam isto — o ficheiro seed declara exatamente quais campos cada coleção precisa. Construa sobre as coleções padrão posts e pages e adicione campos e taxonomias conforme o seu design requer, em vez de inventar tipos de conteúdo inteiramente novos.
  • As páginas de conteúdo do tema devem ser renderizadas no servidor. Num tema, o conteúdo muda em tempo de execução através do painel de administração, por isso 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 que usam o EmDash como fonte de dados em tempo de build podem usar getStaticPaths, mas os temas são sempre SSR.)
  • Sem conteúdo fixo no código. O título do site, tagline, navegação e outro conteúdo dinâmico vêm do CMS através de chamadas à API — não de strings no template.

Estrutura do Projeto

Crie um tema com esta 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 pesquisa
│   │   └── 404.astro         # Não encontrado
│   ├── layouts/
│   │   └── Base.astro        # Layout base
│   └── components/           # Os seus componentes
├── .emdash/
│   ├── seed.json             # Esquema e conteúdo de exemplo
│   └── uploads/              # Ficheiros de media locais opcionais
└── public/                   # Assets estáticos

As páginas vivem na raiz como rota catch-all ([...slug].astro), por isso uma página com slug about é renderizada em /about. Posts, categorias e tags têm os seus próprios diretórios. O diretório .emdash/ contém o ficheiro seed e quaisquer ficheiros de media locais usados no conteúdo de exemplo.

Configurar o 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 de exibição mostrado nos seletores de tema
emdash.descriptionBreve descrição do tema
emdash.seedCaminho para o ficheiro 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ção: posts e pages. Os posts são entradas com timestamp, excertos e imagens de destaque que aparecem em feeds e arquivos. As 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 o seu tema precisar, mas comece aqui.

Ficheiro Seed

O ficheiro seed diz 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" }
	]
}

Os posts têm excerpt e featured_image porque aparecem em listas e feeds. As páginas não precisam deles — são conteúdo autónomo. Adicione campos a qualquer uma das coleções conforme o seu tema requeira.

Consulte Formato do Ficheiro Seed para a especificação completa, incluindo secções, áreas de widgets e referências de media.

Construir Páginas

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

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>

Páginas

As páginas usam uma rota catch-all na raiz para que os seus slugs mapeiem 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 é uma rota catch-all, apenas corresponde a URLs que não têm uma rota mais específica. /posts/hello-world continua a ser tratado por posts/[slug].astro, não por este ficheiro.

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>

Usar Imagens

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

Usar Menus

Consulte menus definidos no admin nos seus layouts. Nunca coloque links de navegação fixos no código:

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

Templates de Página

Os temas frequentemente precisam de múltiplos layouts de página — um layout padrão, um layout de largura total, um layout de landing page. No EmDash, adicione um campo select template à coleção de páginas e mapeie-o para componentes de layout na sua rota catch-all.

Adicione o campo à sua coleção de páginas no ficheiro 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"
}

Depois 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 template de um dropdown no painel de administração ao editar uma página.

Adicionar Secções

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

As secções criadas a partir do ficheiro seed são marcadas com source: "theme". Os editores também podem criar as suas próprias secções (marcadas source: "user"), mas as secções fornecidas pelo tema não podem ser eliminadas do painel de administração.

Adicionar Conteúdo de Exemplo

Inclua conteúdo de exemplo no ficheiro 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"]
				}
			}
		]
	}
}

Incluir Media

Referencie imagens no conteúdo de exemplo usando a sintaxe $media.

Para imagens remotas:

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

Para imagens locais, coloque os ficheiros em .emdash/uploads/ e referencie-os:

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

Durante o seeding, os ficheiros de media são descarregados (ou lidos localmente) e enviados para o armazenamento.

Pesquisa

Se o seu tema inclui uma página de pesquisa, 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>

O LiveSearch fornece pesquisa instantânea com debounce, correspondência de prefixo, stemming de Porter e snippets de resultados com destaque. A pesquisa deve ser ativada por coleção no painel de administração (Content Types > Edit > Features > Search).

Testar o Seu Tema

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

    npm create astro@latest -- --template ./path/to/my-theme
  2. Instale as 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 as coleções, menus, redirecionamentos e conteúdo foram criados corretamente

  5. Teste se todos os templates de página são renderizados corretamente

  6. Crie novo conteúdo através do admin para verificar que todos os campos funcionam

Publicar o Seu Tema

Publique no npm para distribuição:

npm publish --access public

Os utilizadores podem então instalar o seu tema:

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

Para temas alojados no 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. Isto é útil para páginas de marketing, landing pages ou qualquer conteúdo que precise de componentes estruturados para além do rich text padrão.

Definir Blocos Personalizados no Conteúdo Seed

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

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

Renderizar Blocos Personalizados

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

Depois use nas suas páginas:

---
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 aos blocos que devem ser linkáveis:

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

Depois use como âncora no seu componente:

<section id={value._key}>
  <!-- conteúdo -->
</section>

Isto permite links de navegação como /#features.

Checklist do Tema

Antes de publicar, verifique que o seu tema inclui:

  • package.json com campo emdash (label, description, caminho do seed)
  • .emdash/seed.json com esquema 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 base de dados e armazenamento
  • src/live.config.ts com loader EmDash
  • Sem getStaticPaths() nas páginas de conteúdo
  • Sem título do site, tagline ou navegação fixos no código
  • Campos de imagem acedidos como objetos (image.src), não como strings
  • README com instruções de configuração
  • Componentes de bloco personalizados para quaisquer tipos Portable Text não padrão

Próximos Passos