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èmes ni de couche d’abstraction. Un thème est un site packagé comme template. Le fichier seed indique à EmDash quelles collections, champs, menus, redirections et taxonomies créer au premier lancement.
- Le fichier seed déclare le modèle de contenu. Il liste exactement les champs nécessaires à chaque collection. Construisez sur les collections standard posts et pages et ajoutez des champs et 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 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 utilisergetStaticPaths, 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 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 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 de catégorie
│ │ ├── tags/
│ │ │ └── [slug].astro # Archive de 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 placées à la racine comme route catch-all ([...slug].astro), ainsi 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 tout fichier média local utilisé 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"
}
}
| Champ | Description |
|---|---|
emdash.label | Nom d’affichage dans les sélecteurs de thèmes |
emdash.description | Courte description du thème |
emdash.seed | Chemin vers le fichier seed |
emdash.preview | URL vers une démo en ligne (optionnel) |
Le modèle de contenu par défaut
La plupart des thèmes nécessitent deux types de collection : posts et pages. Les posts sont des entrées horodatées avec des extraits et des images à la une qui apparaissent dans les flux et archives. Les pages sont du contenu autonome sur des URLs de premier niveau.
C’est le point de départ recommandé. Ajoutez d’autres collections, taxonomies ou champs selon les besoins de votre thème, mais commencez ici.
Fichier Seed
Le fichier seed indique à EmDash quoi 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 posts obtiennent excerpt et featured_image parce qu’ils apparaissent dans des listes et des flux. Les pages n’en ont pas besoin — ce sont du contenu autonome. Ajoutez des champs à n’importe quelle collection selon les besoins de votre thème.
Consultez Format de fichier Seed pour la spécification complète, incluant les sections, zones de widgets et références médias.
Construire des 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 à chaque 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 correspondent directement aux 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 c’est une route catch-all, elle ne correspond qu’aux URLs sans 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>
Utiliser les images
Les champs d’image sont des objets avec les 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}
priority
/>
)}
<h2><a href={`/posts/${post.slug}`}>{post.data.title}</a></h2>
<p>{post.data.excerpt}</p>
</article>
Utilisez priority uniquement pour l’image probablement visible au-dessus du pli sur une page, comme un hero ou la première image de carte. Cela rend l’image avec loading="eager" et fetchpriority="high". loading contrôle si le navigateur peut différer le chargement, tandis que fetchpriority indique l’importance de la requête une fois que le navigateur la découvre.
Utiliser les menus
Interrogez les menus définis par l’admin dans vos layouts. 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 layouts — un layout par défaut, un pleine largeur, un layout de page d’atterrissage. Dans EmDash, ajoutez un champ select template à la collection de pages et mappez-le à 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"
}
Puis mappez la valeur aux 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 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 récurrents (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 depuis le fichier seed sont marquées avec source: "theme". Les éditeurs peuvent aussi 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 des 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 uploadé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 de préfixes, stemming Porter et extraits de résultats surlignés. La recherche doit être activée par collection dans l’interface d’administration (Content Types > Edit > Features > Search).
Tester votre thème
-
Créez un projet de test depuis votre thème :
npm create astro@latest -- --template ./path/to/my-theme -
Installez les dépendances et lancez 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’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
Un thème hébergé sur GitHub s’installe 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 d’atterrissage ou tout contenu nécessitant des composants structurés au-delà du rich text standard.
Définir des blocs personnalisés dans le contenu seed
Utilisez un _type avec namespace 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 bloc
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>
Rendre les blocs personnalisés
Passez vos composants de bloc 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 doivent être liables :
{
"_type": "marketing.features",
"_key": "features",
"headline": "Features"
}
Utilisez la valeur _key comme ancre dans le composant de bloc :
<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.jsonavec champemdash(label, description, chemin 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 stockage -
src/live.config.tsavec loader EmDash - Pas de
getStaticPaths()dans les pages de contenu - Pas de titre de site, slogan ou navigation codés en dur
- Champs d’image accédés comme objets (
image.src), pas comme chaînes - README avec instructions de configuration
- Composants de bloc personnalisés pour les types Portable Text non standard
Prochaines étapes
- Format de fichier Seed — Référence complète pour les fichiers seed
- Vue d’ensemble des Thèmes — Comment fonctionnent les thèmes dans EmDash
- Porter des Thèmes WordPress — Convertir des thèmes WordPress existants