Themes erstellen

Auf dieser Seite

Ein EmDash-Theme ist eine vollständige Astro-Site — Seiten, Layouts, Komponenten, Styles — die zusätzlich eine Seed-Datei enthält, um das Inhaltsmodell zu bootstrappen. Erstellen Sie eines, um Ihr Design mit anderen zu teilen oder die Site-Erstellung für Ihre Agentur zu standardisieren.

Kernkonzepte

  • Ein Theme ist ein funktionierendes Astro-Projekt. Es gibt keine Theme-API oder Abstraktionsschicht. Sie bauen eine Site und liefern sie als Template aus. Die Seed-Datei teilt EmDash lediglich mit, welche Collections, Felder, Menüs, Redirects und Taxonomien beim ersten Start erstellt werden sollen.
  • EmDash gibt Ihnen mehr Kontrolle über das Inhaltsmodell als WordPress. Themes nutzen dies — die Seed-Datei deklariert exakt, welche Felder jede Collection benötigt. Bauen Sie auf den Standard-Collections posts und pages auf und fügen Sie Felder und Taxonomien nach Bedarf hinzu, anstatt völlig neue Inhaltstypen zu erfinden.
  • Theme-Inhaltsseiten müssen serverseitig gerendert werden. In einem Theme ändern sich Inhalte zur Laufzeit über die Admin-UI, daher dürfen Seiten, die EmDash-Inhalte anzeigen, nicht vorgerendert werden. Verwenden Sie kein getStaticPaths() in Theme-Inhaltsrouten. (Statische Site-Builds, die EmDash als Build-Zeit-Datenquelle nutzen, können getStaticPaths verwenden, aber Themes sind immer SSR.)
  • Keine hart codierten Inhalte. Site-Titel, Tagline, Navigation und andere dynamische Inhalte kommen über API-Aufrufe aus dem CMS — nicht aus Template-Strings.

Projektstruktur

Erstellen Sie ein Theme mit dieser Struktur:

my-emdash-theme/
├── package.json              # Theme-Metadaten
├── astro.config.mjs          # Astro- + EmDash-Konfiguration
├── src/
│   ├── live.config.ts        # Live-Collections-Setup
│   ├── pages/
│   │   ├── index.astro       # Startseite
│   │   ├── [...slug].astro   # Seiten (Catch-all)
│   │   ├── posts/
│   │   │   ├── index.astro   # Beitragsarchiv
│   │   │   └── [slug].astro  # Einzelner Beitrag
│   │   ├── categories/
│   │   │   └── [slug].astro  # Kategoriearchiv
│   │   ├── tags/
│   │   │   └── [slug].astro  # Tag-Archiv
│   │   ├── search.astro      # Suchseite
│   │   └── 404.astro         # Nicht gefunden
│   ├── layouts/
│   │   └── Base.astro        # Basis-Layout
│   └── components/           # Ihre Komponenten
├── .emdash/
│   ├── seed.json             # Schema und Beispielinhalte
│   └── uploads/              # Optionale lokale Mediendateien
└── public/                   # Statische Assets

Seiten liegen als Catch-all-Route ([...slug].astro) im Stammverzeichnis, sodass eine Seite mit Slug about unter /about gerendert wird. Beiträge, Kategorien und Tags erhalten eigene Verzeichnisse. Das Verzeichnis .emdash/ enthält die Seed-Datei und optionale lokale Mediendateien für Beispielinhalte.

package.json konfigurieren

Fügen Sie das emdash-Feld zu Ihrer package.json hinzu:

{
	"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"
	}
}
FeldBeschreibung
emdash.labelAnzeigename in Theme-Auswahlmenüs
emdash.descriptionKurze Beschreibung des Themes
emdash.seedPfad zur Seed-Datei
emdash.previewURL zu einer Live-Demo (optional)

Das Standard-Inhaltsmodell

Die meisten Themes benötigen zwei Collection-Typen: posts und pages. Posts sind zeitgestempelte Einträge mit Auszügen und Beitragsbildern, die in Feeds und Archiven erscheinen. Pages sind eigenständige Inhalte unter Top-Level-URLs.

Dies ist der empfohlene Ausgangspunkt. Fügen Sie nach Bedarf weitere Collections, Taxonomien oder Felder hinzu, aber starten Sie hier.

Seed-Datei

Die Seed-Datei teilt EmDash mit, was beim ersten Start erstellt werden soll. Erstellen Sie .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 erhalten excerpt und featured_image, weil sie in Listen und Feeds erscheinen. Pages benötigen diese nicht — sie sind eigenständige Inhalte. Fügen Sie nach Bedarf weitere Felder hinzu.

Unter Seed-Datei-Format finden Sie die vollständige Spezifikation, einschließlich Sections, Widget-Bereiche und Medienreferenzen.

Seiten bauen

Alle Seiten, die EmDash-Inhalte anzeigen, werden serverseitig gerendert. Verwenden Sie Astro.params, um den Slug aus der URL zu extrahieren und Inhalte zur Anfragezeit abzufragen.

Startseite

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

Einzelner Beitrag

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

Seiten

Seiten verwenden eine Catch-all-Route im Stammverzeichnis, sodass ihre Slugs direkt auf Top-Level-URLs abgebildet werden — eine Seite mit Slug about wird unter /about gerendert:

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

Da es sich um eine Catch-all-Route handelt, greift sie nur bei URLs, die keine spezifischere Route haben. /posts/hello-world trifft weiterhin posts/[slug].astro, nicht diese Datei.

Kategoriearchiv

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

Bilder verwenden

Bildfelder sind Objekte mit src- und alt-Eigenschaften, keine Strings. Verwenden Sie die Image-Komponente aus emdash/ui für optimiertes Bild-Rendering:

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

