<!--
  canonical-url: https://www.avonture.be/fr/blog/docusaurus-series
  published:     2025-09-09
  This is a static plain-Markdown mirror generated at build time.
  Visit the canonical URL above for the fully rendered page, with images and interactive components.
-->

# Organisez votre contenu Docusaurus avec un composant Series personnalisé

> Découvrez comment regrouper des articles de blog Docusaurus liés en une expérience de lecture fluide grâce à un composant Series personnalisé.

---
![Organisez votre contenu Docusaurus avec un composant Series personnalisé](/img/v2/docusaurus_component.webp)

**TL;DR**

Ce guide détaillé vous montre comment implémenter une fonctionnalité complète de « série d'articles » dans Docusaurus. Vous apprendrez à créer un composant `<SeriesPosts>` qui affiche une bannière de série sur chaque article, ainsi qu'une page `/series` dédiée qui liste toutes vos séries sous forme de cartes. Le tutoriel couvre la création de plusieurs composants React, des helpers utilitaires pour récupérer les données des articles, la surcharge de composants du thème Docusaurus via le swizzling, et la construction d'un plugin local personnalisé pour gérer les routes dynamiques des séries. Enfin, il montre comment ajouter des descriptions et des images personnalisées à vos séries via un fichier de données centralisé.

Si vous utilisez Docusaurus depuis longtemps, vous avez peut-être remarqué qu'il n'est pas possible de relier des articles entre eux sous forme de série.

Ce serait pratique d'écrire un premier article, un deuxième, un troisième, etc., et de pouvoir dire à Docusaurus que ces articles font partie de la même série.

Cela aura aussi un impact positif sur votre SEO (en créant un meilleur maillage interne) et gardera vos lecteurs plus longtemps. *Pour des articles liés entre eux mais qui ne forment pas une série ordonnée, [Displaying related posts below our Docusaurus article](/blog/docusaurus-relatedposts) joue le même rôle sur base des tags partagés.*

Ce n'est pas possible nativement, alors créons un composant pour ça.

> **Attention, spoiler:**
>
> Cliquez simplement sur le lien [Séries](/series) pour voir à quel point le composant **SeriesPosts** est sympa. Vous allez apprendre à le faire ici.

Voici le composant en action, une fois branché — une bannière annonçant la série, directement sur l'article :

![Notre composant tourne maintenant](./images/our-component-is-running.webp)

La partie 2 va plus loin et ajoute une page `/series` complète qui liste chaque série sous forme de cartes :

![Une page avec toutes les séries](./images/series-page.webp)

Construisons les deux, en commençant par la bannière.

> **Information:**
>
> Vous avez déjà vu cet article ? Regardez en haut. Vous verrez une bannière qui dit : « Cet article fait partie de la série **Creating Docusaurus components** ». C'est exactement ce que nous allons apprendre à faire tout de suite.

Ce long article comporte deux sections principales : l'une sur la création du composant, l'autre sur la navigation dans le blog.

*Il combine les deux techniques utilisées partout dans cette série : le swizzling d'un composant de thème (comme dans [la notice signalant un article de plus d'un an](/blog/docusaurus-old-notice)) et l'écriture d'un plugin local (comme dans [le plugin de recherche & remplacement](/blog/docusaurus-plugin-replace)).*

## Partie 1 - Création du composant SeriesPosts {#part-1---creation-of-the-seriesposts-component}

Dans ce premier chapitre, nous allons créer notre composant `SeriesPosts` afin qu'à l'ouverture d'un article, vous voyiez un message « Cet article fait partie de... » et la liste des autres articles de cette série.

**Cela implique plusieurs choses :**

1. Nous aurons besoin de quelques helpers.

2. Nous aurons besoin d'un nouveau composant.

3. Les articles de blog devront avoir une entrée `series` dans leur YAML front matter, et

4. Nous devrons surcharger la façon standard d'afficher un article de blog.

### 1.1. Nous avons besoin de quelques helpers {#11-we-need-some-helpers}

Si vous ne l'avez pas encore, créez le fichier `src/components/Blog/utils/posts.ts`. Il contiendra une fonction helper que nous pourrons réutiliser pour plusieurs composants.

Ce helper va scanner chaque fichier Markdown avec une extension `.md` ou `.mdx` dans le sous-dossier `blog`.

Pour chaque fichier, le script va regarder le YAML front matter et utiliser certaines de ses propriétés. Par exemple :

* Si l'article a `draft: true` ou `unlisted: true`, il sera ignoré.
* Si l'article a un `slug`, il sera utilisé ; sinon, le slug sera généré par le code.
* Si l'article a une image associée, elle sera utilisée ; sinon, une image par défaut sera utilisée.
* Ensuite, le helper retournera la liste des articles et leurs propriétés.

Certaines propriétés sont personnalisées, comme `mainTag` (voir mon article [Displaying related posts below our Docusaurus article](/blog/docusaurus-relatedposts)) et, pour notre besoin actuel, la propriété `series`.

Donc, copiez-collez simplement le contenu du fichier ci-dessous et créez le fichier `src/components/Blog/utils/posts.ts` dans la structure de votre projet.

**src/components/Blog/utils/posts.ts**

````typescript
/**
 * getBlogMetadata
 *
 * Extracts metadata from all MDX blog posts located in the `/blog` directory.
 * Uses Webpack's `require.context` to dynamically load and parse frontmatter
 * from each post, returning a structured array of metadata objects.
 *
 * Behavior:
 * - Resolves permalinks based on `slug` or folder structure
 * - Normalizes image paths for static assets
 * - Filters out invalid or missing entries
 *
 * Returned metadata includes:
 * - `title`: Post title
 * - `description`: Short summary
 * - `image`: Resolved image path
 * - `draft`: Boolean flag for unpublished posts
 * - `unlisted`: Boolean flag for hidden posts
 * - `permalink`: URL path to the post
 * - `tags`: Array of tags
 * - `mainTag`: Primary tag (optional); used by the RelatedBlogPost component
 * - `authors`: Array of author names
 * - `date`: Publication date
 * - `series`: Series name (optional); used by the SeriesBlogPost component
 *
 * Usage:
 * ```ts
 * import { getBlogMetadata } from './posts';
 * const posts = getBlogMetadata();
 * const withDrafts = getBlogMetadata({ includeDrafts: true });
 * ```
 *
 * By default, posts flagged `draft` or `unlisted` are excluded so consumers don't have to
 * remember to filter them out themselves. Pass `includeDrafts`/`includeUnlisted` to opt back in
 * (e.g. author-facing counters that need to show "in progress" articles).
 *
 * Note:
 * This function is intended for use in static site generation or client-side rendering
 * where Webpack's `require.context` is available.
 */

/**
 * A tag as it reaches these helpers: a plain slug string from front matter, or —
 * defensively, since related.ts and TagArticlesPage both handle it — a
 * Docusaurus-normalised tag object.
 */
export type BlogTag = string | { label: string };

export interface BlogPostMetadata {
  authors: string[];
  blueskyRecordKey: string | null;
  /**
   * ISO date string from front matter, e.g. "2026-08-28". Every article under blog/ carries
   * one (blog authoring convention, and the blog plugin itself refuses a post without a
   * date), so this is not optional.
   *
   * Runtime note: plugins/frontmatter-loader actually hands back a real `Date` instance for
   * a typical unquoted YAML date (see that plugin's own header comment) — not a string.
   * Declaring `string` here anyway matches the pre-existing contract every niveau 1-4 `.tsx`
   * consumer already compiles against (`Post.date?: string`, …); every one of them passes
   * this straight into `new Date(...)`, which accepts a `Date` argument just as well as a
   * string, so nothing behavioral changes. Widening this to the more literally accurate type
   * would ripple into every already-migrated consumer — out of scope here.
   */
  date: string;
  description: string | undefined;
  draft: boolean;
  image: string | undefined;
  mainTag: string | null;
  permalink: string;
  series: string | null;
  tags: BlogTag[];
  title: string;
  unlisted: boolean;
}

