# Display Docusaurus Blog Posts as Cards - A Step-by-Step Guide A series of 2 article(s), in reading order. # Afficher les articles liés sous nos articles Docusaurus > Améliorez votre blog Docusaurus en affichant les articles liés sous chaque article pour faciliter la découverte de contenu. --- ![Afficher les articles liés sous nos articles Docusaurus](/img/v2/docusaurus_react.webp) **TL;DR** Ce guide explique comment ajouter une section « Articles liés » sous vos articles de blog Docusaurus. Vous allez apprendre à créer un composant React `` personnalisé qui affiche une liste d'articles partageant le même tag principal (`mainTag`). La démarche consiste à construire un composant `` réutilisable, à créer un script utilitaire pour récupérer et parser les données des articles, et enfin à surcharger le thème `BlogPostItem` de Docusaurus pour injecter votre nouveau composant en bas de chaque article. Docusaurus peut afficher une liste de tags, mais pas d'articles liés. *Ce `mainTag` n'est utile que si vos tags sont cohérents ; [Tags management in Docusaurus](/blog/docusaurus-tags) présente le script de nettoyage que j'utilise pour qu'ils le restent.* Quand vous parcourez mon blog, vous voyez une liste d'**articles liés** sous chaque article. C'est un composant que j'ai codé avec l'aide de l'IA. *Pour les articles qui suivent un ordre de lecture voulu plutôt qu'un tag commun, voyez [Organize Your Docusaurus Content with a Custom Series Component](/blog/docusaurus-series).* L'objectif de notre composant est d'afficher quelque chose comme ceci : ![Articles de blog liés](./images/related.webp) Voici le résultat final une fois le tout branché : jusqu'à six cartes, choisies parmi les articles partageant le même `mainTag`. ![Le résultat final](./images/final.webp) ## Pourquoi ça fonctionne {#why-it-works} * La correspondance se fait sur `mainTag`, un seul champ de front matter personnalisé — pas de sélection manuelle, pas de pondération de mots-clés, juste « même sujet principal ». * Le composant `` réutilise le même `` que vous construiriez pour n'importe quelle liste d'articles, donc il n'y a qu'une seule mise en page de carte à maintenir sur tout le site. * C'est une surcharge de thème, pas un fork : `BlogPostItem` affiche toujours tout ce que Docusaurus fait normalement, on injecte simplement un composant en plus tout en bas. ## Il nous faut un composant Card {#we-need-a-card-component} Suivez les instructions du chapitre [Utiliser le composant Card réutilisable de Docux](/blog/docusaurus-cards#using-the-reusable-card-component-of-docux). Vous devrez créer jusqu'à cinq fichiers dans un dossier `src/components/Card/` (et ses sous-dossiers). Revenez à cet article une fois les cinq fichiers créés. ## Il nous faut un moyen d'extraire les informations des articles {#we-need-a-way-to-extract-information-from-blog-posts} Si ce n'est pas déjà fait, créez le fichier `src/components/Blog/utils/posts.ts`. Il contiendra une fonction utilitaire réutilisable par plusieurs composants. Cette fonction va scanner chaque fichier Markdown avec une extension `.md` ou `.mdx` dans le sous-dossier `blog`. Pour chaque fichier, le script va regarder le front matter YAML 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, la fonction retournera la liste des articles et leurs propriétés. Certaines propriétés, comme `mainTag`, sont personnalisées. Copiez-collez donc 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, }; }); } ```` ## Notre composant RelatedPosts {#our-relatedposts-component} Créez maintenant le fichier `src/components/Blog/RelatedPosts/index.tsx` : **src/components/Blog/RelatedPosts/index.tsx** ```typescript /** * 🔗 RelatedPosts Component * * Displays a list of related blog posts based on shared tags or a designated `mainTag`. * Designed for Docusaurus blogs to enhance content discoverability. * * Behavior: * - Uses `useBlogPost()` to get current post metadata * - Delegates the selection to `useRelatedPosts()` (mainTag, then fallback on shared tags, * current post excluded, newest first) so this block and `MobileQuickLinks` stay in sync * * Styling: * - Uses Docusaurus grid and card classes * - Inline styles for layout and visual polish * * Returns: * - A responsive grid of related blog post cards, or a fallback message if none found */ import type { JSX } from "react"; import { useBlogPost } from "@docusaurus/plugin-content-blog/client"; import Translate from "@docusaurus/Translate"; import { useRelatedPosts } from "@site/src/components/Blog/utils/related"; import PostCard from "@site/src/components/Blog/PostCard"; import styles from "./styles.module.css"; interface Props { /** Number of related posts to display */ count?: number; /** Whether to show post descriptions */ description?: boolean; } export default function RelatedPosts({ count = 3, description = false, }: Props): JSX.Element | null { const { metadata } = useBlogPost(); // `mainTag` is a project-specific front matter field, so Docusaurus types it // as `unknown` — narrow it to what useRelatedPosts() expects. const mainTag = metadata.frontMatter.mainTag as string | undefined; const tags = metadata.frontMatter.tags || []; // Hook before early return: useRelatedPosts reads the locale-aware corpus, and it already // returns [] when there is no tag to match on. const related = useRelatedPosts({ mainTag, tags, excludePermalink: metadata.permalink, count, }); if (!mainTag && tags.length === 0) { return null; } if (!related.length) { return (

