Themes erstellen

Auf dieser Seite

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

Schlüsselkonzepte

  • Ein Theme ist ein funktionierendes Astro-Projekt. Es gibt keine Theme-API oder Abstraktionsschicht. Ein Theme ist eine Site, die als Template ausgeliefert wird. Die Seed-Datei teilt EmDash mit, welche Collections, Felder, Menüs, Weiterleitungen und Taxonomien beim ersten Start erstellt werden sollen.
  • Die Seed-Datei deklariert das Inhaltsmodell. Sie listet genau auf, welche Felder jede Collection benötigt. Bauen Sie auf den Standard-Collections posts und pages auf und fügen Sie Felder und Taxonomien hinzu, wie es das Design erfordert, anstatt völlig neue Inhaltstypen zu erfinden.
  • Theme-Inhaltsseiten müssen server-gerendert sein. 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-Time-Datenquelle verwenden, können getStaticPaths verwenden, aber Themes sind immer SSR.)
  • Keine hartcodierten Inhalte. Seitentitel, Tagline, Navigation und andere dynamische Inhalte kommen vom CMS über API-Aufrufe — nicht aus Template-Strings.

Projektstruktur

Ein Theme verwendet folgende 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 befinden sich im Stammverzeichnis als Catch-all-Route ([...slug].astro), sodass eine Seite mit dem Slug about unter /about gerendert wird. Beiträge, Kategorien und Tags erhalten ihre eigenen Verzeichnisse. Das .emdash/-Verzeichnis enthält die Seed-Datei und alle lokalen Mediendateien, die in Beispielinhalten verwendet werden.

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 weitere Collections, Taxonomien oder Felder hinzu, wie es Ihr Theme erfordert, 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 brauchen diese nicht — sie sind eigenständige Inhalte. Fügen Sie bei Bedarf Felder zu beiden Collections hinzu.

Siehe Seed-Datei-Format für die vollständige Spezifikation, einschließlich Sections, Widget-Areas und Medienreferenzen.

Seiten erstellen

Alle Seiten, die EmDash-Inhalte anzeigen, sind server-gerendert. Verwenden Sie Astro.params, um den Slug aus der URL zu erhalten und Inhalte zur Anfragezeitpunkt 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 abbilden — 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 dies eine Catch-all-Route ist, passt sie nur auf URLs, die keine spezifischere Route haben. /posts/hello-world trifft immer noch 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 von 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}
      priority
    />
  )}
  <h2><a href={`/posts/${post.slug}`}>{post.data.title}</a></h2>
  <p>{post.data.excerpt}</p>
</article>

Verwenden Sie priority nur für das wahrscheinlich über dem Fold sichtbare Bild auf einer Seite, wie ein Hero- oder erstes Kartenbild. Es rendert das Bild mit loading="eager" und fetchpriority="high". loading steuert, ob der Browser das Laden aufschieben darf, während fetchpriority andeutet, wie wichtig die Anfrage ist, sobald der Browser sie entdeckt.

Menüs verwenden

Fragen Sie admin-definierte Menüs in Ihren Layouts ab. Niemals Navigationslinks hartcodieren:

---
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 Standard-Layout, ein Vollbreite-Layout, ein Landing-Page-Layout. In EmDash fügen Sie ein template-Auswahlfeld zur Pages-Collection hinzu und ordnen es in Ihrer Catch-all-Route Layout-Komponenten zu.

Fügen Sie das Feld zur Pages-Collection in der Seed-Datei 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 dann den Wert 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 aus einem Dropdown in der Admin-UI beim Bearbeiten einer Seite.

Sections hinzufügen

Sections sind wiederverwendbare Inhaltsblöcke, die Redakteure in jedes Portable-Text-Feld mit dem /section-Slash-Befehl einfügen können. Wenn Ihr Theme häufige Inhaltsmuster hat (Hero-Banner, CTAs, Feature-Grids), definieren Sie sie 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."
						}
					]
				}
			]
		}
	]
}

Sections, die aus der Seed-Datei erstellt werden, sind mit source: "theme" markiert. Redakteure können auch eigene Sections erstellen (markiert mit source: "user"), aber theme-bereitgestellte 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. Ein Remote-Bild wird per URL referenziert:

{
	"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 sie per Dateiname:

{
	"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 (Content Types > Edit > Features > Search).

Ihr 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, Weiterleitungen und Inhalte korrekt erstellt wurden

  5. Testen Sie, ob alle Seitenvorlagen korrekt gerendert werden

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

Ihr Theme veröffentlichen

Veröffentlichen Sie auf npm zur Verteilung:

npm publish --access public

Benutzer können dann Ihr Theme installieren:

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

Ein auf GitHub gehostetes Theme wird mit dem github:-Template-Präfix installiert:

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

Benutzerdefinierte Portable-Text-Blöcke

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

Benutzerdefinierte Blöcke in Seed-Content definieren

Verwenden Sie einen namespaced _type im Portable-Text-Content 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 benutzerdefinierten 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>

Benutzerdefinierte Blöcke rendern

Übergeben Sie Ihre benutzerdefinierten 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 }} />

Rendern Sie die Wrapper-Komponente in einer Seite:

---
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 den _key-Wert als Anker in der Block-Komponente:

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

Dies ermöglicht Navigationslinks wie /#features.

Theme-Checkliste

Vor der Veröffentlichung überprüfen Sie, ob Ihr Theme enthält:

  • package.json mit emdash-Feld (Label, Beschreibung, Seed-Pfad)
  • .emdash/seed.json mit gültigem Schema
  • Alle in Seiten referenzierten Collections existieren im Seed
  • In Layouts verwendete Menüs sind im Seed 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 hartcodierter Seitentitel, Tagline oder Navigation
  • Bildfelder als Objekte zugegriffen (image.src), nicht als Strings
  • README mit Setup-Anweisungen
  • Benutzerdefinierte Block-Komponenten für nicht-standardmäßige Portable-Text-Typen

Nächste Schritte