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

# 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 `<Snippet>` 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 `<summary>` (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 `<summary>` comme ceci :

```html
<details>
  <summary>blog/index.md</summary>

  Hello world! Proud to be here!!!
</details>
```

> **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
<Snippet filename="blog/index.md"> Hello world! Proud to be here!!! </Snippet>
```

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 `<CodeBlock>` 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 <code className="language-xyz"> 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<string, string> = {
  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<string, VariantIcon> = {
  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<string, string>;
}

// Eli5CodeBlock renders its own <pre>, bypassing Docusaurus's native
// <CodeBlock> (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<number | undefined>(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<string | null>(null);
  const [tooltipStyle, setTooltipStyle] = useState<CSSProperties | null>(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<Record<string, HTMLButtonElement | null>>({});

  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
  // `<span>`. 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 (
    <>
      <pre className={clsx(`language-${lang}`, styles.eli5_pre)}>
        <button
          type="button"
          className={styles.eli5_copy_button}
          onClick={() => copy(code)}
          aria-label={
            copied
              ? translate({ id: "common.copied", message: "Copied" })
              : translate({ id: "common.copyCode", message: "Copy code to clipboard" })
          }
          title={translate({ id: "common.copy", message: "Copy" })}
        >
          {copied ? (
            <IconSuccess className={styles.eli5_copy_icon} />
          ) : (
            <IconCopy className={styles.eli5_copy_icon} />
          )}
        </button>
        <code className={`language-${lang}`}>
          {lines.map((lineTokens, i) => {
            const lineNum = String(i + 1);
            const explanation = eli5[lineNum];
            const isActive = activeLine === lineNum;

            return (
              <div key={i} className={styles.eli5_line}>
                <span className={styles.eli5_code}>
                  {lineTokens.map((token, tokenIdx) => (
                    <span key={tokenIdx} className={clsx("token", ...token.types)}>
                      {/* Display only: the copy button still copies `code`, real bytes and all. */}
                      {showControlChars(token.content)}
                    </span>
                  ))}
                </span>
                {explanation ? (
                  <span className={styles.eli5_badge_wrapper}>
                    <button
                      type="button"
                      ref={(el) => {
                        badgeRefs.current[lineNum] = el;
                      }}
                      className={clsx(
                        styles.eli5_badge,
                        isActive && styles.eli5_badge_active,
                      )}
                      aria-label={translate(
                        { id: "snippet.explainLine", message: "Explain line {line}" },
                        { line: lineNum },
                      )}
                      aria-expanded={isActive}
                      onClick={() => handleBadgeClick(lineNum)}
                      onMouseEnter={() => {
                        positionTooltip(lineNum);
                        setActiveLine(lineNum);
                      }}
                      onMouseLeave={() => setActiveLine(null)}
                      onFocus={() => {
                        positionTooltip(lineNum);
                        setActiveLine(lineNum);
                      }}
                      onBlur={() => setActiveLine(null)}
                    >
                      ?
                    </button>
                  </span>
                ) : (
                  <span className={styles.eli5_badge_placeholder} />
                )}
              </div>
            );
          })}
        </code>
      </pre>
      {mounted &&
        activeExplanation &&
        tooltipStyle &&
        createPortal(
          <span role="tooltip" className={styles.eli5_tooltip} style={tooltipStyle}>
            {activeExplanation}
          </span>,
          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/<CodeBlock>): 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<HTMLDivElement | null>(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 (
    <div className={styles.eli5_summary_block}>
      <button
        type="button"
        className={styles.eli5_summary_toggle}
        onClick={() => setOpen((prev) => !prev)}
        aria-expanded={open}
        aria-controls={contentId}
      >
        <span>
          {open
            ? translate({ id: "snippet.hideExplanation", message: "Hide explanation" })
            : translate({ id: "snippet.explain", message: "Explain this snippet" })}
        </span>
        <span className={styles.eli5_summary_toggle_right}>
          <span className={styles.eli5_summary_badge}>
            <Translate id="snippet.poweredByAi">Powered by AI</Translate>
          </span>
          <span className={`${styles.chevron} ${open ? styles.rotate : ""}`}>
            &#9662;
          </span>
        </span>
      </button>
      <div
        ref={contentRef}
        id={contentId}
        className={styles.eli5_summary_content}
        style={{ maxHeight: height }}
      >
        <p className={styles.eli5_summary_text}>{summary}</p>
      </div>
    </div>
  );
}

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<HTMLDivElement | null>(null);

  // Parse eli5json string (injected by remark-snippet-loader) into an object
  const eli5 = useMemo((): Record<string, string> | 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) => (
        <VarToken key={key}>{value}</VarToken>
      )),
    [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 <CodeBlock> 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 <Eli5CodeBlock code={resolvedCode} lang={lang} eli5={eli5 ?? {}} />;
    }
    if (resolvedCode) {
      return <CodeBlock className={`language-${lang}`}>{resolvedCode}</CodeBlock>;
    }
    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 (
    <div className={clsx(styles.snippet_block, variantClass)}>
      <button
        type="button"
        className={styles.snippet_summary}
        onClick={handleToggle}
        aria-expanded={open}
        aria-controls={contentId}
      >
        <span className={styles.filename_wrapper}>
          <LogoIcon
            name={iconify}
            className={styles.snippet_logo}
            aria-label={ariaLabel}
            size="32"
          />
          {displayTitle}
        </span>
        <span className={clsx(styles.chevron, open && styles.rotate)}>&#9662;</span>
      </button>

      {/*
        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 && (
        <div
          ref={contentRef}
          id={contentId}
          className={clsx(styles.snippet_content, peeking && styles.snippet_peek)}
          style={peeking ? { ["--peek-lines" as string]: String(PEEK_LINES) } : undefined}
        >
          <div className={styles.snippet_inner}>{codeBlock}</div>
          {peeking && (
            <button
              type="button"
              className={styles.snippet_expand}
              onClick={handleExpandLong}
            >
              <Translate
                id="snippet.showRemaining"
                values={{ count: lineCount - PEEK_LINES }}
                description="Control revealing the rest of a truncated snippet"
              >
                {"Show the remaining {count} lines"}
              </Translate>
            </button>
          )}
        </div>
      )}

      {open && eli5Summary && <Eli5SummaryBlock summary={eli5Summary} />}
    </div>
  );
}
```

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:
 *
 *   -  <div className={clsx(styles.snippet_block, variantClass, "alert alert--info")}>
 *   +  <div className={clsx(styles.snippet_block, variantClass)}>
 *
 * 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 <CodeBlock> 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 <pre>, warm dark
 *   2. native <CodeBlock>  — Prism's light theme
 *   3. raw children        — rendered MDX prose, no <pre> at all, no padding
 *
 * Paths 1 and 2 are code: one dark surface, owned by the <pre>. 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 <pre> 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 <Icon> 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 (
    <Icon
      icon={name}
      ssr
      width={size}
      height={size}
      className={className}
      aria-label={ariaLabel}
    />
  );
}
```

## Utiliser le composant Snippets {#using-the-snippets-component}

Maintenant, si vous voulez ajouter un snippet dans votre blog, faites simplement ceci :

```html
<Snippet filename="who_are_you.py">
  name = input("What's your name? ") if name: print(f"Hello, {name}!") else:
  print("Hello, stranger!") print("Nice to meet you.")