No related posts.

); } return (

Related posts

{related.map((post) => ( ))}
); } ``` ## Surcharger le template BlogPostItem {#overriding-the-blogpostitem-template} Nous devons maintenant surcharger le template **BlogPostItem** de Docusaurus pour pouvoir injecter notre composant `` en bas de chaque article. Dans un terminal, lancez `yarn docusaurus swizzle @docusaurus/theme-classic BlogPostItem`, puis sélectionnez `Javascript`, ensuite `Eject`, et enfin `YES`. Beaucoup de fichiers seront créés dans le dossier `src/theme/BlogPostItem` de votre projet Docusaurus. On peut supprimer sans risque tous les fichiers sauf `src/theme/BlogPostItem/index.js`, que nous devons éditer. Supprimez donc tous les fichiers et dossiers sous `src/theme/BlogPostItem` à l'exception du 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"; import RelatedPosts from "@site/src/components/Blog/RelatedPosts"; // 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 ( {children} // highlight-next-line {isBlogPostPage && } ); } ``` 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 le container et démarrez-en un nouveau. ## Éditer nos articles de blog {#editing-our-blog-posts} Nous avons fait toute la configuration nécessaire, mais quand vous visitez votre blog, vous ne voyez rien qui ressemble à « Articles liés ». Pourquoi ? Par exemple, dans l'image ci-dessous, on ne voit aucune carte. ![Il n'y a aucun article lié, pourquoi ?](./images/no_related_articles.webp) **C'est parce qu'il nous faut deux choses :** 1. 1\. Votre article doit avoir un \`mainTag\` et 2. 2\. Les articles de votre blog doivent avoir un tableau \`tags\` Regardez l'exemple ci-dessous : **post.md** ```markdown --- date: 2025-09-03 slug: docusaurus-relatedposts title: Displaying related posts below our Docusaurus article authors: [christophe] image: /img/social-card.webp series: Creating Docusaurus components # highlight-start mainTag: docusaurus tags: [component, docusaurus, markdown, react, swizzle] # highlight-end --- ``` L'article doit avoir un élément `mainTag`, associé au tag le plus important pour cet article. Disons `docusaurus`. Ensuite, tous les articles de votre blog doivent avoir le tableau `tags` standard, dans lequel vous pouvez lister plusieurs tags. Le composant `RelatedPosts` va alors chercher `docusaurus` dans vos articles et afficher jusqu'à six cartes — le résultat déjà montré en haut de cet article. ## Conclusion {#conclusion} Un champ `mainTag`, une fonction utilitaire `posts.ts`, un `` que vous avez probablement déjà, et une surcharge de thème : voilà toute la recette d'un bloc « Articles liés » qui ne demande aucune sélection manuelle. Voyez [Organize Your Docusaurus Content with a Custom Series Component](/blog/docusaurus-series) pour les articles qui suivent un ordre de lecture voulu plutôt qu'un tag commun, et [Tags management in Docusaurus](/blog/docusaurus-tags) pour garder vos `mainTag`/`tags` cohérents sur tout le blog. --- # Afficher les articles de blog Docusaurus sous forme de cartes - Guide pas à pas > Apprenez à mettre en valeur vos articles de blog Docusaurus grâce à des mises en page en cartes personnalisables. --- ![Afficher les articles de blog Docusaurus sous forme de cartes - Guide pas à pas](/img/v2/docusaurus_react.webp) **TL;DR** Ce guide complet montre comment transformer les listes d'articles par défaut de Docusaurus en une mise en page en cartes plus agréable à l'œil. Vous allez implémenter un composant `` réutilisable en plusieurs parties, puis créer un composant `` pour afficher les informations propres à chaque article. Le tutoriel vous guide dans la création d'un plugin Docusaurus local pour surcharger la route `/blog/tags/:tag` par défaut, et d'un nouveau composant de page pour afficher les cartes. Il couvre aussi des scripts utilitaires pour récupérer les métadonnées des articles et générer les slugs. Dans cet article, nous allons apprendre à créer une carte à partir d'un article de blog. Si vous [utilisez Docusaurus pour votre blog](/blog/docusaurus-docker-own-blog), vous cherchez peut-être des moyens d'améliorer la mise en page. Plutôt que d'afficher les articles sous forme de liste, voyons comment les afficher sous forme de **cartes**. Dans ce guide, nous allons voir comment faire. Il vous suffira de copier/coller des fichiers dans votre site et de faire une petite modification dans votre fichier de configuration. ![Exemple de carte](./images/cards.webp) Cela va impliquer un grand nombre de fichiers, mais l'idée est ensuite de réutiliser les composants / fichiers créés plus tard. ## Utiliser le composant Card réutilisable de Docux {#using-the-reusable-card-component-of-docux} D'abord, nous allons récupérer le [composant Card réutilisable](https://docuxlab.com/blog/card-component-docusaurus-infima/) de notre ami ![Docux](/img/docux.webp)[Docux](https://github.com/Juniors017). C'est un ensemble de fichiers que vous devez créer ; c'est parti. > **Information:** > > On peut aussi le récupérer sur le site de la [Docusaurus Community](https://docusaurus.community/knowledge/component-library/new/Card/), mais peut-être pas dans sa dernière version. Dans votre site Docusaurus, créez ces 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/CardFooter/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?: string; style?: CSSProperties; children?: ReactNode; } const CardFooter = ({ 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 CardFooter; ``` **src/components/Card/CardHeader/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. 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 CardHeader = ({ 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 CardHeader; ``` **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 ( {alt} ); }; export default CardImage; ``` Vous avez donc maintenant créé six fichiers sur votre disque. Avant d'aller plus loin dans ce tutoriel, jouons un peu avec le composant Card et vérifions que tout a été enregistré correctement. Créez un nouvel article dans votre dossier `blog`, mais attention à l'extension : ce doit être un fichier `.mdx` (parce que nous allons ajouter du code qui doit être interprété). **blog/card.mdx** ```mdx import Card from "@site/src/components/Card"; import CardFooter from "@site/src/components/Card/CardFooter"; import CardImage from "@site/src/components/Card/CardImage";
``` Si vous lancez votre blog maintenant, vous devriez obtenir ceci : ![Carte Docux](./images/docux_sample.webp) > **Note:** > > Si vous voulez récupérer la mignonne image de dinosaure, prenez-la ici et enregistrez-la dans votre dossier `/static/img` sous le nom `docux.webp`. > > Vous pouvez bien sûr réutiliser n'importe quelle autre image. ## Parfait, ça fonctionne. Continuons avec un exemple concret {#nice-it-works-lets-continue-with-a-practical-sample} Dans cet article, nous allons surcharger la page standard qui affiche la liste des tags. Comme vous le savez, Docusaurus sait nativement afficher une page avec [la liste des tags](/blog/docusaurus-tags) utilisés dans vos articles si vous accédez à la page **/blog/tags**. Pour mon propre blog, la page est et vous obtenez quelque chose comme ceci : ![La page des tags](./images/tags.webp) En cliquant sur un tag précis, Docusaurus affiche une liste d'articles pour ce tag, et c'est ce que nous allons changer. Par exemple, ceci : ![Avant notre surcharge du tag](./images/tag_before.webp) > **Information:** > > C'est sympa mais... OK, je dois beaucoup scroller avant de voir tous les articles et de trouver celui qui pourrait m'intéresser. Nous allons remplacer ça par une liste de cartes d'articles. Par exemple, sur mon blog, j'écris aussi à propos de [python](https://www.avonture.be/blog/tags/python) et voici comment j'affiche la page : ![La page du tag Python](./images/tag_python.webp) Cet exemple concret va nous permettre de mettre en œuvre deux concepts de Docusaurus : **le concept de plugins et celui de composants**. ## Surcharger la page /blog/tags/tag en créant un plugin {#overriding-the-blogtagstag-page-by-creating-a-plugin} Créez le fichier ci-dessous. **plugins/docusaurus-plugin-tag-route/index.cjs** ```cjs /** * Docusaurus Plugin: tag-route * * Registers the `/blog/tags/` pages rendered by `TagArticlesPage`. * * Why one route per tag instead of a single `/blog/tags/:tag` (TODO 0092): * Docusaurus's `handleBrokenLinks` resolves links against the list of *registered* * routes, and a parameterized path matches nothing there. With a single `:tag` route * every `` in the corpus 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 tags at build time makes those * links resolvable, so link checking can be switched back on. * * Note this is *not* the blog plugin's own tag listing: that one lives at * `/blog/tags/tags/` (the doubled segment comes from blog/tags.yml's `permalink` * values being prefixed with the `/blog/tags` base), so the two never collide. */ const path = require("path"); const { normalizeUrl } = require("@docusaurus/utils"); const { listTagSlugs } = require("../lib/blog-taxonomy.cjs"); module.exports = function (context) { return { name: "docusaurus-plugin-tag-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 listTagSlugs(context.siteDir)) { actions.addRoute({ // MUST go through `baseUrl`: under a non-default locale it becomes `/fr/`, and a // hardcoded `/blog/tags/` registers a route the locale-prefixed links never // reach. That mismatch reported 2520 broken links on the `fr` build — the routes // existed as `/blog/tags/` while every link pointed at // `/fr/blog/tags/`. See TODO 0119. path: normalizeUrl([context.baseUrl, "blog/tags", slug]), component: "@site/src/components/Blog/Tags/TagArticlesPage", exact: true, }); } }, }; }; ``` Prenez quelques secondes pour lire ce court script : comme vous le voyez, il dit à Docusaurus de ne plus utiliser sa propre page standard `/blog/tags/:tag` mais la nôtre (c'est-à-dire `src/components/Blog/Tags/TagArticlesPage`). Créons-la. ## Créer le TagArticlesPage {#creating-the-tagarticlespage} Créez le fichier ci-dessous. C'est la fonction `TagArticlesPage` qui sera appelée par Docusaurus lorsqu'il affiche un tag précis. **src/components/Blog/Tags/TagArticlesPage.tsx** ```typescript import type { JSX } from "react"; import { createSlug } from "@site/src/components/Blog/utils/slug"; import { useBlogMetadata } from "@site/src/components/Blog/utils/posts"; import { useLocation, matchPath } from "@docusaurus/router"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import { useBaseUrlUtils } from "@docusaurus/useBaseUrl"; import Layout from "@theme/Layout"; import Link from "@docusaurus/Link"; import Translate, { translate } from "@docusaurus/Translate"; import styles from "./styles.module.css"; import PostCard from "@site/src/components/Blog/PostCard"; import Head from "@docusaurus/Head"; import FollowFeed from "@site/src/components/FollowFeed"; import { useTagLabel } from "@site/src/components/Blog/utils/tagsI18n"; import { useSourceLocaleUrls } from "@site/src/components/Blog/utils/localeUrls"; type Tag = string | { label: string }; export default function TagArticlesPage(): JSX.Element { const location = useLocation(); // Hooks must run before any early return. const posts = useBlogMetadata(); const tagLabel = useTagLabel(); const { isDefaultLocale, sourceLabel, sourceLocale, sourceUrl } = useSourceLocaleUrls(); const { withBaseUrl } = useBaseUrlUtils(); // The pattern MUST carry `baseUrl`: under the `fr` locale the pathname is `/fr/blog/tags/` // 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}/blog/tags/:slug`, exact: true, }); const rawTag = match?.params?.slug; if (!rawTag) { return (

No tag specified.

Go back to all tags
); } // Local helper, unrelated to `useTagLabel`: it reads the raw value out of a front-matter // entry, which may be a bare string or a `{ label }` object. What comes out is the KEY. const getTagKey = (t: Tag) => (typeof t === "string" ? t : t.label); // Find original tag name based on slug let displayTag = rawTag; for (const post of posts) { const foundTag = (post.tags || []).find( (t) => createSlug(getTagKey(t)) === rawTag ); if (foundTag) { displayTag = getTagKey(foundTag); break; } } // `displayTag` is the front-matter KEY; this is what the reader sees. Resolving it here, once, // also closes the inconsistency noted below: the

