Créer des thèmes

Sur cette page

Un thème EmDash est un site Astro complet — pages, mises en page, composants, styles — qui inclut également un fichier seed pour initialiser le modèle de contenu. Créez-en un pour partager votre design avec d’autres, ou pour standardiser la création de sites pour votre agence.

Concepts clés

  • Un thème est un projet Astro fonctionnel. Il n’y a pas d’API de thème ni de couche d’abstraction. Vous construisez un site et le distribuez comme template. Le fichier seed indique simplement à EmDash quelles collections, champs, menus, redirections et taxonomies créer au premier lancement.
  • EmDash vous donne plus de contrôle sur le modèle de contenu que WordPress. Les thèmes en tirent parti — le fichier seed déclare exactement les champs nécessaires pour chaque collection. Construisez à partir des collections standard posts et pages et ajoutez les champs et taxonomies requis par votre design, plutôt que d’inventer des types de contenu entièrement nouveaux.
  • Les pages de contenu du thème doivent être rendues côté serveur. Dans un thème, le contenu change au runtime via l’interface d’administration, donc les pages affichant du contenu EmDash ne doivent pas être pré-rendues. N’utilisez pas getStaticPaths() dans les routes de contenu du thème. (Les builds de sites statiques utilisant EmDash comme source de données au build peuvent utiliser getStaticPaths, mais les thèmes sont toujours en SSR.)
  • Pas de contenu codé en dur. Le titre du site, le slogan, la navigation et tout autre contenu dynamique proviennent du CMS via des appels API — pas de chaînes de caractères en dur dans les templates.

Structure du projet

Créez un thème avec cette structure :

my-emdash-theme/
├── package.json              # Métadonnées du thème
├── astro.config.mjs          # Configuration Astro + EmDash
├── src/
│   ├── live.config.ts        # Configuration des Live Collections
│   ├── pages/
│   │   ├── index.astro       # Page d'accueil
│   │   ├── [...slug].astro   # Pages (catch-all)
│   │   ├── posts/
│   │   │   ├── index.astro   # Archive des articles
│   │   │   └── [slug].astro  # Article individuel
│   │   ├── categories/
│   │   │   └── [slug].astro  # Archive par catégorie
│   │   ├── tags/
│   │   │   └── [slug].astro  # Archive par tag
│   │   ├── search.astro      # Page de recherche
│   │   └── 404.astro         # Page non trouvée
│   ├── layouts/
│   │   └── Base.astro        # Mise en page de base
│   └── components/           # Vos composants
├── .emdash/
│   ├── seed.json             # Schéma et contenu d'exemple
│   └── uploads/              # Fichiers médias locaux optionnels
└── public/                   # Ressources statiques

Les pages sont à la racine comme route catch-all ([...slug].astro), donc une page avec le slug about s’affiche à /about. Les articles, catégories et tags ont leurs propres répertoires. Le répertoire .emdash/ contient le fichier seed et les fichiers médias locaux utilisés dans le contenu d’exemple.

Configurer package.json

Ajoutez le champ emdash à votre 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"
	}
}
ChampDescription
emdash.labelNom d’affichage dans les sélecteurs de thème
emdash.descriptionBrève description du thème
emdash.seedChemin vers le fichier seed
emdash.previewURL vers une démo en ligne (optionnel)

Le modèle de contenu par défaut

La plupart des thèmes ont besoin de deux types de collections : posts et pages. Les articles sont des entrées horodatées avec des extraits et des images à la une qui apparaissent dans les flux et les archives. Les pages sont du contenu autonome à des URL de premier niveau.

C’est le point de départ recommandé. Ajoutez plus de collections, taxonomies ou champs selon les besoins de votre thème, mais commencez ici.

Fichier seed

Le fichier seed indique à EmDash ce qu’il doit créer au premier lancement. Créez .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" }
	]
}

Les articles ont excerpt et featured_image car ils apparaissent dans les listes et les flux. Les pages n’en ont pas besoin — ce sont du contenu autonome. Ajoutez des champs à l’une ou l’autre collection selon les besoins de votre thème.

Voir Format du fichier seed pour la spécification complète, incluant les sections, zones de widgets et références média.

Construire les pages

Toutes les pages affichant du contenu EmDash sont rendues côté serveur. Utilisez Astro.params pour obtenir le slug depuis l’URL et interroger le contenu au moment de la requête.

Page d’accueil

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

Article individuel

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

Les pages utilisent une route catch-all à la racine pour que leurs slugs correspondent directement aux URL de premier niveau — une page avec le slug about s’affiche à /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>

Comme c’est une route catch-all, elle ne correspond qu’aux URL qui n’ont pas de route plus spécifique. /posts/hello-world atteint toujours posts/[slug].astro, pas ce fichier.

Archive par catégorie

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

Utiliser les images

