# Creating Docusaurus components
A series of 26 article(s), in reading order.
# Modifier la façon dont Docusaurus génère les balises img
> Personnalisez la manière dont Docusaurus génère les balises pour mieux contrôler le rendu et le comportement des images.
---

**TL;DR**
Cet article explique comment personnaliser le rendu des images dans Docusaurus en créant un composant React `` sur mesure. Vous allez apprendre à construire le composant, à le styler en CSS, puis à utiliser un plugin Remark personnalisé pour remplacer automatiquement les images Markdown standard par votre nouveau composant. Le guide couvre aussi la configuration du plugin pour ignorer la première image d'un article et son enregistrement dans la configuration de Docusaurus, ce qui vous donne un contrôle total sur l'affichage de vos images.
Je démarre une série sur l'écriture de composants pour Docusaurus.
Comme c'est toujours plus sympa de commencer par quelque chose de très concret et pratique, voici ce que nous allons faire : nous allons intercepter la conversion de `` vers une balise HTML ``. Nous allons forcer Docusaurus à utiliser à la place notre propre composant ``.
Nous allons personnaliser notre balise `` pour d'abord injecter un `
` parent, puis forcer notre `` avec des attributs comme le CSS, le lazy loading, ... **mais pas pour toutes les balises de l'article, puisque nous allons ignorer la première.**
En effet, l'image d'introduction de l'article doit rester inchangée ; nous commencerons à modifier les autres images.
Et voici le résultat de notre composant `` fait main :
(image: Un suricate heureux)
Comme vous le voyez, l'image est centrée, elle a un effet au survol, des coins arrondis, ... Si vous regardez le code HTML, vous verrez l'attribut `loading="lazy"`.
C'est plutôt chouette : notre composant fonctionne ! Construisons-le.
Il y a plusieurs choses à faire, alors commençons par la première : créer notre composant ``.
## 1. Création de notre composant Image {#1-creation-of-our-image-component}
Écrire un composant pour Docusaurus est assez simple. Il faut créer un nouveau dossier dans le dossier existant `src/components`.
Dans cet article, nous allons créer un composant `Image` ; créons donc le fichier `src/components/Image/index.tsx` avec ce contenu :
**src/components/Image/index.tsx**
```typescript
/**
* 🖼️ Image Component
*
* A lightweight wrapper for rendering images in Docusaurus with base URL resolution.
* Ensures that image paths are correctly resolved relative to the site's base URL,
* making it ideal for static assets stored in the `static/img` directory.
*
* 🔍 Behavior:
* - Uses `useBaseUrl()` to resolve the image path
* - Applies scoped styling via CSS modules
* - Supports optional `alt` and `title` attributes for accessibility and tooltips
* - Enables lazy loading for performance optimization
*/
import type { JSX } from "react";
import styles from "./styles.module.css";
import useBaseUrl from "@docusaurus/useBaseUrl";
interface Props {
/** Path to the image (e.g. `/img/example.png`), or a `require()`d asset */
src: string | { default: string };
/** Tooltip text shown on hover */
title?: string;
/** Alternative text for accessibility (if missing, reuse the Title property) */
alt?: string;
}
export default function Image({ src, alt, title }: Props): JSX.Element {
const isAbsolutePath = typeof src === "string" && src.startsWith("/");
const resolvedUrl = useBaseUrl(isAbsolutePath ? src : undefined);
const imgSrc = (isAbsolutePath ? resolvedUrl : src) as string;
return (
);
}
```
Notre composant `Image` attend trois paramètres, mais seul `img` est obligatoire. On peut aussi passer un titre (`title`) et le texte alternatif (`alt`).
Comme vous le comprenez facilement, le composant `` va simplement créer un `div` contenant une balise `img`.
Rien d'extraordinaire, sauf que nous utiliserons un peu de CSS pour le look & feel et que nous forcerons le [lazy loading](/blog/docusaurus-lazy-loading).
Créez également le fichier `src/components/Image/styles.module.css` :
**src/components/Image/styles.module.css**
```css
.container {
text-align: center;
padding: 10px;
background: rgb(255, 255, 255, 0.15);
border: 1px solid rgb(255, 255, 255, 0.3);
border-radius: 16px;
backdrop-filter: blur(10px) saturate(180%);
}
.container img {
border-radius: 12px;
box-shadow: 0 8px 20px rgb(0, 0, 0, 0.15);
transition:
transform 0.4s ease,
box-shadow 0.4s ease,
filter 0.4s ease;
}
.container img:hover {
transform: scale(1.05) translateY(-4px);
box-shadow: 0 12px 25px rgb(0, 0, 0, 0.25);
filter: brightness(1.05) saturate(1.1);
}
```
## 2. Informer Docusaurus de l'existence de notre composant {#2-tell-docusaurus-about-our-component}
Avant de pouvoir utiliser le composant ``, il faut dire à Docusaurus quoi faire quand il le rencontrera.
Éditez le fichier `src/theme/MDXComponents.js` (et s'il n'existe pas, créez-le)
**src/theme/MDXComponents.js**
```javascript
import MDXComponents from "@theme-original/MDXComponents";
import Image from "@site/src/components/Image";
export default {
// Reusing the default mapping
...MDXComponents,
Image,
};
```
Tout est maintenant en place.
## 3. Utiliser le composant Image {#3-using-the-image-component}
Dès maintenant, dans un article de blog, au lieu d'écrire
```html

```
nous pouvons écrire
```html
```
La partie `require` est nécessaire parce que l'image se trouve dans un sous-dossier de l'article. C'est exactement la balise rendue en haut de cet article.
Si la source était quelque chose comme `/img/happy.jpg`, le code est plus simple :
```html
```
> **Caution:**
>
> Mais, oh oh, sur ce blog, j'ai plus de 250 articles à l'heure actuelle (août 2025) ; je ne vais quand même pas parcourir tous mes articles pour remplacer mes images, si ?
>
> Et puis, peut-être que demain je ne voudrai plus de cette balise ; je veux donc garder mes articles en **Markdown vanilla** (le langage standard). Comment faire ? La réponse : écrire un plugin (j'ai utilisé exactement la même astuce pour mon [plugin de recherche & remplacement](/blog/docusaurus-plugin-replace)).
Le plugin va pouvoir intercepter le code Markdown de l'article et le manipuler. C'est exactement ce que nous voulons ici !
Nous allons intercepter la conversion des images de Markdown vers HTML et dire à Docusaurus d'utiliser notre balise Image au lieu de img.
## 4. Écrire et enregistrer un plugin (optionnel — seulement si vous avez des centaines d'articles comme moi) {#4-writing-and-registering-a-plugin-optional--only-if-you-have-hundreds-of-articles-like-me}
Si vous n'avez qu'une poignée d'articles, il est plus simple de les éditer à la main et de sauter cette section. Nous avons donc besoin d'un plugin pour intercepter la conversion de `` vers une balise image HTML ; automatiquement.
> **Caution:**
>
> Ajoutons une complexité : nous ne voulons pas modifier la première image, c'est-à-dire l'image d'introduction de l'article. Pour cette première image, laissons Docusaurus faire son travail (il le fait très bien). Nous intercepterons à partir de la deuxième image de l'article.
Créez le fichier `plugins/remark-image-transformer/index.cjs` :
**plugins/remark-image-transformer/index.cjs**
```cjs
/**
* @fileoverview
* A Remark plugin for transforming Markdown image nodes (``) into MDX-compatible `` components.
* This plugin is tailored for Docusaurus environments, where image paths often need to be resolved using `require()` or `useBaseUrl()`.
* It supports:
* - Relative paths (e.g. `./image.png`) → converted to `require()` expressions
* - Absolute paths (e.g. `/img/banner.png`) → passed directly, as Docusaurus resolves them
* - External URLs → passed directly as string literals
* Additionally, it preserves `alt` and `title` attributes.
*
* @module remarkReplaceImgToImage
*/
// cspell:ignore mdxjs
const { visit } = require("unist-util-visit");
const acorn = require("acorn");
/**
* Parses a JavaScript expression string into an ESTree-compatible expression node.
*
* @param {string} expression - A valid JavaScript expression as a string.
* @returns {object} ESTree expression node representing the parsed expression.
*/
function expressionToEstree(expression) {
return acorn.parse(expression, {
ecmaVersion: 2020,
sourceType: "module",
}).body[0].expression;
}
/**
* Creates an MDX JSX attribute node with an embedded JavaScript expression.
*
* @param {string} name - The name of the JSX attribute (e.g. "src").
* @param {string} expr - A JavaScript expression string to embed in the attribute.
* @returns {object} MDX AST node representing the JSX attribute with expression value.
*/
function makeJsxAttributeExpression(name, expr) {
return {
type: "mdxJsxAttribute",
name,
value: {
type: "mdxJsxAttributeValueExpression",
value: expr,
data: {
estree: {
type: "Program",
body: [
{
type: "ExpressionStatement",
expression: expressionToEstree(expr),
},
],
sourceType: "module",
},
},
},
};
}
/**
* Remark plugin that replaces Markdown image nodes with MDX `` components.
*
* @param {object} [options={}] - Plugin configuration options.
* @param {boolean} [options.skipFirst=true] - Whether to skip transforming the first image node.
* @returns {function} Transformer function to be used by Remark.
*/
function remarkReplaceImgToImage({ skipFirst = true } = {}) {
return (tree) => {
let imageCount = 0;
visit(tree, "image", (node, index, parent) => {
imageCount++;
if (skipFirst && imageCount === 1) return;
const url = node.url || "";
const attributes = [
{ type: "mdxJsxAttribute", name: "alt", value: node.alt || "" },
];
if (node.title)
attributes.push({
type: "mdxJsxAttribute",
name: "title",
value: node.title,
});
if (/^\.+\//.test(url)) {
// Relative to MD file -> use require()
const expr = `require("${url}").default`;
attributes.unshift(makeJsxAttributeExpression("src", expr));
} else if (/^\//.test(url)) {
// Absolute -> just pass the path, Docusaurus will handle it.
attributes.unshift({
type: "mdxJsxAttribute",
name: "src",
value: url,
});
} else {
// Fallback: external URL
attributes.unshift({
type: "mdxJsxAttribute",
name: "src",
value: url,
});
}
const imageNode = {
type: "mdxJsxFlowElement",
name: "Image",
attributes,
children: [],
};
parent.children.splice(index, 1, imageNode);
});
// The useBaseUrl import is no longer needed.
};
}
module.exports = remarkReplaceImgToImage;
```
*Ce plugin est plus complexe parce qu'il faut s'assurer que les chemins vers les images sont correctement gérés (on utilise des chemins relatifs comme ``, ou absolus (``), ou même externes).*
Une fois le plugin créé, il faut mettre à jour la configuration de Docusaurus pour le charger.
Pour cela, éditez votre fichier `docusaurus.config.js` et ajoutez les lignes mises en évidence comme illustré ci-dessous.
**docusaurus.config.js**
```javascript
const config = {
// [ ... ]
presets: [
[
'classic',
({
blog: {
// highlight-next-line
beforeDefaultRemarkPlugins: [require('./plugins/remark-image-transformer')],
},
// [ ... ]
}),
],
],
};
export default config;
```
Redémarrez maintenant votre serveur Docusaurus (pour que le plugin soit enregistré).
Si tout est correctement en place, ouvrez n'importe lequel de vos anciens articles et vous verrez que ça fonctionne.
## Conclusion {#conclusion}
À vous maintenant d'éditer le fichier `src/components/Image/styles.module.css` et d'utiliser votre propre CSS.
Et une fois que chaque image passe par votre propre composant, il devient facile de les auditer : voyez [Running some checks on your Docusaurus images](/blog/docusaurus-check-images).
---
# Surcharger la génération des balises img avec Docusaurus
> Apprenez à personnaliser le rendu des balises dans Docusaurus pour mieux contrôler la sortie des images.
---

**TL;DR**
Cet article propose une solution simple pour activer le lazy loading des images dans Docusaurus et améliorer les performances. Il explique comment intercepter le rendu par défaut des balises `` en personnalisant le fichier `src/theme/MDXComponents.js`. Avec un simple mapping, vous pouvez forcer le chargement différé de toutes les images et même appliquer des styles personnalisés, sans avoir à créer un composant ou un plugin complexe.
En cherchant de bons trucs et astuces sur Docusaurus quand le nombre d'articles devient important, j'ai lu qu'il fallait faire attention au lazy loading des images et, en effet, par défaut toutes les images sont chargées dès le premier accès à un article de blog.
Dans cet article, nous allons voir à quel point il est facile d'intercepter la création de la balise `` lorsque le contenu Markdown est converti en HTML.
Cette conversion se produit à deux endroits : quand vous naviguez sur votre site ou quand vous générez une version statique de celui-ci.
Pour faire ça, en fait, pas besoin de créer un composant ! Il suffit d'ajouter quelques lignes de code dans le fichier `src/theme/MDXComponents.js`. *Si vous avez besoin de plus que deux ou trois attributs supplémentaires — un `div` wrapper, du CSS personnalisé, un effet de zoom — alors un vrai composant s'impose ; c'est le sujet de [Change how Docusaurus will create img tags](/blog/docusaurus-override-img).*
Si vous n'avez pas encore ce fichier, créez-le.
> **Note:**
>
> La notation `// [...]` est un marqueur pour vous montrer que vous aurez peut-être déjà quelques lignes de code à cet endroit. Ne la supprimez pas ; ajoutez simplement les lignes surlignées comme ci-dessous.
**src/theme/MDXComponents.js**
```javascript
import React from "react";
import MDXComponents from "@theme-original/MDXComponents";
// [...]
export default {
// Reusing the default mapping
...MDXComponents,
// highlight-start
img: (props) => {
const { loading, decoding, className, style, height, width, ...rest } = props;
return (
);
},
// highlight-end
// [...]
};
```
Une fois l'ajout fait dans votre fichier `MDXComponents.js`, retournez dans le navigateur ; ouvrez n'importe quel article et rafraîchissez la page. Vous verrez que les images ont maintenant l'attribut `loading="lazy"` (vous pouvez le vérifier avec le panneau des outils de développement de votre navigateur). Si, comme dans l'exemple donné ci-dessus, vous avez ajouté du style CSS, vous le verrez immédiatement.
Facile, non ?
Maintenant, comment s'assurer que ça reste vrai sur deux cents articles ? En le vérifiant automatiquement : voyez [Running some checks on your Docusaurus images](/blog/docusaurus-check-images).
---
# 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é.
---