used to print the raw key ("ai") while // the feed button right under it printed the real label ("Artificial Intelligence (AI)"). const displayLabel = tagLabel(displayTag); // Filter and sort posts by slug-matched tag (most recent first) const taggedPosts = posts .filter((post) => post.tags?.some((t) => createSlug(getTagKey(t)) === rawTag) ) .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); // Descending if (taggedPosts.length === 0) { return (

{'No articles found with tag "{label}"'}

Browse all tags
); } const feedUrl = `/blog/tags/${rawTag}/rss.xml`; return (

{"Articles tagged: {label}"}

{/* This route is registered by docusaurus-plugin-tag-route, not by the blog plugin, so Docusaurus injects no feed autodiscovery of its own here — same reasoning as src/components/MarkdownAlternate. */} {/* `feedUrl` is a hand-built path to a per-locale static file, so it needs the prefix; the second points at the source language, which covers the whole blog. Helmet de-duplicates on `href`, so both are kept. */} {!isDefaultLocale && ( )}
{taggedPosts.map((post) => ( ))}
); } ``` Comme vous le voyez tout en haut du fichier `TagArticlesPage.tsx`, nous importons quelques fichiers. Certains existent déjà dans votre site Docusaurus ; d'autres doivent être créés. Il y a trois fichiers à créer. ## Le générateur de slug {#the-slug-generator} **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 } ``` ## La fonction getBlogMetaData {#the-getblogmetadata-function} Il nous faut une fonction pour parcourir tous nos articles de blog existants (tous les fichiers `.md` ou `.mdx` dans le dossier `blog`) et récupérer des informations comme le titre de l'article, l'image, le slug, etc. **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 './getBlogMetadata'; * const posts = getBlogMetadata(); * ``` * * ⚠️ Note: * This function is intended for use in static site generation or client-side rendering * where Webpack's `require.context` is available. */ export interface BlogPostMetadata { title: string; description?: string; image?: string; draft: boolean; unlisted: boolean; permalink: string; tags: string[]; mainTag: string | null; authors: string[]; date?: string; series: string | null; } const posts = require.context("../../../../blog", true, /\.mdx?$/); export function getBlogMetadata(): BlogPostMetadata[] { return posts .keys() .map((key): BlogPostMetadata => { const post = posts(key) as { frontMatter: Record }; const dir = key.replace(/\/index\.mdx?$/, "").replace(/^\.\//, ""); let permalink: string; if (typeof post.frontMatter.slug === "string") { permalink = post.frontMatter.slug.startsWith("/") ? post.frontMatter.slug : `/blog/${post.frontMatter.slug.replace(/^\//, "")}`; } else { permalink = `/blog/${dir}/`; } let imageUrl = post.frontMatter.image as string | undefined; if (imageUrl && imageUrl.startsWith("./")) { imageUrl = `/blog/${dir}/${imageUrl.replace("./", "")}`; } return { title: post.frontMatter.title as string, description: post.frontMatter.description as string | undefined, image: imageUrl, draft: Boolean(post.frontMatter.draft), unlisted: Boolean(post.frontMatter.unlisted), permalink, tags: (post.frontMatter.tags as string[]) || [], mainTag: (post.frontMatter.mainTag as string) || null, authors: (post.frontMatter.authors as string[]) || [], date: post.frontMatter.date as string | undefined, series: (post.frontMatter.series as string) || null, }; }) .filter(Boolean); } ```` ## Notre composant PostCard {#our-postcard-component} Et, enfin, il nous faut notre composant PostCard ; celui qui affichera un article de blog sous forme de carte. Vous voyez, en haut du code, que nous importons quelques parties du [composant Card](https://docusaurus.community/knowledge/component-library/new/Card/) de Docux. **src/components/Blog/PostCard/index.tsx** ```typescript /** * ♣️ PostCard component * * A reusable Docusaurus component that displays a blog post preview card. * It shows the post image (with fallback), title (as a link), and description. * * Accessibility: * - The title is wrapped in a semantic link with an aria-label for screen readers. * - The image includes alt and title attributes. * * Styling: * - Uses Infima utility classes for layout and spacing. * - Custom styles can be applied via `styles.module.css`. * * Location: src/components/Blog/PostCard/index.tsx */ 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 styles from "./styles.module.css"; interface FormattedDateProps { date?: string; layout: "big" | "small"; } /** Renders a formatted date string. */ function FormattedDate({ date, layout }: FormattedDateProps): JSX.Element | null { if (!date) { return null; } return (

