Themes erstellen

Auf dieser Seite

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

Kernkonzepte

  • 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, Redirects und Taxonomien beim ersten Start erstellt werden sollen.
  • Die Seed-Datei deklariert das Content-Modell. 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 Content-Typen zu erfinden.
  • Theme-Content-Seiten müssen server-gerendert sein. In einem Theme ändert sich der Content zur Laufzeit über die Admin-UI, daher dürfen Seiten, die EmDash-Content anzeigen, nicht vorgerendert werden. Verwenden Sie nicht getStaticPaths() in Theme-Content-Routen. (Statische Site-Builds, die EmDash als Build-Zeit-Datenquelle verwenden, können getStaticPaths verwenden, aber Themes sind immer SSR.)
  • Kein hart-codierter Content. Site-Titel, Tagline, Navigation und andere dynamische Inhalte kommen über API-Aufrufe aus dem CMS — nicht aus Template-Strings.

Projektstruktur

Ein Theme verwendet die 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       # Homepage
│   │   ├── [...slug].astro   # Seiten (Catch-all)
│   │   ├── posts/
│   │   │   ├── index.astro   # Post-Archiv
│   │   │   └── [slug].astro  # Einzelner Post
│   │   ├── categories/
│   │   │   └── [slug].astro  # Kategorie-Archiv
│   │   ├── 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 Beispiel-Content
│   └── uploads/              # Optionale lokale Mediendateien
└── public/                   # Statische Assets

Seiten befinden sich im Root als Catch-all-Route ([...slug].astro), sodass eine Seite mit dem Slug about unter /about gerendert wird. Posts, Kategorien und Tags erhalten ihre eigenen Verzeichnisse. Das .emdash/-Verzeichnis enthält die Seed-Datei und alle lokalen Mediendateien, die im Beispiel-Content 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-Auswahlen
emdash.descriptionKurze Beschreibung des Themes
emdash.seedPfad zur Seed-Datei
emdash.previewURL zu einer Live-Demo (optional)

Das Standard-Content-Modell

Die meisten Themes benötigen zwei Collection-Typen: posts und pages. Posts sind zeitgestempelte Einträge mit Auszügen und Featured Images, 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 Ihr Theme sie benötigt, aber beginnen 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 Felder zu beiden Collections hinzu, wie Ihr Theme sie benötigt.

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

Seiten erstellen

Alle Seiten, die EmDash-Content anzeigen, sind server-gerendert. Verwenden Sie Astro.params, um den Slug aus der URL zu erhalten und Content zur Anfrage-Zeit abzufragen.

Homepage

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

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

Pages verwenden eine Catch-all-Route im Root, sodass ihre Slugs direkt auf Top-Level-URLs abgebildet werden — eine Seite mit dem 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, matcht sie nur URLs, die keine spezifischere Route haben. /posts/hello-world trifft immer noch auf posts/[slug].astro, nicht auf diese Datei.

Kategorie-Archiv

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

Menüs verwenden

Fragen Sie admin-definierte Menüs in Ihren Layouts ab. Codieren Sie niemals Navigations-Links 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>

Seiten-Templates

Themes benötigen oft mehrere Seiten-Layouts — ein Standard-Layout, ein Full-Width-Layout, ein Landing-Page-Layout. Fügen Sie in EmDash ein template-Select-Feld zur Pages-Collection hinzu und mappen Sie es in Ihrer Catch-all-Route auf Layout-Komponenten.

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

Mappen Sie dann den Wert in der Catch-all-Route auf Layout-Komponenten:

---
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 das Template aus einem Dropdown in der Admin-UI beim Bearbeiten einer Seite.

Sections hinzufügen

Sections sind wiederverwendbare Content-Blöcke, die Redakteure mit dem /section-Slash-Befehl in jedes Portable Text-Feld einfügen können. Wenn Ihr Theme häufige Content-Muster 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 wurden, 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.

Beispiel-Content hinzufügen

Fügen Sie Beispiel-Content 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 im Beispiel-Content 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 sie per Dateiname:

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

Während des Seedings werden Mediendateien heruntergeladen (oder lokal gelesen) und in den Storage 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 verzögerte 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 Test-Projekt 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-Wizard unter http://localhost:4321/_emdash/admin ab

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

  5. Testen Sie, dass alle Seiten-Templates ordnungsgemäß rendern

  6. Erstellen Sie neuen Content über die Admin-UI, um zu überprüfen, 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 jeden Content, der strukturierte Komponenten über Standard-Rich-Text hinaus benötigt.

Benutzerdefinierte Blöcke im Seed-Content definieren

Verwenden Sie einen Namespace-_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}>
  <!-- content -->
</section>

Dies ermöglicht Navigations-Links wie /#features.

Theme-Checkliste

Bevor Sie veröffentlichen, überprüfen Sie, 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 im Seed
  • In Layouts verwendete Menüs sind im Seed definiert
  • Beispiel-Content demonstriert das Design des Themes
  • astro.config.mjs mit Datenbank- und Storage-Konfiguration
  • src/live.config.ts mit EmDash-Loader
  • Kein getStaticPaths() auf Content-Seiten
  • Kein hart-codierter Site-Titel, Tagline oder Navigation
  • Bildfelder werden als Objekte (image.src) aufgerufen, nicht als Strings
  • README mit Setup-Anweisungen
  • Benutzerdefinierte Block-Komponenten für alle nicht-standardmäßigen Portable Text-Typen

Nächste Schritte