Menüs verwenden

Fragen Sie im Admin definierte Menüs in Ihren Layouts ab. Codieren Sie Navigationslinks niemals hart:

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

Seitenvorlagen

Themes benötigen oft mehrere Seitenlayouts — ein Standardlayout, ein Layout in voller Breite, ein Landing-Page-Layout. In EmDash fügen Sie der Pages-Collection ein template-Auswahlfeld hinzu und ordnen es in Ihrer Catch-all-Route Layout-Komponenten zu.

Fügen Sie das Feld in der Seed-Datei zur Pages-Collection hinzu:

{
	"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"
}

Ordnen Sie den Wert dann in der Catch-all-Route Layout-Komponenten zu:

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

Redakteure wählen die Vorlage beim Bearbeiten einer Seite über ein Dropdown in der Admin-UI.

Sections hinzufügen

Sections sind wiederverwendbare Inhaltsblöcke, die Redakteure per Slash-Befehl /section in jedes Portable-Text-Feld einfügen können. Wenn Ihr Theme häufige Inhaltsmuster hat (Hero-Banner, CTAs, Feature-Grids), definieren Sie diese als Sections in der Seed-Datei:

{
	"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."
						}
					]
				}
			]
		}
	]
}

Aus der Seed-Datei erstellte Sections werden mit source: "theme" markiert. Redakteure können auch eigene Sections erstellen (markiert mit source: "user"), aber Theme-Sections können nicht aus der Admin-UI gelöscht werden.

Beispielinhalte hinzufügen

Fügen Sie Beispielinhalte in die Seed-Datei ein, um das Design Ihres Themes zu demonstrieren:

{
	"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"]
				}
			}
		]
	}
}

Medien einbinden

Referenzieren Sie Bilder in Beispielinhalten mit der $media-Syntax.

Für Remote-Bilder:

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

Für lokale Bilder platzieren Sie Dateien in .emdash/uploads/ und referenzieren diese:

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

Beim Seeding werden Mediendateien heruntergeladen (oder lokal gelesen) und in den Speicher hochgeladen.

Suche

Wenn Ihr Theme eine Suchseite enthält, verwenden Sie die LiveSearch-Komponente für sofortige Ergebnisse:

---
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 bietet entprellte Sofortsuche mit Präfix-Matching, Porter-Stemming und hervorgehobenen Ergebnis-Snippets. Die Suche muss pro Collection in der Admin-UI aktiviert werden (Inhaltstypen > Bearbeiten > Features > Suche).

Theme testen

  1. Erstellen Sie ein Testprojekt aus Ihrem Theme:

    npm create astro@latest -- --template ./path/to/my-theme
  2. Installieren Sie Abhängigkeiten und starten Sie den Dev-Server:

    cd test-site
    npm install
    npm run dev
  3. Schließen Sie den Setup-Assistenten unter http://localhost:4321/_emdash/admin ab

  4. Überprüfen Sie, ob Collections, Menüs, Redirects und Inhalte korrekt erstellt wurden

  5. Testen Sie, ob alle Seitenvorlagen korrekt gerendert werden

  6. Erstellen Sie neue Inhalte über das Admin, um zu verifizieren, dass alle Felder funktionieren

Theme veröffentlichen

Veröffentlichen Sie auf npm zur Verteilung:

npm publish --access public

Benutzer können Ihr Theme dann installieren:

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

Für auf GitHub gehostete Themes:

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

Eigene Portable-Text-Blöcke

Themes können eigene Portable-Text-Blocktypen für spezialisierte Inhalte definieren. Das ist nützlich für Marketingseiten, Landing Pages oder Inhalte, die strukturierte Komponenten jenseits von Standard-Rich-Text benötigen.

Eigene Blöcke in Seed-Inhalten definieren

Verwenden Sie einen namespaced _type in den Portable-Text-Inhalten Ihrer Seed-Datei:

{
	"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."
								}
							]
						}
					]
				}
			}
		]
	}
}

Block-Komponenten erstellen

Erstellen Sie Astro-Komponenten für jeden eigenen Blocktyp:

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

Eigene Blöcke rendern

Übergeben Sie Ihre eigenen Block-Komponenten an die PortableText-Komponente:

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

Verwenden Sie es dann in Ihren Seiten:

---
import { getEmDashEntry } from "emdash";
import MarketingBlocks from "../components/MarketingBlocks.astro";

const { entry: page } = await getEmDashEntry("pages", "home");
---

<MarketingBlocks value={page.data.content} />

Anker-IDs für Navigation

Fügen Sie _key zu Blöcken hinzu, die verlinkbar sein sollen:

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

Verwenden Sie es dann als Anker in Ihrer Komponente:

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

Dies ermöglicht Navigationslinks wie /#features.

Theme-Checkliste

Überprüfen Sie vor der Veröffentlichung, dass Ihr Theme Folgendes enthält:

  • package.json mit emdash-Feld (Label, Beschreibung, Seed-Pfad)
  • .emdash/seed.json mit gültigem Schema
  • Alle in Seiten referenzierten Collections existieren in der Seed-Datei
  • In Layouts verwendete Menüs sind in der Seed-Datei definiert
  • Beispielinhalte demonstrieren das Design des Themes
  • astro.config.mjs mit Datenbank- und Speicherkonfiguration
  • src/live.config.ts mit EmDash-Loader
  • Kein getStaticPaths() auf Inhaltsseiten
  • Kein hart codierter Site-Titel, Tagline oder Navigation
  • Bildfelder werden als Objekte (image.src) aufgerufen, nicht als Strings
  • README mit Setup-Anleitung
  • Eigene Block-Komponenten für alle nicht standardmäßigen Portable-Text-Typen

Nächste Schritte