{new Date(date).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric", })}

); } interface Post { permalink: string; image?: string; title: string; description?: string; date?: string; } interface Props { post: Post; layout?: "big" | "small"; defaultImage?: string; } export default function PostCard({ post, layout = "big", defaultImage = "/img/default.jpg", }: Props): JSX.Element { const { permalink, image, title, description, date } = post; if (layout === "small") { return (
{title}

{title}

{description && (
{description}
)}
Read more
); } // Default "big" layout return (

{title} →

{description &&

{description}

}
); } ``` Pour terminer, nous allons créer un fichier CSS pour pouvoir personnaliser un peu l'ensemble : **src/components/Blog/PostCard/styles.module.css** ```css .description { font-size: 12px; } .date { color: #888; font-size: 0.95em; margin-bottom: 8; font-style: italic; } ``` ## Il faut charger notre plugin {#we-need-to-load-our-plugin} Nous y sommes presque. Rappelez-vous : le tout premier fichier que nous avons créé était `plugins/docusaurus-plugin-tag-route/index.cjs` ; un plugin. Modifiez votre fichier `docusaurus.config.js` existant comme illustré ci-dessous : **docusaurus.config.js** ```javascript import pluginTagRoute from "./plugins/docusaurus-plugin-tag-route/index.cjs"; const config = { // [...] plugins: [ // [...] [pluginTagRoute, {}], ], // [...] }; ``` C'est-à-dire : 1. Vous devez importer le plugin en haut de votre fichier 2. Puis, dans la section `plugins`, vous devez ajouter une nouvelle ligne pour charger le plugin importé. Nous avons presque fini ! > **Caution:** > > Arrêtez et relancez votre serveur Docusaurus. Dans mon cas ([j'utilise Docker](/blog/running-docusaurus-with-docker)), il me suffit d'arrêter et de relancer mon container. Si tout se passe bien après le redémarrage (aucun message d'erreur), rendez-vous sur la page des tags de votre site et ouvrez un tag. Vous devriez maintenant voir vos articles sous forme de cartes. Félicitations ! Dans [les prochains articles](/blog/docusaurus-relatedposts), nous réutiliserons ce concept de cartes d'articles. Restez à l'écoute ! ## Bonus {#extra} Si vous voulez que vos articles de blog soient listés du plus récent au plus ancien, utilisez ce script. Mais assurez-vous que chacun de vos articles possède bien un champ `date` dans son front matter YAML. **src/components/Blog/Tags/TagArticlesPage.tsx** ```typescript import { createSlug } from "@site/src/components/Blog/utils/slug"; import { getBlogMetadata } from "@site/src/components/Blog/utils/posts"; import { useLocation } from "@docusaurus/router"; import Layout from "@theme/Layout"; import Link from "@docusaurus/Link"; import PostCard from "@site/src/components/Blog/PostCard"; function getTagFromPathname(pathname: string): string | null { const match = pathname.match(/\/blog\/tags\/([^/]+)/); return match ? decodeURIComponent(match[1]) : null; } export default function TagArticlesPage() { const location = useLocation(); const rawTag = getTagFromPathname(location.pathname); if (!rawTag) { return (

No tag specified.

Go back to all tags
); } const posts = getBlogMetadata(); // Find original tag name based on slug const allTags = Array.from(new Set(posts.flatMap((post) => post.tags || []))); const displayTag = allTags.find((t) => createSlug(String(t)) === rawTag) || rawTag; // highlight-start // Filter and sort posts by slug-matched tag (most recent first) const taggedPosts = posts .filter((post) => post.tags?.some((t) => createSlug(String(t)) === rawTag)) .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); // Descending // highlight-end if (taggedPosts.length === 0) { return (

No articles found with tag "{displayTag}"

Browse all tags
); } return (

Articles tagged: {displayTag}

{taggedPosts.map((post) => ( ))}
); } ``` ---