# 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. --- ![Modifier la façon dont Docusaurus génère les balises img](/img/v2/docusaurus_component.webp) **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 `![Alt text](./img/example.webp)` 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 (
{alt
); } ``` 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 ![A happy meerkat](./images/happy.webp) ``` 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 `![Alt text](./img/example.webp)` 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 (`![alt](url)`) 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 `![Example image](./img/example.webp)`, ou absolus (`![Example image](/img/example.webp)`), 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. --- ![Surcharger la génération des balises img avec Docusaurus](/img/v2/docusaurus_component.webp) **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é. --- ![Organisez votre contenu Docusaurus avec un composant Series personnalisé](/img/v2/docusaurus_component.webp) **TL;DR** Ce guide détaillé vous montre comment implémenter une fonctionnalité complète de « série d'articles » dans Docusaurus. Vous apprendrez à créer un composant `` qui affiche une bannière de série sur chaque article, ainsi qu'une page `/series` dédiée qui liste toutes vos séries sous forme de cartes. Le tutoriel couvre la création de plusieurs composants React, des helpers utilitaires pour récupérer les données des articles, la surcharge de composants du thème Docusaurus via le swizzling, et la construction d'un plugin local personnalisé pour gérer les routes dynamiques des séries. Enfin, il montre comment ajouter des descriptions et des images personnalisées à vos séries via un fichier de données centralisé. Si vous utilisez Docusaurus depuis longtemps, vous avez peut-être remarqué qu'il n'est pas possible de relier des articles entre eux sous forme de série. Ce serait pratique d'écrire un premier article, un deuxième, un troisième, etc., et de pouvoir dire à Docusaurus que ces articles font partie de la même série. Cela aura aussi un impact positif sur votre SEO (en créant un meilleur maillage interne) et gardera vos lecteurs plus longtemps. *Pour des articles liés entre eux mais qui ne forment pas une série ordonnée, [Displaying related posts below our Docusaurus article](/blog/docusaurus-relatedposts) joue le même rôle sur base des tags partagés.* Ce n'est pas possible nativement, alors créons un composant pour ça. > **Attention, spoiler:** > > Cliquez simplement sur le lien [Séries](/series) pour voir à quel point le composant **SeriesPosts** est sympa. Vous allez apprendre à le faire ici. Voici le composant en action, une fois branché — une bannière annonçant la série, directement sur l'article : ![Notre composant tourne maintenant](./images/our-component-is-running.webp) La partie 2 va plus loin et ajoute une page `/series` complète qui liste chaque série sous forme de cartes : ![Une page avec toutes les séries](./images/series-page.webp) Construisons les deux, en commençant par la bannière. > **Information:** > > Vous avez déjà vu cet article ? Regardez en haut. Vous verrez une bannière qui dit : « Cet article fait partie de la série **Creating Docusaurus components** ». C'est exactement ce que nous allons apprendre à faire tout de suite. Ce long article comporte deux sections principales : l'une sur la création du composant, l'autre sur la navigation dans le blog. *Il combine les deux techniques utilisées partout dans cette série : le swizzling d'un composant de thème (comme dans [la notice signalant un article de plus d'un an](/blog/docusaurus-old-notice)) et l'écriture d'un plugin local (comme dans [le plugin de recherche & remplacement](/blog/docusaurus-plugin-replace)).* ## Partie 1 - Création du composant SeriesPosts {#part-1---creation-of-the-seriesposts-component} Dans ce premier chapitre, nous allons créer notre composant `SeriesPosts` afin qu'à l'ouverture d'un article, vous voyiez un message « Cet article fait partie de... » et la liste des autres articles de cette série. **Cela implique plusieurs choses :** 1. Nous aurons besoin de quelques helpers. 2. Nous aurons besoin d'un nouveau composant. 3. Les articles de blog devront avoir une entrée `series` dans leur YAML front matter, et 4. Nous devrons surcharger la façon standard d'afficher un article de blog. ### 1.1. Nous avons besoin de quelques helpers {#11-we-need-some-helpers} Si vous ne l'avez pas encore, créez le fichier `src/components/Blog/utils/posts.ts`. Il contiendra une fonction helper que nous pourrons réutiliser pour plusieurs composants. Ce helper va scanner chaque fichier Markdown avec une extension `.md` ou `.mdx` dans le sous-dossier `blog`. Pour chaque fichier, le script va regarder le YAML front matter et utiliser certaines de ses propriétés. Par exemple : * Si l'article a `draft: true` ou `unlisted: true`, il sera ignoré. * Si l'article a un `slug`, il sera utilisé ; sinon, le slug sera généré par le code. * Si l'article a une image associée, elle sera utilisée ; sinon, une image par défaut sera utilisée. * Ensuite, le helper retournera la liste des articles et leurs propriétés. Certaines propriétés sont personnalisées, comme `mainTag` (voir mon article [Displaying related posts below our Docusaurus article](/blog/docusaurus-relatedposts)) et, pour notre besoin actuel, la propriété `series`. Donc, copiez-collez simplement le contenu du fichier ci-dessous et créez le fichier `src/components/Blog/utils/posts.ts` dans la structure de votre projet. **src/components/Blog/utils/posts.ts** ````typescript /** * getBlogMetadata * * Extracts metadata from all MDX blog posts located in the `/blog` directory. * Uses Webpack's `require.context` to dynamically load and parse frontmatter * from each post, returning a structured array of metadata objects. * * Behavior: * - Resolves permalinks based on `slug` or folder structure * - Normalizes image paths for static assets * - Filters out invalid or missing entries * * Returned metadata includes: * - `title`: Post title * - `description`: Short summary * - `image`: Resolved image path * - `draft`: Boolean flag for unpublished posts * - `unlisted`: Boolean flag for hidden posts * - `permalink`: URL path to the post * - `tags`: Array of tags * - `mainTag`: Primary tag (optional); used by the RelatedBlogPost component * - `authors`: Array of author names * - `date`: Publication date * - `series`: Series name (optional); used by the SeriesBlogPost component * * Usage: * ```ts * import { getBlogMetadata } from './posts'; * const posts = getBlogMetadata(); * const withDrafts = getBlogMetadata({ includeDrafts: true }); * ``` * * By default, posts flagged `draft` or `unlisted` are excluded so consumers don't have to * remember to filter them out themselves. Pass `includeDrafts`/`includeUnlisted` to opt back in * (e.g. author-facing counters that need to show "in progress" articles). * * Note: * This function is intended for use in static site generation or client-side rendering * where Webpack's `require.context` is available. */ /** * A tag as it reaches these helpers: a plain slug string from front matter, or — * defensively, since related.ts and TagArticlesPage both handle it — a * Docusaurus-normalised tag object. */ export type BlogTag = string | { label: string }; export interface BlogPostMetadata { authors: string[]; blueskyRecordKey: string | null; /** * ISO date string from front matter, e.g. "2026-08-28". Every article under blog/ carries * one (blog authoring convention, and the blog plugin itself refuses a post without a * date), so this is not optional. * * Runtime note: plugins/frontmatter-loader actually hands back a real `Date` instance for * a typical unquoted YAML date (see that plugin's own header comment) — not a string. * Declaring `string` here anyway matches the pre-existing contract every niveau 1-4 `.tsx` * consumer already compiles against (`Post.date?: string`, …); every one of them passes * this straight into `new Date(...)`, which accepts a `Date` argument just as well as a * string, so nothing behavioral changes. Widening this to the more literally accurate type * would ripple into every already-migrated consumer — out of scope here. */ date: string; description: string | undefined; draft: boolean; image: string | undefined; mainTag: string | null; permalink: string; series: string | null; tags: BlogTag[]; title: string; unlisted: boolean; } interface RawFrontMatter { title?: string; slug?: string; image?: string; authors?: string[]; blueskyRecordKey?: string; date?: BlogPostMetadata["date"]; description?: string; draft?: boolean; mainTag?: string; series?: string; tags?: BlogTag[]; unlisted?: boolean; } // The `!!…!` prefix is load-bearing, do not simplify it away: it bypasses the // rules configured by @docusaurus/plugin-content-blog and reads the files with // plugins/frontmatter-loader instead. Without it, Webpack MDX-compiles every // file under `blog/`, and any article flagged `draft: true` crashes the // production build with "Blog post not found for filePath=…" — because the blog // plugin drops drafts from its post list while this context still pulls them in. // See plugins/frontmatter-loader/index.cjs for the full story. import { useTranslationState, slugFromPermalink } from "./translations"; const posts = require.context( "!!../../../../plugins/frontmatter-loader/index.cjs!../../../../blog", true, /\.mdx?$/, ); // Second corpus: the French translations. Needed because the context above reads `blog/` — the // English source — whatever locale is being rendered, so every card, list and related-post // block showed an English title on a French page even for articles that ARE translated. // // The locale is spelled out rather than computed: `require.context` is a webpack construct and // its path must be statically analysable. Adding a locale means adding a context here. const translatedPosts = require.context( "!!../../../../plugins/frontmatter-loader/index.cjs!../../../../i18n/fr/docusaurus-plugin-content-blog", true, /\.mdx?$/, ); /** slug -> the translated `title`/`description`, for the one non-default locale we ship. */ function buildTranslatedFrontMatter(): Map { 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:"} } >
    {posts.map((post) => { const isCurrent = post.permalink === excludePermalink; return (
  • {isCurrent ? ( {post.title} ) : ( {post.title} )}
  • ); })}
); } ``` 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 (
{title}

{title}

{counter &&
{counter}
} {formattedDate &&

{formattedDate}

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

{title} →

{description &&

{description}

} {counter &&

{counter}

} {(formattedDate || readingTime) && (

{formattedDate && {formattedDate}} {readingTime && ( {" "} ·{" "} {"{minutes} min read"} )}

)}
); } ``` 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 ( {alt} ); }; export default CardImage; ``` ### Tester notre URL /series {#testing-our-series-url} Tout est maintenant en place pour notre page web `/series`. Nous avons créé `src/pages/series.mdx` et toutes ses dépendances. Maintenant, accédez simplement à l'URL `/series`. Sur mon localhost, c'est `http://127.0.0.1:3000/series`. Vous devriez obtenir la même page que celle montrée en haut de cet article, sans mise en forme pour l'instant. Chouette, non ? ### 2.4 Ajouter une entrée pour la page des séries à votre blog {#24-adding-an-entry-for-the-series-page-to-your-blog} Éditez votre fichier `docusaurus.config.js` et, dans la section `navbar` -> `items`, ajoutez un lien vers `/series` : **docusaurus.config.js** ```javascript // [...] const config = { themeConfig: ({ navbar: { items: [ // [...] { href: "/series", label: "My series", }, // [...] ], }, }), }; export default config; ``` ![Mes séries](./images/my-series.webp) ### 2.5 Gérer l'URL /series/slug {#25-handling-the-seriesslug-url} Ok, ça fonctionne, mais depuis la page `/series`, si vous cliquez sur une série, vous obtenez cette erreur : ![Page 404 en visitant une série spécifique](./images/page-404.webp) Nous venons donc de créer la page `/series`. Mais quand votre lecteur clique sur une série pour obtenir la liste des articles, l'URL devient quelque chose comme `/series/the-name-of-the-series`. Il faut donc dire à Docusaurus comment gérer ces nouvelles URL. Par défaut, Docusaurus ne sait pas quoi en faire. Pour ça, nous aurons besoin d'un plugin. Créez le fichier `plugins/docusaurus-plugin-series-route/index.cjs` avec ce contenu : **plugins/docusaurus-plugin-series-route/index.cjs** ```cjs /** * Docusaurus Plugin: series-route * * Purpose: * This plugin adds the routes for displaying articles by series slug under the * `/series/` 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. ![Affichage d'une série spécifique](./images/a-series.webp) ## Partie 3 - Ajouter un fichier /src/data/series.js pour la personnalisation {#part-3---adding-a-srcdataseriesjs-file-for-customization} Le composant fonctionne bien pour l'instant, mais il nous manque deux fonctionnalités importantes : 1. La possibilité d'écrire une description pour la série. 2. La possibilité d'utiliser une image personnalisée. Créons un nouveau fichier appelé `/src/data/series.js` avec un contenu comme celui-ci : **/src/data/series.js** ```javascript const SERIES_DATA = [ { name: "Bluesky Docusaurus component", description: "Build and integrate Bluesky-powered components inside Docusaurus.", image: "/img/series/bluesky.webp", }, { name: "Coding using a devcontainer", description: "Develop consistently across machines using VS Code Dev Containers.", image: "/img/series/devcontainer.webp", }, { name: "Create your joomla website using Docker", description: "Run Joomla locally using Docker and streamline your web development.", image: "/img/series/joomla.webp", }, { name: "Creating Docusaurus components", description: "Custom components, hooks, layouts, and reusable UI for your Docusaurus site.", image: "/img/series/components.webp", }, // ... ]; export default SERIES_DATA; ``` Vous devez respecter une règle importante : la valeur que vous indiquez dans la clé `name` doit correspondre à la valeur de votre `series` telle qu'indiquée dans votre YAML front matter. Par exemple, pour cet article, ma clé YAML est `series: Creating Docusaurus components`, je dois donc la retrouver dans mon `/src/data/series.js`. Le composant pourra alors trouver la `description` et l'`image` à utiliser. Maintenant, votre page Series ressemblera à ceci : ![La page des séries avec une description](./images/with-description.webp) ## Conclusion {#conclusion} C'était clairement compliqué. Beaucoup de fichiers à créer, d'autres à mettre à jour, et quelques éléments de configuration à ajuster, mais le résultat ne valait-il pas le coup ? Vous avez maintenant un blog où vous pouvez écrire des articles « piliers » avec un contenu si dense que vous pouvez facilement le découper en plusieurs parties et le proposer à vos lecteurs sous forme de série. Vos lecteurs seront guidés pas à pas, ce qui leur permettra de mieux comprendre les différentes étapes à suivre. Côté SEO, vous gagnerez en visibilité grâce à ces articles « piliers », entre autres, mais aussi parce que vous améliorerez la structure de navigation de vos pages. Les moteurs de recherche apprécient beaucoup ça. --- # 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. --- ![Ajouter un bouton « retour en haut » dans les articles Docusaurus](/img/v2/go_top_banner.webp) **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](/img/docux.webp) [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 > > ![Le bouton retour en haut avec le suricate, en bas à droite, sur un article défilé](./images/go_top_button.webp) De plus près, c'est un bouton rond qui contient l'icône que vous lui donnez : ![Gros plan du bouton rond de retour en haut affichant l'icône du suricate](./images/go_top_button_zoom.webp) 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. --- ![Créer un plugin de recherche/remplacement pour Docusaurus](/img/v2/docusaurus_component.webp) **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. --- ![Un composant pour afficher des snippets de code dans un blog Docusaurus](/img/v2/docusaurus_react.webp) **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 (
    <>
      
        
        
          {lines.map((lineTokens, i) => {
            const lineNum = String(i + 1);
            const explanation = eli5[lineNum];
            const isActive = activeLine === lineNum;

            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](/img/docux.webp) [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 (
{title}

{title}

{counter &&
{counter}
} {formattedDate &&

{formattedDate}

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

{title} →

{description &&

{description}

} {counter &&

{counter}

} {(formattedDate || readingTime) && (

{formattedDate && {formattedDate}} {readingTime && ( {" "} ·{" "} {"{minutes} min read"} )}

)}
); } ``` **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 --- ![Un composant Docusaurus qui prévient le lecteur que l'article a plus d'un an](/img/v2/old_blog_notice.webp) **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 --- ![Afficher le changelog de votre article](/img/v2/changelog.webp) **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 : ![Demo](./images/sample.webp) ## 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 (

{" "} Changelog

{sortedUpdates.map((update, i) => { const isLatest = i === 0; return (
{isLatest && ( Latest )}
); })}
); } ``` **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 (
// [...] {isBlogPostPage && ( <> // [...] )} // [...]
); } ``` ## 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. --- ![Injecter de l'ASCII art dans toutes les pages HTML générées par Docusaurus](/img/v2/ascii_art_html.webp) **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 `` : ![HTML meerkat](./images/html_meerkat.webp) > **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. --- ![Best Practice : personnaliser le flux RSS de Docusaurus pour un contenu complet et des images](/img/v2/docusaurus_rss_enhanced.webp) **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). ![Le fichier rss.xml par défaut avec Docusaurus](./images/default_rss.webp) > **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 : ![Ce que je voyais dans Inoreader](./images/before_inoreader.webp) Dans les chapitres suivants, nous allons voir comment améliorer notre `rss.xml`. Une fois fait, vous obtiendrez ceci : ![Après notre optimisation](./images/after_inoreader.webp) Et en ouvrant un article, nous obtiendrons ceci : ![Affichage d'un article](./images/article_inoreader.webp) ## 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} params.items - items to serialize, newest first. * @param {string} params.siteUrl - site origin, no trailing slash. * @param {string} params.baseUrl - Docusaurus baseUrl. * @param {object} params.authorsData - parsed `blog/authors.yml`. * @param {boolean} params.includeContent - emit `` bodies. * @param {boolean} params.includeImages - emit `` and inline ``. * @param {boolean} [params.verbose] - per-item logging (site-wide feed only). * @returns {string} the complete RSS 2.0 document. */ function buildRssXml({ Feed, channel, items, siteUrl, baseUrl, authorsData, includeContent, includeImages, verbose = false, }) { const feed = new Feed({ title: channel.title, description: channel.description, id: channel.id, link: channel.link, language: channel.language, // Drives . Deliberately the newest post's date rather // than `new Date()`: a wall-clock value rewrote this 1.3 MB file on // every build, so the deploy re-uploaded it even when no article had // changed. The date of the most recent entry is also the more honest // answer to "when did this feed last actually change?". Per-topic feeds // take *their own* newest entry for the same reason — a shared clock // would dirty all of them on every build. updated: items.length ? new Date(items[0].date) : new Date(0), }); // Parallel to `items`: the dc:creator markup each entry needs, consumed by the // cheerio pass below. It used to be smuggled through `feed.addItem()` as a // `customRssData` key and read back off the *source* item — which never // carried it, so the injection silently no-opped and no feed this plugin ever // wrote had a single in it. Keeping it here, where it is // produced, is what makes the feature actually work. const creatorMarkup = []; items.forEach((item) => { // Resolve authors const authorKeys = Array.isArray(item.frontMatter.authors) ? item.frontMatter.authors : item.frontMatter.authors ? [item.frontMatter.authors] : []; const rssAuthors = authorKeys .map((key) => authorsData[key]) .filter(Boolean) .map((a) => ({ name: a.name })); creatorMarkup.push( rssAuthors .map((a) => ``) .join(""), ); // Log to confirm data is ready if (verbose && rssAuthors.length > 0) { console.log( `[BlogFeedPlugin] ✍️ Post "${ item.title }" - Found authors: ${rssAuthors.map((a) => a.name).join(", ")}`, ); } // Handle enclosure image and description injection const postImageUrl = item.frontMatter?.image; const absoluteImageUrl = postImageUrl ? absoluteUrl(siteUrl, baseUrl, postImageUrl) : null; const mimeType = absoluteImageUrl ? getMimeTypeFromUrl(absoluteImageUrl) : null; let descriptionWithImage = cleanProblemChars(item.description || ""); if (includeImages && absoluteImageUrl) { descriptionWithImage = `${cleanProblemChars(
          item.title,
        )}` + descriptionWithImage; } const itemLink = absoluteUrl(siteUrl, baseUrl, item.permalink); feed.addItem({ title: cleanProblemChars(item.title), id: item.permalink, link: itemLink, category: Array.isArray(item.frontMatter.tags) ? item.frontMatter.tags.map((t) => ({ name: t })) : item.frontMatter.tags ? [{ name: item.frontMatter.tags }] : undefined, description: descriptionWithImage, content: includeContent ? cleanProblemChars(item.fullContentBody || "") : undefined, date: new Date(item.date), author: rssAuthors, // Add enclosure tag if image is present ...(includeImages && absoluteImageUrl && mimeType && { enclosure: { url: absoluteImageUrl, type: mimeType, length: 0, // Length is often 0 for dynamically generated feeds }, }), }); }); let rssContent = feed.rss2(); // --- CUSTOM XML MANIPULATION (Cheerio) --- // The 'feed' library does not natively support dc:creator with CData, // so we use Cheerio for post-generation injection. // 1. Load the content const $ = cheerio.load(rssContent, { xmlMode: true }); // 2. Inject the namespace cleanly into the root element $("rss").attr("xmlns:dc", "http://purl.org/dc/elements/1.1/"); // 3. Get ALL generated XML items // In XML mode, the path is typically rss > channel > item const xmlItems = $("channel > item"); if (verbose) { console.log(`[BlogFeedPlugin] 🔧 Injecting DC tags into ${xmlItems.length} items...`); } // 4. Inject each item's dc:creator. Positional matching is safe: the feed // library serializes items in the order they were added, which is the // order of `items` — and so of `creatorMarkup`. xmlItems.each((index, element) => { const markup = creatorMarkup[index]; if (markup) $(element).append(markup); }); // 5. Get the final XML content rssContent = $.xml(); // Apply a custom XML declaration and stylesheet reference for better browser // rendering. The href is ABSOLUTE on purpose: a relative "rss.xsl" only // resolves for the feed sitting at /blog/, and would 404 for every per-topic // feed one directory deeper — the browser then shows raw XML. return rssContent.replace( /^<\?xml[^>]+\?>/, `\n`, ); } // --- Plugin ------------------------------------------------------------------ module.exports = function blogFeedPlugin(context, options = {}) { const { maxItems = 20, includeContent = true, includeImages = true, stripSelectors = [], ignorePatterns = ["**/_archived/**"], emitTopicFeeds = true, topicMaxItems = 10, minPostsPerTagFeed = 1, } = options; return { name: "blog-feed-plugin", async postBuild({ siteConfig, outDir, siteDir, i18n }) { try { // AJOUTEZ CETTE LIGNE ICI 👇 const { Feed } = await import("feed"); console.log(`[BlogFeedPlugin] 🚀 Generating RSS feed...`); const blogDir = path.join(siteDir, "blog"); const baseUrl = siteConfig.baseUrl || "/"; const siteUrl = siteConfig.url?.replace(/\/+$/g, "") || ""; const blogBasePath = posixJoin(baseUrl, "blog"); if (!siteUrl) { console.error( `[BlogFeedPlugin] siteConfig.url is missing. Please set it to your site's origin.`, ); return; } if (!fs.existsSync(blogDir)) { console.error(`[BlogFeedPlugin] Blog source directory not found: ${blogDir}`); return; } // --- Load Authors --- const authorsFile = path.join(siteDir, "blog", "authors.yml"); let authorsData = {}; if (fs.existsSync(authorsFile)) { authorsData = yaml.load(fs.readFileSync(authorsFile, "utf8")); console.log(`[BlogFeedPlugin] Loaded authors from ${authorsFile}`); } // --- Load Tags --- // Only for the human-readable label and description of a per-tag feed's // channel; the grouping itself works off the posts' own front matter. const tagsFile = path.join(siteDir, "blog", "tags.yml"); let tagsData = {}; if (fs.existsSync(tagsFile)) { tagsData = yaml.load(fs.readFileSync(tagsFile, "utf8")) || {}; } const postFiles = glob.sync("**/*.{md,mdx}", { cwd: blogDir, ignore: ignorePatterns, }); // Collect raw metadata from blog files const rawMetadataItems = await Promise.all( postFiles.map(async (relativeFilePath) => { const fullPath = path.join(blogDir, relativeFilePath); const fileContent = await fs.readFile(fullPath, "utf8"); const contentData = frontMatter(fileContent); const { attributes } = contentData; // Drafts and unlisted posts get no HTML page in the build, so they // can never feed the RSS body — skip them before we go looking for // a file that is not there. if (attributes.draft === true || attributes.unlisted === true) { return null; } const date = attributes.date || fs.statSync(fullPath).birthtime; const finalSlug = computeSlug(attributes, relativeFilePath); // Ignore files that map to "index" or have no slug if (!finalSlug || finalSlug === "index") return null; const permalink = posixJoin(blogBasePath, finalSlug); return { title: attributes.title || finalSlug, description: attributes.description || "No description", date, permalink, frontMatter: attributes, finalSlug, }; }), ); const metadataItems = rawMetadataItems.filter(Boolean); if (!metadataItems.length) { console.log(`[BlogFeedPlugin] No blog posts found.`); return; } // Collect full HTML content for each post (required for RSS body) const feedItemsWithContent = await Promise.all( metadataItems.map(async (item) => { const fullContentBody = await getArticleHtml( item.permalink, outDir, stripSelectors, baseUrl, ); return { ...item, fullContentBody }; }), ); // Filter out drafts (published: false) and articles with no content body let publishedFeedItems = feedItemsWithContent .filter((item) => item.frontMatter.published !== false && item.fullContentBody) .sort((a, b) => new Date(b.date) - new Date(a.date)); // In a non-default locale, only articles that really are translated belong in the feed. // Docusaurus's i18n fallback means every English article also has a `/fr/` URL, so // without this the French feed would advertise 257 English posts as French content — // the same duplicate-content problem plugins/i18n-seo-guard solves for the HTML pages. // See TODO 0119. const currentLocale = i18n?.currentLocale ?? siteConfig.i18n?.defaultLocale; if (currentLocale && currentLocale !== siteConfig.i18n?.defaultLocale) { const { collectTranslations, } = require("../translations-manifest-plugin/index.cjs"); const translated = new Set(collectTranslations(siteDir)[currentLocale] ?? []); // `finalSlug` is the item's own slug, already computed upstream — use it rather than // re-deriving one from the permalink, which silently matched nothing and emptied the // whole feed on the first attempt. publishedFeedItems = publishedFeedItems.filter((item) => translated.has(item.finalSlug), ); console.log( `[BlogFeedPlugin] locale ${currentLocale}: ${publishedFeedItems.length} translated article(s) in the feed.`, ); } const language = currentLocale || siteConfig.i18n?.defaultLocale || "en-US"; // --- Site-wide feed -------------------------------------------------- const finalFeedItems = publishedFeedItems.slice(0, Math.max(1, Number(maxItems))); const rssPath = path.join(outDir, "blog", "rss.xml"); await fs.ensureDir(path.dirname(rssPath)); await fs.writeFile( rssPath, buildRssXml({ Feed, channel: { title: siteConfig.title, description: siteConfig.tagline || "Personal blog feed", id: siteUrl, link: absoluteUrl(siteUrl, baseUrl, "blog"), language, }, items: finalFeedItems, siteUrl, baseUrl, authorsData, includeContent, includeImages, verbose: true, }), ); console.log( `[BlogFeedPlugin] ✅ RSS feed written to ${rssPath} (${finalFeedItems.length} items).`, ); if (!emitTopicFeeds) return; // --- Per-topic feeds ------------------------------------------------- // // One feed per tag and per series, so a reader can watch a single subject // instead of the whole blog. Two deliberate differences from the // site-wide feed: // // * no . The site-wide feed weighs ~45 KB per item // because it carries every article's full HTML; replaying that // across ~75 topic feeds would add tens of megabytes of duplicated // content to build/. Descriptions only keeps each one in atom.xml // territory (tens of KB), and a topic feed's job is to announce a // post, not to replace it. // * a lower item count (`topicMaxItems`), for the same reason. // // The expensive part — reading and cheerio-parsing every post's built // HTML — already happened above for the site-wide feed, so these cost // serialization and I/O only. const topicFeeds = []; for (const group of groupItemsByTag(publishedFeedItems).values()) { // Raising this above 1 strands the article action bar: it offers the // feed of the post's own mainTag without knowing how many posts share // it, so a skipped tag becomes a 404 for the reader who clicks. Only // raise it together with a way for the UI to know which feeds exist. // // Thin feeds are cheap and not useless anyway: a subscriber signs up // for what comes next, not for the archive depth, and 49 description- // only tag feeds weigh about as much as one copy of the site-wide one. if (group.items.length < minPostsPerTagFeed) continue; const meta = tagsData[group.label] || {}; topicFeeds.push({ outPath: path.join(outDir, "blog", "tags", group.slug, "rss.xml"), channel: { title: `${siteConfig.title} — ${meta.label || group.label}`, description: meta.description || `Posts tagged “${meta.label || group.label}” on ${siteConfig.title}`, id: absoluteUrl(siteUrl, baseUrl, `blog/tags/${group.slug}`), link: absoluteUrl(siteUrl, baseUrl, `blog/tags/${group.slug}`), language, }, items: group.items, }); } for (const group of groupItemsBySeries(publishedFeedItems).values()) { // No threshold for series: a series is by definition a sequence, and // "tell me when the next episode lands" is the strongest reason to // subscribe this blog has to offer. topicFeeds.push({ // Next to the page it belongs to — the series route is /series/, // not /blog/series/. outPath: path.join(outDir, "series", group.slug, "rss.xml"), channel: { title: `${siteConfig.title} — ${group.label}`, description: `Every post in the “${group.label}” series on ${siteConfig.title}`, id: absoluteUrl(siteUrl, baseUrl, `series/${group.slug}`), link: absoluteUrl(siteUrl, baseUrl, `series/${group.slug}`), language, }, items: group.items, }); } await Promise.all( topicFeeds.map(async ({ outPath, channel, items }) => { const feedItems = items.slice(0, Math.max(1, Number(topicMaxItems))); await fs.ensureDir(path.dirname(outPath)); await fs.writeFile( outPath, buildRssXml({ Feed, channel, items: feedItems, siteUrl, baseUrl, authorsData, includeContent: false, includeImages, }), ); }), ); console.log( `[BlogFeedPlugin] ✅ ${topicFeeds.length} per-topic feeds written (tags + series).`, ); } catch (err) { console.error(`[BlogFeedPlugin] RSS feed generation failed:`, err); } }, }; }; ``` ### Mettre à jour votre configuration {#update-your-configuration} Mettez à jour votre `docusaurus.config.js` et faites deux choses. 1. Nous devons désactiver la génération du flux RSS par Docusaurus 2. Nous devons implémenter notre propre plugin Voyez ci-dessous comment procéder : **docusaurus.config.js** ```javascript // [...] presets: [ [ "classic", ({ blog: { // ... feedOptions: { type: ["atom"], }, }), ], ], plugins: [ // [...] [ "./plugins/blog-feed-plugin/index.js", { maxItems: 20 }, ], ], ``` > **atom peut rester:** > > Dans `feedOptions`, vous pouvez garder `type: ["atom"]` si vous l'avez, mais supprimez `rss` si vous le voyez. > > Il existe deux types de flux, l'ancien (**rss**) et le plus récent (**atom**). ### Lancer le plugin {#run-the-plugin} Une fois fait, lancez simplement `yarn run build` dans un terminal pour générer la version statique de votre site. ```bash $ yarn run build ``` Pendant cette étape, Docusaurus appellera notre plugin pour générer le fichier `rss.xml` au lieu d'utiliser sa propre fonctionnalité standard. Une fois terminé, si vous voulez voir le fichier en action sur votre machine, lancez `yarn run serve` pour démarrer votre site statique Docusaurus puis rendez-vous sur `http://localhost/blog/rss.xml` (votre URL locale) et vous verrez votre XML. ### Visualiser le nouveau rendu {#visualize-the-new-lookfeel} Utilisons un outil en ligne comme . Dans la partie gauche, copiez/collez le contenu de votre `blog/rss.xml` et regardez comment il sera affiché ; pas mal, non ? ![Le rss.xml en RSS 2.0 vu avec https://codebeautify.org/rssviewer](./images/using_codebeautify.webp) Vous pouvez utiliser un autre outil comme mais ici, vous devez d'abord envoyer votre fichier `blog/rss.xml` sur votre site de production. ![Le rss.xml en RSS 2.0 vu avec https://rss.app/rss-feed/](./images/using_rss_app.webp) ## Pourquoi c'est mieux ? {#why-is-this-better} Comme vous pouvez le voir, le nouveau fichier `rss.xml` exposera maintenant l'image d'introduction, mais pas seulement : vous aurez aussi beaucoup plus d'informations, comme le HTML de votre article. > **Voir la source:** > > Pour rappel, appuyez simplement sur `CTRL`+`U` pour voir le code source XML. Les agrégateurs RSS, même les plus anciens, auront accès au contenu complet et pourront donc afficher l'article sans devoir aller le récupérer sur votre site. ## Options de configuration du plugin {#configuration-options-of-the-plugin} Le plugin peut être configuré directement dans votre fichier `docusaurus.config.js`. Voyez ci-dessous toutes les options facultatives que vous pouvez utiliser : ```javascript const config = { // ... plugins: [ [ "./plugins/blog-feed-plugin/index.js", { maxItems: 20, // optional includeContent: true, // optional includeImages: true, // optional stripSelectors: [ // optional — add any site-specific UI selectors to remove ".custom-ads", ".share-buttons", ".newsletter-signup", ], ignorePatterns: ["**/_archived/**"], // optional }], ], }; export default config; ``` * **maxItems** (nombre, défaut : `20`) : limite le nombre d'éléments dans le flux. Par défaut, Docusaurus n'en affiche que 20. Plus ce nombre est élevé, plus le fichier rss.xml sera volumineux. Vous pourriez être tenté de vouloir tous les articles (dans mon cas, près de 215 actuellement), mais ce n'est pas la vocation première d'un fichier RSS. Ce fichier est là pour lister les **derniers articles** et vous permettre de voir les publications les plus récentes de votre blog. Donc restez... raisonnable. * **includeContent** (booléen, défaut : `true`) : inclut le corps HTML nettoyé de chaque article dans le champ ``. Si vous voulez obliger les gens à visiter votre site pour lire le contenu complet, mettez cette option à `false`. * **includeImages** (booléen, défaut : `true`) : ajoute l'image de l'article en haut de la description et inclut une balise `` avec le bon type MIME. * **stripSelectors** (string\[], défaut : fusionné avec les valeurs par défaut) : sélecteurs CSS supplémentaires à supprimer du HTML de l'article. Les sélecteurs critiques (`header`, `svg`) sont toujours supprimés. * **ignorePatterns** (string\[], défaut : \[`"**/_archived/**"`]) : motifs glob pour exclure certains fichiers du blog. ## Quelques outils {#some-tools} ### Outils de visualisation {#visualization-tools} Pendant que vous codez le plugin, utilisez l'un de ces outils de visualisation pour vous assurer que le contenu s'affiche correctement. * * * ### Outils de validation {#validation-tools} Si vous voulez vous assurer que votre `rss.xml` est valide : * * * --- # Quelques vérifications sur les images de votre site Docusaurus > Un script Python pour vérifier le lazy loading et la taille des images de votre site Docusaurus avec Playwright et BeautifulSoup. --- ![Quelques vérifications sur les images de votre site Docusaurus](/img/v2/check_images.webp) **TL;DR** Cet article explique comment imposer le lazy loading sur un blog Docusaurus en surchargeant le composant image MDX par défaut pour injecter automatiquement l'attribut loading. Il détaille ensuite comment valider ce comportement à l'aide d'un script Python et Playwright containerisé qui simule le scroll d'un utilisateur pour contrôler les attributs des images et la taille des fichiers. Ce workflow garantit que les bonnes pratiques de performance sont appliquées et vérifiées de manière cohérente sur tout le contenu du blog. Vous me connaissez suffisamment maintenant : la performance me tient à cœur. Pour plein de bonnes raisons, mais surtout parce que c'est amusant d'optimiser les choses et de voir les résultats immédiatement. Et c'est aussi un joli défi ! Mon blog est devenu assez gros (plus de 200 articles) et je voulais m'assurer que toutes les images sont bien chargées en lazy loading — mais comment en être sûr ? Le lazy load, c'est quoi ? Quand vous chargez une image en lazy loading, vous dites au navigateur de ne la charger qu'au moment où elle est sur le point d'entrer dans le viewport (c'est-à-dire quand l'utilisateur scrolle à proximité). Donc, au lieu de charger toutes les images d'un coup au chargement de la page, seules les images immédiatement visibles sont chargées en premier. Cela réduit le temps de chargement initial et économise de la bande passante, en particulier sur les pages comportant beaucoup d'images. Dans cet article, je partage comment j'ai imposé le lazy loading sur ce blog Docusaurus et, surtout, comment j'ai automatisé la vérification de ce comportement avec un script Python. ## Lancer la vérification {#running-the-check} Une fois le script (montré plus bas) et son container en place, auditer chaque image du site tient en une seule commande : ```bash $ docker run -it --rm -v .:/app -w /app --entrypoint /bin/sh mcr.microsoft.com/playwright/python:v1.61.0-jammy -c "pip install --root-user-action=ignore beautifulsoup4 pillow playwright requests >/dev/null && python check-images.py" ``` J'obtiens dans la console un rapport indiquant les images auxquelles il manque l'attribut `loading="lazy"`, surtout si elles sont volumineuses. Par exemple : ![Exemple de sortie](./images/output.webp) À vous, maintenant, d'agir sur les résultats ! Sur mon propre blog, j'ai ajouté quelques attributs manquants et optimisé des images trop lourdes. J'ai aussi revu mes composants React pour m'assurer qu'ils incluent le lazy loading là où c'est pertinent. ## Étape 1 : imposer le lazy loading dans Docusaurus {#step-1-enforcing-lazy-loading-in-docusaurus} Par défaut, quand vous écrivez la syntaxe Markdown standard pour une image `![Alt text](image.webp)`, Docusaurus génère une balise HTML `` classique. Les navigateurs modernes supportent l'attribut `loading="lazy"`, mais le framework ne l'ajoute pas toujours automatiquement. Pour garantir que **chaque image** de mon blog est chargée en lazy loading (et décodée de manière asynchrone), j'ai surchargé le composant `img` par défaut de Docusaurus. J'ai déjà raconté cette histoire deux fois : sous l'angle du composant dans [Change how Docusaurus will create img tags](/blog/docusaurus-override-img) et sous l'angle du plugin dans [Overrides the generation of img tags with Docusaurus](/blog/docusaurus-lazy-loading). Cela se fait dans `src/theme/MDXComponents.js`. En englobant le composant image par défaut, je peux injecter les attributs dont j'ai besoin : **src/theme/MDXComponents.js** ```javascript import MDXComponents from "@theme-original/MDXComponents"; export default { // Reusing the default mapping ...MDXComponents, // Override the img tag img: (props) => { const { loading, decoding, className, style, ...rest } = props; return ( ); }, }; ``` Avec ce simple changement, chaque image générée depuis du Markdown possède désormais `loading="lazy"`. ## Étape 2 : faire confiance, mais vérifier {#step-2-trust-but-verify} Écrire le code est une chose ; s'assurer que ça fonctionne sur des centaines d'articles en est une autre. Je voulais être sûr que : 1. Les images ont bien l'attribut `loading="lazy"`. 2. Les grandes images sont correctement gérées. 3. Le comportement persiste même après une mise à jour du site. Inspecter les éléments à la main dans les DevTools de Chrome est fastidieux. J'ai donc écrit un **script Python** pour automatiser tout ça. ### Le défi : le scroll {#the-challenge-scrolling} L'analyse statique (télécharger simplement le HTML) ne suffit pas. Certaines implémentations de lazy loading (ou certains processus d'hydratation) peuvent dépendre du fait que l'utilisateur scrolle réellement la page. Pour simuler un vrai utilisateur, j'ai utilisé **Playwright**. Il me permet de lancer un navigateur headless, de rendre la page et de scroller jusqu'en bas par programmation, ce qui déclenche les mécanismes de lazy loading. ### Le script {#the-script} Le script effectue les actions suivantes : 1. **Parcourt** une liste d'URLs (mes articles de blog). 2. **Scrolle** plusieurs fois vers le bas de la page (comme un lecteur). 3. **Parse** le DOM avec `BeautifulSoup`. 4. **Inspecte** chaque balise `` pour vérifier la présence de `loading="lazy"`. 5. **Contrôle** les dimensions des images avec `Pillow` pour signaler les images volumineuses qui devraient absolument être en lazy loading. **check-images.py** ```python # type: ignore # cspell:ignore networkidle """ Advanced Image Audit Script for React Sites This script uses Playwright to render a React website in a headless Chromium browser, scrolls the page to trigger lazy loading, and audits all tags for: - Lazy loading usage - Image dimensions (width x height) - Format (e.g., WebP, PNG, JPEG) - Compression (file size) - Presence of width/height attributes - Duplicate image sources Usage in Docker: docker run -it --rm \ -v ${PWD}:/app \ -w /app \ --entrypoint /bin/sh \ mcr.microsoft.com/playwright/python:v1.57.0-jammy \ -c "pip install --root-user-action=ignore beautifulsoup4 pillow playwright requests >/dev/null && python lazy-load.py" Notes: - The script ignores SSL errors (useful for local dev or self-signed certs) - It scrolls the page multiple times to ensure lazy-loaded images appear in the DOM - Requires 'beautifulsoup4' and 'playwright' Python packages """ from playwright.sync_api import sync_playwright from bs4 import BeautifulSoup import time import requests from PIL import Image from io import BytesIO from urllib.parse import urljoin import urllib3 # List of pages to check urls = [ "https://host.docker.internal:3000/", # "https://host.docker.internal:3000/about", # "https://host.docker.internal:3000/blog", # "https://host.docker.internal:3000/blog/archive", # add more pages here ] # Disable SSL warnings for unverified HTTPS requests urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) def get_image_info(src, base_url): """Returns image size (width, height), format, and file size in KB""" if not src or src.startswith("data:image/"): return None # Skip inline base64 images full_url = urljoin(base_url, src) try: response = requests.get(full_url, stream=True, timeout=5, verify=False) response.raise_for_status() img = Image.open(BytesIO(response.content)) size_kb = len(response.content) / 1024 return { "width": img.width, "height": img.height, "format": img.format, "size_kb": round(size_kb, 1), } except Exception as e: print(f"⚠️ Could not get info for {full_url}: {e}") return None # Scrolling configuration NUM_SCROLLS = 10 # Number of scrolls to perform to trigger lazy-loaded images SCROLL_HEIGHT = 1000 # Pixels per scroll WAIT_PER_SCROLL = 1 # Seconds to wait after each scroll with sync_playwright() as p: browser = p.chromium.launch(headless=True) for url in urls: print(f"\n🔎 Checking page: {url}") page = browser.new_page(ignore_https_errors=True) # Navigate and wait for network to be idle page.goto(url) page.wait_for_load_state("networkidle") # Scroll to trigger lazy loading for _ in range(NUM_SCROLLS): page.mouse.wheel(0, SCROLL_HEIGHT) time.sleep(WAIT_PER_SCROLL) # Get the rendered HTML html = page.content() page.close() # Parse HTML soup = BeautifulSoup(html, "html.parser") images = soup.find_all("img") print(f"{len(images)} images found on {url}\n") lazy_images = [] non_lazy_images = [] warnings = [] for img in images: src = img.get("src") if not src: warnings.append("[missing src] image tag") continue # Check lazy loading is_lazy = img.get("loading") == "lazy" # Check width/height attributes has_dimensions = img.get("width") and img.get("height") if not has_dimensions: warnings.append(f"{src} missing width/height attributes") # Get image info info = get_image_info(src, url) if info is None: continue # Skip if image couldn't be loaded width = info["width"] height = info["height"] format = info["format"] size_kb = info["size_kb"] # Check format if format.lower() in ["jpeg", "jpg", "png"]: warnings.append(f"{src} is {format}, consider converting to WebP") # Check compression if size_kb > 200: warnings.append(f"{src} is {size_kb} KB — consider compressing") # Classify image if is_lazy: lazy_images.append(f"{src} ✅ lazy ({width}x{height}, {format}, {size_kb} KB)") else: non_lazy_images.append(f"{src} ❌ not lazy ({width}x{height}, {format}, {size_kb} KB)") # Print results for entry in non_lazy_images: print(entry) for entry in warnings: print(f"⚠️ Warning: {entry}") print(f"\n✅ Lazy images: {len(lazy_images)}") print(f"❌ Non-lazy images: {len(non_lazy_images)}") print(f"⚠️ Warnings: {len(warnings)}") browser.close() ``` Vous me connaissez très bien maintenant : j'aime containeriser les choses. Je n'utilisais donc pas Python ni Playwright directement sur ma machine hôte, mais dans un container Docker. Ce container monte le répertoire courant, installe les packages nécessaires et exécute `check-images.py` — exactement la commande montrée en haut de cet article. > **Note:** > > Ce script a depuis été retiré de ma boîte à outils et n'est conservé ici qu'à titre de référence ; le code ci-dessus fonctionne toujours comme décrit. ### Vérifications supplémentaires {#additional-checks} Le script réalise aussi ces contrôles : * Vérifier si le format de l'image est WebP ; sinon, il émet un avertissement. * Vérifier si le poids de l'image dépasse un certain seuil (200 Ko) ; si oui, il émet un avertissement. * Vérifier si les attributs de hauteur et de largeur sont définis ; sinon, il émet un avertissement. Vous pouvez facilement adapter ces vérifications dans le script selon vos besoins. Et ajouter les vôtres ! ## Conclusion {#conclusion} L'optimisation des performances est un chemin, pas une destination. En combinant une surcharge globale dans Docusaurus avec un script de validation automatisé, je peux écrire tranquillement, en sachant que mes images ne ralentiront pas l'expérience de mes lecteurs. Si le script complet vous intéresse, vous le trouverez dans le dossier `.scripts` de mon repository ! --- # Gestion des tags dans Docusaurus > Automatisez la gestion des tags Docusaurus avec ce script Python. Nettoyez les doublons, corrigez la casse et fusionnez les tags en toute sécurité sur des centaines de fichiers Markdown via Docker. --- ![Gestion des tags dans Docusaurus](/img/v2/tags_manager.webp) **TL;DR** Cet article présente un script Python maison conçu pour automatiser le nettoyage et la gestion des tags incohérents d'un blog Docusaurus. L'outil tourne dans un container Docker pour lister, renommer et supprimer des tags, tout en proposant des suggestions intelligentes pour fusionner les doublons ou corriger les variations de casse. Il aborde aussi les options de configuration natives de Docusaurus pour imposer des règles d'utilisation des tags plus strictes à l'avenir. Plus de 200 articles plus tard, mon site Docusaurus a accumulé un joli chaos de tags. Trop de tags similaires, des majuscules incohérentes, du singulier contre du pluriel... Des tags avec un ou deux articles seulement. Et ainsi de suite. Dans cet article, je partage un **script Python** que j'ai écrit pour automatiser la gestion des tags : en supprimer certains, en fusionner d'autres et nettoyer les incohérences. C'est quasi impossible à faire à la main quand vous avez des centaines de fichiers Markdown. En plus, le faire manuellement risque de casser le formatage du frontmatter YAML. ## Voir les suggestions {#seeing-the-suggestions} Une seule commande liste tous les tags et signale les doublons probables : ```bash $ make tags-manager ARGS="list" ``` ![Liste des tags](./images/list.webp) ## La solution : un gestionnaire de tags intelligent {#the-solution-a-smart-tag-manager} Je voulais un outil capable de faire trois choses : 1. **Lister** tous les tags par fréquence. 2. **Suggérer** des optimisations (trouver les doublons ou les variations singulier/pluriel). 3. **Renommer** ou **supprimer** des tags sans rien casser. ### Suggestions intelligentes {#smart-suggestions} La partie la plus sympa du script, ce sont les « suggestions d'optimisation ». Il compare chaque tag à tous les autres pour repérer les doublons potentiels. Il cherche : * **Les variations de casse** : `GitHub` contre `github`. * **Les pluriels** : `snippet` contre `snippets`. * **Les sous-chaînes** : `visual studio code` contre `vscode`. Ça m'aide à repérer le bazar dont je ne soupçonnais même pas l'existence. ## Comment l'exécuter (Docker d'abord) {#how-to-run-it-docker-first} Comme toujours, je ne veux pas polluer ma machine hôte avec des dépendances Python. Je lance ce script avec Docker — exactement comme le vérificateur d'images décrit dans [Running some checks on your Docusaurus images](/blog/docusaurus-check-images). J'ai une cible `Makefile` prête, mais en gros, elle exécute une commande comme celle-ci : ```bash $ docker run -it --rm -v .:/app -w /app --entrypoint /bin/sh python:3.14-slim -c "pip install --root-user-action=ignore oyaml python-frontmatter >/dev/null && python .scripts/tags-manager.py list" ``` Mais bon, cette commande est franchement difficile à retenir, donc j'utilise plutôt une cible [Makefile](/blog/makefile_tips) : **makefile** ``` .PHONY: tags-manager tags-manager: ## Tags manager @clear ifeq ($(strip $(ARGS)),) @echo "--------------------------------------------------------" @echo "🚨 ERROR: Missing command arguments (ARGS)." @echo "--------------------------------------------------------" @echo "USAGE EXAMPLE:" @echo " make tags-manager ARGS=\"delete tag\"" @echo "" @echo " make tags-manager ARGS=\"list\"" @echo " make tags-manager ARGS=\"list --sort count\"" @echo " make tags-manager ARGS=\"list --sort name\"" @echo "" @echo " make tags-manager ARGS=\"rename old,new\"" @echo "" @echo " make tags-manager ARGS=\"--help\"" @echo "" @echo "For full help, run: make tags-manager ARGS=\"--help\"" @echo "--------------------------------------------------------" @exit 1 else @echo "--- Running Tags Manager with arguments: $(ARGS) ---" docker run -it --rm -v ${PWD}:/app -w /app python sh -c "pip install oyaml python-frontmatter > /dev/null 2>&1 && python .scripts/tags-manager.py $(ARGS)" endif ``` ## Le script {#the-script} Voici le script Python complet. Il utilise `argparse` pour gérer les arguments en ligne de commande et `python-frontmatter` pour analyser les fichiers. **.scripts/tags-manager.py** ```python # tag_manager.py import argparse from typing import List, Dict, Any, Tuple, Set import os import glob import subprocess from collections import Counter # YAML processing libraries import oyaml as yaml import frontmatter # --- ANSI Color Codes --- class Colors: """ANSI color codes for console output.""" HEADER = '\033[95m' OKBLUE = '\033[94m' OKCYAN = '\033[96m' OKGREEN = '\033[92m' WARNING = '\033[93m' FAIL = '\033[91m' ENDC = '\033[0m' BOLD = '\033[1m' UNDERLINE = '\033[4m' # --- Configuration --- DOCS_DIR: str = 'blog' FILE_PATTERN_BASE: str = os.path.join(DOCS_DIR, '**', '*.md*') MAX_LENGTH_DIFFERENCE: int = 3 # Max difference in characters to suggest a merge based on substring # Tags to exclude from merge suggestions (case-insensitive and order-independent) # Example: ('php', 'phpcbf') will prevent 'php' vs 'phpcbf' from being suggested. MERGE_EXCEPTIONS: Set[Tuple[str, str]] = { ('ftp', 'sftp'), ('git', 'github'), ('git', 'gitlab'), ('mysql', 'sql'), ('php', 'phpcbf'), ('php', 'phpcs'), ('php', 'phpdoc'), ('scp', 'winscp'), ('ssh', 'sshpass'), ('xml', 'xmlstarlet'), } # --- YAML Dumper Configuration --- def custom_list_representer(dumper: yaml.SafeDumper, data: List[Any]) -> yaml.nodes.SequenceNode: """Forces Python lists to be represented in YAML flow style: [item1, item2].""" return dumper.represent_sequence('tag:yaml.org,2002:seq', data, flow_style=True) def custom_string_representer(dumper: yaml.SafeDumper, data: str) -> yaml.nodes.ScalarNode: """Prevents PyYAML from folding long strings into multi-line blocks.""" if '\n' in data: # Use block style if the string explicitly contains newlines return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='|') # Use plain style for single-line strings, preventing folding return dumper.represent_scalar('tag:yaml.org,2002:str', data, style='') # Registering custom representers on the SafeDumper yaml.add_representer(list, custom_list_representer, Dumper=yaml.SafeDumper) yaml.add_representer(str, custom_string_representer, Dumper=yaml.SafeDumper) def custom_dumper_factory(metadata: Dict[str, Any], content: str) -> str: """ Generates the final file content string (front matter + markdown content) while preserving YAML formatting. """ import io output = io.StringIO() yaml.dump( metadata, output, Dumper=yaml.SafeDumper, default_flow_style=False, allow_unicode=True, width=4096 # High width ensures long single-line strings are not folded ) yaml_output: str = output.getvalue() return f"---\n{yaml_output}---\n{content}" # ---------------------------------------------------------------------- # Core Tag Management Functions # ---------------------------------------------------------------------- def list_tags(sort_by: str = 'count') -> None: """ Collects and displays all tags, sorted either by count (desc) or name (asc). It also lists tags with case variations and suggests tags for potential merging. Args: sort_by: The primary sorting method ('count' or 'name'). """ tag_counter: Counter = Counter() print(f"{Colors.BOLD}Action:{Colors.ENDC} {Colors.OKBLUE}Listing all tags{Colors.ENDC} (Sort by: {Colors.OKCYAN}{sort_by.upper()}{Colors.ENDC})") print(f"Base Directory: {Colors.OKBLUE}{DOCS_DIR}/{Colors.ENDC}") if not os.path.exists(DOCS_DIR): print(f"{Colors.FAIL}{Colors.BOLD}ERROR: Directory '{DOCS_DIR}' not found.{Colors.ENDC}") return files: List[str] = glob.glob(FILE_PATTERN_BASE, recursive=True) if not files: print(f"{Colors.WARNING}No files found matching '{FILE_PATTERN_BASE}'.{Colors.ENDC}") return print(f"{Colors.OKBLUE}INFO: {len(files)} file(s) found. Processing tags...{Colors.ENDC}") case_variants: Dict[str, List[str]] = {} unique_tags: set[str] = set() for filepath in files: try: post = frontmatter.load(filepath) tags: List[str] = post.metadata.get('tags', []) if isinstance(tags, list): tag_counter.update(tags) for tag in tags: tag_lower = tag.lower() # Fill case variant dictionary if tag_lower not in case_variants: case_variants[tag_lower] = [] if tag not in case_variants[tag_lower]: case_variants[tag_lower].append(tag) # Fill unique tags set (case-sensitive) unique_tags.add(tag) except Exception: pass print("\n" + "=" * 50) if not tag_counter: print(f"{Colors.WARNING}No tags found in the files.{Colors.ENDC}") return # --- PART 1: PRIMARY SORT AND LISTING --- tag_list: List[Tuple[str, int]] = list(tag_counter.items()) if sort_by == 'count': # Default sort: Primary by Count (DESC), Secondary by Tag Name (ASC) tag_list.sort(key=lambda item: item[0]) # Secondary sort by name (ASC) tag_list.sort(key=lambda item: item[1], reverse=True) # Primary sort by count (DESC) sort_desc = "Frequency DESC, Name ASC" elif sort_by == 'name': # Name sort: Only by Tag Name (ASC) tag_list.sort(key=lambda item: item[0]) sort_desc = "Name ASC" print(f"{Colors.HEADER}{Colors.BOLD}Top Tags ({sort_desc}):{Colors.ENDC}") for tag, count in tag_list: print(f" {Colors.OKGREEN}{tag:<30}{Colors.ENDC}{Colors.BOLD}{Colors.OKBLUE}{count}{Colors.ENDC}") print("=" * 50) # --- PART 2 & 3: OPTIMIZATION SUGGESTIONS --- case_issues: List[List[str]] = [ variants for variants in case_variants.values() if len(variants) > 1 ] merge_suggestions: set[Tuple[str, str]] = set() sorted_unique_tags = sorted(list(unique_tags)) # Logic to check if a pair is an exception (case-insensitive and order-independent) def is_exception(t1: str, t2: str) -> bool: t1_lower, t2_lower = t1.lower(), t2.lower() # Create the canonical lowercased and sorted tuple (e.g., ('php', 'phpcbf')) canonical_pair = tuple(sorted((t1_lower, t2_lower))) # Check against the MERGE_EXCEPTIONS set return canonical_pair in MERGE_EXCEPTIONS # Refined merge suggestion logic for i in range(len(sorted_unique_tags)): t1 = sorted_unique_tags[i] t1_lower = t1.lower() for j in range(i + 1, len(sorted_unique_tags)): t2 = sorted_unique_tags[j] t2_lower = t2.lower() # Skip tags that only differ by case if t1_lower == t2_lower: continue # Skip if the pair is explicitly listed as an exception if is_exception(t1, t2): continue pair = tuple(sorted((t1, t2))) # Heuristic 1: Singular/Plural Check (Highly reliable) # Example: 'snippet' vs 'snippets' if (t1_lower + 's' == t2_lower or t2_lower + 's' == t1_lower): merge_suggestions.add(pair) continue # Heuristic 2: Substring with Length Constraint len_diff = abs(len(t1_lower) - len(t2_lower)) if len_diff > 0 and len_diff <= MAX_LENGTH_DIFFERENCE: # Check if the shorter tag is a substring of the longer tag if len(t1_lower) < len(t2_lower): shorter_tag = t1_lower longer_tag = t2_lower else: shorter_tag = t2_lower longer_tag = t1_lower if shorter_tag in longer_tag: merge_suggestions.add(pair) print(f"\n{Colors.WARNING}{Colors.BOLD}--- TAG OPTIMIZATION SUGGESTIONS ---{Colors.ENDC}") # Display Case Issues (Part 2) if case_issues: print(f"{Colors.WARNING}1. Case Variations (Easy Duplicates):{Colors.ENDC}") for variants in sorted(case_issues, key=lambda x: x[0].lower()): counts_info = ", ".join([ f"{v} ({tag_counter[v]})" for v in sorted(variants) ]) print(f" {Colors.FAIL}❌ Case Issue ({variants[0].lower()}): {Colors.ENDC}{counts_info}") print(f" {Colors.OKCYAN} Suggestion: Rename all to a canonical form (e.g., '{variants[0]}').{Colors.ENDC}") # Display Merge Suggestions (Part 3 - Refined) if merge_suggestions: print(f"\n{Colors.WARNING}2. Potential Merge Candidates (Singular/Plural or Small Prefix/Suffix):{Colors.ENDC}") for t1, t2 in sorted(list(merge_suggestions)): count1 = tag_counter[t1] count2 = tag_counter[t2] print(f" {Colors.WARNING}⚠️ Found: {Colors.ENDC}{t1} ({count1}) vs {t2} ({count2})") print(f" {Colors.OKCYAN} Suggestion: Merge into a canonical form (e.g., '{t1}' or '{t2}').{Colors.ENDC}") if not case_issues and not merge_suggestions: print(f" {Colors.OKGREEN}No obvious tag duplicates or merge candidates found.{Colors.ENDC}") print("-" * 50) def run_node_script(script_name: str, extra_args: List[str]) -> int: """ Proxies to a Node script under scripts/ — kept here so the single 'tags' cheatsheet entry stays the one wiring point, while the AI/corpus logic itself (Ollama, blog-corpus helpers) lives in JS and isn't duplicated in Python. Used by 'suggest' (scripts/suggest-tags.mjs) and 'review' (scripts/tags-review.mjs). """ script_root = os.path.dirname(os.path.abspath(__file__)) node_script = os.path.join(script_root, '..', 'scripts', script_name) result = subprocess.run(['node', node_script, *extra_args]) return result.returncode def set_tags_for_file(filepath: str, new_tags: List[str]) -> bool: """ Overwrites one article's frontmatter 'tags' key with new_tags, via the same custom_dumper_factory used by delete/rename — forced flow style ([a, b, c]), including for a file whose tags were still block-style, which this normalizes on the way. """ try: post = frontmatter.load(filepath) except Exception as e: print(f"{Colors.FAIL} ❌ Could not read {filepath}: {e}{Colors.ENDC}") return False post.metadata['tags'] = new_tags full_content: str = custom_dumper_factory(post.metadata, post.content) with open(filepath, 'w', encoding='utf-8') as f: f.write(full_content) print(f"{Colors.OKGREEN} ✓ Tags set to [{', '.join(new_tags)}] in: {filepath}{Colors.ENDC}") return True # ---------------------------------------------------------------------- # Argparse Configuration (Remains unchanged) # ---------------------------------------------------------------------- def _process_files(action: str, old_tag: str = None, new_tag: str = None) -> None: # ... (function body remains unchanged) ... if action == 'delete': operation_desc: str = f"{Colors.FAIL}Deleting tag '{old_tag}'{Colors.ENDC}" elif action == 'rename': operation_desc: str = f"{Colors.OKCYAN}Renaming tag '{old_tag}' to '{new_tag}'{Colors.ENDC}" else: return print(f"{Colors.BOLD}Operation:{Colors.ENDC} {operation_desc}") print(f"Base Directory: {Colors.OKBLUE}{DOCS_DIR}/{Colors.ENDC}") if not os.path.exists(DOCS_DIR): print(f"{Colors.FAIL}{Colors.BOLD}ERROR: Directory '{DOCS_DIR}' not found.{Colors.ENDC}") return files: List[str] = glob.glob(FILE_PATTERN_BASE, recursive=True) if not files: print(f"{Colors.WARNING}No files found matching '{FILE_PATTERN_BASE}'.{Colors.ENDC}") return print(f"{Colors.OKBLUE}INFO: {len(files)} file(s) found. Processing...{Colors.ENDC}") changes_made: int = 0 old_tag_lower: str | None = old_tag.lower() if old_tag else None new_tag_lower: str | None = new_tag.lower() if new_tag else None for filepath in files: try: post = frontmatter.load(filepath) metadata: Dict[str, Any] = post.metadata file_changed: bool = False if 'tags' in metadata and isinstance(metadata['tags'], list): original_tags: List[str] = metadata['tags'] new_tags: List[str] = [] for tag in original_tags: tag_lower: str = tag.lower() if tag_lower == old_tag_lower: if action == 'delete': file_changed = True continue elif action == 'rename': # Avoid adding the new tag if a case-insensitive match already exists if new_tag_lower and new_tag_lower not in [t.lower() for t in new_tags]: new_tags.append(new_tag) file_changed = True else: # Ensure no duplicates in the new list (case-insensitive) if tag_lower not in [t.lower() for t in new_tags]: new_tags.append(tag) if file_changed: post.metadata['tags'] = new_tags # Generate content and write the file manually to preserve YAML formatting full_content: str = custom_dumper_factory(post.metadata, post.content) with open(filepath, 'w', encoding='utf-8') as f: f.write(full_content) if action == 'delete': print(f"{Colors.OKGREEN} ✓ Deleted '{old_tag}' from: {filepath}{Colors.ENDC}") else: print(f"{Colors.OKGREEN} ✓ Renamed '{old_tag}' to '{new_tag}' in: {filepath}{Colors.ENDC}") changes_made += 1 except Exception as e: error_message: str = str(e) print(f"{Colors.FAIL} ❌ FATAL ERROR PROCESSING {filepath}: {error_message}{Colors.ENDC}") print("-" * 50) print(f"{Colors.BOLD}Operation completed.{Colors.ENDC} {changes_made} file(s) modified.") # ... (The rest of the argparse block remains the same) ... if __name__ == "__main__": parser = argparse.ArgumentParser( description=f"{Colors.BOLD}{Colors.OKCYAN}Tag Manager Tool{Colors.ENDC} for Markdown files (path: {DOCS_DIR}).", epilog="Use 'python tag_manager.py -h' for action-specific help.", formatter_class=argparse.RawTextHelpFormatter ) # Define subparsers and actions subparsers = parser.add_subparsers(dest="action", help="Available actions.") # LIST action parser_list = subparsers.add_parser( 'list', help=f"List all tags found in '{DOCS_DIR}', sorted by frequency and name." ) parser_list.add_argument( '--sort', type=str, choices=['name', 'count'], default='count', help="Primary sorting method: 'count' (default, frequency descending) or 'name' (alphabetical ascending)." ) # DELETE action parser_delete = subparsers.add_parser( 'delete', help=f"Delete a specified tag from all files in '{DOCS_DIR}'. (Parameter: TAG)" ) parser_delete.add_argument( "tag", type=str, metavar="", help="The tag to DELETE (e.g., 'draft')." ) # RENAME action parser_rename = subparsers.add_parser( 'rename', help=f"Rename an old tag to a new one in all files in '{DOCS_DIR}'. (Parameters: OLD_TAG,NEW_TAG)" ) parser_rename.add_argument( "tags", type=str, metavar="", help="The tags to rename, in 'old_tag,new_tag' format (e.g., 'prog,programming')." ) # SUGGEST action (proxies to scripts/suggest-tags.mjs — see run_node_script) parser_suggest = subparsers.add_parser( 'suggest', help="Use AI (via scripts/suggest-tags.mjs) to suggest tags for a specific file." ) parser_suggest.add_argument("file", type=str, help="Path to the .md file.") # REVIEW action — listed here for --help only; its own flags are handled before # parser.parse_args() runs (see the argv[1] == 'review' check below). subparsers.add_parser( 'review', help="[--force] [--status] [
] — refresh .todos/0000-tags-review-journal.md via Ollama." ) # SET-TAGS action parser_set_tags = subparsers.add_parser( 'set-tags', help="Overwrite one article's 'tags' frontmatter key. (Parameters: FILE, TAG1,TAG2,...)" ) parser_set_tags.add_argument("file", type=str, help="Path to the .md/.mdx file.") parser_set_tags.add_argument( "tags", type=str, metavar="", help="Comma-separated final tag list, e.g. 'docusaurus,react,component'." ) # Display full help page if no arguments are provided if len(os.sys.argv) == 1: parser.print_help() os.sys.exit(0) # 'review' takes its own free-form flags (--force, --status, an article path), which # argparse's REMAINDER handles unreliably once the first one starts with '-' (a known # argparse quirk). Bypassing argparse for this branch avoids it entirely; the subparser # above still exists so 'review' shows up in --help. if os.sys.argv[1] == 'review': os.sys.exit(run_node_script('tags-review.mjs', os.sys.argv[2:])) # Parse arguments args = parser.parse_args() # Execute actions if args.action == 'list': list_tags(args.sort) elif args.action == 'suggest': os.sys.exit(run_node_script('suggest-tags.mjs', [args.file])) elif args.action == 'set-tags': new_tags: List[str] = [t.strip() for t in args.tags.split(',') if t.strip()] if not new_tags: print(f"{Colors.FAIL}Error: at least one tag is required.{Colors.ENDC}") os.sys.exit(1) if not os.path.isfile(args.file): print(f"{Colors.FAIL}Error: file not found: {args.file}{Colors.ENDC}") os.sys.exit(1) os.sys.exit(0 if set_tags_for_file(args.file, new_tags) else 1) elif args.action == 'delete' or args.action == 'rename': old_tag: str | None = None new_tag: str | None = None if args.action == 'delete': old_tag = args.tag elif args.action == 'rename': if ',' not in args.tags: print(f"{Colors.FAIL}Error: Rename argument must be in 'OLD_TAG,NEW_TAG' format.{Colors.ENDC}") os.sys.exit(1) try: old_tag, new_tag = [t.strip() for t in args.tags.split(',', 1)] if not old_tag or not new_tag: print(f"{Colors.FAIL}Error: Both tags (old and new) must be specified and non-empty.{Colors.ENDC}") os.sys.exit(1) except ValueError: print(f"{Colors.FAIL}Error: Problem splitting tags. Expected format: 'OLD_TAG,NEW_TAG'.{Colors.ENDC}") os.sys.exit(1) _process_files( action=args.action, old_tag=old_tag, new_tag=new_tag ) ``` ## D'autres exemples {#more-examples} **Renommer un tag :** Si, en parcourant la liste des tags, je vois que j'ai un tag `snippets` et un tag `snippet`, je vais les fusionner comme ceci : ```bash $ make tags-manager ARGS="rename snippets,snippet" ``` **Supprimer un tag :** Je peux aussi constater qu'un tag précis n'apporte rien ; pour le supprimer, je lance quelque chose comme ça : ```bash $ make tags-manager ARGS="delete draft" ``` **Bonus - Le fichier tags.yml** Nativement, Docusaurus vous permet de créer un fichier spécial appelé `blog/tags.yml`. Voici celui que j'utilise : **blog/tags.yml** ```yaml adminer: label: "Adminer" permalink: "/tags/adminer" description: "Lightweight web-based database management tool supporting MySQL, PostgreSQL, SQLite, and more." ai: label: "Artificial Intelligence (AI)" permalink: "/tags/ai" description: "Tools, models, and concepts related to AI, including Ollama, Claude, and Gemini." apache: label: "Apache HTTP Server" permalink: "/tags/apache" description: "Configuration and troubleshooting for the Apache web server." api: label: "API" permalink: "/tags/api" description: "Development, consumption, and documentation of APIs (REST, OpenAPI, FastAPI, PostgREST)." bash: label: "Bash" permalink: "/tags/bash" description: "Scripts, commands, and tips for the Bash shell." bluesky: label: "Bluesky" permalink: "/tags/bluesky" description: "Bluesky social network — integration, sharing, and embedding in web projects." code-quality: label: "Code Quality" permalink: "/tags/code-quality" description: "Tools and practices to improve code quality: static analysis, linters, formatters, and Git hooks (PHPStan, Ruff, pre-commit, etc.)." component: label: "Component / UI" permalink: "/tags/component" description: "Creation and management of React or Docusaurus UI components." customization: label: "Customization" permalink: "/tags/customization" description: "Personalizing shell environments, terminals, editors, or applications." database: label: "Database" permalink: "/tags/database" description: "Concepts, administration, and queries for relational and NoSQL databases (MySQL, PostgreSQL, Oracle, SQL Server, Access)." devcontainer: label: "DevContainer" permalink: "/tags/devcontainer" description: "Containerized development environments using VS Code Dev Containers and Docker." doc-as-code: label: "Documentation as Code" permalink: "/tags/doc-as-code" description: "Managing documentation like software: diagrams (Mermaid, Markmap), Quarto slideshows, Pandoc, and visualization tools." docker: label: "Docker" permalink: "/tags/docker" description: "Containerization, Docker Compose, and image management." docusaurus: label: "Docusaurus" permalink: "/tags/docusaurus" description: "Building, customizing, and deploying Docusaurus static sites." excel: label: "Microsoft Excel" permalink: "/tags/excel" description: "Formulas, VBA macros, and advanced usage of Microsoft Excel." fzf: label: "fzf" permalink: "/tags/fzf" description: "Fuzzy finder for the command line — interactive search and selection." git: label: "Git" permalink: "/tags/git" description: "Commands, workflow, and version control with Git." github: label: "GitHub" permalink: "/tags/github" description: "GitHub platform, Actions, features, and integrations." gitlab: label: "GitLab" permalink: "/tags/gitlab" description: "GitLab platform, CI/CD pipelines, and project management." joomla: label: "Joomla" permalink: "/tags/joomla" description: "Joomla Content Management System installation and configuration." laravel: label: "Laravel" permalink: "/tags/laravel" description: "Laravel PHP framework — events, Filament, Telescope, and testing." linux: label: "Linux" permalink: "/tags/linux" description: "Administration, commands, and tips for Linux systems (Ubuntu, tools, shell utilities)." makefile: label: "Makefile" permalink: "/tags/makefile" description: "Automating tasks and workflows with GNU Make." markdown: label: "Markdown" permalink: "/tags/markdown" description: "Syntax, tools, and conversion of Markdown documents." msaccess: label: "Microsoft Access" permalink: "/tags/msaccess" description: "Database development and VBA automation with Microsoft Access." mysql: label: "MySQL" permalink: "/tags/mysql" description: "MySQL relational database — configuration, queries, and Docker-based setups." nodejs: label: "Node.js" permalink: "/tags/nodejs" description: "Server-side JavaScript runtime environment and its ecosystem." npm: label: "npm" permalink: "/tags/npm" description: "Node package manager — installing and managing JavaScript dependencies." ollama: label: "Ollama" permalink: "/tags/ollama" description: "Local environment to run and manage large language models on your own machine." oracle: label: "Oracle Database" permalink: "/tags/oracle" description: "Running and querying Oracle Database, including Docker-based setups." php: label: "PHP" permalink: "/tags/php" description: "PHP development, environment setup, and tips." phpmyadmin: label: "phpMyAdmin" permalink: "/tags/phpmyadmin" description: "Web-based MySQL/MariaDB administration interface." postgresql: label: "PostgreSQL" permalink: "/tags/postgresql" description: "PostgreSQL relational database — configuration, queries, and Docker-based setups." python: label: "Python" permalink: "/tags/python" description: "Python language development, data processing, and its ecosystem (FastAPI, Pandas)." quarto: label: "Quarto" permalink: "/tags/quarto" description: "Scientific and technical publishing with Quarto — documents, slideshows, and reports." react: label: "React" permalink: "/tags/react" description: "React and JavaScript — UI development, components, and tooling." security: label: "Security" permalink: "/tags/security" description: "Web security, protection tools, and secure configuration." self-hosted: label: "Self-Hosted" permalink: "/tags/self-hosted" description: "Software solutions hosted on your own server (Uptime Kuma, Roundcube, LimeSurvey, Matomo, etc.)." ssh: label: "SSH (Secure Shell)" permalink: "/tags/ssh" description: "Secure remote connection, file transfer (SFTP, SCP), and SSH configuration." ssl: label: "SSL / TLS" permalink: "/tags/ssl" description: "Security certificates, HTTPS configuration, and the SSL/TLS protocol." tests: label: "Testing" permalink: "/tags/tests" description: "Strategies and tools for testing: unit (PHPUnit, Pest), functional (Cypress, Behat), and BDD." vba: label: "VBA / VBScript" permalink: "/tags/vba" description: "Programming macros in Microsoft Office applications (Excel, Access) and Windows scripting with VBScript." vscode: label: "VS Code" permalink: "/tags/vscode" description: "Configuration, extensions, tips, and productivity features for Visual Studio Code." windows: label: "Microsoft Windows" permalink: "/tags/windows" description: "Windows administration, DOS/cmd, PowerShell, WinGet, and Windows-specific tooling." windows-terminal: label: "Windows Terminal" permalink: "/tags/windows-terminal" description: "Using and configuring the Windows Terminal application." winscp: label: "WinSCP" permalink: "/tags/winscp" description: "Graphical SFTP/FTP/SCP client for Windows, including PuTTY integration." wsl: label: "WSL (Windows Subsystem for Linux)" permalink: "/tags/wsl" description: "Running a Linux environment on Windows using WSL2." yarn: label: "Yarn" permalink: "/tags/yarn" description: "Fast, reliable, and secure dependency management for JavaScript." zsh: label: "Zsh" permalink: "/tags/zsh" description: "The Z shell — configuration, plugins, functions, and productivity." ``` Avec un tel fichier, vous pouvez ensuite configurer votre fichier `docusaurus.config.js` et mettre l'attribut `onInlineTags` à `warn` ou `throw`. Besoin de plus d'infos ? Lisez la documentation officielle [Tags File](https://docusaurus.io/docs/api/plugins/@docusaurus/plugin-content-docs#tags-file). ## Conclusion {#conclusion} Garder un blog organisé est un travail permanent. Avec ce script, je peux auditer mes tags rapidement et m'assurer que `Docusaurus` ne devienne pas `docusaurus` ou `DocuSaurus` avec le temps. N'hésitez pas à récupérer le script et à l'adapter à votre propre générateur de site statique ! --- # Automatiser les résumés TL;DR avec Gemini AI > Améliorez l'expérience de vos lecteurs en générant automatiquement des résumés TL;DR concis pour vos articles de blog avec Gemini AI de Google et Python. --- ![Automatiser les résumés TL;DR avec Gemini AI](/img/v2/gemini_tldr.webp) **TL;DR** Écrire des résumés pour de longs articles améliore l'expérience utilisateur, mais ça prend du temps. Cet article explique comment automatiser la génération de sections « Too Long; Didn't Read » (TL;DR) avec un script Python et Gemini AI de Google. Nous verrons la mise en place Docker, l'utilisation du script et son intégration dans votre workflow Docusaurus. En tant que blogueur technique, je cherchais un moyen d'améliorer l'expérience de mes lecteurs en proposant des résumés concis au début de mes articles — pour qu'ils ne perdent pas leur temps (j'espère !) à lire un article entier avant de se dire : « Attends, ce n'est pas ce que je cherchais. » J'ai donc décidé d'automatiser la génération de résumés TL;DR avec Gemini AI de Google et un simple script Python. Dans cet article, je vous explique comment j'ai fait. *Ce n'est pas mon seul usage de Gemini sur ce blog : lisez [How I used Google Gemini Nano Banana on my blog](/blog/gemini-meerkat) pour le volet illustrations de l'histoire.* **Quick Jump:** [Avant et après](#before-and-after) | [Récupérer tous les fichiers](#conclusion) ## Avant et après {#before-and-after} Le script prend un article entier — chaque paragraphe, chaque titre — et le réduit à quelques phrases. Voici un exemple réel, tiré directement de [l'article sur Lovable.dev](/blog/lovable-dev-ai) de ce blog. **Avant** — les premiers paragraphes que le script lit réellement : > Cette semaine, un collègue m'a parlé de Lovable.dev en me disant : « *Dans un prompt, il suffit de décrire le programme que vous voulez générer, et l'outil le construit et le déploie même pour vous* ». Waouh, il fallait absolument que j'essaie. > > Mais que demander ? Et si je lui demandais de créer un clone de Marknotes ? Ceux qui me suivent depuis des années savent que j'ai créé Marknotes, une application de prise de notes, il y a 10 ans. J'y ai travaillé pendant cinq ans avant de passer à autre chose. > > Voyons si Lovable.dev peut construire la même chose en une seule heure. **Spoiler : non, mais ce n'était pas si mal.** **Après** — le `` que le script a généré et injecté en haut de ce même article : ![Le TLDR généré, tel qu'il s'affiche sur l'article publié](./images/example_tldr_output.png) La même densité d'information dont les lecteurs ont vraiment besoin, en une fraction du temps de lecture. ## Le concept {#the-concept} L'idée est simple : il me faut un script qui parcourt mes articles de blog (un ou plusieurs) et génère un court résumé avec Gemini AI. Ensuite, il doit insérer ce résumé dans l'article, enveloppé dans un composant `` maison pour le style et la visibilité. Le script va faire ceci : 1. Recevoir un chemin en entrée (fichier ou dossier). 2. Lire les fichiers Markdown. 3. Envoyer le contenu à Gemini AI pour résumé. 4. Récupérer le résumé. 5. Réinjecter le résumé dans le fichier Markdown. Ma structure de dossiers ressemble à ceci : `blog/YYYY/MM/DD/index.md` pour chaque article. Donc en appelant le script avec `blog/2026/01/`, il traitera tous les articles de janvier 2026. ## Le composant React {#the-react-component} D'abord, il nous faut une façon d'afficher joliment le résumé dans Docusaurus. J'utilise un simple composant maison pour ça. Voici l'implémentation : ```html This is the summary of the article... ``` Le code du composant est le suivant : **src/components/TLDR/index.tsx** ```typescript import { useMemo, type JSX, type ReactNode } from "react"; import Head from "@docusaurus/Head"; import MobileQuickLinks from "@site/src/components/Blog/MobileQuickLinks"; import styles from "./styles.module.css"; import { translate } from "@docusaurus/Translate"; // Recursively extracts plain text from React nodes for JSON-LD abstract function extractText(node: unknown): string { if (typeof node === "string") return node; if (typeof node === "number") return String(node); if (Array.isArray(node)) return node.map(extractText).join(""); if (node && typeof node === "object") { const children = (node as { props?: { children?: unknown } }).props?.children; if (children) return extractText(children); } return ""; } interface Props { children: ReactNode; } // Injects JSON-LD so search engines read the abstract via Schema.org BlogPosting export default function TLDR({ children }: Props): JSX.Element { const structuredData = useMemo( () => ({ "@context": "https://schema.org", "@type": "BlogPosting", abstract: extractText(children), }), [children], ); return ( <>
⚡
TL;DR
{children}
{/* The TL;DR is the one block every post opens with, which makes it the only reliable anchor for a "read next" hint placed *after* the reader has confirmed the article is the right one. MobileQuickLinks decides on its own whether it has anything to show. */} ); } ``` **src/components/TLDR/styles.module.css** ```css /* * TL;DR — was borrowing Infima's "alert alert--info" utility classes, which is * where its unexplained Docusaurus blue came from. The summary is the first * block of every article on the site: it should carry the site's own colour, * not a framework default. */ .tldrContainer { composes: mk-callout from global; composes: mk-callout--key from global; } /* * font-variant-emoji: text asks for the monochrome TEXT presentation of the * lightning character, so it takes --mk-on-line from .mk-dot like every other * dot glyph in the site. Without it the emoji keeps its own yellow-blue * colours on the terracotta disc — the one bicolour pastille on the page. * index.tsx needs no change. */ .tldrIcon { composes: mk-dot from global; font-size: 0.9rem; font-variant-emoji: text; } .tldrBody { min-width: 0; } .tldrTitle { composes: mk-eyebrow from global; display: block; margin-bottom: 0.25rem; color: inherit; } /* * The old rule indented the summary by a hardcoded 2.5rem to clear the icon, * and dimmed it with opacity: 0.9. Both are gone: the flex row aligns the text * for free, and a summary is the last thing on the page that should be faded. */ .tldrContent { font-size: 0.95rem; line-height: 1.65; } .tldrContent > :last-child { margin-bottom: 0; } .tldrContent a { color: var(--tone-line); text-decoration: underline; text-underline-offset: 2px; } ``` ## Le script d'automatisation {#the-automation-script} Sur mon blog, j'ai créé un dossier `.scripts/python_tldr` pour héberger le script Python et ses dépendances. > **API Gemini:** > > Il vous faudra une clé d'API Google Gemini pour que ça fonctionne. Vous pouvez en obtenir une sur . ### Exécution avec Docker {#running-with-docker} Comme je suis un grand fan de Docker, je voulais m'assurer que le script tourne dans un environnement reproductible. Voici comment démarrer le container : ```bash $ docker run -it --rm --env-file .env -v .:/app -w /app python sh -c "pip install google-genai python-dotenv > /dev/null 2>&1 && /bin/bash" ``` Cette commande va créer un container Python, monter votre répertoire courant sur `/app`, lire et charger votre fichier d'environnement, installer les packages nécessaires (`google-genai` pour l'accès à l'API Gemini et `python-dotenv` pour la gestion des variables d'environnement), puis lancer un shell bash où vous pourrez exécuter le script. > **Variables d'environnement:** > > Vous devrez créer un fichier `.env` à la racine de votre projet avec le contenu suivant : > > **.env** > > ``` > GOOGLE_API_KEY=your_gemini_api_key_here > ``` ### Générer les résumés {#generating-summaries} Une fois dans le container, vous pouvez lancer le script sur un fichier précis ou sur tout un répertoire. Pour traiter un seul fichier : ```bash $ python .scripts/python_tldr/main.py blog/2026/01/index.md ``` Pour traiter tous les articles d'un mois donné : ```bash $ python .scripts/python_tldr/main.py blog/2026/01/ ``` Le script est assez malin pour ignorer les fichiers qui contiennent déjà un tag ``, vous pouvez donc le lancer sans risque sur toute l'archive de votre blog. > **Soyez transparent avec vos lecteurs:** > > Un résumé écrit par une machine reste du contenu généré par une machine. Je le signale explicitement sur chaque article concerné ; voir [How to indicate AI-assisted content in a Docusaurus blog](/blog/docusaurus-ai-gemini). Le même principe — *générer une fois à l'écriture, committer le résultat* — est réutilisé dans [AI-Powered Code Tooltips in Docusaurus](/blog/docusaurus-eli5-snippet-tooltips) : les lecteurs n'attendent jamais un appel d'API et votre clé ne quitte jamais votre machine. ## Erreur - 429 RESOURCE\_EXHAUSTED {#error---429-resource\_exhausted} Cette erreur indique que vous avez dépassé vos limites d'utilisation de l'API Gemini. Selon votre plan, il peut y avoir des restrictions sur le nombre de requêtes ou sur le volume de données traitables dans un intervalle de temps donné. *Le plan gratuit est assez limité : si vous avez beaucoup d'articles, envisagez de passer à un plan supérieur ou de lancer le script par lots pour éviter d'atteindre la limite.* Vous pouvez toujours utiliser la même fonctionnalité, mais manuellement : allez sur , collez le contenu de votre article et demandez à Gemini de générer un résumé TL;DR. Copiez ensuite le résultat et collez-le dans votre article, à l'intérieur du composant ``. Voici le prompt utilisé pour obtenir le résumé : ```text You are an expert technical editor. Read the following article content and generate a 'TL;DR' summary. The summary must be concise (max 3 sentences) and written in the same language as the article. ``` ## Conclusion {#conclusion} Vous trouverez ci-dessous tous les fichiers nécessaires pour mettre ça en place sur votre propre blog Docusaurus. N'hésitez pas à adapter et améliorer le script selon vos besoins ! **Project: /your\_docusaurus\_site** > **Note:** > > Maintenant, lancez 'docker run -it --rm -v .:/app -w /app python sh -c "pip install google-genai python-dotenv && python .scripts/python\_tldr/main.py blog/"' pour ajouter le résumé TLDR dans chaque article sous /blog/. **.env** ``` GOOGLE_API_KEY=your_gemini_api_key_here ``` **.scripts/python\_tldr/main.py** ```python # How to run: # # 1. Ensure you have Docker installed. # 2. Place your Gemini API key in a .env file at the root of your blog: # GEMINI_API_KEY=your_actual_api_key_here # 3. Start a terminal, go to your blog and run the following command, # docker run -it --rm -v ${PWD}:/app -w /app python sh -c "pip install google-genai python-dotenv > /dev/null 2>&1 && /bin/bash" # 4. Inside the container, run: # python .scripts/python_tldr/main.py # # Examples: # Process the entire blog directory: # python .scripts/python_tldr/main.py blog/ # # Process a specific year i.e. all posts from 2026: # python .scripts/python_tldr/main.py blog/2026/ # # Process a specific year and month i.e. all posts from January 2026: # python .scripts/python_tldr/main.py blog/2026/01 # # Process a single blog post: # python .scripts/python_tldr/main.py blog/2026/01/05 import argparse import os import sys from pathlib import Path from typing import List, Final from dotenv import load_dotenv # --- PATH PATCH --- # Ensures 'src' module is found regardless of execution context BASE_DIR = Path(__file__).resolve().parent if str(BASE_DIR) not in sys.path: sys.path.insert(0, str(BASE_DIR)) # ------------------ try: from src.ai_service import AIService from src.file_manager import FileManager except ImportError as e: print(f"❌ Configuration Error: Could not import internal modules.\nDetails: {e}") sys.exit(1) def process_single_file(file_path: Path, ai_service: AIService) -> str: """ Processes a single markdown file: reads, checks for existence, generates TL;DR, and injects. Returns a status string for logging. """ try: file_manager = FileManager(file_path) content = file_manager.read_content() # 1. Skip if already exists if file_manager.has_tldr(content): return "SKIPPED (Exists)" # 2. Generate print(f" Generating summary for {file_path.name}...") tldr = ai_service.generate_tldr(content) # 3. Inject file_manager.inject_tldr(content, tldr) return "SUCCESS" except Exception as e: return f"ERROR: {e}" def main() -> None: """Main execution entry point.""" load_dotenv(dotenv_path=BASE_DIR / ".env") if not os.getenv("GEMINI_API_KEY"): load_dotenv() # Fallback parser = argparse.ArgumentParser(description="Inject AI-generated TL;DR into markdown files recursively.") parser.add_argument("path", type=Path, help="Path to a markdown file or a directory containing markdown files.") args = parser.parse_args() target_path: Path = args.path if not target_path.is_absolute(): target_path = Path.cwd() / target_path if not target_path.exists(): print(f"❌ Error: Path not found: {target_path}") sys.exit(1) # Validate API Key api_key: str | None = os.getenv("GEMINI_API_KEY") if not api_key: print("❌ Error: GEMINI_API_KEY not found.") sys.exit(1) # Initialize AI Service once (reused for all files) try: ai_service = AIService(api_key) except Exception as e: print(f"❌ Error initializing AI Service: {e}") sys.exit(1) # Collect files to process files_to_process: List[Path] = [] if target_path.is_file(): if target_path.suffix.lower() == ".md": files_to_process.append(target_path) elif target_path.is_dir(): # Recursive search for all .md files print(f"📂 Scanning directory: {target_path} ...") files_to_process = list(target_path.rglob("*.md")) if not files_to_process: print("⚠️ No Markdown files found to process.") sys.exit(0) print(f"🔍 Found {len(files_to_process)} file(s). Starting processing...\n") # Stats counters stats = {"SUCCESS": 0, "SKIPPED": 0, "ERROR": 0} for i, file_path in enumerate(files_to_process): print(f"📄 Processing [{i+1}/{len(files_to_process)}]: {file_path.name}...", end="", flush=True) status = process_single_file(file_path, ai_service) if "SKIPPED" in status: stats["SKIPPED"] += 1 print(f"\r⚠️ {file_path.name}: {status}") # Pas besoin de dormir si on a sauté le fichier elif "ERROR" in status: stats["ERROR"] += 1 print(f"\r❌ {file_path.name}: {status}") # En cas d'erreur 429, on pourrait vouloir attendre plus, # mais ici on continue simplement. else: stats["SUCCESS"] += 1 print(f"\r✅ {file_path.name}: Injected successfully.") # Final Summary print("\n" + "="*30) print(" BATCH COMPLETE") print("="*30) print(f"✅ Injected: {stats['SUCCESS']}") print(f"⏭️ Skipped: {stats['SKIPPED']}") print(f"❌ Failed: {stats['ERROR']}") print("="*30) if __name__ == "__main__": main() ``` **.scripts/python\_tldr/src/ai\_service.py** ```python import os import time import logging from typing import Final from google import genai from google.genai import types from google.genai.errors import ClientError class AIService: """ Handles interactions with the Google Gen AI SDK (v1.0+). Includes retry logic for rate limits (429). """ # Let's switch to the standard Flash 2.0. # It often has a more stable pool than the 'Lite' or 'Preview' versions. _MODEL_NAME: Final[str] = "gemini-2.0-flash" def __init__(self, api_key: str | None) -> None: if not api_key: raise ValueError("API Key is missing. Check your .env file.") self._client = genai.Client(api_key=api_key) def generate_tldr(self, content: str) -> str: """ Generates a concise TL;DR summary using Gemini. Includes exponential backoff for 429 Resource Exhausted errors. """ prompt: str = ( "You are an expert technical editor. " "Read the following article content and generate a 'TL;DR' summary. " "The summary must be concise (max 3 sentences) and written in the same language as the article. " "Do NOT use blockquotes (>), markdown formatting, or introductory text. " "Just provide the raw summary text.\n\n" f"Content:\n{content}" ) max_retries = 5 base_delay = 5 # Start with 5 seconds for attempt in range(max_retries): try: response = self._client.models.generate_content( model=self._MODEL_NAME, contents=prompt, config=types.GenerateContentConfig( temperature=0.3, ) ) if not response.text: raise RuntimeError("Empty response from Gemini API.") return response.text.strip().lstrip(">").strip() except ClientError as e: # Check if it is a Quota/Rate Limit error (429) if e.code == 429: if attempt == max_retries - 1: raise RuntimeError(f"Max retries exceeded after 429 error: {e}") from e # Calculate wait time: 5s, 10s, 20s, 40s... wait_time = base_delay * (2 ** attempt) print(f"\n ⏳ Quota hit (429). Waiting {wait_time}s before retry {attempt + 1}/{max_retries}...") time.sleep(wait_time) else: # If it's another error (400, 404, 500), crash immediately raise RuntimeError(f"AI Generation failed: {e}") from e except Exception as e: raise RuntimeError(f"Unexpected error: {e}") from e raise RuntimeError("Failed to generate TL;DR after retries.") ``` **.scripts/python\_tldr/src/file\_manager.py** ```python from pathlib import Path from typing import Final, List class FileManager: """ Handles file reading, content checking, and safe injection. """ _FRONTMATTER_DELIMITER: Final[str] = "---" _IMAGE_START_TAG: Final[str] = "![" _TLDR_TAG: Final[str] = "" def __init__(self, file_path: Path) -> None: if not file_path.exists(): raise FileNotFoundError(f"File not found: {file_path}") self._file_path = file_path def read_content(self) -> str: """Reads the file content securely.""" return self._file_path.read_text(encoding="utf-8") def has_tldr(self, content: str) -> bool: """ Checks if the TLDR component is already present in the content. """ return self._TLDR_TAG in content def inject_tldr(self, original_content: str, tldr_text: str) -> None: """ Injects the TL;DR into the content using the custom React component. Inserts after the first image following the frontmatter. """ lines: List[str] = original_content.splitlines() insertion_index: int = 0 # 1. Detect Frontmatter end if lines and lines[0].strip() == self._FRONTMATTER_DELIMITER: try: frontmatter_end_index = lines.index(self._FRONTMATTER_DELIMITER, 1) insertion_index = frontmatter_end_index + 1 except ValueError: insertion_index = 0 # 2. Scan for the first image AFTER the frontmatter for i in range(insertion_index, len(lines)): line = lines[i].strip() if line.startswith(self._IMAGE_START_TAG): insertion_index = i + 1 break # 3. Prepare the React Component content formatted_tldr = ( f"\n{self._TLDR_TAG}\n" f"{tldr_text}\n" f"\n" ) # 4. Insert and Save lines.insert(insertion_index, formatted_tldr) new_content = "\n".join(lines) self._file_path.write_text(new_content, encoding="utf-8") print(f"Successfully injected React into {self._file_path.name} (Line {insertion_index + 1})") ``` **src/components/TLDR/index.tsx** ```typescript import { useMemo, type JSX, type ReactNode } from "react"; import Head from "@docusaurus/Head"; import MobileQuickLinks from "@site/src/components/Blog/MobileQuickLinks"; import styles from "./styles.module.css"; import { translate } from "@docusaurus/Translate"; // Recursively extracts plain text from React nodes for JSON-LD abstract function extractText(node: unknown): string { if (typeof node === "string") return node; if (typeof node === "number") return String(node); if (Array.isArray(node)) return node.map(extractText).join(""); if (node && typeof node === "object") { const children = (node as { props?: { children?: unknown } }).props?.children; if (children) return extractText(children); } return ""; } interface Props { children: ReactNode; } // Injects JSON-LD so search engines read the abstract via Schema.org BlogPosting export default function TLDR({ children }: Props): JSX.Element { const structuredData = useMemo( () => ({ "@context": "https://schema.org", "@type": "BlogPosting", abstract: extractText(children), }), [children], ); return ( <>
⚡
TL;DR
{children}
{/* The TL;DR is the one block every post opens with, which makes it the only reliable anchor for a "read next" hint placed *after* the reader has confirmed the article is the right one. MobileQuickLinks decides on its own whether it has anything to show. */} ); } ``` **src/components/TLDR/styles.module.css** ```css /* * TL;DR — was borrowing Infima's "alert alert--info" utility classes, which is * where its unexplained Docusaurus blue came from. The summary is the first * block of every article on the site: it should carry the site's own colour, * not a framework default. */ .tldrContainer { composes: mk-callout from global; composes: mk-callout--key from global; } /* * font-variant-emoji: text asks for the monochrome TEXT presentation of the * lightning character, so it takes --mk-on-line from .mk-dot like every other * dot glyph in the site. Without it the emoji keeps its own yellow-blue * colours on the terracotta disc — the one bicolour pastille on the page. * index.tsx needs no change. */ .tldrIcon { composes: mk-dot from global; font-size: 0.9rem; font-variant-emoji: text; } .tldrBody { min-width: 0; } .tldrTitle { composes: mk-eyebrow from global; display: block; margin-bottom: 0.25rem; color: inherit; } /* * The old rule indented the summary by a hardcoded 2.5rem to clear the icon, * and dimmed it with opacity: 0.9. Both are gone: the flex row aligns the text * for free, and a summary is the last thing on the page that should be faded. */ .tldrContent { font-size: 0.95rem; line-height: 1.65; } .tldrContent > :last-child { margin-bottom: 0; } .tldrContent a { color: var(--tone-line); text-decoration: underline; text-underline-offset: 2px; } ``` > **Limitations de Gemini:** > > Selon votre plan Gemini AI, il peut y avoir des limites sur le nombre de requêtes ou la longueur du texte traité. Vérifiez bien les détails de votre plan pour éviter les frais inattendus ou les interruptions. > > Actuellement, mon propre plan me permet de faire 20 appels par jour. > **src/theme/MDXComponents.js:** > > N'oubliez pas de mettre à jour votre fichier `src/theme/MDXComponents.js` pour y inclure le nouveau composant `TLDR`, afin que Docusaurus sache comment le rendre. Si vous n'avez pas encore ce fichier, créez-le. Voici son contenu : > > **src/theme/MDXComponents.js** > > ```javascript > // [...] > > // highlight-next-line > import TLDR from "@site/src/components/TLDR"; > > export default { > // Reusing the default mapping > ...MDXComponents, > // [...] > // highlight-next-line > TLDR, > // [...] > }; > ``` --- # Le composant ProjectSetup - Une façon standardisée de partager des structures de projet > Découvrez comment utiliser le nouveau composant ProjectSetup pour partager facilement des structures de projet dans vos articles de blog Docusaurus, avec des snippets de fichiers interactifs et des scripts d'installation automatisés. --- ![Le composant ProjectSetup - Une façon standardisée de partager des structures de projet](/img/v2/project_setup.webp) **TL;DR** Le composant `ProjectSetup` est un nouvel outil pour Docusaurus qui permet de partager des structures de projet de façon standardisée et interactive. Il affiche les arborescences de fichiers avec des snippets repliables, génère des scripts d'installation et propose un téléchargement au format ZIP. Vos lecteurs peuvent ainsi reproduire les projets de vos articles beaucoup plus facilement. Sur mon blog, je partage souvent des [composants que j'ai créés pour Docusaurus](/blog/docusaurus-series). Pour permettre aux lecteurs de reproduire facilement ces projets, je cherchais une manière interactive et standardisée de partager des arborescences de fichiers. C'est ce qui m'a amené à créer le composant `ProjectSetup`. Il permet d'afficher la structure d'un projet de façon claire et interactive, avec un snippet de code pour chaque fichier. Mais sa vraie force, sa « **killer feature** », c'est ceci : en un seul clic sur le bouton **« Generate install script »**, vous obtenez une commande shell. Copiez-la, collez-la dans votre terminal Linux, exécutez-la et... voilà. Toute l'arborescence du projet, dossiers et fichiers compris, est créée pour vous. Pour ceux qui préfèrent, le composant propose aussi de télécharger le projet sous forme d'archive ZIP. Dans cet article, je vous montre comment ça fonctionne et comment l'intégrer pour partager efficacement vos propres projets. **Quick Jump:** [Le voir à l'œuvre](#seeing-it-work) | [Installer le reste](#installing-the-rest) ## Le voir à l'œuvre {#seeing-it-work} La meilleure façon de vous montrer comment utiliser `ProjectSetup`, c'est d'utiliser le composant lui-même. Voici comment installer le composant `LogoIcon` dans votre propre projet Docusaurus. **Project: src/components/Blog/LogoIcon** > **Note:** > > Installez la dépendance : `npm install @iconify/react` **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 ( ); } ``` À ce stade, vous vous dites peut-être : oh la la, ça doit être fastidieux d'écrire le code HTML derrière cette jolie boîte **📦 Project setup: src/components/Blog/LogoIcon** ci-dessus. En fait, pas du tout. Tout le rendu est pris en charge par le composant `ProjectSetup` lui-même. Voici le code Markdown que j'ai écrit dans mon article Docusaurus : ```markdown Install the dependency: `npm install @iconify/react` ``` > **Toujours à jour:** > > Et vous savez quoi ? Le composant `Snippet` utilisé ici est assez malin pour lire la structure et le contenu du fichier `src/components/Blog/LogoIcon/index.tsx`, et pour en faire ce joli rendu interactif. Vous n'avez qu'à écrire le code Markdown ci-dessus, le composant fait le reste. Votre documentation reste toujours synchronisée avec le contenu réel du fichier, et vous n'avez pas à vous soucier du formatage ou du style. Vous écrivez le Markdown, le composant s'occupe du reste. ## Qu'est-ce que ProjectSetup ? {#what-is-projectsetup} `ProjectSetup` est un composant React qui permet d'afficher la structure de fichiers d'un projet de façon propre, 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 au format ZIP. Cet article illustre comment utiliser le composant `ProjectSetup` pour partager des structures de projet dans vos articles de blog Docusaurus, et ainsi permettre à vos lecteurs de reproduire vos projets et d'en tirer profit plus facilement. ## Fonctionnalités principales {#core-features} * **Affichage interactif des fichiers** : les fichiers s'affichent dans des snippets repliables, avec coloration syntaxique. * **Génération de script shell** : un script bash est généré automatiquement pour créer toute l'arborescence de dossiers et de fichiers. * **Téléchargement ZIP** : les utilisateurs peuvent télécharger le projet complet sous forme d'archive ZIP. * **Instructions post-installation** : vous pouvez ajouter des consignes à afficher après l'installation. ## Dépendances {#dependencies} Le composant `ProjectSetup` s'appuie sur deux autres composants : 1. **`LogoIcon`** : un composant simple qui affiche une icône selon le type de fichier, ce qui rend l'interface plus intuitive. Il utilise la très répandue bibliothèque `@iconify/react`. LogoIcon a été créé par ![Docux](/img/docux.webp) [Docux](https://github.com/Juniors017), allez jeter un œil à son travail : [Component LogoIcon](https://docuxlab.com/blog/logoicon-component-docusaurus/). 2. **`Snippet`** : ce composant se charge d'afficher chaque fichier. C'est un container repliable qui montre le nom du fichier, une icône pertinente et le code correspondant. Lisez mon article précédent sur le sujet : [A component for showing code snippets in a Docusaurus blog](/blog/docusaurus-snippets) ## Installer le reste {#installing-the-rest} Voici comment installer les composants `Snippet` et `ProjectSetup` restants dans votre propre projet Docusaurus. ### 1. Installer `Snippet` {#1-installing-snippet} Mettons maintenant en place le composant `Snippet`. Il a un peu plus de dépendances. **Project: src/components/Snippet** > **Note:** > > Installez les dépendances : `npm install clsx` **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 (
    <>
      
        
        
          {lines.map((lineTokens, i) => {
            const lineNum = String(i + 1);
            const explanation = eli5[lineNum];
            const isActive = activeLine === lineNum;

            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 && }
); } ``` **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);
}
```

### 2. Installer `ProjectSetup` {#2-installing-projectsetup}

Enfin, voici comment installer le composant `ProjectSetup` lui-même.

**Project: src/components/ProjectSetup**

> **Note:**
>
> Installez les dépendances : `npm install jszip`

**src/components/ProjectSetup/index.tsx**

```typescript
import React, {
  useState,
  Children,
  isValidElement,
  useMemo,
  useCallback,
  useRef,
  useEffect,
  type JSX,
  type ReactNode,
} from "react";
import CodeBlock from "@theme/CodeBlock";
import Snippet from "@site/src/components/Snippet";
import Translate, { translate } from "@docusaurus/Translate";
import JSZip from "jszip";
import styles from "./styles.module.css";

/**
 * Guideline Component
 * Minimalist component to capture post-install instructions.
 */
export const Guideline = Object.assign(() => null, { isGuideline: true });

/**
 * EmptyFolder Component
 * Use this to ensure a specific empty directory is created.
 */
export const EmptyFolder = Object.assign(() => null, { isEmptyFolder: true });

interface ChildProps {
  children?: ReactNode;
  filename?: string;
  title?: string;
  name?: string;
  code?: string;
}

interface Props {
  folderName?: string;
  createFolder?: boolean;
  /** Alternative to `children`: a flat map of fileName -> file content. */
  files?: Record;
  children?: ReactNode;
}

// Extract plain text from Guideline children (handles MDX wrapping like 

) const getGuidelineText = (nodes: ReactNode): string => { let text = ""; Children.forEach(nodes, (child) => { if (typeof child === "string" || typeof child === "number") { text += child; } else if (isValidElement(child)) { text += getGuidelineText((child.props as ChildProps).children); } }); return text; }; /** * ProjectSetup Component * * A component that renders a list of files and provides tools to scaffold the project: * - View files with syntax highlighting. * - Generate a Bash "one-liner" to create the folder structure. * - Download the project as a ZIP file. * * @example * * * npm install && npm start * * * */ export default function ProjectSetup({ folderName = "my-project", createFolder = true, files, children, }: Props): JSX.Element { const [showScript, setShowScript] = useState(false); const [expandAll, setExpandAll] = useState(false); const [copied, setCopied] = useState(false); const [isDownloading, setIsDownloading] = useState(false); const [zipError, setZipError] = useState(null); const copiedTimerRef = useRef | null>(null); useEffect(() => { return () => { if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current); }; }, []); // Parse children once; recalculate only when files or children change const { fileList, guidelines, folders } = useMemo(() => { const fileList: { fileName: string; content: string; originalNode?: React.ReactElement; }[] = []; const guidelines: React.ReactElement[] = []; const folders: string[] = []; if (files) { Object.entries(files).forEach(([fileName, content]) => { fileList.push({ fileName, content }); }); } else if (children) { const processChildren = (nodes: ReactNode) => { Children.forEach(nodes, (child) => { if (!isValidElement(child)) return; const childType = child.type as unknown as { isGuideline?: boolean; isEmptyFolder?: boolean; }; const props = child.props as ChildProps; if (child.type === Guideline || childType.isGuideline) { guidelines.push(child); return; } if (child.type === EmptyFolder || childType.isEmptyFolder) { if (props.name) folders.push(props.name); return; } // Exclude HTML elements (like

) from being treated as files. const isFileComponent = child.type === Snippet || props.filename || (props.title && typeof child.type !== "string"); if (isFileComponent) { const { filename, title, code, children: childChildren } = props; const name = filename || title || "unknown"; // The remark plugin injects the file content into the 'code' prop. // We prioritize 'code'. We do NOT use 'source' as content because it is just the file path. let content = code; if (!content && typeof childChildren === "string") content = childChildren; if (content) fileList.push({ fileName: name, content, originalNode: child }); } else if (props.children) { processChildren(props.children); } }); }; processChildren(children); } return { fileList, guidelines, folders }; }, [files, children]); // Memoize the generated script so it isn't rebuilt on every UI state change const shellScript = useMemo(() => { // ~ is not expanded inside double quotes in bash; replace with $HOME so // paths like ~/.zsh/fns don't create a literal "~" directory. const shellPath = (p: string) => p.replace(/^~/, "$HOME"); const shellFolder = shellPath(folderName); let script = ""; if (createFolder) { script += `mkdir -p "${shellFolder}" && cd "${shellFolder}"\n`; } folders.forEach((f) => { script += `\nmkdir -p "${shellPath(f)}"\n`; }); fileList.forEach(({ fileName, content }) => { const delimiter = "PROJECT_SETUP_EOF"; script += `\nmkdir -p "$(dirname "${fileName}")" && cat <<'${delimiter}' > "${fileName}"\n${content.trim()}\n${delimiter}\n`; }); script += `\necho "✅ Setup completed in folder: ${shellFolder}"`; guidelines.forEach((g) => { const text = getGuidelineText((g.props as ChildProps).children) .trim() .replace(/\s+/g, " "); script += `\necho "🚀 ${text}"`; }); return script; }, [createFolder, folderName, folders, fileList, guidelines]); const handleCopyScript = useCallback(() => { navigator.clipboard.writeText(shellScript); setCopied(true); if (copiedTimerRef.current) clearTimeout(copiedTimerRef.current); copiedTimerRef.current = setTimeout(() => setCopied(false), 2000); }, [shellScript]); const downloadZip = useCallback(async () => { if (fileList.length === 0 && folders.length === 0) { setZipError( translate({ id: "projectSetup.zipEmpty", message: "No files or folders found to zip.", }), ); return; } setZipError(null); setIsDownloading(true); try { const zip = new JSZip(); // Remove leading slashes to ensure a valid zip folder path (fixes issues with "/tmp/...") const safeFolderName = folderName.replace(/^\/+/, ""); const folder = zip.folder(safeFolderName); // JSZip.folder() is typed `JSZip | null`; it only returns null on an // invalid name, which `safeFolderName` already guards against. The throw // is caught below and surfaced through setZipError(). if (!folder) { throw new Error(`Could not create the "${safeFolderName}" zip folder.`); } folders.forEach((f) => folder.folder(f)); fileList.forEach(({ fileName, content }) => folder.file(fileName, content.trim())); if (guidelines.length > 0) { let readme = `# Project Setup: ${folderName}\n\n## Next Steps:\n\n`; guidelines.forEach((g) => { const text = getGuidelineText((g.props as ChildProps).children) .trim() .replace(/\s+/g, " "); readme += `- ${text}\n`; }); folder.file("README.md", readme); } const blob = await zip.generateAsync({ type: "blob" }); const url = window.URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = `${safeFolderName}.zip`; // Append to DOM before clicking for Firefox compatibility document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(url); } finally { setIsDownloading(false); } }, [fileList, folders, folderName, guidelines]); return (
📦 Project setup: {folderName}
Help {showScript ? ( ) : ( )}
{zipError && (

{zipError}

)} {showScript ? (

Copy this block to your terminal (Linux/WSL2). {!createFolder && ( {" "} {folderName} }} > {"Make sure you are located in the {folder} folder."} )}

{shellScript}
) : (

{" "} Need help here? Click the "Help" button above for setup instructions.

{fileList.map(({ fileName, content, originalNode }, index) => originalNode ? ( React.cloneElement( originalNode as React.ReactElement<{ code?: string; defaultOpen?: boolean; }>, { code: content, defaultOpen: expandAll, key: `${fileName || index}-${expandAll}`, }, ) ) : ( ), )}
)}
); } ``` **src/components/ProjectSetup/styles.module.css** ```css /* * ProjectSetup — shares Snippet's toolbar and surface exactly. Before, the two * sat one above the other in the same article with different radii, different * header backgrounds and different header type. */ .projectSetupContainer { composes: mk-surface from global; margin: 2rem 0; overflow: hidden; } .header { composes: mk-toolbar from global; } .headerTitle { composes: mk-eyebrow from global; } .content { padding: 1rem 1.25rem; } .scriptContainer { max-height: 400px; overflow: auto; } .tip { margin-bottom: 1rem; } .error { margin-bottom: 0.75rem; font-size: 0.875rem; color: var(--mk-danger-line); } .buttonGroup { composes: mk-toolbar__actions from global; } /* Toolbar actions: quiet by default, terracotta on hover. They used to be * Infima buttons, which put two filled buttons in a header bar. */ .buttonGroup button, .buttonGroup a { font-family: var(--ifm-font-family-monospace); font-size: 0.72rem; font-weight: 600; color: var(--mk-ink-soft); text-decoration: none; cursor: pointer; background: none; border: none; padding: 0.25rem 0.5rem; border-radius: var(--mk-radius-sm); } .buttonGroup button:hover, .buttonGroup a:hover { color: var(--mk-primary); background: var(--ifm-hover-overlay); } .buttonGroup button:focus-visible, .buttonGroup a:focus-visible { outline: 2px solid var(--mk-primary); outline-offset: 2px; } ``` ### 3. La page d'aide project\_setup {#3-the-project\_setup-help-page} Le composant ProjectSetup renvoie vers une page d'aide qui explique comment l'utiliser. Vous pouvez personnaliser cette page en éditant le fichier `src/pages/project_setup.mdx`. Voici la mienne : **src/pages/project\_setup.mdx** ```mdx --- title: "How to use the Project Setup component" description: "A detailed guide on how to use the Project Setup component, create a ZIP file, or generate an install script." --- import ProjectSetup, { EmptyFolder, Guideline } from "@site/src/components/ProjectSetup"; import Snippet from "@site/src/components/Snippet"; import Tabs from "@theme/Tabs"; import TabItem from "@theme/TabItem"; import CodeBlock from "@theme/CodeBlock"; import styles from "./project_setup.module.css"; # How to Use the Project Setup Component The **Project Setup** component is a powerful tool designed to help you quickly scaffold a new project. It provides a list of files and offers two main ways to get started: downloading a ZIP archive or using a generated installation script. Below a fully functional example of the component looks like. Now, simply run 'docker compose up -d' to start the project then open your browser and surf to http://localhost:8080 ## Option 1: Download as a ZIP File This is the simplest way to get all the project files on your local machine. 1. **Click the "ZIP" button**: This will bundle all the files and folders into a single `.zip` archive and trigger a download in your browser. 2. **Save the file**: Choose a location on your computer to save the ZIP file. 3. **Uncompress the archive**:
Right-click the downloaded file and select "Extract All...". Choose a destination folder, and the files will be uncompressed there.
Open your terminal, navigate to the directory where you saved the file, and run:
unzip your-project-name.zip -d your-project-name
4. **Read the `README.md`**: Inside the uncompressed folder, you'll find a `README.md` file. It contains the next steps, such as installation commands (e.g., `npm install`) and how to run the project. Below an example of how using the ZIP in an MS-DOS console. Just download the archive, unzip it , use `cd` to jump in the folder where files are located then, finally, display the instructions (`type README.md`) and follow them in order to run the project. In the example below, instructions are to run `docker compose up -d` then to open a webpage. ![Unzip on DOS](./images/unzip_using_dos.webp) ## Option 2: Generate Install Script This option is ideal for users who are comfortable with the command line (Linux, macOS, or WSL on Windows). It generates a "one-liner" script that creates all the files and folders directly in your terminal. 1. **Click the "Generate install script" button**: This will switch the view from the file list to a code block containing a Bash script. 2. **Copy the script**: Click the **"Copy"** button to copy the entire script to your clipboard. 3. **Paste and run in your terminal**: - Open your terminal. - Navigate to the directory where you want to create the project. - Paste the script and press `Enter`. 4. **Follow the on-screen instructions**: The script will create the project folder, all the necessary subdirectories and files, and then print the next steps to follow. Once you've clicked on the `Generate install script` button, you've to click on the `Copy` button to copy it in your clipboard. Start a console (MS-DOS, Powershell or most likely a Linux/WSL one) and paste the content of the clipboard. This done, press Enter. ![Generate install script](./images/generate_install_script.webp) Then start a console (Ubuntu in the example below) and paste: ![Pasting clipboard to WSL](./images/pasting_on_wsl.webp) As you can see on the image, instructions are then displayed at the bottom of the screen. ## Option 3: Manual File Creation If you prefer to create the files yourself, you can do so by following these steps: 1. **Create the project folder**: Manually create the main folder for your project (e.g., `my-project`) or jump in any existing one. 2. **View the file list**: In the **Project Setup** component, you can see a list of all the files and their content. 3. **Create each file**: For each file in the list, create a new file with the same name and copy the content into it. 4. **Create empty folders**: If there are any empty folders specified, make sure to create them as well. 5. **Follow the guidelines**: After creating all the files, follow the instructions provided in the "guidelines" section of the component to install dependencies and run the project. ``` ## Conclusion {#conclusion} Le composant `ProjectSetup` est un outil puissant pour partager des structures de projet dans vos articles de blog Docusaurus. Il offre une manière interactive d'explorer l'arborescence des fichiers, génère des scripts d'installation et propose un téléchargement ZIP, ce qui facilite la reproduction de vos projets. En l'utilisant, vous améliorez l'expérience d'apprentissage de vos lecteurs et les incitez à s'impliquer davantage dans votre contenu. --- # Comment signaler un contenu assisté par IA dans un blog Docusaurus > Un guide pas à pas pour indiquer qu'un article a été écrit avec l'aide d'une IA comme Google Gemini, en ajoutant une icône et un auteur. --- ![Comment signaler un contenu assisté par IA dans un blog Docusaurus](/img/v2/gemini-co-author.webp) **TL;DR** Cet article explique comment signaler un contenu assisté par IA dans un blog Docusaurus, en ajoutant une icône et un co-auteur. À l'heure des assistants IA puissants comme Google Gemini, il devient de plus en plus courant de les utiliser pour créer du contenu, brainstormer ou même coder. En tant que créateur de contenu, je crois à la transparence envers mon audience. Si une IA a contribué de manière significative à un contenu, le lecteur doit le savoir. *Deux cas concrets où je m'appuie sur une IA et où je lève donc ce drapeau : [Automating TL;DR Summaries with Gemini AI](/blog/gemini-tldr) et [AI-Powered Code Tooltips in Docusaurus](/blog/docusaurus-eli5-snippet-tooltips).* Cet article est un guide pas à pas de la façon dont j'ai implémenté un indicateur « AI Assisted » dans mon blog Docusaurus. Quand j'ajoute `ai_assisted: true` au front matter YAML d'un article, deux choses se produisent automatiquement : 1. Une petite icône « AI Assisted » apparaît à côté de la date et du temps de lecture de l'article. 2. « Google Gemini » est ajouté comme co-auteur. Voici comment faire la même chose sur votre propre blog. **Quick Jump:** [Voir le résultat](#the-result) | [Étape 2 : créer le composant AIIcon](#step-2-creating-the-aiicon-component) ## Le résultat {#the-result} Voici ce que produit un simple flag `ai_assisted: true`, rendu sur un vrai article de ce blog : ![Le badge « AI Assisted » et le co-auteur Google Gemini, rendus dans l'en-tête d'un vrai article](./images/ai_assisted_badge.png) L'icône se place juste à côté de la date et du temps de lecture, et l'IA apparaît comme un véritable co-auteur aux côtés de l'auteur humain — rien de caché, rien à cliquer pour le découvrir. ## Pourquoi faire ça {#why-do-this} En tant que créateur de contenu, je crois à la transparence envers mon audience. Si une IA a contribué de manière significative à un contenu, le lecteur doit le savoir — pas enterré dans un pied de page, pas omis, mais là, dans l'en-tête, où la date et le temps de lecture se trouvent déjà. Un seul flag dans le front matter pilote à la fois l'icône et le co-auteur : pas d'étape séparée à oublier. ## Étape 1 : le front matter `ai_assisted` {#step-1-the-ai\_assisted-front-matter} Tout le processus est piloté par un seul flag dans le front matter de vos articles. C'est la partie la plus simple : ajoutez juste `ai_assisted: true` à chaque article pour lequel une IA vous a aidé. **your-post.md** ```markdown --- slug: my-awesome-post title: My Awesome Post authors: [christophe] ai_assisted: true --- ... content ... ``` Ce flag sera le déclencheur de toute la logique que nous allons construire ensuite. ## Étape 2 : créer le composant `AIIcon` {#step-2-creating-the-aiicon-component} Commençons par créer le composant visuel de notre indicateur. C'est un simple composant React qui affiche une icône et le texte « AI Assisted ». **Project: src/components/Blog/AIIcon** **src/components/Blog/AIIcon/index.tsx** ```typescript import React from "react"; import styles from "./index.module.css"; import Translate, { translate } from "@docusaurus/Translate"; const AIIcon = (): React.JSX.Element => { return (
AI Assisted
); }; export default AIIcon; ``` **src/components/Blog/AIIcon/index.module.css** ```css .aiIcon { display: inline-flex; align-items: center; vertical-align: middle; gap: 8px; border: 1px solid var(--ifm-color-primary-dark); padding: 4px 8px; border-radius: 8px; font-size: 0.8rem; color: var(--ifm-color-primary-dark); margin-left: 4px; } .icon { width: 16px; height: 16px; display: block; } ``` ## Étape 3 : afficher l'icône grâce au swizzling {#step-3-displaying-the-icon-with-swizzling} Il faut maintenant faire apparaître ce composant dans l'en-tête de nos articles. Pour cela, nous devons modifier certains composants du thème de base de Docusaurus. C'est là que le **swizzling** entre en jeu. Le swizzling est une fonctionnalité de Docusaurus qui permet de remplacer un composant du thème par votre propre version personnalisée. Dans cette implémentation, la logique est répartie entre trois composants : `BlogPostItem`, son `Header`, et la section `Info` à l'intérieur de l'en-tête. Nous devrons swizzler les trois. Lancez les commandes suivantes dans votre terminal : ```bash $ yarn run swizzle @docusaurus/theme-classic BlogPostItem $ yarn run swizzle @docusaurus/theme-classic BlogPostItem/Header $ yarn run swizzle @docusaurus/theme-classic BlogPostItem/Header/Info ``` Cela va copier les fichiers originaux du thème dans votre répertoire `src/theme`, prêts à être édités. ### 3.1. Modifier `src/theme/BlogPostItem/index.js` {#31-modify-srcthemeblogpostitemindexjs} Ici, nous allons importer notre nouveau `AIIcon` et décider s'il doit être affiché en fonction du front matter. ```javascript title="src/theme/BlogPostItem/index.js" {1,9,12} import AIIcon from "@site/src/components/Blog/AIIcon"; 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"; export default function BlogPostItem({ children, className }) { const { metadata, isBlogPostPage } = useBlogPost(); const { frontMatter } = metadata; const containerClassName = useContainerClassName(); const aiIcon = frontMatter.ai_assisted && isBlogPostPage ? : null; return ( {children} ); } ``` ### 3.2. Modifier `src/theme/BlogPostItem/Header/index.js` {#32-modify-srcthemeblogpostitemheaderindexjs} Ce composant a juste besoin d'accepter la prop `aiIcon` et de la transmettre à son enfant. ```javascript title="src/theme/BlogPostItem/Header/index.js" {6,10} import React from 'react'; import BlogPostItemHeaderTitle from '@theme/BlogPostItem/Header/Title'; import BlogPostItemHeaderInfo from '@theme/BlogPostItem/Header/Info'; import BlogPostItemHeaderAuthors from '@theme/BlogPostItem/Header/Authors'; // Add aiIcon to the function signature export default function BlogPostItemHeader({aiIcon}) { return (
); } ``` ### 3.3. Modifier `src/theme/BlogPostItem/Header/Info/index.js` {#33-modify-srcthemeblogpostitemheaderinfoindexjs} Enfin, ce composant reçoit la prop et l'affiche. ```javascript title="src/theme/BlogPostItem/Header/Info/index.js" {11,24-25} import React from "react"; import clsx from "clsx"; import { translate } from "@docusaurus/Translate"; // ... other imports function Spacer() { return <>{" · "}; } export default function BlogPostItemHeaderInfo({ className, aiIcon }) { const { metadata } = useBlogPost(); const { date, readingTime } = metadata; // ... other code return (
{typeof readingTime !== "undefined" && ( <> )} {aiIcon && } {aiIcon}
); } ``` Avec ces modifications, l'icône « AI Assisted » apparaîtra désormais sur tout article de blog dont le front matter est correct. ## Étape 4 : ajouter « Google Gemini » comme auteur {#step-4-adding-google-gemini-as-an-author} Comme pour l'icône, nous pouvons ajouter automatiquement un auteur. ### 4.1. Définir l'auteur IA {#41-define-the-ai-author} D'abord, définissez votre auteur IA dans `blog/authors.yml`. **blog/authors.yml** ```yaml gemini: name: Google Gemini title: AI Assistant url: https://gemini.google.com/ image_url: /img/gemini-logo.webp ``` ### 4.2. Swizzler le composant Authors {#42-swizzle-the-authors-component} Nous devons swizzler un composant de plus pour injecter la logique d'auteur. ```bash $ yarn run swizzle @docusaurus/theme-classic BlogPostItem/Header/Authors ``` ### 4.3. Modifier `src/theme/BlogPostItem/Header/Authors/index.js` {#43-modify-srcthemeblogpostitemheaderauthorsindexjs} Éditez maintenant le fichier fraîchement créé pour y ajouter la logique. Nous lisons le flag `ai_assisted` depuis le front matter et, s'il est à true, nous ajoutons notre objet auteur « Gemini » à la liste des auteurs. ```javascript title="src/theme/BlogPostItem/Header/Authors/index.js" {14-24} import React from "react"; import clsx from "clsx"; import { useBlogPost } from "@docusaurus/plugin-content-blog/client"; import BlogAuthor from "@theme/Blog/Components/Author"; import styles from "./styles.module.css"; export default function BlogPostItemHeaderAuthors({ className }) { const { metadata: { authors }, frontMatter, assets, } = useBlogPost(); const allAuthors = [...authors]; if (frontMatter.ai_assisted) { const geminiAuthor = { name: "Google Gemini", title: "AI Assistant", url: "https://gemini.google.com/", imageURL: "/img/gemini-logo.webp", }; if (!allAuthors.find((a) => a.name === geminiAuthor.name)) { allAuthors.push(geminiAuthor); } } const authorsCount = allAuthors.length; // ... rest of the component ``` ## Conclusion {#conclusion} Voilà, c'est tout ! Avec quelques personnalisations de composants, vous disposez maintenant d'un système solide pour signaler de façon transparente l'assistance d'une IA dans votre blog. Cette approche garde vos fichiers Markdown propres (un seul flag à ajouter) et centralise la logique dans votre thème Docusaurus, ce qui la rend facile à maintenir et à faire évoluer. En étant ouvert sur votre processus, vous gagnez la confiance de votre audience. Ce flag `ai_assisted`, vous le verrez sur d'autres articles où l'IA a joué un vrai rôle, comme [How I used Google Gemini Nano Banana on my blog](/blog/gemini-meerkat) (la génération de toutes mes images de bannière) ou [Lovable.dev](/blog/lovable-dev-ai) (un constructeur d'applications piloté par IA) — l'article même dont la capture d'écran ci-dessus est tirée. --- # Donnez vie à vos tutoriels CLI avec un terminal machine à écrire > Ajoutez une animation machine à écrire à votre composant Terminal Docusaurus — les lignes de commande tapées caractère par caractère, les lignes de sortie qui apparaissent l'une après l'autre, avec un curseur clignotant et un clic pour passer l'animation. Trois nouvelles props, aucune rupture de compatibilité. --- ![Donnez vie à vos tutoriels CLI avec un terminal machine à écrire](/img/v2/typewriter_terminal.webp) **TL;DR** Un bloc de code statique explique ce qu'il faut lancer. Un terminal animé montre ce qui *se passe*. Cet article étend le composant `Terminal` existant avec une prop `typewriter` : les lignes de commande (celles qui commencent par `$` ou `#`) sont tapées caractère par caractère avec un curseur clignotant ; les lignes de sortie apparaissent d'un bloc après une courte pause. Trois nouvelles props contrôlent la vitesse et le rythme. Le changement est totalement rétrocompatible — chaque bloc `` existant reste identique tant que vous ne l'activez pas. L'implémentation, c'est du state React pur + `setTimeout`, sans aucune librairie. Les tutoriels CLI sont denses. Le lecteur suit, copie-colle les commandes, et espère que la sortie affichée correspond à ce que vous avez écrit. Mais quand l'article ne montre qu'un bloc de texte figé, la séquence des événements — la commande, la pause, la réponse — s'effondre en un seul mur de monospace. Une animation machine à écrire restitue cette séquence. La commande apparaît lettre par lettre, comme si quelqu'un la tapait vraiment. Puis la sortie suit. L'œil du lecteur est guidé naturellement à travers l'interaction, sans explication supplémentaire. **Quick Jump:** [La nouvelle prop typewriter](#the-new-typewriter-prop) | [Créer le composant](#create-the-component) Si vous lisez régulièrement mon blog, vous savez que j'ai développé un composant appelé `Terminal` qui me permet de lister une série de commandes que j'ai lancées dans une console de style Linux — et le rendu est plutôt réaliste. C'est le pendant « console » du [composant que j'utilise pour afficher des snippets de code](/blog/docusaurus-snippets). Voici un exemple : ```bash $ docker compose up -d [+] Running 3/3 ✔ Network myapp_default Created ✔ Container myapp-db-1 Started ✔ Container myapp-web-1 Started ``` Il manque juste un peu de « vie » ; voyons comment le rendre vraiment sympa avec une animation de type machine à écrire. La sortie du Terminal ci-dessus a été obtenue avec ce bloc dans mon contenu Markdown : ```jsx $ docker compose up -d [+] Running 3/3 ✔ Network myapp_default Created ✔ Container myapp-db-1 Started ✔ Container myapp-web-1 Started ``` Docusaurus fait ensuite le rendu lors de la prévisualisation de ce post ou lors de la génération du HTML. ## La nouvelle prop `typewriter` {#the-new-typewriter-prop} Ajoutez `typewriter` pour activer l'animation : ```jsx $ docker compose up -d [+] Running 3/3 ✔ Network myapp_default Created ✔ Container myapp-db-1 Started ✔ Container myapp-web-1 Started ``` Voici ce qui se passe, image par image : 1. `$` apparaît, puis `d`, puis `o`, puis `c`… la commande se tape toute seule, caractère par caractère 2. Un curseur vert clignotant `▋` suit la position courante 3. Une fois la commande entièrement tapée, une courte pause la laisse se poser 4. `[+] Running 3/3` apparaît comme une ligne complète 5. Chaque ligne de sortie suit dans l'ordre 6. Le curseur disparaît à la fin de l'animation > **Passer l'animation:** > > Cliquez n'importe où sur le terminal pour sauter à la fin. Pratique pour les lecteurs qui ont déjà vu l'animation et veulent juste copier les commandes. ```bash $ docker compose up -d [+] Running 3/3 ✔ Network myapp_default Created ✔ Container myapp-db-1 Started ✔ Container myapp-web-1 Started ``` Un autre exemple : ```bash $ npm install added 347 packages, and audited 348 packages in 12s 42 packages are looking for funding run "npm fund for details found 0 vulnerabilities ``` ```bash $ npm run build ✓ frontend@1.0.0 build ✓ vite build vite v6.2.1 building for production... ✓ 124 modules transformed. dist/index.html 0.58 kB dist/assets/index-8af3d2.js 215.43 kB ✓ built in 2.14s $ npm run preview ✓ frontend@1.0.0 preview ✓ vite preview ➜ Local: http://localhost:4173/ ➜ Network: http://192.168.1.42:4173/ ``` ```bash $ git status On branch feature/authentication Changes not staged for commit: modified: src/auth/login.ts modified: src/auth/session.ts $ git add . $ git commit -m "feat(auth): add session refresh" [feature/authentication 7c1f2ab] feat(auth): add session refresh 2 files changed, 48 insertions(+), 12 deletions(-) $ git push origin feature/authentication Enumerating objects: 12, done. Counting objects: 100% (12/12), done. Writing objects: 100% (8/8), 1.24 KiB | 1.24 MiB/s, done. Total 8 (delta 4), reused 0 (delta 0) To github.com:company/project.git e1a9c2f..7c1f2ab feature/authentication -> feature/authentication ``` ```bash $ docker compose up -d [+] Running 4/4 ✔ Network api_default Created ✔ Container api-db-1 Started ✔ Container api-redis-1 Started ✔ Container api-backend-1 Started $ docker compose ps NAME STATUS PORTS api-db-1 Up 3 seconds 5432/tcp api-redis-1 Up 3 seconds 6379/tcp api-backend-1 Up 2 seconds 0.0.0.0:8080->8080/tcp $ docker compose logs backend --tail=10 backend-1 | Connecting to PostgreSQL... backend-1 | Database connection established backend-1 | Redis connection established backend-1 | HTTP server listening on :8080 ``` ```bash $ apt update Hit:1 http://archive.ubuntu.com/ubuntu noble InRelease Get:2 http://security.ubuntu.com/ubuntu noble-security InRelease [126 kB] Fetched 126 kB in 1s $ apt upgrade -y Reading package lists... Building dependency tree... Calculating upgrade... 12 upgraded, 0 newly installed, 0 to remove. $ systemctl restart nginx $ systemctl status nginx ● nginx.service - nginx web server Loaded: loaded Active: active (running) $ df -h Filesystem Size Used Avail Use% /dev/sda1 80G 41G 36G 54% ``` ## L'animation ne démarre que si le terminal est visible {#animation-starts-only-when-visible} Un terminal enterré au bas d'un long article aurait fini son animation bien avant que le lecteur n'y arrive. Le composant règle ça avec l'[Intersection Observer API](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) : l'effet machine à écrire démarre seulement quand au moins 10 % du terminal entre dans le viewport. Faites défiler la page et l'animation vous accueille pile au moment où vous arrivez. > **Tip:** > > Envie de vérifier ? Rechargez simplement cette page (`CTRL`+`F5`) et appuyez sur End pour sauter à la conclusion de ce post. Puis remontez. Les animations démarrent. Tout simplement parce que le composant Terminal se trouve maintenant dans la partie visible de la page. ## La vitesse s'adapte automatiquement au nombre de lignes {#speed-scales-automatically-with-line-count} Deux props supplémentaires permettent d'ajuster finement le rythme, mais vous en aurez rarement besoin — le composant s'ajuste tout seul selon le nombre de lignes : | Lignes | `typewriterSpeed` | `typewriterLineDelay` | Durée totale approx. | | ------ | ----------------- | --------------------- | -------------------- | | ≤ 5 | 40 ms/car. | 400 ms/ligne | \~2 s | | 6–10 | 25 ms/car. | 200 ms/ligne | \~2,5 s | | 11–20 | 20 ms/car. | 150 ms/ligne | \~3,5 s | | > 20 | 12 ms/car. | 100 ms/ligne | \~3,5 s | Une session de 30 lignes se termine à peu près dans le même temps qu'une de 3 lignes. L'animation reste accrocheuse sans devenir une salle d'attente. Ne surchargez ces valeurs que si vous cherchez un effet précis — une introduction lente et posée à un nouvel outil : ```jsx $ fzf --version 0.54.3 (brew) ``` ## Comment les lignes sont classées {#how-the-lines-are-classified} L'animation distingue deux types de lignes en regardant les premiers caractères non blancs : * **Lignes de commande** — commencent par `$` ou `#`. Tapées caractère par caractère à `typewriterSpeed` ms/car. * **Lignes de sortie** — tout le reste. Affichées d'un bloc après `typewriterLineDelay` ms. * **Lignes vides** — apparaissent après une pause de 80 ms, ce qui préserve la respiration visuelle du contenu d'origine. Autrement dit, vous n'avez pas besoin d'annoter votre contenu d'une manière particulière. Écrivez la session de terminal naturellement ; le composant en déduit la structure. ## Aucune rupture de compatibilité {#zero-breaking-changes} Chaque bloc `` existant dans le code continue de s'afficher exactement comme avant. La prop `typewriter` vaut `false` par défaut, donc le comportement statique reste celui par défaut. Si vous voulez activer l'animation partout d'un coup, un rechercher-remplacer global de ` c + 1)` après `typewriterSpeed` ms * Sinon (ligne de sortie, ou commande entièrement tapée) → planifier le passage à la ligne suivante après `typewriterLineDelay` ms Quand `lineIdx` atteint la fin du tableau de lignes, `animDone` passe à `true` et le composant revient au rendu du nœud React `{children}` d'origine — la coloration syntaxique et le balisage riche des enfants sont donc préservés une fois l'animation terminée. Le curseur clignotant, c'est une simple animation CSS : ```css @keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } } .cursor { display: inline-block; color: #00ff00; animation: blink 1s step-end infinite; } ``` Pas de librairie externe. Pas de canvas. Juste du state et des timers. ## Créer le composant {#create-the-component} Vous devrez créer trois nouveaux fichiers dans votre propre site Docusaurus. Le quatrième est optionnel : c'est la documentation du composant. **Project: /your\_docusaurus\_site** **src/components/Terminal/index.tsx** ```typescript import { Fragment, useState, useEffect, useCallback, useMemo, useRef, type JSX, type ReactElement, type ReactNode, type SVGProps, } from "react"; import { useMDXComponents } from "@mdx-js/react"; import clsx from "clsx"; import { useVarResolver } from "@site/src/components/Vars/store"; import { substituteChildren, substitutePlainText, } from "@site/src/components/Vars/substitute"; import VarToken from "@site/src/components/Vars/VarToken"; import Icon from "./icon.svg"; import styles from "./styles.module.css"; import { translate } from "@docusaurus/Translate"; // headingPrefixMap is built inside the component (via useMDXComponents) and // passed here so both copy and animation recover the "# " prefix that MDX strips. // `separator` joins *sibling* array items — "\n" by default, because a bare // array of Terminal children is one source line per item (see the heading // comment below). `substituteChildren` (TODO 0088) wraps the fragments of a // single line it split — e.g. "--name " / a VarToken / " -p " — in a // Fragment, and that one case needs "" instead: switched right below, when // recursing into a Fragment's own children, so a substituted value never // gets a spurious line break glued to it (it did — see git history). type HeadingPrefixMap = Map; function getCopyText( children: ReactNode, headingPrefixMap: HeadingPrefixMap, separator = "\n", ): string { let text = ""; if (Array.isArray(children)) { children.forEach((child, index) => { text += getCopyText(child, headingPrefixMap); if (index < children.length - 1) { text += separator; } }); } else if (typeof children === "string") { text += children; } else if (typeof children === "object" && children !== null) { const el = children as ReactElement; const elProps = el.props as { children?: ReactNode }; const prefix = headingPrefixMap?.get(el.type) ?? ""; if (elProps.children) { const childSeparator = el.type === Fragment ? "" : "\n"; text += prefix + getCopyText(elProps.children, headingPrefixMap, childSeparator); } } return text; } // Auto-scales speed based on line count so long terminals don't drag on. // Explicit props always take priority over these defaults. function autoSpeed(lineCount: number) { if (lineCount <= 5) return { speed: 40, delay: 400 }; if (lineCount <= 10) return { speed: 25, delay: 200 }; if (lineCount <= 20) return { speed: 20, delay: 150 }; return { speed: 12, delay: 100 }; } const CopyIcon = (props: SVGProps) => ( ); const CheckIcon = (props: SVGProps) => ( ); interface Props { children: ReactNode; title?: string; wrap?: boolean; /** Enables typewriter animation. Command lines ($/#) typed char-by-char; output lines appear whole. Click to skip. */ typewriter?: boolean; /** ms per character on command lines. Omit to auto-scale based on line count. */ typewriterSpeed?: number; /** ms before each output line appears. Omit to auto-scale based on line count. */ typewriterLineDelay?: number; /** When true, the copy button copies only command lines ($ / #), with the prompt prefix stripped, so the result is directly pasteable. */ copyCommandOnly?: boolean; } export default function Terminal({ children, title, wrap = true, typewriter = false, typewriterSpeed, typewriterLineDelay, copyCommandOnly = false, }: Props): JSX.Element { const resolve = useVarResolver(); const displayTitle = substitutePlainText( title || "user@machine: ~/yourproject", resolve, ); const containerRef = useRef(null); const [copied, setCopied] = useState(false); // Docusaurus registers h1–h6 as anonymous arrow functions in the MDX components // map, so children.type is a function reference, not the string "h1". Build a // Map keyed by those exact references so getCopyText can recover the "# " prefix. const mdxComponents = useMDXComponents(); const headingPrefixMap = useMemo(() => { const map = new Map(); ["h1", "h2", "h3", "h4", "h5", "h6"].forEach((tag, i) => { const prefix = "#".repeat(i + 1) + " "; if (mdxComponents[tag]) map.set(mdxComponents[tag], prefix); map.set(tag, prefix); // fallback for native HTML headings }); return map; }, [mdxComponents]); // Resolve any `default` marker (TODO 0088) against the reader's // current values before anything else touches `children` — copy, the // typewriter animation, and the plain render all read the resolved tree // from here on, so a substitution can never accidentally skip one of them. const resolvedChildren = useMemo( () => substituteChildren(children, resolve, (name, value, key) => ( {value} )), [children, resolve], ); // Extract lines once from children text content. Deliberately captured only // once per typewriter run, not kept live: if the reader edits a value while // the animation is still typing, the in-flight animation finishes with the // text it started with — resolvedChildren (used once animDone, and by copy) // always reflects the latest edit. const animLines = useMemo( () => (typewriter ? getCopyText(resolvedChildren, headingPrefixMap).split("\n") : []), // eslint-disable-next-line react-hooks/exhaustive-deps [typewriter], ); // Effective speed: explicit prop wins; otherwise auto-scale to line count const { speed: effectiveSpeed, delay: effectiveDelay } = useMemo(() => { const auto = autoSpeed(animLines.length); return { speed: typewriterSpeed ?? auto.speed, delay: typewriterLineDelay ?? auto.delay, }; }, [animLines.length, typewriterSpeed, typewriterLineDelay]); // Start animation only when the terminal scrolls into view const [hasBeenVisible, setHasBeenVisible] = useState(false); useEffect(() => { if (!typewriter) return; const el = containerRef.current; if (!el || typeof IntersectionObserver === "undefined") { // Fallback for environments without IntersectionObserver (SSR, old browsers) setHasBeenVisible(true); return; } const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) { setHasBeenVisible(true); observer.disconnect(); } }, { threshold: 0.1 }, ); observer.observe(el); return () => observer.disconnect(); }, [typewriter]); const [revealedLines, setRevealedLines] = useState([]); const [lineIdx, setLineIdx] = useState(0); const [charIdx, setCharIdx] = useState(0); const [animDone, setAnimDone] = useState(false); // Animation tick — only runs once the component is visible useEffect(() => { if (!typewriter || !hasBeenVisible || animDone) return; if (lineIdx >= animLines.length) { // eslint-disable-next-line react-hooks/set-state-in-effect -- terminal step of a setTimeout-driven typewriter animation, not derivable at render time setAnimDone(true); return; } const line = animLines[lineIdx]; const isCommand = /^\s*[$#]/.test(line); if (isCommand && charIdx < line.length) { const t = setTimeout(() => setCharIdx((c) => c + 1), effectiveSpeed); return () => clearTimeout(t); } // Line complete — pause then advance const pause = line.trim() === "" ? 80 : effectiveDelay; const t = setTimeout(() => { setRevealedLines((prev) => [...prev, line]); setLineIdx((l) => l + 1); setCharIdx(0); }, pause); return () => clearTimeout(t); }, [ typewriter, hasBeenVisible, animDone, lineIdx, charIdx, animLines, effectiveSpeed, effectiveDelay, ]); const skipAnimation = useCallback(() => { if (!typewriter || animDone) return; setRevealedLines(animLines); setLineIdx(animLines.length); setAnimDone(true); }, [typewriter, animDone, animLines]); const handleCopy = useCallback(async () => { let textToCopy = getCopyText(resolvedChildren, headingPrefixMap); if (copyCommandOnly) { textToCopy = textToCopy .split("\n") .filter((line) => /^\s*[$#]/.test(line)) .map((line) => line.replace(/^\s*[$#] ?/, "")) .join("\n"); } try { await navigator.clipboard.writeText(textToCopy); setCopied(true); } catch (err) { console.error("Failed to copy text: ", err); } }, [resolvedChildren, headingPrefixMap, copyCommandOnly]); useEffect(() => { if (copied) { const timer = setTimeout(() => setCopied(false), 2000); return () => clearTimeout(timer); } }, [copied]); const animDisplay = useMemo(() => { if (!typewriter || animDone) return null; const partial = animLines[lineIdx]?.slice(0, charIdx) ?? ""; return [...revealedLines, partial].join("\n"); }, [typewriter, animDone, revealedLines, lineIdx, charIdx, animLines]); const isAnimating = typewriter && !animDone; return (
{displayTitle}
        {isAnimating ? (
          <>
            {/*
             * Ghost: renders the real children invisibly so the 
 immediately
             * occupies its final height. Prevents page reflow on every typed line.
             */}
            
            {/* Animated text overlaid on the ghost */}
            
              {animDisplay}
              
            
          
        ) : (
          resolvedChildren
        )}
      
); } ``` **src/components/Terminal/icon.svg** ```svg > ``` **src/components/Terminal/styles.module.css** ```css /* * Terminal — stays a real terminal: dark in both themes, macOS traffic lights, * prompt and cursor. That is deliberate and unchanged. * * What changed: * - Three hardcoded "Fira Code" declarations and one bare `monospace` → * var(--ifm-font-family-monospace). Changing the site's code face is now * one line, not five. * - #1e1e1e / #2d2d2d (neutral grey-black) → the warm --mk-term-* shell, so * the terminal belongs to the same world as the rest of the page. * - #00ff00 for the prompt and cursor → --mk-term-prompt, the savanna green. * Pure green on near-black is the single harshest colour pair on the site * and it appeared in every article. * - The macOS dots keep their meaning but are desaturated to the palette. * - The header title is an eyebrow, matching Snippet's toolbar exactly. * * Every colour below is a token: no stylelint-disable needed any more. */ .terminal { position: relative; margin: 1.5rem 0; overflow-x: auto; font-family: var(--ifm-font-family-monospace); color: var(--mk-code-fg); background: var(--mk-term-bg); border: 1px solid var(--mk-term-border); border-radius: var(--mk-radius-md); box-shadow: var(--mk-shadow-1); } .terminal_header { display: flex; align-items: center; justify-content: space-between; padding: 0.5rem 0.875rem; background: var(--mk-term-head); border-bottom: 1px solid var(--mk-term-border); border-radius: var(--mk-radius-md) var(--mk-radius-md) 0 0; } .terminal_left { display: flex; gap: 0.5rem; align-items: center; overflow: hidden; white-space: nowrap; } .terminal_icon { width: 16px; height: 16px; flex-shrink: 0; color: var(--mk-n-500); } .terminal_title { composes: mk-eyebrow from global; overflow: hidden; color: var(--mk-n-500); text-overflow: ellipsis; } .terminal_controls { display: flex; gap: 5px; } .dot { display: inline-block; width: 9px; height: 9px; border-radius: 50%; } .red { background: var(--mk-term-dot-red); } .yellow { background: var(--mk-term-dot-amber); } .green { background: var(--mk-term-dot-green); } .terminal_body { position: relative; margin: 0; padding: 0.875rem 1rem; font-family: var(--ifm-font-family-monospace); font-size: 0.85rem; line-height: 1.6; white-space: pre-wrap; word-break: break-all; background: var(--mk-term-bg); } .terminal_line { display: flex; gap: 0.5rem; } .first_line { margin-bottom: 0.5rem; } .prompt { flex-shrink: 0; color: var(--mk-term-prompt); user-select: none; pointer-events: none; } .terminal_line span { display: inline-block; min-width: 0; } .no_wrap { overflow-x: auto; white-space: pre; word-break: normal; } /* Copy button — unchanged behaviour (fades in on container hover), retokenised. */ .copyButton { position: absolute; top: 2.75rem; right: 0.5rem; z-index: 10; padding: 0.35rem; color: var(--mk-n-500); cursor: pointer; background: transparent; border: none; border-radius: var(--mk-radius-sm); opacity: 0; transition: opacity 0.2s ease-in-out, color 0.2s ease-in-out; } .copyButton:hover { color: var(--mk-code-fg); background: rgb(255, 255, 255, 0.08); } .copyButton:focus-visible { opacity: 1; outline: 2px solid var(--mk-term-prompt); outline-offset: 2px; } .terminal:hover .copyButton { opacity: 1; } .copyButtonSvg { width: 1rem; height: 1rem; fill: currentcolor; } .terminal a { color: var(--mk-term-prompt); text-decoration: underline; text-underline-offset: 2px; } .terminal_ghost { visibility: hidden; user-select: none; pointer-events: none; } .terminal_overlay { position: absolute; inset: 0; padding: inherit; font-family: inherit; color: var(--mk-code-fg); white-space: inherit; word-break: inherit; } @keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } } .cursor { display: inline-block; font-family: var(--ifm-font-family-monospace); color: var(--mk-term-prompt); animation: blink 1s step-end infinite; } @media (prefers-reduced-motion: reduce) { .cursor { animation: none; } } /* MDX turns "# foo" inside into

; reset so it reads as a shell * comment rather than a chapter heading. */ .terminal_body :is(h1, h2, h3, h4, h5, h6) { margin: 0; padding: 0; font-size: inherit; font-weight: inherit; line-height: inherit; letter-spacing: inherit; color: var(--mk-term-comment); border: none; } .terminal_body h1::before { content: "# "; } .terminal_body h2::before { content: "## "; } .terminal_body h3::before { content: "### "; } .terminal_body h4::before { content: "#### "; } .terminal_body h5::before { content: "##### "; } .terminal_body h6::before { content: "###### "; } .terminal_body p { margin: 0; padding: 0; } ``` **src/components/Terminal/readme.md** ````markdown # 🖥️ Terminal Component A stylized terminal emulator UI for Docusaurus pages. Ideal for showcasing CLI commands, installation steps, or shell output in a visually engaging format. ## ✨ Features - 🧑‍💻 Linux-style header with icon and control dots - 📝 Customizable terminal title - 💻 Styled code block for terminal content - 🎨 Theme-friendly layout using scoped CSS - ⌨️ Optional typewriter animation — command lines typed char-by-char, output appears whole ## Example Out-of-the-box, here is how the component will looks like: ![Example](sample.png) ## 📁 Location This component lives at `src/components/Terminal/index.tsx`. ## 🚀 Usage ```jsx import Terminal from "@site/src/components/Terminal"; npm install npm run build; ``` If no title is provided, the default is: `christophe@home: ~` ### With Word Wrapping Disabled ```jsx $ echo "This is a very long line that should not wrap unless explicitly allowed" ``` ## 🛠 Props | Prop | Type | Required | Default | Description | | --------------------- | ----------------- | -------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `children` | `React.ReactNode` | ✅* | — | Terminal content to display. Not needed when `source` is used (plugin injects it automatically). | | `source` | string | ❌ | — | Path to an external `.txt` file relative to the article (e.g. `./files/terminal-1.txt`). The `remark-snippet-loader` plugin reads the file at build time and injects its content as children. Use this to keep long terminal outputs out of the MDX file. | | `title` | string | ❌ | `user@machine: ~/yourproject` | Optional terminal title shown in the header | | `wrap` | boolean | ❌ | `true` | Enables word wrapping in the terminal body. Set to `false` to disable it. | | `typewriter` | boolean | ❌ | `false` | Enables typewriter animation. Lines starting with `$` or `#` are typed char-by-char; output lines appear whole. Click the terminal to skip. Animation starts only when the terminal scrolls into view. | | `typewriterSpeed` | number | ❌ | auto | ms per character on command lines. Omit to auto-scale: ≤5 lines→40, ≤10→25, ≤20→20, >20→12. | | `typewriterLineDelay` | number | ❌ | auto | ms before each output line appears. Omit to auto-scale: ≤5 lines→400, ≤10→200, ≤20→150, >20→100. | \* `children` is required when `source` is not provided. ### Loading content from a file (`source`) For long terminal outputs (typically > 5 lines), you can store the content in a separate `.txt` file and reference it with `source`. The `remark-snippet-loader` plugin injects the file content at build time — no runtime fetch involved. ```jsx ``` The file `./files/terminal-1.txt` is co-located with the article (same `files/` subfolder used by `Snippet`). Its content is plain text, exactly as it would appear inline: ```text $ docker compose up --detach [+] Running 2/2 ✔ Network demo_default Created ✔ Container counter Started ``` All other props (`typewriter`, `title`, `wrap`, etc.) work exactly the same. ### Typewriter mode example ```jsx {`$ docker compose up -d [+] Running 3/3 ✔ Network myapp_default Created ✔ Container myapp-db-1 Started ✔ Container myapp-web-1 Started`} ``` Speed is auto-scaled from line count by default — no need to set `typewriterSpeed` or `typewriterLineDelay` manually. Override only when you want a specific pacing: ```jsx {`$ fzf --version 0.54.3`} ``` ## 💡 Command Detection Lines starting with `$` or `>` are treated as commands and rendered with a prompt symbol (`>`). This helps visually distinguish commands from output. ## 🎨 Styling This component uses scoped CSS via `styles.module.css`. Key classes include: - `.terminal` — outer container - `.terminal_header` — header bar - `.terminal_left` — icon + title - `.terminal_controls` — control dots - `.terminal_body` — code block area - `.terminal_line` — individual line - `.prompt` — command prompt symbol - `.no_wrap` — disables word wrapping - `.dot.red`, `.dot.yellow`, `.dot.green` — control dot colors Customize these styles to match your site's design system. ## ⚠️ Escaping $ When writing shell commands inside JSX, be mindful that the `$` symbol is interpreted by JavaScript as the start of a template literal (e.g., `${variable}`). To prevent this and ensure your command renders correctly in the terminal, you’ll need to escape `$` signs using a backslash (`\`). For example: ```jsx {`$ docker run --detach --name step_1_2 -p 81:80 -v $(pwd):/var/www/html -u \${UID}:\${GID} php:8.1.5-apache`} ``` This ensures that `\${UID}` and `\${GID}` are treated as literal strings rather than JavaScript expressions. Without escaping, your code may throw an error or render incorrectly. ## 📄 License MIT — free to use and modify. ## 💬 AI generated This code has been generated by Christophe Avonture using Google Gemini and Claude Code. ```` Éditez ensuite (ou créez) le fichier `src/theme/MDXComponents.js`. Si le fichier existe déjà, ajoutez simplement les lignes surlignées ci-dessous. S'il n'existe pas encore, créez-le avec le contenu ci-dessous : **src/theme/MDXComponents.js** ```javascript // At the top of your file, add this line: // highlight-next-line import Terminal from "@site/src/components/Terminal"; // Then in your export section, add this line too: export default { // Reusing the default mapping ...MDXComponents, // [...] // highlight-next-line Terminal }; ``` À partir de maintenant, vous pouvez utiliser le composant `` dans vos posts. Amusez-vous bien ! *Ce fichier `src/theme/MDXComponents.js` est le registre central de tous les composants personnalisés de votre blog ; [Change how Docusaurus will create img tags](/blog/docusaurus-override-img) montre comment il permet aussi de surcharger les balises natives, et pas seulement d'en ajouter de nouvelles.* --- # Espiègleries de suricate : semer des easter eggs partout sur mon blog > Tour d'horizon de huit petits easter eggs à découvrir sur ce blog Docusaurus — un suricate en ASCII art caché dans le source de la page, un sprite lancé par le code Konami, un clin d'œil en console.log, un changement de favicon quand on quitte l'onglet, des messages 404 aléatoires, un commentaire caché dans le sitemap et plus encore — ainsi que les vrais bugs (répétition de touche, incompatibilité clavier AZERTY, mauvais emoji, race condition dans postBuild, favicon flou) débusqués en chemin. --- ![Espiègleries de suricate : semer des easter eggs partout sur mon blog](/img/v2/easter_eggs.webp)**TL;DR**Au-delà de la mascotte suricate déjà visible sur ce blog, celui-ci cache huit petits easter eggs volontairement trouvables : un suricate en ASCII art posé directement dans le source de la page, un clin d'œil en `console.log`, des messages 404 aléatoires, un sprite lancé par le code Konami, un changement de titre et de favicon quand on quitte l'onglet, un en-tête HTTP `X-Powered-By`, une feuille de style d'impression et un commentaire caché dans `sitemap.xml`. Cet article les passe tous en revue avec le code source réel et — plus utile encore — les vrais bugs que nous avons livrés puis corrigés en chemin : une race condition de répétition de touche dans le handler Konami, une incompatibilité clavier AZERTY trouvée par un vrai utilisateur (pas par nos tests), un mauvais codepoint d'emoji, un piège de concurrence dans le `postBuild` de Docusaurus, et un favicon illisible jusqu'à ce qu'on en redessine le cadrage.Vous savez déjà que ce site a une mascotte : un suricate qui apparaît sur la page 404, chevauche le bouton « remonter en haut » et se cache dans un [commentaire HTML en ASCII art](/blog/docusaurus-ascii-art) sur chaque page. C'est ce dernier qui m'a fait réfléchir : si un commentaire dans le source de la page est amusant, sur quoi d'autre un visiteur curieux pourrait-il tomber ?Alors, ce week-end, je me suis posé — et j'ai posé à [Claude Code](/blog/claude-ia-spare-tokens) — une question simple : jusqu'où peut-on aller sans que ça devienne pénible ? La réponse a donné huit petits easter eggs — le commentaire en ASCII art revu en détail, plus sept nouveautés — chacun suivant la même règle : **si vous ne le cherchez pas, vous ne le verrez jamais.** Pas de pop-up, pas de confettis au chargement, rien qui gêne la lecture. Juste de discrètes récompenses pour les curieux.> **La règle unique derrière tout ça:** > > Chaque easter egg de cette liste vit dans la console, le source, les en-têtes de réponse, ou dans un état que le visiteur doit déclencher volontairement (quitter l'onglet, imprimer une page, taper un code secret). Aucun n'apparaît jamais dans le flux de lecture normal.## 1. Celui par lequel tout a commencé : un suricate en ASCII art dans le source de la page {#1-the-one-that-started-it-all-an-ascii-art-meerkat-in-the-page-source}C'est l'easter egg qui a inspiré tous les autres, il mérite donc la première place. Appuyez sur `CTRL`+`U` sur n'importe quelle page de ce blog — ou faites un clic droit et choisissez « Afficher le code source de la page » — et juste après la ligne d'ouverture ``, avant qu'un seul pixel visible n'ait été décrit, vous trouverez un suricate dessiné entièrement en caractères ASCII, à l'intérieur d'un commentaire HTML :> Screenshot — view-source:https\://www\.avonture.be/blog/ripgrep/ > > ![CTRL-U - Afficher le source](/img/v2/suricate_ctrl_u.webp)Il est invisible dans la page rendue — un commentaire HTML n'atteint jamais l'écran — et ne coûte pas un octet de mise en page ni une ligne de markup visible. C'est purement une récompense pour qui est assez curieux pour regarder la réponse brute.Sous le capot, un petit plugin `postBuild` (`plugins/ascii-injector/index.mjs`) parcourt chaque fichier HTML généré après la fin de `yarn docusaurus build` et insère le contenu de `src/data/banner.txt` sous forme de commentaire juste après la balise ``. *Le plugin lui-même est construit pas à pas dans [Inject ASCII Art in any HTML pages rendered by Docusaurus](/blog/docusaurus-ascii-art), et la bannière provient du même générateur que celui de [Bash - ASCII art](/blog/bash-ascii-art) :*```bash $ yarn docusaurus build $ grep -A2 doctype build/index.html "; if (xml.includes(comment)) { return; } const urlsetTag = /]*>/i; if (!urlsetTag.test(xml)) { console.warn("[sitemap-easter-egg] tag not found, skipping."); return; } const updated = xml.replace(urlsetTag, (m) => `${m}${comment}`); await fs.writeFile(sitemapPath, updated, "utf8"); console.log("[sitemap-easter-egg] Comment injected into sitemap.xml"); }, }; } ```Enregistré de la même façon que l'[injecteur d'ASCII art](/blog/docusaurus-ascii-art) :**docusaurus.config.js (excerpt)**```javascript // [...] plugins: [ // [...] [ "./plugins/ascii-injector/index.mjs", { bannerPath: "src/data/banner.txt" }, ], "./plugins/sitemap-easter-egg/index.mjs", ], ```## Le résultat final {#what-we-ended-up-with}**Les huit easter eggs**- **Suricate en ASCII art** — caché dans le source de la page, juste après `` - **Clin d'œil console.log** — un message stylé pour qui a les DevTools ouverts - **Messages 404 tournants** — cinq variantes aléatoires aux couleurs de la mascotte - **Code Konami** — `↑ ↑ ↓ ↓ ← → ← → B A` lance un suricate à travers l'écran - **Changement à la sortie d'onglet** — titre et favicon changent quand l'onglet est masqué, et reviennent au retour - **En-tête X-Powered-By** — un clin d'œil dans les en-têtes de réponse HTTP - **Feuille de style d'impression** — une petite note de bas de page sur les pages imprimées - **Commentaire dans sitemap.xml** — un commentaire caché et valide XML comme premier enfant de ``Aucun de ces éléments ne change la façon dont un lecteur lit un article. C'était tout l'objectif — le travail d'un blog reste d'être lu, pas de faire des tours de magie à la figure du lecteur. Mais pour les développeurs, les crawlers, les curieux des DevTools et les gens qui impriment vraiment des articles ou inspectent les en-têtes de réponse, il y a désormais un peu plus de personnalité cachée juste sous la surface. Sympa, non ?Cette liste s'est depuis agrandie d'une unité : chaque easter egg ci-dessus suppose un clavier ou un panneau DevTools ouvert, ce qui ne sert à rien à quelqu'un qui lit sur son téléphone — alors [secouer le téléphone](/blog/docusaurus-shake-easter-egg) fait maintenant sursauter la mascotte en plein écran pendant quelques secondes, sans le moindre clavier.Si vous gérez votre propre blog Docusaurus et que vous avez une mascotte, un logo, ou même juste une couleur préférée : piochez-en un ou deux — ils sont tous petits, autonomes, et aucun ne demande de toucher à votre contenu. Commencez par celui qui correspond à l'endroit où vos lecteurs se trouvent déjà (la console DevTools si votre audience est composée de développeurs, les en-têtes HTTP si ce sont d'autres blogueurs qui aiment fouiller, le CSS d'impression si les gens impriment vos recettes ou tutoriels) et développez à partir de là. --- # Tooltips de code assistés par l'IA dans Docusaurus — Explique-moi comme à un enfant de cinq ans > Ajoutez des tooltips au survol sur les lignes de code délicates de votre blog Docusaurus — propulsés par Claude, générés au moment du build, zéro latence côté navigateur. On couvre le plugin remark, le renderer React et le script CLI. --- ![Tooltips de code assistés par l'IA dans Docusaurus — Explique-moi comme à un enfant de cinq ans](/img/v2/ai_snippets.webp) **TL;DR** Votre Dockerfile est limpide pour vous. Pour un lecteur débutant, `RUN npm ci --omit=dev` est du charabia. Cet article ajoute un badge `?` sur les lignes délicates de vos blocs de code ``. Au survol, une explication en langage simple apparaît, générée une seule fois par Claude et stockée dans un fichier JSON — pas de clé API dans le navigateur, pas de latence au survol. Le système compte trois parties : un script Node.js qui appelle l'API Claude et écrit un fichier `.eli5.json`, une extension d'une ligne à votre plugin `remark-snippet-loader` existant qui injecte automatiquement les annotations, et un renderer React personnalisé dans le composant `Snippet` qui superpose les badges. Le workflow de l'auteur : lancer le script, committer le JSON, terminé. Quand vous écrivez un tutoriel destiné à des développeurs en apprentissage, il y a toujours un écart entre ce que vous supposez qu'ils savent et ce qu'ils savent réellement. Vous pourriez ajouter des explications inline — mais ça alourdit l'article. Vous pourriez ajouter un glossaire — mais les lecteurs le sautent. Ce que vous voulez vraiment, c'est du contexte optionnel, à la demande, qui apparaît exactement là où le lecteur bloque. C'est ce que fait cette fonctionnalité. Un petit badge `?` flotte au bord droit des lignes annotées. Survolez-le, et un tooltip explique la ligne en langage simple. Éloignez la souris, et le code redevient propre. ## À quoi ça ressemble {#what-it-looks-like} ```plaintext ┌─ Dockerfile ─────────────────────────────────────────── ▾ ─┐ │ 1 FROM node:20-alpine [?] │ │ 2 │ │ 3 WORKDIR /app │ │ 4 COPY package*.json ./ [?] │ │ 5 RUN npm ci --omit=dev [?] │ │ 6 │ │ 7 COPY . . │ │ 8 EXPOSE 3000 [?] │ │ 9 CMD ["node", "server.js"] [?] │ └────────────────────────────────────────────────────────────┘ ↓ hover [?] on line 5 ┌──────────────────────────────────────────────────────────────┐ │ npm ci installs exactly what's in package-lock.json. │ │ --omit=dev skips dev tools — keeps your image smaller. │ └──────────────────────────────────────────────────────────────┘ ``` Les badges `?` n'apparaissent que sur les lignes que Claude a jugées non triviales. Les lignes vides, les accolades fermantes et les affectations évidentes sont ignorées automatiquement. ## Architecture {#architecture} Le système comporte trois pièces mobiles qui fonctionnent indépendamment : ```plaintext ① scripts/generate-eli5.mjs (run once, by the author) ↓ calls Claude API ↓ writes blog/2026-xx-xx-my-post/files/Dockerfile.eli5.json ↓ commit that file ② plugins/remark-snippet-loader (runs at build time, automatically) ↓ reads Dockerfile ↓ finds Dockerfile.eli5.json alongside it ↓ injects eli5json="..." prop into ③ src/components/Snippet/index.tsx (runs in the browser) ↓ parses eli5json prop ↓ renders Prism-highlighted code line by line ↓ overlays ? badges with tooltips ``` La décision de conception clé, c'est que **les appels à l'IA ont lieu au moment de la rédaction, pas au moment de la lecture** — exactement le même pattern que celui utilisé pour [générer mes résumés TL;DR avec Gemini](/blog/gemini-tldr). Les fichiers `.eli5.json` sont committés dans git et embarqués avec le site comme n'importe quel autre asset statique. Les lecteurs n'attendent jamais un appel API, et votre clé API ne quitte jamais votre machine. ## Étape 1 — Le script d'annotation {#step-1--the-annotation-script} Créez `scripts/generate-eli5.mjs`. Ce script lit un fichier source, l'envoie à Claude avec un prompt qui demande des explications ligne par ligne, et écrit le résultat dans un fichier JSON à côté de la source. **scripts/generate-eli5.mjs** ```mjs #!/usr/bin/env node /** * Generate "Explain Like I'm Five" annotations for a source file. * * Usage: * node scripts/generate-eli5.mjs [--force] [--output ] [--locale ] * node scripts/generate-eli5.mjs --locale fr --articles ... [--dry-run] * * Reads ANTHROPIC_API_KEY from process.env or a .env file at the project root — unless * --backend ollama is passed, which calls the local model instead and needs no key. * Writes .eli5.json alongside the source file (or to --output path). * * With --locale (TODO 0121), the output language changes but THE INPUT DOES NOT: an ELI5 * describes code, and the code is identical in every locale. There is no "generate from the * translated article" here — that is the shape of the sibling question index (TODO 0120), * which describes prose. The localized sidecar is written next to the code it explains, as * `.eli5..json`, because `files/` is never duplicated under `i18n/` * (plugins/remark-i18n-assets). * * --articles is the batch `translate` calls once an article is translated: it asks * `scripts/lib/i18n-eligibility.mjs` which of that article's snippets are eligible, and never * re-derives the three conditions itself. With --dry-run it constructs no client and spends * nothing, which is what makes the cost quotable before the confirmation prompt. */ import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; import { createRequire } from "module"; import { hashSource } from "./lib/eli5-hash.mjs"; import { cmd } from "./lib/cheatsheet-hint.mjs"; import { DEFAULT_BACKEND, ELI5_BACKENDS, callEli5Backend, modelFor, printCostNotice, resolveBackend, } from "./lib/eli5-backend.mjs"; import { ELI5_COST_PER_FILE, articleKeyOf, eli5Candidates, } from "./lib/i18n-eligibility.mjs"; const require = createRequire(import.meta.url); const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(__dirname, ".."); // Load .env from project root (best-effort) try { const dotenv = require("dotenv"); // quiet: dotenv's banner goes to stdout, which would corrupt the single "\t" // line that --porcelain promises to the shell. dotenv.config({ path: path.join(projectRoot, ".env"), quiet: true }); } catch { // dotenv not available — rely on environment variables already set } /** * Escapes the backslashes that do not start a valid JSON escape sequence. * * A snippet whose CODE contains a regex makes the model quote that regex back inside its * explanation, and `~* \.(?:css|js)$` carries a `\.` — a perfectly good regex, an invalid JSON * escape, "Bad escaped character in JSON". It cost the nginx.conf annotation of * `running-docusaurus-with-docker` two full runs before the cause was clear, because the * fenced-response fallback looks like it should already have handled a malformed answer. * * Safe to run over the whole document: in JSON a backslash may only ever appear inside a string, * so any backslash that is not part of `\" \\ \/ \b \f \n \r \t \uXXXX` is a literal the model * failed to escape. Valid escapes are matched first and handed back untouched, `\u` included — * doubling those would corrupt the text this is meant to save. */ function repairLoneBackslashes(text) { return text.replace(/\\(u[0-9a-fA-F]{4}|["\\/bfnrt])|\\/g, (whole, valid) => valid ? whole : "\\\\", ); } // Language detection — mirrors the mapping in remark-snippet-loader const EXT_TO_LANG = { js: "javascript", mjs: "javascript", cjs: "javascript", jsx: "jsx", ts: "typescript", tsx: "typescript", py: "python", php: "php", sh: "bash", bash: "bash", zsh: "bash", css: "css", html: "html", json: "json", yml: "yaml", yaml: "yaml", md: "markdown", mdx: "markdown", dockerfile: "docker", ini: "ini", sql: "sql", xml: "xml", toml: "toml", env: "bash", txt: "plaintext", ps1: "powershell", bat: "batch", cmd: "batch", }; const BASE_NAME_TO_LANG = { dockerfile: "docker", ".env": "bash", ".gitignore": "plaintext", ".dockerignore": "plaintext", makefile: "makefile", }; function detectLang(filePath) { const baseName = path.basename(filePath).toLowerCase(); if (BASE_NAME_TO_LANG[baseName]) return BASE_NAME_TO_LANG[baseName]; const ext = path.extname(filePath).slice(1).toLowerCase(); return EXT_TO_LANG[ext] || ext || "plaintext"; } // Output language per locale. `en` carries no extra instruction — it is what the prompt below // already asks for, and 798 existing sidecars were generated without one. const LANGUAGES = { en: { name: "English", extra: "" }, fr: { name: "French", extra: ` Write the "summary" and every value in "explanations" in FRENCH. Keep code identifiers, file names, commands, flags and option names exactly as they appear in the source — translate the prose around them, never the code itself. Use the vouvoiement-free, direct technical register of French developer documentation.`, }, }; const systemPromptFor = ( locale, ) => `You are a patient senior developer explaining code to a junior colleague. Given a source file with numbered lines, produce two things: 1. "summary": two to four plain sentences narrating what this snippet does as a whole and why, the way you'd explain it out loud to someone reading over your shoulder — not a recap of individual lines. No jargon unless immediately defined. 2. "explanations": for each line a junior developer might find confusing, one or two plain sentences. Think "explain like I'm five but I'm also a developer". Skip blank lines, closing braces/brackets, import lines that are self-evident, comments that already explain themselves, trivially obvious assignments. Return ONLY a valid JSON object shaped exactly like: {"summary": "...", "explanations": {"3": "...", "7": "..."}} Keys in "explanations" are line numbers as strings. Omit lines that need no explanation. No markdown, no code fences.${LANGUAGES[locale].extra}`; /** Where a locale's sidecar lives. `en` keeps the historical unsuffixed name. */ function sidecarPath(absSource, locale) { return locale === "en" ? `${absSource}.eli5.json` : `${absSource}.eli5.${locale}.json`; } async function generateEli5( sourceFile, { force = false, outputPath = null, locale = "en", backend = DEFAULT_BACKEND, quoteCost = true, } = {}, ) { if (!LANGUAGES[locale]) { throw new Error( `Unsupported locale "${locale}" (known: ${Object.keys(LANGUAGES).join(", ")}).`, ); } const absSource = path.resolve(sourceFile); if (!fs.existsSync(absSource)) { throw new Error(`Source file not found: ${absSource}`); } if (fs.statSync(absSource).isDirectory()) { throw new Error( `"${sourceFile}" is a directory.\n` + ` To process an entire directory, use the bulk script instead:\n` + ` ${cmd("eli5:bulk", "--dir", sourceFile)}`, ); } const destPath = outputPath || sidecarPath(absSource, locale); if (!force && fs.existsSync(destPath)) { console.log(`⏭ Skipped (already exists): ${path.relative(projectRoot, destPath)}`); console.log(` Use --force to regenerate.`); return { skipped: true }; } const code = fs.readFileSync(absSource, "utf-8"); const lang = detectLang(absSource); const lines = code.split("\n"); // Number the lines for the prompt const numberedCode = lines.map((line, i) => `${i + 1}: ${line}`).join("\n"); const maxTokens = lines.length > 50 ? 2048 : 1024; const model = modelFor(backend); // `quoteCost`: the batch modes below announce the whole run's price once, before the first // call — repeating "1 file × $0.01" on every iteration would bury it. if (quoteCost) printCostNotice(backend, 1); console.log( `🤖 Calling ${model} for ${path.basename(absSource)} (${lines.length} lines, lang: ${lang}` + `${locale === "en" ? "" : `, text: ${locale}`})...`, ); const raw = await callEli5Backend({ backend, systemPrompt: systemPromptFor(locale), userContent: `Language: ${lang}\n\n${numberedCode}`, maxTokens, }); // Parse JSON — strip markdown fences if the model wrapped it anyway, then repair the one // malformation it produces on its own (see repairLoneBackslashes). Both backends go through // this: a grammar-constrained local answer is not trusted any more than a paid one. const candidates = [raw]; const fenced = raw.match(/\{[\s\S]*\}/); if (fenced) candidates.push(fenced[0]); candidates.push(...candidates.map(repairLoneBackslashes)); let parsed; for (const candidate of candidates) { try { parsed = JSON.parse(candidate); break; } catch { // Try the next shape; the throw below reports the original text, never the repaired one. } } if (!parsed) { throw new Error(`Could not parse the ${model} response as JSON:\n${raw}`); } const summary = typeof parsed.summary === "string" && parsed.summary.trim() ? parsed.summary.trim() : null; // Validate: only keep string values with string-number keys const explanations = parsed.explanations && typeof parsed.explanations === "object" ? parsed.explanations : {}; const cleaned = {}; for (const [k, v] of Object.entries(explanations)) { if (/^\d+$/.test(k) && typeof v === "string" && v.trim()) { const lineIdx = parseInt(k, 10) - 1; if (lineIdx >= 0 && lineIdx < lines.length) { cleaned[k] = v.trim(); } } } const result = { version: 1, // The model that actually wrote this file — not a constant. Reading a sidecar is how you // tell an Haiku annotation from one produced locally while iterating on the prompt. model, generated: new Date().toISOString(), source: path.basename(absSource), sourceHash: hashSource(code), lang, // `lang` is the language of the CODE (yaml, bash); `textLang` the language of the prose. // Reusing one key for both senses is the contresens TODO 0121 called out up front. textLang: locale, summary, explanations: cleaned, }; fs.writeFileSync(destPath, JSON.stringify(result, null, 2) + "\n"); console.log( `✅ Written: ${path.relative(projectRoot, destPath)}\n ${Object.keys(cleaned).length} annotations on ${lines.length} lines, summary: ${summary ? "yes" : "no"}.`, ); return { skipped: false, destPath, count: Object.keys(cleaned).length }; } /** * Generate the localized sidecars of one or more ARTICLES' snippets. * * What is eligible is decided entirely by `scripts/lib/i18n-eligibility.mjs` — article really * translated, English sidecar present, localized one missing or stale. Nothing here re-tests * any of that, which is why `--force` has no meaning in this mode: a file the module returned * is one that needs writing, and a file it withheld must not be paid for again. That is what * makes `translate --force` free of ELI5 charges when the code did not change. */ async function runArticleBatch({ locale, articlePaths, dryRun, porcelain, assumeTranslated, backend = DEFAULT_BACKEND, }) { if (articlePaths.length === 0) { throw new Error("--articles needs at least one article path."); } const articleKeys = []; for (const candidate of articlePaths) { const key = articleKeyOf(projectRoot, candidate); if (!key) { // Not fatal: `translate` may legitimately hand over a path outside blog/ (a page, say), // and refusing the whole batch over it would block the articles that are fine. if (!porcelain) { console.warn(`⚠ Not inside an article folder, ignored: ${candidate}`); } continue; } articleKeys.push(key); } const { eligible } = eli5Candidates(projectRoot, locale, { articleKeys, assumeTranslated, }); const cost = eligible.length * ELI5_COST_PER_FILE; if (porcelain) { // One line, two fields — the contract translation.sh reads. console.log(`${eligible.length}\t${cost.toFixed(4)}`); return; } if (eligible.length === 0) { console.log(`✅ No ELI5 to generate for ${locale} — every snippet is up to date.`); return; } if (dryRun) { console.log( `🔍 ${eligible.length} ELI5 sidecar(s) would be generated for "${locale}" ≈ $${cost.toFixed(2)}:`, ); eligible.forEach((f) => console.log(` ${path.relative(projectRoot, f)}`)); console.log("\nDry run — nothing written, no API call made."); return; } // Announced once, for the whole run, before the first call — not per file. printCostNotice(backend, eligible.length); let ok = 0; let failed = 0; for (const [index, source] of eligible.entries()) { const relative = path.relative(projectRoot, source); // A plain line, not a trailing `... ` write: generateEli5 logs lines of its own, which // would otherwise land mid-sentence. console.log(` [${index + 1}/${eligible.length}] ${relative}`); try { // force: the eligibility module already decided; a stale localized sidecar must be // overwritten, and generateEli5's own "file exists" guard would otherwise skip it. await generateEli5(source, { locale, force: true, backend, quoteCost: false }); ok++; } catch (err) { // Soft-fail, like the plugin's own ELI5 handling: a missing annotation degrades to the // English one, it never costs the reader the article. console.log(`❌ ${err.message}`); failed++; } } console.log(`\n🧠 ELI5 ${locale}: ${ok} generated, ${failed} failed.`); if (failed > 0) { console.log( ` Those snippets keep showing their English annotation until regenerated.`, ); } } // ── CLI ────────────────────────────────────────────────────────────────────── // Guard: only run CLI logic when this file is the entry point, not when imported. const { fileURLToPath: _fileURLToPath } = await import("url"); const _isMain = process.argv[1] === _fileURLToPath(import.meta.url); if (!_isMain) { /* exported as module — skip CLI */ } else { const args = process.argv.slice(2); if (args.length === 0 || args.includes("--help") || args.includes("-h")) { console.log(` Usage: node scripts/generate-eli5.mjs [options] node scripts/generate-eli5.mjs --locale fr --articles ... [options] Options: --force Regenerate even if the sidecar already exists --backend Who writes the annotation: claude (default, paid, ~$0.01 per file) or ollama (local, free, no API key) — known: ${ELI5_BACKENDS.join(", ")} --locale Write the explanations in that language, to .eli5..json (known: ${Object.keys(LANGUAGES).join(", ")}) --articles Treat the positional arguments as ARTICLES, and generate every eligible snippet sidecar under them (requires --locale) --output Write to a custom path instead of the default sidecar name --dry-run With --articles: report what would be generated, and its cost, without constructing the API client — spends nothing --porcelain With --articles: print "\\t" only, for scripts --assume-translated With --articles: count the snippets of articles that are ABOUT to be translated, for quoting a price before the translation runs --help, -h Show this help Examples: node scripts/generate-eli5.mjs blog/2026-01-01-my-post/Dockerfile node scripts/generate-eli5.mjs blog/2026-01-01-my-post/compose.yaml --force node scripts/generate-eli5.mjs src/my-file.sh --output docs/my-file.sh.eli5.json node scripts/generate-eli5.mjs --locale fr --articles blog/2026/09/17/docling --dry-run Requires ANTHROPIC_API_KEY in your environment or in a .env file at the project root (except for --dry-run and --backend ollama, which never call the API). `); process.exit(0); } const force = args.includes("--force"); const dryRun = args.includes("--dry-run"); let backend; try { backend = resolveBackend(args); } catch (err) { console.error(`Error: ${err.message}`); process.exit(1); } const porcelain = args.includes("--porcelain"); const outputIdx = args.indexOf("--output"); const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : null; const localeIdx = args.indexOf("--locale"); const locale = localeIdx !== -1 ? args[localeIdx + 1] : "en"; if (localeIdx !== -1 && !LANGUAGES[locale]) { console.error( `Error: unsupported --locale "${locale}" (known: ${Object.keys(LANGUAGES).join(", ")}).`, ); process.exit(1); } // Values consumed by a preceding flag are not positional arguments. Miss one here and // `eli5 --backend ollama` runs against a source file named "ollama". const valueIndexes = new Set( [outputIdx, localeIdx, args.indexOf("--backend")] .filter((i) => i !== -1) .map((i) => i + 1), ); const positionals = args.filter((a, i) => !a.startsWith("--") && !valueIndexes.has(i)); if (args.includes("--articles")) { if (locale === "en") { console.error("Error: --articles requires --locale (e.g. --locale fr)."); console.error(" The English sidecars are generated by the bulk script instead:"); console.error(` ${cmd("eli5:bulk")}`); process.exit(1); } runArticleBatch({ locale, articlePaths: positionals, dryRun, porcelain, assumeTranslated: args.includes("--assume-translated"), backend, }).catch((err) => { console.error("❌ Error:", err.message); process.exit(1); }); } else { const sourceFile = positionals[0]; if (!sourceFile) { console.error("Error: no source file specified."); console.error("Run with --help for usage."); process.exit(1); } generateEli5(sourceFile, { force, outputPath, locale, backend }).catch((err) => { console.error("❌ Error:", err.message); process.exit(1); }); } } // end isMain guard ``` Quelques détails à noter : * **La détection du langage** reproduit le mapping de `remark-snippet-loader`, pour que les deux composants soient toujours d'accord sur le langage d'un fichier. * **Le prompt** indique à Claude d'ignorer les lignes triviales et de ne renvoyer qu'un objet JSON. Le message `system` lui impose de produire du JSON pur ; la température est maintenue basse pour des explications factuelles et cohérentes. * **Le nettoyage du JSON** vérifie que toutes les clés sont des chaînes de chiffres dans l'intervalle attendu et que toutes les valeurs sont des chaînes non vides, pour qu'une réponse malformée de Claude ne puisse pas casser le build. * **Génération incrémentale** : si le `.eli5.json` existe déjà, le script s'arrête sans appeler l'API. Utilisez `--force` pour régénérer. ### Utilisation {#usage} ```bash # Generate for a single file node scripts/generate-eli5.mjs blog/2026-01-01-my-post/files/Dockerfile # Regenerate after the source file changed node scripts/generate-eli5.mjs blog/2026-01-01-my-post/files/compose.yaml --force # Write to a custom path node scripts/generate-eli5.mjs src/some-file.sh --output docs/files/some-file.sh.eli5.json ``` Vous pouvez aussi ajouter un alias pratique dans `package.json` : ```json "scripts": { "eli5": "node scripts/generate-eli5.mjs", "eli5:bulk": "node scripts/bulk-eli5.mjs" } ``` L'appel devient alors `yarn eli5 blog/2026-01-01-my-post/files/Dockerfile`. ### Le format de sortie {#the-output-format} Le script écrit un fichier comme celui-ci : ```json { "version": 1, "model": "claude-haiku-4-5-20251001", "generated": "2026-06-13T10:00:00.000Z", "source": "Dockerfile", "lang": "docker", "explanations": { "1": "FROM picks the base image — the pre-built foundation your image starts from. node:20-alpine is Node 20 on the tiny Alpine Linux OS.", "4": "COPY package*.json ./ copies only the dependency manifest first so Docker can cache the npm install step separately from your application code.", "5": "npm ci is like npm install but strictly follows package-lock.json. --omit=dev leaves out development-only tools, shrinking the final image.", "8": "EXPOSE documents which port the container listens on. It is a label, not a firewall rule — docker run -p actually opens the port.", "9": "CMD is the default command when the container starts. Square-bracket form (exec form) runs node directly without a shell in between." } } ``` Seules les lignes annotées apparaissent. Le consommateur (le plugin remark et le composant React) considère les lignes absentes comme n'ayant pas de badge. ## Étape 2 — Le script par lot {#step-2--the-batch-script} Quand vous avez beaucoup d'articles, lancer le générateur fichier par fichier est fastidieux. `scripts/bulk-eli5.mjs` parcourt tout votre répertoire `blog/`, trouve chaque référence ``, résout le path et génère le `.eli5.json` en une seule passe. **scripts/bulk-eli5.mjs** ```mjs #!/usr/bin/env node /** * Batch "Explain Like I'm Five" generator. * * Scans all MDX/MD files under --dir (default: blog/) for usages, * resolves each source file, and generates a .eli5.json annotation file alongside it. * Already-existing .eli5.json files are skipped unless --force is passed. * * Usage: * node scripts/bulk-eli5.mjs [--force] [--dir blog/] [--dry-run] [--backend ollama] * * Requires ANTHROPIC_API_KEY in your environment or in a .env file at the project root — * unless --backend ollama is passed, which calls the local model instead and needs no key. */ import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; import { createRequire } from "module"; import { hashSource } from "./lib/eli5-hash.mjs"; import { DEFAULT_BACKEND, ELI5_BACKENDS, callEli5Backend, modelFor, printCostNotice, resolveBackend, } from "./lib/eli5-backend.mjs"; const require = createRequire(import.meta.url); const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(__dirname, ".."); // Load .env from project root (best-effort) try { const dotenv = require("dotenv"); dotenv.config({ path: path.join(projectRoot, ".env") }); } catch { // rely on environment variables already set } // --- Language detection (same as generate-eli5.mjs) --- const EXT_TO_LANG = { js: "javascript", mjs: "javascript", cjs: "javascript", jsx: "jsx", ts: "typescript", tsx: "typescript", py: "python", php: "php", sh: "bash", bash: "bash", zsh: "bash", css: "css", html: "html", json: "json", yml: "yaml", yaml: "yaml", md: "markdown", mdx: "markdown", dockerfile: "docker", ini: "ini", sql: "sql", xml: "xml", toml: "toml", env: "bash", txt: "plaintext", ps1: "powershell", bat: "batch", cmd: "batch", }; const BASE_NAME_TO_LANG = { dockerfile: "docker", ".env": "bash", ".gitignore": "plaintext", ".dockerignore": "plaintext", makefile: "makefile", }; function detectLang(filePath) { const baseName = path.basename(filePath).toLowerCase(); if (BASE_NAME_TO_LANG[baseName]) return BASE_NAME_TO_LANG[baseName]; const ext = path.extname(filePath).slice(1).toLowerCase(); return EXT_TO_LANG[ext] || ext || "plaintext"; } const SYSTEM_PROMPT = `You are a patient senior developer explaining code to a junior colleague. Given a source file with numbered lines, produce two things: 1. "summary": two to four plain-English sentences narrating what this snippet does as a whole and why, the way you'd explain it out loud to someone reading over your shoulder — not a recap of individual lines. No jargon unless immediately defined. 2. "explanations": for each line a junior developer might find confusing, one or two plain-English sentences. Think "explain like I'm five but I'm also a developer". Skip blank lines, closing braces/brackets, import lines that are self-evident, comments that already explain themselves, trivially obvious assignments. Return ONLY a valid JSON object shaped exactly like: {"summary": "...", "explanations": {"3": "...", "7": "..."}} Keys in "explanations" are line numbers as strings. Omit lines that need no explanation. No markdown, no code fences.`; async function generateForFile( absSource, { force = false, backend = DEFAULT_BACKEND } = {}, ) { const destPath = absSource + ".eli5.json"; if (!force && fs.existsSync(destPath)) { return { status: "skipped", path: destPath }; } const code = fs.readFileSync(absSource, "utf-8"); const lang = detectLang(absSource); const lines = code.split("\n"); const numberedCode = lines.map((l, i) => `${i + 1}: ${l}`).join("\n"); const model = modelFor(backend); const raw = await callEli5Backend({ backend, systemPrompt: SYSTEM_PROMPT, userContent: `Language: ${lang}\n\n${numberedCode}`, maxTokens: lines.length > 50 ? 2048 : 1024, }); let parsed; try { parsed = JSON.parse(raw); } catch { const match = raw.match(/\{[\s\S]*\}/); if (match) parsed = JSON.parse(match[0]); else throw new Error(`Invalid JSON from ${model} for ${path.basename(absSource)}`); } const summary = typeof parsed.summary === "string" && parsed.summary.trim() ? parsed.summary.trim() : null; // Validate const explanations = parsed.explanations && typeof parsed.explanations === "object" ? parsed.explanations : {}; const cleaned = {}; for (const [k, v] of Object.entries(explanations)) { if (/^\d+$/.test(k) && typeof v === "string" && v.trim()) { const idx = parseInt(k, 10) - 1; if (idx >= 0 && idx < lines.length) cleaned[k] = v.trim(); } } const result = { version: 1, // The model that actually wrote this file — see scripts/lib/eli5-backend.mjs. model, generated: new Date().toISOString(), source: path.basename(absSource), sourceHash: hashSource(code), lang, summary, explanations: cleaned, }; fs.writeFileSync(destPath, JSON.stringify(result, null, 2) + "\n"); return { status: "generated", path: destPath, count: Object.keys(cleaned).length }; } // --- Find all MDX/MD files recursively --- function findMarkdownFiles(dir) { const results = []; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const full = path.join(dir, entry.name); if (entry.isDirectory()) { results.push(...findMarkdownFiles(full)); } else if (entry.isFile() && /\.(mdx?|md)$/.test(entry.name)) { results.push(full); } } return results; } // --- Extract source paths from usages --- const SNIPPET_SOURCE_RE = /]*\ssource="([^"]+)"/g; function extractSourcePaths(mdxFile) { const content = fs.readFileSync(mdxFile, "utf-8"); const dir = path.dirname(mdxFile); const sources = []; let match; while ((match = SNIPPET_SOURCE_RE.exec(content)) !== null) { const srcAttr = match[1]; let absPath; if (srcAttr.startsWith("./") || srcAttr.startsWith("../")) { absPath = path.resolve(dir, srcAttr); } else { absPath = path.resolve(projectRoot, srcAttr); } sources.push(absPath); } return sources; } // --- CLI --- const args = process.argv.slice(2); if (args.includes("--help") || args.includes("-h")) { console.log(` Usage: node scripts/bulk-eli5.mjs [options] Options: --force Regenerate even if .eli5.json already exists --backend Who writes the annotations: claude (default, paid, ~$0.01 per file) or ollama (local, free, no API key) — known: ${ELI5_BACKENDS.join(", ")} --dir Directory to scan (default: blog/) --dry-run Show what would be generated without calling the API --help, -h Show this help Examples: node scripts/bulk-eli5.mjs node scripts/bulk-eli5.mjs --force node scripts/bulk-eli5.mjs --dir blog/2026 --dry-run node scripts/bulk-eli5.mjs --dir blog/2026 --backend ollama --force Iterate locally, publish with Claude: --backend ollama is there to try a prompt change on a slice of the corpus without spending anything; the published sidecars stay Haiku-generated. Requires ANTHROPIC_API_KEY in your environment or in a .env file at the project root (except for --dry-run and --backend ollama, which never call the API). `); process.exit(0); } const force = args.includes("--force"); const dryRun = args.includes("--dry-run"); let backend; try { backend = resolveBackend(args); } catch (err) { console.error(`Error: ${err.message}`); process.exit(1); } const dirIdx = args.indexOf("--dir"); const scanDir = dirIdx !== -1 ? path.resolve(projectRoot, args[dirIdx + 1]) : path.resolve(projectRoot, "blog"); if (!fs.existsSync(scanDir)) { console.error(`Error: directory not found: ${scanDir}`); process.exit(1); } console.log(`🔍 Scanning ${path.relative(projectRoot, scanDir)} for Snippet usages...`); const mdxFiles = findMarkdownFiles(scanDir); const sourceMap = new Map(); // absPath → Set of mdx files referencing it for (const mdxFile of mdxFiles) { for (const src of extractSourcePaths(mdxFile)) { if (!sourceMap.has(src)) sourceMap.set(src, new Set()); sourceMap.get(src).add(path.relative(projectRoot, mdxFile)); } } if (sourceMap.size === 0) { console.log('No usages found.'); process.exit(0); } console.log( `Found ${sourceMap.size} unique source file(s) across ${mdxFiles.length} posts.\n`, ); if (dryRun) { for (const [src, refs] of sourceMap) { const relSrc = path.relative(projectRoot, src); const exists = fs.existsSync(src + ".eli5.json"); const action = exists && !force ? "SKIP" : "GENERATE"; console.log(` [${action}] ${relSrc}`); for (const ref of refs) console.log(` ← ${ref}`); } console.log("\nDry run — nothing written."); process.exit(0); } // What this run will really call for: the sources that exist and are not being skipped. // Counting the map would quote a price for the ⏭ files too. const toGenerate = [...sourceMap.keys()].filter( (src) => fs.existsSync(src) && (force || !fs.existsSync(src + ".eli5.json")), ).length; printCostNotice(backend, toGenerate); let generated = 0, skipped = 0, errors = 0; for (const [src] of sourceMap) { const relSrc = path.relative(projectRoot, src); if (!fs.existsSync(src)) { console.warn(` ⚠ Source not found, skipping: ${relSrc}`); errors++; continue; } process.stdout.write(` 📄 ${relSrc} ... `); try { const result = await generateForFile(src, { force, backend }); if (result.status === "skipped") { console.log("⏭ skipped"); skipped++; } else { console.log(`✅ ${result.count} annotations`); generated++; } } catch (err) { console.log(`❌ ${err.message}`); errors++; } } console.log(`\n──────────────────────────────────────`); console.log(`Generated: ${generated} Skipped: ${skipped} Errors: ${errors}`); if (errors > 0) process.exit(1); ``` ### Référence des options {#options-reference} | Flag | Défaut | Description | | -------------- | ------- | ------------------------------------------------------ | | `--dir ` | `blog/` | Répertoire à parcourir (relatif à la racine du projet) | | `--force` | off | Régénère même si `.eli5.json` existe déjà | | `--dry-run` | off | Montre ce qui se passerait, sans appel API | ## Comment fonctionne le script par lot {#how-the-bulk-script-works} Avant de passer au workflow de migration, il est utile de comprendre exactement ce que fait le script — et ce qu'il ne fait pas. ### Comment il trouve les fichiers à annoter {#how-it-finds-files-to-annotate} Le script lit chaque fichier `.md` et `.mdx` sous `--dir` (par défaut : `blog/`), et cherche dans chacun les occurrences de `` à l'aide d'une expression régulière. Pour chaque correspondance, il résout le chemin source en chemin absolu, déduplique (un fichier référencé dans plusieurs articles n'est traité qu'une fois), puis traite la liste. Il n'annote **pas** les blocs de code inline — uniquement les appels `` qui référencent un fichier externe via `source="./files/..."`. ### La règle de saut — idempotent par conception {#the-skip-rule--idempotent-by-design} Pour chaque fichier source, le script vérifie d'abord si `.eli5.json` existe déjà : * **Le fichier existe → `⏭ skipped`** — zéro appel API, zéro coût, instantané. * **Le fichier manque → `✅ N annotations`** — un appel API vers Claude, fichier écrit. Ce qui signifie : * Vous pouvez lancer `yarn eli5:bulk` autant de fois que vous voulez. Seuls les fichiers dont le `.eli5.json` manque déclencheront un appel API. * **La première exécution** est la seule qui coûte de l'argent (pour les articles existants). Toutes les suivantes sont gratuites. * Si vous ajoutez un nouvel article avec un `` le mois prochain, relancer `yarn eli5:bulk` n'annotera que ce nouveau fichier. * Si le script est interrompu (erreur réseau, Ctrl+C), relancez-le simplement — les fichiers déjà générés sont ignorés, seuls les restants sont traités. N'utilisez `--force` que lorsque vous avez volontairement modifié un fichier source et voulez régénérer ses annotations à zéro. ## Annoter vos articles existants {#annotating-your-existing-articles} Si vous avez un blog avec beaucoup d'articles — disons 240 — vous ne voulez pas lancer le générateur fichier par fichier. Voici le workflow de migration recommandé. ### Phase 1 — Auditer avec dry-run {#phase-1--audit-with-dry-run} Avant de toucher à l'API, faites-vous une idée claire de ce que vous avez. Lancez le script par lot avec `--dry-run` sur tout votre blog : ```bash $ node scripts/bulk-eli5.mjs --dry-run ``` C'est instantané — aucun appel API n'est fait. La sortie montre chaque fichier source référencé par un `` dans un article, et vous dit si son `.eli5.json` existe déjà ou doit encore être généré : ```plaintext 🔍 Scanning blog/ for Snippet usages... Found 47 unique source file(s) across 240 posts. [SKIP] blog/2025/03/12-wsl2-tricks/files/wsl.conf ← blog/2025/03/12-wsl2-tricks/index.md [GENERATE] blog/2025/04/05-docker-compose/files/compose.yaml ← blog/2025/04/05-docker-compose/index.md [GENERATE] blog/2025/04/05-docker-compose/files/Dockerfile ← blog/2025/04/05-docker-compose/index.md [GENERATE] blog/2025/06/20-zsh-config/files/.zshrc ← blog/2025/06/20-zsh-config/index.md ... ────────────────────────────────────── Generated: 0 Skipped: 3 Errors: 0 ``` Deux choses à remarquer ici : 1. **Seulement 47 fichiers sources pour 240 articles.** La plupart des articles n'utilisent pas du tout `` — ils utilisent des blocs de code inline. Le script par lot les ignore automatiquement. Vous n'annotez pas 240 fichiers ; vous annotez seulement les articles qui utilisent déjà le composant Snippet avec un fichier source externe. 2. **Les labels `SKIP` / `GENERATE`** vous indiquent d'un coup d'œil quels fichiers ont déjà une annotation et lesquels en attendent une. Ça rend le dry-run utile aussi après des exécutions partielles. Sauvegardez la sortie du dry-run si vous voulez garder une trace d'audit : ```bash $ node scripts/bulk-eli5.mjs --dry-run > eli5-audit.txt ``` ### Phase 2 — Estimer le coût {#phase-2--estimate-the-cost} `claude-haiku-4-5-20251001` coûte une fraction de centime par appel. Un fichier source typique (20 à 60 lignes) coûte environ **0,0002 à 0,001 $**. Multipliez par le nombre d'entrées `[GENERATE]` de votre dry-run. Pour 47 fichiers sources, vous êtes sous les **0,05 $** au total. Même si chacun de vos 240 articles avait un fichier source, le coût total resterait sous **0,25 $**. ### Phase 3 — Générer par périmètre {#phase-3--generate-by-scope} Vous pouvez traiter tout le blog en une commande, ou restreindre à une année ou un mois précis. Tous les chemins sont relatifs à la racine du projet. **Tout traiter d'un coup :** ```bash $ node scripts/bulk-eli5.mjs ``` Le script s'exécute séquentiellement (un appel API à la fois), donc aucun risque d'atteindre les limites de débit. Sur une connexion classique, 47 fichiers prennent environ deux minutes. **Traiter une seule année :** ```bash node scripts/bulk-eli5.mjs --dir blog/2025 node scripts/bulk-eli5.mjs --dir blog/2026 ``` **Traiter un seul mois :** ```bash node scripts/bulk-eli5.mjs --dir blog/2026/06 ``` **Traiter un seul dossier d'article :** ```bash node scripts/bulk-eli5.mjs --dir blog/2025/04/05-docker-compose ``` Cette dernière forme équivaut à lancer `generate-eli5.mjs` individuellement pour chaque fichier source de cet article — mais le script par lot gère la déduplication automatiquement (si le même fichier source est référencé deux fois dans le même article, il n'est généré qu'une fois). ### Phase 4 — À quoi ressemble la sortie pendant l'exécution {#phase-4--what-the-output-looks-like-while-running} ```plaintext 🔍 Scanning blog/2025 for Snippet usages... Found 31 unique source file(s) across 140 posts. 📄 blog/2025/04/05-docker-compose/files/compose.yaml ... ✅ 6 annotations 📄 blog/2025/04/05-docker-compose/files/Dockerfile ... ✅ 5 annotations 📄 blog/2025/04/12-zsh-setup/files/.zshrc ... ✅ 9 annotations 📄 blog/2025/05/01-devcontainer/files/devcontainer.json ... ✅ 7 annotations 📄 blog/2025/06/20-fzf/files/fzf-preview.sh ... ⏭ skipped ... ────────────────────────────────────── Generated: 30 Skipped: 1 Errors: 0 ``` Chaque ligne vous dit combien de lignes Claude a décidé d'annoter. Un fichier avec `0 annotations` signifierait que Claude n'a rien trouvé de non trivial — ça peut arriver pour des fichiers très courts ou très simples. Un fichier avec `⏭ skipped` avait déjà un `.eli5.json` issu d'une exécution précédente. **Le script peut être interrompu et relancé sans risque.** S'il s'arrête en cours de route (erreur réseau, erreur API, Ctrl+C), relancez-le. Les fichiers déjà générés afficheront `⏭ skipped` ; seuls les restants seront traités. ### Phase 5 — Gérer les erreurs {#phase-5--handle-errors} Si un fichier échoue (Claude renvoie du JSON malformé, timeout réseau), ça affiche : ```plaintext 📄 blog/2025/04/05-docker-compose/files/Makefile ... ❌ Invalid JSON from Claude ``` Le script continue avec le fichier suivant et se termine avec le code `1` (pour qu'un pipeline CI puisse le détecter). Pour ne réessayer que le fichier en échec : ```bash node scripts/generate-eli5.mjs blog/2025/04/05-docker-compose/files/Makefile ``` Si le même fichier échoue de nouveau, ajoutez `--force` pour régénérer avec un nouvel appel API. ### Phase 6 — Vérifier un échantillon {#phase-6--verify-a-sample} Avant de committer, ouvrez un article avec `yarn start` et vérifiez que les badges `?` apparaissent sur les bonnes lignes. Inspectez un ou deux fichiers `.eli5.json` pour confirmer la qualité des explications. > **Aucun build nécessaire — le mode dev fonctionne d'emblée:** > > Les badges `?` sont injectés par le plugin remark pendant le build MDX, pas à l'exécution. Autrement dit, `yarn start` (port 3000) et `yarn build` produisent des résultats identiques pour les annotations ELI5. Vous n'avez jamais besoin de lancer un build complet juste pour prévisualiser les tooltips. > > Si vous générez un nouveau `.eli5.json` alors que le serveur de dev tourne déjà, enregistrez le fichier `.md` correspondant pour relancer le build MDX — les badges apparaîtront au prochain hot reload. Si une explication est fausse ou trop verbeuse, éditez directement le fichier JSON — c'est du texte brut, aucune régénération nécessaire. ### Phase 7 — Committer {#phase-7--commit} Committez les fichiers `.eli5.json` à côté des fichiers sources qu'ils annotent. C'est volontaire : le CI/CD n'a pas besoin de `ANTHROPIC_API_KEY` puisque les annotations sont pré-générées et stockées dans git. ```bash Stage all newly generated annotation files {#stage-all-newly-generated-annotation-files} git add blog/**/*.eli5.json git commit -m "feat: add ELI5 annotations for existing Snippet blocks" ``` ### Résumé — la migration en cinq commandes {#summary--migration-in-five-commands} ```bash 1. Preview scope (no API calls) {#1-preview-scope-no-api-calls} node scripts/bulk-eli5.mjs --dry-run 2. Generate by year (repeat for each year) {#2-generate-by-year-repeat-for-each-year} node scripts/bulk-eli5.mjs --dir blog/2025 node scripts/bulk-eli5.mjs --dir blog/2026 3. Verify in the browser {#3-verify-in-the-browser} yarn start 4. Commit {#4-commit} git add blog/**/*.eli5.json && git commit -m "feat: ELI5 annotations" ``` ## Étape 3 — Étendre le plugin remark {#step-3--extend-the-remark-plugin} Votre `remark-snippet-loader` lit déjà le fichier source et injecte les props `code` et `lang` dans le nœud `` au moment du build. Si vous n'avez jamais écrit un tel plugin, mon article [search\&replace plugin for Docusaurus](/blog/docusaurus-plugin-replace) explique l'anatomie d'un plugin remark depuis zéro. Ajoutez onze lignes pour injecter aussi les annotations ELI5 quand un `.eli5.json` existe à côté du fichier source. Dans `plugins/remark-snippet-loader/index.cjs`, après le bloc qui ajoute l'attribut `lang` : **./files/extend\_remark.js** ```javascript // Auto-inject ELI5 explanations if a .eli5.json file exists const eli5Path = absolutePath + ".eli5.json"; if (fs.existsSync(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), }); } } catch (e) { console.warn(`Snippet plugin: could not parse ELI5 file ${eli5Path}:`, e.message); } } ``` Les annotations sont sérialisées en chaîne JSON (prop `eli5json`) pour rentrer dans le modèle d'attributs MDX sans devoir construire un nœud d'expression estree. Le composant `Snippet` reparse cette chaîne en objet avec `JSON.parse`. Cette approche signifie que **l'auteur n'a pas une seule ligne de MDX à changer**. Ajoutez le fichier `.eli5.json`, relancez le build — les tooltips apparaissent automatiquement. ## Étape 4 — Mettre à jour le composant Snippet {#step-4--update-the-snippet-component} Le [Snippet component](/blog/docusaurus-snippets) demande deux changements : un nouveau sous-composant `Eli5CodeBlock` qui affiche le code ligne par ligne avec les badges, et une petite mise à jour de la fonction principale `Snippet` pour parser `eli5json` et router vers le nouveau renderer. **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 (
    <>
      
        
        
          {lines.map((lineTokens, i) => {
            const lineNum = String(i + 1);
            const explanation = eli5[lineNum];
            const isActive = activeLine === lineNum;

            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 && }
); } ``` ### Comment fonctionne le renderer {#how-the-renderer-works} Quand `eli5json` est présent et que `code` est une chaîne, le composant appelle directement `Prism.highlight()` au lieu de déléguer au `` de Docusaurus. Prism est déjà embarqué par Docusaurus — l'importer est sans risque et n'a aucun coût sur la taille du bundle. Le HTML colorisé est découpé aux retours à la ligne. Chaque ligne devient un `
` contenant : * Un `` avec le code colorisé (via `dangerouslySetInnerHTML` — sans risque puisque Prism n'entoure les tokens que d'éléments ``) * Soit un bouton `?` (si la ligne a une explication), soit un placeholder invisible de même largeur (pour garder toutes les lignes alignées) Le tooltip apparaît au survol via une paire `onMouseEnter`/`onMouseLeave` sur le wrapper du badge, et bascule au clic pour l'accès clavier/mobile. Les handlers focus/blur le rendent accessible au clavier sans acrobaties ARIA supplémentaires. Quand `eli5json` est absent (le cas normal), le composant se comporte exactement comme avant — `` est utilisé, rien ne change. ## Étape 5 — Ajouter le CSS {#step-5--add-the-css} Ajoutez ces classes à `src/components/Snippet/styles.module.css` : **src/components/Snippet/styles.module.css** ```css .eli5_pre { margin: 0; padding: 0.75rem 0; overflow-x: auto; background: var(--prism-background-color, #282c34); border-radius: 0 0 4px 4px; font-family: var(--ifm-font-family-monospace); font-size: var(--ifm-code-font-size, 0.875rem); line-height: 1.5; } .eli5_line { display: flex; align-items: flex-start; min-height: 1.5em; padding: 0 1rem; } .eli5_line:hover { background: rgba(255, 255, 255, 0.04); } .eli5_code { flex: 1; white-space: pre; overflow: hidden; } .eli5_badge_placeholder { display: inline-block; width: 1.2rem; flex-shrink: 0; margin-left: 0.5rem; } .eli5_badge_wrapper { position: relative; display: inline-flex; align-items: center; flex-shrink: 0; margin-left: 0.5rem; } .eli5_badge { display: inline-flex; align-items: center; justify-content: center; width: 1.2rem; height: 1.2rem; border-radius: 50%; border: 1px solid rgba(150, 150, 200, 0.35); background: rgba(100, 100, 180, 0.12); color: rgba(180, 180, 230, 0.6); font-size: 0.6rem; font-weight: 700; line-height: 1; padding: 0; cursor: pointer; transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease; flex-shrink: 0; } .eli5_badge:hover, .eli5_badge:focus-visible, .eli5_badge_active { background: rgba(80, 130, 230, 0.35); color: #e8eaf6; border-color: rgba(120, 170, 255, 0.65); outline: none; } .eli5_tooltip { position: absolute; right: 0; top: calc(100% + 5px); z-index: 200; width: 270px; max-width: 90vw; padding: 0.5rem 0.75rem; background: var(--ifm-background-surface-color, #fff); border: 1px solid var(--ifm-color-emphasis-300); border-radius: 6px; box-shadow: 0 4px 18px rgba(0, 0, 0, 0.18); font-family: var(--ifm-font-family-base); font-size: 0.8rem; line-height: 1.55; color: var(--ifm-font-color-base); white-space: normal; pointer-events: none; } .eli5_tooltip::before { content: ""; position: absolute; top: -5px; right: 4px; width: 8px; height: 8px; background: var(--ifm-background-surface-color, #fff); border-left: 1px solid var(--ifm-color-emphasis-300); border-top: 1px solid var(--ifm-color-emphasis-300); transform: rotate(45deg); } [data-theme="dark"] .eli5_tooltip { background: #1e2030; border-color: rgba(255, 255, 255, 0.1); box-shadow: 0 4px 22px rgba(0, 0, 0, 0.5); } [data-theme="dark"] .eli5_tooltip::before { background: #1e2030; border-color: rgba(255, 255, 255, 0.1); } ``` Le `eli5_badge_placeholder` mérite une explication : chaque ligne du bloc de code doit avoir un élément de droite de même largeur, qu'elle ait un badge ou non. Sans le placeholder, les lignes sans `?` seraient plus étroites et le code paraîtrait irrégulier. ## Workflow de l'auteur (de bout en bout) {#author-workflow-end-to-end} Pour chaque article de blog contenant des blocs ``, le workflow après une première publication est : ```bash 1. Write your post. Place source files in ./files/ as usual {#1-write-your-post-place-source-files-in-files-as-usual} Example: blog/2026-07-01-my-docker-post/files/Dockerfile {#example-blog2026-07-01-my-docker-postfilesdockerfile} 2. Generate ELI5 annotations {#2-generate-eli5-annotations} $ yarn eli5 blog/2026-07-01-my-docker-post/files/Dockerfile 3. Preview locally — the ? badges appear automatically {#3-preview-locally--the--badges-appear-automatically} $ yarn start 4. Commit the source file and the annotation file {#4-commit-the-source-file-and-the-annotation-file} $ git add blog/2026-07-01-my-docker-post/files/Dockerfile $ git add blog/2026-07-01-my-docker-post/files/Dockerfile.eli5.json 5. Build and deploy as usual — no ANTHROPIC_API_KEY needed in CI {#5-build-and-deploy-as-usual--no-anthropic_api_key-needed-in-ci} $ yarn build ``` Si le fichier source change après publication, régénérez avec `--force` : ```bash $ yarn eli5 blog/2026-07-01-my-docker-post/files/Dockerfile --force ``` ## Mise en place de l'environnement {#environment-setup} ### Étape 1 — Créer un compte Anthropic et obtenir une clé API {#step-1--create-an-anthropic-account-and-get-an-api-key} > **L'accès API est distinct de Claude Pro:** > > Si vous avez déjà un abonnement **Claude Pro** (claude.ai), il ne couvre que l'interface de chat — il n'inclut pas l'accès API. Les scripts de génération utilisent l'**API Anthropic**, un produit séparé, à l'usage, facturé à la consommation de tokens. Il vous faut un compte distinct sur `console.anthropic.com` avec un solde de crédit. La bonne nouvelle : annoter 240 articles avec `claude-haiku-4-5` coûte quelques centimes au total (Haiku est le modèle Claude le moins cher, à 1 $ par million de tokens en entrée, et chaque fichier source ne fait que quelques centaines de tokens). Rendez-vous sur **** et inscrivez-vous (ou connectez-vous si vous avez déjà un compte). Une fois connecté : 1. Cliquez sur **Settings** dans la barre latérale gauche (ou allez directement sur `https://console.anthropic.com/settings/keys`). 2. Sélectionnez **API Keys** dans le menu des paramètres. 3. Cliquez sur **Create Key**. 4. Donnez un nom à votre clé (par exemple `docusaurus-eli5`) pour pouvoir l'identifier plus tard. 5. **Copiez la clé immédiatement** — elle n'est affichée qu'une fois et ne peut pas être récupérée après la fermeture de la boîte de dialogue. Elle ressemble à `sk-ant-api03-...`. Stockez la clé en lieu sûr (un gestionnaire de mots de passe fait très bien l'affaire). Si vous la perdez, il faudra en créer une nouvelle. > **N'exposez jamais votre clé API:** > > Ne collez pas votre clé dans du code source, ne la committez pas dans git, ne la partagez pas publiquement. Une clé fuitée peut servir à faire des appels API facturés sur votre compte. Il vous faudra aussi un **solde de crédit de facturation** pour passer des appels API. Dans la console Anthropic, allez dans **Settings → Billing** et ajoutez un petit crédit (quelques dollars suffisent largement pour annoter des centaines d'articles avec Haiku — le coût est de l'ordre d'une fraction de centime par fichier). ### Étape 2 — Ajouter la clé à votre projet {#step-2--add-the-key-to-your-project} Créez un fichier `.env` à la racine de votre projet Docusaurus (à côté de `package.json`) : ```dotenv # .env — do not commit this file ANTHROPIC_API_KEY=sk-ant-api03-... ``` Assurez-vous ensuite que `.env` est dans votre `.gitignore` : ```bash $ echo ".env" >> .gitignore ``` La clé n'est lue par les scripts de génération qu'au moment de la rédaction. Elle n'est **jamais** incluse dans le site généré, ni nécessaire en CI — les fichiers d'annotation `.eli5.json` sont committés dans git à côté des fichiers sources. ### Étape 3 — Installer les dépendances {#step-3--install-dependencies} `dotenv` et `@anthropic-ai/sdk` sont déjà présents comme dépendances dans ce projet. Si vous partez d'un projet Docusaurus neuf, installez-les en dépendances de développement : ```bash $ yarn add --dev @anthropic-ai/sdk dotenv ``` ## Limitations connues {#known-limitations} * **Nécessite la prop `source`** — le renderer ELI5 ne s'active que lorsque le plugin remark injecte `code` sous forme de chaîne (c'est-à-dire quand vous utilisez `source="./files/myfile"`). Les snippets qui utilisent des `children` inline (blocs de code dans le MDX) ne sont pas annotés. * **Annotations obsolètes** — si vous modifiez fortement un fichier source après avoir généré `.eli5.json`, les annotations peuvent ne plus correspondre aux lignes. Lancez `--force` pour rafraîchir. Il n'y a pas de détection automatique d'obsolescence. * **Lignes longues** — si une ligne se replie visuellement (scroll en overflow-x), le badge `?` reste à la fin de la ligne logique, potentiellement hors écran. C'est un cas limite pour la plupart des types de fichiers (Dockerfile, YAML, bash) où les lignes sont courtes. * **Couverture des langages Prism** — `Prism.highlight()` retombe sur du texte brut pour les langages non enregistrés dans le bundle Prism de Docusaurus. Les types de fichiers les plus courants (Dockerfile, YAML, Bash, JSON, JavaScript) sont tous dans le bundle par défaut. ## Quel modèle Claude utiliser {#what-claude-model-to-use} Les scripts utilisent `claude-haiku-4-5-20251001`, le modèle Claude le plus rapide et le moins cher. Pour un Dockerfile de 20 lignes, un appel coûte une fraction de centime et répond en moins de deux secondes. La qualité suffit pour l'usage ELI5 — des explications courtes et factuelles de constructions bien connues. Si vous voulez des explications plus nuancées (par exemple pour des pipelines shell complexes ou des ancres YAML avancées), passez à `claude-sonnet-4-6` dans les scripts. Le prompt et le format de sortie restent identiques. ## Prolonger l'idée {#extending-the-idea} Quelques pistes à explorer une fois la fonctionnalité de base en place : * **Niveaux de difficulté** — le prompt pourrait demander à Claude de taguer chaque explication en `beginner` / `intermediate` / `advanced`, et le badge pourrait changer de couleur en conséquence (`?` en vert/jaune/rouge). * **Localisation** — passez une prop `lang` au script et ajoutez `Please respond in French` au prompt système. Le format `.eli5.json` a déjà un champ `lang` pour ça. * **Détection d'obsolescence** — ajoutez un champ `sourceHash` (SHA-256 du fichier source au moment de la génération) dans `.eli5.json`. Le plugin remark peut le comparer au hash actuel du fichier et émettre un avertissement pendant le build en cas de divergence. * **Hook de régénération automatique** — un pre-hook `yarn build` (`"prebuild": "node scripts/bulk-eli5.mjs"`) régénérerait toutes les annotations manquantes avant chaque build. Combiné avec `ANTHROPIC_API_KEY` dans les secrets CI, ça rend la génération d'annotations entièrement automatique. --- # Ajouter des réactions de lecteurs à votre blog Docusaurus > Construisez un widget « Cet article vous a-t-il été utile ? » entièrement fonctionnel pour votre blog Docusaurus. Guide pas à pas couvrant le backend PHP, le composant React, le module CSS, le swizzle Docusaurus et un tableau de bord d'administration — tout ce qu'il faut pour le reproduire de zéro. --- ![Ajouter des réactions de lecteurs à votre blog Docusaurus](/img/v2/docusaurus_like_button.webp) **TL;DR** Ce guide détaille la construction d'un widget « Cet article vous a-t-il été utile ? » pour un blog Docusaurus — de zéro, de bout en bout. Vous allez créer un script PHP léger qui stocke les votes dans un fichier JSON et envoie des notifications par e-mail limitées en fréquence, un composant React qui lit et écrit ces votes, un module CSS qui s'intègre sans accroc à n'importe quel thème Docusaurus, et un swizzle de `BlogPostItem` pour injecter le widget **en bas** de chaque article. Une page bonus de tableau de bord d'administration vous permet de suivre les taux d'approbation de tous les articles d'un coup d'œil. Le retour des lecteurs fait partie de ces choses qui paraissent simples de l'extérieur mais qui cachent un nombre surprenant de pièces mobiles dès qu'on se met à les construire. Je voulais quelque chose de discret — pas de service tiers, pas de cookies, pas d'inscription. Juste un petit widget « Cet article vous a-t-il été utile ? » en bas de chaque article, un fichier JSON sur le serveur, et un e-mail dans ma boîte quand un lecteur vote. Le résultat est un système autonome : un backend PHP de 160 lignes, un composant React, un module CSS et un seul swizzle. Tout vit dans votre propre repository et sur votre propre hébergement. Pas de dépendance externe, pas d'implication côté vie privée, pas de coût récurrent. Dans cet article, je vous fais parcourir chaque fichier, chaque décision et chaque ligne qui compte — pour que vous puissiez le reproduire sur votre blog Docusaurus et l'adapter à vos besoins. **Quick Jump:** [Vue d'ensemble](#the-big-picture) | [Tous les fichiers en un coup d'œil](#all-files-at-a-glance) > Screenshot — https\://www\.avonture.be/blog/docusaurus-easter-eggs > > ![Reactions](./images/reactions.webp) ## Vue d'ensemble {#the-big-picture} Avant d'entrer dans le vif du sujet, voici ce que nous allons construire et comment les pièces s'assemblent : ```plaintext Browser Your Server │ │ ├─ GET /api/reactions.php?slug=... ─▶ reactions-data.json (read) │◀─ { helpful: 12, not_helpful: 3 } ─┤ │ │ ├─ POST /api/reactions.php ─▶ reactions-data.json (write) │ { slug, vote } │ + email notification │◀─ { helpful: 13, not_helpful: 3 } ─┤ │ │ └─ GET /api/reactions.php?admin=... ─▶ reactions-data.json (full dump) ``` **Cinq fichiers sont concernés :** | Fichier | Rôle | | ------------------------------------------- | ---------------------------------------------------- | | `api/reactions.php` | Backend PHP : stocke les votes, envoie les e-mails | | `src/components/Reaction/index.tsx` | Composant React affiché en bas de chaque article | | `src/components/Reaction/styles.module.css` | Module CSS du widget | | `src/theme/BlogPostItem/index.js` | Composant Docusaurus swizzlé qui injecte le widget | | `src/pages/reactions-dashboard.js` | Page d'administration pour visualiser tous les votes | Deux fichiers JSON sont créés à l'exécution par le backend : * `api/reactions-data.json` — le stockage des votes, une entrée par slug d'article * `api/notifications.json` — les timestamps de limitation, une entrée par slug d'article Construisons-les un par un. *** ## Étape 1 — Le backend PHP {#step-1--the-php-backend} Créez le fichier `api/reactions.php` à la racine de votre serveur web (à côté de, ou dans, votre dossier de sortie Docusaurus). Le script PHP n'a pas besoin de se trouver dans l'arborescence source de Docusaurus ; il doit juste être joignable par le navigateur. ### 1.1 — Configuration {#11--configuration} **api/reactions.php** ```php $message]); exit; } // Sends an email notification for a new vote, with a per-article cooldown. function maybeNotify(string $slug, string $vote, array $counts): void { $throttleFile = __DIR__ . '/notifications.json'; $throttle = loadData($throttleFile); if (time() - ($throttle[$slug] ?? 0) < NOTIFY_COOLDOWN_SECONDS) { return; } $articleUrl = SITE_URL . '/' . $slug; $dashboardUrl = SITE_URL . '/reactions-dashboard'; $total = $counts['helpful'] + $counts['not_helpful']; $ratio = $total > 0 ? round($counts['helpful'] / $total * 100) : 0; $subject = "[Blog] New reaction on: $slug"; $body = implode("\n", [ "A reader just reacted to one of your blog posts.", "", "Article : $articleUrl", "Vote : $vote", "Helpful : {$counts['helpful']}", "Not helpful : {$counts['not_helpful']}", "Approval : {$ratio}%", "", "View full dashboard: $dashboardUrl", ]); $headers = implode("\r\n", [ "From: " . NO_REPLY, "Reply-To: " . NO_REPLY, "Content-Type: text/plain; charset=utf-8", ]); if (@mail(ADMIN_EMAIL, $subject, $body, $headers)) { $throttle[$slug] = time(); saveData($throttleFile, $throttle); } } // ── Parse input ─────────────────────────────────────────────────────────────── $method = $_SERVER['REQUEST_METHOD']; $slug = ''; $vote = ''; if ($method === 'GET') { // Admin request: return all reaction data if (array_key_exists('admin', $_GET)) { if (ADMIN_TOKEN === '' || ($_GET['admin'] ?? '') !== ADMIN_TOKEN) { jsonError(403, 'Forbidden'); } echo json_encode(loadData(__DIR__ . '/reactions-data.json')); exit; } $slug = sanitizeSlug($_GET['slug'] ?? ''); } elseif ($method === 'POST') { $body = json_decode(file_get_contents('php://input'), true) ?? []; $slug = sanitizeSlug($body['slug'] ?? ''); $vote = $body['vote'] ?? ''; } else { jsonError(405, 'Method not allowed'); } if ($slug === '') { jsonError(400, 'Missing slug'); } // ── Load data ───────────────────────────────────────────────────────────────── $dataFile = __DIR__ . '/reactions-data.json'; $data = loadData($dataFile); if (!isset($data[$slug])) { $data[$slug] = ['helpful' => 0, 'not_helpful' => 0]; } // ── Record vote ─────────────────────────────────────────────────────────────── if ($method === 'POST') { if ($vote === 'helpful') { $data[$slug]['helpful']++; } elseif ($vote === 'not_helpful') { $data[$slug]['not_helpful']++; } else { jsonError(400, 'Invalid vote value'); } saveData($dataFile, $data); maybeNotify($slug, $vote, $data[$slug]); } echo json_encode($data[$slug]); ``` Quatre constantes, quatre décisions à prendre : * **`ADMIN_EMAIL`** — où partent les notifications par e-mail. Utilisez votre propre adresse. * **`ADMIN_TOKEN`** — une longue chaîne aléatoire (32 caractères ou plus). Elle protège l'endpoint d'administration qui renvoie tous les votes. Générez-la avec `openssl rand -base64 24` dans votre terminal et ne la mettez jamais dans un commit sur un repository public. * **`NOREPLY`** — votre adresse e-mail noreply. * **`NOTIFY_COOLDOWN_SECONDS`** — le nombre minimum de secondes entre deux e-mails de notification *pour le même article*. La valeur par défaut est 3 600 (une heure). Cela évite que votre boîte soit inondée quand un article devient viral. * **`SITE_URL`** — votre domaine public, utilisé pour construire les liens cliquables dans l'e-mail de notification et pour valider les origines CORS. **Pensez à remplacer cette valeur par la vôtre.** > **Utiliser un fichier .env:** > > Ne codez pas en dur votre e-mail, votre token, etc. dans le fichier PHP. Ajoutez le fichier `api/.env` à côté du fichier `reactions.php` et remplacez les valeurs d'exemple par les vôtres. > > **api/.env** > > ``` > ADMIN_EMAIL=your_email@your_domain > ADMIN_TOKEN=change-me-to-a-long-random-string > NO_REPLY=noreply@your_domain > IP_HASH_SALT=change-me-to-another-long-random-string > NONCE_SECRET=change-me-to-yet-another-long-random-string > ``` ### 1.2 — CORS {#12--cors} Un mot sur le CORS : dans ce fichier `api/reactions.php`, vous trouverez ce snippet : ```php title="api/reactions.php" $allowedOrigins = [ SITE_URL, 'https://localhost:3000', ]; $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; if (!in_array($origin, $allowedOrigins, true)) { http_response_code(403); exit; } header('Content-Type: application/json; charset=utf-8'); header("Access-Control-Allow-Origin: $origin"); header('Access-Control-Allow-Methods: GET, POST, OPTIONS'); header('Access-Control-Allow-Headers: Content-Type'); header('Vary: Origin'); if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; } ``` Le bloc CORS fait deux choses. D'abord, il rejette toute requête qui ne provient pas de votre site ou de `localhost:3000` (votre serveur de développement Docusaurus local). Sans ce contrôle, n'importe qui pourrait envoyer de faux votes en POST depuis n'importe quelle origine. Le contrôle est strict (`in_array` avec le troisième argument `true`) : pas de correspondance partielle, pas de joker. Ensuite, il renvoie l'origine validée dans l'en-tête `Access-Control-Allow-Origin`. C'est plus correct que l'approche avec le joker `*` parce que cela fonctionne avec les credentials et indique explicitement aux navigateurs quelle origine est autorisée — et l'en-tête `Vary: Origin` garantit que les caches respectent les réponses par origine. La branche `OPTIONS` gère le preflight du navigateur qui précède tout POST cross-origin avec un en-tête `Content-Type: application/json`. > **Pourquoi localhost:3000 ?:** > > Pendant le développement, `yarn start` sert Docusaurus sur `https://localhost:3000`. Sans l'ajouter à la liste des origines autorisées, chaque vote depuis votre environnement de dev serait silencieusement rejeté par le navigateur avant même d'atteindre le serveur. ### 1.3 — Notification par e-mail avec limitation par article {#13--email-notification-with-per-article-throttling} Un mot sur la notification par e-mail : dans ce fichier `api/reactions.php`, vous trouverez ce snippet : ```php title="api/reactions.php" function maybeNotify(string $slug, string $vote, array $counts): void { $throttleFile = __DIR__ . '/notifications.json'; $throttle = loadData($throttleFile); if (time() - ($throttle[$slug] ?? 0) < NOTIFY_COOLDOWN_SECONDS) { return; } $articleUrl = SITE_URL . '/' . $slug; $dashboardUrl = SITE_URL . '/reactions-dashboard'; $total = $counts['helpful'] + $counts['not_helpful']; $ratio = $total > 0 ? round($counts['helpful'] / $total * 100) : 0; $subject = "[Blog] New reaction on: $slug"; $body = implode("\n", [ "A reader just reacted to one of your blog posts.", "", "Article : $articleUrl", "Vote : $vote", "Helpful : {$counts['helpful']}", "Not helpful : {$counts['not_helpful']}", "Approval : {$ratio}%", "", "View full dashboard: $dashboardUrl", ]); $headers = implode("\r\n", [ "From: " . NO_REPLY, "Reply-To: " . NO_REPLY, "Content-Type: text/plain; charset=utf-8", ]); if (@mail(ADMIN_EMAIL, $subject, $body, $headers)) { $throttle[$slug] = time(); saveData($throttleFile, $throttle); } } ``` La ligne clé est le contrôle de limitation : `time() - ($throttle[$slug] ?? 0) < NOTIFY_COOLDOWN_SECONDS`. Si le dernier e-mail pour cet article a été envoyé il y a moins de `NOTIFY_COOLDOWN_SECONDS`, la fonction retourne immédiatement sans rien envoyer. Le timestamp de limitation n'est enregistré que si `mail()` réussit vraiment, donc un envoi raté ne bloque pas la tentative suivante. L'e-mail de notification inclut le type de vote, les totaux courants et le taux d'approbation — assez de contexte pour décider si un article mérite attention sans devoir ouvrir le tableau de bord. > **Adaptez le cooldown à votre trafic:** > > Sur un blog à faible trafic, un cooldown d'une heure est raisonnable. Si vous finissez un jour sur un agrégateur à fort trafic, envisagez de passer temporairement `NOTIFY_COOLDOWN_SECONDS` à `86400` (24 heures). Les votes ne sont jamais perdus, vous recevez simplement moins d'e-mails à leur sujet. ### 1.4 — Routage : GET et POST {#14--routing-get-and-post} ```php title="api/reactions.php" $method = $_SERVER['REQUEST_METHOD']; $slug = ''; $vote = ''; if ($method === 'GET') { // Admin request: return all reaction data if (array_key_exists('admin', $_GET)) { if (($_GET['admin'] ?? '') !== ADMIN_TOKEN) { jsonError(403, 'Forbidden'); } echo json_encode(loadData(__DIR__ . '/reactions-data.json')); exit; } $slug = sanitizeSlug($_GET['slug'] ?? ''); } elseif ($method === 'POST') { $body = json_decode(file_get_contents('php://input'), true) ?? []; $slug = sanitizeSlug($body['slug'] ?? ''); $vote = $body['vote'] ?? ''; } else { jsonError(405, 'Method not allowed'); } if ($slug === '') { jsonError(400, 'Missing slug'); } ``` La branche GET gère deux cas. Quand le paramètre de query `admin` est présent, le script le compare à `ADMIN_TOKEN`. Un token correspondant renvoie le contenu complet de `reactions-data.json` — les données consommées par le tableau de bord. Un token qui ne correspond pas renvoie un `403 Forbidden`. Quand `admin` est absent, le script lit le paramètre de query `slug`. C'est l'endpoint public que le composant React appelle au chargement de la page pour obtenir les compteurs de votes d'un article donné. La branche POST lit le corps de la requête en JSON (le composant React envoie `Content-Type: application/json`). Elle extrait `slug` et `vote`. Les deux passent par une validation dans la section suivante. ```php title="api/reactions.php" $dataFile = __DIR__ . '/reactions-data.json'; $data = loadData($dataFile); if (!isset($data[$slug])) { $data[$slug] = ['helpful' => 0, 'not_helpful' => 0]; } if ($method === 'POST') { if ($vote === 'helpful') { $data[$slug]['helpful']++; } elseif ($vote === 'not_helpful') { $data[$slug]['not_helpful']++; } else { jsonError(400, 'Invalid vote value'); } saveData($dataFile, $data); maybeNotify($slug, $vote, $data[$slug]); } echo json_encode($data[$slug]); ``` Au premier accès, l'entrée du slug est créée avec des compteurs à zéro. La branche POST incrémente le bon compteur, enregistre le fichier et déclenche la notification limitée. Toute valeur de `vote` autre que `helpful` ou `not_helpful` est rejetée immédiatement. La dernière ligne renvoie les compteurs (mis à jour ou non) pour ce slug, quelle que soit la méthode. *** ## Étape 2 — Le composant React {#step-2--the-react-component} Créez le dossier `src/components/Reaction/` et le fichier `index.tsx` à l'intérieur. **src/components/Reaction/index.tsx** ```typescript import { useState, useEffect, useCallback, type JSX } from "react"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import useBaseUrl from "@docusaurus/useBaseUrl"; import styles from "./styles.module.css"; import Translate, { translate } from "@docusaurus/Translate"; interface Props { metadata?: { permalink?: string; }; } interface Counts { helpful: number; not_helpful: number; } export default function Reaction({ metadata }: Props): JSX.Element | null { const { siteConfig } = useDocusaurusContext(); const slug = metadata?.permalink?.replace(/^\/|\/$/g, "") ?? ""; const apiUrl = `${siteConfig.url}/api/reactions.php`; const storageKey = `reaction_${slug}`; const mascotYesUrl = useBaseUrl("/img/reaction-mascot-yes.webp"); const mascotNoUrl = useBaseUrl("/img/reaction-mascot-no.webp"); const [counts, setCounts] = useState(null); // null until the client-side effect runs — avoids SSR/hydration mismatch (#418). // If voted were read in useState(), SSR would produce null (no localStorage) // while the client would produce "helpful", causing a React tree mismatch. const [voted, setVoted] = useState(null); const [submitError, setSubmitError] = useState(false); useEffect(() => { try { const stored = localStorage.getItem(storageKey); // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional SSR/hydration-safe read, see comment on `voted` above if (stored) setVoted(stored); } catch {} }, [storageKey]); useEffect(() => { if (!slug) return; fetch(`${apiUrl}?slug=${encodeURIComponent(slug)}`) .then((r) => (r.ok ? r.json() : null)) .then((data) => { if (data) setCounts(data); }) .catch(() => {}); }, [slug, apiUrl]); const handleVote = useCallback( async (vote: string) => { setSubmitError(false); try { const res = await fetch(apiUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slug, vote }), }); if (!res.ok) { setSubmitError(true); return; } const data = await res.json(); setCounts(data); setVoted(vote); try { localStorage.setItem(storageKey, vote); } catch {} } catch { setSubmitError(true); } }, [slug, apiUrl, storageKey], ); if (!slug) return null; return (
{!voted ? ( <> Was this article helpful?
{submitError && ( Could not save your vote — please try again. )} ) : (
{voted === "helpful" ? translate({ id: "reaction.thanksHelpful", message: "Glad it helped! 🙌" }) : translate({ id: "reaction.thanksNotHelpful", message: "Thanks for the feedback!", })} {counts && ( 👍 {counts.helpful} 👎 {counts.not_helpful} )}
)}
); } ``` ### 2.1 — Imports et mise en place {#21--imports-and-setup} ```tsx title="src/components/Reaction/index.tsx" import { useState, useEffect, useCallback, type JSX } from "react"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import styles from "./styles.module.css"; interface Props { metadata?: { permalink?: string; }; } interface Counts { helpful: number; not_helpful: number; } export default function Reaction({ metadata }: Props): JSX.Element | null { const { siteConfig } = useDocusaurusContext(); const slug = metadata?.permalink?.replace(/^\/|\/$/g, "") ?? ""; const apiUrl = `${siteConfig.url}/api/reactions.php`; const storageKey = `reaction_${slug}`; ``` `metadata` est l'objet que Docusaurus passe à chaque composant d'article de blog. Nous utilisons `metadata.permalink` — le path de l'URL de l'article — comme slug. Les deux appels à `replace` retirent les slashes de début et de fin, pour que le slug corresponde à ce que produit `sanitizeSlug` côté PHP : `blog/my-article`, et non `/blog/my-article/`. L'interface `Props` n'a besoin que de `metadata.permalink` ; tout le reste de ce que passe Docusaurus est ignoré. `Counts` est la forme `{ helpful, not_helpful }` que renvoie l'endpoint PHP — réutilisée plus bas pour l'état `counts`. `siteConfig.url` est votre URL de production issue de `docusaurus.config.js`. Le composant construit l'URL de l'API à partir d'elle dynamiquement, ce qui signifie que cela fonctionne aussi correctement en développement (où `url` pointera vers `https://localhost:3000` si vous le configurez ainsi, mais en pratique le composant n'est monté qu'après la résolution de la config du site par Docusaurus). `storageKey` donne à chaque article sa propre entrée dans `localStorage` (`reaction_blog-my-article`), ce qui empêche qu'un vote sur un article affecte un autre. ### 2.2 — L'état {#22--state} ```tsx title="src/components/Reaction/index.tsx" const [counts, setCounts] = useState(null); const [voted, setVoted] = useState(null); const [submitError, setSubmitError] = useState(false); useEffect(() => { try { const stored = localStorage.getItem(storageKey); if (stored) setVoted(stored); } catch {} }, [storageKey]); ``` `counts` contient l'objet `{ helpful, not_helpful }` renvoyé par l'API. Il démarre à `null` (pas encore chargé). `submitError` est un drapeau activé quand un POST de vote échoue, pour que le rendu affiche un message de réessai. `voted` contient le vote précédent pour cet article. Il démarre à `null` et n'est rempli qu'**après le montage**, dans un `useEffect` qui lit `localStorage`. Le `try/catch` est là parce que `localStorage` peut lever une exception en navigation privée ou quand le stockage est plein. > **Pourquoi ne pas lire localStorage dans useState ?:** > > Docusaurus fait un rendu serveur de chaque page. Si `voted` était initialisé depuis `localStorage` dans un initialiseur `useState`, le serveur produirait `null` (pas de `localStorage` là-bas) alors que le navigateur produirait le `"helpful"` stocké — les deux arbres HTML divergent, et React lève une erreur de hydration mismatch. Reporter la lecture dans `useEffect` garde le premier rendu client identique à celui du serveur, puis met à jour. Tout ce qui est en dehors de `useState`, `useEffect` ou d'un gestionnaire d'événement a toujours besoin d'une garde `typeof window !== "undefined"`. ### 2.3 — Charger les compteurs au montage {#23--loading-counts-on-mount} ```tsx title="src/components/Reaction/index.tsx" useEffect(() => { if (!slug) return; fetch(`${apiUrl}?slug=${encodeURIComponent(slug)}`) .then((r) => (r.ok ? r.json() : null)) .then((data) => { if (data) setCounts(data); }) .catch(() => {}); }, [slug, apiUrl]); ``` Au montage, le composant récupère les compteurs actuels pour cet article. L'appel à `encodeURIComponent` garantit que le slug est sûr dans une query string d'URL. Si la requête échoue pour une raison quelconque (erreur réseau, serveur hors service, statut non-OK), l'erreur est avalée silencieusement — le widget reste simplement dans son état initial, ce qui convient très bien. ### 2.4 — Envoyer un vote {#24--sending-a-vote} ```tsx title="src/components/Reaction/index.tsx" const handleVote = useCallback( async (vote: string) => { setSubmitError(false); try { const res = await fetch(apiUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slug, vote }), }); if (!res.ok) { setSubmitError(true); return; } const data = await res.json(); setCounts(data); setVoted(vote); try { localStorage.setItem(storageKey, vote); } catch {} } catch { setSubmitError(true); } }, [slug, apiUrl, storageKey], ); ``` `handleVote` envoie `{ slug, vote }` en POST au format JSON. En cas de succès, il met à jour les compteurs affichés avec les valeurs fraîches renvoyées par le serveur, définit `voted` sur la valeur choisie (ce qui fait passer l'interface des « boutons de vote » à l'état « merci ») et enregistre le vote dans `localStorage`. Tout échec — une réponse non-OK ou un `fetch` qui lève une exception — active `submitError`, qui fait apparaître le message de réessai dans le rendu ci-dessous ; les échecs d'écriture dans `localStorage` sont avalés puisque le vote lui-même est déjà passé. `useCallback` mémoïse la fonction pour qu'elle ne soit pas recréée à chaque rendu, ce qui est important puisqu'elle est passée en prop `onClick`. ### 2.5 — Le rendu {#25--rendering} ```tsx title="src/components/Reaction/index.tsx" if (!slug) return null; return (
{!voted ? ( <> Was this article helpful?
{submitError && ( Could not save your vote — please try again. )} ) : (
{voted === "helpful" ? "Glad it helped! 🙌" : "Thanks for the feedback!"} {counts && ( 👍 {counts.helpful} 👎 {counts.not_helpful} )}
)}
); } ``` Le rendu a deux états. Avant le vote (`!voted`) : la question, deux boutons et — uniquement quand `submitError` est activé — un message de réessai en dessous. Après le vote (`voted` est défini) : un message de remerciement personnalisé selon le choix, plus les compteurs de votes — désormais visibles puisque le lecteur a exprimé son opinion. Les attributs `aria-label` rendent les boutons accessibles aux lecteurs d'écran : les libellés composés uniquement d'emojis comme « 👍 Helpful » ne sont pas toujours lus correctement par les lecteurs d'écran, un libellé explicite aide donc. Les interfaces `Props` et `Counts` en haut constituent tout le contrat : le compilateur signale une forme de `metadata` incorrecte ou un champ de compteur mal utilisé au moment du build, il n'y a donc pas de vérification `PropTypes` à ajouter à l'exécution. *** ## Étape 3 — Le module CSS {#step-3--the-css-module} Créez le fichier `styles.module.css` dans `src/components/Reaction/`. **src/components/Reaction/styles.module.css** ```css /* * ⚠️ DUPLICATION — Reaction/styles.module.css and TriedIt/styles.module.css are * the SAME FILE, byte for byte, except that Reaction adds .submitError. Two * components, one widget: a question, a row of buttons, a thank-you state and * a count. They were tokenised identically here so they stay in step, but the * real fix is to have one of them import the other's stylesheet (CSS modules * support `composes: … from "../Reaction/styles.module.css"`), or to extract a * shared Feedback component. Flagged, not done: that is a refactor decision, * not a styling one. * * Fixed while tokenising: the hover filled the button with solid primary and * set `color: white` — a literal, and the only white text on terracotta in * this part of the site. It now uses --mk-on-line, which flips with the theme * (in dark mode the primary lightens, so white-on-it fails contrast). */ .container { display: flex; align-items: center; flex-wrap: wrap; gap: 0.75rem; padding: 0.9rem 1.25rem; margin: 1.5rem 0 0.5rem; border: 1px solid var(--mk-border); border-radius: var(--mk-radius-md); background: var(--mk-surface); box-shadow: var(--mk-shadow-1); } .question { font-weight: 600; color: var(--mk-ink); white-space: nowrap; } .buttons { display: flex; gap: 0.5rem; flex-wrap: wrap; } .btn { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.35rem 0.85rem; font-family: inherit; border: 1px solid var(--mk-border-strong); border-radius: var(--mk-radius-sm); background: transparent; color: var(--mk-ink); font-size: 0.875rem; cursor: pointer; transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease; } .mascot { width: 1.5rem; height: 1.5rem; object-fit: contain; } .btn:hover { background-color: var(--mk-primary); border-color: var(--mk-primary); color: var(--mk-on-line); } .btn:focus-visible { outline: 2px solid var(--mk-primary); outline-offset: 2px; } .btnNeutral:hover { background-color: var(--mk-n-300); border-color: var(--mk-n-300); color: var(--mk-n-900); } .thanks { display: flex; align-items: center; flex-wrap: wrap; gap: 1rem; } .thanksMsg { font-weight: 600; color: var(--mk-ink); } .counts { display: flex; gap: 0.75rem; font-size: 0.875rem; color: var(--mk-ink-soft); font-variant-numeric: tabular-nums; } .submitError { font-size: 0.8rem; color: var(--mk-danger-line); width: 100%; } ``` Le `container` extérieur est une ligne flex avec retour à la ligne, il se replie donc proprement sur les écrans étroits. Les couleurs utilisent les propriétés CSS personnalisées de Docusaurus (`--ifm-*`), ce qui signifie que le widget s'adapte automatiquement au mode clair, au mode sombre et à n'importe quel thème Docusaurus personnalisé sans écrire une seule media query ni une règle dupliquée. Le bouton « Helpful » prend la couleur primaire du thème au survol ; le bouton « Not really » prend un gris neutre. La transition de 0,15 s rend le survol réactif sans être tape-à-l'œil. *** ## Étape 4 — Injecter le widget via le swizzling {#step-4--injecting-the-widget-via-swizzling} Le **swizzling** est le mécanisme de Docusaurus pour remplacer un composant de thème intégré par votre propre version. Nous voulons injecter `` en bas de chaque page d'article de blog — et le bon endroit pour cela est `BlogPostItem`, le composant qui englobe le contenu et le pied de page de chaque article. ### 4.1 — Lancer la commande de swizzle {#41--run-the-swizzle-command} ```bash yarn run swizzle @docusaurus/theme-classic BlogPostItem --wrap ``` > **Wrap ou Eject:** > > Le flag `--wrap` crée un fin wrapper autour du composant d'origine plutôt que de copier tout le source. Cependant, pour `BlogPostItem`, nous voulons un contrôle fin sur la mise en page (pour placer `` exactement où nous le souhaitons, entre le contenu et le pied de page). En pratique, vous allez probablement ejecter, pas wrapper. Lancez la commande sans `--wrap` et Docusaurus vous demandera de confirmer ; choisissez **Eject**. Cela crée `src/theme/BlogPostItem/index.js` avec le source d'origine. Ouvrez-le. ### 4.2 — Modifier le fichier swizzlé {#42--modify-the-swizzled-file} Vous devez faire trois changements : importer le composant, détecter si nous sommes sur une page d'article unique, et afficher `` conditionnellement. Voici la partie concernée : ```javascript title="src/theme/BlogPostItem/index.js" {1,8,17-23} import Reaction from "@site/src/components/Reaction"; 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"; function useContainerClassName() { const { isBlogPostPage } = useBlogPost(); return !isBlogPostPage ? "margin-bottom--xl" : undefined; } export default function BlogPostItem({ children, className }) { const { metadata, isBlogPostPage } = useBlogPost(); const containerClassName = useContainerClassName(); return ( {children} {isBlogPostPage && ( )} ); } ``` La garde clé est `isBlogPostPage`. Docusaurus affiche `BlogPostItem` dans deux contextes : 1. **Les pages de liste du blog** — où les cartes d'articles sont affichées en grille ou en flux. 2. **Les pages d'article unique** — où l'article complet est affiché. Sans cette garde, un widget de réaction apparaîtrait sous chaque carte d'article sur la page d'accueil du blog, ce qui n'est pas ce que nous voulons. `isBlogPostPage` vaut `true` uniquement quand vous lisez un article seul, le widget apparaît donc exactement au bon endroit. > **Le placement compte:** > > Si vous avez d'autres composants personnalisés (un bouton de partage Bluesky, des [articles liés](/blog/docusaurus-relatedposts), etc.), l'ordre ici définit l'ordre visuel en bas de chaque article. Ajustez selon vos préférences. ### 4.3 — Le fichier swizzlé complet SUR MON SITE {#43--the-complete-swizzled-file-on-my-site} > **:** > > Ci-dessous se trouve le contenu du fichier index.js sur mon blog. Je le partage comme exemple et pour comparaison, puisque j'utilise bien plus de composants React que le seul que nous venons de voir dans cet article. **src/theme/BlogPostItem/index.js** ```javascript import ArticleActions from "@site/src/components/Blog/ArticleActions"; import PropTypes from "prop-types"; 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 Reaction from "@site/src/components/Reaction"; import TriedIt from "@site/src/components/TriedIt"; import TypoReport from "@site/src/components/TypoReport"; import clsx from "clsx"; // Our posts components import RelatedPosts from "@site/src/components/Blog/RelatedPosts"; // Locale awareness — both render nothing on the English site unless a translation exists. import TranslationNotice from "@site/src/components/Blog/TranslationNotice"; // Our Bluesky component import Bluesky from "@site/src/components/Bluesky"; // 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 { frontMatter } = metadata; const containerClassName = useContainerClassName(); // These actions only make sense for the article the reader is actually on, // never for a card in a list view. const actions = isBlogPostPage ? : null; return ( <> {/* Above the content, never inside the translated Markdown file: the banner must not be something the translator can mangle, and the flag must be reachable before the reader has scrolled through prose they may not want to read. */} {/* The flag badge used to sit here too. Removed on 2026-09-16: the sentence below says the same thing in words, and a badge that duplicates it is noise at the very place a reader is trying to start the article. */} {isBlogPostPage && } {children} {/* Only display our RelatedPosts and Bluesky components on the post page; not the blog view */} {isBlogPostPage && ( <> {/* Bluesky comes first: it and RelatedPosts are both exit points (off-site vs. another post), and whichever renders first wins the click of a reader who just finished and is ready to act. Bluesky engagement is the current priority ask. */} {/* Readers who reach the end of an article should meet the next one to read before the reaction, comment and typo report blocks. Buried under those widgets, these links were never seen. */} {frontMatter.tried_it !== false && } )} ); } BlogPostItem.propTypes = { children: PropTypes.node, className: PropTypes.string, }; ``` *** ## Étape 5 — Le tableau de bord d'administration {#step-5--the-admin-dashboard} Le tableau de bord est une page Docusaurus classique (pas un article de blog) qui appelle l'endpoint d'administration de `reactions.php` et présente les données dans un tableau avec des barres d'approbation par article. Créez `src/pages/reactions-dashboard.js` avec ce contenu : **src/pages/reactions-dashboard.js** ```javascript import { useState, useEffect, useCallback } from "react"; import PropTypes from "prop-types"; import Layout from "@theme/Layout"; import Head from "@docusaurus/Head"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import styles from "./reactions-dashboard.module.css"; // ── Data helpers ───────────────────────────────────────────────────────────── function computeTotals(data) { let totalHelpful = 0; let totalNot = 0; const rows = Object.entries(data) .map(([slug, counts]) => { const helpful = counts.helpful ?? 0; const notHelpful = counts.not_helpful ?? 0; const total = helpful + notHelpful; const ratio = total > 0 ? Math.round((helpful / total) * 100) : 0; totalHelpful += helpful; totalNot += notHelpful; return { slug, helpful, notHelpful, total, ratio }; }) .sort((a, b) => b.helpful - a.helpful || b.total - a.total); const grandTotal = totalHelpful + totalNot; const approval = grandTotal > 0 ? Math.round((totalHelpful / grandTotal) * 100) : 0; return { rows, totalHelpful, totalNot, grandTotal, approval }; } // ── Sub-components ─────────────────────────────────────────────────────────── function SummaryCards({ totalHelpful, totalNot, grandTotal, approval }) { return (
{grandTotal}
Total votes
👍 {totalHelpful}
Helpful
👎 {totalNot}
Not helpful
{approval}%
Approval rate
); } SummaryCards.propTypes = { totalHelpful: PropTypes.number.isRequired, totalNot: PropTypes.number.isRequired, grandTotal: PropTypes.number.isRequired, approval: PropTypes.number.isRequired, }; function ApprovalBar({ ratio }) { return (
{ratio}%
); } ApprovalBar.propTypes = { ratio: PropTypes.number.isRequired, }; function ReactionsTable({ rows, siteUrl }) { if (rows.length === 0) { return

No reactions recorded yet.

; } return ( {rows.map(({ slug, helpful, notHelpful, total, ratio }) => ( ))}
Article 👍 👎 Total Approval
{slug} {helpful} {notHelpful} {total}
); } ReactionsTable.propTypes = { rows: PropTypes.arrayOf( PropTypes.shape({ slug: PropTypes.string.isRequired, helpful: PropTypes.number.isRequired, notHelpful: PropTypes.number.isRequired, total: PropTypes.number.isRequired, ratio: PropTypes.number.isRequired, }), ).isRequired, siteUrl: PropTypes.string.isRequired, }; function AuthForm({ onSubmit }) { const [token, setToken] = useState(""); return (

Enter your admin token to view the dashboard.

{ e.preventDefault(); onSubmit(token.trim()); }} > setToken(e.target.value)} autoFocus />
); } AuthForm.propTypes = { onSubmit: PropTypes.func.isRequired, }; const STORAGE_KEY = "reactions_admin_token"; // ── Page ───────────────────────────────────────────────────────────────────── export default function ReactionsDashboard() { const { siteConfig } = useDocusaurusContext(); const apiUrl = `${siteConfig.url}/api/reactions.php`; const [token, setToken] = useState(""); const [data, setData] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); // Priority: URL hash → localStorage → empty (show form). // Must run in useEffect — localStorage is unavailable during SSR, // and React does not re-run useState initializers on client hydration. useEffect(() => { const initial = window.location.hash.slice(1) || localStorage.getItem(STORAGE_KEY) || ""; // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional SSR/hydration-safe read, see comment above if (initial) setToken(initial); }, []); const fetchData = useCallback( async (t) => { if (!t) return; setLoading(true); setError(null); try { const res = await fetch(`${apiUrl}?admin=${encodeURIComponent(t)}`); if (res.status === 403) { localStorage.removeItem(STORAGE_KEY); setError("Invalid token."); setData(null); return; } if (!res.ok) throw new Error(`HTTP ${res.status}`); localStorage.setItem(STORAGE_KEY, t); setData(await res.json()); } catch (e) { setError(`Failed to load data: ${e.message}`); setData(null); } finally { setLoading(false); } }, [apiUrl], ); // Reactive sync: refetch whenever the token changes (login, restore from storage, hash link). useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- fetchData sets state, but this is a legitimate reaction to `token` changing, not derivable at render time if (token) fetchData(token); }, [token, fetchData]); const handleTokenSubmit = (t) => { setToken(t); fetchData(t); }; const handleForget = () => { localStorage.removeItem(STORAGE_KEY); setToken(""); setData(null); setError(null); }; const stats = data ? computeTotals(data) : null; return (

Reactions Dashboard

Per-article reader feedback — sorted by helpful count

{!token && } {token && loading &&

Loading…

} {token && error &&

{error}

} {stats && ( <> )}
); } ``` ### 5.1 — Authentification {#51--authentication} La page lit le token d'administration depuis le hash de l'URL (`/reactions-dashboard#your-token`). S'il n'y a pas de hash, elle affiche un formulaire pour saisir le token manuellement. Le token n'est jamais envoyé dans le path de l'URL ni stocké dans `localStorage`. ```javascript title="src/pages/reactions-dashboard.js" const [token, setToken] = useState(() => { if (typeof window === "undefined") return ""; return window.location.hash.slice(1); }); ``` Passer le token dans le hash est un choix pratique : le hash n'est jamais envoyé au serveur dans la requête HTTP, il n'apparaît donc pas dans les logs du serveur, et vous pouvez mettre l'URL en favori. Saisissez votre `ADMIN_TOKEN` pour vous connecter : > Screenshot — https\://www\.avonture.be/reactions-dashboard/ > > The reaction dashboard login page ### 5.2 — Le chargement des données {#52--data-loading} ```javascript title="src/pages/reactions-dashboard.js" const fetchData = useCallback(async (t) => { if (!t) return; setLoading(true); setError(null); try { const res = await fetch(`${apiUrl}?admin=${encodeURIComponent(t)}`); if (res.status === 403) { setError("Invalid token."); setData(null); return; } if (!res.ok) throw new Error(`HTTP ${res.status}`); setData(await res.json()); } catch (e) { setError(`Failed to load data: ${e.message}`); setData(null); } finally { setLoading(false); } }, [apiUrl]); ``` Un `403` signifie que le token est incorrect et donne un message clair « Invalid token. ». Tout autre statut non-OK est traité comme une erreur générique. Le bloc `finally` remet toujours l'état de chargement à zéro. ### 5.3 — Agrégation {#53--aggregation} ```javascript title="src/pages/reactions-dashboard.js" function computeTotals(data) { let totalHelpful = 0; let totalNot = 0; const rows = Object.entries(data) .map(([slug, counts]) => { const helpful = counts.helpful ?? 0; const notHelpful = counts.not_helpful ?? 0; const total = helpful + notHelpful; const ratio = total > 0 ? Math.round((helpful / total) * 100) : 0; totalHelpful += helpful; totalNot += notHelpful; return { slug, helpful, notHelpful, total, ratio }; }) .sort((a, b) => b.helpful - a.helpful || b.total - a.total); const grandTotal = totalHelpful + totalNot; const approval = grandTotal > 0 ? Math.round((totalHelpful / grandTotal) * 100) : 0; return { rows, totalHelpful, totalNot, grandTotal, approval }; } ``` `computeTotals` parcourt chaque article de `reactions-data.json`, calcule un taux d'approbation par article et trie par nombre de votes « helpful » décroissant (les égalités étant départagées par le total des votes). Les totaux généraux à la fin alimentent les cartes de synthèse en haut de la page. Et voici ce que vous pouvez obtenir : > Screenshot — https\://www\.avonture.be/reactions-dashboard/ > > The reaction dashboard *** ## Tous les fichiers en un coup d'œil {#all-files-at-a-glance} Voici l'ensemble complet des fichiers concernés, regroupés pour une installation facile (affichés avec mon [code snippets component](/blog/docusaurus-snippets)). > **... sauf src/theme/BlogPostItem/index.js:** > > Dans la liste ci-dessous, je n'ai pas inclus `src/theme/BlogPostItem/index.js` parce que, comme dit, ce fichier sera différent entre mon blog et le vôtre. Reportez-vous à l'**Étape 4** et créez le fichier manuellement. **Project: Reaction widget** **api/.env** ``` ADMIN_EMAIL=your_email@your_domain ADMIN_TOKEN=change-me-to-a-long-random-string NO_REPLY=noreply@your_domain IP_HASH_SALT=change-me-to-another-long-random-string NONCE_SECRET=change-me-to-yet-another-long-random-string ``` **api/reactions.php** ```php $message]); exit; } // Sends an email notification for a new vote, with a per-article cooldown. function maybeNotify(string $slug, string $vote, array $counts): void { $throttleFile = __DIR__ . '/notifications.json'; $throttle = loadData($throttleFile); if (time() - ($throttle[$slug] ?? 0) < NOTIFY_COOLDOWN_SECONDS) { return; } $articleUrl = SITE_URL . '/' . $slug; $dashboardUrl = SITE_URL . '/reactions-dashboard'; $total = $counts['helpful'] + $counts['not_helpful']; $ratio = $total > 0 ? round($counts['helpful'] / $total * 100) : 0; $subject = "[Blog] New reaction on: $slug"; $body = implode("\n", [ "A reader just reacted to one of your blog posts.", "", "Article : $articleUrl", "Vote : $vote", "Helpful : {$counts['helpful']}", "Not helpful : {$counts['not_helpful']}", "Approval : {$ratio}%", "", "View full dashboard: $dashboardUrl", ]); $headers = implode("\r\n", [ "From: " . NO_REPLY, "Reply-To: " . NO_REPLY, "Content-Type: text/plain; charset=utf-8", ]); if (@mail(ADMIN_EMAIL, $subject, $body, $headers)) { $throttle[$slug] = time(); saveData($throttleFile, $throttle); } } // ── Parse input ─────────────────────────────────────────────────────────────── $method = $_SERVER['REQUEST_METHOD']; $slug = ''; $vote = ''; if ($method === 'GET') { // Admin request: return all reaction data if (array_key_exists('admin', $_GET)) { if (ADMIN_TOKEN === '' || ($_GET['admin'] ?? '') !== ADMIN_TOKEN) { jsonError(403, 'Forbidden'); } echo json_encode(loadData(__DIR__ . '/reactions-data.json')); exit; } $slug = sanitizeSlug($_GET['slug'] ?? ''); } elseif ($method === 'POST') { $body = json_decode(file_get_contents('php://input'), true) ?? []; $slug = sanitizeSlug($body['slug'] ?? ''); $vote = $body['vote'] ?? ''; } else { jsonError(405, 'Method not allowed'); } if ($slug === '') { jsonError(400, 'Missing slug'); } // ── Load data ───────────────────────────────────────────────────────────────── $dataFile = __DIR__ . '/reactions-data.json'; $data = loadData($dataFile); if (!isset($data[$slug])) { $data[$slug] = ['helpful' => 0, 'not_helpful' => 0]; } // ── Record vote ─────────────────────────────────────────────────────────────── if ($method === 'POST') { if ($vote === 'helpful') { $data[$slug]['helpful']++; } elseif ($vote === 'not_helpful') { $data[$slug]['not_helpful']++; } else { jsonError(400, 'Invalid vote value'); } saveData($dataFile, $data); maybeNotify($slug, $vote, $data[$slug]); } echo json_encode($data[$slug]); ``` **src/components/Reaction/index.tsx** ```typescript import { useState, useEffect, useCallback, type JSX } from "react"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import useBaseUrl from "@docusaurus/useBaseUrl"; import styles from "./styles.module.css"; import Translate, { translate } from "@docusaurus/Translate"; interface Props { metadata?: { permalink?: string; }; } interface Counts { helpful: number; not_helpful: number; } export default function Reaction({ metadata }: Props): JSX.Element | null { const { siteConfig } = useDocusaurusContext(); const slug = metadata?.permalink?.replace(/^\/|\/$/g, "") ?? ""; const apiUrl = `${siteConfig.url}/api/reactions.php`; const storageKey = `reaction_${slug}`; const mascotYesUrl = useBaseUrl("/img/reaction-mascot-yes.webp"); const mascotNoUrl = useBaseUrl("/img/reaction-mascot-no.webp"); const [counts, setCounts] = useState(null); // null until the client-side effect runs — avoids SSR/hydration mismatch (#418). // If voted were read in useState(), SSR would produce null (no localStorage) // while the client would produce "helpful", causing a React tree mismatch. const [voted, setVoted] = useState(null); const [submitError, setSubmitError] = useState(false); useEffect(() => { try { const stored = localStorage.getItem(storageKey); // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional SSR/hydration-safe read, see comment on `voted` above if (stored) setVoted(stored); } catch {} }, [storageKey]); useEffect(() => { if (!slug) return; fetch(`${apiUrl}?slug=${encodeURIComponent(slug)}`) .then((r) => (r.ok ? r.json() : null)) .then((data) => { if (data) setCounts(data); }) .catch(() => {}); }, [slug, apiUrl]); const handleVote = useCallback( async (vote: string) => { setSubmitError(false); try { const res = await fetch(apiUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slug, vote }), }); if (!res.ok) { setSubmitError(true); return; } const data = await res.json(); setCounts(data); setVoted(vote); try { localStorage.setItem(storageKey, vote); } catch {} } catch { setSubmitError(true); } }, [slug, apiUrl, storageKey], ); if (!slug) return null; return (
{!voted ? ( <> Was this article helpful?
{submitError && ( Could not save your vote — please try again. )} ) : (
{voted === "helpful" ? translate({ id: "reaction.thanksHelpful", message: "Glad it helped! 🙌" }) : translate({ id: "reaction.thanksNotHelpful", message: "Thanks for the feedback!", })} {counts && ( 👍 {counts.helpful} 👎 {counts.not_helpful} )}
)}
); } ``` **src/components/Reaction/styles.module.css** ```css /* * ⚠️ DUPLICATION — Reaction/styles.module.css and TriedIt/styles.module.css are * the SAME FILE, byte for byte, except that Reaction adds .submitError. Two * components, one widget: a question, a row of buttons, a thank-you state and * a count. They were tokenised identically here so they stay in step, but the * real fix is to have one of them import the other's stylesheet (CSS modules * support `composes: … from "../Reaction/styles.module.css"`), or to extract a * shared Feedback component. Flagged, not done: that is a refactor decision, * not a styling one. * * Fixed while tokenising: the hover filled the button with solid primary and * set `color: white` — a literal, and the only white text on terracotta in * this part of the site. It now uses --mk-on-line, which flips with the theme * (in dark mode the primary lightens, so white-on-it fails contrast). */ .container { display: flex; align-items: center; flex-wrap: wrap; gap: 0.75rem; padding: 0.9rem 1.25rem; margin: 1.5rem 0 0.5rem; border: 1px solid var(--mk-border); border-radius: var(--mk-radius-md); background: var(--mk-surface); box-shadow: var(--mk-shadow-1); } .question { font-weight: 600; color: var(--mk-ink); white-space: nowrap; } .buttons { display: flex; gap: 0.5rem; flex-wrap: wrap; } .btn { display: inline-flex; align-items: center; gap: 0.35rem; padding: 0.35rem 0.85rem; font-family: inherit; border: 1px solid var(--mk-border-strong); border-radius: var(--mk-radius-sm); background: transparent; color: var(--mk-ink); font-size: 0.875rem; cursor: pointer; transition: background-color 0.15s ease, border-color 0.15s ease, color 0.15s ease; } .mascot { width: 1.5rem; height: 1.5rem; object-fit: contain; } .btn:hover { background-color: var(--mk-primary); border-color: var(--mk-primary); color: var(--mk-on-line); } .btn:focus-visible { outline: 2px solid var(--mk-primary); outline-offset: 2px; } .btnNeutral:hover { background-color: var(--mk-n-300); border-color: var(--mk-n-300); color: var(--mk-n-900); } .thanks { display: flex; align-items: center; flex-wrap: wrap; gap: 1rem; } .thanksMsg { font-weight: 600; color: var(--mk-ink); } .counts { display: flex; gap: 0.75rem; font-size: 0.875rem; color: var(--mk-ink-soft); font-variant-numeric: tabular-nums; } .submitError { font-size: 0.8rem; color: var(--mk-danger-line); width: 100%; } ``` **src/pages/reactions-dashboard.js** ```javascript import { useState, useEffect, useCallback } from "react"; import PropTypes from "prop-types"; import Layout from "@theme/Layout"; import Head from "@docusaurus/Head"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import styles from "./reactions-dashboard.module.css"; // ── Data helpers ───────────────────────────────────────────────────────────── function computeTotals(data) { let totalHelpful = 0; let totalNot = 0; const rows = Object.entries(data) .map(([slug, counts]) => { const helpful = counts.helpful ?? 0; const notHelpful = counts.not_helpful ?? 0; const total = helpful + notHelpful; const ratio = total > 0 ? Math.round((helpful / total) * 100) : 0; totalHelpful += helpful; totalNot += notHelpful; return { slug, helpful, notHelpful, total, ratio }; }) .sort((a, b) => b.helpful - a.helpful || b.total - a.total); const grandTotal = totalHelpful + totalNot; const approval = grandTotal > 0 ? Math.round((totalHelpful / grandTotal) * 100) : 0; return { rows, totalHelpful, totalNot, grandTotal, approval }; } // ── Sub-components ─────────────────────────────────────────────────────────── function SummaryCards({ totalHelpful, totalNot, grandTotal, approval }) { return (
{grandTotal}
Total votes
👍 {totalHelpful}
Helpful
👎 {totalNot}
Not helpful
{approval}%
Approval rate
); } SummaryCards.propTypes = { totalHelpful: PropTypes.number.isRequired, totalNot: PropTypes.number.isRequired, grandTotal: PropTypes.number.isRequired, approval: PropTypes.number.isRequired, }; function ApprovalBar({ ratio }) { return (
{ratio}%
); } ApprovalBar.propTypes = { ratio: PropTypes.number.isRequired, }; function ReactionsTable({ rows, siteUrl }) { if (rows.length === 0) { return

No reactions recorded yet.

; } return ( {rows.map(({ slug, helpful, notHelpful, total, ratio }) => ( ))}
Article 👍 👎 Total Approval
{slug} {helpful} {notHelpful} {total}
); } ReactionsTable.propTypes = { rows: PropTypes.arrayOf( PropTypes.shape({ slug: PropTypes.string.isRequired, helpful: PropTypes.number.isRequired, notHelpful: PropTypes.number.isRequired, total: PropTypes.number.isRequired, ratio: PropTypes.number.isRequired, }), ).isRequired, siteUrl: PropTypes.string.isRequired, }; function AuthForm({ onSubmit }) { const [token, setToken] = useState(""); return (

Enter your admin token to view the dashboard.

{ e.preventDefault(); onSubmit(token.trim()); }} > setToken(e.target.value)} autoFocus />
); } AuthForm.propTypes = { onSubmit: PropTypes.func.isRequired, }; const STORAGE_KEY = "reactions_admin_token"; // ── Page ───────────────────────────────────────────────────────────────────── export default function ReactionsDashboard() { const { siteConfig } = useDocusaurusContext(); const apiUrl = `${siteConfig.url}/api/reactions.php`; const [token, setToken] = useState(""); const [data, setData] = useState(null); const [error, setError] = useState(null); const [loading, setLoading] = useState(false); // Priority: URL hash → localStorage → empty (show form). // Must run in useEffect — localStorage is unavailable during SSR, // and React does not re-run useState initializers on client hydration. useEffect(() => { const initial = window.location.hash.slice(1) || localStorage.getItem(STORAGE_KEY) || ""; // eslint-disable-next-line react-hooks/set-state-in-effect -- intentional SSR/hydration-safe read, see comment above if (initial) setToken(initial); }, []); const fetchData = useCallback( async (t) => { if (!t) return; setLoading(true); setError(null); try { const res = await fetch(`${apiUrl}?admin=${encodeURIComponent(t)}`); if (res.status === 403) { localStorage.removeItem(STORAGE_KEY); setError("Invalid token."); setData(null); return; } if (!res.ok) throw new Error(`HTTP ${res.status}`); localStorage.setItem(STORAGE_KEY, t); setData(await res.json()); } catch (e) { setError(`Failed to load data: ${e.message}`); setData(null); } finally { setLoading(false); } }, [apiUrl], ); // Reactive sync: refetch whenever the token changes (login, restore from storage, hash link). useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- fetchData sets state, but this is a legitimate reaction to `token` changing, not derivable at render time if (token) fetchData(token); }, [token, fetchData]); const handleTokenSubmit = (t) => { setToken(t); fetchData(t); }; const handleForget = () => { localStorage.removeItem(STORAGE_KEY); setToken(""); setData(null); setError(null); }; const stats = data ? computeTotals(data) : null; return (

Reactions Dashboard

Per-article reader feedback — sorted by helpful count

{!token && } {token && loading &&

Loading…

} {token && error &&

{error}

} {stats && ( <> )}
); } ``` *** ## Considérations de sécurité {#security-considerations} Quelques points à garder en tête avant de déployer : **Changez le `ADMIN_TOKEN`.** L'exemple du repository utilise une valeur d'exemple. Générez la vôtre avec `openssl rand -base64 32` et mettez à jour la constante avant de déployer. Quiconque possède ce token peut lire tous les votes jamais enregistrés. **`reactions-data.json` et `notifications.json` ne doivent pas être accessibles publiquement.** Si votre serveur web sert directement le dossier `api/`, assurez-vous que ces fichiers JSON ne soient pas téléchargeables en y naviguant simplement. Ajoutez une règle `.htaccess` pour bloquer l'accès direct aux fichiers `*.json` dans ce répertoire : ```apacheconf title="api/.htaccess" Require all denied ``` **Le script PHP n'authentifie pas les votants.** N'importe qui peut envoyer plusieurs votes pour le même article depuis différents clients. `localStorage` empêche seulement le double vote depuis le même navigateur, pas depuis des scripts ou d'autres appareils. Pour un blog personnel, c'est un compromis acceptable — vous obtenez un signal, pas des données de sondage précises. **Permissions des fichiers.** Assurez-vous que `api/reactions-data.json` et `api/notifications.json` soient accessibles en écriture par l'utilisateur du serveur web. Le script les crée à la première utilisation, donc le répertoire `api/` lui-même doit être accessible en écriture. Sous Linux : `chmod 775 api/` et vérifiez que l'utilisateur du serveur web est propriétaire de ce dossier ou fait partie de son groupe. *** ## Le tester {#testing-it} Une fois les fichiers en place : 1. **Démarrez le serveur de développement Docusaurus** : `yarn start` 2. Ouvrez n'importe quel article de blog. Vous devriez voir le widget « Was this article helpful? » en bas. 3. Cliquez sur l'un des boutons. Le widget devrait passer à l'état de remerciement. 4. Rechargez la page. Le widget devrait toujours afficher l'état de remerciement (chargé depuis `localStorage`). 5. Ouvrez un autre navigateur (ou une fenêtre de navigation privée) et rechargez le même article. Les compteurs de votes de l'étape 3 devraient être visibles après avoir voté. 6. Rendez-vous sur `/reactions-dashboard` et saisissez votre token d'administration. L'article pour lequel vous venez de voter devrait apparaître dans le tableau. > **L'URL du tableau de bord:** > > Vous pouvez mettre `/reactions-dashboard#your-token` en favori pour ouvrir le tableau de bord directement. Le token dans le fragment de hash n'est jamais envoyé au serveur dans la requête HTTP, il n'apparaît donc pas dans les logs d'accès du serveur. *** ## Conclusion {#conclusion} L'ensemble du système représente environ 250 lignes de code réparties sur cinq fichiers, n'a aucune dépendance externe au-delà de ce que Docusaurus fournit déjà, et ne demande rien côté infrastructure hormis un serveur capable d'exécuter PHP. Ce que je préfère dans cette approche, c'est que chaque octet de données vit dans vos propres fichiers, sous votre propre contrôle. Les votes sont stockés dans un simple fichier JSON que vous pouvez ouvrir avec n'importe quel éditeur de texte, sauvegarder avec n'importe quel outil et migrer vers n'importe quel autre système quand vous le voulez. Pas d'abonnement SaaS à résilier, pas de clé d'API à renouveler, pas de politique de confidentialité à mettre à jour parce que le SDK de quelqu'un d'autre a ajouté un tracker. Si vous partez de là pour ajouter des fonctionnalités — réactions par section, questions des lecteurs, [marqueurs d'obsolescence](/blog/docusaurus-old-notice) — le même schéma passe naturellement à l'échelle : un fichier PHP par fonctionnalité, un composant React, un stockage JSON. --- # Votre blog est illisible pour une IA. Voici la solution. > Une part croissante de vos lecteurs arrive via un assistant IA — et ce qu'ils reçoivent, c'est votre HTML, emballé dans la navigation, des widgets React et des accordéons fermés. Cet article génère un miroir en Markdown pur de chaque article, un index /llms.txt et des bundles full-text par série, en dégradant la source MDX plutôt qu'en convertissant le HTML rendu. Avec la règle de repli qui rend l'export impossible à casser. --- ![Votre blog est illisible pour une IA. Voici la solution.](/img/v2/llms-txt.webp) **TL;DR** `llms.txt` est une convention proposée pour rendre un site lisible par les assistants IA — et elle n'a rien de prouvé : aucun assistant majeur (ChatGPT, Claude, Perplexity) ne confirme publiquement le lire. Je l'ai explorée quand même, surtout parce que ce blog est déjà écrit avec Claude Code qui fait l'essentiel du travail sur les fichiers ; maintenir un miroir `llms.txt` ne coûte donc presque rien. Ce que j'ai découvert en le construisant : le HTML publié, emballé dans la navigation, des widgets React et 107 accordéons `` fermés, est réellement *moins complet* que la source que j'ai écrite. La solution est un plugin exécuté au build qui produit un miroir en Markdown pur de chaque article, un index `/llms.txt` et un bundle full-text par série. La décision clé : dégrader la **source MDX**, jamais le HTML rendu — et ne jamais laisser un composant inconnu faire échouer l'export. Je voulais explorer la piste `llms.txt`, surtout par curiosité, pas parce que j'avais la preuve que des lecteurs arrivaient via un assistant. L'idée — proposée par [Jeremy Howard d'Answer.AI](https://www.answer.ai/posts/2024-09-03-llmstxt.html) en septembre 2024 — est de publier un index d'un site en Markdown pur sur `/llms.txt`, pour qu'un assistant n'ait pas à se battre contre une application React pour trouver le contenu réel. On est loin d'une pratique établie : ni ChatGPT, ni Claude, ni Perplexity ne confirment publiquement que leur pipeline de récupération lit le fichier, et cela ne prendra peut-être jamais comme `robots.txt` ou `sitemap.xml`. Ce qui a fait basculer la balance pour moi : ce blog est déjà écrit avec Claude Code qui fait l'essentiel du travail sur les fichiers, donc garder un miroir `llms.txt` à jour ne serait pas une corvée manuelle — juste une étape de build en plus. Quand essayer coûte aussi peu, on essaie. ## Ce qui est généré {#what-gets-generated} Un `yarn build`, trois artefacts, tous statiques : ```bash $ yarn build [markdown-export] Mirroring 247/248 blog posts to Markdown… [markdown-export] Done — 247 article(s) in /llms.txt, 247/247 mirrored, 0 failed. ``` Le premier est `/llms.txt` (voyez le mien [en ligne](https://www.avonture.be/llms.txt)) — l'index du site, au format proposé par [llmstxt.org](https://llmstxt.org) : un titre, un résumé d'une ligne, puis chaque article sous forme de lien accompagné de sa description, groupé par sujet : ```bash # Christophe Avonture > Personal blog about Docker, WSL, Python, Quarto, PHP, Joomla, Docusaurus and even more 247 articles. Each has a plain-Markdown mirror at its own permalink plus `.md` (e.g. `https://www.avonture.be/blog/.md`). ## Series (full-text bundles) Each link is every article in that series, concatenated in reading order — one request instead of many. - [Bluesky Docusaurus component](https://www.avonture.be/llms/bluesky-docusaurus-component.txt) — 2 article(s) - [Building and testing REST APIs](https://www.avonture.be/llms/building-and-testing-rest-apis.txt) — 5 article(s) - [code quality](https://www.avonture.be/llms/code-quality.txt) — 10 article(s) … 22 more series … ## component - [A component for showing code snippets in a Docusaurus blog](https://www.avonture.be/blog/docusaurus-snippets) — Learn how to build a custom React component to embed dynamic code snippets in your Docusaurus blog for a more interactive reader experience. - [A Docusaurus component that alerts readers the article is over a year old](https://www.avonture.be/blog/docusaurus-old-notice) — Displays a warning banner when the page content is over a year old … 245 more articles, grouped under 40 topic headings … ``` Le deuxième est un miroir en Markdown pur de chaque article, à sa propre URL suffixée de `.md`. Prenez [l'article sur le bouton de retour en haut de page](/blog/docusaurus-go-top), récupérez [`/blog/docusaurus-go-top.md`](https://www.avonture.be/blog/docusaurus-go-top.md), et vous obtenez ceci — pas de JSX, pas d'accordéons, chaque fichier de code intégré en entier : **/blog/docusaurus-go-top.md** ```markdown # Providing a go to the top button in Docusaurus posts > Learn how to easily add a "go to the top" button, often with a fun animated icon, to your Docusaurus blog posts using a custom React component and theme override. --- ![Providing a go to the top button in Docusaurus posts](/img/v2/go_top_banner.webp) **TL;DR** This article is a quick tutorial on how to add a 'scroll to top' button to your Docusaurus blog. It walks you through implementing a custom `` React component, including its CSS for styling. You'll learn how to override the default `BlogPostItem` theme component to automatically inject the button at the bottom of every blog post. The guide also provides tips on adding the button to other parts of your site, such as the blog archive or custom pages. More on smartphones than on computers; it's pretty nice to have a *Back to top of page* button to avoid dislocating your thumb. ![Docux](/img/docux.webp) [Docux](https://github.com/Juniors017), a friend, recently developed this feature and I was really happy to add it, especially since it's terribly easy to use and adds a fun element; it's cool. Just scroll down any page of this blog to see a little meerkat appear in the bottom right corner. Click on it and take the lift back to the top of the page. ## What it looks like Here is this very page, scrolled down. The meerkat sits in the bottom right corner, floating above your own content: > Screenshot — https\://www\.avonture.be/blog/docusaurus-go-top > > ![The meerkat scroll-to-top button, bottom right, on a scrolled blog post](./images/go_top_button.webp) Up close, it's a round button holding whatever icon you hand it: … ``` Le troisième est celui auquel je n'aurais pas pensé tout seul : **un bundle full-text par [série](/blog/docusaurus-series)**, sur `/llms/.txt`, qui concatène tous les articles de la série dans l'ordre de lecture. Le bundle [Docusaurus components](https://www.avonture.be/llms/creating-docusaurus-components.txt) fait 710 Ko de Markdown pur — 23 articles qu'un assistant peut récupérer en une seule requête au lieu de 23. ## Pourquoi la source, et jamais le HTML {#why-the-source-and-never-the-html} Tous les outils du domaine reconvertissent le HTML rendu en Markdown. C'est la mauvaise direction, et la raison n'est pas esthétique : * **Le HTML a déjà perdu de l'information.** Ce blog replie 107 snippets de code dans des accordéons `` fermés, certains composants n'affichent rien avant l'hydratation, et les images sont derrière du lazy loading — un passage HTML → Markdown ne peut récupérer que ce que le HTML contient, et le HTML contient moins que la source. * **La source est déjà du Markdown.** Un fichier MDX, c'est du Markdown avec un peu de JSX saupoudré dessus. Aller de la source vers le Markdown, c'est gérer quelques dizaines de noms de composants ; aller du HTML vers le Markdown, c'est faire de l'ingénierie inverse sur tout un pipeline de rendu. * **Le fichier généré est réellement meilleur que la page.** `` devient un bloc de code délimité contenant le fichier entier. Le lecteur du miroir obtient le code que le lecteur de la page doit cliquer pour révéler. * **Rien n'est inventé.** Chaque composant de ce blog emballe soit du contenu écrit par l'auteur, soit pointe vers un fichier sur le disque. Aucun composant ne fabrique du contenu à l'exécution — une dégradation au niveau de la source peut donc être complète par construction. Le résultat n'est pas un export dégradé de mon site. C'est une version *plus complète*. ## La mise en place {#building-it} ### La table de dégradation {#the-degradation-table} Mes articles ne sont pas du Markdown pur : c'est du MDX, du Markdown mélangé à quelques dizaines de composants React maison — `` pour un bloc de code repliable, `` pour un encart, `` pour une référence croisée, et ainsi de suite. Chacun existe pour ajouter un peu d'interactivité à la page, et chacun est exactement ce qu'un miroir Markdown ne peut pas rendre tel quel. Le plugin doit donc savoir, composant par composant, en quel Markdown pur le transformer. Le tout est un pipeline remark : parser la source MDX en AST, la parcourir, remplacer chaque nœud JSX par des nœuds Markdown purs, puis sérialiser. Chaque composant a sa règle : | Composant | Utilisations | Dégradation | | ------------------------------------------------------------- | -----------: | --------------------------------------------------------------- | | `Snippet` | 917 | bloc délimité ; `source=` (841×) résolu et intégré en entier | | `Link` | 779 | `[text](href)` | | `AlertBox` | 546 | `> **{title}:** …` (le variant devient le mot-clé) | | `Terminal` | 455 | bloc ` ```bash ` ; `source=` (146×) résolu | | `TLDR` | 253 | `> **TL;DR** …` | | `BrowserWindow` | 98 | les enfants, plus une légende `> Screenshot — {url}` | | `StepsCard` | 55 | liste ordonnée | | `ProjectSetup` | 28 | `### Project: {folderName}` puis chaque `Snippet` en bloc titré | | `Details` | 18 | laissé en `
` — déjà du Markdown valide | | `Prerequisite` | 10 | liste à puces | | `Columns` / `Column` | 9 | sections successives | | `Reaction`, `ScrollToTopButton`, `Bluesky`, `RelatedPosts`, … | \~15 | **supprimés** — de l'interface, pas du contenu | | `Trees` / `Folder` / `File` | 0 | **rien à faire** (voir ci-dessous) | Cette dernière ligne est ma préférée dans tout le plugin. Ces composants existent parce qu'un plugin remark transforme une arborescence ASCII de la source en composants React. Ne pas appliquer ce plugin restitue l'arborescence ASCII d'origine, gratuitement. La bonne règle était de n'écrire aucune règle. **plugins/markdown-export-plugin/degrade.cjs** ````cjs /** * @fileoverview plugins/markdown-export-plugin/degrade.cjs * * Turns a raw blog-post source (the .md/.mdx file exactly as the author typed it — * NOT the rendered HTML) into a plain, self-contained Markdown document: no React, * no JSX, no collapsed accordions. Used by plugins/markdown-export-plugin/index.cjs * to write the `/blog/.md` mirrors and the `/llms*.txt` indexes. * * THE ONE RULE THAT MUST NEVER BE BROKEN * ---------------------------------------------------------------------------- * An unrecognised component never fails the export. Its wrapper is discarded and * its (already-degraded) children take its place in the tree. The component's * name is recorded in `unknownComponents` so the caller can warn once per build — * that warning, not this table, is what keeps the export at 100% coverage as new * components get added. See COMPONENT_RULES below for the components this file * currently understands explicitly. * * WHY require() WORKS HERE FOR ESM-ONLY PACKAGES * ---------------------------------------------------------------------------- * `unified`/`remark-*` ship ESM-only. This file stays CommonJS (matching every * other plugin in plugins/) because Node 20.19+ (this project's own image is * node:20-bookworm-slim — see Dockerfile) resolves `require("esm-package")` * synchronously; each import below reads `.default` (or the package's own named * export, for `unified` itself) exactly as that interop exposes it. Verified * against the actual devcontainer runtime before writing this file. */ const fs = require("fs"); const path = require("path"); const { unified } = require("unified"); // createProcessor with format:"mdx" — NOT format:"md" — is load-bearing, and not // the obvious choice. @mdx-js/mdx's own core.js only attaches remark-mdx (the JSX/ // expression tokenizer) when format !== "md"; under format:"md" a `` // parses as an inert `html` text node instead of a component node, which is *exactly* // what Docusaurus wants for its own hast-level raw-HTML passthrough, but is useless // here — nothing would ever reach COMPONENT_RULES. format:"mdx" gives real // mdxJsxFlowElement/mdxJsxTextElement nodes for every file regardless of its .md/.mdx // extension (verified against this corpus's few .md files that even have top-level // `import` statements — they still parse fine). Confirmed empirically while writing // this file. const { createProcessor } = require("@mdx-js/mdx"); // Used only by the small standalone `inlineParser` below (parseInlineMarkdown) — // the main parser gets its own parsing from createProcessor, which attaches // remark-parse internally. const remarkParse = require("remark-parse").default; const remarkStringify = require("remark-stringify").default; const remarkFrontmatter = require("remark-frontmatter").default; const remarkGfm = require("remark-gfm").default; const remarkDirective = require("remark-directive").default; // Docusaurus enables MDX-1-compat raw HTML comments by default (markdown.mdx1Compat.comments), // which is what lets `` (and any other ``) live inside MDX content // without a syntax error — plain remark-mdx does not parse that syntax at all. Same package // @docusaurus/mdx-loader itself uses (see its processor.js), so this mirrors the real pipeline. const remarkComment = require("@slorber/remark-comment").default; // Both public @docusaurus/utils exports, applied as raw-text preprocessing on the // source BEFORE parsing — exactly mirroring @docusaurus/mdx-loader's own // preprocessContent() (see its preprocessor.js). Skipping either would reintroduce // a crash format:"mdx" would otherwise hit on real corpus content: // escapeMarkdownHeadingIds turns `## Title {#id}` into an escaped `\{#id}` so // remark-mdx's expression tokenizer never tries (and fails) to parse `#id` as JS; // admonitionTitleToDirectiveLabel turns Docusaurus's `:::tip Free text title` // shorthand into remark-directive's `:::tip[Free text title]` label syntax. const { escapeMarkdownHeadingIds, admonitionTitleToDirectiveLabel, } = require("@docusaurus/utils"); const { resolveSourcePath } = require("../remark-snippet-loader/index.cjs"); // `:::tip Title text` (Docusaurus's own admonition syntax — 7 articles use it) // needs one more thing standard remark-directive doesn't provide on its own: an AST // pass that reads the bracket label (produced by admonitionTitleToDirectiveLabel // above) back off into `data.hProperties` (Docusaurus's own admonitions plugin — an // *internal* path, not published API, so this is wrapped defensively: if it ever // moves in a future @docusaurus/core bump, admonitions degrade through the generic // "unknown directive" fallback below instead of crashing the build). let admonitionsPlugin = null; try { admonitionsPlugin = require("@docusaurus/mdx-loader/lib/remark/admonitions").default; } catch { admonitionsPlugin = null; } const ADMONITION_KEYWORDS = [ "secondary", "info", "success", "danger", "note", "tip", "warning", "important", "caution", ]; // Small, deliberately narrower sibling of remark-snippet-loader's own table: // only used to label fenced code blocks, so an unmapped extension merely means // "no syntax highlighting" instead of a broken export. const EXTENSION_TO_LANG = { js: "javascript", jsx: "jsx", ts: "typescript", tsx: "typescript", py: "python", php: "php", sh: "bash", bash: "bash", 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", }; function langForFilename(filename) { if (!filename) return ""; // Some `filename=` attrs carry a human annotation after the real name, e.g. // "InvoiceProcessor.php (staged diff)" — kept verbatim in the displayed // title, but path.extname() on the raw string would return ".php (staged // diff)" (everything after the *last* dot), which then leaks a space into // the fenced code block's language info-string. Strip the annotation first. const withoutAnnotation = filename.replace(/\s*\(.*\)\s*$/, "").trim(); const base = path.basename(withoutAnnotation).toLowerCase(); if (base === "dockerfile") return "docker"; const ext = path.extname(base).slice(1); return EXTENSION_TO_LANG[ext] || EXTENSION_TO_LANG[base] || ext || ""; } // --------------------------------------------------------------------------- // Reading a `source="…"` file — mirrors remark-snippet-loader's own fallback: // never throw, always return text (an error comment at worst). // --------------------------------------------------------------------------- function readSourceFile(sourcePath, ctx) { const absolutePath = resolveSourcePath(sourcePath, ctx.currentFileDir, ctx.projectRoot); try { return fs.readFileSync(absolutePath, "utf-8"); } catch (err) { return `Error loading source file: ${sourcePath} (${err.code || err.message})`; } } // --------------------------------------------------------------------------- // Safe literal evaluation of JSX expression attributes (`prop={…}`). // // MDX attaches a full acorn/acorn-jsx estree to `mdxJsxAttributeValueExpression` // attributes. We walk it for the shapes that actually appear in this corpus — // string/number/boolean/array/object literals, template literals with no // interpolation, `require("./x").default` (StepsCard, ShortcutList, Image, // DownloadButton), and small JSX fragments (ShortcutList's `desc`). Anything // else (a variable reference, a function call we don't recognise) resolves to // `undefined` rather than guessing — callers must treat that as "no value". // --------------------------------------------------------------------------- function literalFromEstree(node) { if (!node) return undefined; switch (node.type) { case "Literal": return node.value; case "TemplateLiteral": // Only static templates (no ${…}) can be read without a real JS // evaluator — exactly the `installOutput={`\nfoo\n`}` pattern used by // . if (node.expressions.length === 0) { return node.quasis.map((q) => q.value.cooked).join(""); } return undefined; case "ArrayExpression": return node.elements.map((el) => (el ? literalFromEstree(el) : undefined)); case "ObjectExpression": { const obj = {}; for (const prop of node.properties) { if (prop.type !== "Property") continue; const key = prop.key.type === "Identifier" ? prop.key.name : prop.key.value; obj[key] = literalFromEstree(prop.value); } return obj; } case "UnaryExpression": if (node.operator === "-") { const value = literalFromEstree(node.argument); return typeof value === "number" ? -value : undefined; } return undefined; // require("./file.ext") or require("./file.ext").default — the pattern // used to pass a webpack-imported asset (Image, DownloadButton). We can't // know the final fingerprinted build URL from source alone, so we surface // the raw relative path and let the caller decide how to degrade it. case "CallExpression": if (node.callee?.type === "Identifier" && node.callee.name === "require") { const arg = node.arguments[0]; if (arg?.type === "Literal" && typeof arg.value === "string") { return { __requirePath: arg.value }; } } return undefined; case "MemberExpression": { const objectValue = literalFromEstree(node.object); if ( objectValue && typeof objectValue === "object" && "__requirePath" in objectValue ) { // `.default` (or any property) on a require() result — the path is // the only part we can use, so keep it as-is. return objectValue; } return undefined; } case "JSXFragment": case "JSXElement": return { __jsxText: jsxToText(node) }; default: return undefined; } } // Best-effort flattening of small JSX fragments embedded in an expression // attribute (e.g. ShortcutList's `desc: <>Press Enter`). This is // NOT the mdast pipeline — it walks acorn-jsx estree nodes, a different shape. // Deliberately returns *plain* text, with formatting tags unwrapped rather than // translated to Markdown syntax (`` `…` ``/`**…**`): the result lands in a // single mdast `text` node (see shortcutDescToText), and remark-stringify would // correctly escape any literal backtick/asterisk it contains right back into // `\`…\`` — i.e. synthesizing Markdown syntax here just to have it escaped away // a moment later. Losing kbd/bold emphasis in this one nested-JSX-in-attribute // shape (a single ShortcutList article, at the time of writing) is an accepted, // minor simplification — the words themselves are never lost. function jsxToText(node) { if (!node) return ""; switch (node.type) { case "JSXFragment": return node.children.map(jsxToText).join(""); case "JSXElement": { const tag = (node.openingElement?.name?.name || "").toLowerCase(); const inner = node.children.map(jsxToText).join(""); if (tag === "br") return "\n"; return inner; } case "JSXText": return node.value; case "JSXExpressionContainer": { const value = literalFromEstree(node.expression); return typeof value === "string" ? value : ""; } default: return ""; } } function degradeAttrValue(attr) { if (attr.value === null) return true; // boolean shorthand: `typewriter` if (typeof attr.value === "string") return attr.value; if (attr.value && attr.value.type === "mdxJsxAttributeValueExpression") { const program = attr.value.data?.estree?.body?.[0]; if (program?.type === "ExpressionStatement") { return literalFromEstree(program.expression); } } return undefined; } function getAttrs(node) { const attrs = {}; for (const attr of node.attributes || []) { if (attr.type !== "mdxJsxAttribute") continue; // skips {...spread} attrs attrs[attr.name] = degradeAttrValue(attr); } return attrs; } // --------------------------------------------------------------------------- // mdast → plain text (used only where a component needs flat text: Terminal's // children, Prerequisite already gets its text from attrs). Block siblings // (paragraphs) join on "\n"; a node's own children concatenate directly — // good enough for the prose/terminal-output shapes this corpus actually uses. // --------------------------------------------------------------------------- function mdastToText(node) { if (node == null) return ""; if (Array.isArray(node)) return node.map(mdastToText).join("\n"); if (node.type === "break") return "\n"; if (typeof node.value === "string") return node.value; if (node.children) return node.children.map(mdastToText).join(""); return ""; } // --------------------------------------------------------------------------- // Reader-adjustable values — `default`, written // directly inside ``/`` text by the author (`%%`, not // `{{`, because a bare `{` inside literal MDX children opens a JS expression // — see src/components/Vars/substitute.js for the full rationale; this is // that same live-component contract, resolved at export time instead of // render time). `=`, not `:`, separates the name from its default: this // file's own `remarkDirective` (below, for `:::tip` admonitions) treats any // bare `word:word` text as a false-positive inline directive — confirmed // empirically, a colon-separated marker logged a spurious "unknown // directive" warning for every occurrence. The default lives in the marker // itself, so resolving it here needs no lookup against a `` node // elsewhere in the tree: a plain regex swap. Without this, the exported // `.md`/`llms.txt` would show the literal marker — invalid input for a // reader or an LLM to copy-paste. // --------------------------------------------------------------------------- const VAR_MARKER_RE = /%%(\w+)=([^%]*)%%/g; function resolveVarMarkers(text) { if (typeof text !== "string") return text; return text.replace(VAR_MARKER_RE, (_match, _name, defaultValue) => defaultValue); } // --------------------------------------------------------------------------- // String attributes that carry their own inline Markdown, not literal text — // AlertBox/Details `title`/`label` routinely embed `` `code` `` (over 30 uses // across this corpus), and StepsCard's `steps[].content` is markdown by // contract (the live component runs it through its own parseMarkdown()). // Wrapping such a string in a single text node, as everything else in this // file does for genuinely literal text, makes remark-stringify escape the // author's `` ` ``/`*` right back into `\`…\`` — this parses the string as its // own tiny Markdown document and lifts out the real inline nodes instead. // --------------------------------------------------------------------------- const inlineParser = unified().use(remarkParse).use(remarkGfm); function parseInlineMarkdown(text) { if (typeof text !== "string" || text === "") return [textNode("")]; const tree = inlineParser.parse(text); const paragraphs = (tree.children || []).filter((node) => node.type === "paragraph"); if (paragraphs.length === 0) return [textNode(text)]; return paragraphs.flatMap((p) => p.children); } // --------------------------------------------------------------------------- // Custom-component degradation table. // Each handler receives the JSX node (children already recursively degraded // into plain mdast) and the shared ctx, and returns an array of mdast nodes. // --------------------------------------------------------------------------- const ALERT_LABELS = { info: "Information", note: "Note", tip: "Tip", caution: "Caution", important: "Important", highlyImportant: "Highly Important", coreConcept: "Core Concept", danger: "Danger", }; function textNode(value) { return { type: "text", value }; } function paragraph(children) { return { type: "paragraph", children }; } function strongText(value) { return { type: "strong", children: [textNode(value)] }; } // Same as strongText, but for an author-written string that may itself // contain Markdown syntax (AlertBox/Details title & label, StepsCard title) — // see parseInlineMarkdown's doc comment. function strongParsed(value) { return { type: "strong", children: parseInlineMarkdown(value) }; } function codeBlock(value, lang) { return { type: "code", lang: lang || null, value: value.replace(/\n$/, "") }; } const COMPONENT_RULES = { // --- Content-bearing leaves -------------------------------------------- Link(node) { const attrs = getAttrs(node); const url = typeof attrs.to === "string" ? attrs.to : attrs.href || ""; return [{ type: "link", url, children: node.children || [] }]; }, ConnectionInfo(node) { const attrs = getAttrs(node); const items = Array.isArray(attrs.items) ? attrs.items : []; if (items.length === 0) return []; const out = []; if (typeof attrs.title === "string") out.push(paragraph([strongParsed(attrs.title)])); out.push({ type: "table", align: [null, null], children: [ tableRow(["Label", "Value"]), ...items.map((item) => tableRow([ item?.label || "", [{ type: "inlineCode", value: item?.value || "" }], ]), ), ], }); return out; }, // Props-only (no children) — a small "Quick Jump: A | B" line, links resolved // against the same in-page anchors the headings below stringify to, so they // stay meaningful in the flat mirror. QuickJump(node) { const attrs = getAttrs(node); const links = Array.isArray(attrs.links) ? attrs.links : []; if (links.length === 0) return []; const title = typeof attrs.title === "string" ? attrs.title : "Quick Jump"; const linkNodes = links.flatMap((link, index) => { const item = { type: "link", url: typeof link?.to === "string" ? link.to : "", children: [textNode(typeof link?.label === "string" ? link.label : "")], }; return index === 0 ? [item] : [textNode(" | "), item]; }); return [paragraph([strongText(`${title}:`), textNode(" "), ...linkNodes])]; }, Snippet(node, ctx) { const attrs = getAttrs(node); let code; let lang; if (typeof attrs.source === "string") { code = readSourceFile(attrs.source, ctx); lang = langForFilename(attrs.filename || attrs.source); } else if (typeof attrs.code === "string") { code = attrs.code; lang = langForFilename(attrs.filename) || attrs.lang || ""; } else { code = mdastToText(node.children); lang = langForFilename(attrs.filename) || ""; } const title = typeof attrs.filename === "string" ? [paragraph([strongText(attrs.filename)])] : []; return [...title, codeBlock(resolveVarMarkers(code), lang)]; }, Terminal(node, ctx) { const attrs = getAttrs(node); const code = typeof attrs.source === "string" ? readSourceFile(attrs.source, ctx) : mdastToText(node.children); return [codeBlock(resolveVarMarkers(code), "bash")]; }, // Reader-values bar — UI only, nothing for the plain-Markdown // mirror to show. The values it declares are already the same defaults // baked into every `default` marker (resolveVarMarkers above), so // dropping this node loses no information. Vars() { return []; }, // Inline prose sibling of the marker (see the VAR_MARKER_RE comment above): // `default` inside a paragraph. children is already // the literal default text — keep it, drop the wrapper. Var(node) { return node.children || []; }, // `textdefaulttext` (see Code.js) — one // code span mixing literal text and a ``. The tree walk is bottom-up // (see transformChildren below), so `node.children` here already has each // nested `` resolved to its plain default text; flatten it into one // `inlineCode`, same as a literal ``/`` HTML tag two rules up. Code(node) { return [{ type: "inlineCode", value: mdastToText(node.children) }]; }, // --- Wrappers whose children already carry the content ------------------ TLDR(node) { return [paragraph([strongText("TL;DR")]), ...(node.children || [])]; }, AlertBox(node) { const attrs = getAttrs(node); // attrs.title is author-written and routinely embeds `` `code` `` (30+ // uses in this corpus) — parse it, then append the literal ":" as its own // trailing text node rather than folding it into a string first. const labelNodes = typeof attrs.title === "string" ? [...parseInlineMarkdown(attrs.title), textNode(":")] : [textNode(`${ALERT_LABELS[attrs.variant] || ALERT_LABELS.info}:`)]; return [ { type: "blockquote", children: [ paragraph([{ type: "strong", children: labelNodes }]), ...(node.children || []), ], }, ]; }, Details(node) { const attrs = getAttrs(node); const summary = typeof attrs.label === "string" ? attrs.label : "Details"; return [paragraph([strongParsed(summary)]), ...(node.children || [])]; }, Prerequisite(node) { const attrs = getAttrs(node); const lines = [paragraph([textNode(`Prerequisite: ${attrs.name || ""}`)])]; if (attrs.install) { const installText = `$ ${attrs.install}${attrs.installOutput ? `\n${attrs.installOutput}` : ""}`; lines.push(codeBlock(installText, "bash")); } if (attrs.check) { const checkText = `$ ${attrs.check}${attrs.checkOutput ? `\n${attrs.checkOutput}` : ""}`; lines.push(paragraph([textNode("Verify:")])); lines.push(codeBlock(checkText, "bash")); } return lines; }, BrowserWindow(node) { const attrs = getAttrs(node); const caption = typeof attrs.url === "string" ? `Screenshot — ${resolveVarMarkers(attrs.url)}` : "Screenshot"; return [ { type: "blockquote", children: [paragraph([textNode(caption)]), ...(node.children || [])], }, ]; }, StepsCard(node) { const attrs = getAttrs(node); const out = []; if (typeof attrs.title === "string") out.push(paragraph([strongParsed(attrs.title)])); const steps = Array.isArray(attrs.steps) ? attrs.steps : []; if (steps.length > 0) { out.push({ type: "list", ordered: attrs.variant !== "remember", children: steps.map((step) => stepToListItem(step)), }); } return out; }, ShortcutList(node) { const attrs = getAttrs(node); const items = Array.isArray(attrs.items) ? attrs.items : []; if (items.length === 0) return []; return [ { type: "table", align: [null, null], children: [ tableRow(["Shortcut", "Description"]), ...items.map((item) => tableRow([keysToInlineNodes(item.keys), shortcutDescToText(item.desc)]), ), ], }, ]; }, ProjectSetup(node) { const attrs = getAttrs(node); const out = [paragraph([strongText(`Project: ${attrs.folderName || "my-project"}`)])]; for (const child of node.children || []) { out.push(child); } return out; }, // / render null in React (ProjectSetup reads them as // configuration, not layout) — degrade to the same instructional text a // reader of the live page would see. Guideline(node) { // node.children are already-degraded block nodes (typically one or more // paragraphs) — they must be siblings inside the blockquote, never spliced // into the "Note:" paragraph's own inline children (a block node nested // inside a paragraph is invalid mdast; remark-stringify does not reject it, // it just emits garbled HTML-entity-escaped text — verified on this corpus). return [ { type: "blockquote", children: [paragraph([strongText("Note:")]), ...(node.children || [])], }, ]; }, EmptyFolder(node) { const attrs = getAttrs(node); return [paragraph([textNode(`(create empty directory: ${attrs.name || "?"})`)])]; }, // --- Cards ---------------------------------------------------------------- Card(node) { return node.children || []; }, CardHeader(node) { return [{ type: "heading", depth: 4, children: inlineOnly(node.children) }]; }, CardBody(node) { return node.children || []; }, CardFooter(node) { return node.children || []; }, CardImage(node) { const attrs = getAttrs(node); const src = resolveDisplayableImageSrc(attrs.src); return src ? [paragraph([{ type: "image", url: src, alt: attrs.alt || "" }])] : []; }, // --- Simple layout / leaf components -------------------------------------- Columns(node) { return node.children || []; }, Column(node) { return node.children || []; }, Highlight(node) { return [{ type: "strong", children: node.children || [] }]; }, Image(node) { const attrs = getAttrs(node); const src = resolveDisplayableImageSrc(attrs.src); return src ? [paragraph([{ type: "image", url: src, alt: attrs.alt || attrs.title || "" }])] : [paragraph([textNode(`(image: ${attrs.alt || attrs.title || "untitled"})`)])]; }, DownloadButton(node) { const attrs = getAttrs(node); const requirePath = attrs.file && typeof attrs.file === "object" ? attrs.file.__requirePath : null; const label = attrs.label || "Download"; // The final build asset URL is fingerprinted by webpack and unknowable // from source alone (see literalFromEstree's CallExpression case) — link // text names the file instead of guessing a URL that could 404. return [ paragraph([ textNode(`${label}: `), { type: "inlineCode", value: requirePath || "?" }, ]), ]; }, // --- Pure interface widgets: no textual content, drop entirely ----------- Reaction: () => [], ScrollToTopButton: () => [], KonamiEasterEgg: () => [], RelatedPosts: () => [], SeriesPosts: () => [], PostCard: () => [], Bluesky: () => [], AIIcon: () => [], TriedIt: () => [], TypoReport: () => [], // Trees/Folder/File need NO rule at all: they only exist after // remark-tree-to-component rewrites a fenced ```tree block, and this file // degrades the raw source *before* that plugin ever runs — so the fenced // block is still a plain `code` node here, already valid Markdown. }; function inlineOnly(nodes) { // Best-effort: unwrap paragraphs into their inline children, for contexts // (headings) that cannot contain block content. const out = []; for (const node of nodes || []) { if (node.type === "paragraph") out.push(...node.children); else out.push(node); } return out; } function resolveDisplayableImageSrc(src) { // Absolute paths (`/img/…`) and full URLs resolve identically whether the // reader is on the HTML page or on the flat `/blog/.md` mirror, so // they survive as real image links. A relative `require("./x.webp")` result // is fingerprinted by webpack into a hashed build URL this script has no // way to predict — see [[0082]]'s "Ce qui est garanti" — so it is dropped // rather than emitting a link that would 404. if (typeof src === "string" && (src.startsWith("/") || /^https?:\/\//.test(src))) { return src; } return null; } // StepsCard's `steps[].content`/`steps[].substeps[]` strings are Markdown by // contract — the live component runs each through its own parseMarkdown() — // hence parseInlineMarkdown() rather than a literal textNode() here. function stepToListItem(step) { if (typeof step === "string") { return { type: "listItem", children: [paragraph(parseInlineMarkdown(step))] }; } const content = typeof step?.content === "string" ? step.content : ""; const children = [paragraph(parseInlineMarkdown(content))]; if (Array.isArray(step?.substeps) && step.substeps.length > 0) { children.push({ type: "list", ordered: false, children: step.substeps.map((s) => ({ type: "listItem", children: [paragraph(parseInlineMarkdown(typeof s === "string" ? s : ""))], })), }); } return { type: "listItem", children }; } function shortcutDescToText(desc) { if (typeof desc === "string") return desc; if (desc && typeof desc === "object" && "__jsxText" in desc) return desc.__jsxText; return ""; } // `cells`: each entry is either a plain string (wrapped in a single text node) // or a pre-built array of inline mdast nodes (e.g. an `inlineCode` node) — the // latter for any cell that needs real Markdown formatting rather than escaped // literal characters. mdast-gfm needs no header flag on the row itself: by // convention the *first* row of a `table` node's children is the header row. function tableRow(cells) { return { type: "tableRow", children: cells.map((cell) => ({ type: "tableCell", children: Array.isArray(cell) ? cell : [textNode(cell)], })), }; } function keysToInlineNodes(keys) { if (!Array.isArray(keys) || keys.length === 0) return [textNode("")]; const nodes = []; keys.forEach((key, index) => { if (index > 0) nodes.push(textNode("+")); nodes.push({ type: "inlineCode", value: key }); }); return nodes; } // --------------------------------------------------------------------------- // Plain HTML tags authored directly in MDX (Table B) — `
`, // `

`, ``, ``, ``, ``… These are NOT custom // components: remark-mdx parses every JSX tag, lowercase or not, into the same // mdxJsxFlowElement/mdxJsxTextElement node types, so without this table they // would fall through to the generic "unknown → unwrap" rule and silently lose // their formatting (bold/italic/inline-code/heading level). // --------------------------------------------------------------------------- const HTML_HEADING_DEPTH = { h1: 1, h2: 2, h3: 3, h4: 4, h5: 5, h6: 6 }; function degradeHtmlTag(node) { const name = node.name.toLowerCase(); const children = node.children || []; if (HTML_HEADING_DEPTH[name]) { return [ { type: "heading", depth: HTML_HEADING_DEPTH[name], children: inlineOnly(children), }, ]; } if (name === "p") return [paragraph(children)]; if (name === "blockquote") return [{ type: "blockquote", children }]; if (name === "strong" || name === "b") return [{ type: "strong", children }]; if (name === "em" || name === "i") return [{ type: "emphasis", children }]; if (name === "del" || name === "s") return [{ type: "delete", children }]; if (name === "code" || name === "kbd") return [{ type: "inlineCode", value: mdastToText(children) }]; if (name === "a") { const attrs = getAttrs(node); return [{ type: "link", url: attrs.href || "", children }]; } if (name === "img") { const attrs = getAttrs(node); const src = resolveDisplayableImageSrc(attrs.src); if (src) return [{ type: "image", url: src, alt: attrs.alt || "" }]; // Relative/require()'d src can't be resolved to a real URL (see // resolveDisplayableImageSrc) — keep the alt text rather than vanish // entirely, since a bare has no children to fall back to. return attrs.alt ? [textNode(attrs.alt)] : []; } if (name === "br") return [{ type: "break" }]; if (name === "hr") return [{ type: "thematicBreak" }]; // div, span, section, article, and any other purely structural tag: unwrap. return children; } // --------------------------------------------------------------------------- // Tree walk: bottom-up so a nested custom component is already resolved // before its parent inspects `node.children`. // --------------------------------------------------------------------------- function transformChildren(children, ctx) { const out = []; for (const child of children || []) { out.push(...transformNode(child, ctx)); } return out; } function transformNode(node, ctx) { if (node.children) { node.children = transformChildren(node.children, ctx); } if (node.type === "mdxJsxFlowElement" || node.type === "mdxJsxTextElement") { return degradeComponent(node, ctx); } if (node.type === "mdxFlowExpression" || node.type === "mdxTextExpression") { // A bare {expression} in prose, outside any attribute. None exist in this // corpus outside of code fences (verified) — best-effort fallback rather // than a crash if one ever appears. return typeof node.value === "string" && node.value.trim() ? [textNode(node.value)] : []; } if (node.type === "mdxjsEsm") { return []; // import/export statements — not content. } if (node.type === "yaml") { return []; // frontmatter — the caller writes its own header instead. } if (node.type === "comment") { return []; // `` and any other raw HTML comment — not content. } if (node.type === "containerDirective" || node.type === "leafDirective") { return degradeDirective(node); } if (node.type === "textDirective") { // remark-directive's inline `:name` syntax has no word-boundary check, so // ordinary prose like "user_id:group_id", "github.com:company/x.git" or // "nginx:latest" tokenizes as a textDirective too — and every occurrence // ever found in this corpus has been exactly that, never a real directive // (this blog's own `:::tip`-style directives go through containerDirective/ // leafDirective above, not here). Not worth a per-build warning: reconstruct // the exact original characters from the source, silently. Unlike // containerDirective, a directive with no `[label]` has *no children at // all* to fall back to, so keeping `node.children` here would silently // delete the word after the colon instead. if (node.position && typeof ctx.rawSource === "string") { return [ textNode( ctx.rawSource.slice(node.position.start.offset, node.position.end.offset), ), ]; } return node.children || []; } return [node]; } function degradeDirective(node) { // Docusaurus's admonitions plugin (when available — see the try/catch at the // top of this file) annotates the directive with the resolved type/title // instead of changing its node type, so `node.type` is still "containerDirective" // here even for a fully recognised `:::tip[…]`. const type = node.data?.hProperties?.type || node.name; const title = node.data?.hProperties?.title; const label = title || ALERT_LABELS[type] || (type ? type[0].toUpperCase() + type.slice(1) : "Note"); return [ { type: "blockquote", children: [paragraph([strongText(`${label}:`)]), ...(node.children || [])], }, ]; } function degradeComponent(node, ctx) { const name = node.name; if (!name) { // React fragment <>… return node.children || []; } const isHtmlTag = name[0] === name[0].toLowerCase(); if (isHtmlTag) { return degradeHtmlTag(node); } const handler = COMPONENT_RULES[name]; if (handler) { return handler(node, ctx) || []; } // The one rule that can never be skipped: keep the content, drop the // wrapper, and tell the caller so it can warn about the gap. ctx.unknownComponents.add(name); return node.children || []; } // --------------------------------------------------------------------------- // Public entry point // --------------------------------------------------------------------------- // Parse-only processor, always format:"mdx" regardless of the file's own .md/.mdx // extension — see the createProcessor import comment above for why. Never call // `.run()`/`.compile()`/`.process()` on this one: createProcessor bakes in the // full remark→rehype→recma→JS-estree compile chain, and `.parse()` is the only // method that stops cleanly at the mdast stage this file actually wants. function buildParser() { return createProcessor({ format: "mdx" }) .use(remarkFrontmatter) .use(remarkGfm) .use(remarkComment) .use(remarkDirective); } // Separate, bare processor: `.run()`-only, used solely to apply Docusaurus's // own admonitions AST transform (`:::tip[…]` → data.hProperties) to the tree // `buildParser` produced. Kept off the parser above because a bare unified() // has no recma/JS-compile stage to accidentally trigger. const directiveTransformer = admonitionsPlugin ? unified().use(admonitionsPlugin, true) : null; const stringifier = unified().use(remarkStringify).use(remarkGfm); /** * @param {string} rawSource - the full .md/.mdx file content, frontmatter included. * @param {object} opts * @param {string} opts.currentFileDir - absolute directory of the article (for * resolving relative `source="./…"` attributes). * @param {string} [opts.projectRoot] - defaults to process.cwd(). * @returns {Promise<{ markdown: string, unknownComponents: string[] }>} */ async function mdxToMarkdown(rawSource, { currentFileDir, projectRoot = process.cwd() }) { let preprocessed = escapeMarkdownHeadingIds(rawSource); if (admonitionsPlugin) { preprocessed = admonitionTitleToDirectiveLabel(preprocessed, ADMONITION_KEYWORDS); } const tree = buildParser().parse(preprocessed); if (directiveTransformer) { await directiveTransformer.run(tree); } const ctx = { currentFileDir, projectRoot, rawSource: preprocessed, unknownComponents: new Set(), }; tree.children = transformChildren(tree.children, ctx); const markdown = stringifier.stringify(tree); return { markdown, unknownComponents: [...ctx.unknownComponents].sort() }; } module.exports = { mdxToMarkdown }; ```` ### L'orchestrateur {#the-orchestrator} Le plugin s'exécute dans `postBuild`, dégrade chaque article publié, écrit les miroirs, puis construit les deux index à partir de ce qui a réussi : **plugins/markdown-export-plugin/index.cjs** ```cjs /** * @fileoverview Docusaurus Plugin: markdown-export-plugin * * Purpose: * Writes a plain-Markdown mirror of every published blog post next to its HTML * page (`/blog/` → `/blog/.md`), plus a site-wide `/llms.txt` index * and a per-series `/llms/.txt` bundle — so a reader (human or an * LLM) can fetch the article's full content without React, JSX, or the 107 * `defaultOpen={false}` accordions this corpus collapses in the HTML. * * The actual MDX → Markdown degradation lives in ./degrade.cjs; this file only * orchestrates: find the posts that really shipped, degrade each one, write * the mirrors, then build the two kinds of index on top of what didn't fail. * * Usage: * - Add to docusaurus.config.js `plugins` array: `"./plugins/markdown-export-plugin/index.cjs"` * - Runs only in `postBuild` (i.e. only during `yarn build`, never `yarn start`), * same as every other postBuild plugin in this repo. */ const fs = require("fs"); const path = require("path"); const frontMatter = require("front-matter"); const { mdxToMarkdown } = require("./degrade.cjs"); const BLOG_DIR = "blog"; /** Recursively collects every article file under a directory (mirrors the same * pattern used by scripts/internal-link-opportunities.mjs and blog-feed-plugin). */ function findPosts(dir) { const found = []; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const target = path.join(dir, entry.name); if (entry.isDirectory()) { found.push(...findPosts(target)); } else if (entry.name === "index.md" || entry.name === "index.mdx") { found.push(target); } } return found; } // Mirrors src/components/Blog/utils/slug.ts's createSlug() exactly. Duplicated // rather than imported: that file is ESM written for Webpack/browser bundling // (`export function`), which a plain Node `require()` here cannot load without // a bundler. Keep both in sync if the slugify algorithm ever changes. function createSlug(text) { return text .replace(/([a-z0-9])([A-Z])/g, "$1-$2") .toLowerCase() .normalize("NFD") .replace(/[\u0300-\u036f]/g, "") // strips diacritics .replace(/[^a-z0-9\s-]/g, "") .replace(/\s+/g, "-") .replace(/-+/g, "-") .replace(/^-+|-+$/g, ""); } // Mirrors src/components/Blog/utils/posts.ts's permalink derivation exactly — // same "why duplicated" rationale as createSlug above. function permalinkFor(attributes, dir) { if (attributes.slug) { return attributes.slug.startsWith("/") ? attributes.slug : `/blog/${attributes.slug.replace(/^\//, "")}`; } return `/blog/${dir}/`; } /** * @param {string} siteDir * @param {string} [translationDir] when building a non-default locale, the matching * `i18n//docusaurus-plugin-content-blog` tree. Any article that has a file there is * read FROM there, so the mirror and the llms.txt index carry the translated title, * description and body. Without it, `/fr/blog/.md` served the English Markdown under a * French URL and `/fr/llms.txt` advertised English titles — the content equivalent of the * duplicate-content problem plugins/i18n-seo-guard solves for HTML. See TODO 0119. */ function loadPosts(siteDir, translationDir = null) { const blogDir = path.join(siteDir, BLOG_DIR); const posts = []; for (const file of findPosts(blogDir)) { let sourceFile = file; if (translationDir) { const relative = path.relative(blogDir, file); const translated = path.join(translationDir, relative); if (fs.existsSync(translated)) sourceFile = translated; } const raw = fs.readFileSync(sourceFile, "utf-8"); const { attributes } = frontMatter(raw); // A Markdown file under blog/ without a title isn't an article (a // fragment, a note) — same rule posts.js applies. if (!attributes.title) continue; const dir = path.relative(blogDir, path.dirname(file)).split(path.sep).join("/"); posts.push({ file, raw, // Deliberately the ENGLISH article's folder, even when `raw` came from the translation: // `files/` and `images/` are co-located with the source, never duplicated under i18n/. currentFileDir: path.dirname(file), permalink: permalinkFor(attributes, dir), title: attributes.title, description: attributes.description || "", date: attributes.date, mainTag: attributes.mainTag || null, series: attributes.series || null, draft: attributes.draft || false, unlisted: attributes.unlisted || false, }); } return posts; } /** * Absolute URL for a site-relative path, honouring the locale's baseUrl. * * `siteConfig.url` is the bare origin and `siteConfig.baseUrl` carries the locale segment * (`/` for the default locale, `/fr/` for French). Concatenating `url + permalink` skips the * locale entirely, so the French llms.txt advertised English URLs for translated articles. * See TODO 0119. */ function absoluteUrl(siteConfig, sitePath) { const base = siteConfig.baseUrl.endsWith("/") ? siteConfig.baseUrl : `${siteConfig.baseUrl}/`; return `${siteConfig.url}${base}${String(sitePath).replace(/^\//, "")}`; } // YAML frontmatter dates (`date: 2024-02-23`) are auto-typed as JS Date // objects by the YAML parser front-matter uses — string-concatenating one // directly calls its verbose default toString(). Normalize to plain // YYYY-MM-DD instead. function formatDate(date) { if (!date) return "—"; return date instanceof Date ? date.toISOString().slice(0, 10) : String(date); } // The comment carries the same info a human reader would otherwise lose once // this file is copied or pasted elsewhere: where the rendered original lives // (images, interactive components, the lot). Kept as an HTML comment — invisible // in Markdown, so a reader pasting the file into a renderer isn't shown a stray // metadata block, while it still reads as plain text in a raw view or in // whatever an LLM is handed. // // Deliberately no build timestamp. One wall-clock value rewrote all 247 mirrors // plus the 25 llms bundles on every single build (5.2 MB), which the deploy then // had to re-upload even when no article had changed. The prose below already // says the file is generated at build time, and `published` carries the date a // reader actually needs. function buildMetadataComment(post, siteConfig) { const url = absoluteUrl(siteConfig, post.permalink); return [ "", "", ].join("\n"); } function buildHeader(post, siteConfig) { const lines = [buildMetadataComment(post, siteConfig), `# ${post.title}`, ""]; if (post.description) { lines.push(`> ${post.description}`, ""); } lines.push("---", ""); return lines.join("\n"); } function writeLlmsTxt(outDir, siteConfig, posts, seriesFiles) { const byTag = new Map(); for (const post of posts) { const tag = post.mainTag || "uncategorized"; if (!byTag.has(tag)) byTag.set(tag, []); byTag.get(tag).push(post); } const lines = [`# ${siteConfig.title}`, ""]; if (siteConfig.tagline) lines.push(`> ${siteConfig.tagline}`, ""); lines.push( `${posts.length} articles. Each has a plain-Markdown mirror at its own permalink ` + `plus \`.md\` (e.g. \`${absoluteUrl(siteConfig, "/blog/.md")}\`).`, "", ); // This is the only place the per-series bundles are ever linked from — without // it they exist on disk but are unreachable from anything an LLM would fetch. if (seriesFiles.length > 0) { lines.push( "## Series (full-text bundles)", "", "Each link is every article in that series, concatenated in reading order — " + "one request instead of many.", "", ); for (const s of seriesFiles) { lines.push( `- [${s.name}](${absoluteUrl(siteConfig, `/llms/${s.slug}.txt`)}) — ${s.count} article(s)`, ); } lines.push(""); } for (const tag of [...byTag.keys()].sort()) { lines.push(`## ${tag}`, ""); const sorted = [...byTag.get(tag)].sort((a, b) => a.title.localeCompare(b.title)); for (const post of sorted) { const desc = post.description ? ` — ${post.description}` : ""; lines.push(`- [${post.title}](${absoluteUrl(siteConfig, post.permalink)})${desc}`); } lines.push(""); } fs.writeFileSync(path.join(outDir, "llms.txt"), lines.join("\n"), "utf-8"); } // Returns the series that were actually written, sorted by name — this list is // what lets writeLlmsTxt() link to them; without it they'd be orphaned on disk. function writeSeriesFull(outDir, posts) { const bySeries = new Map(); for (const post of posts) { if (!post.series) continue; if (!bySeries.has(post.series)) bySeries.set(post.series, []); bySeries.get(post.series).push(post); } if (bySeries.size === 0) return []; const seriesDir = path.join(outDir, "llms"); fs.mkdirSync(seriesDir, { recursive: true }); const written = []; for (const [seriesName, seriesPosts] of bySeries) { const sorted = [...seriesPosts].sort((a, b) => new Date(a.date) - new Date(b.date)); const chunks = [ `# ${seriesName}`, "", `A series of ${sorted.length} article(s), in reading order.`, "", ]; for (const post of sorted) { chunks.push(post.markdown, "", "---", ""); } const slug = createSlug(seriesName); fs.writeFileSync(path.join(seriesDir, `${slug}.txt`), chunks.join("\n"), "utf-8"); written.push({ name: seriesName, slug, count: sorted.length }); } return written.sort((a, b) => a.name.localeCompare(b.name)); } module.exports = function markdownExportPlugin() { return { name: "markdown-export-plugin", async postBuild({ outDir, siteDir, siteConfig, routesPaths, i18n }) { const currentLocale = i18n?.currentLocale; const defaultLocale = i18n?.defaultLocale ?? siteConfig.i18n?.defaultLocale; const isTranslatedLocale = Boolean( currentLocale && currentLocale !== defaultLocale, ); const posts = loadPosts( siteDir, isTranslatedLocale ? path.join(siteDir, "i18n", currentLocale, "docusaurus-plugin-content-blog") : null, ); // In a non-default locale, mirror and index ONLY the articles really translated into it. // `loadPosts()` reads `blog/`, i.e. the English corpus, and Docusaurus's i18n fallback // gives every one of them a live `/fr/` route — so without this, `/fr/llms.txt` would // advertise 257 English articles as the French corpus, and `/fr/blog/.md` would // mirror English prose under a French URL. See TODO 0119. let localeFiltered = posts; if (isTranslatedLocale) { const { collectTranslations, } = require("../translations-manifest-plugin/index.cjs"); const translated = new Set(collectTranslations(siteDir)[currentLocale] ?? []); // These post objects carry a `permalink`, not a `slug` — assuming the latter silently // filtered everything out (`translated.has(undefined)` is always false). localeFiltered = posts.filter((post) => { const match = String(post.permalink ?? "").match(/blog\/([^/]+)\/?$/); return match ? translated.has(match[1]) : false; }); console.log( `[markdown-export] locale ${currentLocale}: ${localeFiltered.length} translated article(s).`, ); } // Only mirror posts that actually got a live route — this is what makes // "draft" (excluded in prod by Docusaurus's own blog plugin) and // ".unpublished/" (outside blog/, never globbed here anyway) fall out // for free, without re-deriving Docusaurus's own draft rules. // `routesPaths` carries the locale's baseUrl (`/fr/blog//`) while `permalinkFor()` // builds a bare `/blog/` — comparing them directly matched nothing under a // non-default locale, so every mirror silently disappeared while the build stayed green. // This is the mirror image of the bug in blog-feed-plugin's getArticleHtml(), where the // prefix was present twice instead of missing once. See TODO 0119. const baseUrl = siteConfig.baseUrl.endsWith("/") ? siteConfig.baseUrl : `${siteConfig.baseUrl}/`; const withBaseUrl = (permalink) => `${baseUrl}${permalink.replace(/^\//, "")}`.replace(/\/$/, ""); const liveRoutes = new Set(routesPaths.map((route) => route.replace(/\/$/, ""))); const live = localeFiltered.filter((post) => liveRoutes.has(withBaseUrl(post.permalink)), ); console.log( `[markdown-export] Mirroring ${live.length}/${posts.length} blog posts to Markdown…`, ); const unknownAll = new Set(); const indexable = []; let failed = 0; for (const post of live) { let markdown; try { const result = await mdxToMarkdown(post.raw, { currentFileDir: post.currentFileDir, projectRoot: siteDir, }); markdown = buildHeader(post, siteConfig) + result.markdown; for (const name of result.unknownComponents) unknownAll.add(name); } catch (err) { // One broken article must never take the whole `yarn build` down — // the "one rule" in degrade.cjs already prevents almost every crash; // this is the last-resort net for whatever it doesn't catch. failed += 1; console.error( `[markdown-export] Failed to degrade ${post.file}: ${err.message}`, ); continue; } const mirrorPath = path.join(outDir, `${post.permalink.replace(/\/$/, "")}.md`); fs.mkdirSync(path.dirname(mirrorPath), { recursive: true }); fs.writeFileSync(mirrorPath, markdown, "utf-8"); if (!post.draft && !post.unlisted) { indexable.push({ ...post, markdown }); } } const seriesFiles = writeSeriesFull(outDir, indexable); writeLlmsTxt(outDir, siteConfig, indexable, seriesFiles); if (unknownAll.size > 0) { console.warn( `[markdown-export] ${unknownAll.size} component(s) had no explicit degrade rule and fell back ` + `to the generic "keep children, drop wrapper" behavior — add a rule in ` + `plugins/markdown-export-plugin/degrade.cjs's COMPONENT_RULES if this is a real gap:\n` + [...unknownAll] .sort() .map((name) => ` - ${name}`) .join("\n"), ); } console.log( `[markdown-export] Done — ${indexable.length} article(s) in /llms.txt, ` + `${live.length - failed}/${live.length} mirrored, ${failed} failed.`, ); }, }; }; ``` Enregistrez-le comme n'importe quel autre plugin : ```javascript title="docusaurus.config.js" plugins: [ "./plugins/markdown-export-plugin/index.cjs", // ... ], ``` > **Auto-hébergé ? Attention au type MIME:** > > `/blog/my-slug` (comme `/blog/docusaurus-llms-txt` pour cet article) est un *répertoire* contenant `index.html`, donc son voisin `/blog/my-slug.md` ne crée aucun conflit de route — celui-là est gratuit. Mais Apache n'a aucun mapping MIME natif pour `.md`, donc sans `AddType text/markdown .md` dans `.htaccess`, le navigateur propose un téléchargement au lieu d'afficher le fichier. ## La seule règle qui rend tout ça sûr {#the-one-rule-that-makes-this-safe} **Un composant inconnu ne fait jamais échouer l'export. Son wrapper est supprimé et ses enfants prennent sa place.** Aucune exception, aucune erreur, jamais. C'est cette unique règle qui permet à une table de dégradation de 900 lignes de vivre dans un pipeline de build sans devenir un risque. Ajoutez un composant dans six mois, oubliez d'écrire sa règle, et le pire cas est que son wrapper disparaisse alors que son contenu survit. Mais un repli silencieux est une fuite lente : le plugin enregistre donc chaque composant qu'il n'a pas reconnu et avertit une fois par build. **C'est cet avertissement, pas la table, qui maintient la couverture à 100 % dans le temps** — la table est figée le jour où vous l'écrivez ; l'avertissement remarque le jour où vous la cassez. ## Rendre le tout découvrable {#making-it-discoverable} Générer un fichier n'est pas le publier : sans lien qui pointe vers lui, personne — crawler ou humain — ne trouve `/llms.txt` ou un bundle de série tout seul. Quatre points d'accroche comblent ce manque, chacun visant un consommateur différent : **1. `llms.txt` référence ses propres bundles.** L'index s'ouvre sur une section « Series (full-text bundles) ». C'est le seul endroit où ils sont référencés. **2. Un `` à l'échelle du site,** dans `headTags`, pour qu'il soit rendu côté serveur sur chaque page — exactement la façon dont on a toujours découvert le RSS : ```javascript title="docusaurus.config.js" { tagName: "link", attributes: { rel: "alternate", type: "text/markdown", href: "https://www.avonture.be/llms.txt", title: "llms.txt — full site index in Markdown, for LLMs and readers", }, }, ``` **3. Un équivalent par article,** pointant vers le miroir de cet article, injecté par un composant minuscule branché sur la page d'article : **src/components/MarkdownAlternate/index.tsx** ```typescript // Points crawlers/tools that scan at this exact // article's plain-Markdown mirror (written by plugins/markdown-export-plugin, // see src/components/CopyAsMarkdown for the human-facing equivalent). Same // discovery pattern as RSS's , // scoped to a single page instead of the whole site — the site-wide // counterpart (pointing at /llms.txt) lives in docusaurus.config.js's // headTags, since that one applies to every page, not just blog posts. import type { JSX } from "react"; import Head from "@docusaurus/Head"; import { useBlogPost } from "@docusaurus/plugin-content-blog/client"; import { translate } from "@docusaurus/Translate"; export default function MarkdownAlternate(): JSX.Element { const { metadata } = useBlogPost(); return ( ); } ``` **4. Un fil d'Ariane dans `robots.txt`.** Il n'existe aucune directive standard pour ça — c'est un commentaire, lu par tout ce qui parse déjà ce fichier et en cherche un. Ça a coûté deux lignes. > **Une limitation, honnêtement:** > > Le lien destiné aux humains « View this series as plain Markdown » sur `/series/` est invisible pour un crawler qui n'exécute pas JavaScript : cette page est une route React Router côté client, donc son lien n'apparaît jamais dans le HTML rendu côté serveur. Les deux balises `` ci-dessus n'ont pas ce problème — et c'est précisément pour ça que les points de découverte qui comptent vivent dans `headTags` et dans un composant, pas dans le corps d'une page. Et puis il y a la partie qu'aucun code ne fera pour vous : **soumettre aux annuaires**. Les bons sont ceux vers lesquels la page officielle de la spec renvoie. J'ai réussi à m'enregistrer sur `llmstxt.site` et `directory.llmstxt.cloud` ; `llmstxthub.com` avait un formulaire de soumission cassé le jour où j'ai essayé. Dix minutes bien investies. ## Conclusion {#conclusion} Ce que j'aime dans le résultat a moins à voir avec l'adoption par les IA que je ne l'imaginais au départ : * **N'importe qui peut faire un `curl` sur un article et l'obtenir en entier.** Pas seulement un bot — un script, un collègue, moi sur une connexion lente — obtient le texte complet, sans HTML, sans JavaScript, sans navigation à nettoyer à la main d'abord. * **Le blog est bot-friendly, que `llms.txt` prenne ou non.** Une version en Markdown pur de chaque article existe maintenant comme effet de bord, indépendamment du sort de cette convention. * **Un fichier Markdown tombe directement dans un éditeur de texte ou dans la fenêtre de contexte d'un LLM.** Plus besoin de tout sélectionner et de supprimer la barre de navigation avant qu'un article soit réellement utilisable ailleurs. * **`/llms.txt` fait aussi office d'inventaire du blog** — chaque article, une ligne chacun, groupé par sujet. Une table des matières que je n'avais pas avant, générée gratuitement. * **Les bundles par série transforment 23 requêtes en une.** Pratique pour un assistant qui récupère du contexte, tout aussi pratique pour un lecteur qui veut une série entière dans un seul fichier, à lire ou à archiver. * **Rien de tout ça n'a besoin d'être retenu.** C'est une étape de build, pas une habitude — et c'est exactement pour ça qu'une convention non prouvée méritait une heure d'essai. Si les 107 accordéons [``](/blog/docusaurus-snippets) fermés qui ont déclenché toute cette histoire sont nouveaux pour vous, ce composant mérite sa propre lecture. --- # Secouez votre téléphone, surprenez le suricate > Un neuvième easter egg pour ce blog, pensé pour les lecteurs sur téléphone : secouez-le — dans Chrome ou dans la PWA installée — et la mascotte suricate prend tout l'écran, surprise, pendant environ deux secondes. On y parle du calcul du « jerk » via devicemotion derrière la détection de secousse, de l'animation de l'overlay en trois phases, de la façon de tester le tout depuis une console DevTools sur desktop sans le moindre accéléromètre, et d'un vrai bug découvert en chemin : un générateur d'images IA qui a dessiné le damier censé représenter la transparence sous forme de pixels réellement opaques au lieu d'un vrai canal alpha. --- ![Secouez votre téléphone, surprenez le suricate](/img/v2/shaked_meerkat.webp) **TL;DR** Ce blog cache déjà [huit petits easter eggs](/blog/docusaurus-easter-eggs), mais chacun d'eux suppose un clavier ou un panneau DevTools ouvert — inutile pour un lecteur sur téléphone. En voici un neuvième : secouez le téléphone et la mascotte apparaît en plein écran, surprise, pendant environ deux secondes. Au programme : le calcul de la détection de secousse (un seuil de « jerk » calculé entre deux échantillons `devicemotion` consécutifs), l'animation de l'overlay en trois phases (apparition, tremblement, disparition), comment la déclencher et la tester depuis une console DevTools sur desktop sans aucun accéléromètre, et un vrai bug découvert en chemin : un générateur d'images IA qui a dessiné le damier utilisé pour *représenter* la transparence sous forme de pixels réellement opaques, repéré en lisant les octets bruts du PNG plutôt qu'en se fiant à un aperçu. **Android uniquement**, délibérément et définitivement — la demande de permission de mouvement d'iOS Safari exige un tap, et toutes les façons d'en fournir un entrent en conflit avec la règle « invisible jusqu'à ce que vous tombiez dessus » que ce site applique à ses easter eggs. Jusqu'ici, chaque easter egg de ce blog suppose un clavier : `CTRL`+`U` pour afficher la source, une séquence Konami à dix touches, un `console.log` qui n'apparaît qu'avec les DevTools ouverts. Tout à fait raisonnable sur un portable — et invisible pour qui lit sur un téléphone, ce qui, sur un blog technique, représente encore une part non négligeable du trafic. Un téléphone n'a pas de touches fléchées, mais il a quelque chose qu'un portable n'a pas : un accéléromètre, à un `window.addEventListener("devicemotion", …)` de distance, sur n'importe quelle page servie en HTTPS. Donc : secouez le téléphone, et la mascotte réagit. ## Secouez votre téléphone et voyez ce qui se passe {#shake-your-phone-see-what-happens} Visitez ce blog sur Android — dans Chrome, ou dans la PWA installée — et secouez vraiment le téléphone. Voici ce qui apparaît, en plein écran, pendant environ deux secondes : > Screenshot — https\://www\.avonture.be/ > > ![Un suricate surpris qui remplit l'écran après une secousse du téléphone](./images/shake_overlay.webp) Pas de rechargement de page, rien à activer d'abord — le capteur de mouvement du téléphone a fait tout le travail. Voici le peu de code que ça demande réellement. > **Rien ne se passe quand vous secouez ?:** > > Vérifiez que ce site est autorisé à lire les **capteurs de mouvement** — tapez sur l'icône à gauche de la barre d'adresse → Autorisations. Chrome pour Android peut bloquer ça silencieusement site par site ; voyez [Comment débugger si la secousse ne fait rien](#how-to-debug-if-the-shake-does-nothing) plus bas. ## Pourquoi ça fonctionne {#why-it-works} * **La détection de secousse a besoin d'un seul nombre, pas de trois.** Un événement `devicemotion` rapporte l'accélération sur trois axes (x, y, z) ; comparer chacun à un seuil fonctionne à peine, parce qu'une manipulation ordinaire fait déjà osciller les trois en permanence. Ce qui marque réellement une secousse volontaire, c'est *la vitesse à laquelle* ces trois valeurs changent entre deux relevés consécutifs — sommée sur les axes et normalisée par le temps écoulé entre les échantillons, elle se réduit à une seule valeur de « jerk » avec un unique seuil propre à régler. * **Android n'a pas de prompt `requestPermission()` ; iOS, si.** Résultat : livré uniquement pour Android — délibérément, pas temporairement ; voyez [pourquoi](#why-ios-is-left-out-on-purpose) plus bas. Le fonctionnement des permissions d'Android a de toute façon sa propre subtilité, détaillée dans [Comment débugger si la secousse ne fait rien](#how-to-debug-if-the-shake-does-nothing). * **C'est un cooldown, pas un booléen, qui empêche le redéclenchement.** L'overlay reste à l'écran plus de deux secondes ; sans un écart minimum entre deux déclenchements, la fin du même geste de secousse le relancerait immédiatement. Comparer des timestamps est plus simple qu'un verrou, et ça ne peut pas rester bloqué en position « actif » si une étape de nettoyage est un jour sautée. * **C'est un seul composant, monté une seule fois, globalement** — [le même pattern que celui déjà utilisé pour le code Konami et le changement de favicon au changement d'onglet](/blog/docusaurus-easter-eggs) : pas de nouvelle route, pas de câblage page par page, une ligne de plus dans `Root.js`. * **La vibration haptique est un bonus, pas une exigence.** Un court appel à `navigator.vibrate()` ajoute un « cri » physique au « cri » visuel — et sur les navigateurs qui l'ignorent silencieusement en dehors d'un geste utilisateur (ce qu'un événement de capteur n'est pas), l'easter egg fonctionne très bien, juste sans la vibration. ## Pourquoi iOS est volontairement laissé de côté {#why-ios-is-left-out-on-purpose} iOS Safari 13+ exige un appel explicite à `DeviceMotionEvent.requestPermission()`, lié à un vrai tap, avant de livrer le moindre relevé `devicemotion` — et cet appel ne peut pas être fait de façon proactive au chargement de la page. Quelque chose doit le déclencher, ce qui laisse exactement deux options, toutes deux rejetées. La première est un contrôle visible : un petit bouton ou bandeau « Activer les effets de secousse », affiché une fois sur iOS. Ça fonctionnerait — mais ce serait aussi le premier élément d'interface de tout ce blog qui existe uniquement pour demander quelque chose avant que le lecteur ait fait quoi que ce soit pour le mériter. Tous les autres easter eggs ici suivent une règle, énoncée noir sur blanc dans l'article qui présentait les huit premiers : > **La règle à laquelle ce blog soumet chaque easter egg:** > > Chaque easter egg vit dans la console, dans la source, dans les en-têtes de réponse, ou dans un état que le visiteur déclenche volontairement. Aucun n'apparaît jamais dans le flux de lecture normal. **Si vous ne le cherchez pas, vous ne le verrez jamais.** Un bouton demandant l'accès au capteur de mouvement casse ça à vue, pour chaque visiteur iOS, qu'il se soucie ou non d'un suricate secoué — un coût payé d'avance par 100 % d'entre eux, pour une récompense que seule une minorité curieuse déclencherait. C'est un mauvais échange avant même d'être un choix de design. La seconde option est pire d'une autre manière : supprimer le bouton visible et déclencher `requestPermission()` au tout premier tap du visiteur n'importe où sur la page — un lien de navigation, une icône de recherche, peu importe. Techniquement invisible dans l'interface de *ce* site, puisque rien de rendu par ce composant n'apparaîtrait — mais iOS affiche quand même sa propre boîte de dialogue système « Autoriser l'accès aux mouvements et à l'orientation ? » la première fois que l'appel est fait pour une origine. Un lecteur qui a tapé sur un élément de menu se voit interrompu par une demande d'autorisation, sans aucun contexte expliquant pourquoi un blog l'interroge soudain sur les capteurs de mouvement. Ce n'est pas la surprise amusante que ce site recherche ; c'est un non-sens qui dégrade la toute première interaction d'une visite sans rapport. Les deux chemins dépensent quelque chose qui appartient à tous les visiteurs — l'attention, la confiance, ou un premier tap propre — pour financer une fonctionnalité que seuls certains voudraient. Un easter egg gagne son droit d'exister en ne coûtant rien à tous ceux qui ne le cherchent pas ; une demande d'autorisation, sous n'importe quelle forme, échoue à ce test avant même que le suricate ait une chance de surprendre. Android ne demandait rien, donc c'est sur Android que ça sort. iOS reste dehors, non pas parce que le code est difficile, mais parce que chaque façon de le rendre possible là-bas fait payer un loyer à un lecteur venu ici pour lire, pas pour accorder des permissions. ## Installation {#installation} Le tout tient en deux fichiers plus un import. En partant d'une image à fond transparent de la mascotte surprise — plus de détails sur la façon dont cette image a réellement été produite, et sur l'erreur qui a failli passer en production, dans [Sous le capot](#under-the-hood-skip-this-if-you-just-want-to-use-it) plus bas — la détection et l'overlay vivent entièrement dans `index.tsx` : **src/components/ShakeEasterEgg/index.tsx** ```typescript import { useCallback, useEffect, useRef, useState, type JSX } from "react"; import clsx from "clsx"; import scaredImg from "@site/static/img/meerkat/suricate_scared.webp"; import styles from "./styles.module.css"; import { translate } from "@docusaurus/Translate"; // "Jerk" (rate of change of acceleration) threshold that counts as a shake. // History: 28 (initial) fired from a mere tilt; 60 (first field fix) still // fired too easily across a full day of real-world phone use. Raise further // if it still fires too easily, lower it if a real shake stops triggering it. const SHAKE_JERK_THRESHOLD = 100; // Ignore devicemotion samples firing faster than this: some devices report // at 60Hz+, which would otherwise make every tiny jitter look like a spike. const MIN_SAMPLE_INTERVAL_MS = 100; // Minimum time between two triggers, so one shake gesture doesn't retrigger // itself while the overlay is still showing. const COOLDOWN_MS = 4000; // How long the overlay stays fully visible before it starts fading out. const VISIBLE_DURATION_MS = 2200; // Fade-out duration; kept in sync with the `overlay-out` keyframes below. const EXIT_DURATION_MS = 250; type Phase = "hidden" | "visible" | "leaving"; export default function ShakeEasterEgg(): JSX.Element | null { const [phase, setPhase] = useState("hidden"); const lastAcceleration = useRef<{ x: number; y: number; z: number } | null>(null); const lastSampleTime = useRef(0); const lastTriggerTime = useRef(0); const leaveTimeoutRef = useRef(undefined); const hideTimeoutRef = useRef(undefined); const clearTimers = useCallback(() => { window.clearTimeout(leaveTimeoutRef.current); window.clearTimeout(hideTimeoutRef.current); }, []); const dismiss = useCallback(() => { clearTimers(); setPhase("leaving"); hideTimeoutRef.current = window.setTimeout( () => setPhase("hidden"), EXIT_DURATION_MS, ); }, [clearTimers]); const trigger = useCallback(() => { clearTimers(); setPhase("visible"); // Best-effort haptic feedback. Browsers that require a user gesture for // navigator.vibrate() (devicemotion isn't one) will just ignore this. navigator.vibrate?.([40, 30, 40]); leaveTimeoutRef.current = window.setTimeout(() => { setPhase("leaving"); hideTimeoutRef.current = window.setTimeout( () => setPhase("hidden"), EXIT_DURATION_MS, ); }, VISIBLE_DURATION_MS); }, [clearTimers]); useEffect(() => { if (typeof window === "undefined" || !window.DeviceMotionEvent) { return undefined; } const handleMotion = (event: DeviceMotionEvent) => { const acceleration = event.accelerationIncludingGravity; // All three axes must be present: DeviceMotionEventAcceleration types each // as `number | null`, and the jerk maths below reads x, y and z. if ( !acceleration || acceleration.x === null || acceleration.y === null || acceleration.z === null ) return; const now = Date.now(); const deltaTime = now - lastSampleTime.current; if (deltaTime < MIN_SAMPLE_INTERVAL_MS) return; const { x, y, z } = acceleration; const previous = lastAcceleration.current; if (previous) { const jerk = ((Math.abs(x - previous.x) + Math.abs(y - previous.y) + Math.abs(z - previous.z)) / deltaTime) * 1000; if (jerk > SHAKE_JERK_THRESHOLD && now - lastTriggerTime.current > COOLDOWN_MS) { lastTriggerTime.current = now; trigger(); } } lastAcceleration.current = { x, y, z }; lastSampleTime.current = now; }; window.addEventListener("devicemotion", handleMotion); return () => window.removeEventListener("devicemotion", handleMotion); }, [trigger]); useEffect(() => clearTimers, [clearTimers]); if (phase === "hidden") { return null; } return ( ); } ``` Et l'overlay plein écran, son animation apparition/tremblement/disparition, ainsi que le repli `prefers-reduced-motion` vivent dans le module CSS : **src/components/ShakeEasterEgg/styles.module.css** ```css .overlay { position: fixed; inset: 0; z-index: 10001; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; margin: 0; padding: 0; border: none; background: radial-gradient( circle at center, rgb(0, 0, 0, 0.65) 0%, rgb(0, 0, 0, 0.88) 100% ); cursor: pointer; animation: overlay-in 0.2s ease-out both; } .overlay.leaving { animation: overlay-out 0.25s ease-in both; } .meerkat { width: min(88vw, 80vh); height: auto; filter: drop-shadow(0 16px 40px rgb(0, 0, 0, 0.6)); animation: pop-in 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) both, tremble 0.3s ease-in-out 0.35s 6; } @keyframes overlay-in { from { opacity: 0; } to { opacity: 1; } } @keyframes overlay-out { from { opacity: 1; } to { opacity: 0; } } @keyframes pop-in { from { transform: scale(0.4) rotate(-8deg); opacity: 0; } to { transform: scale(1) rotate(0deg); opacity: 1; } } @keyframes tremble { 0%, 100% { transform: translate(0, 0) rotate(0deg); } 20% { transform: translate(-6px, 3px) rotate(-3deg); } 40% { transform: translate(5px, -4px) rotate(3deg); } 60% { transform: translate(-4px, -2px) rotate(-2deg); } 80% { transform: translate(6px, 3px) rotate(2deg); } } @media (prefers-reduced-motion: reduce) { .overlay { animation: none; } .overlay.leaving { animation: none; opacity: 0; } .meerkat { animation: none; } } ``` Monté une fois, globalement, depuis `src/theme/Root.js` — juste à côté du composant du code Konami dont il emprunte la forme : ```jsx title="src/theme/Root.js (excerpt)" import ShakeEasterEgg from "@site/src/components/ShakeEasterEgg"; // ... return ( <> {children} ); ``` C'est tout — pas de nouvelle route, pas de flag de configuration, pas d'étape de build. ## Tester sans rien secouer {#testing-it-without-shaking-anything} Se lever de son bureau pour secouer physiquement un téléphone, vingt fois de suite, pour vérifier un ajustement de timing CSS, lasse très vite. `devicemotion` n'est qu'un événement — rien n'empêche d'en envoyer un de façon synthétique depuis une console de navigateur, sur desktop, sans aucun capteur dans la boucle : **Paste in DevTools console** ``` // Paste in your browser's DevTools console — desktop Chrome works fine, // no physical accelerometer needed — to trigger the shake overlay without // shaking anything. This is the exact snippet used to test the effect // before a single real device was ever involved. const fire = (x, y, z) => { window.dispatchEvent( new DeviceMotionEvent("devicemotion", { accelerationIncludingGravity: { x, y, z }, acceleration: { x, y, z }, interval: 16, }), ); }; fire(0, 0, 9.8); setTimeout(() => fire(25, -20, 15), 150); ``` Deux appels, espacés de 150 ms, avec un écart suffisamment grand entre les deux relevés d'accélération pour franchir le seuil de jerk — voilà tout le geste « physique », réduit à deux nombres. C'est aussi, honnêtement, comme ça que la capture d'écran ci-dessus a été produite : un navigateur headless, un viewport mobile, et exactement ce snippet envoyé dans la page — pas un vrai téléphone. ## Comment débugger si la secousse ne fait rien {#how-to-debug-if-the-shake-does-nothing} Deux vérifications, dans l'ordre — chacune prend moins d'une minute et aucune ne demande de toucher au code. ### 1. Autoriser les capteurs de mouvement pour le site {#1-allow-motion-sensors-for-the-site} Chrome pour Android a sa propre autorisation **Capteurs de mouvement** par site, indépendante de tout ce que ce composant contrôle. Tapez sur l'icône à gauche de la barre d'adresse (cadenas, « i », ou un triangle d'avertissement si le certificat est auto-signé) → **Autorisations** → assurez-vous que **Capteurs de mouvement** est réglé sur *Autoriser*, pas sur *Bloquer*. Si ça n'y figure pas, vérifiez plutôt **Chrome ⋮ → Paramètres → Paramètres des sites → Capteurs de mouvement** pour un blocage global. Rechargez la page après modification — à elle seule, cette manip était le correctif dont l'unique appareil réel de test avait besoin. ### 2. Observer les données brutes du capteur {#2-watch-the-raw-sensor-data} Quand l'étape 1 n'explique rien, une petite page de diagnostic autonome règle le reste — pas d'étape de build, pas de configuration de DevTools distants, juste des chiffres en direct sur l'écran du téléphone : **static/shake-debug.html** ```html Shake debug

Shake debug

DeviceMotionEvent?
isSecureContext?
location?
requestPermission (iOS-style)?
Permissions API query?
Waiting for the first devicemotion event…
events received0
x-
y-
z-
jerk (threshold 100)-
max jerk seen0
``` Visitez `/shake-debug.html` sur la même origine que le reste du site et la page rapporte, en direct : si `DeviceMotionEvent` existe tout simplement, l'état de `navigator.permissions.query({ name: "accelerometer" })` (`"granted"` / `"denied"` / `"prompt"`), un compteur d'événements reçus, les relevés `x`/`y`/`z` en direct, et la valeur de jerk calculée face au seuil actuel. Trois signatures d'échec distinctes à y lire : * **Aucun événement, compteur bloqué à 0** — le navigateur ou l'OS bloque totalement le capteur, ou `DeviceMotionEvent` n'est pas supporté (iOS sans le flux de permission, voyez la limitation ci-dessus). * **Les événements arrivent, mais `x`/`y`/`z` restent à `null`** — c'est l'autorisation Capteurs de mouvement de l'étape 1, confirmée : le navigateur émet l'événement par habitude mais retient les données. * **De vrais chiffres, mais le jerk maximum ne franchit jamais le seuil** — pas un bug, juste un seuil calibré pour une autre main ; voyez la note de réglage plus bas. C'est aussi, autant l'admettre franchement, comme ça que le bug de l'étape 1 a été trouvé au départ : la secousse ne faisait rien sur un vrai téléphone, cette page montrait le compteur d'événements grimper avec `x`/`y`/`z` bloqués à `null`, et la requête de permission expliquait exactement pourquoi en un seul mot — `"denied"`. ## Sous le capot (passez si vous voulez juste l'utiliser) {#under-the-hood-skip-this-if-you-just-want-to-use-it} ### L'image était la partie difficile, pas le code {#the-image-was-the-hard-part-not-the-code} Le composant lui-même s'est écrit d'un seul jet. Obtenir une **vraie** image à fond transparent du suricate surpris a demandé plus de soin que le code — bon à savoir si vous générez des visuels avec un outil d'image IA pour quoi que ce soit qui doit se composer par-dessus d'autres contenus. Les visuels de la mascotte du site sont [générés avec Google Gemini](/blog/gemini-meerkat), en fournissant une image de référence existante pour que les nouvelles poses restent visuellement cohérentes. Pour un overlay plein écran, « fond transparent » n'est pas une préférence esthétique, c'est une exigence stricte : le fichier a besoin d'un vrai canal alpha, pas juste de quelque chose qui *paraît* transparent dans un aperçu. Le prompt demandait explicitement un export PNG, avec une ligne indiquant au modèle de ne pas dessiner le damier gris et blanc que les éditeurs utilisent pour *représenter* la transparence comme s'il faisait partie de l'image — ce motif est une convention d'interface, pas des pixels à peindre. Deux faits liés aux formats rendent le résultat facile à vérifier, plutôt que de se fier à un aperçu : * Le JPEG ne peut structurellement pas stocker la transparence — un export PNG (ou WebP) est non négociable dès le départ. * Le chunk `IHDR` d'un PNG porte un octet dédié exactement à ça : `6` signifie RGBA avec un vrai canal alpha, `2` signifie du RGB simple sans alpha. Lire cet octet — ou juste échantillonner un pixel dans un coin et vérifier que son alpha vaut réellement `0` — règle la question sans ouvrir d'éditeur. Le fichier final a passé les deux contrôles : alpha `6` dans le chunk `IHDR`, coins à alpha `0`, personnage entièrement opaque — vérifié pixel par pixel plutôt qu'à l'œil. ### Régler le seuil de jerk sur un vrai téléphone {#tuning-the-jerk-threshold-on-a-real-phone} La première valeur livrée pour `SHAKE_JERK_THRESHOLD` (`28`) a été choisie au feeling, avec une note admettant qu'elle n'avait pas été mesurée sur un appareil physique. Elle n'a pas survécu au premier contact : sur un vrai téléphone Android, une simple *inclinaison* — sans aucune secousse — suffisait déjà à la franchir. Une première correction l'a portée à `60`, ce qui a réglé le déclenchement à l'inclinaison — mais une journée entière avec le téléphone porté normalement le déclenchait encore régulièrement, ce que `60` seul n'avait pas révélé en cinq minutes de test. C'est `100` qui a fini par être retenu. Aucune formule ne permet de trouver ça depuis un bureau, et apparemment pas non plus en cinq minutes sur un vrai téléphone ; un seuil de « jerk » doit survivre au port et à la manipulation réels dans le temps, pas seulement à une secousse de test volontaire, avant qu'on puisse lui faire confiance. ## Conclusion {#conclusion} Un neuvième easter egg, et le premier que ce blog conçoit pour un téléphone plutôt que pour un clavier : une détection de secousse réduite à un seul nombre de « jerk », un overlay plein écran qui respecte `prefers-reduced-motion` et se ferme d'un tap, et une façon de tester tout ça sans quitter son bureau. La vraie leçon n'était pas dans le code de détection — elle était dans le fait de ne pas faire confiance à un aperçu d'image : un fichier peut *paraître* transparent et être totalement opaque, et le seul moyen d'en être sûr est de vérifier les octets. iOS reste dehors définitivement, [pas par manque d'essais mais par choix](#why-ios-is-left-out-on-purpose), et c'est en soi le rappel à garder : un easter egg n'a qu'à ravir ceux qui le cherchent, et ne jamais rien coûter à ceux qui ne le cherchent pas — une barre sous laquelle une vraie fonctionnalité ne peut pas passer, mais une récompense cachée, si. Si vous faites tourner votre propre blog Docusaurus avec sa mascotte, le [code Konami et le changement de favicon au changement d'onglet](/blog/docusaurus-easter-eggs) sont les cousins desktop de celui-ci — même forme « monté une fois dans `Root.js` », pas de capteur, pas de compromis sur les permissions. **Ce qu'il faut régler** * **`SHAKE_JERK_THRESHOLD`** (par défaut `100`, relevé depuis `28` puis `60` après un usage réel où ça se déclenchait trop facilement) — augmentez-le encore si l'easter egg se déclenche toujours trop facilement, baissez-le si une secousse volontaire ne fait rien * **`COOLDOWN_MS`** (par défaut `4000`) — temps minimum entre deux déclenchements * **`VISIBLE_DURATION_MS`** (par défaut `2200`) / **`EXIT_DURATION_MS`** (par défaut `250`) — combien de temps l'overlay reste affiché et à quelle vitesse il disparaît ; gardez la durée CSS `overlay-out` synchronisée avec la seconde * Les quatre se trouvent en haut de `src/components/ShakeEasterEgg/index.tsx` --- # Ajouter un bouton « Copy as Markdown » à votre blog Docusaurus > Créez un bouton « Copy as Markdown » et un lien « View raw » pour les articles de votre blog Docusaurus — un plugin exécuté au build qui écrit un miroir en Markdown pur à côté de chaque article, plus le petit composant React qui le récupère et le copie dans le presse-papiers. Code source complet inclus. --- ![Ajouter un bouton « Copy as Markdown » à votre blog Docusaurus](/img/v2/copy-as-markdown.webp) **TL;DR** Ce guide ajoute un bouton « Copy as Markdown » et un lien « View raw » à chaque article d'un blog Docusaurus. Deux petites pièces suffisent : un plugin exécuté au build qui écrit un miroir en Markdown pur à côté de la page HTML de chaque article (`/blog/my-post` → `/blog/my-post.md`), et un composant React qui récupère ce miroir et le copie dans le presse-papiers. Pas de serveur, pas de base de données, environ 90 lignes de code au total — et les lecteurs, ou le LLM dans lequel ils collent l'URL, obtiennent le texte intégral de l'article sans React, sans JSX, et sans les accordéons repliés que la page HTML masque par défaut. Chaque fois que je veux coller un de mes propres articles dans un LLM pour lui poser une question, je passe par le même rituel agaçant : ouvrir la page, tout sélectionner, copier, coller, puis passer une minute à supprimer le menu de navigation, le widget « Was this helpful? » et une demi-douzaine de libellés d'interface venus avec le reste. Le vrai contenu de l'article — la partie que je voulais — est enfoui là-dedans quelque part. La source d'un article de blog est *déjà* du Markdown. Il n'y a aucune raison qu'un lecteur doive se battre avec le HTML rendu pour la récupérer. J'ai donc construit un petit bouton « Copy as Markdown » placé directement dans l'en-tête de l'article : un clic, et la source en texte brut arrive dans le presse-papiers, prête à être collée n'importe où. Cet article parcourt les deux pièces nécessaires pour le reproduire — un générateur de miroir au build et le bouton lui-même — avec le code source réel et actuel du bouton. **Quick Jump:** [Ce qui est copié](#what-gets-copied) | [Tous les fichiers en un coup d'œil](#all-files-at-a-glance) ## Ce qui est copié {#what-gets-copied} Voici le fichier que le bouton récupère réellement, produit par une étape `postBuild` que vous écrirez dans un instant — c'est le texte littéral qui arrive dans le presse-papiers quand un lecteur clique sur le bouton : ```bash $ yarn build [SUCCESS] Generated static files in "build". [INFO] markdown-mirror-plugin: wrote 247 .md mirrors $ curl -s https://your-domain.tld/blog/docusaurus-go-top.md | head -n 13 This article is a quick tutorial on how to add a 'scroll to top' button to your Docusaurus blog. It walks you through implementing a custom `` React component, including its CSS for styling. You'll learn how to override the default `BlogPostItem` theme component to automatically inject the button at the bottom of every blog post. The guide also provides tips on adding the button to other parts of your site, such as the blog archive or custom pages. More on smartphones than on computers; it's pretty nice to have a *Back to top of page* button to avoid dislocating your thumb. ``` Même la balise `` survit intacte — suffisant pour qu'un humain parcoure le texte ou qu'un LLM l'analyse. Remarquez aussi le commentaire HTML tout en haut : c'est ainsi que le miroir renvoie vers la vraie page entièrement rendue. ## Pourquoi il n'y a que deux pièces mobiles {#why-its-just-two-moving-parts} * Un bouton React qui récupère une URL dérivée du permalien de la page courante et copie le texte renvoyé — il n'a aucune idée de ce que « Markdown » veut dire. * Une étape de build qui fait en sorte que cette URL réponde vraiment quelque chose qui vaille la peine d'être copié : un fichier en texte brut par article, posé tranquillement à côté de la page HTML qu'il reflète. * Comme les deux ne communiquent qu'à travers une simple URL, l'un ou l'autre peut être remplacé indépendamment plus tard — un générateur de miroir plus intelligent, un bouton plus élaboré — sans toucher à l'autre fichier. * Un miroir manquant (mauvais build, serveur de dev, faute de frappe dans l'URL) se dégrade en un message clair « Could not copy » plutôt qu'en un échec silencieux ou un crash. ## Étape 1 — Générer un miroir en Markdown pur au build {#step-1--generate-a-plain-markdown-mirror-at-build-time} Le bouton n'est que la moitié de l'histoire : sans fichier `.md` à récupérer, il finit toujours dans l'état « Could not copy ». Ce fichier doit exister *avant* que le bouton soit utile, donc construisez-le d'abord. Créez `plugins/markdown-mirror-plugin/index.cjs` : **plugins/markdown-mirror-plugin/index.cjs** ```cjs // plugins/markdown-mirror-plugin/index.cjs // // Writes a plain-Markdown mirror of every published blog post next to its // HTML page: /blog/ -> /blog/.md // // Runs only during `yarn build` (postBuild), never during `yarn start`. const fs = require("fs"); const path = require("path"); const frontMatter = require("front-matter"); const BLOG_DIR = "blog"; function findPosts(dir) { const found = []; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const target = path.join(dir, entry.name); if (entry.isDirectory()) { found.push(...findPosts(target)); } else if (entry.name === "index.md" || entry.name === "index.mdx") { found.push(target); } } return found; } // An HTML comment, not visible prose: hidden by any Markdown renderer, but // still plain text in a raw view or in whatever an LLM is handed — exactly // where a reader who lost track of the original page needs it. function buildMetadataComment(url, buildDate) { return [ "", "", ].join("\n"); } module.exports = function markdownMirrorPlugin() { return { name: "markdown-mirror-plugin", async postBuild({ siteDir, outDir, siteConfig, routesPaths }) { const blogDir = path.join(siteDir, BLOG_DIR); const knownRoutes = new Set(routesPaths); // One timestamp for the whole run, shared by every mirror it writes. const buildDate = new Date().toISOString(); let written = 0; for (const file of findPosts(blogDir)) { const raw = fs.readFileSync(file, "utf-8"); const { attributes, body } = frontMatter(raw); if (!attributes.slug) continue; const permalink = `/blog/${attributes.slug}`; // Cross-checking against Docusaurus's own routesPaths is what makes // draft: true posts fall out for free — no need to duplicate that // logic here. if (!knownRoutes.has(permalink)) continue; const url = `${siteConfig.url}${permalink}`; const content = body.replace("\n", "").trim() + "\n"; const markdown = buildMetadataComment(url, buildDate) + content; const outFile = path.join(outDir, "blog", `${attributes.slug}.md`); fs.mkdirSync(path.dirname(outFile), { recursive: true }); fs.writeFileSync(outFile, markdown); written += 1; } console.log(`markdown-mirror-plugin: wrote ${written} .md mirrors`); }, }; }; ``` Quatre points méritent d'être soulignés : ```javascript title="plugins/markdown-mirror-plugin/index.cjs" function buildMetadataComment(url, buildDate) { return [ "", "", ].join("\n"); } ``` C'est la pièce qui répond à une question que tout miroir en texte brut finit par soulever : *d'où vient ce fichier ?* Un commentaire HTML est le bon contenant pour ça — invisible dès que le fichier est collé dans quelque chose qui rend vraiment le Markdown (Notion, Obsidian, une zone de commentaire GitHub), mais toujours en texte brut dans une vue brute ou dans ce qu'on donne à un LLM, si bien que l'URL survit exactement là où un lecteur ayant perdu la trace de la page d'origine en a besoin. `generated-at` utilise un seul horodatage partagé par tous les miroirs de l'exécution (calculé une fois dans `postBuild`, pas par fichier), ce qui reflète « ce build » et non « cette milliseconde ». ```javascript title="plugins/markdown-mirror-plugin/index.cjs" async postBuild({ siteDir, outDir, siteConfig, routesPaths }) { ``` `postBuild` est un [lifecycle hook](https://docusaurus.io/docs/api/plugin-methods/lifecycle-apis#postBuild) Docusaurus — il se déclenche une seule fois, après que `yarn build` a fini d'écrire le HTML, et jamais pendant `yarn start`. C'est exactement le bon moment : le miroir n'a besoin d'exister qu'en production. `siteConfig.url` est ce qui transforme un permalien nu en URL absolue dont le commentaire ci-dessus a besoin. ```javascript title="plugins/markdown-mirror-plugin/index.cjs" if (!knownRoutes.has(permalink)) continue; ``` `routesPaths` est la liste des URLs que Docusaurus lui-même a décidé de publier. La recouper — au lieu de réimplémenter à la main la logique « cet article est-il un brouillon ? » — est ce qui fait que les articles en `draft: true` disparaissent du miroir gratuitement. ```javascript title="plugins/markdown-mirror-plugin/index.cjs" const content = body.replace("\n", "").trim() + "\n"; const markdown = buildMetadataComment(url, buildDate) + content; ``` Le bloc de frontmatter est retiré par le package `front-matter` avant même que cette ligne s'exécute ; le `replace` ici ne supprime que le marqueur ``, qui n'a aucun sens en dehors de la logique de découpage des résumés propre à Docusaurus. Le commentaire de métadonnées vu plus haut est ajouté en dernier, juste avant l'écriture du fichier. Branchez-le dans votre configuration : ```javascript title="docusaurus.config.js" const config = { // ... plugins: ["./plugins/markdown-mirror-plugin/index.cjs"], }; ``` > **front-matter est un petit package sans dépendances:** > > `npm install front-matter` si votre projet ne l'a pas déjà — c'est un parseur de frontmatter YAML à usage unique, pas une chaîne d'outils Markdown complète. > **Cette version est volontairement simple:** > > Elle recopie votre source Markdown telle quelle, donc tout composant MDX personnalisé (un ``, une ``, vos propres widgets) apparaît dans le miroir sous forme de texte JSX brut plutôt qu'en équivalent Markdown. Pour une poignée de composants, c'est parfaitement lisible, comme le montre le miroir ci-dessus. Si votre blog s'appuie sur des dizaines de composants personnalisés comme celui-ci, voyez « Sous le capot » plus bas pour ce qu'une version plus complète doit gérer. ## Étape 2 — Le composant CopyAsMarkdown {#step-2--the-copyasmarkdown-component} Voici le code source réel et actuel qui tourne sur ce blog — pas un extrait simplifié : **src/components/CopyAsMarkdown/index.tsx** ```typescript import { useState, useCallback, useEffect, type JSX } from "react"; import styles from "./styles.module.css"; import Translate, { translate } from "@docusaurus/Translate"; interface Props { metadata: { permalink: string; }; } type Status = "idle" | "copying" | "copied" | "error"; /** * "Copy as Markdown" / "View raw" — reads the plain-Markdown mirror that * plugins/markdown-export-plugin writes next to every post's HTML page * (`/blog/` → `/blog/.md`, see TODO 0082) and copies its full * content to the clipboard. Lets a reader — or an LLM they paste it into — * get the article's complete text without React, JSX, or the accordions the * HTML page collapses by default. * * Only meaningful on a real `yarn build` output: the mirror is written in * `postBuild`, so on `yarn start` the fetch below 404s and the button * reports "Could not copy" — the same graceful-failure path a mirror that's * missing for any other reason takes. */ export default function CopyAsMarkdown({ metadata }: Props): JSX.Element { const [status, setStatus] = useState("idle"); const mdUrl = `${metadata.permalink.replace(/\/$/, "")}.md`; const handleCopy = useCallback(async () => { setStatus("copying"); try { const res = await fetch(mdUrl); if (!res.ok) throw new Error(`HTTP ${res.status}`); const text = await res.text(); await navigator.clipboard.writeText(text); setStatus("copied"); } catch (err) { console.error("CopyAsMarkdown: failed to copy", err); setStatus("error"); } }, [mdUrl]); useEffect(() => { if (status !== "copied" && status !== "error") return; const timer = setTimeout(() => setStatus("idle"), 2000); return () => clearTimeout(timer); }, [status]); return (
); } ``` ### 2.1 — L'état et l'URL dérivée {#21--state-and-the-derived-url} ```javascript title="src/components/CopyAsMarkdown/index.tsx" const [status, setStatus] = useState("idle"); // idle | copying | copied | error const mdUrl = `${metadata.permalink.replace(/\/$/, "")}.md`; ``` `status` est une petite machine à quatre états qui pilote tout ce que le bouton affiche. `mdUrl` retire le slash final éventuel du permalien de l'article et ajoute `.md` — ce qui correspond exactement à ce que le plugin de l'étape 1 écrit sur le disque. ### 2.2 — Le gestionnaire de copie {#22--the-copy-handler} ```javascript title="src/components/CopyAsMarkdown/index.tsx" const handleCopy = useCallback(async () => { setStatus("copying"); try { const res = await fetch(mdUrl); if (!res.ok) throw new Error(`HTTP ${res.status}`); const text = await res.text(); await navigator.clipboard.writeText(text); setStatus("copied"); } catch (err) { console.error("CopyAsMarkdown: failed to copy", err); setStatus("error"); } }, [mdUrl]); ``` Un `fetch` pour le miroir, un `writeText` vers le presse-papiers, et un seul `catch` autour des deux. Ce bloc `catch` fait double emploi : il attrape un miroir manquant (404, `!res.ok`) *et* un rejet de l'API Clipboard (permission refusée, contexte non sécurisé) avec le même résultat « error » — le lecteur n'a pas besoin de savoir lequel s'est produit, seulement que la copie n'a pas fonctionné. > **L'API Clipboard exige un contexte sécurisé:** > > `navigator.clipboard` n'est disponible qu'en HTTPS, ou sur `localhost`. Testez le bouton sur une simple adresse `http://` depuis n'importe quel autre host et `writeText` échouera à chaque fois — c'est le navigateur, pas un bug dans ce composant. ### 2.3 — Remise à zéro automatique {#23--auto-reset} ```javascript title="src/components/CopyAsMarkdown/index.tsx" useEffect(() => { if (status !== "copied" && status !== "error") return; const timer = setTimeout(() => setStatus("idle"), 2000); return () => clearTimeout(timer); }, [status]); ``` Dès que `status` passe à `copied` ou `error`, cet effet programme un retour à `idle` deux secondes plus tard, pour que le bouton ne reste pas bloqué sur « ✓ Copied » indéfiniment. La fonction de nettoyage annule un timer en attente si le lecteur reclique avant la fin de ces deux secondes. ### 2.4 — Le rendu {#24--rendering} ```javascript title="src/components/CopyAsMarkdown/index.tsx" return (
View raw
); ``` Le libellé du bouton est une simple chaîne de ternaires basée sur `status` ; il est désactivé pendant qu'un fetch est en cours pour qu'un lecteur ne puisse pas lancer une seconde requête en pleine copie. Le lien « View raw » n'a besoin d'aucun JavaScript — il pointe directement vers `mdUrl` et laisse le navigateur faire le travail, `target="_blank"` et `rel="noopener noreferrer"` empêchant le nouvel onglet de garder une référence vers celui-ci. ## Étape 3 — Le module CSS {#step-3--the-css-module} **src/components/CopyAsMarkdown/styles.module.css** ```css /* * No outer margin: this sits inside Blog/ArticleActions, which owns the row's * spacing. A margin here would stack with the row's and push the actions off * the date line they belong to. * * The gap is tighter than the row's (16px): "Copy as Markdown" and "View raw" * are two ways to get the same thing, so they read as a pair distinct from the * other, unrelated actions in the row (e.g. "Follow"). * * PAIRED FILE: .copyBtn below and Blog/Series's .markdownLink are deliberately * identical so the two "get this as plain Markdown" affordances read as one * family. Both were tokenised together in the same pass — change one, change * the other. */ .wrapper { display: inline-flex; align-items: center; gap: 8px; } .copyBtn { padding: 5px 12px; font-family: inherit; background: transparent; color: var(--mk-ink-soft); border: 1px solid var(--mk-border-strong); border-radius: var(--mk-radius-sm); font-size: 0.82rem; cursor: pointer; } .copyBtn:disabled { cursor: default; opacity: 0.7; } .copyBtn:hover:not(:disabled) { border-color: var(--mk-primary); color: var(--mk-primary); } /* * Bordered like .copyBtn, on purpose: as a bare link it sat closer to the button * than to anything else and still read as an unstyled third action rather than * as a button in its own right. */ .rawLink { padding: 5px 12px; color: var(--mk-ink-soft); text-decoration: none; border: 1px solid var(--mk-border-strong); border-radius: var(--mk-radius-sm); font-size: 0.82rem; } .rawLink:hover { color: var(--mk-primary); text-decoration: none; border-color: var(--mk-primary); } ``` Chaque couleur ici est une propriété personnalisée Infima (`--ifm-color-emphasis-*`), donc le bouton s'adapte au mode clair, au mode sombre ou à un thème Docusaurus entièrement personnalisé, sans une seule valeur hexadécimale en dur ni surcharge `[data-theme="dark"]` à maintenir. ## Étape 4 — L'intégrer dans l'en-tête de l'article {#step-4--wire-it-into-the-blog-post-header} Docusaurus rend le contenu de votre article via un composant de thème appelé `BlogPostItem`. Pour faire apparaître le bouton, il faut le swizzler : ```bash yarn run swizzle @docusaurus/theme-classic BlogPostItem --eject ``` Confirmez l'eject quand on vous le demande — un `--wrap` ne vous donnerait pas d'endroit pour insérer le bouton entre l'en-tête et le contenu. Ouvrez le `src/theme/BlogPostItem/index.js` obtenu et ajoutez trois choses : l'import, la garde `isBlogPostPage` (pour que le bouton n'apparaisse jamais sur une carte en vue liste), et le composant lui-même. ```javascript title="src/theme/BlogPostItem/index.js" {1,10} import CopyAsMarkdown from "@site/src/components/CopyAsMarkdown"; 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"; export default function BlogPostItem({ children, className }) { const { metadata, isBlogPostPage } = useBlogPost(); return ( {isBlogPostPage && } {children} ); } ``` `isBlogPostPage` vaut `true` uniquement quand un lecteur consulte un article isolé, jamais sur la page d'accueil du blog ou la grille d'archives — la même garde utilisée pour placer correctement [le widget de réactions des lecteurs](/blog/docusaurus-reactions) et [le bouton de retour en haut de page](/blog/docusaurus-go-top) sur ce blog même. > **Sur mon propre site, il passe plutôt par l'en-tête:** > > Mon `BlogPostItem/index.js` transmet `` à `BlogPostItem/Header` sous forme de prop au lieu de le rendre en ligne, parce que cet en-tête gère déjà un badge « assisté par IA » et la liste des auteurs. Vous n'avez besoin de cette indirection que si votre propre en-tête fait quelque chose de similaire — pour la plupart des blogs, le rendre directement comme montré ci-dessus est plus simple et tout aussi correct. ## Tous les fichiers en un coup d'œil {#all-files-at-a-glance} **Project: Copy as Markdown** **plugins/markdown-mirror-plugin/index.cjs** ```cjs // plugins/markdown-mirror-plugin/index.cjs // // Writes a plain-Markdown mirror of every published blog post next to its // HTML page: /blog/ -> /blog/.md // // Runs only during `yarn build` (postBuild), never during `yarn start`. const fs = require("fs"); const path = require("path"); const frontMatter = require("front-matter"); const BLOG_DIR = "blog"; function findPosts(dir) { const found = []; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const target = path.join(dir, entry.name); if (entry.isDirectory()) { found.push(...findPosts(target)); } else if (entry.name === "index.md" || entry.name === "index.mdx") { found.push(target); } } return found; } // An HTML comment, not visible prose: hidden by any Markdown renderer, but // still plain text in a raw view or in whatever an LLM is handed — exactly // where a reader who lost track of the original page needs it. function buildMetadataComment(url, buildDate) { return [ "", "", ].join("\n"); } module.exports = function markdownMirrorPlugin() { return { name: "markdown-mirror-plugin", async postBuild({ siteDir, outDir, siteConfig, routesPaths }) { const blogDir = path.join(siteDir, BLOG_DIR); const knownRoutes = new Set(routesPaths); // One timestamp for the whole run, shared by every mirror it writes. const buildDate = new Date().toISOString(); let written = 0; for (const file of findPosts(blogDir)) { const raw = fs.readFileSync(file, "utf-8"); const { attributes, body } = frontMatter(raw); if (!attributes.slug) continue; const permalink = `/blog/${attributes.slug}`; // Cross-checking against Docusaurus's own routesPaths is what makes // draft: true posts fall out for free — no need to duplicate that // logic here. if (!knownRoutes.has(permalink)) continue; const url = `${siteConfig.url}${permalink}`; const content = body.replace("\n", "").trim() + "\n"; const markdown = buildMetadataComment(url, buildDate) + content; const outFile = path.join(outDir, "blog", `${attributes.slug}.md`); fs.mkdirSync(path.dirname(outFile), { recursive: true }); fs.writeFileSync(outFile, markdown); written += 1; } console.log(`markdown-mirror-plugin: wrote ${written} .md mirrors`); }, }; }; ``` **src/components/CopyAsMarkdown/index.tsx** ```typescript import { useState, useCallback, useEffect, type JSX } from "react"; import styles from "./styles.module.css"; import Translate, { translate } from "@docusaurus/Translate"; interface Props { metadata: { permalink: string; }; } type Status = "idle" | "copying" | "copied" | "error"; /** * "Copy as Markdown" / "View raw" — reads the plain-Markdown mirror that * plugins/markdown-export-plugin writes next to every post's HTML page * (`/blog/` → `/blog/.md`, see TODO 0082) and copies its full * content to the clipboard. Lets a reader — or an LLM they paste it into — * get the article's complete text without React, JSX, or the accordions the * HTML page collapses by default. * * Only meaningful on a real `yarn build` output: the mirror is written in * `postBuild`, so on `yarn start` the fetch below 404s and the button * reports "Could not copy" — the same graceful-failure path a mirror that's * missing for any other reason takes. */ export default function CopyAsMarkdown({ metadata }: Props): JSX.Element { const [status, setStatus] = useState("idle"); const mdUrl = `${metadata.permalink.replace(/\/$/, "")}.md`; const handleCopy = useCallback(async () => { setStatus("copying"); try { const res = await fetch(mdUrl); if (!res.ok) throw new Error(`HTTP ${res.status}`); const text = await res.text(); await navigator.clipboard.writeText(text); setStatus("copied"); } catch (err) { console.error("CopyAsMarkdown: failed to copy", err); setStatus("error"); } }, [mdUrl]); useEffect(() => { if (status !== "copied" && status !== "error") return; const timer = setTimeout(() => setStatus("idle"), 2000); return () => clearTimeout(timer); }, [status]); return (
View raw
); } ``` **src/components/CopyAsMarkdown/styles.module.css** ```css /* * No outer margin: this sits inside Blog/ArticleActions, which owns the row's * spacing. A margin here would stack with the row's and push the actions off * the date line they belong to. * * The gap is tighter than the row's (16px): "Copy as Markdown" and "View raw" * are two ways to get the same thing, so they read as a pair distinct from the * other, unrelated actions in the row (e.g. "Follow"). * * PAIRED FILE: .copyBtn below and Blog/Series's .markdownLink are deliberately * identical so the two "get this as plain Markdown" affordances read as one * family. Both were tokenised together in the same pass — change one, change * the other. */ .wrapper { display: inline-flex; align-items: center; gap: 8px; } .copyBtn { padding: 5px 12px; font-family: inherit; background: transparent; color: var(--mk-ink-soft); border: 1px solid var(--mk-border-strong); border-radius: var(--mk-radius-sm); font-size: 0.82rem; cursor: pointer; } .copyBtn:disabled { cursor: default; opacity: 0.7; } .copyBtn:hover:not(:disabled) { border-color: var(--mk-primary); color: var(--mk-primary); } /* * Bordered like .copyBtn, on purpose: as a bare link it sat closer to the button * than to anything else and still read as an unstyled third action rather than * as a button in its own right. */ .rawLink { padding: 5px 12px; color: var(--mk-ink-soft); text-decoration: none; border: 1px solid var(--mk-border-strong); border-radius: var(--mk-radius-sm); font-size: 0.82rem; } .rawLink:hover { color: var(--mk-primary); text-decoration: none; border-color: var(--mk-primary); } ``` `src/theme/BlogPostItem/index.js` n'est pas inclus ci-dessus — comme indiqué à l'étape 4, ce fichier existe déjà sous une forme ou une autre sur votre site, et vous le modifiez, vous ne le créez pas de zéro. Affiché ici avec [le même composant de snippets de code](/blog/docusaurus-snippets) utilisé tout au long de cet article. ## À vous d'essayer {#try-it-yourself} 1. Ajoutez le plugin à `docusaurus.config.js` et créez les trois fichiers ci-dessus. 2. Lancez `yarn build` — pas `yarn start`. Les hooks `postBuild` ne se déclenchent jamais sur le serveur de dev, donc le miroir n'existe réellement pas avant un vrai build. 3. Servez la sortie localement, par exemple `npx serve build`, et ouvrez n'importe quel article. 4. Cliquez sur **Copy as Markdown**, puis collez dans un éditeur de texte. Vous devriez voir la source Markdown brute, balises `` comprises. 5. Cliquez sur **View raw**. Le fichier `.md` devrait s'ouvrir en texte brut dans un nouvel onglet. > **Auto-hébergé sur Apache ? Attention au type MIME:** > > Si l'étape 5 déclenche une invite de téléchargement au lieu d'afficher le fichier, votre serveur ne sait pas que `.md` est du texte. Ajoutez `AddType text/markdown .md` à votre `.htaccess` (ou l'équivalent Nginx, un bloc `types`) pour qu'il soit servi en ligne. ## Sous le capot (passez si vous voulez juste l'utiliser) {#under-the-hood-skip-this-if-you-just-want-to-use-it} **Pourquoi le miroir ne peut pas entrer en collision avec l'URL de l'article.** `/blog/my-post` est un *répertoire* contenant un `index.html` — c'est ainsi que Docusaurus sert des URLs propres. `/blog/my-post.md` est un *fichier* voisin dans ce même répertoire. Même dossier, fichiers différents, zéro conflit de routage. **Pourquoi `postBuild`, et pas un loader webpack ou une route en dev.** Un loader devrait rejouer la même logique MDX-vers-texte à chaque hot reload sans aucun bénéfice — personne ne copie un article depuis un blog qu'il est en train d'éditer. Restreindre le miroir à la production garde le serveur de dev rapide et la logique en un seul endroit. **Là où la version simple ne suffit plus.** Le plugin de l'étape 1 fait passer tout composant personnalisé tel quel, en texte JSX littéral. C'est acceptable pour un bloc `` isolé, mais ça cesse vite de ressembler à du Markdown dès qu'un article s'appuie sur des dizaines de composants — snippets de code, alertes, cartes étape par étape, etc. Le faire correctement implique de parser le MDX en véritable arbre syntaxique et de remplacer chaque nœud de composant par un équivalent en Markdown pur, composant par composant. C'est un projet nettement plus gros qu'un bouton et une boucle de copie — le genre de chose pour laquelle existe le pipeline de dégradation, beaucoup plus imposant, de ce blog — et un bon candidat pour un article de suivi à part entière. **Une date de frontmatter n'est pas une chaîne.** Si vous étendez le commentaire de métadonnées pour afficher aussi le champ `date:` de l'article, ne le concaténez pas directement comme une chaîne. YAML type automatiquement un `date: 2024-02-23` non quoté en objet `Date` JS, et `` `${date}` `` appelle son `toString()` par défaut, très verbeux (`Mon Jul 27 2026 00:00:00 GMT+0000 (Coordinated Universal Time)`). Normalisez-le d'abord : `date instanceof Date ? date.toISOString().slice(0, 10) : String(date)`. ## Conclusion {#conclusion} Une petite centaine de lignes de code, réparties entre un plugin de build et un composant React, suffisent pour arrêter de se battre avec le HTML rendu chaque fois que vous voulez le texte brut d'un article. Le bouton ne sait pas ce qu'est le Markdown, et le plugin ne sait pas ce qu'est un bouton — chacun fait un petit travail, et l'ensemble tient debout parce que ni l'un ni l'autre n'a besoin de connaître les entrailles de l'autre. Si vous partez de là, l'étape suivante naturelle est celle signalée dans *Sous le capot* : apprendre au générateur de miroir à vraiment dégrader vos composants personnalisés au lieu de les recopier en JSX brut. En attendant, cette version résout déjà le problème qui a tout déclenché — plus de chirurgie manuelle de copier-coller, ni pour moi ni pour quiconque veut donner un de mes articles à un LLM. --- # Ask My Blog : transformer 259 articles en 2 160 questions cherchables > Un LLM local lit chaque article du blog et rédige les questions qu'un développeur taperait vraiment dans une barre de recherche, chacune reliée au titre exact qui y répond. Le résultat est un index de questions généré au build et interrogé dans le navigateur avec un simple BM25 — pas d'embeddings, pas de clé d'API, pas de base vectorielle. Pipeline, plugin et composant complets inclus. --- ![Ask My Blog : transformer 259 articles en 2 160 questions cherchables](/img/v2/ask-my-blog.webp) **TL;DR** Une recherche de site ne trouve que les mots que *vous* avez écrits. Un lecteur dont l'image Docker est trop grosse tape « my docker image is huge », pas « optimizing layer caching » — et ne trouve rien. J'ai donc fait lire mes 259 articles à un modèle Ollama local, en lui demandant d'écrire pour chacun les 8 à 12 questions qu'un développeur taperait réellement, chacune reliée au titre qui y répond. Ça a donné 2 160 questions, servies comme un index généré au build et interrogées dans le navigateur avec un simple BM25. Pas d'embeddings, pas de base vectorielle, pas de clé d'API. La recherche sur un blog personnel est presque toujours décevante, et j'ai mis un moment à comprendre pourquoi. Ce n'est pas la faute du moteur de recherche — le mien indexe parfaitement chaque mot de chaque page. Le problème, c'est qu'il ne peut trouver que les mots que *j'ai* écrits. J'écris « réduire la taille finale de l'image avec des builds multi-stage ». Le lecteur en galère tape « mon image docker est énorme ». Zéro résultat. L'article dont il avait besoin est là, indexé, comparé à un vocabulaire qu'il n'a jamais employé. La réponse habituelle, ce sont les embeddings : tout transformer en vecteurs, comparer des sens plutôt que des mots. Mais ça veut dire un modèle dans le navigateur, ou un appel d'API à chaque frappe, pour un blog statique sans backend. Il existe une façon bien moins coûteuse de faire le pont entre deux vocabulaires — et ça commence par remarquer que ce pont ne doit être construit qu'**une seule fois**. **Quick Jump:** [Ce que vous obtenez](#what-you-get) | [Comment les questions sont générées](#generating-the-questions) ## Ce que vous obtenez {#what-you-get} La [page `/faq`](/faq) s'ouvre sur un hub : des cartes de sujets, une par tag principal — *Docker*, *AI*, *Bash*, *WSL*, et d'autres. Chaque carte indique combien de questions y sont classées. Cliquez sur l'une d'elles et vous arrivez sur une page qui liste toutes les questions de ce sujet, chacune pointant vers le titre d'article exact qui y répond : > Screenshot — https\://www\.avonture.be/fr/faq > > ![Hub FAQ — cartes de sujets avec le nombre de questions par tag](./images/faq-hub.png) Le même index alimente aussi une boîte de recherche sur cette page. Tapez un problème avec vos propres mots — « mon image docker est trop grosse », « comment installer joomla » — et vous obtenez des questions, pas des titres de page. Chaque résultat pointe vers le titre exact qui y répond : > Screenshot — https\://www\.avonture.be/fr/faq > > ![Résultats de recherche — les questions correspondant à la requête, chacune liée au titre qui y répond](./images/faq-search.png) C'est toute l'idée, et le reste de cet article explique comment c'est construit. ## Pourquoi ça marche sans modèle dans le navigateur {#why-this-works-without-a-model-in-the-browser} L'idée est minuscule et c'est tout l'article : **le travail sémantique n'a pas à se faire au moment de la recherche.** * La requête d'un lecteur et la prose d'un article sont écrites dans deux vocabulaires différents. Quelque chose doit traduire entre les deux. * Cette traduction est une propriété fixe de l'article. Elle ne dépend ni de qui cherche, ni de quand. Elle peut donc être calculée une fois et stockée. * Une fois qu'un modèle a déjà formulé « comment empêcher mon image Docker d'être énorme ? » et l'a classée à côté du bon titre, comparer la requête d'un lecteur à **cette** phrase devient un simple problème de similarité de chaînes. Un classement lexical vieux de vingt ans s'en sort parfaitement. * Ce qui part vers le navigateur, c'est donc une liste de phrases et une fonction de scoring — pas un modèle, pas un index vectoriel, pas un appel réseau. Le build devient plus lent (environ 11 secondes par article, une seule fois). Chaque lecteur ensuite obtient une réponse instantanée. ## Générer les questions {#generating-the-questions} Le générateur demande à une instance [Ollama](/blog/ollama-installation) locale des questions sur un article, et les écrit dans un fichier sidecar à côté de celui-ci — la même convention que celle déjà utilisée par mes [tooltips ELI5](/blog/docusaurus-eli5-snippet-tooltips) pour leur propre contenu généré : ```bash $ time yarn questions blog/2025/09/12/docusaurus-go-top/index.md --force ✅ Written: blog/2025/09/12/docusaurus-go-top/index.md.questions.json 8 questions. real 0m11.147s $ yarn questions:bulk --dry-run --limit 4 🔍 4 article(s) under blog (--limit 4). [SKIP] blog/2023/11/02/docker-php-run-script-or-website/index.md [SKIP] blog/2023/11/02/keepass-start-rdp/index.md [SKIP] blog/2023/11/02/welcome/index.md [SKIP] blog/2023/11/02/wslg-rpd-connection/index.md Dry run — nothing written. ``` Le résultat est un petit fichier JSON, `index.md.questions.json`, committé à côté de l'article : **blog/2025/09/12/docusaurus-go-top/index.md.questions.json** ```json { "version": 1, "model": "task-tiny:latest", "generated": "2026-08-12T13:24:09.634Z", "source": "index.md", "sourceHash": "981d607fb90f06dd35b65cdfa3d549560453a8ea", "questions": [ { "question": "How do I add a 'go to the top' button to my Docusaurus blog posts using a custom React component and theme override?", "anchor": "what-it-looks-like" }, { "question": "What are the steps to implement a 'scroll to top' button in Docusaurus blog posts?", "anchor": "how-it-works" }, { "question": "How can I add a 'go to the top' button to other pages in my Docusaurus site besides blog posts?", "anchor": "you-can-do-this-for-other-pages-for-sure" }, { "question": "What is the purpose of overriding the BlogPostItem page in Docusaurus for adding a 'go to the top' button?", "anchor": "overriding-the-blogpostitem-page" }, { "question": "How do I create and use a custom React component for the 'go to the top' button in Docusaurus blog posts?", "anchor": "how-it-works" }, { "question": "What is the CSS for styling the 'go to the top' button in Docusaurus blog posts?", "anchor": "how-it-works" }, { "question": "How do I inject the custom React component for the 'go to the top' button into my Docusaurus pages?", "anchor": "overriding-the-blogpostitem-page" }, { "question": "What is the purpose of swizzling in Docusaurus and how can it be used to add a 'go to the top' button to blog posts?", "anchor": "how-it-works" } ] } ``` Trois choses rendent cette sortie utilisable plutôt que simplement plausible. **On explique au modèle ce que « spécifique » veut dire.** Le system prompt interdit purement et simplement les questions génériques (« Qu'est-ce que Docker ? »), exige un mélange de requêtes courtes façon mots-clés et de phrases complètes, et interdit deux questions qui ne diffèrent que par l'ordre des mots : ```text title="scripts/generate-questions.mjs — system prompt (extract)" - Every question must be SPECIFIC to this article's actual content — never generic ("What is Docker?", "How does Markdown work?"). - Vary the phrasing style: some short keyword-style queries, some full questions. - Each question maps to exactly one heading by index. Index 0 means "the article as a whole / introduction", not tied to a specific heading. - Do not invent facts not supported by the material given. ``` **La forme de la réponse est imposée, pas espérée.** Ollama accepte un schéma JSON dans `format:`, donc le modèle ne peut pas renvoyer de la prose, une liste markdown ou un champ nommé différemment. Le script valide quand même chaque élément après coup, et fait échouer l'article entier s'il en reste moins de cinq — une entrée maigre est pire qu'aucune entrée. **Les titres sont résolus en vraies ancres.** Le prompt donne au modèle une liste numérotée de titres et lui demande un index ; le script remappe cet index vers le slug que Docusaurus va lui-même générer, avec le même package `github-slugger` que Docusaurus utilise en interne. Un mauvais index n'est pas fatal — la question retombe en haut de l'article au lieu d'être jetée. Voici le générateur complet : **scripts/generate-questions.mjs** ```mjs #!/usr/bin/env node /** * "Ask my blog" question generation — see .todos/0083-ask-my-blog-question-index.md. * * For one article (or, with --all, every published article under blog/), asks a local * Ollama model to write 8-12 search questions a developer would actually type into a search * bar for the problem this article solves, each mapped to the `##`/`###` heading that answers * it. Writes `
.questions.json` alongside the article — same convention as the * existing `.eli5.json` sidecar files (see scripts/lib/eli5-hash.mjs). * * The semantic work happens once, at build time, on the author's machine — not in every * reader's browser. See the TODO's "Ce qu'on ne fait pas, et pourquoi" section for why * client-side embeddings were rejected in favor of this precompute-then-lexical-match design. * * Usage: * node scripts/generate-questions.mjs [--force] [--output ] * node scripts/generate-questions.mjs --all [--force] [--dir blog/] [--limit N] [--dry-run] * node scripts/generate-questions.mjs --locale fr * node scripts/generate-questions.mjs --locale fr --all [--force] [--limit N] [--dry-run] * * With --locale (TODO 0120), the questions are generated FROM THE TRANSLATED ARTICLE, in that * language, and written next to it under `i18n//docusaurus-plugin-content-blog/`. * Generating from the English text and translating the questions would describe wording — and * heading anchors — the reader never sees. Which articles qualify is decided by * `scripts/lib/i18n-eligibility.mjs`, never re-derived here. A localized sidecar whose * translated article changed is regenerated without --force; a fresh one is left alone. * * Requires a local Ollama instance — see OLLAMA_URL below. No API key: this never leaves the * author's machine (or, in the devcontainer, the host it runs on). */ import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; import GithubSlugger from "github-slugger"; import { hashSource } from "./lib/eli5-hash.mjs"; import { localizedBlogPath, questionCandidates } from "./lib/i18n-eligibility.mjs"; import { findPosts, parseFrontMatter, stripCodeFences, toProse, } from "./lib/blog-corpus.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(__dirname, ".."); // The devcontainer reaches the host's Ollama through the Docker bridge gateway — same // address this project's other host-side tooling (AnythingLLM) already uses. Override with // OLLAMA_URL when running outside the devcontainer (e.g. `http://localhost:11434`). const OLLAMA_URL = process.env.OLLAMA_URL || "http://172.17.0.1:11434"; // code-quality with thinking, for every locale. Blind bench on Ollama 0.34.3 (2026-09-23), // 16 English outputs: code-quality 4.5/5 at ~36 s/article, think:false 4.0, code-fast 3.4, // task-tiny 1.9 — it mapped every question of a 31-heading article to the introduction. // Only thinking reaches the late headings: the prose below is capped at 4000 chars. const OLLAMA_MODEL = process.env.OLLAMA_MODEL || "code-quality:latest"; // The `code-quality` tag pointed to a different, ~5x slower model before the 2026-09-23 stack // upgrade. Timings recorded earlier would inflate every bulk-run estimate, so they are ignored. const TIMINGS_VALID_SINCE = "2026-09-23"; // Seconds of idle between two articles in a bulk run. A full French corpus is hours of // uninterrupted GPU load, typically left running overnight — the pause costs ~16% of wall time // and buys a lower sustained temperature and quieter fans. `--pause 0` turns it off. const DEFAULT_PAUSE_SECONDS = 30; const MIN_QUESTIONS = 8; const MAX_QUESTIONS = 12; // A response can drop a few items to validation (bad index, empty text) without being // considered malformed. Below this, something is wrong with the prompt/model for this // article and the whole article must fail rather than ship a thin, low-value entry. const MIN_VALID_QUESTIONS = 5; // Output language per locale. The instruction is spelled out in the prompt because a 3B model // otherwise drifts back to English whenever the article quotes English commands or titles. // // `model` stays per locale so one language can diverge again; today both use the default. // French was on code-quality first: on the Docling pilot (2026-09-17), task-tiny shifted the // heading index by one on 5 of 12 questions, while code-quality mapped all 10 correctly. const LANGUAGES = { en: { name: "English", extra: "", model: OLLAMA_MODEL }, fr: { name: "French", model: OLLAMA_MODEL, extra: ` - Write every question in natural French, the way a French-speaking developer would type it (tutoiement or impersonal phrasing, never a word-for-word translation of English). - Keep product names, commands, file names and flags exactly as written (Docker, \`docker compose\`, \`.bashrc\`) — never translate them.`, }, }; const systemPromptFor = (locale) => { const { name, extra } = LANGUAGES[locale]; return SYSTEM_PROMPT.replace("in English.", `in ${name}.`) + extra; }; const SYSTEM_PROMPT = `You are helping build a search index for a technical blog. Given an article's title, description, tags, and section headings, write search questions a developer would actually type into a search bar when they have the problem this article solves — using their own vocabulary, which may differ from the article's wording. Rules: - 8 to 12 questions, in English. - Every question must be SPECIFIC to this article's actual content — never generic ("What is Docker?", "How does Markdown work?"). - Vary the phrasing style: some short keyword-style queries, some full questions. - Each question maps to exactly one heading by index. Index 0 means "the article as a whole / introduction", not tied to a specific heading. - Do not invent facts not supported by the material given. - Every question must be meaningfully different from every other one — no two questions may ask the same thing with swapped words (e.g. "convert CSV to Markdown" and "convert Markdown to CSV" are different; "how to convert CSV to Markdown" and "convert CSV to Markdown" are NOT — pick one).`; const RESPONSE_SCHEMA = { type: "object", properties: { questions: { type: "array", minItems: MIN_QUESTIONS, maxItems: MAX_QUESTIONS, items: { type: "object", properties: { question: { type: "string" }, headingIndex: { type: "integer" }, }, required: ["question", "headingIndex"], }, }, }, required: ["questions"], }; /** * Strips the inline formatting a heading may carry before it becomes an anchor/prompt text. * Resolves each construct to its inner text (bold/italic/code/link label) rather than * dropping it — this must match Docusaurus's own remark-slug algorithm, which keeps a code * span's text as part of the heading it slugs. Deliberately NOT toProse() from blog-corpus.mjs: * that helper is built for prose/search extraction, where blanking out code spans as "not real * prose" is correct — but doing the same here produced anchors Docusaurus never generates * (e.g. "## What `ai-test` Does For You" slugged to "what---does-for-you" instead of * "what-ai-test-does-for-you"), breaking every link built from it. */ function cleanHeadingText(text) { return text .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") .replace(/<[^>]+>/g, "") .replace(/\*\*([^*]+)\*\*/g, "$1") .replace(/\*([^*]+)\*/g, "$1") .replace(/`([^`]+)`/g, "$1") .trim(); } /** * Collects `##`/`###` headings in document order, resolving each to the anchor slug * Docusaurus would generate for it (github-slugger — same package Docusaurus's own MDX * pipeline uses), honoring an explicit `{#custom-id}` override when present. */ export function extractHeadings(body) { const slugger = new GithubSlugger(); const headings = []; for (const line of stripCodeFences(body)) { // The custom id accepts anything but a brace or a space: the ids pinned onto translated // articles are the slugs github-slugger derives from the English headings, and an emoji // heading yields one that starts with a variation selector (`{#\uFE0F-a-final-word-…}`). // An `[a-zA-Z0-9-_]+` class missed those, the `{#…}` suffix stayed part of the heading text, // and the anchor became the slug of "French heading + English id" — a link to nowhere. const match = line.match(/^(#{2,3})\s+(.*?)\s*(?:\{#([^}\s]+)\})?$/); if (!match) continue; const [, hashes, rawText, customId] = match; const text = cleanHeadingText(rawText); if (!text) continue; const anchor = customId || slugger.slug(text); headings.push({ level: hashes.length, text, anchor }); } return headings; } function buildUserPrompt({ title, description, tags, headings, prose }) { const headingsList = [ "0: (general / introduction — not tied to a specific heading)", ...headings.map((h, i) => `${i + 1}: ${h.text}`), ].join("\n"); // Bounding the prose keeps prompts (and generation time) predictable across the corpus — // title/description/headings already carry most of the signal a 3B model needs. const MAX_PROSE_CHARS = 4000; const boundedProse = prose.length > MAX_PROSE_CHARS ? prose.slice(0, MAX_PROSE_CHARS) + " […]" : prose; return `Title: ${title} Description: ${description} Tags: ${tags.join(", ") || "(none)"} Headings: ${headingsList} Article body (for context only — do not quote it verbatim): ${boundedProse} Write the search questions now.`; } async function callOllama(userPrompt, locale, model) { let res; try { res = await fetch(`${OLLAMA_URL}/api/chat`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model, stream: false, format: RESPONSE_SCHEMA, options: { temperature: 0.4 }, messages: [ { role: "system", content: systemPromptFor(locale) }, { role: "user", content: userPrompt }, ], }), }); } catch (err) { throw new Error( `Could not reach Ollama at ${OLLAMA_URL} (is it running? set OLLAMA_URL to override): ${err.message}`, { cause: err }, ); } if (!res.ok) { const body = await res.text(); throw new Error(`Ollama returned HTTP ${res.status}: ${body}`); } const json = await res.json(); return json.message?.content ?? ""; } /** Validates and normalizes the model's raw output into `{ question, anchor }` entries. */ function toValidatedQuestions(raw, headings) { let parsed; try { parsed = JSON.parse(raw); } catch { throw new Error(`Model response is not valid JSON:\n${raw}`); } if (!parsed || !Array.isArray(parsed.questions)) { throw new Error(`Model response is missing a "questions" array:\n${raw}`); } const seen = new Set(); const questions = []; for (const item of parsed.questions) { if (!item || typeof item.question !== "string" || !item.question.trim()) continue; if (!Number.isInteger(item.headingIndex)) continue; const question = item.question.trim(); // Same normalization as questions-index-plugin's normalizeForDedupe(): accents folded // first, or "créer" and "cr er" would be the same key. const dedupeKey = question .normalize("NFD") .replace(/\p{M}/gu, "") .toLowerCase() .replace(/[^\p{L}\p{N}]+/gu, " ") .trim(); if (seen.has(dedupeKey)) continue; seen.add(dedupeKey); // A small model miscounting the heading list is a wrong index on an otherwise good, // specific question — worth keeping by falling back to "general" (anchor "") rather than // discarding it outright. Only a genuinely broken shape (missing text, non-integer index) // drops the item entirely, per the "malformed must fail, not silently degrade" rule. const inRange = item.headingIndex >= 1 && item.headingIndex <= headings.length; const anchor = inRange ? headings[item.headingIndex - 1].anchor : ""; questions.push({ question, anchor }); } if (questions.length < MIN_VALID_QUESTIONS) { throw new Error( `Only ${questions.length} valid question(s) survived validation (need >= ${MIN_VALID_QUESTIONS}):\n${raw}`, ); } return questions; } /** * Generates (or skips) the `.questions.json` sidecar for one article. * `articleFile` is the `index.md`/`index.mdx` path, absolute or repo-relative. */ export async function generateQuestions( articleFile, { force = false, outputPath = null, locale = "en" } = {}, ) { if (!LANGUAGES[locale]) { throw new Error( `Unsupported locale "${locale}" (known: ${Object.keys(LANGUAGES).join(", ")}).`, ); } const absSource = locale === "en" ? path.resolve(articleFile) : resolveTranslated(articleFile, locale); if (!fs.existsSync(absSource)) { throw new Error(`Article not found: ${absSource}`); } const destPath = outputPath || absSource + ".questions.json"; // An article excluded during review (scripts/questions-review.mjs, `x`) must never get // questions again — not even with --force, which is exactly the run that would otherwise // undo the decision. Only `questions review ` + `i` lifts it. if (fs.existsSync(destPath)) { let existing; try { existing = JSON.parse(fs.readFileSync(destPath, "utf-8")); } catch { existing = null; } if (existing?.excluded === true) { return { status: "excluded", path: destPath }; } // A localized sidecar follows its translation: stale means regenerate, even without // --force — that is the whole point of the hash. The English corpus keeps its historical // "exists = skip" behavior (its staleness is reported by questions:check instead). const fresh = locale === "en" || existing?.sourceHash === hashSource(fs.readFileSync(absSource, "utf-8")); if (!force && fresh) { return { status: "skipped", path: destPath }; } } const raw = fs.readFileSync(absSource, "utf-8"); const { data, body } = parseFrontMatter(raw); if (!data.title) { throw new Error(`${path.relative(projectRoot, absSource)} has no frontmatter title.`); } const headings = extractHeadings(body); const prose = toProse(body); const tags = Array.isArray(data.tags) ? data.tags : []; const userPrompt = buildUserPrompt({ title: data.title, description: data.description || "", tags, headings, prose, }); const model = LANGUAGES[locale].model; const startedAt = Date.now(); const modelRaw = await callOllama(userPrompt, locale, model); const questions = toValidatedQuestions(modelRaw, headings); const durationMs = Date.now() - startedAt; const result = { version: 1, model, generated: new Date().toISOString(), // Read back by pastDurations() to estimate how long the next bulk run will take. durationMs, source: path.basename(absSource), ...(locale === "en" ? {} : { locale }), sourceHash: hashSource(raw), questions, }; fs.writeFileSync(destPath, JSON.stringify(result, null, 2) + "\n"); return { status: "generated", path: destPath, count: questions.length, durationMs }; } /** * The translated article for a `--locale` run. Accepts either the English source * (`blog/…/index.md`) or the translated file itself, so the path the author has at hand works. */ function resolveTranslated(articleFile, locale) { const abs = path.resolve(articleFile); const blogRoot = path.join(projectRoot, "blog") + path.sep; if (!abs.startsWith(blogRoot)) return abs; const translated = localizedBlogPath(projectRoot, abs, locale); if (!fs.existsSync(translated)) { throw new Error( `${path.relative(projectRoot, abs)} is not translated into ${locale} — translate it first ` + `(\`translate ${path.relative(projectRoot, path.dirname(abs))}\`).`, ); } // Same condition 2 as the eligibility module: no English questions, no localized ones. try { const english = JSON.parse(fs.readFileSync(`${abs}.questions.json`, "utf-8")); if (english.excluded === true) { throw new Error( `${path.relative(projectRoot, abs)} is excluded from "Ask my blog".`, ); } } catch (err) { if (err.code !== "ENOENT" && !(err instanceof SyntaxError)) throw err; } return translated; } // ── Timing ─────────────────────────────────────────────────────────────────── /** * Past generation times for `model`, read back from the `durationMs` each sidecar records. * Sidecars written before that field existed, or before TIMINGS_VALID_SINCE, simply don't count. */ function pastDurations(model) { const durations = []; for (const root of ["blog", "i18n"]) { const dir = path.join(projectRoot, root); if (!fs.existsSync(dir)) continue; for (const rel of fs.readdirSync(dir, { recursive: true })) { if (!rel.endsWith(".questions.json")) continue; try { const sidecar = JSON.parse(fs.readFileSync(path.join(dir, rel), "utf-8")); if ( sidecar.model === model && Number.isFinite(sidecar.durationMs) && sidecar.generated >= TIMINGS_VALID_SINCE ) { durations.push(sidecar.durationMs); } } catch { /* an unreadable sidecar is reported by questions:check, not here */ } } } return durations; } const mean = (values) => values.reduce((sum, value) => sum + value, 0) / values.length; function formatDuration(ms) { const seconds = Math.round(ms / 1000); if (seconds < 60) return `${seconds} s`; const minutes = Math.round(seconds / 60); if (minutes < 60) return `${minutes} min`; return `${Math.floor(minutes / 60)} h ${String(minutes % 60).padStart(2, "0")} min`; } // ── Bulk mode ──────────────────────────────────────────────────────────────── /** * `--pause` between two articles. It spends no less energy on the run — the same work is done * either way — but it lowers the sustained GPU temperature and fan noise of an unattended * multi-hour batch, which is the point: one process to launch, not a tranche to restart by hand. */ const pauseBetweenArticles = (seconds) => new Promise((resolve) => setTimeout(resolve, seconds * 1000)); /** ` 3/58` — padded so the counter column stays aligned from the first article to the last. */ const counterOf = (index, total) => `${String(index + 1).padStart(String(total).length)}/${total}`; /** `--locale` bulk run: the eligibility module decides, this only reports and generates. */ async function runLocalizedBulk({ locale, force, limit, dryRun, pause }) { const { eligible, skipped } = questionCandidates(projectRoot, locale); // --force re-runs the fresh ones too, never the excluded or the untranslated. const fresh = skipped .filter((s) => s.reason === "localized sidecar already fresh") .map((s) => s.file); const all = force ? [...eligible, ...fresh].sort() : eligible; const targets = typeof limit === "number" ? all.slice(0, limit) : all; console.log( `🔍 ${targets.length} translated article(s) to process for "${locale}" ` + `(${fresh.length} already fresh, ${skipped.length - fresh.length} not eligible).`, ); // A full run on code-quality takes hours — say so before it starts, not after. const model = LANGUAGES[locale]?.model; const history = model ? pastDurations(model) : []; if (model && targets.length > 0) { console.log( history.length > 0 ? `⏱ Estimated time: ~${formatDuration( targets.length * mean(history) + Math.max(targets.length - 1, 0) * pause * 1000, )} ` + `(${targets.length} × ${Math.round(mean(history) / 1000)} s average per article ` + `for ${model}, measured on ${history.length} article(s)).` : `⏱ No timing history yet for ${model} — the next run will show an estimate.`, ); if (pause) { console.log(`⏸ Pausing ${pause}s between articles to keep the GPU cooler.`); } } let generated = 0, errors = 0; for (const [index, file] of targets.entries()) { const rel = path.relative(projectRoot, file); const counter = counterOf(index, targets.length); if (dryRun) { console.log(` [GENERATE] ${counter} ${rel}`); continue; } process.stdout.write(` 📄 ${counter} ${rel} ... `); try { const result = await generateQuestions(file, { force, locale }); console.log( result.status === "generated" ? `✅ ${result.count} questions` : `⏭ ${result.status}`, ); if (result.status === "generated") generated++; } catch (err) { console.log(`❌ ${err.message}`); errors++; } if (pause && index < targets.length - 1) await pauseBetweenArticles(pause); } if (dryRun) console.log("\nDry run — nothing written."); else console.log(`\nGenerated: ${generated} Errors: ${errors}`); if (errors > 0) process.exitCode = 1; } async function runBulk({ force, dir, limit, dryRun, pause }) { // Drafts (blog/ with draft: true, or anything under .unpublished/) aren't public yet — the // aggregation plugin already excludes them from the shipped index, but generating sidecars // for them here would still burn Ollama time and commit sidecar files for unpublished // content. Mirrors the same draft check questions-index-plugin applies at aggregation time. const isDraft = (file) => { const { data } = parseFrontMatter(fs.readFileSync(file, "utf-8")); return data.draft === true || data.draft === "true"; }; const posts = findPosts(dir) .filter((file) => !isDraft(file)) .sort(); const targets = typeof limit === "number" ? posts.slice(0, limit) : posts; console.log( `🔍 ${targets.length} article(s) under ${path.relative(projectRoot, dir)}${ typeof limit === "number" ? ` (--limit ${limit})` : "" }.`, ); if (pause && !dryRun) { console.log(`⏸ Pausing ${pause}s between articles to keep the GPU cooler.`); } if (dryRun) { for (const [index, file] of targets.entries()) { const rel = path.relative(projectRoot, file); const sidecarPath = file + ".questions.json"; const exists = fs.existsSync(sidecarPath); let label = exists && !force ? "SKIP" : "GENERATE"; if (exists) { try { if (JSON.parse(fs.readFileSync(sidecarPath, "utf-8")).excluded === true) { label = "EXCLUDED"; } } catch { /* an unreadable sidecar is reported by questions:check, not here */ } } console.log(` [${label}] ${counterOf(index, targets.length)} ${rel}`); } console.log("\nDry run — nothing written."); return; } let generated = 0, skipped = 0, excluded = 0, errors = 0; for (const [index, file] of targets.entries()) { const rel = path.relative(projectRoot, file); process.stdout.write(` 📄 ${counterOf(index, targets.length)} ${rel} ... `); // Only a real generation heated anything up — a skipped article never reached Ollama. let calledOllama = false; try { const result = await generateQuestions(file, { force }); if (result.status === "excluded") { console.log("🚫 excluded"); excluded++; } else if (result.status === "skipped") { console.log("⏭ skipped"); skipped++; } else { console.log(`✅ ${result.count} questions`); generated++; calledOllama = true; } } catch (err) { console.log(`❌ ${err.message}`); errors++; } if (pause && calledOllama && index < targets.length - 1) { await pauseBetweenArticles(pause); } } console.log(`\n──────────────────────────────────────`); console.log( `Generated: ${generated} Skipped: ${skipped} Excluded: ${excluded} Errors: ${errors}`, ); if (errors > 0) process.exitCode = 1; } // ── CLI ────────────────────────────────────────────────────────────────────── const { fileURLToPath: _fileURLToPath } = await import("url"); const _isMain = process.argv[1] === _fileURLToPath(import.meta.url); if (_isMain) { const args = process.argv.slice(2); if (args.length === 0 || args.includes("--help") || args.includes("-h")) { console.log(` Usage: node scripts/generate-questions.mjs [options] node scripts/generate-questions.mjs --all [options] Options: --force Regenerate even if
.questions.json already exists --output (single-file mode) write to a custom path --all Process every published article under --dir --dir Directory to scan in --all mode (default: blog/) --limit (--all mode) only process the first n articles — useful for a hand-reviewed pilot batch before a full corpus run --dry-run (--all mode) show what would be generated without calling Ollama --pause (--all mode) wait between two articles (default: ${DEFAULT_PAUSE_SECONDS}). Same total work, but a lower sustained GPU temperature on an unattended multi-hour batch. --pause 0 disables it --locale Generate from the translated article, in that language (e.g. fr). With --all, only the eligible translated articles are processed. --help, -h Show this help Environment: OLLAMA_URL Ollama endpoint (default: http://172.17.0.1:11434 — the devcontainer's bridge to the host) OLLAMA_MODEL Model name (default: code-quality:latest) `); process.exit(0); } const force = args.includes("--force"); const localeIdx = args.indexOf("--locale"); const locale = localeIdx !== -1 ? args[localeIdx + 1] : "en"; if (localeIdx !== -1 && !LANGUAGES[locale]) { console.error(`Error: unsupported --locale "${locale}".`); process.exit(1); } if (args.includes("--all")) { const dirIdx = args.indexOf("--dir"); const dir = dirIdx !== -1 ? path.resolve(projectRoot, args[dirIdx + 1]) : path.resolve(projectRoot, "blog"); const limitIdx = args.indexOf("--limit"); const limit = limitIdx !== -1 ? parseInt(args[limitIdx + 1], 10) : undefined; const dryRun = args.includes("--dry-run"); const pauseIdx = args.indexOf("--pause"); const pause = pauseIdx !== -1 ? Number(args[pauseIdx + 1]) : DEFAULT_PAUSE_SECONDS; if (pauseIdx !== -1 && (!Number.isFinite(pause) || pause < 0)) { console.error(`--pause needs a number of seconds, got "${args[pauseIdx + 1]}".`); process.exit(1); } if (locale !== "en") { await runLocalizedBulk({ locale, force, limit, dryRun, pause }); } else if (!fs.existsSync(dir)) { console.error(`Error: directory not found: ${dir}`); process.exit(1); } else { await runBulk({ force, dir, limit, dryRun, pause }); } } else { const outputIdx = args.indexOf("--output"); const outputPath = outputIdx !== -1 ? args[outputIdx + 1] : null; const articleFile = args.find( (a, i) => !a.startsWith("--") && !(outputIdx !== -1 && i === outputIdx + 1) && !(localeIdx !== -1 && i === localeIdx + 1), ); if (!articleFile) { console.error("Error: no article file specified. Run with --help for usage."); process.exit(1); } try { const result = await generateQuestions(articleFile, { force, outputPath, locale }); if (result.status === "excluded") { console.log(`🚫 Excluded from "Ask my blog": ${path.basename(result.path)}`); console.log( ` Lift it with: node scripts/questions-review.mjs ${articleFile} (then press i)`, ); } else if (result.status === "skipped") { console.log(`⏭ Skipped (already exists): ${path.basename(result.path)}`); console.log(` Use --force to regenerate.`); } else { console.log(`✅ Written: ${result.path}\n ${result.count} questions.`); } } catch (err) { console.error("❌ Error:", err.message); process.exit(1); } } } ``` > **Choisir un petit modèle, volontairement:** > > Ça tourne sur `task-tiny`, un modèle instruct de 3B. Les modèles locaux plus gros ne produisaient pas de meilleures questions en comparaison directe et tournaient environ dix fois plus lentement. Écrire des questions de recherche à partir d'un titre, d'une description et d'une liste de titres est une tâche d'*extraction*, pas de raisonnement — et 259 articles à 11 secondes chacun, c'est une pause café, alors que 259 à deux minutes chacun, c'est un après-midi. ## Servir 2 160 questions de trois manières différentes {#serving-2160-questions-three-different-ways} Un plugin Docusaurus agrège les 259 sidecars en un seul index. Le point intéressant, c'est qu'il n'expédie **pas** cet index une fois — il l'expédie trois fois, sous trois formes différentes, parce qu'il a trois consommateurs aux besoins incompatibles : | Consommateur | Ce dont il a besoin | Comment il l'obtient | | ------------------------ | ---------------------------------------------------------------------------------------- | ------------------------------------------------------ | | Le hub `/faq` | 40 noms de sujets et leurs compteurs, groupés par [tag principal](/blog/docusaurus-tags) | `setGlobalData` — quelques centaines d'octets | | Les pages `/faq/` | Les questions d'un seul sujet, crawlables | `addRoute` + `createData`, code-split par route | | La boîte de recherche | Tout le corpus | Un simple fichier JSON statique, récupéré à la demande | C'est la troisième ligne qui compte. Le corpus complet pèse **468 Ko** (63 Ko gzippés), et la boîte de recherche vit dans un composant monté sur *chaque* page du site. L'exposer via `setGlobalData` aurait embarqué les 468 Ko dans le JavaScript de chaque page, que quelqu'un tape un caractère ou non. Le plugin l'écrit donc directement sur disque comme asset statique, et le client le `fetch` la première fois que quelqu'un ouvre vraiment la boîte de recherche : **plugins/questions-index-plugin/index.cjs** ```cjs /** * @fileoverview Docusaurus Plugin: questions-index-plugin * * Purpose (TODO 0083 — "Ask my blog"): * Aggregates every article's `
.questions.json` sidecar (see * scripts/generate-questions.mjs) into one corpus-wide, build-time question index — roughly * 8-12 "how would a developer search for this" questions per article, each pointing at * (article permalink, heading anchor). * * The corpus is heavy at scale (2000+ questions once the whole blog is covered) and has three * different consumers with three different performance needs, so it's shaped three ways * rather than shipped once as one blob: * * 1. `themes` (tiny — a few dozen rows) via `setGlobalData`, for the `/faq` hub's * table-of-contents. Cheap enough to embed globally like any other small plugin data. * 2. One route per theme (`/faq/`, via `addRoute` + `createData`), each carrying only * that theme's slice. Docusaurus code-splits per route automatically, so this is * server-rendered, crawlable HTML without any one page paying for the whole corpus. * 3. The full corpus as a plain static JSON asset (`/questions-index.json`, written straight * to disk and served via `staticDirectories`, see docusaurus.config.js), for * `AskMyBlog`/`CommandPalette`'s cross-theme search. Deliberately NOT exposed via * `setGlobalData`: that component tree is mounted on every page (`src/theme/Layout`), so a * `usePluginData` reference — even an unused one — would have gotten the whole ~470 KB * corpus bundled into every page's JS. A plain `fetch()` (same bridge pattern * `CommandPalette/utils.ts` already uses for Pagefind) keeps that cost opt-in: only a * reader who actually opens the search box pays for it, once, cached after that. * * An article with no `.questions.json` yet (not generated) simply contributes nothing — the * index degrades gracefully rather than erroring, so this plugin can ship before the corpus * is fully processed (mirrors the "activation progressive" idea in TODO 0084). * * Locales (TODO 0120): under a non-default locale the corpus is read from * `i18n//docusaurus-plugin-content-blog/` — the translated articles and the question * sidecars generated from them (`yarn questions --locale fr`). There is deliberately NO fallback * to the English sidecars: a French reader types a French query, and an English index cannot * answer it. An untranslated article therefore contributes nothing under `/fr/`. */ const fs = require("fs"); const path = require("path"); const { normalizeUrl } = require("@docusaurus/utils"); const frontMatter = require("front-matter"); const yaml = require("js-yaml"); const BLOG_DIR = "blog"; const I18N_BLOG_DIR = "docusaurus-plugin-content-blog"; const GENERATED_DIR_NAME = "questions-index-plugin"; // Public path (under the site's baseUrl) the client fetches for the full corpus — kept in // sync with the `staticDirectories` entry in docusaurus.config.js pointing at this plugin's // generated static folder. const SEARCH_INDEX_FILENAME = "questions-index.json"; // Mirrors plugins/markdown-export-plugin/index.cjs's findPosts()/permalinkFor() — duplicated // rather than imported from scripts/lib/blog-corpus.mjs, which is ESM and cannot be loaded by // a plain CJS require() here (same rationale as that file's own comment on the topic). function findPosts(dir) { const found = []; for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const target = path.join(dir, entry.name); if (entry.isDirectory()) { found.push(...findPosts(target)); } else if (entry.name === "index.md" || entry.name === "index.mdx") { found.push(target); } } return found; } // Deliberately NOT prefixed with the locale's baseUrl: every consumer navigates through // `` (or `withBaseUrl` in the command palette), which adds `/fr/` itself. Prefixing // here would produce `/fr/fr/blog/…`. A translation copies `slug` byte for byte, so the same // function serves both locales. See .claude/rules/i18n-locale-safety.md. function permalinkFor(attributes, dir) { if (attributes.slug) { return attributes.slug.startsWith("/") ? attributes.slug : `/blog/${attributes.slug.replace(/^\//, "")}`; } return `/blog/${dir}/`; } // Accents are folded before the ASCII filter: without it, "créer" became "cr er" and two // different French questions could collapse into the same key. function normalizeForDedupe(question) { return question .normalize("NFD") .replace(/\p{M}/gu, "") .toLowerCase() .replace(/[^\p{L}\p{N}]+/gu, " ") .trim(); } /** * Drops any question whose normalized text collides with another article's — see the TODO's * Risk section ("si 40 articles génèrent 'How do I install Docker?', l'index est cassé"). * Every colliding instance is dropped, including the first: a question that isn't specific to * exactly one article failed the generation prompt's own "specific to this article" rule, so * keeping one copy would still ship a too-generic entry. */ function dedupeAcrossCorpus(entries) { const counts = new Map(); for (const entry of entries) { const key = normalizeForDedupe(entry.question); counts.set(key, (counts.get(key) || 0) + 1); } const kept = entries.filter( (entry) => counts.get(normalizeForDedupe(entry.question)) === 1, ); return { kept, droppedCount: entries.length - kept.length }; } /** * The directory the corpus is read from: `blog/` on the default locale, the translated tree * otherwise. The sidecars live next to the article they were generated from, so the same walk * finds both. */ function corpusDirFor(siteDir, i18n) { return i18n.currentLocale === i18n.defaultLocale ? path.join(siteDir, BLOG_DIR) : path.join(siteDir, "i18n", i18n.currentLocale, I18N_BLOG_DIR); } function collectEntries(blogDir, siteDir) { if (!fs.existsSync(blogDir)) return { entries: [], articlesWithQuestions: 0 }; const entries = []; let articlesWithQuestions = 0; for (const file of findPosts(blogDir)) { const raw = fs.readFileSync(file, "utf-8"); const { attributes } = frontMatter(raw); if (!attributes.title || attributes.draft === true || attributes.draft === "true") continue; const sidecarPath = `${file}.questions.json`; if (!fs.existsSync(sidecarPath)) continue; let sidecar; try { sidecar = JSON.parse(fs.readFileSync(sidecarPath, "utf-8")); } catch { console.warn( `⚠ questions-index-plugin: unreadable sidecar, skipped — ${path.relative(siteDir, sidecarPath)}`, ); continue; } if (!Array.isArray(sidecar.questions) || sidecar.questions.length === 0) continue; const dir = path.relative(blogDir, path.dirname(file)).split(path.sep).join("/"); const permalink = permalinkFor(attributes, dir); articlesWithQuestions += 1; for (const q of sidecar.questions) { if (!q || typeof q.question !== "string" || !q.question.trim()) continue; entries.push({ question: q.question.trim(), anchor: typeof q.anchor === "string" ? q.anchor : "", permalink, title: attributes.title, mainTag: attributes.mainTag || null, }); } } const { kept, droppedCount } = dedupeAcrossCorpus(entries); if (droppedCount > 0) { console.log( `questions-index-plugin: dropped ${droppedCount} cross-article duplicate question(s).`, ); } return { entries: kept, articlesWithQuestions }; } function readTags(tagsYamlPath) { try { return yaml.load(fs.readFileSync(tagsYamlPath, "utf-8")) ?? {}; } catch { return {}; } } /** * Tag labels for the theme headings. The localized `tags.yml` is the one Docusaurus already * renders `/fr/blog/tags/` from (same source as `src/components/Blog/utils/tagsI18n.ts`); the * English file fills any key it lacks. */ function loadTagLabels(siteDir, blogDir) { const english = readTags(path.join(siteDir, BLOG_DIR, "tags.yml")); const localized = readTags(path.join(blogDir, "tags.yml")); return { ...english, ...localized }; } /** Groups entries by mainTag, sorted alphabetically by display label — same grouping the * `/faq` hub and each `/faq/` page render. */ // The one theme label that does not come from `tags.yml` (articles with no `mainTag`). const OTHER_LABEL = { en: "Other", fr: "Autres" }; function buildThemes(entries, tagLabels, locale) { const groups = new Map(); for (const entry of entries) { const key = entry.mainTag || "other"; if (!groups.has(key)) groups.set(key, []); groups.get(key).push(entry); } return [...groups.entries()] .map(([key, items]) => ({ key, label: key === "other" ? (OTHER_LABEL[locale] ?? OTHER_LABEL.en) : (tagLabels[key]?.label ?? key), items: [...items].sort((a, b) => a.question.localeCompare(b.question)), })) .sort((a, b) => a.label.localeCompare(b.label)); } function buildIndex(siteDir, i18n) { const blogDir = corpusDirFor(siteDir, i18n); const { entries, articlesWithQuestions } = collectEntries(blogDir, siteDir); const tagLabels = loadTagLabels(siteDir, blogDir); const themes = buildThemes(entries, tagLabels, i18n.currentLocale); return { // No build timestamp: this index ships in the client bundle and in // build/questions-index.json, and a wall-clock value would change // main.js's hash on every build (see plugins/blog-graph-plugin). entries, themes, meta: { articleCount: articlesWithQuestions, questionCount: entries.length, }, }; } module.exports = function questionsIndexPlugin(context) { const { siteDir, generatedFilesDir } = context; return { name: "questions-index-plugin", async loadContent() { return buildIndex(siteDir, context.i18n); }, async contentLoaded({ content, actions }) { const { entries, themes, meta } = content; // 3. Full corpus, as a plain static asset — see the file header for why this is // deliberately NOT setGlobalData(). Written directly to disk (not via createData, which // produces a webpack-bundled module) so it's a plain HTTP resource the client fetches // on demand; served via the `staticDirectories` entry in docusaurus.config.js. const staticDir = path.join(generatedFilesDir, GENERATED_DIR_NAME, "static"); fs.mkdirSync(staticDir, { recursive: true }); fs.writeFileSync( path.join(staticDir, SEARCH_INDEX_FILENAME), JSON.stringify(entries), ); // 2. One route per theme, each carrying only its own slice — code-split per route by // Docusaurus, so no page other than that theme's own pays for the others' questions. for (const theme of themes) { const dataPath = await actions.createData( `${theme.key}.json`, JSON.stringify(theme), ); await actions.addRoute({ // Through `baseUrl` — see docusaurus-plugin-tag-route. Hardcoding the path leaves // the route unreachable under a non-default locale. path: normalizeUrl([context.baseUrl, "faq", theme.key]), component: "@site/src/components/FaqThemePage", modules: { theme: dataPath }, exact: true, }); } // 1. Small summary for the `/faq` hub's table of contents — cheap enough to embed // globally like any other small plugin data. actions.setGlobalData({ meta, themes: themes.map((theme) => ({ key: theme.key, label: theme.label, permalink: `/faq/${theme.key}`, count: theme.items.length, })), }); }, getPathsToWatch() { // Rebuild the index in `yarn start` whenever an article or its question sidecar changes. return [ path.join(siteDir, "blog/**/index.{md,mdx}"), path.join(siteDir, "blog/**/*.questions.json"), path.join(siteDir, "blog/tags.yml"), path.join(siteDir, `i18n/*/${I18N_BLOG_DIR}/**/index.{md,mdx}`), path.join(siteDir, `i18n/*/${I18N_BLOG_DIR}/**/*.questions.json`), path.join(siteDir, `i18n/*/${I18N_BLOG_DIR}/tags.yml`), ]; }, }; }; ``` Le fetch est mis en cache au niveau du module, donc la palette et la page `/faq` partagent un seul téléchargement — et un fetch raté vide le cache au lieu de l'empoisonner, pour qu'un lecteur ayant cherché avant la fin du build du serveur de dev puisse simplement réessayer : **src/components/AskMyBlog/questionsIndex.ts** ```typescript /** * Lazy-loads the full "Ask my blog" question corpus (see plugins/questions-index-plugin) as a * plain HTTP fetch against a static JSON asset, instead of `usePluginData` — that corpus is * ~470 KB uncompressed / ~60 KB gzip once the whole blog is covered, and `AskMyBlog` / * `CommandPalette`'s `?` mode are the only two places that need the *full*, cross-theme * corpus. `CommandPalette` in particular is mounted on every page (`src/theme/Layout`), so a * `usePluginData` reference there would have shipped that whole corpus in every page's JS — * see the plugin's own header comment for the measured impact. * * Fetched once and cached at module scope (shared between `AskMyBlog` and `CommandPalette`, * whichever opens search first) — same "fetch a static build-time asset" bridge * `CommandPalette/utils.ts` already uses for Pagefind. */ import type { QuestionEntry } from "./utils"; let inFlight: Promise | null = null; /** `questionsIndexUrl` should be the baseUrl-aware absolute path (see `useBaseUrl`/ * `useBaseUrlUtils` — this module can't call that hook itself, it isn't a component). */ export function loadQuestionsIndex(questionsIndexUrl: string): Promise { if (!inFlight) { inFlight = fetch(questionsIndexUrl) .then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json() as Promise; }) .catch((err) => { // Let the next caller retry instead of caching a permanent failure — e.g. a reader // opening search before the dev server has finished its first build. inFlight = null; throw err; }); } return inFlight; } ``` Et la boîte de recherche elle-même : **Project: src/components/AskMyBlog** **src/components/AskMyBlog/index.tsx** ```typescript /** * AskMyBlog — a search-as-you-type box over the build-time "ask my blog" question index * (see .todos/0083-ask-my-blog-question-index.md and plugins/questions-index-plugin). * * Lazy-fetches the full `{ question, anchor, permalink, title, mainTag }` corpus on mount * (./questionsIndex.js — a plain static JSON asset, not `usePluginData`; see that module's * comment for why) and ranks it against the reader's query with plain BM25 (./utils.js), * entirely client-side: the semantic work already happened once at build time, when Ollama * phrased each question in a developer's own search vocabulary. */ import { useEffect, useMemo, useState, type JSX } from "react"; import Link from "@docusaurus/Link"; import { useBaseUrlUtils } from "@docusaurus/useBaseUrl"; import { buildSearchIndex, search, type QuestionEntry } from "./utils"; import { loadQuestionsIndex } from "./questionsIndex"; import styles from "./styles.module.css"; import Translate, { translate } from "@docusaurus/Translate"; interface Props { placeholder?: string; maxResults?: number; showLabel?: boolean; } export default function AskMyBlog({ placeholder, maxResults = 8, showLabel = true, }: Props): JSX.Element { const { withBaseUrl } = useBaseUrlUtils(); // null = not fetched yet, [] = loaded (possibly empty), only ever read after loading. const [questions, setQuestions] = useState(null); const [loadFailed, setLoadFailed] = useState(false); const [query, setQuery] = useState(""); useEffect(() => { let cancelled = false; loadQuestionsIndex(withBaseUrl("/questions-index.json")) .then((data) => { if (!cancelled) setQuestions(data); }) .catch(() => { if (!cancelled) setLoadFailed(true); }); return () => { cancelled = true; }; }, [withBaseUrl]); const searchIndex = useMemo(() => buildSearchIndex(questions ?? []), [questions]); const trimmed = query.trim(); const results = useMemo( () => (trimmed && questions ? search(searchIndex, trimmed, maxResults) : []), [searchIndex, trimmed, maxResults, questions], ); const status = !trimmed ? null : !questions && !loadFailed ? translate({ id: "askMyBlog.loading", message: "Loading…" }) : loadFailed ? translate({ id: "askMyBlog.unavailable", message: "Search is unavailable right now.", }) : results.length === 1 ? translate({ id: "askMyBlog.resultCount.one", message: "1 result" }) : translate( { id: "askMyBlog.resultCount.other", message: "{count} results" }, { count: results.length }, ); // Resolved inside the component, not as a parameter default: a default value is evaluated at // module scope, where translate() would freeze the locale of the first bundle load. const resolvedPlaceholder = placeholder ?? translate({ id: "blog.askMyBlog.defaultPlaceholder", message: 'Ask a question, e.g. "how do I reduce my image size?"', }); return (
setQuery(event.target.value)} placeholder={resolvedPlaceholder} autoComplete="off" aria-describedby="ask-my-blog-status" />
{status}
{results.length > 0 && (
    {results.map((entry, index) => (
  • {entry.question} {entry.title}
  • ))}
)} {trimmed && questions && !loadFailed && results.length === 0 && (

{/* Deliberately small: this state comes and goes on every keystroke, so a large illustration would flash while the reader types. */} {translate({ No matching article yet — try different words.

)}
); } ``` **src/components/AskMyBlog/utils.ts** ```typescript /** * Pure BM25 lexical scoring over the build-time question index — see the plugin * (plugins/questions-index-plugin) for where `questions` comes from and ../../../.todos * (0083) for why this is plain lexical matching rather than embeddings: the semantic bridge * between a reader's vocabulary and an article's wording was already built once, at build * time, by the model that phrased each question — nothing here needs to "understand" the * query, only find the closest wording among the ~2500 precomputed ones. */ // Standard Okapi BM25 constants — term-frequency saturation and length-normalization strength. const K1 = 1.5; const B = 0.75; export interface QuestionEntry { question: string; anchor?: string; permalink: string; title: string; mainTag?: string; } export interface SearchIndex { questions: QuestionEntry[]; docs: string[][]; docFrequency: Map; avgDocLength: number; docCount: number; } /** Lowercase word tokens — good enough for short question strings; hyphenated terms like * "php-cs-fixer" split into individual words, which still matches a query typed either way. * CamelCase/compound product names (e.g. "CaesiumCLT") get a boundary inserted before * lowercasing, so a query for just "caesium" still matches the "caesium" part of the token — * without this, "caesiumclt" is one indivisible token that a partial name never matches. * The *unsplit* form is kept too (union, not replace): "WordPress" would otherwise only * yield "word" + "press", and a reader typing "wordpress" as one word would match neither. * * Letters are matched as Unicode (`\p{L}`), then folded to their unaccented form: an ASCII-only * class split the French corpus mid-word ("déployer" became "d" + "ployer"), and folding lets a * reader typing "deployer" find "déployer". Applied to both the index and the query, since both * go through this function. A no-op on the English corpus. See TODO 0120. */ export function tokenize(text: string): string[] { const folded = text.normalize("NFD").replace(/\p{M}/gu, ""); const withBoundaries = folded.replace(/([\p{Ll}\p{N}])(\p{Lu})/gu, "$1 $2"); const split = withBoundaries.toLowerCase().match(/[\p{L}\p{N}]+/gu) || []; const whole = folded.toLowerCase().match(/[\p{L}\p{N}]+/gu) || []; return [...new Set([...split, ...whole])]; } /** * Precomputes everything BM25 needs to score `questions` against future queries: per-document * tokens, document length, and per-term document frequency. Call once when the index loads, * not per keystroke — scoring itself (in `search`) is the only per-keystroke work. */ export function buildSearchIndex(questions: QuestionEntry[]): SearchIndex { const docs = questions.map((entry) => tokenize(entry.question)); const docFrequency = new Map(); for (const tokens of docs) { for (const term of new Set(tokens)) { docFrequency.set(term, (docFrequency.get(term) || 0) + 1); } } const avgDocLength = docs.reduce((sum, tokens) => sum + tokens.length, 0) / (docs.length || 1); return { questions, docs, docFrequency, avgDocLength, docCount: docs.length }; } function idf(docFrequency: Map, docCount: number, term: string) { const n = docFrequency.get(term) || 0; return Math.log((docCount - n + 0.5) / (n + 0.5) + 1); } /** Expands one query token to the indexed terms it should match: itself, if it's already an * indexed term, or — for a still-being-typed prefix like "docus" — every indexed term it's a * prefix of (e.g. "docusaurus"). Below 3 characters a prefix matches too much of the vocabulary * to be useful, so short unindexed tokens are dropped instead of expanded. */ function expandTerm(docFrequency: Map, term: string): string[] { if (docFrequency.has(term)) return [term]; if (term.length < 3) return []; const matches = []; for (const key of docFrequency.keys()) { if (key.startsWith(term)) matches.push(key); } return matches; } /** Scores and ranks `index` against `query`, returning the top `limit` matches with score > 0. */ export function search(index: SearchIndex, query: string, limit = 8): QuestionEntry[] { const queryTerms = tokenize(query); if (queryTerms.length === 0) return []; const { questions, docs, docFrequency, avgDocLength, docCount } = index; // Expand once per query, not per document — the doc loop below only ever scores against // real indexed terms. const expandedTerms = queryTerms.flatMap((term) => expandTerm(docFrequency, term)); if (expandedTerms.length === 0) return []; const scored: { entry: QuestionEntry; score: number }[] = []; for (let i = 0; i < docs.length; i += 1) { const tokens = docs[i]; if (tokens.length === 0) continue; let score = 0; for (const term of expandedTerms) { const freq = tokens.filter((t) => t === term).length; if (freq === 0) continue; const numerator = freq * (K1 + 1); const denominator = freq + K1 * (1 - B + (B * tokens.length) / avgDocLength); score += idf(docFrequency, docCount, term) * (numerator / denominator); } if (score > 0) scored.push({ entry: questions[i], score }); } scored.sort((a, b) => b.score - a.score); return scored.slice(0, limit).map((s) => s.entry); } ``` **src/components/AskMyBlog/styles.module.css** ```css .container { margin: 1.5rem 0; } /* Was a hand-rolled eyebrow (0.85rem / 600 / 0.04em). */ .label { composes: mk-eyebrow from global; display: block; margin-bottom: 0.4rem; } .srOnly { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; } .input { width: 100%; padding: 0.75rem 1rem; font-family: inherit; font-size: 1.05rem; color: var(--mk-ink); background: var(--mk-surface); border: 1px solid var(--mk-border-strong); border-radius: var(--mk-radius-md); transition: border-color 0.15s ease, box-shadow 0.15s ease; } .input::placeholder { color: var(--mk-n-500); } .input:focus { outline: none; border-color: var(--mk-primary); box-shadow: 0 0 0 3px var(--mk-key-bg); } .status { margin-top: 0.4rem; font-size: 0.85rem; color: var(--mk-ink-soft); min-height: 1.2em; } .results { list-style: none; margin: 0.5rem 0 0; padding: 0; display: flex; flex-direction: column; gap: 0.5rem; } .resultItem { display: flex; flex-direction: column; padding: 0.6rem 0.9rem; background: var(--mk-surface); border: 1px solid var(--mk-border); border-radius: var(--mk-radius-md); } .resultLink { font-weight: 600; } .resultSource { margin-top: 0.15rem; font-size: 0.8rem; color: var(--mk-ink-soft); } /* The italic is dropped: it is a status line, and italics on a warm neutral at this size read as an error rather than as "nothing found". */ .empty { display: flex; align-items: center; gap: 0.6rem; margin-top: 0.75rem; color: var(--mk-ink-soft); } /* The thumbnail ships on an opaque cream background, so round it off and let it sit on its own tint rather than cutting a pale square out of a dark page. */ .emptyImage { flex: 0 0 auto; width: 56px; height: 56px; border-radius: 50%; object-fit: cover; } ``` ## Sous le capot (passez votre chemin si vous voulez juste les questions) {#under-the-hood-skip-this-if-you-just-want-the-questions} ### Interroger l'index depuis la ligne de commande {#querying-the-index-from-the-command-line} Le fichier JSON est un simple tableau, vous pouvez donc le sonder hors du navigateur en quelques lignes. Pratique pour déboguer l'index avant de le livrer, ou pour vérifier si une formulation précise correspond : ```bash $ npx tsx --eval " import { buildSearchIndex, search } from './src/components/AskMyBlog/utils.ts'; import fs from 'fs'; const rel = 'questions-index-plugin/static/questions-index.json'; const p = ['.docusaurus', '.docusaurus-dev'].map(d => d + '/' + rel).find(fs.existsSync); if (p === undefined) throw new Error('Run yarn build or start the dev server first'); const corpus = JSON.parse(fs.readFileSync(p, 'utf8')); const index = buildSearchIndex(corpus); for (const r of search(index, process.argv[1], 4)) { console.log(r.question); console.log(' ' + r.title + ' -> ' + r.permalink + (r.anchor ? '#' + r.anchor : '')); }" "run docusaurus in docker" How do I run my Docusaurus site using Docker? Running Docusaurus with Docker -> /blog/docusaurus-docker#run-docusaurus What commands are used to build and run the production Docker image for Docusaurus? Running Docusaurus using Docker -> /blog/running-docusaurus-with-docker#build-and-run-the-production-image How do I run Quarto Markdown in Docker? Running Quarto Markdown in Docker -> /blog/docker-quarto How do I run Firefox in a Docker container? Docker - Run Graphical User Interfaces - Firefox, Chrome & GIMP -> /blog/docker-run-linux-gui#creating-our-own-chrome-docker-image ``` ### BM25 en quarante lignes {#bm25-in-forty-lines} Le classement, c'est Okapi BM25 — fréquence des termes avec saturation, plus normalisation par la longueur, pondérée par la fréquence inverse de document. C'est ce qu'utilisent Lucene et Elasticsearch, et ça tient dans un petit fichier parce que chaque « document » ici est une seule question d'une douzaine de mots. Deux adaptations étaient nécessaires pour cette longueur de document inhabituellement courte. **Le matching par préfixe, développé une fois par requête.** Un lecteur qui tape `docus` n'a pas fini son mot, donc un token inconnu de trois caractères ou plus est développé vers tous les termes indexés qu'il préfixe. En dessous de trois caractères, un préfixe correspond à trop de vocabulaire pour signifier quoi que ce soit, donc les tokens inconnus courts sont simplement écartés. Point crucial : l'expansion a lieu une fois par requête, pas une fois par document — la boucle de scoring ne voit jamais que de vrais termes indexés. **Un tokenizer qui découpe les mots composés sans les perdre.** `CaesiumCLT` tokenisé naïvement est un seul token indivisible, donc un lecteur qui tape « caesium » ne trouve rien. Insérer une frontière avant de passer en minuscules règle ça — mais alors `WordPress` devient `word` + `press`, et quelqu'un qui tape « wordpress » en un mot ne correspond à aucune des deux moitiés. La solution est une union plutôt qu'un remplacement : garder à la fois la forme découpée et la forme entière. ```ts title="src/components/AskMyBlog/utils.ts" export function tokenize(text: string): string[] { const withBoundaries = text.replace(/([a-z0-9])([A-Z])/g, "$1 $2"); const split = withBoundaries.toLowerCase().match(/[a-z0-9]+/g) || []; const whole = text.toLowerCase().match(/[a-z0-9]+/g) || []; return [...new Set([...split, ...whole])]; } ``` ### Le problème des doublons {#the-duplicate-problem} Si quarante articles génèrent chacun « Comment installer Docker ? », l'index ne sert à rien — le lecteur obtient quarante lignes identiques et aucun signal. Le plugin normalise chaque question et supprime **toutes** les instances en collision, y compris la première. Ce dernier détail est volontaire : une question qui n'est pas spécifique à exactement un article a déjà enfreint la règle du prompt, donc en garder une copie reviendrait quand même à livrer une mauvaise entrée. Sur mon corpus, ça retire 5 questions sur 2 055. Le prompt fait l'essentiel du travail ; ceci n'est que le filet de sécurité. ### Les sidecars sont faits pour être édités à la main {#sidecars-are-meant-to-be-hand-edited} La génération est un premier jet. La plupart des entrées sont correctes, certaines sont une blague reprise telle quelle dans la prose d'un article et reformatée en question. Un petit élagueur interactif les retrouve par mot-clé et permet de supprimer par numéro : ```console $ yarn questions:edit "dinosaur" blog/2024/05/17/some-article/index.md.questions.json [0] How do I install the CLI? → #installation [1] How can I play with a green dinosaur? [2] What does the --force flag do? → #options Delete which number(s)? (space-separated, Enter to skip): 1 ✅ Removed 1 question(s), 11 left. ``` Supprimer une question ne touche jamais à l'article lui-même, ce qui compte pour la dernière pièce. ### Détecter la dérive {#detecting-drift} Une question générée peut pourrir en silence : renommez un titre et son ancre casse ; réécrivez une section et les questions cessent de la décrire. `yarn questions:check` compare le `sourceHash` stocké dans chaque sidecar au contenu actuel de l'article et signale `STALE`, plus les articles qui n'ont aucun sidecar. Par défaut il se contente de rapporter ; `--strict` échoue sur les entrées périmées mais jamais sur la couverture manquante, parce qu'un article fraîchement écrit n'a légitimement aucune question tant que vous n'avez pas lancé le générateur dessus. **scripts/check-questions-freshness.mjs** ```mjs #!/usr/bin/env node /** * Detect drift between *.questions.json sidecar files and the article they describe, and * flag published articles that have no sidecar at all yet (a new article, or one written * before this pipeline existed). A source edit can invalidate generated questions (a removed * heading breaks an anchor, a rewritten section makes a question stop matching) without * anything catching it — this flags that drift the same way check-eli5-freshness.mjs does * for .eli5.json. Coverage (missing sidecars) has no equivalent in the eli5 checker, since * eli5 annotations are opt-in per snippet, not expected for every article. * * Usage: * node scripts/check-questions-freshness.mjs [--strict] [--quiet] * * With --quiet: print nothing at all unless something is actually actionable (a stale or * orphaned sidecar, or a published article with no sidecar yet). Counts and review progress * are suppressed. This is what the pre-commit hook runs — the hook is verbose, so anything * the script prints shows up on every single commit. Run it by hand (`yarn questions:check`) * for the full report. * * Without --strict: report-only, always exits 0. * With --strict: exits 1 if any STALE or ORPHANED sidecar is found. Missing coverage never * fails --strict — a freshly written article legitimately has no questions yet until someone * runs `yarn questions` for it; this check only surfaces that, it doesn't gate commits on it. */ import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; import { execFileSync } from "child_process"; import { hashSource } from "./lib/eli5-hash.mjs"; import { loadPosts } from "./lib/blog-corpus.mjs"; import { questionCandidates } from "./lib/i18n-eligibility.mjs"; import { cmd } from "./lib/cheatsheet-hint.mjs"; import { orphanFixHint } from "./lib/orphan-hint.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const projectRoot = path.resolve(__dirname, ".."); const strict = process.argv.includes("--strict"); const quiet = process.argv.includes("--quiet"); // Enumerate via git rather than a filesystem walk — see check-eli5-freshness.mjs for why // (sibling worktrees under the project root would otherwise be double-counted). const gitListArgs = [ "ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", "blog/*.questions.json", // Localized sidecars (TODO 0120) sit next to the translated article, so the same // "strip the suffix, hash the source" check applies: a retranslation makes them stale. "i18n/*/docusaurus-plugin-content-blog/*.questions.json", ]; const relFiles = execFileSync("git", gitListArgs, { cwd: projectRoot, encoding: "utf-8" }) .split("\0") .filter(Boolean) // An article can *show* a sample sidecar in its own files/ folder // (blog/2026/09/28/docusaurus-ask-my-blog/files/demo.questions.json): documentation, not a // corpus sidecar, which always sits next to index.md. Scoping to blog/ was not enough — once // the draft was published it was reported ORPHANED again, and the printed `rm` hint got the // file deleted, breaking the build on a dangling . .filter((f) => !f.split("/").includes("files")); const files = relFiles.map((f) => path.resolve(projectRoot, f)); let fresh = 0, stale = 0, excluded = 0, orphaned = 0; // Collected so the summary can offer one copy-pasteable command covering every stale sidecar // — a batch of edited articles routinely produces eight or nine of these at once. const staleSources = []; // Localized stale sidecars are regenerated per locale by the eligibility-driven bulk run, // never with the English one-file command. const staleLocales = new Set(); for (const jsonPath of files) { const relJson = path.relative(projectRoot, jsonPath); const sourcePath = jsonPath.slice(0, -".questions.json".length); const relSource = path.relative(projectRoot, sourcePath); if (!fs.existsSync(sourcePath)) { console.warn(`⚠ ORPHANED — source missing for ${relJson}`); for (const line of orphanFixHint(relSource, relJson, projectRoot)) console.warn(line); orphaned++; continue; } let record; try { record = JSON.parse(fs.readFileSync(jsonPath, "utf-8")); } catch { console.warn(`⚠ UNREADABLE — ${relJson} is not valid JSON`); console.warn(` Fix with: ${cmd("questions", "--force", relSource)}`); orphaned++; continue; } // Articles deliberately excluded during review (scripts/questions-review.mjs) carry no // questions on purpose, so there is nothing to be stale about — editing the article must // not put them back on the "regenerate me" list. if (record.excluded === true) { excluded++; continue; } const currentHash = hashSource(fs.readFileSync(sourcePath, "utf-8")); if (currentHash !== record.sourceHash) { // No per-file warning here — staleSources feeds the single batched command printed // below, so nothing tempts a one-by-one `questions --force` per file. const localeMatch = relSource.match(/^i18n\/([^/]+)\//); if (localeMatch) staleLocales.add(localeMatch[1]); else staleSources.push(relSource); stale++; } else { fresh++; } } // Coverage — published articles (drafts already excluded by loadPosts()) with no sidecar at // all yet: a new article, or one written before this pipeline existed. Not a freshness // problem (nothing to compare against), just a gap worth surfacing. const missing = loadPosts().filter( (post) => !fs.existsSync(`${post.file}.questions.json`), ); // Localized coverage — translated articles whose English questions exist but whose localized // ones do not yet. Stale ones are already counted above. const i18nRoot = path.join(projectRoot, "i18n"); const locales = fs.existsSync(i18nRoot) ? fs .readdirSync(i18nRoot, { withFileTypes: true }) .filter((e) => e.isDirectory()) .map((e) => e.name) : []; const missingLocalized = locales.flatMap((locale) => questionCandidates(projectRoot, locale) .eligible.filter((file) => !fs.existsSync(`${file}.questions.json`)) .map((file) => ({ locale, file })), ); // In quiet mode the warnings above (if any) are the whole message — the counts below are // context that only makes sense when someone asked for a report. const actionable = stale > 0 || orphaned > 0 || missing.length > 0 || missingLocalized.length > 0; if (!quiet || actionable) { console.log( `\nquestions freshness: ${fresh} fresh, ${stale} stale, ${excluded} excluded, ${orphaned} orphaned (of ${files.length} sidecar file(s)).`, ); } if (staleSources.length > 0) { const list = staleSources.map((f) => `"${f}"`).join(" "); console.log( `⚠ STALE — ${staleSources.length} file(s) changed since their sidecar was generated:`, ); console.log(` for f in ${list}; do ${cmd("questions", "--force", '"$f"')}; done`); } for (const locale of staleLocales) { console.log(`⚠ STALE — "${locale}" question sidecar(s) behind their translation:`); console.log(` ${cmd("questions", "--locale", locale, "--all")}`); } if (missingLocalized.length > 0) { const byLocale = new Map(); for (const { locale } of missingLocalized) byLocale.set(locale, (byLocale.get(locale) || 0) + 1); for (const [locale, count] of byLocale) { console.log( `questions coverage (${locale}): ${count} translated article(s) with no sidecar yet — ` + cmd("questions", "--locale", locale, "--all"), ); } } if (!quiet || missing.length > 0) { console.log( `questions coverage: ${missing.length} published article(s) with no sidecar yet.`, ); } if (missing.length > 0) { // Capped — this is a report-only hook that can run on every commit; a 235-line dump (the // corpus-wide count before the bulk run has caught up) would drown the real signal. const MAX_LISTED = 10; for (const post of missing.slice(0, MAX_LISTED)) { console.log(` - ${cmd("questions", path.relative(projectRoot, post.file))}`); } if (missing.length > MAX_LISTED) { console.log( ` … and ${missing.length - MAX_LISTED} more — cover them all with: ` + cmd("questions:bulk"), ); } } // Human review progress — the sidecars are a first draft from a small local model until // someone has read them (see scripts/questions-review.mjs). const reviewed = files.filter((jsonPath) => { try { return Boolean(JSON.parse(fs.readFileSync(jsonPath, "utf-8")).reviewed); } catch { return false; } }).length; if (!quiet) { console.log( `questions review: ${reviewed}/${files.length} sidecar(s) reviewed by hand.` + (reviewed < files.length ? ` Continue with: ${cmd("questions:review")}` : ""), ); } if (strict && (stale > 0 || orphaned > 0)) { process.exit(1); } ``` ### Les pages de sujet embarquent des données structurées FAQ {#the-topic-pages-carry-faq-structured-data} Chaque page `/faq/` émet du JSON-LD `FAQPage`. Autant être honnête sur le retour : Google a restreint les rich results FAQ en août 2023 à un ensemble étroit de sites gouvernementaux et de santé faisant autorité, donc un blog personnel n'obtiendra pas l'affichage dépliable. Ça reste du balisage correct et conforme aux standards, que d'autres consommateurs lisent — mais pas un gain garanti dans la page de résultats. ## Conclusion {#conclusion} J'ai commencé en voulant de la recherche sémantique et j'ai fini sans avoir besoin de la moindre sémantique à l'exécution. Le modèle a fait le plus dur une fois, hors ligne, sur ma propre machine, gratuitement — et ce qu'il a laissé derrière lui, ce sont 2 160 phrases en anglais courant qu'un classement lexical ordinaire traite à merveille. Ce recadrage vaut la peine d'être transposé ailleurs. « Ça nécessite un modèle » signifie très souvent « ça a nécessité un modèle, une fois, et maintenant ça nécessite un fichier ». La version de votre fonctionnalité qui fait tourner un modèle de langage dans le navigateur de chaque visiteur et celle qui livre un fichier JSON calculé au build peuvent produire la même réponse — et une seule des deux fonctionne encore quand la clé d'API expire. Mon index complet, généré localement et committé dans Git, pèse 63 Ko sur le réseau. C'est moins que l'image de bannière en haut de cet article. --- # La troisième porte : une bulle de chat flottante, et la fonctionnalité que personne ne trouvait > Une fonctionnalité que vos visiteurs ne trouvent pas n'existe pas. Cet article ajoute une bulle flottante « Ask my blog » à un site Docusaurus — un troisième point d'entrée vers le même index de questions, et le premier qui ne demande aucune connaissance préalable. Avec l'astuce d'exclusion mutuelle qui empêche deux dialogues flottants indépendants de s'ouvrir en même temps. --- ![La troisième porte : une bulle de chat flottante, et la fonctionnalité que personne ne trouvait](/img/v2/ask-my-blog-chatbox.webp) **TL;DR** J'avais construit une carte du corpus, un index de 2 050 questions et une palette de commandes à six modes — puis j'ai remarqué que chaque accès à l'index de questions supposait de savoir qu'il existait. Cet article est la moitié bon marché de cette histoire : une bulle de chat flottante qui ouvre la recherche de questions depuis n'importe quelle page, et un indice de première visite qui mentionne le raccourci clavier une seule fois. Plus la petite astuce pub-sub qui garantit que deux dialogues flottants indépendants ne sont jamais ouverts en même temps. Il existe un embarras particulier, réservé aux gens qui construisent seuls. J'avais passé des semaines sur des fonctionnalités dont j'étais sincèrement fier : une [carte interactive de tout le blog](/blog/docusaurus-blog-map), un [index de questions](/blog/docusaurus-ask-my-blog) généré par un modèle local, une [palette de commandes](/blog/?q=docker) à six modes. Puis je me suis posé une question plus simple que d'habitude : comment un lecteur qui n'est jamais venu ici trouverait-il l'index de questions ? Il y avait exactement deux portes d'entrée. Appuyer sur `Ctrl`+`K`, puis `?` pour basculer la palette en mode question — ce qui suppose de connaître à la fois le raccourci et l'existence du mode. Ou aller sur `/faq`, ce qui suppose de savoir que la page existe. Les deux portes étaient ouvertes. Les deux ne fonctionnaient que pour quelqu'un qui avait déjà lu l'article les annonçant, c'est-à-dire : moi. **Quick Jump:** [Ce qui a changé](#what-changed) | [Construire la bulle](#building-the-bubble) ## Ce qui a changé {#what-changed} Deux choses, dont aucune n'est une nouvelle fonctionnalité : ```plaintext 1. A floating bubble 2. A first-visit pill ┌────────────────┐ ┌─────────────────┐ │ 🦫 Ask my blog │ │ Press ⌘K to │ ├────────────────┤ │ search ✕ │ │ [Ask a questi…]│ └─────────────────┘ │ › How do I … │ once per browser, │ › What is … │ then never again └────────────────┘ bottom-right, every page ``` La bulle est la plus intéressante, parce qu'elle est la **troisième** voie vers exactement le même index de questions : `Ctrl+K` puis `?`, la page autonome `/faq`, et maintenant un bouton en forme de chat qui suit le lecteur partout. Même corpus, même classement, mêmes résultats — trois portes. ## Pourquoi trois portes, ce n'est pas de la redondance {#why-three-doors-is-not-redundancy} J'ai résisté à la bulle un moment, au motif que la palette faisait déjà le travail. C'était une erreur, et voici le raisonnement qui m'a fait changer d'avis : * **Chaque porte sert un lecteur différent.** La palette sert ceux qui savent déjà qu'elle existe. `/faq` sert les moteurs de recherche et les gens qui veulent parcourir un sujet. La bulle sert le lecteur bloqué au milieu d'un article, qui n'aurait jamais l'idée d'appuyer sur un raccourci clavier. * **Un bouton flottant est la seule affordance que tout le monde comprend déjà.** Personne n'a besoin qu'on lui explique ce que fait une bulle de chat dans un coin. C'est précisément pour ça qu'elle mérite un coin — et pourquoi l'en-tête du panneau arbore la même [mascotte suricate](/blog/docusaurus-easter-eggs) qu'on retrouve partout ailleurs sur ce site. * **Ça ne coûte presque rien à ajouter.** La bulle ne réimplémente pas la recherche — elle monte le composant de recherche existant dans un panneau. Environ 150 lignes, dont l'essentiel est de la gestion du focus. * **La partie chère était déjà payée.** Le corpus de questions, le classement, le chargement paresseux : tout existait. Ajouter un point d'entrée à une fonctionnalité déjà construite est le travail au meilleur rapport effort/résultat, et c'est celui qu'on saute. Ce raisonnement a une limite, et il vaut mieux l'énoncer avant d'aller ajouter une quatrième porte. Le test n'est pas « combien de points d'entrée puis-je caser ? » mais « celui-ci atteint-il un lecteur que les autres ratent ? ». J'avais aussi prévu une tuile en page d'accueil pointant vers la palette, et je l'ai abandonnée : la barre de navigation affiche déjà un champ de recherche visible en permanence, avec le raccourci imprimé dedans, sur chaque page. Trois portes pour trois lecteurs différents, oui. Trois rappels de la même porte, non. ## Construire la bulle {#building-the-bubble} Le composant est volontairement mince. Quand le panneau est fermé, rien de lié à la recherche n'est monté — le corpus de questions de 468 Ko n'est donc téléchargé que par un lecteur qui l'ouvre vraiment : **Project: src/components/AskMyBlogWidget** **src/components/AskMyBlogWidget/index.tsx** ```typescript /** * AskMyBlogWidget — a third, always-visible entry point into "Ask my blog" (alongside * Ctrl+K → `?` and the standalone /faq page): a floating bubble, bottom-right, that opens a * small panel wrapping the existing search box (src/components/AskMyBlog). It * doesn't reimplement search — is only mounted once the panel is actually open, * so the question corpus fetch stays on-demand exactly as it already is for CommandPalette's * `?` mode (see AskMyBlog/questionsIndex.js for why that fetch is lazy). * * Reserves a fixed slot stacked above the scroll-to-top button's corner * (src/components/ScrollToTopButton, mounted on every page by src/theme/Layout) rather than * detecting its presence at runtime — that button stays hidden until the reader scrolls, and the * empty space above it meanwhile is harmless. */ import { useCallback, useEffect, useRef, useState, type JSX } from "react"; import { translate } from "@docusaurus/Translate"; import AskMyBlog from "@site/src/components/AskMyBlog"; import { setActiveOverlay } from "@site/src/components/CommandPalette/paletteBus"; import mascot from "@site/static/img/ask_my_blog.webp"; import styles from "./styles.module.css"; const HINT_STORAGE_KEY = "askmyblog_bubble_hint_shown"; const HINT_DELAY_MS = 15_000; export default function AskMyBlogWidget(): JSX.Element { const [open, setOpen] = useState(false); const [showHint, setShowHint] = useState(false); const panelRef = useRef(null); const bubbleRef = useRef(null); const clearOverlayRef = useRef<(() => void) | null>(null); // First-visit discoverability, shown at most once ever — same mechanic as // CommandPalette/Hint.tsx, offset from its 10s delay so the two don't animate in together. useEffect(() => { if (localStorage.getItem(HINT_STORAGE_KEY)) return undefined; const timer = setTimeout(() => { localStorage.setItem(HINT_STORAGE_KEY, "1"); setShowHint(true); }, HINT_DELAY_MS); return () => clearTimeout(timer); }, []); const close = useCallback(() => { setOpen(false); clearOverlayRef.current?.(); bubbleRef.current?.focus(); }, []); const toggle = () => { setShowHint(false); if (open) { close(); return; } setOpen(true); clearOverlayRef.current = setActiveOverlay(close); }; useEffect(() => { if (!open) return undefined; panelRef.current?.querySelector("input")?.focus(); function onKeyDown(event: KeyboardEvent) { if (event.key === "Escape") { event.preventDefault(); close(); } } function onTab(event: KeyboardEvent) { if (event.key !== "Tab" || !panelRef.current) return; const focusable = panelRef.current.querySelectorAll( 'button, [href], input, [tabindex]:not([tabindex="-1"])', ); if (focusable.length === 0) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } } document.addEventListener("keydown", onKeyDown); document.addEventListener("keydown", onTab); return () => { document.removeEventListener("keydown", onKeyDown); document.removeEventListener("keydown", onTab); }; }, [open, close]); return ( <> {open && (
{translate({ id: "askMyBlogWidget.title", message: "Ask my blog" })}
)} ); } ``` **src/components/AskMyBlogWidget/styles.module.css** ```css .bubble { position: fixed; right: 30px; /* 16px clear of the scroll-to-top button below it (ScrollToTopButton: bottom 30px, 60px tall → top edge at 90px) — the two used to sit 10px apart, easy to mis-tap in the bottom-right thumb zone. */ bottom: 106px; z-index: 999; display: flex; align-items: center; justify-content: center; width: 60px; height: 60px; padding: 0; cursor: pointer; background: var(--mk-primary); border: none; border-radius: 50%; box-shadow: var(--mk-shadow-3); transition: transform 0.2s, background 0.2s; } .bubble:hover { background: var(--ifm-color-primary-dark); transform: scale(1.06); } .bubble:focus-visible { outline: 2px solid var(--mk-primary); outline-offset: 3px; } @media (prefers-reduced-motion: reduce) { .bubble, .bubble:hover { transform: none; transition: none; } } .bubbleIcon { width: 30px; height: 30px; color: var(--mk-on-line); } .pulseRing { position: absolute; inset: -4px; border: 2px solid var(--mk-primary); border-radius: 50%; animation: askPulse 2.2s ease-out 3; pointer-events: none; } @media (prefers-reduced-motion: reduce) { .pulseRing { display: none; animation: none; } } @keyframes askPulse { 0% { opacity: 0.7; transform: scale(1); } 100% { opacity: 0; transform: scale(1.7); } } @media (max-width: 600px) { .bubble { right: 16px; /* Same 16px clearance above the mobile scroll-to-top button (bottom 16px, 44px tall → top edge at 60px). */ bottom: 76px; width: 44px; height: 44px; } .bubbleIcon { width: 22px; height: 22px; } } .panel { position: fixed; right: 30px; bottom: 176px; z-index: 999; display: flex; flex-direction: column; width: min(360px, calc(100vw - 2rem)); max-height: min(480px, calc(100vh - 200px)); overflow: hidden; background: var(--mk-surface); border: 1px solid var(--mk-border-strong); border-radius: var(--mk-radius-lg); box-shadow: var(--mk-shadow-3); animation: panelIn 0.15s ease-out; } @media (prefers-reduced-motion: reduce) { .panel { animation: none; } } @keyframes panelIn { from { opacity: 0; transform: translateY(8px) scale(0.98); } to { opacity: 1; transform: translateY(0) scale(1); } } .panelHead { display: flex; align-items: center; justify-content: space-between; padding: 0.55rem 1rem; font-size: 0.95rem; font-weight: 700; color: var(--mk-on-line); background: var(--mk-primary); } .panelHeadTitle { display: flex; align-items: center; gap: 0.5rem; } .panelHeadMascot { flex-shrink: 0; object-fit: cover; border: 2px solid var(--mk-on-line); border-radius: 50%; } .panelClose { padding: 0.1rem 0.3rem; font-size: 1.3rem; line-height: 1; color: var(--mk-on-line); opacity: 0.85; background: none; border: none; cursor: pointer; } .panelClose:hover { opacity: 1; } .panelBody { padding: 0.75rem 1rem 1rem; overflow-y: auto; } @media (max-width: 600px) { .panel { right: 16px; left: 16px; bottom: 128px; width: auto; } } ``` Il est monté une fois par page, dans le même swizzle `Layout` qui monte la palette : ```javascript title="src/theme/Layout/index.js" export default function LayoutWrapper(props) { return ( {props.children} ); } ``` Une décision de mise en page à copier : la bulle réserve un **emplacement fixe empilé au-dessus** du coin occupé par le [bouton de retour en haut](/blog/docusaurus-go-top), plutôt que de détecter la présence de ce bouton à l'exécution. Le retour en haut n'est pas monté sur toutes les pages, et un peu d'espace vide au-dessus quand il est absent est totalement inoffensif — bien plus inoffensif que deux boutons flottants se disputant les mêmes 60 pixels. ## Le problème des deux overlays {#the-two-overlays-problem} La palette et le panneau de la bulle sont des dialogues flottants indépendants, chacun ouvrable depuis plusieurs endroits, et aucun ne sait que l'autre existe. Ouvrez l'un pendant que l'autre est ouvert et vous obtenez deux modales empilées, deux pièges de focus qui se battent, et deux gestionnaires `Esc`. La correction tient en huit lignes, et elle vit dans le module de bus de la palette — comme ça, aucun composant n'a besoin d'importer l'autre : ```typescript title="src/components/CommandPalette/paletteBus.ts" let activeCloser = null; export function setActiveOverlay(closeFn) { if (activeCloser && activeCloser !== closeFn) activeCloser(); activeCloser = closeFn; return () => { if (activeCloser === closeFn) activeCloser = null; }; } ``` Chaque dialogue appelle `setActiveOverlay(close)` à son ouverture, et appelle la fonction de nettoyage qu'il récupère à sa fermeture. Celui qui s'ouvre en second ferme celui qui était ouvert en premier. Ajouter un troisième overlay plus tard ne demande de changer absolument rien. ## Un indice autorisé à apparaître une seule fois (passez si vous ne voulez que la bulle) {#a-hint-that-is-allowed-to-appear-exactly-once-skip-this-if-you-just-want-the-bubble} La pastille de la palette et l'anneau pulsant de la bulle sont des indices de première visite, et tous deux suivent les trois mêmes règles : * Le flag `localStorage` est écrit **quand l'indice est affiché**, pas quand il est fermé. Un visiteur qui l'ignore ne le revoit pas à la page suivante. * Les délais sont décalés — la pastille de la palette à 4 ou 10 secondes, le pulse de la bulle à 15 — pour que les deux ne s'animent jamais ensemble et ne passent pas pour un pop-up. * L'anneau pulsant est entièrement supprimé sous `prefers-reduced-motion`, pas seulement ralenti. Le délai de la palette est volontairement différent selon le type de page, et la raison mérite d'être dite : 10 secondes sur un article, pour que la pastille n'interrompe jamais quelqu'un qui vient de commencer à lire. La page d'accueil n'a aucune lecture à interrompre et c'est là que les visiteurs rebondissent le plus vite, donc elle a 4 secondes — assez long pour ne pas ressembler à un pop-up au chargement. ```typescript title="src/components/CommandPalette/Hint.tsx" function delayFor(pathname) { if (HOME_PATH.test(pathname)) return HOME_DELAY_MS; if (ARTICLE_PATH.test(pathname)) return ARTICLE_DELAY_MS; return null; } ``` Renvoyer `null` — plutôt qu'un grand nombre — fait de « ce chemin n'affiche rien » un état distinct et lisible, au lieu d'un délai qu'il faut déchiffrer en plissant les yeux. ## Conclusion {#conclusion} Les fonctionnalités de cette série ont pris des semaines. Les rendre trouvables a pris un après-midi, et il est tout à fait possible que cet après-midi ait compté davantage. Il y a un mode d'échec propre à la construction de ses propres outils : vous êtes le seul utilisateur incapable d'évaluer la découvrabilité, parce que vous ne pouvez pas désapprendre où se trouvent les choses. De l'intérieur, chaque porte paraît évidente. Le seul remède que j'ai trouvé est mécanique : listez les points d'entrée d'une fonctionnalité, et pour chacun, notez ce que le lecteur doit déjà savoir pour l'utiliser. Tout ce dont la réponse est « que cette fonctionnalité existe » n'est pas un point d'entrée du tout. Et en cas de doute, mettez un bouton dans le coin. Tout le monde sait ce qu'il fait. --- # Dessiner une carte de mon propre blog > Construisez une carte interactive de tout votre corpus Docusaurus — chaque article devient une bulle portant la mascotte du site, dimensionnée selon le nombre d'articles qui pointent vers elle, positionnée par un layout force-directed calculé au build dans Node, si bien que le navigateur ne charge jamais d3-force. Inclut le plugin, le composant canvas et le fallback en liste simple qui garde la page utilisable sans JavaScript. --- ![Dessiner une carte de mon propre blog](/img/v2/post_mindmaps.webp) **TL;DR** Après 262 articles, je ne savais plus répondre à des questions simples sur mon propre blog : quels billets sont des hubs vers lesquels tout pointe, et lesquels restent seuls dans un coin. J'ai donc construit une page `/map` — un graphe force-directed de tout le corpus, où chaque article est un point dimensionné selon son degré entrant et relié par trois types d'arêtes (vrais liens inline, voisins de série, tags partagés). L'astuce qui rend ça peu coûteux : le layout est calculé **une seule fois, dans Node, au build**, donc le navigateur reçoit des coordonnées `(x, y)` finales et ne charge jamais de moteur physique. Je publie deux fois par semaine, le lundi et le jeudi, et ça dure depuis un bon moment. Quelque part autour du billet numéro 150, je n'ai plus été capable de répondre à des questions que j'aurais dû connaître par cœur : cet article sur `fzf`, est-il seulement lié depuis quelque part ? Quel est le billet vers lequel tout le reste renvoie ? Est-ce que toute la série Quarto s'est retrouvée isolée du reste du blog ? J'avais les données — le frontmatter de chaque article, chaque lien inline — mais aucun moyen de les *regarder*. Une liste de 262 lignes, ce n'est pas une façon de regarder quoi que ce soit. J'ai donc construit [/map](/map) — une page qui dessine le blog sous forme de graphe. Voici comment ça fonctionne et comment en construire une. **Quick Jump:** [Ce que la page carte vous montre](#what-the-map-page-shows-you) | [La construire](#building-it) ## Ce que la page carte vous montre {#what-the-map-page-shows-you} Chaque article publié est une bulle. Plus elle est grosse, plus d'autres articles pointent vers elle. Des lignes relient les billets réellement liés entre eux — survolez-en un et tout ce qui n'est pas son voisin direct s'estompe : ```plaintext title="/map" ┌─ Blog Map ──────────────────────────────────────────────────────────────────┐ │ │ │ Filter by topic [ Top 120 most-linked articles ▾ ] │ │ 262 articles · 25 series · 680 internal links │ │ │ │ Running Docusaurus with Docker │ │ · · ● │ │ · ╲ │ ╱ · ╱ ╲ │ │ ·──── (◉) ────· ● ●───────● Docker-out-of-Docker │ │ · ╱ │ ╲ · ╲ ╱ │ │ · · ● │ │ 🦫 │ │ (drawn bigger than its size: │ │ nothing links to this one yet)│ │ │ │ ▸ View as list instead │ └─────────────────────────────────────────────────────────────────────────────┘ ``` Trois choses à faire sur cette page : * **Filtrer par sujet** avec la liste déroulante pour restreindre le graphe à un seul `mainTag`. Les articles qui partagent le tag mais ne sont liés à rien autour d'eux restent visibles — c'est exactement le genre d'orphelin que vous ne remarqueriez jamais autrement. * **Survoler un nœud** pour voir son titre et mettre en évidence ses connexions directes. Un cluster qui paraît dense de loin se révèle souvent être deux ou trois hubs avec beaucoup de satellites qui ne pointent que vers l'intérieur. * **Passer en liste** en bas de page si vous préférez le texte au canvas, ou si vous avez désactivé JavaScript : chaque article est toujours là, groupé par série, entièrement indexé. ## Pourquoi calculer le layout au build change tout {#why-computing-the-layout-at-build-time-changes-everything} Un graphe force-directed, c'est normalement une affaire de navigateur : vous embarquez une bibliothèque physique, vous y déposez les nœuds et vous les regardez se stabiliser pendant quelques secondes. Cette approche échoue sur trois exigences qui comptaient vraiment pour moi, et déplacer la simulation dans Node règle les trois d'un coup. * **La page doit fonctionner sans JavaScript.** Si les positions sont déjà finales dans le HTML, la seule chose qu'ajoute JavaScript est le dessin — le chemin sans JS peut donc retomber sur une liste simple, rendue côté serveur et entièrement indexable, de tous les articles, au lieu d'une boîte vide. * **Rien ne doit s'animer tout seul.** Il n'y a pas de branche `prefers-reduced-motion` à écrire, parce qu'il n'y a aucun mouvement à supprimer : le graphe est déjà au repos à l'instant où il apparaît. * **`d3-force` n'atteint jamais le lecteur.** Il reste une dépendance de build. Le navigateur reçoit des nombres, pas un solveur. * **Le même build produit la même carte.** L'ordre des nœuds est trié avant le démarrage de la simulation, et `d3-force` place les nœuds à partir de leur index de tableau plutôt que de `Math.random()` : un rebuild sans changement de contenu reproduit exactement la même image. Le coût de tout ça, c'est une seconde de plus dans `yarn build`. C'est le meilleur arbitrage que j'aie fait sur ce blog. ## La construire {#building-it} Deux morceaux : un plugin qui calcule le graphe, et un composant qui le dessine. ### Le plugin {#the-plugin} Il lit le corpus via le même loader que celui déjà utilisé par mon [outillage de tags](/blog/docusaurus-tags), construit les arêtes, colore chaque nœud, exécute 300 ticks de simulation et passe le résultat à `setGlobalData` : **plugins/blog-graph-plugin/index.mjs** ```mjs /** * Docusaurus Plugin: blog-graph * * Purpose: * Builds the corpus-wide node/edge graph (link, series and tag relationships between every * published article) that powers the `/map` page's `BlogGraph` component — see * `.todos/DONE/DONE_0081-blog-knowledge-graph-map-page.md` for the full spec. * * Why the force-directed layout is computed *here*, at build time, instead of in the * browser: * `/map`'s non-negotiable constraints (works with JS disabled, static rendering under * `prefers-reduced-motion`, indexable without hydration) all fall out for free once the * *positions* are already final by the time the page reaches the browser — so the * simulation runs once, in Node, and only the converged `(x, y)` per node ships to the * client. Nothing in the spec asks for live drag-to-reposition, so the client never needs * `d3-force` at all: the ~20 Ko dependency stays a build-time-only cost, out of the browser * bundle entirely. * * Data flow: * - `loadContent()` runs on every `yarn start` reload and once for `yarn build`. It reads * the same corpus `scripts/internal-link-opportunities.mjs` uses * (`scripts/lib/blog-corpus.mjs`, extracted for exactly this reason — see that file's * header), builds nodes/edges, colors each node, then runs a deterministic `d3-force` * simulation to converge `(x, y)`. * - `contentLoaded()` exposes the result as global plugin data via `setGlobalData`, so * `src/pages/map.js` reads it with `usePluginData("blog-graph-plugin")` — available * during SSR (baked into the static HTML) and during hydration, no client-side fetch. * * Node color — *not* an invented palette: * A post's node reuses whichever accent color the site already assigns it elsewhere: the * hand-picked `series.color` (`src/data/series.js`) for posts that belong to a series — * the same color `SeriesArticlesPage` themes its hero with — else the post's own * auto-extracted banner accent (`src/data/postColors.generated.js`, keyed by the post's * `image` frontmatter), the same color `BlogPostItem/Content` themes its hero with. See * `src/components/Blog/utils/color.md`. A post with neither falls back to a neutral gray. * * Edge types: * - "link" — a real inline link from one post's prose to another (heaviest pull). * - "series" — two consecutive posts (by date) within the same series. * - "tag" — shared tags between two posts. Only pairs sharing >= 2 tags are emitted at * all: at this corpus size almost every pair of posts shares at least one tag, so a * weight-1 threshold would connect everything to everything and defeat both the layout * and the "don't draw the weak edges" rule from the spec. There is no separate * simulate-but-don't-draw step — dropping weight-1 pairs before the layout runs is a * simplification of the spec's threshold, made possible by the layout itself running * once at build time rather than live in the browser. */ import path from "node:path"; import { fileURLToPath } from "node:url"; import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation, } from "d3-force"; import { loadPosts } from "../../scripts/lib/blog-corpus.mjs"; import translationsManifest from "../translations-manifest-plugin/index.cjs"; const { collectTranslations, collectTranslatedFrontMatter } = translationsManifest; import SERIES_DATA from "../../src/data/series.js"; import POST_COLORS from "../../src/data/postColors.generated.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); // Virtual coordinate space the layout is computed in. The client normalizes/scales this to // whatever the actual canvas size is, so these are arbitrary but must stay fixed for the // simulation's forces (distance, center) to make sense relative to one another. const WIDTH = 1600; const HEIGHT = 1000; const MIN_NODE_RADIUS = 3; const MAX_NODE_RADIUS = 20; // Below this many shared tags, a pair of posts is not connected at all — see the "tag" bullet // in the module comment above. const MIN_SHARED_TAGS = 2; // The layout (computeLayout, below) uses every edge down to MIN_SHARED_TAGS for spatial // pull, but the client never runs a simulation and only ever draws a "tag" edge above a // stricter threshold — shipping the weight-2 pairs (the large majority: ~1150 of ~1350 tag // edges in this corpus) would triple the payload for edges nothing ever renders. This is the // build-time equivalent of the spec's "simulate but don't draw below a threshold" rule. const SHIP_MIN_SHARED_TAGS = 3; const FALLBACK_COLOR = "#94a3b8"; // Neutral slate — only used when a post has neither a // series color nor a banner accent (no image, or an image `generate-post-colors.mjs` // couldn't extract a vivid color from). const AVERAGE_READING_WPM = 200; /** Approximate reading time from the already-prose-stripped body text. */ function readingTimeMinutes(prose) { const words = prose.split(/\s+/).filter(Boolean).length; return Math.max(1, Math.round(words / AVERAGE_READING_WPM)); } /** Builds the "link" / "series" / "tag" edges between posts. */ function buildEdges(posts) { const known = new Set(posts.map((post) => post.permalink)); const edges = []; // "link" — real inline links, the strongest signal of relatedness. for (const post of posts) { for (const target of post.links) { if (target !== post.permalink && known.has(target)) { edges.push({ source: post.permalink, target, type: "link", weight: 3 }); } } } // "series" — consecutive posts (by date) within the same series. const bySeries = new Map(); for (const post of posts) { if (!post.series) continue; if (!bySeries.has(post.series)) bySeries.set(post.series, []); bySeries.get(post.series).push(post); } for (const seriesPosts of bySeries.values()) { seriesPosts.sort((a, b) => a.date.localeCompare(b.date)); for (let i = 0; i < seriesPosts.length - 1; i += 1) { edges.push({ source: seriesPosts[i].permalink, target: seriesPosts[i + 1].permalink, type: "series", weight: 2, }); } } // "tag" — shared tags, weight = number of shared tags. See MIN_SHARED_TAGS above. for (let i = 0; i < posts.length; i += 1) { for (let j = i + 1; j < posts.length; j += 1) { const shared = posts[i].tags.filter((tag) => posts[j].tags.includes(tag)); if (shared.length >= MIN_SHARED_TAGS) { edges.push({ source: posts[i].permalink, target: posts[j].permalink, type: "tag", weight: shared.length, }); } } } return edges; } /** In-degree (how many *other* articles link to this one) per permalink, from "link" edges only. */ function computeInDegree(posts, edges) { const inDegree = new Map(posts.map((post) => [post.permalink, 0])); for (const edge of edges) { if (edge.type === "link" && inDegree.has(edge.target)) { inDegree.set(edge.target, inDegree.get(edge.target) + 1); } } return inDegree; } /** Reuses the site's own already-established accent colors — never an invented palette. */ function buildColorPicker() { const seriesColorByName = new Map( SERIES_DATA.map((series) => [series.name, series.color]), ); return (post) => { if (post.series && seriesColorByName.has(post.series)) { return seriesColorByName.get(post.series); } if (post.image && POST_COLORS[post.image]) { return POST_COLORS[post.image]; } return FALLBACK_COLOR; }; } /** Radius grows with the square root of in-degree — linear would let hubs swallow the map. */ function buildRadiusScale(maxInDegree) { const safeMax = Math.max(maxInDegree, 1); return (inDegree) => MIN_NODE_RADIUS + (MAX_NODE_RADIUS - MIN_NODE_RADIUS) * Math.sqrt(inDegree / safeMax); } const EDGE_TUNING = { link: { distance: 55, strength: 0.85 }, series: { distance: 70, strength: 0.5 }, tag: { distance: 110, strength: 0.08 }, // scaled further by weight below }; /** * Runs the force simulation to convergence and returns `{ permalink, x, y }` for every node. * Deterministic: node order is sorted before the simulation starts (d3-force's default * initial placement is a function of array index, not `Math.random()`), so re-running the * build without any corpus change reproduces the same layout. */ function computeLayout(nodes, edges) { const sortedNodes = [...nodes].sort((a, b) => a.permalink.localeCompare(b.permalink)); const radiusByPermalink = new Map( sortedNodes.map((node) => [node.permalink, node.radius]), ); const simNodes = sortedNodes.map((node) => ({ id: node.permalink })); const simLinks = edges.map((edge) => ({ ...edge })); const simulation = forceSimulation(simNodes) .force( "link", forceLink(simLinks) .id((d) => d.id) .distance((edge) => EDGE_TUNING[edge.type].distance) .strength((edge) => { const { strength } = EDGE_TUNING[edge.type]; return edge.type === "tag" ? Math.min(strength * edge.weight, 0.6) : strength; }), ) .force("charge", forceManyBody().strength(-45)) .force("center", forceCenter(WIDTH / 2, HEIGHT / 2)) .force( "collide", forceCollide((d) => radiusByPermalink.get(d.id) + 2), ) .stop(); // Pre-converge synchronously instead of animating in the browser — this is the "simulation // pré-convergée au build" the reduced-motion/no-JS requirements depend on. const TICKS = 300; for (let i = 0; i < TICKS; i += 1) { simulation.tick(); } return new Map(simNodes.map((node) => [node.id, { x: node.x, y: node.y }])); } /** * Builds the full graph — nodes, edges, layout and corpus-wide counters. * * The graph is a listing surface like any other, and a non-default locale needs **two** things * from it, not one. `loadPosts()` reads `blog/`, i.e. the English corpus, and Docusaurus's i18n * fallback gives every one of those articles a live `/fr/` route: * * 1. filter — show only articles that are really translated, or a French reader clicks a node * and lands on English prose; * 2. localize — label the survivors with their French title, or the French map reads entirely * in English. Filtering alone is what shipped: the four translated articles were correctly * the only four nodes on `/fr/map/`, each carrying its English title. * * Both the canvas labels and the `` fallback read `node.title`, so the overlay * belongs here — once — rather than in either component. See TODO 0119. */ function buildGraph({ siteDir, currentLocale, defaultLocale } = {}) { let posts = loadPosts(); /** slug -> localized front matter. Empty on the default locale, where titles are the source. */ let localized = {}; if (currentLocale && defaultLocale && currentLocale !== defaultLocale) { const translated = new Set(collectTranslations(siteDir)[currentLocale] ?? []); posts = posts.filter((post) => { const match = String(post.permalink ?? "").match(/blog\/([^/]+)\/?$/); return match ? translated.has(match[1]) : false; }); localized = collectTranslatedFrontMatter(siteDir)[currentLocale] ?? {}; } const edges = buildEdges(posts); const inDegreeByPermalink = computeInDegree(posts, edges); const maxInDegree = Math.max(0, ...inDegreeByPermalink.values()); const radiusScale = buildRadiusScale(maxInDegree); const colorForPost = buildColorPicker(); const nodes = posts.map((post) => { const inDegree = inDegreeByPermalink.get(post.permalink) ?? 0; return { slug: post.slug, // The node label — the only text a reader sees on the map. `post.title` is the English // source; in a non-default locale the corpus above has already been narrowed to articles // that DO have a translation, so the overlay finds one for every surviving node. title: localized[post.slug]?.title || post.title, permalink: post.permalink, date: post.date, mainTag: post.mainTag, series: post.series, readingTime: readingTimeMinutes(post.prose), inDegree, radius: radiusScale(inDegree), color: colorForPost(post), }; }); const layout = computeLayout(nodes, edges); for (const node of nodes) { const position = layout.get(node.permalink); node.x = position.x; node.y = position.y; } const seriesCount = new Set(posts.map((post) => post.series).filter(Boolean)).size; const linkEdgeCount = edges.filter((edge) => edge.type === "link").length; // See SHIP_MIN_SHARED_TAGS above: the layout already used the full tag-edge set, this only // trims what ships to the browser. const shippedEdges = edges.filter( (edge) => edge.type !== "tag" || edge.weight >= SHIP_MIN_SHARED_TAGS, ); return { // No build timestamp here on purpose. This object ships inside the client // bundle, so a wall-clock value changes main.js's content hash on every // build, which renames the file, which changes every HTML page that // references it — ~44 MB re-uploaded on each FTP deploy for nothing. width: WIDTH, height: HEIGHT, nodes, edges: shippedEdges, meta: { articleCount: posts.length, seriesCount, linkCount: linkEdgeCount, }, }; } export default function blogGraphPlugin(context) { return { name: "blog-graph-plugin", async loadContent() { return buildGraph({ siteDir: context.siteDir, currentLocale: context.i18n?.currentLocale, defaultLocale: context.i18n?.defaultLocale, }); }, async contentLoaded({ content, actions }) { actions.setGlobalData(content); }, getPathsToWatch() { // Rebuild the graph in `yarn start` whenever an article's frontmatter/body or the // corpus-loading lib itself changes — not just on a hard restart. return [ path.join(__dirname, "../../blog/**/index.{md,mdx}"), path.join(__dirname, "../../scripts/lib/blog-corpus.mjs"), path.join(__dirname, "../../src/data/series.js"), path.join(__dirname, "../../src/data/postColors.generated.js"), ]; }, }; } ``` L'exécuter en standalone montre à quoi ressemble la charge utile : ```bash $ node --input-type=module -e " import('./plugins/blog-graph-plugin/index.mjs').then(async m => { const graph = await m.default().loadContent(); const byType = {}; for (const edge of graph.edges) byType[edge.type] = (byType[edge.type] ?? 0) + 1; console.log('nodes ', graph.nodes.length); console.log('edges ', graph.edges.length, byType); console.log('meta ', graph.meta); console.log('payload', JSON.stringify(graph).length, 'bytes'); });" nodes 262 edges 1026 { link: 680, series: 144, tag: 202 } meta { articleCount: 262, seriesCount: 25, linkCount: 680 } payload 183292 bytes ``` 262 nœuds, 1026 arêtes, 179 Ko de JSON — calculés une fois au build, plus jamais touchés par le navigateur. Une décision mérite d'être soulignée : **les couleurs des nœuds ne sont pas une nouvelle palette.** Un billet appartenant à une série réutilise la couleur d'accent de cette série depuis `src/data/series.js` — la même que celle dont la [page des séries](/blog/docusaurus-series) habille son hero. Un billet sans série retombe sur la couleur d'accent extraite automatiquement de son image de bannière. Seul un billet qui n'a ni l'un ni l'autre obtient un gris neutre. La carte ressemble donc au reste du site gratuitement, et un lecteur qui reconnaît déjà « la bleue, c'est la série Quarto » la reconnaît ici aussi. ### Le composant {#the-component} `BlogGraph` lit ces données globales, les dessine sur un `` et se remplace par une liste simple en dessous de 768px : **Project: src/components/BlogGraph** **src/components/BlogGraph/index.tsx** ```typescript /** * BlogGraph — the interactive corpus map rendered on `/map`. * * Reads the pre-computed graph (nodes, edges, build-time force layout) from * `plugins/blog-graph-plugin` via `usePluginData`, then: * - draws it on a `` (248 nodes + edges in the DOM would crawl on hover/drag — * see 0081's "Solution" §2), never more than the top ~120 articles by in-degree, or a * single mainTag's subgraph once one is picked from the filter; * - degrades to `GroupedList` (an SSR'd, fully indexable plain list) with JavaScript * disabled and on narrow viewports, where a force-directed graph has no room to be * legible. * * The layout itself is already static (computed once, in Node — see the plugin's header * comment) so there is no `prefers-reduced-motion` branch to build here: nothing animates * on its own in the first place. The only motion is the hover crossfade, which the * stylesheet already turns off under that media query. * * No client-side simulation, no drag-to-reposition — out of scope per the TODO (only hover * and click are specified), and dropping it keeps `d3-force` a build-time-only dependency. * * The nodes themselves are drawn as this blog's own meerkat mascot rather than as flat dots — * one sticker per mainTag, so a topic reads as a family at a glance. See ./meerkats for the * mapping, and MEERKAT_MIN_DRAWN_RADIUS in ./utils for the size below which a face stops being * legible and the node falls back to the colored dot it always was. */ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import clsx from "clsx"; import { useHistory } from "@docusaurus/router"; import { usePluginData } from "@docusaurus/useGlobalData"; import { useBaseUrlUtils } from "@docusaurus/useBaseUrl"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import { useTagLabel } from "@site/src/components/Blog/utils/tagsI18n"; import GroupedList from "./GroupedList"; import { DEFAULT_TOP_N, MEERKAT_MIN_DRAWN_RADIUS, PERMANENT_LABEL_COUNT, computeCanvasHeight, displayRadius, fitTransform, neighborsOf, pickOrphanNodes, selectVisibleEdges, selectVisibleNodes, toCanvasSpace, type BlogGraphData, type BlogGraphNode, type Transform, } from "./utils"; import { meerkatPathFor } from "./meerkats"; import styles from "./styles.module.css"; import Translate, { translate } from "@docusaurus/Translate"; /** * Sticker path (as `meerkatPathFor` returns it, before `withBaseUrl`) -> the decoded image, for * the stickers the current view actually needs. */ type MeerkatImages = Record; interface LabelCandidate { node: BlogGraphNode; x: number; y: number; dimmed: boolean; } const MOBILE_BREAKPOINT = 768; const DIMMED_ALPHA = 0.15; const HOVER_HIT_PADDING = 3; const LABEL_FONT = "12px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"; const EDGE_ALPHA: Record = { link: 0.55, series: 0.4, tag: 0.18 }; const EDGE_WIDTH: Record = { link: 1.4, series: 1, tag: 0.7 }; const LABEL_HEIGHT = 14; const LABEL_PADDING = 2; /** * Greedily keeps only the labels that don't overlap an already-placed one, in priority * order (the caller sorts `candidates` — hovered node first, then its neighbors, then the * permanently-labeled hubs). A crowded mainTag filter can otherwise stack a dozen titles on * top of each other, which is exactly the "hairball" outcome the spec says must not ship. */ function selectNonOverlappingLabels( ctx: CanvasRenderingContext2D, candidates: LabelCandidate[], ): LabelCandidate[] { const placed: LabelCandidate[] = []; const drawnRects: { left: number; right: number; top: number; bottom: number }[] = []; for (const candidate of candidates) { const width = ctx.measureText(candidate.node.title).width; const rect = { left: candidate.x - LABEL_PADDING, right: candidate.x + width + LABEL_PADDING, top: candidate.y - LABEL_HEIGHT / 2, bottom: candidate.y + LABEL_HEIGHT / 2, }; const overlapsExisting = drawnRects.some( (drawn) => rect.left < drawn.right && rect.right > drawn.left && rect.top < drawn.bottom && rect.bottom > drawn.top, ); if (!overlapsExisting) { drawnRects.push(rect); placed.push(candidate); } } return placed; } /** Reads the current theme's own colors so the canvas never hardcodes a hex value. */ function readThemeColors(): { edge: string; label: string } { const style = getComputedStyle(document.documentElement); return { edge: style.getPropertyValue("--ifm-color-emphasis-500").trim() || "#a2917a", label: style.getPropertyValue("--ifm-font-color-base").trim() || "#1c1e21", }; } /** * The sticker to draw inside a node, or null when it shouldn't wear one: too small for a face * to read (see MEERKAT_MIN_DRAWN_RADIUS), or simply not downloaded yet — in both cases the * caller falls back to the flat colored dot. */ function resolveMeerkatImage( node: BlogGraphNode, radius: number, images: MeerkatImages, ): HTMLImageElement | null { if (radius < MEERKAT_MIN_DRAWN_RADIUS) return null; const path = meerkatPathFor(node); return (path && images[path]) || null; } /** Draws `image` centered on (cx, cy), scaled to cover a `size` × `size` box (crop, not stretch). */ function drawImageCover( ctx: CanvasRenderingContext2D, image: HTMLImageElement, cx: number, cy: number, size: number, ) { const naturalWidth = image.naturalWidth || image.width; const naturalHeight = image.naturalHeight || image.height; const scale = Math.max(size / naturalWidth, size / naturalHeight); const drawWidth = naturalWidth * scale; const drawHeight = naturalHeight * scale; ctx.drawImage(image, cx - drawWidth / 2, cy - drawHeight / 2, drawWidth, drawHeight); } export default function BlogGraph() { const graph = usePluginData("blog-graph-plugin") as BlogGraphData | undefined; const history = useHistory(); const { withBaseUrl } = useBaseUrlUtils(); const { i18n } = useDocusaurusContext(); const tagLabel = useTagLabel(); const wrapRef = useRef(null); const canvasRef = useRef(null); const [mounted, setMounted] = useState(false); const [isMobile, setIsMobile] = useState(false); const [mainTag, setMainTag] = useState(""); const [hovered, setHovered] = useState(null); const [containerWidth, setContainerWidth] = useState(0); // Bumped on theme toggle so the draw effect re-reads the (now different) CSS variables — // canvas has no way to react to a CSS variable change on its own. const [themeVersion, setThemeVersion] = useState(0); // Starts empty, so the draw effect just falls back to a flat color until (and unless) an // image is ready. const [meerkatImages, setMeerkatImages] = useState({}); // Every URL a load has already been started for — including the ones that failed, which are // deliberately never retried: a node that can't get its sticker keeps its colored dot, and a // broken asset must not turn into one request per redraw. const requestedRef = useRef(new Set()); useEffect(() => { // eslint-disable-next-line react-hooks/set-state-in-effect -- SSR-safe mount detection, same pattern as ScrollToTopButton/reactions-dashboard setMounted(true); const mediaQuery = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`); const updateIsMobile = () => setIsMobile(mediaQuery.matches); updateIsMobile(); mediaQuery.addEventListener("change", updateIsMobile); return () => mediaQuery.removeEventListener("change", updateIsMobile); }, []); useEffect(() => { const observer = new MutationObserver(() => setThemeVersion((v) => v + 1)); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"], }); return () => observer.disconnect(); }, []); // Progressive enhancement only: canvas graph once mounted on a wide-enough viewport, // the plain grouped list everywhere else (no JS at all, or a narrow screen). const showCanvas = mounted && !isMobile; useEffect(() => { if (!showCanvas || !wrapRef.current) return undefined; const el = wrapRef.current; const updateWidth = () => setContainerWidth(el.clientWidth); updateWidth(); const resizeObserver = new ResizeObserver(updateWidth); resizeObserver.observe(el); return () => resizeObserver.disconnect(); }, [showCanvas]); const mainTags = useMemo(() => { if (!graph) return []; const keys = [ ...new Set(graph.nodes.map((node) => node.mainTag).filter(Boolean)), ] as string[]; // Sorted on the DISPLAYED label, not on the key: under `fr`, "ai" reads "Intelligence // artificielle (IA)" and sorting by key would order the dropdown by invisible values. // `currentLocale` is passed explicitly — the runtime default differs between the Node // render and the browser, which would reorder the list at hydration. return keys.sort((a, b) => tagLabel(a).localeCompare(tagLabel(b), i18n.currentLocale), ); }, [graph, tagLabel, i18n.currentLocale]); const visibleNodes = useMemo(() => { if (!graph) return []; return selectVisibleNodes(graph.nodes, { mainTag: mainTag || null, topN: DEFAULT_TOP_N, }); }, [graph, mainTag]); // Height fits the *currently visible* nodes, not a constant tuned for the full 120-node // view — see computeCanvasHeight() for why a two-node mainTag filter needs two independent // signals (shape *and* count) to reliably get a short canvas instead of inheriting the // default view's height with almost everything empty. const canvasSize = useMemo( () => ({ width: containerWidth, height: computeCanvasHeight(visibleNodes, containerWidth), }), [containerWidth, visibleNodes], ); const visiblePermalinks = useMemo( () => new Set(visibleNodes.map((node) => node.permalink)), [visibleNodes], ); const nodeByPermalink = useMemo( () => new Map(visibleNodes.map((node) => [node.permalink, node])), [visibleNodes], ); const visibleEdges = useMemo(() => { if (!graph) return []; return selectVisibleEdges(graph.edges, visiblePermalinks); }, [graph, visiblePermalinks]); const orphanNodes = useMemo( () => pickOrphanNodes(visibleNodes, visibleEdges), [visibleNodes, visibleEdges], ); // Only the stickers this view needs, not all 68: the default view uses one per mainTag among // its 120 nodes, and a filtered view exactly one. `.sort().join()` keeps the dependency a // stable string, so switching filters back and forth doesn't re-run the effect for a set // that is already loaded. const meerkatPaths = useMemo(() => { const paths = new Set(); for (const node of visibleNodes) { const path = meerkatPathFor(node); if (path) paths.add(path); } return [...paths].sort().join(" "); }, [visibleNodes]); useEffect(() => { const missing = meerkatPaths .split(" ") .filter((path) => path && !requestedRef.current.has(path)); if (missing.length === 0) return undefined; for (const path of missing) requestedRef.current.add(path); let cancelled = false; const load = (path: string) => new Promise<[string, HTMLImageElement | null]>((resolve) => { const image = new Image(); image.onload = () => resolve([path, image]); // A failed load (offline, blocked asset, a sticker renamed since the last build) just // means that node keeps its flat color — never a reason to break the graph. image.onerror = () => resolve([path, null]); // The images live in `static/`, which each locale build copies under its own prefix: // without withBaseUrl, /fr/map/ would request /img/... and get the SPA fallback. image.src = withBaseUrl(path); }); Promise.all(missing.map(load)).then((entries) => { if (cancelled) return; const loaded: MeerkatImages = {}; for (const [path, image] of entries) { if (image) loaded[path] = image; } if (Object.keys(loaded).length > 0) { setMeerkatImages((previous) => ({ ...previous, ...loaded })); } }); return () => { cancelled = true; }; }, [meerkatPaths, withBaseUrl]); // The list fallback (mobile, no JS, or the "View as list" disclosure) ignores the top-N // cutoff — a plain list has no legibility ceiling the way a canvas does — but must still // respect the mainTag filter: otherwise picking a topic from the setMainTag(event.target.value)} > {mainTags.map((tag) => ( ))}

{"{articles} articles · {series} series · {links} internal links"}

{showCanvas && ( Your browser does not support the canvas element — see the list below instead. )} {showCanvas ? (
View as list instead
) : ( )}
); } ``` **src/components/BlogGraph/utils.ts** ```typescript /** * Pure helpers for BlogGraph — kept separate from the canvas/React code so the filtering, * fit-to-viewport and labeling logic can be read (and changed) without wading through event * handlers. */ // Default view: the corpus is never shown all at once (248 nodes is a hairball) — only the // top articles by in-degree, unless a mainTag is picked instead. See 0081's "Solution" §2. export const DEFAULT_TOP_N = 120; // How many of the most-connected *visible* nodes keep a permanently drawn label — the rest // only label on hover, per the spec's "pas 248 labels" rule. export const PERMANENT_LABEL_COUNT = 8; // Nodes are drawn as the site's own mascot (see ./meerkats for which sticker) rather than as // flat-colored dots — but only where the sticker still says something. Measured on the // rendered sizes: at a 16px diameter the meerkat is recognizable, at 12px it is a warm blob // with a colored ring, and below that the 1.5px ring eats a third of it and a flat dot reads // better. In the default view every node clears this at any desktop width (drawn radii run // 6-18px); the leftovers are the marginal satellites of a filtered view. export const MEERKAT_MIN_DRAWN_RADIUS = 6; // A node with no visible connection sits at MIN_NODE_RADIUS (a few px) — under the threshold // above, so it would be the one node the reader's eye lands on and the only one still a dot. // Only those get this floor; everything else keeps its data-driven size, which is what the // build-time collision layout was computed against. export const MEERKAT_MIN_RADIUS = 13; /** One article, as shipped by `plugins/blog-graph-plugin` (build-time layout already computed). */ export interface BlogGraphNode { slug: string; title: string; permalink: string; date: string; mainTag: string | null; series: string | null; readingTime: number; inDegree: number; radius: number; color: string; x: number; y: number; } export type BlogGraphEdgeType = "link" | "series" | "tag"; export interface BlogGraphEdge { source: string; target: string; type: BlogGraphEdgeType; weight: number; } export interface BlogGraphData { width: number; height: number; nodes: BlogGraphNode[]; edges: BlogGraphEdge[]; meta: { articleCount: number; seriesCount: number; linkCount: number; }; } /** Maps the fixed virtual coordinate space onto the actual canvas pixel size. */ export interface Transform { scale: number; offsetX: number; offsetY: number; } /** * The nodes to draw: either every node under the chosen mainTag, or — by default — the top * `topN` articles by in-degree across the whole corpus. Never the full corpus at once. */ export function selectVisibleNodes( nodes: BlogGraphNode[], { mainTag, topN = DEFAULT_TOP_N }: { mainTag?: string | null; topN?: number } = {}, ): BlogGraphNode[] { if (mainTag) { return nodes.filter((node) => node.mainTag === mainTag); } return [...nodes].sort((a, b) => b.inDegree - a.inDegree).slice(0, topN); } /** Edges whose two endpoints are both currently visible. */ export function selectVisibleEdges( edges: BlogGraphEdge[], visiblePermalinks: Set, ): BlogGraphEdge[] { return edges.filter( (edge) => visiblePermalinks.has(edge.source) && visiblePermalinks.has(edge.target), ); } /** * Maps the (fixed, build-time) virtual coordinate space onto the actual canvas size, * fitting the *visible* nodes' bounding box so a single mainTag's cluster — which only * occupies a fraction of the full layout — isn't drawn tiny in a mostly-empty canvas. */ export function fitTransform( nodes: BlogGraphNode[], canvasWidth: number, canvasHeight: number, padding = 40, ): Transform { if (nodes.length === 0) { return { scale: 1, offsetX: 0, offsetY: 0 }; } let minX = Infinity; let maxX = -Infinity; let minY = Infinity; let maxY = -Infinity; for (const node of nodes) { minX = Math.min(minX, node.x - node.radius); maxX = Math.max(maxX, node.x + node.radius); minY = Math.min(minY, node.y - node.radius); maxY = Math.max(maxY, node.y + node.radius); } const spanX = Math.max(maxX - minX, 1); const spanY = Math.max(maxY - minY, 1); const availableWidth = Math.max(canvasWidth - padding * 2, 1); const availableHeight = Math.max(canvasHeight - padding * 2, 1); const scale = Math.min(availableWidth / spanX, availableHeight / spanY, 4); const drawnWidth = spanX * scale; const drawnHeight = spanY * scale; const offsetX = (canvasWidth - drawnWidth) / 2 - minX * scale; const offsetY = (canvasHeight - drawnHeight) / 2 - minY * scale; return { scale, offsetX, offsetY }; } /** * The height/width ratio of the visible nodes' own bounding box, in the fixed virtual * coordinate space (so it's independent of the actual canvas pixel size). `null` when there * aren't enough nodes to infer a meaningful shape (0 or 1). */ function contentAspectRatio(nodes: BlogGraphNode[]): number | null { if (nodes.length < 2) { return null; } let minX = Infinity; let maxX = -Infinity; let minY = Infinity; let maxY = -Infinity; for (const node of nodes) { minX = Math.min(minX, node.x - node.radius); maxX = Math.max(maxX, node.x + node.radius); minY = Math.min(minY, node.y - node.radius); maxY = Math.max(maxY, node.y + node.radius); } const spanX = Math.max(maxX - minX, 1); const spanY = Math.max(maxY - minY, 1); return spanY / spanX; } /** * The canvas element's own height for the current container width and visible node set. * Blends two independent estimates and keeps whichever is *smaller*, because either one * alone has a blind spot that reproduces the "gigantic canvas for two elements" bug: * * - **aspect-ratio** fits the visible nodes' own bounding-box shape — works well when a * filtered mainTag's articles happen to cluster together spatially, but a couple of nodes * that are simply far apart *in the shared, whole-corpus layout* (they don't need to link * to each other to share a mainTag) produce an extreme ratio that clamps right back up to * the default max height, i.e. no visible shrink at all. * - **density** scales down purely from how few nodes are visible relative to the default * top-N view — catches exactly that case, but on its own would happily give a tall, tightly * clustered pair of nodes a squashed canvas it didn't need. * * Taking the minimum means neither blind spot alone can produce an oversized canvas. */ export function computeCanvasHeight( nodes: BlogGraphNode[], containerWidth: number, { defaultTopN = DEFAULT_TOP_N, minHeight = 220, maxRatio = 0.62, padding = 80, }: { defaultTopN?: number; minHeight?: number; maxRatio?: number; padding?: number; } = {}, ): number { if (containerWidth <= 0) { return 0; } const maxHeight = Math.round(containerWidth * maxRatio); const clamp = (height: number) => Math.min(Math.max(height, minHeight), maxHeight); const ratio = contentAspectRatio(nodes) ?? maxRatio; const aspectHeight = clamp(Math.round(containerWidth * ratio) + padding); const density = Math.min(nodes.length / defaultTopN, 1); const densityHeight = clamp( Math.round(minHeight + (maxHeight - minHeight) * Math.sqrt(density)), ); return Math.min(aspectHeight, densityHeight); } /** Applies a fitTransform to a single point. */ export function toCanvasSpace( node: BlogGraphNode, transform: Transform, ): { x: number; y: number } { return { x: node.x * transform.scale + transform.offsetX, y: node.y * transform.scale + transform.offsetY, }; } /** Permalinks of a node's direct neighbors, across every visible edge. */ export function neighborsOf( permalink: string, visibleEdges: BlogGraphEdge[], ): Set { const neighbors = new Set(); for (const edge of visibleEdges) { if (edge.source === permalink) neighbors.add(edge.target); else if (edge.target === permalink) neighbors.add(edge.source); } return neighbors; } /** Degree (any edge type) of each node, counted only from the currently visible edges. */ export function computeVisibleDegree( nodes: BlogGraphNode[], edges: BlogGraphEdge[], ): Map { const degree = new Map(nodes.map((node) => [node.permalink, 0])); for (const edge of edges) { if (degree.has(edge.source)) degree.set(edge.source, degree.get(edge.source)! + 1); if (degree.has(edge.target)) degree.set(edge.target, degree.get(edge.target)! + 1); } return degree; } /** * The visible nodes with no visible connection at all — degree zero in the *currently drawn* * edge set, which is not the same as `inDegree === 0` (a post can be linked to by an article * the top-N cutoff or the mainTag filter left out). They are the only nodes whose drawn size * is not purely data-driven; see MEERKAT_MIN_RADIUS. */ export function pickOrphanNodes( nodes: BlogGraphNode[], edges: BlogGraphEdge[], ): Set { const degree = computeVisibleDegree(nodes, edges); return new Set( nodes .filter((node) => (degree.get(node.permalink) ?? 0) === 0) .map((node) => node.permalink), ); } /** The on-canvas radius for a node, floored for orphans only (see MEERKAT_MIN_RADIUS). */ export function displayRadius( node: BlogGraphNode, transform: Transform, isOrphan: boolean, ): number { const base = node.radius * transform.scale; return isOrphan ? Math.max(base, MEERKAT_MIN_RADIUS) : base; } /** Groups posts by mainTag for the no-JS / mobile fallback list, each group sorted by date desc. */ export function groupByMainTag(nodes: BlogGraphNode[]): [string, BlogGraphNode[]][] { const groups = new Map(); for (const node of nodes) { const key = node.mainTag ?? "other"; if (!groups.has(key)) groups.set(key, []); groups.get(key)!.push(node); } for (const group of groups.values()) { group.sort((a, b) => b.date.localeCompare(a.date)); } return [...groups.entries()].sort((a, b) => b[1].length - a[1].length); } ``` **src/components/BlogGraph/GroupedList.tsx** ```typescript /** * The graph's fallback rendering — a plain list of every article grouped by mainTag. * * Used in two situations, per 0081's accessibility requirements: * - Always server-rendered, so the page stays indexable and useful with JavaScript * disabled (the canvas graph is a progressive enhancement on top of this, not a * replacement for it). * - Kept as the *only* rendering on narrow viewports, where a force-directed graph has no * room to be legible (BlogGraph swaps to this below the mobile breakpoint). */ import Link from "@docusaurus/Link"; import Translate from "@docusaurus/Translate"; import { useTagLabel } from "@site/src/components/Blog/utils/tagsI18n"; import { groupByMainTag, type BlogGraphNode } from "./utils"; import styles from "./styles.module.css"; interface Props { nodes: BlogGraphNode[]; } export default function GroupedList({ nodes }: Props) { const groups = groupByMainTag(nodes); const tagLabel = useTagLabel(); return (
{groups.map(([mainTag, posts]) => (

{/* "other" is `groupByMainTag`'s bucket for articles with no mainTag — it is not a tags.yml key, so it has no label to look up and needs its own UI string. */} {mainTag === "other" ? ( Other ) : ( tagLabel(mainTag) )}{" "} ({posts.length})

    {posts.map((post) => (
  • {post.title}
  • ))}
))}
); } ``` **src/components/BlogGraph/styles.module.css** ```css .wrap { margin: 1.5rem 0; } .intro { color: var(--mk-ink-soft); margin-bottom: 1rem; } .controls { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 1rem; margin-bottom: 0.75rem; } .selectLabel { composes: mk-eyebrow from global; display: flex; flex-direction: column; gap: 0.3rem; } .select { padding: 0.45rem 0.6rem; font-family: inherit; font-size: 0.95rem; letter-spacing: 0; text-transform: none; border: 1px solid var(--mk-border-strong); border-radius: var(--mk-radius-sm); background-color: var(--mk-surface); color: var(--mk-ink); min-width: 260px; } .select:focus { outline: none; border-color: var(--mk-primary); box-shadow: 0 0 0 3px var(--mk-key-bg); } .counter { margin: 0; color: var(--mk-ink-soft); font-size: 0.9rem; white-space: nowrap; font-variant-numeric: tabular-nums; } /* * NOTE for whoever tokenises index.tsx: it reads * --ifm-color-emphasis-500 from getComputedStyle for the canvas edge colour, * with a "#999999" fallback. That variable is remapped now, so the edges * follow the palette on their own — but the cold-grey fallback is still there * and should become #a2917a (--mk-n-500) for the case where the read fails. */ .canvas { display: block; width: 100%; border: 1px solid var(--mk-border); border-radius: var(--mk-radius-md); background-color: var(--mk-surface); } .listFallback { margin-top: 1rem; border: 1px solid var(--mk-border); border-radius: var(--mk-radius-md); padding: 0.5rem 0.9rem; } .listFallback summary { cursor: pointer; color: var(--mk-primary); font-weight: 600; padding: 0.4rem 0; transition: color 0.15s ease; } .listFallback summary:hover { color: var(--ifm-color-primary-dark); } .listFallback[open] summary { margin-bottom: 0.5rem; } .groupedList { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 1.25rem 2rem; } .group { min-width: 0; } .groupTitle { font-family: var(--mk-font-display); font-size: 1rem; margin: 0 0 0.4rem; color: var(--mk-primary); } .groupCount { color: var(--mk-ink-soft); font-weight: 400; font-size: 0.85rem; font-variant-numeric: tabular-nums; } .groupItems { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 0.3rem; } .groupItems li { font-size: 0.9rem; line-height: 1.35; } @media (max-width: 768px) { .controls { flex-direction: column; align-items: stretch; } .select { min-width: 0; width: 100%; } .counter { white-space: normal; } } @media (prefers-reduced-motion: reduce) { .listFallback summary { transition: none; } } ``` Canvas, pas SVG, et ce n'est pas une préférence esthétique : 262 nœuds plus un millier d'arêtes en éléments DOM, et chaque survol devient un recalcul de layout sur un millier de nœuds. Sur un canvas, un survol, c'est un redessin complet de quelques centaines de formes, autant dire rien. Enregistrez le plugin dans `docusaurus.config.js` et donnez-lui une page : ```javascript title="docusaurus.config.js" plugins: [ "./plugins/blog-graph-plugin/index.mjs", // ... ], ``` ```mdx title="src/pages/map.mdx" --- title: "Blog Map" description: "An interactive map of the whole blog." hide_table_of_contents: true --- import BlogGraph from "@site/src/components/BlogGraph"; ``` ## Les trois types de connexion {#the-three-kinds-of-connection} Une arête signifie « ces deux billets sont liés », mais la parenté a des intensités, et les mélanger à poids égal produit une pelote de laine. Mon corpus émet trois types, chacun avec sa propre traction sur le layout : | Type | Ce que ça signifie | Nombre | Traction sur le layout | | -------- | ------------------------------------------------------ | ------ | ---------------------------------------------------- | | `link` | Le texte d'un billet pointe vraiment vers un autre | 680 | La plus forte — distance 55, force 0.85 | | `series` | Deux billets consécutifs (par date) dans la même série | 144 | Moyenne — distance 70, force 0.5 | | `tag` | Deux billets partageant plusieurs tags | 202 | La plus faible — distance 110, pondérée par le poids | La ligne `tag` est l'endroit où se cache l'échec intéressant. À cette taille de corpus, **presque chaque paire de billets partage au moins un tag** — les relier toutes reviendrait à tout relier à tout et détruirait à la fois le layout et l'image. Monter la barre à deux tags partagés laisse encore 1 355 paires, dont 1 153 sont du type le plus faible possible : ```plaintext shared tags: 2 3 4 5 pairs: 1153 159 40 3 ``` Le plugin fait donc deux choses différentes avec cet ensemble. Le **layout** utilise toutes les paires jusqu'à deux tags partagés, parce que cette faible traction spatiale est précisément ce qui regroupe « tous mes billets Docker ». La **charge utile** n'embarque que les paires à trois tags ou plus, parce que rien ne dessine jamais le reste — embarquer la majorité à poids 2 aurait triplé le JSON pour des lignes invisibles. Mêmes données, deux seuils, un pour la physique et un pour le réseau. ## Sous le capot (passez si vous voulez juste la carte) {#under-the-hood-skip-this-if-you-just-want-the-map} Quatre problèmes qui n'étaient pas évidents avant que la page soit réellement à l'écran. ### Un filtre à deux nœuds donnait un canvas géant et vide {#a-two-node-filter-got-a-giant-empty-canvas} Choisissez un sujet de niche dans le filtre et vous obtiendrez peut-être trois articles. Le canvas, lui, restait dimensionné pour 120. Mon premier correctif a été de le dimensionner selon le ratio d'aspect de la bounding box des nœuds visibles — ce qui a échoué, parce que deux articles partageant un `mainTag` ne sont pas obligés d'être proches dans le layout partagé calculé sur tout le corpus. Deux nœuds aux extrémités opposées produisent un ratio extrême qui revient directement se caler sur la hauteur maximale. Le deuxième signal, le nombre de nœuds relatif à la vue par défaut, attrape ce cas mais écraserait un cluster réellement haut et compact. `computeCanvasHeight()` calcule donc les deux et garde **le plus petit**. Aucun des deux angles morts ne peut produire un canvas surdimensionné à lui seul : ```typescript title="src/components/BlogGraph/utils.ts" const ratio = contentAspectRatio(nodes) ?? maxRatio; const aspectHeight = clamp(Math.round(containerWidth * ratio) + padding); const density = Math.min(nodes.length / defaultTopN, 1); const densityHeight = clamp( Math.round(minHeight + (maxHeight - minHeight) * Math.sqrt(density)), ); return Math.min(aspectHeight, densityHeight); ``` ### Des libellés qui s'empilent les uns sur les autres {#labels-stacking-on-top-of-each-other} Dessiner 262 titres est illisible : seuls les huit nœuds visibles les plus connectés gardent un libellé permanent, auxquels s'ajoutent le nœud survolé et ses voisins directs. Même ça s'empile quand un sujet filtré est chargé. `selectNonOverlappingLabels()` parcourt les candidats par ordre de priorité — le nœud survolé d'abord, puis les voisins, puis les hubs — mesure chacun avec `ctx.measureText()` et écarte tout libellé dont la boîte croiserait une boîte déjà placée. Les libellés les moins prioritaires perdent ; celui du nœud survolé gagne toujours. ### Un canvas n'entend pas un changement de thème {#canvas-cannot-hear-a-theme-change} Les couleurs des nœuds et des arêtes sont lues depuis les variables CSS du site, pour que rien ne soit en dur — mais un canvas est un bitmap, et basculer en mode sombre ne repeint rien. La solution : un `MutationObserver` sur l'attribut `data-theme` de `` qui incrémente un compteur, lequel figure dans le tableau de dépendances de l'effet de redessin : ```typescript title="src/components/BlogGraph/index.tsx" useEffect(() => { const observer = new MutationObserver(() => setThemeVersion((v) => v + 1)); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"], }); return () => observer.disconnect(); }, []); ``` ### Les suricates {#the-meerkats} Ce blog a une mascotte — elle se cache dans le [code source de la page](/blog/docusaurus-ascii-art), chevauche le [bouton de retour en haut](/blog/docusaurus-go-top) et apparaît sur la page 404. Sur la carte, ce n'est pas une garniture sur quelques nœuds : **chaque bulle est un suricate**, portant le sticker que lui a valu son `mainTag`. Les articles PHP tiennent un petit éléphant violet, les articles Bash portent un sweat à capuche, `code-quality` arrive avec une coche verte. Une trentaine de tags ont un sticker explicite et le reste en tire un de façon déterministe parmi ce qui reste, si bien qu'un sujet a toujours la même tête et que ses articles se lisent comme une famille sur le canvas. Ce qui fait marcher tout ça, ce sont les images, pas le code : ce sont des portraits carrés, centrés sur le visage. Les poses en pied avec lesquelles j'avais commencé perdaient leur tête dans un recadrage circulaire de 20px, il a donc fallu les choisir et les recadrer une par une ; un portrait centré survit à un simple dessin « cover » sans réglage image par image. Deux règles empêchent que ça tourne à la bouillie : * **un sticker a besoin de place.** En dessous de 12px de diamètre, l'anneau mange un tiers de l'image et un point coloré plat en dit davantage — c'est donc ce que gardent ces nœuds. * **un orphelin est dessiné plus gros.** Un nœud avec **zéro connexion visible** se retrouve au rayon minimal, c'est-à-dire juste sous ce seuil. Il obtient un plancher de 13px à la place, pour que l'unique article vers lequel rien ne pointe soit celui que vous ne pouvez pas rater. L'anneau autour de chaque bulle conserve la couleur d'accent de l'article, donc rien du signal série/tag n'est perdu au profit de l'illustration. ## Conclusion {#conclusion} La carte a fait exactement ce que j'espérais : cinq minutes après son premier rendu, j'avais la liste des billets vers lesquels rien ne pointait, et je voyais qu'une série avait dérivé loin de tout le reste. C'est un outil de maintenance déguisé en jolie image. La partie qui mérite d'être reprise, pourtant, ce n'est pas le graphe — c'est l'endroit où le travail se fait. Chaque exigence dure de cette page (pas de JavaScript, pas de mouvement, indexable, charge utile légère) s'est dissoute à l'instant où la simulation est passée du navigateur à `loadContent()`. Les plugins Docusaurus tournent dans Node, avec tout le corpus sous la main et une étape de build que personne n'attend ; ça laisse beaucoup de marge pour calculer les choses correctement, une fois, plutôt que de demander au portable de chaque visiteur de recommencer. La prochaine fois que vous êtes sur le point d'attraper une bibliothèque côté client, vérifiez si la réponse qu'elle calcule n'aurait pas pu être une constante dans votre sortie de build. --- # Laissez vos lecteurs signaler les problèmes — un widget de feedback multi-types pour Docusaurus > Construisez un widget de feedback lecteur sécurisé et SSR-safe pour votre blog Docusaurus — sélection de texte, 4 types de feedback, API PHP avec nonce HMAC, rate limiting, déduplication, dashboard admin et notifications par email. --- ![Laissez vos lecteurs signaler les problèmes — un widget de feedback multi-types pour Docusaurus](/img/v2/docusaurus_typos.webp) **TL;DR** Un lecteur repère un problème dans votre article — une faute de frappe, une erreur factuelle, une commande obsolète. Il n'a aucun moyen de vous le dire. Il passe à autre chose. Vous n'en saurez jamais rien. Cet article construit de A à Z un widget de feedback à sens unique : le lecteur sélectionne un passage, un popover apparaît avec quatre types de problème (faute de frappe, information incorrecte, contenu obsolète, suggestion), il ajoute éventuellement des détails, et le signalement atterrit dans un fichier JSON sur votre serveur avec une notification par email. L'API PHP est durcie avec des nonces HMAC, un honeypot, du rate limiting et de la déduplication. Le composant React est SSR-safe, utilise une machine à états propre et limite les envois via `localStorage`. Un dashboard admin associé, sur `/typo-dashboard`, vous permet de relire tous les signalements par type. Pas de base de données, pas de service tiers, aucune dépendance npm au-delà de ce que Docusaurus embarque déjà. Votre blog est votre vitrine publique. Une faute de frappe qui traîne, une commande qui ne fonctionne plus, une API qui a changé — chaque lecteur qui tombe sur ce paragraphe vous coûte en crédibilité. Le problème, c'est que la plupart des lecteurs ne prendront pas la peine d'ouvrir une issue GitHub ou d'envoyer un email. Mais si vous rendez la chose vraiment triviale — un clic, là où ils se trouvent — un nombre surprenant le fera. Cet article construit ce mécanisme de bout en bout. **Quick Jump:** [Démonstration](#see-it-in-action) | [Créer le composant](#create-the-component) ## Démonstration {#see-it-in-action} Le widget est actif sur cette page même. **Sélectionnez quelques mots de ce paragraphe** avec votre souris (ou votre doigt sur un téléphone) : un popover apparaît juste sous la sélection, avec quatre types de feedback. > Screenshot — https\://www\.avonture.be/blog/docusaurus-typo-report-component > > ![Le popover de feedback, avec ses quatre types, sous une sélection de texte](./images/typo_popover.webp) Le lecteur en choisit un, puis un petit formulaire lui permet d'ajouter un commentaire facultatif avant l'envoi. Le formulaire le dit clairement : **c'est un signal à sens unique** — le lecteur ne laisse pas d'email, donc personne n'attend de réponse. > Screenshot — https\://www\.avonture.be/blog/docusaurus-typo-report-component > > ![Le formulaire de détails, affiché une fois le type de feedback choisi](./images/typo_form.webp) De votre côté, chaque signalement atterrit dans un fichier JSON sur le serveur, vous recevez un email, et une page protégée par token liste tout, du plus récent au plus ancien, avec des compteurs par type : > Screenshot — https\://www\.avonture.be/typo-dashboard > > ![Le dashboard admin listant les signalements reçus](./images/typo_dashboard.webp) ## Comment ça marche {#how-it-works} La fonctionnalité se compose de deux moitiés indépendantes qui reprennent le schéma utilisé par le [composant Reaction](/blog/docusaurus-reactions) (votes like / dislike) : ```plaintext Reader selects text └─ mouseup / touchend fires on
└─ popover shows 4 feedback-type buttons (phase: selecting) └─ reader picks a type → optional details form (phase: confirming) └─ POST api/typo.php └─ validates nonce, honeypot, rate limits, dedup └─ writes typo-data.json └─ emails admin (6h cooldown) ``` * **Pas de base de données** — les signalements sont ajoutés à un fichier JSON, sous verrou de fichier. * **Pas de service tiers** — un simple script PHP posé à côté de vos fichiers statiques. * **Défense en profondeur** — neuf couches indépendantes bloquent les bots, les floods et les doublons (détaillées à la fin, pour les curieux). * **SSR-safe** — le composant ne rend rien tant que le lecteur n'a pas sélectionné de texte. *** ## Partie 1 — L'API PHP {#part-1--the-php-api} ### Pourquoi PHP ? {#why-php} Docusaurus produit un site statique. Il n'a aucun runtime côté serveur. Si vous êtes sur un hébergement mutualisé (OVH, Infomaniak, o2switch…), un petit [PHP script](/blog/php-api-tips) posé à côté de vos fichiers statiques est le backend le moins contraignant possible — pas de process Node à gérer, pas de container Docker, pas de démarrage à froid serverless. ### Structure des fichiers sur le serveur {#file-structure-on-the-server} ```tree expanded=true showJSX=false debug=false title="File structure" public_html/ api/ .env ← secrets (never committed) .env.example ← template committed to git .htaccess ← protects JSON files typo.php ← the API typo-data.json ← reports (writable by web server) typo-ratelimit.json ← counters typo-notifications.json ← email throttle ``` ### Le script {#the-script} L'API répond à trois requêtes : `GET` distribue un nonce (et, avec `?admin=TOKEN`, la liste complète des signalements), `POST` enregistre un signalement, `OPTIONS` répond au preflight CORS. Chaque `POST` traverse les neuf couches décrites dans la section sécurité, à la fin de cet article, avant que quoi que ce soit ne soit écrit. **api/typo.php** ```php $message]); exit; } // ── Nonce ───────────────────────────────────────────────────────────────────── function makeNonce(int $window): string { return hash_hmac('sha256', $window . '|typo-report', NONCE_SECRET); } function validateNonce(string $candidate): bool { $now = (int) floor(time() / NONCE_COOLDOWN_SECONDS); return hash_equals(makeNonce($now), $candidate) || hash_equals(makeNonce($now - 1), $candidate); } // ── Input validation ────────────────────────────────────────────────────────── function validateAndSanitize(array $body): array { if (($body['website'] ?? '') !== '') return []; // honeypot $slug = sanitizeSlug($body['slug'] ?? ''); if ($slug === '') jsonError(400, 'Missing slug'); $text = trim($body['text'] ?? ''); if (mb_strlen($text) < 3 || mb_strlen($text) > 150 || !preg_match('/^\P{C}+$/u', $text)) jsonError(400, 'Invalid text'); $allowedTypes = ['typo', 'incorrect', 'outdated', 'suggestion']; $type = trim($body['type'] ?? ''); if (!in_array($type, $allowedTypes, true)) jsonError(400, 'Invalid type'); $comment = trim($body['comment'] ?? ''); if ($comment !== '' && (mb_strlen($comment) > 300 || !preg_match('/^\P{C}+$/u', $comment))) jsonError(400, 'Invalid comment'); $context = mb_substr(trim($body['context'] ?? ''), 0, 300); return compact('slug', 'text', 'type', 'comment', 'context'); } // ── Rate limiting ───────────────────────────────────────────────────────────── function checkRateLimits(string $slug): void { $file = __DIR__ . '/typo-ratelimit.json'; $ip = $_SERVER['REMOTE_ADDR'] ?? ''; $ipKey = hash('sha256', IP_HASH_SALT . $ip); $now = time(); $fp = fopen($file, 'c+'); if (!$fp || !flock($fp, LOCK_EX)) { if ($fp) fclose($fp); return; } $raw = stream_get_contents($fp); $data = $raw !== '' ? (json_decode($raw, true) ?: []) : []; $g = $data['__global__'] ?? ['window_start' => $now, 'count' => 0]; if ($now - $g['window_start'] > GLOBAL_WINDOW) $g = ['window_start' => $now, 'count' => 0]; if (++$g['count'] > GLOBAL_MAX) { flock($fp, LOCK_UN); fclose($fp); jsonError(429, 'Too many requests'); } $data['__global__'] = $g; $ipData = $data[$ipKey] ?? ['hourly' => [], 'articles' => []]; $ipData['hourly'] = array_values(array_filter($ipData['hourly'], fn($ts) => $ts > $now - 3600)); if (count($ipData['hourly']) >= IP_HOURLY_MAX) { flock($fp, LOCK_UN); fclose($fp); jsonError(429, 'Too many requests'); } $artKey = $ipKey . '|' . $slug; $artData = array_values(array_filter($data[$artKey] ?? [], fn($ts) => $ts > $now - 86400)); if (count($artData) >= IP_ARTICLE_MAX) { flock($fp, LOCK_UN); fclose($fp); jsonError(429, 'Too many requests'); } $ipData['hourly'][] = $now; $artData[] = $now; $data[$ipKey] = $ipData; $data[$artKey] = $artData; ftruncate($fp, 0); rewind($fp); fwrite($fp, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); flock($fp, LOCK_UN); fclose($fp); } // ── Dedup + store ───────────────────────────────────────────────────────────── function isDuplicate(string $slug, string $text): bool { $hash = hash('sha256', $slug . '|' . mb_strtolower($text)); foreach (loadData(__DIR__ . '/typo-data.json')[$slug] ?? [] as $r) { if (($r['text_hash'] ?? '') === $hash) return true; } return false; } function storeReport(array $fields): void { ['slug' => $slug, 'text' => $text, 'type' => $type, 'comment' => $comment, 'context' => $context] = $fields; $ip = $_SERVER['REMOTE_ADDR'] ?? ''; $fp = fopen(__DIR__ . '/typo-data.json', 'c+'); if (!$fp) return; if (flock($fp, LOCK_EX)) { $raw = stream_get_contents($fp); $data = $raw !== '' ? (json_decode($raw, true) ?: []) : []; $data[$slug][] = [ 'id' => bin2hex(random_bytes(6)), 'type' => $type, 'text' => $text, 'text_hash' => hash('sha256', $slug . '|' . mb_strtolower($text)), 'context' => $context, 'comment' => $comment, 'ts' => time(), 'ip_hash' => hash('sha256', IP_HASH_SALT . $ip), ]; ftruncate($fp, 0); rewind($fp); fwrite($fp, json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); flock($fp, LOCK_UN); } fclose($fp); } function maybeNotifyTypo(string $slug, string $text, string $type): void { $file = __DIR__ . '/typo-notifications.json'; $throttle = loadData($file); if (time() - ($throttle[$slug] ?? 0) < NOTIFY_COOLDOWN_SECONDS) return; $typeLabels = [ 'typo' => '🔤 Typo', 'incorrect' => '❌ Incorrect info', 'outdated' => '⏰ Outdated content', 'suggestion' => '💡 Suggestion', ]; $typeLabel = $typeLabels[$type] ?? $type; $articleUrl = SITE_URL . '/' . $slug; $subject = "[Blog] Feedback ($type) on: $slug"; $body = implode("\n", [ "A reader flagged an issue.", "", "Article : $articleUrl", "Type : $typeLabel", "Text : $text", "", "Review all reports: GET " . SITE_URL . "/api/typo.php?admin=TOKEN", ]); $headers = "From: noreply@yourdomain.com\r\nContent-Type: text/plain; charset=utf-8"; if (@mail(ADMIN_EMAIL, $subject, $body, $headers)) { $throttle[$slug] = time(); saveData($file, $throttle); } } // ── Router ──────────────────────────────────────────────────────────────────── $method = $_SERVER['REQUEST_METHOD']; if ($method === 'GET') { if (array_key_exists('nonce', $_GET)) { echo json_encode(['nonce' => makeNonce((int) floor(time() / NONCE_COOLDOWN_SECONDS))]); exit; } if (array_key_exists('admin', $_GET)) { if (ADMIN_TOKEN === '' || ($_GET['admin'] ?? '') !== ADMIN_TOKEN) jsonError(403, 'Forbidden'); echo json_encode(loadData(__DIR__ . '/typo-data.json')); exit; } jsonError(400, 'Missing parameter'); } if ($method !== 'POST') jsonError(405, 'Method not allowed'); $body = json_decode(file_get_contents('php://input'), true) ?? []; $nonce = $body['nonce'] ?? ''; if ($nonce === '' || !validateNonce($nonce)) jsonError(403, 'Invalid nonce'); $fields = validateAndSanitize($body); if ($fields === []) { echo json_encode(['ok' => true]); exit; } ['slug' => $slug, 'text' => $text] = $fields; checkRateLimits($slug); if (isDuplicate($slug, $text)) { echo json_encode(['ok' => true]); exit; } storeReport($fields); maybeNotifyTypo($slug, $text, $fields['type']); echo json_encode(['ok' => true]); ``` ### Protéger les fichiers JSON {#protect-the-json-files} Sans la [règle .htaccess](/blog/apache-htaccess) livrée dans `api/.htaccess`, n'importe qui peut faire `GET https://yourdomain.com/api/typo-data.json` et lire tous les signalements. ### Les secrets {#secrets} Le fichier `api/.env` contient deux secrets. Générez-les avec : ```bash openssl rand -hex 32 # IP_HASH_SALT openssl rand -hex 32 # NONCE_SECRET ``` *** ## Partie 2 — Le composant React {#part-2--the-react-component} ### La sécurité SSR d'abord {#ssr-safety-first} Docusaurus rend les pages côté serveur (Node.js) avant de les hydrater dans le navigateur. Tout code qui touche à `window`, `document` ou `localStorage` fera planter la passe SSR s'il s'exécute au niveau du module ou dans le corps du composant. Les règles : 1. Tout accès au DOM se fait dans un `useEffect` — il ne s'exécute que dans le navigateur. 2. L'état est initialisé à `"idle"`, pas à une valeur lue depuis le navigateur. 3. Le composant retourne `null` à l'état idle — pas de DOM, pas de mismatch SSR. ### Machine à états {#state-machine} Six phases, linéaires avec une seule bifurcation : ```plaintext idle └─ user selects text → selecting └─ user picks a feedback type → confirming └─ form submitted → submitting ├─ success → done └─ error → error ``` `done` et `error` affichent un badge que l'on peut fermer et qui remet l'état à `idle`. `selecting` et `confirming` repassent à `idle` si l'utilisateur clique en dehors du popover. `confirming` propose un lien « changer » qui ramène à `selecting`. ### Les helpers localStorage {#localstorage-helpers} Avant que le popover de sélection du type n'apparaisse, deux gardes côté client s'exécutent : 1. **Rate limit** — maximum 5 envois par navigateur et par heure. Les timestamps sont stockés dans un tableau JSON dans `localStorage` sous la clé `"typo_reports"`. Les entrées de plus de 24 heures sont purgées à chaque écriture. 2. **Déduplication** — un hash FNV-1a de `slug + "|" + lowercase(text)` est stocké à côté du timestamp. Si le même texte a déjà été signalé depuis ce navigateur (quel que soit le type), le composant saute directement à `done`. FNV-1a est utilisé plutôt que `crypto.subtle.digest` parce qu'il est synchrone, et le risque de collision est acceptable dans ce cas d'usage. ```js function fnv1a(str) { let hash = 0x811c9dc5; for (let i = 0; i < str.length; i++) { hash ^= str.charCodeAt(i); hash = (hash * 0x01000193) >>> 0; } return hash.toString(16); } ``` ### Les types de feedback {#feedback-types} Quatre types couvrent la grande majorité des problèmes qu'un lecteur peut remarquer : ```js const FEEDBACK_TYPES = [ { id: "typo", icon: "🔤", label: "Typo" }, { id: "incorrect", icon: "❌", label: "Incorrect" }, { id: "outdated", icon: "⏰", label: "Outdated" }, { id: "suggestion", icon: "💡", label: "Suggestion" }, ]; ``` Ils sont affichés dans une grille 2×2. L'API PHP valide le champ `type` contre cette même whitelist — toute autre valeur renvoie un 400. ### Capturer le contexte {#capturing-context} Quand l'utilisateur sélectionne du texte, le composant enregistre ±100 caractères de texte environnant depuis `article.innerText`. Ce contexte est envoyé à l'API pour que vous puissiez localiser la phrase exacte sans ouvrir l'article : ```js const fullText = article.innerText || ""; const idx = fullText.indexOf(text); contextRef.current = idx !== -1 ? fullText.slice(Math.max(0, idx - 100), idx + text.length + 100) : ""; ``` ### Positionnement du tooltip {#tooltip-positioning} Le popover apparaît juste sous la sélection. `window.scrollY + rect.bottom + 8` le place 8px sous le bord inférieur du texte sélectionné. Pour éviter que le formulaire de 280px de large ne déborde du bord droit du viewport : ```js const left = Math.min(window.scrollX + rect.left, window.innerWidth - 296); ``` ### Le composant {#the-component} **src/components/TypoReport/index.tsx** ```typescript import { useState, useEffect, useRef, useCallback, type FormEvent, type JSX, } from "react"; import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; import styles from "./styles.module.css"; const STORAGE_KEY = "typo_reports"; // [{ts, hash}] const MAX_PER_HOUR = 5; const FEEDBACK_TYPES = [ { id: "typo", icon: "🔤", label: "Typo" }, { id: "incorrect", icon: "❌", label: "Incorrect" }, { id: "outdated", icon: "⏰", label: "Outdated" }, { id: "suggestion", icon: "💡", label: "Suggestion" }, ]; interface StoredReport { ts: number; hash: string; } // FNV-1a 32-bit hash (no crypto needed for client-side dedup) function fnv1a(str: string): string { let hash = 0x811c9dc5; for (let i = 0; i < str.length; i++) { hash ^= str.charCodeAt(i); hash = (hash * 0x01000193) >>> 0; } return hash.toString(16); } function getStored(): StoredReport[] { try { return JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]"); } catch { return []; } } function isLocalRateLimited() { const now = Date.now(); return getStored().filter((r) => r.ts > now - 3_600_000).length >= MAX_PER_HOUR; } function isLocalDuplicate(slug: string, text: string) { const hash = fnv1a(slug + "|" + text.toLowerCase()); return getStored().some((r) => r.hash === hash); } function recordLocalSubmission(slug: string, text: string) { const now = Date.now(); const hash = fnv1a(slug + "|" + text.toLowerCase()); const pruned = getStored().filter((r) => r.ts > now - 86_400_000); try { localStorage.setItem(STORAGE_KEY, JSON.stringify([...pruned, { ts: now, hash }])); } catch {} } interface Props { metadata?: { permalink?: string; }; } type Phase = "idle" | "selecting" | "confirming" | "submitting" | "done" | "error"; export default function TypoReport({ metadata }: Props): JSX.Element | null { const { siteConfig } = useDocusaurusContext(); const slug = metadata?.permalink?.replace(/^\/|\/$/g, "") ?? ""; const apiUrl = `${siteConfig.url}/api/typo.php`; // State machine: idle → selecting → confirming → submitting → done | error const [phase, setPhase] = useState("idle"); const [selectedText, setSelectedText] = useState(""); const [tooltipPos, setTooltipPos] = useState({ top: 0, left: 0 }); const [feedbackType, setFeedbackType] = useState(""); const [comment, setComment] = useState(""); const nonceRef = useRef(null); const articleRef = useRef(null); const wrapperRef = useRef(null); const contextRef = useRef(""); // ±100 chars around selection // Fetch nonce + wire up DOM listeners on mount. useEffect(() => { if (!slug) return; fetch(`${apiUrl}?nonce`) .then((r) => (r.ok ? r.json() : null)) .then((data) => { if (data?.nonce) nonceRef.current = data.nonce; }) .catch(() => {}); const article = document.querySelector("article") || document.querySelector(".theme-doc-markdown"); if (!article) return; articleRef.current = article; // Arrow-assigned, not a `function` declaration: that lets TS carry the // `if (!article) return` narrowing above into this closure, so `article` is // non-null on lines below without a `!` assertion. const handleSelection = () => { const sel = window.getSelection(); if (!sel || sel.isCollapsed) return; const text = sel.toString().trim(); if (text.length < 3) return; const range = sel.getRangeAt(0); const rect = range.getBoundingClientRect(); if (!article.contains(range.commonAncestorContainer)) return; const fullText = article.innerText || ""; const idx = fullText.indexOf(text); contextRef.current = idx !== -1 ? fullText.slice(Math.max(0, idx - 100), idx + text.length + 100) : ""; const top = window.scrollY + rect.bottom + 8; const left = Math.min(window.scrollX + rect.left, window.innerWidth - 296); setSelectedText(text); setTooltipPos({ top, left }); setPhase("selecting"); }; function handleMouseDown(e: MouseEvent) { if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) { setPhase("idle"); } } article.addEventListener("mouseup", handleSelection); article.addEventListener("touchend", handleSelection); document.addEventListener("mousedown", handleMouseDown); return () => { article.removeEventListener("mouseup", handleSelection); article.removeEventListener("touchend", handleSelection); document.removeEventListener("mousedown", handleMouseDown); }; }, [slug, apiUrl]); const handleTypeSelect = useCallback( (type: string) => { if (isLocalRateLimited()) { setPhase("error"); return; } if (isLocalDuplicate(slug, selectedText)) { setPhase("done"); return; } setFeedbackType(type); setComment(""); setPhase("confirming"); }, [slug, selectedText], ); const handleSubmit = useCallback( async (e: FormEvent) => { e.preventDefault(); setPhase("submitting"); let nonce = nonceRef.current; if (!nonce) { try { const r = await fetch(`${apiUrl}?nonce`); const d = r.ok ? await r.json() : null; nonce = d?.nonce ?? null; nonceRef.current = nonce; } catch {} } const honeypot = (e.currentTarget.elements.namedItem("website") as HTMLInputElement | null) ?.value ?? ""; try { const res = await fetch(apiUrl, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ slug, text: selectedText, type: feedbackType, comment, context: contextRef.current, website: honeypot, nonce: nonce ?? "", }), }); if (!res.ok) { setPhase("error"); return; } recordLocalSubmission(slug, selectedText); setPhase("done"); } catch { setPhase("error"); } }, [slug, selectedText, feedbackType, comment, apiUrl], ); const handleBack = useCallback(() => setPhase("selecting"), []); const handleCancel = useCallback(() => setPhase("idle"), []); const handleDismiss = useCallback(() => setPhase("idle"), []); // SSR-safe: render nothing when idle. if (phase === "idle") return null; const typeInfo = FEEDBACK_TYPES.find((t) => t.id === feedbackType); return (
{phase === "selecting" && (

What kind of issue?

{FEEDBACK_TYPES.map(({ id, icon, label }) => ( ))}
)} {phase === "confirming" && (
" {selectedText.length > 80 ? selectedText.slice(0, 80) + "…" : selectedText} "
{typeInfo && (
{typeInfo.icon} {typeInfo.label}
)}