Un thème EmDash est un site Astro complet — pages, layouts, 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. Un thème est un site livré en tant que template. Le fichier seed indique à EmDash quelles collections, champs, menus, redirections et taxonomies créer au premier démarrage.
- Le fichier seed déclare le modèle de contenu. Il liste exactement quels champs chaque collection nécessite. Construisez sur les collections standards posts et pages et ajoutez des champs et des taxonomies selon les besoins du 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 à l’exécution via l’interface d’administration, donc les pages qui affichent 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 moment du build peuvent utilisergetStaticPaths, mais les thèmes sont toujours en SSR.) - Pas de contenu codé en dur. Le titre du site, la baseline, la navigation et autre contenu dynamique proviennent du CMS via des appels API — pas de chaînes de template.
Structure du Projet
Un thème utilise la structure suivante :
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 posts
│ │ │ └── [slug].astro # Post individuel
│ │ ├── categories/
│ │ │ └── [slug].astro # Archive des catégories
│ │ ├── tags/
│ │ │ └── [slug].astro # Archive des tags
│ │ ├── search.astro # Page de recherche
│ │ └── 404.astro # Non trouvé
│ ├── layouts/
│ │ └── Base.astro # Layout de base
│ └── components/ # Vos composants
├── .emdash/
│ ├── seed.json # Schéma et contenu d'exemple
│ └── uploads/ # Fichiers médias locaux optionnels
└── public/ # Assets statiques
Les pages sont à la racine en tant que route catch-all ([...slug].astro), donc une page avec le slug about est rendue à /about. Les posts, catégories et tags obtiennent leurs propres répertoires. Le répertoire .emdash/ contient le fichier seed et tous les fichiers médias locaux utilisés dans le contenu d’exemple.
Configuration de 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"
}
}
| Champ | Description |
|---|---|
emdash.label | Nom affiché dans les sélecteurs de thèmes |
emdash.description | Brève description du thème |
emdash.seed | Chemin vers le fichier seed |
emdash.preview | URL vers une démo en direct (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 posts sont des entrées horodatées avec des extraits et des images mises en avant qui apparaissent dans les flux et les archives. Les pages sont du contenu autonome à des URLs de premier niveau.
C’est le point de départ recommandé. Ajoutez plus de collections, de taxonomies ou de champs selon les besoins de votre thème, mais commencez ici.
Fichier Seed
Le fichier seed indique à EmDash quoi créer au premier démarrage. 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 posts obtiennent 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, les zones de widgets et les références de médias.
Construction des Pages
Toutes les pages qui affichent du contenu EmDash sont rendues côté serveur. Utilisez Astro.params pour obtenir le slug de 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>
Post 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 soient directement mappés sur des URLs de premier niveau — une page avec le slug about est rendue à /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 il s’agit d’une route catch-all, elle ne correspond qu’aux URLs qui n’ont pas de route plus spécifique. /posts/hello-world atteint toujours posts/[slug].astro, pas ce fichier.
Archive de 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>
Utilisation des Images
Les champs d’image sont des objets avec des propriétés src et alt, pas des chaînes. 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>
Utilisation des Menus
Interrogez les menus définis par l’administrateur dans vos layouts. Ne codez jamais de 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 layouts de page — un layout par défaut, un layout pleine largeur, un layout de page de destination. Dans EmDash, ajoutez un champ select template à la collection de pages et mappez-le sur des composants de layout dans votre route catch-all.
Ajoutez le champ à la 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"
}
Mappez ensuite la valeur sur des composants de layout 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 dans un menu déroulant de l’interface d’administration lors de l’édition d’une page.
Ajout de 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 motifs de contenu courants (bannières hero, CTAs, 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 de l’interface d’administration.
Ajout de 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"]
}
}
]
}
}
Inclusion de Médias
Référencez les images dans le contenu d’exemple en utilisant la syntaxe $media. Une image distante est référencée par URL :
{
"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 par nom de fichier :
{
"data": {
"featured_image": {
"$media": {
"file": "hero.jpg",
"alt": "A descriptive alt text"
}
}
}
}
Pendant le seeding, les fichiers médias sont téléchargés (ou lus localement) et téléversés dans 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 différée avec correspondance de préfixe, stemming de Porter et extraits de résultats mis en évidence. La recherche doit être activée par collection dans l’interface d’administration (Content Types > Edit > Features > Search).
Test de votre Thème
-
Créez un projet de test à partir de votre thème :
npm create astro@latest -- --template ./path/to/my-theme -
Installez les dépendances et démarrez le serveur de développement :
cd test-site npm install npm run dev -
Complétez l’Assistant de Configuration à
http://localhost:4321/_emdash/admin -
Vérifiez que les collections, menus, redirections et contenu ont été créés correctement
-
Testez que tous les templates de page se rendent correctement
-
Créez du nouveau contenu via l’interface d’administration pour vérifier que tous les champs fonctionnent
Publication de votre Thème
Publiez sur npm pour la distribution :
npm publish --access public
Les utilisateurs peuvent alors installer votre thème :
npm create astro@latest -- --template @your-org/emdash-theme-blog
Un thème hébergé sur GitHub est installé avec le préfixe de template 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 de destination ou tout contenu qui nécessite des composants structurés au-delà du texte riche standard.
Définition de Blocs Personnalisés dans le Contenu Seed
Utilisez un _type avec espace de noms 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éation de 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 }} />
Rendez le composant wrapper dans une page :
---
import { getEmDashEntry } from "emdash";
import MarketingBlocks from "../components/MarketingBlocks.astro";
const { entry: page } = await getEmDashEntry("pages", "home");
---
<MarketingBlocks value={page.data.content} />
IDs d’Ancre pour la Navigation
Ajoutez _key aux blocs qui devraient être liables :
{
"_type": "marketing.features",
"_key": "features",
"headline": "Features"
}
Utilisez la valeur _key comme ancre dans le composant du bloc :
<section id={value._key}>
<!-- content -->
</section>
Cela permet des liens de navigation comme /#features.
Liste de Contrôle du Thème
Avant de publier, vérifiez que votre thème inclut :
-
package.jsonavec champemdash(label, description, chemin du seed) -
.emdash/seed.jsonavec schéma valide - Toutes les collections référencées dans les pages existent dans le seed
- Les menus utilisés dans les layouts sont définis dans le seed
- Le contenu d’exemple démontre le design du thème
-
astro.config.mjsavec configuration de base de données et de stockage -
src/live.config.tsavec chargeur EmDash - Pas de
getStaticPaths()sur les pages de contenu - Pas de titre de site, baseline ou navigation codés en dur
- Les champs d’image sont accédés comme objets (
image.src), pas comme chaînes - README avec instructions de configuration
- Composants de blocs personnalisés pour tous les types Portable Text non standard
Prochaines Étapes
- Format du Fichier Seed — Référence complète pour les fichiers seed
- Aperçu des Thèmes — Comment fonctionnent les thèmes dans EmDash
- Porter des Thèmes WordPress — Convertir des thèmes WordPress existants