<!--
  canonical-url: https://www.avonture.be/fr/blog/docusaurus-project-setup
  published:     2026-02-23
  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.
-->

# 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 <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}
    />
  );
}
```

À 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
<ProjectSetup folderName="src/components/Blog/LogoIcon">
  <Guideline>
    Install the dependency: `npm install @iconify/react`
  </Guideline>
  <Snippet filename="src/components/Blog/LogoIcon/index.tsx" source="src/components/Blog/LogoIcon/index.tsx" />
</ProjectSetup>
```

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

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

### 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<string, string>;
  children?: ReactNode;
}

// Extract plain text from Guideline children (handles MDX wrapping like <p>)
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
 * <ProjectSetup folderName="my-app">
 *   <EmptyFolder name="empty-dir" />
 *   <Guideline>npm install && npm start</Guideline>
 *   <Snippet filename="package.json" code={`{ "name": "app" }`} />
 *   <Snippet filename="index.js" code={`console.log("Hello");`} />
 * </ProjectSetup>
 */
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<string | null>(null);
  const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | 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 <div title="...">) 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 (
    <div className={styles.projectSetupContainer}>
      <div className={styles.header}>
        <strong>
          📦 <Translate>Project setup:</Translate> {folderName}
        </strong>
        <div className={styles.buttonGroup}>
          <a
            className="button button--secondary button--sm"
            href="/project_setup"
            target="_blank"
          >
            <Translate>Help</Translate>
          </a>
          {showScript ? (
            <button
              className="button button--secondary button--sm"
              onClick={handleCopyScript}
              aria-live="polite"
            >
              {copied ? <Translate>Copied!</Translate> : <Translate>Copy</Translate>}
            </button>
          ) : (
            <button
              className="button button--secondary button--sm"
              onClick={() => setExpandAll((v) => !v)}
            >
              {expandAll ? (
                <Translate>Collapse all</Translate>
              ) : (
                <Translate>Expand all</Translate>
              )}
            </button>
          )}
          <button
            className="button button--outline button--primary button--sm"
            onClick={downloadZip}
            disabled={isDownloading}
            aria-label={translate({
              id: "projectSetup.zipAriaLabel",
              message: "Download project as ZIP",
            })}
          >
            {isDownloading ? "⏳" : "ZIP"}
          </button>
          <button
            className="button button--secondary button--sm"
            onClick={() => setShowScript((v) => !v)}
          >
            {showScript ? (
              <Translate>View files</Translate>
            ) : (
              <Translate>Generate install script</Translate>
            )}
          </button>
        </div>
      </div>

      <div className={styles.content}>
        {zipError && (
          <p className={styles.error} role="alert">
            {zipError}
          </p>
        )}
        {showScript ? (
          <div>
            <p>
              <small>
                <Translate>Copy this block to your terminal (Linux/WSL2).</Translate>
                {!createFolder && (
                  <mark>
                    {" "}
                    <Translate
                      id="projectSetup.locatedIn"
                      values={{ folder: <strong>{folderName}</strong> }}
                    >
                      {"Make sure you are located in the {folder} folder."}
                    </Translate>
                  </mark>
                )}
              </small>
            </p>
            <div className={styles.scriptContainer}>
              <CodeBlock language="bash">{shellScript}</CodeBlock>
            </div>
          </div>
        ) : (
          <div>
            <p className={styles.tip}>
              <small>
                <span aria-hidden="true">💡</span>{" "}
                <Translate id="theme.projectSetup.tip">
                  Need help here? Click the &quot;Help&quot; button above for setup
                  instructions.
                </Translate>
              </small>
            </p>
            {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}`,
                  },
                )
              ) : (
                <Snippet
                  key={`${fileName || index}-${expandAll}`}
                  filename={fileName}
                  code={content}
                  defaultOpen={expandAll}
                />
              ),
            )}
          </div>
        )}
      </div>
    </div>
  );
}
```

**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.

<ProjectSetup folderName="/tmp/phpinfo">
  <Snippet
    filename="compose.yaml"
    code={`services:
    app:
      build: .
      ports:
        - "8080:80"`}
  />
  <Snippet
    filename="Dockerfile"
    code={`FROM php:8.4-apache
COPY index.php /var/www/html/
`}
  />
  <Snippet
    filename="index.php"
    code={`<?php
echo "Hello from PHP 8.4 in Docker!";
phpinfo();
`}
  />
  <Guideline>
    Now, simply run 'docker compose up -d' to start the project then open your browser and
    surf to http://localhost:8080
  </Guideline>
</ProjectSetup>

## <img src="/img/meerkat/emojis/gesture_thumbs_up.webp" alt="" className={styles.headingIcon} />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**:
    <Tabs>
      <TabItem value="windows" label="Windows" default>
        <div>
          Right-click the downloaded file and select <b>"Extract All..."</b>. Choose a
          destination folder, and the files will be uncompressed there.
        </div>
      </TabItem>
      <TabItem value="linux" label="Linux/macOS">
        <div>
          Open your terminal, navigate to the directory where you saved the file, and run:
        </div>
        <CodeBlock language="bash">
          unzip your-project-name.zip -d your-project-name
        </CodeBlock>
      </TabItem>
    </Tabs>
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.

<AlertBox variant="tip" title="MS-DOS example">
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)

</AlertBox>

## <img src="/img/meerkat/emojis/job_hacker.webp" alt="" className={styles.headingIcon} />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.

<AlertBox variant="tip" title="Click on the Copy button">
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 <kbd>Enter</kbd>.

![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.

</AlertBox>

## <img src="/img/meerkat/emojis/job_handyman.webp" alt="" className={styles.headingIcon} />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.