Les champs image sont des objets avec les propriétés src et alt, pas des chaînes de caractères. Utilisez le composant Image de emdash/ui pour un rendu d’image optimisé :

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

Utiliser les menus

Interrogez les menus définis dans l’admin dans vos mises en page. Ne codez jamais les liens de navigation en dur :

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

Les thèmes ont souvent besoin de plusieurs mises en page — une mise en page par défaut, une pleine largeur, une page d’atterrissage. Dans EmDash, ajoutez un champ select template à la collection de pages et associez-le aux composants de mise en page dans votre route catch-all.

Ajoutez le champ à votre collection de pages dans le fichier 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"
}

Puis associez la valeur aux composants de mise en page dans la route 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} />

Les éditeurs choisissent le template depuis un menu déroulant dans l’interface d’administration lors de l’édition d’une page.

Ajouter des sections

Les sections sont des blocs de contenu réutilisables que les éditeurs peuvent insérer dans n’importe quel champ Portable Text en utilisant la commande slash /section. Si votre thème a des patterns de contenu courants (bannières hero, CTA, grilles de fonctionnalités), définissez-les comme sections dans le fichier 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."
						}
					]
				}
			]
		}
	]
}

Les sections créées à partir du fichier seed sont marquées avec source: "theme". Les éditeurs peuvent également créer leurs propres sections (marquées source: "user"), mais les sections fournies par le thème ne peuvent pas être supprimées depuis l’interface d’administration.

Ajouter du contenu d’exemple

Incluez du contenu d’exemple dans le fichier seed pour démontrer le design de votre thème :

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

Inclure des médias

Référencez les images dans le contenu d’exemple en utilisant la syntaxe $media.

Pour les images distantes :

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

Pour les images locales, placez les fichiers dans .emdash/uploads/ et référencez-les :

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

Lors du seeding, les fichiers médias sont téléchargés (ou lus localement) et envoyés vers le stockage.

Recherche

Si votre thème inclut une page de recherche, utilisez le composant LiveSearch pour des résultats instantanés :

---
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 fournit une recherche instantanée avec debounce, correspondance par préfixe, stemming de Porter et extraits de résultats surlignés. La recherche doit être activée par collection dans l’interface d’administration (Types de contenu > Modifier > Fonctionnalités > Recherche).

Tester votre thème

  1. Créez un projet de test à partir de votre thème :

    npm create astro@latest -- --template ./path/to/my-theme
  2. Installez les dépendances et démarrez le serveur de développement :

    cd test-site
    npm install
    npm run dev
  3. Complétez l’assistant de configuration à http://localhost:4321/_emdash/admin

  4. Vérifiez que les collections, menus, redirections et le contenu ont été créés correctement

  5. Testez que tous les templates de page s’affichent correctement

  6. Créez du nouveau contenu via l’admin pour vérifier que tous les champs fonctionnent

Publier votre thème

Publiez sur npm pour la distribution :

npm publish --access public

Les utilisateurs peuvent ensuite installer votre thème :

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

Pour les thèmes hébergés sur GitHub :

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

Blocs Portable Text personnalisés

Les thèmes peuvent définir des types de blocs Portable Text personnalisés pour du contenu spécialisé. C’est utile pour les pages marketing, les pages d’atterrissage, ou tout contenu nécessitant des composants structurés au-delà du texte riche standard.

Définir des blocs personnalisés dans le contenu seed

Utilisez un _type namespacé dans le contenu Portable Text de votre fichier 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."
								}
							]
						}
					]
				}
			}
		]
	}
}

Créer des composants de blocs

Créez des composants Astro pour chaque type de bloc personnalisé :

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

Rendu des blocs personnalisés

Passez vos composants de blocs personnalisés au composant 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 }} />

Puis utilisez-le dans vos pages :

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

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

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

ID d’ancrage pour la navigation

Ajoutez _key aux blocs qui doivent être liables :

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

Puis utilisez-le comme ancre dans votre composant :

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

Cela permet des liens de navigation comme /#features.

Checklist du thème

Avant de publier, vérifiez que votre thème inclut :

  • package.json avec le champ emdash (label, description, chemin du seed)
  • .emdash/seed.json avec un schéma valide
  • Toutes les collections référencées dans les pages existent dans le seed
  • Les menus utilisés dans les mises en page sont définis dans le seed
  • Le contenu d’exemple démontre le design du thème
  • astro.config.mjs avec la configuration de base de données et de stockage
  • src/live.config.ts avec le loader EmDash
  • Pas de getStaticPaths() sur les pages de contenu
  • Pas de titre de site, slogan ou navigation codés en dur
  • Les champs image sont accédés comme objets (image.src), pas comme chaînes
  • README avec les instructions de configuration
  • Composants de blocs personnalisés pour tout type Portable Text non standard

Prochaines étapes