**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 `` 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 :

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

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 {
const bySlug = new Map();
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 (
{seriesLabel}
),
}}
>
{"This article is part of the {seriesLink} series:"}
}
>
);
}
```
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 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 , not a
, 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
[...]
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 `` 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 (
// highlight-start
{isBlogPostPage && (
)}
// highlight-end
{children}
// highlight-start
{isBlogPostPage && (
)}
// highlight-end
);
}
```
> **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";
import SeriesCards from "@site/src/components/Blog/SeriesCards";
import SeriesStats from "@site/src/components/Blog/SeriesStats";
import TranslationCoverage from "@site/src/components/Blog/TranslationCoverage";
All Series
Each series has its own RSS feed —{" "}
get the next episode automatically, without checking back.
Discover our collections of articles organized by theme.
Each series offers you a progressive learning path to master a specific area of web development.
```
> **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 (
No article series found
);
}
return (
{enriched.map((serie) => (
))}
);
}
```
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();
// 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 ``, which calls
// `useBaseUrl` itself.
const smallLayoutImageUrl = useBaseUrl(image || defaultImage);
if (layout === "small") {
return (
);
}
```
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 (
{children}
);
};
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 (
{children}
);
};
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 (
);
};
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;
```

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

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/` URL pattern.
*
* Why:
* Docusaurus by default does not support routes like `/series/`. 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 `` 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/`
// 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 (
No series specified.
Go back to all series
);
}
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 (
{sortedPosts.length > 0 && (
<>
{/*
/series/ 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.
*/}
{/* The same series in the source language: more articles, since only part of the
series is translated. Helmet keys on `href`, so this is kept too. */}
{!isDefaultLocale && (
)}
>
)}
{sortedPosts.length > 0 ? (
{sortedPosts.map((post) => (
))}
) : (
No articles found for this series
Oops, it looks like that series doesn't exist. Please check the name in the URL to make sure it's correct.
Click here to browse all available series from our homepage.
)}
);
}
```
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.

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

## 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.
---
# Ajouter un bouton « retour en haut » dans les articles Docusaurus
> Découvrez comment ajouter facilement un bouton « retour en haut », souvent accompagné d'une icône animée amusante, à vos articles de blog Docusaurus grâce à un composant React personnalisé et une surcharge de thème.
---

**TL;DR**
Cet article est un tutoriel rapide pour ajouter un bouton « retour en haut » à votre blog Docusaurus. Il vous guide dans l'implémentation d'un composant React personnalisé ``, avec son CSS pour la mise en forme. Vous apprendrez à surcharger le composant de thème `BlogPostItem` par défaut afin d'injecter automatiquement le bouton au bas de chaque article. Le guide donne aussi des astuces pour ajouter le bouton à d'autres parties de votre site, comme l'archive du blog ou vos pages personnalisées.
Sur smartphone plus encore que sur ordinateur, c'est bien pratique d'avoir un bouton *Retour en haut de page* pour éviter de se luxer le pouce.
 [Docux](https://github.com/Juniors017), un ami, a récemment développé cette fonctionnalité et j'ai été ravi de l'ajouter, d'autant qu'elle est terriblement simple à utiliser et apporte un côté fun ; c'est sympa.
Faites simplement défiler n'importe quelle page de ce blog pour voir un petit suricate apparaître en bas à droite. Cliquez dessus et prenez l'ascenseur jusqu'en haut de la page.
## À quoi ça ressemble {#what-it-looks-like}
Voici cette page même, après défilement. Le suricate se tient en bas à droite, flottant au-dessus de votre contenu :
> Screenshot — https\://www\.avonture.be/blog/docusaurus-go-top
>
> 
De plus près, c'est un bouton rond qui contient l'icône que vous lui donnez :

Le bouton reste masqué tant que vous n'avez pas défilé de plus de 300 pixels ; une page courte ne l'affiche donc jamais. Deux fichiers et une surcharge, c'est ci-dessous.
## Comment ça fonctionne {#how-it-works}
Docux a créé un [composant Docusaurus ScrollToTopButton](https://github.com/Juniors017/docux-blog/tree/main/src/components/ScrollToTopButton), c'est-à-dire un bout de Javascript avec du css ; rien de plus.
Créez les deux fichiers ci-dessous dans l'arborescence de votre blog :
**src/components/ScrollToTopButton/index.tsx**
```typescript
import { useState, useEffect, type JSX } from "react";
import clsx from "clsx";
import { translate } from "@docusaurus/Translate";
import styles from "./styles.module.css";
import buttontop from "@site/static/img/meerkat/suricate_no_background.webp";
export default function ScrollToTopButton(): JSX.Element {
const [isVisible, setIsVisible] = useState(false);
const [fly, setFly] = useState(false);
useEffect(() => {
const toggleVisibility = () => setIsVisible(window.scrollY > 300);
window.addEventListener("scroll", toggleVisibility, { passive: true });
return () => window.removeEventListener("scroll", toggleVisibility);
}, []);
const scrollToTop = () => {
setFly(true);
window.scrollTo({ top: 0, behavior: "smooth" });
setTimeout(() => setFly(false), 800);
};
return (
);
}
```
**src/components/ScrollToTopButton/styles.module.css**
```css
.scrollBtn {
position: fixed;
bottom: 30px;
right: 30px;
width: 60px;
height: 60px;
cursor: pointer;
opacity: 0;
visibility: hidden;
transition:
opacity 0.4s,
visibility 0.4s;
z-index: 1000;
border: none;
background: transparent;
padding: 0;
}
.scrollBtn img {
width: 100%;
height: 100%;
object-fit: contain;
border-radius: 50%;
box-shadow: var(--mk-shadow-3);
transition: transform 0.3s;
}
.scrollBtn:hover img {
transform: scale(1.1);
}
/* Bouton visible après scroll */
.scrollBtn.show {
opacity: 1;
visibility: visible;
}
/* Animation "vol vers le haut" */
.scrollBtn.fly img {
animation: flyUp 0.8s ease-in forwards;
}
@keyframes flyUp {
0% {
transform: translateY(0) scale(1);
opacity: 1;
}
100% {
transform: translateY(-300px) scale(0.6);
opacity: 0;
}
}
/* The meerkat flying off the top of the page is the site's signature gesture,
so it is kept — but it is pure decoration, and a reader who asked for less
motion should get the plain jump. */
@media (prefers-reduced-motion: reduce) {
.scrollBtn.fly img {
animation: none;
}
.scrollBtn:hover img,
.scrollBtn img {
transition: none;
transform: none;
}
}
@media (max-width: 600px) {
.scrollBtn {
width: 44px;
height: 44px;
bottom: 16px;
right: 16px;
}
}
```
Ensuite, vous devez injecter `` dans vos pages. *Cette injection se fait en swizzlant `BlogPostItem`, exactement comme dans [Displaying related posts below our Docusaurus article](/blog/docusaurus-relatedposts) et [Adding Reader Reactions to Your Docusaurus Blog](/blog/docusaurus-reactions) ; si vous avez déjà fait l'un des deux, vous avez déjà le fichier.*
> **Information:**
>
> Le composant importe son icône dès les premières lignes : `import buttontop from "@site/static/img/meerkat/suricate_no_background.webp"`. Faites pointer cet import vers votre propre image carrée dans `/static` et c'est réglé.
## Surcharger la page BlogPostItem {#overriding-the-blogpostitem-page}
Suivez le chapitre « [Nous devons surcharger la façon dont Docusaurus rend l'article](/blog/docusaurus-bluesky-share/#we-need-to-override-how-the-article-is-rendered-by-docusaurus) » d'un article précédent.
Vous devez créer le fichier `src/theme/BlogPostItem/index.js`.
Si vous ne voulez pas les détails, ne lisez pas ce chapitre et créez simplement ce fichier :
**src/theme/BlogPostItem/index.js**
```javascript
import React from 'react';
import clsx from 'clsx';
import {useBlogPost} from '@docusaurus/plugin-content-blog/client';
import BlogPostItemContainer from '@theme/BlogPostItem/Container';
import BlogPostItemHeader from '@theme/BlogPostItem/Header';
import BlogPostItemContent from '@theme/BlogPostItem/Content';
import BlogPostItemFooter from '@theme/BlogPostItem/Footer';
// apply a bottom margin in list view
function useContainerClassName() {
const {isBlogPostPage} = useBlogPost();
return !isBlogPostPage ? 'margin-bottom--xl' : undefined;
}
export default function BlogPostItem({children, className}) {
const containerClassName = useContainerClassName();
return (
{children}
);
}
```
Une fois que vous l'avez, éditez-le car nous devons y injecter le code ``.
Ci-dessous, voyez les deux lignes surlignées à ajouter :
**src/theme/BlogPostItem/index.js**
```javascript
import React from 'react';
import clsx from 'clsx';
import {useBlogPost} from '@docusaurus/plugin-content-blog/client';
import BlogPostItemContainer from '@theme/BlogPostItem/Container';
import BlogPostItemHeader from '@theme/BlogPostItem/Header';
import BlogPostItemContent from '@theme/BlogPostItem/Content';
import BlogPostItemFooter from '@theme/BlogPostItem/Footer';
// highlight-next-line
import ScrollToTopButton from "@site/src/components/ScrollToTopButton";
// apply a bottom margin in list view
function useContainerClassName() {
const {isBlogPostPage} = useBlogPost();
return !isBlogPostPage ? 'margin-bottom--xl' : undefined;
}
export default function BlogPostItem({children, className}) {
const containerClassName = useContainerClassName();
return (
{children}
// highlight-next-line
);
}
```
> **Nous devons redémarrer Docusaurus:**
>
> Comme nous venons d'introduire une surcharge, nous devons redémarrer notre serveur Docusaurus pour que les changements soient pris en compte.
> **Lancez npm run start:**
>
> Si vous lancez Docusaurus en local, exécutez simplement `npm run start` dans votre console.
> Si, comme moi, vous lancez Docusaurus via Docker, tuez le container et démarrez-en un nouveau.
## Vous pouvez bien sûr le faire pour d'autres pages {#you-can-do-this-for-other-pages-for-sure}
Sur mon site, j'ai une page `archive`. Vous voulez y utiliser le bouton `` aussi ? Swizzlez simplement la page en exécutant `yarn docusaurus swizzle @docusaurus/theme-classic BlogArchivePage` dans une console, puis mettez à jour le fichier `index.js` comme illustré ci-dessus.
Si vous avez d'autres pages dans votre dossier `src/pages/` (comme `about.mdx`, `index.mdx`, ...), pas besoin de swizzler d'abord : ajoutez directement les deux lignes dans ces fichiers aussi.
## Conclusion {#conclusion}
Fini les pouces luxés : un petit composant, un `BlogPostItem` swizzlé, et chaque article long de votre site obtient un chemin de retour vers le haut. Le plus amusant, c'est que l'icône est la vôtre ; la mienne fait signe aux lecteurs qui descendent trop loin.
Si vous constituez votre collection de composants Docusaurus, le prochain à ajouter est [les articles liés sous chaque article](/blog/docusaurus-relatedposts), qui réutilise exactement le même fichier de surcharge.
---
# Créer un plugin de recherche/remplacement pour Docusaurus
> Remplacez automatiquement « markdown » par « Markdown » dans tout votre contenu pour garder des articles cohérents.
---

**TL;DR**
Cet article montre comment créer un plugin remark personnalisé pour Docusaurus afin de rechercher et remplacer automatiquement certains termes sur tout votre site. L'objectif : garantir une terminologie cohérente (par exemple transformer « vscode » en « VSCode ») sans modifier les fichiers Markdown d'origine. Vous allez apprendre à écrire un plugin qui parcourt l'arbre syntaxique abstrait (AST) du contenu pour n'appliquer les remplacements qu'au texte brut, en évitant les blocs de code et les URL. Le guide montre aussi comment configurer le plugin dans votre `docusaurus.config.js`.
Pour le fun (parce que cette solution n'est peut-être pas infaillible), j'ai demandé à une IA de générer un plugin capable de scanner mes 200 articles et de remplacer des motifs comme `docusaurus` par `Docusaurus`, `github` par `GitHub`, `vscode` par `VSCode`, afin de normaliser tout mon contenu.
Ça peut être risqué, car si le mot `vscode` apparaît :
* dans une URL (comme `https://github.com/microsoft/vscode/`),
* dans un nom (comme `vstirbu.vscode-mermaid-preview`),
* dans un nom de fichier (comme `vscode.png`),
* dans un snippet de code (à l'intérieur d'un bloc ` ```...``` ` ou `` `.` ``),
* ...
nous ne voulons évidemment pas faire le remplacement.
Mais si le mot se trouve dans un simple paragraphe, c'est une autre histoire : là, oui, on veut le remplacement.
Donc, après quelques prompts avec l'IA, un plugin a été généré et il fonctionne jusqu'ici.
Redémarrez votre serveur Docusaurus une fois le plugin en place et, au démarrage suivant, si des changements doivent être faits, vous les verrez dans votre console :
```bash
🔎 Replacing 'vscode' with 'VSCode' in file: /opt/[...]/index.md
Sentence: One of the best features in vscode is the
🔎 Replacing 'vscode' with 'VSCode' in file: /opt/[...]/index.md
Sentence: With vscode, it's ultra-simple: multiple cursors.
```
Silencieux, sûr, et ça ne touche jamais vos fichiers Markdown d'origine : uniquement le HTML généré. Construisons-le.
## Le plugin {#the-plugin}
Créez le fichier `plugins/remark-replace-terms/index.cjs` et regardez le tableau `replacements`. Ajoutez-y les vôtres.
*Il s'agit d'un plugin **remark** : il parcourt l'AST Markdown avant la production du HTML. J'ai utilisé le même mécanisme pour [remplacer chaque balise `` par mon propre composant](/blog/docusaurus-override-img) et pour [injecter des tooltips générés par IA dans les snippets de code](/blog/docusaurus-eli5-snippet-tooltips).*
La syntaxe est `[/\b(1)\b/g, "(2)"],` où `(1)` est le mot à rechercher (écrit exactement tel quel, sensible à la casse) et `(2)` la valeur de remplacement.
**plugins/remark-replace-terms/index.cjs**
```cjs
/**
* @fileoverview
* Docusaurus Remark Plugin – remark-replace-words
*
* This plugin is designed for use with Docusaurus to automatically correct the casing
* of common technology terms in Markdown content. It scans Markdown AST text nodes and
* replaces lowercase variants like "github" or "markdown" with their properly capitalized
* forms ("GitHub", "Markdown", etc.).
*
* ⚠️ Replacements are skipped inside links, images, and code blocks to avoid unintended changes.
* 🧠 Compound words (e.g., "vscode-docker") are also preserved.
*
* Intended for use in the Docusaurus Markdown pipeline via `remarkPlugins`.
*
* @module remarkReplaceWords
* @returns {Function} A remark transformer function
*/
const { visit } = require("unist-util-visit");
// Define replacements as [searchRegex, replacement]
const replacements = [
[/\bdocusaurus\b/g, "Docusaurus"],
[/\bgithub\b/g, "GitHub"],
[/\bmarkdown\b/g, "Markdown"],
[/\bvscode\b/g, "VSCode"],
];
// A heading's explicit anchor id — `## Docusaurus {#docusaurus}` — is still ordinary text when
// this plugin runs: it sits in `beforeDefaultRemarkPlugins`, so Docusaurus parses that `{#id}`
// suffix only afterwards. Capitalizing a term inside it rewrites the id itself (`{#Docusaurus}`),
// and every link built from the English slug then points at an anchor the page no longer has —
// which is exactly how the `fr` build died on "broken anchors". Ids are never prose: skip them.
// Translated articles carry one on every heading (see scripts/lib/translate-anchors.mjs).
const EXPLICIT_HEADING_ID = /\{#[^}\s]*\}/g;
/** Applies `transform` to `value`, leaving every explicit `{#heading-id}` untouched. */
function outsideHeadingIds(value, transform) {
const parts = [];
let cursor = 0;
for (const match of value.matchAll(EXPLICIT_HEADING_ID)) {
parts.push(transform(value.slice(cursor, match.index)), match[0]);
cursor = match.index + match[0].length;
}
parts.push(transform(value.slice(cursor)));
return parts.join("");
}
function remarkReplaceWords() {
return (tree) => {
visit(tree, "text", (node, index, parent) => {
if (typeof node.value !== "string") return;
// Skip inside certain node types (URLs, code, etc.)
const forbiddenParents = ["link", "image", "inlineCode", "code"];
if (parent && forbiddenParents.includes(parent.type)) {
return;
}
node.value = outsideHeadingIds(node.value, (text) => {
let result = text;
for (const [regex, replacement] of replacements) {
result = result.replace(regex, (match, offset, fullString) => {
const before = fullString[offset - 1] || "";
const after = fullString[offset + match.length] || "";
// Skip compound words like "vscode-docker", "foo.markdown"
if (["-", ".", "/"].includes(before) || ["-", ".", "/"].includes(after)) {
return match;
}
return replacement;
});
}
return result;
});
});
};
}
module.exports = remarkReplaceWords;
```
## Ajouter le plugin dans votre configuration {#adding-the-plugin-in-your-configuration}
L'étape suivante consiste à enregistrer votre plugin dans la configuration de Docusaurus. Pour ce faire, éditez votre fichier `docusaurus.config.js` et ajoutez les lignes surlignées comme illustré ci-dessous.
**docusaurus.config.js**
```javascript
// highlight-next-line
import remarkReplaceWords from "./plugins/remark-replace-terms";
const config = {
// [ ... ]
presets: [
[
'classic',
({
// [ ... ]
blog: {
beforeDefaultRemarkPlugins: [
// [ ... ]
// highlight-next-line
remarkReplaceWords
],
},
// [ ... ]
}),
],
],
};
export default config;
```
Cela fait, redémarrez votre serveur Docusaurus et vous obtiendrez la même sortie console que celle montrée au début de cet article.
> **Caution:**
>
> La recherche/remplacement ne sera pas effectuée sur vos fichiers Markdown d'origine, mais uniquement lors du rendu HTML. Lancer ce plugin est donc sans danger ; vos fichiers ne seront pas impactés du tout.
---
# Un composant pour afficher des snippets de code dans un blog Docusaurus
> Apprenez à créer un composant React personnalisé pour intégrer des snippets de code dynamiques dans votre blog Docusaurus et offrir une expérience de lecture plus interactive.
---

**TL;DR**
Cet article vous guide dans la création d'un composant `` puissant pour Docusaurus, destiné à afficher des blocs de code. Ce composant personnalisé améliore le HTML natif en ajoutant des fonctionnalités comme des icônes propres au type de fichier et un style sur mesure. Le tutoriel montre comment construire le composant React, l'intégrer à une bibliothèque d'icônes, puis va plus loin avec la création d'un plugin remark. Ce plugin permet de charger dynamiquement du code depuis des fichiers sources directement dans vos articles, pour que vos snippets soient toujours à jour, sans copier/coller manuel. Vous apprendrez à gérer les chemins relatifs comme absolus et à enregistrer le plugin dans la configuration de votre site.
Si vous lisez ce blog régulièrement, vous savez que je partage beaucoup de snippets de code.
*Ce composant est devenu la base de deux autres : [ProjectSetup](/blog/docusaurus-project-setup), qui assemble plusieurs snippets en une arborescence de projet téléchargeable, et [les tooltips ELI5](/blog/docusaurus-eli5-snippet-tooltips), qui annotent les lignes délicates d'un snippet.*
La façon native de faire ça en HTML, c'est l'élément `` (voir la [documentation officielle](https://www.w3schools.com/tags/tag_summary.asp)).
Peut-on faire quelque chose, pas forcément mieux, mais plus esthétique ?
## L'élément summary {#the-summary-element}
En HTML pur, on peut utiliser l'élément DOM `` comme ceci :
```html
blog/index.md
Hello world! Proud to be here!!!
```
> **Information:**
>
> Ça fonctionne mais... peut-on faire mieux. Je pense par exemple à ajouter une icône selon le type de langage, distinguer aussi les couleurs par langage et, bien mieux encore, ne plus copier/coller le code dans l'article mais le lire directement depuis le disque.
Dans cet article, nous allons apprendre à créer un composant `Snippets` et obtenir ce look\&feel :
```html
Hello world! Proud to be here!!!
```
et voici le rendu :
**blog/index.md**
```markdown
Hello world! Proud to be here!!!
```
## Création de notre composant Snippets {#creation-of-our-snippets-component}
Créez le fichier `src/components/Snippet/index.tsx` avec ce contenu :
**src/components/Snippet/index.tsx**
```typescript
import React, {
useState,
useRef,
useEffect,
useCallback,
useMemo,
useId,
type CSSProperties,
type JSX,
type ReactNode,
} from "react";
import { createPortal } from "react-dom";
import Prism from "prismjs";
import { useTokenize } from "prism-react-renderer";
// Eli5CodeBlock (below) needs its own Prism grammars registered on this exact
// `prismjs` singleton — everything beyond markup/html/xml/css/clike/javascript
// (Prism's default bundle) only exists here as a side effect of requiring its
// component file. `prism-react-renderer` (peer-depends on "prismjs": "*", so
// it shares this same module instance) registers `additionalLanguages` the
// same way whenever a native `` renders — meaning `Prism.languages
// .bash` etc. was previously only defined here if some *other* CodeBlock had
// already rendered first in this process/session. Registering every grammar
// `Eli5CodeBlock` can receive (see remark-snippet-loader's `extensionToLang`
// map) unconditionally, here, makes that no longer order-dependent.
// markup/html/xml/css/javascript need no entry — already in Prism's default
// bundle (verified empirically).
import "prismjs/components/prism-jsx";
import "prismjs/components/prism-typescript";
import "prismjs/components/prism-python";
import "prismjs/components/prism-markup-templating"; // prism-php's own dependency
import "prismjs/components/prism-php";
import "prismjs/components/prism-bash";
import "prismjs/components/prism-json";
import "prismjs/components/prism-yaml";
import "prismjs/components/prism-markdown";
import "prismjs/components/prism-docker";
import "prismjs/components/prism-ini";
import "prismjs/components/prism-sql";
import CodeBlock from "@theme/CodeBlock";
import IconCopy from "@theme/Icon/Copy";
import IconSuccess from "@theme/Icon/Success";
import LogoIcon from "@site/src/components/Blog/LogoIcon";
import clsx from "clsx";
import { useVarResolver } from "@site/src/components/Vars/store";
import {
substitutePlainText,
substituteChildren,
} from "@site/src/components/Vars/substitute";
import VarToken from "@site/src/components/Vars/VarToken";
import styles from "./styles.module.css";
import Translate, { translate } from "@docusaurus/Translate";
// Extract language from inside children
const getLanguageFromChildren = (children: ReactNode): string | null => {
const findLang = (node: ReactNode): string | null => {
if (React.isValidElement(node)) {
const props = node.props as {
className?: unknown;
children?: ReactNode;
};
if (typeof props.className === "string") {
const match = props.className.match(/language-(\w+)/);
if (match) return match[1].toLowerCase();
}
if (props.children) {
const nested = props.children;
if (Array.isArray(nested)) {
for (const child of nested) {
const lang = findLang(child);
if (lang) return lang;
}
} else {
return findLang(nested);
}
}
}
return null;
};
return findLang(children);
};
// Maps code language to styling variant
const mapLangToVariant: Record = {
apacheconf: "apacheconf",
asm: "asm",
bas: "vb",
bash: "bash",
bashrc: "bash",
bat: "batch",
batch: "batch",
cjs: "js",
cmd: "batch",
css: "css",
csv: "csv",
diff: "diff",
docker: "docker",
gherkin: "gherkin",
html: "html",
ignore: "ignore",
ini: "ini",
java: "java",
javascript: "js",
js: "js",
jsx: "js",
json: "json",
log: "log",
makefile: "makefile",
markdown: "md",
markdownlint_ignore: "json",
md: "md",
mdx: "md",
mjs: "js",
none: "none",
pascal: "pascal",
php: "php",
plaintext: "txt",
powershell: "powershell",
ps1: "powershell",
py: "python",
python: "python",
qmd: "md",
sh: "bash",
sql: "sql",
svg: "svg",
toml: "toml",
ts: "ts",
tsx: "ts",
txt: "txt",
typescript: "ts",
vb: "vb",
vba: "vb",
vbnet: "vbnet",
vbs: "vb",
xml: "xml",
yaml: "yaml",
yml: "yaml",
zsh: "bash",
};
// Map variant keys to their icon components and CSS classes
// For retrieving the value for the iconify key, just go to "https://icon-sets.iconify.design/?query=python"
// and, in the top right search area, type the name of the language.
// When found, click on the desired icon and retrieve the "icon name" as displayed in the bottom part.
interface VariantIcon {
iconClassName: string;
iconify: string;
ariaLabel: string;
}
const variantIcons: Record = {
apacheconf: {
iconClassName: styles.apacheconf_icon,
iconify: "logos:apache",
ariaLabel: "ApacheConf Logo",
},
asm: {
iconClassName: styles.asm_icon,
iconify: "vscode-icons:file-type-assembly",
ariaLabel: "Asm Logo",
},
bash: {
iconClassName: styles.bash_icon,
iconify: "devicon:bash",
ariaLabel: "Bash Logo",
},
batch: {
iconClassName: styles.batch_icon,
iconify: "file-icons:ms-dos",
ariaLabel: "Batch Logo",
},
css: {
iconClassName: styles.css_icon,
iconify: "ph:file-css",
ariaLabel: "CSS Logo",
},
csv: {
iconClassName: styles.csv_icon,
iconify: "ph:file-csv",
ariaLabel: "CSV Logo",
},
diff: {
iconClassName: styles.diff_icon,
iconify: "ph:git-diff",
ariaLabel: "Diff Logo",
},
docker: {
iconClassName: styles.docker_icon,
iconify: "uil:docker",
ariaLabel: "Docker Logo",
},
docusaurus: {
iconClassName: styles.docker_docusaurus,
iconify: "logos:docusaurus",
ariaLabel: "Docusaurus Logo",
},
gherkin: {
iconClassName: styles.gherkin_icon,
iconify: "skill-icons:gherkin-light",
ariaLabel: "Gherkin Logo",
},
html: {
iconClassName: styles.html_icon,
iconify: "ph:file-html",
ariaLabel: "HTML Logo",
},
ignore: {
iconClassName: styles.ignore_icon,
iconify: "codicon:sync-ignored",
ariaLabel: "Ignore Logo",
},
ini: {
iconClassName: styles.ini_icon,
iconify: "ph:file-ini",
ariaLabel: "INI Logo",
},
java: {
iconClassName: styles.java_icon,
iconify: "vscode-icons:file-type-java",
ariaLabel: "Java Logo",
},
js: {
iconClassName: styles.js_icon,
iconify: "logos:javascript",
ariaLabel: "JS Logo",
},
json: {
iconClassName: styles.json_icon,
iconify: "ix:json-document",
ariaLabel: "JSON Logo",
},
log: {
iconClassName: styles.log_icon,
iconify: "ix:log",
ariaLabel: "Log Logo",
},
makefile: {
iconClassName: styles.makefile_icon,
iconify: "vscode-icons:file-type-makefile",
ariaLabel: "GNU Makefile Logo",
},
md: {
iconClassName: styles.md_icon,
iconify: "ph:markdown-logo",
ariaLabel: "Markdown Logo",
},
none: {
iconClassName: styles.none_icon,
iconify: "ph:empty",
ariaLabel: "None Logo",
},
pascal: {
iconClassName: styles.pascal_icon,
iconify: "file-icons:pascal",
ariaLabel: "Pascal Logo",
},
php: {
iconClassName: styles.php_icon,
iconify: "bi:filetype-php",
ariaLabel: "PHP Logo",
},
powershell: {
iconClassName: styles.powershell_icon,
iconify: "file-icons:powershell",
ariaLabel: "Powershell Logo",
},
python: {
iconClassName: styles.python_icon,
iconify: "devicon:python",
ariaLabel: "Python Logo",
},
sql: {
iconClassName: styles.sql_icon,
iconify: "ph:file-sql",
ariaLabel: "SQL Logo",
},
svg: {
iconClassName: styles.svg_icon,
iconify: "ph:file-svg",
ariaLabel: "SVG Logo",
},
toml: {
iconClassName: styles.toml_icon,
iconify: "tabler:toml",
ariaLabel: "Toml Logo",
},
ts: {
iconClassName: styles.ts_icon,
iconify: "logos:typescript-icon",
ariaLabel: "TypeScript Logo",
},
txt: {
iconClassName: styles.txt,
iconify: "ph:file-txt",
ariaLabel: "Txt Logo",
},
vb: {
iconClassName: styles.vb_icon,
iconify: "fluent:document-vb-16-regular",
ariaLabel: "VB Logo",
},
vbnet: {
iconClassName: styles.vbnet_icon,
iconify: "fluent:document-vb-16-regular",
ariaLabel: "VbNet Logo",
},
xml: {
iconClassName: styles.xml_icon,
iconify: "hugeicons:xml-01",
ariaLabel: "XML Logo",
},
yaml: {
iconClassName: styles.yaml_icon,
iconify: "devicon-plain:yaml",
ariaLabel: "YAML Logo",
},
};
// ELI5 line-by-line renderer — used when eli5json prop is present.
// Calls Prism.highlight() directly to get syntax-colored HTML per line,
// then overlays interactive ? badges for annotated lines.
// The tooltip is rendered via a React Portal into document.body so it
// escapes every overflow:hidden / stacking-context ancestor.
// C0 control characters other than tab/newline/carriage return, plus DEL. A few example files
// carry them for real — `make.bat` colours its output with ESC sequences — and a raw control
// character in the page is invalid HTML (the SSG minifier flags it on every build, in every
// locale). They are shown as their Unicode "control picture" (ESC -> ␛) instead: visible, honest
// about what the file contains, and valid markup.
const isControlChar = (code: number): boolean =>
(code <= 0x1f && code !== 0x09 && code !== 0x0a && code !== 0x0d) || code === 0x7f;
function hasControlChars(text: string): boolean {
for (let i = 0; i < text.length; i += 1) {
if (isControlChar(text.charCodeAt(i))) return true;
}
return false;
}
function showControlChars(text: string): string {
if (!hasControlChars(text)) return text;
let out = "";
for (let i = 0; i < text.length; i += 1) {
const code = text.charCodeAt(i);
// U+2400..U+241F picture each C0 control; U+2421 pictures DEL.
out += isControlChar(code)
? String.fromCharCode(code === 0x7f ? 0x2421 : 0x2400 + code)
: text[i];
}
return out;
}
interface Eli5CodeBlockProps {
code: string;
lang: string;
eli5: Record;
}
// Eli5CodeBlock renders its own
, bypassing Docusaurus's native
// (see below) and losing its built-in copy button in the
// process — reimplemented locally rather than pulling in
// `@docusaurus/theme-common/internal`'s CodeBlockContext, which TODO 0112
// already names as a suspect in an open React hydration bug.
function useCopyToClipboard() {
const [copied, setCopied] = useState(false);
const timeoutRef = useRef(undefined);
const copy = useCallback((text: string) => {
navigator.clipboard.writeText(text).then(() => {
window.clearTimeout(timeoutRef.current);
setCopied(true);
timeoutRef.current = window.setTimeout(() => setCopied(false), 1000);
});
}, []);
useEffect(() => () => window.clearTimeout(timeoutRef.current), []);
return { copied, copy };
}
function Eli5CodeBlock({ code, lang, eli5 }: Eli5CodeBlockProps): JSX.Element {
const [activeLine, setActiveLine] = useState(null);
const [tooltipStyle, setTooltipStyle] = useState(null);
const { copied, copy } = useCopyToClipboard();
// false until mounted client-side — gates the tooltip's document.body Portal,
// which doesn't exist during SSR.
const [mounted, setMounted] = useState(false);
const badgeRefs = useRef>({});
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- intentional mount flag, see comment on `mounted` above
setMounted(true);
}, []);
// `Prism.highlight()` + `.split("\n")` (the previous approach) corrupts any
// token that spans multiple lines — a `/** … */` JSDoc comment most
// commonly — because the highlighted HTML gets cut across an unclosed
// ``. Browsers silently repair that malformed HTML while parsing the
// page, but repair it *differently* than what React's virtual tree expects
// once it hydrates, which is a genuine structural mismatch (React error
// #418) on any snippet whose language has multi-line comments/strings —
// confirmed via a server-vs-hydrated-DOM diff on `docusaurus-cards.mdx`
// (see TODO 0112). `useTokenize` avoids the string-splitting step entirely:
// it walks Prism's real token tree and only ever hands back whole tokens,
// already split at line boundaries.
const grammar = useMemo(
() => Prism.languages[lang] || Prism.languages.plaintext || Prism.languages.clike,
[lang],
);
const tokenizedLines = useTokenize({ prism: Prism, code, grammar, language: lang });
// A trailing `\n` in `code` makes normalizeTokens() append one synthetic
// last "line" holding a single `{ content: "\n", empty: true }` token —
// drop it so line numbers keep matching `eli5`'s 1-based keys, exactly like
// the previous `rawLines.pop()` did for a trailing blank line.
const lines = useMemo(() => {
const last = tokenizedLines[tokenizedLines.length - 1];
if (last?.length === 1 && last[0].empty) {
return tokenizedLines.slice(0, -1);
}
return tokenizedLines;
}, [tokenizedLines]);
// Compute position: tooltip appears above the badge, right-aligned.
const positionTooltip = useCallback((lineNum: string) => {
const el = badgeRefs.current[lineNum];
if (!el) return;
const rect = el.getBoundingClientRect();
setTooltipStyle({
top: rect.top, // translateY(-100%-gap) shifts it above
right: window.innerWidth - rect.right, // right-align with the badge
});
}, []);
const handleBadgeClick = useCallback(
(lineNum: string) => setActiveLine((prev) => (prev === lineNum ? null : lineNum)),
[],
);
// Keep the tooltip glued to its badge: position is computed once on
// hover/focus, but `position: fixed` doesn't move with scrolling — without
// this, scrolling the page (or the horizontally-scrollable code block)
// while a tooltip is open leaves it stranded at its old screen position.
useEffect(() => {
if (!activeLine) return undefined;
const reposition = () => positionTooltip(activeLine);
window.addEventListener("scroll", reposition, { capture: true, passive: true });
window.addEventListener("resize", reposition, { passive: true });
return () => {
window.removeEventListener("scroll", reposition, { capture: true });
window.removeEventListener("resize", reposition);
};
}, [activeLine, positionTooltip]);
const activeExplanation = activeLine ? eli5[activeLine] : null;
return (
<>
{lineTokens.map((token, tokenIdx) => (
{/* Display only: the copy button still copies `code`, real bytes and all. */}
{showControlChars(token.content)}
))}
{explanation ? (
) : (
)}
);
})}
{mounted &&
activeExplanation &&
tooltipStyle &&
createPortal(
{activeExplanation}
,
document.body,
)}
>
);
}
// ELI5 verbose summary — a Show/Hide disclosure rendered below the code block,
// giving a narrative explanation of the whole snippet (as opposed to
// Eli5CodeBlock's per-line tooltips). Kept as its own component, deliberately
// outside Eli5CodeBlock's render tree — see TODO 0112 (open React hydration
// bug on Eli5CodeBlock/): this feature must not add surface area
// there. Also kept as a sibling of the collapsible `snippet_content` div
// (not nested inside it): nesting would let this block's own open/close
// change the *inner* scrollHeight without the outer collapsible's height
// (computed once per its own toggle) ever re-measuring, clipping the summary
// whenever a reader opens it after the snippet was already expanded.
interface Eli5SummaryBlockProps {
summary: string;
}
function Eli5SummaryBlock({ summary }: Eli5SummaryBlockProps): JSX.Element {
const [open, setOpen] = useState(false);
const contentRef = useRef(null);
const [height, setHeight] = useState("0px");
const contentId = `eli5-summary-${useId()}`;
useEffect(() => {
if (contentRef.current) {
setHeight(open ? `${contentRef.current.scrollHeight}px` : "0px");
}
}, [open, summary]);
return (
{summary}
);
}
interface Props {
filename?: string;
title?: ReactNode;
code?: string;
children?: ReactNode;
/**
* Force the open/closed state. Leave undefined and the component decides:
* open, unless the snippet is longer than COLLAPSE_OVER_LINES.
*
* Pass `false` explicitly on file-index sections (ProjectSetup articles,
* "All Files at a Glance"): there the page is a LIST of files the reader
* scans and opens one of, not a narrative the code belongs to.
*/
defaultOpen?: boolean;
variant?: string;
lang?: string;
eli5json?: string;
eli5summary?: string;
}
/**
* Above this many lines a snippet opens PARTIALLY: the first lines are shown
* under a fade, with a "show the remaining N lines" control. Below it, the
* snippet simply opens.
*
* 30 is roughly one screenful of code at 0.85rem/1.6 on a laptop — the point
* where "I can take this in at a glance" stops being true.
*/
const COLLAPSE_OVER_LINES = 30;
/** Height of the peek window, in lines. Deliberately less than
* COLLAPSE_OVER_LINES so a truncated block always LOOKS truncated. */
const PEEK_LINES = 18;
export default function Snippet({
filename,
title,
code,
children,
defaultOpen,
variant,
lang: pluginLang,
eli5json,
eli5summary,
}: Props): JSX.Element {
const contentRef = useRef(null);
// Parse eli5json string (injected by remark-snippet-loader) into an object
const eli5 = useMemo((): Record | null => {
if (!eli5json || typeof eli5json !== "string") return null;
try {
return JSON.parse(eli5json);
} catch {
return null;
}
}, [eli5json]);
const eli5Summary =
typeof eli5summary === "string" && eli5summary.trim() ? eli5summary.trim() : null;
const lineCount = useMemo(
() => (typeof code === "string" ? code.replace(/\n$/, "").split("\n").length : 0),
[code],
);
const isLong = lineCount > COLLAPSE_OVER_LINES;
/*
* The default is OPEN. A reader who clicked "Install Docker under WSL2" came
* for the compose file; making them click again to see it is asking them to
* work for the thing they searched for.
*
* Three concrete costs of the old closed-by-default, beyond the extra click:
* - find-in-page broke. The collapsed content sits in a max-height: 0 /
* overflow: hidden box, which Ctrl+F cannot reach: a reader searching the
* page for a variable name got "no results" while the word was right
* there.
* - printing produced empty boxes.
* - the ELI5 "Explain this snippet" control is gated on `open`, so the
* line-by-line explanations nobody had expanded were never discovered.
*
* Long files do not get the old all-or-nothing treatment either: they open
* truncated (see PEEK_LINES), so the reader sees WHAT it is immediately and
* the page is not eaten by 300 lines.
*/
const [open, setOpen] = useState(defaultOpen ?? true);
const [expandedLong, setExpandedLong] = useState(false);
const handleToggle = useCallback(() => setOpen((prev) => !prev), []);
const handleExpandLong = useCallback(() => setExpandedLong(true), []);
const contentId = `snippet-content-${useId()}`;
const lang = useMemo(() => {
// 1. Check for the `lang` prop provided by your plugin
if (pluginLang) return pluginLang;
// 2. Fallback to the existing logic for `children`
const languageFromChildren = getLanguageFromChildren(children);
if (languageFromChildren) return languageFromChildren;
// 3. Fallback to the filename extension if no other language is found
if (typeof filename === "string") {
const ext = filename.split(".").pop();
return mapLangToVariant[ext ?? ""] || "plaintext";
}
return "plaintext";
}, [pluginLang, children, filename]);
// Resolve `default` markers (TODO 0088) before rendering. `code`
// (from `source=`/`code=`, including files loaded at build time) is plain
// text feeding Prism, so substitution there is a string swap — no visible
// "this is yours" underline, which the `children` path below gets for
// free; an acceptable v1 trade-off, see Vars/readme.md.
const resolve = useVarResolver();
const resolvedCode = useMemo(
() => (code ? substitutePlainText(code, resolve) : code),
[code, resolve],
);
const resolvedChildren = useMemo(
() =>
substituteChildren(children, resolve, (name, value, key) => (
{value}
)),
[children, resolve],
);
// Use line-by-line ELI5 renderer when annotations are available and code is a string.
// Otherwise fall back to Docusaurus CodeBlock (or raw children).
//
// Code holding control characters also takes the ELI5 renderer, with no annotations: it is the
// only one that can show them escaped while its copy button keeps the original bytes. The
// native would either print them raw or copy the escaped version.
const codeBlock = useMemo(() => {
const hasEli5 = Boolean(eli5 && Object.keys(eli5).length > 0);
if (resolvedCode && (hasEli5 || hasControlChars(resolvedCode))) {
return ;
}
if (resolvedCode) {
return {resolvedCode};
}
return resolvedChildren;
}, [resolvedCode, eli5, lang, resolvedChildren]);
const baseName = useMemo(
() =>
typeof filename === "string"
? (filename.split("/").pop() ?? "").toLowerCase()
: null,
[filename],
);
const isDockerFile = useMemo(() => {
if (!baseName) return false;
return (
baseName === "dockerfile" ||
baseName === "docker" ||
baseName.endsWith(".docker") ||
baseName.endsWith(".dockerfile") ||
baseName.endsWith(".dockerignore") ||
baseName.endsWith("compose.yaml") ||
baseName.endsWith("compose.yml") ||
baseName.endsWith("docker-compose.yaml")
);
}, [baseName]);
const isDocusaurus = useMemo(() => baseName === "docusaurus.config.js", [baseName]);
const variantKey = useMemo(
() =>
variant ||
(isDockerFile ? "docker" : isDocusaurus ? "docusaurus" : mapLangToVariant[lang]) ||
"none",
[variant, isDockerFile, isDocusaurus, lang],
);
const variantClass = styles[`variant_${variantKey}`] || "";
const IconInfo = variantIcons[variantKey] || variantIcons.none;
const { iconify, ariaLabel } = IconInfo;
const displayTitle =
title ||
filename ||
(lang
? lang.toUpperCase()
: translate({ id: "snippet.defaultTitle", message: "Snippet" }));
// Peek mode: long, open, and the reader has not asked for the rest yet.
const peeking = open && isLong && !expandedLong;
return (
{/*
Conditional render, not a max-height animation on a permanently mounted
div. With the old approach the collapsed code stayed in the DOM inside
an overflow: hidden box — invisible to Ctrl+F but still found by a
screen reader, which is the worst of both. Unmounting is honest: closed
means closed.
*/}
{open && (
{codeBlock}
{peeking && (
)}
)}
{open && eli5Summary && }
);
}
```
Créez aussi la feuille de style *(icône CSS et bordure bleue ici)* :
**src/components/Snippet/styles.module.css**
```css
/*
* Snippet.
*
* The language brand colours were NOT regenerated: the 33 values below are
* exactly the ones already in this file. What changed is how far each one
* reaches. Each variant now declares a single --lang-color, consumed by three
* things at once:
*
* 1. the 3px rule under the toolbar,
* 2. the language logo — but only the MONOCHROME ones. Iconify sets
* ph:*, bi:*, ix:*, file-icons:* etc. render in currentColor, which is
* why they used to come out black; logos:*, devicon:* and
* vscode-icons:* ship their own brand colours and ignore it. Both end
* up correct. Practical consequence: if a logo ever renders grey, it
* is a monochrome icon whose .variant_ class is missing.
* 3. the focus ring of the disclosure button.
*
* So the logo stays, in its real brand colour, and is the only saturated thing
* in an otherwise sand-coloured toolbar. Languages present in
* mapLangToVariant but with no .variant_ class fall back to the neutral sand
* defined on [class^="variant_"] — nothing to add per language.
*/
/*
* IMPORTANT — companion change in index.tsx:
*
* -
* +
*
* The root element was carrying Infima's "alert alert--info" utility classes,
* which paint the whole card in Docusaurus blue. Same issue as TLDR and
* QuickJump. It was invisible on snippets WITH ELI5 annotations, because
* .eli5_pre paints its own background over it; it showed on every other
* snippet, where the native is transparent and let the blue
* through.
*
* The .snippet_block rule below is defensive against that class coming back
* (Infima sets background/color/border on .alert with plain specificity).
*/
.snippet_block {
composes: mk-surface from global;
margin: 1.5rem 0;
padding: 0;
overflow: hidden;
background: var(--mk-surface);
color: var(--mk-ink);
}
/*
* ── One surface per rendering path ────────────────────────────────────────
*
* A Snippet renders through one of THREE paths, and they had three different
* looks:
* 1. Eli5CodeBlock — its own
, warm dark
* 2. native — Prism's light theme
* 3. raw children — rendered MDX prose, no
at all, no padding
*
* Paths 1 and 2 are code: one dark surface, owned by the
. Path 3 is
* prose (a .md snippet renders as text, not as source) and had no padding at
* all. All three now share ONE dark surface, owned by .snippet_inner: keeping
* the prose path light was tried and rejected — a .md snippet sitting white
* between two dark ones read as a different component.
*/
.snippet_inner {
padding: 0.875rem 1.125rem;
font-family: var(--ifm-font-family-monospace);
font-size: 0.85rem;
line-height: 1.6;
color: var(--mk-code-fg);
background: var(--mk-code-bg);
}
.snippet_inner > :last-child {
margin-bottom: 0;
}
/* :has() — when the snippet holds a real code block, the
brings its own
* padding, so the wrapper gives that up. The surface stays here either way. */
.snippet_inner:has(pre) {
padding: 0;
}
/* Every engine's output goes transparent; the dark ground above shows through,
* so all three paths land on exactly one colour. */
.snippet_inner :global(.theme-code-block),
.snippet_inner :global(div[class*="codeBlockContainer"]),
.snippet_inner :global(div[class*="codeBlockContent"]),
.snippet_inner :global(pre[class*="codeBlock"]),
.snippet_inner :global(.prism-code),
.snippet_inner pre,
.snippet_inner pre code {
margin: 0;
background: transparent !important;
border-radius: 0;
box-shadow: none;
}
/* Prism's light theme also sets a dark text colour — unreadable now. */
.snippet_inner :global(.prism-code),
.snippet_inner pre code {
color: var(--mk-code-fg);
}
/* Docusaurus's own copy / word-wrap buttons, now on a dark ground. */
.snippet_inner :global(div[class*="buttonGroup"]) button {
color: var(--mk-n-500);
background: rgb(255, 255, 255, 0.06);
border-color: rgb(255, 255, 255, 0.15);
}
.snippet_inner :global(div[class*="buttonGroup"]) button:hover {
color: var(--mk-code-fg);
background: rgb(255, 255, 255, 0.12);
}
/* The file header is now the shared toolbar — byte-identical to the one
* ProjectSetup uses, so the two components read as one family. */
.snippet_summary {
composes: mk-toolbar from global;
width: 100%;
cursor: pointer;
border: none;
border-bottom: 1px solid var(--mk-border);
--chevron-rotation: 0deg;
}
.snippet_summary:hover {
background: var(--mk-n-100);
}
.snippet_summary[aria-expanded="true"] {
--chevron-rotation: 180deg;
}
.snippet_summary:focus-visible {
outline: 2px solid var(--lang-color, var(--mk-primary));
outline-offset: -2px;
}
/* The filename: an eyebrow, like every other component label. It used to be
* 1rem bold — the weight of a small heading.
*
* .filename_wrapper is the class index.tsx actually uses today; .snippet_filename
* is the name used in the migration notes. Both are defined so the stylesheet
* works whether or not the markup is renamed. */
.snippet_filename,
.filename_wrapper {
composes: mk-eyebrow from global;
display: flex;
gap: 0.6rem;
align-items: center;
min-width: 0;
overflow: hidden;
text-align: left;
text-overflow: ellipsis;
white-space: nowrap;
}
.chevron {
margin-left: auto;
color: var(--mk-n-500);
transition: transform 0.3s ease;
transform: rotate(var(--chevron-rotation));
}
.chevron.rotate {
transform: rotate(180deg);
}
.snippet_content {
overflow: hidden;
transition: max-height 0.35s ease;
}
/*
* Peek mode: a long snippet opens truncated rather than closed.
*
* The height is expressed in LINES, not pixels: --peek-lines is set inline by
* index.tsx and multiplied by the code block's own line-height here, so the
* window always cuts between two lines instead of through the middle of one.
*/
.snippet_peek {
position: relative;
overflow: hidden;
max-height: calc(var(--peek-lines, 18) * 1.6 * 0.85rem + 1.75rem);
}
/* The fade tells the reader the block continues. It sits above the content but
below the button, and must not intercept text selection. */
.snippet_peek::after {
content: "";
position: absolute;
right: 0;
bottom: 0;
left: 0;
height: 5.5rem;
pointer-events: none;
background: linear-gradient(to bottom, transparent, var(--mk-code-bg) 85%);
}
.snippet_expand {
position: absolute;
right: 0;
bottom: 0;
left: 0;
z-index: 1;
padding: 0.55rem 1rem;
font-family: var(--ifm-font-family-base);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--mk-code-fg);
cursor: pointer;
background: none;
border: none;
}
.snippet_expand:hover {
color: var(--mk-term-prompt);
}
.snippet_expand:focus-visible {
outline: 2px solid var(--mk-term-prompt);
outline-offset: -2px;
}
/* A reader who asked for less motion gets the whole file rather than a
height that would otherwise animate open. */
@media (prefers-reduced-motion: reduce) {
.snippet_content {
transition: none;
}
}
/*
* PRINT — the reason default-open matters most. A printed article must carry
* its code: no truncation window, no fade, no control.
*/
@media print {
.snippet_peek {
max-height: none;
}
.snippet_peek::after,
.snippet_expand,
.chevron {
display: none;
}
}
.snippet_toolbar {
display: flex;
justify-content: flex-end;
padding: 0.5rem 1rem 0;
}
/* === The language logo ================================================== */
/*
* 20px, always left, always in its brand colour. The previous rule sized every
* [class$="_icon"] at 18px with a 0.5rem right margin; spacing now comes from
* the toolbar's gap, which survives markup edits.
*/
.snippet_logo,
[class$="_icon"] {
width: 20px;
height: 20px;
flex-shrink: 0;
color: var(--lang-color, var(--mk-n-500));
}
/*
* Light brand colours (JavaScript #f0d91e, Docusaurus #3ecc5f, Batch #8bad2e)
* float on the sand toolbar. A 12%-black hairline anchors them without
* repainting the mark — the only concession the logos make to the theme.
*/
.variant_js .snippet_logo,
.variant_docusaurus .snippet_logo,
.variant_batch .snippet_logo {
filter: drop-shadow(0 0 0.5px rgb(0, 0, 0, 0.45));
}
/* === Variant base + per-language brand colours =========================== */
/*
* Border colours map to each language/tool's own brand identity (Docker blue,
* PHP purple, GitHub's linguist palette…) — external identities, not site UI,
* so they intentionally don't follow the light/dark theme. Legitimate
* color-no-hex exception, see AGENTS.md and
* .todos/039-hardcoded-hex-colors-no-token-system.md.
*
* Values unchanged from the previous version of this file. The old rule also
* set `color: #fff` on every variant, which was dead weight: no text in these
* blocks sits on the brand colour.
*/
/* stylelint-disable color-no-hex */
[class^="variant_"] {
--lang-color: var(--mk-n-500);
border-top: 3px solid var(--lang-color);
}
.variant_apacheconf {
--lang-color: #d22128;
}
.variant_asm {
--lang-color: #6e4c13;
}
.variant_bash {
--lang-color: #3eaaaf;
}
.variant_batch {
--lang-color: #8bad2e;
}
.variant_csv {
--lang-color: #2d7f5e;
}
.variant_css {
--lang-color: #1e4fb4;
}
.variant_diff {
--lang-color: #e36209;
}
.variant_docker {
--lang-color: #0aa2ce;
}
.variant_docusaurus {
--lang-color: #3ecc5f;
}
.variant_gherkin {
--lang-color: #5a9a00;
}
.variant_html {
--lang-color: #e44d26;
}
.variant_ignore {
--lang-color: #6a737d;
}
.variant_ini {
--lang-color: #008080;
}
.variant_java {
--lang-color: #e11e2b;
}
.variant_js {
--lang-color: #f0d91e;
}
.variant_json {
--lang-color: #ad8b00;
}
.variant_log {
--lang-color: #808080;
}
.variant_makefile {
--lang-color: #6e4f1e;
}
.variant_md {
--lang-color: #083fa1;
}
.variant_none {
--lang-color: #aaaaaa;
}
.variant_pascal {
--lang-color: #4278b8;
}
.variant_php {
--lang-color: #7a87b9;
}
.variant_powershell {
--lang-color: #0078d7;
}
.variant_python {
--lang-color: #2c628d;
}
.variant_sql {
--lang-color: #e38c00;
}
.variant_svg {
--lang-color: #f04e23;
}
.variant_toml {
--lang-color: #f36d25;
}
.variant_ts {
--lang-color: #3178c6;
}
.variant_txt {
--lang-color: #6a737d;
}
.variant_vb {
--lang-color: #571a66;
}
.variant_vbnet {
--lang-color: #68217a;
}
.variant_xml {
--lang-color: #bf3b1b;
}
.variant_yaml {
--lang-color: #a31416;
}
/* stylelint-enable color-no-hex */
/* ═══════════════════════════════════════════════════════════════════════════
ELI5 — line-by-line renderer
Still deliberately dark-optimised regardless of site theme (it sits on a
Prism background), but now on the palette's own warm black and with the
badge/tooltip using tokens instead of six rgba() literals.
═══════════════════════════════════════════════════════════════════════════ */
.eli5_pre {
position: relative;
margin: 0;
padding: 0.75rem 0;
overflow-x: auto;
font-family: var(--ifm-font-family-monospace);
font-size: var(--ifm-code-font-size, 0.875rem);
line-height: 1.5;
color: var(--mk-code-fg);
background: var(--mk-code-bg);
border-radius: 0 0 var(--mk-radius-md) var(--mk-radius-md);
}
.eli5_line {
display: flex;
align-items: flex-start;
min-height: 1.5em;
padding: 0 1rem;
}
.eli5_line:hover {
background: rgb(255, 255, 255, 0.04);
}
.eli5_code {
flex: 1;
overflow: hidden;
white-space: pre;
}
.eli5_badge_placeholder {
display: inline-block;
width: 1.2rem;
flex-shrink: 0;
margin-left: 0.5rem;
}
.eli5_badge_wrapper {
position: relative;
display: inline-flex;
flex-shrink: 0;
align-items: center;
margin-left: 0.5rem;
}
/* The "?" badge was indigo-tinted — the one cool accent left in the site.
* It now uses the savanna green that the Terminal prompt uses. */
.eli5_badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.2rem;
height: 1.2rem;
padding: 0;
font-size: 0.6rem;
font-weight: 700;
line-height: 1;
color: var(--mk-term-prompt);
cursor: pointer;
background: rgb(147, 183, 100, 0.14);
border: 1px solid rgb(147, 183, 100, 0.45);
border-radius: 50%;
flex-shrink: 0;
transition:
background 0.15s ease,
color 0.15s ease,
border-color 0.15s ease;
}
.eli5_badge:hover,
.eli5_badge:focus-visible,
.eli5_badge_active {
color: var(--mk-code-bg);
background: var(--mk-term-prompt);
border-color: var(--mk-term-prompt);
outline: none;
}
.eli5_copy_button {
position: absolute;
top: 0.6rem;
right: 1rem;
display: flex;
align-items: center;
justify-content: center;
width: 1.9rem;
height: 1.9rem;
padding: 0.35rem;
color: var(--mk-n-500);
cursor: pointer;
background: rgb(255, 255, 255, 0.06);
border: 1px solid rgb(255, 255, 255, 0.15);
border-radius: var(--mk-radius-sm);
opacity: 0;
transition:
opacity 0.15s ease-in-out,
background 0.15s ease-in-out,
color 0.15s ease-in-out;
}
.eli5_copy_button:hover,
.eli5_copy_button:focus-visible {
color: var(--mk-code-fg);
background: rgb(255, 255, 255, 0.12);
opacity: 1;
outline: none;
}
/* :where() zeroes specificity so this never outranks the button's own hover
* state above — see the original note, the reasoning is unchanged. */
/* stylelint-disable-next-line no-descending-specificity */
:where(.eli5_pre:hover) .eli5_copy_button {
opacity: 0.55;
}
.eli5_copy_icon {
width: 1.1rem;
height: 1.1rem;
}
.eli5_tooltip {
position: fixed;
z-index: 9999;
width: 270px;
max-width: min(270px, 90vw);
padding: 0.5rem 0.75rem;
font-family: var(--ifm-font-family-base);
font-size: 0.8rem;
line-height: 1.55;
color: var(--mk-ink);
white-space: normal;
pointer-events: none;
background: var(--mk-surface);
border: 1px solid var(--mk-border);
border-radius: var(--mk-radius-md);
box-shadow: var(--mk-shadow-3);
transform: translateY(calc(-100% - 8px));
}
.eli5_tooltip::before {
position: absolute;
bottom: -5px;
right: 4px;
width: 8px;
height: 8px;
content: "";
background: var(--mk-surface);
border-right: 1px solid var(--mk-border);
border-bottom: 1px solid var(--mk-border);
transform: rotate(45deg);
}
.eli5_summary_block {
border-top: 1px solid var(--mk-border);
}
.eli5_summary_toggle {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 0.4rem 1rem;
font-size: 0.8rem;
font-weight: 500;
color: var(--mk-ink-soft);
cursor: pointer;
background: none;
border: none;
}
.eli5_summary_toggle_right {
display: inline-flex;
gap: 0.5rem;
align-items: center;
}
.eli5_summary_badge {
composes: mk-eyebrow from global;
font-size: 0.65rem;
font-weight: 500;
}
.eli5_summary_toggle:hover {
background: var(--ifm-hover-overlay);
}
.eli5_summary_toggle:focus-visible {
outline: 2px solid var(--mk-primary);
outline-offset: -2px;
}
.eli5_summary_content {
overflow: hidden;
transition: max-height 0.3s ease;
}
.eli5_summary_text {
margin: 0;
padding: 0 1rem 0.75rem;
font-size: 0.85rem;
line-height: 1.6;
color: var(--mk-ink);
}
```
Dernière chose à faire : apprendre à Docusaurus l'existence de notre composant personnalisé.
Éditez également le fichier `src/theme/MDXComponents.js` (et s'il n'existe pas, créez-le)
**src/theme/MDXComponents.js**
```javascript
import MDXComponents from "@theme-original/MDXComponents";
import Snippets from "@site/src/components/Snippets";
export default {
// Reusing the default mapping
...MDXComponents,
Snippets,
};
```
### LogoIcon de Docux {#logoicon-of-docux}
Vous aurez aussi besoin du composant [LogoIcon](https://docuxlab.com/blog/logoicon-component-docusaurus/) créé par  [Docux](https://github.com/Juniors017).
Ce composant permet de récupérer facilement une icône SVG pour un langage (disons Python) et de l'afficher. En coulisses, LogoIcon utilise [Iconify](https://icon-sets.iconify.design/).
En très court :
1. Lancez `yarn add @iconify/react` pour installer la bibliothèque iconify
2. Créez ce fichier `src/components/Blog/LogoIcon/index.tsx`
**src/components/Blog/LogoIcon/index.tsx**
```typescript
import type { JSX } from "react";
import { Icon, addCollection } from "@iconify/react";
import { iconCollections } from "./iconBundle.generated";
// Register the bundled icon data once, at module load time, so it is already available
// (synchronously, on both server and client) before the first ever renders. This
// avoids the React hydration mismatch (#418) caused by @iconify/react's default behaviour
// of fetching icon data from its API asynchronously: SSR would render an empty placeholder
// while the client, once the fetch resolved, rendered the actual SVG.
Object.values(iconCollections).forEach((collection) => addCollection(collection));
interface Props {
name: string;
size?: number | string;
className?: string;
"aria-label"?: string;
}
export default function LogoIcon({
name,
size = 48,
className,
"aria-label": ariaLabel,
}: Props): JSX.Element {
return (
);
}
```
## Utiliser le composant Snippets {#using-the-snippets-component}
Maintenant, si vous voulez ajouter un snippet dans votre blog, faites simplement ceci :
```html
name = input("What's your name? ") if name: print(f"Hello, {name}!") else:
print("Hello, stranger!") print("Nice to meet you.")
```
Et voici le rendu :
**who\_are\_you.py**
```python
name = input("What's your name? ")
if name:
print(f"Hello, {name}!")
else:
print("Hello, stranger!")
print("Nice to meet you.")
```
## Ne plus copier/coller de contenu {#dont-copypaste-content-anymore}
Faisons beaucoup, beaucoup mieux : ne plus copier/coller le code dans l'article mais le lire depuis le disque.
Imaginons ceci :
``
* `filename` est donc le titre à afficher dans l'article, pour que le lecteur sache comment le fichier doit être nommé
* `source` est le chemin relatif (depuis le dossier racine de Docusaurus) où le fichier peut être récupéré. Dans ce scénario, on n'a pas à mettre le code source dans le fichier, Docusaurus le fera pour nous :
* En prévisualisation du site (mode dev), un plugin lira le contenu directement depuis le disque et injectera son contenu. Ainsi, si le fichier source est modifié, votre article sera toujours à jour
* Lors du build de la version statique (mode prod), le moteur de build de Docusaurus lira aussi le contenu du fichier depuis le disque et l'injectera dans votre article.
> **Caution:**
>
> Vous avez deux types de chemins : depuis votre dossier racine ou relatif à l'article de blog.
>
> Si vous utilisez la syntaxe `source="./files/example.js"` (le chemin commence par un point), le fichier sera relatif à votre article de blog.
>
> La seconde syntaxe possible est sans le point, comme `source="src/components/Snippet/index.tsx"` et, dans ce cas, le nom de fichier sera relatif au dossier racine de Docusaurus, c'est-à-dire le dossier où se trouve le fichier `docusaurus.config.js`.
### Pour que ça fonctionne, il nous faut un plugin {#to-make-this-work-we-need-a-plugin}
Dans le vocabulaire Docusaurus, il nous faut un plugin pour faire ça, c'est-à-dire lire le contenu du fichier depuis le disque et l'« injecter » dans l'article.
Le plugin va donc parcourir l'article, chercher chaque appel à `` et vérifier s'il y a un attribut `src=`. Si oui, le plugin ouvrira le fichier et lira son contenu. Le plugin extraira aussi l'extension du fichier (comme `.js`). Avec ces deux informations (contenu et extension), le plugin injectera le contenu dans la balise ``, exactement comme si vous, l'auteur de l'article, aviez fait un copier/coller.
L'avantage ultime ici : votre article sera toujours à jour ; si vous modifiez le fichier sur le disque, votre article récupérera automatiquement la dernière version.
Créez le fichier `plugins/remark-snippet-loader/index.cjs` avec le contenu ci-dessous :
**plugins/remark-snippet-loader/index.cjs**
```cjs
const fs = require("fs");
const path = require("path");
const { visit } = require("unist-util-visit");
const extensionToLang = {
js: "javascript",
jsx: "jsx",
ts: "typescript",
tsx: "typescript",
py: "python",
php: "php",
sh: "bash",
bash: "bash",
// Prism has no "zsh" grammar: without these, a .zsh/.zshrc/.bashrc snippet
// gets language-zsh / language-.zshrc and renders with zero highlighting.
zsh: "bash",
".zshrc": "bash",
".bashrc": "bash",
css: "css",
html: "html",
json: "json",
yml: "yaml",
yaml: "yaml",
md: "markdown",
dockerfile: "docker",
ini: "ini",
sql: "sql",
xml: "xml",
// Add more as needed
};
// Resolves a `source="…"` attribute the same way for every consumer: a
// `./`/`../`-prefixed path is relative to the file that carries the
// attribute, anything else is relative to the project root. Exported so the
// Markdown-export plugin (plugins/markdown-export-plugin/degrade.cjs) can
// inline the very same files without re-deriving this rule.
function resolveSourcePath(sourcePath, currentFileDir, projectRoot = process.cwd()) {
if (sourcePath.startsWith("./") || sourcePath.startsWith("../")) {
return path.resolve(currentFileDir, sourcePath);
}
return path.resolve(projectRoot, sourcePath);
}
// i18n//docusaurus-plugin-content-/… — the locale of the file being compiled.
// It has to come from the file's own path and not from the `source` attribute, because
// remark-i18n-assets runs FIRST and has already rewritten that attribute to point back at the
// English article's folder. The vfile path is the only thing left that still says "fr".
const I18N_LOCALE_RE =
/(^|[/\\])i18n[/\\]([^/\\]+)[/\\]docusaurus-plugin-content-(?:blog|pages)(?:[/\\]|$)/;
/** The locale a file is compiled for, or null for the English sources. */
function localeOf(filePath) {
const match = String(filePath ?? "").match(I18N_LOCALE_RE);
return match ? match[2] : null;
}
/**
* The ELI5 sidecar to read for this locale, preferring the localized one.
*
* Falling back to the English sidecar is deliberate (TODO 0121): a reader of a technical blog
* reads English, and showing nothing would cost them information for the sake of visual
* uniformity. The fallback only fires on a gap — a failed generation, or a snippet added to the
* article after it was translated.
*/
function eli5PathFor(absolutePath, locale) {
if (locale) {
const localized = `${absolutePath}.eli5.${locale}.json`;
if (fs.existsSync(localized)) return localized;
}
const english = `${absolutePath}.eli5.json`;
return fs.existsSync(english) ? english : null;
}
function snippetLoader() {
return (tree, vfile) => {
// The absolute path of the currently processed .mdx/blog/doc file
const blogPostPath = vfile.path;
// Which locale this compilation is for — drives ELI5 sidecar selection below.
const locale = localeOf(blogPostPath);
// The directory of the .mdx/blog/doc file
const currentFileDir = path.dirname(blogPostPath);
// The Docusaurus project root (where docusaurus.config.js is located)
const projectRoot = process.cwd();
visit(tree, "mdxJsxFlowElement", (node) => {
if (node.name !== "Snippet" && node.name !== "Terminal") return;
const sourceAttr = node.attributes.find((attr) => attr.name === "source");
if (!sourceAttr || typeof sourceAttr.value !== "string") return;
const sourcePath = sourceAttr.value;
const absolutePath = resolveSourcePath(sourcePath, currentFileDir, projectRoot);
// Terminal: inject the file content as a text child of the node.
// This reproduces exactly what ...inline content... does,
// avoiding any issues with JSX attribute serialisation of multi-line strings.
if (node.name === "Terminal") {
// Intentionally not caught: a missing/unreadable source must fail the
// build loudly, not degrade to a placeholder string that gets published.
// See .todos/0106-migration-composants-js-vers-typescript.md — this is
// exactly what silently broke 4 articles during the TS migration.
let rawContent;
try {
rawContent = fs.readFileSync(absolutePath, "utf-8");
} catch (err) {
throw new Error(
` in ${blogPostPath} could not read ${absolutePath}: ${err.message}`,
{ cause: err },
);
}
// Replace self-closing node with one that has a single child holding the
// file content. It must be an *expression* child, not a text child: MDX
// applies JSX whitespace rules to text children, which trims the leading
// spaces of every line and destroys the indentation of the terminal
// output. An expression carrying a plain string literal is emitted
// verbatim, so indentation survives.
node.children = [
{
type: "mdxFlowExpression",
value: JSON.stringify(rawContent),
data: {
estree: {
type: "Program",
sourceType: "module",
comments: [],
body: [
{
type: "ExpressionStatement",
expression: { type: "Literal", value: rawContent },
},
],
},
},
},
];
return;
}
// Snippet: language detection, code injection, eli5.
const filenameAttr = node.attributes.find((attr) => attr.name === "filename");
// Reading the source itself is not caught: a missing/unreadable file must
// fail the build loudly, not degrade to a "// Error loading..." comment
// that gets published as if it were real code. See
// .todos/0106-migration-composants-js-vers-typescript.md — this is
// exactly what silently broke 4 articles during the TS migration.
let code;
try {
code = fs.readFileSync(absolutePath, "utf-8");
} catch (err) {
throw new Error(
` in ${blogPostPath} could not read ${absolutePath}: ${err.message}`,
{ cause: err },
);
}
const pathForLang =
filenameAttr && typeof filenameAttr.value === "string"
? filenameAttr.value
: sourcePath;
// Determine extension for language detection
const ext =
path.extname(pathForLang).slice(1).toLowerCase() ||
path.basename(pathForLang).toLowerCase();
let lang = extensionToLang[ext];
if (!lang) {
// Try to detect language based on the base file name (useful for Dockerfile, compose.yaml)
const baseName = path.basename(pathForLang).toLowerCase();
if (baseName === "dockerfile") {
lang = "docker";
} else if (baseName.includes("compose.yaml") || baseName.includes(".yml")) {
lang = "yaml";
} else {
lang = ext; // Use the extension or base name as fallback
}
}
node.attributes.push({
type: "mdxJsxAttribute",
name: "code",
value: code,
});
node.attributes.push({
type: "mdxJsxAttribute",
name: "lang",
value: lang,
});
// Auto-inject ELI5 explanations if a .eli5.json file exists alongside the
// source. This one stays a soft-fail: a missing/broken ELI5 cache is a content
// nicety, not the code sample itself — losing it shouldn't fail the build.
const eli5Path = eli5PathFor(absolutePath, locale);
if (eli5Path) {
try {
const eli5Raw = fs.readFileSync(eli5Path, "utf-8");
const eli5Data = JSON.parse(eli5Raw);
if (eli5Data.explanations && typeof eli5Data.explanations === "object") {
node.attributes.push({
type: "mdxJsxAttribute",
name: "eli5json",
value: JSON.stringify(eli5Data.explanations),
});
}
if (typeof eli5Data.summary === "string" && eli5Data.summary.trim()) {
node.attributes.push({
type: "mdxJsxAttribute",
name: "eli5summary",
value: eli5Data.summary.trim(),
});
}
} catch (e) {
console.warn(
`Snippet plugin: could not parse ELI5 file ${eli5Path}:`,
e.message,
);
}
}
});
};
}
// Attached (not a named export) so the default-import used by
// docusaurus.config.js (`beforeDefaultRemarkPlugins: [remarkSnippetLoader, …]`)
// keeps seeing a plain callable plugin factory.
snippetLoader.resolveSourcePath = resolveSourcePath;
module.exports = snippetLoader;
```
Éditez aussi votre fichier `docusaurus.config.js` comme ceci :
**docusaurus.config.js**
```javascript
// highlight-next-line
import remarkSnippetLoader from "./plugins/remark-snippet-loader/index.cjs";
const config = {
// [ ... ]
presets: [
[
"classic",
{
// [ ... ]
blog: {
beforeDefaultRemarkPlugins: [
// [ ... ]
// highlight-next-line
remarkSnippetLoader,
],
},
// [ ... ]
},
],
],
};
export default config;
```
Cela fait, redémarrez votre serveur Docusaurus et au prochain démarrage, si des changements doivent être faits, vous les verrez dans votre console :
## Démo, contenu en dur {#demo-hardcoded-content}
Dans cette section, j'ai copié/collé la source dans la balise ``. Utile quand je n'ai pas le fichier sur mon disque.
**sample.apacheconf**
```apacheconf
ServerName www.example.com
DocumentRoot /www/domain
```
**sample.asm**
```asm
section .data
msg db 'Hello, world!', 0Ah
section .text
global _start
_start:
mov edx, 13
```
**script.sh**
```bash
#!/bin/bash
echo "Hello, world!"
```
**script.bat**
```bat
@echo off
echo Hello, world!
```
**styles.css**
```css
body {
background-color: #f0f0f0;
}
```
**data.csv**
```csv
name,age
Alice,30
Bob,25
```
**change.diff**
```diff
- old line
+ new line
```
**Dockerfile**
```docker
FROM node:18
WORKDIR /app
COPY . .
RUN npm install
CMD ["node", "index.js"]
```
**login.feature**
```feature
Feature: User login
Scenario: Valid login
Given the user is on the login page
When the user enters valid credentials
Then they are redirected to the dashboard
```
**hello.html**
```html
Hello, world!
```
**.gitignore**
```
node_modules/
dist/
.env
```
**config.ini**
```ini
[server]
port=8080
host=localhost
```
**HelloWorld.java**
```java
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello, world!");
}
}
```
**script.js**
```javascript
console.log("Hello, world!");
```
**data.json**
```json
{
"name": "example",
"version": "1.0.0"
}
```
**application.log**
```log
[INFO] Server started at 10:00
[ERROR] Connection failed
```
**makefile**
```
build:
\tgcc main.c -o app
```
**readme.md**
```markdown
# Hello World
This is a markdown example.
```
**hello\_world.pas**
```pas
program HelloWorld;
begin
writeln('Hello, world!');
end.
```
**HelloWorld.php**
```php
```
**Hello\_World.ps1**
```ps1
Write-Output "Hello, world!"
```
**hello\_world.py**
```python
print("Hello, world!")
```
**active.sql**
```sql
SELECT * FROM users WHERE active = 1;
```
**logo.svg**
```svg
```
**pyproject.toml**
```toml
[package]
name = "example"
version = "0.1.0"
```
**HelloWorld.vb**
```vb
Sub Main()
Console.WriteLine("Hello, world!")
End Sub
End Module
```
**message.xml**
```xml
Hello, world!
```
**config.yaml**
```yaml
name: Example
version: 1.0.0
```
## Démo, récupérer le contenu depuis le disque {#demo-get-content-from-the-disk}
Contrairement au chapitre précédent, ici la syntaxe `` a été utilisée. Le code source est donc injecté dynamiquement pendant la prévisualisation/le rendu du blog. Si le fichier source est modifié, l'article le sera aussi. Les deux sont synchronisés.
**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 ``, which calls
// `useBaseUrl` itself.
const smallLayoutImageUrl = useBaseUrl(image || defaultImage);
if (layout === "small") {
return (
);
}
```
**src/components/Blog/PostCard/readme.md**
````markdown
# 📄 PostCard Component
A reusable React component for Docusaurus that renders a blog post preview card with image, title and description.
## 📦 Location
Place the component in your Docusaurus project:
```bash
src/components/Blog/PostCard/index.tsx
src/components/Blog/PostCard/styles.module.css
```
## ✨ Features
- Displays:
- Featured image (with fallback)
- Optional tag badge (real link, not a nested fake link) when `post.mainTag` is set
- Title (linked to the post)
- Short description
- Accessible markup with semantic link and alt text
- Customizable fallback image via `defaultImage` prop
- Optional eager-loading of the image via `lazyImage` (useful for above-the-fold cards)
- Uses Infima utility classes and optional CSS module styling
## 🚀 Usage
```jsx
import PostCard from '@site/src/components/Blog/PostCard';
```
## 🧾 Props
| Prop | Type | Required | Default | Description |
| --- | --- | --- | --- | --- |
| `post` | object | ✅ | — | Blog post metadata (see structure below) |
| `layout` | `"big"` \| `"small"` | ❌ | `"big"` | Card variant to render |
| `defaultImage` | string | ❌ | `/img/default.jpg` | Fallback image path if `post.image` is missing |
| `lazyImage` | boolean | ❌ | `true` | Set to `false` for above-the-fold cards to avoid hurting LCP |
### post object structure
```js
{
permalink: "/blog/my-post",
image: "/img/my-post.webp",
title: "My Blog Post",
description: "A short summary of the post.",
mainTag: "docker" // optional, renders a tag badge on the "big" layout
}
```
## 🧠 Accessibility Notes
- The title is wrapped in a `` with an aria-label for screen reader clarity.
- The tag badge is a real `` (not a nested fake link), and sits as a sibling of the
title link rather than inside it — no interactive element is ever nested inside another.
- The image includes alt and title attributes for better context.
## 🎨 Styling
Custom styles can be added via `styles.module.css`.
Example: `.description` class for paragraph styling.
## 🧩 Related Components
- `Card`
- `CardBody`
- `CardImage`
## 📄 License
MIT — free to use, modify, and contribute.
## 💬 AI generated
This code has been generated by Christophe Avonture using AI.
````
**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;
}
```
Et, dernière démo, récupérer le contenu d'un fichier relatif à cet article **(le nom du fichier source commence donc ici par un point pour indiquer qu'il est relatif au fichier `.md`)** :
**./files/hello\_world.py**
```python
def greet_user(name):
print(f"Hello, {name}! Welcome to the Python world.")
def main():
name = input("What's your name? ")
if name.strip():
greet_user(name)
else:
print("You didn't enter a name. Try again!")
if __name__ == "__main__":
main()
```
## Aller plus loin avec ProjectSetup {#go-further-with-projectsetup}
Le composant `Snippet` est une excellente façon d'afficher des snippets de code dans votre blog Docusaurus, mais que faire si vous voulez partager toute une structure de projet avec vos lecteurs ? C'est là qu'intervient le composant `ProjectSetup`.
Poursuivez votre lecture pour découvrir le composant `ProjectSetup`, qui permet d'afficher la structure de fichiers d'un projet de façon claire, interactive et standardisée. Il montre non seulement les fichiers et leur contenu, mais fournit aussi des outils pour générer le projet en une seule commande ou le télécharger en ZIP.
[Introducing the ProjectSetup Component - A Standardized Way to Share Project Structures](/blog/docusaurus-project-setup)
---
# Un composant Docusaurus qui prévient le lecteur que l'article a plus d'un an
> Affiche une bannière d'avertissement quand le contenu de la page a plus d'un an
---

**TL;DR**
Cet article montre comment créer un composant Docusaurus qui prévient le lecteur quand un billet de blog a plus d'un an. Il s'agit de construire un composant React et de « swizzler » le template `BlogPostPage` pour y injecter cet avis. Le composant détermine l'âge d'un article en regardant son champ `last_update` ou `date` dans le front matter.
Dans cet article, voyons comment afficher un petit cadre « Attention : cet article a au moins un an, ce qui, en années tech, relève quasiment de la préhistoire. »
Nous allons créer un composant React pour Docusaurus et l'injecter dans la page de nos billets de blog : une fois en place, tout fonctionnera comme par magie.
Le voici en action, sur un vieux billet de ce blog :
> Screenshot — https\://www\.avonture.be/blog/joomla-show-table
>
> Old post notice in action
C'est parti.
## Créer le composant {#create-the-component}
Vous devez créer deux nouveaux fichiers dans votre propre site Docusaurus :
**Project: /your\_docusaurus\_site**
**src/components/Blog/OldPostNotice/index.tsx**
```typescript
/**
* OldPostNotice
*
* A Docusaurus blog component that displays a visual warning if the article
* is older than one year. This helps readers understand that the content may
* be outdated and should be interpreted with caution.
*
* Features:
* - Uses `useBlogPost` to access the post's metadata and publication date.
* - Calculates whether the post is older than one year.
* - Renders the shared .mk-callout primitive (see src/css/theme.css) through
* styles.module.css — NOT Docusaurus's own alert classes.
*
* Usage:
* Import and include this component inside your swizzled `BlogPostItem/Content/index.js`
* to automatically show the notice on older posts.
*
* Styling:
* Additional styles can be customized via `styles.module.css` in the same folder.
*/
import type { JSX } from "react";
import { useBlogPost } from "@docusaurus/plugin-content-blog/client";
import { useDateTimeFormat } from "@docusaurus/theme-common/internal";
import Translate from "@docusaurus/Translate";
import styles from "./styles.module.css";
export default function OldPostNotice(): JSX.Element | null {
const { metadata } = useBlogPost();
const { date, frontMatter } = metadata;
// Declared here, above every early return: it is a hook. `timeZone: "UTC"` because
// `review_date` is a date-only front-matter string parsed as UTC midnight.
const dateTimeFormat = useDateTimeFormat({
year: "numeric",
month: "long",
day: "numeric",
timeZone: "UTC",
});
// `updates` and `review_date` are this blog's own frontmatter fields (see CLAUDE.md),
// not part of Docusaurus's own BlogPostFrontMatter type — hence the casts.
const updates = frontMatter.updates as { date: string; note: string }[] | undefined;
const reviewDate = frontMatter.review_date as string | undefined;
let lastUpdated = date;
if (updates && updates.length > 0) {
const mostRecentUpdate = [...updates].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
)[0];
lastUpdated = mostRecentUpdate.date;
}
const postDate = new Date(lastUpdated);
const oneYearAgo = new Date();
oneYearAgo.setFullYear(oneYearAgo.getFullYear() - 1);
const isOld = postDate < oneYearAgo;
if (!isOld) {
return null;
}
if (reviewDate) {
const reviewDateObj = new Date(reviewDate);
const isReviewRecent = reviewDateObj >= oneYearAgo;
if (isReviewRecent) {
// `useDateTimeFormat` follows the locale being rendered; the hardcoded "en-US" it
// replaces printed "September 14, 2026" inside an otherwise French notice.
const formattedDate = dateTimeFormat.format(reviewDateObj);
return (
✓
{formattedDate} }}
>
{"This article is over a year old but was reviewed on {date} — the content is still accurate."}
);
}
}
return (
!
This article is over a year old. The information may be outdated.
);
}
```
**src/components/Blog/OldPostNotice/styles.module.css**
```css
/*
* OldPostNotice — was using Infima's warning/success contrast pairs, a fifth
* colour system in the site. Both notices are callouts now, so a reader meets
* the same object whether the message comes from an article's author or from
* the site itself.
*
* index.tsx no longer adds "alert alert--warning" / "alert alert--success" on
* top of these classes: Infima's .alert sets its own background, full border
* and text colour, so the two systems were stacking and neither won cleanly.
*/
.oldPostNotice {
composes: mk-callout from global;
composes: mk-callout--warn from global;
margin: 0 0 1.5rem;
}
.reviewedPostNotice {
composes: mk-callout from global;
composes: mk-callout--tip from global;
margin: 0 0 1.5rem;
}
.icon {
composes: mk-dot from global;
}
.body {
min-width: 0;
margin: 0;
font-size: 0.95rem;
line-height: 1.6;
}
/* The date inside the "reviewed" notice. The old rule added a right margin to
* every , which also indented the last word of a sentence ending on
* one — the flex row and the sentence's own spaces handle this. */
.body strong {
font-weight: 600;
}
```
## Surcharger le template BlogPostPage {#override-blogpostpage-template}
Côté Docusaurus, nous devons **swizzler** la page responsable du rendu d'un billet. Cette page s'appelle `BlogPostPage`.
Pour cela, ouvrez une console et lancez `yarn swizzle @docusaurus/theme-classic BlogPostItem/Content`.
Dès maintenant, vous avez un nouveau fichier sur votre disque : `src/theme/BlogPostItem/Content/index.js`
> **Vous avez peut-être déjà ce fichier:**
>
> [Showing the changelog of your post](/blog/docusaurus-changelog) swizzle exactement le même fichier. Si vous avez suivi cet article, ajoutez simplement le nouveau composant à côté de l'existant au lieu de swizzler à nouveau.
Ci-dessous, le contenu original du fichier (Docusaurus v3.8.1) :
**src/theme/BlogPostItem/Content/index.js**
```javascript
import React from 'react';
import clsx from 'clsx';
import {blogPostContainerID} from '@docusaurus/utils-common';
import {useBlogPost} from '@docusaurus/plugin-content-blog/client';
import MDXContent from '@theme/MDXContent';
export default function BlogPostItemContent({children, className}) {
const {isBlogPostPage} = useBlogPost();
return (
{children}
);
}
```
Pour injecter notre nouveau composant `OldPostNotice`, éditez le fichier comme ceci (voir les lignes surlignées) :
**src/theme/BlogPostItem/Content/index.js**
```javascript
import React from 'react';
import clsx from 'clsx';
import {blogPostContainerID} from '@docusaurus/utils-common';
import {useBlogPost} from '@docusaurus/plugin-content-blog/client';
import MDXContent from '@theme/MDXContent';
// highlight-next-line
import OldPostNotice from "@site/src/components/Blog/OldPostNotice";
export default function BlogPostItemContent({children, className}) {
const {isBlogPostPage} = useBlogPost();
return (
// highlight-next-line
<>
// highlight-next-line
{children}
// highlight-next-line
>
);
}
```
Sauvez le fichier et rafraîchissez votre blog. Vous devriez obtenir la même bannière que celle montrée en début d'article.
> **Information:**
>
> Le composant utilise le champ `date` que vous devez mentionner dans votre front matter YAML.
>
> Donc, dans chacun de vos billets `.md`, vous devez avoir un bloc YAML comme celui-ci :
>
> ```yaml
> ---
> title: Your blog post article
> authors: [you]
> date: 2025-09-30
> ---
> ```
## Marquer un billet relu {#marking-a-reviewed-post}
Si vous revenez sur un vieil article et confirmez que le contenu est toujours exact, vous pouvez ajouter un champ `review_date` dans le front matter :
```yaml
---
date: 2023-05-10
review_date: 2026-07-30
---
```
Quand `review_date` est présent **et date de moins d'un an**, le composant remplace l'avertissement orange par une bannière verte de confirmation :
> ✅ Cet article a plus d'un an mais a été relu le 30 juillet 2026 — le contenu est toujours exact.
Si `review_date` a lui-même plus d'un an, l'avertissement standard réapparaît — parce qu'une relecture périmée ne vaut pas mieux que pas de relecture du tout.
## Position de l'avertissement {#position-of-the-warning}
L'avis sera placé avant le contenu du billet. Sur mon blog, j'ai manipulé le code ci-dessous pour extraire la toute première image (c'est-à-dire la bannière) afin de l'afficher en premier, puis l'avis, puis le reste de l'article. Mais c'est sans doute trop complexe pour l'instant. Nous verrons ça dans un prochain article.
*L'image de bannière est traitée différemment des autres pour exactement la même raison que dans [Change how Docusaurus will create img tags](/blog/docusaurus-override-img) : la première image d'un billet est spéciale et doit être laissée telle quelle.*
```jsx
{children}
```
---
# Afficher le changelog de votre article
> Afficher le changelog de votre article
---

**TL;DR**
Cet article montre comment mettre en place un changelog détaillé pour les articles de blog Docusaurus, au-delà du champ `last_update` par défaut. Il s'agit de créer un composant React personnalisé qui lit un tableau `updates` depuis le frontmatter YAML de l'article, ce qui permet d'avoir plusieurs notes datées. La solution demande de créer ce composant puis de swizzler le thème `BlogPostItem/Content` pour afficher automatiquement l'historique des mises à jour.
En tant qu'auteur de blog, il vous arrive forcément de retoucher d'anciens articles pour ajouter ou clarifier un passage. Ou simplement pour supprimer une information obsolète.
Docusaurus propose l'entrée `last_update` ([source](https://docusaurus.io/docs/api/plugins/@docusaurus/plugin-content-docs#last_update)) comme illustré ci-dessous, mais vous ne pouvez indiquer qu'une seule date, sans description.
```yaml
last_update:
date: 1/1/2000
author: custom author name
```
Créons un tout petit composant pour améliorer ça.
## Le résultat {#the-result}
Une fois le composant branché (voir plus bas), il suffit d'ajouter une entrée `updates` dans le frontmatter d'un article pour obtenir ceci, automatiquement :

## Pourquoi ça marche {#why-it-works}
* Le composant lit un tableau `updates` directement dans le frontmatter YAML de l'article — aucun fichier de changelog séparé à maintenir.
* Contrairement au `last_update` natif de Docusaurus, il accepte plusieurs entrées, chacune avec sa propre `date` et sa `note`.
* Une fois swizzlé, il s'affiche automatiquement sur chaque article qui contient une entrée `updates` — rien à appeler manuellement article par article.
## Installation {#installation}
Nous allons devoir créer un nouveau composant et swizzler le BlogPostPage, afin que le composant soit exécuté automatiquement.
### Créer le composant Updated {#create-the-updated-component}
Créez un nouveau dossier `src/components/Blog/Updated` dans lequel vous allez créer deux fichiers : `index.tsx` et `styles.module.css`.
**src/components/Blog/Updated/index.tsx**
```typescript
import type { JSX } from "react";
import clsx from "clsx";
import Translate from "@docusaurus/Translate";
import useDocusaurusContext from "@docusaurus/useDocusaurusContext";
import { parseMarkdown } from "@site/src/components/Blog/utils/markdown";
import styles from "./styles.module.css";
interface UpdateEntry {
date: string;
note: string;
}
interface Props {
updates?: UpdateEntry[];
}
export default function Updated({ updates }: Props): JSX.Element | null {
const { i18n } = useDocusaurusContext();
if (!updates || updates.length === 0) return null;
const sortedUpdates = [...updates].sort(
(a, b) => new Date(b.date).getTime() - new Date(a.date).getTime(),
);
return (
);
}
```
**src/components/Blog/Updated/styles.module.css**
```css
/*
* Updated — the timeline structure is good and is kept as-is. What changed:
* it no longer borrows --card-radius from PostCard's module (see that file),
* the timeline node is the shared .mk-dot, and the title is an eyebrow rather
* than a 1.1rem bold heading competing with the article's own.
*/
.revisionHistoryContainer {
composes: mk-surface from global;
padding: 1.25rem 1.375rem;
}
.revisionHistoryTitle {
composes: mk-eyebrow from global;
display: flex;
gap: 0.5rem;
align-items: center;
margin: 0 0 1rem;
}
.timeline {
position: relative;
}
/* The connecting line: centred on the 26px node (13px) rather than the old
* 30px one (15px). */
.timeline::before {
position: absolute;
top: 4px;
left: 13px;
width: 2px;
height: calc(100% - 8px);
content: "";
background: var(--mk-border);
}
.timelineItem {
position: relative;
display: flex;
min-height: 26px;
margin-bottom: 1.25rem;
}
.timelineItem:last-child {
margin-bottom: 0;
}
.timelineIcon {
composes: mk-dot from global;
position: absolute;
top: 0;
left: 0;
z-index: 1;
color: var(--mk-on-line);
background: var(--mk-n-300);
}
/* The latest revision is the only filled terracotta node, with a halo in the
* key tone — the halo used to be a color-mix() of the primary at 15%. */
.timelineItemLatest .timelineIcon {
background: var(--mk-primary);
box-shadow: 0 0 0 4px var(--mk-key-bg);
}
.timelineContent {
flex: 1;
padding-top: 0.15rem;
padding-left: 40px;
}
.timelineDate {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-bottom: 0.3rem;
font-size: 0.85rem;
font-weight: 600;
color: var(--mk-ink-soft);
font-variant-numeric: tabular-nums;
}
.timelineItemLatest .timelineDate {
color: var(--mk-primary);
}
.latestBadge {
composes: mk-pill from global;
}
.timelineDescription {
font-size: 0.95rem;
line-height: 1.6;
color: var(--mk-ink);
}
```
En résumé, le composant vérifie si le frontmatter YAML de votre article de blog contient une entrée `updates`. Si c'est le cas, il la lit. Ce doit être un tableau avec deux informations : une `date` et une `note`.
### Surcharger le template BlogPostPage {#override-the-blogpostpage-template}
Vérifiez d'abord si vous n'avez pas déjà le fichier `src/theme/BlogPostItem/Content/index.js`. Si vous êtes un lecteur de ce blog et que vous avez déjà personnalisé Docusaurus, vous l'avez peut-être déjà : [le composant qui signale qu'un article a plus d'un an](/blog/docusaurus-old-notice) swizzle exactement ce même fichier.
Sinon, ouvrez une console et lancez `yarn swizzle @docusaurus/theme-classic BlogPostItem/Content`.
À partir de maintenant, vous avez un nouveau fichier sur votre disque : `src/theme/BlogPostItem/Content/index.js`.
Nous devons injecter notre nouveau composant `Updated`, éditez le fichier `index.js` pour y insérer le code nécessaire.
Voici les lignes minimales à ajouter au bon endroit dans votre propre fichier.
**src/theme/BlogPostItem/Content/index.js**
```javascript
// [...]
import Updated from "@site/src/components/Blog/Updated";
export default function BlogPostContent({ children }) {
// [...]
const { metadata, isBlogPostPage } = useBlogPost();
// [...]
return (
);
}
```
## Autres démos {#more-demos}
### Utiliser le composant {#use-the-component}
Éditez maintenant n'importe lequel de vos articles existants et ajoutez une entrée `updates` dans votre frontmatter YAML, par exemple
```yaml
updates:
- date: 2024-11-19
note: review Dockerfile, use Quarto 1.6.36.
- date: 2024-12-01
note: Added new theme options for blog posts.
- date: 2024-12-15
note: Fixed bug with image rendering on mobile devices.
- date: 2025-01-05
note: Implemented search functionality for documentation pages.
- date: 2025-01-20
note: Optimized build process for faster deployment times.
```
Enregistrez l'article et rafraîchissez votre page web. Si tout est correct, vous verrez l'information dans votre navigateur — le même rendu que celui présenté en haut de cet article.
N'hésitez pas à modifier `src/components/Blog/Updated/styles.module.css` pour coller à vos propres préférences de style.
## Conclusion {#conclusion}
Un petit tableau lu dans le frontmatter et un seul fichier de thème swizzlé remplacent la date unique de `last_update` par un vrai changelog à plusieurs entrées — les lecteurs voient exactement ce qui a changé et quand, sans que vous ayez à maintenir un fichier séparé.
Ça se marie bien avec [l'avertissement affiché sur les articles de plus d'un an](/blog/docusaurus-old-notice) : le premier dit *ce qui* a changé, le second dit *quand* c'est arrivé pour la dernière fois.
---
# Injecter de l'ASCII art dans toutes les pages HTML générées par Docusaurus
> Injectez une bannière en ASCII art dans chaque page HTML générée par Docusaurus grâce à un plugin postBuild léger. Elle apparaît dans le code source de la page ou peut être affichée visiblement.
---

**TL;DR**
Cet article montre comment injecter une bannière ASCII art personnalisée dans chaque page HTML générée par Docusaurus. Il s'agit de créer un plugin `postBuild` léger qui parcourt les fichiers générés et insère l'ASCII art sous forme de commentaire HTML juste après la balise ``. Le guide détaille les étapes pour créer le fichier ASCII art, le plugin, et configurer Docusaurus pour l'activer.
C'est peut-être totalement inutile, et seuls les plus curieux le remarqueront, mais nous allons voir comment injecter une bannière ASCII art personnalisée tout en haut de chaque page HTML générée pour notre blog.
Dès maintenant, appuyez sur `CTRL`+`U` (l'équivalent de `View page source`) pour voir ce que nous allons faire dans cet article.
## Le résultat {#the-result}
Une fois le plugin en place (voir plus bas), un `CTRL`+`U` sur n'importe quelle page du site généré affiche ceci, juste après la balise `` :

> **Votre site est manifestement de classe mondiale et absolument unique dans son domaine. ;-):**
## Pourquoi ça fonctionne {#why-it-works}
* Le plugin s'accroche à l'événement `postBuild` de Docusaurus — il ne s'exécute qu'une fois que `yarn docusaurus build` a fini d'écrire le HTML sur le disque.
* Il parcourt chaque fichier `.html` généré dans la sortie du build.
* Pour chacun, il injecte l'ASCII art sous forme de commentaire HTML juste après la balise ouvrante `` — invisible sur la page affichée, visible uniquement dans le code source.
*Contrairement aux plugins remark que j'ai écrits auparavant (voir [Creating a search\&replace plugin for Docusaurus](/blog/docusaurus-plugin-replace)), celui-ci ne touche pas du tout au Markdown : il travaille sur le HTML généré.*
## Installation {#installation}
### Créez votre logo personnalisé {#create-your-personalized-logo}
D'abord, il nous faut de l'ASCII art. Si vous n'en avez pas encore, vous pouvez utiliser cet outil en ligne : [Image to ASCII Art Converter](https://folge.me/tools/image-to-ascii). Uploadez simplement un petit personnage et convertissez-le. *J'ai aussi abordé la méthode en ligne de commande pour produire ce genre de bannières dans [Bash - ASCII art](/blog/bash-ascii-art).*
Dans votre site Docusaurus, créez le fichier `src/data/banner.txt` et collez-y votre ASCII art. Voici le mien :
**src/data/banner.txt**
```txt
:+***###%%%%%%%%##****=:
.-*##%%%%%%##%%%%%%%%%%%%%%%%%%%%##+.
.=#%%%#%#%+.:..........::--------=#%%%%%%%%*:
.+#%%%##:........:------------------------+%%@@@@@:.
..=%#%##=......:----------------------------------*%%@@@#..
.:#%##*=.....:----:---------------------------=#@@%+=--*%@@@%-.
:####+.....:-+%@@@*=:-------------------------=@@@@@@@+----#@@@%=.
:#%%#+....:::*@@@@@@@@:-------------------------=**+-+##=------#@@@%-
.*#%%+....:::::=#%*--++-------------------------------------------=#@@@#:
-#%%*....:::::::::------------------------------------=++++++=-----===%@@#-
=#%%=...:::::::--=++++++----------------------------+************+=--===*@@%=
+%%%-...:-:::-++************+-----------------------******************====*@@@=.
+%%%-..:-----******************+--------------------****#%%%%%%##*******+===*@@@=
=%%%-..:----**********%@@@@@@****+------------------***%@@@%**%@@@@@*******===*@@@=
-#%%-..:----*******#@@@@%*=-=%@@@%**-----------------*#@#-::....::-#%@@#*****===*@@@%%=.
...=#%%=...:--=******#%@@+-::.....::-%@%*----------------*%-::+@@#-.....:%@@*****+==+#@@@%%%@:
.-%%%%%%%=..:---******#@@*-:....+%@@@%-:-%#----------------#==#:..+@@@%-....#@%*****+==*@@@##%%%*.
.-%%%%+*%%=..:---*******%@*:.......*@@@@@%--%*---------------==@@#-+%@@@@@-....@@*****+===#@@%#*%%%%.
.%%%#=.=%%%=..:--=******#@@*....**==%@@@@@@%:+*----------------*@@@@@@@@@@@-....@%******+==#@@@##+%@%+
.%%%#..-#@@@=..:--+******#%@*...:*@@@@@@@@@@%==#-------++++++=--=@@@@@@@@@@@-...=@%******+==+%@@##*%@@%
*%%#.:+*%@@#-.:---+*******#@*:...+@@@@@@@@@@%-#=---=*#*+=====*%*=-%@@@@@@@%-...=@%******+====#@@%##%@@%
%%%+.++#%@@*-::----********#@#-...-#@@@@@@%=:*+:::=#%#*+++++*#%%%-:=+*#+=-...+%@#******+-:===#@@@#*@@@%
%%%+=+##%@@#-------=*********%%*-...:------**-:...=@@%%%%%%@@@@@@#..:=++**#%##********+:..-==#@@@#%@@@:
%%%%++**#@@#=------.:**********#%%#####%*#*-:.....+@@@@@@@@@@@@@%-:....:=**********+:.....-==#@@%%%@%:
-@@@#+**#@@@+-----:...:*****************-:.........:*@@@@@@@@@@#=-:::....................:-=*%@@@@@%.
=@@@%+*#@@@+-----:.......-*+++****=:.................-#@@@@*=--::::=*...................:-=#@@@@+.
.%@@@#*%@@%=-----........................:@@#............:-::-:-=%@*:::...............:==*@@@-
.-%@@@@@@@*-----.........................:*%@#-:......:::::-=+@@#=::::::............:==+#@@*:
.=##%@@@+-----...........................-#%@@@%*===+#@@@%#*::::::::::.........:-==+%@@#:
+%@@*-----:.............................:+#******#-:::::::::::::::.......-===*@@@*:
=%@@#=-----:...................................::::::::::::::::::....:-====#@@%=.
-#@@@*------:................................:::::::::::::::::::::-====+#@@@#:
.-%@@@#-------::......................:::::::::::::::::::::::--=====#%@@@#- =+*+.
.-%@@@%#==-------::.........::::::::::::::::::::::::::---=====-#%@@@@#: +%@@%@@*
.:%@@@@%#+========------::::::::::::::::::::------======-*%@@@@@#:. .#@=--*@%-
.:*@@@@@@%#+--==--------------------------------====#@@@@@+:. :#@=---@@=
..:#@@@@@@@@%=------------------------------=-===#@@@: :#@---=@@=
.+%@@@=-----------------------------------*%@@: .#@%*-@@@@@@%+.
-#@@=------------------------------------#@@+. .*%%%*%@@=-=%=%@#-
.*%%-..--------------------:::::::-------#@@@: -#@=----=@%=*%=+#@*:
*%%#..----------------:..............:--+%@@:. +@@+==#%@@*#@#+*%@*:
+%%%:..-------------:..................:-#@@@%==: ..-+%%%*+++###%@@@%@@%-
:#%%:..:----------:......................*@@@@@@%%%%%%%%%%*::=++++++*#@@%+.
.=#%%%*-...:--------:........................#@@#+++*###*==:..:---===+++*@@#.
-#%%%#=...::---------:.........................=%@@*--::::::::---------==+@@%+
.-#%%%+:..::------------:..........................#@@@=-----------------===@@@*.
:#%%#=...::--------------:..........................-%@@%=-------------====#@@@*.
.#%%%+...:-----------------:..........................:+@@@+==---------====+%%@%:
.%%%*...:::-----------------:...........................-%@@%====--=======+%@@@%
.%%%*..:--------------=%=----:...........................--@@@#++++=====+#@@@@#.
%%%#..::------------+%%+-----:...........................:-%@@%+++++**%@@@@#+.
.%%#:..::----------#@@%*=-----:............................-=@@@@@@@@@@@%#+.
=%%#..-:----------%@@@=+=-----:............................-=#@@@@%#*+=.
:%%%-------------=+%@@#=------:............................-==@@%+
%%%#-------------=+%@@#------:............................:==@@@#
.@@%#=------------=+@@%+-----:.............................==*@@#:
.#@@%*=---------==++@@%-----:.............................===@@@*
.-%@@@%%%%%%%*++++*@@%-----:.............................===%@@*
..=@@@@@*+++++++@@%=-----:.............................-==+@@#-
=@@#+++++++%@@@+=----:..............................:==+@@@*
=@@#*++%@@@@@%===----:..............................:===%@@*
.=%@@@@@@%%++===-----:..............................:===*@@#:
.:=+%%@#*++--------:::..........:::-----+%@@#+++++===-----:...............................:===+@@@+
:+%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%%@@#+=====--------:..............................::===+@@@*
:#%%%******++++++**#######*++++=++++++++*#%%%=.:-------------:............................::::===+%@@*
-%%%#*********************-:::::::::::::=#%%+..:-------------:..........................::::::===+#@@*.
.+%%%#*******************=--------------*%%*:.::-------------:........................::::::::====+@@#:
.=%@@@%#****************=-------------+@@#=..:--------------:......................:::::::::-====+@@#-
.-#@@@%%*********####*=============+@@@+..:---------------:....................::::::::::-=====+@@@+
..+%@@@@@@#***#####+=============#@@*..:-----------------:................::::::::::::-======+@@@+
.#@@@@@@@@%###+============+@@@*.:-------------------:............::::::::::::::========+@@@+
.*%%@@@@@@@@@@%*=++++++#@@*..:---------------------:.......::::::::::::::--=====+===+@@@+
.:*##%@@@@@@@@@@@@@@@*.:-------------------------::::::::::::::::--============+@@@+
.:=+***##@@@*.:-----------------------======---------===========+=+===+@@@+
:@@%+:--------------------================================+===+@@@+
:@@%=------------------====================++++++++++++++=====+@@@+
.:@@#=---------------==========*###%@@@@@@@@@@@@%%##*+==+++====+@@%=
.-@@%=------------=========*#%@@@@@@@@##@@@@@@@@@@%##*+++++++++*@@#-
:@@%+----------========+#%@@@#=-... :@@@%++++++++++++++++++*@@#:
=#%%@@%*-------=========+#@@@*-. .-@@@#=++++++++++++++++*@@@@%%*:
=%%@@#:::------==========+%@@#:. .=@@%*+++++++++++++++++=-=*%@@@%-
#@@@....-----============+@@@# .*@@#++++++++++++++++++++=--*@@@:
.:%@@@@@@@@@@@@@@@@@@@@@@@@@@@# :%@@@@@@@@@@@@@@@@@@@@@@@@@@@@@#:
.+############################: :##############################:.
```
### Créez le plugin et chargez-le {#create-the-plugin-and-load-it}
Passons au plugin. Créez `plugins/ascii-injector/index.mjs` et copiez/collez le code source ci-dessous dans votre fichier :
**plugins/ascii-injector/index.mjs**
```mjs
/**
* Docusaurus Plugin: ascii-injector
*
* Purpose:
* This plugin will prepend an ASCII art banner (from a specified text file)
* in all generated HTML files as an HTML comment.
*
* Usage:
* - Save this plugin as `plugins/ascii-injector/index.mjs`
* - Create the file containing the ASCII art in `src/data/banner.txt`.
* - Add the plugin to `docusaurus.config.js` plugins array.
*
* Please see readme.md for more details
*/
import { promises as fs } from "node:fs";
import path from "node:path";
/**
* Recursively collect all HTML files in a given directory.
*/
async function collectHtmlFiles(dir) {
const entries = await fs.readdir(dir, { withFileTypes: true });
const files = await Promise.all(
entries.map(async (entry) => {
const fullPath = path.resolve(dir, entry.name);
if (entry.isDirectory()) return collectHtmlFiles(fullPath);
if (entry.isFile() && entry.name.endsWith(".html")) return [fullPath];
return [];
}),
);
return files.flat();
}
/**
* Docusaurus plugin that injects an ASCII art banner into all generated HTML files.
*
* @param {object} context - Docusaurus plugin context (unused).
* @param {object} options - Plugin configuration options.
* @param {string} options.bannerPath - Relative path (from siteDir) to the ASCII banner file.
*/
export default function asciiInjectorPlugin(context, options) {
if (!options || typeof options.bannerPath !== "string") {
throw new Error("[ascii-injector] Missing required option: bannerPath (string)");
}
return {
name: "ascii-injector-plugin",
async postBuild({ outDir, siteDir }) {
const bannerPath = path.resolve(siteDir, options.bannerPath);
let rawBanner;
try {
rawBanner = await fs.readFile(bannerPath, "utf8");
} catch (err) {
console.error(`[ascii-injector] Banner file missing: ${err.message}`);
return;
}
const commentBanner = `\n\n`;
const htmlFiles = await collectHtmlFiles(outDir);
console.log(
`[ascii-injector] Preparing to inject banner into ${htmlFiles.length} HTML files...`,
);
const results = await Promise.allSettled(
htmlFiles.map(async (filePath) => {
try {
const html = await fs.readFile(filePath, "utf8");
// proceed to inject; files are always regenerated during build
let updated;
// Default: insert comment after
const doctype = /]*>/i;
if (doctype.test(html)) {
updated = html.replace(doctype, (m) => `${m}\n${commentBanner}`);
} else {
// fallback to prepending comment
updated = commentBanner + html;
}
// Write atomically: temp file + rename avoids race conditions
// with other postBuild plugins that read HTML concurrently.
const tmpPath = `${filePath}.tmp`;
await fs.writeFile(tmpPath, updated, "utf8");
await fs.rename(tmpPath, filePath);
return { status: "ok", filePath };
} catch (err) {
return { status: "error", filePath, error: err };
}
}),
);
const ok = results.filter(
(r) => r.status === "fulfilled" && r.value && r.value.status === "ok",
).length;
const skipped = results.filter(
(r) => r.status === "fulfilled" && r.value && r.value.status === "skipped",
).length;
const failed =
results.filter(
(r) => r.status === "fulfilled" && r.value && r.value.status === "error",
).length + results.filter((r) => r.status === "rejected").length;
console.log(
`[ascii-injector] Injection complete — ok: ${ok}, skipped: ${skipped}, failed: ${failed}`,
);
},
};
}
```
C'est presque fini. Finalisez l'installation en éditant votre fichier `docusaurus.config.js` et, sous `plugins`, faites ceci :
**docusaurus.config.js**
```javascript
// [...]
plugins: [
// [...]
[
"./plugins/ascii-injector/index.mjs",
{ bannerPath: "src/data/banner.txt" },
],
],
```
Dernière étape : redémarrez votre site Docusaurus pour charger le fichier de configuration mis à jour.
## Autres démos {#more-demos}
### Place aux tests {#time-to-test}
Rien de neuf ici, générez simplement la version statique de votre site.
```bash
$ yarn docusaurus build
$ yarn docusaurus serve
```
Une fois votre site généré, ouvrez n'importe quelle page, appuyez sur `CTRL`+`U` et tadaaa, votre ASCII art est là — exactement le résultat montré en haut de cet article.
## Sous le capot (passez votre chemin si vous voulez juste l'utiliser) {#under-the-hood-skip-this-if-you-just-want-to-use-it}
> **Pas en mode preview:**
>
> Ce plugin n'est déclenché que lors de l'événement `postBuild`, c'est-à-dire uniquement après que votre blog a été rendu sous forme de page HTML. Donc si vous lancez Docusaurus en mode preview, vous ne verrez pas l'ASCII art.
## Conclusion {#conclusion}
Un plugin `postBuild`, un fichier ASCII art et trois lignes de configuration en plus — et chaque page générée par votre site porte dans son source une petite signature inutile, totalement assumée. Si le côté ligne de commande de la génération de bannières ASCII vous intéresse plus que la tuyauterie Docusaurus, [Bash - ASCII art](/blog/bash-ascii-art) couvre cette partie.
---
# Best Practice - Personnaliser le flux RSS de Docusaurus pour un contenu complet et des images
> Améliorez le fichier RSS de votre blog Docusaurus. Écrivez un plugin pour désactiver le flux de base et inclure le contenu complet des articles, les images et des métadonnées enrichies pour de meilleurs lecteurs.
---

**TL;DR**
Le flux RSS par défaut de Docusaurus est basique : il n'inclut ni le contenu complet des articles ni les images, ce qui limite son intérêt pour les lecteurs RSS. Cet article propose une solution : créer un plugin personnalisé qui désactive la génération du flux par défaut. Le nouveau plugin produit un fichier `rss.xml` enrichi, avec le contenu HTML complet des articles, les images et des métadonnées améliorées pour une bien meilleure expérience de lecture.
Si vous utilisez Docusaurus, vous pouvez lui demander de générer un fichier `rss.xml` en ajoutant le code ci-dessous dans la section `blog` de `docusaurus.config.js`.
```javascript
feedOptions: {
type: ["rss"],
},
```
Docusaurus utilise **RSS 2.0** et exporte, par défaut, jusqu'à 20 articles. Le fichier généré est relativement basique ; par exemple, il n'inclut ni l'image de l'article ni son contenu HTML. Docusaurus crée un fichier assez simple.
Voyons comment l'améliorer.
*Voici un autre plugin Docusaurus local, comme ceux construits dans [Creating a search\&replace plugin for Docusaurus](/blog/docusaurus-plugin-replace) et [Inject ASCII Art in any HTML pages rendered by Docusaurus](/blog/docusaurus-ascii-art) — celui-ci s'accroche aussi à l'événement `postBuild`.*
## Qu'est-ce que le fichier rss.xml ? {#what-is-the-rssxml-file}
Ce fichier est utilisé par les lecteurs RSS pour être informés des nouveaux articles de votre blog. Certaines personnes, dont moi, utilisent ces outils pour la veille technologique : elles passent par des services comme [Feedly](https://feedly.com/) ou [inoreader](https://www.inoreader.com/) pour s'abonner aux nouveaux contenus publiés sur les sites qu'elles suivent. Résultat : en ouvrant leur lecteur RSS, elles voient immédiatement la liste des nouveautés, le tout alimenté par le flux RSS.
Le fichier `rss.xml` standard généré par Docusaurus est trop basique pour moi. En effet, quand j'ouvre mon lecteur RSS, il me manque le contenu complet de l'article et l'image d'introduction.
Si vous maintenez un blog avec Docusaurus, votre RSS se trouve à l'emplacement `blog/rss.xml`. Pour mon site, c'est .
C'est un fichier XML, mais vous verrez une jolie page HTML grâce à un `XSLT` (une transformation du XML en HTML).

> **Voir la source:**
>
> Appuyez simplement sur `CTRL`+`U` pour voir le code source XML si ça vous intéresse.
## Visualiser le RSS avec Inoreader {#viewing-the-rss-using-inoreader}
Ci-dessous, ce que je voyais dans Inoreader :

Dans les chapitres suivants, nous allons voir comment améliorer notre `rss.xml`. Une fois fait, vous obtiendrez ceci :

Et en ouvrant un article, nous obtiendrons ceci :

## Comment Docusaurus gère les flux {#how-docusaurus-is-managing-feeds}
La génération des flux est définie par l'élément `feedOptions` du fichier `docusaurus.config.js`.
J'ai essayé la documentation officielle () mais **je n'ai pas réussi à maîtriser l'élément `createFeedItems`** : on est censé pouvoir définir quelles informations (auteur, catégories, description, contenu, images, ...) on veut dans le flux mais, je dois l'avouer, toutes mes tentatives ont échoué.
C'est pourquoi, après des heures, j'ai créé mon propre plugin avec l'aide de l'IA.
## Optimisons tout ça {#lets-optimize-it}
### Préparer votre système {#prepare-your-system}
Ouvrez une console et ajoutez les quelques dépendances nécessaires au plugin que nous allons créer.
```bash
$ yarn add cheerio feed front-matter fs-extra glob
```
### Créer le plugin {#create-the-plugin}
Créez le fichier `plugins/blog-feed-plugin/index.js` avec le code ci-dessous
**plugins/blog-feed-plugin/index.js**
```javascript
/**
* @fileoverview Docusaurus Blog Feed Plugin (RSS 2.0)
*
* Generates an optimized RSS feed (blog/rss.xml) from the final HTML in the Docusaurus build output.
* Extracts and cleans article content to avoid leaking UI elements, respects front matter metadata,
* and includes optional image support with MIME type detection.
*
* Key features:
* - Robust article extraction with selector fallbacks.
* - Critical selectors always stripped (header, svg).
* - Configurable cleaning via options.stripSelectors (user-defined).
* - Properly normalized slugs and POSIX-safe permalinks.
* - Absolute URLs for links and images (siteConfig.url + baseUrl + paths).
* - Optional image injection to descriptions and enclosure tags.
* - Safe character cleanup to prevent invalid XML characters.
*
* Options:
* - maxItems?: number (default: 20) — items in the site-wide feed
* - includeContent?: boolean (default: true)
* - includeImages?: boolean (default: true)
* - stripSelectors?: string[] (default: curated list, merged with critical)
* - ignorePatterns?: string[]
* - emitTopicFeeds?: boolean (default: true) — per-tag and per-series feeds
* - topicMaxItems?: number (default: 10) — items in each per-topic feed
* - minPostsPerTagFeed?: number (default: 1) — a tag below this gets no feed
*
* Usage:
* const blogFeedPlugin = require("./plugins/blog-feed-plugin/index.js");
*
* module.exports = {
* // ...
* plugins: [
* [blogFeedPlugin, {
* maxItems: 20,
* includeContent: true,
* includeImages: true,
* stripSelectors: [
* ".custom-ads",
* ".share-buttons"
* ],
* }],
* ],
* };
*
* Output:
* - The RSS file is written to: /blog/rss.xml (served at /blog/rss.xml).
* - Per-tag feeds: /blog/tags//rss.xml (served at /blog/tags//rss.xml).
* - Per-series feeds: /series//rss.xml (served at /series//rss.xml).
*
* @license MIT — free to use, modify, and contribute.
*/
const yaml = require("js-yaml");
const fs = require("fs-extra");
const path = require("path");
const frontMatter = require("front-matter");
const glob = require("glob");
const cheerio = require("cheerio");
// Which posts land in which per-topic feed. Split out because it is pure and
// self-contained; this file keeps the serializing and the writing.
const { groupItemsByTag, groupItemsBySeries } = require("./topic-feeds.cjs");
// --- Utilities ---------------------------------------------------------------
/**
* Join URL segments with POSIX semantics and ensure leading slash consistency.
* @param {string[]} parts
* @returns {string}
*/
function posixJoin(...parts) {
const joined = path.posix.join(...parts);
return joined.startsWith("/") ? joined : `/${joined}`;
}
/**
* Resolve an absolute URL from site config components.
* @param {string} siteUrl
* @param {string} baseUrl
* @param {string} relPath
* @returns {string}
*/
function absoluteUrl(siteUrl, baseUrl, relPath) {
const base = baseUrl || "/";
const rel = relPath || "/";
const fullPath = posixJoin(base, rel);
return `${siteUrl.replace(/\/+$/g, "")}${fullPath}`;
}
/**
* Determine the MIME type from an image URL.
* @param {string} url
* @returns {string}
*/
function getMimeTypeFromUrl(url) {
const ext = path.extname(url).toLowerCase();
switch (ext) {
case ".jpg":
case ".jpeg":
return "image/jpeg";
case ".png":
case ".avif":
return "image/png";
case ".gif":
return "image/gif";
case ".webp":
return "image/webp";
case ".svg":
return "image/svg+xml";
default:
return "application/octet-stream";
}
}
/**
* Remove control/non-printable characters that can break XML parsers.
* @param {string} s
* @returns {string}
*/
function cleanProblemChars(s) {
return (
String(s)
.replace(/\u00a0/g, " ")
.replace(/\u200b/g, "")
// eslint-disable-next-line no-control-regex
.replace(/[\x00-\x09\x0B\x0C\x0E-\x1F\x7F]/g, "")
.trim()
);
}
// --- Strip selectors ---------------------------------------------------------
const CRITICAL_STRIP_SELECTORS = ["header", "svg"];
const DEFAULT_STRIP_SELECTORS = [
'h3:contains("Related posts")',
"div.row:has(div.col.col--4)",
"footer.docusaurus-mt-lg",
".blueSkyContainer",
'p:contains("Loading comments")',
".scrollBtn_Pv66",
];
function getStripSelectors(userSelectors = []) {
return [...CRITICAL_STRIP_SELECTORS, ...DEFAULT_STRIP_SELECTORS, ...userSelectors];
}
// --- Article extraction ------------------------------------------------------
/**
* Locates a built page from its permalink.
*
* `outDir` is ALREADY the locale's output directory (`build/` for the default locale,
* `build/fr/` for `fr`), while `permalink` already carries the locale's baseUrl
* (`/fr/blog/`). Joining them naively produces `build/fr/fr/blog/` — a path that
* never exists, so every article silently lost its body and the French feed came out empty
* with a green build. Strip the baseUrl prefix before joining. See TODO 0119.
*/
async function getArticleHtml(permalink, outDir, stripSelectors = [], baseUrl = "/") {
const normalizedBase = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
const relativePermalink =
normalizedBase !== "/" && permalink.startsWith(normalizedBase)
? permalink.slice(normalizedBase.length - 1)
: permalink;
const htmlFilePath = path.join(outDir, relativePermalink, "index.html");
if (!fs.existsSync(htmlFilePath)) {
console.warn(
`[BlogFeedPlugin] HTML file not found for ${permalink} at: ${htmlFilePath}`,
);
return null;
}
const htmlContent = await fs.readFile(htmlFilePath, "utf8");
const $ = cheerio.load(htmlContent);
// Attempt to find the main article container using multiple fallbacks
let articleContainer = $("article").first();
if (!articleContainer.length) {
articleContainer = $(".theme-doc-content, .theme-doc-markdown").first();
}
if (!articleContainer.length) {
console.warn(`[BlogFeedPlugin] Could not find article container for ${permalink}.`);
return null;
}
// Remove elements based on strip selectors
const selectorsToRemove = getStripSelectors(stripSelectors);
selectorsToRemove.forEach((sel) => {
articleContainer.find(sel).remove();
});
// Handle Docusaurus code snippet wrapper cleanup for cleaner RSS content
articleContainer.find(".snippet_block_pySp").each((_, element) => {
const $snippet = $(element);
const $codeBlock = $snippet.find(".codeBlockContainer_Ckt0").first();
if ($codeBlock.length) {
$snippet.replaceWith($codeBlock);
} else {
$snippet.remove();
}
});
const contentHtml = articleContainer.html();
if (!contentHtml) return null;
return cleanProblemChars(contentHtml);
}
// --- Slug computation --------------------------------------------------------
function computeSlug(attributes, relativeFilePath) {
if (attributes.slug) {
const normalized = String(attributes.slug).replace(/^\/|\/$/g, "");
return normalized || null;
}
const slugWithDate = relativeFilePath.replace(/\.(md|mdx)$/i, "");
const parts = slugWithDate.split(path.sep);
let finalSlug;
// Handle index file inside a folder (e.g., folder/index.md)
if (parts[parts.length - 1].toLowerCase() === "index" && parts.length > 1) {
finalSlug = parts[parts.length - 2];
} else {
finalSlug = parts[parts.length - 1];
}
if (!finalSlug) return null;
// Normalize slug to lowercase and use hyphens
return finalSlug.toLowerCase().replace(/_/g, "-");
}
// --- Feed assembly -----------------------------------------------------------
/**
* Serialize one RSS 2.0 document — channel header, items, dc:creator injection
* and the XSLT processing instruction.
*
* Extracted from `postBuild` so the same recipe produces the site-wide feed and
* every per-topic one. The cheerio pass has to live in here rather than at the
* call site: it runs on the serialized XML, so a feed built outside it would
* silently ship without its `` elements.
*
* @param {object} params
* @param {Function} params.Feed - the `feed` package's Feed constructor.
* @param {object} params.channel - `{title, description, id, link, language}`.
* @param {Array