interface RawFrontMatter {
  title?: string;
  slug?: string;
  image?: string;
  authors?: string[];
  blueskyRecordKey?: string;
  date?: BlogPostMetadata["date"];
  description?: string;
  draft?: boolean;
  mainTag?: string;
  series?: string;
  tags?: BlogTag[];
  unlisted?: boolean;
}

// The `!!…!` prefix is load-bearing, do not simplify it away: it bypasses the
// rules configured by @docusaurus/plugin-content-blog and reads the files with
// plugins/frontmatter-loader instead. Without it, Webpack MDX-compiles every
// file under `blog/`, and any article flagged `draft: true` crashes the
// production build with "Blog post not found for filePath=…" — because the blog
// plugin drops drafts from its post list while this context still pulls them in.
// See plugins/frontmatter-loader/index.cjs for the full story.
import { useTranslationState, slugFromPermalink } from "./translations";

const posts = require.context(
  "!!../../../../plugins/frontmatter-loader/index.cjs!../../../../blog",
  true,
  /\.mdx?$/,
);

// Second corpus: the French translations. Needed because the context above reads `blog/` — the
// English source — whatever locale is being rendered, so every card, list and related-post
// block showed an English title on a French page even for articles that ARE translated.
//
// The locale is spelled out rather than computed: `require.context` is a webpack construct and
// its path must be statically analysable. Adding a locale means adding a context here.
const translatedPosts = require.context(
  "!!../../../../plugins/frontmatter-loader/index.cjs!../../../../i18n/fr/docusaurus-plugin-content-blog",
  true,
  /\.mdx?$/,
);