</Snippet>
```

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 :

`<Snippet filename="src/components/Snippet/index.tsx" source="src/components/Snippet/index.tsx" />`

* `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 à `<Snippet>` 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 `<Snippet>`, 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/<locale>/docusaurus-plugin-content-<kind>/… — 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 <Terminal>...inline content...</Terminal> 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(
            `<Terminal source="${sourcePath}"> 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(
          `<Snippet source="${sourcePath}"> 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 <source>.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 `<Snippet>`. Utile quand je n'ai pas le fichier sur mon disque.

**sample.apacheconf**

```apacheconf
<VirtualHost *:80>
    ServerName www.example.com
    DocumentRoot /www/domain
</VirtualHost>
```

**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
<!DOCTYPE html>
<html>
  <body>
    <p>Hello, world!</p>
  </body>
</html>
```

**.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
<?php
echo "Hello, world!";
?>
```

**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
<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
  <circle cx="50" cy="50" r="40" fill="red" />
</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
<message>
  <text>Hello, world!</text>
</message>
```

**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 `<Snippet filename="src/components/Blog/PostCard/index.tsx" source="src/components/Blog/PostCard/index.tsx" />` 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 `<CardImage>`, which calls
  // `useBaseUrl` itself.
  const smallLayoutImageUrl = useBaseUrl(image || defaultImage);

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

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

**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';

<PostCard post={post} defaultImage="/img/default.webp" />
```

## 🧾 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 `<Link>` with an aria-label for screen reader clarity.
- The tag badge is a real `<Link>` (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)
