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. Crie um para partilhar o seu design com outros, ou para padronizar a criação de sites para a sua agência.

Conceitos-chave

  • Um tema é um projeto Astro funcional. Não há API de temas nem camada de abstração. Um tema é um site empacotado como template. O ficheiro seed indica ao EmDash que coleções, campos, menus, redirecionamentos e taxonomias criar na primeira execução.
  • O ficheiro seed declara o modelo de conteúdo. Lista exatamente quais campos cada coleção necessita. 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. Num 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() em rotas de conteúdo do tema. (Builds de sites estáticos que usam EmDash como fonte de dados em tempo de build podem usar getStaticPaths, mas temas são sempre SSR.)
  • Sem conteúdo hardcoded. 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 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 categoria
│   │   ├── 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             # Schema e conteúdo de exemplo
│   └── uploads/              # Ficheiros multimédia locais opcionais
└── public/                   # Assets estáticos

As páginas ficam na raiz como rota catch-all ([...slug].astro), assim 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 multimédia locais usados no conteúdo de exemplo.

Configurar 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 nos seletores de temas
emdash.descriptionBreve descrição do tema
emdash.seedCaminho para o ficheiro seed
emdash.previewURL para uma demo ao vivo (opcional)

O modelo de conteúdo padrão

A maioria dos temas necessita de dois tipos de coleção: posts e pages. Posts são entradas com timestamp, com excertos e imagens de destaque que aparecem em feeds e arquivos. Pages são conteúdo independente em URLs de nível superior.

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

Ficheiro Seed

O ficheiro seed indica 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. Pages não precisam deles — são conteúdo independente. Adicione campos a qualquer coleção conforme o seu tema necessitar.

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

Construir 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 conteúdo em cada 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, só corresponde a URLs sem uma rota mais específica. /posts/hello-world ainda chega a posts/[slug].astro, não a este ficheiro.

Arquivo de categoria

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

Use priority apenas para a imagem provavelmente visível acima da dobra numa página, como um hero ou primeira imagem de card. Renderiza a imagem com loading="eager" e fetchpriority="high". loading controla se o navegador pode adiar o carregamento, enquanto fetchpriority indica a importância do pedido após o navegador o descobrir.

Usar menus

Consulte menus definidos pelo admin nos seus layouts. Nunca hardcode 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>

Templates de página

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

Adicione o campo à 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 a 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 a partir de um dropdown na interface 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 da interface 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 multimé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 ficheiros em .emdash/uploads/ e referencie-os pelo nome do ficheiro:

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

Durante o seeding, os ficheiros multimédia são descarregados (ou lidos localmente) e carregados 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>

LiveSearch fornece pesquisa instantânea com debounce, correspondência de prefixos, stemming Porter e snippets de resultados destacados. A pesquisa deve ser ativada por coleção na interface 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 que coleções, menus, redirecionamentos e conteúdo foram criados corretamente

  5. Teste que 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

Um tema alojado no GitHub é instalado com o prefixo de template 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 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 }} />

Renderize o componente wrapper numa 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 linkáveis:

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

Use o valor _key como âncora no componente de bloco:

<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 seed)
  • .emdash/seed.json com schema válido
  • Todas as coleções referenciadas nas páginas existem no seed
  • Menus usados nos layouts estão definidos no seed
  • 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 hardcoded
  • Campos de imagem acedidos como objetos (image.src), não strings
  • README com instruções de configuração
  • Componentes de bloco personalizados para tipos Portable Text não padrão

Próximos passos