/** slug -> the translated `title`/`description`, for the one non-default locale we ship. */
function buildTranslatedFrontMatter(): Map<string, { title: string; description: string }> {
  const bySlug = new Map<string, { title: string; description: string }>();

  for (const key of translatedPosts.keys()) {
    const post = translatedPosts(key) as { frontMatter: RawFrontMatter };
    if (!post.frontMatter?.title) continue;

    const dir = key.replace(/\/index\.mdx?$/, "").replace(/^\.\//, "");
    const slug = post.frontMatter.slug
      ? post.frontMatter.slug.replace(/^\/+|\/+$/g, "").replace(/^blog\//, "")
      : dir.split("/").pop() ?? "";

    if (slug) {
      bySlug.set(slug, {
        title: post.frontMatter.title,
        description: post.frontMatter.description ?? "",
      });
    }
  }

  return bySlug;
}

const TRANSLATED_FRONT_MATTER = buildTranslatedFrontMatter();

export function getBlogMetadata({
  includeDrafts = false,
  includeUnlisted = false,
}: { includeDrafts?: boolean; includeUnlisted?: boolean } = {}): BlogPostMetadata[] {
  return posts
    .keys()
    .map((key): BlogPostMetadata | null => {
      const post = posts(key) as { frontMatter: RawFrontMatter };

      // A Markdown file under `blog/` without a title is not an article
      // (a fragment, a note, a readme…); the blog plugin ignores it, so do we.
      if (!post.frontMatter?.title) {
        return null;
      }

      const dir = key.replace(/\/index\.mdx?$/, "").replace(/^\.\//, "");

      let permalink: string;
      if (post.frontMatter.slug) {
        permalink = post.frontMatter.slug.startsWith("/")
          ? post.frontMatter.slug
          : `/blog/${post.frontMatter.slug.replace(/^\//, "")}`;
      } else {
        permalink = `/blog/${dir}/`;
      }

      let imageUrl = post.frontMatter.image;
      if (imageUrl && imageUrl.startsWith("./")) {
        imageUrl = `/blog/${dir}/${imageUrl.replace("./", "")}`;
      }

      return {
        authors: post.frontMatter.authors || [],
        blueskyRecordKey: post.frontMatter.blueskyRecordKey || null,
        date: post.frontMatter.date as BlogPostMetadata["date"],
        description: post.frontMatter.description,
        draft: post.frontMatter.draft || false,
        image: imageUrl,
        mainTag: post.frontMatter.mainTag || null,
        permalink,
        series: post.frontMatter.series || null,
        tags: post.frontMatter.tags || [],
        title: post.frontMatter.title,
        unlisted: post.frontMatter.unlisted || false,
      };
    })
    .filter((post): post is BlogPostMetadata => post !== null)
    .filter(
      (post) => (includeDrafts || !post.draft) && (includeUnlisted || !post.unlisted),
    );
}

/**
 * Locale-aware counterpart of `getBlogMetadata()` — the one every rendering surface should use.
 *
 * `getBlogMetadata()` reads `blog/` through `require.context`, so it always returns the **English**
 * corpus, whatever locale the page is being rendered in. Left unfiltered, a French listing shows
 * entries whose titles are English and whose pages open on English prose — which reads as a broken
 * site rather than as a partially translated one.
 *
 * On the default locale this is `getBlogMetadata()` unchanged, so switching a call site over is
 * always safe.
 *
 * Note this filters, it does not translate: the titles and descriptions of the surviving entries
 * still come from the English source. Showing the translated ones is a separate (larger) job —
 * see TODO 0119, lot C, "rendre le corpus de posts.ts conscient de la locale".
 */
export function useBlogMetadata(
  options: { includeDrafts?: boolean; includeUnlisted?: boolean } = {},
): BlogPostMetadata[] {
  const { filterTranslated, isDefaultLocale } = useTranslationState();

  const visible = filterTranslated(getBlogMetadata(options), (post) =>
    slugFromPermalink(post.permalink),
  );

  if (isDefaultLocale) return visible;

  // Overlay the translated title and description. Only these two are localized: `permalink`,
  // `image`, `tags`, `series` and `date` are shared with the English source by design — the
  // translation copies them byte for byte (see the translation contract in TODO 0119).
  return visible.map((post) => {
    const translated = TRANSLATED_FRONT_MATTER.get(slugFromPermalink(post.permalink));
    if (!translated) return post;

    return {
      ...post,
      title: translated.title,
      description: translated.description || post.description,
    };
  });
}
````

Nous aurons aussi besoin d'un second helper. Créez le fichier `src/components/Blog/utils/slug.ts` :

**src/components/Blog/utils/slug.ts**

```typescript
/**
 * Converts a string into a URL-friendly slug.
 *
 * This function:
 * - Converts the string to lowercase
 * - Normalizes accented characters (e.g., accented e to plain e)
 * - Removes diacritics and special characters
 * - Replaces spaces with hyphens
 * - Collapses multiple hyphens into one
 * - Trims leading and trailing hyphens
 *
 * @param text - The input string to convert.
 * @returns The slugified version of the input.
 */
export function createSlug(text: string): string {
  return text
    .replace(/([a-z0-9])([A-Z])/g, "$1-$2") // camelCase to kebab-case
    .toLowerCase()
    .normalize("NFD") // Decomposes accented characters
    .replace(/[\u0300-\u036f]/g, "") // Removes diacritics
    .replace(/[^a-z0-9\s-]/g, "") // Removes special characters
    .replace(/\s+/g, "-") // Replaces spaces with hyphens
    .replace(/-+/g, "-") // Collapses multiple hyphens
    .replace(/^-+|-+$/g, ""); // Trims leading/trailing hyphens
}
```

### 1.2. Notre composant SeriesPosts {#12-our-seriesposts-component}

Maintenant, nous allons créer notre composant. Créez le fichier `src/components/Blog/SeriesPosts/index.tsx` avec le code suivant :

**src/components/Blog/SeriesPosts/index.tsx**

```typescript
/**
 * 📚 SeriesPosts Component
 *
 * Displays a list of blog posts that belong to the same series.
 * Useful for linking related articles together in a Docusaurus blog.
 *
 * Behavior:
 * - Fetches blog metadata via `getBlogMetadata()`
 * - Filters posts by series name
 * - Sorts posts chronologically
 * - Highlights or links each post depending on whether it's the current one
 *
 * Styling:
 * - Uses scoped CSS from `styles.module.css`
 *
 * Returns:
 * - A styled list of blog posts in the same series, or null if none found.
 */

import type { JSX } from "react";
import Link from "@docusaurus/Link";
import Translate from "@docusaurus/Translate";
import clsx from "clsx";
import Details from "@site/src/components/Details";
import { useBlogMetadata } from "@site/src/components/Blog/utils/posts";
import { createSlug } from "@site/src/components/Blog/utils/slug";
import { useSeriesLocalizer } from "@site/src/components/Blog/utils/seriesI18n";

import styles from "./styles.module.css";

interface Props {
  /** The name of the blog series to display */
  series: string;
  /** Permalink of the current post to exclude from linking */
  excludePermalink?: string | null;
  /** Whether to visually highlight the current post */
  highlightCurrent?: boolean;
}

export default function SeriesPosts({
  series,
  excludePermalink = null,
  highlightCurrent = true,
}: Props): JSX.Element | null {
  const posts = useBlogMetadata()
    .filter((post) => post.series === series)
    .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());

  // `series` is the English key — it matches the front matter and builds the slug. Only the link
  // text is localized, so a French article stops announcing an English series name.
  const seriesLabel = useSeriesLocalizer()(series)?.label ?? series;

  if (!posts.length) return null;

  return (
    <div className={styles.seriesBlogPost}>
      <Details
        summaryClassName={styles.seriesIntro}
        label={
          <Translate
            id="blog.seriesPosts.intro"
            values={{
              seriesLink: (
                <Link href={`/series/${createSlug(series)}`}>{seriesLabel}</Link>
              ),
            }}
          >
            {"This article is part of the {seriesLink} series:"}
          </Translate>
        }
      >
        <ul>
          {posts.map((post) => {
            const isCurrent = post.permalink === excludePermalink;

            return (
              <li key={post.permalink}>
                {isCurrent ? (
                  <span
                    className={clsx(
                      styles.currentPost,
                      highlightCurrent && styles.currentPostDimmed,
                    )}
                  >
                    {post.title}
                  </span>
                ) : (
                  <Link to={post.permalink}>{post.title}</Link>
                )}
              </li>
            );
          })}
        </ul>
      </Details>
    </div>
  );
}
```

Ce code JavaScript va récupérer la liste de tous les articles de blog. Ensuite, il va extraire une propriété appelée `series` (qui peut être présente ou non dans le YAML front matter de votre article). Si un article a la propriété `series`, le script comparera sa valeur avec celle de l'article courant.

S'il y a correspondance, nous avons d'autres articles dans la série, et le script affichera « Cet article fait partie de... » suivi du nom de la série et de la liste des articles.

Les articles sont affichés dans l'ordre chronologique pour permettre au lecteur de suivre la série dans un ordre logique.

Le reste de la logique affiche simplement une liste à puces.

Et le dernier fichier à créer est le fichier CSS :

**src/components/Blog/SeriesPosts/styles.module.css**

```css
/*
 * The "this post is part of a series" box.
 *
 * NO BACKGROUND TINT. That was tried and removed on review, and the reason is
 * worth keeping: the page ground is already cream (--mk-bg #fffdf7) and the
 * "key" tone is cream too (--mk-key-bg #faf0de). A tinted block on a tinted
 * page reads as a washed-out rectangle rather than as a distinct object — it
 * costs a lot of visual weight to say very little. The rail alone says
 * "this is an aside" perfectly well, and it is the same rail as the article's
 * own h2, so the box sits in the page's existing grid of left marks instead of
 * interrupting it.
 *
 * Keep this in mind for any future aside: on this site, tint is for callouts
 * that carry a WARNING or a KEY IDEA. A navigation aid gets a rail and nothing
 * more (see QuickJump, which made the same call).
 */
.seriesBlogPost {
  color: var(--mk-ink);
  border-left: var(--mk-rail) solid var(--mk-primary);
  padding: 0.25rem 0 0.25rem 1.25rem;
  margin-bottom: 2rem;
}

/* Applied to Details's <summary> via its summaryClassName prop — not a
   ".seriesBlogPost p" descendant selector, which composes can't use (single
   local class only) and which never matched anyway: the intro text renders
   inside <summary>, not a <p>, so the pre-harmonisation bold/italic/1.1rem
   rule here was dead CSS. */
.seriesIntro {
  composes: mk-eyebrow from global;
  cursor: pointer;
}

.seriesIntro:hover {
  color: var(--mk-primary);
}

.seriesBlogPost ul {
  list-style-type: none;
  padding-left: 0;
  counter-reset: post-counter;
  margin: 0.625rem 0 0;
}

.seriesBlogPost li {
  counter-increment: post-counter;
  display: flex;
  align-items: baseline;
  font-size: 0.95rem;
  line-height: 1.6;
}

.seriesBlogPost li:not(:last-child) {
  margin-bottom: 0.4rem;
}

.seriesBlogPost li::before {
  content: counter(post-counter);
  margin-right: 0.75rem;
  min-width: 1.1rem;
  font-weight: 700;
  font-size: 0.85rem;
  color: var(--mk-primary);
  font-variant-numeric: tabular-nums;
}

.seriesBlogPost li a,
.seriesBlogPost li span {
  text-decoration: none;
  color: inherit;
}

.seriesBlogPost li a:hover {
  text-decoration: underline;
  text-underline-offset: 2px;
  color: var(--mk-primary);
}

.currentPost {
  font-weight: 700;
  color: var(--mk-primary);
}

.currentPostDimmed {
  opacity: 0.6;
}

/*
 * No dark-mode block: every value above is a token that flips on its own.
 * (The pre-harmonisation override pointed at --ifm-background-surface-color,
 * the CARD colour, which made this box indistinguishable from the article
 * around it in dark mode.)
 */
```

### 1.3. Modifier nos articles {#13-editing-our-articles}

Maintenant, la partie facile : éditez quelques-uns de vos articles existants et ajoutez la clé `series` au YAML front matter. Par exemple :

**index.md**

```markdown
---
title: Create your Joomla website using Docker - Part 1
[...]
<!-- highlight-next-line -->
series: Create your joomla website using Docker
[...]
---

In this article, we will learn how to ...
```

Faites-le pour quelques articles afin d'avoir plusieurs articles dans cette série.

> **Caution:**
>
> Si vous visitez votre blog maintenant et que vous naviguez vers votre article, vous ne verrez aucune différence. C'est parfaitement normal : nous venons juste de créer un composant, il faut encore dire à Docusaurus de l'utiliser.

### 1.4. Surcharger le template BlogPostItem {#14-overriding-the-blogpostitem-template}

Nous devons donc appeler notre composant fraîchement créé. Bien sûr, nous pourrions inclure la balise `<SeriesBlogPost>` dans chaque article, mais nous sommes plus malins que ça, non ?

Créons une surcharge du template `BlogPostItem` de Docusaurus.

Dans un terminal, lancez `yarn docusaurus swizzle @docusaurus/theme-classic BlogPostItem`, puis choisissez `Javascript`, `Eject`, et enfin `YES`.

Beaucoup de fichiers seront créés dans le dossier `src/theme/BlogPostItem` de votre projet Docusaurus. Nous pouvons supprimer sans risque tous les fichiers sauf `src/theme/BlogPostItem/index.js`, que nous devons éditer.

Donc, supprimez tous les fichiers ou dossiers sous `src/theme/BlogPostItem` sauf le fichier `index.js`.

Dans le code ci-dessous, les lignes surlignées sont celles que nous devons ajouter.

**src/theme/BlogPostItem/index.js**

```javascript
import { useBlogPost } from "@docusaurus/plugin-content-blog/client";
import BlogPostItemContainer from "@theme/BlogPostItem/Container";
import BlogPostItemContent from "@theme/BlogPostItem/Content";
import BlogPostItemFooter from "@theme/BlogPostItem/Footer";
import BlogPostItemHeader from "@theme/BlogPostItem/Header";
import clsx from "clsx";

// highlight-next-line
import SeriesPosts from "@site/src/components/Blog/SeriesPosts";

// apply a bottom margin in list view
function useContainerClassName() {
  const { isBlogPostPage } = useBlogPost();
  return !isBlogPostPage ? "margin-bottom--xl" : undefined;
}
export default function BlogPostItem({ children, className }) {
  // We need to retrieve the isBlogPostPage flag
  const { metadata, isBlogPostPage } = useBlogPost();
  const containerClassName = useContainerClassName();
  return (
    <BlogPostItemContainer className={clsx(containerClassName, className)}>
      <BlogPostItemHeader />
      // highlight-start
      {isBlogPostPage && (
        <SeriesPosts
          series={metadata.frontMatter.series}
          excludePermalink={metadata.permalink}
          highlightCurrent={true}
        />
      )}
      // highlight-end
      <BlogPostItemContent>{children}</BlogPostItemContent>
      <BlogPostItemFooter />
      // highlight-start
      {isBlogPostPage && (
        <SeriesPosts
          series={metadata.frontMatter.series}
          excludePermalink={metadata.permalink}
          highlightCurrent={true}
        />
      )}
      // highlight-end
    </BlogPostItemContainer>
  );
}
```

> **Nous devons redémarrer Docusaurus:**
>
> Maintenant, comme nous venons d'introduire une surcharge, nous devons redémarrer notre serveur Docusaurus pour que les changements prennent effet.

> **Lancez npm run start:**
>
> Si vous faites tourner Docusaurus en local, lancez simplement `npm run start` dans votre console.
> Si, comme moi, vous faites tourner Docusaurus avec Docker, arrêtez simplement le container et démarrez-en un nouveau.

### Voyons si ça fonctionne {#lets-see-if-its-working}

Retournez sur votre blog et rafraîchissez la page. Vous devriez voir la même bannière que celle montrée en haut de cet article (avec votre propre contenu, bien sûr).

Super ! Le composant tourne maintenant.

Nous pouvons créer nos séries. Dans cet exemple, j'ai édité trois articles sur Joomla et ajouté la clé `series` à leur YAML front matter.

Vous pouvez naviguer d'un article à l'autre.

> **Information:**
>
> Nous pourrions nous arrêter ici, mais ce serait n'avoir fait que la moitié du chemin. Il nous reste à implémenter une page qui affichera la liste des séries et, quand on clique sur une série, la liste des articles qu'elle contient.

## Partie 2 - Ajouter une navigation vers les séries {#part-2---adding-a-navigation-to-series}

Comme annoncé, nous allons créer une page `/series` sur notre blog pour accéder immédiatement à toutes les séries.

**Nous allons donc devoir**

1. Créer la page /series

2. Créer un composant SeriesCards

3. Créer un composant PostCard

4. Ajouter une entrée /series à notre barre de navigation et

5. Créer un plugin pour gérer les URL `/series/a-series-name`.

### 2.1 Ajouter une nouvelle page pour afficher toutes les séries {#21-adding-a-new-page-to-show-all-series}

Ce serait chouette d'avoir une page sur notre site Docusaurus pour afficher la liste des séries, non ? Allons-y.

D'abord, créez le fichier `src/pages/series.mdx` avec ce contenu :

**src/pages/series.mdx**

```mdx
---
title: "All articles series"
hide_table_of_contents: true
image: "/img/series.webp"
authors: [christophe]
---

import "@site/src/css/series.css";

<!-- markdownlint-disable MD041 -->

import SeriesCards from "@site/src/components/Blog/SeriesCards";
import SeriesStats from "@site/src/components/Blog/SeriesStats";
import TranslationCoverage from "@site/src/components/Blog/TranslationCoverage";

<h1 className="text--center">All Series</h1>

<SeriesStats />

<!-- Renders nothing on `en`. Under `fr`, `SeriesStats` above counts the FILTERED corpus and
     announces 3 series / 3 articles against 26 and 176 — a reader arriving here from the menu,
     without passing through /fr/blog/, would conclude the blog is thin. -->

<TranslationCoverage />

<div className="text--center">
  Each series has its own RSS feed —{" "}
  <Link to="/follow">get the next episode automatically</Link>, without checking back.
</div>

<div className="seriesIntro">
    Discover our collections of articles organized by theme.

    Each series offers you a progressive learning path to master a specific area of web development.

</div>

<SeriesCards />
```

> **Caution:**
>
> Comme vous pouvez le voir, cette page contient du contenu Markdown avec une particularité : elle contient du code JavaScript. C'est pourquoi il est très important que l'extension du fichier soit `.mdx`.

Comme vous le voyez, il y a du contenu Markdown (adaptez-le à vos besoins) et l'utilisation d'un nouveau composant, `SeriesCards`. Créons-le.

### 2.2 Création du composant SeriesCards {#22-creation-of-the-seriescards-component}

Créez le fichier `src/components/Blog/SeriesCards/index.tsx` avec ce contenu :

**src/components/Blog/SeriesCards/index.tsx**

```typescript
import { useMemo, type JSX } from "react";
import { useSeriesList } from "@site/src/components/Blog/utils/series";
import Translate from "@docusaurus/Translate";
import PostCard from "@site/src/components/Blog/PostCard";
import SERIES_DATA from "@site/src/data/series.js";
import styles from "./styles.module.css";

/**
 * SeriesCards component
 *
 * Renders a responsive grid of blog article series using metadata generated by `useSeriesList`.
 * Each card displays the series title, image, and a summary of published and draft articles.
 * Cards link to a dedicated page for each series.
 *
 * Layout:
 * - Uses Docusaurus Infima grid classes (`row`, `col--4`) for responsive layout.
 * - Falls back to a friendly message if no series are found.
 *
 * Dependencies:
 * - `useSeriesList` (utility to group and format blog posts by series)
 * - `PostCard` (component to render individual series cards)
 *
 * Location: src/components/Blog/SeriesCards/index.tsx
 */

const DEFAULT_IMAGE = "/img/default.webp";

export default function SeriesCards(): JSX.Element {
  const seriesList = useSeriesList();

  const enriched = useMemo(
    () =>
      seriesList.map((serie) => {
        const data = SERIES_DATA.find((s) => s.name === serie.seriesName);

        const imagePath = data?.image ?? DEFAULT_IMAGE;

        return {
          ...serie,
          counter: serie.counter,
          // SERIES_DATA is the ENGLISH source of truth; useSeriesList() only
          // returns a `description` when the current locale has a translated
          // row for this series. Reading SERIES_DATA first published English
          // descriptions under every /fr/series/ card.
          description: serie.description ?? data?.description,
          image: imagePath,
          title: serie.title,
        };
      }),
    [seriesList],
  );

  if (enriched.length === 0) {
    return (
      <div className="text--center margin-vert--xl">
        <h2>
          <Translate id="blog.seriesCards.noSeries">No article series found</Translate>
        </h2>
      </div>
    );
  }

  return (
    <div className="margin-top--lg margin-bottom--lg">
      <div className={styles.seriesGrid}>
        {enriched.map((serie) => (
          <PostCard key={serie.permalink} post={serie} />
        ))}
      </div>
    </div>
  );
}
```

Nous avons besoin d'un nouveau helper, `src/components/Blog/utils/series.ts` :

**src/components/Blog/utils/series.ts**

```typescript
import { translate } from "@docusaurus/Translate";
import { createSlug } from "@site/src/components/Blog/utils/slug";
import { useSeriesLocalizer } from "@site/src/components/Blog/utils/seriesI18n";
import { useBlogMetadata, type BlogPostMetadata } from "@site/src/components/Blog/utils/posts";

/**
 * series.ts
 *
 * Utility function to generate structured metadata for blog article series in Docusaurus.
 *
 * It groups blog posts by their `series` field, sorts them chronologically,
 * and returns a list of series objects containing permalink, image, title, and description.
 *
 * @example
 * const seriesList = generateSeriesList("/series/", "/img/default.webp");
 *
 * Each object in the returned array looks like:
 *   {
 *     seriesName: "Introduction to Docusaurus",
 *     permalink: "/series/introduction-to-docusaurus",
 *     image: "/img/docusaurus-intro.png",
 *     title: "Introduction to Docusaurus",
 *     counter: "5 published article(s) • 2 in progress"
 *   }
 */

export interface SeriesListEntry {
  seriesName: string;
  permalink: string;
  image: string;
  /** Displayed series name — localized when the current locale has a row for it. */
  title: string;
  /**
   * Displayed series description, localized. Absent on the default locale, where the English
   * `src/data/series.js` is the source of truth and callers read it directly.
   */
  description?: string;
  /** Human-readable count, e.g. "5 published article(s) • 2 in progress". */
  counter: string;
}

export function useSeriesList(
  permalink = "/series/",
  defaultImage = "/img/default.webp",
): SeriesListEntry[] {
  const seriesMap = new Map<string, BlogPostMetadata[]>();

  // Array of blog post objects; drafts are needed here to compute the "in progress" counter
  const posts = useBlogMetadata({ includeDrafts: true });
  const localizeSeries = useSeriesLocalizer();

  // Loop all posts, process the ones that are part of a serie and push the post entry in his own series
  posts.forEach((post) => {
    const seriesName = post.series;
    if (seriesName) {
      if (!seriesMap.has(seriesName)) {
        seriesMap.set(seriesName, []);
      }
      seriesMap.get(seriesName)!.push(post);
    }
  });

  // Process all series, process all posts and generate an array with the name of the serie, a link
  // to a page where we can access the articles, ... (see the @example in the intro docblock)
  return [...seriesMap.keys()].sort().map((seriesName) => {
    const posts = seriesMap.get(seriesName)!;
    const sortedPosts = [...posts].sort(
      (a, b) => new Date(a.date).getTime() - new Date(b.date).getTime(),
    );

    const publishedCount = posts.filter((post) => !post.draft).length;
    const draftCount = posts.filter((post) => post.draft).length;

    const counter =
      translate(
        { id: "blog.series.publishedCount", message: "{count} published article(s)" },
        { count: publishedCount },
      ) +
      (draftCount > 0
        ? ` • ${translate(
            { id: "blog.series.inProgress", message: "{count} in progress" },
            { count: draftCount },
          )}`
        : "");

    // `seriesName` stays the ENGLISH name — it is the key that drives the slug and links the
    // article front matter. Only what is displayed is localized. See utils/seriesI18n.ts.
    const localized = localizeSeries(seriesName);

    return {
      seriesName,
      permalink: `${permalink}${createSlug(seriesName)}`,
      image: sortedPosts[0]?.image || defaultImage,
      title: localized?.label ?? seriesName,
      description: localized?.description,
      counter,
    };
  });
}
```

En bref, le composant `SeriesCards` est assez simple. Il récupère toutes les séries de vos articles existants, puis les affiche sous forme de cartes. Rien de plus.

### 2.3 Création du composant PostCard {#23-creation-of-the-postcard-component}

Nous avons aussi besoin d'un composant pour afficher un article sous forme de carte. Créez le fichier `src/components/Blog/PostCard/index.tsx` avec ce contenu :

**src/components/Blog/PostCard/index.tsx**

```typescript
import type { JSX } from "react";
import Card from "@site/src/components/Card";
import CardBody from "@site/src/components/Card/CardBody";
import CardImage from "@site/src/components/Card/CardImage";
import Link from "@docusaurus/Link";
import Translate, { translate } from "@docusaurus/Translate";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import useBaseUrl from "@docusaurus/useBaseUrl";
import { formatPostDate } from "@site/src/components/Blog/utils/date";
import { createSlug } from "@site/src/components/Blog/utils/slug";
import { useTagLabel } from "@site/src/components/Blog/utils/tagsI18n";
import styles from "./styles.module.css";

interface Post {
  permalink: string;
  image?: string;
  title: string;
  // `null` is accepted alongside `undefined`: LatestPosts / RelatedPosts pass
  // `{ ...post, description: null }` to render a card without its description,
  // and getBlogMetadata() types `mainTag` as `string | null`.
  description?: string | null;
  date?: string;
  counter?: string;
  mainTag?: string | null;
  readingTime?: number;
}

interface Props {
  post: Post;
  layout?: "big" | "small";
  defaultImage?: string;
  lazyImage?: boolean;
}

export default function PostCard({
  post,
  layout = "big",
  defaultImage = "/img/default.jpg",
  lazyImage = true,
}: Props): JSX.Element {
  const { permalink, image, title, description, date, counter, mainTag, readingTime } = post;
  const { i18n } = useDocusaurusContext();
  const formattedDate = formatPostDate(date, i18n.currentLocale);
  const tagLabel = useTagLabel();

  // `image` is a front-matter path (`/img/v2/atuin.webp`) — a string this repo wrote, not one
  // Docusaurus handed us, so it does NOT carry the locale's baseUrl. Under `fr` the static file
  // is served from `/fr/img/...`; requesting `/img/...` gets the dev server's SPA fallback,
  // which answers `200 text/html` and paints a broken banner rather than 404-ing visibly.
  // Only the "small" layout needs this: the "big" one goes through `<CardImage>`, which calls
  // `useBaseUrl` itself.
  const smallLayoutImageUrl = useBaseUrl(image || defaultImage);

  if (layout === "small") {
    return (
      <div className={`col col--4 ${styles.cardSmallWrapper}`}>
        <div className={`card ${styles.cardSmall}`}>
          <div className="card__image">
            <img
              src={smallLayoutImageUrl}
              alt={title}
              loading="lazy"
              className={styles.cardSmallImage}
            />
          </div>
          <div className={`card__body ${styles.cardSmallBody}`}>
            <h3>
              <Link to={permalink}>{title}</Link>
            </h3>
            {counter && <div className={styles.cardSmallCounter}>{counter}</div>}
            {formattedDate && <p className={styles.date}><span>{formattedDate}</span></p>}
          </div>
          <div className={`card__footer ${styles.cardSmallFooter}`}>
            <Link className="button button--primary button--sm" to={permalink}>
              <Translate id="blog.postCard.readMore">Read more</Translate>
            </Link>
          </div>
        </div>
      </div>
    );
  }

  // Default "big" layout
  return (
    <div className="col col--3 margin-bottom--lg">
      <Card className={styles.cardEnhanced} shadow="md">
        <CardImage
          cardImageUrl={image || defaultImage}
          alt={title}
          title={title}
          lazy={lazyImage}
          className={styles.cardImageEnhanced}
        />
        <CardBody className={styles.cardBodyEnhanced}>
          {mainTag && (
            <Link
              to={`/blog/tags/${createSlug(mainTag)}`}
              className={styles.cardTagBadge}
            >
              {tagLabel(mainTag)}
            </Link>
          )}
          <h3 className={styles.cardTitle}>
            <Link
              to={permalink}
              aria-label={translate(
                { id: "blog.postCard.readAriaLabel", message: "Read article: {title}" },
                { title },
              )}
              className={styles.cardTitleLink}
            >
              {title}&nbsp;→
            </Link>
          </h3>
          {description && <p className={styles.description}>{description}</p>}
          {counter && <p className={styles.counter}>{counter}</p>}
          {(formattedDate || readingTime) && (
            <p className={styles.date}>
              {formattedDate && <span>{formattedDate}</span>}
              {readingTime && (
                <span className={styles.readingTime}>
                  {" "}
                  ·{" "}
                  <Translate
                    id="blog.postCard.readingTime"
                    values={{ minutes: Math.ceil(readingTime) }}
                  >
                    {"{minutes} min read"}
                  </Translate>
                </span>
              )}
            </p>
          )}
        </CardBody>
      </Card>
    </div>
  );
}
```

Créez également `src/components/Blog/PostCard/styles.module.css` :

**src/components/Blog/PostCard/styles.module.css**

```css
/*
 * PostCard.
 *
 * Note the removal of the :root block at the top of the old file: it declared
 * --card-radius / --card-transition / --card-hover-transform from inside a CSS
 * module, and Blog/Updated consumed --card-radius through a fallback. Two
 * unrelated components were coupled through a variable whose definition
 * depended on PostCard being on the page. Those now live in theme.css.
 *
 * The tag badge was a filled terracotta pill — the loudest thing in a grid of
 * twenty cards. It becomes the shared .mk-pill: cream ground, terracotta text.
 */

.cardEnhanced {
  composes: mk-surface from global;
  composes: mk-liftable from global;
  display: flex;
  flex-direction: column;
  height: 100%;
  min-width: 0;
  overflow: hidden;
}

.cardBodyEnhanced {
  display: flex;
  flex: 1;
  flex-direction: column;
  gap: 0.5rem;
  padding: 1rem;
}

.cardImageEnhanced {
  display: block;
  width: 100%;
  aspect-ratio: 2 / 1;
  object-fit: contain;
  background: var(--mk-n-100);
  transition: transform 0.2s ease;
}

.cardEnhanced:hover .cardImageEnhanced {
  transform: scale(1.03);
}

.cardTitle {
  display: -webkit-box;
  margin: 0;
  overflow: hidden;
  font-family: var(--mk-font-display);
  font-size: 1.02rem;
  font-weight: 700;
  line-height: 1.35;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
}

.cardTitleLink {
  color: var(--mk-ink);
  text-decoration: none;
  transition: color 0.15s ease;
}

.cardTitleLink:hover {
  color: var(--mk-primary);
  text-decoration: none;
}

.cardTagBadge {
  composes: mk-pill from global;
  align-self: flex-start;
}

/*
 * The date was italic and the reading time italic too — two italics in a
 * four-word line. Both are now plain, quiet, and tabular so the dates line up
 * from card to card in a grid.
 */
.date {
  margin-bottom: 0;
  font-size: 0.82rem;
  color: var(--mk-ink-soft);
  font-variant-numeric: tabular-nums;
}

.readingTime {
  font-variant-numeric: tabular-nums;
}

.description {
  display: -webkit-box;
  margin: 0;
  overflow: hidden;
  font-size: 0.88rem;
  line-height: 1.55;
  color: var(--mk-ink-soft);
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
}

.counter {
  composes: mk-pill from global;
  margin-top: 0.5rem;
}

/* ── Small layout ─────────────────────────────────────── */

.cardSmallWrapper {
  display: flex;
  margin-bottom: 2rem;
}

.cardSmall {
  display: flex;
  flex-direction: column;
  width: 100%;
  height: 100%;
}

.cardSmallImage {
  width: 100%;
  aspect-ratio: 2 / 1;
  object-fit: contain;
  background: var(--mk-n-100);
  border-radius: var(--mk-radius-lg) var(--mk-radius-lg) 0 0;
}

.cardSmallBody {
  flex: 1;
}

.cardSmallCounter {
  composes: mk-eyebrow from global;
  margin-bottom: 0.375rem;
  color: var(--mk-primary);
}

.cardSmallFooter {
  text-align: right;
}
```

Le composant `PostCard` utilise le [composant Card réutilisable de Docux](/blog/docusaurus-cards#using-the-reusable-card-component-of-docux). Si vous ne l'avez pas encore, créez au moins ces quatre fichiers :

**src/components/Card/index.tsx**

```typescript
import type { CSSProperties, JSX, ReactNode } from "react";
import clsx from "clsx"; // clsx helps manage conditional className names in a clean and concise manner.

interface Props {
  /** Custom classes for the container card */
  className?: string;
  /** Custom styles for the container card */
  style?: CSSProperties;
  /** Content to be included within the card */
  children?: ReactNode;
  /** Shadow under the card. Expected values: low (lw), medium (md), tall (tl) */
  shadow?: "lw" | "md" | "tl";
}

const Card = ({ className, style, children, shadow }: Props): JSX.Element => {
  const cardShadow = shadow ? `item shadow--${shadow}` : "";
  return (
    <div className={clsx("card", className, cardShadow)} style={style}>
      {children}
    </div>
  );
};

export default Card;
```

**src/components/Card/styles.module.css**

```css
.truncate p {
  display: -webkit-box;
  -webkit-line-clamp: 3;
  line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
  text-overflow: ellipsis;
  margin-bottom: 0;
  overflow-wrap: break-word;
  max-height: 4.5em; /* 3 lines × 1.5em line-height */
  line-height: 1.5em;
}
```

**src/components/Card/CardBody/index.tsx**

```typescript
import type { CSSProperties, JSX, ReactNode } from "react";
import clsx from "clsx";
import styles from "../styles.module.css";
import { buildTextClasses, type CardTextOptions } from "../utils";

interface Props extends CardTextOptions {
  /** className for the container card */
  className?: string;
  /** Custom styles for the container card */
  style?: CSSProperties;
  /** Content to be included within the card */
  children?: ReactNode;
}

const CardBody = ({
  className,
  style,
  children,
  textAlign,
  variant,
  italic = false,
  noDecoration = false,
  transform,
  truncate = false,
  weight,
}: Props): JSX.Element => {
  const {
    text,
    textColor,
    textItalic,
    textDecoration,
    textType,
    textTruncate,
    textWeight,
  } = buildTextClasses(
    { textAlign, variant, italic, noDecoration, transform, truncate, weight },
    styles.truncate,
  );
  return (
    <div
      className={clsx(
        "card__body",
        className,
        text,
        textType,
        textColor,
        textItalic,
        textDecoration,
        textTruncate,
        textWeight,
      )}
      style={style}
    >
      {children}
    </div>
  );
};

export default CardBody;
```

**src/components/Card/CardImage/index.tsx**

```typescript
/**
 * CardImage Component
 *
 * Renders an image for a card layout with optional lazy loading and
 * customizable size. Automatically resolves the image URL using Docusaurus's
 * `useBaseUrl` hook. Applies default styling via the "card__image" class
 * and supports additional custom styles.
 *
 * Notes:
 * - Using `width` and `height` helps reduce layout shift by reserving space
 *   for the image before it loads.
 * - You can also override sizing via CSS or the `style` prop if more flexibility is needed.
 */

import type { CSSProperties, JSX } from "react";
import clsx from "clsx";
import useBaseUrl from "@docusaurus/useBaseUrl";

interface Props {
  /** Optional additional CSS classes to apply. */
  className?: string;
  /** Inline styles for the image element. */
  style?: CSSProperties;
  /** Relative or absolute path to the image. */
  cardImageUrl: string;
  /** Alternative text for accessibility. */
  alt: string;
  /** Optional tooltip text shown on hover. */
  title?: string;
  /** If true, enables `loading="lazy"` to defer image loading until visible. */
  lazy?: boolean;
  /** Optional width of the image in pixels. */
  width?: number;
  /** Optional height of the image in pixels. */
  height?: number;
}

const CardImage = ({
  className,
  style,
  cardImageUrl,
  alt,
  title,
  lazy = true,
  width,
  height,
}: Props): JSX.Element => {
  const generatedCardImageUrl = useBaseUrl(cardImageUrl);

  return (
    <img
      className={clsx("card__image", className)}
      style={style}
      src={generatedCardImageUrl}
      alt={alt}
      title={title}
      loading={lazy ? "lazy" : undefined}
      width={width}
      height={height}
    />
  );
};

export default CardImage;
```

### Tester notre URL /series {#testing-our-series-url}

Tout est maintenant en place pour notre page web `/series`. Nous avons créé `src/pages/series.mdx` et toutes ses dépendances.

Maintenant, accédez simplement à l'URL `/series`. Sur mon localhost, c'est `http://127.0.0.1:3000/series`. Vous devriez obtenir la même page que celle montrée en haut de cet article, sans mise en forme pour l'instant.

Chouette, non ?

### 2.4 Ajouter une entrée pour la page des séries à votre blog {#24-adding-an-entry-for-the-series-page-to-your-blog}

Éditez votre fichier `docusaurus.config.js` et, dans la section `navbar` -> `items`, ajoutez un lien vers `/series` :

**docusaurus.config.js**

```javascript
// [...]
const config = {
  themeConfig:
    ({
      navbar: {
        items: [
          // [...]
          {
            href: "/series",
            label: "My series",
          },
          // [...]
        ],
      },
    }),
};

export default config;
```

![Mes séries](./images/my-series.webp)

### 2.5 Gérer l'URL /series/slug {#25-handling-the-seriesslug-url}

Ok, ça fonctionne, mais depuis la page `/series`, si vous cliquez sur une série, vous obtenez cette erreur :

![Page 404 en visitant une série spécifique](./images/page-404.webp)

Nous venons donc de créer la page `/series`. Mais quand votre lecteur clique sur une série pour obtenir la liste des articles, l'URL devient quelque chose comme `/series/the-name-of-the-series`. Il faut donc dire à Docusaurus comment gérer ces nouvelles URL.

Par défaut, Docusaurus ne sait pas quoi en faire.

Pour ça, nous aurons besoin d'un plugin. Créez le fichier `plugins/docusaurus-plugin-series-route/index.cjs` avec ce contenu :

**plugins/docusaurus-plugin-series-route/index.cjs**

```cjs
/**
 * Docusaurus Plugin: series-route
 *
 * Purpose:
 * This plugin adds the routes for displaying articles by series slug under the
 * `/series/<slug>` URL pattern.
 *
 * Why:
 * Docusaurus by default does not support routes like `/series/<slug>`. This plugin
 * programmatically registers one route per series, each pointing to the
 * `SeriesArticlesPage` React component, which fetches and renders articles based on
 * the series slug taken from the URL.
 *
 * Why one route per series instead of a single `/series/:slug` (TODO 0092):
 * Docusaurus's `handleBrokenLinks` resolves links against the list of *registered*
 * routes, and a parameterized path matches nothing there. With a single `:slug` route
 * every `<Link href="/series/writing-better-bash-scripts">` was reported as broken,
 * which is why `onBrokenLinks` had to be set to "ignore" site-wide — and a genuinely
 * dead link then went out silently with it. Enumerating the real series at build time
 * makes those links resolvable, so link checking can be switched back on.
 *
 * Usage:
 * - Save this plugin as `plugins/docusaurus-plugin-series-route/index.cjs`
 * - Add the plugin to `docusaurus.config.js` plugins array.
 * - Create the React component at the specified path (`src/components/Blog/Series/SeriesArticlesPage.js`).
 *
 * This allows URLs like `/series/my-series-name` to display articles for that series.
 *
 * See readme.md for more details
 */

const path = require("path");
const { normalizeUrl } = require("@docusaurus/utils");
const { listSeriesSlugs } = require("../lib/blog-taxonomy.cjs");

module.exports = function (context) {
  return {
    name: "docusaurus-plugin-series-route",
    // Routes are enumerated from article frontmatter, so a new tag/series would
    // otherwise only get a route on the next dev-server restart. Watching the corpus
    // makes `yarn start` pick it up on save, like the parameterized route used to.
    getPathsToWatch() {
      return [path.join(context.siteDir, "blog", "**", "index.{md,mdx}")];
    },
    async contentLoaded({ actions }) {
      for (const slug of listSeriesSlugs(context.siteDir)) {
        actions.addRoute({
          // Through `baseUrl` — see the same comment in docusaurus-plugin-tag-route: a
          // hardcoded absolute path registers a route no locale-prefixed link can reach.
          path: normalizeUrl([context.baseUrl, "series", slug]),
          component: "@site/src/components/Blog/Series/SeriesArticlesPage",
          exact: true,
        });
      }
    },
  };
};
```

Comme vous le voyez dans le code source du plugin, nous avons besoin d'un nouveau composant : `src/components/Blog/Series/SeriesArticlesPage.tsx`. Créons-le :

**src/components/Blog/Series/SeriesArticlesPage.tsx**

```typescript
import type { CSSProperties, JSX } from "react";
import { createSlug } from "@site/src/components/Blog/utils/slug";
import { useBlogMetadata } from "@site/src/components/Blog/utils/posts";
import { hexToRgba } from "@site/src/components/Blog/utils/color";
import { useSeriesLocalizer } from "@site/src/components/Blog/utils/seriesI18n";
import { useLocation, matchPath } from "@docusaurus/router";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import { useBaseUrlUtils } from "@docusaurus/useBaseUrl";
import { useSourceLocaleUrls } from "@site/src/components/Blog/utils/localeUrls";
import Layout from "@theme/Layout";
import Link from "@docusaurus/Link";
import Translate, { translate } from "@docusaurus/Translate";
import PostCard from "@site/src/components/Blog/PostCard";
import SERIES_DATA from "@site/src/data/series.js";
import Head from "@docusaurus/Head";
import FollowFeed from "@site/src/components/FollowFeed";
import styles from "./styles.module.css";

const DEFAULT_IMAGE = "/img/default.webp";

export default function SeriesArticlesPage(): JSX.Element {
  const location = useLocation();
  // Hooks must run before any early return.
  const posts = useBlogMetadata();
  const localizeSeries = useSeriesLocalizer();
  // The hook flavour would have to sit here, above the early return, but the banner path is only
  // known further down — so take the plain function and call it where the value exists.
  const { withBaseUrl } = useBaseUrlUtils();
  const { isDefaultLocale, sourceLabel, sourceLocale, sourceUrl } =
    useSourceLocaleUrls();


  // The pattern MUST carry `baseUrl`: under the `fr` locale the pathname is `/fr/series/<slug>`
  // and a hardcoded pattern silently matches nothing, rendering the "not found" branch on a
  // perfectly valid page (TODO 0119).
  const { siteConfig } = useDocusaurusContext();
  const base = siteConfig.baseUrl.replace(/\/$/, "");
  const match = matchPath<{ slug: string }>(location.pathname, {
    path: `${base}/series/:slug`,
    exact: true,
  });
  const slug = match?.params?.slug;

  if (!slug) {
    return (
      <Layout>
        <div className="container">
          <p>
            <Translate id="blog.seriesPage.noSeriesSpecified">No series specified.</Translate>
          </p>
          <Link to="/series">
            <Translate id="blog.seriesPage.backToSeries">Go back to all series</Translate>
          </Link>
        </div>
      </Layout>
    );
  }

  const seriesPosts = posts.filter((post) => {
    if (!post.series) return false;
    return createSlug(post.series) === slug;
  });

  // seriesPosts is filtered on a truthy `series`, so the first entry's `series`
  // is always a string here; `?? slug` covers the empty-list case (and satisfies
  // the `string | null` type without a cast).
  const originalSeriesName = seriesPosts[0]?.series ?? slug;

  const seriesData = SERIES_DATA.find((s) => s.name === originalSeriesName);
  const seriesBannerUrl = withBaseUrl(seriesData?.image ?? DEFAULT_IMAGE);

  // `originalSeriesName` is the functional key — it drives the slug, the front matter match and
  // the RSS feed, and must never be translated. Only these two are shown to the reader, and they
  // fall back to the English SERIES_DATA when the locale has no row for this series.
  const localized = localizeSeries(originalSeriesName);
  const displayName = localized?.label ?? originalSeriesName;
  const displayDescription = localized?.description ?? seriesData?.description;

  const heroStyle: CSSProperties | undefined = seriesData?.color
    ? ({
        "--series-accent-solid": hexToRgba(seriesData.color, 1),
        "--series-accent-light": hexToRgba(seriesData.color, 0.16),
        "--series-accent-dark": hexToRgba(seriesData.color, 0.3),
        "--series-accent-glow-light": hexToRgba(seriesData.color, 0.35),
        "--series-accent-glow-dark": hexToRgba(seriesData.color, 0.55),
      } as CSSProperties)
    : undefined;

  const sortedPosts = seriesPosts.sort(
    (a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()
  );

  return (
    <Layout
      title={translate(
        {
          id: "blog.seriesPage.metaTitle",
          message: "Series articles: {name}",
        },
        { name: displayName }
      )}
    >
      <div className="container margin-top--lg margin-bottom--lg">
        <div className={styles.seriesHero} style={heroStyle}>
          {sortedPosts.length > 0 && (
            <img
              // Same baseUrl rule as PostCard: a series banner path comes from
              // `src/data/series.js`, so it carries no locale prefix of its own.
              src={seriesBannerUrl}
              alt={displayName}
              loading="lazy"
              className={styles.seriesBanner}
            />
          )}
          <h1>
            <Translate id="blog.seriesPage.title" values={{ name: displayName }}>
              {"Series articles: {name}"}
            </Translate>
          </h1>
          <span className={styles.seriesTitleAccent} aria-hidden="true" />
          {displayDescription && (
            <p className={styles.seriesDescription}>{displayDescription}</p>
          )}
          {sortedPosts.length > 0 && (
            // Plain <a>, not Docusaurus's <Link>: this file is a static asset
            // written by plugins/markdown-export-plugin's postBuild step, not
            // a registered route, so <Link> would have nothing to resolve
            // against. Same reasoning as CopyAsMarkdown's "View raw" link.
            //
            // Because it is not a route, nothing prefixes it either: the plugin writes one
            // bundle per locale into that locale's outDir (`build/fr/llms/…`), so a bare
            // `/llms/…` sends a French reader to the English bundle.
            <a
              href={withBaseUrl(`/llms/${slug}.txt`)}
              className={styles.markdownLink}
              target="_blank"
              rel="noopener noreferrer"
            >
              <Translate id="blog.seriesPage.markdownLink">
                📄 View this series as plain Markdown
              </Translate>
            </a>
          )}
        </div>
        {sortedPosts.length > 0 && (
          <>
            {/*
              /series/<slug> is a custom route (docusaurus-plugin-series-route),
              so nothing injects feed autodiscovery here on its own — see
              src/components/MarkdownAlternate for the same pattern.
            */}
            <Head>
              <link
                rel="alternate"
                type="application/rss+xml"
                href={withBaseUrl(`/series/${slug}/rss.xml`)}
                title={translate(
                  { id: "feed.linkTitle", message: "{name} — RSS feed" },
                  { name: originalSeriesName },
                )}
              />
              {/* The same series in the source language: more articles, since only part of the
                  series is translated. Helmet keys <link> on `href`, so this is kept too. */}
              {!isDefaultLocale && (
                <link
                  rel="alternate"
                  type="application/rss+xml"
                  href={sourceUrl(`/series/${slug}/rss.xml`)}
                  hrefLang={sourceLocale}
                  title={translate(
                    {
                      id: "feed.linkTitleSourceSeries",
                      message: "{name} — RSS feed ({language} — the whole series)",
                    },
                    { name: originalSeriesName, language: sourceLabel },
                  )}
                />
              )}
            </Head>
            <FollowFeed
              feedUrl={`/series/${slug}/rss.xml`}
              label={translate(
                {
                  id: "blog.followFeed.label.series",
                  message: 'the "{name}" series',
                },
                { name: displayName },
              )}
            />
          </>
        )}
        {sortedPosts.length > 0 ? (
          <div className={styles.seriesGrid}>
            {sortedPosts.map((post) => (
              <PostCard key={post.permalink} post={post} />
            ))}
          </div>
        ) : (
          <div className="text--center margin-vert--xl">
            <h2>
              <Translate id="blog.seriesPage.notFound.title">No articles found for this series</Translate>
            </h2>
            <p>
              <Translate id="blog.seriesPage.notFound.message">
                Oops, it looks like that series doesn&apos;t exist. Please check the name in the URL to make sure it&apos;s correct.
              </Translate>
            </p>
            <Link to="/series">
              <Translate id="blog.seriesPage.notFound.link">Click here to browse all available series from our homepage.</Translate>
            </Link>
          </div>
        )}
      </div>
    </Layout>
  );
}
```

Et nous devons charger ce plugin, il faut donc mettre à jour à nouveau le fichier `docusaurus.config.js` :

**docusaurus.config.js**

```javascript
// [...]
import pluginSeriesRoute from "./plugins/docusaurus-plugin-series-route/index.cjs"

const config = {
  // [...]

  // WE SHOULD IGNORE BROKEN LINKS because Docusaurus’s link checker doesn't
  // recognize dynamic routes created via plugins; and we're using at least one
  // i.e. "plugins/docusaurus-plugin-series-route/index.cjs".
  // If we don't ignore broken links, Docusaurus will always throw an error during build
  // time.
  onBrokenLinks: "ignore",
  // [...]
  plugins: [
    // [...]
    [pluginSeriesRoute, {}],
  ],
  // [...]
};

export default config;
```

> **Danger:**
>
> La propriété `onBrokenLinks` doit être mise à `ignore` car Docusaurus ne charge pas les plugins de routage pendant le rendu de la version statique du site. Il ne comprendra donc pas les URL `/series/xxx` et les considérera comme cassées.

Pour la dernière fois, redémarrez votre serveur Docusaurus.

Maintenant, dans votre menu principal, vous devriez avoir votre entrée `Series`. En cliquant dessus, vous obtenez la liste des séries. En cliquant sur une série, vous obtenez une nouvelle page avec la liste des articles de cette série. Cliquez sur un article pour y accéder.

![Affichage d'une série spécifique](./images/a-series.webp)

## Partie 3 - Ajouter un fichier /src/data/series.js pour la personnalisation {#part-3---adding-a-srcdataseriesjs-file-for-customization}

Le composant fonctionne bien pour l'instant, mais il nous manque deux fonctionnalités importantes :

1. La possibilité d'écrire une description pour la série.
2. La possibilité d'utiliser une image personnalisée.

Créons un nouveau fichier appelé `/src/data/series.js` avec un contenu comme celui-ci :

**/src/data/series.js**

```javascript
const SERIES_DATA = [
  {
    name: "Bluesky Docusaurus component",
    description:
      "Build and integrate Bluesky-powered components inside Docusaurus.",
    image: "/img/series/bluesky.webp",
  },
  {
    name: "Coding using a devcontainer",
    description:
      "Develop consistently across machines using VS Code Dev Containers.",
    image: "/img/series/devcontainer.webp",
  },
  {
    name: "Create your joomla website using Docker",
    description:
      "Run Joomla locally using Docker and streamline your web development.",
    image: "/img/series/joomla.webp",
  },
  {
    name: "Creating Docusaurus components",
    description:
      "Custom components, hooks, layouts, and reusable UI for your Docusaurus site.",
    image: "/img/series/components.webp",
  },
  // ...
];

export default SERIES_DATA;
```

Vous devez respecter une règle importante : la valeur que vous indiquez dans la clé `name` doit correspondre à la valeur de votre `series` telle qu'indiquée dans votre YAML front matter.

Par exemple, pour cet article, ma clé YAML est `series: Creating Docusaurus components`, je dois donc la retrouver dans mon `/src/data/series.js`.

Le composant pourra alors trouver la `description` et l'`image` à utiliser.

Maintenant, votre page Series ressemblera à ceci :

![La page des séries avec une description](./images/with-description.webp)

## Conclusion {#conclusion}

C'était clairement compliqué. Beaucoup de fichiers à créer, d'autres à mettre à jour, et quelques éléments de configuration à ajuster, mais le résultat ne valait-il pas le coup ?

Vous avez maintenant un blog où vous pouvez écrire des articles « piliers » avec un contenu si dense que vous pouvez facilement le découper en plusieurs parties et le proposer à vos lecteurs sous forme de série.

Vos lecteurs seront guidés pas à pas, ce qui leur permettra de mieux comprendre les différentes étapes à suivre. Côté SEO, vous gagnerez en visibilité grâce à ces articles « piliers », entre autres, mais aussi parce que vous améliorerez la structure de navigation de vos pages. Les moteurs de recherche apprécient beaucoup ça.
