<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet type="text/xsl" href="/fr/blog/rss.xsl"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>Christophe Avonture</title>
        <link>https://www.avonture.be/fr/blog</link>
        <description>Personal blog about Docker, WSL, Python, Quarto, PHP, Joomla, Docusaurus and even more</description>
        <lastBuildDate>Thu, 12 Nov 2026 00:00:00 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <language>fr</language>
        <item>
            <title><![CDATA[ai-explain: ELI5 for Scripts and Errors, Right in Your Terminal]]></title>
            <link>https://www.avonture.be/fr/fr/blog/ollama-ai-explain</link>
            <guid isPermaLink="false">/fr/blog/ollama-ai-explain</guid>
            <pubDate>Thu, 12 Nov 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[<img src="https://www.avonture.be/fr/img/v2/playing_with_ollama.webp" alt="ai-explain: ELI5 for Scripts and Errors, Right in Your Terminal" style="display:block;max-width:100%;height:auto;">Another function in the Ollama daily-use series: pipe any script, error message, or stack trace to ai-explain and get a plain-English explanation of what it does or what went wrong — entirely local, no cloud required.]]></description>
            <content:encoded><![CDATA[<div class="offer_jqr1"><span class="flag_Hnep" aria-hidden="true">🇬🇧</span><span>Cet article existe aussi en anglais —<!-- --> <a href="/blog/ollama-ai-explain/" hreflang="en">lire la version originale</a>.</span></div><div class="alertBox_R1Ot mk-callout caution_LuPi mk-callout--warn" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Traduction automatique</p><div class="content_gFLB">Cette traduction a été produite par une IA. Elle peut contenir des contresens ou des maladresses, en particulier sur les termes techniques. En cas de doute,<!-- --> <a href="/blog/ollama-ai-explain/">consultez l'article de référence en anglais</a> <!-- -->— c'est lui qui fait foi.</div></div></div><div><div class="seriesBlogPost_bjUd"><details class="details_rTsq"><summary class="seriesIntro_HIK1 mk-eyebrow">Cet article fait partie de la série <a href="/fr/series/ollama-daily-use/">Ollama au quotidien</a> :</summary><div class="content_OqVG"><ul><li><a href="/fr/blog/ollama-installation/">Installer Ollama et obtenir une IA locale</a></li><li><a href="/fr/blog/accessing-ollama-across-your-local-network/">Accéder à Ollama depuis votre réseau local</a></li><li><a href="/fr/blog/ollama-test-generator/">ai-test : générer les tests unitaires manquants depuis votre terminal avec un LLM local</a></li><li><a href="/fr/blog/ollama-git-precommit/">ai-review, ai-secrets, ai-commit : trois vérifications zsh avant chaque git commit</a></li><li><a href="/fr/blog/anythingllm-chat-with-your-docs/">AnythingLLM : discuter avec toute votre documentation éparpillée</a></li><li><a href="/fr/blog/ollama-ai-docs/">ai-translate &amp; ai-summarize : des documents confidentiels, traités 100 % en local</a></li><li><a href="/fr/blog/ollama-ai-diagram/">ai-diagram : d'une phrase (ou d'un compose.yaml) à un diagramme Mermaid</a></li><li><a href="/fr/blog/ollama-ai-data/">ai-data : laissez un LLM local écrire vos one-liners jq et awk</a></li><li><a href="/fr/blog/ollama-ai-explain/">ai-explain : l'ELI5 des scripts et des erreurs, directement dans votre terminal</a></li></ul></div></details></div><div><p><img alt="ai-explain : l&amp;#39;ELI5 des scripts et des erreurs, directement dans votre terminal" src="/fr/assets/images/playing_with_ollama-c8c8352da75d9a65bf343b59c28b2a5e.webp" height="auto" width="" loading="lazy" decoding="async" class=""></p>
<div class="tldrContainer_qQW_ mk-callout mk-callout--key" role="region" aria-label="Résumé de l'article"><span role="img" aria-label="éclair" class="tldrIcon_SLBz mk-dot">⚡</span><div class="tldrBody_lGty"><strong class="tldrTitle_Aha8 mk-eyebrow">TL;DR</strong><div class="tldrContent_m27u"><p><code>ai-explain</code> est une nouvelle fonction de la série Ollama au quotidien. Envoyez-lui un script, une sortie de log ou une stack trace — ou donnez-lui un chemin de fichier, ou collez un message d'erreur en argument — et vous obtenez une explication en langage clair : ce que fait le script, ce que signifie l'erreur et quoi corriger. Une fonction, trois modes d'entrée, zéro cloud.</p></div></div></div>
<p>Vous avez cloné un repository et vous y trouvez un script Bash exécuté par le pipeline CI. Trois cents lignes, aucun commentaire. Vous devez comprendre ce qu'il fait avant de le modifier. Vous pouvez le lire attentivement, chercher la signification des flags, suivre la logique. Ou vous pouvez l'envoyer à <code>ai-explain</code>.</p>
<p>Même histoire pour les stack traces. Une erreur React en production avec six frames de sortie minifiée. Un <code>Fatal error</code> PHP à la ligne 847 d'un fichier que vous n'avez jamais ouvert. Un build Docker qui échoue sur une étape RUN que vous n'avez pas écrite.</p>
<p><code>ai-explain</code> est la version terminal du <a href="/fr/blog/docusaurus-eli5-snippet-tooltips/">composant de tooltip ELI5</a> de ce blog — la même idée « explique-moi comme si j'avais cinq ans », mais dans votre shell plutôt que dans le navigateur.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="explain-a-script-you-didnt-write">Expliquer un script que vous n'avez pas écrit<a href="#explain-a-script-you-didnt-write" class="hash-link" aria-label="Lien direct vers Expliquer un script que vous n'avez pas écrit" title="Lien direct vers Expliquer un script que vous n'avez pas écrit" translate="no"></a></h2>

<div class="terminal_qOWF" style="cursor:pointer" title="Cliquez pour passer l'animation"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><span class="terminal_ghost_aie1" aria-hidden="true">$ ai-explain deploy.sh

This script automates deploying an application using Docker and Docker Compose. It first checks that `docker`, `git`, and `curl` are installed, exiting if any are missing. It pulls the latest code from the `main` branch and builds a new Docker image tagged with the current git commit hash. Then it starts the app service in detached mode without restarting dependent services. After starting the service, it waits up to 60 seconds (12 attempts of 5 seconds each) checking if the app responds with HTTP 200 at `/health`. If successful, it prints a success message and exits. If the health check fails after all attempts, it reports the failure and exits with error code 1. The script modifies the local Docker environment by building a new image and starting containers, but doesn't change any files on disk except through Git operations. Risks include failing to start the app or timing out if the service takes longer than 60 seconds to become healthy. To fix potential issues, ensure all prerequisites are installed and the app's health endpoint is correctly configured.
</span><span class="terminal_overlay_fznj"><span class="cursor_o7aQ" aria-hidden="true">▋</span></span></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Voilà toute l'interaction : un chemin de fichier en entrée, une explication en langage clair en sortie — les prérequis, le tag d'image basé sur le hash du commit, le redémarrage avec <code>--no-deps</code>, le health check de 60 secondes et ce qui se passe en cas de timeout.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="why-it-works">Pourquoi ça fonctionne<a href="#why-it-works" class="hash-link" aria-label="Lien direct vers Pourquoi ça fonctionne" title="Lien direct vers Pourquoi ça fonctionne" translate="no"></a></h2>
<p><code>ai-explain</code> gère trois modes d'entrée, vérifiés dans cet ordre : l'entrée via pipe (donc <code>cat file | ai-explain</code> l'emporte toujours, même si un nom de fichier est aussi passé), un fichier existant, ou du texte inline pour une question rapide — une seule fonction couvre « explique-moi ce fichier » et « explique-moi cette erreur » sans commandes séparées à retenir. Le prompt demande quinze lignes de texte maximum, jamais une copie de l'entrée, et transmet <code>uname -s</code> pour que les commandes suggérées correspondent à la plateforme sur laquelle vous êtes réellement.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="installing-it">Installation<a href="#installing-it" class="hash-link" aria-label="Lien direct vers Installation" title="Lien direct vers Installation" translate="no"></a></h2>
<p>Tout tourne sur votre machine : zsh, <code>curl</code> et <code>jq</code> (qui construit la requête envoyée à Ollama), et un serveur Ollama accessible — voir <a href="/fr/blog/ollama-installation/">installer Ollama</a> si vous n'en avez pas encore.</p>
<p><strong>1. Créez les fichiers.</strong> Chaque fonction de cette série vit dans son propre fichier dans <code>~/.zsh/fns/</code>. La commande ci-dessous crée ce dossier et les deux fichiers d'un coup — ou téléchargez le ZIP, ou créez chaque fichier à la main et collez-y son contenu. <code>_ollama.zsh</code> est la fondation partagée par toute la série, présentée dans <a href="/fr/blog/ollama-test-generator/"><code>ai-test</code></a> : si un article précédent vous l'a déjà donné, la copie ici est le même fichier.</p>
<div class="projectSetupContainer_LW1i mk-surface"><div class="header_Htsx mk-toolbar"><strong>📦 <!-- -->Mise en place du projet :<!-- --> <!-- -->~/.zsh/fns</strong><div class="buttonGroup_y07f mk-toolbar__actions"><a class="button button--secondary button--sm" href="/project_setup" target="_blank">Aide</a><button class="button button--secondary button--sm">Tout déplier</button><button class="button button--outline button--primary button--sm" aria-label="Télécharger le projet en ZIP">ZIP</button><button class="button button--secondary button--sm">Générer le script d'installation</button></div></div><div class="content_bo8S"><div><p class="tip_qyDH"><small><span aria-hidden="true">💡</span> <!-- -->Besoin d'aide ? Cliquez sur le bouton « Aide » ci-dessus pour les instructions de mise en place.</small></p></div></div></div>
<p><strong>2. Chargez le dossier depuis <code>~/.zshrc</code>.</strong> Déjà fait si vous avez suivi un article précédent de cette série. Sinon, ajoutez ces lignes à la fin de <code>~/.zshrc</code> — elles sourcent chaque fichier <code>.zsh</code> du dossier, donc la prochaine fonction que vous ajouterez ne demandera aucune modification ici :</p>
<div class="language-zsh codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockTitle_OeMC">~/.zshrc</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-zsh codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">for fn_file in ~/.zsh/fns/*.zsh; do</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  source "$fn_file"</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">done</span><br></div></code></pre></div></div>
<p><strong>3. Ouvrez un nouveau terminal</strong> (ou lancez <code>exec zsh</code> dans celui en cours), et vérifiez que le shell connaît la nouvelle fonction :</p>
<div class="language-zsh codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-zsh codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">type ai-explain</span><br></div></code></pre></div></div>
<p><code>ai-explain is a shell function from …/ai-explain.zsh</code> signifie que vous êtes prêt. <code>ai-explain not found</code> signifie qu'il manque l'étape 2.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="more-demos">Autres démos<a href="#more-demos" class="hash-link" aria-label="Lien direct vers Autres démos" title="Lien direct vers Autres démos" translate="no"></a></h2>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="explain-a-stack-trace">Expliquer une stack trace<a href="#explain-a-stack-trace" class="hash-link" aria-label="Lien direct vers Expliquer une stack trace" title="Lien direct vers Expliquer une stack trace" translate="no"></a></h3>

<div class="terminal_qOWF" style="cursor:pointer" title="Cliquez pour passer l'animation"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><span class="terminal_ghost_aie1" aria-hidden="true">$ ai-explain example-error.txt

The error happens because the code tries to call `.map()` on a variable that is `undefined` in the ProductList component. This occurs at line 42 of ProductList.jsx where it expects an array but gets nothing.

To fix this, add a check to ensure the data exists before mapping:
```jsx
{products &amp;&amp; products.map(product =&gt; (
  &lt;div key={product.id}&gt;{product.name}&lt;/div&gt;
))}
```

If `products` is undefined, the component won't crash and will render nothing instead of throwing an error.
</span><span class="terminal_overlay_fznj"><span class="cursor_o7aQ" aria-hidden="true">▋</span></span></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="pipe-any-command-output">Envoyer la sortie de n'importe quelle commande<a href="#pipe-any-command-output" class="hash-link" aria-label="Lien direct vers Envoyer la sortie de n'importe quelle commande" title="Lien direct vers Envoyer la sortie de n'importe quelle commande" translate="no"></a></h3>
<p>Le pattern le plus utile : lancez une commande qui échoue et envoyez sa sortie directement :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>docker build . 2&gt;&amp;1 | ai-explain</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>npm install 2&gt;&amp;1 | ai-explain</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>./run-tests.sh 2&gt;&amp;1 | tail -50 | ai-explain</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Le <code>2&gt;&amp;1</code> redirige stderr vers stdout pour que les deux streams arrivent dans le pipe. Sans lui, les messages d'erreur de la plupart des outils CLI n'atteignent jamais <code>ai-explain</code>.</p>
<p>Pour les commandes qui produisent beaucoup de sortie, passez d'abord par <code>tail</code> pour ne garder que la fin pertinente (là où les erreurs atterrissent généralement).</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="use-from-the-ai-dispatcher">Utilisation depuis le dispatcher <code>ai</code><a href="#use-from-the-ai-dispatcher" class="hash-link" aria-label="Lien direct vers use-from-the-ai-dispatcher" title="Lien direct vers use-from-the-ai-dispatcher" translate="no"></a></h3>
<p>Comme toutes les fonctions de cette série, <code>ai-explain</code> s'enregistre dans le registre <code>AI_COMMANDS</code>. Le dispatcher <code>ai</code> (issu de <code>_ollama.zsh</code>) liste chaque fonction enregistrée — ici avec les fonctions publiées de cette série installées :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb">$ ai

ai &gt;
  ai-blog-search — ask your blog a question (RAG over every published article)
  ai-commit  — draft a Conventional Commits message from staged changes
  ai-data &lt;file.json|file.csv&gt;  — suggest jq/awk commands tailored to this file
  ai-diagram &lt;description&gt;|&lt;file&gt;  — turn a description or a config file into a Mermaid diagram
  ai-explain [file|text]  — explain a script, an error or piped output in plain English
  ai-review  — code review of staged changes (SOLID, magic constants, naming)
  ai-secrets  — scan staged changes for hardcoded credentials
  ai-summarize &lt;file&gt; [points]  — summarize a document locally (default: 5 points)
  ai-test &lt;file.sh|.php|.py&gt;  — generate or complete a unit test suite
  ai-translate &lt;file&gt; [language]  — translate a document locally (default: French)
</pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Sélectionnez <code>ai-explain</code> et le dispatcher ouvre d'abord son sélecteur de fichiers fzf : <code>AI_PARAMS[explain]="file"</code> lui indique de demander un fichier plutôt qu'une ligne de texte.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="under-the-hood-skip-this-if-you-just-want-to-use-it">Sous le capot (passez votre chemin si vous voulez juste l'utiliser)<a href="#under-the-hood-skip-this-if-you-just-want-to-use-it" class="hash-link" aria-label="Lien direct vers Sous le capot (passez votre chemin si vous voulez juste l'utiliser)" title="Lien direct vers Sous le capot (passez votre chemin si vous voulez juste l'utiliser)" translate="no"></a></h2>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="when-ai-explain-is-most-useful">Quand ai-explain est le plus utile<a href="#when-ai-explain-is-most-useful" class="hash-link" aria-label="Lien direct vers Quand ai-explain est le plus utile" title="Lien direct vers Quand ai-explain est le plus utile" translate="no"></a></h3>
<ul>
<li class=""><strong>Scripts hérités</strong> : comprendre ce que fait un script CI/CD avant de le modifier</li>
<li class=""><strong>Erreurs inconnues</strong> : une stack trace d'un langage ou d'un framework que vous maîtrisez mal</li>
<li class=""><strong>Sorties de log cryptiques</strong> : avertissements du daemon Docker, entrées du journal systemd, messages du kernel</li>
<li class=""><strong>Archéologie de one-liners</strong> : cette commande <code>awk '{ split($0,a,":"); ... }'</code> trouvée dans un Makefile</li>
<li class=""><strong>Vérification rapide</strong> : est-ce que ce script fait bien ce que son nom suggère ?</li>
</ul>
<p>C'est aussi utile comme outil d'apprentissage : envoyez-lui un script que vous avez écrit vous-même et voyez si l'explication correspond à votre intention. Si ce n'est pas le cas, soit l'explication est fausse (ça arrive), soit le script contient un bug subtil.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="conclusion">Conclusion<a href="#conclusion" class="hash-link" aria-label="Lien direct vers Conclusion" title="Lien direct vers Conclusion" translate="no"></a></h2>
<p><code>ai-explain</code> rejoint la série « Ollama daily use ».</p>
<p>La série a commencé avec <a href="/fr/blog/ollama-test-generator/"><code>ai-test</code></a> (la fondation : <code>_ollama.zsh</code>, le dispatcher, le registre <code>AI_COMMANDS</code>) et s'est enrichie de fonctions une par une — chacune résolvant un problème précis de workflow dans le terminal avec un LLM local :</p>
<ul>
<li class=""><strong>Workflow du développeur</strong> : <a href="/fr/blog/ollama-git-precommit/"><code>ai-review</code>, <code>ai-secrets</code>, <code>ai-commit</code></a>, <code>ai-standup</code></li>
<li class=""><strong>Opérations</strong> : <code>ai-ci</code>, <code>ai-fix</code></li>
<li class=""><strong>Assistance shell</strong> : <code>ai-ask</code>, <a href="/fr/blog/ollama-ai-data/"><code>ai-data</code></a>, <a href="/fr/blog/ollama-ai-diagram/"><code>ai-diagram</code></a></li>
<li class=""><strong>Travail documentaire</strong> : <a href="/fr/blog/ollama-ai-docs/"><code>ai-translate</code>, <code>ai-summarize</code></a>, <code>ai-diff</code></li>
<li class=""><strong>Votre propre documentation</strong> : <a href="/fr/blog/anythingllm-chat-with-your-docs/"><code>ai-blog-search</code></a></li>
<li class=""><strong>Explication</strong> : <code>ai-explain</code></li>
</ul>
<p>Toutes partagent la même fondation <code>_ollama_query</code>. Ensemble, elles couvrent les moments d'une journée de développement où vous iriez normalement chercher dans un navigateur ou une IA dans le cloud — mais où un modèle local est plus rapide d'accès et garde tout sur votre machine.</p>
<p>Les fonctions sans lien sont encore à venir. Lancez <code>ai</code> pour voir ce que votre terminal sait déjà faire.</p></div><hr><div class="seriesBlogPost_bjUd"><details class="details_rTsq"><summary class="seriesIntro_HIK1 mk-eyebrow">Cet article fait partie de la série <a href="/fr/series/ollama-daily-use/">Ollama au quotidien</a> :</summary><div class="content_OqVG"><ul><li><a href="/fr/blog/ollama-installation/">Installer Ollama et obtenir une IA locale</a></li><li><a href="/fr/blog/accessing-ollama-across-your-local-network/">Accéder à Ollama depuis votre réseau local</a></li><li><a href="/fr/blog/ollama-test-generator/">ai-test : générer les tests unitaires manquants depuis votre terminal avec un LLM local</a></li><li><a href="/fr/blog/ollama-git-precommit/">ai-review, ai-secrets, ai-commit : trois vérifications zsh avant chaque git commit</a></li><li><a href="/fr/blog/anythingllm-chat-with-your-docs/">AnythingLLM : discuter avec toute votre documentation éparpillée</a></li><li><a href="/fr/blog/ollama-ai-docs/">ai-translate &amp; ai-summarize : des documents confidentiels, traités 100 % en local</a></li><li><a href="/fr/blog/ollama-ai-diagram/">ai-diagram : d'une phrase (ou d'un compose.yaml) à un diagramme Mermaid</a></li><li><a href="/fr/blog/ollama-ai-data/">ai-data : laissez un LLM local écrire vos one-liners jq et awk</a></li><li><a href="/fr/blog/ollama-ai-explain/">ai-explain : l'ELI5 des scripts et des erreurs, directement dans votre terminal</a></li></ul></div></details></div></div><div class="relatedPosts_zJiB"><h3>Articles liés</h3></div><div class="container_mNR1"><span class="question_WdqX">Cet article vous a-t-il été utile ?</span><div class="buttons_vgpi"><button class="btn_svQp" aria-label="Oui, cet article m'a été utile"><img src="/fr/img/reaction-mascot-yes.webp" alt="" class="mascot_Fait">Utile</button><button class="btn_svQp btnNeutral_Iqek" aria-label="Non, cet article ne m'a pas été utile"><img src="/fr/img/reaction-mascot-no.webp" alt="" class="mascot_Fait">Pas vraiment</button></div></div><div class="container_IJwe"><span class="question_nb7b">Avez-vous suivi les étapes de cet article ?</span><div class="buttons_Xkzw"><button class="btn_RMiq" aria-label="Oui, j'ai essayé et ça a marché"><img src="/fr/img/like.webp" alt="" class="mascot_wB1d">Ça a marché !</button><button class="btn_RMiq btnNeutral_z71B" aria-label="Non, ça n'a pas marché pour moi"><img src="/fr/img/dislike.webp" alt="" class="mascot_wB1d">Ça n'a pas marché</button></div></div>]]></content:encoded>
            <author>Christophe</author>
            <author>Claude Code</author>
            <category>ai</category>
            <category>ollama</category>
            <category>zsh</category>
            <category>linux</category>
            <category>bash</category>
            <category>docker</category>
            <category>git</category>
            <enclosure length="0" type="image/webp" url="https://www.avonture.be/fr/img/v2/playing_with_ollama.webp"/>
        <dc:creator><![CDATA[Christophe]]></dc:creator><dc:creator><![CDATA[Claude Code]]></dc:creator></item>
        <item>
            <title><![CDATA[DuckDB - Query JSON and CSV Files With SQL, No Database Required]]></title>
            <link>https://www.avonture.be/fr/fr/blog/duckdb-json-csv</link>
            <guid isPermaLink="false">/fr/blog/duckdb-json-csv</guid>
            <pubDate>Mon, 09 Nov 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[<img src="https://www.avonture.be/fr/img/v2/duckdb-json-csv.webp" alt="DuckDB - Query JSON and CSV Files With SQL, No Database Required" style="display:block;max-width:100%;height:auto;">Run real SQL — joins, window functions, GROUP BY — directly against JSON and CSV files, no import step, no server, no schema to define. Dockerized, with a duckdb shell function that works from any folder. The tool I reach for once awk or jq stop being enough.]]></description>
            <content:encoded><![CDATA[<div class="offer_jqr1"><span class="flag_Hnep" aria-hidden="true">🇬🇧</span><span>Cet article existe aussi en anglais —<!-- --> <a href="/blog/duckdb-json-csv/" hreflang="en">lire la version originale</a>.</span></div><div class="alertBox_R1Ot mk-callout caution_LuPi mk-callout--warn" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Traduction automatique</p><div class="content_gFLB">Cette traduction a été produite par une IA. Elle peut contenir des contresens ou des maladresses, en particulier sur les termes techniques. En cas de doute,<!-- --> <a href="/blog/duckdb-json-csv/">consultez l'article de référence en anglais</a> <!-- -->— c'est lui qui fait foi.</div></div></div><div><div><p><img alt="DuckDB - Interroger des fichiers JSON et CSV en SQL, sans base de données" src="/fr/assets/images/duckdb-json-csv-b45715df132fc13800e5c798c24c151f.webp" height="auto" width="" loading="lazy" decoding="async" class=""></p>
<div class="tldrContainer_qQW_ mk-callout mk-callout--key" role="region" aria-label="Résumé de l'article"><span role="img" aria-label="éclair" class="tldrIcon_SLBz mk-dot">⚡</span><div class="tldrBody_lGty"><strong class="tldrTitle_Aha8 mk-eyebrow">TL;DR</strong><div class="tldrContent_m27u"><p><a href="https://duckdb.org/" target="_blank" rel="noopener noreferrer" class="">DuckDB</a> est une base de données analytique embarquée qui interroge des fichiers CSV et JSON directement en SQL — <code>SELECT * FROM 'file.csv'</code> fonctionne, tout simplement : pas d'import, pas de serveur, pas de schéma à définir au préalable. Cet article le met en place sous forme d'image Docker avec une fonction shell <code>duckdb</code> chargée par votre zsh ou votre bash, le même pattern que <a class="" href="/fr/blog/markitdown/">Markitdown</a> et <a class="" href="/fr/blog/docling/">Docling</a> : une commande, utilisable depuis n'importe quel dossier. C'est l'outil qui prend le relais exactement là où <code>awk</code> et <code>jq</code> ne suffisent plus — au moment où une question réclame une jointure, une fonction de fenêtrage ou un GROUP BY sur deux formats de fichiers différents à la fois.</p></div></div></div><nav class="mobileQuickLinks_La_l" aria-label="Articles liés"><p class="title_rsOi mk-eyebrow">Sur le même sujet</p><ul class="list_CXCl"><li class="item_grzi"><a href="/fr/blog/duckdb-json-csv/">DuckDB - Interroger des fichiers JSON et CSV en SQL, sans base de données</a></li><li class="item_grzi"><a href="/fr/blog/vba-access-export/">Exporter les objets MS Access</a></li><li class="item_grzi"><a href="/fr/blog/pentaho-discovery/">À la découverte de Pentaho, un programme ETL</a></li></ul></nav>
<p>J'ai écrit <a class="" href="/fr/blog/python-pandas-merge/">Pandas — merge two or more files</a> il y a quelque temps pour exactement ce genre de travail, et ça marche, mais cela implique d'écrire un script : importer pandas, lire les fichiers, fusionner, peut-être réexporter. La plupart du temps, ce que je veux vraiment, c'est une requête, lancée une fois, qui répond une fois. <code>awk</code> gère la version simple de tout ça — grouper et compter, filtrer — mais dès que j'ai besoin d'un vrai <code>JOIN</code> ou d'une fonction de fenêtrage, me revoilà à écrire du Python pour une question qui tient littéralement en une ligne de SQL.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="demo">Démo<a href="#demo" class="hash-link" aria-label="Lien direct vers Démo" title="Lien direct vers Démo" translate="no"></a></h2>
<div class="language-csv codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-csv codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">order_id,region,amount</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">1001,EMEA,120.50</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">1002,APAC,89.00</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">1003,EMEA,45.20</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">1004,AMER,300.00</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">1005,APAC,60.75</span><br></div></code></pre></div></div>

<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"> </span><span class="token property">"region"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"EMEA"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token property">"manager"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"Alice Martin"</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"> </span><span class="token property">"region"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"APAC"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token property">"manager"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"Chloé Bernard"</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"> </span><span class="token property">"region"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"AMER"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token property">"manager"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"Bruno Dupont"</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token punctuation" style="color:rgb(212, 212, 212)">]</span><br></div></code></pre></div></div>
<div class="terminal_qOWF" style="cursor:pointer" title="Cliquez pour passer l'animation"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><span class="terminal_ghost_aie1" aria-hidden="true">$ duckdb -c "SELECT region, COUNT(*) AS orders, SUM(amount) AS total FROM 'orders.csv' GROUP BY region ORDER BY total DESC"

┌─────────┬────────┬────────┐
│ region  │ orders │ total  │
│ varchar │ int64  │ double │
├─────────┼────────┼────────┤
│ AMER    │      1 │  300.0 │
│ EMEA    │      2 │  165.7 │
│ APAC    │      2 │ 149.75 │
└─────────┴────────┴────────┘

$ duckdb -c "SELECT name FROM read_json_auto('users.json') WHERE active = true"

┌───────────────┐
│     name      │
│    varchar    │
├───────────────┤
│ Alice Martin  │
│ Chloé Bernard │
└───────────────┘

$ duckdb -c "SELECT order_id, region, amount, RANK() OVER (PARTITION BY region ORDER BY amount DESC) AS rank_in_region FROM 'orders.csv' ORDER BY region, rank_in_region"

┌──────────┬─────────┬────────┬────────────────┐
│ order_id │ region  │ amount │ rank_in_region │
│  int64   │ varchar │ double │     int64      │
├──────────┼─────────┼────────┼────────────────┤
│     1004 │ AMER    │  300.0 │              1 │
│     1002 │ APAC    │   89.0 │              1 │
│     1005 │ APAC    │  60.75 │              2 │
│     1001 │ EMEA    │  120.5 │              1 │
│     1003 │ EMEA    │   45.2 │              2 │
└──────────┴─────────┴────────┴────────────────┘

$ duckdb -c "SELECT r.manager, o.region, COUNT(*) AS orders, SUM(o.amount) AS total FROM 'orders.csv' o JOIN 'regions.json' r USING (region) GROUP BY ALL ORDER BY total DESC"

┌───────────────┬─────────┬────────┬────────┐
│    manager    │ region  │ orders │ total  │
│    varchar    │ varchar │ int64  │ double │
├───────────────┼─────────┼────────┼────────┤
│ Bruno Dupont  │ AMER    │      1 │  300.0 │
│ Alice Martin  │ EMEA    │      2 │  165.7 │
│ Chloé Bernard │ APAC    │      2 │ 149.75 │
└───────────────┴─────────┴────────┴────────┘
</span><span class="terminal_overlay_fznj"><span class="cursor_o7aQ" aria-hidden="true">▋</span></span></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Quatre types de requêtes vraiment différents, aucun n'a nécessité d'écrire un script : un <code>GROUP BY</code>/<code>SUM</code> sur le CSV, un filtre <code>WHERE</code> via <code>read_json_auto</code> sur le JSON, une fonction de fenêtrage <code>RANK() OVER (PARTITION BY ...)</code> et un <code>JOIN</code> entre le CSV et un fichier JSON, dans la même requête — le genre de question pour laquelle <code>awk</code> n'a tout simplement pas de réponse propre.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="what-makes-duckdb-different">Ce qui rend DuckDB différent<a href="#what-makes-duckdb-different" class="hash-link" aria-label="Lien direct vers Ce qui rend DuckDB différent" title="Lien direct vers Ce qui rend DuckDB différent" translate="no"></a></h2>
<p>DuckDB est un vrai moteur SQL — la syntaxe, le planificateur de requêtes, tout — sauf qu'il tourne dans le processus, n'a besoin d'aucun serveur et lit les fichiers CSV/JSON/Parquet directement comme s'il s'agissait de tables :</p>
<div class="language-sql codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-sql codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token keyword" style="color:rgb(86, 156, 214)">SELECT</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">*</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">FROM</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">'orders.csv'</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token keyword" style="color:rgb(86, 156, 214)">SELECT</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">*</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">FROM</span><span class="token plain"> read_json_auto</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token string" style="color:rgb(206, 145, 120)">'users.json'</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><br></div></code></pre></div></div>
<p>Pas de <code>CREATE TABLE</code>, pas d'étape d'import, pas de schéma à définir — il déduit les types de colonnes en échantillonnant le fichier, la même idée que le parsing automatique de <a class="" href="/fr/blog/linux-jq/"><code>jq</code></a> mais avec tout SQL par-dessus au lieu d'un langage de filtres.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="installation">Installation<a href="#installation" class="hash-link" aria-label="Lien direct vers Installation" title="Lien direct vers Installation" translate="no"></a></h2>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="try-it-in-two-minutes">À tester en deux minutes<a href="#try-it-in-two-minutes" class="hash-link" aria-label="Lien direct vers À tester en deux minutes" title="Lien direct vers À tester en deux minutes" translate="no"></a></h3>
<p>Cliquez sur le bouton ci-dessous pour obtenir un script qui crée <code>/tmp/duckdb-json</code> avec le Dockerfile et les trois fichiers de démo :</p>
<div class="projectSetupContainer_LW1i mk-surface"><div class="header_Htsx mk-toolbar"><strong>📦 <!-- -->Mise en place du projet :<!-- --> <!-- -->/tmp/duckdb-json</strong><div class="buttonGroup_y07f mk-toolbar__actions"><a class="button button--secondary button--sm" href="/project_setup" target="_blank">Aide</a><button class="button button--secondary button--sm">Tout déplier</button><button class="button button--outline button--primary button--sm" aria-label="Télécharger le projet en ZIP">ZIP</button><button class="button button--secondary button--sm">Générer le script d'installation</button></div></div><div class="content_bo8S"><div><p class="tip_qyDH"><small><span aria-hidden="true">💡</span> <!-- -->Besoin d'aide ? Cliquez sur le bouton « Aide » ci-dessus pour les instructions de mise en place.</small></p></div></div></div>
<p>Il n'existe pas de package apt ou pip pour le CLI DuckDB lui-même (seulement pour les bindings Python), donc le Dockerfile télécharge le binaire officiel de la release.</p>
<div class="alertBox_R1Ot mk-callout note_xvll mk-callout--note" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Version figée volontairement</p><div class="content_gFLB"><p><code>DUCKDB_VERSION</code> est un build arg, figé à <code>2.0.0</code> — la version courante au moment de l'écriture. Consultez la <a href="https://github.com/duckdb/duckdb/releases" target="_blank" rel="noopener noreferrer" class="">page des releases</a> pour en trouver une plus récente et montez la valeur de l'arg ; DuckDB publie de nouvelles versions assez souvent pour qu'un pin explicite, plutôt que de suivre <code>latest</code>, garde le build reproductible.</p></div></div></div>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="make-duckdb-available-everywhere">Rendre <code>duckdb</code> disponible partout<a href="#make-duckdb-available-everywhere" class="hash-link" aria-label="Lien direct vers make-duckdb-available-everywhere" title="Lien direct vers make-duckdb-available-everywhere" translate="no"></a></h3>
<p><code>/tmp/duckdb-json</code> servait seulement à la démo : il disparaît au prochain reboot. Ce qui doit rester sur votre PC, c'est le <strong>Dockerfile</strong>, pour que l'image puisse être reconstruite à tout moment (nouvelle machine, <code>docker image prune</code>, nouvelle version de DuckDB). Donnez-lui une place permanente dans <code>~/tools/duckdb</code>, et construisez l'image depuis là :</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token function" style="color:rgb(220, 220, 170)">mkdir</span><span class="token plain"> </span><span class="token parameter variable" style="color:rgb(156, 220, 254)">-p</span><span class="token plain"> ~/tools/duckdb</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token function" style="color:rgb(220, 220, 170)">cp</span><span class="token plain"> /tmp/duckdb-json/Dockerfile ~/tools/duckdb/</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token function" style="color:rgb(220, 220, 170)">docker</span><span class="token plain"> build </span><span class="token parameter variable" style="color:rgb(156, 220, 254)">-t</span><span class="token plain"> duckdb-cli ~/tools/duckdb</span><br></div></code></pre></div></div>
<p>Ce qui transforme cette image en commande, c'est une fonction, et elle a son propre fichier dans un dossier que votre shell charge au démarrage, la même organisation que dans <a href="/fr/blog/modular-zsh-workflow/">mon workflow ZSH modulaire</a> : votre fichier rc reste court, et supprimer l'outil revient à effacer un fichier.</p>
<p>Le code est le même pour zsh et bash ; seuls le dossier et le loader diffèrent.</p>
<div class="theme-tabs-container tabs-container tabList__CuJ"><ul role="tablist" aria-orientation="horizontal" class="tabs"><li role="tab" tabindex="0" aria-selected="true" class="tabs__item tabItem_LNqP tabs__item--active">zsh</li><li role="tab" tabindex="-1" aria-selected="false" class="tabs__item tabItem_LNqP">bash</li></ul><div class="margin-top--md"><div role="tabpanel" class="tabItem_Ymn6"><p><strong>1. Créez le fichier</strong> dans <code>~/.zsh/fns/</code> :</p><div class="projectSetupContainer_LW1i mk-surface"><div class="header_Htsx mk-toolbar"><strong>📦 <!-- -->Mise en place du projet :<!-- --> <!-- -->~/.zsh/fns</strong><div class="buttonGroup_y07f mk-toolbar__actions"><a class="button button--secondary button--sm" href="/project_setup" target="_blank">Aide</a><button class="button button--secondary button--sm">Tout déplier</button><button class="button button--outline button--primary button--sm" aria-label="Télécharger le projet en ZIP">ZIP</button><button class="button button--secondary button--sm">Générer le script d'installation</button></div></div><div class="content_bo8S"><div><p class="tip_qyDH"><small><span aria-hidden="true">💡</span> <!-- -->Besoin d'aide ? Cliquez sur le bouton « Aide » ci-dessus pour les instructions de mise en place.</small></p></div></div></div><p><strong>2. Chargez le dossier depuis <code>~/.zshrc</code>.</strong> Déjà fait si vous avez suivi un de mes articles <a href="/fr/series/ollama-daily-use/">« Ollama au quotidien »</a>. Sinon, ajoutez à la fin de <code>~/.zshrc</code> :</p><div class="language-zsh codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockTitle_OeMC">~/.zshrc</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-zsh codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">for fn_file in ~/.zsh/fns/*.zsh; do</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  source "$fn_file"</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">done</span><br></div></code></pre></div></div><p><strong>3. Ouvrez un nouveau terminal</strong> (ou lancez <code>exec zsh</code>).</p></div><div role="tabpanel" class="tabItem_Ymn6" hidden=""><p><strong>1. Créez le fichier</strong> dans <code>~/.bash/fns/</code> :</p><div class="projectSetupContainer_LW1i mk-surface"><div class="header_Htsx mk-toolbar"><strong>📦 <!-- -->Mise en place du projet :<!-- --> <!-- -->~/.bash/fns</strong><div class="buttonGroup_y07f mk-toolbar__actions"><a class="button button--secondary button--sm" href="/project_setup" target="_blank">Aide</a><button class="button button--secondary button--sm">Tout déplier</button><button class="button button--outline button--primary button--sm" aria-label="Télécharger le projet en ZIP">ZIP</button><button class="button button--secondary button--sm">Générer le script d'installation</button></div></div><div class="content_bo8S"><div><p class="tip_qyDH"><small><span aria-hidden="true">💡</span> <!-- -->Besoin d'aide ? Cliquez sur le bouton « Aide » ci-dessus pour les instructions de mise en place.</small></p></div></div></div><p><strong>2. Chargez le dossier depuis <code>~/.bashrc</code>.</strong> Ajoutez à la fin de <code>~/.bashrc</code> (le test <code>-r</code> évite qu'un dossier vide n'affiche une erreur) :</p><div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockTitle_OeMC">~/.bashrc</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token keyword" style="color:rgb(86, 156, 214)">for</span><span class="token plain"> </span><span class="token for-or-select variable" style="color:rgb(156, 220, 254)">fn_file</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">in</span><span class="token plain"> ~/.bash/fns/*.sh</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">do</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token plain"> </span><span class="token parameter variable" style="color:rgb(156, 220, 254)">-r</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"</span><span class="token string variable" style="color:rgb(156, 220, 254)">$fn_file</span><span class="token string" style="color:rgb(206, 145, 120)">"</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">]</span><span class="token punctuation" style="color:rgb(212, 212, 212)">]</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name" style="color:rgb(78, 201, 176)">source</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"</span><span class="token string variable" style="color:rgb(156, 220, 254)">$fn_file</span><span class="token string" style="color:rgb(206, 145, 120)">"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token keyword" style="color:rgb(86, 156, 214)">done</span><br></div></code></pre></div></div><p><strong>3. Ouvrez un nouveau terminal</strong> (ou lancez <code>exec bash</code>).</p></div></div></div>
<p>Vérifiez que le shell connaît la fonction : <code>type duckdb</code> doit répondre <code>duckdb is a shell function</code>. Allez ensuite dans le dossier de démo et rejouez la première requête de cet article :</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token builtin class-name" style="color:rgb(78, 201, 176)">cd</span><span class="token plain"> /tmp/duckdb-json</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">duckdb </span><span class="token parameter variable" style="color:rgb(156, 220, 254)">-c</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"SELECT region, COUNT(*) AS orders, SUM(amount) AS total FROM 'orders.csv' GROUP BY region ORDER BY total DESC"</span><br></div></code></pre></div></div>
<p>Désormais, ça fonctionne pareil dans n'importe quel dossier contenant un fichier CSV ou JSON. La fonction monte le <strong>dossier courant</strong> comme <code>/workspace</code>, donc les chemins dans votre SQL sont relatifs à l'endroit où vous êtes. Ce dossier est monté en <strong>lecture seule</strong> sauf si le premier argument est <code>--write</code> ; <code>--read-only</code> de son côté verrouille le système de fichiers du container lui-même, pour que rien ne puisse être écrit dans l'image non plus ; un fichier hors du dossier courant n'est pas visible, faites d'abord un <code>cd</code> dessus. Sans argument, <code>duckdb</code> ouvre le shell SQL interactif ; avec <code>-c "SQL"</code>, il exécute une requête et se termine ; du SQL envoyé par un pipe (<code>cat report.sql | duckdb</code>) fonctionne aussi.</p>
<p>Si l'image est absente, la fonction n'échoue pas sur une erreur Docker cryptique, elle vous explique comment la reconstruire depuis <code>~/tools/duckdb</code> :</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">Error: the Docker image "duckdb-cli" does not exist on this machine.</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">How to continue:</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  1. docker build -t duckdb-cli "${HOME}/tools/duckdb"</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  2. Run your duckdb command again.</span><br></div></code></pre></div></div>
<p>Et si <code>~/tools/duckdb/Dockerfile</code> a disparu aussi, le message renvoie vers cet article pour le récupérer.</p>
<div class="alertBox_R1Ot mk-callout caution_LuPi mk-callout--warn" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Elle masque un duckdb natif</p><div class="content_gFLB"><p>La fonction s'appelle <code>duckdb</code>, elle a donc la priorité sur un binaire <code>duckdb</code> installé localement. C'est précisément le but ici (pas d'installation locale), mais si vous installez DuckDB nativement un jour, supprimez la fonction ou renommez-la.</p></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="working-with-duckdb">Utiliser <code>duckdb</code><a href="#working-with-duckdb" class="hash-link" aria-label="Lien direct vers working-with-duckdb" title="Lien direct vers working-with-duckdb" translate="no"></a></h2>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="the-interactive-console">La console interactive<a href="#the-interactive-console" class="hash-link" aria-label="Lien direct vers La console interactive" title="Lien direct vers La console interactive" translate="no"></a></h3>
<p>Une fois la fonction en place, taper <code>duckdb</code> sans argument ouvre une console SQL interactive, l'endroit le plus confortable pour jouer avec les deux instructions de <a href="#what-makes-duckdb-different" class="">Ce qui rend DuckDB différent</a> :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb">$ duckdb
Read-only: your files cannot be modified. Start with "duckdb --write" to save files.
DuckDB v2.0.0 (Cyanoptera)
Enter ".help" for usage hints.
memory D SELECT * FROM 'orders.csv';
┌──────────┬─────────┬────────┐
│ order_id │ region  │ amount │
│  int64   │ varchar │ double │
├──────────┼─────────┼────────┤
│     1001 │ EMEA    │  120.5 │
│     1002 │ APAC    │   89.0 │
│     1003 │ EMEA    │   45.2 │
│     1004 │ AMER    │  300.0 │
│     1005 │ APAC    │  60.75 │
└──────────┴─────────┴────────┘
memory D SELECT * FROM read_json_auto('users.json');
┌───────────────┬────────────┬─────────┐
│     name      │ birth_date │ active  │
│    varchar    │    date    │ boolean │
├───────────────┼────────────┼─────────┤
│ Alice Martin  │ 1990-04-12 │ true    │
│ Bruno Dupont  │ 1985-11-02 │ false   │
│ Chloé Bernard │ 1998-07-23 │ true    │
└───────────────┴────────────┴─────────┘
memory D .quit
</pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Regardez la seconde table : personne n'a rien déclaré, et pourtant <code>birth_date</code> revient en <code>date</code> et <code>active</code> en <code>boolean</code>, déduits des valeurs JSON elles-mêmes. Le prompt <code>memory D</code> signifie que la session tourne sur une base en mémoire jetable : un <code>SELECT</code> lit vos fichiers et ne change rien. <code>.help</code> liste les commandes de la console.</p>
<div class="alertBox_R1Ot mk-callout tip_shCb mk-callout--tip" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Pour quitter la console, tapez .quit</p><div class="content_gFLB"><p>Les commandes de la console commencent par un <strong>point</strong> : tapez <code>.quit</code> (ou <code>.exit</code>, ou appuyez sur <kbd>Ctrl</kbd>+<kbd>D</kbd>) pour revenir à votre shell. Un simple <code>exit</code> ou <code>quit</code> ne fonctionne <strong>pas</strong> : DuckDB le lit comme le début d'une instruction SQL et attend silencieusement la suite, jusqu'à ce qu'un <code>;</code> y mette fin avec une erreur de syntaxe.</p></div></div></div>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="update-and-delete-read-only-by-default---write-when-you-mean-it">UPDATE et DELETE : lecture seule par défaut, <code>--write</code> quand c'est voulu<a href="#update-and-delete-read-only-by-default---write-when-you-mean-it" class="hash-link" aria-label="Lien direct vers update-and-delete-read-only-by-default---write-when-you-mean-it" title="Lien direct vers update-and-delete-read-only-by-default---write-when-you-mean-it" translate="no"></a></h3>
<p>DuckDB exécute <code>UPDATE</code> et <code>DELETE</code> sur des <strong>tables</strong>, jamais sur un fichier lu directement : <code>UPDATE 'orders.csv' SET …</code> est refusé avec <code>Can only update base table</code>, quelles que soient les permissions. Modifier un fichier passe donc toujours par les trois mêmes étapes : le charger dans une table, modifier la table, la réécrire avec <code>COPY ... TO</code>.</p>
<p>Cette dernière étape est une écriture, et la fonction <code>duckdb</code> la refuse par défaut : elle monte le dossier courant en <strong>lecture seule</strong>, pour qu'aucune requête ne puisse altérer vos fichiers par accident. Mettez <code>--write</code> en premier pour l'autoriser :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb">$ duckdb -c "CREATE TABLE o AS FROM 'orders.csv'; UPDATE o SET amount = round(amount * 1.21, 2) WHERE region = 'EMEA'; DELETE FROM o WHERE region = 'APAC'; COPY o TO 'orders_fixed.csv'"

IO Error: Cannot open file "orders_fixed.csv": Read-only file system
Tip: the current folder is read-only. If this failed while writing a file, run it again as: duckdb --write ...

$ duckdb --write -c "CREATE TABLE o AS FROM 'orders.csv'; UPDATE o SET amount = round(amount * 1.21, 2) WHERE region = 'EMEA'; DELETE FROM o WHERE region = 'APAC'; COPY o TO 'orders_fixed.csv'"

$ cat orders_fixed.csv
order_id,region,amount
1001,EMEA,145.81
1003,EMEA,54.69
1004,AMER,300.0
</pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Le <code>orders.csv</code> d'origine est intact ; <code>orders_fixed.csv</code> contient les montants EMEA avec 21 % de TVA et plus aucune row APAC. Écrivez plutôt dans <code>'orders.csv'</code> et c'est le fichier lui-même qui est réécrit. <code>COPY ... TO</code> écrit du CSV, du JSON (<code>'out.json'</code>) ou du Parquet (<code>'out.parquet'</code>), choisi d'après l'extension. Pour conserver des tables entre deux sessions au lieu d'une base en mémoire jetable, attachez un fichier de base de données, ce qui est aussi une écriture : <code>duckdb --write</code>, puis <code>ATTACH 'shop.duckdb' AS shop;</code>.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="key-takeaways">À retenir<a href="#key-takeaways" class="hash-link" aria-label="Lien direct vers À retenir" title="Lien direct vers À retenir" translate="no"></a></h2>
<div class="steps_wrapper_r_yO" data-variant="remember"><p class="steps_title_bD7c mk-eyebrow">duckdb en référence rapide</p><ul class="steps_list_Vtzi"><li class="step_item_FtAN"><span class="step_bullet_sanI mk-dot" aria-hidden="true">•</span><div class="step_content_KvQY"><span><strong>Aucune étape d'import</strong> — <code>SELECT * FROM 'file.csv'</code> et <code>read_json_auto('file.json')</code> interrogent les fichiers directement</span></div></li><li class="step_item_FtAN"><span class="step_bullet_sanI mk-dot" aria-hidden="true">•</span><div class="step_content_KvQY"><span><strong>Du vrai SQL</strong> — jointures, fonctions de fenêtrage, <code>GROUP BY</code>, exactement comme une base complète, sans serveur</span></div></li><li class="step_item_FtAN"><span class="step_bullet_sanI mk-dot" aria-hidden="true">•</span><div class="step_content_KvQY"><span><strong>Pas de package CLI</strong> — le Dockerfile télécharge le binaire officiel de la release, avec une version figée</span></div></li><li class="step_item_FtAN"><span class="step_bullet_sanI mk-dot" aria-hidden="true">•</span><div class="step_content_KvQY"><span><strong>Un fichier dans <code>~/.zsh/fns/</code> ou <code>~/.bash/fns/</code></strong> — <code>duckdb</code> fonctionne depuis n'importe quel dossier, et explique comment reconstruire l'image quand elle a disparu</span></div></li><li class="step_item_FtAN"><span class="step_bullet_sanI mk-dot" aria-hidden="true">•</span><div class="step_content_KvQY"><span><strong>Interactif ou one-shot</strong> — <code>duckdb</code> seul pour un REPL, <code>-c "SQL"</code> pour une requête unique</span></div></li><li class="step_item_FtAN"><span class="step_bullet_sanI mk-dot" aria-hidden="true">•</span><div class="step_content_KvQY"><span><strong>Lecture seule par défaut</strong> — <code>duckdb --write</code> pour enregistrer des fichiers avec <code>COPY ... TO</code> ou <code>ATTACH</code></span></div></li><li class="step_item_FtAN"><span class="step_bullet_sanI mk-dot" aria-hidden="true">•</span><div class="step_content_KvQY"><span><strong>Complémentaire d'<code>ai-data</code></strong> — sortez <code>awk</code>/<code>jq</code> d'abord, DuckDB dès que la question réclame une jointure ou une fonction de fenêtrage</span></div></li></ul></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="conclusion">Conclusion<a href="#conclusion" class="hash-link" aria-label="Lien direct vers Conclusion" title="Lien direct vers Conclusion" translate="no"></a></h2>
<p>Entre <a class="" href="/fr/blog/python-pandas-merge/"><code>python-pandas-merge</code></a> pour tout ce qui demande du vrai scripting, <code>jq</code>/<code>awk</code>/<a class="" href="/fr/blog/ollama-ai-data/"><code>ai-data</code></a> pour les one-liners rapides, et maintenant DuckDB pour tout ce qui se situe entre les deux et relève vraiment d'une question SQL, j'ai nettement arrêté de me dire « allez, un petit script Python ». Un fichier <code>.csv</code> et un fichier <code>.json</code>, interrogés et joints ensemble en une ligne de vrai SQL, sans serveur, sans import — voilà un outil qui mérite une place permanente dans mon <code>~/.zsh/fns/</code>.</p></div><hr></div><div class="relatedPosts_zJiB"><h3>Articles liés</h3></div><div class="container_mNR1"><span class="question_WdqX">Cet article vous a-t-il été utile ?</span><div class="buttons_vgpi"><button class="btn_svQp" aria-label="Oui, cet article m'a été utile"><img src="/fr/img/reaction-mascot-yes.webp" alt="" class="mascot_Fait">Utile</button><button class="btn_svQp btnNeutral_Iqek" aria-label="Non, cet article ne m'a pas été utile"><img src="/fr/img/reaction-mascot-no.webp" alt="" class="mascot_Fait">Pas vraiment</button></div></div><div class="container_IJwe"><span class="question_nb7b">Avez-vous suivi les étapes de cet article ?</span><div class="buttons_Xkzw"><button class="btn_RMiq" aria-label="Oui, j'ai essayé et ça a marché"><img src="/fr/img/like.webp" alt="" class="mascot_wB1d">Ça a marché !</button><button class="btn_RMiq btnNeutral_z71B" aria-label="Non, ça n'a pas marché pour moi"><img src="/fr/img/dislike.webp" alt="" class="mascot_wB1d">Ça n'a pas marché</button></div></div>]]></content:encoded>
            <author>Christophe</author>
            <author>Claude Code</author>
            <category>database</category>
            <category>docker</category>
            <category>linux</category>
            <enclosure length="0" type="image/webp" url="https://www.avonture.be/fr/img/v2/duckdb-json-csv.webp"/>
        <dc:creator><![CDATA[Christophe]]></dc:creator><dc:creator><![CDATA[Claude Code]]></dc:creator></item>
        <item>
            <title><![CDATA[Devcontainer - Name Your Container and Group It in Docker Desktop]]></title>
            <link>https://www.avonture.be/fr/fr/blog/devcontainer-container-name</link>
            <guid isPermaLink="false">/fr/blog/devcontainer-container-name</guid>
            <pubDate>Thu, 05 Nov 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[<img src="https://www.avonture.be/fr/img/v2/devcontainer.webp" alt="Devcontainer - Name Your Container and Group It in Docker Desktop" style="display:block;max-width:100%;height:auto;">Stop getting random container names like serene_ganguly from VS Code devcontainers; use runArgs to force a name and group containers in Docker Desktop.]]></description>
            <content:encoded><![CDATA[<div class="offer_jqr1"><span class="flag_Hnep" aria-hidden="true">🇬🇧</span><span>Cet article existe aussi en anglais —<!-- --> <a href="/blog/devcontainer-container-name/" hreflang="en">lire la version originale</a>.</span></div><div class="alertBox_R1Ot mk-callout caution_LuPi mk-callout--warn" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Traduction automatique</p><div class="content_gFLB">Cette traduction a été produite par une IA. Elle peut contenir des contresens ou des maladresses, en particulier sur les termes techniques. En cas de doute,<!-- --> <a href="/blog/devcontainer-container-name/">consultez l'article de référence en anglais</a> <!-- -->— c'est lui qui fait foi.</div></div></div><div><div class="revisionHistoryContainer_DGTV mk-surface margin-top--lg"><h3 class="revisionHistoryTitle_eX7C mk-eyebrow"><span aria-hidden="true">📜</span> <!-- -->Journal des modifications</h3><div class="timeline_jZcr"><div class="timelineItem_LQdV timelineItemLatest_AzU9"><div class="timelineIcon_qAPq mk-dot"><span aria-hidden="true">✨</span></div><div class="timelineContent_eAHl"><div class="timelineDate_GzO4"><time datetime="2026-11-08T00:00:00.000Z">8 novembre 2026</time><span class="latestBadge_s2Ev mk-pill">Dernière</span></div><div class="timelineDescription_SGKc">Add a <code>-devcontainer</code> suffix to the container name and the group label</div></div></div></div></div><div><p><img alt="Devcontainer - Nommer son container et le regrouper dans Docker Desktop" src="/fr/assets/images/devcontainer-868b3cff263aadedc38c93d42e967c06.webp" height="auto" width="" loading="lazy" decoding="async" class=""></p>
<div class="tldrContainer_qQW_ mk-callout mk-callout--key" role="region" aria-label="Résumé de l'article"><span role="img" aria-label="éclair" class="tldrIcon_SLBz mk-dot">⚡</span><div class="tldrBody_lGty"><strong class="tldrTitle_Aha8 mk-eyebrow">TL;DR</strong><div class="tldrContent_m27u"><p>Quand VS Code construit un devcontainer à partir d'un <code>Dockerfile</code>, Docker lui colle un nom aléatoire du genre <code>serene_ganguly</code> et le container atterrit, tout seul, quelque part dans la liste de Docker Desktop. Deux lignes dans la clé <code>runArgs</code> de <code>devcontainer.json</code> règlent les deux problèmes : <code>--name=docusaurus-devcontainer</code> donne un nom stable au container, et <code>--label=com.docker.compose.project=blog-devcontainer</code> fait que Docker Desktop le regroupe, exactement comme la clé <code>name:</code> d'un fichier <code>compose.yaml</code>. Le suffixe <code>-devcontainer</code> permet de distinguer les environnements de développement des containers qui font tourner des services.</p></div></div></div><nav class="mobileQuickLinks_La_l" aria-label="Articles liés"><p class="title_rsOi mk-eyebrow">Sur le même sujet</p><ul class="list_CXCl"><li class="item_grzi"><a href="/fr/blog/devcontainer-container-name/">Devcontainer - Nommer son container et le regrouper dans Docker Desktop</a></li><li class="item_grzi"><a href="/fr/blog/vscode-remote-ssh-proxyjump-devcontainer/">Ouvrir un DevContainer sur un serveur distant avec VSCode Remote SSH et ProxyJump</a></li><li class="item_grzi"><a href="/fr/blog/quarto-industrialisation/">Quarto - Comment j'ai construit un écosystème auto-documenté pour plus de 50 projets</a></li></ul></nav>
<p>Ouvrez la liste des containers de Docker Desktop après quelques jours de travail en devcontainer et vous obtenez quelque chose comme <code>serene_ganguly</code>, <code>objective_hopper</code>, <code>vigilant_tesla</code>... Lequel est mon blog ? Lequel est mon projet PHP ?</p>
<p>Un nom aléatoire est aussi un vrai problème dès que vous voulez être générique : un script, un alias ou une commande <code>docker exec</code> ne peuvent pas cibler un container dont le nom change à chaque rebuild.</p>
<p>Avec un fichier <code>compose.yaml</code>, il suffirait de définir <code>container_name:</code> pour le container et la clé <code>name:</code> de premier niveau pour regrouper les services — voir <a href="/fr/blog/docker-name-property/">Docker - Comment regrouper vos containers</a>. Un devcontainer construit depuis un simple <code>Dockerfile</code> n'a ni l'une ni l'autre.</p>
<p>Il a mieux : <code>runArgs</code>.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="the-two-lines">Les deux lignes<a href="#the-two-lines" class="hash-link" aria-label="Lien direct vers Les deux lignes" title="Lien direct vers Les deux lignes" translate="no"></a></h2>
<p><code>runArgs</code> est un tableau d'arguments supplémentaires que VS Code passe à <code>docker run</code> au moment de créer le container. N'importe quel flag de <code>docker run</code> y fonctionne, donc :</p>

<p>Reconstruisez le container (<strong>Dev Containers: Rebuild Container</strong> depuis la palette de commandes) et regardez à nouveau Docker Desktop.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="--name-a-stable-container-name"><code>--name</code> : un nom de container stable<a href="#--name-a-stable-container-name" class="hash-link" aria-label="Lien direct vers --name-a-stable-container-name" title="Lien direct vers --name-a-stable-container-name" translate="no"></a></h2>
<p><code>--name=docusaurus-devcontainer</code> est l'équivalent, côté <code>docker run</code>, du <code>container_name:</code> d'un fichier compose. Le container s'appelle désormais toujours <code>docusaurus-devcontainer</code>, donc ceci fonctionne depuis n'importe quel terminal, à chaque rebuild :</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token function" style="color:rgb(220, 220, 170)">docker</span><span class="token plain"> </span><span class="token builtin class-name" style="color:rgb(78, 201, 176)">exec</span><span class="token plain"> </span><span class="token parameter variable" style="color:rgb(156, 220, 254)">-it</span><span class="token plain"> docusaurus-devcontainer </span><span class="token function" style="color:rgb(220, 220, 170)">bash</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token function" style="color:rgb(220, 220, 170)">docker</span><span class="token plain"> logs </span><span class="token parameter variable" style="color:rgb(156, 220, 254)">--follow</span><span class="token plain"> docusaurus-devcontainer</span><br></div></code></pre></div></div>
<div class="alertBox_R1Ot mk-callout caution_LuPi mk-callout--warn" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Un nom, un container</p><div class="content_gFLB"><p>Un nom de container est unique sur l'host Docker. Ouvrez deux fois le même projet (deux clones, un git worktree, ...) et le second <code>docker run</code> échoue avec <em>Conflict. The container name "/docusaurus-devcontainer" is already in use</em>. Utilisez un nom par checkout, par exemple <code>--name=${localWorkspaceFolderBasename}-devcontainer</code>.</p></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="--label-grouping-in-docker-desktop"><code>--label</code> : le regroupement dans Docker Desktop<a href="#--label-grouping-in-docker-desktop" class="hash-link" aria-label="Lien direct vers --label-grouping-in-docker-desktop" title="Lien direct vers --label-grouping-in-docker-desktop" translate="no"></a></h2>
<p>Docker Desktop ne regroupe pas les containers par magie : il lit le label <code>com.docker.compose.project</code> que Docker Compose pose sur chaque container qu'il crée. La clé <code>name:</code> d'un fichier <code>compose.yaml</code> n'est rien d'autre que la valeur de ce label.</p>
<p>Rien ne vous empêche de définir ce label vous-même. Avec <code>--label=com.docker.compose.project=blog-devcontainer</code>, le devcontainer rejoint le groupe <code>blog-devcontainer</code> dans Docker Desktop — et peut être arrêté en même temps que tout autre container portant le même label.</p>
<p>Choisissez la même valeur pour tout ce qui va ensemble, et gardez le suffixe <code>-devcontainer</code> : sur un vrai projet, le groupe <code>blog</code> contient les containers qui font tourner les services (une base de données, un reverse proxy, ...) tandis que <code>blog-devcontainer</code> contient les environnements dans lesquels vous codez. Un coup d'œil à Docker Desktop vous dit qui est qui, et arrêter un groupe n'arrête jamais l'autre.</p>
<div class="alertBox_R1Ot mk-callout info_J1rX mk-callout--info" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Une fonctionnalité de Docker Desktop</p><div class="content_gFLB"><p>Comme la clé <code>name:</code>, le regroupement n'est visible que dans Docker Desktop. <code>docker ps</code> affiche toujours une liste à plat ; filtrez-la avec <code>docker ps --filter label=com.docker.compose.project=blog-devcontainer</code>.</p></div></div></div>
<div class="alertBox_R1Ot mk-callout caution_LuPi mk-callout--warn" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Le label n'est pas un vrai projet compose</p><div class="content_gFLB"><p>Le container <em>ressemble</em> à un élément d'un projet compose, mais il n'a pas été créé par <code>docker compose</code>. Ne lancez pas <code>docker compose -p blog-devcontainer down</code> en espérant un résultat propre : Compose va trouver un container portant son label sans le reste des métadonnées qu'il attend. Gérez le devcontainer via VS Code, comme avant.</p></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="and-if-my-devcontainer-uses-composeyaml">Et si mon devcontainer utilise compose.yaml ?<a href="#and-if-my-devcontainer-uses-composeyaml" class="hash-link" aria-label="Lien direct vers Et si mon devcontainer utilise compose.yaml ?" title="Lien direct vers Et si mon devcontainer utilise compose.yaml ?" translate="no"></a></h2>
<p>Alors vous n'avez pas besoin de <code>runArgs</code> du tout (VS Code l'ignore dans ce mode de toute façon) : définissez <code>container_name:</code> sur le service et la clé <code>name:</code> de premier niveau dans le fichier compose, comme expliqué dans <a href="/fr/blog/docker-name-property/">Docker - Comment regrouper vos containers</a>.</p>
<p><code>runArgs</code> est la réponse pour la configuration la plus courante : un <code>devcontainer.json</code> qui pointe vers une <code>image</code> ou un <code>Dockerfile</code>, comme dans <a href="/fr/blog/vscode-devcontainer/">Développement PHP dans un devcontainer avec des outils de qualité de code préinstallés</a>.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="conclusion">Conclusion<a href="#conclusion" class="hash-link" aria-label="Lien direct vers Conclusion" title="Lien direct vers Conclusion" translate="no"></a></h2>
<p>Deux lignes dans <code>devcontainer.json</code>, et le <code>serene_ganguly</code> aléatoire devient <code>docusaurus-devcontainer</code>, bien rangé sous <code>blog-devcontainer</code> dans Docker Desktop. Je les ajoute maintenant à chaque nouveau devcontainer, juste après la clé <code>name</code>.</p></div><hr></div><div class="relatedPosts_zJiB"><h3>Articles liés</h3></div><div class="container_mNR1"><span class="question_WdqX">Cet article vous a-t-il été utile ?</span><div class="buttons_vgpi"><button class="btn_svQp" aria-label="Oui, cet article m'a été utile"><img src="/fr/img/reaction-mascot-yes.webp" alt="" class="mascot_Fait">Utile</button><button class="btn_svQp btnNeutral_Iqek" aria-label="Non, cet article ne m'a pas été utile"><img src="/fr/img/reaction-mascot-no.webp" alt="" class="mascot_Fait">Pas vraiment</button></div></div><div class="container_IJwe"><span class="question_nb7b">Avez-vous suivi les étapes de cet article ?</span><div class="buttons_Xkzw"><button class="btn_RMiq" aria-label="Oui, j'ai essayé et ça a marché"><img src="/fr/img/like.webp" alt="" class="mascot_wB1d">Ça a marché !</button><button class="btn_RMiq btnNeutral_z71B" aria-label="Non, ça n'a pas marché pour moi"><img src="/fr/img/dislike.webp" alt="" class="mascot_wB1d">Ça n'a pas marché</button></div></div>]]></content:encoded>
            <author>Christophe</author>
            <category>devcontainer</category>
            <category>docker</category>
            <category>vscode</category>
            <enclosure length="0" type="image/webp" url="https://www.avonture.be/fr/img/v2/devcontainer.webp"/>
        <dc:creator><![CDATA[Christophe]]></dc:creator></item>
        <item>
            <title><![CDATA[Docker Build Cache Ate 180 GB — Here's How to Get Them Back]]></title>
            <link>https://www.avonture.be/fr/fr/blog/wsl-docker-vhdx-cleanup</link>
            <guid isPermaLink="false">/fr/blog/wsl-docker-vhdx-cleanup</guid>
            <pubDate>Mon, 02 Nov 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[<img src="https://www.avonture.be/fr/img/v2/docker-shrink-vhdx.webp" alt="Docker Build Cache Ate 180 GB — Here's How to Get Them Back" style="display:block;max-width:100%;height:auto;">Docker's build cache accumulates silently inside WSL2's VHDX virtual disk — and Windows never reclaims that space on its own. Here is the full fix: three common steps, then one compaction method that depends on whether you have local admin rights.]]></description>
            <content:encoded><![CDATA[<div class="offer_jqr1"><span class="flag_Hnep" aria-hidden="true">🇬🇧</span><span>Cet article existe aussi en anglais —<!-- --> <a href="/blog/wsl-docker-vhdx-cleanup/" hreflang="en">lire la version originale</a>.</span></div><div class="alertBox_R1Ot mk-callout caution_LuPi mk-callout--warn" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Traduction automatique</p><div class="content_gFLB">Cette traduction a été produite par une IA. Elle peut contenir des contresens ou des maladresses, en particulier sur les termes techniques. En cas de doute,<!-- --> <a href="/blog/wsl-docker-vhdx-cleanup/">consultez l'article de référence en anglais</a> <!-- -->— c'est lui qui fait foi.</div></div></div><div><div><p><img alt="Le build cache Docker a avalé 180 Go — voici comment les récupérer" src="/fr/assets/images/docker-shrink-vhdx-b346d16c5d300459e6940e2691f47722.webp" height="auto" width="" loading="lazy" decoding="async" class=""></p>
<div class="tldrContainer_qQW_ mk-callout mk-callout--key" role="region" aria-label="Résumé de l'article"><span role="img" aria-label="éclair" class="tldrIcon_SLBz mk-dot">⚡</span><div class="tldrBody_lGty"><strong class="tldrTitle_Aha8 mk-eyebrow">TL;DR</strong><div class="tldrContent_m27u"><p>Le build cache de Docker s'accumule dans le disque virtuel <code>.vhdx</code> de WSL2 — et ce disque ne fait que grossir, il ne rétrécit jamais de lui-même. Le correctif tient en trois étapes à la portée de tous (prune, trim, shutdown), puis une étape de compactage dont la méthode dépend de vos droits d'administrateur local. Sans droits admin, le <em>Reset to factory defaults</em> de Docker Desktop crée un <code>.vhdx</code> tout neuf — aucun ticket IT nécessaire. Avec les droits admin, <code>diskpart</code> ou <code>Optimize-VHD</code> compactent le fichier existant sur place. Dans les deux cas, <code>sparseVhd=true</code> et un prune hebdomadaire planifié empêchent le problème de revenir.</p></div></div></div><nav class="mobileQuickLinks_La_l" aria-label="Articles liés"><p class="title_rsOi mk-eyebrow">Sur le même sujet</p><ul class="list_CXCl"><li class="item_grzi"><a href="/fr/blog/wsl-docker-vhdx-cleanup/">Le build cache Docker a avalé 180 Go — voici comment les récupérer</a></li><li class="item_grzi"><a href="/fr/blog/ripgrep/">ripgrep — l'outil de recherche qui a changé mon workflow WSL2</a></li><li class="item_grzi"><a href="/fr/blog/windows-terminal-ssh-profile/">Windows Terminal - Ajouter un profil SSH</a></li></ul></nav>
<p>Windows affiche 2 Go libres. Rien côté Windows ne l'explique — pas de gros téléchargements, pas d'installation récente. Le coupable est un fichier qui n'apparaît pas dans l'analyse d'espace disque de l'Explorateur : <code>docker_data.vhdx</code>, le disque virtuel où Docker Desktop stocke tout ce qu'il construit.</p>
<p><code>docker system df</code> révèle la situation réelle :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb">TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Images          149       15        94.53GB   61.71GB (65%)
Containers      15        2         5.092GB   3.765GB (73%)
Local Volumes   67        41        26.76GB   4.667GB (17%)
Build Cache     2842      0         180.4GB   109.3GB (60%)
</pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>2 842 entrées de build cache, 180 Go, accumulées silencieusement au fil de mois d'utilisation normale de Docker.</p>
<p>Avant toute chose, vérifiez la taille réelle du fichier <code>.vhdx</code> sur le disque. Ouvrez PowerShell et lancez :</p>
<div class="language-powershell codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-powershell codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">Get-ChildItem "$env:LOCALAPPDATA\Docker\wsl\disk\" -Filter "*.vhdx" |</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    Select-Object Name, FullName, @{n='SizeGB';e={[math]::Round($_.Length/1GB,2)}}</span><br></div></code></pre></div></div>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb">Name               FullName                                                                SizeGB
----               --------                                                                ------
docker_data.vhdx   C:\Users\christophe\AppData\Local\Docker\wsl\disk\docker_data.vhdx     288.45
</pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>La colonne <code>FullName</code> donne le chemin exact dont vous aurez besoin à l'étape de compactage. <code>SizeGB</code> est le nombre à surveiller — 288 Go ici, pour un contenu Docker réel d'environ 90 à 110 Go.</p>
<p>Si le dossier <code>disk\</code> est vide, ouvrez Docker Desktop → l'icône d'engrenage → <strong>Resources → Advanced</strong> : le champ <em>Disk image location</em> indique le dossier parent ; le VHDX se trouve un niveau plus bas, dans le sous-dossier <code>disk</code>.</p>
<p><img alt="Docker Desktop Resources → Advanced — le champ Disk image location indique le dossier parent de docker_data.vhdx" src="/fr/assets/images/docker_resources-f3cf983615f6f9bf3f07836e1c19880f.webp" height="auto" width="" loading="lazy" decoding="async" class=""></p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="after-the-fix">Après le correctif<a href="#after-the-fix" class="hash-link" aria-label="Lien direct vers Après le correctif" title="Lien direct vers Après le correctif" translate="no"></a></h2>
<p>Une fois le cache nettoyé et le VHDX compacté, <code>docker system df</code> est propre :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb">TYPE            TOTAL     ACTIVE    SIZE      RECLAIMABLE
Images          149       15        94.53GB   61.71GB (65%)
Containers      15        2         5.094GB   3.765GB (73%)
Local Volumes   67        41        26.76GB   4.667GB (17%)
Build Cache     0         0         0B        0B
</pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Et <code>docker_data.vhdx</code>, qui pesait 288 Go dans l'Explorateur Windows, est revenu à son contenu réel. L'Explorateur reflète le changement immédiatement après le compactage.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="why-pruning-alone-is-not-enough">Pourquoi le prune seul ne suffit pas<a href="#why-pruning-alone-is-not-enough" class="hash-link" aria-label="Lien direct vers Pourquoi le prune seul ne suffit pas" title="Lien direct vers Pourquoi le prune seul ne suffit pas" translate="no"></a></h2>
<p>Quatre faits expliquent l'écart entre « Docker a libéré 180 Go » et « Windows affiche toujours 2 Go libres » :</p>
<ul>
<li class=""><strong>WSL2 stocke toutes les données Docker dans un seul fichier <code>.vhdx</code>.</strong> Images, containers, volumes, build cache — tout vit dans <code>docker_data.vhdx</code>. Docker n'écrit pas directement sur <code>C:\</code>.</li>
<li class=""><strong>Le VHDX ne fait que grossir.</strong> Quand Docker ajoute des données, le fichier grandit. Quand Docker supprime des données, les blocs sont marqués libres <em>à l'intérieur</em> du système de fichiers virtuel, mais le fichier <code>.vhdx</code> garde la même taille physique.</li>
<li class=""><strong><code>docker builder prune</code> libère des blocs ; il ne compacte pas le disque.</strong> Après le prune, 180 Go sont disponibles dans le système de fichiers virtuel. Windows voit toujours le même fichier de 288 Go.</li>
<li class=""><strong><code>fstrim</code> est le pont.</strong> Après un prune, les blocs libérés dans ext4 ne sont pas remis à zéro — ils sont simplement marqués non alloués dans le système de fichiers. La couche <code>.vhdx</code> n'a aucun moyen de savoir que ces blocs sont vides tant que <code>fstrim</code> ne le signale pas explicitement. Sans <code>fstrim</code>, l'étape de compactage ne trouve rien à supprimer et le fichier ne rétrécit pas.</li>
</ul>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="step-1--clear-the-build-cache">Étape 1 — Vider le build cache<a href="#step-1--clear-the-build-cache" class="hash-link" aria-label="Lien direct vers Étape 1 — Vider le build cache" title="Lien direct vers Étape 1 — Vider le build cache" translate="no"></a></h2>
<p>Lancez <code>docker system df</code> pour confirmer que la ligne Build Cache est bien le principal responsable, puis nettoyez-la :</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token function" style="color:rgb(220, 220, 170)">docker</span><span class="token plain"> builder prune </span><span class="token parameter variable" style="color:rgb(156, 220, 254)">-a</span><span class="token plain"> </span><span class="token parameter variable" style="color:rgb(156, 220, 254)">-f</span><br></div></code></pre></div></div>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb">ID                                              RECLAIMABLE     SIZE            LAST ACCESSED
9v69gh88n3lsd778zxygzi6oj*                      true            11.6MB          2 months ago
ckqqh9pmzysj5bba2598kw58o*                      true            54.49MB         7 weeks ago
mrriwuzhxnbf09fvs6ezamzi6*                      true            15.51MB         2 months ago
[... 2839 more entries ...]
oneg7c4vr96xkpwb28nqgqwa6                       true            435.5MB         Less than a second ago
08or6idagqqm3fi66y48c3aft                       true            104.3MB         Less than a second ago
hq7jnob1j3bnb0n6nylra53a9                       true            113.5MB         Less than a second ago
cilxc42q905svkob16oiapvfd                       true            28.23MB         Less than a second ago
Total:  180.4GB
</pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Le flag <code>-a</code> supprime toutes les entrées du cache, y compris celles qui pourraient encore accélérer un rebuild. Le flag <code>-f</code> saute la demande de confirmation.</p>
<p>Les images et les volumes peuvent eux aussi s'accumuler à côté du cache :</p>
<ul>
<li class=""><code>docker image prune -a</code> — supprime toute image non référencée par un container en cours d'exécution ou arrêté.</li>
<li class=""><code>docker container prune</code> — supprime les containers arrêtés. Sans risque si vous utilisez <code>--rm</code> par défaut.</li>
<li class=""><code>docker volume prune</code> — supprime les volumes qui ne sont attachés à aucun container.</li>
</ul>
<div class="alertBox_R1Ot mk-callout warning_xBFJ mk-callout--warn" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Les volumes contiennent des données non reconstructibles</p><div class="content_gFLB"><p>Les données d'un <a href="/fr/blog/docker-volumes/">volume nommé</a> ne peuvent pas être reconstruites à partir d'un Dockerfile. Si un volume contient une base de données, des fichiers uploadés ou l'état persistant d'un environnement de dev, <code>docker volume prune</code> le supprime définitivement. Lancez <code>docker volume ls</code> et vérifiez chaque volume avant de nettoyer.</p></div></div></div>
<p>Relancez <code>docker system df</code> pour confirmer que la ligne Build Cache affiche <code>0B</code> avant de continuer.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="step-2--tell-the-vhdx-which-blocks-are-free-fstrim">Étape 2 — Indiquer au VHDX quels blocs sont libres (fstrim)<a href="#step-2--tell-the-vhdx-which-blocks-are-free-fstrim" class="hash-link" aria-label="Lien direct vers Étape 2 — Indiquer au VHDX quels blocs sont libres (fstrim)" title="Lien direct vers Étape 2 — Indiquer au VHDX quels blocs sont libres (fstrim)" translate="no"></a></h2>
<p>Docker sait que les blocs sont libres. Le <code>.vhdx</code>, non. <code>fstrim</code> perce des trous dans le disque virtuel, en signalant quels blocs sont désormais vides pour que l'étape de compactage puisse les supprimer physiquement :</p>
<div class="language-powershell codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-powershell codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">docker run --rm --privileged --pid=host alpine nsenter -t 1 -m -- fstrim -av</span><br></div></code></pre></div></div>
<p>Lancez ceci dans PowerShell ou Windows Terminal — <strong>pas dans WSL</strong>. La commande démarre un container, entre dans le namespace root de la VM Docker Desktop et exécute <code>fstrim</code> sur tous les systèmes de fichiers montés.</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb">/parent-distro/mnt/host/wsl/docker-desktop-bind-mounts/Ubuntu-24.04/a3f7c91b2e8d05f634a8c7d9e1b4f2a8c6d3e5f7a9b1c3d5e7f9a1b3c5d7e9f1: 987.1 GiB (1059882524672 bytes) trimmed on /dev/sdf
/mnt/docker-desktop-disk: 6.1 GiB (6548037632 bytes) trimmed on /dev/sdd
/parent-distro: 48 MiB (50331648 bytes) trimmed on /dev/sde
</pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<div class="alertBox_R1Ot mk-callout tip_shCb mk-callout--tip" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Les chiffres de trim peuvent dépasser la taille de votre disque — c'est normal</p><div class="content_gFLB"><p>Un VHDX a une taille <em>virtuelle</em> (ce que voit la VM, souvent 1 To ou plus) et une taille <em>physique</em> (le fichier réel sur Windows, ici 288 Go). <code>fstrim -av</code> parcourt tout l'espace virtuel et rapporte les discards sur ce volume logique. L'espace réellement rendu à Windows n'apparaît qu'après l'étape de compactage.</p></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="step-3--shut-down-docker-and-wsl">Étape 3 — Arrêter Docker et WSL<a href="#step-3--shut-down-docker-and-wsl" class="hash-link" aria-label="Lien direct vers Étape 3 — Arrêter Docker et WSL" title="Lien direct vers Étape 3 — Arrêter Docker et WSL" translate="no"></a></h2>
<p>Sauvegardez votre travail en cours dans VSCode, puis quittez Docker Desktop depuis la zone de notification (clic droit → <em>Quit Docker Desktop</em>).</p>
<p>Attendez que le processus Docker Desktop ait complètement disparu :</p>
<div class="language-powershell codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-powershell codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">Get-Process "Docker Desktop" -ErrorAction SilentlyContinue</span><br></div></code></pre></div></div>
<p>Relancez cette commande jusqu'à ce qu'elle ne renvoie plus rien — une sortie vide signifie que le processus s'est terminé. Vérifiez ensuite WSL :</p>
<div class="language-powershell codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-powershell codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">wsl --list --running</span><br></div></code></pre></div></div>
<p>Si la liste n'est pas vide :</p>
<div class="language-powershell codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-powershell codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">wsl --shutdown</span><br></div></code></pre></div></div>
<p>La sortie attendue de <code>wsl --list --running</code> ensuite est <code>There are no running distributions.</code></p>
<div class="alertBox_R1Ot mk-callout danger_tlW9 mk-callout--danger" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Arrêtez Docker Desktop avant wsl --shutdown</p><div class="content_gFLB"><p>Docker Desktop gère plusieurs distributions WSL en interne. Appeler <code>wsl --shutdown</code> pendant que Docker Desktop est encore en train de s'arrêter laisse le service WSL dans un état cassé. Le seul remède est un redémarrage complet de Windows. Quittez toujours Docker Desktop d'abord et attendez que <code>Get-Process "Docker Desktop"</code> ne renvoie plus rien.</p></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="compact-the-vhdx--choose-your-path">Compacter le VHDX — choisissez votre voie<a href="#compact-the-vhdx--choose-your-path" class="hash-link" aria-label="Lien direct vers Compacter le VHDX — choisissez votre voie" title="Lien direct vers Compacter le VHDX — choisissez votre voie" translate="no"></a></h2>
<p>Les étapes 1 à 3 sont les mêmes pour tout le monde. L'étape de compactage dépend de vos droits sur la machine.</p>
<nav class="quickJump_q5pQ" aria-label="Votre situation"><span class="title_Njuu mk-eyebrow">Votre situation</span><ul class="list_A_9v"><li class="item_gTjz"><a href="#compact-admin">J'ai les droits administrateur local</a></li><li class="item_gTjz"><a href="#compact-non-admin">Je n'ai pas les droits administrateur local</a></li></ul></nav>
<p>J'ai rencontré ce problème sur mon portable professionnel, où je n'ai pas les droits admin local. Mon collègue IT a pris la main à distance et a lancé la séquence <code>diskpart</code> du chemin admin. Une fois déconnecté, j'ai suivi le chemin non-admin pour mettre en place les garde-fous — histoire que ça ne se reproduise pas.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="compact-admin">J'ai les droits administrateur local<a href="#compact-admin" class="hash-link" aria-label="Lien direct vers J'ai les droits administrateur local" title="Lien direct vers J'ai les droits administrateur local" translate="no"></a></h3>
<div class="alertBox_R1Ot mk-callout danger_tlW9 mk-callout--danger" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Docker Desktop et WSL doivent être complètement arrêtés</p><div class="content_gFLB"><p>Compacter un fichier VHDX ouvert le corrompt. Vérifiez que <code>wsl --list --running</code> renvoie <code>There are no running distributions.</code> et que Docker Desktop n'est plus visible dans la zone de notification avant de lancer quoi que ce soit ci-dessous.</p></div></div></div>
<p>Vérifiez d'abord si le module PowerShell Hyper-V est disponible sur votre machine :</p>
<div class="language-powershell codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-powershell codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">Get-Module -ListAvailable Hyper-V</span><br></div></code></pre></div></div>
<h4 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="option-a--optimize-vhd-no-diskpart-needed">Option A — Optimize-VHD (sans diskpart)<a href="#option-a--optimize-vhd-no-diskpart-needed" class="hash-link" aria-label="Lien direct vers Option A — Optimize-VHD (sans diskpart)" title="Lien direct vers Option A — Optimize-VHD (sans diskpart)" translate="no"></a></h4>
<p>Si la commande a renvoyé une entrée de module :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>Optimize-VHD -Path "<span class="token_SjcP">C:\Users\your-name\AppData\Local\Docker\wsl\disk\docker_data.vhdx</span>" -Mode Full</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Sur certaines machines d'entreprise, <code>Optimize-VHD</code> exige quand même l'appartenance au groupe Hyper-V Administrators, même si le module est présent. En cas d'erreur « Access denied », passez à l'option B.</p>
<h4 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="option-b--diskpart">Option B — diskpart<a href="#option-b--diskpart" class="hash-link" aria-label="Lien direct vers Option B — diskpart" title="Lien direct vers Option B — diskpart" translate="no"></a></h4>
<p>Ouvrez un PowerShell élevé (clic droit → <em>Exécuter en tant qu'administrateur</em>), tapez <code>diskpart</code> puis lancez les quatre commandes une par une :</p>
<div class="language-powershell codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-powershell codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">diskpart</span><br></div></code></pre></div></div>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>select vdisk file="<span class="token_SjcP">C:\Users\your-name\AppData\Local\Docker\wsl\disk\docker_data.vhdx</span>"
attach vdisk readonly
compact vdisk
detach vdisk
exit</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p><code>attach vdisk readonly</code> ouvre le fichier en lecture seule — aucune donnée n'est modifiée. L'étape <code>compact</code> affiche un pourcentage de progression qui grimpe jusqu'à 100 %. Comptez de quelques minutes sur un NVMe rapide à plus d'une heure sur un gros disque lent.</p>
<p>Une fois le compactage terminé, le fichier <code>.vhdx</code> rétrécit et l'Explorateur affiche immédiatement les gigaoctets récupérés. Démarrez Docker Desktop : tout fonctionne exactement comme avant.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="compact-non-admin">Je n'ai pas les droits administrateur local<a href="#compact-non-admin" class="hash-link" aria-label="Lien direct vers Je n'ai pas les droits administrateur local" title="Lien direct vers Je n'ai pas les droits administrateur local" translate="no"></a></h3>
<p>Sans droits admin local, ni <code>diskpart</code> ni <code>Optimize-VHD</code> ne sont directement accessibles. Les deux approches ci-dessous partent du panneau <strong>Troubleshoot</strong> de Docker Desktop (icône d'engrenage → Troubleshoot) :</p>
<p><img alt="Panneau Troubleshoot de Docker Desktop — Clean up data supprime les objets Docker sélectionnés ; Reset to factory defaults recrée le VHDX de zéro" src="/fr/assets/images/docker_troubleshoot-ab707b2b27b69b7bb05c6fdc0e4b8603.webp" height="auto" width="" loading="lazy" decoding="async" class=""></p>
<p>Si l'IT peut vous accorder cinq minutes, envoyez à votre collègue les quatre commandes <code>diskpart</code> du <a href="#compact-admin" class="">chemin admin ci-dessus</a> accompagnées du chemin <code>.vhdx</code> exact obtenu à l'étape de diagnostic. La séquence est sûre et réversible : <code>attach vdisk readonly</code> ouvre le fichier en lecture seule, <code>compact vdisk</code> supprime les blocs vides, <code>detach vdisk</code> le relâche.</p>
<p>Sinon, le <em>Reset to factory defaults</em> de Docker Desktop supprime <code>docker_data.vhdx</code> et en crée un neuf. Avec <code>sparseVhd=true</code> déjà défini dans <code>.wslconfig</code> (voir la section <a href="#prevention" class="">Prévention</a>), le nouveau fichier se compactera automatiquement à mesure que Docker libère de l'espace — ce qui en fait une opération unique.</p>
<div class="alertBox_R1Ot mk-callout danger_tlW9 mk-callout--danger" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Le reset usine supprime toutes les données Docker locales</p><div class="content_gFLB"><p>Images, containers et volumes sont supprimés. Tout ce qui provient d'un registry public se reconstruit avec <code>docker compose up</code>. Sauvegardez les volumes nommés contenant des données importantes avant de continuer.</p></div></div></div>
<h4 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="before-the-reset--save-what-you-cannot-rebuild">Avant le reset — sauvegardez ce que vous ne pouvez pas reconstruire<a href="#before-the-reset--save-what-you-cannot-rebuild" class="hash-link" aria-label="Lien direct vers Avant le reset — sauvegardez ce que vous ne pouvez pas reconstruire" title="Lien direct vers Avant le reset — sauvegardez ce que vous ne pouvez pas reconstruire" translate="no"></a></h4>
<p>Les volumes nommés contenant des données importantes :</p>
<div class="language-powershell codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-powershell codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">docker volume ls</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">docker run --rm `</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  -v &lt;volume-name&gt;:/data `</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  -v C:\docker-backup:/backup `</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  alpine tar czf /backup/&lt;volume-name&gt;.tar.gz -C /data .</span><br></div></code></pre></div></div>
<p>Les images provenant de registries publics et les containers arrêtés n'ont pas besoin d'être sauvegardés — ils se reconstruisent en quelques minutes depuis leurs Dockerfiles. La seule exception est une image créée avec <code>docker commit</code> (modifications à l'exécution capturées sans Dockerfile) ; si vous en avez une, sauvegardez-la avec <code>docker save &lt;image&gt;:&lt;tag&gt; -o backup.tar</code> avant de continuer.</p>
<h4 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="run-the-reset">Lancer le reset<a href="#run-the-reset" class="hash-link" aria-label="Lien direct vers Lancer le reset" title="Lien direct vers Lancer le reset" translate="no"></a></h4>
<p>Docker Desktop → l'icône d'engrenage → <strong>Troubleshoot</strong> → <strong>Reset to factory defaults</strong>.</p>
<p>Après le reset, vérifiez que le nouveau <code>.vhdx</code> est bien sparse :</p>
<div class="language-powershell codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-powershell codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">fsutil sparse queryflag "$env:LOCALAPPDATA\Docker\wsl\disk\docker_data.vhdx"</span><br></div></code></pre></div></div>
<p>La sortie attendue est <code>This file is set as sparse</code>. Si elle indique toujours <code>NOT set as sparse</code>, c'est que <code>sparseVhd=true</code> ne s'applique pas au disque de données de Docker Desktop dans votre version — le prune hebdomadaire ci-dessous empêche le fichier de grossir, et un compactage ponctuel par l'IT (le <a href="#compact-admin" class="">chemin admin</a>) reste possible si vous avez besoin de récupérer l'espace.</p>
<h4 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="restore-your-saved-data">Restaurer vos données sauvegardées<a href="#restore-your-saved-data" class="hash-link" aria-label="Lien direct vers Restaurer vos données sauvegardées" title="Lien direct vers Restaurer vos données sauvegardées" translate="no"></a></h4>
<div class="language-powershell codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-powershell codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">docker load -i "C:\docker-backup\&lt;image&gt;.tar"</span><br></div></code></pre></div></div>
<div class="language-powershell codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-powershell codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">docker volume create &lt;volume-name&gt;</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">docker run --rm `</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  -v &lt;volume-name&gt;:/data `</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  -v C:\docker-backup:/backup `</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  alpine tar xzf /backup/&lt;volume-name&gt;.tar.gz -C /data</span><br></div></code></pre></div></div>
<p>Reconstruisez tout le reste avec <code>docker compose up</code> dans chaque dossier de projet.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="prevention">Prévention<a href="#prevention" class="hash-link" aria-label="Lien direct vers Prévention" title="Lien direct vers Prévention" translate="no"></a></h2>
<p>Le correctif fonctionne, mais sans action le <code>.vhdx</code> regrossit en quelques mois. Trois niveaux de défense, de l'immédiat au permanent.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="1-enable-sparsevhd">1. Activer sparseVhd<a href="#1-enable-sparsevhd" class="hash-link" aria-label="Lien direct vers 1. Activer sparseVhd" title="Lien direct vers 1. Activer sparseVhd" translate="no"></a></h3>
<p>Depuis WSL 1.3.17 (mi-2024), Microsoft a ajouté <code>sparseVhd</code> à <code>.wslconfig</code> :</p>
<div class="language-ini codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-ini codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token section punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token section section-name selector" style="color:rgb(215, 186, 125)">experimental</span><span class="token section punctuation" style="color:rgb(212, 212, 212)">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token key attr-name" style="color:rgb(156, 220, 254)">sparseVhd</span><span class="token punctuation" style="color:rgb(212, 212, 212)">=</span><span class="token value attr-value" style="color:rgb(206, 145, 120)">true</span><br></div></code></pre></div></div>
<p>Éditez <code>C:\Users\&lt;your-name&gt;\.wslconfig</code> et ajoutez ces deux lignes. Un VHDX sparse rend automatiquement à Windows les blocs libérés au fur et à mesure que Docker les supprime — plus besoin de compactage manuel par la suite.</p>
<p>Ce réglage ne concerne que les disques nouvellement créés. Si votre <code>docker_data.vhdx</code> actuel n'est pas déjà sparse (vérifiez avec <code>fsutil sparse queryflag</code>), il n'a aucun effet dessus. Laissez quand même le réglage dans <code>.wslconfig</code> : après un reset usine ou l'ajout d'une nouvelle distribution WSL, le nouveau disque en profitera automatiquement.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="2-daily-size-check-in-your-terminal">2. Contrôle quotidien de la taille dans votre terminal<a href="#2-daily-size-check-in-your-terminal" class="hash-link" aria-label="Lien direct vers 2. Contrôle quotidien de la taille dans votre terminal" title="Lien direct vers 2. Contrôle quotidien de la taille dans votre terminal" translate="no"></a></h3>
<p>Ajoutez cette fonction à votre <code>.bashrc</code> ou <code>.zshrc</code> — elle affiche un avertissement d'une ligne à la première ouverture de terminal de la journée lorsque le <code>.vhdx</code> dépasse 150 Go :</p>

<p>Le fichier témoin daté dans <code>~/.cache/</code> garantit que le contrôle ne tourne qu'une fois par jour. Ajustez le seuil de <code>150</code> selon votre situation disque.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="3-weekly-automatic-prune-via-windows-task-scheduler">3. Prune automatique hebdomadaire via le Planificateur de tâches Windows<a href="#3-weekly-automatic-prune-via-windows-task-scheduler" class="hash-link" aria-label="Lien direct vers 3. Prune automatique hebdomadaire via le Planificateur de tâches Windows" title="Lien direct vers 3. Prune automatique hebdomadaire via le Planificateur de tâches Windows" translate="no"></a></h3>
<p>Le Planificateur de tâches Windows peut lancer <code>docker builder prune</code> automatiquement — une tâche au niveau utilisateur ne demande pas de droits admin. Ouvrez PowerShell avec votre compte habituel :</p>
<div class="language-powershell codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-powershell codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">$action = New-ScheduledTaskAction `</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    -Execute "powershell.exe" `</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    -Argument '-NoProfile -NonInteractive -Command "wsl -e docker builder prune -a -f --filter ''until=168h''"'</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">$trigger = New-ScheduledTaskTrigger -Weekly -DaysOfWeek Monday -At "08:00"</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">Register-ScheduledTask -TaskName "Docker-Weekly-Prune" -Action $action -Trigger $trigger</span><br></div></code></pre></div></div>
<p><code>wsl -e docker</code> exécute la CLI <code>docker</code> dans votre distribution WSL par défaut : cette distribution doit donc avoir l'intégration WSL de Docker Desktop activée (icône d'engrenage → <strong>Resources → WSL integration</strong>) ; sinon la tâche échoue sans aucune erreur visible.</p>
<p>Le flag <code>--filter "until=168h"</code> conserve les entrées de cache de moins d'une semaine, préservant la vitesse de rebuild des projets actifs tout en supprimant tout ce qui est plus ancien.</p>
<h4 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="verify-and-manage-the-task">Vérifier et gérer la tâche<a href="#verify-and-manage-the-task" class="hash-link" aria-label="Lien direct vers Vérifier et gérer la tâche" title="Lien direct vers Vérifier et gérer la tâche" translate="no"></a></h4>
<p>Confirmez que la tâche est bien enregistrée :</p>
<div class="language-powershell codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-powershell codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">Get-ScheduledTask -TaskName "Docker-Weekly-Prune"</span><br></div></code></pre></div></div>
<p>Sortie attendue : <code>State: Ready</code>. Inspectez ensuite le déclencheur et l'action :</p>
<div class="language-powershell codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-powershell codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">$task = Get-ScheduledTask -TaskName "Docker-Weekly-Prune"</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">$task.Triggers</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">$task.Actions</span><br></div></code></pre></div></div>
<p>Lancez-la tout de suite pour tester :</p>
<div class="language-powershell codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-powershell codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">Start-ScheduledTask -TaskName "Docker-Weekly-Prune"</span><br></div></code></pre></div></div>
<p>Attendez 10 à 15 secondes le temps que WSL démarre et que Docker réponde, puis vérifiez le résultat :</p>
<div class="language-powershell codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-powershell codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">Get-ScheduledTask -TaskName "Docker-Weekly-Prune" | Get-ScheduledTaskInfo |</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    Select-Object LastRunTime, LastTaskResult, NextRunTime</span><br></div></code></pre></div></div>
<p><code>LastTaskResult: 0</code> signifie succès. La valeur <code>267011</code> (<code>SCHED_S_TASK_NOT_SCHEDULED</code>) est la valeur par défaut de Windows avant la première exécution d'une tâche — ce n'est pas une erreur.</p>
<div class="alertBox_R1Ot mk-callout tip_shCb mk-callout--tip" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">La tâche peut ne rien nettoyer lors du premier passage</p><div class="content_gFLB"><p><code>--filter "until=168h"</code> protège tout ce qui a été créé ou utilisé durant les 7 derniers jours. Sur une machine active où vous buildez régulièrement, la plupart des entrées de cache tombent dans cette fenêtre et la tâche se termine proprement sans rien supprimer. C'est le comportement voulu : cela évite l'accumulation sur le long terme sans casser les rebuilds incrémentaux. <code>docker system df</code> indique séparément l'espace récupérable pour les images et les volumes — ceux-ci ne sont pas touchés par cette tâche.</p></div></div></div>
<p>Pour ouvrir la tâche dans l'interface graphique du Planificateur de tâches :</p>
<div class="language-powershell codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-powershell codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">Start-Process taskschd.msc</span><br></div></code></pre></div></div>
<p>Cliquez directement sur <strong>Bibliothèque du Planificateur de tâches</strong> dans le panneau de gauche (pas sur un de ses sous-dossiers — la tâche se trouve à la racine <code>\</code>, pas dans GoogleSystem, Microsoft ou un sous-dossier d'entreprise). Le panneau central liste toutes les tâches de la racine ; trouvez <em>Docker-Weekly-Prune</em>, clic droit → <em>Propriétés</em> pour modifier le déclencheur ou la commande. L'onglet <em>Historique</em> montre chaque exécution passée et son code de sortie.</p>
<p><img alt="Planificateur de tâches — Docker-Weekly-Prune à la racine, état Prêt, déclencheur « À 8:00 chaque lundi », onglet Actions affichant la commande powershell.exe complète" src="/fr/assets/images/task-scheduler-docker-3cb12524c4e61f496bff2ebd75146023.webp" height="auto" width="" loading="lazy" decoding="async" class=""></p>
<p><em>Petite remarque : si l'interface du Planificateur de tâches vous semble être une relique de l'ère Windows NT — dense, modale, rien là où on s'y attend — c'est parce qu'elle l'est, en gros. Les cmdlets PowerShell ci-dessus font le travail plus vite.</em></p>
<div class="alertBox_R1Ot mk-callout tip_shCb mk-callout--tip" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">taskschd.msc bloqué sur votre machine ?</p><div class="content_gFLB"><p>Certaines configurations de stratégie de groupe en entreprise restreignent l'accès à l'interface du Planificateur de tâches. Cette restriction ne concerne que le composant MMC — elle n'affecte pas la tâche elle-même, qui s'exécutera quand même comme prévu. Utilisez les cmdlets PowerShell ci-dessus pour l'inspecter et la gérer.</p></div></div></div>
<p>Pour faire passer la fenêtre de rétention de 7 à, disons, 14 jours :</p>
<div class="language-powershell codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-powershell codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">$action = New-ScheduledTaskAction `</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    -Execute "powershell.exe" `</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    -Argument '-NoProfile -NonInteractive -Command "wsl -e docker builder prune -a -f --filter ''until=336h''"'</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">Set-ScheduledTask -TaskName "Docker-Weekly-Prune" -Action $action</span><br></div></code></pre></div></div>
<p>Pour supprimer complètement la tâche :</p>
<div class="language-powershell codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-powershell codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">Unregister-ScheduledTask -TaskName "Docker-Weekly-Prune" -Confirm:$false</span><br></div></code></pre></div></div>
<div class="alertBox_R1Ot mk-callout tip_shCb mk-callout--tip" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Le prune ne compacte pas — vérifiez la taille de temps en temps</p><div class="content_gFLB"><p>Le prune planifié empêche le cache de s'accumuler, mais il ne compacte pas le <code>.vhdx</code>. Avec <code>sparseVhd=true</code> sur un nouveau disque sparse, l'OS récupère automatiquement les blocs libérés. Sans support sparse sur le disque existant, lancez un compactage manuel tous les quelques mois, après un gros prune.</p></div></div></div>
<p>Désormais, chaque lundi matin, la tâche se réveille, se connecte à WSL, vide du build cache tout ce qui a plus d'une semaine, et se termine — aucune action manuelle requise.</p>
<p>Savoir si <code>fstrim</code> reste nécessaire tient à une seule chose : lancez <code>fsutil sparse queryflag "$env:LOCALAPPDATA\Docker\wsl\disk\docker_data.vhdx"</code> dans PowerShell.</p>
<ul>
<li class=""><strong><code>This file is set as sparse</code></strong> — Windows rend automatiquement les blocs libérés au disque. <code>fstrim</code> n'est plus jamais nécessaire.</li>
<li class=""><strong><code>NOT set as sparse</code></strong> — l'espace libéré reste dans le <code>.vhdx</code> mais Docker le réutilise pour les builds suivants. Le fichier ne grossit plus, Windows arrête de se plaindre, et c'est un résultat parfaitement stable. Un compactage manuel (étapes 2–3 plus le compactage) reste une option si vous voulez un jour rendre physiquement ces gigaoctets à Windows, mais ce n'est plus urgent.</li>
</ul>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="if-something-goes-wrong">En cas de problème<a href="#if-something-goes-wrong" class="hash-link" aria-label="Lien direct vers En cas de problème" title="Lien direct vers En cas de problème" translate="no"></a></h2>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="wslservicee_unexpected--the-wsl-service-is-stuck"><code>Wsl/Service/E_UNEXPECTED</code> — le service WSL est bloqué<a href="#wslservicee_unexpected--the-wsl-service-is-stuck" class="hash-link" aria-label="Lien direct vers wslservicee_unexpected--the-wsl-service-is-stuck" title="Lien direct vers wslservicee_unexpected--the-wsl-service-is-stuck" translate="no"></a></h3>
<p>Cette erreur apparaît quand <code>wsl --shutdown</code> a été appelé alors que Docker Desktop était encore en train de s'arrêter. <strong>La solution est un redémarrage complet de Windows.</strong> Aucune commande WSL n'aidera à ce stade. Après le redémarrage, votre distribution démarre normalement.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="verify-your-vhdx-files-after-a-reboot">Vérifier vos fichiers VHDX après un redémarrage<a href="#verify-your-vhdx-files-after-a-reboot" class="hash-link" aria-label="Lien direct vers Vérifier vos fichiers VHDX après un redémarrage" title="Lien direct vers Vérifier vos fichiers VHDX après un redémarrage" translate="no"></a></h3>
<p>Vérifiez que les fichiers VHDX sont intacts avant de relancer Docker Desktop :</p>
<div class="language-powershell codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-powershell codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">Get-ChildItem HKCU:\Software\Microsoft\Windows\CurrentVersion\Lxss |</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  ForEach-Object { Get-ItemProperty $_.PSPath } |</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  Select-Object DistributionName, BasePath</span><br></div></code></pre></div></div>
<p>Pour le disque de données de Docker Desktop en particulier :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb">Get-Item "<span class="token_SjcP">C:\Users\your-name\AppData\Local\Docker\wsl\disk\docker_data.vhdx</span>" |
  Select-Object Name, @{n='SizeGB';e={[math]::Round($_.Length/1GB,2)}}
</pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Une taille de fichier non nulle et un Docker Desktop qui démarre normalement confirment que rien n'est corrompu.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="conclusion">Conclusion<a href="#conclusion" class="hash-link" aria-label="Lien direct vers Conclusion" title="Lien direct vers Conclusion" translate="no"></a></h2>
<p>Le build cache Docker s'accumule silencieusement et Windows ne récupère jamais l'espace de lui-même. Trois étapes — prune, trim, shutdown — sont identiques pour tout le monde. La méthode de compactage dépend ensuite d'une seule question : avez-vous les droits admin local ? Avec les droits admin, <code>diskpart</code> ou <code>Optimize-VHD</code> compactent le fichier existant sur place. Sans droits admin, <em>Reset to factory defaults</em> dans Docker Desktop crée un <code>.vhdx</code> tout neuf — aucun droit IT nécessaire pour le reset lui-même, uniquement pour le compactage ponctuel si vous voulez éviter la perte de données. Dans les deux cas, <code>sparseVhd=true</code> et un prune hebdomadaire planifié empêchent le problème de revenir.</p>
<p>Pour une vue d'ensemble de ce qui s'accumule avant de décider quoi nettoyer, <a href="/fr/blog/lazydocker/">lazydocker</a> affiche chaque container, image et volume d'un coup d'œil depuis un seul panneau de terminal. Les <a href="/fr/blog/zsh-docker-functions/">fonctions ZSH pour Docker</a> que j'utilise au quotidien — <code>dex</code>, <code>dstop</code>, <code>dnuke</code> — rendent la question « qu'est-ce qu'on peut nettoyer sans risque ? » triviale : <code>dnuke</code> démonte tout, volumes compris, pour un projet terminé, en une seule commande. Et si le problème de disque est plus profond que le cache — c'est la distribution WSL elle-même qui doit migrer vers un disque plus grand — <a href="/fr/blog/move-wsl-to-another-location/">déplacer une distribution WSL vers un autre emplacement</a> détaille la méthode export-import.</p></div><hr></div><div class="relatedPosts_zJiB"><h3>Articles liés</h3></div><div class="container_mNR1"><span class="question_WdqX">Cet article vous a-t-il été utile ?</span><div class="buttons_vgpi"><button class="btn_svQp" aria-label="Oui, cet article m'a été utile"><img src="/fr/img/reaction-mascot-yes.webp" alt="" class="mascot_Fait">Utile</button><button class="btn_svQp btnNeutral_Iqek" aria-label="Non, cet article ne m'a pas été utile"><img src="/fr/img/reaction-mascot-no.webp" alt="" class="mascot_Fait">Pas vraiment</button></div></div><div class="container_IJwe"><span class="question_nb7b">Avez-vous suivi les étapes de cet article ?</span><div class="buttons_Xkzw"><button class="btn_RMiq" aria-label="Oui, j'ai essayé et ça a marché"><img src="/fr/img/like.webp" alt="" class="mascot_wB1d">Ça a marché !</button><button class="btn_RMiq btnNeutral_z71B" aria-label="Non, ça n'a pas marché pour moi"><img src="/fr/img/dislike.webp" alt="" class="mascot_wB1d">Ça n'a pas marché</button></div></div>]]></content:encoded>
            <author>Christophe</author>
            <author>Claude Code</author>
            <category>docker</category>
            <category>windows</category>
            <category>wsl</category>
            <enclosure length="0" type="image/webp" url="https://www.avonture.be/fr/img/v2/docker-shrink-vhdx.webp"/>
        <dc:creator><![CDATA[Christophe]]></dc:creator><dc:creator><![CDATA[Claude Code]]></dc:creator></item>
        <item>
            <title><![CDATA[Let Readers Flag Issues — A Multi-Type Feedback Widget for Docusaurus]]></title>
            <link>https://www.avonture.be/fr/fr/blog/docusaurus-typo-report-component</link>
            <guid isPermaLink="false">/fr/blog/docusaurus-typo-report-component</guid>
            <pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[<img src="https://www.avonture.be/fr/img/v2/docusaurus_typos.webp" alt="Let Readers Flag Issues — A Multi-Type Feedback Widget for Docusaurus" style="display:block;max-width:100%;height:auto;">Build a secure, SSR-safe reader-feedback widget for your Docusaurus blog — text selection, 4 feedback types, PHP API with HMAC nonce, rate limiting, deduplication, admin dashboard, and email notifications.]]></description>
            <content:encoded><![CDATA[<div class="offer_jqr1"><span class="flag_Hnep" aria-hidden="true">🇬🇧</span><span>Cet article existe aussi en anglais —<!-- --> <a href="/blog/docusaurus-typo-report-component/" hreflang="en">lire la version originale</a>.</span></div><div class="alertBox_R1Ot mk-callout caution_LuPi mk-callout--warn" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Traduction automatique</p><div class="content_gFLB">Cette traduction a été produite par une IA. Elle peut contenir des contresens ou des maladresses, en particulier sur les termes techniques. En cas de doute,<!-- --> <a href="/blog/docusaurus-typo-report-component/">consultez l'article de référence en anglais</a> <!-- -->— c'est lui qui fait foi.</div></div></div><div><div class="seriesBlogPost_bjUd"><details class="details_rTsq"><summary class="seriesIntro_HIK1 mk-eyebrow">Cet article fait partie de la série <a href="/fr/series/creating-docusaurus-components/">Créer des composants Docusaurus</a> :</summary><div class="content_OqVG"><ul><li><a href="/fr/blog/docusaurus-override-img/">Modifier la façon dont Docusaurus génère les balises img</a></li><li><a href="/fr/blog/docusaurus-lazy-loading/">Surcharger la génération des balises img avec Docusaurus</a></li><li><a href="/fr/blog/docusaurus-series/">Organisez votre contenu Docusaurus avec un composant Series personnalisé</a></li><li><a href="/fr/blog/docusaurus-go-top/">Ajouter un bouton « retour en haut » dans les articles Docusaurus</a></li><li><a href="/fr/blog/docusaurus-plugin-replace/">Créer un plugin de recherche/remplacement pour Docusaurus</a></li><li><a href="/fr/blog/docusaurus-snippets/">Un composant pour afficher des snippets de code dans un blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-old-notice/">Un composant Docusaurus qui prévient le lecteur que l'article a plus d'un an</a></li><li><a href="/fr/blog/docusaurus-changelog/">Afficher le changelog de votre article</a></li><li><a href="/fr/blog/docusaurus-ascii-art/">Injecter de l'ASCII art dans toutes les pages HTML générées par Docusaurus</a></li><li><a href="/fr/blog/blog-post-feed/">Best Practice - Personnaliser le flux RSS de Docusaurus pour un contenu complet et des images</a></li><li><a href="/fr/blog/docusaurus-check-images/">Quelques vérifications sur les images de votre site Docusaurus</a></li><li><a href="/fr/blog/docusaurus-tags/">Gestion des tags dans Docusaurus</a></li><li><a href="/fr/blog/gemini-tldr/">Automatiser les résumés TL;DR avec Gemini AI</a></li><li><a href="/fr/blog/docusaurus-project-setup/">Le composant ProjectSetup - Une façon standardisée de partager des structures de projet</a></li><li><a href="/fr/blog/docusaurus-ai-gemini/">Comment signaler un contenu assisté par IA dans un blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-terminal-typewriter/">Donnez vie à vos tutoriels CLI avec un terminal machine à écrire</a></li><li><a href="/fr/blog/docusaurus-easter-eggs/">Espiègleries de suricate : semer des easter eggs partout sur mon blog</a></li><li><a href="/fr/blog/docusaurus-eli5-snippet-tooltips/">Tooltips de code assistés par l'IA dans Docusaurus — Explique-moi comme à un enfant de cinq ans</a></li><li><a href="/fr/blog/docusaurus-reactions/">Ajouter des réactions de lecteurs à votre blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-llms-txt/">Votre blog est illisible pour une IA. Voici la solution.</a></li><li><a href="/fr/blog/docusaurus-shake-easter-egg/">Secouez votre téléphone, surprenez le suricate</a></li><li><a href="/fr/blog/docusaurus-copy-as-markdown/">Ajouter un bouton « Copy as Markdown » à votre blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-ask-my-blog/">Ask My Blog : transformer 259 articles en 2 160 questions cherchables</a></li><li><a href="/fr/blog/docusaurus-ask-my-blog-bubble/">La troisième porte : une bulle de chat flottante, et la fonctionnalité que personne ne trouvait</a></li><li><a href="/fr/blog/docusaurus-blog-map/">Dessiner une carte de mon propre blog</a></li><li><a href="/fr/blog/docusaurus-typo-report-component/">Laissez vos lecteurs signaler les problèmes — un widget de feedback multi-types pour Docusaurus</a></li></ul></div></details></div><div><p><img alt="Laissez vos lecteurs signaler les problèmes — un widget de feedback multi-types pour Docusaurus" src="/fr/assets/images/docusaurus_typos-234773d811196f8e9fa956af3496c6b4.webp" height="auto" width="" loading="lazy" decoding="async" class=""></p>
<div class="tldrContainer_qQW_ mk-callout mk-callout--key" role="region" aria-label="Résumé de l'article"><span role="img" aria-label="éclair" class="tldrIcon_SLBz mk-dot">⚡</span><div class="tldrBody_lGty"><strong class="tldrTitle_Aha8 mk-eyebrow">TL;DR</strong><div class="tldrContent_m27u"><p>Un lecteur repère un problème dans votre article — une faute de frappe, une erreur factuelle, une commande obsolète. Il n'a aucun moyen de vous le dire. Il passe à autre chose. Vous n'en saurez jamais rien. Cet article construit de A à Z un widget de feedback à sens unique : le lecteur sélectionne un passage, un popover apparaît avec quatre types de problème (faute de frappe, information incorrecte, contenu obsolète, suggestion), il ajoute éventuellement des détails, et le signalement atterrit dans un fichier JSON sur votre serveur avec une notification par email. L'API PHP est durcie avec des nonces HMAC, un honeypot, du rate limiting et de la déduplication. Le composant React est SSR-safe, utilise une machine à états propre et limite les envois via <code>localStorage</code>. Un dashboard admin associé, sur <code>/typo-dashboard</code>, vous permet de relire tous les signalements par type. Pas de base de données, pas de service tiers, aucune dépendance npm au-delà de ce que Docusaurus embarque déjà.</p></div></div></div>
<p>Votre blog est votre vitrine publique. Une faute de frappe qui traîne, une commande qui ne fonctionne plus, une API qui a changé — chaque lecteur qui tombe sur ce paragraphe vous coûte en crédibilité. Le problème, c'est que la plupart des lecteurs ne prendront pas la peine d'ouvrir une issue GitHub ou d'envoyer un email. Mais si vous rendez la chose vraiment triviale — un clic, là où ils se trouvent — un nombre surprenant le fera.</p>
<p>Cet article construit ce mécanisme de bout en bout.</p>
<nav class="quickJump_q5pQ" aria-label="Quick Jump"><span class="title_Njuu mk-eyebrow">Quick Jump</span><ul class="list_A_9v"><li class="item_gTjz"><a href="#see-it-in-action">Démonstration</a></li><li class="item_gTjz"><a href="#create-the-component">Créer le composant</a></li></ul></nav>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="see-it-in-action">Démonstration<a href="#see-it-in-action" class="hash-link" aria-label="Lien direct vers Démonstration" title="Lien direct vers Démonstration" translate="no"></a></h2>
<p>Le widget est actif sur cette page même. <strong>Sélectionnez quelques mots de ce paragraphe</strong> avec votre souris (ou votre doigt sur un téléphone) : un popover apparaît juste sous la sélection, avec quatre types de feedback.</p>
<div class="browserWindow_my1Q"><div class="browserWindowHeader_jXSR"><div class="buttons_uHc7"><span class="dot_giz1" style="background:#f25f58"></span><span class="dot_giz1" style="background:#fbbe3c"></span><span class="dot_giz1" style="background:#58cb42"></span></div><div class="browserWindowAddressBar_Pd8y text--truncate">https://www.avonture.be/blog/docusaurus-typo-report-component</div><div class="browserWindowMenuIcon_Vhuh"><div><span class="bar_rrRL"></span><span class="bar_rrRL"></span><span class="bar_rrRL"></span></div></div></div><div class="browserWindowBody_Idgs"><p><img alt="Le popover de feedback, avec ses quatre types, sous une sélection de texte" src="/fr/assets/images/typo_popover-55bcddfb22ac0c5d63830785070f0ff8.webp" height="auto" width="" loading="lazy" decoding="async" class=""></p></div></div>
<p>Le lecteur en choisit un, puis un petit formulaire lui permet d'ajouter un commentaire facultatif avant l'envoi. Le formulaire le dit clairement : <strong>c'est un signal à sens unique</strong> — le lecteur ne laisse pas d'email, donc personne n'attend de réponse.</p>
<div class="browserWindow_my1Q"><div class="browserWindowHeader_jXSR"><div class="buttons_uHc7"><span class="dot_giz1" style="background:#f25f58"></span><span class="dot_giz1" style="background:#fbbe3c"></span><span class="dot_giz1" style="background:#58cb42"></span></div><div class="browserWindowAddressBar_Pd8y text--truncate">https://www.avonture.be/blog/docusaurus-typo-report-component</div><div class="browserWindowMenuIcon_Vhuh"><div><span class="bar_rrRL"></span><span class="bar_rrRL"></span><span class="bar_rrRL"></span></div></div></div><div class="browserWindowBody_Idgs"><p><img alt="Le formulaire de détails, affiché une fois le type de feedback choisi" src="data:image/webp;base64,UklGRj4YAABXRUJQVlA4IDIYAAAQcwCdASoyAekAPlEgi0QjoiEYen3kOAUEsrd8L1QqSo+X9F/Ev232S9XgTB+M8Uo9PYD5z9s3ob/03qAf8jpCeYD9j/2M913+1frv7sf7z00/+49lT+zf8f2Ef4P/hP//7WXrHf4j1mNOd8pf1DtF/r348/ul62+E/zP41cqbqXzI/jn19+/f3z9tvxr+JP9v/bPEn4vfzv9R9gL1R/u/7Hvz9nf1V9gL1B+bf5b7dPQN/fv7t+x39V/////+hvrR/fvcA/j/81/zH3CfJn+I/5HjCed/SB9gn8s/qv+3/uH5t/TL/M/+H/Nfmv7X/0P/C/93/Efkr9hf8x/rH/N/wPtpew79xP//7pf7YFbpFgxEWR7Dp6ueMPMFBm/Y+en7b6Ws+6+9FC5wWp59gsKyYGjUEdCX+GUtqoN7uClJ+EjZ5HiFgF8HqfBldxp1Du+KZ44TS8NDznNYeGaj06i4BG9QGIcPUwvfMKAxOmnq54w8MbbvTeDZ27tyeNo6/pGXNVS4eCwaXSlGY/pxEwnG7hPePRRa5ZDiubi45eUG9lgfzQB04g22YcBJOZs6rPMS7ibdDvtt6gGRh6X2k8Irql6ILwuUnYs3phfwu+EkuShKfFoFOUGihCvLZN89BUwr/9UFDpaUBSHo6LbP6UVZrKvN6X6ECgvzB2tnQqYBUy5quYiPIUaCZ+aLHxEWbq9N7NgjpkYhA8jX8pgIQjLbd4IwDMLYZTgwK+r8CetsukNU03Z//SdgoVMai7SAtELrt1WGyl0BndbgxvvFn8wAyHSgIr4K05WBrRmGUrhHi/Fsqi7OyW7HQGh1btf0oe2kUtDq9pFLQ6shQmQuDC98CaG2ku1hU6Vu4EFd03SUm0ck5831KwVGDnakQELgH4yN2wCthDZduT3hmQU4Y7s9HkrhgElnR0urJ3CNND2zxV+EY8IW7VoyTYEIW9lRgzslrFYsaPhoBhDZeFZMnsWne+WE+h3Axlb7ykbbvy/MVEwDuoX99GtClFxpd0ga4lwVOoyGUci2c7gop2+eT3513bpHtMx6yI7muAG6BDd0jHelr9KlcGhZVpbW3BD/KT1hnHhNbaYH5GiIxEqnIqnnaLDh9UQs98N/cXatqVrqrUuf75Wa1BUmBsA425hg9la7OppRGx6YfYXM+akGvrfGiYAHdfG31I+dabLIvlyA9nOYFyConbBfoIm/udpcKpvO8rcrT4SCWCabtNuiKxmNAAD+9JkEv/UtcuyzJ3iXKhqR3QbvuZdBYal8q77Arq6jmWEy6BjW0Smltnny8YLJloshJk+zHneTcFrXcTynR/beU0k1c+1EZ/BBGwq0C2uMkjUXBRuTXt0ABWJk5HjvQGdPakwOIzQ/M3GK3wSbRt9oRueM4o7QfDdtCsD1MoJLFPTUgxcDYldXdCCuopzcM7vRgt3drhJ+SodBo5mVl1mewZQ8vXysTrUviWhaPqvOh7Czd81ByqiahJ8N3j8/3QhypYLArCjJPvVp5slSxWzsgLWr3YxWBo1QVDMjzfsLuuMKMNPmr9dQqFzWuKPwMz/RvgTSQQ73OWOlGT1R33swXoIWyIrKOdGtWOs11yRo5gNzivQogGquBP3mZhLZmhQS18qLDyHlRyI75ao8jvgdPElwNeBzK6JjxgrmqwC1qJcAgVnRF3ioHRq639vaFn1Rt0o1ZMQn3NlUQi6aFEDf0AlG690wDs6bB2X4JwFNW1EU/fdhIse4mFCc9zdX3haohb5DOdxjugfWstnh1dsQl+laaf2mNUWyNSSh7hb9OnePoHr9e6yL4YJYqwoMvYm6pSR98jVaozvhMOGt6r6iXK5pq+x02xbgIvGTO4c0rs7lP62kvjfyyVqKuD5TuDTSo2L4bdw/CUf5muIhwWa8nAIR1jAFQbmMw6L/bGmyoGUplI+kLT3iUyP9Diog1b/0beh57WqI1SQuXrkvXocaZBvRu4S5EMKVXLetckwfdsHpgVs+FLAu/sb5PEmKf8qAGh7Sg06UDhXadP9a0W0EFCc3UQu4N+zM2Br7CiPmOxkDi8qE14FRBcvvGMFp10fKjBhMjQ3uNsAHI/05qV5Whlp1LMy/UeQlY1RjSrfFTNXA/XDKH88JbJZtgn+LOVQXGjpUbjQ9Is/yZ+4p38nLFT3QaXioo5sMI86MUEaM9/7jo6xNx4mtnVS9G+sFSdxi6y+D/XAJ6uPF2xIz3qC6P6lCOw1Pabgs6opXHTmu74/KZyGeLXuTLZlPEP842hobJkvIwLetCt3jrhyI+060d+H4tNTodGcfScecODNz/mWUcoBqrfPTzTfQc1kbu3n6YA6GA+zJuzfJ64769fPQi9og+c1O77TnONaZBGRoHCxqAitEE3X/KZyGeLXuTAf5GV0WjEdaE75ieje6zEToRt+b1bFEmsMmUfFMkIbAzrj2L4JK6c3Ueyi05ZUUgknLkTTMPbonBlPwalYASr/EohfDUB+Oo3z1L8BCd+mewkefhyRwAb0rrfhIeyu04vnu5G8eB1/CNeNmokEiJntKil0g3G89YH0Xgi87xU5e6bzwKvKnOkz2eB/ljHhZ4JeJyUKUgGeugGl8OPpZPONhu5zziCeABsC52PrtfV2AczqtXWLdCtTWP4Ovp3wrotsdNZXEdRqiFEOZtf5LxL8YJGZnh9a7o3WB4YLzesaT0fwuv+RjEhuejSZI9ohOPts3Z8n5TkJltmm4ZYM4Z5Z38uQebLoM6lQEvskQpTL21qmM/8od+QTx1P21CbWCsJN8nwShRid6vBcoeGHo0xyo87u10gXEz03IgIuHOfgxxzhh27NL1/Zu4AJyEEQ0AA8KcIWQuz6c5/T73kb0o4S17nHbF8tr4kTsm2Cqr3CwshmfN2oXrCSsBikTK1JLJ5soWgVVHYsLlqQU11fr3nd84v1jujq1ypL5tLzDY06ujM98GC1VPnCoWTrAwufaC1iQAk9PMZgvPujj+i/vuuzYOQ/8AJxHqWGC7yKjtAE+kZcHZoeqfzyskUoPDm9NCax2S0FAlPf7fNMn/YU5WWFVLusM8DLmBxpo4T8/cjRA4ob/lD9JLCF2FEXSktLMkxmT8qx2WBrh9w2dqmwPUm/dK/F2HthsSu+yk74mmxa8XTmyMp7IWZD/fMo38/mm5ElptqFLqGhKb9J0PLQLGObgfZtmV2rP3RPWo0l1Als2UiMolOEDdCpxtmZh226Zts+v+qodFeudmxNoGpvzMwiMiLyesOI39A/8/AWmOA4Bif0bZH0c/qL9wMylJgBHJDcELNd5wB1ukpvO27giNk1QLFYhbcbC3TLYDBb+WN/hepzv+q+evnyP/zR6t5Auq7l8b2xCEtuexeA1s6dtcCUDEo+8dHDS6l1uksoJf49Tmv7Ee+iIrHEs8gCDdA7i4//COynp69suFW6Pd1chxJM3dM5bkb76+uzxfX61xCPAF2Za1sjtWDyEx7yXFoQjNozRIDDLWMJagmyA3+hW5gI4+mjCc+VS6OePr+Ge3ktf/jFoO4zonjUWInfIqzT7x9psS6EhiqrqFdnGLYCKnNTeiEAuIWPuGOXtQo04E6UXdpUHbckPgSugOcDrA0SJwjvRjBZnZdtgbWwxwLrswia+pSAXrruoyvu3xfmENozbiBCaoIay88nkNz6vIc4N3bMIcxsdJbesOMAAAAAAAAAFeTsxxRjVvgTwenjzrXh6mWDBSaNC5xN4AoYGljXtTzCeWujjjaX7Ijv8NXIqZK9I/Z2l0vwX5/kUEct6KBnRZzgLOqptxMt18F7hutFYtpojuk7Zs+ZbIRdGI0D83O+e2x4tizDjlf4Nlnd7kH5T69hJODesMSdVPJRbaiQxpKim34FMiDWA376JGwp8QELDUpIKhMi//xaVjd/0bhVu7e05UPgDjnV5/0I7wrw260A0mve8i/qaC6ln/eOk9yztTMH7mStwANopV6SoARue0c9DfsyExrQu/D1hbP+4W50BVwwwrjg6sldIjrg6tiBCGOniWSD138yt9i9zL5Cq44xNcinll5PdjQJu0FT5wS+7U0TP5HWuUc85CKhcNchlHUz+g1iH3QjinKT3do2a8OOMXU9CVk9c2kfRGqduZK3tPY8Hot0i0qXelduzGAxynhML3m1pIdONoqzAE/6CdnGl88J35cuozPtI8E/rquW12zWWaZHVbQ5w9fukKZYR1CZdEgpvtSgiFHr6gqBDyTuE4PLhdyc2XriIW/piiA6bhVc9xeIlU/tHv9R/hOVH2xPLfZp82hFL0/PwGpYS7U0JobDnOFsTF6XIzJ9KfnTK1K8K3rzqxKg8wtaYl8Qg5LnI6cYaH5E9AZIc8t2jgGBu5HuXq3W9a9IQT3gcWcw7XJlo0KsgfIBS7A4nskj/RuGhEYnvBrz6Lvcsg/YKRquZCTGvWFTpdGsG7oDsRlVLUAU/cEK35NKnQ6vS/78VhQpsWrAHgWi+uJjB1aKKoWPcHetRyppMCwViL6cxde0+QW263LUisq22INdqiW99nx+dop6WKxXQ/DROgLBWFAcleK94kBf18qT1MYcRc400S6N5wU5LquV9wI+cBpGNwrtg8Q6m92dn884WxsD/QvpnPw50RRJpXiCkehDw5jjUn1jlu4Cf4FlR1EZgOTHmNHbdSzKOP6A9zJrcfUlR0cJ3B8R2u5f72xrmV9WzpMisXySsgDLfxzpMKV1uWmQcvUuZj+0aw6ALE+kF7+ocYWCVhiaxJK6GQ/3jxxP6QIGGOYPD6C51ucgbtow6BnFRPvSK0jFzK6533JnqikFQIQm3QnfR+Kyadb/auyY9AQjX3glD+mV8BsnYPDbggRMAtEluy+dJLyQNSswAABY1m03ngNaYpMABx1iqE+fw2AAAAGW8a2exuFNsng1Ao0ZN8P9b81ZRW4mrgaTfYm5Cb3crddt5o8+JzN/ic2tkj4BYM/A746LwOKjKVZxWA7SH1Am+lP6BOprgVNZICXYtEbE79ysB3Qi2OvFx4r3lR8d5eykgmZZlYfxMFHUkDxGlHU8psb3mPKK/5TYRDnjpycJZq03NkJF+RyhtKqkzS6SIe+G1maF4jguaqYmiuSazviCe3PzBckAWAqmELld41u6FyWqYYDNJgTclt2hx3qHApxclWp08NHcTG9wTuC+ULeo/p7XJtws/78oACCD6JlPOQwNBL5dzZ1OjTjiUkOteobEVnO04o5w9SCYI/hV4FoSfh8X5+QlaUVEWiyJLFAgD0d1rFHxtjwvuiNF3t6BSzv8elQufW1ubc7tt4MYxy7W6+gAxi+Karw9iXhNO7mtSmZC9XYmVOZ7yDM32WHvjK8Bt/43NbMLAkSV68JHfMMwNg9+sA94rtvNinKM1nrVwLhobGRSuz+EPq/AQ6CaZBrjAO42iBQC+aEMMW3OR/TV1ERZh9WRDuxZP2iH4n6ksEf367gu/0lzf9MxACPC5mu9RmgkOIA3AX5y9y8R2NYbpf4h4nY7ILnZOxBiTwvzBvWKmAKKHsOAuuvWKfEA6ENrST5LEgjk+klH1rH32lyfmgUHl82/C0as7mjHl0GbYfZOyU1J7t0DyD2ahmNZfE4tlgzCQKiMuB8OqRm2GyNhMsK//5E+4kuwqS93jS8jCJQ//zXPNYufzbyO8+ZoxzTBe/ODhA5tpHLgWG1zdq7ceHPT2ZYeyHqCInM3qUcRmCw1/Wjrf9bia7ggzGgCJk//PDb0ho2rDg86aaaL7V1AHGfXwYWt9Swe+mXQ5XX+HNlcNWwmWd/cD7RFUUDDHwNh1MwLTYTy4yWwhcufqb7FmTgpUKv2Osjs9y0Aa4PFFKBBVt3r/RYtmHoMwOnMszR8u6QyO9DA/oSsdIKMql8i3ahxGeOfznh4E5Rx4EnJvSGwsoOIrc0yjEVufyq/8a7cNijIhscpMPBc1YDDqummD2kcChi+t8MSelaJmyK3OHghuRV2NJl3IlsW7leQSKNtXh5LWds4CMSK4oG538hoSKglQTZnHsKj4hJEtcLZq9zlxRpiDFOZ0gp5HvHfktC2eK3LMjnwtsW6HDWwzkEMHjWqrravKOohNdwKBm8QLgkKPc7QL6q5TTxBL6ktVy3Le4TzAifutU1UE6uyKUR7/3r2sETGiOmA1SKoxY7ADdFeamciJ4xTFdX2pBgZ0QKn7NhLOe1cUm/UPD+cLocCnjOUicWKQIHvP9GOPSeRvvNNHDw60jwHE5T+7ZdIKS498YrMEy1A3l4ADRUL4QoN99Y26XG9v5ZHzNRA/ys8aWBpisrzhVkLRzPmm7mUAYroiHvmtWefVq5KHvDJO2fEZRoR9zu8llNzNrKt2pZzcL/oGhCOYbPXxY60PDOqPoojtPbf+00C83YErSkMRev7inO3DJHlpYgpd9ne3gnXCsgVY7IL0nPt40ewdlJ/A+46RAnxgQtOnIAU5Vr6p9H0Ur+2/l0Qxozq2kyhrYSpqW3NCV2a0aLFnejZyuIhSGSQ3dSbgqoHOjWjHOLogXE02cvdiLYKSl39suEiLxkswRrrL4GKK+OCTuwPcxrjCzH1KroLZvnoAJL6/SW9YBkUZE+veytF1j7rERTo8cspnKGwBePjbwqPp/E0D066RfWFSSyy7eIX0GC26iMibMshopwHlEfVm1X15Fz4eduZpAgxIDxhXFyqxV1uMLzedy1KHyXn7iQtCdOPXJVIPKXsvT4jNYg59vSw3kFPOo3okFLY+wMyM7LozrRNwsBAp7i0lMcH1qEaK+sgfCsgIJ1scAESG1OgvwTjXv1EGP6qP4UnqG7rtdKti51PPGdoGE+7DzlUudjs0kjQS3Xtat0krkTRNaOSEdkEv7xhEsq6f/oLTqn8lFSzmbenIsRZGB8ffWbUFBlLczUHDUoaAP8loF7+PrOp8e+ykjMkouw7XSn+osXy1oLzEobdAtid9Mh7rsnTHsut3CJk51DiS8peHsQKQ4Bc+E96ukil/oRhRLTf6QWYZVwRBo4WtltplGf5+Hk5Rc7Se1eKnDXb8jasZzo7qMsNWMCJCYs/elDoNIWMsPP/8oWox0DmffSdUvra9dasmDB8/cl4Vcnqo2hXvop17TKDXSqGz/S1zOpboAfmc4y6DXeFOwjDorH6mqrTVX0jhcU4Xyj8A5dsIlVeM5BhPnqc0ekTl4USXNu56zb9kL0AIYhotlEqCyJWwztqfF60XMJgcjNQuxaKrKUdMcPp2USbmph8bf12I6308o6oK6r25tNelWAYeicxve6f68K7ZkSmhEXf8kPqSCQZMNRWG/FFhjiL1BNn9GVsEljGMFrdfbhUKjBLxWrEvbq7TDHtHCo7uTdmG6pNnBLfbPL/uHLeROoRSw+ggImcuciZtTFMwnB7PXYATMTMl5quBLWhNC5PYbCCGlUgohP5qUFdskeqD365m64W+ayRJic4IaB3+28WwyEktvuyuLQ/yNgNgPLiDYkGzqG4uiIoaNdBdTeKj/lw7gcs5C1t8aWpjJg4vVILvM6gm2Hgh28CTmkbgeW1+OD/ji4/+FfKBEJ+8mMsUCemlyQ0hsDib2GSuMVPPMnjCobpHQlf9+96c+Qokct/zBhfeekjkPoaCiCoV+RjbTPsJrNmyzrGZPdcUC0u3NzcgQMqLQNWOooDv6ln+JjLltfeJCjw6WK5ZTqUEk0kENM4yV5iJm2C269BUddnPaNMygNFrxoAvFl9hyyGfvO/FqViZhSyra2xF9zxgWzD9UjEfJ1m7ytKxVhVm1PdfdK1VwVh7liV/Z/y3SOGnm7tZRapNpH7qMe03cBf9XRkksk1+lzF89McdvIu4zQ0XBjkPFRI9n7w8rJ95Oq0651W/BUnNzB4r+4Qb97UAq8Hp24361soisjBM9cBX3W/5SwsVsxqLDAsYz6TQFQAvo/DQrC+k9rx+SH57ic5MIBKNy/SEjQImS20Fv2DaPYsDzGwl+HaWVY0GuQ4FlKeVyAXJF6HPNQswvUUd+fXK93fQVMuZyUmGfdlORdrAM1CVm2Cyy/SV+SGgPVbeWrbnSdj83zeRgVut+q2TkcDSfqrTlaUqse2Hd4ge0UqiYEqZVXdK90/AuLHPcwVtKEyDKRrDNiKWBF+hHDKTxL2wV4ryK+hEVXpcoOS0BLe5Zk8W1Mtid+ZC6+dMuvY+XNS3e9KHP/EtARGCo1rgX6FBXdzPPgZ5Nu5H1l7tb1KZ+tdCeENt6oo0O8yffGkVeM2sFg6TseLxmbejEYdk8yay4a88I9kHTgAAAA==" height="auto" width="" loading="lazy" decoding="async" class=""></p></div></div>
<p>De votre côté, chaque signalement atterrit dans un fichier JSON sur le serveur, vous recevez un email, et une page protégée par token liste tout, du plus récent au plus ancien, avec des compteurs par type :</p>
<div class="browserWindow_my1Q"><div class="browserWindowHeader_jXSR"><div class="buttons_uHc7"><span class="dot_giz1" style="background:#f25f58"></span><span class="dot_giz1" style="background:#fbbe3c"></span><span class="dot_giz1" style="background:#58cb42"></span></div><div class="browserWindowAddressBar_Pd8y text--truncate">https://www.avonture.be/typo-dashboard</div><div class="browserWindowMenuIcon_Vhuh"><div><span class="bar_rrRL"></span><span class="bar_rrRL"></span><span class="bar_rrRL"></span></div></div></div><div class="browserWindowBody_Idgs"><p><img alt="Le dashboard admin listant les signalements reçus" src="/fr/assets/images/typo_dashboard-2c03d5f2124c4a4ad832c93198d1d761.webp" height="auto" width="" loading="lazy" decoding="async" class=""></p></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="how-it-works">Comment ça marche<a href="#how-it-works" class="hash-link" aria-label="Lien direct vers Comment ça marche" title="Lien direct vers Comment ça marche" translate="no"></a></h2>
<p>La fonctionnalité se compose de deux moitiés indépendantes qui reprennent le schéma utilisé par le <a class="" href="/fr/blog/docusaurus-reactions/">composant Reaction</a> (votes like / dislike) :</p>
<div class="language-plaintext codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-plaintext codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">Reader selects text</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  └─ mouseup / touchend fires on &lt;article&gt;</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">       └─ popover shows 4 feedback-type buttons (phase: selecting)</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">            └─ reader picks a type → optional details form (phase: confirming)</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">                 └─ POST api/typo.php</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">                      └─ validates nonce, honeypot, rate limits, dedup</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">                           └─ writes typo-data.json</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">                                └─ emails admin (6h cooldown)</span><br></div></code></pre></div></div>
<ul>
<li class=""><strong>Pas de base de données</strong> — les signalements sont ajoutés à un fichier JSON, sous verrou de fichier.</li>
<li class=""><strong>Pas de service tiers</strong> — un simple script PHP posé à côté de vos fichiers statiques.</li>
<li class=""><strong>Défense en profondeur</strong> — neuf couches indépendantes bloquent les bots, les floods et les doublons (détaillées à la fin, pour les curieux).</li>
<li class=""><strong>SSR-safe</strong> — le composant ne rend rien tant que le lecteur n'a pas sélectionné de texte.</li>
</ul>
<hr>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="part-1--the-php-api">Partie 1 — L'API PHP<a href="#part-1--the-php-api" class="hash-link" aria-label="Lien direct vers Partie 1 — L'API PHP" title="Lien direct vers Partie 1 — L'API PHP" translate="no"></a></h2>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="why-php">Pourquoi PHP ?<a href="#why-php" class="hash-link" aria-label="Lien direct vers Pourquoi PHP ?" title="Lien direct vers Pourquoi PHP ?" translate="no"></a></h3>
<p>Docusaurus produit un site statique. Il n'a aucun runtime côté serveur. Si vous êtes sur un hébergement mutualisé (OVH, Infomaniak, o2switch…), un petit <a href="/fr/blog/php-api-tips/">PHP script</a> posé à côté de vos fichiers statiques est le backend le moins contraignant possible — pas de process Node à gérer, pas de container Docker, pas de démarrage à froid serverless.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="file-structure-on-the-server">Structure des fichiers sur le serveur<a href="#file-structure-on-the-server" class="hash-link" aria-label="Lien direct vers Structure des fichiers sur le serveur" title="Lien direct vers Structure des fichiers sur le serveur" translate="no"></a></h3>
<div class="treeContainer_O5o2"><h3 class="treeTitle_tvqP mk-eyebrow">File structure</h3><div class="treeContent_aVsc"><div class="treeNode_HY25"><div class="nodeContent_COuA " style="padding-left:0px"><span class="nodeIcon_LKCk"><span></span><span class="nodeLabel_KPgt">public_html/</span></span></div></div><div class="treeNode_HY25"><div class="nodeContent_COuA hasChildren_A_rI" style="padding-left:0px"><span class="expandIcon_qtYl">▼</span><span class="nodeIcon_LKCk"><span></span><span class="nodeLabel_KPgt">api/</span></span></div><div class="nodeChildren_ulR6"><div class="treeNode_HY25"><div class="nodeContent_COuA " style="padding-left:20px"><span class="nodeIcon_LKCk"><span class="nodeLabel_KPgt">.env                    ← secrets (never committed)</span></span></div></div><div class="treeNode_HY25"><div class="nodeContent_COuA " style="padding-left:20px"><span class="nodeIcon_LKCk"><span class="nodeLabel_KPgt">.env.example            ← template committed to git</span></span></div></div><div class="treeNode_HY25"><div class="nodeContent_COuA " style="padding-left:20px"><span class="nodeIcon_LKCk"><span class="nodeLabel_KPgt">.htaccess               ← protects JSON files</span></span></div></div><div class="treeNode_HY25"><div class="nodeContent_COuA " style="padding-left:20px"><span class="nodeIcon_LKCk"><span class="nodeLabel_KPgt">typo.php                ← the API</span></span></div></div><div class="treeNode_HY25"><div class="nodeContent_COuA " style="padding-left:20px"><span class="nodeIcon_LKCk"><span class="nodeLabel_KPgt">typo-data.json          ← reports (writable by web server)</span></span></div></div><div class="treeNode_HY25"><div class="nodeContent_COuA " style="padding-left:20px"><span class="nodeIcon_LKCk"><span class="nodeLabel_KPgt">typo-ratelimit.json     ← counters</span></span></div></div><div class="treeNode_HY25"><div class="nodeContent_COuA " style="padding-left:20px"><span class="nodeIcon_LKCk"><span class="nodeLabel_KPgt">typo-notifications.json ← email throttle</span></span></div></div></div></div></div></div>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="the-script">Le script<a href="#the-script" class="hash-link" aria-label="Lien direct vers Le script" title="Lien direct vers Le script" translate="no"></a></h3>
<p>L'API répond à trois requêtes : <code>GET</code> distribue un nonce (et, avec <code>?admin=TOKEN</code>, la liste complète des signalements), <code>POST</code> enregistre un signalement, <code>OPTIONS</code> répond au preflight CORS. Chaque <code>POST</code> traverse les neuf couches décrites dans la section sécurité, à la fin de cet article, avant que quoi que ce soit ne soit écrit.</p>

<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="protect-the-json-files">Protéger les fichiers JSON<a href="#protect-the-json-files" class="hash-link" aria-label="Lien direct vers Protéger les fichiers JSON" title="Lien direct vers Protéger les fichiers JSON" translate="no"></a></h3>
<p>Sans la <a href="/fr/blog/apache-htaccess/">règle .htaccess</a> livrée dans <code>api/.htaccess</code>, n'importe qui peut faire <code>GET https://yourdomain.com/api/typo-data.json</code> et lire tous les signalements.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="secrets">Les secrets<a href="#secrets" class="hash-link" aria-label="Lien direct vers Les secrets" title="Lien direct vers Les secrets" translate="no"></a></h3>
<p>Le fichier <code>api/.env</code> contient deux secrets. Générez-les avec :</p>
<div class="terminal_qOWF" style="cursor:pointer" title="Cliquez pour passer l'animation"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><span class="terminal_ghost_aie1" aria-hidden="true"><p>openssl rand -hex 32   # IP_HASH_SALT
openssl rand -hex 32   # NONCE_SECRET</p></span><span class="terminal_overlay_fznj"><span class="cursor_o7aQ" aria-hidden="true">▋</span></span></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<hr>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="part-2--the-react-component">Partie 2 — Le composant React<a href="#part-2--the-react-component" class="hash-link" aria-label="Lien direct vers Partie 2 — Le composant React" title="Lien direct vers Partie 2 — Le composant React" translate="no"></a></h2>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="ssr-safety-first">La sécurité SSR d'abord<a href="#ssr-safety-first" class="hash-link" aria-label="Lien direct vers La sécurité SSR d'abord" title="Lien direct vers La sécurité SSR d'abord" translate="no"></a></h3>
<p>Docusaurus rend les pages côté serveur (Node.js) avant de les hydrater dans le navigateur. Tout code qui touche à <code>window</code>, <code>document</code> ou <code>localStorage</code> fera planter la passe SSR s'il s'exécute au niveau du module ou dans le corps du composant.</p>
<p>Les règles :</p>
<ol>
<li class="">Tout accès au DOM se fait dans un <code>useEffect</code> — il ne s'exécute que dans le navigateur.</li>
<li class="">L'état est initialisé à <code>"idle"</code>, pas à une valeur lue depuis le navigateur.</li>
<li class="">Le composant retourne <code>null</code> à l'état idle — pas de DOM, pas de mismatch SSR.</li>
</ol>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="state-machine">Machine à états<a href="#state-machine" class="hash-link" aria-label="Lien direct vers Machine à états" title="Lien direct vers Machine à états" translate="no"></a></h3>
<p>Six phases, linéaires avec une seule bifurcation :</p>
<div class="language-plaintext codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-plaintext codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">idle</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  └─ user selects text → selecting</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">       └─ user picks a feedback type → confirming</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">            └─ form submitted → submitting</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">                 ├─ success → done</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">                 └─ error   → error</span><br></div></code></pre></div></div>
<p><code>done</code> et <code>error</code> affichent un badge que l'on peut fermer et qui remet l'état à <code>idle</code>. <code>selecting</code> et <code>confirming</code> repassent à <code>idle</code> si l'utilisateur clique en dehors du popover. <code>confirming</code> propose un lien « changer » qui ramène à <code>selecting</code>.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="localstorage-helpers">Les helpers localStorage<a href="#localstorage-helpers" class="hash-link" aria-label="Lien direct vers Les helpers localStorage" title="Lien direct vers Les helpers localStorage" translate="no"></a></h3>
<p>Avant que le popover de sélection du type n'apparaisse, deux gardes côté client s'exécutent :</p>
<ol>
<li class="">
<p><strong>Rate limit</strong> — maximum 5 envois par navigateur et par heure. Les timestamps sont stockés dans un tableau JSON dans <code>localStorage</code> sous la clé <code>"typo_reports"</code>. Les entrées de plus de 24 heures sont purgées à chaque écriture.</p>
</li>
<li class="">
<p><strong>Déduplication</strong> — un hash FNV-1a de <code>slug + "|" + lowercase(text)</code> est stocké à côté du timestamp. Si le même texte a déjà été signalé depuis ce navigateur (quel que soit le type), le composant saute directement à <code>done</code>.</p>
</li>
</ol>
<p>FNV-1a est utilisé plutôt que <code>crypto.subtle.digest</code> parce qu'il est synchrone, et le risque de collision est acceptable dans ce cas d'usage.</p>
<div class="language-js codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-js codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token keyword" style="color:rgb(86, 156, 214)">function</span><span class="token plain"> </span><span class="token function" style="color:rgb(220, 220, 170)">fnv1a</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token parameter">str</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token keyword" style="color:rgb(86, 156, 214)">let</span><span class="token plain"> hash </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">0x811c9dc5</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token keyword" style="color:rgb(86, 156, 214)">for</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token keyword" style="color:rgb(86, 156, 214)">let</span><span class="token plain"> i </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">0</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"> i </span><span class="token operator" style="color:rgb(212, 212, 212)">&lt;</span><span class="token plain"> str</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token plain">length</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"> i</span><span class="token operator" style="color:rgb(212, 212, 212)">++</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    hash </span><span class="token operator" style="color:rgb(212, 212, 212)">^=</span><span class="token plain"> str</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">charCodeAt</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">i</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    hash </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">hash </span><span class="token operator" style="color:rgb(212, 212, 212)">*</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">0x01000193</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">&gt;&gt;&gt;</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">0</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token keyword" style="color:rgb(86, 156, 214)">return</span><span class="token plain"> hash</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">toString</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token number" style="color:rgb(181, 206, 168)">16</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><br></div></code></pre></div></div>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="feedback-types">Les types de feedback<a href="#feedback-types" class="hash-link" aria-label="Lien direct vers Les types de feedback" title="Lien direct vers Les types de feedback" translate="no"></a></h3>
<p>Quatre types couvrent la grande majorité des problèmes qu'un lecteur peut remarquer :</p>
<div class="language-js codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-js codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token keyword" style="color:rgb(86, 156, 214)">const</span><span class="token plain"> </span><span class="token constant" style="color:rgb(100, 102, 149)">FEEDBACK_TYPES</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"> </span><span class="token literal-property property">id</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"typo"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain">       </span><span class="token literal-property property">icon</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"🔤"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token literal-property property">label</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"Typo"</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"> </span><span class="token literal-property property">id</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"incorrect"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain">  </span><span class="token literal-property property">icon</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"❌"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token literal-property property">label</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"Incorrect"</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"> </span><span class="token literal-property property">id</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"outdated"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain">   </span><span class="token literal-property property">icon</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"⏰"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token literal-property property">label</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"Outdated"</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"> </span><span class="token literal-property property">id</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"suggestion"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token literal-property property">icon</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"💡"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token literal-property property">label</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"Suggestion"</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token punctuation" style="color:rgb(212, 212, 212)">]</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><br></div></code></pre></div></div>
<p>Ils sont affichés dans une grille 2×2. L'API PHP valide le champ <code>type</code> contre cette même whitelist — toute autre valeur renvoie un 400.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="capturing-context">Capturer le contexte<a href="#capturing-context" class="hash-link" aria-label="Lien direct vers Capturer le contexte" title="Lien direct vers Capturer le contexte" translate="no"></a></h3>
<p>Quand l'utilisateur sélectionne du texte, le composant enregistre ±100 caractères de texte environnant depuis <code>article.innerText</code>. Ce contexte est envoyé à l'API pour que vous puissiez localiser la phrase exacte sans ouvrir l'article :</p>
<div class="language-js codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-js codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token keyword" style="color:rgb(86, 156, 214)">const</span><span class="token plain"> fullText </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> article</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token plain">innerText </span><span class="token operator" style="color:rgb(212, 212, 212)">||</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">""</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token keyword" style="color:rgb(86, 156, 214)">const</span><span class="token plain"> idx </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> fullText</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">indexOf</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">text</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">contextRef</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token plain">current </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> idx </span><span class="token operator" style="color:rgb(212, 212, 212)">!==</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">-</span><span class="token number" style="color:rgb(181, 206, 168)">1</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token operator" style="color:rgb(212, 212, 212)">?</span><span class="token plain"> fullText</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">slice</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">Math</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">max</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token number" style="color:rgb(181, 206, 168)">0</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> idx </span><span class="token operator" style="color:rgb(212, 212, 212)">-</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">100</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> idx </span><span class="token operator" style="color:rgb(212, 212, 212)">+</span><span class="token plain"> text</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token plain">length </span><span class="token operator" style="color:rgb(212, 212, 212)">+</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">100</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">""</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><br></div></code></pre></div></div>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="tooltip-positioning">Positionnement du tooltip<a href="#tooltip-positioning" class="hash-link" aria-label="Lien direct vers Positionnement du tooltip" title="Lien direct vers Positionnement du tooltip" translate="no"></a></h3>
<p>Le popover apparaît juste sous la sélection. <code>window.scrollY + rect.bottom + 8</code> le place 8px sous le bord inférieur du texte sélectionné. Pour éviter que le formulaire de 280px de large ne déborde du bord droit du viewport :</p>
<div class="language-js codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-js codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token keyword" style="color:rgb(86, 156, 214)">const</span><span class="token plain"> left </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> Math</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">min</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">window</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token plain">scrollX </span><span class="token operator" style="color:rgb(212, 212, 212)">+</span><span class="token plain"> rect</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token plain">left</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> window</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token plain">innerWidth </span><span class="token operator" style="color:rgb(212, 212, 212)">-</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">296</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><br></div></code></pre></div></div>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="the-component">Le composant<a href="#the-component" class="hash-link" aria-label="Lien direct vers Le composant" title="Lien direct vers Le composant" translate="no"></a></h3>

<p>Trois points à noter :</p>
<ul>
<li class=""><code>touchend</code> est enregistré en plus de <code>mouseup</code> pour que le widget fonctionne sur les appareils tactiles.</li>
<li class=""><code>handleTypeSelect</code> exécute les vérifications de rate limit et de déduplication au moment du choix du type, pas à la sélection — cela évite de consommer un slot de rate limit quand l'utilisateur sélectionne simplement du texte pour le copier.</li>
<li class="">Le paragraphe <code>disclaimer</code> (« Signal à sens unique — aucune réponse ne sera envoyée ») est volontaire : le dire explicitement évite la frustration.</li>
</ul>
<p>Le module CSS utilise partout les propriétés personnalisées <code>--ifm-*</code> de Docusaurus, donc le widget suit votre thème et le mode sombre sans effort supplémentaire.</p>
<hr>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="part-3--wiring-into-docusaurus">Partie 3 — L'intégration dans Docusaurus<a href="#part-3--wiring-into-docusaurus" class="hash-link" aria-label="Lien direct vers Partie 3 — L'intégration dans Docusaurus" title="Lien direct vers Partie 3 — L'intégration dans Docusaurus" translate="no"></a></h2>
<p>C'est ici qu'intervient le swizzling de Docusaurus. Plutôt que de modifier directement le fichier du thème en amont, Docusaurus vous permet de copier n'importe quel composant du thème dans <code>src/theme/</code> et de le surcharger. Si vous n'avez pas encore swizzlé <code>BlogPostItem</code>, lancez :</p>
<div class="terminal_qOWF" style="cursor:pointer" title="Cliquez pour passer l'animation"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><span class="terminal_ghost_aie1" aria-hidden="true"><p>yarn swizzle @docusaurus/theme-classic BlogPostItem --wrap</p></span><span class="terminal_overlay_fznj"><span class="cursor_o7aQ" aria-hidden="true">▋</span></span></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Cela crée <code>src/theme/BlogPostItem/index.js</code>. Ouvrez-le et ajoutez :</p>

<p>Le garde <code>isBlogPostPage</code> est essentiel — sans lui, le composant se monterait aussi sur la page de liste du blog, où il n'y a pas d'élément article.</p>
<hr>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="part-4--the-admin-dashboard">Partie 4 — Le dashboard admin<a href="#part-4--the-admin-dashboard" class="hash-link" aria-label="Lien direct vers Partie 4 — Le dashboard admin" title="Lien direct vers Partie 4 — Le dashboard admin" translate="no"></a></h2>
<p>L'endpoint admin PHP (<code>GET ?admin=TOKEN</code>) renvoie du JSON brut. Plutôt que de lire cette réponse brute, une petite page React sur <code>/typo-dashboard</code> la présente dans un format lisible : elle aplatit tous les tableaux de signalements par slug en une seule liste triée du plus récent au plus ancien, et affiche des compteurs récapitulatifs par type en haut.</p>
<p>Le dashboard est protégé par token : il réutilise la même clé <code>reactions_admin_token</code> dans <code>localStorage</code> que le dashboard des réactions, donc vous ne vous authentifiez qu'une fois pour les deux.</p>
<p>Si vous avez une page d'accueil admin, ajoutez cette entrée à son tableau <code>TOOLS</code> dans <code>src/pages/admin.js</code> :</p>

<hr>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="create-the-component">Créer le composant<a href="#create-the-component" class="hash-link" aria-label="Lien direct vers Créer le composant" title="Lien direct vers Créer le composant" translate="no"></a></h2>
<p>Créez ces fichiers dans votre site Docusaurus, puis copiez <code>api/.env.example</code> vers <code>api/.env</code> et collez-y les deux secrets générés plus haut :</p>
<div class="projectSetupContainer_LW1i mk-surface"><div class="header_Htsx mk-toolbar"><strong>📦 <!-- -->Mise en place du projet :<!-- --> <!-- -->/your_docusaurus_site</strong><div class="buttonGroup_y07f mk-toolbar__actions"><a class="button button--secondary button--sm" href="/project_setup" target="_blank">Aide</a><button class="button button--secondary button--sm">Tout déplier</button><button class="button button--outline button--primary button--sm" aria-label="Télécharger le projet en ZIP">ZIP</button><button class="button button--secondary button--sm">Générer le script d'installation</button></div></div><div class="content_bo8S"><div><p class="tip_qyDH"><small><span aria-hidden="true">💡</span> <!-- -->Besoin d'aide ? Cliquez sur le bouton « Aide » ci-dessus pour les instructions de mise en place.</small></p></div></div></div>
<p>Montez ensuite le composant dans <code>src/theme/BlogPostItem/index.js</code> comme montré en partie 3.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="deployment">Déploiement<a href="#deployment" class="hash-link" aria-label="Lien direct vers Déploiement" title="Lien direct vers Déploiement" translate="no"></a></h3>
<p>L'API utilise <code>declare(strict_types=1)</code> et un type de retour <code>never</code> sur <code>jsonError()</code>, donc votre serveur doit tourner en <strong>PHP 8.1 ou plus récent</strong> :</p>
<div class="terminal_qOWF" style="cursor:pointer" title="Cliquez pour passer l'animation"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><span class="terminal_ghost_aie1" aria-hidden="true"><p>php -v</p></span><span class="terminal_overlay_fznj"><span class="cursor_o7aQ" aria-hidden="true">▋</span></span></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Les trois fichiers JSON doivent être accessibles en écriture par le process du serveur web :</p>
<div class="terminal_qOWF" style="cursor:pointer" title="Cliquez pour passer l'animation"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><span class="terminal_ghost_aie1" aria-hidden="true"><p>$ chmod 664 api/typo-data.json api/typo-ratelimit.json api/typo-notifications.json</p></span><span class="terminal_overlay_fznj"><span class="cursor_o7aQ" aria-hidden="true">▋</span></span></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Une fois déployé, vérifiez l'aller-retour complet avant d'annoncer la fonctionnalité :</p>

<p>Nettoyez après le test :</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token builtin class-name" style="color:rgb(78, 201, 176)">echo</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">'{}'</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">&gt;</span><span class="token plain"> api/typo-data.json</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token builtin class-name" style="color:rgb(78, 201, 176)">echo</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">'{}'</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">&gt;</span><span class="token plain"> api/typo-ratelimit.json</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token builtin class-name" style="color:rgb(78, 201, 176)">echo</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">'{}'</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">&gt;</span><span class="token plain"> api/typo-notifications.json</span><br></div></code></pre></div></div>
<hr>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="security-design--layer-by-layer-skip-this-if-you-just-want-it-running">Conception de la sécurité — couche par couche (sautez si vous voulez juste que ça tourne)<a href="#security-design--layer-by-layer-skip-this-if-you-just-want-it-running" class="hash-link" aria-label="Lien direct vers Conception de la sécurité — couche par couche (sautez si vous voulez juste que ça tourne)" title="Lien direct vers Conception de la sécurité — couche par couche (sautez si vous voulez juste que ça tourne)" translate="no"></a></h2>
<p>Chaque <code>POST</code> traverse neuf couches indépendantes avant qu'un signalement ne soit écrit.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="layer-1--cors">Couche 1 — CORS<a href="#layer-1--cors" class="hash-link" aria-label="Lien direct vers Couche 1 — CORS" title="Lien direct vers Couche 1 — CORS" translate="no"></a></h3>
<p>Seuls <code>https://www.yourdomain.com</code> et <code>http://localhost:3000</code> (dev) sont autorisés comme <code>Origin</code>. Toute autre origine reçoit un <code>403</code> silencieux. Les requêtes same-origin n'envoient pas d'en-tête <code>Origin</code> et passent.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="layer-2--method-whitelist">Couche 2 — Whitelist des méthodes<a href="#layer-2--method-whitelist" class="hash-link" aria-label="Lien direct vers Couche 2 — Whitelist des méthodes" title="Lien direct vers Couche 2 — Whitelist des méthodes" translate="no"></a></h3>
<p>L'API ne répond qu'à <code>GET</code> (nonce + admin), <code>POST</code> (envoi) et <code>OPTIONS</code> (preflight). Tout le reste reçoit <code>405 Method Not Allowed</code>.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="layer-3--hmac-nonce">Couche 3 — Nonce HMAC<a href="#layer-3--hmac-nonce" class="hash-link" aria-label="Lien direct vers Couche 3 — Nonce HMAC" title="Lien direct vers Couche 3 — Nonce HMAC" translate="no"></a></h3>
<p>Sans cela, quiconque connaît l'URL de votre API peut envoyer des signalements arbitraires avec une simple commande <code>curl</code>. Le nonce est un token HMAC basé sur le temps :</p>
<div class="language-php codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-php codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token variable" style="color:rgb(156, 220, 254)">$nonce</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> </span><span class="token function" style="color:rgb(220, 220, 170)">hash_hmac</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token string single-quoted-string" style="color:rgb(206, 145, 120)">'sha256'</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token function" style="color:rgb(220, 220, 170)">floor</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token function" style="color:rgb(220, 220, 170)">time</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">/</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">900</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">.</span><span class="token plain"> </span><span class="token string single-quoted-string" style="color:rgb(206, 145, 120)">'|typo-report'</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token constant" style="color:rgb(100, 102, 149)">NONCE_SECRET</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><br></div></code></pre></div></div>
<p>Le token est valable pour la fenêtre de 15 minutes en cours et la précédente (pour éviter une expiration gênante quand un utilisateur récupère le nonce à la minute 14 et envoie à la minute 15). Il n'y a aucun stockage — le token s'auto-valide. Chaque <code>POST</code> doit inclure cette valeur dans son body.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="layer-4--input-validation-type-whitelist-and-honeypot">Couche 4 — Validation des entrées, whitelist de types et honeypot<a href="#layer-4--input-validation-type-whitelist-and-honeypot" class="hash-link" aria-label="Lien direct vers Couche 4 — Validation des entrées, whitelist de types et honeypot" title="Lien direct vers Couche 4 — Validation des entrées, whitelist de types et honeypot" translate="no"></a></h3>
<p>Le honeypot est un <code>&lt;input name="website"&gt;</code> caché, invisible pour les humains mais rempli par les bots. Si <code>website !== ""</code>, l'API renvoie <code>200 OK</code> sans rien stocker.</p>
<p>Les vraies entrées sont ensuite validées :</p>
<ul>
<li class=""><code>slug</code> — alphanumérique, tirets, slashes, 200 caractères max</li>
<li class=""><code>text</code> — 3 à 150 caractères, Unicode imprimable uniquement (<code>\P{C}</code> — pas de caractères de contrôle)</li>
<li class=""><code>type</code> — doit valoir <code>typo</code>, <code>incorrect</code>, <code>outdated</code> ou <code>suggestion</code> (toute autre valeur → 400)</li>
<li class=""><code>comment</code> — facultatif, 300 caractères max, même filtre de caractères</li>
<li class=""><code>context</code> — facultatif, 300 caractères max, trimmé</li>
</ul>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="layer-5--global-rate-limit">Couche 5 — Rate limit global<a href="#layer-5--global-rate-limit" class="hash-link" aria-label="Lien direct vers Couche 5 — Rate limit global" title="Lien direct vers Couche 5 — Rate limit global" translate="no"></a></h3>
<p>Une fenêtre glissante de 60 secondes avec un maximum de 20 requêtes toutes IP confondues. Cela protège des pics soudains. L'état de la fenêtre est stocké sous une clé <code>__global__</code> dans <code>typo-ratelimit.json</code>, mis à jour dans un unique bloc <code>LOCK_EX</code>.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="layer-6--per-ip-rate-limits">Couche 6 — Rate limits par IP<a href="#layer-6--per-ip-rate-limits" class="hash-link" aria-label="Lien direct vers Couche 6 — Rate limits par IP" title="Lien direct vers Couche 6 — Rate limits par IP" translate="no"></a></h3>
<p>Deux limites suivies indépendamment :</p>
<ul>
<li class="">10 signalements max par IP et par heure glissante</li>
<li class="">3 signalements max par IP, par article, par 24 heures</li>
</ul>
<p>Les adresses IP ne sont jamais stockées en clair. Elles sont hashées avec un sel secret : <code>hash('sha256', IP_HASH_SALT . $ip)</code>. À sens unique — vous ne pouvez pas retrouver l'IP d'origine — mais suffisamment cohérent pour agréger.</p>
<p>Le script lit <code>REMOTE_ADDR</code>, jamais <code>X-Forwarded-For</code> : sur un hébergement mutualisé, <code>X-Forwarded-For</code> peut être falsifié par le client, ce qui permettrait à une seule IP d'en usurper des milliers et de contourner les limites par IP.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="layer-7--deduplication">Couche 7 — Déduplication<a href="#layer-7--deduplication" class="hash-link" aria-label="Lien direct vers Couche 7 — Déduplication" title="Lien direct vers Couche 7 — Déduplication" translate="no"></a></h3>
<p>Un hash SHA-256 de <code>slug + "|" + lowercase(text)</code> est stocké dans <code>text_hash</code> sur chaque signalement. Avant l'écriture, les signalements existants pour ce slug sont parcourus. Si le hash existe déjà, l'API renvoie <code>200 OK</code> sans rien stocker. Cela absorbe silencieusement les envois en double.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="layer-8--file-locked-writes">Couche 8 — Écritures sous verrou de fichier<a href="#layer-8--file-locked-writes" class="hash-link" aria-label="Lien direct vers Couche 8 — Écritures sous verrou de fichier" title="Lien direct vers Couche 8 — Écritures sous verrou de fichier" translate="no"></a></h3>
<p>PHP sur hébergement mutualisé est multi-process. Chaque écriture utilise <code>fopen</code> + <code>flock(LOCK_EX)</code> + <code>ftruncate</code> + <code>rewind</code> + <code>fwrite</code> + <code>flock(LOCK_UN)</code> + <code>fclose</code>. La vérification du rate limit et l'écriture se font dans le même verrou pour éviter les races TOCTOU.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="layer-9--email-throttle">Couche 9 — Throttle des emails<a href="#layer-9--email-throttle" class="hash-link" aria-label="Lien direct vers Couche 9 — Throttle des emails" title="Lien direct vers Couche 9 — Throttle des emails" translate="no"></a></h3>
<p>Vous ne voulez pas d'une boîte mail inondée si un article populaire récolte 50 signalements en une heure. Le système de notification consulte <code>typo-notifications.json</code> pour connaître le timestamp du dernier email par article. Un nouvel email n'est envoyé que si plus de 6 heures se sont écoulées depuis le précédent.</p>
<hr>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="known-limitations">Limitations connues<a href="#known-limitations" class="hash-link" aria-label="Lien direct vers Limitations connues" title="Lien direct vers Limitations connues" translate="no"></a></h2>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="tooltip-overflow-on-the-left-edge">Débordement du tooltip sur le bord gauche<a href="#tooltip-overflow-on-the-left-edge" class="hash-link" aria-label="Lien direct vers Débordement du tooltip sur le bord gauche" title="Lien direct vers Débordement du tooltip sur le bord gauche" translate="no"></a></h3>
<p>Le clamp <code>Math.min(left, window.innerWidth - 296)</code> empêche le formulaire de 280px de sortir de l'écran à droite. Un débordement à gauche (sélection près du bord gauche sur des viewports très étroits) n'est pas géré.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="shared-hosting-nat">NAT en hébergement mutualisé<a href="#shared-hosting-nat" class="hash-link" aria-label="Lien direct vers NAT en hébergement mutualisé" title="Lien direct vers NAT en hébergement mutualisé" translate="no"></a></h3>
<p>Les utilisateurs derrière le même NAT (par exemple le Wi-Fi d'un bureau) partagent la même <code>REMOTE_ADDR</code>. Si trois personnes différentes du même bureau signalent des problèmes sur le même article en 24 heures, la quatrième sera bloquée par le rate limit. C'est un compromis acceptable vu la contrainte de l'hébergement mutualisé.</p>
<hr>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="conclusion">Conclusion<a href="#conclusion" class="hash-link" aria-label="Lien direct vers Conclusion" title="Lien direct vers Conclusion" translate="no"></a></h2>
<p>Les lecteurs n'ouvriront jamais une issue GitHub pour vous dire qu'une commande est obsolète — mais ils sélectionneront une phrase et cliqueront sur un bouton. Avec environ 250 lignes de PHP et 300 lignes de React, chacun de ces signaux arrive désormais dans votre boîte mail, avec la phrase exacte et son contexte, sans base de données ni service tiers.</p>
<p>L'architecture est volontairement simple : chaque morceau peut être compris, débogué et modifié en lisant un seul fichier. Si vous voulez aussi que vos lecteurs vous disent ce qu'ils ont <em>aimé</em>, le <a class="" href="/fr/blog/docusaurus-reactions/">composant Reaction</a> utilise exactement le même schéma PHP + JSON.</p></div><hr><div class="seriesBlogPost_bjUd"><details class="details_rTsq"><summary class="seriesIntro_HIK1 mk-eyebrow">Cet article fait partie de la série <a href="/fr/series/creating-docusaurus-components/">Créer des composants Docusaurus</a> :</summary><div class="content_OqVG"><ul><li><a href="/fr/blog/docusaurus-override-img/">Modifier la façon dont Docusaurus génère les balises img</a></li><li><a href="/fr/blog/docusaurus-lazy-loading/">Surcharger la génération des balises img avec Docusaurus</a></li><li><a href="/fr/blog/docusaurus-series/">Organisez votre contenu Docusaurus avec un composant Series personnalisé</a></li><li><a href="/fr/blog/docusaurus-go-top/">Ajouter un bouton « retour en haut » dans les articles Docusaurus</a></li><li><a href="/fr/blog/docusaurus-plugin-replace/">Créer un plugin de recherche/remplacement pour Docusaurus</a></li><li><a href="/fr/blog/docusaurus-snippets/">Un composant pour afficher des snippets de code dans un blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-old-notice/">Un composant Docusaurus qui prévient le lecteur que l'article a plus d'un an</a></li><li><a href="/fr/blog/docusaurus-changelog/">Afficher le changelog de votre article</a></li><li><a href="/fr/blog/docusaurus-ascii-art/">Injecter de l'ASCII art dans toutes les pages HTML générées par Docusaurus</a></li><li><a href="/fr/blog/blog-post-feed/">Best Practice - Personnaliser le flux RSS de Docusaurus pour un contenu complet et des images</a></li><li><a href="/fr/blog/docusaurus-check-images/">Quelques vérifications sur les images de votre site Docusaurus</a></li><li><a href="/fr/blog/docusaurus-tags/">Gestion des tags dans Docusaurus</a></li><li><a href="/fr/blog/gemini-tldr/">Automatiser les résumés TL;DR avec Gemini AI</a></li><li><a href="/fr/blog/docusaurus-project-setup/">Le composant ProjectSetup - Une façon standardisée de partager des structures de projet</a></li><li><a href="/fr/blog/docusaurus-ai-gemini/">Comment signaler un contenu assisté par IA dans un blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-terminal-typewriter/">Donnez vie à vos tutoriels CLI avec un terminal machine à écrire</a></li><li><a href="/fr/blog/docusaurus-easter-eggs/">Espiègleries de suricate : semer des easter eggs partout sur mon blog</a></li><li><a href="/fr/blog/docusaurus-eli5-snippet-tooltips/">Tooltips de code assistés par l'IA dans Docusaurus — Explique-moi comme à un enfant de cinq ans</a></li><li><a href="/fr/blog/docusaurus-reactions/">Ajouter des réactions de lecteurs à votre blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-llms-txt/">Votre blog est illisible pour une IA. Voici la solution.</a></li><li><a href="/fr/blog/docusaurus-shake-easter-egg/">Secouez votre téléphone, surprenez le suricate</a></li><li><a href="/fr/blog/docusaurus-copy-as-markdown/">Ajouter un bouton « Copy as Markdown » à votre blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-ask-my-blog/">Ask My Blog : transformer 259 articles en 2 160 questions cherchables</a></li><li><a href="/fr/blog/docusaurus-ask-my-blog-bubble/">La troisième porte : une bulle de chat flottante, et la fonctionnalité que personne ne trouvait</a></li><li><a href="/fr/blog/docusaurus-blog-map/">Dessiner une carte de mon propre blog</a></li><li><a href="/fr/blog/docusaurus-typo-report-component/">Laissez vos lecteurs signaler les problèmes — un widget de feedback multi-types pour Docusaurus</a></li></ul></div></details></div></div><div class="relatedPosts_zJiB"><h3>Articles liés</h3></div><div class="container_mNR1"><span class="question_WdqX">Cet article vous a-t-il été utile ?</span><div class="buttons_vgpi"><button class="btn_svQp" aria-label="Oui, cet article m'a été utile"><img src="/fr/img/reaction-mascot-yes.webp" alt="" class="mascot_Fait">Utile</button><button class="btn_svQp btnNeutral_Iqek" aria-label="Non, cet article ne m'a pas été utile"><img src="/fr/img/reaction-mascot-no.webp" alt="" class="mascot_Fait">Pas vraiment</button></div></div><div class="container_IJwe"><span class="question_nb7b">Avez-vous suivi les étapes de cet article ?</span><div class="buttons_Xkzw"><button class="btn_RMiq" aria-label="Oui, j'ai essayé et ça a marché"><img src="/fr/img/like.webp" alt="" class="mascot_wB1d">Ça a marché !</button><button class="btn_RMiq btnNeutral_z71B" aria-label="Non, ça n'a pas marché pour moi"><img src="/fr/img/dislike.webp" alt="" class="mascot_wB1d">Ça n'a pas marché</button></div></div>]]></content:encoded>
            <author>Christophe</author>
            <category>docusaurus</category>
            <category>component</category>
            <category>php</category>
            <category>react</category>
            <category>security</category>
            <enclosure length="0" type="image/webp" url="https://www.avonture.be/fr/img/v2/docusaurus_typos.webp"/>
        <dc:creator><![CDATA[Christophe]]></dc:creator></item>
        <item>
            <title><![CDATA[Open WebUI — Beyond the Chat: Models, RAG, Tools and API Keys]]></title>
            <link>https://www.avonture.be/fr/fr/blog/open-webui-advanced</link>
            <guid isPermaLink="false">/fr/blog/open-webui-advanced</guid>
            <pubDate>Mon, 26 Oct 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[<img src="https://www.avonture.be/fr/img/v2/exploring_webgui.webp" alt="Open WebUI — Beyond the Chat: Models, RAG, Tools and API Keys" style="display:block;max-width:100%;height:auto;">Open WebUI is much more than a chat interface for Ollama. Custom model presets with system prompts, local document RAG, web search, Python Tools and personal API keys let you build a genuinely useful local AI workspace.]]></description>
            <content:encoded><![CDATA[<div class="offer_jqr1"><span class="flag_Hnep" aria-hidden="true">🇬🇧</span><span>Cet article existe aussi en anglais —<!-- --> <a href="/blog/open-webui-advanced/" hreflang="en">lire la version originale</a>.</span></div><div class="alertBox_R1Ot mk-callout caution_LuPi mk-callout--warn" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Traduction automatique</p><div class="content_gFLB">Cette traduction a été produite par une IA. Elle peut contenir des contresens ou des maladresses, en particulier sur les termes techniques. En cas de doute,<!-- --> <a href="/blog/open-webui-advanced/">consultez l'article de référence en anglais</a> <!-- -->— c'est lui qui fait foi.</div></div></div><div><div><p><img alt="Open WebUI — au-delà du chat : modèles, RAG, Tools et clés d&amp;#39;API" src="/fr/assets/images/exploring_webgui-96b408530f4f0f0d0658ef36b3833073.webp" height="auto" width="" loading="lazy" decoding="async" class=""></p>
<div class="tldrContainer_qQW_ mk-callout mk-callout--key" role="region" aria-label="Résumé de l'article"><span role="img" aria-label="éclair" class="tldrIcon_SLBz mk-dot">⚡</span><div class="tldrBody_lGty"><strong class="tldrTitle_Aha8 mk-eyebrow">TL;DR</strong><div class="tldrContent_m27u"><p>La fenêtre de chat d'Open WebUI, c'est la surface. En dessous : des presets de modèles avec system prompt et température fixes, une base de connaissances pour le RAG (uploadez des PDF et des fichiers Markdown, posez des questions, obtenez des réponses sourcées), l'intégration de la recherche web, un système de plugins Python (Tools et Functions) pour l'étendre, et des clés d'API personnelles pour piloter le tout depuis vos scripts. Cet article passe en revue chaque point, en partant d'une installation de base.</p></div></div></div><nav class="mobileQuickLinks_La_l" aria-label="Articles liés"><p class="title_rsOi mk-eyebrow">Sur le même sujet</p><ul class="list_CXCl"><li class="item_grzi"><a href="/fr/blog/ollama-ai-explain/">ai-explain : l'ELI5 des scripts et des erreurs, directement dans votre terminal</a></li><li class="item_grzi"><a href="/fr/blog/open-webui-advanced/">Open WebUI — au-delà du chat : modèles, RAG, Tools et clés d'API</a></li><li class="item_grzi"><a href="/fr/blog/ollama-ai-data/">ai-data : laissez un LLM local écrire vos one-liners jq et awk</a></li></ul></nav>
<p>Si vous avez installé Open WebUI pour discuter avec Ollama et que vous n'êtes pas allé plus loin, vous utilisez environ 20 % de ce qui est disponible. Le reste se trouve dans les onglets et les menus qu'on ne remarque pas quand on veut juste poser une question.</p>
<p>Cet article reprend là où <a href="/fr/blog/ollama-installation/">le guide d'installation d'Ollama</a> s'arrêtait — en supposant qu'Ollama tourne en local et qu'Open WebUI est accessible sur le port 4000 (ou là où vous l'avez déployé).</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="seeing-it-work">Voir la chose en action<a href="#seeing-it-work" class="hash-link" aria-label="Lien direct vers Voir la chose en action" title="Lien direct vers Voir la chose en action" translate="no"></a></h2>
<p>Open WebUI expose une API compatible OpenAI, protégée par une clé d'API personnelle. Ici, elle résume de vraies notes de réunion, appelée directement depuis un terminal :</p>
<div class="terminal_qOWF" style="cursor:pointer" title="Cliquez pour passer l'animation"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><span class="terminal_ghost_aie1" aria-hidden="true">$ export TOKEN="sk-..."

$ curl -s -H "Authorization: Bearer $TOKEN" http://localhost:4000/api/models | jq -r '.data[].id'
code-quality:latest
qwen2.5-coder:3b
qwen3-coder:30b
task-tiny:latest

$ export MODEL="qwen2.5-coder:3b"

$ curl -s -X POST http://localhost:4000/api/chat/completions \
    -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
    -d '{
      "model": "'"$MODEL"'",
      "messages": [
        {"role": "system", "content": "You summarize text in exactly 3 bullet points, focusing on decisions, numbers and deadlines."},
        {"role": "user", "content": "Infra meeting, September 24. Present: Sarah, Tom, Lea. Tom presented the staging database status: still on Postgres 12, end of life is near. Decision: migrate staging to Postgres 16, deadline October 15. Lea asked about backups: weekly is no longer enough, the team agreed to switch to daily backups starting today. Sarah got the budget approved: 4,200 EUR for an external contractor to help with the migration. Action: Tom writes the rollback plan and reviews it with Sarah before October 1. Coffee machine on floor 2 is broken again."}
      ]
    }' | jq -r '.choices[0].message.content'
- **Postgres Migration**: Staging database to migrate from Postgres 12 to Postgres 16 by October 15.
- **Backup Change**: Weekly backups replaced with daily backups starting today.
- **Budget Approval**: 4,200 EUR for an external contractor to help with migration.
</span><span class="terminal_overlay_fznj"><span class="cursor_o7aQ" aria-hidden="true">▋</span></span></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Remplacez <code>qwen2.5-coder:3b</code> par un des ids affichés par la seconde commande sur votre machine — la <a href="#4-pick-a-model-name-the-api-knows" class="">section sur le nom du modèle</a> explique pourquoi. Trois puces, basées sur les chiffres et les dates réellement présents dans le texte — pas une reformulation générique. C'est exactement la requête qu'envoie la fenêtre de chat — la <a href="#api-keys" class="">section sur les clés d'API</a> plus bas montre comment obtenir le <code>$TOKEN</code>, et la <a href="#tools-and-functions" class="">section Tools</a> empaquette le même prompt sous forme de Tool réutilisable.</p>
<div class="alertBox_R1Ot mk-callout tip_shCb mk-callout--tip" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Traduire en...</p><div class="content_gFLB"><p>Ajoutez simplement <code>Answer in French.</code> dans le system prompt pour obtenir des réponses en français.</p><div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">- **Decision:** Migrer le staging de PostgreSQL </span><span class="token number" style="color:rgb(181, 206, 168)">12</span><span class="token plain"> à PostgreSQL </span><span class="token number" style="color:rgb(181, 206, 168)">16</span><span class="token plain">, échéance </span><span class="token function" style="color:rgb(220, 220, 170)">du</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">15</span><span class="token plain"> octobre.</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">- **Action:** Tom rédige un plan de remise en arrière pour une vérification avec Sarah avant le </span><span class="token number" style="color:rgb(181, 206, 168)">15</span><span class="token plain"> octobre.</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">- **Budget:** Approuvé un budget de </span><span class="token number" style="color:rgb(181, 206, 168)">4,200</span><span class="token plain"> EUR pour un contractor externe pour la migration.</span><br></div></code></pre></div></div></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="why-it-works">Pourquoi ça marche<a href="#why-it-works" class="hash-link" aria-label="Lien direct vers Pourquoi ça marche" title="Lien direct vers Pourquoi ça marche" translate="no"></a></h2>
<ul>
<li class=""><strong>Les presets de modèles</strong> sont une combinaison sauvegardée de modèle de base, system prompt et température — passer de « Code Reviewer » à « Brainstorm Partner » se fait dans une liste déroulante, sans retaper le contexte à chaque fois.</li>
<li class=""><strong>Knowledge (RAG)</strong> indexe vos propres documents et y ancre les réponses, en citant le passage source — le modèle répond à partir de ce que vous lui avez donné, pas de ses données d'entraînement.</li>
<li class=""><strong>La recherche web</strong> donne au modèle des informations actuelles, au-delà de sa date de coupure d'entraînement ; activable message par message ou par défaut dans un preset.</li>
<li class=""><strong>Tools et Functions</strong> sont du code Python qui tourne dans le container — les Tools que le modèle peut appeler, et les Functions réservées aux admins (Pipes, Filters, Actions) qui étendent la plateforme elle-même.</li>
<li class=""><strong>Les clés d'API</strong> permettent à n'importe quel script d'atteindre tout ce qui précède via un endpoint compatible OpenAI.</li>
</ul>
<p>Ces cinq fonctionnalités sont réellement indépendantes ; elles partagent juste une fenêtre de chat. Ce ne sont pas cinq vues sur la même chose.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="setup-reminder">Rappel d'installation<a href="#setup-reminder" class="hash-link" aria-label="Lien direct vers Rappel d'installation" title="Lien direct vers Rappel d'installation" translate="no"></a></h2>
<p>Si Open WebUI ne tourne pas encore chez vous, voici le <code>compose.yaml</code> minimal ; la procédure complète, avec captures d'écran, se trouve dans la <a href="/fr/blog/ollama-installation/#using-a-web-interface">section Open WebUI du guide d'installation d'Ollama</a>.</p>

<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>docker compose up -d</p><h1>open <a href="http://localhost:4000" target="_blank" rel="noopener noreferrer" class="">http://localhost:4000</a></h1></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Le volume <code>open-webui</code> conserve toutes vos données — conversations, documents uploadés, modèles personnalisés, functions — au fil des redémarrages et des mises à jour du container. Ne l'oubliez pas.</p>
<div class="alertBox_R1Ot mk-callout info_J1rX mk-callout--info" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Note de version</p><div class="content_gFLB"><p>Open WebUI évolue vite. Les chemins de menu de cet article ont été vérifiés sur la documentation officielle en octobre 2026. Un changement récurrent d'une release à l'autre : tout ce qui concerne <strong>tous</strong> les utilisateurs (recherche web, paramètres des documents, Functions, clés d'API) se trouve dans l'<strong>Admin Panel</strong>, tandis que les briques personnelles (Models, Knowledge, Tools) sont dans le <strong>Workspace</strong>.</p></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="model-presets--same-ollama-model-different-persona">Presets de modèles — même modèle Ollama, personas différents<a href="#model-presets--same-ollama-model-different-persona" class="hash-link" aria-label="Lien direct vers Presets de modèles — même modèle Ollama, personas différents" title="Lien direct vers Presets de modèles — même modèle Ollama, personas différents" translate="no"></a></h2>
<p>Ollama n'a qu'un seul modèle <code>qwen3-coder:30b</code>. Mais vous voulez peut-être lui parler comme à un relecteur de code intransigeant à un moment, et comme à un partenaire de brainstorming juste après — avec des system prompts et des réglages de température différents pour chacun.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="workspace--models---new-model">Workspace → Models → + New Model<a href="#workspace--models---new-model" class="hash-link" aria-label="Lien direct vers Workspace → Models → + New Model" title="Lien direct vers Workspace → Models → + New Model" translate="no"></a></h3>
<p>Donnez-lui un nom (« Code Reviewer »), sélectionnez le modèle de base dans Ollama, puis configurez :</p>
<ul>
<li class=""><strong>System prompt</strong> : l'instruction qui cadre chaque conversation avec ce preset. Pour un relecteur de code : <em>"You are a strict code reviewer. Focus on correctness, security, and maintainability. Point out issues directly. Do not suggest cosmetic changes."</em></li>
<li class=""><strong>Temperature</strong> : basse (0.1–0.3) pour une sortie déterministe et structurée ; haute (0.7–0.9) pour les tâches créatives. La valeur par défaut est généralement 0.8.</li>
<li class=""><strong>Context length</strong> : combien de tokens de l'historique de conversation inclure. L'augmenter permet au modèle de se souvenir de plus de choses dans une longue conversation, au prix de plus de VRAM.</li>
</ul>
<p>Sauvegardez le preset. Il apparaît maintenant dans le sélecteur de modèle en haut de chaque chat. Vous passez de « Code Reviewer » à « Brainstorm Partner » ou « Explain Like I'm Five » aussi facilement que d'un onglet à l'autre.</p>
<div class="alertBox_R1Ot mk-callout tip_shCb mk-callout--tip" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Des emojis dans les noms de modèles</p><div class="content_gFLB"><p>Open WebUI affiche les noms de modèles dans la liste du sélecteur. Un préfixe emoji (<code>🔍 Code Reviewer</code>, <code>🧠 Brainstorm</code>, <code>🐣 ELI5</code>) rend la liste beaucoup plus lisible d'un coup d'œil.</p></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="knowledge--rag-on-your-local-documents">Knowledge — du RAG sur vos documents locaux<a href="#knowledge--rag-on-your-local-documents" class="hash-link" aria-label="Lien direct vers Knowledge — du RAG sur vos documents locaux" title="Lien direct vers Knowledge — du RAG sur vos documents locaux" translate="no"></a></h2>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="workspace--knowledge---new-knowledge">Workspace → Knowledge → + New Knowledge<a href="#workspace--knowledge---new-knowledge" class="hash-link" aria-label="Lien direct vers Workspace → Knowledge → + New Knowledge" title="Lien direct vers Workspace → Knowledge → + New Knowledge" translate="no"></a></h3>
<p>Une collection Knowledge est un groupe de documents qu'Open WebUI indexe et utilise pour ancrer ses réponses. Contrairement à un chat brut, les réponses d'une query RAG citent les passages précis dont elles sont issues. Si le Q&amp;R sur documents est tout ce dont vous avez besoin, <a href="/fr/blog/anythingllm-chat-with-your-docs/">AnythingLLM</a> est une alternative dédiée qui dialogue avec la même instance Ollama.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="ce-que-vous-pouvez-uploader">Ce que vous pouvez uploader<a href="#ce-que-vous-pouvez-uploader" class="hash-link" aria-label="Lien direct vers Ce que vous pouvez uploader" title="Lien direct vers Ce que vous pouvez uploader" translate="no"></a></h3>
<ul>
<li class="">des fichiers PDF (les documents scannés fonctionnent si le texte est extractible)</li>
<li class="">des fichiers Markdown</li>
<li class="">du texte brut, DOCX, XLSX</li>
<li class="">des URL de pages web (Open WebUI récupère et indexe le contenu)</li>
</ul>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="workflow">Workflow<a href="#workflow" class="hash-link" aria-label="Lien direct vers Workflow" title="Lien direct vers Workflow" translate="no"></a></h3>
<ol>
<li class="">Créez une collection Knowledge, par exemple « Project Documentation »</li>
<li class="">Uploadez vos documents (glisser-déposer dans la vue de la collection)</li>
<li class="">Attendez l'indexation — un indicateur de progression montre chaque fichier en cours de traitement</li>
<li class="">Dans un chat, cliquez sur le bouton <code>+</code> → <strong>Knowledge</strong> → sélectionnez votre collection</li>
<li class="">Posez vos questions : <em>"What does the API authentication section say about token expiry?"</em></li>
</ol>
<p>Open WebUI récupère les passages les plus pertinents et les inclut dans le contexte du modèle. Le modèle répond à partir de vos documents, pas de ses données d'entraînement — et cite le document et la section utilisés.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="cas-dusage-concrets">Cas d'usage concrets<a href="#cas-dusage-concrets" class="hash-link" aria-label="Lien direct vers Cas d'usage concrets" title="Lien direct vers Cas d'usage concrets" translate="no"></a></h3>
<ul>
<li class="">la documentation technique d'un projet sur lequel vous travaillez</li>
<li class="">une collection de notes Markdown que vous voulez interroger</li>
<li class="">un ensemble de specs PDF ou de RFC à recouper</li>
<li class="">vos propres articles de blog (interroger vos propres écrits)</li>
</ul>
<div class="alertBox_R1Ot mk-callout info_J1rX mk-callout--info" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Modèles d'embedding</p><div class="content_gFLB"><p>Le RAG nécessite un modèle d'embedding pour indexer les documents. Par défaut, Open WebUI utilise Ollama pour les embeddings s'il est disponible, sinon un sentence transformer intégré. Vous pouvez changer le modèle d'embedding dans <strong>Admin Panel → Settings → Documents → Embedding Model</strong>. Un modèle d'embedding dédié (comme <code>nomic-embed-text</code>) améliore la qualité de la recherche et consomme moins de VRAM que de passer par votre LLM principal.</p></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="web-search-integration">Intégration de la recherche web<a href="#web-search-integration" class="hash-link" aria-label="Lien direct vers Intégration de la recherche web" title="Lien direct vers Intégration de la recherche web" translate="no"></a></h2>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="admin-panel--settings--web-search">Admin Panel → Settings → Web Search<a href="#admin-panel--settings--web-search" class="hash-link" aria-label="Lien direct vers Admin Panel → Settings → Web Search" title="Lien direct vers Admin Panel → Settings → Web Search" translate="no"></a></h3>
<p>Activez-la et choisissez un moteur de recherche. Les options incluent SearXNG (auto-hébergé — il s'accorde naturellement avec une installation locale d'Open WebUI), DuckDuckGo, Google, Brave et d'autres. Pour SearXNG, vous pointez vers l'URL de votre propre instance et aucune clé d'API n'est nécessaire.</p>
<p>Avec la recherche web active, vous pouvez l'activer message par message via l'icône de recherche dans la barre d'outils du chat, ou l'activer par défaut pour un preset de modèle. Quand elle est active, Open WebUI interroge le moteur de recherche, récupère les premiers résultats et inclut leur contenu dans le contexte du modèle — lui donnant des informations actuelles au-delà de sa date de coupure d'entraînement.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="tools-and-functions">Tools et Functions — étendre Open WebUI avec Python<a href="#tools-and-functions" class="hash-link" aria-label="Lien direct vers Tools et Functions — étendre Open WebUI avec Python" title="Lien direct vers Tools et Functions — étendre Open WebUI avec Python" translate="no"></a></h2>
<p>Open WebUI dispose de deux systèmes de plugins Python, gérés à deux endroits différents :</p>
<ul>
<li class=""><strong>Tools</strong> (<strong>Workspace → Tools</strong>) : des fonctions que le modèle peut décider d'appeler pendant un chat, comme les outils MCP. Tout utilisateur qui en a la permission peut en ajouter.</li>
<li class=""><strong>Functions</strong> (<strong>Admin Panel → Functions</strong>) : des extensions de la plateforme elle-même, réservées aux admins, pour tous les utilisateurs. Leur type est déduit du nom de la classe :<!-- -->
<ul>
<li class="">les <strong>Pipes</strong> apparaissent comme un modèle sélectionnable et remplacent entièrement l'appel au modèle (appeler une API externe, chaîner des modèles, etc.)</li>
<li class="">les <strong>Filters</strong> modifient les messages à l'entrée ou à la sortie — pré-traiter votre prompt, post-traiter la réponse</li>
<li class="">les <strong>Actions</strong> ajoutent un bouton sous un message</li>
</ul>
</li>
</ul>
<p>Le plugin le plus simple est un Tool. En voici un qui encapsule un prompt de résumé — exactement celui qui se cache derrière la démo en début d'article :</p>

<p>Pour l'installer : <strong>Workspace → Tools → + New Tool → coller → Save</strong>.</p>
<p>Activez-le ensuite pour un chat avec le bouton <code>+</code> (ou attachez-le définitivement à un preset de modèle). Collez du texte, demandez un résumé, et le modèle appelle <code>summarize</code>, qui formule la requête selon le nombre de mots et la langue configurés.</p>
<p>Tools et Functions peuvent faire bien plus — interroger une base de données, appeler une API REST, lire des fichiers sur l'host — parce que ce sont du Python exécuté dans le container Open WebUI. Le pattern <code>Valves</code> (un modèle Pydantic) fournit des réglages configurables par l'admin, affichés dans le panneau de paramètres du plugin.</p>
<div class="alertBox_R1Ot mk-callout caution_LuPi mk-callout--warn" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">C'est du code arbitraire</p><div class="content_gFLB"><p>Tools et Functions exécutent du Python arbitraire sur votre serveur. Lisez le code avant d'en installer un — une fiche sur le site communautaire n'est pas un audit de sécurité.</p></div></div></div>
<div class="alertBox_R1Ot mk-callout tip_shCb mk-callout--tip" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Plugins communautaires</p><div class="content_gFLB"><p>Le <a href="https://openwebui.com/" target="_blank" rel="noopener noreferrer" class="">site communautaire</a> d'Open WebUI propose des Tools et des Functions prêts à importer pour les tâches courantes : scraping web, exécution de code, génération d'images, traduction, et plus encore.</p></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="api-keys">Clés d'API — appeler Open WebUI depuis vos scripts<a href="#api-keys" class="hash-link" aria-label="Lien direct vers Clés d'API — appeler Open WebUI depuis vos scripts" title="Lien direct vers Clés d'API — appeler Open WebUI depuis vos scripts" translate="no"></a></h2>
<p>L'appel <code>curl</code> en début d'article envoie <code>Authorization: Bearer $TOKEN</code>. Ce token est une clé d'API personnelle, et elle est désactivée par défaut. Trois interrupteurs séparent une installation fraîche d'une clé fonctionnelle.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="1-turn-the-feature-on-admin">1. Activer la fonctionnalité (admin)<a href="#1-turn-the-feature-on-admin" class="hash-link" aria-label="Lien direct vers 1. Activer la fonctionnalité (admin)" title="Lien direct vers 1. Activer la fonctionnalité (admin)" translate="no"></a></h3>
<p><strong>Admin Panel → Settings → General → Authentication</strong>, activez <strong>Enable API Key</strong>, puis <strong>Save</strong>.</p>
<p>C'est l'interrupteur général : tant qu'il est éteint, personne — pas même un admin — ne peut générer de clé. Dans la même section, <strong>API Key Endpoint Restrictions</strong> permet de limiter les routes qu'une clé peut appeler (par exemple uniquement <code>/api/models,/api/chat/completions</code>).</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="2-allow-users-to-create-keys-admin">2. Autoriser les utilisateurs à créer des clés (admin)<a href="#2-allow-users-to-create-keys-admin" class="hash-link" aria-label="Lien direct vers 2. Autoriser les utilisateurs à créer des clés (admin)" title="Lien direct vers 2. Autoriser les utilisateurs à créer des clés (admin)" translate="no"></a></h3>
<p>Les utilisateurs non-admin ont aussi besoin de la permission <strong>API Keys</strong> :</p>
<ul>
<li class=""><strong>Pour tout le monde</strong> : <strong>Admin Panel → Users → Groups → Default Permissions → Features Permissions</strong>, activez <strong>API Keys</strong>, puis <strong>Save</strong>.</li>
<li class=""><strong>Pour quelques personnes seulement</strong> (recommandé) : <strong>Admin Panel → Users → Groups</strong>, créez ou sélectionnez un groupe, ajoutez les utilisateurs, puis <strong>Permissions → Features Permissions → API Keys</strong>.</li>
</ul>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="3-create-the-key-user">3. Créer la clé (utilisateur)<a href="#3-create-the-key-user" class="hash-link" aria-label="Lien direct vers 3. Créer la clé (utilisateur)" title="Lien direct vers 3. Créer la clé (utilisateur)" translate="no"></a></h3>
<p>Cliquez sur votre icône de profil (en bas à gauche) → <strong>Settings → Account</strong>. La ligne <strong>API keys</strong> n'est qu'un titre : les contrôles se trouvent sur la ligne juste en dessous, <strong>Secrets</strong>. Cliquez sur <strong>Show</strong> sur cette ligne, puis sur <strong>Create new secret key</strong>. Une fois la clé créée, <strong>Copy API Key</strong> la place dans votre presse-papiers ; elle commence par <code>sk-</code>.</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>$ export TOKEN="sk-..."
$ curl -H "Authorization: Bearer $TOKEN" <a href="http://localhost:4000/api/models" target="_blank" rel="noopener noreferrer" class="">http://localhost:4000/api/models</a></p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Une liste JSON de modèles signifie que la clé fonctionne. Une clé porte exactement les permissions de l'utilisateur qui l'a créée — elle voit les mêmes modèles, les mêmes collections Knowledge et les mêmes Tools — alors créez-la depuis un compte qui n'a pas plus de droits que ce dont le script a besoin.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="4-pick-a-model-name-the-api-knows">4. Choisir un nom de modèle que l'API connaît<a href="#4-pick-a-model-name-the-api-knows" class="hash-link" aria-label="Lien direct vers 4. Choisir un nom de modèle que l'API connaît" title="Lien direct vers 4. Choisir un nom de modèle que l'API connaît" translate="no"></a></h3>
<p>Le champ <code>model</code> d'une requête doit être l'<strong><code>id</code> exact</strong> d'un modèle de votre instance — nom et tag compris. Tout le reste renvoie :</p>
<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token property">"detail"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token string" style="color:rgb(206, 145, 120)">"Model not found"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><br></div></code></pre></div></div>
<p>Ce message n'a rien à voir avec la clé : il signifie que le nom envoyé n'est pas dans la liste. Affichez les ids valides — le filtre <code>jq</code> ne garde que le champ <code>id</code> de chaque entrée (voir <a href="/fr/blog/linux-jq/">L'utilitaire jq pour Linux</a> si la syntaxe vous est inconnue) :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>$ curl -s -H "Authorization: Bearer $TOKEN" <a href="http://localhost:4000/api/models" target="_blank" rel="noopener noreferrer" class="">http://localhost:4000/api/models</a> | jq -r '.data[].id'</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Copiez-en un dans <code>MODEL</code>, comme dans l'exemple en début d'article.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="under-the-hood-skip-this-if-you-just-want-to-use-it">Sous le capot (passez si vous voulez juste l'utiliser)<a href="#under-the-hood-skip-this-if-you-just-want-to-use-it" class="hash-link" aria-label="Lien direct vers Sous le capot (passez si vous voulez juste l'utiliser)" title="Lien direct vers Sous le capot (passez si vous voulez juste l'utiliser)" translate="no"></a></h2>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="conversation-management">Gestion des conversations<a href="#conversation-management" class="hash-link" aria-label="Lien direct vers Gestion des conversations" title="Lien direct vers Gestion des conversations" translate="no"></a></h3>
<p>Quelques fonctionnalités qui rendent l'usage quotidien plus confortable :</p>
<p><strong>Folders</strong> : organisez vos conversations dans des dossiers nommés (clic droit sur une conversation dans la barre latérale). Pratique quand vous maintenez des fils séparés pour différents projets.</p>
<p><strong>Tags</strong> : taguez vos conversations pour les filtrer rapidement. La barre latérale a un filtre par tag en bas.</p>
<p><strong>Partage de conversation</strong> : générez un lien partageable pour n'importe quelle conversation (l'icône de partage dans l'en-tête du chat). Utile pour montrer à un collègue l'échange exact sans copier-coller.</p>
<p><strong>Regenerate</strong> : survolez n'importe quelle réponse du modèle et cliquez sur le bouton de régénération (les flèches circulaires) pour obtenir une réponse différente avec le même prompt et les mêmes réglages. Le modèle est stochastique — la température fait qu'un même prompt produit des sorties différentes.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="accessing-open-webui-from-other-devices">Accéder à Open WebUI depuis d'autres appareils<a href="#accessing-open-webui-from-other-devices" class="hash-link" aria-label="Lien direct vers Accéder à Open WebUI depuis d'autres appareils" title="Lien direct vers Accéder à Open WebUI depuis d'autres appareils" translate="no"></a></h3>
<p>Si Ollama tourne sur votre station de travail principale et qu'Open WebUI écoute sur le port 4000, les autres appareils de votre réseau local peuvent y accéder via <code>http://your-ip:4000</code>. L'article <a href="/fr/blog/accessing-ollama-across-your-local-network/">Accéder à Ollama depuis votre réseau local</a> couvre la configuration réseau — Open WebUI se place devant Ollama, la même approche s'applique donc.</p>
<p>Pour du HTTPS sur le réseau local (utile si vous voulez utiliser les API caméra ou micro dans le navigateur), placez un reverse proxy comme Caddy devant Open WebUI.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="conclusion">Conclusion<a href="#conclusion" class="hash-link" aria-label="Lien direct vers Conclusion" title="Lien direct vers Conclusion" translate="no"></a></h2>
<p>La valeur d'Open WebUI se cumule à mesure que vous empilez ces fonctionnalités. Un preset de modèle avec le bon system prompt et la bonne température supprime la friction de réexpliquer votre contexte à chaque chat. Une base de connaissances RAG ancre les réponses dans vos vrais documents. La recherche web ajoute l'information actuelle. Tools et Functions ajoutent des capacités que le modèle de base n'a pas, et une clé d'API ouvre tout cela à vos scripts — l'appel de résumé en début d'article en est le plus petit exemple.</p>
<p>La fenêtre de chat n'est qu'un début — passez une après-midi dans Workspace et Settings, et vous découvrirez un environnement IA local réellement configurable, qui vaut le temps investi à le mettre en place correctement.</p></div><hr></div><div class="relatedPosts_zJiB"><h3>Articles liés</h3></div><div class="container_mNR1"><span class="question_WdqX">Cet article vous a-t-il été utile ?</span><div class="buttons_vgpi"><button class="btn_svQp" aria-label="Oui, cet article m'a été utile"><img src="/fr/img/reaction-mascot-yes.webp" alt="" class="mascot_Fait">Utile</button><button class="btn_svQp btnNeutral_Iqek" aria-label="Non, cet article ne m'a pas été utile"><img src="/fr/img/reaction-mascot-no.webp" alt="" class="mascot_Fait">Pas vraiment</button></div></div>]]></content:encoded>
            <author>Christophe</author>
            <author>Claude Code</author>
            <category>ai</category>
            <category>docker</category>
            <category>ollama</category>
            <category>self-hosted</category>
            <enclosure length="0" type="image/webp" url="https://www.avonture.be/fr/img/v2/exploring_webgui.webp"/>
        <dc:creator><![CDATA[Christophe]]></dc:creator><dc:creator><![CDATA[Claude Code]]></dc:creator></item>
        <item>
            <title><![CDATA[ai-data: Let a Local LLM Write Your jq and awk One-Liners]]></title>
            <link>https://www.avonture.be/fr/fr/blog/ollama-ai-data</link>
            <guid isPermaLink="false">/fr/blog/ollama-ai-data</guid>
            <pubDate>Thu, 22 Oct 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[<img src="https://www.avonture.be/fr/img/v2/json.webp" alt="ai-data: Let a Local LLM Write Your jq and awk One-Liners" style="display:block;max-width:100%;height:auto;">A zsh function that reads a JSON or CSV file, understands its actual fields, and suggests 5 practical jq/awk commands tailored to that exact file — picked via fzf, then loaded onto your command line (not run for you) so you see, edit and learn the exact command before pressing Enter.]]></description>
            <content:encoded><![CDATA[<div class="offer_jqr1"><span class="flag_Hnep" aria-hidden="true">🇬🇧</span><span>Cet article existe aussi en anglais —<!-- --> <a href="/blog/ollama-ai-data/" hreflang="en">lire la version originale</a>.</span></div><div class="alertBox_R1Ot mk-callout caution_LuPi mk-callout--warn" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Traduction automatique</p><div class="content_gFLB">Cette traduction a été produite par une IA. Elle peut contenir des contresens ou des maladresses, en particulier sur les termes techniques. En cas de doute,<!-- --> <a href="/blog/ollama-ai-data/">consultez l'article de référence en anglais</a> <!-- -->— c'est lui qui fait foi.</div></div></div><div><div class="seriesBlogPost_bjUd"><details class="details_rTsq"><summary class="seriesIntro_HIK1 mk-eyebrow">Cet article fait partie de la série <a href="/fr/series/ollama-daily-use/">Ollama au quotidien</a> :</summary><div class="content_OqVG"><ul><li><a href="/fr/blog/ollama-installation/">Installer Ollama et obtenir une IA locale</a></li><li><a href="/fr/blog/accessing-ollama-across-your-local-network/">Accéder à Ollama depuis votre réseau local</a></li><li><a href="/fr/blog/ollama-test-generator/">ai-test : générer les tests unitaires manquants depuis votre terminal avec un LLM local</a></li><li><a href="/fr/blog/ollama-git-precommit/">ai-review, ai-secrets, ai-commit : trois vérifications zsh avant chaque git commit</a></li><li><a href="/fr/blog/anythingllm-chat-with-your-docs/">AnythingLLM : discuter avec toute votre documentation éparpillée</a></li><li><a href="/fr/blog/ollama-ai-docs/">ai-translate &amp; ai-summarize : des documents confidentiels, traités 100 % en local</a></li><li><a href="/fr/blog/ollama-ai-diagram/">ai-diagram : d'une phrase (ou d'un compose.yaml) à un diagramme Mermaid</a></li><li><a href="/fr/blog/ollama-ai-data/">ai-data : laissez un LLM local écrire vos one-liners jq et awk</a></li><li><a href="/fr/blog/ollama-ai-explain/">ai-explain : l'ELI5 des scripts et des erreurs, directement dans votre terminal</a></li></ul></div></details></div><div><p><img alt="ai-data : laissez un LLM local écrire vos one-liners jq et awk" src="/fr/assets/images/json-7cc484d52d3a6218a8b967ba55092cc8.webp" height="auto" width="" loading="lazy" decoding="async" class=""></p>
<div class="tldrContainer_qQW_ mk-callout mk-callout--key" role="region" aria-label="Résumé de l'article"><span role="img" aria-label="éclair" class="tldrIcon_SLBz mk-dot">⚡</span><div class="tldrBody_lGty"><strong class="tldrTitle_Aha8 mk-eyebrow">TL;DR</strong><div class="tldrContent_m27u"><p>Cet article ajoute <code>ai-data</code> à la série « Ollama daily use » : pointez-la vers un fichier <code>.json</code> ou <code>.csv</code> et elle demande au modèle local 5 commandes <code>jq</code>/<code>awk</code> pratiques, adaptées aux noms de champs réels de ce fichier — pas des exemples génériques. Choisissez-en une avec <code>fzf</code> et, au lieu de l'exécuter pour vous, <code>ai-data</code> la dépose sur votre ligne de commande zsh avec <code>print -z</code>, prête à être lue, modifiée, puis seulement exécutée. L'objectif n'est pas juste d'avoir une réponse : c'est de repartir avec une commande que vous comprenez.</p></div></div></div>
<p>Mon usage de <a href="/fr/blog/linux-jq/"><code>jq</code></a> reste très basique ; je suis loin de savoir écrire une ligne avec <code>group_by</code> du premier coup. À chaque fois, c'est la même histoire : je dois bricoler (et perdre plusieurs minutes) juste pour obtenir ce que je veux — ou même… je ne sais même pas qu'une certaine fonctionnalité existe. Avec <code>awk</code>, c'est encore pire : je sais que ça existe et que c'est incroyablement puissant, mais la syntaxe est tout simplement impossible.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="demo--json">Démo — JSON<a href="#demo--json" class="hash-link" aria-label="Lien direct vers Démo — JSON" title="Lien direct vers Démo — JSON" translate="no"></a></h2>
<div class="terminal_qOWF" style="cursor:pointer" title="Cliquez pour passer l'animation"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><span class="terminal_ghost_aie1" aria-hidden="true">$ ai-data users.json

→ Analyzing users.json (json)...

ai-data &gt;
  5/5 ────────────────────────────────
  Extract all user names from the JSON file
  Count how many active users exist in the file
  Display name and birth date for each user, separated by colon
&gt; Group users by active status and count them
  Find names of users born in the 1990s

$ jq 'group_by(.active) | map({"active": .[0].active, "count": length})' users.json

[
  {
    "active": false,
    "count": 1
  },
  {
    "active": true,
    "count": 2
  }
]
</span><span class="terminal_overlay_fznj"><span class="cursor_o7aQ" aria-hidden="true">▋</span></span></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Cinq suggestions, listées par ce à quoi elles répondent — chacune construite sur les vrais noms de champs (<code>active</code>, <code>birth_date</code>, <code>name</code>) que le modèle a vus dans l'échantillon, pas sur des placeholders d'un tutoriel <code>jq</code>. En choisissant la quatrième dans <code>fzf</code>, ce filtre <code>group_by</code> exact s'est retrouvé sur mon prompt ; je l'ai lu, il avait l'air correct, j'ai appuyé sur Entrée.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="demo--csv">Démo — CSV<a href="#demo--csv" class="hash-link" aria-label="Lien direct vers Démo — CSV" title="Lien direct vers Démo — CSV" translate="no"></a></h2>
<div class="terminal_qOWF" style="cursor:pointer" title="Cliquez pour passer l'animation"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><span class="terminal_ghost_aie1" aria-hidden="true">$ ai-data orders.csv

→ Analyzing orders.csv (csv)...

ai-data &gt;
  5/5 ────────────────────────────────
  Show order IDs and amounts for EMEA region only
  Calculate total sales amount across all orders
&gt; Count orders by region and display in descending order
  Display all orders with amount greater than 100
  Show first 10 orders with all fields

$ awk -F, 'NR&gt;1 {print $2}' orders.csv | sort | uniq -c | sort -nr

      2 EMEA
      2 APAC
      1 AMER
</span><span class="terminal_overlay_fznj"><span class="cursor_o7aQ" aria-hidden="true">▋</span></span></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Le modèle a remarqué tout seul que <code>region</code> est la colonne intéressante pour un regroupement, et a répondu avec le classique pipeline <code>awk | sort | uniq -c</code> — le genre de ligne que je reconnais quand je la vois mais que je n'écris jamais de mémoire. L'avoir lue une fois sur mon propre prompt, je la comprends mieux que si j'avais juste vu la sortie.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="the-idea--suggestions-not-answers">L'idée — des suggestions, pas des réponses<a href="#the-idea--suggestions-not-answers" class="hash-link" aria-label="Lien direct vers L'idée — des suggestions, pas des réponses" title="Lien direct vers L'idée — des suggestions, pas des réponses" translate="no"></a></h2>
<p>La plupart des fonctions de cette série posent une question au modèle et affichent la réponse — <a href="/fr/blog/ollama-ai-diagram/">ai-diagram</a> affiche un bloc Mermaid, prêt à coller. <code>ai-data</code> est différente volontairement : elle demande au modèle des <strong>commandes candidates</strong>, les présente via <a href="/fr/blog/linux-fzf-introduction/"><code>fzf</code></a>, et quand vous en choisissez une, elle ne l'exécute pas — elle la dépose sur votre prompt, modifiable, grâce au <code>print -z</code> de zsh. Vous lisez le filtre <code>jq</code> ou le script <code>awk</code> exact avant qu'il ne touche vos données, et comme c'est vous qui appuyez sur Entrée, vous assimilez vraiment la syntaxe au lieu de simplement consommer la sortie.</p>
<div class="alertBox_R1Ot mk-callout tip_shCb mk-callout--tip" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">print -z, en une phrase</p><div class="content_gFLB"><p><code>print -z "some command"</code> pousse une chaîne dans le buffer d'édition de zsh — le prompt suivant l'affiche pré-tapée, exactement comme si vous l'aviez saisie vous-même, curseur prêt, rien d'exécuté.</p></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="the-ai-data-function">La fonction <code>ai-data</code><a href="#the-ai-data-function" class="hash-link" aria-label="Lien direct vers the-ai-data-function" title="Lien direct vers the-ai-data-function" translate="no"></a></h2>
<p>Pas besoin de copier quoi que ce soit pour l'instant : les fichiers <code>.zsh</code> montrés dans cet article sont là pour être lus, et <a href="#installing-it" class="">L'installer</a> à la fin les crée tous dans <code>~/.zsh/fns/</code> pour vous.</p>

<p>Étape par étape :</p>
<ol>
<li class="">Détecter JSON ou CSV à partir de l'extension.</li>
<li class="">Construire un petit échantillon réel — pour le JSON, les 3 premiers éléments s'il s'agit d'un tableau (<code>jq -c '.[0:3]'</code>) ; pour le CSV, l'en-tête plus 5 lignes. Jamais le fichier entier : un CSV de 50 000 lignes n'a pas besoin d'en laisser plus de quelques-unes dans le prompt.</li>
<li class="">Pour le CSV, vérifier si <a href="https://miller.readthedocs.io/" target="_blank" rel="noopener noreferrer" class=""><code>mlr</code></a> (Miller) est installé — si oui, on indique au modèle qu'il peut proposer <code>mlr</code> pour les regroupements/agrégations, ce qui se lit nettement mieux que l'équivalent <code>awk</code>. Sinon, il s'en tient à <code>awk</code>/<code>sort</code>/<code>uniq</code>.</li>
<li class="">Demander exactement 5 commandes, une par ligne, au format <code>COMMAND ||| description</code> — délibérément pas une sortie JSON, car parser cinq lignes de « texte avant un triple pipe » est trivial et évite le risque que le modèle emballe sa réponse dans des blocs Markdown ou ajoute des commentaires.</li>
<li class="">Envoyer les cinq lignes dans <code>fzf</code>, en n'affichant que la description (<code>--with-nth=2</code>) pour que vous choisissiez par <em>intention</em>, et non en plissant les yeux sur de la syntaxe <code>jq</code>.</li>
<li class="">À la sélection, <code>print -rz</code> du premier champ de la commande choisie.</li>
</ol>
<div class="alertBox_R1Ot mk-callout note_xvll mk-callout--note" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Pas de fzf ? Ça marche quand même</p><div class="content_gFLB"><p>Sans <code>fzf</code> installé, <code>ai-data</code> affiche simplement les cinq suggestions en texte brut — vous copiez celle que vous voulez manuellement. Même information, un confort en moins.</p></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="registered-in-the-ai-menu">Enregistrée dans le menu <code>ai</code><a href="#registered-in-the-ai-menu" class="hash-link" aria-label="Lien direct vers registered-in-the-ai-menu" title="Lien direct vers registered-in-the-ai-menu" translate="no"></a></h2>
<p>Une ligne — <code>AI_COMMANDS[data]=...</code> — accessible via <code>ai data &lt;file&gt;</code> ou directement avec <code>ai-data</code>, aux côtés de toutes les autres fonctions de la série.</p>
<p><code>_ollama_query</code> et le registre <code>AI_COMMANDS</code> vivent dans la fondation partagée <code>_ollama.zsh</code> introduite dans <a href="/fr/blog/ollama-test-generator/">ai-test</a>. Si vous avez déjà suivi un article précédent de cette série, elle est déjà dans <code>~/.zsh/fns/</code> et sourcée par votre autoloader — rien de plus à faire. Vous commencez ici ? C'est un des fichiers créés dans <a href="#installing-it" class="">L'installer</a> plus bas.</p>
<div class="alertBox_R1Ot mk-callout caution_LuPi mk-callout--warn" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">L'échantillonnage veut dire que le modèle peut se tromper sur les cas limites</p><div class="content_gFLB"><p>Cinq lignes ne révéleront pas un champ <code>null</code> rare, un format de date incohérent trois lignes plus loin ou une colonne supplémentaire égarée. Les commandes proposées sont un bon point de départ, pas une garantie — exécutez-les, vérifiez que le nombre de lignes semble cohérent, et ajustez si les données réservent des surprises que l'échantillon ne montrait pas.</p></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="key-takeaways">Points clés<a href="#key-takeaways" class="hash-link" aria-label="Lien direct vers Points clés" title="Lien direct vers Points clés" translate="no"></a></h2>
<div class="steps_wrapper_r_yO" data-variant="remember"><p class="steps_title_bD7c mk-eyebrow">ai-data en bref</p><ul class="steps_list_Vtzi"><li class="step_item_FtAN"><span class="step_bullet_sanI mk-dot" aria-hidden="true">•</span><div class="step_content_KvQY"><span><strong>Un échantillon, pas le fichier entier</strong> — 3 éléments JSON ou 5 lignes CSV, assez pour voir les vrais noms de champs</span></div></li><li class="step_item_FtAN"><span class="step_bullet_sanI mk-dot" aria-hidden="true">•</span><div class="step_content_KvQY"><span><strong>Des suggestions, pas une seule réponse</strong> — 5 commandes candidates, choisies par intention via <code>fzf</code></span></div></li><li class="step_item_FtAN"><span class="step_bullet_sanI mk-dot" aria-hidden="true">•</span><div class="step_content_KvQY"><span><strong><code>print -z</code>, pas d'exécution automatique</strong> — la commande choisie arrive sur votre prompt, modifiable, rien ne s'exécute avant que vous appuyiez sur Entrée</span></div></li><li class="step_item_FtAN"><span class="step_bullet_sanI mk-dot" aria-hidden="true">•</span><div class="step_content_KvQY"><span><strong><code>mlr</code> quand il est disponible</strong> — syntaxe de regroupement/agrégation plus agréable que <code>awk</code> si Miller est installé</span></div></li><li class="step_item_FtAN"><span class="step_bullet_sanI mk-dot" aria-hidden="true">•</span><div class="step_content_KvQY"><span><strong>S'enregistre dans <code>ai</code></strong> — accessible via <code>ai data &lt;file&gt;</code></span></div></li></ul></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="installing-it">L'installer<a href="#installing-it" class="hash-link" aria-label="Lien direct vers L'installer" title="Lien direct vers L'installer" translate="no"></a></h2>
<p>Tout tourne sur votre machine : zsh, <code>curl</code> et <code>jq</code> (qui construit la requête envoyée à Ollama), et un serveur Ollama joignable — voyez <a href="/fr/blog/ollama-installation/">installer Ollama</a> si vous n'en avez pas encore.</p>
<p><strong>1. Créez les fichiers.</strong> Chaque fonction de cette série vit dans son propre fichier dans <code>~/.zsh/fns/</code>. La commande ci-dessous crée ce dossier et les deux fichiers d'un coup — ou téléchargez le ZIP, ou créez chaque fichier à la main et collez-y son contenu. <code>_ollama.zsh</code> est la fondation partagée par toute la série : si un article précédent vous l'a déjà donnée, la copie ici est le même fichier.</p>
<div class="projectSetupContainer_LW1i mk-surface"><div class="header_Htsx mk-toolbar"><strong>📦 <!-- -->Mise en place du projet :<!-- --> <!-- -->~/.zsh/fns</strong><div class="buttonGroup_y07f mk-toolbar__actions"><a class="button button--secondary button--sm" href="/project_setup" target="_blank">Aide</a><button class="button button--secondary button--sm">Tout déplier</button><button class="button button--outline button--primary button--sm" aria-label="Télécharger le projet en ZIP">ZIP</button><button class="button button--secondary button--sm">Générer le script d'installation</button></div></div><div class="content_bo8S"><div><p class="tip_qyDH"><small><span aria-hidden="true">💡</span> <!-- -->Besoin d'aide ? Cliquez sur le bouton « Aide » ci-dessus pour les instructions de mise en place.</small></p></div></div></div>
<p><strong>2. Chargez le dossier depuis <code>~/.zshrc</code>.</strong> Déjà fait si vous avez suivi un article précédent de cette série. Sinon, ajoutez ces lignes à la fin de <code>~/.zshrc</code> — elles sourcent chaque fichier <code>.zsh</code> du dossier, donc la prochaine fonction que vous ajouterez ne demandera aucun changement ici :</p>
<div class="language-zsh codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockTitle_OeMC">~/.zshrc</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-zsh codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">for fn_file in ~/.zsh/fns/*.zsh; do</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  source "$fn_file"</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">done</span><br></div></code></pre></div></div>
<p><strong>3. Ouvrez un nouveau terminal</strong> (ou lancez <code>exec zsh</code> dans celui en cours), et vérifiez que le shell connaît la nouvelle fonction :</p>
<div class="language-zsh codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-zsh codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">type ai-data</span><br></div></code></pre></div></div>
<p><code>ai-data is a shell function from …/ai-data.zsh</code> signifie que vous êtes prêt. <code>ai-data not found</code> signifie que l'étape 2 manque.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="conclusion">Conclusion<a href="#conclusion" class="hash-link" aria-label="Lien direct vers Conclusion" title="Lien direct vers Conclusion" translate="no"></a></h2>
<p><code>ai-data</code> est la fonction de cette série dont j'attends le plus en termes d'apprentissage, justement parce qu'elle refuse de me donner une réponse toute faite — elle me donne la commande et me laisse être celui qui la lance. Quelques semaines à piocher des filtres <code>jq</code> dans une liste <code>fzf</code> et à les lire avant d'appuyer sur Entrée, et je soupçonne que j'en aurai moins besoin qu'aujourd'hui. Ce serait une forme étrange de succès pour un outil d'IA : l'objectif n'est pas de l'utiliser pour toujours, c'est de se rendre discrètement moins nécessaire, un <code>group_by</code> à la fois.</p></div><hr><div class="seriesBlogPost_bjUd"><details class="details_rTsq"><summary class="seriesIntro_HIK1 mk-eyebrow">Cet article fait partie de la série <a href="/fr/series/ollama-daily-use/">Ollama au quotidien</a> :</summary><div class="content_OqVG"><ul><li><a href="/fr/blog/ollama-installation/">Installer Ollama et obtenir une IA locale</a></li><li><a href="/fr/blog/accessing-ollama-across-your-local-network/">Accéder à Ollama depuis votre réseau local</a></li><li><a href="/fr/blog/ollama-test-generator/">ai-test : générer les tests unitaires manquants depuis votre terminal avec un LLM local</a></li><li><a href="/fr/blog/ollama-git-precommit/">ai-review, ai-secrets, ai-commit : trois vérifications zsh avant chaque git commit</a></li><li><a href="/fr/blog/anythingllm-chat-with-your-docs/">AnythingLLM : discuter avec toute votre documentation éparpillée</a></li><li><a href="/fr/blog/ollama-ai-docs/">ai-translate &amp; ai-summarize : des documents confidentiels, traités 100 % en local</a></li><li><a href="/fr/blog/ollama-ai-diagram/">ai-diagram : d'une phrase (ou d'un compose.yaml) à un diagramme Mermaid</a></li><li><a href="/fr/blog/ollama-ai-data/">ai-data : laissez un LLM local écrire vos one-liners jq et awk</a></li><li><a href="/fr/blog/ollama-ai-explain/">ai-explain : l'ELI5 des scripts et des erreurs, directement dans votre terminal</a></li></ul></div></details></div></div><div class="relatedPosts_zJiB"><h3>Articles liés</h3></div><div class="container_mNR1"><span class="question_WdqX">Cet article vous a-t-il été utile ?</span><div class="buttons_vgpi"><button class="btn_svQp" aria-label="Oui, cet article m'a été utile"><img src="/fr/img/reaction-mascot-yes.webp" alt="" class="mascot_Fait">Utile</button><button class="btn_svQp btnNeutral_Iqek" aria-label="Non, cet article ne m'a pas été utile"><img src="/fr/img/reaction-mascot-no.webp" alt="" class="mascot_Fait">Pas vraiment</button></div></div><div class="container_IJwe"><span class="question_nb7b">Avez-vous suivi les étapes de cet article ?</span><div class="buttons_Xkzw"><button class="btn_RMiq" aria-label="Oui, j'ai essayé et ça a marché"><img src="/fr/img/like.webp" alt="" class="mascot_wB1d">Ça a marché !</button><button class="btn_RMiq btnNeutral_z71B" aria-label="Non, ça n'a pas marché pour moi"><img src="/fr/img/dislike.webp" alt="" class="mascot_wB1d">Ça n'a pas marché</button></div></div>]]></content:encoded>
            <author>Christophe</author>
            <author>Claude Code</author>
            <category>ai</category>
            <category>ollama</category>
            <category>zsh</category>
            <category>fzf</category>
            <enclosure length="0" type="image/webp" url="https://www.avonture.be/fr/img/v2/json.webp"/>
        <dc:creator><![CDATA[Christophe]]></dc:creator><dc:creator><![CDATA[Claude Code]]></dc:creator></item>
        <item>
            <title><![CDATA[yq — The YAML Processor You Already Know How to Use]]></title>
            <link>https://www.avonture.be/fr/fr/blog/linux-yq</link>
            <guid isPermaLink="false">/fr/blog/linux-yq</guid>
            <pubDate>Mon, 19 Oct 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[<img src="https://www.avonture.be/fr/img/v2/linux-yq.webp" alt="yq — The YAML Processor You Already Know How to Use" style="display:block;max-width:100%;height:auto;">yq is to YAML what jq is to JSON. Query and edit your Docker Compose files, CI configs, and any YAML document from the command line — no installation required beyond Docker.]]></description>
            <content:encoded><![CDATA[<div class="offer_jqr1"><span class="flag_Hnep" aria-hidden="true">🇬🇧</span><span>Cet article existe aussi en anglais —<!-- --> <a href="/blog/linux-yq/" hreflang="en">lire la version originale</a>.</span></div><div class="alertBox_R1Ot mk-callout caution_LuPi mk-callout--warn" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Traduction automatique</p><div class="content_gFLB">Cette traduction a été produite par une IA. Elle peut contenir des contresens ou des maladresses, en particulier sur les termes techniques. En cas de doute,<!-- --> <a href="/blog/linux-yq/">consultez l'article de référence en anglais</a> <!-- -->— c'est lui qui fait foi.</div></div></div><div><div class="seriesBlogPost_bjUd"><details class="details_rTsq"><summary class="seriesIntro_HIK1 mk-eyebrow">Cet article fait partie de la série <a href="/fr/series/modern-cli-tools-for-your-terminal/">Outils CLI modernes pour votre terminal</a> :</summary><div class="content_OqVG"><ul><li><a href="/fr/blog/linux-jq/">L'utilitaire jq pour Linux</a></li><li><a href="/fr/blog/linux-xmlstarlet/">L'utilitaire xmlstarlet pour Linux</a></li><li><a href="/fr/blog/linux-fzf-introduction/">Introduction à fzf - Fuzzy Finder</a></li><li><a href="/fr/blog/linux-eza/">Revisitons la commande ls grâce à eza</a></li><li><a href="/fr/blog/ssh-with-fuzzy-finder/">Maîtrisez votre commande ssh et choisissez l'host dans une liste</a></li><li><a href="/fr/blog/fzf-ripgrep/">FZF + ripgrep : recherche de code interactive avec aperçu en direct</a></li><li><a href="/fr/blog/git-delta/">delta : un pager avec coloration syntaxique pour git diff</a></li><li><a href="/fr/blog/ripgrep/">ripgrep — l'outil de recherche qui a changé mon workflow WSL2</a></li><li><a href="/fr/blog/linux-yq/">yq — Le processeur YAML que vous savez déjà utiliser</a></li></ul></div></details></div><div class="revisionHistoryContainer_DGTV mk-surface margin-top--lg"><h3 class="revisionHistoryTitle_eX7C mk-eyebrow"><span aria-hidden="true">📜</span> <!-- -->Journal des modifications</h3><div class="timeline_jZcr"><div class="timelineItem_LQdV timelineItemLatest_AzU9"><div class="timelineIcon_qAPq mk-dot"><span aria-hidden="true">✨</span></div><div class="timelineContent_eAHl"><div class="timelineDate_GzO4"><time datetime="2026-11-08T00:00:00.000Z">8 novembre 2026</time><span class="latestBadge_s2Ev mk-pill">Dernière</span></div><div class="timelineDescription_SGKc">The Docker alias is now a yq shell function for zsh and bash, in its own file (~/.zsh/fns or ~/.bash/fns).</div></div></div></div></div><div><p><img alt="yq — Le processeur YAML que vous savez déjà utiliser" src="/fr/assets/images/linux-yq-64f26ff3921b8bf27462fb7971eb0cd5.webp" height="auto" width="" loading="lazy" decoding="async" class=""></p>
<div class="tldrContainer_qQW_ mk-callout mk-callout--key" role="region" aria-label="Résumé de l'article"><span role="img" aria-label="éclair" class="tldrIcon_SLBz mk-dot">⚡</span><div class="tldrBody_lGty"><strong class="tldrTitle_Aha8 mk-eyebrow">TL;DR</strong><div class="tldrContent_m27u"><p><code>yq</code> est un processeur YAML en ligne de commande avec la même syntaxe que <code>jq</code>. Si vous utilisez déjà <code>jq</code> pour JSON, vous savez déjà utiliser <code>yq</code>. Lancez-le sans rien installer grâce à un alias Docker d'une ligne, puis interrogez et modifiez vos <code>compose.yaml</code>, vos pipelines CI et n'importe quel fichier YAML directement depuis le terminal.</p></div></div></div>
<p>Vous avez un <code>compose.yaml</code> avec dix services. Vous voulez savoir quelle image utilise le service <code>db</code> sans ouvrir d'éditeur. Vous voulez incrémenter un numéro de version dans un fichier CI sans une one-liner sed que vous ne retiendrez jamais. Vous voulez extraire tous les noms de services dans une liste.</p>
<p>Ce sont des problèmes pour <code>jq</code>. Mais vos fichiers sont en YAML, pas en JSON.</p>
<p>Voici <code>yq</code>.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="what-yq-is--and-which-one">Ce qu'est yq — et lequel<a href="#what-yq-is--and-which-one" class="hash-link" aria-label="Lien direct vers Ce qu'est yq — et lequel" title="Lien direct vers Ce qu'est yq — et lequel" translate="no"></a></h2>
<p>Il existe deux outils nommés <code>yq</code>. Cet article traite de <a href="https://github.com/mikefarah/yq" target="_blank" rel="noopener noreferrer" class=""><code>yq</code> de Mike Farah</a> (Go, image Docker <code>mikefarah/yq</code>), qui gère nativement YAML et offre une syntaxe compatible <code>jq</code>. L'autre (<code>kislyuk/yq</code>) encapsule <code>jq</code> après avoir converti le YAML en JSON — idée similaire, comportement différent pour les fonctionnalités propres à YAML comme les commentaires et les fichiers multi-documents. Sur Debian et Ubuntu, <code>apt install yq</code> installe cet autre outil (<code>kislyuk/yq</code> 3.x), pas celui utilisé ici.</p>
<p>Si vous avez la version Go installée localement (<code>yq --version</code> renvoie quelque chose comme <code>yq (https://github.com/mikefarah/yq/) version v4.x</code>), tout est prêt. Sinon, l'approche Docker ci-dessous ne demande rien de plus.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="seeing-it-in-action-with-docker">Le voir à l'œuvre avec Docker<a href="#seeing-it-in-action-with-docker" class="hash-link" aria-label="Lien direct vers Le voir à l'œuvre avec Docker" title="Lien direct vers Le voir à l'œuvre avec Docker" translate="no"></a></h2>
<p>Avant d'installer quoi que ce soit (ou même de mettre en place l'alias ci-dessous), essayez <code>yq</code> dans un
container jetable — le <code>compose.yaml</code> exact de cet article s'y trouve déjà.</p>
<div class="alertBox_R1Ot mk-callout tip_shCb mk-callout--tip" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Pourquoi Docker d'abord ?</p><div class="content_gFLB"><p>Le Dockerfile ci-dessous installe directement le binaire <code>yq</code> et dépose le <code>compose.yaml</code> exact dans le
répertoire de travail. Rien à créer, à coller ou à télécharger.</p></div></div></div>

<p>Buildez et lancez :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yq-demo</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>$ docker build -t yq-demo .
[+] Building 8.7s (7/7) FINISHED
✔ exporting to image</p><p>$ docker run --rm -it yq-demo
🐳 root ~/demo # yq '.services.web.image' compose.yaml
nginx:1.25</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Même fichier, même query, même résultat — testez directement dessus le reste des sections « Lire des valeurs »
et « Modifier sur place » ci-dessous, il n'y a plus rien à préparer.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="install--or-just-alias">Installer — ou juste créer un alias<a href="#install--or-just-alias" class="hash-link" aria-label="Lien direct vers Installer — ou juste créer un alias" title="Lien direct vers Installer — ou juste créer un alias" translate="no"></a></h2>
<p>Un gestionnaire de packages installe la version Go sur toutes les plateformes :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>$ brew install yq            # macOS and Linux (Homebrew)
$ winget install MikeFarah.yq  # Windows</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Ne rien installer du tout est aussi une option : une petite fonction <code>yq</code> lance l'image officielle à la place, dans son propre fichier placé dans un dossier que votre shell charge au démarrage (l'organisation expliquée dans <a href="/fr/blog/modular-zsh-workflow/">mon workflow ZSH modulaire</a>) :</p>

<p>Elle monte votre répertoire courant dans le container et exécute <code>yq</code> de façon transparente, avec votre propre ID utilisateur pour que les fichiers modifiés avec <code>-i</code> gardent leur propriétaire. Toutes les commandes <code>yq</code> ci-dessous fonctionnent avec elle telles quelles. Le code est identique pour zsh et bash ; seuls le dossier et le chargeur diffèrent.</p>
<div class="theme-tabs-container tabs-container tabList__CuJ"><ul role="tablist" aria-orientation="horizontal" class="tabs"><li role="tab" tabindex="0" aria-selected="true" class="tabs__item tabItem_LNqP tabs__item--active">zsh</li><li role="tab" tabindex="-1" aria-selected="false" class="tabs__item tabItem_LNqP">bash</li></ul><div class="margin-top--md"><div role="tabpanel" class="tabItem_Ymn6"><p><strong>1. Créez le fichier</strong> dans <code>~/.zsh/fns/</code> :</p><div class="projectSetupContainer_LW1i mk-surface"><div class="header_Htsx mk-toolbar"><strong>📦 <!-- -->Mise en place du projet :<!-- --> <!-- -->~/.zsh/fns</strong><div class="buttonGroup_y07f mk-toolbar__actions"><a class="button button--secondary button--sm" href="/project_setup" target="_blank">Aide</a><button class="button button--secondary button--sm">Tout déplier</button><button class="button button--outline button--primary button--sm" aria-label="Télécharger le projet en ZIP">ZIP</button><button class="button button--secondary button--sm">Générer le script d'installation</button></div></div><div class="content_bo8S"><div><p class="tip_qyDH"><small><span aria-hidden="true">💡</span> <!-- -->Besoin d'aide ? Cliquez sur le bouton « Aide » ci-dessus pour les instructions de mise en place.</small></p></div></div></div><p><strong>2. Chargez le dossier depuis <code>~/.zshrc</code>.</strong> C'est déjà fait si vous avez suivi un de mes articles « Ollama au quotidien ». Sinon, ajoutez ceci à la fin de <code>~/.zshrc</code> :</p><div class="language-zsh codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockTitle_OeMC">~/.zshrc</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-zsh codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">for fn_file in ~/.zsh/fns/*.zsh; do</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  source "$fn_file"</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">done</span><br></div></code></pre></div></div><p><strong>3. Ouvrez un nouveau terminal</strong> (ou lancez <code>exec zsh</code>).</p></div><div role="tabpanel" class="tabItem_Ymn6" hidden=""><p><strong>1. Créez le fichier</strong> dans <code>~/.bash/fns/</code> :</p><div class="projectSetupContainer_LW1i mk-surface"><div class="header_Htsx mk-toolbar"><strong>📦 <!-- -->Mise en place du projet :<!-- --> <!-- -->~/.bash/fns</strong><div class="buttonGroup_y07f mk-toolbar__actions"><a class="button button--secondary button--sm" href="/project_setup" target="_blank">Aide</a><button class="button button--secondary button--sm">Tout déplier</button><button class="button button--outline button--primary button--sm" aria-label="Télécharger le projet en ZIP">ZIP</button><button class="button button--secondary button--sm">Générer le script d'installation</button></div></div><div class="content_bo8S"><div><p class="tip_qyDH"><small><span aria-hidden="true">💡</span> <!-- -->Besoin d'aide ? Cliquez sur le bouton « Aide » ci-dessus pour les instructions de mise en place.</small></p></div></div></div><p><strong>2. Chargez le dossier depuis <code>~/.bashrc</code>.</strong> Ajoutez ceci à la fin de <code>~/.bashrc</code> (le test <code>-r</code> évite qu'un dossier vide ne génère une erreur) :</p><div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockTitle_OeMC">~/.bashrc</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token keyword" style="color:rgb(86, 156, 214)">for</span><span class="token plain"> </span><span class="token for-or-select variable" style="color:rgb(156, 220, 254)">fn_file</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">in</span><span class="token plain"> ~/.bash/fns/*.sh</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">do</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token plain"> </span><span class="token parameter variable" style="color:rgb(156, 220, 254)">-r</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"</span><span class="token string variable" style="color:rgb(156, 220, 254)">$fn_file</span><span class="token string" style="color:rgb(206, 145, 120)">"</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">]</span><span class="token punctuation" style="color:rgb(212, 212, 212)">]</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name" style="color:rgb(78, 201, 176)">source</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"</span><span class="token string variable" style="color:rgb(156, 220, 254)">$fn_file</span><span class="token string" style="color:rgb(206, 145, 120)">"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token keyword" style="color:rgb(86, 156, 214)">done</span><br></div></code></pre></div></div><p><strong>3. Ouvrez un nouveau terminal</strong> (ou lancez <code>exec bash</code>).</p></div></div></div>
<p>Vérifiez que le shell connaît la fonction : <code>type yq</code> doit répondre <code>yq is a shell function</code>.</p>
<p>La <a href="https://github.com/mikefarah/yq/releases" target="_blank" rel="noopener noreferrer" class="">page des releases</a> propose aussi des binaires statiques pour Linux, macOS et Windows — sans aucune dépendance à l'exécution.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="reading-values">Lire des valeurs<a href="#reading-values" class="hash-link" aria-label="Lien direct vers Lire des valeurs" title="Lien direct vers Lire des valeurs" translate="no"></a></h2>
<p>Travaillons avec un <code>compose.yaml</code> typique :</p>

<p>Lire une valeur unique :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>yq '.services.web.image' compose.yaml</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">nginx:1.25</span><br></div></code></pre></div></div>
<p>Lire une valeur imbriquée :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>yq '.services.db.environment.POSTGRES_DB' compose.yaml</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">myapp_db</span><br></div></code></pre></div></div>
<p>Lister tous les noms de services :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>yq '.services | keys' compose.yaml</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">- web</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">- db</span><br></div></code></pre></div></div>
<p>Récupérer toutes les images de tous les services :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>yq '.services.*.image' compose.yaml</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">nginx:1.25</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">postgres:16</span><br></div></code></pre></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="filtering-and-selecting">Filtrer et sélectionner<a href="#filtering-and-selecting" class="hash-link" aria-label="Lien direct vers Filtrer et sélectionner" title="Lien direct vers Filtrer et sélectionner" translate="no"></a></h2>
<p>Récupérer uniquement les services qui définissent une clé <code>ports</code> :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>yq '.services | with_entries(select(.value | has("ports")))' compose.yaml</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<div class="language-yaml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-yaml codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token key atrule">web</span><span class="token punctuation" style="color:rgb(212, 212, 212)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token key atrule">image</span><span class="token punctuation" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> nginx</span><span class="token punctuation" style="color:rgb(212, 212, 212)">:</span><span class="token number" style="color:rgb(181, 206, 168)">1.25</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token key atrule">ports</span><span class="token punctuation" style="color:rgb(212, 212, 212)">:</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(212, 212, 212)">-</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"8080:80"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(212, 212, 212)">...</span><br></div></code></pre></div></div>
<p>Lister tous les noms de variables d'environnement du service <code>web</code> :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>yq '.services.web.environment | keys' compose.yaml</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">- APP_ENV</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">- LOG_LEVEL</span><br></div></code></pre></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="editing-in-place">Modifier sur place<a href="#editing-in-place" class="hash-link" aria-label="Lien direct vers Modifier sur place" title="Lien direct vers Modifier sur place" translate="no"></a></h2>
<p>Mettre à jour la version de l'image <code>web</code>. Le flag <code>-i</code> modifie le fichier sur place, exactement comme <a href="/fr/blog/update-env-files-cli/"><code>sed -i</code></a> :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>yq -i '.services.web.image = "nginx:1.27"' compose.yaml</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Ajouter une nouvelle variable d'environnement :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>yq -i '.services.web.environment.DEBUG = "false"' compose.yaml</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Supprimer une clé :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>yq -i 'del(.services.db.environment.POSTGRES_PASSWORD)' compose.yaml</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<div class="alertBox_R1Ot mk-callout warning_xBFJ mk-callout--warn" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">yq -i reformate le fichier</p><div class="content_gFLB"><p><code>yq -i</code> conserve les commentaires et les guillemets, mais supprime les lignes vides — la ligne vide entre <code>web</code> et <code>db</code> disparaît après la première modification. Vérifiez le diff avant de committer un fichier formaté à la main.</p></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="reading-from-stdin-and-piping">Lire depuis stdin et chaîner les commandes<a href="#reading-from-stdin-and-piping" class="hash-link" aria-label="Lien direct vers Lire depuis stdin et chaîner les commandes" title="Lien direct vers Lire depuis stdin et chaîner les commandes" translate="no"></a></h2>
<p><code>yq</code> lit depuis stdin quand aucun fichier n'est fourni, ce qui facilite le chaînage avec d'autres commandes :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>cat compose.yaml | yq '.services | keys'</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Combinez-le avec <code>curl</code> pour parser des API ou des configs YAML téléchargées à la volée :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>curl -s <a href="https://example.com/config.yaml" target="_blank" rel="noopener noreferrer" class="">https://example.com/config.yaml</a> | yq '.database.host'</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="practical-use-cases">Cas d'usage pratiques<a href="#practical-use-cases" class="hash-link" aria-label="Lien direct vers Cas d'usage pratiques" title="Lien direct vers Cas d'usage pratiques" translate="no"></a></h2>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="extract-all-port-mappings">Extraire tous les mappings de ports<a href="#extract-all-port-mappings" class="hash-link" aria-label="Lien direct vers Extraire tous les mappings de ports" title="Lien direct vers Extraire tous les mappings de ports" translate="no"></a></h3>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>yq '.services.*.ports[]' compose.yaml</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token number" style="color:rgb(181, 206, 168)">8080</span><span class="token plain">:80</span><br></div></code></pre></div></div>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="check-if-a-key-exists-before-using-it">Vérifier qu'une clé existe avant de l'utiliser<a href="#check-if-a-key-exists-before-using-it" class="hash-link" aria-label="Lien direct vers Vérifier qu'une clé existe avant de l'utiliser" title="Lien direct vers Vérifier qu'une clé existe avant de l'utiliser" translate="no"></a></h3>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>yq '.services | has("redis")' compose.yaml</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token boolean">false</span><br></div></code></pre></div></div>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="update-a-version-across-a-ci-file">Mettre à jour une version dans tout un fichier CI<a href="#update-a-version-across-a-ci-file" class="hash-link" aria-label="Lien direct vers Mettre à jour une version dans tout un fichier CI" title="Lien direct vers Mettre à jour une version dans tout un fichier CI" translate="no"></a></h3>
<p>Pour un <code>.gitlab-ci.yml</code> qui référence une version d'image à plusieurs endroits, <code>..</code> parcourt chaque nœud du fichier, quelle que soit sa profondeur :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>yq -i '(.. | select(. == "node:20")) = "node:22"' .gitlab-ci.yml</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="merge-two-yaml-files">Merger deux fichiers YAML<a href="#merge-two-yaml-files" class="hash-link" aria-label="Lien direct vers Merger deux fichiers YAML" title="Lien direct vers Merger deux fichiers YAML" translate="no"></a></h3>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>yq '. *= load("overrides.yaml")' base.yaml</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Cette commande fusionne en profondeur <code>overrides.yaml</code> dans <code>base.yaml</code> et affiche le résultat — les fichiers source restent inchangés, sauf si vous ajoutez <code>-i</code>.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="convert-yaml-to-json">Convertir du YAML en JSON<a href="#convert-yaml-to-json" class="hash-link" aria-label="Lien direct vers Convertir du YAML en JSON" title="Lien direct vers Convertir du YAML en JSON" translate="no"></a></h3>
<p>Quand vous devez passer des données YAML à un outil qui n'accepte que du JSON :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>yq -o json '.services.web' compose.yaml</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<div class="language-json codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-json codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token property">"image"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"nginx:1.25"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token property">"ports"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token string" style="color:rgb(206, 145, 120)">"8080:80"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(212, 212, 212)">]</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token property">"environment"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token property">"APP_ENV"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"production"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token property">"LOG_LEVEL"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"info"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token property">"volumes"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token string" style="color:rgb(206, 145, 120)">"./html:/usr/share/nginx/html:ro"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(212, 212, 212)">]</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token property">"depends_on"</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token string" style="color:rgb(206, 145, 120)">"db"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(212, 212, 212)">]</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><br></div></code></pre></div></div>
<p>Vous pouvez ensuite envoyer cette sortie directement à <code>jq</code> pour la traiter davantage.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="the-jq-crossover">Le pont avec jq<a href="#the-jq-crossover" class="hash-link" aria-label="Lien direct vers Le pont avec jq" title="Lien direct vers Le pont avec jq" translate="no"></a></h2>
<p>Si vous utilisez déjà <a href="/fr/blog/linux-jq/"><code>jq</code></a> pour JSON, la syntaxe de <code>yq</code> vous semblera immédiatement familière. Les expressions <code>jq</code> du quotidien fonctionnent telles quelles :</p>
<table><thead><tr><th>Tâche</th><th>jq (JSON)</th><th>yq (YAML)</th></tr></thead><tbody><tr><td>Lire une clé</td><td><code>jq '.key'</code></td><td><code>yq '.key'</code></td></tr><tr><td>Filtrer un tableau</td><td><code>jq '.[] | select(.active)'</code></td><td><code>yq '.[] | select(.active)'</code></td></tr><tr><td>Récupérer les clés</td><td><code>jq 'keys'</code></td><td><code>yq 'keys'</code></td></tr><tr><td>Modifier sur place</td><td>—</td><td><code>yq -i '.key = "value"'</code></td></tr><tr><td>Convertir le format</td><td>—</td><td><code>yq -o json '.'</code></td></tr></tbody></table>
<p>Les principales différences : <code>yq</code> ajoute <code>-i</code> pour les modifications sur place et <code>-o</code> pour le format de sortie, et gère les fonctionnalités natives de YAML comme les ancres et les fichiers multi-documents (séparateurs <code>---</code>).</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="conclusion">Conclusion<a href="#conclusion" class="hash-link" aria-label="Lien direct vers Conclusion" title="Lien direct vers Conclusion" translate="no"></a></h2>
<p><code>yq</code> comble le manque que tout utilisateur de <code>jq</code> finit par ressentir : le même outil, mais pour YAML. Avec un alias Docker, il n'y a rien à installer, et la syntaxe se transpose directement depuis tout ce que vous connaissez déjà. Si votre workflow implique des <code>compose.yaml</code>, GitLab CI, GitHub Actions ou des manifests Kubernetes, <code>yq</code> a sa place dans votre boîte à outils aux côtés de <code>jq</code> et de son équivalent XML, <a href="/fr/blog/linux-xmlstarlet/"><code>xmlstarlet</code></a>.</p>
<p>La prochaine fois que vous ouvrirez un éditeur de texte pour vérifier une version d'image ou modifier une clé de configuration, utilisez plutôt <code>yq</code>.</p></div><hr><div class="seriesBlogPost_bjUd"><details class="details_rTsq"><summary class="seriesIntro_HIK1 mk-eyebrow">Cet article fait partie de la série <a href="/fr/series/modern-cli-tools-for-your-terminal/">Outils CLI modernes pour votre terminal</a> :</summary><div class="content_OqVG"><ul><li><a href="/fr/blog/linux-jq/">L'utilitaire jq pour Linux</a></li><li><a href="/fr/blog/linux-xmlstarlet/">L'utilitaire xmlstarlet pour Linux</a></li><li><a href="/fr/blog/linux-fzf-introduction/">Introduction à fzf - Fuzzy Finder</a></li><li><a href="/fr/blog/linux-eza/">Revisitons la commande ls grâce à eza</a></li><li><a href="/fr/blog/ssh-with-fuzzy-finder/">Maîtrisez votre commande ssh et choisissez l'host dans une liste</a></li><li><a href="/fr/blog/fzf-ripgrep/">FZF + ripgrep : recherche de code interactive avec aperçu en direct</a></li><li><a href="/fr/blog/git-delta/">delta : un pager avec coloration syntaxique pour git diff</a></li><li><a href="/fr/blog/ripgrep/">ripgrep — l'outil de recherche qui a changé mon workflow WSL2</a></li><li><a href="/fr/blog/linux-yq/">yq — Le processeur YAML que vous savez déjà utiliser</a></li></ul></div></details></div></div><div class="relatedPosts_zJiB"><h3>Articles liés</h3></div><div class="container_mNR1"><span class="question_WdqX">Cet article vous a-t-il été utile ?</span><div class="buttons_vgpi"><button class="btn_svQp" aria-label="Oui, cet article m'a été utile"><img src="/fr/img/reaction-mascot-yes.webp" alt="" class="mascot_Fait">Utile</button><button class="btn_svQp btnNeutral_Iqek" aria-label="Non, cet article ne m'a pas été utile"><img src="/fr/img/reaction-mascot-no.webp" alt="" class="mascot_Fait">Pas vraiment</button></div></div><div class="container_IJwe"><span class="question_nb7b">Avez-vous suivi les étapes de cet article ?</span><div class="buttons_Xkzw"><button class="btn_RMiq" aria-label="Oui, j'ai essayé et ça a marché"><img src="/fr/img/like.webp" alt="" class="mascot_wB1d">Ça a marché !</button><button class="btn_RMiq btnNeutral_z71B" aria-label="Non, ça n'a pas marché pour moi"><img src="/fr/img/dislike.webp" alt="" class="mascot_wB1d">Ça n'a pas marché</button></div></div>]]></content:encoded>
            <author>Christophe</author>
            <author>Claude Code</author>
            <category>bash</category>
            <category>docker</category>
            <category>linux</category>
            <enclosure length="0" type="image/webp" url="https://www.avonture.be/fr/img/v2/linux-yq.webp"/>
        <dc:creator><![CDATA[Christophe]]></dc:creator><dc:creator><![CDATA[Claude Code]]></dc:creator></item>
        <item>
            <title><![CDATA[ai-diagram: From a Sentence (or a compose.yaml) to a Mermaid Diagram]]></title>
            <link>https://www.avonture.be/fr/fr/blog/ollama-ai-diagram</link>
            <guid isPermaLink="false">/fr/blog/ollama-ai-diagram</guid>
            <pubDate>Thu, 15 Oct 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[<img src="https://www.avonture.be/fr/img/v2/ai_diagram.webp" alt="ai-diagram: From a Sentence (or a compose.yaml) to a Mermaid Diagram" style="display:block;max-width:100%;height:auto;">A zsh function that turns a plain-English description, or an existing config file like a docker-compose.yaml, into a Mermaid diagram — printed as a ready-to-paste fenced code block. Where docker-python-mermaid needs a script written for each input format, ai-diagram takes whatever you have in hand.]]></description>
            <content:encoded><![CDATA[<div class="offer_jqr1"><span class="flag_Hnep" aria-hidden="true">🇬🇧</span><span>Cet article existe aussi en anglais —<!-- --> <a href="/blog/ollama-ai-diagram/" hreflang="en">lire la version originale</a>.</span></div><div class="alertBox_R1Ot mk-callout caution_LuPi mk-callout--warn" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Traduction automatique</p><div class="content_gFLB">Cette traduction a été produite par une IA. Elle peut contenir des contresens ou des maladresses, en particulier sur les termes techniques. En cas de doute,<!-- --> <a href="/blog/ollama-ai-diagram/">consultez l'article de référence en anglais</a> <!-- -->— c'est lui qui fait foi.</div></div></div><div><div class="seriesBlogPost_bjUd"><details class="details_rTsq"><summary class="seriesIntro_HIK1 mk-eyebrow">Cet article fait partie de la série <a href="/fr/series/ollama-daily-use/">Ollama au quotidien</a> :</summary><div class="content_OqVG"><ul><li><a href="/fr/blog/ollama-installation/">Installer Ollama et obtenir une IA locale</a></li><li><a href="/fr/blog/accessing-ollama-across-your-local-network/">Accéder à Ollama depuis votre réseau local</a></li><li><a href="/fr/blog/ollama-test-generator/">ai-test : générer les tests unitaires manquants depuis votre terminal avec un LLM local</a></li><li><a href="/fr/blog/ollama-git-precommit/">ai-review, ai-secrets, ai-commit : trois vérifications zsh avant chaque git commit</a></li><li><a href="/fr/blog/anythingllm-chat-with-your-docs/">AnythingLLM : discuter avec toute votre documentation éparpillée</a></li><li><a href="/fr/blog/ollama-ai-docs/">ai-translate &amp; ai-summarize : des documents confidentiels, traités 100 % en local</a></li><li><a href="/fr/blog/ollama-ai-diagram/">ai-diagram : d'une phrase (ou d'un compose.yaml) à un diagramme Mermaid</a></li><li><a href="/fr/blog/ollama-ai-data/">ai-data : laissez un LLM local écrire vos one-liners jq et awk</a></li><li><a href="/fr/blog/ollama-ai-explain/">ai-explain : l'ELI5 des scripts et des erreurs, directement dans votre terminal</a></li></ul></div></details></div><div><p><img alt="ai-diagram : d&amp;#39;une phrase (ou d&amp;#39;un compose.yaml) à un diagramme Mermaid" src="/fr/assets/images/ai_diagram-a23d048d9ea783fd337f57460a4a5ac4.webp" height="auto" width="" loading="lazy" decoding="async" class=""></p>
<div class="tldrContainer_qQW_ mk-callout mk-callout--key" role="region" aria-label="Résumé de l'article"><span role="img" aria-label="éclair" class="tldrIcon_SLBz mk-dot">⚡</span><div class="tldrBody_lGty"><strong class="tldrTitle_Aha8 mk-eyebrow">TL;DR</strong><div class="tldrContent_m27u"><p>Cet article ajoute <code>ai-diagram</code> à la série « Ollama daily use » : donnez-lui la description d'un processus en langage naturel, ou pointez-le vers un fichier comme un <code>docker-compose.yaml</code>, et il renvoie un diagramme Mermaid — un bloc de code, prêt à coller directement dans un fichier Markdown. C'est un pont volontaire entre cette série et mes articles doc-as-code : <code>docker-compose-viz</code> et <code>docker-python-mermaid</code> transforment tous deux des données <em>structurées</em> en diagrammes avec de vrais parsers ; <code>ai-diagram</code> couvre le cas qu'ils ne savent pas traiter — une description sans le moindre schéma, ou un fichier pour lequel vous n'avez pas envie d'écrire un parser juste pour esquisser un diagramme.</p></div></div></div>
<p>J'ai écrit plus d'articles « transformer X en diagramme » que je ne le croyais : <a href="/fr/blog/docker-diagram-as-code/">Diagrams as code</a>, <a href="/fr/blog/docker-compose-viz/">un graphe à partir de compose.yaml</a>, <a href="/fr/blog/docker-python-mermaid/">la documentation as code avec Python et Mermaid</a>. Tous partagent la même forme : un vrai parser lit une entrée structurée et une librairie dessine l'image. Ça fonctionne très bien — jusqu'au moment où ce que je veux représenter est un paragraphe que je viens de taper, ou un fichier de config ponctuel qui ne mérite pas qu'on lui écrive un parser.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="demo--plain-english-description">Démo — une description en langage naturel<a href="#demo--plain-english-description" class="hash-link" aria-label="Lien direct vers Démo — une description en langage naturel" title="Lien direct vers Démo — une description en langage naturel" translate="no"></a></h2>
<div class="terminal_qOWF" style="cursor:pointer" title="Cliquez pour passer l'animation"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><span class="terminal_ghost_aie1" aria-hidden="true">$ ai-diagram "a user submits a contact form, the API validates the payload, saves it to Postgres, then sends a confirmation email via SMTP"

```mermaid
flowchart TD
    A[User submits contact form] --&gt; B[API validates payload]
    B --&gt;|Validation fails| C[Return validation errors]
    B --&gt;|Validation passes| D[Save to Postgres]
    D --&gt; E[Send confirmation email via SMTP]
    E --&gt; F[Email sent successfully]
```
Preview: https://mermaid.live/edit#base64:eyJjb2RlIjoiZmxvd2NoYXJ0IFREXG4gICAgQVtVc2VyIHN1Ym1pdHMgY29udGFjdCBmb3JtXSAtLT4gQltBUEkgdmFsaWRhdGVzIHBheWxvYWRdXG4gICAgQiAtLT58VmFsaWRhdGlvbiBmYWlsc3wgQ1tSZXR1cm4gdmFsaWRhdGlvbiBlcnJvcnNdXG4gICAgQiAtLT58VmFsaWRhdGlvbiBwYXNzZXN8IERbU2F2ZSB0byBQb3N0Z3Jlc11cbiAgICBEIC0tPiBFW1NlbmQgY29uZmlybWF0aW9uIGVtYWlsIHZpYSBTTVRQXVxuICAgIEUgLS0-IEZbRW1haWwgc2VudCBzdWNjZXNzZnVsbHldIn0
</span><span class="terminal_overlay_fznj"><span class="cursor_o7aQ" aria-hidden="true">▋</span></span></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Pas de YAML, pas de schéma, juste une phrase — et un flowchart qui se ramifie sur la validation, la seule étape de cette phrase dont l'issue décide de la suite, tandis que l'enregistrement et l'envoi restent de simples étapes. Collé dans cette page, le bloc s'affiche ainsi :</p>
<!-- -->
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="demo--an-existing-config-file">Démo — un fichier de config existant<a href="#demo--an-existing-config-file" class="hash-link" aria-label="Lien direct vers Démo — un fichier de config existant" title="Lien direct vers Démo — un fichier de config existant" translate="no"></a></h2>

<div class="terminal_qOWF" style="cursor:pointer" title="Cliquez pour passer l'animation"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><span class="terminal_ghost_aie1" aria-hidden="true">$ ai-diagram compose.yaml

```mermaid
graph TD
  app["app · build · :8080"]
  db["db · postgres:16"]
  cache["cache · redis:7"]
  db_data[("db-data")]

  app -- "depends_on" --&gt; db
  app -- "depends_on" --&gt; cache
  db -- "volumes" --&gt; db_data
```
Preview: https://mermaid.live/edit#base64:eyJjb2RlIjoiZ3JhcGggVERcbiAgYXBwW1wiYXBwIMK3IGJ1aWxkIMK3IDo4MDgwXCJdXG4gIGRiW1wiZGIgwrcgcG9zdGdyZXM6MTZcIl1cbiAgY2FjaGVbXCJjYWNoZSDCtyByZWRpczo3XCJdXG4gIGRiX2RhdGFbKFwiZGItZGF0YVwiKV1cblxuICBhcHAgLS0gXCJkZXBlbmRzX29uXCIgLS0-IGRiXG4gIGFwcCAtLSBcImRlcGVuZHNfb25cIiAtLT4gY2FjaGVcbiAgZGIgLS0gXCJ2b2x1bWVzXCIgLS0-IGRiX2RhdGEifQ
</span><span class="terminal_overlay_fznj"><span class="cursor_o7aQ" aria-hidden="true">▋</span></span></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Trois services, leurs liens de dépendance et le volume nommé — lus directement dans la structure du fichier compose, avec chaque image et chaque port publié intégrés au label de son service :</p>
<!-- -->
<div class="alertBox_R1Ot mk-callout caution_LuPi mk-callout--warn" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Relisez le Mermaid avant de lui faire confiance</p><div class="content_gFLB"><p>Pour tout ce qui part dans de la vraie documentation — un document d'onboarding, un architecture decision record — traitez ça comme la sortie de <code>docker-compose-viz</code> : un bon premier jet. Le modèle peut rater un cas limite (un <code>depends_on: condition: service_healthy</code>, un alias réseau moins évident) qu'un vrai parser comme celui de <code>docker-python-mermaid</code> n'aurait pas manqué.</p></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="more-sentences-to-try">D'autres phrases à essayer<a href="#more-sentences-to-try" class="hash-link" aria-label="Lien direct vers D'autres phrases à essayer" title="Lien direct vers D'autres phrases à essayer" translate="no"></a></h2>
<p>Trois descriptions de plus, chacune prête à copier dans votre propre terminal. Chaque lien ouvre le diagramme produit par <code>ai-diagram</code>, dans le Mermaid Live Editor.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="support-ticket-triage">Tri des tickets de support<a href="#support-ticket-triage" class="hash-link" aria-label="Lien direct vers Tri des tickets de support" title="Lien direct vers Tri des tickets de support" translate="no"></a></h3>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">ai-diagram </span><span class="token string" style="color:rgb(206, 145, 120)">"When a customer submits a support ticket, check if it is urgent. If it is urgent, assign it directly to a senior agent. If it is not urgent, place it in the standard queue and send an automated confirmation email to the customer."</span><br></div></code></pre></div></div>
<p><a href="https://mermaid.live/edit#base64:eyJjb2RlIjoiZmxvd2NoYXJ0IFREXG4gICAgQVtDdXN0b21lciBzdWJtaXRzIHN1cHBvcnQgdGlja2V0XSAtLT4gQntJcyB0aWNrZXQgdXJnZW50P31cbiAgICBCIC0tIFlFUyAtLT4gQ1tBc3NpZ24gdG8gc2VuaW9yIGFnZW50XVxuICAgIEIgLS0gTk8gLS0-IERbUGxhY2UgaW4gc3RhbmRhcmQgcXVldWVdXG4gICAgRCAtLT4gRVtTZW5kIGF1dG9tYXRlZCBjb25maXJtYXRpb24gZW1haWxdXG4gICAgQyAtLT4gRltFbmRdXG4gICAgRSAtLT4gRltFbmRdIn0" target="_blank" rel="noopener noreferrer" class="">Voir le diagramme obtenu sur mermaid.live</a></p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="login-with-a-lockout">Connexion avec verrouillage de compte<a href="#login-with-a-lockout" class="hash-link" aria-label="Lien direct vers Connexion avec verrouillage de compte" title="Lien direct vers Connexion avec verrouillage de compte" translate="no"></a></h3>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">ai-diagram </span><span class="token string" style="color:rgb(206, 145, 120)">"To log into the system, the user enters their username and password. If the credentials are correct, grant access and display the main dashboard. If the credentials are incorrect, check if the attempt count is less than three. If it is less than three, increment the counter and ask the user to try again. Otherwise, lock the account and send a password reset link to the user's email."</span><br></div></code></pre></div></div>
<p><a href="https://mermaid.live/edit#base64:eyJjb2RlIjoiZmxvd2NoYXJ0IFREXG4gICAgQVtVc2VyIGVudGVycyB1c2VybmFtZSBhbmQgcGFzc3dvcmRdIC0tPiBCe0NyZWRlbnRpYWxzIGNvcnJlY3Q_fVxuICAgIEIgLS0gWWVzIC0tPiBDW0dyYW50IGFjY2Vzc11cbiAgICBDIC0tPiBEW0Rpc3BsYXkgbWFpbiBkYXNoYm9hcmRdXG4gICAgQiAtLSBObyAtLT4gRXtBdHRlbXB0IGNvdW50IDwgMz99XG4gICAgRSAtLSBZZXMgLS0-IEZbSW5jcmVtZW50IGNvdW50ZXJdXG4gICAgRiAtLT4gR1tBc2sgdXNlciB0byB0cnkgYWdhaW5dXG4gICAgRSAtLSBObyAtLT4gSFtMb2NrIGFjY291bnRdXG4gICAgSCAtLT4gSVtTZW5kIHBhc3N3b3JkIHJlc2V0IGxpbmsgdG8gZW1haWxdIn0" target="_blank" rel="noopener noreferrer" class="">Voir le diagramme obtenu sur mermaid.live</a></p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="an-e-commerce-order">Une commande e-commerce<a href="#an-e-commerce-order" class="hash-link" aria-label="Lien direct vers Une commande e-commerce" title="Lien direct vers Une commande e-commerce" translate="no"></a></h3>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">ai-diagram </span><span class="token string" style="color:rgb(206, 145, 120)">"When receiving an e-commerce order, first verify if the requested item is in stock. If it is out of stock, notify the customer and ask if they want to backorder or cancel. If they cancel, process a full refund. If the item is in stock, check if the payment is processed successfully. If payment fails, prompt the user to update their payment method. If payment succeeds, pack the order, generate a shipping label, ship the item, and send a tracking number to the customer."</span><br></div></code></pre></div></div>
<p><a href="https://mermaid.live/edit#base64:eyJjb2RlIjoiZmxvd2NoYXJ0IFREXG4gICAgQVtSZWNlaXZlIGUtY29tbWVyY2Ugb3JkZXJdIC0tPiBCe0lzIGl0ZW0gaW4gc3RvY2s_fVxuICAgIEIgLS0gTm8gLS0-IENbTm90aWZ5IGN1c3RvbWVyXVxuICAgIEMgLS0-IER7V2FudCB0byBiYWNrb3JkZXIgb3IgY2FuY2VsP31cbiAgICBEIC0tIENhbmNlbCAtLT4gRVtQcm9jZXNzIGZ1bGwgcmVmdW5kXVxuICAgIEQgLS0gQmFja29yZGVyIC0tPiBGW1Byb2NlZWQgd2l0aCBvcmRlcl1cbiAgICBCIC0tIFllcyAtLT4gR3tQYXltZW50IHByb2Nlc3NlZCBzdWNjZXNzZnVsbHk_fVxuICAgIEcgLS0gTm8gLS0-IEhbUHJvbXB0IHVzZXIgdG8gdXBkYXRlIHBheW1lbnQgbWV0aG9kXVxuICAgIEggLS0-IEdcbiAgICBHIC0tIFllcyAtLT4gSVtQYWNrIG9yZGVyXVxuICAgIEkgLS0-IEpbR2VuZXJhdGUgc2hpcHBpbmcgbGFiZWxdXG4gICAgSiAtLT4gS1tTaGlwIGl0ZW1dXG4gICAgSyAtLT4gTFtTZW5kIHRyYWNraW5nIG51bWJlciB0byBjdXN0b21lcl0ifQ" target="_blank" rel="noopener noreferrer" class="">Voir le diagramme obtenu sur mermaid.live</a></p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="where-this-actually-sits-next-to-docker-python-mermaid">Sa place réelle à côté de <code>docker-python-mermaid</code><a href="#where-this-actually-sits-next-to-docker-python-mermaid" class="hash-link" aria-label="Lien direct vers where-this-actually-sits-next-to-docker-python-mermaid" title="Lien direct vers where-this-actually-sits-next-to-docker-python-mermaid" translate="no"></a></h2>
<div class="alertBox_R1Ot mk-callout info_J1rX mk-callout--info" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Pas un remplaçant — un autre métier</p><div class="content_gFLB"><p><a href="/fr/blog/docker-python-mermaid/"><code>docker-python-mermaid</code></a> parse lui aussi un <code>compose.yaml</code>, et c'est le bon outil pour un diagramme qui vit dans la documentation : un script écrit une fois pour ce format d'entrée, déterministe, régénéré à chaque changement. <code>ai-diagram</code> sert à l'autre moitié du travail : une description de la taille d'un message Slack, ou un format pour lequel vous n'avez aucun script — un <code>nginx.conf</code>, un workflow CI, un schéma SQL. Moins de précision, aucune installation — j'utilise les deux, selon que le diagramme doit rester synchronisé ou simplement exister cet après-midi.</p></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="the-ai-diagram-function">La fonction <code>ai-diagram</code><a href="#the-ai-diagram-function" class="hash-link" aria-label="Lien direct vers the-ai-diagram-function" title="Lien direct vers the-ai-diagram-function" translate="no"></a></h2>
<p>Pas besoin de copier quoi que ce soit pour l'instant : les fichiers <code>.zsh</code> montrés dans cet article sont là pour être lus, et <a href="#installing-it" class="">L'installer</a> à la fin les crée tous dans <code>~/.zsh/fns/</code> pour vous.</p>

<p>Deux modes d'entrée, distingués par un seul test — si le premier argument est un fichier existant, c'est un fichier :</p>
<ol>
<li class=""><strong>Un fichier</strong> — lu tel quel, et le prompt demande au modèle de déduire le type de diagramme du contexte (un <code>compose.yaml</code> implique un graphe de services, un listing de dossier implique un arbre, une spec OpenAPI implique un diagramme de séquence). Une courte liste de règles garde le graphe honnête : un nœud par entité, une arête par relation, les attributs comme une image ou un port dans les labels, le stockage dessiné comme un cylindre.</li>
<li class=""><strong>Du texte libre</strong> — tout ce qui suit la commande est traité comme la description, et le modèle choisit lui-même le type de diagramme Mermaid le plus adapté, en ajoutant une branche d'échec uniquement là où une étape est elle-même une vérification (une validation, une connexion, un paiement) — jamais un « email envoyé ? » inventé sur une action simple.</li>
</ol>
<p>Dans les deux cas, la sortie est un bloc <code>```mermaid</code> et rien d'autre. Le prompt demande exactement ça, et <code>_ai_strip_fences</code> ne conserve ensuite que les lignes du bloc avant que la fonction ne les réencadre elle-même — de sorte que même une réponse qui commence par « Here is your diagram: » ressort prête à coller dans un article, où ce blog la rend nativement.</p>
<p>Avant de la coller quelque part, vous voudrez la voir. La dernière ligne, <code>Preview: https://mermaid.live/edit#base64:…</code>, ouvre le diagramme dans le <a href="https://mermaid.live" target="_blank" rel="noopener noreferrer" class="">Mermaid Live Editor</a> avec un Ctrl+clic depuis le terminal — rendu, et modifiable si le modèle s'est trompé sur un label. L'éditeur lit tout son état dans la partie de l'URL après le <code>#</code> : le JSON <code>{"code": "…"}</code>, encodé en base64url, donc <code>jq</code> et <code>base64</code> suffisent à le construire. Ce fragment n'est jamais envoyé au serveur dans la requête ; le diagramme est décodé et dessiné par la page, dans votre navigateur. Le lien part sur stderr, donc <code>ai-diagram compose.yaml &gt; docs/services.md</code> n'écrit toujours rien d'autre que le bloc Mermaid.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="registered-in-the-ai-menu">Enregistrée dans le menu <code>ai</code><a href="#registered-in-the-ai-menu" class="hash-link" aria-label="Lien direct vers registered-in-the-ai-menu" title="Lien direct vers registered-in-the-ai-menu" translate="no"></a></h2>
<p>Deux lignes — <code>AI_COMMANDS[diagram]=...</code> et <code>AI_PARAMS[diagram]="text"</code> — la rendent accessible via <code>ai diagram &lt;input&gt;</code> ou directement via <code>ai-diagram</code>, aux côtés de toutes les autres fonctions de la série. Choisie depuis le menu <code>ai</code> seul, elle demande son entrée : tapez une description, ou un nom de fichier.</p>
<p><code>_ollama_query</code>, <code>_ai_strip_fences</code> et le registre <code>AI_COMMANDS</code> vivent tous dans la fondation partagée <code>_ollama.zsh</code> présentée dans <a href="/fr/blog/ollama-test-generator/">ai-test</a>. Si vous avez déjà suivi un article précédent de cette série, elle se trouve déjà dans <code>~/.zsh/fns/</code> et est sourcée par votre autoloader — rien de plus à faire. Vous commencez ici ? C'est l'un des fichiers créés dans <a href="#installing-it" class="">L'installer</a> plus bas.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="key-takeaways">À retenir<a href="#key-takeaways" class="hash-link" aria-label="Lien direct vers À retenir" title="Lien direct vers À retenir" translate="no"></a></h2>
<div class="steps_wrapper_r_yO" data-variant="remember"><p class="steps_title_bD7c mk-eyebrow">ai-diagram en bref</p><ul class="steps_list_Vtzi"><li class="step_item_FtAN"><span class="step_bullet_sanI mk-dot" aria-hidden="true">•</span><div class="step_content_KvQY"><span><strong>Deux modes d'entrée</strong> — un fichier existant, ou une description en texte libre, détectés automatiquement</span></div></li><li class="step_item_FtAN"><span class="step_bullet_sanI mk-dot" aria-hidden="true">•</span><div class="step_content_KvQY"><span><strong>Le type de diagramme est déduit</strong> — flowchart, séquence, ER, classe, état — le modèle choisit selon le contexte</span></div></li><li class="step_item_FtAN"><span class="step_bullet_sanI mk-dot" aria-hidden="true">•</span><div class="step_content_KvQY"><span><strong>La sortie n'est qu'un bloc</strong> — un bloc <code></code>`mermaid brut, réencadré par la fonction elle-même, destiné à être collé directement</span></div></li><li class="step_item_FtAN"><span class="step_bullet_sanI mk-dot" aria-hidden="true">•</span><div class="step_content_KvQY"><span><strong>Ctrl+clic pour prévisualiser</strong> — un lien mermaid.live sur stderr ouvre le diagramme rendu, stdout reste prêt à coller</span></div></li><li class="step_item_FtAN"><span class="step_bullet_sanI mk-dot" aria-hidden="true">•</span><div class="step_content_KvQY"><span><strong>Complète, ne remplace pas, <code>docker-python-mermaid</code></strong> — un script pour les diagrammes qui doivent rester synchronisés ; ceci pour tout le reste</span></div></li><li class="step_item_FtAN"><span class="step_bullet_sanI mk-dot" aria-hidden="true">•</span><div class="step_content_KvQY"><span><strong>S'enregistre dans <code>ai</code></strong> — accessible via <code>ai diagram</code></span></div></li></ul></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="installing-it">L'installer<a href="#installing-it" class="hash-link" aria-label="Lien direct vers L'installer" title="Lien direct vers L'installer" translate="no"></a></h2>
<p>Tout tourne sur votre machine : zsh, <code>curl</code> et <code>jq</code> (qui construit la requête envoyée à Ollama), et un serveur Ollama accessible — voyez <a href="/fr/blog/ollama-installation/">installing Ollama</a> si vous n'en avez pas encore.</p>
<p><strong>1. Créez les fichiers.</strong> Chaque fonction de cette série vit dans son propre fichier dans <code>~/.zsh/fns/</code>. La ligne de commande ci-dessous crée ce dossier et les deux fichiers d'un coup — sinon téléchargez le ZIP, ou créez chaque fichier à la main et collez son contenu. <code>_ollama.zsh</code> est la fondation partagée par toute la série : si un article précédent vous l'a déjà donnée, la copie ici est le même fichier.</p>
<div class="projectSetupContainer_LW1i mk-surface"><div class="header_Htsx mk-toolbar"><strong>📦 <!-- -->Mise en place du projet :<!-- --> <!-- -->~/.zsh/fns</strong><div class="buttonGroup_y07f mk-toolbar__actions"><a class="button button--secondary button--sm" href="/project_setup" target="_blank">Aide</a><button class="button button--secondary button--sm">Tout déplier</button><button class="button button--outline button--primary button--sm" aria-label="Télécharger le projet en ZIP">ZIP</button><button class="button button--secondary button--sm">Générer le script d'installation</button></div></div><div class="content_bo8S"><div><p class="tip_qyDH"><small><span aria-hidden="true">💡</span> <!-- -->Besoin d'aide ? Cliquez sur le bouton « Aide » ci-dessus pour les instructions de mise en place.</small></p></div></div></div>
<p><strong>2. Chargez le dossier depuis <code>~/.zshrc</code>.</strong> Déjà fait si vous avez suivi un article précédent de cette série. Sinon, ajoutez ces lignes à la fin de <code>~/.zshrc</code> — elles sourcent chaque fichier <code>.zsh</code> du dossier, donc la prochaine fonction que vous ajouterez ne nécessitera aucun changement ici :</p>
<div class="language-zsh codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockTitle_OeMC">~/.zshrc</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-zsh codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">for fn_file in ~/.zsh/fns/*.zsh; do</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  source "$fn_file"</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">done</span><br></div></code></pre></div></div>
<p><strong>3. Ouvrez un nouveau terminal</strong> (ou lancez <code>exec zsh</code> dans celui en cours), et vérifiez que le shell connaît la nouvelle fonction :</p>
<div class="language-zsh codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-zsh codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">type ai-diagram</span><br></div></code></pre></div></div>
<p><code>ai-diagram is a shell function from …/ai-diagram.zsh</code> signifie que vous êtes prêt. <code>ai-diagram not found</code> signifie que l'étape 2 manque.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="conclusion">Conclusion<a href="#conclusion" class="hash-link" aria-label="Lien direct vers Conclusion" title="Lien direct vers Conclusion" translate="no"></a></h2>
<p>Mes articles doc-as-code ont toujours supposé que j'avais déjà quelque chose de structuré à donner à un parser. <code>ai-diagram</code> comble le vide en amont — la phrase que je viens de taper, le fichier que j'ai mais pour lequel je n'ai pas écrit de parser. Il ne remplacera pas <a href="/fr/blog/docker-diagram-as-code/">Diagrams as code</a> ni <a href="/fr/blog/docker-python-mermaid/">docker-python-mermaid</a> pour ce qui doit être exact et reproductible, mais pour le cas « laisse-moi juste esquisser ça vite fait » qui signifiait avant d'ouvrir un outil de whiteboard, c'est devenu une commande et un collage.</p></div><hr><div class="seriesBlogPost_bjUd"><details class="details_rTsq"><summary class="seriesIntro_HIK1 mk-eyebrow">Cet article fait partie de la série <a href="/fr/series/ollama-daily-use/">Ollama au quotidien</a> :</summary><div class="content_OqVG"><ul><li><a href="/fr/blog/ollama-installation/">Installer Ollama et obtenir une IA locale</a></li><li><a href="/fr/blog/accessing-ollama-across-your-local-network/">Accéder à Ollama depuis votre réseau local</a></li><li><a href="/fr/blog/ollama-test-generator/">ai-test : générer les tests unitaires manquants depuis votre terminal avec un LLM local</a></li><li><a href="/fr/blog/ollama-git-precommit/">ai-review, ai-secrets, ai-commit : trois vérifications zsh avant chaque git commit</a></li><li><a href="/fr/blog/anythingllm-chat-with-your-docs/">AnythingLLM : discuter avec toute votre documentation éparpillée</a></li><li><a href="/fr/blog/ollama-ai-docs/">ai-translate &amp; ai-summarize : des documents confidentiels, traités 100 % en local</a></li><li><a href="/fr/blog/ollama-ai-diagram/">ai-diagram : d'une phrase (ou d'un compose.yaml) à un diagramme Mermaid</a></li><li><a href="/fr/blog/ollama-ai-data/">ai-data : laissez un LLM local écrire vos one-liners jq et awk</a></li><li><a href="/fr/blog/ollama-ai-explain/">ai-explain : l'ELI5 des scripts et des erreurs, directement dans votre terminal</a></li></ul></div></details></div></div><div class="relatedPosts_zJiB"><h3>Articles liés</h3></div><div class="container_mNR1"><span class="question_WdqX">Cet article vous a-t-il été utile ?</span><div class="buttons_vgpi"><button class="btn_svQp" aria-label="Oui, cet article m'a été utile"><img src="/fr/img/reaction-mascot-yes.webp" alt="" class="mascot_Fait">Utile</button><button class="btn_svQp btnNeutral_Iqek" aria-label="Non, cet article ne m'a pas été utile"><img src="/fr/img/reaction-mascot-no.webp" alt="" class="mascot_Fait">Pas vraiment</button></div></div><div class="container_IJwe"><span class="question_nb7b">Avez-vous suivi les étapes de cet article ?</span><div class="buttons_Xkzw"><button class="btn_RMiq" aria-label="Oui, j'ai essayé et ça a marché"><img src="/fr/img/like.webp" alt="" class="mascot_wB1d">Ça a marché !</button><button class="btn_RMiq btnNeutral_z71B" aria-label="Non, ça n'a pas marché pour moi"><img src="/fr/img/dislike.webp" alt="" class="mascot_wB1d">Ça n'a pas marché</button></div></div>]]></content:encoded>
            <author>Christophe</author>
            <author>Claude Code</author>
            <category>ai</category>
            <category>ollama</category>
            <category>zsh</category>
            <category>doc-as-code</category>
            <enclosure length="0" type="image/webp" url="https://www.avonture.be/fr/img/v2/ai_diagram.webp"/>
        <dc:creator><![CDATA[Christophe]]></dc:creator><dc:creator><![CDATA[Claude Code]]></dc:creator></item>
        <item>
            <title><![CDATA[hyperfine — Stop Guessing Which Command Is Faster, Measure It]]></title>
            <link>https://www.avonture.be/fr/fr/blog/hyperfine</link>
            <guid isPermaLink="false">/fr/blog/hyperfine</guid>
            <pubDate>Mon, 12 Oct 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[<img src="https://www.avonture.be/fr/img/v2/hyperfine.webp" alt="hyperfine — Stop Guessing Which Command Is Faster, Measure It" style="display:block;max-width:100%;height:auto;">hyperfine is a command-line benchmarking tool written in Rust. It runs your commands many times, supports warmup runs, computes statistics on time and memory, and tells you definitively which one is faster — with export to Markdown, CSV or JSON.]]></description>
            <content:encoded><![CDATA[<div class="offer_jqr1"><span class="flag_Hnep" aria-hidden="true">🇬🇧</span><span>Cet article existe aussi en anglais —<!-- --> <a href="/blog/hyperfine/" hreflang="en">lire la version originale</a>.</span></div><div class="alertBox_R1Ot mk-callout caution_LuPi mk-callout--warn" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Traduction automatique</p><div class="content_gFLB">Cette traduction a été produite par une IA. Elle peut contenir des contresens ou des maladresses, en particulier sur les termes techniques. En cas de doute,<!-- --> <a href="/blog/hyperfine/">consultez l'article de référence en anglais</a> <!-- -->— c'est lui qui fait foi.</div></div></div><div><div><p><img alt="hyperfine — arrêtez de deviner quelle commande est la plus rapide, mesurez-la" src="/fr/assets/images/hyperfine-3dc347b36d221fc2addd119843838f8a.webp" height="auto" width="" loading="lazy" decoding="async" class=""></p>
<div class="tldrContainer_qQW_ mk-callout mk-callout--key" role="region" aria-label="Résumé de l'article"><span role="img" aria-label="éclair" class="tldrIcon_SLBz mk-dot">⚡</span><div class="tldrBody_lGty"><strong class="tldrTitle_Aha8 mk-eyebrow">TL;DR</strong><div class="tldrContent_m27u"><p><code>hyperfine</code> est un outil de benchmark en ligne de commande qui exécute vos commandes de nombreuses fois, chauffe les caches disque à la demande, vous avertit des valeurs aberrantes et rapporte le temps moyen, l'écart-type, le pic mémoire et une comparaison relative. Une seule commande pour trancher définitivement : <code>ripgrep</code> est-il plus rapide que <code>grep</code> sur votre machine ? Votre build Docker optimisé est-il réellement plus rapide ? Plus de devinettes, plus de chronomètre — juste des chiffres.</p></div></div></div><nav class="mobileQuickLinks_La_l" aria-label="Articles liés"><p class="title_rsOi mk-eyebrow">Sur le même sujet</p><ul class="list_CXCl"><li class="item_grzi"><a href="/fr/blog/ollama-ai-explain/">ai-explain : l'ELI5 des scripts et des erreurs, directement dans votre terminal</a></li><li class="item_grzi"><a href="/fr/blog/duckdb-json-csv/">DuckDB - Interroger des fichiers JSON et CSV en SQL, sans base de données</a></li><li class="item_grzi"><a href="/fr/blog/linux-yq/">yq — Le processeur YAML que vous savez déjà utiliser</a></li></ul></nav>
<p>Vous avez remplacé <code>grep</code> par <code>ripgrep</code>. Vous êtes à peu près sûr que c'est plus rapide. Mais de combien ? La différence est-elle réelle ou n'est-ce qu'un effet de cache lié au premier run ?</p>
<p>Vous avez optimisé une étape de build Docker. Vous pensez que c'est mieux. Mais vous ne l'avez chronométrée qu'une fois, avec <code>time</code>, qui vous donne un seul point de mesure et aucune statistique.</p>
<p><code>hyperfine</code> répond correctement à ces questions.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="a-first-benchmark">Un premier benchmark<a href="#a-first-benchmark" class="hash-link" aria-label="Lien direct vers Un premier benchmark" title="Lien direct vers Un premier benchmark" translate="no"></a></h2>
<p>Comparons <code>grep -r</code> et <code>git grep</code> pour trouver tous les <code>TODO</code> d'un repository. Les deux sont déjà sur votre machine, et <code>git grep</code> ne cherche que dans les fichiers suivis par git :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>hyperfine --warmup 3 'grep -r "TODO" .' 'git grep "TODO"'</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">Benchmark 1: grep -r "TODO" . (10 runs)</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">                mean     ±       σ          min     …     max</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  Wall Time    321.4 ms  ±     7.0 ms     315.6 ms  …   335.2 ms</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  Memory        11.5 MiB ±     0.0 MiB     11.5 MiB …    11.6 MiB</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">Benchmark 2: git grep "TODO" (29 runs)</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">                mean     ±       σ          min     …     max</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  Wall Time    102.3 ms  ±     1.9 ms      99.7 ms  …   106.4 ms           -68.2%  (3.1x faster)</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  Memory        31.8 MiB ±     0.8 MiB     29.6 MiB …    33.0 MiB         +176.1%  (2.8x more)</span><br></div></code></pre></div></div>
<p>Moyenne, écart-type, min, max — pour le temps <strong>et</strong> la mémoire, sur autant de runs que nécessaire pour obtenir des chiffres stables. Chaque commande après la première est comparée à la première : ici <code>git grep</code> est 3,1 fois plus rapide, et utilise 2,8 fois plus de mémoire pour y arriver.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="install">Installation<a href="#install" class="hash-link" aria-label="Lien direct vers Installation" title="Lien direct vers Installation" translate="no"></a></h2>
<p><strong>Avec Homebrew (macOS, Linux et WSL) :</strong></p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>brew install hyperfine</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p><strong>Avec cargo (toolchain Rust) :</strong></p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>cargo install hyperfine</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p><strong>Avec apt (Debian, Ubuntu) :</strong></p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>sudo apt install hyperfine</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<div class="alertBox_R1Ot mk-callout info_J1rX mk-callout--info" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">apt fournit hyperfine 1.x</p><div class="content_gFLB"><p>Ubuntu 24.04 package la version 1.18 et Debian 13 la version 1.19. La version 2.0 a changé le format de sortie et la façon dont les commandes sont exécutées (voir <a href="#no-shell-by-default" class="">Pas de shell par défaut</a>), donc les exemples de cet article n'auront pas exactement la même allure. Pour obtenir la 2.0 sur Debian ou Ubuntu, installez le <code>.deb</code> attaché à la <a href="https://github.com/sharkdp/hyperfine/releases/tag/v2.0.0" target="_blank" rel="noopener noreferrer" class="">release GitHub</a> :</p></div></div></div>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>curl -LO <a href="https://github.com/sharkdp/hyperfine/releases/download/v2.0.0/hyperfine-musl_2.0.0_amd64.deb" target="_blank" rel="noopener noreferrer" class="">https://github.com/sharkdp/hyperfine/releases/download/v2.0.0/hyperfine-musl_2.0.0_amd64.deb</a>
sudo apt install ./hyperfine-musl_2.0.0_amd64.deb</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Vérification :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>hyperfine --version</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">hyperfine 2.0.0</span><br></div></code></pre></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="warmup-runs">Les runs de warmup<a href="#warmup-runs" class="hash-link" aria-label="Lien direct vers Les runs de warmup" title="Lien direct vers Les runs de warmup" translate="no"></a></h2>
<p>Le premier run d'une commande est souvent plus lent : les caches disque sont froids, l'OS n'a rien préchargé. C'est ce qui rend peu fiables les outils à mesure unique comme <code>time</code>. <code>hyperfine</code> gère ça avec <code>--warmup</code> :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>hyperfine --warmup 3 'grep -r "TODO" .' 'rg "TODO"'</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Trois runs jetables avant le début de la mesure. Le benchmark ne chronomètre que l'état chaud — la condition qui compte en usage réel.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="controlling-run-count">Contrôler le nombre de runs<a href="#controlling-run-count" class="hash-link" aria-label="Lien direct vers Contrôler le nombre de runs" title="Lien direct vers Contrôler le nombre de runs" translate="no"></a></h2>
<p>Par défaut, hyperfine exécute chaque commande au moins 10 fois, et continue pendant au moins trois secondes — c'est pourquoi les commandes rapides obtiennent des centaines de runs. Vous pouvez surcharger ce comportement :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>hyperfine --min-runs 20 'my-command'   # at least 20 runs
hyperfine --runs 5 'my-command'        # exactly 5 runs (faster, less accurate)</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Pour les commandes lentes (builds Docker, compilation), utilisez moins de runs :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>hyperfine --runs 3 'docker build -t myapp<!-- -->:v1<!-- --> -f Dockerfile.v1 .'</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="no-shell-by-default">Pas de shell par défaut<a href="#no-shell-by-default" class="hash-link" aria-label="Lien direct vers Pas de shell par défaut" title="Lien direct vers Pas de shell par défaut" translate="no"></a></h2>
<p>Depuis la version 2.0, hyperfine exécute chaque commande <strong>directement</strong>, sans shell intermédiaire. Cela retire le temps de démarrage du shell de la mesure, mais cela signifie aussi que la syntaxe shell — pipes, redirections, <code>&amp;&amp;</code>, <code>~</code> — n'est pas interprétée. hyperfine refuse une telle commande plutôt que de mesurer silencieusement la mauvaise chose :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>$ hyperfine 'seq 100 | sort'
Error: Command 'seq 100 | sort' contains unquoted shell syntax ('|').
Explicitly choose how to execute it:
-S / --shell=default  Interpret shell syntax.
-N / --shell=none     Run directly, passing '|' as a literal argument.</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Ajoutez <code>-S</code> pour exécuter la commande via <code>sh</code>, ou <code>--shell bash</code> / <code>--shell zsh</code> pour en choisir un. Vos alias et fonctions shell ne sont jamais disponibles, avec ou sans <code>-S</code> : hyperfine ne voit que de vrais exécutables.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="benchmarking-with-variable-inputs">Benchmarker avec des entrées variables<a href="#benchmarking-with-variable-inputs" class="hash-link" aria-label="Lien direct vers Benchmarker avec des entrées variables" title="Lien direct vers Benchmarker avec des entrées variables" translate="no"></a></h2>
<p>Utilisez des placeholders <code>{var}</code> combinés à <code>--parameter-list</code>. Le pipe ci-dessous nécessite un shell, d'où le <code>-S</code> :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>hyperfine -S --parameter-list size 100,1000,10000 <br>
<!-- -->'seq {size} | sort'</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">Benchmark 1: seq 100 | sort (1870 runs)</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">                mean     ±       σ          min     …     max</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  Wall Time    569.5 µs  ±   209.2 µs     393.0 µs  …  1907.6 µs</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  Memory         1.7 MiB ±     0.1 MiB      1.6 MiB …     1.9 MiB</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">Benchmark 2: seq 1000 | sort (2920 runs)</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">                mean     ±       σ          min     …     max</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  Wall Time    700.3 µs  ±   321.8 µs     396.0 µs  …  2185.2 µs           +23.0%</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  Memory         2.1 MiB ±     0.2 MiB      1.9 MiB …     2.4 MiB          +21.6%</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">Benchmark 3: seq 10000 | sort (1949 runs)</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">                mean     ±       σ          min     …     max</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  Wall Time   1081.8 µs  ±   336.0 µs     840.7 µs  …  2630.4 µs           +89.9%  (1.9x slower)</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  Memory         2.6 MiB ±     0.1 MiB      2.4 MiB …     2.8 MiB          +52.4%  (1.5x more)</span><br></div></code></pre></div></div>
<p>Cela lance trois benchmarks automatiquement et vous montre comment la performance évolue avec la taille de l'entrée. Pour des commandes aussi courtes, hyperfine affiche aussi un avertissement : en dessous de 5 ms, le temps de démarrage du shell pèse sur le résultat.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="prepare-and-cleanup">Préparation et nettoyage<a href="#prepare-and-cleanup" class="hash-link" aria-label="Lien direct vers Préparation et nettoyage" title="Lien direct vers Préparation et nettoyage" translate="no"></a></h2>
<p>Certains benchmarks ont besoin d'une étape de setup qui ne doit pas être mesurée — vider un cache, créer un fichier de test, réinitialiser un état. Utilisez <code>--prepare</code> :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>hyperfine --prepare 'sync; echo 3 | sudo tee /proc/sys/vm/drop_caches' <br>
<!-- -->'grep -r "function" /usr/lib/python3'</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>La commande <code>--prepare</code> est exécutée avant chaque run chronométré, sans faire partie du temps mesuré. Elle passe toujours par un shell, donc le <code>;</code> et le pipe fonctionnent ici sans <code>-S</code>.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="exporting-results">Exporter les résultats<a href="#exporting-results" class="hash-link" aria-label="Lien direct vers Exporter les résultats" title="Lien direct vers Exporter les résultats" translate="no"></a></h2>
<p>Trois formats d'export sont les plus utiles :</p>
<p><strong>Table Markdown</strong> (à coller directement dans un article de blog ou un README) :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>hyperfine --export-markdown results.md 'find . -name "*.md"' 'grep -rl "TODO" .'</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>La table ne contient que la métrique principale — le wall time, sauf si vous en choisissez une autre avec <code>--metrics</code> :</p>
<div class="language-markdown codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-markdown codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token table table-header-row punctuation" style="color:rgb(212, 212, 212)">|</span><span class="token table table-header-row table-header important"> Command </span><span class="token table table-header-row punctuation" style="color:rgb(212, 212, 212)">|</span><span class="token table table-header-row table-header important"> Mean Wall Time [ms] </span><span class="token table table-header-row punctuation" style="color:rgb(212, 212, 212)">|</span><span class="token table table-header-row table-header important"> Change </span><span class="token table table-header-row punctuation" style="color:rgb(212, 212, 212)">|</span><span class="token table table-header-row table-header important"> Factor </span><span class="token table table-header-row punctuation" style="color:rgb(212, 212, 212)">|</span><span class="token table table-header-row"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token table table-header-row"></span><span class="token table table-line punctuation" style="color:rgb(212, 212, 212)">|</span><span class="token table table-line punctuation" style="color:rgb(212, 212, 212)">:---</span><span class="token table table-line punctuation" style="color:rgb(212, 212, 212)">|</span><span class="token table table-line punctuation" style="color:rgb(212, 212, 212)">---:</span><span class="token table table-line punctuation" style="color:rgb(212, 212, 212)">|</span><span class="token table table-line punctuation" style="color:rgb(212, 212, 212)">---:</span><span class="token table table-line punctuation" style="color:rgb(212, 212, 212)">|</span><span class="token table table-line punctuation" style="color:rgb(212, 212, 212)">:---</span><span class="token table table-line punctuation" style="color:rgb(212, 212, 212)">|</span><span class="token table table-line"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token table table-line"></span><span class="token table table-data-rows punctuation" style="color:rgb(212, 212, 212)">|</span><span class="token table table-data-rows table-data"> </span><span class="token table table-data-rows table-data code-snippet code keyword" style="color:rgb(86, 156, 214)">`find . -name "*.md"`</span><span class="token table table-data-rows table-data"> </span><span class="token table table-data-rows punctuation" style="color:rgb(212, 212, 212)">|</span><span class="token table table-data-rows table-data"> 6.2 ± 0.2 </span><span class="token table table-data-rows punctuation" style="color:rgb(212, 212, 212)">|</span><span class="token table table-data-rows table-data">  </span><span class="token table table-data-rows punctuation" style="color:rgb(212, 212, 212)">|</span><span class="token table table-data-rows table-data">  </span><span class="token table table-data-rows punctuation" style="color:rgb(212, 212, 212)">|</span><span class="token table table-data-rows"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token table table-data-rows"></span><span class="token table table-data-rows punctuation" style="color:rgb(212, 212, 212)">|</span><span class="token table table-data-rows table-data"> </span><span class="token table table-data-rows table-data code-snippet code keyword" style="color:rgb(86, 156, 214)">`grep -rl "TODO" .`</span><span class="token table table-data-rows table-data"> </span><span class="token table table-data-rows punctuation" style="color:rgb(212, 212, 212)">|</span><span class="token table table-data-rows table-data"> 33.9 ± 0.8 </span><span class="token table table-data-rows punctuation" style="color:rgb(212, 212, 212)">|</span><span class="token table table-data-rows table-data"> +446.7% </span><span class="token table table-data-rows punctuation" style="color:rgb(212, 212, 212)">|</span><span class="token table table-data-rows table-data"> (5.5x slower) </span><span class="token table table-data-rows punctuation" style="color:rgb(212, 212, 212)">|</span><br></div></code></pre></div></div>
<p><strong>CSV</strong> (pour les tableurs ou une analyse plus poussée, une colonne par métrique) :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>hyperfine --export-csv results.csv 'command1' 'command2'</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p><strong>JSON</strong> (pour le scripting, avec chaque run individuel) :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>hyperfine --export-json results.json 'command1' 'command2'</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="real-world-examples">Exemples concrets<a href="#real-world-examples" class="hash-link" aria-label="Lien direct vers Exemples concrets" title="Lien direct vers Exemples concrets" translate="no"></a></h2>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="grep-vs-ripgrep--is-the-hype-justified">grep vs ripgrep — le battage est-il justifié ?<a href="#grep-vs-ripgrep--is-the-hype-justified" class="hash-link" aria-label="Lien direct vers grep vs ripgrep — le battage est-il justifié ?" title="Lien direct vers grep vs ripgrep — le battage est-il justifié ?" translate="no"></a></h3>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>hyperfine --warmup 3 <br>
<!-- -->'grep -r "ERROR" /var/log' <br>
<!-- -->'rg "ERROR" /var/log'</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="two-docker-build-strategies">Deux stratégies de build Docker<a href="#two-docker-build-strategies" class="hash-link" aria-label="Lien direct vers Deux stratégies de build Docker" title="Lien direct vers Deux stratégies de build Docker" translate="no"></a></h3>
<p>Vous avez un Dockerfile monolithique et une version multi-stage. Laquelle build le plus vite à partir d'un cache froid ?</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>hyperfine --runs 3 <br>
<!-- -->'docker build --no-cache -t test<!-- -->:mono<!-- --> -f Dockerfile.mono .' <br>
<!-- -->'docker build --no-cache -t test<!-- -->:multi<!-- --> -f Dockerfile.multi .'</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Le flag <code>--no-cache</code> garantit que chaque run repart de zéro. Sans lui, le cache de layers de Docker rend le deuxième run quasi instantané, quelle que soit la stratégie.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="shell-startup-time">Temps de démarrage du shell<a href="#shell-startup-time" class="hash-link" aria-label="Lien direct vers Temps de démarrage du shell" title="Lien direct vers Temps de démarrage du shell" translate="no"></a></h3>
<p>Si vous optimisez votre <code>~/.zshrc</code> (peut-être après avoir lu l'article sur <a href="/fr/blog/modular-zsh-workflow/">les workflows ZSH modulaires</a>), mesurez l'impact avant et après :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>hyperfine --warmup 5 <br>
<!-- -->'zsh -i -c exit' <br>
<!-- -->'bash -i -c exit'</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="find-alternatives-comparison">Comparaison des alternatives à find<a href="#find-alternatives-comparison" class="hash-link" aria-label="Lien direct vers Comparaison des alternatives à find" title="Lien direct vers Comparaison des alternatives à find" translate="no"></a></h3>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>hyperfine --warmup 3 <br>
<!-- -->'find . -type f -name "<em>.ts"' <br>
<!-- -->'fd -t f -e ts' <br>
<!-- -->'rg --files -g "</em>.ts"'</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p><code>fd</code> est rarement installé par défaut : <code>brew install fd</code>, ou <code>sudo apt install fd-find</code> sur Debian et Ubuntu, où la commande s'appelle <code>fdfind</code>.</p>
<p>Cela compare trois outils sur 10 runs ou plus chacun — impossible à évaluer équitablement avec un chronomètre.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="interpreting-the-output">Interpréter la sortie<a href="#interpreting-the-output" class="hash-link" aria-label="Lien direct vers Interpréter la sortie" title="Lien direct vers Interpréter la sortie" translate="no"></a></h2>
<p>Les chiffres clés :</p>
<ul>
<li class=""><strong>Wall Time, moyenne ± σ</strong> : temps moyen et écart-type. Un σ élevé par rapport à la moyenne signifie que les résultats varient beaucoup — plus de runs ou un <code>--prepare</code> pour vider les caches aideraient.</li>
<li class=""><strong>min … max</strong> : le run le plus rapide et le plus lent. Un écart important suggère des interférences externes (autres processus, I/O).</li>
<li class=""><strong>Memory</strong> : le pic mémoire utilisé par la commande. Pratique quand l'outil le plus rapide est aussi le plus gourmand.</li>
<li class=""><strong>La colonne de comparaison</strong> : chaque benchmark après le premier est comparé au premier, en pourcentage et sous la forme « N.Nx faster » ou « slower ». C'est le chiffre que vous êtes venu chercher — mettez donc votre commande de référence en premier.</li>
</ul>
<div class="alertBox_R1Ot mk-callout info_J1rX mk-callout--info" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Faites confiance à la moyenne, pas au minimum</p><div class="content_gFLB"><p>Certains outils de benchmark ne rapportent que le temps minimum (l'argument étant que le minimum est la « vraie » vitesse et que tout le reste est du bruit). <code>hyperfine</code> compare les moyennes parce qu'en pratique, les commandes tournent en conditions réelles — pas dans un isolement idéal. La moyenne, c'est ce que vos utilisateurs vivent.</p></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="under-the-hood-skip-this-if-you-just-want-to-use-it">Sous le capot (passez votre chemin si vous voulez juste l'utiliser)<a href="#under-the-hood-skip-this-if-you-just-want-to-use-it" class="hash-link" aria-label="Lien direct vers Sous le capot (passez votre chemin si vous voulez juste l'utiliser)" title="Lien direct vers Sous le capot (passez votre chemin si vous voulez juste l'utiliser)" translate="no"></a></h2>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="hyperfine-vs-time">hyperfine vs time<a href="#hyperfine-vs-time" class="hash-link" aria-label="Lien direct vers hyperfine vs time" title="Lien direct vers hyperfine vs time" translate="no"></a></h3>
<p>La commande intégrée <code>time</code> vous donne une mesure. C'est très bien pour une curiosité ponctuelle. <code>hyperfine</code> sert quand le résultat compte :</p>
<table><thead><tr><th></th><th><code>time</code></th><th><code>hyperfine</code></th></tr></thead><tbody><tr><td>Runs</td><td>1</td><td>10+ (configurable)</td></tr><tr><td>Warmup</td><td>Non</td><td>Oui (<code>--warmup</code>)</td></tr><tr><td>Statistiques</td><td>Non</td><td>Moyenne, σ, min, max</td></tr><tr><td>Mémoire</td><td>Non</td><td>Pic mémoire, par défaut</td></tr><tr><td>Comparaison</td><td>Manuelle</td><td>Automatique (contre la première commande)</td></tr><tr><td>Export</td><td>Non</td><td>Markdown, CSV, JSON</td></tr><tr><td>Entrées variables</td><td>Non</td><td>Oui (<code>--parameter-list</code>)</td></tr></tbody></table>
<p>Si vous présentez un benchmark à une équipe, rédigez un article de blog ou prenez une décision basée sur des données de performance, utilisez <code>hyperfine</code>. Pour une vérification rapide au feeling, <code>time</code> suffit.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="conclusion">Conclusion<a href="#conclusion" class="hash-link" aria-label="Lien direct vers Conclusion" title="Lien direct vers Conclusion" translate="no"></a></h2>
<p><code>hyperfine</code> rend les affirmations de performance testables. <a href="/fr/blog/ripgrep/">ripgrep</a> est-il plus rapide que grep ? Votre étape de build refactorisée est-elle vraiment une amélioration ? L'alias <a href="/fr/blog/linux-eza/"><code>eza</code></a> que vous avez ajouté à la place de <code>ls</code> se remarque-t-il ? Ce sont des questions auxquelles hyperfine répond en quelques secondes, avec assez de rigueur statistique pour faire confiance au résultat.</p>
<p>Ajoutez-le à votre boîte à outils, à côté des outils que vous benchmarkez déjà, et arrêtez de deviner.</p></div><hr></div><div class="relatedPosts_zJiB"><h3>Articles liés</h3></div><div class="container_mNR1"><span class="question_WdqX">Cet article vous a-t-il été utile ?</span><div class="buttons_vgpi"><button class="btn_svQp" aria-label="Oui, cet article m'a été utile"><img src="/fr/img/reaction-mascot-yes.webp" alt="" class="mascot_Fait">Utile</button><button class="btn_svQp btnNeutral_Iqek" aria-label="Non, cet article ne m'a pas été utile"><img src="/fr/img/reaction-mascot-no.webp" alt="" class="mascot_Fait">Pas vraiment</button></div></div><div class="container_IJwe"><span class="question_nb7b">Avez-vous suivi les étapes de cet article ?</span><div class="buttons_Xkzw"><button class="btn_RMiq" aria-label="Oui, j'ai essayé et ça a marché"><img src="/fr/img/like.webp" alt="" class="mascot_wB1d">Ça a marché !</button><button class="btn_RMiq btnNeutral_z71B" aria-label="Non, ça n'a pas marché pour moi"><img src="/fr/img/dislike.webp" alt="" class="mascot_wB1d">Ça n'a pas marché</button></div></div>]]></content:encoded>
            <author>Christophe</author>
            <author>Claude Code</author>
            <category>bash</category>
            <category>docker</category>
            <category>linux</category>
            <enclosure length="0" type="image/webp" url="https://www.avonture.be/fr/img/v2/hyperfine.webp"/>
        <dc:creator><![CDATA[Christophe]]></dc:creator><dc:creator><![CDATA[Claude Code]]></dc:creator></item>
        <item>
            <title><![CDATA[Drawing a Map of My Own Blog]]></title>
            <link>https://www.avonture.be/fr/fr/blog/docusaurus-blog-map</link>
            <guid isPermaLink="false">/fr/blog/docusaurus-blog-map</guid>
            <pubDate>Thu, 08 Oct 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[<img src="https://www.avonture.be/fr/img/v2/post_mindmaps.webp" alt="Drawing a Map of My Own Blog" style="display:block;max-width:100%;height:auto;">Build an interactive map of your whole Docusaurus corpus — every article as a bubble wearing the site's mascot, sized by how many other posts link to it, positioned by a force-directed layout that runs at build time in Node so the browser never ships d3-force. Includes the plugin, the canvas component, and the plain-list fallback that keeps the page usable with JavaScript disabled.]]></description>
            <content:encoded><![CDATA[<div class="offer_jqr1"><span class="flag_Hnep" aria-hidden="true">🇬🇧</span><span>Cet article existe aussi en anglais —<!-- --> <a href="/blog/docusaurus-blog-map/" hreflang="en">lire la version originale</a>.</span></div><div class="alertBox_R1Ot mk-callout caution_LuPi mk-callout--warn" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Traduction automatique</p><div class="content_gFLB">Cette traduction a été produite par une IA. Elle peut contenir des contresens ou des maladresses, en particulier sur les termes techniques. En cas de doute,<!-- --> <a href="/blog/docusaurus-blog-map/">consultez l'article de référence en anglais</a> <!-- -->— c'est lui qui fait foi.</div></div></div><div><div class="seriesBlogPost_bjUd"><details class="details_rTsq"><summary class="seriesIntro_HIK1 mk-eyebrow">Cet article fait partie de la série <a href="/fr/series/creating-docusaurus-components/">Créer des composants Docusaurus</a> :</summary><div class="content_OqVG"><ul><li><a href="/fr/blog/docusaurus-override-img/">Modifier la façon dont Docusaurus génère les balises img</a></li><li><a href="/fr/blog/docusaurus-lazy-loading/">Surcharger la génération des balises img avec Docusaurus</a></li><li><a href="/fr/blog/docusaurus-series/">Organisez votre contenu Docusaurus avec un composant Series personnalisé</a></li><li><a href="/fr/blog/docusaurus-go-top/">Ajouter un bouton « retour en haut » dans les articles Docusaurus</a></li><li><a href="/fr/blog/docusaurus-plugin-replace/">Créer un plugin de recherche/remplacement pour Docusaurus</a></li><li><a href="/fr/blog/docusaurus-snippets/">Un composant pour afficher des snippets de code dans un blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-old-notice/">Un composant Docusaurus qui prévient le lecteur que l'article a plus d'un an</a></li><li><a href="/fr/blog/docusaurus-changelog/">Afficher le changelog de votre article</a></li><li><a href="/fr/blog/docusaurus-ascii-art/">Injecter de l'ASCII art dans toutes les pages HTML générées par Docusaurus</a></li><li><a href="/fr/blog/blog-post-feed/">Best Practice - Personnaliser le flux RSS de Docusaurus pour un contenu complet et des images</a></li><li><a href="/fr/blog/docusaurus-check-images/">Quelques vérifications sur les images de votre site Docusaurus</a></li><li><a href="/fr/blog/docusaurus-tags/">Gestion des tags dans Docusaurus</a></li><li><a href="/fr/blog/gemini-tldr/">Automatiser les résumés TL;DR avec Gemini AI</a></li><li><a href="/fr/blog/docusaurus-project-setup/">Le composant ProjectSetup - Une façon standardisée de partager des structures de projet</a></li><li><a href="/fr/blog/docusaurus-ai-gemini/">Comment signaler un contenu assisté par IA dans un blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-terminal-typewriter/">Donnez vie à vos tutoriels CLI avec un terminal machine à écrire</a></li><li><a href="/fr/blog/docusaurus-easter-eggs/">Espiègleries de suricate : semer des easter eggs partout sur mon blog</a></li><li><a href="/fr/blog/docusaurus-eli5-snippet-tooltips/">Tooltips de code assistés par l'IA dans Docusaurus — Explique-moi comme à un enfant de cinq ans</a></li><li><a href="/fr/blog/docusaurus-reactions/">Ajouter des réactions de lecteurs à votre blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-llms-txt/">Votre blog est illisible pour une IA. Voici la solution.</a></li><li><a href="/fr/blog/docusaurus-shake-easter-egg/">Secouez votre téléphone, surprenez le suricate</a></li><li><a href="/fr/blog/docusaurus-copy-as-markdown/">Ajouter un bouton « Copy as Markdown » à votre blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-ask-my-blog/">Ask My Blog : transformer 259 articles en 2 160 questions cherchables</a></li><li><a href="/fr/blog/docusaurus-ask-my-blog-bubble/">La troisième porte : une bulle de chat flottante, et la fonctionnalité que personne ne trouvait</a></li><li><a href="/fr/blog/docusaurus-blog-map/">Dessiner une carte de mon propre blog</a></li><li><a href="/fr/blog/docusaurus-typo-report-component/">Laissez vos lecteurs signaler les problèmes — un widget de feedback multi-types pour Docusaurus</a></li></ul></div></details></div><div><p><img alt="Dessiner une carte de mon propre blog" src="/fr/assets/images/post_mindmaps-99e780e537e2b4dce6519e521b8ce5fc.webp" height="auto" width="" loading="lazy" decoding="async" class=""></p>
<div class="tldrContainer_qQW_ mk-callout mk-callout--key" role="region" aria-label="Résumé de l'article"><span role="img" aria-label="éclair" class="tldrIcon_SLBz mk-dot">⚡</span><div class="tldrBody_lGty"><strong class="tldrTitle_Aha8 mk-eyebrow">TL;DR</strong><div class="tldrContent_m27u"><p>Après 262 articles, je ne savais plus répondre à des questions simples sur mon propre blog : quels billets sont des hubs vers lesquels tout pointe, et lesquels restent seuls dans un coin. J'ai donc construit une page <code>/map</code> — un graphe force-directed de tout le corpus, où chaque article est un point dimensionné selon son degré entrant et relié par trois types d'arêtes (vrais liens inline, voisins de série, tags partagés). L'astuce qui rend ça peu coûteux : le layout est calculé <strong>une seule fois, dans Node, au build</strong>, donc le navigateur reçoit des coordonnées <code>(x, y)</code> finales et ne charge jamais de moteur physique.</p></div></div></div>
<p>Je publie deux fois par semaine, le lundi et le jeudi, et ça dure depuis un bon moment. Quelque part autour du billet numéro 150, je n'ai plus été capable de répondre à des questions que j'aurais dû connaître par cœur : cet article sur <code>fzf</code>, est-il seulement lié depuis quelque part ? Quel est le billet vers lequel tout le reste renvoie ? Est-ce que toute la série Quarto s'est retrouvée isolée du reste du blog ?</p>
<p>J'avais les données — le frontmatter de chaque article, chaque lien inline — mais aucun moyen de les <em>regarder</em>. Une liste de 262 lignes, ce n'est pas une façon de regarder quoi que ce soit.</p>
<p>J'ai donc construit <a class="" href="/fr/map/">/map</a> — une page qui dessine le blog sous forme de graphe. Voici comment ça fonctionne et comment en construire une.</p>
<nav class="quickJump_q5pQ" aria-label="Quick Jump"><span class="title_Njuu mk-eyebrow">Quick Jump</span><ul class="list_A_9v"><li class="item_gTjz"><a href="#what-the-map-page-shows-you">Ce que la page carte vous montre</a></li><li class="item_gTjz"><a href="#building-it">La construire</a></li></ul></nav>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="what-the-map-page-shows-you">Ce que la page carte vous montre<a href="#what-the-map-page-shows-you" class="hash-link" aria-label="Lien direct vers Ce que la page carte vous montre" title="Lien direct vers Ce que la page carte vous montre" translate="no"></a></h2>
<p>Chaque article publié est une bulle. Plus elle est grosse, plus d'autres articles pointent vers elle. Des lignes relient les billets réellement liés entre eux — survolez-en un et tout ce qui n'est pas son voisin direct s'estompe :</p>
<div class="language-plaintext codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockTitle_OeMC">/map</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-plaintext codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">┌─ Blog Map ──────────────────────────────────────────────────────────────────┐</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">│                                                                             │</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">│  Filter by topic  [ Top 120 most-linked articles  ▾ ]                       │</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">│  262 articles · 25 series · 680 internal links                              │</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">│                                                                             │</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">│                           Running Docusaurus with Docker                    │</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">│         ·   ·                     ●                                         │</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">│       ·  ╲  │ ╱  ·               ╱ ╲                                        │</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">│     ·──── (◉) ────·             ●   ●───────● Docker-out-of-Docker          │</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">│       ·  ╱  │ ╲  ·               ╲ ╱                                        │</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">│         ·   ·                     ●                                         │</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">│                                                       🦫                    │</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">│                                              (drawn bigger than its size:   │</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">│                                               nothing links to this one yet)│</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">│                                                                             │</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">│  ▸ View as list instead                                                     │</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">└─────────────────────────────────────────────────────────────────────────────┘</span><br></div></code></pre></div></div>
<p>Trois choses à faire sur cette page :</p>
<ul>
<li class=""><strong>Filtrer par sujet</strong> avec la liste déroulante pour restreindre le graphe à un seul <code>mainTag</code>. Les articles qui partagent le tag mais ne sont liés à rien autour d'eux restent visibles — c'est exactement le genre d'orphelin que vous ne remarqueriez jamais autrement.</li>
<li class=""><strong>Survoler un nœud</strong> pour voir son titre et mettre en évidence ses connexions directes. Un cluster qui paraît dense de loin se révèle souvent être deux ou trois hubs avec beaucoup de satellites qui ne pointent que vers l'intérieur.</li>
<li class=""><strong>Passer en liste</strong> en bas de page si vous préférez le texte au canvas, ou si vous avez désactivé JavaScript : chaque article est toujours là, groupé par série, entièrement indexé.</li>
</ul>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="why-computing-the-layout-at-build-time-changes-everything">Pourquoi calculer le layout au build change tout<a href="#why-computing-the-layout-at-build-time-changes-everything" class="hash-link" aria-label="Lien direct vers Pourquoi calculer le layout au build change tout" title="Lien direct vers Pourquoi calculer le layout au build change tout" translate="no"></a></h2>
<p>Un graphe force-directed, c'est normalement une affaire de navigateur : vous embarquez une bibliothèque physique, vous y déposez les nœuds et vous les regardez se stabiliser pendant quelques secondes. Cette approche échoue sur trois exigences qui comptaient vraiment pour moi, et déplacer la simulation dans Node règle les trois d'un coup.</p>
<ul>
<li class=""><strong>La page doit fonctionner sans JavaScript.</strong> Si les positions sont déjà finales dans le HTML, la seule chose qu'ajoute JavaScript est le dessin — le chemin sans JS peut donc retomber sur une liste simple, rendue côté serveur et entièrement indexable, de tous les articles, au lieu d'une boîte vide.</li>
<li class=""><strong>Rien ne doit s'animer tout seul.</strong> Il n'y a pas de branche <code>prefers-reduced-motion</code> à écrire, parce qu'il n'y a aucun mouvement à supprimer : le graphe est déjà au repos à l'instant où il apparaît.</li>
<li class=""><strong><code>d3-force</code> n'atteint jamais le lecteur.</strong> Il reste une dépendance de build. Le navigateur reçoit des nombres, pas un solveur.</li>
<li class=""><strong>Le même build produit la même carte.</strong> L'ordre des nœuds est trié avant le démarrage de la simulation, et <code>d3-force</code> place les nœuds à partir de leur index de tableau plutôt que de <code>Math.random()</code> : un rebuild sans changement de contenu reproduit exactement la même image.</li>
</ul>
<p>Le coût de tout ça, c'est une seconde de plus dans <code>yarn build</code>. C'est le meilleur arbitrage que j'aie fait sur ce blog.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="building-it">La construire<a href="#building-it" class="hash-link" aria-label="Lien direct vers La construire" title="Lien direct vers La construire" translate="no"></a></h2>
<p>Deux morceaux : un plugin qui calcule le graphe, et un composant qui le dessine.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="the-plugin">Le plugin<a href="#the-plugin" class="hash-link" aria-label="Lien direct vers Le plugin" title="Lien direct vers Le plugin" translate="no"></a></h3>
<p>Il lit le corpus via le même loader que celui déjà utilisé par mon <a href="/fr/blog/docusaurus-tags/">outillage de tags</a>, construit les arêtes, colore chaque nœud, exécute 300 ticks de simulation et passe le résultat à <code>setGlobalData</code> :</p>

<p>L'exécuter en standalone montre à quoi ressemble la charge utile :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb">$ node --input-type=module -e "
import('./plugins/blog-graph-plugin/index.mjs').then(async m =&gt; {
  const graph = await m.default().loadContent();
  const byType = {};
  for (const edge of graph.edges) byType[edge.type] = (byType[edge.type] ?? 0) + 1;
  console.log('nodes  ', graph.nodes.length);
  console.log('edges  ', graph.edges.length, byType);
  console.log('meta   ', graph.meta);
  console.log('payload', JSON.stringify(graph).length, 'bytes');
});"

nodes   262
edges   1026 { link: 680, series: 144, tag: 202 }
meta    { articleCount: 262, seriesCount: 25, linkCount: 680 }
payload 183292 bytes
</pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>262 nœuds, 1026 arêtes, 179 Ko de JSON — calculés une fois au build, plus jamais touchés par le navigateur.</p>
<p>Une décision mérite d'être soulignée : <strong>les couleurs des nœuds ne sont pas une nouvelle palette.</strong> Un billet appartenant à une série réutilise la couleur d'accent de cette série depuis <code>src/data/series.js</code> — la même que celle dont la <a href="/fr/blog/docusaurus-series/">page des séries</a> habille son hero. Un billet sans série retombe sur la couleur d'accent extraite automatiquement de son image de bannière. Seul un billet qui n'a ni l'un ni l'autre obtient un gris neutre. La carte ressemble donc au reste du site gratuitement, et un lecteur qui reconnaît déjà « la bleue, c'est la série Quarto » la reconnaît ici aussi.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="the-component">Le composant<a href="#the-component" class="hash-link" aria-label="Lien direct vers Le composant" title="Lien direct vers Le composant" translate="no"></a></h3>
<p><code>BlogGraph</code> lit ces données globales, les dessine sur un <code>&lt;canvas&gt;</code> et se remplace par une liste simple en dessous de 768px :</p>
<div class="projectSetupContainer_LW1i mk-surface"><div class="header_Htsx mk-toolbar"><strong>📦 <!-- -->Mise en place du projet :<!-- --> <!-- -->src/components/BlogGraph</strong><div class="buttonGroup_y07f mk-toolbar__actions"><a class="button button--secondary button--sm" href="/project_setup" target="_blank">Aide</a><button class="button button--secondary button--sm">Tout déplier</button><button class="button button--outline button--primary button--sm" aria-label="Télécharger le projet en ZIP">ZIP</button><button class="button button--secondary button--sm">Générer le script d'installation</button></div></div><div class="content_bo8S"><div><p class="tip_qyDH"><small><span aria-hidden="true">💡</span> <!-- -->Besoin d'aide ? Cliquez sur le bouton « Aide » ci-dessus pour les instructions de mise en place.</small></p></div></div></div>
<p>Canvas, pas SVG, et ce n'est pas une préférence esthétique : 262 nœuds plus un millier d'arêtes en éléments DOM, et chaque survol devient un recalcul de layout sur un millier de nœuds. Sur un canvas, un survol, c'est un redessin complet de quelques centaines de formes, autant dire rien.</p>
<p>Enregistrez le plugin dans <code>docusaurus.config.js</code> et donnez-lui une page :</p>
<div class="language-javascript codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockTitle_OeMC">docusaurus.config.js</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-javascript codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token literal-property property">plugins</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token string" style="color:rgb(206, 145, 120)">"./plugins/blog-graph-plugin/index.mjs"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token comment" style="color:rgb(106, 153, 85)">// ...</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token punctuation" style="color:rgb(212, 212, 212)">]</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><br></div></code></pre></div></div>
<div class="language-mdx codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockTitle_OeMC">src/pages/map.mdx</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-mdx codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">---</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">title: "Blog Map"</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">description: "An interactive map of the whole blog."</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">hide_table_of_contents: true</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">---</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">import BlogGraph from "@site/src/components/BlogGraph";</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">&lt;BlogGraph /&gt;</span><br></div></code></pre></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="the-three-kinds-of-connection">Les trois types de connexion<a href="#the-three-kinds-of-connection" class="hash-link" aria-label="Lien direct vers Les trois types de connexion" title="Lien direct vers Les trois types de connexion" translate="no"></a></h2>
<p>Une arête signifie « ces deux billets sont liés », mais la parenté a des intensités, et les mélanger à poids égal produit une pelote de laine. Mon corpus émet trois types, chacun avec sa propre traction sur le layout :</p>
<table><thead><tr><th>Type</th><th>Ce que ça signifie</th><th>Nombre</th><th>Traction sur le layout</th></tr></thead><tbody><tr><td><code>link</code></td><td>Le texte d'un billet pointe vraiment vers un autre</td><td>680</td><td>La plus forte — distance 55, force 0.85</td></tr><tr><td><code>series</code></td><td>Deux billets consécutifs (par date) dans la même série</td><td>144</td><td>Moyenne — distance 70, force 0.5</td></tr><tr><td><code>tag</code></td><td>Deux billets partageant plusieurs tags</td><td>202</td><td>La plus faible — distance 110, pondérée par le poids</td></tr></tbody></table>
<p>La ligne <code>tag</code> est l'endroit où se cache l'échec intéressant. À cette taille de corpus, <strong>presque chaque paire de billets partage au moins un tag</strong> — les relier toutes reviendrait à tout relier à tout et détruirait à la fois le layout et l'image. Monter la barre à deux tags partagés laisse encore 1 355 paires, dont 1 153 sont du type le plus faible possible :</p>
<div class="language-plaintext codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-plaintext codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">shared tags:   2      3     4    5</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">pairs:       1153    159    40    3</span><br></div></code></pre></div></div>
<p>Le plugin fait donc deux choses différentes avec cet ensemble. Le <strong>layout</strong> utilise toutes les paires jusqu'à deux tags partagés, parce que cette faible traction spatiale est précisément ce qui regroupe « tous mes billets Docker ». La <strong>charge utile</strong> n'embarque que les paires à trois tags ou plus, parce que rien ne dessine jamais le reste — embarquer la majorité à poids 2 aurait triplé le JSON pour des lignes invisibles. Mêmes données, deux seuils, un pour la physique et un pour le réseau.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="under-the-hood-skip-this-if-you-just-want-the-map">Sous le capot (passez si vous voulez juste la carte)<a href="#under-the-hood-skip-this-if-you-just-want-the-map" class="hash-link" aria-label="Lien direct vers Sous le capot (passez si vous voulez juste la carte)" title="Lien direct vers Sous le capot (passez si vous voulez juste la carte)" translate="no"></a></h2>
<p>Quatre problèmes qui n'étaient pas évidents avant que la page soit réellement à l'écran.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="a-two-node-filter-got-a-giant-empty-canvas">Un filtre à deux nœuds donnait un canvas géant et vide<a href="#a-two-node-filter-got-a-giant-empty-canvas" class="hash-link" aria-label="Lien direct vers Un filtre à deux nœuds donnait un canvas géant et vide" title="Lien direct vers Un filtre à deux nœuds donnait un canvas géant et vide" translate="no"></a></h3>
<p>Choisissez un sujet de niche dans le filtre et vous obtiendrez peut-être trois articles. Le canvas, lui, restait dimensionné pour 120. Mon premier correctif a été de le dimensionner selon le ratio d'aspect de la bounding box des nœuds visibles — ce qui a échoué, parce que deux articles partageant un <code>mainTag</code> ne sont pas obligés d'être proches dans le layout partagé calculé sur tout le corpus. Deux nœuds aux extrémités opposées produisent un ratio extrême qui revient directement se caler sur la hauteur maximale.</p>
<p>Le deuxième signal, le nombre de nœuds relatif à la vue par défaut, attrape ce cas mais écraserait un cluster réellement haut et compact. <code>computeCanvasHeight()</code> calcule donc les deux et garde <strong>le plus petit</strong>. Aucun des deux angles morts ne peut produire un canvas surdimensionné à lui seul :</p>
<div class="language-typescript codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockTitle_OeMC">src/components/BlogGraph/utils.ts</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-typescript codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token keyword" style="color:rgb(86, 156, 214)">const</span><span class="token plain"> ratio </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> </span><span class="token function" style="color:rgb(220, 220, 170)">contentAspectRatio</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">nodes</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">??</span><span class="token plain"> maxRatio</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token keyword" style="color:rgb(86, 156, 214)">const</span><span class="token plain"> aspectHeight </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> </span><span class="token function" style="color:rgb(220, 220, 170)">clamp</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">Math</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">round</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">containerWidth </span><span class="token operator" style="color:rgb(212, 212, 212)">*</span><span class="token plain"> ratio</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">+</span><span class="token plain"> padding</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token keyword" style="color:rgb(86, 156, 214)">const</span><span class="token plain"> density </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> Math</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">min</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">nodes</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token plain">length </span><span class="token operator" style="color:rgb(212, 212, 212)">/</span><span class="token plain"> defaultTopN</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">1</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token keyword" style="color:rgb(86, 156, 214)">const</span><span class="token plain"> densityHeight </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> </span><span class="token function" style="color:rgb(220, 220, 170)">clamp</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  Math</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">round</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">minHeight </span><span class="token operator" style="color:rgb(212, 212, 212)">+</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">maxHeight </span><span class="token operator" style="color:rgb(212, 212, 212)">-</span><span class="token plain"> minHeight</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">*</span><span class="token plain"> Math</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">sqrt</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">density</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token keyword" style="color:rgb(86, 156, 214)">return</span><span class="token plain"> Math</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">min</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">aspectHeight</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> densityHeight</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><br></div></code></pre></div></div>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="labels-stacking-on-top-of-each-other">Des libellés qui s'empilent les uns sur les autres<a href="#labels-stacking-on-top-of-each-other" class="hash-link" aria-label="Lien direct vers Des libellés qui s'empilent les uns sur les autres" title="Lien direct vers Des libellés qui s'empilent les uns sur les autres" translate="no"></a></h3>
<p>Dessiner 262 titres est illisible : seuls les huit nœuds visibles les plus connectés gardent un libellé permanent, auxquels s'ajoutent le nœud survolé et ses voisins directs. Même ça s'empile quand un sujet filtré est chargé. <code>selectNonOverlappingLabels()</code> parcourt les candidats par ordre de priorité — le nœud survolé d'abord, puis les voisins, puis les hubs — mesure chacun avec <code>ctx.measureText()</code> et écarte tout libellé dont la boîte croiserait une boîte déjà placée. Les libellés les moins prioritaires perdent ; celui du nœud survolé gagne toujours.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="canvas-cannot-hear-a-theme-change">Un canvas n'entend pas un changement de thème<a href="#canvas-cannot-hear-a-theme-change" class="hash-link" aria-label="Lien direct vers Un canvas n'entend pas un changement de thème" title="Lien direct vers Un canvas n'entend pas un changement de thème" translate="no"></a></h3>
<p>Les couleurs des nœuds et des arêtes sont lues depuis les variables CSS du site, pour que rien ne soit en dur — mais un canvas est un bitmap, et basculer en mode sombre ne repeint rien. La solution : un <code>MutationObserver</code> sur l'attribut <code>data-theme</code> de <code>&lt;html&gt;</code> qui incrémente un compteur, lequel figure dans le tableau de dépendances de l'effet de redessin :</p>
<div class="language-typescript codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockTitle_OeMC">src/components/BlogGraph/index.tsx</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-typescript codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token function" style="color:rgb(220, 220, 170)">useEffect</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">=&gt;</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token keyword" style="color:rgb(86, 156, 214)">const</span><span class="token plain"> observer </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">new</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(78, 201, 176)">MutationObserver</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">=&gt;</span><span class="token plain"> </span><span class="token function" style="color:rgb(220, 220, 170)">setThemeVersion</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">v</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">=&gt;</span><span class="token plain"> v </span><span class="token operator" style="color:rgb(212, 212, 212)">+</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">1</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  observer</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">observe</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">document</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token plain">documentElement</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    attributes</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token boolean">true</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    attributeFilter</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token string" style="color:rgb(206, 145, 120)">"data-theme"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">]</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token keyword" style="color:rgb(86, 156, 214)">return</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">=&gt;</span><span class="token plain"> observer</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">disconnect</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token punctuation" style="color:rgb(212, 212, 212)">]</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><br></div></code></pre></div></div>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="the-meerkats">Les suricates<a href="#the-meerkats" class="hash-link" aria-label="Lien direct vers Les suricates" title="Lien direct vers Les suricates" translate="no"></a></h3>
<p>Ce blog a une mascotte — elle se cache dans le <a href="/fr/blog/docusaurus-ascii-art/">code source de la page</a>, chevauche le <a href="/fr/blog/docusaurus-go-top/">bouton de retour en haut</a> et apparaît sur la page 404. Sur la carte, ce n'est pas une garniture sur quelques nœuds : <strong>chaque bulle est un suricate</strong>, portant le sticker que lui a valu son <code>mainTag</code>. Les articles PHP tiennent un petit éléphant violet, les articles Bash portent un sweat à capuche, <code>code-quality</code> arrive avec une coche verte. Une trentaine de tags ont un sticker explicite et le reste en tire un de façon déterministe parmi ce qui reste, si bien qu'un sujet a toujours la même tête et que ses articles se lisent comme une famille sur le canvas.</p>
<p>Ce qui fait marcher tout ça, ce sont les images, pas le code : ce sont des portraits carrés, centrés sur le visage. Les poses en pied avec lesquelles j'avais commencé perdaient leur tête dans un recadrage circulaire de 20px, il a donc fallu les choisir et les recadrer une par une ; un portrait centré survit à un simple dessin « cover » sans réglage image par image.</p>
<p>Deux règles empêchent que ça tourne à la bouillie :</p>
<ul>
<li class=""><strong>un sticker a besoin de place.</strong> En dessous de 12px de diamètre, l'anneau mange un tiers de l'image et un point coloré plat en dit davantage — c'est donc ce que gardent ces nœuds.</li>
<li class=""><strong>un orphelin est dessiné plus gros.</strong> Un nœud avec <strong>zéro connexion visible</strong> se retrouve au rayon minimal, c'est-à-dire juste sous ce seuil. Il obtient un plancher de 13px à la place, pour que l'unique article vers lequel rien ne pointe soit celui que vous ne pouvez pas rater.</li>
</ul>
<p>L'anneau autour de chaque bulle conserve la couleur d'accent de l'article, donc rien du signal série/tag n'est perdu au profit de l'illustration.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="conclusion">Conclusion<a href="#conclusion" class="hash-link" aria-label="Lien direct vers Conclusion" title="Lien direct vers Conclusion" translate="no"></a></h2>
<p>La carte a fait exactement ce que j'espérais : cinq minutes après son premier rendu, j'avais la liste des billets vers lesquels rien ne pointait, et je voyais qu'une série avait dérivé loin de tout le reste. C'est un outil de maintenance déguisé en jolie image.</p>
<p>La partie qui mérite d'être reprise, pourtant, ce n'est pas le graphe — c'est l'endroit où le travail se fait. Chaque exigence dure de cette page (pas de JavaScript, pas de mouvement, indexable, charge utile légère) s'est dissoute à l'instant où la simulation est passée du navigateur à <code>loadContent()</code>. Les plugins Docusaurus tournent dans Node, avec tout le corpus sous la main et une étape de build que personne n'attend ; ça laisse beaucoup de marge pour calculer les choses correctement, une fois, plutôt que de demander au portable de chaque visiteur de recommencer.</p>
<p>La prochaine fois que vous êtes sur le point d'attraper une bibliothèque côté client, vérifiez si la réponse qu'elle calcule n'aurait pas pu être une constante dans votre sortie de build.</p></div><hr><div class="seriesBlogPost_bjUd"><details class="details_rTsq"><summary class="seriesIntro_HIK1 mk-eyebrow">Cet article fait partie de la série <a href="/fr/series/creating-docusaurus-components/">Créer des composants Docusaurus</a> :</summary><div class="content_OqVG"><ul><li><a href="/fr/blog/docusaurus-override-img/">Modifier la façon dont Docusaurus génère les balises img</a></li><li><a href="/fr/blog/docusaurus-lazy-loading/">Surcharger la génération des balises img avec Docusaurus</a></li><li><a href="/fr/blog/docusaurus-series/">Organisez votre contenu Docusaurus avec un composant Series personnalisé</a></li><li><a href="/fr/blog/docusaurus-go-top/">Ajouter un bouton « retour en haut » dans les articles Docusaurus</a></li><li><a href="/fr/blog/docusaurus-plugin-replace/">Créer un plugin de recherche/remplacement pour Docusaurus</a></li><li><a href="/fr/blog/docusaurus-snippets/">Un composant pour afficher des snippets de code dans un blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-old-notice/">Un composant Docusaurus qui prévient le lecteur que l'article a plus d'un an</a></li><li><a href="/fr/blog/docusaurus-changelog/">Afficher le changelog de votre article</a></li><li><a href="/fr/blog/docusaurus-ascii-art/">Injecter de l'ASCII art dans toutes les pages HTML générées par Docusaurus</a></li><li><a href="/fr/blog/blog-post-feed/">Best Practice - Personnaliser le flux RSS de Docusaurus pour un contenu complet et des images</a></li><li><a href="/fr/blog/docusaurus-check-images/">Quelques vérifications sur les images de votre site Docusaurus</a></li><li><a href="/fr/blog/docusaurus-tags/">Gestion des tags dans Docusaurus</a></li><li><a href="/fr/blog/gemini-tldr/">Automatiser les résumés TL;DR avec Gemini AI</a></li><li><a href="/fr/blog/docusaurus-project-setup/">Le composant ProjectSetup - Une façon standardisée de partager des structures de projet</a></li><li><a href="/fr/blog/docusaurus-ai-gemini/">Comment signaler un contenu assisté par IA dans un blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-terminal-typewriter/">Donnez vie à vos tutoriels CLI avec un terminal machine à écrire</a></li><li><a href="/fr/blog/docusaurus-easter-eggs/">Espiègleries de suricate : semer des easter eggs partout sur mon blog</a></li><li><a href="/fr/blog/docusaurus-eli5-snippet-tooltips/">Tooltips de code assistés par l'IA dans Docusaurus — Explique-moi comme à un enfant de cinq ans</a></li><li><a href="/fr/blog/docusaurus-reactions/">Ajouter des réactions de lecteurs à votre blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-llms-txt/">Votre blog est illisible pour une IA. Voici la solution.</a></li><li><a href="/fr/blog/docusaurus-shake-easter-egg/">Secouez votre téléphone, surprenez le suricate</a></li><li><a href="/fr/blog/docusaurus-copy-as-markdown/">Ajouter un bouton « Copy as Markdown » à votre blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-ask-my-blog/">Ask My Blog : transformer 259 articles en 2 160 questions cherchables</a></li><li><a href="/fr/blog/docusaurus-ask-my-blog-bubble/">La troisième porte : une bulle de chat flottante, et la fonctionnalité que personne ne trouvait</a></li><li><a href="/fr/blog/docusaurus-blog-map/">Dessiner une carte de mon propre blog</a></li><li><a href="/fr/blog/docusaurus-typo-report-component/">Laissez vos lecteurs signaler les problèmes — un widget de feedback multi-types pour Docusaurus</a></li></ul></div></details></div></div><div class="relatedPosts_zJiB"><h3>Articles liés</h3></div><div class="container_mNR1"><span class="question_WdqX">Cet article vous a-t-il été utile ?</span><div class="buttons_vgpi"><button class="btn_svQp" aria-label="Oui, cet article m'a été utile"><img src="/fr/img/reaction-mascot-yes.webp" alt="" class="mascot_Fait">Utile</button><button class="btn_svQp btnNeutral_Iqek" aria-label="Non, cet article ne m'a pas été utile"><img src="/fr/img/reaction-mascot-no.webp" alt="" class="mascot_Fait">Pas vraiment</button></div></div><div class="container_IJwe"><span class="question_nb7b">Avez-vous suivi les étapes de cet article ?</span><div class="buttons_Xkzw"><button class="btn_RMiq" aria-label="Oui, j'ai essayé et ça a marché"><img src="/fr/img/like.webp" alt="" class="mascot_wB1d">Ça a marché !</button><button class="btn_RMiq btnNeutral_z71B" aria-label="Non, ça n'a pas marché pour moi"><img src="/fr/img/dislike.webp" alt="" class="mascot_wB1d">Ça n'a pas marché</button></div></div>]]></content:encoded>
            <author>Christophe</author>
            <author>Claude Code</author>
            <category>docusaurus</category>
            <category>react</category>
            <category>component</category>
            <category>nodejs</category>
            <category>doc-as-code</category>
            <enclosure length="0" type="image/webp" url="https://www.avonture.be/fr/img/v2/post_mindmaps.webp"/>
        <dc:creator><![CDATA[Christophe]]></dc:creator><dc:creator><![CDATA[Claude Code]]></dc:creator></item>
        <item>
            <title><![CDATA[The Third Door: A Floating Chat Bubble, and the Feature Nobody Could Find]]></title>
            <link>https://www.avonture.be/fr/fr/blog/docusaurus-ask-my-blog-bubble</link>
            <guid isPermaLink="false">/fr/blog/docusaurus-ask-my-blog-bubble</guid>
            <pubDate>Mon, 05 Oct 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[<img src="https://www.avonture.be/fr/img/v2/ask-my-blog-chatbox.webp" alt="The Third Door: A Floating Chat Bubble, and the Feature Nobody Could Find" style="display:block;max-width:100%;height:auto;">A feature your visitors cannot find does not exist. This article adds a floating "Ask my blog" bubble to a Docusaurus site — a third entry point into the same question index, and the first one that needs no prior knowledge. Includes the overlay mutual-exclusion trick that keeps two independent floating dialogs from ever opening at once.]]></description>
            <content:encoded><![CDATA[<div class="offer_jqr1"><span class="flag_Hnep" aria-hidden="true">🇬🇧</span><span>Cet article existe aussi en anglais —<!-- --> <a href="/blog/docusaurus-ask-my-blog-bubble/" hreflang="en">lire la version originale</a>.</span></div><div class="alertBox_R1Ot mk-callout caution_LuPi mk-callout--warn" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Traduction automatique</p><div class="content_gFLB">Cette traduction a été produite par une IA. Elle peut contenir des contresens ou des maladresses, en particulier sur les termes techniques. En cas de doute,<!-- --> <a href="/blog/docusaurus-ask-my-blog-bubble/">consultez l'article de référence en anglais</a> <!-- -->— c'est lui qui fait foi.</div></div></div><div><div class="seriesBlogPost_bjUd"><details class="details_rTsq"><summary class="seriesIntro_HIK1 mk-eyebrow">Cet article fait partie de la série <a href="/fr/series/creating-docusaurus-components/">Créer des composants Docusaurus</a> :</summary><div class="content_OqVG"><ul><li><a href="/fr/blog/docusaurus-override-img/">Modifier la façon dont Docusaurus génère les balises img</a></li><li><a href="/fr/blog/docusaurus-lazy-loading/">Surcharger la génération des balises img avec Docusaurus</a></li><li><a href="/fr/blog/docusaurus-series/">Organisez votre contenu Docusaurus avec un composant Series personnalisé</a></li><li><a href="/fr/blog/docusaurus-go-top/">Ajouter un bouton « retour en haut » dans les articles Docusaurus</a></li><li><a href="/fr/blog/docusaurus-plugin-replace/">Créer un plugin de recherche/remplacement pour Docusaurus</a></li><li><a href="/fr/blog/docusaurus-snippets/">Un composant pour afficher des snippets de code dans un blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-old-notice/">Un composant Docusaurus qui prévient le lecteur que l'article a plus d'un an</a></li><li><a href="/fr/blog/docusaurus-changelog/">Afficher le changelog de votre article</a></li><li><a href="/fr/blog/docusaurus-ascii-art/">Injecter de l'ASCII art dans toutes les pages HTML générées par Docusaurus</a></li><li><a href="/fr/blog/blog-post-feed/">Best Practice - Personnaliser le flux RSS de Docusaurus pour un contenu complet et des images</a></li><li><a href="/fr/blog/docusaurus-check-images/">Quelques vérifications sur les images de votre site Docusaurus</a></li><li><a href="/fr/blog/docusaurus-tags/">Gestion des tags dans Docusaurus</a></li><li><a href="/fr/blog/gemini-tldr/">Automatiser les résumés TL;DR avec Gemini AI</a></li><li><a href="/fr/blog/docusaurus-project-setup/">Le composant ProjectSetup - Une façon standardisée de partager des structures de projet</a></li><li><a href="/fr/blog/docusaurus-ai-gemini/">Comment signaler un contenu assisté par IA dans un blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-terminal-typewriter/">Donnez vie à vos tutoriels CLI avec un terminal machine à écrire</a></li><li><a href="/fr/blog/docusaurus-easter-eggs/">Espiègleries de suricate : semer des easter eggs partout sur mon blog</a></li><li><a href="/fr/blog/docusaurus-eli5-snippet-tooltips/">Tooltips de code assistés par l'IA dans Docusaurus — Explique-moi comme à un enfant de cinq ans</a></li><li><a href="/fr/blog/docusaurus-reactions/">Ajouter des réactions de lecteurs à votre blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-llms-txt/">Votre blog est illisible pour une IA. Voici la solution.</a></li><li><a href="/fr/blog/docusaurus-shake-easter-egg/">Secouez votre téléphone, surprenez le suricate</a></li><li><a href="/fr/blog/docusaurus-copy-as-markdown/">Ajouter un bouton « Copy as Markdown » à votre blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-ask-my-blog/">Ask My Blog : transformer 259 articles en 2 160 questions cherchables</a></li><li><a href="/fr/blog/docusaurus-ask-my-blog-bubble/">La troisième porte : une bulle de chat flottante, et la fonctionnalité que personne ne trouvait</a></li><li><a href="/fr/blog/docusaurus-blog-map/">Dessiner une carte de mon propre blog</a></li><li><a href="/fr/blog/docusaurus-typo-report-component/">Laissez vos lecteurs signaler les problèmes — un widget de feedback multi-types pour Docusaurus</a></li></ul></div></details></div><div><p><img alt="La troisième porte : une bulle de chat flottante, et la fonctionnalité que personne ne trouvait" src="/fr/assets/images/ask-my-blog-chatbox-70db45dd050b96e122664850de9a4834.webp" height="auto" width="" loading="lazy" decoding="async" class=""></p>
<div class="tldrContainer_qQW_ mk-callout mk-callout--key" role="region" aria-label="Résumé de l'article"><span role="img" aria-label="éclair" class="tldrIcon_SLBz mk-dot">⚡</span><div class="tldrBody_lGty"><strong class="tldrTitle_Aha8 mk-eyebrow">TL;DR</strong><div class="tldrContent_m27u"><p>J'avais construit une carte du corpus, un index de 2 050 questions et une palette de commandes à six modes — puis j'ai remarqué que chaque accès à l'index de questions supposait de savoir qu'il existait. Cet article est la moitié bon marché de cette histoire : une bulle de chat flottante qui ouvre la recherche de questions depuis n'importe quelle page, et un indice de première visite qui mentionne le raccourci clavier une seule fois. Plus la petite astuce pub-sub qui garantit que deux dialogues flottants indépendants ne sont jamais ouverts en même temps.</p></div></div></div>
<p>Il existe un embarras particulier, réservé aux gens qui construisent seuls. J'avais passé des semaines sur des fonctionnalités dont j'étais sincèrement fier : une <a href="/fr/blog/docusaurus-blog-map/">carte interactive de tout le blog</a>, un <a href="/fr/blog/docusaurus-ask-my-blog/">index de questions</a> généré par un modèle local, une <a href="/fr/blog/?q=docker">palette de commandes</a> à six modes.</p>
<p>Puis je me suis posé une question plus simple que d'habitude : comment un lecteur qui n'est jamais venu ici trouverait-il l'index de questions ? Il y avait exactement deux portes d'entrée. Appuyer sur <kbd>Ctrl</kbd>+<kbd>K</kbd>, puis <code>?</code> pour basculer la palette en mode question — ce qui suppose de connaître à la fois le raccourci et l'existence du mode. Ou aller sur <code>/faq</code>, ce qui suppose de savoir que la page existe.</p>
<p>Les deux portes étaient ouvertes. Les deux ne fonctionnaient que pour quelqu'un qui avait déjà lu l'article les annonçant, c'est-à-dire : moi.</p>
<nav class="quickJump_q5pQ" aria-label="Quick Jump"><span class="title_Njuu mk-eyebrow">Quick Jump</span><ul class="list_A_9v"><li class="item_gTjz"><a href="#what-changed">Ce qui a changé</a></li><li class="item_gTjz"><a href="#building-the-bubble">Construire la bulle</a></li></ul></nav>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="what-changed">Ce qui a changé<a href="#what-changed" class="hash-link" aria-label="Lien direct vers Ce qui a changé" title="Lien direct vers Ce qui a changé" translate="no"></a></h2>
<p>Deux choses, dont aucune n'est une nouvelle fonctionnalité :</p>
<div class="language-plaintext codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-plaintext codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">1. A floating bubble              2. A first-visit pill</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">   ┌────────────────┐               ┌─────────────────┐</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">   │ 🦫 Ask my blog │               │ Press ⌘K to     │</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">   ├────────────────┤               │ search      ✕   │</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">   │ [Ask a questi…]│               └─────────────────┘</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">   │  › How do I …  │                 once per browser,</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">   │  › What is …   │                 then never again</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">   └────────────────┘</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">     bottom-right, every page</span><br></div></code></pre></div></div>
<p>La bulle est la plus intéressante, parce qu'elle est la <strong>troisième</strong> voie vers exactement le même index de questions : <code>Ctrl+K</code> puis <code>?</code>, la page autonome <code>/faq</code>, et maintenant un bouton en forme de chat qui suit le lecteur partout. Même corpus, même classement, mêmes résultats — trois portes.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="why-three-doors-is-not-redundancy">Pourquoi trois portes, ce n'est pas de la redondance<a href="#why-three-doors-is-not-redundancy" class="hash-link" aria-label="Lien direct vers Pourquoi trois portes, ce n'est pas de la redondance" title="Lien direct vers Pourquoi trois portes, ce n'est pas de la redondance" translate="no"></a></h2>
<p>J'ai résisté à la bulle un moment, au motif que la palette faisait déjà le travail. C'était une erreur, et voici le raisonnement qui m'a fait changer d'avis :</p>
<ul>
<li class=""><strong>Chaque porte sert un lecteur différent.</strong> La palette sert ceux qui savent déjà qu'elle existe. <code>/faq</code> sert les moteurs de recherche et les gens qui veulent parcourir un sujet. La bulle sert le lecteur bloqué au milieu d'un article, qui n'aurait jamais l'idée d'appuyer sur un raccourci clavier.</li>
<li class=""><strong>Un bouton flottant est la seule affordance que tout le monde comprend déjà.</strong> Personne n'a besoin qu'on lui explique ce que fait une bulle de chat dans un coin. C'est précisément pour ça qu'elle mérite un coin — et pourquoi l'en-tête du panneau arbore la même <a href="/fr/blog/docusaurus-easter-eggs/">mascotte suricate</a> qu'on retrouve partout ailleurs sur ce site.</li>
<li class=""><strong>Ça ne coûte presque rien à ajouter.</strong> La bulle ne réimplémente pas la recherche — elle monte le composant de recherche existant dans un panneau. Environ 150 lignes, dont l'essentiel est de la gestion du focus.</li>
<li class=""><strong>La partie chère était déjà payée.</strong> Le corpus de questions, le classement, le chargement paresseux : tout existait. Ajouter un point d'entrée à une fonctionnalité déjà construite est le travail au meilleur rapport effort/résultat, et c'est celui qu'on saute.</li>
</ul>
<p>Ce raisonnement a une limite, et il vaut mieux l'énoncer avant d'aller ajouter une quatrième porte. Le test n'est pas « combien de points d'entrée puis-je caser ? » mais « celui-ci atteint-il un lecteur que les autres ratent ? ». J'avais aussi prévu une tuile en page d'accueil pointant vers la palette, et je l'ai abandonnée : la barre de navigation affiche déjà un champ de recherche visible en permanence, avec le raccourci imprimé dedans, sur chaque page. Trois portes pour trois lecteurs différents, oui. Trois rappels de la même porte, non.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="building-the-bubble">Construire la bulle<a href="#building-the-bubble" class="hash-link" aria-label="Lien direct vers Construire la bulle" title="Lien direct vers Construire la bulle" translate="no"></a></h2>
<p>Le composant est volontairement mince. Quand le panneau est fermé, rien de lié à la recherche n'est monté — le corpus de questions de 468 Ko n'est donc téléchargé que par un lecteur qui l'ouvre vraiment :</p>
<div class="projectSetupContainer_LW1i mk-surface"><div class="header_Htsx mk-toolbar"><strong>📦 <!-- -->Mise en place du projet :<!-- --> <!-- -->src/components/AskMyBlogWidget</strong><div class="buttonGroup_y07f mk-toolbar__actions"><a class="button button--secondary button--sm" href="/project_setup" target="_blank">Aide</a><button class="button button--secondary button--sm">Tout déplier</button><button class="button button--outline button--primary button--sm" aria-label="Télécharger le projet en ZIP">ZIP</button><button class="button button--secondary button--sm">Générer le script d'installation</button></div></div><div class="content_bo8S"><div><p class="tip_qyDH"><small><span aria-hidden="true">💡</span> <!-- -->Besoin d'aide ? Cliquez sur le bouton « Aide » ci-dessus pour les instructions de mise en place.</small></p></div></div></div>
<p>Il est monté une fois par page, dans le même swizzle <code>Layout</code> qui monte la palette :</p>
<div class="language-javascript codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockTitle_OeMC">src/theme/Layout/index.js</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-javascript codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token keyword" style="color:rgb(86, 156, 214)">export</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">default</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">function</span><span class="token plain"> </span><span class="token function" style="color:rgb(220, 220, 170)">LayoutWrapper</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token parameter">props</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token keyword" style="color:rgb(86, 156, 214)">return</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token operator" style="color:rgb(212, 212, 212)">&lt;</span><span class="token plain">Layout </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token operator" style="color:rgb(212, 212, 212)">...</span><span class="token plain">props</span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token operator" style="color:rgb(212, 212, 212)">&gt;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">      </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain">props</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token plain">children</span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">      </span><span class="token operator" style="color:rgb(212, 212, 212)">&lt;</span><span class="token plain">CommandPalette </span><span class="token operator" style="color:rgb(212, 212, 212)">/</span><span class="token operator" style="color:rgb(212, 212, 212)">&gt;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">      </span><span class="token operator" style="color:rgb(212, 212, 212)">&lt;</span><span class="token plain">CommandPaletteHint </span><span class="token operator" style="color:rgb(212, 212, 212)">/</span><span class="token operator" style="color:rgb(212, 212, 212)">&gt;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">      </span><span class="token operator" style="color:rgb(212, 212, 212)">&lt;</span><span class="token plain">AskMyBlogWidget </span><span class="token operator" style="color:rgb(212, 212, 212)">/</span><span class="token operator" style="color:rgb(212, 212, 212)">&gt;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token operator" style="color:rgb(212, 212, 212)">&lt;</span><span class="token operator" style="color:rgb(212, 212, 212)">/</span><span class="token plain">Layout</span><span class="token operator" style="color:rgb(212, 212, 212)">&gt;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><br></div></code></pre></div></div>
<p>Une décision de mise en page à copier : la bulle réserve un <strong>emplacement fixe empilé au-dessus</strong> du coin occupé par le <a href="/fr/blog/docusaurus-go-top/">bouton de retour en haut</a>, plutôt que de détecter la présence de ce bouton à l'exécution. Le retour en haut n'est pas monté sur toutes les pages, et un peu d'espace vide au-dessus quand il est absent est totalement inoffensif — bien plus inoffensif que deux boutons flottants se disputant les mêmes 60 pixels.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="the-two-overlays-problem">Le problème des deux overlays<a href="#the-two-overlays-problem" class="hash-link" aria-label="Lien direct vers Le problème des deux overlays" title="Lien direct vers Le problème des deux overlays" translate="no"></a></h2>
<p>La palette et le panneau de la bulle sont des dialogues flottants indépendants, chacun ouvrable depuis plusieurs endroits, et aucun ne sait que l'autre existe. Ouvrez l'un pendant que l'autre est ouvert et vous obtenez deux modales empilées, deux pièges de focus qui se battent, et deux gestionnaires <kbd>Esc</kbd>.</p>
<p>La correction tient en huit lignes, et elle vit dans le module de bus de la palette — comme ça, aucun composant n'a besoin d'importer l'autre :</p>
<div class="language-typescript codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockTitle_OeMC">src/components/CommandPalette/paletteBus.ts</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-typescript codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token keyword" style="color:rgb(86, 156, 214)">let</span><span class="token plain"> activeCloser </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">null</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token keyword" style="color:rgb(86, 156, 214)">export</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">function</span><span class="token plain"> </span><span class="token function" style="color:rgb(220, 220, 170)">setActiveOverlay</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">closeFn</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token keyword" style="color:rgb(86, 156, 214)">if</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">activeCloser </span><span class="token operator" style="color:rgb(212, 212, 212)">&amp;&amp;</span><span class="token plain"> activeCloser </span><span class="token operator" style="color:rgb(212, 212, 212)">!==</span><span class="token plain"> closeFn</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token function" style="color:rgb(220, 220, 170)">activeCloser</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  activeCloser </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> closeFn</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token keyword" style="color:rgb(86, 156, 214)">return</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">=&gt;</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token keyword" style="color:rgb(86, 156, 214)">if</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">activeCloser </span><span class="token operator" style="color:rgb(212, 212, 212)">===</span><span class="token plain"> closeFn</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> activeCloser </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">null</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><br></div></code></pre></div></div>
<p>Chaque dialogue appelle <code>setActiveOverlay(close)</code> à son ouverture, et appelle la fonction de nettoyage qu'il récupère à sa fermeture. Celui qui s'ouvre en second ferme celui qui était ouvert en premier. Ajouter un troisième overlay plus tard ne demande de changer absolument rien.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="a-hint-that-is-allowed-to-appear-exactly-once-skip-this-if-you-just-want-the-bubble">Un indice autorisé à apparaître une seule fois (passez si vous ne voulez que la bulle)<a href="#a-hint-that-is-allowed-to-appear-exactly-once-skip-this-if-you-just-want-the-bubble" class="hash-link" aria-label="Lien direct vers Un indice autorisé à apparaître une seule fois (passez si vous ne voulez que la bulle)" title="Lien direct vers Un indice autorisé à apparaître une seule fois (passez si vous ne voulez que la bulle)" translate="no"></a></h2>
<p>La pastille de la palette et l'anneau pulsant de la bulle sont des indices de première visite, et tous deux suivent les trois mêmes règles :</p>
<ul>
<li class="">Le flag <code>localStorage</code> est écrit <strong>quand l'indice est affiché</strong>, pas quand il est fermé. Un visiteur qui l'ignore ne le revoit pas à la page suivante.</li>
<li class="">Les délais sont décalés — la pastille de la palette à 4 ou 10 secondes, le pulse de la bulle à 15 — pour que les deux ne s'animent jamais ensemble et ne passent pas pour un pop-up.</li>
<li class="">L'anneau pulsant est entièrement supprimé sous <code>prefers-reduced-motion</code>, pas seulement ralenti.</li>
</ul>
<p>Le délai de la palette est volontairement différent selon le type de page, et la raison mérite d'être dite : 10 secondes sur un article, pour que la pastille n'interrompe jamais quelqu'un qui vient de commencer à lire. La page d'accueil n'a aucune lecture à interrompre et c'est là que les visiteurs rebondissent le plus vite, donc elle a 4 secondes — assez long pour ne pas ressembler à un pop-up au chargement.</p>
<div class="language-typescript codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockTitle_OeMC">src/components/CommandPalette/Hint.tsx</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-typescript codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token keyword" style="color:rgb(86, 156, 214)">function</span><span class="token plain"> </span><span class="token function" style="color:rgb(220, 220, 170)">delayFor</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">pathname</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token keyword" style="color:rgb(86, 156, 214)">if</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token constant" style="color:rgb(100, 102, 149)">HOME_PATH</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">test</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">pathname</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">return</span><span class="token plain"> </span><span class="token constant" style="color:rgb(100, 102, 149)">HOME_DELAY_MS</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token keyword" style="color:rgb(86, 156, 214)">if</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token constant" style="color:rgb(100, 102, 149)">ARTICLE_PATH</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">test</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">pathname</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">return</span><span class="token plain"> </span><span class="token constant" style="color:rgb(100, 102, 149)">ARTICLE_DELAY_MS</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token keyword" style="color:rgb(86, 156, 214)">return</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">null</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><br></div></code></pre></div></div>
<p>Renvoyer <code>null</code> — plutôt qu'un grand nombre — fait de « ce chemin n'affiche rien » un état distinct et lisible, au lieu d'un délai qu'il faut déchiffrer en plissant les yeux.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="conclusion">Conclusion<a href="#conclusion" class="hash-link" aria-label="Lien direct vers Conclusion" title="Lien direct vers Conclusion" translate="no"></a></h2>
<p>Les fonctionnalités de cette série ont pris des semaines. Les rendre trouvables a pris un après-midi, et il est tout à fait possible que cet après-midi ait compté davantage.</p>
<p>Il y a un mode d'échec propre à la construction de ses propres outils : vous êtes le seul utilisateur incapable d'évaluer la découvrabilité, parce que vous ne pouvez pas désapprendre où se trouvent les choses. De l'intérieur, chaque porte paraît évidente. Le seul remède que j'ai trouvé est mécanique : listez les points d'entrée d'une fonctionnalité, et pour chacun, notez ce que le lecteur doit déjà savoir pour l'utiliser. Tout ce dont la réponse est « que cette fonctionnalité existe » n'est pas un point d'entrée du tout.</p>
<p>Et en cas de doute, mettez un bouton dans le coin. Tout le monde sait ce qu'il fait.</p></div><hr><div class="seriesBlogPost_bjUd"><details class="details_rTsq"><summary class="seriesIntro_HIK1 mk-eyebrow">Cet article fait partie de la série <a href="/fr/series/creating-docusaurus-components/">Créer des composants Docusaurus</a> :</summary><div class="content_OqVG"><ul><li><a href="/fr/blog/docusaurus-override-img/">Modifier la façon dont Docusaurus génère les balises img</a></li><li><a href="/fr/blog/docusaurus-lazy-loading/">Surcharger la génération des balises img avec Docusaurus</a></li><li><a href="/fr/blog/docusaurus-series/">Organisez votre contenu Docusaurus avec un composant Series personnalisé</a></li><li><a href="/fr/blog/docusaurus-go-top/">Ajouter un bouton « retour en haut » dans les articles Docusaurus</a></li><li><a href="/fr/blog/docusaurus-plugin-replace/">Créer un plugin de recherche/remplacement pour Docusaurus</a></li><li><a href="/fr/blog/docusaurus-snippets/">Un composant pour afficher des snippets de code dans un blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-old-notice/">Un composant Docusaurus qui prévient le lecteur que l'article a plus d'un an</a></li><li><a href="/fr/blog/docusaurus-changelog/">Afficher le changelog de votre article</a></li><li><a href="/fr/blog/docusaurus-ascii-art/">Injecter de l'ASCII art dans toutes les pages HTML générées par Docusaurus</a></li><li><a href="/fr/blog/blog-post-feed/">Best Practice - Personnaliser le flux RSS de Docusaurus pour un contenu complet et des images</a></li><li><a href="/fr/blog/docusaurus-check-images/">Quelques vérifications sur les images de votre site Docusaurus</a></li><li><a href="/fr/blog/docusaurus-tags/">Gestion des tags dans Docusaurus</a></li><li><a href="/fr/blog/gemini-tldr/">Automatiser les résumés TL;DR avec Gemini AI</a></li><li><a href="/fr/blog/docusaurus-project-setup/">Le composant ProjectSetup - Une façon standardisée de partager des structures de projet</a></li><li><a href="/fr/blog/docusaurus-ai-gemini/">Comment signaler un contenu assisté par IA dans un blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-terminal-typewriter/">Donnez vie à vos tutoriels CLI avec un terminal machine à écrire</a></li><li><a href="/fr/blog/docusaurus-easter-eggs/">Espiègleries de suricate : semer des easter eggs partout sur mon blog</a></li><li><a href="/fr/blog/docusaurus-eli5-snippet-tooltips/">Tooltips de code assistés par l'IA dans Docusaurus — Explique-moi comme à un enfant de cinq ans</a></li><li><a href="/fr/blog/docusaurus-reactions/">Ajouter des réactions de lecteurs à votre blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-llms-txt/">Votre blog est illisible pour une IA. Voici la solution.</a></li><li><a href="/fr/blog/docusaurus-shake-easter-egg/">Secouez votre téléphone, surprenez le suricate</a></li><li><a href="/fr/blog/docusaurus-copy-as-markdown/">Ajouter un bouton « Copy as Markdown » à votre blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-ask-my-blog/">Ask My Blog : transformer 259 articles en 2 160 questions cherchables</a></li><li><a href="/fr/blog/docusaurus-ask-my-blog-bubble/">La troisième porte : une bulle de chat flottante, et la fonctionnalité que personne ne trouvait</a></li><li><a href="/fr/blog/docusaurus-blog-map/">Dessiner une carte de mon propre blog</a></li><li><a href="/fr/blog/docusaurus-typo-report-component/">Laissez vos lecteurs signaler les problèmes — un widget de feedback multi-types pour Docusaurus</a></li></ul></div></details></div></div><div class="relatedPosts_zJiB"><h3>Articles liés</h3></div><div class="container_mNR1"><span class="question_WdqX">Cet article vous a-t-il été utile ?</span><div class="buttons_vgpi"><button class="btn_svQp" aria-label="Oui, cet article m'a été utile"><img src="/fr/img/reaction-mascot-yes.webp" alt="" class="mascot_Fait">Utile</button><button class="btn_svQp btnNeutral_Iqek" aria-label="Non, cet article ne m'a pas été utile"><img src="/fr/img/reaction-mascot-no.webp" alt="" class="mascot_Fait">Pas vraiment</button></div></div><div class="container_IJwe"><span class="question_nb7b">Avez-vous suivi les étapes de cet article ?</span><div class="buttons_Xkzw"><button class="btn_RMiq" aria-label="Oui, j'ai essayé et ça a marché"><img src="/fr/img/like.webp" alt="" class="mascot_wB1d">Ça a marché !</button><button class="btn_RMiq btnNeutral_z71B" aria-label="Non, ça n'a pas marché pour moi"><img src="/fr/img/dislike.webp" alt="" class="mascot_wB1d">Ça n'a pas marché</button></div></div>]]></content:encoded>
            <author>Christophe</author>
            <author>Claude Code</author>
            <category>docusaurus</category>
            <category>react</category>
            <category>component</category>
            <enclosure length="0" type="image/webp" url="https://www.avonture.be/fr/img/v2/ask-my-blog-chatbox.webp"/>
        <dc:creator><![CDATA[Christophe]]></dc:creator><dc:creator><![CDATA[Claude Code]]></dc:creator></item>
        <item>
            <title><![CDATA[SSH Config Generator: Wildcard Stanzas, Aliases, and Automated Logins]]></title>
            <link>https://www.avonture.be/fr/fr/blog/ssh-config-tips</link>
            <guid isPermaLink="false">/fr/blog/ssh-config-tips</guid>
            <pubDate>Thu, 01 Oct 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[<img src="https://www.avonture.be/fr/img/v2/ssh.webp" alt="SSH Config Generator: Wildcard Stanzas, Aliases, and Automated Logins" style="display:block;max-width:100%;height:auto;">Generate your SSH config in seconds — then understand how it works: one wildcard eliminates every repeated ProxyJump and IdentityFile, domain stanzas set usernames for FQDNs, short aliases need their own User line, and RemoteCommand lands you directly as the application user.]]></description>
            <content:encoded><![CDATA[<div class="offer_jqr1"><span class="flag_Hnep" aria-hidden="true">🇬🇧</span><span>Cet article existe aussi en anglais —<!-- --> <a href="/blog/ssh-config-tips/" hreflang="en">lire la version originale</a>.</span></div><div class="alertBox_R1Ot mk-callout caution_LuPi mk-callout--warn" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Traduction automatique</p><div class="content_gFLB">Cette traduction a été produite par une IA. Elle peut contenir des contresens ou des maladresses, en particulier sur les termes techniques. En cas de doute,<!-- --> <a href="/blog/ssh-config-tips/">consultez l'article de référence en anglais</a> <!-- -->— c'est lui qui fait foi.</div></div></div><div><div class="seriesBlogPost_bjUd"><details class="details_rTsq"><summary class="seriesIntro_HIK1 mk-eyebrow">Cet article fait partie de la série <a href="/fr/series/ssh-from-your-first-key-to-remote-development/">SSH — de votre première clé au développement distant</a> :</summary><div class="content_OqVG"><ul><li><a href="/fr/blog/github-connect-using-ssh/">GitHub - Connectez votre compte en SSH et travaillez avec le protocole git@</a></li><li><a href="/fr/blog/docker-use-ssh-during-build/">Docker secrets : utiliser votre clé SSH pendant le build</a></li><li><a href="/fr/blog/linux-sftp-cli/">Utiliser sftp en ligne de commande, avec ou sans proxy</a></li><li><a href="/fr/blog/zsh-plugin-ssh-config-suggestions/">SSH - Autosuggestions avec ZSH</a></li><li><a href="/fr/blog/linux-ssh-scp/">SSH - Lancer un terminal sur votre session sans avoir à vous authentifier</a></li><li><a href="/fr/blog/connect-using-ssh-to-your-hosting-server/">Comment se connecter à votre serveur d'hébergement en SSH</a></li><li><a href="/fr/blog/vscode-remote-ssh/">Développement distant en SSH avec VSCode</a></li><li><a href="/fr/blog/vscode-remote-ssh-proxyjump-devcontainer/">Ouvrir un DevContainer sur un serveur distant avec VSCode Remote SSH et ProxyJump</a></li><li><a href="/fr/blog/ssh-config-tips/">Générateur de config SSH : stanzas wildcard, alias et connexions automatisées</a></li></ul></div></details></div><div><p><img alt="Générateur de config SSH : stanzas wildcard, alias et connexions automatisées" src="/fr/assets/images/ssh-1bc1f7f20439fd568c4911eced5d6cbc.webp" height="auto" width="" loading="lazy" decoding="async" class=""></p>
<div class="tldrContainer_qQW_ mk-callout mk-callout--key" role="region" aria-label="Résumé de l'article"><span role="img" aria-label="éclair" class="tldrIcon_SLBz mk-dot">⚡</span><div class="tldrBody_lGty"><strong class="tldrTitle_Aha8 mk-eyebrow">TL;DR</strong><div class="tldrContent_m27u"><p>SSH lit chaque stanza correspondante de haut en bas, et la première valeur l'emporte pour chaque directive — c'est ce qui rend <code>Host * !bastion</code> si puissant : une seule stanza couvre tous les serveurs d'un coup, fini la répétition de <code>ProxyJump</code> et <code>IdentityFile</code>. Les stanzas de domaine ajoutent le bon <code>User</code> pour les connexions par FQDN ; les alias courts doivent le répéter explicitement, car SSH fait la correspondance sur ce que vous tapez, pas sur le hostname résolu. Trois directives — <code>ServerAliveInterval</code>, <code>ControlMaster</code>, <code>ConnectTimeout</code> — renforcent les valeurs globales sans en changer la logique. Pour une machine qui mélange SSH professionnel et personnel, <code>Include</code> sépare proprement les deux. <code>RemoteCommand</code> transforme une séquence <code>sudo su</code> + <code>cd</code> en un simple alias.</p></div></div></div>
<p>Chaque serveur de mon environnement n'est accessible qu'à travers ma propre VM Windows qui joue le rôle de bastion. L'<a href="/fr/blog/vscode-remote-ssh-proxyjump-devcontainer/">article sur ProxyJump et les DevContainers</a> montre une config avec un seul alias. Ajoutez un deuxième serveur et la config double. Ajoutez-en dix et ça devient un problème de maintenance : changez une fois le hostname du bastion et vous réécrivez dix stanzas, chacune portant les mêmes lignes <code>ProxyJump</code> et <code>IdentityFile</code>.</p>
<p>La config ci-dessous couvre n'importe quel nombre de serveurs sans aucune répétition.</p>
<div class="varsbar_xL7w mk-surface"><div class="varsbarHead_nacC"><span class="varsbarTitle_d9dG mk-eyebrow"><span class="dot_kctR"></span>Vos valeurs pour cette page</span><button class="resetLink_X21M" type="button">Revenir aux valeurs par défaut</button></div><div class="varsbarFields_ZGsv"><div class="field_MGlw"><label for="vars-_R_irihmmk5_-vmUser">Nom d'utilisateur du bastion/de la VM</label><div class="inputWrap_vL5t"><input id="vars-_R_irihmmk5_-vmUser" class="mono_VfQ7" type="text" inputmode="text" value="vm-user"><span class="badgeDefault_OkED">défaut</span></div></div><div class="field_MGlw"><label for="vars-_R_irihmmk5_-vmIp">Hostname ou IP du bastion/de la VM</label><div class="inputWrap_vL5t"><input id="vars-_R_irihmmk5_-vmIp" class="mono_VfQ7" type="text" inputmode="text" value="windows-vm-ip"><span class="badgeDefault_OkED">défaut</span></div></div><div class="field_MGlw"><label for="vars-_R_irihmmk5_-sshKey">Nom du fichier de clé privée SSH</label><div class="inputWrap_vL5t"><input id="vars-_R_irihmmk5_-sshKey" class="mono_VfQ7" type="text" inputmode="text" value="id_ed25519"><span class="badgeDefault_OkED">défaut</span></div></div><div class="field_MGlw"><label for="vars-_R_irihmmk5_-userA">Nom d'utilisateur sur les serveurs du projet A</label><div class="inputWrap_vL5t"><input id="vars-_R_irihmmk5_-userA" class="mono_VfQ7" type="text" inputmode="text" value="user-a"><span class="badgeDefault_OkED">défaut</span></div></div><div class="field_MGlw"><label for="vars-_R_irihmmk5_-userB">Nom d'utilisateur sur les serveurs d'entreprise</label><div class="inputWrap_vL5t"><input id="vars-_R_irihmmk5_-userB" class="mono_VfQ7" type="text" inputmode="text" value="user-b"><span class="badgeDefault_OkED">défaut</span></div></div></div><div class="varsbarFoot_vsF4"><span>S'applique aux commandes de cette page</span><span>Enregistré uniquement sur cet appareil</span></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="the-config">La configuration<a href="#the-config" class="hash-link" aria-label="Lien direct vers La configuration" title="Lien direct vers La configuration" translate="no"></a></h2>
<p>Les deux chemins de connexion fonctionnent immédiatement — un alias court et un FQDN complet, sans aucun flag :</p>
<div class="terminal_qOWF" style="cursor:pointer" title="Cliquez pour passer l'animation"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">laptop: ~</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><span class="terminal_ghost_aie1" aria-hidden="true"><p>$ ssh <span class="token_SjcP">project_prod</span> "whoami &amp;&amp; hostname"
<span class="token_SjcP">user-b</span>
server-prod
$ ssh server-test.cloud.project-a.internal "whoami &amp;&amp; hostname"
<span class="token_SjcP">user-a</span>
server-test</p></span><span class="terminal_overlay_fznj"><span class="cursor_o7aQ" aria-hidden="true">▋</span></span></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Pas de flag <code>ProxyJump</code>, pas de clé explicite — la config gère les deux de façon transparente. Créez <code>C:\Users\your_laptop_user\.ssh\config</code> (Windows ; WSL inclus) ou <code>~/.ssh/config</code> (Linux/macOS) avec ceci :</p>
<div class="language-txt codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-txt codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain"># 1. Bastion — no ProxyJump, no IdentityFile</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">Host windows-vm-ip</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    User vm-user</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"># 2. Domain defaults — in effect when connecting via full FQDN only</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">Host *.cloud.project-a.internal</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    User user-a</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">Host *.cloud.corp.example</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    User user-b</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">Host *.office.corp.example</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    User user-b</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"># 3. Short aliases — HostName + User</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">#    (domain defaults above do not apply to aliases — see article)</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">Host project_test</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    HostName server-test.cloud.project-a.internal</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    User user-a</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">Host project_prod</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    HostName server-prod.office.corp.example</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    User user-b</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"># 4. Explicit exceptions — bypass the bastion for specific hosts</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">#    Must come BEFORE "Host *" — SSH uses first-value-wins per directive</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">Host github.com</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    ProxyJump none</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"># 5. Global defaults — fallback for every host except the bastion</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">Host * !windows-vm-ip</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    ProxyJump vm-user@windows-vm-ip</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    IdentityFile ~/.ssh/id_ed25519</span><br></div></code></pre></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="how-ssh-reads-a-config-file">Comment SSH lit un fichier de configuration<a href="#how-ssh-reads-a-config-file" class="hash-link" aria-label="Lien direct vers Comment SSH lit un fichier de configuration" title="Lien direct vers Comment SSH lit un fichier de configuration" translate="no"></a></h2>
<p>Trois règles expliquent pourquoi les <abbr title="chaque bloc Host et ses directives dans un fichier de config SSH s'appelle une « stanza »">stanzas</abbr> se composent sans conflit :</p>
<ul>
<li class=""><strong>Plusieurs stanzas peuvent correspondre.</strong> Lancer <code>ssh project_prod</code> amène SSH à parcourir le fichier de haut en bas et à collecter les réglages de <em>chaque</em> stanza <code>Host</code> qui correspond à <code>project_prod</code>, pas seulement la première.</li>
<li class=""><strong>La première valeur l'emporte pour chaque réglage — l'inverse de la plupart des outils.</strong> En CSS, dans les fichiers <code>.env</code> ou dans la plupart des systèmes de configuration, la dernière déclaration écrase les précédentes. SSH fait l'inverse : la première valeur rencontrée est conservée, et les stanzas suivantes ne peuvent que compléter les réglages non encore définis. Mettez la stanza la plus spécifique en premier ; le fallback wildcard en dernier.</li>
<li class=""><strong>Les motifs correspondent à l'argument de la ligne de commande, pas au hostname résolu.</strong> <code>Host *.office.corp.example</code> correspond à <code>ssh server-prod.office.corp.example</code>. Il ne correspond <strong>pas</strong> à <code>ssh project_prod</code>, même si le <code>HostName</code> de <code>project_prod</code> se résout en <code>server-prod.office.corp.example</code>.</li>
</ul>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="the-five-sections">Les cinq sections<a href="#the-five-sections" class="hash-link" aria-label="Lien direct vers Les cinq sections" title="Lien direct vers Les cinq sections" translate="no"></a></h2>
<p>Le snippet de config ci-dessus est découpé en cinq sections numérotées — voici ce que fait chacune et pourquoi elle est placée dans cet ordre.</p>
<p><strong>Section 1 — Définition du bastion.</strong> Déclare le jump host avec son nom d'utilisateur. Pas de <code>ProxyJump</code> ici (il boucherait sur lui-même) et pas d'<code>IdentityFile</code> (la VM utilise l'authentification que vous avez configurée séparément pour elle).</p>
<p><strong>Section 2 — Valeurs par défaut par domaine.</strong> Elles s'appliquent quand vous tapez un FQDN complet comme <code>ssh server-prod.office.corp.example</code>. SSH récupère le <code>User</code> ici, puisqu'aucune stanza précédente ne l'a défini. Plusieurs stanzas de domaine avec le même nom d'utilisateur, c'est normal — une par suffixe de domaine. Ces stanzas sont aussi utiles pour les scripts qui construisent des hostnames par programme ou pour les appels <code>ssh-copy-id</code>.</p>
<p><strong>Section 3 — Alias courts.</strong> Chaque alias déclare <code>HostName</code> (le vrai FQDN) et <code>User</code>. Ajouter un nouvel alias de serveur, c'est exactement deux lignes.</p>
<p><strong>Section 4 — Exceptions explicites.</strong> Tout host qui ne doit pas passer par le bastion — un service d'hébergement Git, un runner CI, une API SaaS — reçoit <code>ProxyJump none</code> ici. La position est structurante : cette stanza doit venir <em>avant</em> <code>Host *</code>.</p>
<p><strong>Section 5 — Valeurs globales par défaut.</strong> Le préfixe <code>!</code> dans <code>Host * !</code><code class="tokenInline_V0MS">windows-vm-ip</code> signifie « tous les hosts <em>sauf</em> le bastion ». Placée en dernier, cette stanza fournit <code>ProxyJump</code> et <code>IdentityFile</code> comme fallbacks pour chaque stanza précédente qui ne les a pas définis — c'est-à-dire toutes. N'importe quelle stanza des sections 2, 3 ou 4 peut écraser ces valeurs simplement en déclarant la directive en premier, sans toucher à la section 5.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="hardening-section-5-optional">Renforcer la section 5 (optionnel)<a href="#hardening-section-5-optional" class="hash-link" aria-label="Lien direct vers Renforcer la section 5 (optionnel)" title="Lien direct vers Renforcer la section 5 (optionnel)" translate="no"></a></h2>
<p>Quatre directives étendent les valeurs globales sans en changer la logique. Ajoutez celles qui correspondent à votre environnement — aucune n'est requise pour que la config fonctionne.</p>
<div class="language-txt codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-txt codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain"># 4. Explicit exceptions — bypass the bastion for specific hosts</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">#    Must come BEFORE "Host *" — SSH uses first-value-wins per directive</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">Host github.com</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    ProxyJump none</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"># 5. Global defaults — fallback for every host except the bastion</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">Host * !windows-vm-ip</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    ProxyJump vm-user@windows-vm-ip</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    IdentityFile ~/.ssh/id_ed25519</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    # Optional — prevent silently-dropped idle connections</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    ServerAliveInterval 30</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    ServerAliveCountMax 3</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    # Optional — fail fast instead of waiting the default 2 minutes</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    ConnectTimeout 5</span><br></div></code></pre></div></div>
<ul>
<li class=""><strong><code>ServerAliveInterval 30</code> / <code>ServerAliveCountMax 3</code></strong> — SSH envoie un paquet keepalive toutes les 30 secondes. Après 3 paquets sans réponse (90 secondes de silence), la connexion est fermée proprement au lieu de rester bloquée sur un prompt gelé. Sans ça, un terminal inactif sur un serveur d'entreprise se fige souvent sans aucun message d'erreur.</li>
<li class=""><strong><code>ConnectTimeout 5</code></strong> — échoue après 5 secondes si le serveur est injoignable. Sur un réseau d'entreprise avec un bastion fiable, le timeout par défaut de 2 minutes n'est que du bruit.</li>
</ul>
<div class="alertBox_R1Ot mk-callout important_O5eU mk-callout--danger" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">ControlMaster ne fonctionne pas avec OpenSSH sous Windows</p><div class="content_gFLB"><p><code>ControlMaster auto</code> / <code>ControlPath</code> / <code>ControlPersist</code> activent le multiplexage SSH : la première connexion ouvre une socket de domaine Unix dans <code>~/.ssh/</code> ; les connexions suivantes réutilisent le tunnel au lieu d'en négocier un nouveau à travers le bastion. Sous Linux et macOS, ça marche bien.</p><p>Sous <strong>OpenSSH Windows avec ProxyJump</strong>, ces trois directives cassent silencieusement toutes les connexions — <code>ssh</code> se bloque ou sort avec <code>Read from remote host: Unknown error</code> — parce que l'implémentation des sockets Unix sous Windows n'est pas compatible avec le forwarding stdio basé sur des pipes de ProxyJump. Le commentaire <code># Optional</code> n'est pas une protection suffisante : les lignes sont copiées et cassent tout.</p><p>Ne les mettez pas sous Windows. Si vous vous connectez depuis Linux ou macOS, ajoutez-les :</p><div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">    ControlMaster auto</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    ControlPath ~/.ssh/cm_%C</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    ControlPersist 10m</span><br></div></code></pre></div></div><p><code>%C</code> est un hash des paramètres de connexion — pas de deux-points, pas de caractères spéciaux. N'utilisez jamais <code>%r@%h:%p</code> : le <code>:</code> est illégal dans les noms de fichiers Windows et corrompt silencieusement le path de la socket, même quand ControlMaster est par ailleurs fonctionnel.</p></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="why-each-alias-needs-its-own-user-line-skip-if-the-config-just-works-for-you">Pourquoi chaque alias a besoin de sa propre ligne <code>User</code> (à passer si la config fonctionne déjà chez vous)<a href="#why-each-alias-needs-its-own-user-line-skip-if-the-config-just-works-for-you" class="hash-link" aria-label="Lien direct vers why-each-alias-needs-its-own-user-line-skip-if-the-config-just-works-for-you" title="Lien direct vers why-each-alias-needs-its-own-user-line-skip-if-the-config-just-works-for-you" translate="no"></a></h2>
<p>Les stanzas de domaine de la section 2 ne définissent <code>User</code> que quand SSH y trouve une correspondance — et comme noté plus haut, SSH fait correspondre les motifs à l'argument de la ligne de commande, pas au hostname résolu.</p>
<p>En lançant <code>ssh project_prod</code> :</p>
<ol>
<li class="">Ne correspond <strong>pas</strong> à <code>Host *.office.corp.example</code> (section 2) — <code>project_prod</code> n'est pas un FQDN.</li>
<li class="">Correspond à <code>Host project_prod</code> (section 3) → applique <code>HostName server-prod.office.corp.example</code> et <code>User </code><code class="tokenInline_V0MS">user-b</code>.</li>
<li class="">Ne correspond <strong>pas</strong> à <code>Host github.com</code> (section 4) — <code>project_prod</code> n'est pas dans cette liste.</li>
<li class="">Correspond à <code>Host * !</code><code class="tokenInline_V0MS">windows-vm-ip</code> (section 5) → applique <code>ProxyJump</code> et <code>IdentityFile</code>.</li>
</ol>
<p>Sans la ligne <code>User</code> dans la stanza de l'alias, SSH retombe sur votre nom d'utilisateur Windows local pour cette connexion — ce qui est presque certainement faux.</p>
<p>En lançant directement <code>ssh server-prod.office.corp.example</code> :</p>
<ol>
<li class="">Correspond à <code>Host *.office.corp.example</code> (section 2) → <code>User </code><code class="tokenInline_V0MS">user-b</code>.</li>
<li class="">Ne correspond <strong>pas</strong> à <code>Host github.com</code> (section 4) — pas dans cette liste.</li>
<li class="">Correspond à <code>Host * !</code><code class="tokenInline_V0MS">windows-vm-ip</code> (section 5) → <code>ProxyJump</code>, <code>IdentityFile</code>.</li>
</ol>
<p>Les deux chemins arrivent sur le bon utilisateur. Ils ont juste besoin de stanzas différentes pour y arriver.</p>
<div class="alertBox_R1Ot mk-callout tip_shCb mk-callout--tip" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Utilisateurs ZSH : autocomplétez vos alias</p><div class="content_gFLB"><p>Le <a href="/fr/blog/zsh-plugin-ssh-config-suggestions/">plugin zsh-ssh-config-suggestions</a> lit <code>~/.ssh/config</code> et propose des complétions sur <code>ssh &lt;Tab&gt;</code>. Chaque alias de la section 3 devient une suggestion — plus votre liste d'alias est longue, plus c'est utile.</p></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="one-caveat-the-wildcard-is-truly-universal-skip-if-this-machine-is-corporate-only">Une réserve : le wildcard est vraiment universel (à passer si cette machine est purement professionnelle)<a href="#one-caveat-the-wildcard-is-truly-universal-skip-if-this-machine-is-corporate-only" class="hash-link" aria-label="Lien direct vers Une réserve : le wildcard est vraiment universel (à passer si cette machine est purement professionnelle)" title="Lien direct vers Une réserve : le wildcard est vraiment universel (à passer si cette machine est purement professionnelle)" translate="no"></a></h2>
<p><code>Host * !</code><code class="tokenInline_V0MS">windows-vm-ip</code> s'applique à <strong>chaque</strong> connexion SSH depuis cette machine — y compris <code>ssh github.com</code> ou <code>ssh localhost</code>. Les connexions vers des hosts externes tenteront un <code>ProxyJump</code> à travers la VM et échoueront si la VM n'est pas joignable.</p>
<div class="alertBox_R1Ot mk-callout warning_xBFJ mk-callout--warn" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Devcontainers : déclarez les exceptions avant Host *</p><div class="content_gFLB"><p>Quand la config SSH est bind-mountée dans un devcontainer, le hostname du bastion peut ne pas se résoudre dans le réseau du container. Toute connexion SSH depuis le container — <code>git push</code>, <code>scp</code>, <code>rsync</code> — tombe sur <code>Host *</code>, tente un <code>ProxyJump</code> à travers le bastion, n'arrive pas à le résoudre et sort avec :</p><div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">ssh: Could not resolve hostname your-bastion: Name or service not known</span><br></div></code></pre></div></div><p>Les hosts qui doivent contourner le bastion ont besoin d'une stanza <code>ProxyJump none</code> explicite <strong>avant</strong> <code>Host *</code>. SSH applique la règle « première valeur gagnante » par directive : un <code>ProxyJump none</code> placé après <code>Host *</code> est silencieusement ignoré. La section 4 de la config ci-dessus existe précisément pour ça — ajoutez-y tout host qui se connecte directement (hébergement Git, runners CI, API tierces).</p></div></div></div>
<p>Trois options si ça vous pose problème :</p>
<ul>
<li class=""><strong>Remplacez <code>Host *</code> par une liste de domaines explicite :</strong> <code>Host *.project-a.internal *.cloud.corp.example *.office.corp.example</code>. Les connexions vers tout ce qui est en dehors de cette liste ignorent complètement le bastion.</li>
<li class=""><strong>Gardez <code>Host *</code></strong> si cette machine utilise SSH exclusivement pour des serveurs internes à votre réseau d'entreprise. Sur une machine de travail dédiée, le wildcard convient très bien.</li>
<li class=""><strong>Écrasez des hosts spécifiques au-dessus de la section 4.</strong> Comme les valeurs globales sont en dernier, toute stanza placée avant elles l'emporte. Ajoutez une exemption n'importe où au-dessus de la section 4 — la position à l'intérieur des sections 1 à 3 n'a pas d'importance :</li>
</ul>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain"># Anywhere above section 4 — fits naturally in section 3 alongside the other aliases</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">Host github.com gitlab.com</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    ProxyJump none</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    IdentityFile ~/.ssh/id_ed25519_personal</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"># Section 4 — global defaults (last, unchanged)</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">Host * !windows-vm-ip</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    ProxyJump ...</span><br></div></code></pre></div></div>
<ul>
<li class=""><strong>Découpez la config avec <code>Include</code></strong> — la solution la plus propre pour une machine utilisée à la fois pour du SSH professionnel et personnel :</li>
</ul>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain"># ~/.ssh/config</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">Include ~/.ssh/config.d/work</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">Include ~/.ssh/config.d/personal</span><br></div></code></pre></div></div>
<p><code>~/.ssh/config.d/work</code> contient la config d'entreprise complète (bastion, stanzas de domaine, alias, valeurs globales en dernier). <code>~/.ssh/config.d/personal</code> contient une config propre sans routage par bastion. Les directives <code>Include</code> sont traitées de haut en bas avant tout bloc <code>Host</code> du fichier principal — OpenSSH 7.3+ requis.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="bonus-log-in-directly-as-another-user">Bonus : se connecter directement en tant qu'autre utilisateur<a href="#bonus-log-in-directly-as-another-user" class="hash-link" aria-label="Lien direct vers Bonus : se connecter directement en tant qu'autre utilisateur" title="Lien direct vers Bonus : se connecter directement en tant qu'autre utilisateur" translate="no"></a></h2>
<p>Certains serveurs exigent que vous vous connectiez avec votre propre compte puis basculiez vers un utilisateur applicatif — <code>sudo su app_user</code> suivi de <code>cd /opt/project</code>. La directive <code>RemoteCommand</code> automatise toute la séquence depuis l'alias.</p>
<p>Le générateur ci-dessous produit <strong>deux alias par serveur</strong> : un alias simple pour VSCode Remote Explorer (sans <code>RemoteCommand</code>) et une variante <code>_app</code> pour les sessions de terminal qui vous connecte directement en tant qu'utilisateur applicatif.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="generate-your-complete-config">Générez votre configuration complète<a href="#generate-your-complete-config" class="hash-link" aria-label="Lien direct vers Générez votre configuration complète" title="Lien direct vers Générez votre configuration complète" translate="no"></a></h2>
<p>Remplissez vos valeurs une fois et copiez le résultat directement dans <code>~/.ssh/config</code>. Pour un second projet, copiez-collez uniquement les alias de la section 3 et changez le nom du projet — le bastion et les valeurs globales sont déjà en place.</p>
<div class="root_VOBU mk-surface"><div class="header_qIy3"><span class="headerTitle_O0Sb mk-eyebrow">Générateur de ~/.ssh/config</span></div><div class="formColumn_Nbcc"><div class="group_wj64"><button type="button" class="groupToggle_GS7C" aria-expanded="false"><span>Common settings</span><span class="chevron_SCEz">▾</span></button></div><div class="localFields_AHl5"><div class="group_wj64"><button type="button" class="groupToggle_GS7C" aria-expanded="false"><span>Section 3 — Valeurs par défaut par domaine (optionnel)</span><span class="chevron_SCEz">▾</span></button></div><div class="group_wj64"><button type="button" class="groupToggle_GS7C" aria-expanded="true"><span>Section 4 — Serveurs</span><span class="chevron_SCEz chevronOpen_I8oO">▾</span></button><div class="listFieldInner_xRPN"><div class="listRow_Q3ck"><div class="listRowFields_gfv7"><div class="fieldRow_M6yv"><label for="cfg-environment-_R_aml62rihmmk5_" class="fieldLabel_fBvx">Environnement</label><div class="inputWrap_VSaR"><input id="cfg-environment-_R_aml62rihmmk5_" type="text" class="fieldInput_mARG mono_rSuz" title="test" value="test"></div></div><div class="fieldRow_M6yv"><label for="cfg-hostname-_R_iml62rihmmk5_" class="fieldLabel_fBvx">Hostname</label><div class="inputWrap_VSaR"><input id="cfg-hostname-_R_iml62rihmmk5_" type="text" class="fieldInput_mARG mono_rSuz" title="server-test.office.corp.example" value="server-test.office.corp.example"></div></div><div class="fieldRow_M6yv"><label for="cfg-appUser-_R_qml62rihmmk5_" class="fieldLabel_fBvx">Utilisateur applicatif</label><div class="inputWrap_VSaR"><input id="cfg-appUser-_R_qml62rihmmk5_" type="text" class="fieldInput_mARG mono_rSuz" title="app-user" value="app-user"></div></div><div class="fieldRow_M6yv"><label for="cfg-appDir-_R_12ml62rihmmk5_" class="fieldLabel_fBvx">Répertoire de l'application</label><div class="inputWrap_VSaR"><input id="cfg-appDir-_R_12ml62rihmmk5_" type="text" class="fieldInput_mARG mono_rSuz" title="/opt/folder_devcontainer" value="/opt/folder_devcontainer"></div></div></div><button type="button" class="removeRow_eivX" aria-label="Remove row 1">×</button></div><div class="listRow_Q3ck"><div class="listRowFields_gfv7"><div class="fieldRow_M6yv"><label for="cfg-environment-_R_b6l62rihmmk5_" class="fieldLabel_fBvx">Environnement</label><div class="inputWrap_VSaR"><input id="cfg-environment-_R_b6l62rihmmk5_" type="text" class="fieldInput_mARG mono_rSuz" title="prod" value="prod"></div></div><div class="fieldRow_M6yv"><label for="cfg-hostname-_R_j6l62rihmmk5_" class="fieldLabel_fBvx">Hostname</label><div class="inputWrap_VSaR"><input id="cfg-hostname-_R_j6l62rihmmk5_" type="text" class="fieldInput_mARG mono_rSuz" title="server-prod.office.corp.example" value="server-prod.office.corp.example"></div></div><div class="fieldRow_M6yv"><label for="cfg-appUser-_R_r6l62rihmmk5_" class="fieldLabel_fBvx">Utilisateur applicatif</label><div class="inputWrap_VSaR"><input id="cfg-appUser-_R_r6l62rihmmk5_" type="text" class="fieldInput_mARG mono_rSuz" title="app-user" value="app-user"></div></div><div class="fieldRow_M6yv"><label for="cfg-appDir-_R_136l62rihmmk5_" class="fieldLabel_fBvx">Répertoire de l'application</label><div class="inputWrap_VSaR"><input id="cfg-appDir-_R_136l62rihmmk5_" type="text" class="fieldInput_mARG mono_rSuz" title="/opt/project" value="/opt/project"></div></div></div><button type="button" class="removeRow_eivX" aria-label="Remove row 2">×</button></div><button type="button" class="addRow_ydjR">+ <!-- -->Add</button></div></div></div><div class="footer_HveV"><span class="footerNote_vZzz">Saved in this browser only — never sent to any server.</span><button type="button" class="clearBtn_LFCd">Clear saved values</button></div></div><div class="output_zhfj"><div class="outputHeader_vPhP"><span class="outputLabel_gKmY">Generated config</span><div class="outputActions_i4zt"><button type="button" class="actionBtn_bjNo actionBtnActive_EDYR" aria-pressed="true" title="Wrap lines">↵</button><button type="button" class="copyBtn_jRXY" aria-label="Copier le code dans le presse-papiers"><span>Copier</span></button></div></div><pre class="outputPre_JIvZ outputPreWrapped_eyOL"><code># 1. Bastion — no ProxyJump, no IdentityFile
Host windows-vm-ip
    User vm-user

# 2. Domain defaults — in effect when connecting via full FQDN only
Host *.office.corp.example
    User user-b
# 3. Short aliases
Host project_test_app
    RequestTTY yes
    RemoteCommand sudo -u app-user bash -c "cd /opt/folder_devcontainer &amp;&amp; exec bash -l"

Host project_test project_test_app
    HostName server-test.office.corp.example
    User user-b

Host project_prod_app
    RequestTTY yes
    RemoteCommand sudo -u app-user bash -c "cd /opt/project &amp;&amp; exec bash -l"

Host project_prod project_prod_app
    HostName server-prod.office.corp.example
    User user-b

# 4. Global defaults — fallback for every host except the bastion
Host * !windows-vm-ip
    ProxyJump vm-user@windows-vm-ip
    IdentityFile ~/.ssh/id_ed25519

    # Optional — prevent silently-dropped idle connections
    ServerAliveInterval 30
    ServerAliveCountMax 3</code></pre></div></div>
<div class="alertBox_R1Ot mk-callout tip_shCb mk-callout--tip" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Testez d'abord la connexion au bastion</p><div class="content_gFLB"><p>Avant de tester un alias complet, vérifiez que le bastion lui-même est joignable et accepte votre clé :</p><div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">laptop: ~</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb">$ ssh <span class="token_SjcP">vm-user</span>@<span class="token_SjcP">windows-vm-ip</span> "echo bastion-ok"
bastion-ok
</pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div><p><code>echo bastion-ok</code> est une commande non interactive — elle se termine immédiatement après l'affichage, sans laisser de session ouverte. Si la sortie apparaît, la section 1 et la section 4 sont correctement câblées. Si ça bloque ou échoue, le problème se situe entre votre machine et le bastion, indépendamment de tout alias de serveur.</p></div></div></div>
<div class="alertBox_R1Ot mk-callout important_O5eU mk-callout--danger" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">VSCode Remote Explorer ne fonctionne pas avec RemoteCommand</p><div class="content_gFLB"><p>VSCode Remote SSH injecte son propre processus serveur pendant la connexion. <code>RemoteCommand</code> remplace le shell distant avant que cette injection puisse avoir lieu — la connexion se ferme avant que VSCode ait pu prendre pied. Utilisez les alias simples <code>project_test</code> / <code>project_prod</code> pour Remote Explorer ; naviguez vers le répertoire de l'application depuis VSCode une fois connecté.</p></div></div></div>
<p>Le <code>RemoteCommand</code> de la variante <code>_app</code> enchaîne deux choses : un <code>cd</code> vers le répertoire de l'application et un <code>exec bash -l</code> pour démarrer un shell de login en tant qu'utilisateur applicatif. <code>RequestTTY yes</code> alloue un pseudo-terminal — sans ça, la session se ferme immédiatement après la commande au lieu de vous donner un shell interactif.</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">laptop: ~</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb">$ ssh <span class="token_SjcP">project</span>_test_app
<span class="token_SjcP">mass_upload</span>@<span class="token_SjcP">server-test</span>:<span class="token_SjcP">/opt/folder_devcontainer</span>$
</pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">laptop: ~</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb">$ ssh <span class="token_SjcP">project</span>_prod_app
<span class="token_SjcP">app-user</span>@<span class="token_SjcP">server-prod</span>:<span class="token_SjcP">/opt/project</span>$
</pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Les quatre alias récupèrent automatiquement le <code>ProxyJump</code> global de la section 4.</p>
<div class="alertBox_R1Ot mk-callout important_O5eU mk-callout--danger" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">sudo doit être sans mot de passe — ou utilisez su</p><div class="content_gFLB"><p><code>sudo -u </code><code class="tokenInline_V0MS">app-user</code> dans un <code>RemoteCommand</code> ne fonctionne silencieusement que si le fichier sudoers accorde <code>NOPASSWD</code> à votre compte pour cet utilisateur précis. Sinon, la connexion se bloquera en attendant un mot de passe que SSH ne peut pas fournir avant que le TTY soit complètement établi.</p><p>Si sudo sans mot de passe n'est pas configuré, utilisez plutôt <code>sudo su - </code><code class="tokenInline_V0MS">app-user</code> — avec <code>RequestTTY yes</code>, le TTY est alloué avant l'exécution de la commande, donc la demande de mot de passe s'affiche normalement :</p><div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">RemoteCommand sudo su - app-user -c "cd /opt/project &amp;&amp; exec bash -l"</span><br></div></code></pre></div></div><p>Vous verrez la demande de mot de passe sudo une fois, puis vous arriverez dans le shell.</p></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="production-environment-warning">Avertissement pour les environnements de production<a href="#production-environment-warning" class="hash-link" aria-label="Lien direct vers Avertissement pour les environnements de production" title="Lien direct vers Avertissement pour les environnements de production" translate="no"></a></h2>
<p>Une bannière <code>RemoteCommand</code> n'est visible que par celui qui a configuré cet alias SSH. Le bon endroit pour un avertissement de production, c'est le <strong>serveur lui-même</strong> — ainsi il atteint tous les utilisateurs, quel que soit leur client.</p>
<p>Déposez un script dans <code>/etc/profile.d/</code> sur le serveur. Chaque shell interactif source ce répertoire à la connexion :</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockTitle_OeMC">/etc/profile.d/env-banner.sh (on the server)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token shebang important">#!/usr/bin/env bash</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token keyword" style="color:rgb(86, 156, 214)">case</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"</span><span class="token string variable" style="color:rgb(156, 220, 254)">$(</span><span class="token string variable function" style="color:rgb(220, 220, 170)">hostname</span><span class="token string variable" style="color:rgb(156, 220, 254)"> </span><span class="token string variable parameter variable" style="color:rgb(156, 220, 254)">-s</span><span class="token string variable" style="color:rgb(156, 220, 254)">)</span><span class="token string" style="color:rgb(206, 145, 120)">"</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">in</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  *prod*</span><span class="token operator" style="color:rgb(212, 212, 212)">|</span><span class="token plain">*prd*</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token builtin class-name" style="color:rgb(78, 201, 176)">printf</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">'\033[38;5;88;48;5;224m  ⚠  Production server — be careful!  \033[0m\n\n'</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token builtin class-name" style="color:rgb(78, 201, 176)">export</span><span class="token plain"> </span><span class="token assign-left variable environment constant" style="color:rgb(100, 102, 149)">PS1</span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token string" style="color:rgb(206, 145, 120)">'\[\033[38;5;88;48;5;224m\] ⚠ PROD \[\033[0m\] \u@\h:\w\$ '</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token keyword" style="color:rgb(86, 156, 214)">esac</span><br></div></code></pre></div></div>
<p>La paire de couleurs <code>38;5;88</code> (bordeaux foncé) sur <code>48;5;224</code> (rose pâle) est clairement visible sans être agressive — un rappel, pas un panneau d'alarme.</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">laptop: ~</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb">$ ssh project_prod_app
  ⚠  Production server — be careful!

app-user@server-prod:/opt/project$
</pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Comme <code>exec bash -l</code> dans le <code>RemoteCommand</code> démarre un shell de login, les scripts de profil s'exécutent et la bannière apparaît aussi sur chaque connexion <code>_app</code>. Aucun changement nécessaire dans la config SSH.</p>
<div class="alertBox_R1Ot mk-callout tip_shCb mk-callout--tip" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Détection par hostname vs fichier explicite</p><div class="content_gFLB"><p>Faire correspondre <code>hostname -s</code> à <code>*prod*</code> est pratique mais fragile — un host nommé <code>deployments-node</code> ne correspondrait à rien. Un fichier marqueur explicite est plus fiable :</p><div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token keyword" style="color:rgb(86, 156, 214)">if</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token plain"> </span><span class="token parameter variable" style="color:rgb(156, 220, 254)">-f</span><span class="token plain"> /etc/env-type </span><span class="token punctuation" style="color:rgb(212, 212, 212)">]</span><span class="token punctuation" style="color:rgb(212, 212, 212)">]</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">&amp;&amp;</span><span class="token plain"> </span><span class="token function" style="color:rgb(220, 220, 170)">grep</span><span class="token plain"> </span><span class="token parameter variable" style="color:rgb(156, 220, 254)">-qx</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"production"</span><span class="token plain"> /etc/env-type</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">then</span><br></div></code></pre></div></div><p>Créez <code>/etc/env-type</code> avec le contenu <code>production</code> lors du provisioning d'un serveur. Le test est sans ambiguïté, quel que soit le hostname.</p></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="conclusion">Conclusion<a href="#conclusion" class="hash-link" aria-label="Lien direct vers Conclusion" title="Lien direct vers Conclusion" translate="no"></a></h2>
<p>Une seule stanza wildcard élimine la répétition de <code>ProxyJump</code> et <code>IdentityFile</code> dans chaque alias. Les stanzas de domaine ajoutent les noms d'utilisateur pour l'accès direct par FQDN. Les alias courts répètent <code>User</code> explicitement — parce que SSH fait la correspondance sur ce que vous tapez, pas sur ce que l'alias résout.</p>
<p>Ajouter un nouveau serveur, c'est deux lignes : <code>HostName</code> et <code>User</code>. Le routage est déjà géré. Quand un serveur exige un utilisateur et un répertoire spécifiques, <code>RemoteCommand</code> transforme cette séquence en un simple alias. Et quand un serveur de production a besoin d'un avertissement visuel clair pour tout le monde, <code>/etc/profile.d/</code> est le bon endroit — pas la config SSH.</p>
<p>Si vous devez <a href="/fr/blog/github-connect-using-ssh/">générer ou gérer vos clés SSH</a>, cet article couvre la génération de clés <code>ed25519</code> et l'autorisation GitHub depuis zéro — la même clé que celle utilisée dans la ligne <code>IdentityFile</code> ci-dessus.</p></div><hr><div class="seriesBlogPost_bjUd"><details class="details_rTsq"><summary class="seriesIntro_HIK1 mk-eyebrow">Cet article fait partie de la série <a href="/fr/series/ssh-from-your-first-key-to-remote-development/">SSH — de votre première clé au développement distant</a> :</summary><div class="content_OqVG"><ul><li><a href="/fr/blog/github-connect-using-ssh/">GitHub - Connectez votre compte en SSH et travaillez avec le protocole git@</a></li><li><a href="/fr/blog/docker-use-ssh-during-build/">Docker secrets : utiliser votre clé SSH pendant le build</a></li><li><a href="/fr/blog/linux-sftp-cli/">Utiliser sftp en ligne de commande, avec ou sans proxy</a></li><li><a href="/fr/blog/zsh-plugin-ssh-config-suggestions/">SSH - Autosuggestions avec ZSH</a></li><li><a href="/fr/blog/linux-ssh-scp/">SSH - Lancer un terminal sur votre session sans avoir à vous authentifier</a></li><li><a href="/fr/blog/connect-using-ssh-to-your-hosting-server/">Comment se connecter à votre serveur d'hébergement en SSH</a></li><li><a href="/fr/blog/vscode-remote-ssh/">Développement distant en SSH avec VSCode</a></li><li><a href="/fr/blog/vscode-remote-ssh-proxyjump-devcontainer/">Ouvrir un DevContainer sur un serveur distant avec VSCode Remote SSH et ProxyJump</a></li><li><a href="/fr/blog/ssh-config-tips/">Générateur de config SSH : stanzas wildcard, alias et connexions automatisées</a></li></ul></div></details></div></div><div class="relatedPosts_zJiB"><h3>Articles liés</h3></div><div class="container_mNR1"><span class="question_WdqX">Cet article vous a-t-il été utile ?</span><div class="buttons_vgpi"><button class="btn_svQp" aria-label="Oui, cet article m'a été utile"><img src="/fr/img/reaction-mascot-yes.webp" alt="" class="mascot_Fait">Utile</button><button class="btn_svQp btnNeutral_Iqek" aria-label="Non, cet article ne m'a pas été utile"><img src="/fr/img/reaction-mascot-no.webp" alt="" class="mascot_Fait">Pas vraiment</button></div></div><div class="container_IJwe"><span class="question_nb7b">Avez-vous suivi les étapes de cet article ?</span><div class="buttons_Xkzw"><button class="btn_RMiq" aria-label="Oui, j'ai essayé et ça a marché"><img src="/fr/img/like.webp" alt="" class="mascot_wB1d">Ça a marché !</button><button class="btn_RMiq btnNeutral_z71B" aria-label="Non, ça n'a pas marché pour moi"><img src="/fr/img/dislike.webp" alt="" class="mascot_wB1d">Ça n'a pas marché</button></div></div>]]></content:encoded>
            <author>Christophe</author>
            <category>ssh</category>
            <category>linux</category>
            <category>windows</category>
            <enclosure length="0" type="image/webp" url="https://www.avonture.be/fr/img/v2/ssh.webp"/>
        <dc:creator><![CDATA[Christophe]]></dc:creator></item>
        <item>
            <title><![CDATA[Ask My Blog: Turning 259 Articles Into 2,160 Searchable Questions]]></title>
            <link>https://www.avonture.be/fr/fr/blog/docusaurus-ask-my-blog</link>
            <guid isPermaLink="false">/fr/blog/docusaurus-ask-my-blog</guid>
            <pubDate>Mon, 28 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[<img src="https://www.avonture.be/fr/img/v2/ask-my-blog.webp" alt="Ask My Blog: Turning 259 Articles Into 2,160 Searchable Questions" style="display:block;max-width:100%;height:auto;">A local LLM reads every article on your blog and writes the questions a developer would actually type into a search bar, each one mapped to the exact heading that answers it. The result is a build-time question index searched with plain BM25 in the browser — no embeddings, no API key, no vector database. Full pipeline, plugin and component included.]]></description>
            <content:encoded><![CDATA[<div class="offer_jqr1"><span class="flag_Hnep" aria-hidden="true">🇬🇧</span><span>Cet article existe aussi en anglais —<!-- --> <a href="/blog/docusaurus-ask-my-blog/" hreflang="en">lire la version originale</a>.</span></div><div class="alertBox_R1Ot mk-callout caution_LuPi mk-callout--warn" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Traduction automatique</p><div class="content_gFLB">Cette traduction a été produite par une IA. Elle peut contenir des contresens ou des maladresses, en particulier sur les termes techniques. En cas de doute,<!-- --> <a href="/blog/docusaurus-ask-my-blog/">consultez l'article de référence en anglais</a> <!-- -->— c'est lui qui fait foi.</div></div></div><div><div class="seriesBlogPost_bjUd"><details class="details_rTsq"><summary class="seriesIntro_HIK1 mk-eyebrow">Cet article fait partie de la série <a href="/fr/series/creating-docusaurus-components/">Créer des composants Docusaurus</a> :</summary><div class="content_OqVG"><ul><li><a href="/fr/blog/docusaurus-override-img/">Modifier la façon dont Docusaurus génère les balises img</a></li><li><a href="/fr/blog/docusaurus-lazy-loading/">Surcharger la génération des balises img avec Docusaurus</a></li><li><a href="/fr/blog/docusaurus-series/">Organisez votre contenu Docusaurus avec un composant Series personnalisé</a></li><li><a href="/fr/blog/docusaurus-go-top/">Ajouter un bouton « retour en haut » dans les articles Docusaurus</a></li><li><a href="/fr/blog/docusaurus-plugin-replace/">Créer un plugin de recherche/remplacement pour Docusaurus</a></li><li><a href="/fr/blog/docusaurus-snippets/">Un composant pour afficher des snippets de code dans un blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-old-notice/">Un composant Docusaurus qui prévient le lecteur que l'article a plus d'un an</a></li><li><a href="/fr/blog/docusaurus-changelog/">Afficher le changelog de votre article</a></li><li><a href="/fr/blog/docusaurus-ascii-art/">Injecter de l'ASCII art dans toutes les pages HTML générées par Docusaurus</a></li><li><a href="/fr/blog/blog-post-feed/">Best Practice - Personnaliser le flux RSS de Docusaurus pour un contenu complet et des images</a></li><li><a href="/fr/blog/docusaurus-check-images/">Quelques vérifications sur les images de votre site Docusaurus</a></li><li><a href="/fr/blog/docusaurus-tags/">Gestion des tags dans Docusaurus</a></li><li><a href="/fr/blog/gemini-tldr/">Automatiser les résumés TL;DR avec Gemini AI</a></li><li><a href="/fr/blog/docusaurus-project-setup/">Le composant ProjectSetup - Une façon standardisée de partager des structures de projet</a></li><li><a href="/fr/blog/docusaurus-ai-gemini/">Comment signaler un contenu assisté par IA dans un blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-terminal-typewriter/">Donnez vie à vos tutoriels CLI avec un terminal machine à écrire</a></li><li><a href="/fr/blog/docusaurus-easter-eggs/">Espiègleries de suricate : semer des easter eggs partout sur mon blog</a></li><li><a href="/fr/blog/docusaurus-eli5-snippet-tooltips/">Tooltips de code assistés par l'IA dans Docusaurus — Explique-moi comme à un enfant de cinq ans</a></li><li><a href="/fr/blog/docusaurus-reactions/">Ajouter des réactions de lecteurs à votre blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-llms-txt/">Votre blog est illisible pour une IA. Voici la solution.</a></li><li><a href="/fr/blog/docusaurus-shake-easter-egg/">Secouez votre téléphone, surprenez le suricate</a></li><li><a href="/fr/blog/docusaurus-copy-as-markdown/">Ajouter un bouton « Copy as Markdown » à votre blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-ask-my-blog/">Ask My Blog : transformer 259 articles en 2 160 questions cherchables</a></li><li><a href="/fr/blog/docusaurus-ask-my-blog-bubble/">La troisième porte : une bulle de chat flottante, et la fonctionnalité que personne ne trouvait</a></li><li><a href="/fr/blog/docusaurus-blog-map/">Dessiner une carte de mon propre blog</a></li><li><a href="/fr/blog/docusaurus-typo-report-component/">Laissez vos lecteurs signaler les problèmes — un widget de feedback multi-types pour Docusaurus</a></li></ul></div></details></div><div><p><img alt="Ask My Blog : transformer 259 articles en 2 160 questions cherchables" src="/fr/assets/images/ask-my-blog-ab324a423acd2d6821f260a62d9e330d.webp" height="auto" width="" loading="lazy" decoding="async" class=""></p>
<div class="tldrContainer_qQW_ mk-callout mk-callout--key" role="region" aria-label="Résumé de l'article"><span role="img" aria-label="éclair" class="tldrIcon_SLBz mk-dot">⚡</span><div class="tldrBody_lGty"><strong class="tldrTitle_Aha8 mk-eyebrow">TL;DR</strong><div class="tldrContent_m27u"><p>Une recherche de site ne trouve que les mots que <em>vous</em> avez écrits. Un lecteur dont l'image Docker est trop grosse tape « my docker image is huge », pas « optimizing layer caching » — et ne trouve rien. J'ai donc fait lire mes 259 articles à un modèle Ollama local, en lui demandant d'écrire pour chacun les 8 à 12 questions qu'un développeur taperait réellement, chacune reliée au titre qui y répond. Ça a donné 2 160 questions, servies comme un index généré au build et interrogées dans le navigateur avec un simple BM25. Pas d'embeddings, pas de base vectorielle, pas de clé d'API.</p></div></div></div>
<p>La recherche sur un blog personnel est presque toujours décevante, et j'ai mis un moment à comprendre pourquoi. Ce n'est pas la faute du moteur de recherche — le mien indexe parfaitement chaque mot de chaque page. Le problème, c'est qu'il ne peut trouver que les mots que <em>j'ai</em> écrits.</p>
<p>J'écris « réduire la taille finale de l'image avec des builds multi-stage ». Le lecteur en galère tape « mon image docker est énorme ». Zéro résultat. L'article dont il avait besoin est là, indexé, comparé à un vocabulaire qu'il n'a jamais employé.</p>
<p>La réponse habituelle, ce sont les embeddings : tout transformer en vecteurs, comparer des sens plutôt que des mots. Mais ça veut dire un modèle dans le navigateur, ou un appel d'API à chaque frappe, pour un blog statique sans backend. Il existe une façon bien moins coûteuse de faire le pont entre deux vocabulaires — et ça commence par remarquer que ce pont ne doit être construit qu'<strong>une seule fois</strong>.</p>
<nav class="quickJump_q5pQ" aria-label="Quick Jump"><span class="title_Njuu mk-eyebrow">Quick Jump</span><ul class="list_A_9v"><li class="item_gTjz"><a href="#what-you-get">Ce que vous obtenez</a></li><li class="item_gTjz"><a href="#generating-the-questions">Comment les questions sont générées</a></li></ul></nav>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="what-you-get">Ce que vous obtenez<a href="#what-you-get" class="hash-link" aria-label="Lien direct vers Ce que vous obtenez" title="Lien direct vers Ce que vous obtenez" translate="no"></a></h2>
<p>La <a href="/fr/faq/">page <code>/faq</code></a> s'ouvre sur un hub : des cartes de sujets, une par tag principal — <em>Docker</em>, <em>AI</em>, <em>Bash</em>, <em>WSL</em>, et d'autres. Chaque carte indique combien de questions y sont classées. Cliquez sur l'une d'elles et vous arrivez sur une page qui liste toutes les questions de ce sujet, chacune pointant vers le titre d'article exact qui y répond :</p>
<div class="browserWindow_my1Q"><div class="browserWindowHeader_jXSR"><div class="buttons_uHc7"><span class="dot_giz1" style="background:#f25f58"></span><span class="dot_giz1" style="background:#fbbe3c"></span><span class="dot_giz1" style="background:#58cb42"></span></div><div class="browserWindowAddressBar_Pd8y text--truncate">https://www.avonture.be/fr/faq</div><div class="browserWindowMenuIcon_Vhuh"><div><span class="bar_rrRL"></span><span class="bar_rrRL"></span><span class="bar_rrRL"></span></div></div></div><div class="browserWindowBody_Idgs"><p><img alt="Hub FAQ — cartes de sujets avec le nombre de questions par tag" src="/fr/assets/images/faq-hub-e67fafc7f58b581165012766b1f6d649.png" height="auto" width="" loading="lazy" decoding="async" class=""></p></div></div>
<p>Le même index alimente aussi une boîte de recherche sur cette page. Tapez un problème avec vos propres mots — « mon image docker est trop grosse », « comment installer joomla » — et vous obtenez des questions, pas des titres de page. Chaque résultat pointe vers le titre exact qui y répond :</p>
<div class="browserWindow_my1Q"><div class="browserWindowHeader_jXSR"><div class="buttons_uHc7"><span class="dot_giz1" style="background:#f25f58"></span><span class="dot_giz1" style="background:#fbbe3c"></span><span class="dot_giz1" style="background:#58cb42"></span></div><div class="browserWindowAddressBar_Pd8y text--truncate">https://www.avonture.be/fr/faq</div><div class="browserWindowMenuIcon_Vhuh"><div><span class="bar_rrRL"></span><span class="bar_rrRL"></span><span class="bar_rrRL"></span></div></div></div><div class="browserWindowBody_Idgs"><p><img alt="Résultats de recherche — les questions correspondant à la requête, chacune liée au titre qui y répond" src="/fr/assets/images/faq-search-c2998928a699535783b807dd599f8da6.png" height="auto" width="" loading="lazy" decoding="async" class=""></p></div></div>
<p>C'est toute l'idée, et le reste de cet article explique comment c'est construit.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="why-this-works-without-a-model-in-the-browser">Pourquoi ça marche sans modèle dans le navigateur<a href="#why-this-works-without-a-model-in-the-browser" class="hash-link" aria-label="Lien direct vers Pourquoi ça marche sans modèle dans le navigateur" title="Lien direct vers Pourquoi ça marche sans modèle dans le navigateur" translate="no"></a></h2>
<p>L'idée est minuscule et c'est tout l'article : <strong>le travail sémantique n'a pas à se faire au moment de la recherche.</strong></p>
<ul>
<li class="">La requête d'un lecteur et la prose d'un article sont écrites dans deux vocabulaires différents. Quelque chose doit traduire entre les deux.</li>
<li class="">Cette traduction est une propriété fixe de l'article. Elle ne dépend ni de qui cherche, ni de quand. Elle peut donc être calculée une fois et stockée.</li>
<li class="">Une fois qu'un modèle a déjà formulé « comment empêcher mon image Docker d'être énorme ? » et l'a classée à côté du bon titre, comparer la requête d'un lecteur à <strong>cette</strong> phrase devient un simple problème de similarité de chaînes. Un classement lexical vieux de vingt ans s'en sort parfaitement.</li>
<li class="">Ce qui part vers le navigateur, c'est donc une liste de phrases et une fonction de scoring — pas un modèle, pas un index vectoriel, pas un appel réseau.</li>
</ul>
<p>Le build devient plus lent (environ 11 secondes par article, une seule fois). Chaque lecteur ensuite obtient une réponse instantanée.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="generating-the-questions">Générer les questions<a href="#generating-the-questions" class="hash-link" aria-label="Lien direct vers Générer les questions" title="Lien direct vers Générer les questions" translate="no"></a></h2>
<p>Le générateur demande à une instance <a href="/fr/blog/ollama-installation/">Ollama</a> locale des questions sur un article, et les écrit dans un fichier sidecar à côté de celui-ci — la même convention que celle déjà utilisée par mes <a href="/fr/blog/docusaurus-eli5-snippet-tooltips/">tooltips ELI5</a> pour leur propre contenu généré :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb">$ time yarn questions blog/2025/09/12/docusaurus-go-top/index.md --force

✅ Written: blog/2025/09/12/docusaurus-go-top/index.md.questions.json
   8 questions.

real    0m11.147s

$ yarn questions:bulk --dry-run --limit 4

🔍 4 article(s) under blog (--limit 4).
  [SKIP] blog/2023/11/02/docker-php-run-script-or-website/index.md
  [SKIP] blog/2023/11/02/keepass-start-rdp/index.md
  [SKIP] blog/2023/11/02/welcome/index.md
  [SKIP] blog/2023/11/02/wslg-rpd-connection/index.md

Dry run — nothing written.
</pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Le résultat est un petit fichier JSON, <code>index.md.questions.json</code>, committé à côté de l'article :</p>

<p>Trois choses rendent cette sortie utilisable plutôt que simplement plausible.</p>
<p><strong>On explique au modèle ce que « spécifique » veut dire.</strong> Le system prompt interdit purement et simplement les questions génériques (« Qu'est-ce que Docker ? »), exige un mélange de requêtes courtes façon mots-clés et de phrases complètes, et interdit deux questions qui ne diffèrent que par l'ordre des mots :</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockTitle_OeMC">scripts/generate-questions.mjs — system prompt (extract)</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">- Every question must be SPECIFIC to this article's actual content — never generic</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  ("What is Docker?", "How does Markdown work?").</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">- Vary the phrasing style: some short keyword-style queries, some full questions.</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">- Each question maps to exactly one heading by index. Index 0 means "the article as a</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  whole / introduction", not tied to a specific heading.</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">- Do not invent facts not supported by the material given.</span><br></div></code></pre></div></div>
<p><strong>La forme de la réponse est imposée, pas espérée.</strong> Ollama accepte un schéma JSON dans <code>format:</code>, donc le modèle ne peut pas renvoyer de la prose, une liste Markdown ou un champ nommé différemment. Le script valide quand même chaque élément après coup, et fait échouer l'article entier s'il en reste moins de cinq — une entrée maigre est pire qu'aucune entrée.</p>
<p><strong>Les titres sont résolus en vraies ancres.</strong> Le prompt donne au modèle une liste numérotée de titres et lui demande un index ; le script remappe cet index vers le slug que Docusaurus va lui-même générer, avec le même package <code>github-slugger</code> que Docusaurus utilise en interne. Un mauvais index n'est pas fatal — la question retombe en haut de l'article au lieu d'être jetée.</p>
<p>Voici le générateur complet :</p>

<div class="alertBox_R1Ot mk-callout tip_shCb mk-callout--tip" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Choisir un petit modèle, volontairement</p><div class="content_gFLB"><p>Ça tourne sur <code>task-tiny</code>, un modèle instruct de 3B. Les modèles locaux plus gros ne produisaient pas de meilleures questions en comparaison directe et tournaient environ dix fois plus lentement. Écrire des questions de recherche à partir d'un titre, d'une description et d'une liste de titres est une tâche d'<em>extraction</em>, pas de raisonnement — et 259 articles à 11 secondes chacun, c'est une pause café, alors que 259 à deux minutes chacun, c'est un après-midi.</p></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="serving-2160-questions-three-different-ways">Servir 2 160 questions de trois manières différentes<a href="#serving-2160-questions-three-different-ways" class="hash-link" aria-label="Lien direct vers Servir 2 160 questions de trois manières différentes" title="Lien direct vers Servir 2 160 questions de trois manières différentes" translate="no"></a></h2>
<p>Un plugin Docusaurus agrège les 259 sidecars en un seul index. Le point intéressant, c'est qu'il n'expédie <strong>pas</strong> cet index une fois — il l'expédie trois fois, sous trois formes différentes, parce qu'il a trois consommateurs aux besoins incompatibles :</p>
<table><thead><tr><th>Consommateur</th><th>Ce dont il a besoin</th><th>Comment il l'obtient</th></tr></thead><tbody><tr><td>Le hub <code>/faq</code></td><td>40 noms de sujets et leurs compteurs, groupés par <a href="/fr/blog/docusaurus-tags/">tag principal</a></td><td><code>setGlobalData</code> — quelques centaines d'octets</td></tr><tr><td>Les pages <code>/faq/&lt;topic&gt;</code></td><td>Les questions d'un seul sujet, crawlables</td><td><code>addRoute</code> + <code>createData</code>, code-split par route</td></tr><tr><td>La boîte de recherche</td><td>Tout le corpus</td><td>Un simple fichier JSON statique, récupéré à la demande</td></tr></tbody></table>
<p>C'est la troisième ligne qui compte. Le corpus complet pèse <strong>468 Ko</strong> (63 Ko gzippés), et la boîte de recherche vit dans un composant monté sur <em>chaque</em> page du site. L'exposer via <code>setGlobalData</code> aurait embarqué les 468 Ko dans le JavaScript de chaque page, que quelqu'un tape un caractère ou non.</p>
<p>Le plugin l'écrit donc directement sur disque comme asset statique, et le client le <code>fetch</code> la première fois que quelqu'un ouvre vraiment la boîte de recherche :</p>

<p>Le fetch est mis en cache au niveau du module, donc la palette et la page <code>/faq</code> partagent un seul téléchargement — et un fetch raté vide le cache au lieu de l'empoisonner, pour qu'un lecteur ayant cherché avant la fin du build du serveur de dev puisse simplement réessayer :</p>

<p>Et la boîte de recherche elle-même :</p>
<div class="projectSetupContainer_LW1i mk-surface"><div class="header_Htsx mk-toolbar"><strong>📦 <!-- -->Mise en place du projet :<!-- --> <!-- -->src/components/AskMyBlog</strong><div class="buttonGroup_y07f mk-toolbar__actions"><a class="button button--secondary button--sm" href="/project_setup" target="_blank">Aide</a><button class="button button--secondary button--sm">Tout déplier</button><button class="button button--outline button--primary button--sm" aria-label="Télécharger le projet en ZIP">ZIP</button><button class="button button--secondary button--sm">Générer le script d'installation</button></div></div><div class="content_bo8S"><div><p class="tip_qyDH"><small><span aria-hidden="true">💡</span> <!-- -->Besoin d'aide ? Cliquez sur le bouton « Aide » ci-dessus pour les instructions de mise en place.</small></p></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="under-the-hood-skip-this-if-you-just-want-the-questions">Sous le capot (passez votre chemin si vous voulez juste les questions)<a href="#under-the-hood-skip-this-if-you-just-want-the-questions" class="hash-link" aria-label="Lien direct vers Sous le capot (passez votre chemin si vous voulez juste les questions)" title="Lien direct vers Sous le capot (passez votre chemin si vous voulez juste les questions)" translate="no"></a></h2>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="querying-the-index-from-the-command-line">Interroger l'index depuis la ligne de commande<a href="#querying-the-index-from-the-command-line" class="hash-link" aria-label="Lien direct vers Interroger l'index depuis la ligne de commande" title="Lien direct vers Interroger l'index depuis la ligne de commande" translate="no"></a></h3>
<p>Le fichier JSON est un simple tableau, vous pouvez donc le sonder hors du navigateur en quelques lignes. Pratique pour déboguer l'index avant de le livrer, ou pour vérifier si une formulation précise correspond :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb">$ npx tsx --eval "
import { buildSearchIndex, search } from './src/components/AskMyBlog/utils.ts';
import fs from 'fs';
const rel = 'questions-index-plugin/static/questions-index.json';
const p = ['.docusaurus', '.docusaurus-dev'].map(d =&gt; d + '/' + rel).find(fs.existsSync);
if (p === undefined) throw new Error('Run yarn build or start the dev server first');
const corpus = JSON.parse(fs.readFileSync(p, 'utf8'));
const index = buildSearchIndex(corpus);
for (const r of search(index, process.argv[1], 4)) {
  console.log(r.question);
  console.log('    ' + r.title + '  -&gt;  ' + r.permalink + (r.anchor ? '#' + r.anchor : ''));
}" "run docusaurus in docker"

How do I run my Docusaurus site using Docker?
    Running Docusaurus with Docker  -&gt;  /blog/docusaurus-docker#run-docusaurus
What commands are used to build and run the production Docker image for Docusaurus?
    Running Docusaurus using Docker  -&gt;  /blog/running-docusaurus-with-docker#build-and-run-the-production-image
How do I run Quarto Markdown in Docker?
    Running Quarto Markdown in Docker  -&gt;  /blog/docker-quarto
How do I run Firefox in a Docker container?
    Docker - Run Graphical User Interfaces - Firefox, Chrome &amp; GIMP  -&gt;  /blog/docker-run-linux-gui#creating-our-own-chrome-docker-image
</pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="bm25-in-forty-lines">BM25 en quarante lignes<a href="#bm25-in-forty-lines" class="hash-link" aria-label="Lien direct vers BM25 en quarante lignes" title="Lien direct vers BM25 en quarante lignes" translate="no"></a></h3>
<p>Le classement, c'est Okapi BM25 — fréquence des termes avec saturation, plus normalisation par la longueur, pondérée par la fréquence inverse de document. C'est ce qu'utilisent Lucene et Elasticsearch, et ça tient dans un petit fichier parce que chaque « document » ici est une seule question d'une douzaine de mots.</p>
<p>Deux adaptations étaient nécessaires pour cette longueur de document inhabituellement courte.</p>
<p><strong>Le matching par préfixe, développé une fois par requête.</strong> Un lecteur qui tape <code>docus</code> n'a pas fini son mot, donc un token inconnu de trois caractères ou plus est développé vers tous les termes indexés qu'il préfixe. En dessous de trois caractères, un préfixe correspond à trop de vocabulaire pour signifier quoi que ce soit, donc les tokens inconnus courts sont simplement écartés. Point crucial : l'expansion a lieu une fois par requête, pas une fois par document — la boucle de scoring ne voit jamais que de vrais termes indexés.</p>
<p><strong>Un tokenizer qui découpe les mots composés sans les perdre.</strong> <code>CaesiumCLT</code> tokenisé naïvement est un seul token indivisible, donc un lecteur qui tape « caesium » ne trouve rien. Insérer une frontière avant de passer en minuscules règle ça — mais alors <code>WordPress</code> devient <code>word</code> + <code>press</code>, et quelqu'un qui tape « wordpress » en un mot ne correspond à aucune des deux moitiés. La solution est une union plutôt qu'un remplacement : garder à la fois la forme découpée et la forme entière.</p>
<div class="language-ts codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockTitle_OeMC">src/components/AskMyBlog/utils.ts</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-ts codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token keyword" style="color:rgb(86, 156, 214)">export</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">function</span><span class="token plain"> </span><span class="token function" style="color:rgb(220, 220, 170)">tokenize</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">text</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token builtin" style="color:rgb(86, 156, 214)">string</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token builtin" style="color:rgb(86, 156, 214)">string</span><span class="token punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token punctuation" style="color:rgb(212, 212, 212)">]</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token keyword" style="color:rgb(86, 156, 214)">const</span><span class="token plain"> withBoundaries </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> text</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">replace</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token regex regex-delimiter">/</span><span class="token regex regex-source language-regex group punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token regex regex-source language-regex char-class char-class-punctuation punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token regex regex-source language-regex char-class range">a</span><span class="token regex regex-source language-regex char-class range range-punctuation operator" style="color:rgb(212, 212, 212)">-</span><span class="token regex regex-source language-regex char-class range">z</span><span class="token regex regex-source language-regex char-class range">0</span><span class="token regex regex-source language-regex char-class range range-punctuation operator" style="color:rgb(212, 212, 212)">-</span><span class="token regex regex-source language-regex char-class range">9</span><span class="token regex regex-source language-regex char-class char-class-punctuation punctuation" style="color:rgb(212, 212, 212)">]</span><span class="token regex regex-source language-regex group punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token regex regex-source language-regex group punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token regex regex-source language-regex char-class char-class-punctuation punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token regex regex-source language-regex char-class range">A</span><span class="token regex regex-source language-regex char-class range range-punctuation operator" style="color:rgb(212, 212, 212)">-</span><span class="token regex regex-source language-regex char-class range">Z</span><span class="token regex regex-source language-regex char-class char-class-punctuation punctuation" style="color:rgb(212, 212, 212)">]</span><span class="token regex regex-source language-regex group punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token regex regex-delimiter">/</span><span class="token regex regex-flags">g</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"$1 $2"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token keyword" style="color:rgb(86, 156, 214)">const</span><span class="token plain"> split </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> withBoundaries</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">toLowerCase</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">match</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token regex regex-delimiter">/</span><span class="token regex regex-source language-regex char-class char-class-punctuation punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token regex regex-source language-regex char-class range">a</span><span class="token regex regex-source language-regex char-class range range-punctuation operator" style="color:rgb(212, 212, 212)">-</span><span class="token regex regex-source language-regex char-class range">z</span><span class="token regex regex-source language-regex char-class range">0</span><span class="token regex regex-source language-regex char-class range range-punctuation operator" style="color:rgb(212, 212, 212)">-</span><span class="token regex regex-source language-regex char-class range">9</span><span class="token regex regex-source language-regex char-class char-class-punctuation punctuation" style="color:rgb(212, 212, 212)">]</span><span class="token regex regex-source language-regex quantifier number" style="color:rgb(181, 206, 168)">+</span><span class="token regex regex-delimiter">/</span><span class="token regex regex-flags">g</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">||</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token punctuation" style="color:rgb(212, 212, 212)">]</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token keyword" style="color:rgb(86, 156, 214)">const</span><span class="token plain"> whole </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> text</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">toLowerCase</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">match</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token regex regex-delimiter">/</span><span class="token regex regex-source language-regex char-class char-class-punctuation punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token regex regex-source language-regex char-class range">a</span><span class="token regex regex-source language-regex char-class range range-punctuation operator" style="color:rgb(212, 212, 212)">-</span><span class="token regex regex-source language-regex char-class range">z</span><span class="token regex regex-source language-regex char-class range">0</span><span class="token regex regex-source language-regex char-class range range-punctuation operator" style="color:rgb(212, 212, 212)">-</span><span class="token regex regex-source language-regex char-class range">9</span><span class="token regex regex-source language-regex char-class char-class-punctuation punctuation" style="color:rgb(212, 212, 212)">]</span><span class="token regex regex-source language-regex quantifier number" style="color:rgb(181, 206, 168)">+</span><span class="token regex regex-delimiter">/</span><span class="token regex regex-flags">g</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">||</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token punctuation" style="color:rgb(212, 212, 212)">]</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token keyword" style="color:rgb(86, 156, 214)">return</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token operator" style="color:rgb(212, 212, 212)">...</span><span class="token keyword" style="color:rgb(86, 156, 214)">new</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(78, 201, 176)">Set</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token operator" style="color:rgb(212, 212, 212)">...</span><span class="token plain">split</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">...</span><span class="token plain">whole</span><span class="token punctuation" style="color:rgb(212, 212, 212)">]</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">]</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><br></div></code></pre></div></div>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="the-duplicate-problem">Le problème des doublons<a href="#the-duplicate-problem" class="hash-link" aria-label="Lien direct vers Le problème des doublons" title="Lien direct vers Le problème des doublons" translate="no"></a></h3>
<p>Si quarante articles génèrent chacun « Comment installer Docker ? », l'index ne sert à rien — le lecteur obtient quarante lignes identiques et aucun signal. Le plugin normalise chaque question et supprime <strong>toutes</strong> les instances en collision, y compris la première. Ce dernier détail est volontaire : une question qui n'est pas spécifique à exactement un article a déjà enfreint la règle du prompt, donc en garder une copie reviendrait quand même à livrer une mauvaise entrée. Sur mon corpus, ça retire 5 questions sur 2 055. Le prompt fait l'essentiel du travail ; ceci n'est que le filet de sécurité.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="sidecars-are-meant-to-be-hand-edited">Les sidecars sont faits pour être édités à la main<a href="#sidecars-are-meant-to-be-hand-edited" class="hash-link" aria-label="Lien direct vers Les sidecars sont faits pour être édités à la main" title="Lien direct vers Les sidecars sont faits pour être édités à la main" translate="no"></a></h3>
<p>La génération est un premier jet. La plupart des entrées sont correctes, certaines sont une blague reprise telle quelle dans la prose d'un article et reformatée en question. Un petit élagueur interactif les retrouve par mot-clé et permet de supprimer par numéro :</p>
<div class="language-console codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-console codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">$ yarn questions:edit "dinosaur"</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">blog/2024/05/17/some-article/index.md.questions.json</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  [0] How do I install the CLI? → #installation</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  [1] How can I play with a green dinosaur?</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  [2] What does the --force flag do? → #options</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">Delete which number(s)? (space-separated, Enter to skip): 1</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  ✅ Removed 1 question(s), 11 left.</span><br></div></code></pre></div></div>
<p>Supprimer une question ne touche jamais à l'article lui-même, ce qui compte pour la dernière pièce.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="detecting-drift">Détecter la dérive<a href="#detecting-drift" class="hash-link" aria-label="Lien direct vers Détecter la dérive" title="Lien direct vers Détecter la dérive" translate="no"></a></h3>
<p>Une question générée peut pourrir en silence : renommez un titre et son ancre casse ; réécrivez une section et les questions cessent de la décrire. <code>yarn questions:check</code> compare le <code>sourceHash</code> stocké dans chaque sidecar au contenu actuel de l'article et signale <code>STALE</code>, plus les articles qui n'ont aucun sidecar. Par défaut il se contente de rapporter ; <code>--strict</code> échoue sur les entrées périmées mais jamais sur la couverture manquante, parce qu'un article fraîchement écrit n'a légitimement aucune question tant que vous n'avez pas lancé le générateur dessus.</p>

<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="the-topic-pages-carry-faq-structured-data">Les pages de sujet embarquent des données structurées FAQ<a href="#the-topic-pages-carry-faq-structured-data" class="hash-link" aria-label="Lien direct vers Les pages de sujet embarquent des données structurées FAQ" title="Lien direct vers Les pages de sujet embarquent des données structurées FAQ" translate="no"></a></h3>
<p>Chaque page <code>/faq/&lt;topic&gt;</code> émet du JSON-LD <code>FAQPage</code>. Autant être honnête sur le retour : Google a restreint les rich results FAQ en août 2023 à un ensemble étroit de sites gouvernementaux et de santé faisant autorité, donc un blog personnel n'obtiendra pas l'affichage dépliable. Ça reste du balisage correct et conforme aux standards, que d'autres consommateurs lisent — mais pas un gain garanti dans la page de résultats.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="conclusion">Conclusion<a href="#conclusion" class="hash-link" aria-label="Lien direct vers Conclusion" title="Lien direct vers Conclusion" translate="no"></a></h2>
<p>J'ai commencé en voulant de la recherche sémantique et j'ai fini sans avoir besoin de la moindre sémantique à l'exécution. Le modèle a fait le plus dur une fois, hors ligne, sur ma propre machine, gratuitement — et ce qu'il a laissé derrière lui, ce sont 2 160 phrases en anglais courant qu'un classement lexical ordinaire traite à merveille.</p>
<p>Ce recadrage vaut la peine d'être transposé ailleurs. « Ça nécessite un modèle » signifie très souvent « ça a nécessité un modèle, une fois, et maintenant ça nécessite un fichier ». La version de votre fonctionnalité qui fait tourner un modèle de langage dans le navigateur de chaque visiteur et celle qui livre un fichier JSON calculé au build peuvent produire la même réponse — et une seule des deux fonctionne encore quand la clé d'API expire.</p>
<p>Mon index complet, généré localement et committé dans Git, pèse 63 Ko sur le réseau. C'est moins que l'image de bannière en haut de cet article.</p></div><hr><div class="seriesBlogPost_bjUd"><details class="details_rTsq"><summary class="seriesIntro_HIK1 mk-eyebrow">Cet article fait partie de la série <a href="/fr/series/creating-docusaurus-components/">Créer des composants Docusaurus</a> :</summary><div class="content_OqVG"><ul><li><a href="/fr/blog/docusaurus-override-img/">Modifier la façon dont Docusaurus génère les balises img</a></li><li><a href="/fr/blog/docusaurus-lazy-loading/">Surcharger la génération des balises img avec Docusaurus</a></li><li><a href="/fr/blog/docusaurus-series/">Organisez votre contenu Docusaurus avec un composant Series personnalisé</a></li><li><a href="/fr/blog/docusaurus-go-top/">Ajouter un bouton « retour en haut » dans les articles Docusaurus</a></li><li><a href="/fr/blog/docusaurus-plugin-replace/">Créer un plugin de recherche/remplacement pour Docusaurus</a></li><li><a href="/fr/blog/docusaurus-snippets/">Un composant pour afficher des snippets de code dans un blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-old-notice/">Un composant Docusaurus qui prévient le lecteur que l'article a plus d'un an</a></li><li><a href="/fr/blog/docusaurus-changelog/">Afficher le changelog de votre article</a></li><li><a href="/fr/blog/docusaurus-ascii-art/">Injecter de l'ASCII art dans toutes les pages HTML générées par Docusaurus</a></li><li><a href="/fr/blog/blog-post-feed/">Best Practice - Personnaliser le flux RSS de Docusaurus pour un contenu complet et des images</a></li><li><a href="/fr/blog/docusaurus-check-images/">Quelques vérifications sur les images de votre site Docusaurus</a></li><li><a href="/fr/blog/docusaurus-tags/">Gestion des tags dans Docusaurus</a></li><li><a href="/fr/blog/gemini-tldr/">Automatiser les résumés TL;DR avec Gemini AI</a></li><li><a href="/fr/blog/docusaurus-project-setup/">Le composant ProjectSetup - Une façon standardisée de partager des structures de projet</a></li><li><a href="/fr/blog/docusaurus-ai-gemini/">Comment signaler un contenu assisté par IA dans un blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-terminal-typewriter/">Donnez vie à vos tutoriels CLI avec un terminal machine à écrire</a></li><li><a href="/fr/blog/docusaurus-easter-eggs/">Espiègleries de suricate : semer des easter eggs partout sur mon blog</a></li><li><a href="/fr/blog/docusaurus-eli5-snippet-tooltips/">Tooltips de code assistés par l'IA dans Docusaurus — Explique-moi comme à un enfant de cinq ans</a></li><li><a href="/fr/blog/docusaurus-reactions/">Ajouter des réactions de lecteurs à votre blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-llms-txt/">Votre blog est illisible pour une IA. Voici la solution.</a></li><li><a href="/fr/blog/docusaurus-shake-easter-egg/">Secouez votre téléphone, surprenez le suricate</a></li><li><a href="/fr/blog/docusaurus-copy-as-markdown/">Ajouter un bouton « Copy as Markdown » à votre blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-ask-my-blog/">Ask My Blog : transformer 259 articles en 2 160 questions cherchables</a></li><li><a href="/fr/blog/docusaurus-ask-my-blog-bubble/">La troisième porte : une bulle de chat flottante, et la fonctionnalité que personne ne trouvait</a></li><li><a href="/fr/blog/docusaurus-blog-map/">Dessiner une carte de mon propre blog</a></li><li><a href="/fr/blog/docusaurus-typo-report-component/">Laissez vos lecteurs signaler les problèmes — un widget de feedback multi-types pour Docusaurus</a></li></ul></div></details></div></div><div class="relatedPosts_zJiB"><h3>Articles liés</h3></div><div class="container_mNR1"><span class="question_WdqX">Cet article vous a-t-il été utile ?</span><div class="buttons_vgpi"><button class="btn_svQp" aria-label="Oui, cet article m'a été utile"><img src="/fr/img/reaction-mascot-yes.webp" alt="" class="mascot_Fait">Utile</button><button class="btn_svQp btnNeutral_Iqek" aria-label="Non, cet article ne m'a pas été utile"><img src="/fr/img/reaction-mascot-no.webp" alt="" class="mascot_Fait">Pas vraiment</button></div></div><div class="container_IJwe"><span class="question_nb7b">Avez-vous suivi les étapes de cet article ?</span><div class="buttons_Xkzw"><button class="btn_RMiq" aria-label="Oui, j'ai essayé et ça a marché"><img src="/fr/img/like.webp" alt="" class="mascot_wB1d">Ça a marché !</button><button class="btn_RMiq btnNeutral_z71B" aria-label="Non, ça n'a pas marché pour moi"><img src="/fr/img/dislike.webp" alt="" class="mascot_wB1d">Ça n'a pas marché</button></div></div>]]></content:encoded>
            <author>Christophe</author>
            <author>Claude Code</author>
            <category>docusaurus</category>
            <category>ai</category>
            <category>ollama</category>
            <category>react</category>
            <category>component</category>
            <category>nodejs</category>
            <enclosure length="0" type="image/webp" url="https://www.avonture.be/fr/img/v2/ask-my-blog.webp"/>
        <dc:creator><![CDATA[Christophe]]></dc:creator><dc:creator><![CDATA[Claude Code]]></dc:creator></item>
        <item>
            <title><![CDATA[Docker Dive — X-Ray Your Images and Kill the Waste]]></title>
            <link>https://www.avonture.be/fr/fr/blog/docker-dive</link>
            <guid isPermaLink="false">/fr/blog/docker-dive</guid>
            <pubDate>Thu, 24 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[<img src="https://www.avonture.be/fr/img/v2/docker_dive.webp" alt="Docker Dive — X-Ray Your Images and Kill the Waste" style="display:block;max-width:100%;height:auto;">Use dive to inspect every layer of a Docker image, spot wasted megabytes hidden by deleted files, and walk step by step from a 1 GB bloated image down to a tight, efficient build.]]></description>
            <content:encoded><![CDATA[<div class="offer_jqr1"><span class="flag_Hnep" aria-hidden="true">🇬🇧</span><span>Cet article existe aussi en anglais —<!-- --> <a href="/blog/docker-dive/" hreflang="en">lire la version originale</a>.</span></div><div class="alertBox_R1Ot mk-callout caution_LuPi mk-callout--warn" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Traduction automatique</p><div class="content_gFLB">Cette traduction a été produite par une IA. Elle peut contenir des contresens ou des maladresses, en particulier sur les termes techniques. En cas de doute,<!-- --> <a href="/blog/docker-dive/">consultez l'article de référence en anglais</a> <!-- -->— c'est lui qui fait foi.</div></div></div><div><div><p><img alt="Docker Dive — Radiographiez vos images et éliminez le gaspillage" src="/fr/assets/images/docker_dive-9cd9ac70157bec6994cef0b6667d732d.webp" height="auto" width="" loading="lazy" decoding="async" class=""></p>
<div class="tldrContainer_qQW_ mk-callout mk-callout--key" role="region" aria-label="Résumé de l'article"><span role="img" aria-label="éclair" class="tldrIcon_SLBz mk-dot">⚡</span><div class="tldrBody_lGty"><strong class="tldrTitle_Aha8 mk-eyebrow">TL;DR</strong><div class="tldrContent_m27u"><p><code>dive</code> est un outil open source en ligne de commande qui affiche chaque layer d'une image Docker sous forme d'arborescence de fichiers interactive, en mettant en évidence exactement quels fichiers sont gaspillés — présents dans un layer mais supprimés dans un layer suivant, et pourtant toujours embarqués dans l'image finale. Cet article part d'un Dockerfile volontairement catastrophique, montre comment dive expose le gaspillage, puis le corrige en trois étapes successives : nettoyage des caches apt dans le même layer, fusion de plusieurs instructions <code>RUN</code> en une seule, et passage à un multi-stage build qui réduit l'image de 80 %. En bonus, on pousse l'idée à son extrême avec <code>FROM scratch</code> — une image contenant un seul binaire et rien d'autre.</p></div></div></div><nav class="mobileQuickLinks_La_l" aria-label="Articles liés"><p class="title_rsOi mk-eyebrow">Sur le même sujet</p><ul class="list_CXCl"><li class="item_grzi"><a href="/fr/blog/ollama-ai-explain/">ai-explain : l'ELI5 des scripts et des erreurs, directement dans votre terminal</a></li><li class="item_grzi"><a href="/fr/blog/duckdb-json-csv/">DuckDB - Interroger des fichiers JSON et CSV en SQL, sans base de données</a></li><li class="item_grzi"><a href="/fr/blog/devcontainer-container-name/">Devcontainer - Nommer son container et le regrouper dans Docker Desktop</a></li></ul></nav>
<p>Mince. Je viens de lancer <code>docker images</code> et voilà ce que j'ai vu : <code>myapp:latest — 1.19 GB</code>. Une application web Python avec cinq dépendances, et elle a gonflé au-delà du gigaoctet. Quelque chose cloche sérieusement dans ce Dockerfile.</p>
<p>Le problème avec l'obésité des images Docker, c'est qu'elle est invisible tant qu'on ne la cherche pas. Vos containers démarrent, votre app tourne, tout semble normal — et pendant ce temps vous expédiez 400 Mo de cache apt et d'outils de build en production à chaque déploiement.</p>
<p>C'est là qu'intervient <a href="https://github.com/wagoodman/dive" target="_blank" rel="noopener noreferrer">dive</a>. C'est un inspecteur d'images en mode terminal qui permet de naviguer dans les layers de n'importe quelle image Docker, de voir exactement ce que chaque instruction <code>RUN</code>, <code>COPY</code> et <code>ADD</code> a ajouté ou supprimé, et d'obtenir un score d'efficacité sans pitié en bas de l'écran. Voyez ça comme une machine à rayons X pour vos images.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="what-is-dive">Qu'est-ce que Dive ?<a href="#what-is-dive" class="hash-link" aria-label="Lien direct vers Qu'est-ce que Dive ?" title="Lien direct vers Qu'est-ce que Dive ?" translate="no"></a></h2>
<p>Au fond, une image Docker est une pile de layers en lecture seule. Chaque instruction de votre Dockerfile qui touche au système de fichiers crée un nouveau layer. <code>apt-get install</code> — nouveau layer. <code>pip install</code> — nouveau layer. <code>COPY</code> — nouveau layer.</p>
<p>Voilà le piège : si vous installez un package dans le layer 3, puis supprimez son cache dans le layer 5, les données du cache sont <em>toujours dans l'image</em>. La suppression marque seulement les fichiers comme « opaques » dans le layer supérieur — les octets du layer 3 sont toujours là, téléchargés à chaque <code>docker pull</code>.</p>
<p><code>dive</code> rend tout cela visible. Panneau de gauche : l'arborescence de fichiers du layer sélectionné. Panneau de droite : la liste des layers avec leurs tailles. En bas : votre score d'efficacité et l'espace total gaspillé.</p>
<div class="alertBox_R1Ot mk-callout tip_shCb mk-callout--tip" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Raccourcis clavier dans dive</p><div class="content_gFLB"><p>Appuyez sur <code>Tab</code> pour basculer entre la liste des layers et l'arborescence de fichiers. Les flèches permettent de naviguer entre les layers. <code>Space</code> replie/déplie les répertoires de l'arborescence. Les fichiers affichés en jaune sont modifiés, ceux en rouge sont supprimés — ces derniers sont vos octets gaspillés.</p></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="running-dive--no-installation-needed">Lancer Dive — aucune installation nécessaire<a href="#running-dive--no-installation-needed" class="hash-link" aria-label="Lien direct vers Lancer Dive — aucune installation nécessaire" title="Lien direct vers Lancer Dive — aucune installation nécessaire" translate="no"></a></h2>
<p>Vous me connaissez bien maintenant : j'aime mettre les choses dans des containers. Et <code>dive</code> dispose d'une image Docker officielle, donc aucune raison d'installer quoi que ce soit sur votre host :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/myapp</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>$ docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock wagoodman/dive<!-- -->:latest<!-- --> myapp<!-- -->:latest</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Le montage <code>-v /var/run/docker.sock:/var/run/docker.sock</code> est obligatoire : c'est par là que le container <code>dive</code> accède au daemon Docker de votre host pour inspecter les images. Si cela vous met mal à l'aise — et c'est une inquiétude légitime — consultez l'<a href="/fr/blog/docker-out-of-docker-dood/">article sur Docker-out-of-Docker</a> pour le contexte de sécurité autour du montage de la socket.</p>
<p>Si vous préférez un binaire local, <code>dive</code> est disponible sous Linux, macOS et Windows :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>$ wget -q <a href="https://github.com/wagoodman/dive/releases/latest/download/dive_linux_amd64.tar.gz" target="_blank" rel="noopener noreferrer" class="">https://github.com/wagoodman/dive/releases/latest/download/dive_linux_amd64.tar.gz</a> -O - | tar xz dive &amp;&amp; sudo mv dive /usr/local/bin/</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Dans les deux cas, l'usage est identique : <code>dive &lt;image-name&gt;</code>.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="the-patient-a-deliberately-terrible-dockerfile">Le patient : un Dockerfile volontairement catastrophique<a href="#the-patient-a-deliberately-terrible-dockerfile" class="hash-link" aria-label="Lien direct vers Le patient : un Dockerfile volontairement catastrophique" title="Lien direct vers Le patient : un Dockerfile volontairement catastrophique" translate="no"></a></h2>
<p>Construisons quelque chose de vraiment mauvais pour avoir de quoi analyser. Notre app est une petite API Flask — rien d'extraordinaire, juste assez pour tirer de vraies dépendances :</p>

<p>Et le Dockerfile — écrit sans le moindre égard pour la taille de l'image :</p>

<p>Construisons-le et voyons à quoi on a affaire :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/myapp</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>$ docker build -t myapp<!-- -->:bad<!-- --> -f Dockerfile.bad .</p><p>[+] Building 142.3s (9/9) FINISHED</p><p>$ docker images myapp<!-- -->:bad</p><p>REPOSITORY   TAG    IMAGE ID       SIZE
myapp        bad    3f8c1a9e2b71   1.19GB</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>1,19 Go. Pour une app Flask de cinq fichiers. Voyons exactement pourquoi.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="the-diagnosis-what-dive-reveals">Le diagnostic : ce que dive révèle<a href="#the-diagnosis-what-dive-reveals" class="hash-link" aria-label="Lien direct vers Le diagnostic : ce que dive révèle" title="Lien direct vers Le diagnostic : ce que dive révèle" translate="no"></a></h2>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/myapp</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>$ docker run --rm -it -v /var/run/docker.sock:/var/run/docker.sock wagoodman/dive<!-- -->:latest<!-- --> myapp<!-- -->:bad</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Dans l'interface interactive, le panneau de droite (la liste des layers) ressemble à ceci :</p>
<div class="language-plaintext codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-plaintext codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">Cmp   Size  Command</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">     212 MB  FROM ubuntu:24.04</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">      38 MB  RUN apt-get update</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">     368 MB  RUN apt-get install -y curl wget git python3...</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">     184 MB  RUN pip3 install flask requests numpy pandas...</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    0.0 B   RUN mkdir /app</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    1.2 KB  COPY app.py /app/</span><br></div></code></pre></div></div>
<p>Le panneau de gauche affiche l'arborescence du layer sélectionné. Quand vous sélectionnez le layer <code>apt-get update</code>, vous verrez <code>/var/lib/apt/lists/</code> rempli de centaines de mégaoctets de fichiers d'index de packages — et ils sont <em>toujours là</em> dans chaque layer suivant.</p>
<p>Pour un résultat non interactif et scriptable, utilisez le mode CI :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/myapp</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb">$ CI=true docker run --rm \
    -v /var/run/docker.sock:/var/run/docker.sock \
    wagoodman/dive:latest myapp:bad

  Using default CI config
Image Source: docker://myapp:bad
Fetching image... (this can take a while for large images)
Analyzing image...
  efficiency: 0.6341 (63.41%)
  wastedBytes: 438071296 bytes (438 MB)
  userWastedPercent: 36.59%
Result:FAIL [Total:3] [Passed:0] [Failed:3] [Warn:0] [Skipped:0]
</pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p><strong>61,89 % d'efficacité. 438 Mo gaspillés.</strong> C'est brutal, et c'est entièrement de notre faute.</p>
<div class="alertBox_R1Ot mk-callout coreConcept_aIdi mk-callout--key" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Que signifie exactement « octets gaspillés » ?</p><div class="content_gFLB"><p>Dive compte un fichier comme « gaspillé » lorsqu'il existe dans un layer inférieur mais est supprimé ou écrasé dans un layer supérieur. Les octets d'origine du layer inférieur sont toujours physiquement présents dans l'image — ils ne sont simplement plus visibles dans le container en cours d'exécution. Chaque <code>docker pull</code> les télécharge quand même.</p></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="fix-1--clean-up-the-apt-mess-in-the-same-layer">Correctif n°1 — nettoyer le bazar apt dans le même layer<a href="#fix-1--clean-up-the-apt-mess-in-the-same-layer" class="hash-link" aria-label="Lien direct vers Correctif n°1 — nettoyer le bazar apt dans le même layer" title="Lien direct vers Correctif n°1 — nettoyer le bazar apt dans le même layer" translate="no"></a></h2>
<p>La première erreur, et la plus fréquente : séparer <code>apt-get update</code> du nettoyage. La règle est simple — <strong>tout ce que vous voulez faire disparaître doit être supprimé dans la même instruction <code>RUN</code></strong>.</p>

<p>Le changement clé est l'ajout de <code>apt-get clean &amp;&amp; rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*</code> à la fin du bloc <code>RUN</code> d'installation apt. Cela s'exécute <em>dans le même layer</em>, donc les fichiers de cache ne sont jamais committés dans l'image.</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/myapp</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>$ docker build -t myapp<!-- -->:v2<!-- --> -f Dockerfile.v2 .</p><p>$ docker images myapp<!-- -->:v2</p><p>REPOSITORY   TAG    IMAGE ID       SIZE
myapp        v2     7a4e0f1c9d83   712MB</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>712 Mo au lieu de 1,19 Go. Près de 500 Mo envolés. Confirmons l'amélioration avec dive :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/myapp</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb">$ CI=true docker run --rm \
    -v /var/run/docker.sock:/var/run/docker.sock \
    wagoodman/dive:latest myapp:v2

  Using default CI config
Image Source: docker://myapp:v2
Fetching image... (this can take a while for large images)
Analyzing image...
  efficiency: 0.9823 (98.23%)
  wastedBytes: 8192 bytes (8.2 kB)
  userWastedPercent: 1.77%
Result:PASS [Total:3] [Passed:3] [Failed:0] [Warn:0] [Skipped:0]
</pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>98,23 % d'efficacité — un bond énorme. Mais on est encore à 712 Mo pour une minuscule app Flask. Il reste du travail.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="fix-2--fewer-layers-less-overhead">Correctif n°2 — moins de layers, moins de surcharge<a href="#fix-2--fewer-layers-less-overhead" class="hash-link" aria-label="Lien direct vers Correctif n°2 — moins de layers, moins de surcharge" title="Lien direct vers Correctif n°2 — moins de layers, moins de surcharge" translate="no"></a></h2>
<p>Regardez à nouveau la liste des layers de <code>Dockerfile.bad</code>. Chaque instruction <code>RUN</code> séparée crée son propre layer. Même si un layer n'ajoute « qu'un » répertoire ou quelques kilooctets, chaque layer a un coût en métadonnées — et surtout, séparer l'installation du nettoyage rend <em>impossible</em> de les garder dans le même layer.</p>
<p>La solution : regrouper les opérations liées dans une seule chaîne <code>RUN</code> avec <code>&amp;&amp;</code> :</p>

<p>Au-delà du nombre réduit de layers, remarquez les commandes <code>find</code> à la fin : elles suppriment tous les fichiers compilés <code>.pyc</code> et les répertoires <code>__pycache__</code> que pip crée pendant l'installation. Du gaspillage pur dans une image de production.</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/myapp</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>$ docker build -t myapp<!-- -->:v3<!-- --> -f Dockerfile.v3 .</p><p>$ docker images myapp</p><p>REPOSITORY   TAG    IMAGE ID       SIZE
myapp        bad    3f8c1a9e2b71   1.19GB
myapp        v2     7a4e0f1c9d83    712MB
myapp        v3     2b9d4e7f1c05    689MB</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>L'écart entre v2 et v3 est modeste ici — environ 23 Mo. Le vrai intérêt du regroupement des instructions <code>RUN</code>, c'est la clarté et la justesse : vous ne pouvez plus mettre accidentellement le nettoyage dans un autre layer.</p>
<div class="alertBox_R1Ot mk-callout important_O5eU mk-callout--danger" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Mais 689 Mo, c'est encore énorme pour une app Flask</p><div class="content_gFLB"><p>Nous avons toujours <code>build-essential</code>, <code>python3-dev</code>, <code>libssl-dev</code>, <code>pkg-config</code>, <code>wget</code> et <code>git</code> dans l'image de production. Ce sont des outils de build — ils étaient nécessaires pour compiler certains packages pip, mais ils n'ont rien à faire dans l'image qui tourne en production. C'est ici que l'approche mono-stage atteint sa limite.</p></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="fix-3--the-multi-stage-build">Correctif n°3 — le multi-stage build<a href="#fix-3--the-multi-stage-build" class="hash-link" aria-label="Lien direct vers Correctif n°3 — le multi-stage build" title="Lien direct vers Correctif n°3 — le multi-stage build" translate="no"></a></h2>
<p>L'idée centrale du multi-stage build est simple : un <code>FROM</code> pour construire, un autre <code>FROM</code> pour exécuter. Le stage builder peut être aussi gros qu'il veut — outils, caches, compilateurs — puisque seul le stage final est expédié.</p>

<p>Ce qui se passe ici :</p>
<ol>
<li class=""><code>FROM python:3.12 AS builder</code> — image Python complète, utilisée uniquement pour installer les packages</li>
<li class=""><code>pip install --target ./packages</code> — installe tout dans un répertoire local (pas à l'échelle du système)</li>
<li class=""><code>FROM python:3.12-slim AS production</code> — l'image de base slim, environ 130 Mo</li>
<li class=""><code>COPY --from=builder /build/packages ./packages</code> — seuls les packages installés franchissent la frontière ; pas d'outils de build, pas de cache pip, pas de listes apt</li>
</ol>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/myapp</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>$ docker build -t myapp<!-- -->:multistage<!-- --> -f Dockerfile.multistage .</p><p>$ docker images myapp</p><p>REPOSITORY   TAG          IMAGE ID       SIZE
myapp        bad          3f8c1a9e2b71   1.19GB
myapp        v2           7a4e0f1c9d83    712MB
myapp        v3           2b9d4e7f1c05    689MB
myapp        multistage   9c3a1f8e4b22    247MB</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>247 Mo — une réduction de 80 % par rapport au point de départ. Et le score dive :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/myapp</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb">$ CI=true docker run --rm \
    -v /var/run/docker.sock:/var/run/docker.sock \
    wagoodman/dive:latest myapp:multistage

  Using default CI config
Image Source: docker://myapp:multistage
Fetching image... (this can take a while for large images)
Analyzing image...
  efficiency: 0.9971 (99.71%)
  wastedBytes: 4096 bytes (4.1 kB)
  userWastedPercent: 0.29%
Result:PASS [Total:3] [Passed:3] [Failed:0] [Warn:0] [Skipped:0]
</pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>99,71 % d'efficacité. Les 4 Ko d'octets « gaspillés » restants sont du bruit de fond — des métadonnées de layer Docker inévitables.</p>
<div class="alertBox_R1Ot mk-callout tip_shCb mk-callout--tip" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Choisir la bonne base slim</p><div class="content_gFLB"><p><code>python:3.12-slim</code> est le bon compromis pour la plupart des apps Python. <code>python:3.12-alpine</code> est encore plus petite (environ 20 Mo) mais utilise <code>musl libc</code> au lieu de <code>glibc</code>, ce qui peut poser des problèmes de compatibilité avec les wheels binaires comme <code>numpy</code> ou <code>pandas</code>. Testez avant de vous engager sur Alpine pour une stack orientée données.</p></div></div></div>
<p>Les multi-stage builds sont aussi la bonne approche pour les langages compilés. Si vous construisez une app Go, Rust ou C, le stage builder contient toute la chaîne de compilation, et seul le binaire final atterrit dans le stage de production. Ce qui nous amène à la section bonus.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="bonus--from-scratch-the-absolute-minimum">Bonus — FROM scratch : le minimum absolu<a href="#bonus--from-scratch-the-absolute-minimum" class="hash-link" aria-label="Lien direct vers Bonus — FROM scratch : le minimum absolu" title="Lien direct vers Bonus — FROM scratch : le minimum absolu" translate="no"></a></h2>
<p><code>FROM scratch</code> est un mot-clé Docker spécial — ce n'est pas un nom d'image, c'est un signal indiquant au builder Docker que la nouvelle image doit démarrer complètement vide. Pas de shell, pas de gestionnaire de packages, pas de libc, rien. Juste les fichiers que vous y copiez explicitement avec <code>COPY</code>.</p>
<p>Cela ne fonctionne que si votre application est un binaire autonome sans aucune dépendance externe. Le Go compilé statiquement en est l'exemple parfait :</p>


<p>Les flags <code>-ldflags '-w -s'</code> retirent les informations de debug et la table des symboles, grignotant quelques Mo de plus sur le binaire.</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/myserver</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>$ docker build -t myserver<!-- -->:scratch<!-- --> -f Dockerfile.scratch .</p><p>$ docker images myserver<!-- -->:scratch</p><p>REPOSITORY   TAG       IMAGE ID       SIZE
myserver     scratch   1a2b3c4d5e6f    6.88MB</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>6,88 Mo. Moins de 7 Mo pour un serveur HTTP de production. Et dive ?</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/myserver</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb">$ CI=true docker run --rm \
    -v /var/run/docker.sock:/var/run/docker.sock \
    wagoodman/dive:latest myserver:scratch

  Using default CI config
Image Source: docker://myserver:scratch
Fetching image... (this can take a while for large images)
Analyzing image...
  efficiency: 1.0000 (100.00%)
  wastedBytes: 0 bytes (0 B)
  userWastedPercent: 0.00%
Result:PASS [Total:3] [Passed:3] [Failed:0] [Warn:0] [Skipped:0]
</pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>100,00 % d'efficacité. 0 octet gaspillé. Il n'y a littéralement plus rien à améliorer.</p>
<div class="alertBox_R1Ot mk-callout note_xvll mk-callout--note" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">FROM scratch n'est pas toujours pratique</p><div class="content_gFLB"><p>Une image scratch n'a aucun shell — <code>docker exec mycontainer sh</code> échouera. Pas de shell signifie pas de scripts shell dans votre entrypoint, pas de <code>ping</code>, pas de <code>curl</code> pour des health checks depuis l'intérieur du container. Pour le debug, envisagez plutôt <code>gcr.io/distroless/static</code> : presque aussi petite que scratch, mais elle inclut une poignée d'outils POSIX et est maintenue par Google pour les correctifs de sécurité.</p></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="dive-as-a-ci-quality-gate">Dive comme garde-fou qualité en CI<a href="#dive-as-a-ci-quality-gate" class="hash-link" aria-label="Lien direct vers Dive comme garde-fou qualité en CI" title="Lien direct vers Dive comme garde-fou qualité en CI" translate="no"></a></h2>
<p>Lancer dive à la main est utile pendant le développement, mais la vraie puissance est dans le mode <code>--ci</code> — il lit un fichier de configuration et renvoie un code de sortie non nul si votre image ne respecte pas les seuils. Branchez ça directement dans votre pipeline.</p>
<p>Créez un <code>.dive-ci.yaml</code> à la racine de votre projet :</p>

<p>Ajoutez ensuite une étape dans votre pipeline CI :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">CI pipeline step</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>$ DIVE_CONFIG=.dive-ci.yaml CI=true docker run --rm <br>
<!-- -->-v /var/run/docker.sock:/var/run/docker.sock <br>
<!-- -->-v $(pwd)/.dive-ci.yaml:/.dive-ci.yaml <br>
<!-- -->-e DIVE_CONFIG=/.dive-ci.yaml <br>
<!-- -->wagoodman/dive<!-- -->:latest<!-- --> myapp<!-- -->:latest</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Si l'efficacité de l'image tombe sous 95 % ou que les octets gaspillés dépassent 20 Mo, l'étape échoue et le déploiement n'a jamais lieu. Plutôt cool, non ?</p>
<div class="alertBox_R1Ot mk-callout tip_shCb mk-callout--tip" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Quels seuils ont du sens ?</p><div class="content_gFLB"><p><code>lowestEfficiency: 0.95</code> et <code>highestWastedBytes: "20mb"</code> sont des valeurs de départ raisonnables. Augmentez <code>highestWastedBytes</code> si votre image contient légitimement de gros assets statiques (polices, modèles, jeux de données) qu'on ne peut pas séparer proprement.</p></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="claude-code--automate-the-entire-loop">Claude Code — automatiser toute la boucle<a href="#claude-code--automate-the-entire-loop" class="hash-link" aria-label="Lien direct vers Claude Code — automatiser toute la boucle" title="Lien direct vers Claude Code — automatiser toute la boucle" translate="no"></a></h2>
<p>Tout ce que nous avons fait à la main — lancer dive, lire le JSON, repérer les layers gaspilleurs, décider quoi corriger, éditer le Dockerfile, reconstruire, relancer — est une boucle répétable. Si vous utilisez <a href="https://claude.ai/code" target="_blank" rel="noopener noreferrer">Claude Code</a>, vous pouvez piloter l'ensemble avec une seule commande slash :</p>
<div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">/docker-dive-optimization</span><br></div></code></pre></div></div>
<p>Elle détecte automatiquement le Dockerfile principal du projet (ou accepte un chemin ou un tag d'image en argument), construit l'image, exécute dive en mode JSON, et classe chaque constat dans trois catégories — qui correspondent directement aux patterns que nous avons appliqués à la main dans cet article.</p>
<p><strong>Catégorie A — corriger tout de suite</strong> : du gaspillage mécanique, indiscutablement erroné. Un <code>apt-get clean</code> placé dans un <code>RUN</code> distinct de l'installation, un <code>--no-cache</code> absent d'un <code>apk add</code>, un répertoire de cache non nettoyé dans le layer qui l'a créé. Claude applique la modification du Dockerfile, reconstruit, et relance dive pour vérifier le gain avant d'annoncer le succès.</p>
<p><strong>Catégorie B — proposer et attendre</strong> : les constats dont la bonne réponse demande un jugement. Passer de <code>python:3.12</code> à <code>python:3.12-slim</code> dans un stage de production. Introduire un multi-stage build là où il n'y en a pas. Remplacer un runtime de 300 Mo présent uniquement pour un seul appel CLI par un binaire autonome. Claude présente les preuves au niveau des layers et le compromis, puis attend votre décision avant de toucher à quoi que ce soit.</p>
<p><strong>Catégorie C — TODOs</strong> : tout ce qui nécessite des recherches avant d'y toucher. Consigné sous forme d'éléments numérotés dans <code>.todos/</code> pour ne rien perdre entre deux sessions.</p>
<div class="alertBox_R1Ot mk-callout tip_shCb mk-callout--tip" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Cibler n'importe quelle image ou n'importe quel Dockerfile</p><div class="content_gFLB"><p>La commande utilise par défaut le Dockerfile du projet, mais elle accepte directement un chemin ou un tag d'image :</p><div class="language-text codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-text codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">/docker-dive-optimization myapp:latest</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">/docker-dive-optimization ./services/api/Dockerfile</span><br></div></code></pre></div></div><p>Vous pouvez copier le fichier de commande dans le répertoire <code>.claude/commands/</code> de n'importe quel projet — il détecte automatiquement s'il faut utiliser <code>docker compose build</code> ou un simple <code>docker build</code>, donc aucun réglage spécifique au projet n'est nécessaire.</p></div></div></div>
<p>Cette répartition en trois catégories reflète les arbitrages de cet article. Nettoyer le cache apt dans le même layer, c'est la catégorie A — aucune discussion, on merge et on vérifie. Passer à un multi-stage build, c'est la catégorie B — de vrais compromis à examiner d'abord. Remplacer un binaire de niche que vous n'avez jamais audité, c'est la catégorie C — recherche avant d'y toucher. La même analyse que celle faite à la main, automatisée.</p>
<p>Pour la preuve : j'ai lancé <code>/docker-dive-optimization</code> sur le blog même que vous êtes en train de lire. L'image du devcontainer avait gonflé à <strong>2,5 Go</strong>, affichait 89,95 % d'efficacité et transportait 433 Mo d'octets gaspillés — hérités en grande partie d'une base lourde <code>mcr.microsoft.com/devcontainers/javascript-node:20-bookworm</code> qui embarquait silencieusement oh-my-zsh, subversion et une chaîne de compilation C/C++ complète dont personne n'avait besoin. La catégorie A a attrapé un <code>chown -R</code> redondant qui forçait l'overlay FS de Docker à copier 1 410 fichiers dans un layer inutile de 21 Mo. La catégorie B a signalé l'image de base et les 996 Mo de <code>node_modules</code> intégrés à chaque build.</p>
<p><img alt="résultat dive avant et après optimisation sur ce blog" src="/fr/assets/images/final_result-02b7b54932c5949e3be1ca63c76fbc40.png" height="auto" width="" loading="lazy" decoding="async" class=""></p>
<p><strong>De 2,5 Go à 735 Mo — une réduction de 70 %, avec l'efficacité passée de 89,95 % à 98,75 %.</strong> Le simple remplacement de l'image de base spécifique aux devcontainers par <code>node:20-bookworm-slim</code> a économisé plus d'un gigaoctet. Déplacer <code>node_modules</code> vers un volume Docker nommé à l'exécution a récupéré 996 Mo de plus. Toute la session — reconstruction et relance de dive pour vérifier comprises — a pris moins de quinze minutes.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="putting-it-all-together">Récapitulatif<a href="#putting-it-all-together" class="hash-link" aria-label="Lien direct vers Récapitulatif" title="Lien direct vers Récapitulatif" translate="no"></a></h2>
<p>Voici la progression que nous avons suivie :</p>
<div class="steps_wrapper_r_yO" data-variant="remember"><p class="steps_title_bD7c mk-eyebrow">Le parcours en quatre étapes</p><ul class="steps_list_Vtzi"><li class="step_item_FtAN"><span class="step_bullet_sanI mk-dot" aria-hidden="true">•</span><div class="step_content_KvQY"><span><strong>Départ (Dockerfile.bad) — 1,19 Go, 61 % d'efficacité</strong> : un <code>RUN apt-get update</code> séparé, aucun nettoyage de cache, des outils de build dans l'image finale.</span></div></li><li class="step_item_FtAN"><span class="step_bullet_sanI mk-dot" aria-hidden="true">•</span><div class="step_content_KvQY"><span><strong>v2 — 712 Mo, 98 % d'efficacité</strong> : nettoyer le cache et les listes <code>apt</code> dans la même instruction <code>RUN</code> qui les installe.</span></div></li><li class="step_item_FtAN"><span class="step_bullet_sanI mk-dot" aria-hidden="true">•</span><div class="step_content_KvQY"><span><strong>v3 — 689 Mo, 98 % d'efficacité</strong> : regrouper toutes les étapes d'installation dans une seule chaîne <code>RUN</code>, supprimer les fichiers <code>.pyc</code>.</span></div></li><li class="step_item_FtAN"><span class="step_bullet_sanI mk-dot" aria-hidden="true">•</span><div class="step_content_KvQY"><span><strong>Multi-stage — 247 Mo, 99,7 % d'efficacité</strong> : utiliser un stage builder séparé ; seuls les packages installés passent dans l'image de production slim.</span></div></li><li class="step_item_FtAN"><span class="step_bullet_sanI mk-dot" aria-hidden="true">•</span><div class="step_content_KvQY"><span><strong>Bonus (FROM scratch) — 6,88 Mo, 100 % d'efficacité</strong> : binaire compilé statiquement, aucune image de base.</span></div></li></ul></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="conclusion">Conclusion<a href="#conclusion" class="hash-link" aria-label="Lien direct vers Conclusion" title="Lien direct vers Conclusion" translate="no"></a></h2>
<p>Il existe une version de cette histoire où vous expédiez une image de 1,2 Go en production parce que ça marche — et vous ne vous retournez jamais. J'y suis passé. L'app tourne, l'équipe livre, personne ne se plaint. Jusqu'à ce que vous regardiez la facture de stockage de votre registry, ou votre temps de démarrage à froid sur une plateforme serverless, ou le temps de pull sur un runner CI avec un cache vide.</p>
<p><code>dive</code> rend l'invisible visible. Une fois que vous avez vu 438 Mo d'octets gaspillés surlignés en rouge, impossible de l'oublier. Les correctifs n'ont rien d'exotique : nettoyez dans le même layer, combinez vos instructions <code>RUN</code>, utilisez des multi-stage builds. Ce sont des choses que tout praticien de Docker devrait connaître par cœur, et vous avez maintenant un outil qui vous dira immédiatement si vous en avez oublié une.</p>
<p>Pour plus de contexte sur l'écriture de Dockerfiles dignes de la production, <a href="/fr/blog/docker-prod-devcontainer/">One Docker Image for Production and Devcontainers</a> couvre l'approche source unique de vérité où un seul Dockerfile gère proprement les deux environnements. Et si vous cherchez un autre outil Docker orienté terminal, il y a un article compagnon sur <a href="/fr/blog/lazydocker/">lazydocker</a> — un dashboard TUI complet pour gérer vos containers et vos logs sans quitter le terminal.</p></div><hr></div><div class="relatedPosts_zJiB"><h3>Articles liés</h3></div><div class="container_mNR1"><span class="question_WdqX">Cet article vous a-t-il été utile ?</span><div class="buttons_vgpi"><button class="btn_svQp" aria-label="Oui, cet article m'a été utile"><img src="/fr/img/reaction-mascot-yes.webp" alt="" class="mascot_Fait">Utile</button><button class="btn_svQp btnNeutral_Iqek" aria-label="Non, cet article ne m'a pas été utile"><img src="/fr/img/reaction-mascot-no.webp" alt="" class="mascot_Fait">Pas vraiment</button></div></div><div class="container_IJwe"><span class="question_nb7b">Avez-vous suivi les étapes de cet article ?</span><div class="buttons_Xkzw"><button class="btn_RMiq" aria-label="Oui, j'ai essayé et ça a marché"><img src="/fr/img/like.webp" alt="" class="mascot_wB1d">Ça a marché !</button><button class="btn_RMiq btnNeutral_z71B" aria-label="Non, ça n'a pas marché pour moi"><img src="/fr/img/dislike.webp" alt="" class="mascot_wB1d">Ça n'a pas marché</button></div></div>]]></content:encoded>
            <author>Christophe</author>
            <category>docker</category>
            <category>linux</category>
            <category>code-quality</category>
            <enclosure length="0" type="image/webp" url="https://www.avonture.be/fr/img/v2/docker_dive.webp"/>
        <dc:creator><![CDATA[Christophe]]></dc:creator></item>
        <item>
            <title><![CDATA[ai-translate & ai-summarize: Confidential Documents, Handled 100% Locally]]></title>
            <link>https://www.avonture.be/fr/fr/blog/ollama-ai-docs</link>
            <guid isPermaLink="false">/fr/blog/ollama-ai-docs</guid>
            <pubDate>Mon, 21 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[<img src="https://www.avonture.be/fr/img/v2/ai_doc.webp" alt="ai-translate & ai-summarize: Confidential Documents, Handled 100% Locally" style="display:block;max-width:100%;height:auto;">Two zsh functions that translate or summarize a Word, PDF, PowerPoint or Excel document without a single byte leaving your machine — Docling extracts the text locally, a local Ollama model does the translation or summary. Built for the document you'd never paste into a cloud translation tool.]]></description>
            <content:encoded><![CDATA[<div class="offer_jqr1"><span class="flag_Hnep" aria-hidden="true">🇬🇧</span><span>Cet article existe aussi en anglais —<!-- --> <a href="/blog/ollama-ai-docs/" hreflang="en">lire la version originale</a>.</span></div><div class="alertBox_R1Ot mk-callout caution_LuPi mk-callout--warn" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Traduction automatique</p><div class="content_gFLB">Cette traduction a été produite par une IA. Elle peut contenir des contresens ou des maladresses, en particulier sur les termes techniques. En cas de doute,<!-- --> <a href="/blog/ollama-ai-docs/">consultez l'article de référence en anglais</a> <!-- -->— c'est lui qui fait foi.</div></div></div><div><div class="seriesBlogPost_bjUd"><details class="details_rTsq"><summary class="seriesIntro_HIK1 mk-eyebrow">Cet article fait partie de la série <a href="/fr/series/ollama-daily-use/">Ollama au quotidien</a> :</summary><div class="content_OqVG"><ul><li><a href="/fr/blog/ollama-installation/">Installer Ollama et obtenir une IA locale</a></li><li><a href="/fr/blog/accessing-ollama-across-your-local-network/">Accéder à Ollama depuis votre réseau local</a></li><li><a href="/fr/blog/ollama-test-generator/">ai-test : générer les tests unitaires manquants depuis votre terminal avec un LLM local</a></li><li><a href="/fr/blog/ollama-git-precommit/">ai-review, ai-secrets, ai-commit : trois vérifications zsh avant chaque git commit</a></li><li><a href="/fr/blog/anythingllm-chat-with-your-docs/">AnythingLLM : discuter avec toute votre documentation éparpillée</a></li><li><a href="/fr/blog/ollama-ai-docs/">ai-translate &amp; ai-summarize : des documents confidentiels, traités 100 % en local</a></li><li><a href="/fr/blog/ollama-ai-diagram/">ai-diagram : d'une phrase (ou d'un compose.yaml) à un diagramme Mermaid</a></li><li><a href="/fr/blog/ollama-ai-data/">ai-data : laissez un LLM local écrire vos one-liners jq et awk</a></li><li><a href="/fr/blog/ollama-ai-explain/">ai-explain : l'ELI5 des scripts et des erreurs, directement dans votre terminal</a></li></ul></div></details></div><div><p><img alt="ai-translate &amp;amp; ai-summarize : des documents confidentiels, traités 100 % en local" src="/fr/assets/images/ai_doc-b6acdf8746e98d4c3df3db073f5308d6.webp" height="auto" width="" loading="lazy" decoding="async" class=""></p>
<div class="tldrContainer_qQW_ mk-callout mk-callout--key" role="region" aria-label="Résumé de l'article"><span role="img" aria-label="éclair" class="tldrIcon_SLBz mk-dot">⚡</span><div class="tldrBody_lGty"><strong class="tldrTitle_Aha8 mk-eyebrow">TL;DR</strong><div class="tldrContent_m27u"><p>Cet article ajoute <code>ai-translate</code> et <code>ai-summarize</code> à la série « Ollama daily use ». Les deux acceptent un fichier <code>.pdf</code>, <code>.docx</code>, <code>.pptx</code>, <code>.xlsx</code> ou <code>.html</code> — extrait en Markdown par <a class="" href="/fr/blog/docling/">Docling</a> — ou directement un simple fichier <code>.md</code>/<code>.txt</code>, puis passent le texte au modèle Ollama local pour le traduire ou le condenser en points clés. Tout l'aller-retour reste sur votre machine : pas d'API de traduction cloud, aucun upload de document nulle part. C'est précisément l'intérêt pour un contrat, un document RH, ou tout ce que vous hésiteriez à coller dans Google Translate.</p></div></div></div>
<p>Je travaille dans un bureau où un <code>.docx</code> atterrit dans ma boîte mail en néerlandais, en anglais ou en français. Pour de très bonnes raisons, j'ai parfois besoin de traduire d'une langue vers une autre.</p>
<p>Quand ce n'est pas confidentiel du tout, le réflexe de tout le monde est d'ouvrir le navigateur, de coller dans un outil de traduction, et d'espérer que personne ne s'offusque qu'un contrat client vienne de transiter par les serveurs d'un tiers. Personne ne m'a jamais explicitement dit de ne pas le faire. C'est exactement le genre de chose pour laquelle vous ne devriez pas avoir besoin d'une permission.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="demo--ai-translate">Démo — ai-translate<a href="#demo--ai-translate" class="hash-link" aria-label="Lien direct vers Démo — ai-translate" title="Lien direct vers Démo — ai-translate" translate="no"></a></h2>
<p>Traduction d'un contrat depuis n'importe quelle langue vers le français :</p>
<div class="terminal_qOWF" style="cursor:pointer" title="Cliquez pour passer l'animation"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><span class="terminal_ghost_aie1" aria-hidden="true">$ ai-translate contract.docx French

→ Converting 'contract.docx' with docling-convert...

## Note de livraison trimestrielle

*Périmètre, jalons et résiliation — diffusion interne uniquement*

### Article 1

À la suite de la revue trimestrielle tenue le 3 septembre, la présente note consacre le périmètre convenu, les jalons de livraison ainsi que les conditions de résiliation applicables à l'une ou l'autre des parties. Elle se substitue au projet diffusé en juillet. En cas de conflit entre une disposition des présentes et l'accord-cadre, c'est l'accord-cadre qui prévaut, sauf pour l'échéancier de paiement défini à l'article trois, qui est conçu pour le remplacer intégralement.

### Article 2

Le prestataire livrera la première tranche dans un délai de six semaines à compter de la signature, et chaque tranche subséquente à des intervalles n'excédant pas quatre semaines. Une tranche est réputée livrée dès qu'elle a été déployée dans l'environnement de staging et que les tests de réception consignés dans l'annexe B ont été validés. La livraison hors heures ouvrables ne réduit pas le délai de vérification, qui court pendant cinq jours ouvrables à compter de l'avis de livraison.

### Article 3

Les factures sont payables trente jours après réception. Le client peut retenir le paiement d'une tranche ayant échoué aux tests de réception, mais uniquement pour cette tranche et seulement jusqu'à la régularisation du défaut. Les intérêts de retard courent au taux légal. Aucune des parties ne peut compenser les sommes dues au titre des présentes avec celles dues au titre de tout autre accord sans le consentement écrit de l'autre partie.

### Article 4

Toute partie peut résilier le contrat sur préavis écrit de soixante jours. En cas de résiliation, le prestataire devra remettre tout avancement, y compris le code source, la configuration de build et la documentation, sous une forme permettant à un tiers compétent de poursuivre le travail. Le client devra payer les travaux achevés et acceptés jusqu'à la date de résiliation.

| Tranche   | Échéance   | Statut      |
| ------ | --- | --- |
| Première  | 6 semaines | Livrée      |
| Deuxième  | 10 semaines| En cours    |
</span><span class="terminal_overlay_fznj"><span class="cursor_o7aQ" aria-hidden="true">▋</span></span></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Et le même vers le néerlandais :</p>
<div class="terminal_qOWF" style="cursor:pointer" title="Cliquez pour passer l'animation"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><span class="terminal_ghost_aie1" aria-hidden="true">$ ai-translate contract.docx Dutch

→ Converting 'contract.docx' with docling-convert...

## Kwartaal Leveringsnota

*Reikwijdte, mijlpalen en beëindiging — uitsluitend voor intern gebruik*

### Artikel 1

Naar aanleiding van het kwartaaloverleg van 3 september houdt deze nota de overeengekomen reikwijdte, de leveringsmijlpalen en de voorwaarden vast onder welke elke partij kan opzeggen. Deze nota vervangt de in juli verspreide conceptversie. Indien een bepaling in deze nota strijdig is met de raamovereenkomst, heeft de raamovereenkomst voorrang, met uitzondering van de in afdeling drie genoemde betalingsregeling, die geheel ter vervanging daarvan is bedoeld.

### Artikel 2

De leverancier levert de eerste levering binnen zes weken na ondertekening, en elke daaropvolgende levering in intervallen van maximaal vier weken. Een levering wordt geacht geleverd te zijn wanneer deze is geïmplementeerd in de staging-omgeving en de acceptatietests in appendix B zijn geslaagd. Levering buiten werkuren verkort het acceptatievenster niet, dat vijf werkdagen duurt te rekenen vanaf de kennisgeving van levering.

### Artikel 3

Facturen zijn betaalbaar binnen dertig dagen na ontvangst. De opdrachtgever mag de betaling voor een levering die niet is geaccepteerd, inhouden, maar uitsluitend voor die specifieke levering en alleen totdat het gebrek is verholpen. Rentevergoeding bij late betaling loopt tegen het wettelijk rentepercentage. Geen van beide partijen mag vorderingen uit deze nota verrekenen met vorderingen uit enige andere overeenkomst zonder schriftelijke toestemming van de andere partij.

### Artikel 4

Elke partij kan opzeggen met een opzegtermijn van zestig dagen schriftelijk. Bij beëindiging draagt de leverancier alle werk in uitvoering, inclusief broncode, buildconfiguratie en documentatie, over in een vorm die door een kundige derde kan worden voortgezet. De opdrachtgever betaalt voor het tot de datum van beëindiging voltooide en geaccepteerde werk.

| Levering    | Te leveren | Status      |
| ------ | --- | --- |
| Eerste      | 6 weken  | Geleverd   |
| Tweede      | 10 weken | In uitvoering |
</span><span class="terminal_overlay_fznj"><span class="cursor_o7aQ" aria-hidden="true">▋</span></span></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>C'est un contrat de prestation fictif, converti depuis <code>.docx</code> et traduit en français/néerlandais, avec chaque titre et chaque terme en gras intact — le genre de document que je ne collerais vraiment pas dans un traducteur en ligne.</p>
<p>Même document, pour que vous puissiez l'essayer vous-même avant d'adapter l'une ou l'autre fonction à vos propres fichiers :</p>
<a href="/fr/assets/files/contract-1d7ea7544d75b3637d10bf9854043236.docx" download="contract-1d7ea7544d75b3637d10bf9854043236.docx" class="button button--primary downloadButton_m8jH" title="Télécharger le contrat de prestation fictif utilisé dans cette démo">contract.docx — le document source</a>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="demo--ai-summarize">Démo — ai-summarize<a href="#demo--ai-summarize" class="hash-link" aria-label="Lien direct vers Démo — ai-summarize" title="Lien direct vers Démo — ai-summarize" translate="no"></a></h2>
<p>Le même contrat, condensé en quatre points clés plutôt que traduit :</p>
<div class="terminal_qOWF" style="cursor:pointer" title="Cliquez pour passer l'animation"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><span class="terminal_ghost_aie1" aria-hidden="true">$ ai-summarize contract.docx 4

→ Converting 'contract.docx' with docling-convert...

- First increment delivery due within 6 weeks of signature; subsequent increments delivered every ≤4 weeks, requiring staging deployment and Appendix B test completion within a 5-working-day acceptance window.
- Invoices payable 30 days from receipt; payment withholding permitted only for failed increments until remedied, with late interest accruing at the statutory rate.
- Either party may terminate with 60 days written notice, mandating handover of all work-in-progress, source code, and documentation, plus immediate payment for completed and accepted work.
- Framework agreement prevails except for the payment schedule, which is entirely replaced; first increment is delivered, second increment is due at 10 weeks.
</span><span class="terminal_overlay_fznj"><span class="cursor_o7aQ" aria-hidden="true">▋</span></span></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Les délais de livraison, les conditions de paiement, la clause de résiliation et les parties du contrat-cadre qui sont remplacées — les quatre points qui comptent, sortis sans lire le document complet.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="reusing-docling-for-extraction">Réutiliser Docling pour l'extraction<a href="#reusing-docling-for-extraction" class="hash-link" aria-label="Lien direct vers Réutiliser Docling pour l'extraction" title="Lien direct vers Réutiliser Docling pour l'extraction" translate="no"></a></h2>
<p>Les deux fonctions ci-dessous partagent une tâche avant même de parler à Ollama : transformer un document en texte brut. J'ai déjà construit cette brique dans <a class="" href="/fr/blog/docling/">mon article sur Docling</a> — <code>docling-convert file.docx</code> produit <code>file.md</code> en local, avec accélération GPU si vous l'avez configuré ainsi. Plutôt que de dupliquer cette logique deux fois, elle vit dans un petit helper :</p>
<p>Pas besoin de copier quoi que ce soit pour l'instant : les fichiers <code>.zsh</code> montrés dans cet article sont là pour être lus, et <a href="#installing-it" class="">L'installer</a> à la fin les crée tous dans <code>~/.zsh/fns/</code> pour vous.</p>

<div class="alertBox_R1Ot mk-callout note_xvll mk-callout--note" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Les fichiers Markdown et texte sautent la conversion</p><div class="content_gFLB"><p>Si vous avez déjà un fichier <code>.md</code> ou <code>.txt</code>, <code>_ai_extract_text</code> fait juste un <code>cat</code> dessus — pas d'appel à Docling, pas de dépendance. L'étape de conversion ne se déclenche que pour les vrais formats bureautiques.</p></div></div></div>
<div class="alertBox_R1Ot mk-callout caution_LuPi mk-callout--warn" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Pourquoi le subshell autour de docling-convert</p><div class="content_gFLB"><p><code>docling-convert</code> (de l'article précédent) monte le <em>répertoire courant</em> dans son container et attend un nom de fichier relatif à celui-ci — il n'a pas été conçu pour résoudre un chemin absolu arbitraire. <code>( cd "$dir" &amp;&amp; docling-convert "$filename" )</code> l'exécute depuis le dossier du document, dans un subshell, de sorte que votre répertoire de travail réel reste intact au retour de la fonction.</p></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="ai-translate">ai-translate<a href="#ai-translate" class="hash-link" aria-label="Lien direct vers ai-translate" title="Lien direct vers ai-translate" translate="no"></a></h2>

<p>On extrait, puis on demande au modèle de traduire vers la langue passée en second argument (le français par défaut — celle dont j'ai le plus souvent besoin), avec une instruction explicite de préserver la structure Markdown pour que les titres, listes et tableaux survivent à l'aller-retour au lieu de s'écrouler en un pavé de texte.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="ai-summarize">ai-summarize<a href="#ai-summarize" class="hash-link" aria-label="Lien direct vers ai-summarize" title="Lien direct vers ai-summarize" translate="no"></a></h2>

<p>Même étape d'extraction, prompt différent : un nombre fixe de points clés (5 par défaut), avec la consigne explicite de privilégier les décisions, les chiffres, les échéances et les actions à mener plutôt qu'une description générique — et de répondre dans la langue du document source, plutôt que de basculer silencieusement en anglais.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="registered-in-the-ai-menu">Enregistrées dans le menu ai<a href="#registered-in-the-ai-menu" class="hash-link" aria-label="Lien direct vers Enregistrées dans le menu ai" title="Lien direct vers Enregistrées dans le menu ai" translate="no"></a></h2>
<p>Deux lignes — <code>AI_COMMANDS[translate]=...</code> et <code>AI_COMMANDS[summarize]=...</code> — et les deux sont accessibles via <code>ai translate</code> / <code>ai summarize</code>, ou directement via <code>ai-translate</code> / <code>ai-summarize</code>, aux côtés de toutes les autres fonctions de cette série.</p>
<p>Les deux fonctions appellent <code>_ollama_query</code> et s'enregistrent dans <code>AI_COMMANDS</code>, qui vivent dans la base partagée <code>_ollama.zsh</code> introduite dans <a href="/fr/blog/ollama-test-generator/">ai-test</a>. Si vous avez déjà suivi un article précédent de cette série, elle est déjà dans <code>~/.zsh/fns/</code> et sourcée par votre autoloader — rien de plus à faire. Vous commencez ici ? C'est l'un des fichiers créés dans <a href="#installing-it" class="">L'installer</a> ci-dessous.</p>
<div class="alertBox_R1Ot mk-callout tip_shCb mk-callout--tip" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Docling et Ollama ne doivent pas être sur la même machine</p><div class="content_gFLB"><p><code>docling-convert</code> tourne toujours en local, sans GPU requis — convertir un document en Markdown fonctionne sur n'importe quel portable. Traduire ou résumer nécessite par contre un serveur Ollama joignable quelque part : les deux fonctions se connectent à <code>OLLAMA_HOST</code>, même configuration que dans <a href="/fr/blog/accessing-ollama-across-your-local-network/">accéder à Ollama depuis votre réseau local</a> — un LAN partagé, ou un VPN/tunnel entre deux réseaux distincts.</p></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="key-takeaways">À retenir<a href="#key-takeaways" class="hash-link" aria-label="Lien direct vers À retenir" title="Lien direct vers À retenir" translate="no"></a></h2>
<div class="steps_wrapper_r_yO" data-variant="remember"><p class="steps_title_bD7c mk-eyebrow">ai-translate / ai-summarize en bref</p><ul class="steps_list_Vtzi"><li class="step_item_FtAN"><span class="step_bullet_sanI mk-dot" aria-hidden="true">•</span><div class="step_content_KvQY"><span><strong>Extraction partagée</strong> — <code>_ai_extract_text</code> dans <code>_ai-docs.zsh</code>, réutilisée par les deux fonctions</span></div></li><li class="step_item_FtAN"><span class="step_bullet_sanI mk-dot" aria-hidden="true">•</span><div class="step_content_KvQY"><span><strong>Docling pour les formats bureautiques</strong> — <code>.pdf</code>/<code>.docx</code>/<code>.pptx</code>/<code>.xlsx</code>/<code>.html</code> passent d'abord par <code>docling-convert</code></span></div></li><li class="step_item_FtAN"><span class="step_bullet_sanI mk-dot" aria-hidden="true">•</span><div class="step_content_KvQY"><span><strong>Markdown/texte sautent la conversion</strong> — les fichiers <code>.md</code>/<code>.txt</code> sont lus directement</span></div></li><li class="step_item_FtAN"><span class="step_bullet_sanI mk-dot" aria-hidden="true">•</span><div class="step_content_KvQY"><span><strong>Rien ne quitte la machine</strong> — extraction et traduction/résumé tournent en local, de bout en bout</span></div></li><li class="step_item_FtAN"><span class="step_bullet_sanI mk-dot" aria-hidden="true">•</span><div class="step_content_KvQY"><span><strong>S'enregistrent dans <code>ai</code></strong> — accessibles via <code>ai translate</code> et <code>ai summarize</code></span></div></li></ul></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="installing-it">L'installer<a href="#installing-it" class="hash-link" aria-label="Lien direct vers L'installer" title="Lien direct vers L'installer" translate="no"></a></h2>
<p>Tout tourne sur votre machine : zsh, <code>curl</code> et <code>jq</code> (qui construit la requête envoyée à Ollama), et un serveur Ollama que vous pouvez joindre — voyez <a href="/fr/blog/ollama-installation/">installing Ollama</a> si vous n'en avez pas encore. Pour les fichiers <code>.pdf</code>, <code>.docx</code>, <code>.pptx</code>, <code>.xlsx</code> et <code>.html</code>, il vous faut aussi <code>docling-convert</code>, issu de <a href="/fr/blog/docling/">mon article sur Docling</a>.</p>
<p><strong>1. Créez les fichiers.</strong> Chaque fonction de cette série vit dans son propre fichier dans <code>~/.zsh/fns/</code>. La commande ci-dessous crée ce dossier et les quatre fichiers d'un coup — ou téléchargez le ZIP, ou créez chaque fichier à la main et collez son contenu. <code>_ollama.zsh</code> est la base que toute la série partage : si un article précédent vous l'a déjà donnée, la copie ici est le même fichier.</p>
<div class="projectSetupContainer_LW1i mk-surface"><div class="header_Htsx mk-toolbar"><strong>📦 <!-- -->Mise en place du projet :<!-- --> <!-- -->~/.zsh/fns</strong><div class="buttonGroup_y07f mk-toolbar__actions"><a class="button button--secondary button--sm" href="/project_setup" target="_blank">Aide</a><button class="button button--secondary button--sm">Tout déplier</button><button class="button button--outline button--primary button--sm" aria-label="Télécharger le projet en ZIP">ZIP</button><button class="button button--secondary button--sm">Générer le script d'installation</button></div></div><div class="content_bo8S"><div><p class="tip_qyDH"><small><span aria-hidden="true">💡</span> <!-- -->Besoin d'aide ? Cliquez sur le bouton « Aide » ci-dessus pour les instructions de mise en place.</small></p></div></div></div>
<p><strong>2. Chargez le dossier depuis <code>~/.zshrc</code>.</strong> Déjà fait si vous avez suivi un article précédent de cette série. Sinon, ajoutez ces lignes à la fin de <code>~/.zshrc</code> — elles sourcent chaque fichier <code>.zsh</code> du dossier, donc la prochaine fonction que vous ajoutez ne demandera aucune modification ici :</p>
<div class="language-zsh codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockTitle_OeMC">~/.zshrc</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-zsh codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">for fn_file in ~/.zsh/fns/*.zsh; do</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  source "$fn_file"</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">done</span><br></div></code></pre></div></div>
<p><strong>3. Ouvrez un nouveau terminal</strong> (ou lancez <code>exec zsh</code> dans celui en cours), et vérifiez que le shell connaît les nouvelles fonctions :</p>
<div class="language-zsh codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-zsh codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">type ai-translate ai-summarize</span><br></div></code></pre></div></div>
<p><code>ai-translate is a shell function from …/ai-translate.zsh</code> signifie que vous êtes prêt. <code>ai-translate not found</code> signifie que l'étape 2 manque.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="conclusion">Conclusion<a href="#conclusion" class="hash-link" aria-label="Lien direct vers Conclusion" title="Lien direct vers Conclusion" translate="no"></a></h2>
<p>La vraie histoire de confidentialité ici n'est ni une politique ni une promesse — c'est une architecture. Un document entre, Docling le transforme en texte sans appel réseau, Ollama traite ce texte sans appel réseau, et le résultat atterrit dans mon terminal. Il n'y a aucune étape de cette chaîne où « faire confiance au fournisseur » est nécessaire, et c'est exactement ce qui la rend utilisable pour les documents que je n'aurais jamais risqués sur un outil cloud. Entre ça, <code>ai-ci</code> pour les pipelines, <code>ai-standup</code> pour le récap du matin et le reste de cette série, le modèle local que j'avais déjà en route pour d'autres raisons trouve sans cesse une chose de plus à faire, discrètement, en arrière-plan, sans rien me demander d'autre qu'un chemin de fichier.</p></div><hr><div class="seriesBlogPost_bjUd"><details class="details_rTsq"><summary class="seriesIntro_HIK1 mk-eyebrow">Cet article fait partie de la série <a href="/fr/series/ollama-daily-use/">Ollama au quotidien</a> :</summary><div class="content_OqVG"><ul><li><a href="/fr/blog/ollama-installation/">Installer Ollama et obtenir une IA locale</a></li><li><a href="/fr/blog/accessing-ollama-across-your-local-network/">Accéder à Ollama depuis votre réseau local</a></li><li><a href="/fr/blog/ollama-test-generator/">ai-test : générer les tests unitaires manquants depuis votre terminal avec un LLM local</a></li><li><a href="/fr/blog/ollama-git-precommit/">ai-review, ai-secrets, ai-commit : trois vérifications zsh avant chaque git commit</a></li><li><a href="/fr/blog/anythingllm-chat-with-your-docs/">AnythingLLM : discuter avec toute votre documentation éparpillée</a></li><li><a href="/fr/blog/ollama-ai-docs/">ai-translate &amp; ai-summarize : des documents confidentiels, traités 100 % en local</a></li><li><a href="/fr/blog/ollama-ai-diagram/">ai-diagram : d'une phrase (ou d'un compose.yaml) à un diagramme Mermaid</a></li><li><a href="/fr/blog/ollama-ai-data/">ai-data : laissez un LLM local écrire vos one-liners jq et awk</a></li><li><a href="/fr/blog/ollama-ai-explain/">ai-explain : l'ELI5 des scripts et des erreurs, directement dans votre terminal</a></li></ul></div></details></div></div><div class="relatedPosts_zJiB"><h3>Articles liés</h3></div><div class="container_mNR1"><span class="question_WdqX">Cet article vous a-t-il été utile ?</span><div class="buttons_vgpi"><button class="btn_svQp" aria-label="Oui, cet article m'a été utile"><img src="/fr/img/reaction-mascot-yes.webp" alt="" class="mascot_Fait">Utile</button><button class="btn_svQp btnNeutral_Iqek" aria-label="Non, cet article ne m'a pas été utile"><img src="/fr/img/reaction-mascot-no.webp" alt="" class="mascot_Fait">Pas vraiment</button></div></div><div class="container_IJwe"><span class="question_nb7b">Avez-vous suivi les étapes de cet article ?</span><div class="buttons_Xkzw"><button class="btn_RMiq" aria-label="Oui, j'ai essayé et ça a marché"><img src="/fr/img/like.webp" alt="" class="mascot_wB1d">Ça a marché !</button><button class="btn_RMiq btnNeutral_z71B" aria-label="Non, ça n'a pas marché pour moi"><img src="/fr/img/dislike.webp" alt="" class="mascot_wB1d">Ça n'a pas marché</button></div></div>]]></content:encoded>
            <author>Christophe</author>
            <author>Claude Code</author>
            <category>ai</category>
            <category>ollama</category>
            <category>zsh</category>
            <category>docker</category>
            <enclosure length="0" type="image/webp" url="https://www.avonture.be/fr/img/v2/ai_doc.webp"/>
        <dc:creator><![CDATA[Christophe]]></dc:creator><dc:creator><![CDATA[Claude Code]]></dc:creator></item>
        <item>
            <title><![CDATA[Docling - Convert PDF, Word, PowerPoint, Excel and HTML to Markdown, GPU-Accelerated]]></title>
            <link>https://www.avonture.be/fr/fr/blog/docling</link>
            <guid isPermaLink="false">/fr/blog/docling</guid>
            <pubDate>Thu, 17 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[<img src="https://www.avonture.be/fr/img/v2/docling.webp" alt="Docling - Convert PDF, Word, PowerPoint, Excel and HTML to Markdown, GPU-Accelerated" style="display:block;max-width:100%;height:auto;">A Docker batteries-included setup for Docling, IBM's document-conversion library: PDF, Word, PowerPoint, Excel and HTML files converted into clean Markdown, with GPU acceleration for machines with spare VRAM. A companion to my Markitdown article — same idea, a heavier engine underneath.]]></description>
            <content:encoded><![CDATA[<div class="offer_jqr1"><span class="flag_Hnep" aria-hidden="true">🇬🇧</span><span>Cet article existe aussi en anglais —<!-- --> <a href="/blog/docling/" hreflang="en">lire la version originale</a>.</span></div><div class="alertBox_R1Ot mk-callout caution_LuPi mk-callout--warn" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Traduction automatique</p><div class="content_gFLB">Cette traduction a été produite par une IA. Elle peut contenir des contresens ou des maladresses, en particulier sur les termes techniques. En cas de doute,<!-- --> <a href="/blog/docling/">consultez l'article de référence en anglais</a> <!-- -->— c'est lui qui fait foi.</div></div></div><div><div class="revisionHistoryContainer_DGTV mk-surface margin-top--lg"><h3 class="revisionHistoryTitle_eX7C mk-eyebrow"><span aria-hidden="true">📜</span> <!-- -->Journal des modifications</h3><div class="timeline_jZcr"><div class="timelineItem_LQdV timelineItemLatest_AzU9"><div class="timelineIcon_qAPq mk-dot"><span aria-hidden="true">✨</span></div><div class="timelineContent_eAHl"><div class="timelineDate_GzO4"><time datetime="2026-11-08T00:00:00.000Z">8 novembre 2026</time><span class="latestBadge_s2Ev mk-pill">Dernière</span></div><div class="timelineDescription_SGKc">docling-convert is now a shell function for zsh and bash (~/.zsh/fns or ~/.bash/fns); the Dockerfile and the conversion script live in ~/tools/docling.</div></div></div></div></div><div><p><img alt="Docling - Convertir PDF, Word, PowerPoint, Excel et HTML en Markdown, accéléré par GPU" src="/fr/assets/images/docling-a77e6424d86ebcbc3a7dd835831886f7.webp" height="auto" width="" loading="lazy" decoding="async" class=""></p>
<div class="tldrContainer_qQW_ mk-callout mk-callout--key" role="region" aria-label="Résumé de l'article"><span role="img" aria-label="éclair" class="tldrIcon_SLBz mk-dot">⚡</span><div class="tldrBody_lGty"><strong class="tldrTitle_Aha8 mk-eyebrow">TL;DR</strong><div class="tldrContent_m27u"><p>Cet article fait pour <a href="https://github.com/docling-project/docling" target="_blank" rel="noopener noreferrer" class="">Docling</a> exactement ce que <a href="/fr/blog/markitdown/">mon article sur Markitdown</a> faisait pour Markitdown : une image Docker batteries-included et une fonction shell <code>docling-convert</code> pour zsh ou bash, afin que convertir un document en Markdown propre tienne en une commande depuis n'importe quel dossier. <strong>La différence est sous le capot — Docling utilise des modèles dédiés de layout, de structure de tableaux et d'OCR plutôt que des parsers spécifiques à chaque format, tourne sur GPU si vous en avez un, et est conçu dès le départ exactement pour le scénario « document sensible, qui doit rester en local » auquel ce blog revient sans cesse.</strong></p></div></div></div><nav class="mobileQuickLinks_La_l" aria-label="Articles liés"><p class="title_rsOi mk-eyebrow">Sur le même sujet</p><ul class="list_CXCl"><li class="item_grzi"><a href="/fr/blog/docling/">Docling - Convertir PDF, Word, PowerPoint, Excel et HTML en Markdown, accéléré par GPU</a></li><li class="item_grzi"><a href="/fr/blog/docusaurus-copy-as-markdown/">Ajouter un bouton « Copy as Markdown » à votre blog Docusaurus</a></li><li class="item_grzi"><a href="/fr/blog/docusaurus-llms-txt/">Votre blog est illisible pour une IA. Voici la solution.</a></li></ul></nav>
<p>Un ami qui lisait <a href="/fr/blog/markitdown/">mon article sur Markitdown</a> m'a suggéré d'essayer <a href="https://docling.ai/" target="_blank" rel="noopener noreferrer" class="">Docling</a> à la place — projet open source, du côté d'IBM cette fois, même promesse « convertir des documents bureautiques en Markdown », mais construit autour de vrais modèles de compréhension de mise en page plutôt que de parsers par format. J'ai 24 Go de VRAM qui dorment sur mon serveur IA ; si un outil peut les mettre au travail pour transformer un PDF mal scanné en quelque chose de lisible, ça vaut dix minutes de test.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="converting-five-formats">Convertir cinq formats<a href="#converting-five-formats" class="hash-link" aria-label="Lien direct vers Convertir cinq formats" title="Lien direct vers Convertir cinq formats" translate="no"></a></h2>
<p>Une fois l'image et la fonction <code>docling-convert</code> en place (on voit ça plus bas), convertir un document tient en une commande :</p>
<div class="terminal_qOWF" style="cursor:pointer" title="Cliquez pour passer l'animation"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><span class="terminal_ghost_aie1" aria-hidden="true">$ cd ~/documents/

$ docling-convert report.pdf
docling-convert: report.md written in 14s
💡 Not happy with the result? Run it again with --pipeline standard (faster, rougher on titles), --image-export-mode placeholder|embedded (drop the images, or inline them), -v (the full log).
💡 Hide this hint with DOCLING_CONVERT_NO_TIP=1.

$ docling-convert contract.docx
docling-convert: contract.md written in 4s

$ docling-convert slides.pptx
docling-convert: slides.md written in 5s

$ docling-convert budget.xlsx
docling-convert: budget.md written in 4s

$ docling-convert page.html
docling-convert: page.md written in 4s

$ ls *.md

budget.md  contract.md  page.md  report.md  slides.md
</span><span class="terminal_overlay_fznj"><span class="cursor_o7aQ" aria-hidden="true">▋</span></span></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>PDF, DOCX, PPTX, XLSX et HTML, cinq appels distincts à <code>docling-convert</code>, cinq fichiers <code>.md</code> propres posés à côté de leurs originaux — pas d'export-vers-Markdown manuel dans Word ou PowerPoint, pas de convertisseur en ligne à qui confier le contenu.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="what-docling-does-differently">Ce que Docling fait différemment<a href="#what-docling-does-differently" class="hash-link" aria-label="Lien direct vers Ce que Docling fait différemment" title="Lien direct vers Ce que Docling fait différemment" translate="no"></a></h2>
<div class="alertBox_R1Ot mk-callout info_J1rX mk-callout--info" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Même sortie, un moteur très différent</p><div class="content_gFLB"><p><strong>Pour tous les formats de cet article sauf le PDF</strong> — <code>.docx</code>, <code>.xlsx</code>, <code>.pptx</code> et <code>.html</code> — Docling fait ce que fait tout autre convertisseur : il lit le fichier avec une bibliothèque Python dédiée au format et parcourt le résultat. Rien d'exotique, aucun modèle chargé, et <strong>rien qui nécessite un GPU</strong> — <code>--pipeline vlm</code> est simplement ignoré sur ces quatre-là.</p><p>C'est sur le chemin PDF que ça diverge. Un PDF n'a aucune structure à parcourir : c'est un sac de glyphes à des coordonnées, et chaque convertisseur doit <em>deviner</em> où se trouvaient les paragraphes, les titres et les tableaux. Docling le déduit avec des modèles — un pour la mise en page, un pour la structure des tableaux, un pour l'OCR, et en option un modèle vision-langage qui lit la page rendue comme le ferait un humain. C'est ce dernier qui justifie la présence d'un GPU dans cet article.</p></div></div></div>
<p>Comme toujours, je vais d'abord construire une image Docker — pas de Python, pas de <code>pip</code>, rien d'installé globalement sur ma machine.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="installation">Installation<a href="#installation" class="hash-link" aria-label="Lien direct vers Installation" title="Lien direct vers Installation" translate="no"></a></h2>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="create-our-docker-image">Créer notre image Docker<a href="#create-our-docker-image" class="hash-link" aria-label="Lien direct vers Créer notre image Docker" title="Lien direct vers Créer notre image Docker" translate="no"></a></h3>
<p>Deux fichiers obtiennent un emplacement permanent, <code>~/tools/docling</code>, pour que l'image puisse être reconstruite à tout moment (nouvelle machine, <code>docker image prune</code>, nouvelle version de Docling) : le <code>Dockerfile</code>, et <code>docling-convert.sh</code>, le script qui pilote une conversion (détaillé plus bas) :</p>
<div class="projectSetupContainer_LW1i mk-surface"><div class="header_Htsx mk-toolbar"><strong>📦 <!-- -->Mise en place du projet :<!-- --> <!-- -->~/tools/docling</strong><div class="buttonGroup_y07f mk-toolbar__actions"><a class="button button--secondary button--sm" href="/project_setup" target="_blank">Aide</a><button class="button button--secondary button--sm">Tout déplier</button><button class="button button--outline button--primary button--sm" aria-label="Télécharger le projet en ZIP">ZIP</button><button class="button button--secondary button--sm">Générer le script d'installation</button></div></div><div class="content_bo8S"><div><p class="tip_qyDH"><small><span aria-hidden="true">💡</span> <!-- -->Besoin d'aide ? Cliquez sur le bouton « Aide » ci-dessus pour les instructions de mise en place.</small></p></div></div></div>
<p><code>ARG DOCLING_VERSION</code> fige la release exacte au lieu de laisser <code>pip</code> attraper la plus récente au moment du build — Docling sort une nouvelle version tous les quelques jours, et un build non figé donne une image différente à chaque reconstruction. Montez cet <code>ARG</code> quand vous voulez réellement la plus récente.</p>
<div class="alertBox_R1Ot mk-callout note_xvll mk-callout--note" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Un nom de package, toute une liste de courses derrière</p><div class="content_gFLB"><p>Depuis le printemps 2026, <code>docling</code> sur PyPI est un metapackage léger : l'installer tire <code>docling-slim[standard]</code>, et c'est <em>ça</em> qui amène les backends PDF, DOCX, PPTX, XLSX et HTML, PyTorch et les modèles de layout. Donc un simple <code>pip install docling</code> couvre toujours les cinq formats dont parle cet article, sans extras à retenir — mais c'est aussi pour ça que l'image est lourde. Si vous ne convertissez jamais que du <code>.docx</code>, par exemple, <code>pip install "docling-slim[cli,format-docx,models-local]"</code> vous donne une image beaucoup plus petite ; la liste complète des extras est sur <a href="https://pypi.org/project/docling-slim/" target="_blank" rel="noopener noreferrer" class="">la page PyPI du projet</a>.</p></div></div></div>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="build-the-image">Construire l'image<a href="#build-the-image" class="hash-link" aria-label="Lien direct vers Construire l'image" title="Lien direct vers Construire l'image" translate="no"></a></h3>
<p>Pas de <code>compose.yaml</code> ici : les conversions passent par une fonction shell qui monte le répertoire dans lequel vous vous trouvez, ce qu'un fichier Compose posé dans un dossier fixe ne peut pas faire. Un simple build suffit donc :</p>
<div class="terminal_qOWF" style="cursor:pointer" title="Cliquez pour passer l'animation"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><span class="terminal_ghost_aie1" aria-hidden="true"><p>$ docker build -t docling ~/tools/docling</p></span><span class="terminal_overlay_fznj"><span class="cursor_o7aQ" aria-hidden="true">▋</span></span></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Attendez-vous à ce que celui-ci soit nettement plus long que celui de Markitdown : PyTorch, ses bibliothèques CUDA embarquées et les quatre modèles intégrés montent à une image de 8,3 Go ici. Ces modèles sont la partie qui mérite l'attention — ils sont téléchargés une seule fois, au build, et ils vivent <em>dans</em> l'image, donc un container <code>--rm</code> démarre avec tout ce qu'il lui faut et ne télécharge rien.</p>
<p>Optionnellement, si vous voulez vérifier que l'image est correcte, lancez simplement <code>docker run --rm docling --help</code> — <a href="https://docling-project.github.io/docling/" target="_blank" rel="noopener noreferrer" class="">la documentation</a> couvre chaque flag listé.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="make-docling-convert-available-everywhere">Rendre <code>docling-convert</code> disponible partout<a href="#make-docling-convert-available-everywhere" class="hash-link" aria-label="Lien direct vers make-docling-convert-available-everywhere" title="Lien direct vers make-docling-convert-available-everywhere" translate="no"></a></h3>
<p>Même principe que <a href="/fr/blog/markitdown/"><code>md-convert</code></a>, adapté à la forme réelle de la CLI de Docling. Le vrai travail est fait par <code>~/tools/docling/docling-convert.sh</code>, le script que vous venez de créer à côté du Dockerfile :</p>

<p>Il reste un script bash volontairement : votre shell ne charge qu'une courte fonction qui vérifie que le script et l'image sont là, puis lance le script avec <code>bash</code>, que votre propre shell soit zsh ou bash :</p>

<div class="theme-tabs-container tabs-container tabList__CuJ"><ul role="tablist" aria-orientation="horizontal" class="tabs"><li role="tab" tabindex="0" aria-selected="true" class="tabs__item tabItem_LNqP tabs__item--active">zsh</li><li role="tab" tabindex="-1" aria-selected="false" class="tabs__item tabItem_LNqP">bash</li></ul><div class="margin-top--md"><div role="tabpanel" class="tabItem_Ymn6"><p><strong>1. Créez le fichier</strong> dans <code>~/.zsh/fns/</code> :</p><div class="projectSetupContainer_LW1i mk-surface"><div class="header_Htsx mk-toolbar"><strong>📦 <!-- -->Mise en place du projet :<!-- --> <!-- -->~/.zsh/fns</strong><div class="buttonGroup_y07f mk-toolbar__actions"><a class="button button--secondary button--sm" href="/project_setup" target="_blank">Aide</a><button class="button button--secondary button--sm">Tout déplier</button><button class="button button--outline button--primary button--sm" aria-label="Télécharger le projet en ZIP">ZIP</button><button class="button button--secondary button--sm">Générer le script d'installation</button></div></div><div class="content_bo8S"><div><p class="tip_qyDH"><small><span aria-hidden="true">💡</span> <!-- -->Besoin d'aide ? Cliquez sur le bouton « Aide » ci-dessus pour les instructions de mise en place.</small></p></div></div></div><p><strong>2. Chargez le dossier depuis <code>~/.zshrc</code>.</strong> Déjà fait si vous avez suivi un de mes articles « Ollama au quotidien ». Sinon, ajoutez à la fin de <code>~/.zshrc</code> :</p><div class="language-zsh codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockTitle_OeMC">~/.zshrc</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-zsh codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">for fn_file in ~/.zsh/fns/*.zsh; do</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  source "$fn_file"</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">done</span><br></div></code></pre></div></div><p><strong>3. Ouvrez un nouveau terminal</strong> (ou lancez <code>exec zsh</code>).</p></div><div role="tabpanel" class="tabItem_Ymn6" hidden=""><p><strong>1. Créez le fichier</strong> dans <code>~/.bash/fns/</code> :</p><div class="projectSetupContainer_LW1i mk-surface"><div class="header_Htsx mk-toolbar"><strong>📦 <!-- -->Mise en place du projet :<!-- --> <!-- -->~/.bash/fns</strong><div class="buttonGroup_y07f mk-toolbar__actions"><a class="button button--secondary button--sm" href="/project_setup" target="_blank">Aide</a><button class="button button--secondary button--sm">Tout déplier</button><button class="button button--outline button--primary button--sm" aria-label="Télécharger le projet en ZIP">ZIP</button><button class="button button--secondary button--sm">Générer le script d'installation</button></div></div><div class="content_bo8S"><div><p class="tip_qyDH"><small><span aria-hidden="true">💡</span> <!-- -->Besoin d'aide ? Cliquez sur le bouton « Aide » ci-dessus pour les instructions de mise en place.</small></p></div></div></div><p><strong>2. Chargez le dossier depuis <code>~/.bashrc</code>.</strong> Ajoutez à la fin de <code>~/.bashrc</code> (le test <code>-r</code> évite qu'un dossier vide n'affiche une erreur) :</p><div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockTitle_OeMC">~/.bashrc</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token keyword" style="color:rgb(86, 156, 214)">for</span><span class="token plain"> </span><span class="token for-or-select variable" style="color:rgb(156, 220, 254)">fn_file</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">in</span><span class="token plain"> ~/.bash/fns/*.sh</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">do</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token plain"> </span><span class="token parameter variable" style="color:rgb(156, 220, 254)">-r</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"</span><span class="token string variable" style="color:rgb(156, 220, 254)">$fn_file</span><span class="token string" style="color:rgb(206, 145, 120)">"</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">]</span><span class="token punctuation" style="color:rgb(212, 212, 212)">]</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">&amp;&amp;</span><span class="token plain"> </span><span class="token builtin class-name" style="color:rgb(78, 201, 176)">source</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"</span><span class="token string variable" style="color:rgb(156, 220, 254)">$fn_file</span><span class="token string" style="color:rgb(206, 145, 120)">"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token keyword" style="color:rgb(86, 156, 214)">done</span><br></div></code></pre></div></div><p><strong>3. Ouvrez un nouveau terminal</strong> (ou lancez <code>exec bash</code>).</p></div></div></div>
<p>Vérifiez que le shell connaît la fonction : <code>type docling-convert</code> doit répondre <code>docling-convert is a shell function</code>.</p>
<p>Si l'image ou le script manque, <code>docling-convert</code> affiche la commande <code>docker build</code> exacte à lancer, ou renvoie vers cet article quand <code>~/tools/docling</code> a disparu aussi.</p>
<div class="alertBox_R1Ot mk-callout note_xvll mk-callout--note" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Vous venez de la version précédente de cet article ?</p><div class="content_gFLB"><p>Elle installait <code>docling-convert</code> comme script dans <code>/usr/local/bin/</code>. Supprimez-le (<code>sudo rm /usr/local/bin/docling-convert</code>) : la fonction prend le relais, et l'ancien script traînerait sinon sur votre disque.</p></div></div></div>
<div class="alertBox_R1Ot mk-callout note_xvll mk-callout--note" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Pourquoi un wrapper ?</p><div class="content_gFLB"><p>Le script interroge <code>docker info</code> pour détecter le runtime NVIDIA avant d'ajouter <code>--gpus all</code>. Cette vérification n'est pas décorative : <code>docker run --gpus all</code> ne se dégrade pas poliment sur une machine sans le toolkit, il refuse carrément de démarrer le container. Avec la sonde en place, le même script fonctionne sur mon serveur GPU et sur mon portable — plus lentement sur le portable, c'est tout, et rien à configurer dans les deux cas. Mettre ce runtime en place est optionnel et arrive à la fin de cet article.</p></div></div></div>
<p>Laissé tranquille, Docling raconte chaque exécution — choix du moteur, chargement des modèles, comptage des tokens, une vingtaine de lignes <code>INFO</code> pour convertir une seule page — d'où le <code>--quiet</code> passé par le wrapper. Ce n'est pas une impasse : Docling ignore <code>--quiet</code> dès que <code>-v</code> est présent, donc <code>docling-convert report.pdf -v</code> ramène tout le log le jour où quelque chose tourne mal. Les deux variables <code>TRANSFORMERS_*</code> et <code>HF_HUB_*</code> du Dockerfile finissent le travail, en faisant taire la couche de modèles en dessous, qui a ses propres opinions sur les barres de progression.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="first-conversion">Première conversion<a href="#first-conversion" class="hash-link" aria-label="Lien direct vers Première conversion" title="Lien direct vers Première conversion" translate="no"></a></h3>
<p>Il est temps de vérifier que toute la chaîne tient. Les cinq fichiers utilisés tout au long de cet article sont ici — un document, un court bon de livraison, exprimé de cinq façons, pour que vous voyiez ce que chaque format coûte sur le chemin du Markdown plutôt que de comparer cinq choses sans rapport :</p>
<a href="/files/docling/samples.zip" download="samples.zip" class="button button--primary downloadButton_m8jH" title="report.pdf, contract.docx, slides.pptx, budget.xlsx, page.html">samples.zip — les cinq fichiers</a>
<p>Vous préférez rester dans le terminal ? L'archive est à une URL fixe, donc <code>curl</code> la récupère directement dans un dossier temporaire, <code>/tmp/docling</code>, dézippée dans un sous-dossier <code>samples</code> :</p>
<div class="terminal_qOWF" style="cursor:pointer" title="Cliquez pour passer l'animation"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">La première vraie conversion</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><span class="terminal_ghost_aie1" aria-hidden="true">$ mkdir -p /tmp/docling &amp;&amp; cd /tmp/docling

$ curl -L --silent -o samples.zip https://www.avonture.be/files/docling/samples.zip

$ unzip -d samples samples.zip &amp;&amp; rm samples.zip &amp;&amp; cd samples

Archive:  samples.zip
  inflating: samples/report.pdf
  inflating: samples/contract.docx
  inflating: samples/slides.pptx
  inflating: samples/budget.xlsx
  inflating: samples/page.html

$ ls

budget.xlsx  contract.docx  page.html  report.pdf  slides.pptx

$ docling-convert report.pdf

docling-convert: report.md written in 14s
💡 Not happy with the result? Run it again with --pipeline standard (faster, rougher on titles), --image-export-mode placeholder|embedded (drop the images, or inline them), -v (the full log).
💡 Hide this hint with DOCLING_CONVERT_NO_TIP=1.

$ ls

budget.xlsx  contract.docx  page.html  report.md  report.pdf  slides.pptx
</span><span class="terminal_overlay_fznj"><span class="cursor_o7aQ" aria-hidden="true">▋</span></span></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p><code>report.md</code> atterrit à côté de <code>report.pdf</code>, titre et paragraphes intacts — pas de dossier de sortie à nommer, pas de redirection à retenir.</p>
<p>Deux choses méritent attention ici. La première, c'est que <code>docling-convert report.pdf</code> a pris quatorze secondes. La seconde, c'est la liste de flags affichée sous le résultat : elle apparaît sous chaque conversion PDF, les options que vous avez déjà passées en disparaissent, les quatre autres formats n'ont droit à aucune suggestion, et <code>DOCLING_CONVERT_NO_TIP=1</code> la coupe définitivement.</p>
<p>Reste la question évidente : qu'est-ce qu'on achète avec ces quatorze secondes ?</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="why-the-slow-pipeline-is-the-default">Pourquoi le pipeline lent est celui par défaut<a href="#why-the-slow-pipeline-is-the-default" class="hash-link" aria-label="Lien direct vers Pourquoi le pipeline lent est celui par défaut" title="Lien direct vers Pourquoi le pipeline lent est celui par défaut" translate="no"></a></h3>
<p>Docling peut lire un PDF de deux manières. Le pipeline standard détecte les régions, en extrait le texte, puis réassemble le tout. Le pipeline <code>vlm</code> confie la page rendue à un petit modèle vision-langage — GraniteDocling, 258M de paramètres — et lui demande d'en décrire la structure, comme le ferait une personne qui lit la page. C'est celui qu'utilise le wrapper, et voici <code>report.pdf</code> — quatre paragraphes justifiés sous un titre en police display — passé par les deux :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">Standard pipeline vs VLM, same file</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb no_wrap_Uf3P">$ export DOCLING_CONVERT_NO_TIP=1

$ docling-convert report.pdf --pipeline standard
docling-convert: report.md written in 8s

$ cat report.md

## Quarterly Delivery Note d titiitl iltil

y y
Scope, milestones and termination — internal circulation only
Following the quarterly review held on 3 September, this note records the agreed
scope, [...] the payment schedule set out in section three,
which is intended to replace it entirely.
The supplier shall deliver the first increment within six weeks of signature, [...]

$ docling-convert report.pdf
docling-convert: report.md written in 14s

$ cat report.md

## Quarterly Delivery Note

Scope, milestones and termination - internal circulation only
Following the quarterly review held on 3 September, this note records the agreed
scope, [...] the payment schedule set out in section three, which is intended to
replace it entirely.
The supplier shall deliver the first increment within six weeks of signature, [...]
</pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Regardez la première ligne du pipeline standard. <code>Quarterly Delivery Note d titiitl iltil</code>, suivi d'un <code>yy</code> égaré — l'étape OCR s'est déclenchée sur le grand titre et a halluciné des lettres qui n'existent dans aucune version de ce document. Puis elle a coupé la première proposition en deux, laissant <code>which is intended to replace it entirely.</code> seule sur sa ligne. Le VLM n'a produit ni le texte inventé ni l'orpheline. Quatorze secondes contre huit, c'est le prix, et ça achète un fichier que vous pouvez transmettre à quelqu'un sans l'avoir relu.</p>
<p>Le chemin rapide n'échoue pas bruyamment : il invente, et le texte inventé ne se détecte qu'en relisant la sortie.</p>
<div class="alertBox_R1Ot mk-callout caution_LuPi mk-callout--warn" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Une vraie différence avec md-convert</p><div class="content_gFLB"><p>Markitdown écrit le Markdown sur stdout, donc <code>md-convert file.docx &gt; file.md</code> est la façon de le capturer. La CLI de Docling écrit au contraire <code>&lt;basename&gt;.md</code> directement dans le répertoire de sortie — il n'y a pas de mode stdout. <code>docling-convert file.docx</code> produit <code>file.md</code> juste à côté ; aucune redirection <code>&gt;</code> n'est nécessaire, et aucune ne fonctionnera.</p></div></div></div>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="a-word-on-images">Un mot sur les images<a href="#a-word-on-images" class="hash-link" aria-label="Lien direct vers Un mot sur les images" title="Lien direct vers Un mot sur les images" translate="no"></a></h3>
<p>Docling rend tout ce que le modèle de layout classe comme image — et sur un titre composé dans une police display, ça peut être le titre lui-même. Laissé tranquille, il intègre ensuite chacune d'elles dans le Markdown sous forme d'URI <code>data:</code> en base64, ce qui garde le fichier autonome et le gonfle énormément. Le wrapper passe <code>--image-export-mode referenced</code> à la place : les images atterrissent en vrais fichiers PNG dans un dossier <code>budget_artifacts/</code> à côté de <code>budget.md</code>, liées en relatif, pour que les deux voyagent ensemble.</p>
<p>Les deux autres modes sont à un flag de distance. <code>--image-export-mode placeholder</code> <em>supprime</em> les images et laisse un marqueur <code>&lt;!-- image --&gt;</code> à la place de chacune — le bon choix quand le Markdown va alimenter un modèle plutôt qu'une personne. <code>--image-export-mode embedded</code> ramène le base64, quand vous voulez un document unique et autonome. Une chose à savoir si vous appelez un jour Docling sans le wrapper : ces liens sont relatifs grâce à <code>--output .</code>, et un <code>--output</code> absolu écrit les chemins internes au container, qui ne résolvent vers rien à l'extérieur.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="optional--putting-a-gpu-behind-the-pdf-pipeline">Optionnel — Mettre un GPU derrière le pipeline PDF<a href="#optional--putting-a-gpu-behind-the-pdf-pipeline" class="hash-link" aria-label="Lien direct vers Optionnel — Mettre un GPU derrière le pipeline PDF" title="Lien direct vers Optionnel — Mettre un GPU derrière le pipeline PDF" translate="no"></a></h2>
<p>Tout ce qui précède fonctionne sans GPU, et cette section est sautable — deux fois plutôt qu'une :</p>
<ul>
<li class=""><strong>Vous ne convertissez que du <code>.docx</code>, <code>.xlsx</code>, <code>.pptx</code> ou <code>.html</code> ?</strong> Alors ça ne vous apporte strictement rien. Ces quatre formats ne chargent jamais de modèle, il n'y a donc rien à accélérer ; <code>--gpus</code> ne change ni la vitesse ni la sortie.</li>
<li class=""><strong>Vous convertissez des PDF sur une machine sans carte NVIDIA ?</strong> Alors ça fonctionne quand même. <code>--device auto</code> se rabat sur le CPU, le même pipeline VLM tourne, et le même Markdown en sort — c'est juste plus long. Le wrapper affiche le temps écoulé à chaque exécution, votre propre matériel vous dira donc de combien.</li>
</ul>
<p>Recommandé pour les PDF, inutile pour les quatre autres, obligatoire pour personne.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="why-a-toolkit-is-needed-at-all">Pourquoi un toolkit est nécessaire<a href="#why-a-toolkit-is-needed-at-all" class="hash-link" aria-label="Lien direct vers Pourquoi un toolkit est nécessaire" title="Lien direct vers Pourquoi un toolkit est nécessaire" translate="no"></a></h3>
<p>Un container ne voit pas votre carte graphique. La carte, sa VRAM et le <em>driver</em> NVIDIA vivent sur l'host ; un container n'obtient ni <code>/dev/nvidia*</code> dans sa liste de périphériques, ni les bibliothèques du driver dans son système de fichiers. Les bibliothèques CUDA que <code>pip</code> a installées dans notre image — la wheel <code>torch</code> embarque les siennes, raison pour laquelle le Dockerfile part d'un simple <code>python:3.12-slim-trixie</code> et non d'une base <code>nvidia/cuda</code> — ne sont que la moitié <em>supérieure</em> de cette pile : elles parlent au driver, elles ne le remplacent pas. La moitié inférieure manquant, PyTorch ne signale aucun périphérique CUDA et calcule sur le CPU.</p>
<p>Le <a href="https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html" target="_blank" rel="noopener noreferrer" class="">NVIDIA Container Toolkit</a> est la pièce qui réunit les deux moitiés : il enregistre un hook dans le daemon Docker qui, à chaque démarrage de container avec <code>--gpus</code>, y injecte les nœuds de périphériques GPU et les bibliothèques du driver de l'host. C'est toute la raison de sa présence dans cet article — <strong>pas de toolkit, pas de VRAM</strong>. Installez-le sur l'host Docker, jamais dans l'image, puis confirmez que le GPU est visible depuis l'intérieur d'un container :</p>
<div class="terminal_qOWF" style="cursor:pointer" title="Cliquez pour passer l'animation"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">Verifying GPU passthrough</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb no_wrap_Uf3P"><span class="terminal_ghost_aie1" aria-hidden="true">$ docker run --rm --gpus all nvidia/cuda:12.4.1-base-ubuntu22.04 nvidia-smi

Tue Sep 15 19:20:44 2026
+-----------------------------------------------------------------------------------------+
| NVIDIA-SMI 595.71.01              Driver Version: 596.36         CUDA Version: 13.2     |
+-----------------------------------------+------------------------+----------------------+
| GPU  Name                 Persistence-M | Bus-Id          Disp.A | Volatile Uncorr. ECC |
| Fan  Temp   Perf          Pwr:Usage/Cap |           Memory-Usage | GPU-Util  Compute M. |
|                                         |                        |               MIG M. |
|=========================================+========================+======================|
|   0  NVIDIA RTX PRO 4000 Blac...    On  |   00000000:01:00.0  On |                  Off |
| 30%   34C    P8              6W /  145W |     892MiB /  24467MiB |      0%      Default |
|                                         |                        |                  N/A |
+-----------------------------------------+------------------------+----------------------+

+-----------------------------------------------------------------------------------------+
| Processes:                                                                              |
|  GPU   GI   CI              PID   Type   Process name                        GPU Memory |
|        ID   ID                                                               Usage      |
|=========================================================================================|
|  No running processes found                                                             |
+-----------------------------------------------------------------------------------------+
</span><span class="terminal_overlay_fznj"><span class="cursor_o7aQ" aria-hidden="true">▋</span></span></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Ce tableau, c'est la carte de l'host lue depuis <em>l'intérieur</em> d'un container : 24 Go de VRAM, version du driver, ce qui l'utilise actuellement. L'image <code>nvidia/cuda</code> n'est là que comme véhicule pratique pour <code>nvidia-smi</code> — notre propre image n'en a pas besoin.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="if-that-table-doesnt-show-up">Si ce tableau n'apparaît pas<a href="#if-that-table-doesnt-show-up" class="hash-link" aria-label="Lien direct vers Si ce tableau n'apparaît pas" title="Lien direct vers Si ce tableau n'apparaît pas" translate="no"></a></h3>
<p>Rien n'est cassé : <code>docling-convert</code> continue de convertir, sur le CPU, exactement comme il l'a fait tout au long de l'article. Si vous voulez vraiment le GPU, trois pannes couvrent quasiment tous les cas :</p>
<table><thead><tr><th>Ce que vous obtenez à la place</th><th>Ce que ça veut dire</th><th>Quoi faire</th></tr></thead><tbody><tr><td><code>could not select device driver "" with capabilities: [[gpu]]</code></td><td>Docker n'a pas de hook GPU : le toolkit n'est pas installé, ou bien installé mais jamais branché au daemon</td><td>Installez le toolkit depuis <a href="https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html" target="_blank" rel="noopener noreferrer" class="">le dépôt de packages NVIDIA</a>, puis lancez les deux commandes ci-dessous</td></tr><tr><td><code>nvidia-smi</code> échoue <strong>sur l'host aussi</strong>, en dehors de tout container</td><td>Le driver lui-même manque ou est cassé — Docker n'est pas encore en jeu</td><td>Installez le driver et redémarrez jusqu'à ce que <code>nvidia-smi</code> fonctionne sur l'host. Sous WSL2, ce driver est un driver <strong>Windows</strong> : installez-le sur Windows, jamais dans la distribution</td></tr><tr><td><code>Failed to initialize NVML: Unknown Error</code></td><td>Le hook s'est exécuté, mais le périphérique a été refusé au container — un daemon qui tourne encore avec sa configuration d'avant le toolkit</td><td>Les deux commandes ci-dessous</td></tr></tbody></table>
<p>Deux de ces trois cas ont le même correctif, parce que le package ne fait que poser le hook sur le disque — il faut encore en informer le daemon :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">Wiring the toolkit into Docker</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb no_wrap_Uf3P">$ sudo nvidia-ctk runtime configure --runtime=docker
$ sudo systemctl restart docker

$ docker info --format '{{json .Runtimes}}'
{"io.containerd.runc.v2":{"path":"runc"},"nvidia":{"path":"nvidia-container-runtime"},"runc":{"path":"runc"}}
</pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Cette dernière commande est exactement la sonde que le script wrapper exécute avant d'ajouter <code>--gpus all</code> : dès que <code>nvidia</code> apparaît parmi les runtimes de Docker, le prochain PDF passe par le GPU sans rien d'autre à changer.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="what-the-five-files-cost">Ce que coûtent les cinq fichiers<a href="#what-the-five-files-cost" class="hash-link" aria-label="Lien direct vers Ce que coûtent les cinq fichiers" title="Lien direct vers Ce que coûtent les cinq fichiers" translate="no"></a></h2>
<p>Chaque chiffre de cet article a été mesuré sur les fichiers que vous avez dézippés plus haut, et les cinq commandes du tout premier terminal tournent dessus telles quelles. Sur ma machine, les quatre formats bureautiques tiennent en quatre à cinq secondes chacun, presque entièrement du démarrage de processus, et <code>report.pdf</code> prend quatorze secondes avec le GPU en jeu. Cet écart résume tout : seul le PDF a besoin des modèles.</p>
<div class="alertBox_R1Ot mk-callout note_xvll mk-callout--note" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Ce que montre réellement le tableur</p><div class="content_gFLB"><p>Les cellules fusionnées ne sont pas réduites, elles sont <em>répétées</em> : le titre ressort une fois par colonne qu'il couvrait, et chaque ligne de section aussi. Rien n'est perdu, mais n'attendez pas du Markdown qu'il reproduise la fusion visuelle. Et Docling lit les formules, pas leurs résultats — un tableur écrit par un script et jamais ouvert dans Excel se convertit avec tous les totaux vides. C'est pour cette raison que <code>budget.xlsx</code> stocke les valeurs calculées.</p></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="key-takeaways">À retenir<a href="#key-takeaways" class="hash-link" aria-label="Lien direct vers À retenir" title="Lien direct vers À retenir" translate="no"></a></h2>
<div class="steps_wrapper_r_yO" data-variant="remember"><p class="steps_title_bD7c mk-eyebrow">docling-convert en bref</p><ul class="steps_list_Vtzi"><li class="step_item_FtAN"><span class="step_bullet_sanI mk-dot" aria-hidden="true">•</span><div class="step_content_KvQY"><span><strong>Le GPU est optionnel</strong> — <code>--device auto</code> retombe proprement sur le CPU si le passthrough n'est pas configuré</span></div></li><li class="step_item_FtAN"><span class="step_bullet_sanI mk-dot" aria-hidden="true">•</span><div class="step_content_KvQY"><span><strong>Un package, cinq formats</strong> — <code>pip install docling</code> tire <code>docling-slim[standard]</code>, qui couvre PDF, DOCX, PPTX, XLSX et HTML</span></div></li><li class="step_item_FtAN"><span class="step_bullet_sanI mk-dot" aria-hidden="true">•</span><div class="step_content_KvQY"><span><strong>Les modèles sont intégrés</strong> — téléchargés au build, donc un container non privilégié en lecture seule n'a jamais rien à récupérer</span></div></li><li class="step_item_FtAN"><span class="step_bullet_sanI mk-dot" aria-hidden="true">•</span><div class="step_content_KvQY"><span><strong>Écrit des fichiers, pas sur stdout</strong> — <code>docling-convert file.pdf</code> produit <code>file.md</code> directement, pas de redirection <code>&gt;</code></span></div></li><li class="step_item_FtAN"><span class="step_bullet_sanI mk-dot" aria-hidden="true">•</span><div class="step_content_KvQY"><span><strong>Lent volontairement</strong> — <code>--pipeline vlm</code> garde paragraphes et titres intacts ; ajoutez <code>--pipeline standard</code> quand la vitesse compte plus</span></div></li><li class="step_item_FtAN"><span class="step_bullet_sanI mk-dot" aria-hidden="true">•</span><div class="step_content_KvQY"><span><strong>Seuls les PDF ont besoin des modèles</strong> — <code>.docx</code>/<code>.xlsx</code>/<code>.pptx</code>/<code>.html</code> passent par un simple parser, sans GPU ni modèle</span></div></li></ul></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="conclusion">Conclusion<a href="#conclusion" class="hash-link" aria-label="Lien direct vers Conclusion" title="Lien direct vers Conclusion" translate="no"></a></h2>
<p>Entre celui-ci et <a href="/fr/blog/markitdown/">Markitdown</a>, j'ai maintenant deux images Docker qui transforment des documents bureautiques en Markdown sans qu'un seul octet ne quitte ma machine — une légère que j'utilise par réflexe, et celle-ci, plus lourde, pour quand la structure d'un PDF mérite qu'on y passe vingt secondes et un GPU. Et comme les deux produisent du Markdown brut, l'une comme l'autre s'intègre directement dans le genre de pipeline que je décrivais dans <a href="/fr/blog/anythingllm-chat-with-your-docs/">Discutez avec vos documents grâce à AnythingLLM</a> — ou dans le prochain article de la série « Ollama au quotidien », où ce Markdown devient l'entrée d'un pipeline local de traduction et de résumé pour des documents qui n'ont jamais eu vocation à toucher le cloud.</p></div><hr></div><div class="relatedPosts_zJiB"><h3>Articles liés</h3></div><div class="container_mNR1"><span class="question_WdqX">Cet article vous a-t-il été utile ?</span><div class="buttons_vgpi"><button class="btn_svQp" aria-label="Oui, cet article m'a été utile"><img src="/fr/img/reaction-mascot-yes.webp" alt="" class="mascot_Fait">Utile</button><button class="btn_svQp btnNeutral_Iqek" aria-label="Non, cet article ne m'a pas été utile"><img src="/fr/img/reaction-mascot-no.webp" alt="" class="mascot_Fait">Pas vraiment</button></div></div><div class="container_IJwe"><span class="question_nb7b">Avez-vous suivi les étapes de cet article ?</span><div class="buttons_Xkzw"><button class="btn_RMiq" aria-label="Oui, j'ai essayé et ça a marché"><img src="/fr/img/like.webp" alt="" class="mascot_wB1d">Ça a marché !</button><button class="btn_RMiq btnNeutral_z71B" aria-label="Non, ça n'a pas marché pour moi"><img src="/fr/img/dislike.webp" alt="" class="mascot_wB1d">Ça n'a pas marché</button></div></div>]]></content:encoded>
            <author>Christophe</author>
            <author>Claude Code</author>
            <category>docker</category>
            <category>markdown</category>
            <category>python</category>
            <category>ai</category>
            <category>doc-as-code</category>
            <enclosure length="0" type="image/webp" url="https://www.avonture.be/fr/img/v2/docling.webp"/>
        <dc:creator><![CDATA[Christophe]]></dc:creator><dc:creator><![CDATA[Claude Code]]></dc:creator></item>
        <item>
            <title><![CDATA[Atuin — Supercharge Your Shell History With a Searchable, Timestamped Database]]></title>
            <link>https://www.avonture.be/fr/fr/blog/atuin-bash-history</link>
            <guid isPermaLink="false">/fr/blog/atuin-bash-history</guid>
            <pubDate>Mon, 14 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[<img src="https://www.avonture.be/fr/img/v2/atuin.webp" alt="Atuin — Supercharge Your Shell History With a Searchable, Timestamped Database" style="display:block;max-width:100%;height:auto;">Tired of losing commands in the default CTRL+R maze? Atuin replaces your shell history with a full-featured TUI backed by SQLite — timestamps, context, multi-machine sync, and a clean comparison with FZF-based history search.]]></description>
            <content:encoded><![CDATA[<div class="offer_jqr1"><span class="flag_Hnep" aria-hidden="true">🇬🇧</span><span>Cet article existe aussi en anglais —<!-- --> <a href="/blog/atuin-bash-history/" hreflang="en">lire la version originale</a>.</span></div><div class="alertBox_R1Ot mk-callout caution_LuPi mk-callout--warn" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Traduction automatique</p><div class="content_gFLB">Cette traduction a été produite par une IA. Elle peut contenir des contresens ou des maladresses, en particulier sur les termes techniques. En cas de doute,<!-- --> <a href="/blog/atuin-bash-history/">consultez l'article de référence en anglais</a> <!-- -->— c'est lui qui fait foi.</div></div></div><div><div><p><img alt="Atuin — Boostez votre historique shell" src="/fr/assets/images/atuin-86ef4cfdc661f7d1614c4de9337935ef.webp" height="auto" width="" loading="lazy" decoding="async" class=""></p>
<div class="tldrContainer_qQW_ mk-callout mk-callout--key" role="region" aria-label="Résumé de l'article"><span role="img" aria-label="éclair" class="tldrIcon_SLBz mk-dot">⚡</span><div class="tldrBody_lGty"><strong class="tldrTitle_Aha8 mk-eyebrow">TL;DR</strong><div class="tldrContent_m27u"><p>L'historique shell par défaut est un cimetière de commandes que vous ne retrouverez jamais — pas d'horodatage, pas de contexte, 500 lignes et puis plus rien. Atuin le remplace par une TUI adossée à SQLite qui enregistre chaque commande avec son code de sortie, son répertoire de travail, sa durée et son horodatage. Vous obtenez un <kbd>CTRL</kbd>+<kbd>R</kbd> instantanément amélioré, avec filtrage par host ou par dossier, sans sacrifier votre historique existant. La seconde moitié de cet article détaille l'installation sur Bash et ZSH, et se termine par une comparaison en face à face avec l'approche classique FZF+history.</p></div></div></div><nav class="mobileQuickLinks_La_l" aria-label="Articles liés"><p class="title_rsOi mk-eyebrow">Sur le même sujet</p><ul class="list_CXCl"><li class="item_grzi"><a href="/fr/blog/ollama-ai-explain/">ai-explain : l'ELI5 des scripts et des erreurs, directement dans votre terminal</a></li><li class="item_grzi"><a href="/fr/blog/linux-yq/">yq — Le processeur YAML que vous savez déjà utiliser</a></li><li class="item_grzi"><a href="/fr/blog/hyperfine/">hyperfine — arrêtez de deviner quelle commande est la plus rapide, mesurez-la</a></li></ul></nav>
<p>Zut, ça recommence. J'ai tapé <kbd>CTRL</kbd>+<kbd>R</kbd>, cherché cette longue commande <code>docker run</code> que j'avais construite il y a trois semaines, et rien — ou pire, la mauvaise version de mardi dernier. L'historique bash par défaut est juste... insuffisant. Cinq cents lignes, pas d'horodatage, aucun contexte sur le dossier où vous étiez ni même sur le succès de la commande.</p>
<p><a href="/fr/blog/linux-history/">J'ai déjà écrit sur les astuces d'historique intégrées</a> — <code>HISTSIZE</code>, <code>HISTTIMEFORMAT</code>, <code>fc</code>, la recherche inversée — et ça aide, oui. Mais ce sont des contournements, pas une solution. Vous cherchez toujours dans un fichier texte plat sans aucune donnée structurée.</p>
<p>Atuin change complètement la donne.</p>
<nav class="quickJump_q5pQ" aria-label="Quick Jump"><span class="title_Njuu mk-eyebrow">Quick Jump</span><ul class="list_A_9v"><li class="item_gTjz"><a href="#seeing-it-in-action-with-docker">Le voir en action</a></li><li class="item_gTjz"><a href="#installing-atuin">L'installer pour de vrai</a></li><li class="item_gTjz"><a href="#atuin-vs-fzf-history">Atuin vs. FZF</a></li></ul></nav>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="what-is-atuin">Qu'est-ce qu'Atuin ?<a href="#what-is-atuin" class="hash-link" aria-label="Lien direct vers Qu'est-ce qu'Atuin ?" title="Lien direct vers Qu'est-ce qu'Atuin ?" translate="no"></a></h2>
<p><a href="https://github.com/atuinsh/atuin" target="_blank" rel="noopener noreferrer" class="">Atuin</a> (le nom est un clin d'œil à la tortue-monde de Terry Pratchett) remplace l'historique de votre shell. Au lieu d'ajouter les commandes à <code>~/.bash_history</code>, il écrit chaque commande dans une base SQLite locale avec des métadonnées riches :</p>
<ul>
<li class=""><strong>Horodatage</strong> — quand l'avez-vous lancée ?</li>
<li class=""><strong>Durée</strong> — combien de temps a-t-elle pris ?</li>
<li class=""><strong>Code de sortie</strong> — a-t-elle réussi ?</li>
<li class=""><strong>Répertoire de travail</strong> — où étiez-vous ?</li>
<li class=""><strong>Nom d'host</strong> — sur quelle machine ?</li>
</ul>
<p>Quand vous appuyez sur <kbd>CTRL</kbd>+<kbd>R</kbd>, au lieu du minuscule prompt de recherche inversée incrémentale, Atuin ouvre une TUI plein écran où vous pouvez taper, filtrer par host ou par répertoire, et naviguer aux flèches. Ça fonctionne sur Bash, ZSH, Fish et Nushell. Une synchronisation cloud chiffrée de bout en bout, optionnelle, permet de partager l'historique entre machines — mais c'est entièrement facultatif ; Atuin fonctionne parfaitement hors ligne et en local.</p>
<div class="alertBox_R1Ot mk-callout note_xvll mk-callout--note" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Aucun cloud requis</p><div class="content_gFLB"><p>La synchronisation d'Atuin est optionnelle. Si vous ne lancez jamais <code>atuin register</code> ni <code>atuin login</code>, votre historique reste 100 % local dans <code>~/.local/share/atuin/history.db</code>. Cet article ne traite que de l'expérience locale.</p></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="seeing-it-in-action-with-docker">Le voir en action avec Docker<a href="#seeing-it-in-action-with-docker" class="hash-link" aria-label="Lien direct vers Le voir en action avec Docker" title="Lien direct vers Le voir en action avec Docker" translate="no"></a></h2>
<p><em>Vous connaissez déjà Atuin, ou vous voulez juste l'avoir sur votre machine ? <a href="#installing-atuin" class="">Passez directement à l'installation</a> — ce chapitre n'est qu'un container jetable pour l'essayer sans risque.</em></p>
<p>Vous me connaissez bien maintenant — j'aime tout mettre en container. Avant d'installer quoi que ce soit sur votre machine, montons un container jetable pour que vous sentiez ce qu'Atuin donne en pratique.</p>
<div class="alertBox_R1Ot mk-callout tip_shCb mk-callout--tip" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Pourquoi Docker d'abord ?</p><div class="content_gFLB"><p>Un container Docker vous permet de tester Atuin sans toucher à la configuration réelle de votre shell. Si ça ne vous plaît pas, vous supprimez le container. Aucun hook résiduel dans votre <code>~/.bashrc</code>.</p></div></div></div>
<p>Deux fichiers dans un dossier — et tous les deux sont de l'<strong>échafaudage de démo, rien d'autre</strong>. <code>seed-history.sh</code> en particulier ne fait pas partie de l'usage d'Atuin : son seul rôle est de fabriquer un historique plausible pour que l'écran suivant ait quelque chose à montrer. Aucun de ces deux fichiers n'a sa place sur votre propre machine, où <a href="#installing-atuin" class="">installer Atuin</a> se résume à un <code>brew install</code>.</p>
<div class="projectSetupContainer_LW1i mk-surface"><div class="header_Htsx mk-toolbar"><strong>📦 <!-- -->Mise en place du projet :<!-- --> <!-- -->/tmp/atuin-demo</strong><div class="buttonGroup_y07f mk-toolbar__actions"><a class="button button--secondary button--sm" href="/project_setup" target="_blank">Aide</a><button class="button button--secondary button--sm">Tout déplier</button><button class="button button--outline button--primary button--sm" aria-label="Télécharger le projet en ZIP">ZIP</button><button class="button button--secondary button--sm">Générer le script d'installation</button></div></div><div class="content_bo8S"><div><p class="tip_qyDH"><small><span aria-hidden="true">💡</span> <!-- -->Besoin d'aide ? Cliquez sur le bouton « Aide » ci-dessus pour les instructions de mise en place.</small></p></div></div></div>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/atuin-demo</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>$ docker build -t atuin-demo .
[+] Building 28.3s (8/8) FINISHED
✔ exporting to image</p><p>$ docker run --rm -it atuin-demo
Atuin history seeded with 51 commands.
root@4f2a1b3c9d8e:/#</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Ne tapez rien — appuyez tout de suite sur <kbd>CTRL</kbd>+<kbd>R</kbd> :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>[atuin] &gt; _
──────────────────────────────────────────────────────────────────────────
1  [exit 0]  1s     12m   ~/projects/blog  docker compose up -d
2  [exit 0]  320ms  15m   ~/projects/blog  git status
3  [exit 1]  90ms   18m   ~/projects/blog  git push origin main
4  [exit 0]  140ms  21m   ~/projects/blog  git pull --rebase
5  [exit 0]  12s    26m   ~/projects/blog  yarn build
6  [exit 0]  760ms  34m   ~/projects/blog  yarn lint
7  [exit 0]  95ms   41m   ~/projects/blog  rg --type md "atuin" .
8  [exit 0]  210ms  47m   ~/projects/blog  fzf --preview 'bat --color=always <!-- -->'
9  [exit 127] 30ms  55m   ~/projects/blog  yarnn start
10  [exit 0]  45ms   58m   ~/projects/blog  yarn start
──────────────────────────────────────────────────────────────────────────
↑/↓ navigate  Enter select  Ctrl+D delete  Esc quit</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Code de sortie, durée, horodatage et répertoire de travail — tout dans une seule vue. L'entrée 9 est typiquement ce que vous ne voyez qu'avec Atuin : une faute de frappe (<code>yarnn</code>) morte avec le code 127, juste à côté de la commande corrigée qui a suivi trois minutes plus tard.</p>
<p>Maintenant commencez à taper. Tapez <code>git</code> et la liste se réduit aux commandes git ; tapez <code>docker</code> et vous atteindrez des commandes lancées dans un <em>autre</em> répertoire de projet, et même sur une autre machine — les données de démo couvrent deux noms d'host et trois sessions shell, donc le filtre <kbd>CTRL</kbd>+<kbd>F</kbd> a vraiment de quoi travailler.</p>
<p>Cet historique ne sort pas de nulle part : <code>seed-history.sh</code> l'écrit directement dans la base SQLite d'Atuin avant le démarrage du shell — son commentaire d'en-tête explique pourquoi, si ça vous intéresse. Passons d'abord à la vraie installation.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="installing-atuin">Installer Atuin<a href="#installing-atuin" class="hash-link" aria-label="Lien direct vers Installer Atuin" title="Lien direct vers Installer Atuin" translate="no"></a></h2>
<p>Si vous voulez garder Atuin en permanence sur votre machine (et ce sera le cas, après la démo), utilisez votre gestionnaire de packages. Atuin est dans tous les habituels, et c'est de loin la façon la moins excitante de l'obtenir — c'est exactement ce qu'on attend d'une installation.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="just-use-homebrew">Utilisez simplement Homebrew<a href="#just-use-homebrew" class="hash-link" aria-label="Lien direct vers Utilisez simplement Homebrew" title="Lien direct vers Utilisez simplement Homebrew" translate="no"></a></h3>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>$ brew install atuin</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>C'est tout. La formule <code>atuin</code> de Homebrew suit l'upstream de près — 18.22.0 au moment où j'écris, la même version que la dernière release GitHub — et elle fournit une bottle précompilée pour <strong>Linux comme pour macOS</strong>, donc c'est tout autant la bonne réponse dans WSL.</p>
<p>La formule elle-même est publique et relue via une pull request, le binaire vient d'une bottle construite par la CI de Homebrew, et <code>brew upgrade atuin</code> le maintiendra à jour ensuite — vous n'aurez pas besoin de revenir sur cette page pour ça.</p>
<details class="details_rTsq"><summary>Vous n'avez pas encore Homebrew ?</summary><div class="content_OqVG"><p>Installer Homebrew implique un <code>curl</code> redirigé dans <code>bash</code> — j'ai fait exactement ça <a href="/fr/blog/reduce-image-size/">dans un article précédent</a>, et c'est la commande que brew.sh publie. Inutile de prétendre le contraire.</p><p>Une chose en fait un marché plus honnête que la plupart : le script vit dans le repository public <a href="https://github.com/Homebrew/install" target="_blank" rel="noopener noreferrer" class=""><code>Homebrew/install</code></a>, vous pouvez donc épingler une révision que vous avez réellement lue plutôt que d'exécuter ce qui traîne sur la branch aujourd'hui.</p><div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token assign-left variable" style="color:rgb(156, 220, 254)">SHA</span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain">8949852f785a3bacaba2a979d0790337950b0a4a</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">/bin/bash </span><span class="token parameter variable" style="color:rgb(156, 220, 254)">-c</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"</span><span class="token string variable" style="color:rgb(156, 220, 254)">$(</span><span class="token string variable function" style="color:rgb(220, 220, 170)">curl</span><span class="token string variable" style="color:rgb(156, 220, 254)"> </span><span class="token string variable parameter variable" style="color:rgb(156, 220, 254)">-fsSL</span><span class="token string variable" style="color:rgb(156, 220, 254)"> https://raw.githubusercontent.com/Homebrew/install/$</span><span class="token string variable punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token string variable" style="color:rgb(156, 220, 254)">SHA</span><span class="token string variable punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token string variable" style="color:rgb(156, 220, 254)">/install.sh</span><span class="token string variable" style="color:rgb(156, 220, 254)">)</span><span class="token string" style="color:rgb(206, 145, 120)">"</span><br></div></code></pre></div></div><p>Sur Apple Silicon vous pouvez éviter tout ça : Homebrew fournit un <a href="https://github.com/Homebrew/brew/releases/latest" target="_blank" rel="noopener noreferrer" class=""><code>Homebrew.pkg</code></a> signé.</p></div></details>
<div class="alertBox_R1Ot mk-callout note_xvll mk-callout--note" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Il n'y a pas de package apt</p><div class="content_gFLB"><p>Atuin n'est <strong>pas</strong> dans les repositories Debian ou Ubuntu, donc ne cherchez pas <code>apt install atuin</code>. Si vous avez une toolchain Rust, <code>cargo install atuin</code> est une autre voie honnête : il construit depuis les sources publiées plutôt que de faire confiance à un binaire précompilé.</p></div></div></div>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="on-windows-winget">Sous Windows : winget<a href="#on-windows-winget" class="hash-link" aria-label="Lien direct vers Sous Windows : winget" title="Lien direct vers Sous Windows : winget" translate="no"></a></h3>
<p>Le lecteur habituel de ce blog tourne sous WSL, donc celui-ci mérite un mot à part. Atuin est dans le Windows Package Manager :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">PS C:\\Users\\christophe</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>PS&gt; winget install -e --id Atuinsh.Atuin</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Même forme de garantie qu'avec Homebrew : le manifeste vit dans le repository public <code>microsoft/winget-pkgs</code>, relu par pull request, et il épingle le SHA-256 de l'installeur — winget refuse donc tout ce qui ne correspond pas.</p>
<div class="alertBox_R1Ot mk-callout caution_LuPi mk-callout--warn" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Windows et WSL, deux historiques distincts</p><div class="content_gFLB"><p><code>winget</code> installe <code>atuin.exe</code>, le build Windows — il vous donne Atuin dans PowerShell et CMD, <strong>pas</strong> dans votre shell WSL. Si vous vivez dans WSL, l'historique qui vous intéresse vraiment est celui enregistré par le binaire <em>Linux</em>, installé dans votre distribution, avec sa propre base sous <code>~/.local/share/atuin/</code>. Installez les deux si vous voulez les deux ; ce sont deux installations et deux historiques sans rapport.</p></div></div></div>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="without-a-package-manager">Sans gestionnaire de packages<a href="#without-a-package-manager" class="hash-link" aria-label="Lien direct vers Sans gestionnaire de packages" title="Lien direct vers Sans gestionnaire de packages" translate="no"></a></h3>
<details class="details_rTsq"><summary>Télécharger le binaire de la release et le vérifier</summary><div class="content_OqVG"><div class="varsbar_xL7w mk-surface"><div class="varsbarHead_nacC"><span class="varsbarTitle_d9dG mk-eyebrow"><span class="dot_kctR"></span>Vos valeurs pour cette page</span><button class="resetLink_X21M" type="button">Revenir aux valeurs par défaut</button></div><div class="varsbarFields_ZGsv"><div class="field_MGlw"><label for="vars-_R_34mrihmmk5_-version">Version</label><div class="inputWrap_vL5t"><input id="vars-_R_34mrihmmk5_-version" class="mono_VfQ7" type="text" inputmode="text" value="18.22.0"><span class="badgeDefault_OkED">défaut</span></div></div></div><div class="varsbarFoot_vsF4"><span>S'applique aux commandes de cette page</span><span>Enregistré uniquement sur cet appareil</span></div></div><p>Chaque release fournit un binaire simple. Prenez le build <strong>musl</strong> : il est lié statiquement et tourne partout, alors que le <code>-gnu</code> veut une glibc récente et meurt avec <code>GLIBC_2.39 not found</code> sur les distributions plus anciennes.</p><div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>$ V=<span class="token_SjcP">18.22.0</span>
$ BASE=<a href="https://github.com/atuinsh/atuin/releases/download/v$V" target="_blank" rel="noopener noreferrer" class="">https://github.com/atuinsh/atuin/releases/download/v$V</a>
$ curl -fsSLO $BASE/atuin-x86_64-unknown-linux-musl.tar.gz
$ curl -fsSLO $BASE/atuin-x86_64-unknown-linux-musl.tar.gz.sha256
$ sha256sum -c atuin-x86_64-unknown-linux-musl.tar.gz.sha256
atuin-x86_64-unknown-linux-musl.tar.gz: OK</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div><p>Ensuite, mettez-le dans votre <code>PATH</code> :</p><div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>$ tar -xzf atuin-x86_64-unknown-linux-musl.tar.gz
$ sudo install -m 755 atuin-x86_64-unknown-linux-musl/atuin /usr/local/bin/atuin
$ atuin --version
atuin <span class="token_SjcP">18.22.0</span></p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div><p>Les releases d'Atuin embarquent aussi des <a href="https://docs.github.com/en/actions/security-for-github-actions/using-artifact-attestations/using-artifact-attestations-to-establish-provenance-for-builds" target="_blank" rel="noopener noreferrer" class="">attestations de build signées</a> — une preuve cryptographique du workflow qui a construit le binaire, vérifiable avec <code>gh attestation verify</code>. Bon à savoir ; pas indispensable pour démarrer.</p></div></details>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="last-resort-the-official-one-liner">En dernier recours : le one-liner officiel<a href="#last-resort-the-official-one-liner" class="hash-link" aria-label="Lien direct vers En dernier recours : le one-liner officiel" title="Lien direct vers En dernier recours : le one-liner officiel" translate="no"></a></h3>
<p>La documentation d'Atuin met en avant un <code>curl</code> redirigé directement dans <code>sh</code>. Vous le croiserez partout, donc je ne vais pas prétendre qu'il n'existe pas — mais vous devrez aller le chercher, et lire ceci d'abord.</p>
<div class="alertBox_R1Ot mk-callout danger_tlW9 mk-callout--danger" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Ceci confie votre machine à qui contrôle ce domaine</p><div class="content_gFLB"><p><code>curl … | sh</code> télécharge un script et l'exécute <strong>immédiatement</strong>, avec vos propres droits, sans que vous en voyiez une seule ligne. Si <code>setup.atuin.sh</code> est compromis un jour — domaine détourné, identifiant fuité, entrée CDN empoisonnée — alors ce que l'attaquant y a laissé s'exécute sur votre machine dès que vous appuyez sur Entrée. Aucune signature à vérifier, aucune révision à épingler, aucun diff à lire, et aucune trace ensuite.</p><p>Les flags <code>--proto '=https' --tlsv1.2</code> ne sont pas la garantie qu'ils semblent être : ils sécurisent le <em>transport</em>, et ne disent absolument rien du contenu qui arrive.</p><p>Utilisez <code>brew install atuin</code> plus haut, ou l'installation du binaire vérifié juste au-dessus. Ne recourez à celle-ci que sur une machine qui n'a ni l'un ni l'autre.</p><details class="details_rTsq"><summary>Je comprends le risque — montrez-moi la commande quand même</summary><div class="content_OqVG"><div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>$ curl --proto '=https' --tlsv1.2 -LsSf <a href="https://setup.atuin.sh" target="_blank" rel="noopener noreferrer" class="">https://setup.atuin.sh</a> | sh</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div></div></details></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="wiring-atuin-into-your-shell">Brancher Atuin dans votre shell<a href="#wiring-atuin-into-your-shell" class="hash-link" aria-label="Lien direct vers Brancher Atuin dans votre shell" title="Lien direct vers Brancher Atuin dans votre shell" translate="no"></a></h2>
<p>Quelle que soit la route choisie, le binaire est sur votre machine mais votre shell ignore encore son existence — <kbd>CTRL</kbd>+<kbd>R</kbd> est inchangé pour l'instant. Le brancher tient en deux lignes, et ce sont justement les deux lignes qui valent la peine d'être comprises.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="bash">Bash<a href="#bash" class="hash-link" aria-label="Lien direct vers Bash" title="Lien direct vers Bash" translate="no"></a></h3>
<p>Ajoutez ces deux lignes à la <strong>fin</strong> de votre <code>~/.bashrc</code> :</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockTitle_OeMC">~/.bashrc</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token builtin class-name" style="color:rgb(78, 201, 176)">source</span><span class="token plain"> /path/to/bash-preexec.sh   </span><span class="token comment" style="color:rgb(106, 153, 85)"># skip if you're on Bash 4.4+</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token builtin class-name" style="color:rgb(78, 201, 176)">eval</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"</span><span class="token string variable" style="color:rgb(156, 220, 254)">$(</span><span class="token string variable" style="color:rgb(156, 220, 254)">atuin init </span><span class="token string variable function" style="color:rgb(220, 220, 170)">bash</span><span class="token string variable" style="color:rgb(156, 220, 254)">)</span><span class="token string" style="color:rgb(206, 145, 120)">"</span><br></div></code></pre></div></div>
<div class="alertBox_R1Ot mk-callout note_xvll mk-callout--note" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">bash-preexec sur Bash moderne</p><div class="content_gFLB"><p><code>bash-preexec</code> est une petite bibliothèque de hooks qui donne à Bash le mécanisme <code>precmd</code> / <code>preexec</code> que ZSH possède nativement ; Atuin l'utilise pour intercepter une commande avant et après son exécution, ce qui lui permet d'enregistrer le code de sortie et la durée. Sur Bash 4.4+ (la plupart des distributions Linux récentes), c'est optionnel. Atuin retombe sur un hook plus simple. Si vous voulez le suivi complet du code de sortie et de la durée, gardez <code>bash-preexec</code>. Sur Ubuntu 24.04 (Bash 5.2), ça marche très bien sans — mais la démo Docker l'inclut pour une compatibilité maximale.</p></div></div></div>
<p>Puis rechargez votre shell :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>$ source ~/.bashrc</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="zsh">ZSH<a href="#zsh" class="hash-link" aria-label="Lien direct vers ZSH" title="Lien direct vers ZSH" translate="no"></a></h3>
<p>Si vous utilisez déjà ZSH (et vous devriez — <a href="/fr/blog/zsh-install/">voici comment le mettre en place</a>), Atuin s'intègre encore plus proprement car ZSH a des hooks <code>preexec</code>/<code>precmd</code> natifs. Aucune dépendance supplémentaire.</p>
<p>Ajoutez à <code>~/.zshrc</code> :</p>
<div class="language-zsh codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockTitle_OeMC">~/.zshrc</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-zsh codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">eval "$(atuin init zsh)"</span><br></div></code></pre></div></div>
<p>Puis rechargez :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>$ source ~/.zshrc</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Si vous suivez <a href="/fr/blog/modular-zsh-workflow/">un workflow ZSH modulaire</a>, placez la ligne <code>eval</code> dans son propre fichier — par ex. <code>~/.zsh/plugins/atuin.zsh</code> — et sourcez-le depuis votre <code>~/.zshrc</code> principal. Ça garde votre config propre et facile à activer/désactiver.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="import-your-existing-history">Importer votre historique existant<a href="#import-your-existing-history" class="hash-link" aria-label="Lien direct vers Importer votre historique existant" title="Lien direct vers Importer votre historique existant" translate="no"></a></h2>
<p>Atuin importe votre <code>~/.bash_history</code> ou <code>~/.zsh_history</code> actuel au premier lancement. Vous pouvez aussi le déclencher manuellement :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>$ atuin import auto
✓  Importing history from /home/christophe/.bash_history
Imported 4 823 commands</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Vos anciennes commandes sont maintenant dans la base avec leurs horodatages d'origine (s'il y en avait). Vous ne perdez rien.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="configuring-atuin">Configurer Atuin<a href="#configuring-atuin" class="hash-link" aria-label="Lien direct vers Configurer Atuin" title="Lien direct vers Configurer Atuin" translate="no"></a></h2>
<p>La configuration d'Atuin vit dans <code>~/.config/atuin/config.toml</code>. Les valeurs par défaut sont raisonnables, mais quelques options valent le détour :</p>
<div class="language-toml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockTitle_OeMC">~/.config/atuin/config.toml</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-toml codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain"># How many results to show in the TUI</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">search_mode = "fuzzy"      # or "prefix", "fulltext"</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"># Filter by current directory by default (toggle with CTRL+F in the TUI)</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">filter_mode = "global"     # or "host", "session", "directory"</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"># Show the full command, not a truncated one</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">show_preview = true</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"># Inline TUI instead of full-screen overlay</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">style = "compact"          # or "full" (default), "auto"</span><br></div></code></pre></div></div>
<p>Redémarrez votre shell après édition pour appliquer les changements.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="atuin-vs-fzf-history">Atuin vs. l'historique FZF<a href="#atuin-vs-fzf-history" class="hash-link" aria-label="Lien direct vers Atuin vs. l'historique FZF" title="Lien direct vers Atuin vs. l'historique FZF" translate="no"></a></h2>
<p><a href="/fr/blog/linux-fzf-introduction/">FZF</a> est la référence absolue de la recherche floue dans le terminal — y compris pour l'historique shell via <kbd>CTRL</kbd>+<kbd>R</kbd> quand il est branché avec le <code>fzf-history-widget</code>. Alors, comment Atuin se compare-t-il ?</p>
<table><thead><tr><th>Fonctionnalité</th><th>Atuin</th><th>FZF + history</th></tr></thead><tbody><tr><td><strong>Backend</strong></td><td>Base SQLite</td><td>Fichier texte plat</td></tr><tr><td><strong>Horodatage</strong></td><td>Toujours enregistré</td><td>Seulement si <code>HISTTIMEFORMAT</code> est défini</td></tr><tr><td><strong>Code de sortie</strong></td><td>Oui</td><td>Non</td></tr><tr><td><strong>Durée</strong></td><td>Oui</td><td>Non</td></tr><tr><td><strong>Répertoire de travail</strong></td><td>Oui</td><td>Non</td></tr><tr><td><strong>Synchro multi-machines</strong></td><td>Oui (optionnelle, chiffrée E2E)</td><td>Non</td></tr><tr><td><strong>Recherche floue</strong></td><td>Oui (intégrée)</td><td>Oui (via FZF)</td></tr><tr><td><strong>Filtrer par répertoire</strong></td><td>Oui (<code>CTRL+F</code> dans la TUI)</td><td>Non</td></tr><tr><td><strong>Filtrer par host</strong></td><td>Oui</td><td>Non</td></tr><tr><td><strong>Shells supportés</strong></td><td>Bash, ZSH, Fish, Nu</td><td>Bash, ZSH, Fish</td></tr><tr><td><strong>Dépendances</strong></td><td>Un binaire</td><td>Binaire FZF + plugin shell</td></tr><tr><td><strong>Import de l'historique existant</strong></td><td>Oui</td><td>N/A (lit déjà le même fichier)</td></tr><tr><td><strong>Configuration</strong></td><td>Fichier TOML</td><td>Flags en variables de shell</td></tr><tr><td><strong>TUI</strong></td><td>Panneau plein écran</td><td>En ligne (ou plein avec <code>--height=100%</code>)</td></tr></tbody></table>
<p>Aucun des deux n'est strictement meilleur — ça dépend de vos besoins :</p>
<ul>
<li class=""><strong>Utilisez FZF</strong> si vous l'avez déjà branché dans votre workflow pour autre chose (recherche de fichiers, branches git, contextes kubectl…) et que vous n'avez besoin que d'une recherche inversée basique. L'intégration FZF de <kbd>CTRL</kbd>+<kbd>R</kbd> est légère et ne demande ni daemon ni base de données.</li>
<li class=""><strong>Utilisez Atuin</strong> si vous vous souciez du <strong>pourquoi</strong> une commande a tourné (code de sortie, durée, répertoire) et que vous voulez un historique qui survit aux migrations de machine et se corrèle entre hosts. Le modèle base de données rend la recherche d'Atuin structurellement plus riche.</li>
</ul>
<p>Ils ne sont pas mutuellement exclusifs. Certains font tourner les deux : Atuin pour <kbd>CTRL</kbd>+<kbd>R</kbd> (recherche structurée et horodatée) et FZF pour tout le reste (navigation de fichiers, complétions floues). <a href="/fr/blog/fzf-ripgrep/">Combiner FZF avec ripgrep</a> pour la recherche dans le code reste un cas d'usage différent auquel Atuin ne touche jamais.</p>
<div class="alertBox_R1Ot mk-callout tip_shCb mk-callout--tip" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Désactiver le CTRL+R d'Atuin pour garder celui de FZF</p><div class="content_gFLB"><p>Si vous voulez qu'Atuin enregistre l'historique en silence tout en gardant le binding <kbd>CTRL</kbd>+<kbd>R</kbd> de FZF, ajoutez ceci à votre config :</p><div class="language-toml codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockTitle_OeMC">~/.config/atuin/config.toml</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-toml codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">[keys]</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">scroll_exits = false</span><br></div></code></pre></div></div><p>Et dans votre shell, bindez manuellement <kbd>CTRL</kbd>+<kbd>R</kbd> sur <code>fzf-history-widget</code> après avoir sourcé Atuin. Atuin continuera de capturer et stocker les commandes ; il n'interceptera juste plus la touche.</p></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="conclusion">Conclusion<a href="#conclusion" class="hash-link" aria-label="Lien direct vers Conclusion" title="Lien direct vers Conclusion" translate="no"></a></h2>
<p>La recherche inversée <kbd>CTRL</kbd>+<kbd>R</kbd> par défaut a été conçue à une époque où 500 commandes paraissaient largement suffisantes. Ce n'est plus le cas — pas quand vous jonglez entre builds Docker, workflows git, sessions SSH sur une demi-douzaine d'hosts et fonctions shell qui ont pris une heure à mettre au point. Atuin donne à cet historique la structure qu'il mérite : une vraie base de données, de vrais horodatages, un vrai contexte.</p>
<p>La démo Docker est la meilleure façon de sentir la différence sans engagement. Lancez-la, appuyez sur <kbd>CTRL</kbd>+<kbd>R</kbd>, et trois semaines d'historique sont déjà là à vous attendre — vous verrez immédiatement pourquoi un fichier plat ne suffit pas. Si vous êtes déjà <a href="/fr/blog/linux-fzf-introduction/">FZF user</a>, ne voyez pas Atuin comme un remplaçant — voyez-le comme ce qui arrive quand on applique le principe « les données structurées battent le texte brut » à l'outil que vous utilisez plus que tous les autres.</p>
<p>Maintenant, chaque fois que je me trompe sur cette incantation <code>tar</code> parfaite ou que j'oublie dans quel répertoire j'étais quand ce script a enfin marché, Atuin s'en souviendra pour moi.</p></div><hr></div><div class="relatedPosts_zJiB"><h3>Articles liés</h3></div><div class="container_mNR1"><span class="question_WdqX">Cet article vous a-t-il été utile ?</span><div class="buttons_vgpi"><button class="btn_svQp" aria-label="Oui, cet article m'a été utile"><img src="/fr/img/reaction-mascot-yes.webp" alt="" class="mascot_Fait">Utile</button><button class="btn_svQp btnNeutral_Iqek" aria-label="Non, cet article ne m'a pas été utile"><img src="/fr/img/reaction-mascot-no.webp" alt="" class="mascot_Fait">Pas vraiment</button></div></div><div class="container_IJwe"><span class="question_nb7b">Avez-vous suivi les étapes de cet article ?</span><div class="buttons_Xkzw"><button class="btn_RMiq" aria-label="Oui, j'ai essayé et ça a marché"><img src="/fr/img/like.webp" alt="" class="mascot_wB1d">Ça a marché !</button><button class="btn_RMiq btnNeutral_z71B" aria-label="Non, ça n'a pas marché pour moi"><img src="/fr/img/dislike.webp" alt="" class="mascot_wB1d">Ça n'a pas marché</button></div></div>]]></content:encoded>
            <author>Christophe</author>
            <author>Claude Code</author>
            <category>bash</category>
            <category>linux</category>
            <category>zsh</category>
            <enclosure length="0" type="image/webp" url="https://www.avonture.be/fr/img/v2/atuin.webp"/>
        <dc:creator><![CDATA[Christophe]]></dc:creator><dc:creator><![CDATA[Claude Code]]></dc:creator></item>
        <item>
            <title><![CDATA[Add a "Copy as Markdown" Button to Your Docusaurus Blog]]></title>
            <link>https://www.avonture.be/fr/fr/blog/docusaurus-copy-as-markdown</link>
            <guid isPermaLink="false">/fr/blog/docusaurus-copy-as-markdown</guid>
            <pubDate>Thu, 10 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[<img src="https://www.avonture.be/fr/img/v2/copy-as-markdown.webp" alt="Add a "Copy as Markdown" Button to Your Docusaurus Blog" style="display:block;max-width:100%;height:auto;">Build a "Copy as Markdown" button and a "View raw" link for your Docusaurus blog posts — a build-time plugin that writes a plain-Markdown mirror next to every article, plus the tiny React component that fetches it and copies it to the clipboard. Full source included.]]></description>
            <content:encoded><![CDATA[<div class="offer_jqr1"><span class="flag_Hnep" aria-hidden="true">🇬🇧</span><span>Cet article existe aussi en anglais —<!-- --> <a href="/blog/docusaurus-copy-as-markdown/" hreflang="en">lire la version originale</a>.</span></div><div class="alertBox_R1Ot mk-callout caution_LuPi mk-callout--warn" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Traduction automatique</p><div class="content_gFLB">Cette traduction a été produite par une IA. Elle peut contenir des contresens ou des maladresses, en particulier sur les termes techniques. En cas de doute,<!-- --> <a href="/blog/docusaurus-copy-as-markdown/">consultez l'article de référence en anglais</a> <!-- -->— c'est lui qui fait foi.</div></div></div><div><div class="seriesBlogPost_bjUd"><details class="details_rTsq"><summary class="seriesIntro_HIK1 mk-eyebrow">Cet article fait partie de la série <a href="/fr/series/creating-docusaurus-components/">Créer des composants Docusaurus</a> :</summary><div class="content_OqVG"><ul><li><a href="/fr/blog/docusaurus-override-img/">Modifier la façon dont Docusaurus génère les balises img</a></li><li><a href="/fr/blog/docusaurus-lazy-loading/">Surcharger la génération des balises img avec Docusaurus</a></li><li><a href="/fr/blog/docusaurus-series/">Organisez votre contenu Docusaurus avec un composant Series personnalisé</a></li><li><a href="/fr/blog/docusaurus-go-top/">Ajouter un bouton « retour en haut » dans les articles Docusaurus</a></li><li><a href="/fr/blog/docusaurus-plugin-replace/">Créer un plugin de recherche/remplacement pour Docusaurus</a></li><li><a href="/fr/blog/docusaurus-snippets/">Un composant pour afficher des snippets de code dans un blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-old-notice/">Un composant Docusaurus qui prévient le lecteur que l'article a plus d'un an</a></li><li><a href="/fr/blog/docusaurus-changelog/">Afficher le changelog de votre article</a></li><li><a href="/fr/blog/docusaurus-ascii-art/">Injecter de l'ASCII art dans toutes les pages HTML générées par Docusaurus</a></li><li><a href="/fr/blog/blog-post-feed/">Best Practice - Personnaliser le flux RSS de Docusaurus pour un contenu complet et des images</a></li><li><a href="/fr/blog/docusaurus-check-images/">Quelques vérifications sur les images de votre site Docusaurus</a></li><li><a href="/fr/blog/docusaurus-tags/">Gestion des tags dans Docusaurus</a></li><li><a href="/fr/blog/gemini-tldr/">Automatiser les résumés TL;DR avec Gemini AI</a></li><li><a href="/fr/blog/docusaurus-project-setup/">Le composant ProjectSetup - Une façon standardisée de partager des structures de projet</a></li><li><a href="/fr/blog/docusaurus-ai-gemini/">Comment signaler un contenu assisté par IA dans un blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-terminal-typewriter/">Donnez vie à vos tutoriels CLI avec un terminal machine à écrire</a></li><li><a href="/fr/blog/docusaurus-easter-eggs/">Espiègleries de suricate : semer des easter eggs partout sur mon blog</a></li><li><a href="/fr/blog/docusaurus-eli5-snippet-tooltips/">Tooltips de code assistés par l'IA dans Docusaurus — Explique-moi comme à un enfant de cinq ans</a></li><li><a href="/fr/blog/docusaurus-reactions/">Ajouter des réactions de lecteurs à votre blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-llms-txt/">Votre blog est illisible pour une IA. Voici la solution.</a></li><li><a href="/fr/blog/docusaurus-shake-easter-egg/">Secouez votre téléphone, surprenez le suricate</a></li><li><a href="/fr/blog/docusaurus-copy-as-markdown/">Ajouter un bouton « Copy as Markdown » à votre blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-ask-my-blog/">Ask My Blog : transformer 259 articles en 2 160 questions cherchables</a></li><li><a href="/fr/blog/docusaurus-ask-my-blog-bubble/">La troisième porte : une bulle de chat flottante, et la fonctionnalité que personne ne trouvait</a></li><li><a href="/fr/blog/docusaurus-blog-map/">Dessiner une carte de mon propre blog</a></li><li><a href="/fr/blog/docusaurus-typo-report-component/">Laissez vos lecteurs signaler les problèmes — un widget de feedback multi-types pour Docusaurus</a></li></ul></div></details></div><div><p><img alt="Ajouter un bouton « Copy as Markdown » à votre blog Docusaurus" src="/fr/assets/images/copy-as-markdown-3d3e9f5ef1939be802320f5387f88654.webp" height="auto" width="" loading="lazy" decoding="async" class=""></p>
<div class="tldrContainer_qQW_ mk-callout mk-callout--key" role="region" aria-label="Résumé de l'article"><span role="img" aria-label="éclair" class="tldrIcon_SLBz mk-dot">⚡</span><div class="tldrBody_lGty"><strong class="tldrTitle_Aha8 mk-eyebrow">TL;DR</strong><div class="tldrContent_m27u"><p>Ce guide ajoute un bouton « Copy as Markdown » et un lien « View raw » à chaque article d'un blog Docusaurus. Deux petites pièces suffisent : un plugin exécuté au build qui écrit un miroir en Markdown pur à côté de la page HTML de chaque article (<code>/blog/my-post</code> → <code>/blog/my-post.md</code>), et un composant React qui récupère ce miroir et le copie dans le presse-papiers. Pas de serveur, pas de base de données, environ 90 lignes de code au total — et les lecteurs, ou le LLM dans lequel ils collent l'URL, obtiennent le texte intégral de l'article sans React, sans JSX, et sans les accordéons repliés que la page HTML masque par défaut.</p></div></div></div>
<p>Chaque fois que je veux coller un de mes propres articles dans un LLM pour lui poser une question, je passe par le même rituel agaçant : ouvrir la page, tout sélectionner, copier, coller, puis passer une minute à supprimer le menu de navigation, le widget « Was this helpful? » et une demi-douzaine de libellés d'interface venus avec le reste. Le vrai contenu de l'article — la partie que je voulais — est enfoui là-dedans quelque part.</p>
<p>La source d'un article de blog est <em>déjà</em> du Markdown. Il n'y a aucune raison qu'un lecteur doive se battre avec le HTML rendu pour la récupérer.</p>
<p>J'ai donc construit un petit bouton « Copy as Markdown » placé directement dans l'en-tête de l'article : un clic, et la source en texte brut arrive dans le presse-papiers, prête à être collée n'importe où. Cet article parcourt les deux pièces nécessaires pour le reproduire — un générateur de miroir au build et le bouton lui-même — avec le code source réel et actuel du bouton.</p>
<nav class="quickJump_q5pQ" aria-label="Quick Jump"><span class="title_Njuu mk-eyebrow">Quick Jump</span><ul class="list_A_9v"><li class="item_gTjz"><a href="#what-gets-copied">Ce qui est copié</a></li><li class="item_gTjz"><a href="#all-files-at-a-glance">Tous les fichiers en un coup d'œil</a></li></ul></nav>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="what-gets-copied">Ce qui est copié<a href="#what-gets-copied" class="hash-link" aria-label="Lien direct vers Ce qui est copié" title="Lien direct vers Ce qui est copié" translate="no"></a></h2>
<p>Voici le fichier que le bouton récupère réellement, produit par une étape <code>postBuild</code> que vous écrirez dans un instant — c'est le texte littéral qui arrive dans le presse-papiers quand un lecteur clique sur le bouton :</p>
<div class="terminal_qOWF" style="cursor:pointer" title="Cliquez pour passer l'animation"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><span class="terminal_ghost_aie1" aria-hidden="true">$ yarn build
[SUCCESS] Generated static files in "build".
[INFO] markdown-mirror-plugin: wrote 247 .md mirrors

$ curl -s https://your-domain.tld/blog/docusaurus-go-top.md | head -n 13
&lt;!--
  canonical-url: https://your-domain.tld/blog/docusaurus-go-top
  generated-at:  2026-08-12T07:56:14.431Z
  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.
--&gt;
&lt;TLDR&gt;
This article is a quick tutorial on how to add a 'scroll to top' button to your Docusaurus blog. It walks you through implementing a custom `&lt;ScrollToTopButton&gt;` React component, including its CSS for styling. You'll learn how to override the default `BlogPostItem` theme component to automatically inject the button at the bottom of every blog post. The guide also provides tips on adding the button to other parts of your site, such as the blog archive or custom pages.
&lt;/TLDR&gt;

More on smartphones than on computers; it's pretty nice to have a *Back to top of page* button to avoid dislocating your thumb.
</span><span class="terminal_overlay_fznj"><span class="cursor_o7aQ" aria-hidden="true">▋</span></span></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Même la balise <code>&lt;TLDR&gt;</code> survit intacte — suffisant pour qu'un humain parcoure le texte ou qu'un LLM l'analyse. Remarquez aussi le commentaire HTML tout en haut : c'est ainsi que le miroir renvoie vers la vraie page entièrement rendue.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="why-its-just-two-moving-parts">Pourquoi il n'y a que deux pièces mobiles<a href="#why-its-just-two-moving-parts" class="hash-link" aria-label="Lien direct vers Pourquoi il n'y a que deux pièces mobiles" title="Lien direct vers Pourquoi il n'y a que deux pièces mobiles" translate="no"></a></h2>
<ul>
<li class="">Un bouton React qui récupère une URL dérivée du permalien de la page courante et copie le texte renvoyé — il n'a aucune idée de ce que « Markdown » veut dire.</li>
<li class="">Une étape de build qui fait en sorte que cette URL réponde vraiment quelque chose qui vaille la peine d'être copié : un fichier en texte brut par article, posé tranquillement à côté de la page HTML qu'il reflète.</li>
<li class="">Comme les deux ne communiquent qu'à travers une simple URL, l'un ou l'autre peut être remplacé indépendamment plus tard — un générateur de miroir plus intelligent, un bouton plus élaboré — sans toucher à l'autre fichier.</li>
<li class="">Un miroir manquant (mauvais build, serveur de dev, faute de frappe dans l'URL) se dégrade en un message clair « Could not copy » plutôt qu'en un échec silencieux ou un crash.</li>
</ul>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="step-1--generate-a-plain-markdown-mirror-at-build-time">Étape 1 — Générer un miroir en Markdown pur au build<a href="#step-1--generate-a-plain-markdown-mirror-at-build-time" class="hash-link" aria-label="Lien direct vers Étape 1 — Générer un miroir en Markdown pur au build" title="Lien direct vers Étape 1 — Générer un miroir en Markdown pur au build" translate="no"></a></h2>
<p>Le bouton n'est que la moitié de l'histoire : sans fichier <code>.md</code> à récupérer, il finit toujours dans l'état « Could not copy ». Ce fichier doit exister <em>avant</em> que le bouton soit utile, donc construisez-le d'abord.</p>
<p>Créez <code>plugins/markdown-mirror-plugin/index.cjs</code> :</p>

<p>Quatre points méritent d'être soulignés :</p>
<div class="language-javascript codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockTitle_OeMC">plugins/markdown-mirror-plugin/index.cjs</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-javascript codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token keyword" style="color:rgb(86, 156, 214)">function</span><span class="token plain"> </span><span class="token function" style="color:rgb(220, 220, 170)">buildMetadataComment</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token parameter">url</span><span class="token parameter punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token parameter"> buildDate</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token keyword" style="color:rgb(86, 156, 214)">return</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token string" style="color:rgb(206, 145, 120)">"&lt;!--"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token template-string template-punctuation string" style="color:rgb(206, 145, 120)">`</span><span class="token template-string string" style="color:rgb(206, 145, 120)">  canonical-url: </span><span class="token template-string interpolation interpolation-punctuation punctuation" style="color:rgb(212, 212, 212)">${</span><span class="token template-string interpolation">url</span><span class="token template-string interpolation interpolation-punctuation punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token template-string template-punctuation string" style="color:rgb(206, 145, 120)">`</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token template-string template-punctuation string" style="color:rgb(206, 145, 120)">`</span><span class="token template-string string" style="color:rgb(206, 145, 120)">  generated-at:  </span><span class="token template-string interpolation interpolation-punctuation punctuation" style="color:rgb(212, 212, 212)">${</span><span class="token template-string interpolation">buildDate</span><span class="token template-string interpolation interpolation-punctuation punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token template-string template-punctuation string" style="color:rgb(206, 145, 120)">`</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token string" style="color:rgb(206, 145, 120)">"  This is a static plain-Markdown mirror generated at build time."</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token string" style="color:rgb(206, 145, 120)">"  Visit the canonical URL above for the fully rendered page, with images and interactive components."</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token string" style="color:rgb(206, 145, 120)">"--&gt;"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token string" style="color:rgb(206, 145, 120)">""</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(212, 212, 212)">]</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">join</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token string" style="color:rgb(206, 145, 120)">"\n"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><br></div></code></pre></div></div>
<p>C'est la pièce qui répond à une question que tout miroir en texte brut finit par soulever : <em>d'où vient ce fichier ?</em> Un commentaire HTML est le bon contenant pour ça — invisible dès que le fichier est collé dans quelque chose qui rend vraiment le Markdown (Notion, Obsidian, une zone de commentaire GitHub), mais toujours en texte brut dans une vue brute ou dans ce qu'on donne à un LLM, si bien que l'URL survit exactement là où un lecteur ayant perdu la trace de la page d'origine en a besoin. <code>generated-at</code> utilise un seul horodatage partagé par tous les miroirs de l'exécution (calculé une fois dans <code>postBuild</code>, pas par fichier), ce qui reflète « ce build » et non « cette milliseconde ».</p>
<div class="language-javascript codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockTitle_OeMC">plugins/markdown-mirror-plugin/index.cjs</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-javascript codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token keyword" style="color:rgb(86, 156, 214)">async</span><span class="token plain"> </span><span class="token function" style="color:rgb(220, 220, 170)">postBuild</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token parameter punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token parameter"> siteDir</span><span class="token parameter punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token parameter"> outDir</span><span class="token parameter punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token parameter"> siteConfig</span><span class="token parameter punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token parameter"> routesPaths </span><span class="token parameter punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><br></div></code></pre></div></div>
<p><code>postBuild</code> est un <a href="https://docusaurus.io/docs/api/plugin-methods/lifecycle-apis#postBuild" target="_blank" rel="noopener noreferrer" class="">lifecycle hook</a> Docusaurus — il se déclenche une seule fois, après que <code>yarn build</code> a fini d'écrire le HTML, et jamais pendant <code>yarn start</code>. C'est exactement le bon moment : le miroir n'a besoin d'exister qu'en production. <code>siteConfig.url</code> est ce qui transforme un permalien nu en URL absolue dont le commentaire ci-dessus a besoin.</p>
<div class="language-javascript codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockTitle_OeMC">plugins/markdown-mirror-plugin/index.cjs</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-javascript codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token keyword" style="color:rgb(86, 156, 214)">if</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token operator" style="color:rgb(212, 212, 212)">!</span><span class="token plain">knownRoutes</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">has</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">permalink</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">continue</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><br></div></code></pre></div></div>
<p><code>routesPaths</code> est la liste des URLs que Docusaurus lui-même a décidé de publier. La recouper — au lieu de réimplémenter à la main la logique « cet article est-il un brouillon ? » — est ce qui fait que les articles en <code>draft: true</code> disparaissent du miroir gratuitement.</p>
<div class="language-javascript codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockTitle_OeMC">plugins/markdown-mirror-plugin/index.cjs</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-javascript codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token keyword" style="color:rgb(86, 156, 214)">const</span><span class="token plain"> content </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> body</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">replace</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token string" style="color:rgb(206, 145, 120)">"&lt;!-- truncate --&gt;\n"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">""</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">trim</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">+</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"\n"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token keyword" style="color:rgb(86, 156, 214)">const</span><span class="token plain"> markdown </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> </span><span class="token function" style="color:rgb(220, 220, 170)">buildMetadataComment</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">url</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> buildDate</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">+</span><span class="token plain"> content</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><br></div></code></pre></div></div>
<p>Le bloc de frontmatter est retiré par le package <code>front-matter</code> avant même que cette ligne s'exécute ; le <code>replace</code> ici ne supprime que le marqueur <code>&lt;!-- truncate --&gt;</code>, qui n'a aucun sens en dehors de la logique de découpage des résumés propre à Docusaurus. Le commentaire de métadonnées vu plus haut est ajouté en dernier, juste avant l'écriture du fichier.</p>
<p>Branchez-le dans votre configuration :</p>
<div class="language-javascript codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockTitle_OeMC">docusaurus.config.js</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-javascript codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token keyword" style="color:rgb(86, 156, 214)">const</span><span class="token plain"> config </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token comment" style="color:rgb(106, 153, 85)">// ...</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token literal-property property">plugins</span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token string" style="color:rgb(206, 145, 120)">"./plugins/markdown-mirror-plugin/index.cjs"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">]</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><br></div></code></pre></div></div>
<div class="alertBox_R1Ot mk-callout tip_shCb mk-callout--tip" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">front-matter est un petit package sans dépendances</p><div class="content_gFLB"><p><code>npm install front-matter</code> si votre projet ne l'a pas déjà — c'est un parseur de frontmatter YAML à usage unique, pas une chaîne d'outils Markdown complète.</p></div></div></div>
<div class="alertBox_R1Ot mk-callout note_xvll mk-callout--note" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Cette version est volontairement simple</p><div class="content_gFLB"><p>Elle recopie votre source Markdown telle quelle, donc tout composant MDX personnalisé (un <code>&lt;TLDR&gt;</code>, une <code>&lt;Card&gt;</code>, vos propres widgets) apparaît dans le miroir sous forme de texte JSX brut plutôt qu'en équivalent Markdown. Pour une poignée de composants, c'est parfaitement lisible, comme le montre le miroir ci-dessus. Si votre blog s'appuie sur des dizaines de composants personnalisés comme celui-ci, voyez « Sous le capot » plus bas pour ce qu'une version plus complète doit gérer.</p></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="step-2--the-copyasmarkdown-component">Étape 2 — Le composant CopyAsMarkdown<a href="#step-2--the-copyasmarkdown-component" class="hash-link" aria-label="Lien direct vers Étape 2 — Le composant CopyAsMarkdown" title="Lien direct vers Étape 2 — Le composant CopyAsMarkdown" translate="no"></a></h2>
<p>Voici le code source réel et actuel qui tourne sur ce blog — pas un extrait simplifié :</p>

<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="21--state-and-the-derived-url">2.1 — L'état et l'URL dérivée<a href="#21--state-and-the-derived-url" class="hash-link" aria-label="Lien direct vers 2.1 — L'état et l'URL dérivée" title="Lien direct vers 2.1 — L'état et l'URL dérivée" translate="no"></a></h3>
<div class="language-javascript codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockTitle_OeMC">src/components/CopyAsMarkdown/index.tsx</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-javascript codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token keyword" style="color:rgb(86, 156, 214)">const</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token plain">status</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> setStatus</span><span class="token punctuation" style="color:rgb(212, 212, 212)">]</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> </span><span class="token function" style="color:rgb(220, 220, 170)">useState</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token string" style="color:rgb(206, 145, 120)">"idle"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"> </span><span class="token comment" style="color:rgb(106, 153, 85)">// idle | copying | copied | error</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token keyword" style="color:rgb(86, 156, 214)">const</span><span class="token plain"> mdUrl </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> </span><span class="token template-string template-punctuation string" style="color:rgb(206, 145, 120)">`</span><span class="token template-string interpolation interpolation-punctuation punctuation" style="color:rgb(212, 212, 212)">${</span><span class="token template-string interpolation">metadata</span><span class="token template-string interpolation punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token template-string interpolation">permalink</span><span class="token template-string interpolation punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token template-string interpolation function" style="color:rgb(220, 220, 170)">replace</span><span class="token template-string interpolation punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token template-string interpolation regex regex-delimiter">/</span><span class="token template-string interpolation regex regex-source language-regex escape">\/</span><span class="token template-string interpolation regex regex-source language-regex anchor function" style="color:rgb(220, 220, 170)">$</span><span class="token template-string interpolation regex regex-delimiter">/</span><span class="token template-string interpolation punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token template-string interpolation"> </span><span class="token template-string interpolation string" style="color:rgb(206, 145, 120)">""</span><span class="token template-string interpolation punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token template-string interpolation interpolation-punctuation punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token template-string string" style="color:rgb(206, 145, 120)">.md</span><span class="token template-string template-punctuation string" style="color:rgb(206, 145, 120)">`</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><br></div></code></pre></div></div>
<p><code>status</code> est une petite machine à quatre états qui pilote tout ce que le bouton affiche. <code>mdUrl</code> retire le slash final éventuel du permalien de l'article et ajoute <code>.md</code> — ce qui correspond exactement à ce que le plugin de l'étape 1 écrit sur le disque.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="22--the-copy-handler">2.2 — Le gestionnaire de copie<a href="#22--the-copy-handler" class="hash-link" aria-label="Lien direct vers 2.2 — Le gestionnaire de copie" title="Lien direct vers 2.2 — Le gestionnaire de copie" translate="no"></a></h3>
<div class="language-javascript codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockTitle_OeMC">src/components/CopyAsMarkdown/index.tsx</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-javascript codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token keyword" style="color:rgb(86, 156, 214)">const</span><span class="token plain"> handleCopy </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> </span><span class="token function" style="color:rgb(220, 220, 170)">useCallback</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token keyword" style="color:rgb(86, 156, 214)">async</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">=&gt;</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token function" style="color:rgb(220, 220, 170)">setStatus</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token string" style="color:rgb(206, 145, 120)">"copying"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token keyword" style="color:rgb(86, 156, 214)">try</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token keyword" style="color:rgb(86, 156, 214)">const</span><span class="token plain"> res </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">await</span><span class="token plain"> </span><span class="token function" style="color:rgb(220, 220, 170)">fetch</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">mdUrl</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token keyword" style="color:rgb(86, 156, 214)">if</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token operator" style="color:rgb(212, 212, 212)">!</span><span class="token plain">res</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token plain">ok</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">throw</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">new</span><span class="token plain"> </span><span class="token class-name" style="color:rgb(78, 201, 176)">Error</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token template-string template-punctuation string" style="color:rgb(206, 145, 120)">`</span><span class="token template-string string" style="color:rgb(206, 145, 120)">HTTP </span><span class="token template-string interpolation interpolation-punctuation punctuation" style="color:rgb(212, 212, 212)">${</span><span class="token template-string interpolation">res</span><span class="token template-string interpolation punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token template-string interpolation">status</span><span class="token template-string interpolation interpolation-punctuation punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token template-string template-punctuation string" style="color:rgb(206, 145, 120)">`</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token keyword" style="color:rgb(86, 156, 214)">const</span><span class="token plain"> text </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">await</span><span class="token plain"> res</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">text</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token keyword" style="color:rgb(86, 156, 214)">await</span><span class="token plain"> navigator</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token plain">clipboard</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">writeText</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">text</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token function" style="color:rgb(220, 220, 170)">setStatus</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token string" style="color:rgb(206, 145, 120)">"copied"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">catch</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">err</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    console</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token function" style="color:rgb(220, 220, 170)">error</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token string" style="color:rgb(206, 145, 120)">"CopyAsMarkdown: failed to copy"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> err</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token function" style="color:rgb(220, 220, 170)">setStatus</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token string" style="color:rgb(206, 145, 120)">"error"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token plain">mdUrl</span><span class="token punctuation" style="color:rgb(212, 212, 212)">]</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><br></div></code></pre></div></div>
<p>Un <code>fetch</code> pour le miroir, un <code>writeText</code> vers le presse-papiers, et un seul <code>catch</code> autour des deux. Ce bloc <code>catch</code> fait double emploi : il attrape un miroir manquant (404, <code>!res.ok</code>) <em>et</em> un rejet de l'API Clipboard (permission refusée, contexte non sécurisé) avec le même résultat « error » — le lecteur n'a pas besoin de savoir lequel s'est produit, seulement que la copie n'a pas fonctionné.</p>
<div class="alertBox_R1Ot mk-callout note_xvll mk-callout--note" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">L'API Clipboard exige un contexte sécurisé</p><div class="content_gFLB"><p><code>navigator.clipboard</code> n'est disponible qu'en HTTPS, ou sur <code>localhost</code>. Testez le bouton sur une simple adresse <code>http://</code> depuis n'importe quel autre host et <code>writeText</code> échouera à chaque fois — c'est le navigateur, pas un bug dans ce composant.</p></div></div></div>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="23--auto-reset">2.3 — Remise à zéro automatique<a href="#23--auto-reset" class="hash-link" aria-label="Lien direct vers 2.3 — Remise à zéro automatique" title="Lien direct vers 2.3 — Remise à zéro automatique" translate="no"></a></h3>
<div class="language-javascript codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockTitle_OeMC">src/components/CopyAsMarkdown/index.tsx</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-javascript codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token function" style="color:rgb(220, 220, 170)">useEffect</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">=&gt;</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token keyword" style="color:rgb(86, 156, 214)">if</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">status </span><span class="token operator" style="color:rgb(212, 212, 212)">!==</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"copied"</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">&amp;&amp;</span><span class="token plain"> status </span><span class="token operator" style="color:rgb(212, 212, 212)">!==</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"error"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">return</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token keyword" style="color:rgb(86, 156, 214)">const</span><span class="token plain"> timer </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> </span><span class="token function" style="color:rgb(220, 220, 170)">setTimeout</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">=&gt;</span><span class="token plain"> </span><span class="token function" style="color:rgb(220, 220, 170)">setStatus</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token string" style="color:rgb(206, 145, 120)">"idle"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token number" style="color:rgb(181, 206, 168)">2000</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token keyword" style="color:rgb(86, 156, 214)">return</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">=&gt;</span><span class="token plain"> </span><span class="token function" style="color:rgb(220, 220, 170)">clearTimeout</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain">timer</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">[</span><span class="token plain">status</span><span class="token punctuation" style="color:rgb(212, 212, 212)">]</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><br></div></code></pre></div></div>
<p>Dès que <code>status</code> passe à <code>copied</code> ou <code>error</code>, cet effet programme un retour à <code>idle</code> deux secondes plus tard, pour que le bouton ne reste pas bloqué sur « ✓ Copied » indéfiniment. La fonction de nettoyage annule un timer en attente si le lecteur reclique avant la fin de ces deux secondes.</p>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="24--rendering">2.4 — Le rendu<a href="#24--rendering" class="hash-link" aria-label="Lien direct vers 2.4 — Le rendu" title="Lien direct vers 2.4 — Le rendu" translate="no"></a></h3>
<div class="language-javascript codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockTitle_OeMC">src/components/CopyAsMarkdown/index.tsx</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-javascript codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token keyword" style="color:rgb(86, 156, 214)">return</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token operator" style="color:rgb(212, 212, 212)">&lt;</span><span class="token plain">div className</span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain">styles</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token plain">wrapper</span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token operator" style="color:rgb(212, 212, 212)">&gt;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token operator" style="color:rgb(212, 212, 212)">&lt;</span><span class="token plain">button</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">      type</span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token string" style="color:rgb(206, 145, 120)">"button"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">      className</span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain">styles</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token plain">copyBtn</span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">      onClick</span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain">handleCopy</span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">      disabled</span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain">status </span><span class="token operator" style="color:rgb(212, 212, 212)">===</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"copying"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token operator" style="color:rgb(212, 212, 212)">&gt;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">      </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain">status </span><span class="token operator" style="color:rgb(212, 212, 212)">===</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"copied"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">        </span><span class="token operator" style="color:rgb(212, 212, 212)">?</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"✓ Copied"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">        </span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> status </span><span class="token operator" style="color:rgb(212, 212, 212)">===</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"error"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">          </span><span class="token operator" style="color:rgb(212, 212, 212)">?</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"Could not copy"</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">          </span><span class="token operator" style="color:rgb(212, 212, 212)">:</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"📋 Copy as Markdown"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token operator" style="color:rgb(212, 212, 212)">&lt;</span><span class="token operator" style="color:rgb(212, 212, 212)">/</span><span class="token plain">button</span><span class="token operator" style="color:rgb(212, 212, 212)">&gt;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token operator" style="color:rgb(212, 212, 212)">&lt;</span><span class="token plain">a href</span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain">mdUrl</span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token plain"> className</span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain">styles</span><span class="token punctuation" style="color:rgb(212, 212, 212)">.</span><span class="token plain">rawLink</span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token plain"> target</span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token string" style="color:rgb(206, 145, 120)">"_blank"</span><span class="token plain"> rel</span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token string" style="color:rgb(206, 145, 120)">"noopener noreferrer"</span><span class="token operator" style="color:rgb(212, 212, 212)">&gt;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">      View raw</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token operator" style="color:rgb(212, 212, 212)">&lt;</span><span class="token operator" style="color:rgb(212, 212, 212)">/</span><span class="token plain">a</span><span class="token operator" style="color:rgb(212, 212, 212)">&gt;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token operator" style="color:rgb(212, 212, 212)">&lt;</span><span class="token operator" style="color:rgb(212, 212, 212)">/</span><span class="token plain">div</span><span class="token operator" style="color:rgb(212, 212, 212)">&gt;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><br></div></code></pre></div></div>
<p>Le libellé du bouton est une simple chaîne de ternaires basée sur <code>status</code> ; il est désactivé pendant qu'un fetch est en cours pour qu'un lecteur ne puisse pas lancer une seconde requête en pleine copie. Le lien « View raw » n'a besoin d'aucun JavaScript — il pointe directement vers <code>mdUrl</code> et laisse le navigateur faire le travail, <code>target="_blank"</code> et <code>rel="noopener noreferrer"</code> empêchant le nouvel onglet de garder une référence vers celui-ci.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="step-3--the-css-module">Étape 3 — Le module CSS<a href="#step-3--the-css-module" class="hash-link" aria-label="Lien direct vers Étape 3 — Le module CSS" title="Lien direct vers Étape 3 — Le module CSS" translate="no"></a></h2>

<p>Chaque couleur ici est une propriété personnalisée Infima (<code>--ifm-color-emphasis-*</code>), donc le bouton s'adapte au mode clair, au mode sombre ou à un thème Docusaurus entièrement personnalisé, sans une seule valeur hexadécimale en dur ni surcharge <code>[data-theme="dark"]</code> à maintenir.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="step-4--wire-it-into-the-blog-post-header">Étape 4 — L'intégrer dans l'en-tête de l'article<a href="#step-4--wire-it-into-the-blog-post-header" class="hash-link" aria-label="Lien direct vers Étape 4 — L'intégrer dans l'en-tête de l'article" title="Lien direct vers Étape 4 — L'intégrer dans l'en-tête de l'article" translate="no"></a></h2>
<p>Docusaurus rend le contenu de votre article via un composant de thème appelé <code>BlogPostItem</code>. Pour faire apparaître le bouton, il faut le swizzler :</p>
<div class="language-bash codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-bash codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token function" style="color:rgb(220, 220, 170)">yarn</span><span class="token plain"> run swizzle @docusaurus/theme-classic BlogPostItem </span><span class="token parameter variable" style="color:rgb(156, 220, 254)">--eject</span><br></div></code></pre></div></div>
<p>Confirmez l'eject quand on vous le demande — un <code>--wrap</code> ne vous donnerait pas d'endroit pour insérer le bouton entre l'en-tête et le contenu.</p>
<p>Ouvrez le <code>src/theme/BlogPostItem/index.js</code> obtenu et ajoutez trois choses : l'import, la garde <code>isBlogPostPage</code> (pour que le bouton n'apparaisse jamais sur une carte en vue liste), et le composant lui-même.</p>
<div class="language-javascript codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockTitle_OeMC">src/theme/BlogPostItem/index.js</div><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-javascript codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line theme-code-block-highlighted-line" style="color:#9CDCFE"><span class="token keyword" style="color:rgb(86, 156, 214)">import</span><span class="token plain"> CopyAsMarkdown </span><span class="token keyword" style="color:rgb(86, 156, 214)">from</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"@site/src/components/CopyAsMarkdown"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token keyword" style="color:rgb(86, 156, 214)">import</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"> useBlogPost </span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">from</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"@docusaurus/plugin-content-blog/client"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token keyword" style="color:rgb(86, 156, 214)">import</span><span class="token plain"> BlogPostItemContainer </span><span class="token keyword" style="color:rgb(86, 156, 214)">from</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"@theme/BlogPostItem/Container"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token keyword" style="color:rgb(86, 156, 214)">import</span><span class="token plain"> BlogPostItemContent </span><span class="token keyword" style="color:rgb(86, 156, 214)">from</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"@theme/BlogPostItem/Content"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token keyword" style="color:rgb(86, 156, 214)">import</span><span class="token plain"> BlogPostItemFooter </span><span class="token keyword" style="color:rgb(86, 156, 214)">from</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"@theme/BlogPostItem/Footer"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token keyword" style="color:rgb(86, 156, 214)">import</span><span class="token plain"> BlogPostItemHeader </span><span class="token keyword" style="color:rgb(86, 156, 214)">from</span><span class="token plain"> </span><span class="token string" style="color:rgb(206, 145, 120)">"@theme/BlogPostItem/Header"</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token keyword" style="color:rgb(86, 156, 214)">export</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">default</span><span class="token plain"> </span><span class="token keyword" style="color:rgb(86, 156, 214)">function</span><span class="token plain"> </span><span class="token function" style="color:rgb(220, 220, 170)">BlogPostItem</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token parameter punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token parameter"> children</span><span class="token parameter punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token parameter"> className </span><span class="token parameter punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token keyword" style="color:rgb(86, 156, 214)">const</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain"> metadata</span><span class="token punctuation" style="color:rgb(212, 212, 212)">,</span><span class="token plain"> isBlogPostPage </span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token plain"> </span><span class="token function" style="color:rgb(220, 220, 170)">useBlogPost</span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line theme-code-block-highlighted-line" style="color:#9CDCFE"><span class="token plain" style="display:inline-block"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token keyword" style="color:rgb(86, 156, 214)">return</span><span class="token plain"> </span><span class="token punctuation" style="color:rgb(212, 212, 212)">(</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token operator" style="color:rgb(212, 212, 212)">&lt;</span><span class="token plain">BlogPostItemContainer className</span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain">className</span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token operator" style="color:rgb(212, 212, 212)">&gt;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">      </span><span class="token operator" style="color:rgb(212, 212, 212)">&lt;</span><span class="token plain">BlogPostItemHeader </span><span class="token operator" style="color:rgb(212, 212, 212)">/</span><span class="token operator" style="color:rgb(212, 212, 212)">&gt;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">      </span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain">isBlogPostPage </span><span class="token operator" style="color:rgb(212, 212, 212)">&amp;&amp;</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">&lt;</span><span class="token plain">CopyAsMarkdown metadata</span><span class="token operator" style="color:rgb(212, 212, 212)">=</span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain">metadata</span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token plain"> </span><span class="token operator" style="color:rgb(212, 212, 212)">/</span><span class="token operator" style="color:rgb(212, 212, 212)">&gt;</span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">      </span><span class="token operator" style="color:rgb(212, 212, 212)">&lt;</span><span class="token plain">BlogPostItemContent</span><span class="token operator" style="color:rgb(212, 212, 212)">&gt;</span><span class="token punctuation" style="color:rgb(212, 212, 212)">{</span><span class="token plain">children</span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><span class="token operator" style="color:rgb(212, 212, 212)">&lt;</span><span class="token operator" style="color:rgb(212, 212, 212)">/</span><span class="token plain">BlogPostItemContent</span><span class="token operator" style="color:rgb(212, 212, 212)">&gt;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">      </span><span class="token operator" style="color:rgb(212, 212, 212)">&lt;</span><span class="token plain">BlogPostItemFooter </span><span class="token operator" style="color:rgb(212, 212, 212)">/</span><span class="token operator" style="color:rgb(212, 212, 212)">&gt;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    </span><span class="token operator" style="color:rgb(212, 212, 212)">&lt;</span><span class="token operator" style="color:rgb(212, 212, 212)">/</span><span class="token plain">BlogPostItemContainer</span><span class="token operator" style="color:rgb(212, 212, 212)">&gt;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">  </span><span class="token punctuation" style="color:rgb(212, 212, 212)">)</span><span class="token punctuation" style="color:rgb(212, 212, 212)">;</span><span class="token plain"></span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain"></span><span class="token punctuation" style="color:rgb(212, 212, 212)">}</span><br></div></code></pre></div></div>
<p><code>isBlogPostPage</code> vaut <code>true</code> uniquement quand un lecteur consulte un article isolé, jamais sur la page d'accueil du blog ou la grille d'archives — la même garde utilisée pour placer correctement <a href="/fr/blog/docusaurus-reactions/">le widget de réactions des lecteurs</a> et <a href="/fr/blog/docusaurus-go-top/">le bouton de retour en haut de page</a> sur ce blog même.</p>
<div class="alertBox_R1Ot mk-callout note_xvll mk-callout--note" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Sur mon propre site, il passe plutôt par l'en-tête</p><div class="content_gFLB"><p>Mon <code>BlogPostItem/index.js</code> transmet <code>&lt;CopyAsMarkdown&gt;</code> à <code>BlogPostItem/Header</code> sous forme de prop au lieu de le rendre en ligne, parce que cet en-tête gère déjà un badge « assisté par IA » et la liste des auteurs. Vous n'avez besoin de cette indirection que si votre propre en-tête fait quelque chose de similaire — pour la plupart des blogs, le rendre directement comme montré ci-dessus est plus simple et tout aussi correct.</p></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="all-files-at-a-glance">Tous les fichiers en un coup d'œil<a href="#all-files-at-a-glance" class="hash-link" aria-label="Lien direct vers Tous les fichiers en un coup d'œil" title="Lien direct vers Tous les fichiers en un coup d'œil" translate="no"></a></h2>
<div class="projectSetupContainer_LW1i mk-surface"><div class="header_Htsx mk-toolbar"><strong>📦 <!-- -->Mise en place du projet :<!-- --> <!-- -->Copy as Markdown</strong><div class="buttonGroup_y07f mk-toolbar__actions"><a class="button button--secondary button--sm" href="/project_setup" target="_blank">Aide</a><button class="button button--secondary button--sm">Tout déplier</button><button class="button button--outline button--primary button--sm" aria-label="Télécharger le projet en ZIP">ZIP</button><button class="button button--secondary button--sm">Générer le script d'installation</button></div></div><div class="content_bo8S"><div><p class="tip_qyDH"><small><span aria-hidden="true">💡</span> <!-- -->Besoin d'aide ? Cliquez sur le bouton « Aide » ci-dessus pour les instructions de mise en place.</small></p></div></div></div>
<p><code>src/theme/BlogPostItem/index.js</code> n'est pas inclus ci-dessus — comme indiqué à l'étape 4, ce fichier existe déjà sous une forme ou une autre sur votre site, et vous le modifiez, vous ne le créez pas de zéro. Affiché ici avec <a href="/fr/blog/docusaurus-snippets/">le même composant de snippets de code</a> utilisé tout au long de cet article.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="try-it-yourself">À vous d'essayer<a href="#try-it-yourself" class="hash-link" aria-label="Lien direct vers À vous d'essayer" title="Lien direct vers À vous d'essayer" translate="no"></a></h2>
<ol>
<li class="">Ajoutez le plugin à <code>docusaurus.config.js</code> et créez les trois fichiers ci-dessus.</li>
<li class="">Lancez <code>yarn build</code> — pas <code>yarn start</code>. Les hooks <code>postBuild</code> ne se déclenchent jamais sur le serveur de dev, donc le miroir n'existe réellement pas avant un vrai build.</li>
<li class="">Servez la sortie localement, par exemple <code>npx serve build</code>, et ouvrez n'importe quel article.</li>
<li class="">Cliquez sur <strong>Copy as Markdown</strong>, puis collez dans un éditeur de texte. Vous devriez voir la source Markdown brute, balises <code>&lt;TLDR&gt;</code> comprises.</li>
<li class="">Cliquez sur <strong>View raw</strong>. Le fichier <code>.md</code> devrait s'ouvrir en texte brut dans un nouvel onglet.</li>
</ol>
<div class="alertBox_R1Ot mk-callout warning_xBFJ mk-callout--warn" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Auto-hébergé sur Apache ? Attention au type MIME</p><div class="content_gFLB"><p>Si l'étape 5 déclenche une invite de téléchargement au lieu d'afficher le fichier, votre serveur ne sait pas que <code>.md</code> est du texte. Ajoutez <code>AddType text/markdown .md</code> à votre <code>.htaccess</code> (ou l'équivalent Nginx, un bloc <code>types</code>) pour qu'il soit servi en ligne.</p></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="under-the-hood-skip-this-if-you-just-want-to-use-it">Sous le capot (passez si vous voulez juste l'utiliser)<a href="#under-the-hood-skip-this-if-you-just-want-to-use-it" class="hash-link" aria-label="Lien direct vers Sous le capot (passez si vous voulez juste l'utiliser)" title="Lien direct vers Sous le capot (passez si vous voulez juste l'utiliser)" translate="no"></a></h2>
<p><strong>Pourquoi le miroir ne peut pas entrer en collision avec l'URL de l'article.</strong> <code>/blog/my-post</code> est un <em>répertoire</em> contenant un <code>index.html</code> — c'est ainsi que Docusaurus sert des URLs propres. <code>/blog/my-post.md</code> est un <em>fichier</em> voisin dans ce même répertoire. Même dossier, fichiers différents, zéro conflit de routage.</p>
<p><strong>Pourquoi <code>postBuild</code>, et pas un loader webpack ou une route en dev.</strong> Un loader devrait rejouer la même logique MDX-vers-texte à chaque hot reload sans aucun bénéfice — personne ne copie un article depuis un blog qu'il est en train d'éditer. Restreindre le miroir à la production garde le serveur de dev rapide et la logique en un seul endroit.</p>
<p><strong>Là où la version simple ne suffit plus.</strong> Le plugin de l'étape 1 fait passer tout composant personnalisé tel quel, en texte JSX littéral. C'est acceptable pour un bloc <code>&lt;TLDR&gt;</code> isolé, mais ça cesse vite de ressembler à du Markdown dès qu'un article s'appuie sur des dizaines de composants — snippets de code, alertes, cartes étape par étape, etc. Le faire correctement implique de parser le MDX en véritable arbre syntaxique et de remplacer chaque nœud de composant par un équivalent en Markdown pur, composant par composant. C'est un projet nettement plus gros qu'un bouton et une boucle de copie — le genre de chose pour laquelle existe le pipeline de dégradation, beaucoup plus imposant, de ce blog — et un bon candidat pour un article de suivi à part entière.</p>
<p><strong>Une date de frontmatter n'est pas une chaîne.</strong> Si vous étendez le commentaire de métadonnées pour afficher aussi le champ <code>date:</code> de l'article, ne le concaténez pas directement comme une chaîne. YAML type automatiquement un <code>date: 2024-02-23</code> non quoté en objet <code>Date</code> JS, et <code>`${date}`</code> appelle son <code>toString()</code> par défaut, très verbeux (<code>Mon Jul 27 2026 00:00:00 GMT+0000 (Coordinated Universal Time)</code>). Normalisez-le d'abord : <code>date instanceof Date ? date.toISOString().slice(0, 10) : String(date)</code>.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="conclusion">Conclusion<a href="#conclusion" class="hash-link" aria-label="Lien direct vers Conclusion" title="Lien direct vers Conclusion" translate="no"></a></h2>
<p>Une petite centaine de lignes de code, réparties entre un plugin de build et un composant React, suffisent pour arrêter de se battre avec le HTML rendu chaque fois que vous voulez le texte brut d'un article. Le bouton ne sait pas ce qu'est le Markdown, et le plugin ne sait pas ce qu'est un bouton — chacun fait un petit travail, et l'ensemble tient debout parce que ni l'un ni l'autre n'a besoin de connaître les entrailles de l'autre.</p>
<p>Si vous partez de là, l'étape suivante naturelle est celle signalée dans <em>Sous le capot</em> : apprendre au générateur de miroir à vraiment dégrader vos composants personnalisés au lieu de les recopier en JSX brut. En attendant, cette version résout déjà le problème qui a tout déclenché — plus de chirurgie manuelle de copier-coller, ni pour moi ni pour quiconque veut donner un de mes articles à un LLM.</p></div><hr><div class="seriesBlogPost_bjUd"><details class="details_rTsq"><summary class="seriesIntro_HIK1 mk-eyebrow">Cet article fait partie de la série <a href="/fr/series/creating-docusaurus-components/">Créer des composants Docusaurus</a> :</summary><div class="content_OqVG"><ul><li><a href="/fr/blog/docusaurus-override-img/">Modifier la façon dont Docusaurus génère les balises img</a></li><li><a href="/fr/blog/docusaurus-lazy-loading/">Surcharger la génération des balises img avec Docusaurus</a></li><li><a href="/fr/blog/docusaurus-series/">Organisez votre contenu Docusaurus avec un composant Series personnalisé</a></li><li><a href="/fr/blog/docusaurus-go-top/">Ajouter un bouton « retour en haut » dans les articles Docusaurus</a></li><li><a href="/fr/blog/docusaurus-plugin-replace/">Créer un plugin de recherche/remplacement pour Docusaurus</a></li><li><a href="/fr/blog/docusaurus-snippets/">Un composant pour afficher des snippets de code dans un blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-old-notice/">Un composant Docusaurus qui prévient le lecteur que l'article a plus d'un an</a></li><li><a href="/fr/blog/docusaurus-changelog/">Afficher le changelog de votre article</a></li><li><a href="/fr/blog/docusaurus-ascii-art/">Injecter de l'ASCII art dans toutes les pages HTML générées par Docusaurus</a></li><li><a href="/fr/blog/blog-post-feed/">Best Practice - Personnaliser le flux RSS de Docusaurus pour un contenu complet et des images</a></li><li><a href="/fr/blog/docusaurus-check-images/">Quelques vérifications sur les images de votre site Docusaurus</a></li><li><a href="/fr/blog/docusaurus-tags/">Gestion des tags dans Docusaurus</a></li><li><a href="/fr/blog/gemini-tldr/">Automatiser les résumés TL;DR avec Gemini AI</a></li><li><a href="/fr/blog/docusaurus-project-setup/">Le composant ProjectSetup - Une façon standardisée de partager des structures de projet</a></li><li><a href="/fr/blog/docusaurus-ai-gemini/">Comment signaler un contenu assisté par IA dans un blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-terminal-typewriter/">Donnez vie à vos tutoriels CLI avec un terminal machine à écrire</a></li><li><a href="/fr/blog/docusaurus-easter-eggs/">Espiègleries de suricate : semer des easter eggs partout sur mon blog</a></li><li><a href="/fr/blog/docusaurus-eli5-snippet-tooltips/">Tooltips de code assistés par l'IA dans Docusaurus — Explique-moi comme à un enfant de cinq ans</a></li><li><a href="/fr/blog/docusaurus-reactions/">Ajouter des réactions de lecteurs à votre blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-llms-txt/">Votre blog est illisible pour une IA. Voici la solution.</a></li><li><a href="/fr/blog/docusaurus-shake-easter-egg/">Secouez votre téléphone, surprenez le suricate</a></li><li><a href="/fr/blog/docusaurus-copy-as-markdown/">Ajouter un bouton « Copy as Markdown » à votre blog Docusaurus</a></li><li><a href="/fr/blog/docusaurus-ask-my-blog/">Ask My Blog : transformer 259 articles en 2 160 questions cherchables</a></li><li><a href="/fr/blog/docusaurus-ask-my-blog-bubble/">La troisième porte : une bulle de chat flottante, et la fonctionnalité que personne ne trouvait</a></li><li><a href="/fr/blog/docusaurus-blog-map/">Dessiner une carte de mon propre blog</a></li><li><a href="/fr/blog/docusaurus-typo-report-component/">Laissez vos lecteurs signaler les problèmes — un widget de feedback multi-types pour Docusaurus</a></li></ul></div></details></div></div><div class="relatedPosts_zJiB"><h3>Articles liés</h3></div><div class="container_mNR1"><span class="question_WdqX">Cet article vous a-t-il été utile ?</span><div class="buttons_vgpi"><button class="btn_svQp" aria-label="Oui, cet article m'a été utile"><img src="/fr/img/reaction-mascot-yes.webp" alt="" class="mascot_Fait">Utile</button><button class="btn_svQp btnNeutral_Iqek" aria-label="Non, cet article ne m'a pas été utile"><img src="/fr/img/reaction-mascot-no.webp" alt="" class="mascot_Fait">Pas vraiment</button></div></div><div class="container_IJwe"><span class="question_nb7b">Avez-vous suivi les étapes de cet article ?</span><div class="buttons_Xkzw"><button class="btn_RMiq" aria-label="Oui, j'ai essayé et ça a marché"><img src="/fr/img/like.webp" alt="" class="mascot_wB1d">Ça a marché !</button><button class="btn_RMiq btnNeutral_z71B" aria-label="Non, ça n'a pas marché pour moi"><img src="/fr/img/dislike.webp" alt="" class="mascot_wB1d">Ça n'a pas marché</button></div></div>]]></content:encoded>
            <author>Christophe</author>
            <category>docusaurus</category>
            <category>markdown</category>
            <category>react</category>
            <category>component</category>
            <enclosure length="0" type="image/webp" url="https://www.avonture.be/fr/img/v2/copy-as-markdown.webp"/>
        <dc:creator><![CDATA[Christophe]]></dc:creator></item>
        <item>
            <title><![CDATA[Open a DevContainer on a Remote Server with VSCode Remote SSH and ProxyJump]]></title>
            <link>https://www.avonture.be/fr/fr/blog/vscode-remote-ssh-proxyjump-devcontainer</link>
            <guid isPermaLink="false">/fr/blog/vscode-remote-ssh-proxyjump-devcontainer</guid>
            <pubDate>Fri, 04 Sep 2026 00:00:00 GMT</pubDate>
            <description><![CDATA[<img src="https://www.avonture.be/fr/img/v2/ssh-devcontainer.webp" alt="Open a DevContainer on a Remote Server with VSCode Remote SSH and ProxyJump" style="display:block;max-width:100%;height:auto;">Chain VSCode Remote SSH, ProxyJump and Dev Containers to open a devcontainer that runs directly on a remote Linux server — so your scripts can reach services that are only accessible from that server's network.]]></description>
            <content:encoded><![CDATA[<div class="offer_jqr1"><span class="flag_Hnep" aria-hidden="true">🇬🇧</span><span>Cet article existe aussi en anglais —<!-- --> <a href="/blog/vscode-remote-ssh-proxyjump-devcontainer/" hreflang="en">lire la version originale</a>.</span></div><div class="alertBox_R1Ot mk-callout caution_LuPi mk-callout--warn" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Traduction automatique</p><div class="content_gFLB">Cette traduction a été produite par une IA. Elle peut contenir des contresens ou des maladresses, en particulier sur les termes techniques. En cas de doute,<!-- --> <a href="/blog/vscode-remote-ssh-proxyjump-devcontainer/">consultez l'article de référence en anglais</a> <!-- -->— c'est lui qui fait foi.</div></div></div><div><div class="seriesBlogPost_bjUd"><details class="details_rTsq"><summary class="seriesIntro_HIK1 mk-eyebrow">Cet article fait partie de la série <a href="/fr/series/ssh-from-your-first-key-to-remote-development/">SSH — de votre première clé au développement distant</a> :</summary><div class="content_OqVG"><ul><li><a href="/fr/blog/github-connect-using-ssh/">GitHub - Connectez votre compte en SSH et travaillez avec le protocole git@</a></li><li><a href="/fr/blog/docker-use-ssh-during-build/">Docker secrets : utiliser votre clé SSH pendant le build</a></li><li><a href="/fr/blog/linux-sftp-cli/">Utiliser sftp en ligne de commande, avec ou sans proxy</a></li><li><a href="/fr/blog/zsh-plugin-ssh-config-suggestions/">SSH - Autosuggestions avec ZSH</a></li><li><a href="/fr/blog/linux-ssh-scp/">SSH - Lancer un terminal sur votre session sans avoir à vous authentifier</a></li><li><a href="/fr/blog/connect-using-ssh-to-your-hosting-server/">Comment se connecter à votre serveur d'hébergement en SSH</a></li><li><a href="/fr/blog/vscode-remote-ssh/">Développement distant en SSH avec VSCode</a></li><li><a href="/fr/blog/vscode-remote-ssh-proxyjump-devcontainer/">Ouvrir un DevContainer sur un serveur distant avec VSCode Remote SSH et ProxyJump</a></li><li><a href="/fr/blog/ssh-config-tips/">Générateur de config SSH : stanzas wildcard, alias et connexions automatisées</a></li></ul></div></details></div><div><p><img alt="Ouvrir un DevContainer sur un serveur distant avec VSCode Remote SSH et ProxyJump" src="/fr/assets/images/ssh-devcontainer-3cb8299ab418c419c01b24cdfd824006.webp" height="auto" width="" loading="lazy" decoding="async" class=""></p>
<div class="tldrContainer_qQW_ mk-callout mk-callout--key" role="region" aria-label="Résumé de l'article"><span role="img" aria-label="éclair" class="tldrIcon_SLBz mk-dot">⚡</span><div class="tldrBody_lGty"><strong class="tldrTitle_Aha8 mk-eyebrow">TL;DR</strong><div class="tldrContent_m27u"><p>Les extensions Remote - SSH et Dev Containers de VSCode s'enchaînent : vous vous connectez à un serveur Linux distant via SSH, puis vous rouvrez le projet dans un devcontainer qui tourne <em>sur ce serveur</em>. Le container hérite des privilèges réseau du serveur : tout service uniquement joignable depuis ce serveur — un endpoint SFTP privé, une API sécurisée — devient joignable depuis votre terminal. Deux chemins pour y arriver : ProxyJump depuis votre portable (un clic, transparent), ou VSCode sur une VM Windows intermédiaire (solution de repli si le SSH direct n'est pas disponible).</p></div></div></div>
<p>Je devais écrire un script Bash qui récupère des fichiers sur un serveur SFTP et envoie des requêtes POST à une API sécurisée. Assez simple — sauf que les deux se trouvent sur un segment réseau privé uniquement joignable depuis un serveur Linux précis. Et ce serveur Linux n'est lui-même joignable que depuis une VM Windows au bureau. Mon portable, au bout de la chaîne, ne voit aucun des deux services directement.</p>
<p>Sans la technique ci-dessous, le workflow est lourd : ouvrir un terminal sur la VM, faire un SSH vers le serveur Linux, éditer le script avec <code>nano</code>, l'exécuter, copier-coller les erreurs vers mon éditeur. Pas génial.</p>
<p><strong>Ou bien je peux me passer complètement de la VM et travailler directement depuis mon portable. Cet article présente les deux chemins — et la route directe depuis le portable est plus simple qu'elle n'y paraît.</strong></p>
<p>Avec VSCode Remote SSH enchaîné à Dev Containers, le workflow devient : ouvrir VSCode, se connecter une fois, et travailler exactement comme en local — éditeur complet, devcontainer complet, et le serveur SFTP ainsi que l'API joignables depuis le terminal intégré. Il y a deux façons de mettre ça en place, et l'article couvre les deux.</p>
<p><em>Prérequis : vous devez déjà être à l'aise avec les bases de VSCode Remote SSH. Sinon, <a href="/fr/blog/vscode-remote-ssh/">Développement distant en SSH avec VSCode</a> couvre l'installation de l'extension et le fichier de configuration SSH depuis zéro.</em></p>
<nav class="quickJump_q5pQ" aria-label="Quick Jump"><span class="title_Njuu mk-eyebrow">Quick Jump</span><ul class="list_A_9v"><li class="item_gTjz"><a href="#recommended-approach--proxyjump-from-your-laptop">Approche recommandée — ProxyJump depuis votre portable</a></li><li class="item_gTjz"><a href="#fallback-approach--vscode-on-the-windows-vm">Approche de repli — VSCode sur la VM Windows</a></li><li class="item_gTjz"><a href="#connect-with-vscode">Se connecter avec VSCode</a></li><li class="item_gTjz"><a href="#open-the-devcontainer">Ouvrir le DevContainer</a></li></ul></nav>
<div class="varsbar_xL7w mk-surface"><div class="varsbarHead_nacC"><span class="varsbarTitle_d9dG mk-eyebrow"><span class="dot_kctR"></span>Vos valeurs pour cette page</span><button class="resetLink_X21M" type="button">Revenir aux valeurs par défaut</button></div><div class="varsbarFields_ZGsv"><div class="field_MGlw"><label for="vars-_R_12rihmmk5_-vmUser">Nom d'utilisateur de la VM</label><div class="inputWrap_vL5t"><input id="vars-_R_12rihmmk5_-vmUser" class="mono_VfQ7" type="text" inputmode="text" value="vm-user"><span class="badgeDefault_OkED">défaut</span></div></div><div class="field_MGlw"><label for="vars-_R_12rihmmk5_-vmIp">Nom d'hôte ou IP de la VM</label><div class="inputWrap_vL5t"><input id="vars-_R_12rihmmk5_-vmIp" class="mono_VfQ7" type="text" inputmode="text" value="windows-vm-ip"><span class="badgeDefault_OkED">défaut</span></div></div><div class="field_MGlw"><label for="vars-_R_12rihmmk5_-devUser">Nom d'utilisateur du serveur Linux</label><div class="inputWrap_vL5t"><input id="vars-_R_12rihmmk5_-devUser" class="mono_VfQ7" type="text" inputmode="text" value="dev-user"><span class="badgeDefault_OkED">défaut</span></div></div><div class="field_MGlw"><label for="vars-_R_12rihmmk5_-linuxHost">Nom d'hôte du serveur Linux</label><div class="inputWrap_vL5t"><input id="vars-_R_12rihmmk5_-linuxHost" class="mono_VfQ7" type="text" inputmode="text" value="test.example.internal"><span class="badgeDefault_OkED">défaut</span></div></div><div class="field_MGlw"><label for="vars-_R_12rihmmk5_-linuxAlias">Alias SSH</label><div class="inputWrap_vL5t"><input id="vars-_R_12rihmmk5_-linuxAlias" class="mono_VfQ7" type="text" inputmode="text" value="linux-test"><span class="badgeDefault_OkED">défaut</span></div></div><div class="field_MGlw"><label for="vars-_R_12rihmmk5_-sshKey">Nom de la clé SSH</label><div class="inputWrap_vL5t"><input id="vars-_R_12rihmmk5_-sshKey" class="mono_VfQ7" type="text" inputmode="text" value="id_ed25519"><span class="badgeDefault_OkED">défaut</span></div></div></div><div class="varsbarFoot_vsF4"><span>S'applique aux commandes de cette page</span><span>Enregistré uniquement sur cet appareil</span></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="what-you-get-at-the-end">Ce que vous obtenez au final<a href="#what-you-get-at-the-end" class="hash-link" aria-label="Lien direct vers Ce que vous obtenez au final" title="Lien direct vers Ce que vous obtenez au final" translate="no"></a></h2>
<p>Avec l'approche recommandée, l'architecture ressemble à ceci :</p>
<!-- -->
<p>VSCode tourne sur votre portable et se connecte au serveur Linux en un clic — la VM Windows n'est qu'un relais dans la configuration SSH, invisible à l'usage. Le devcontainer tourne sur le serveur Linux, donc il hérite du réseau du serveur : le serveur SFTP et l'API sécurisée sont joignables depuis l'intérieur du container, même s'ils sont invisibles depuis votre portable.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="which-approach-works-for-you">Quelle approche vous convient ?<a href="#which-approach-works-for-you" class="hash-link" aria-label="Lien direct vers Quelle approche vous convient ?" title="Lien direct vers Quelle approche vous convient ?" translate="no"></a></h2>
<p>Deux situations, deux chemins :</p>
<!-- -->
<div class="alertBox_R1Ot mk-callout note_xvll mk-callout--note" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Serveur SSH sur la VM Windows</p><div class="content_gFLB"><p>L'approche recommandée nécessite un serveur SSH sur la VM Windows. Si vous êtes admin sur la VM, il suffit d'une commande pour installer le serveur SSH.</p></div></div></div>
<p><strong>Pour vérifier si l'approche recommandée est disponible</strong>, lancez ceci depuis le terminal de votre portable :</p>
<div class="terminal_qOWF" style="cursor:pointer" title="Cliquez pour passer l'animation"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><span class="terminal_ghost_aie1" aria-hidden="true"><p>ssh -J <span class="token_SjcP">vm-user</span>@<span class="token_SjcP">windows-vm-ip</span> <span class="token_SjcP">dev-user</span>@<span class="token_SjcP">test.example.internal</span> "whoami &amp;&amp; hostname"</p></span><span class="terminal_overlay_fznj"><span class="cursor_o7aQ" aria-hidden="true">▋</span></span></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<ul>
<li class="">Ça affiche <code class="tokenInline_V0MS">dev-user</code> et le nom d'hôte du serveur Linux → <strong>utilisez l'approche recommandée</strong>.</li>
<li class="">Ça part en timeout → votre portable ne peut pas joindre la VM du tout → <strong>utilisez l'approche de repli</strong>.</li>
<li class=""><code>Connection refused</code> sur le port 22 → la VM est joignable mais OpenSSH Server n'est pas installé → <strong>voyez le correctif ci-dessous</strong>.</li>
</ul>
<p><em>La seconde ligne est la sortie de <code>hostname</code> sur la machine distante — elle reflète le nom défini dans <code>/etc/hostname</code>, qui peut différer de l'adresse utilisée pour vous connecter. C'est normal ; ce qui compte, c'est que la commande ait renvoyé quelque chose plutôt que de partir en timeout ou d'être refusée.</em></p>
<details class="details_rTsq"><summary>Correctif : installer OpenSSH Server sur la VM Windows (droits admin requis)</summary><div class="content_OqVG"><p><code>Connection refused</code> signifie que le chemin réseau fonctionne — seul le service SSH manque. Si vous avez les droits admin sur la VM, ouvrez un PowerShell <strong>en tant qu'administrateur</strong> et lancez :</p><div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">vm-user@windows-vm-ip: PowerShell (Administrator)</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>$ Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0
$ Start-Service sshd
$ Set-Service -Name sshd -StartupType Automatic</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div><p>Une fois fait, relancez le test depuis votre portable — il devrait maintenant afficher <code class="tokenInline_V0MS">dev-user</code> et le nom d'hôte du serveur. Si c'est le cas, suivez l'<strong>approche recommandée</strong>.</p><p><strong>L'autorisation de la clé est optionnelle.</strong> La connexion fonctionne aussi avec l'authentification par mot de passe — on vous demandera simplement le mot de passe de la VM à chaque connexion de VSCode. Pour un usage occasionnel, c'est acceptable ; pour un usage quotidien, la clé rend la connexion totalement silencieuse.</p><p><em>Vous préférez une installation graphique ? Ouvrez <strong>Paramètres → Système → Fonctionnalités optionnelles</strong>, cherchez <strong>OpenSSH Server</strong> et installez-le depuis là — puis lancez les commandes <code>Start-Service</code> et <code>Set-Service</code> ci-dessus pour le démarrer et l'activer.</em></p><p><strong>Remarque</strong> Si vous rencontrez des difficultés à l'installation d'OpenSSH Server (en CLI ou en interface graphique), vous pouvez en alternative télécharger le fichier <code>.msi</code> à la main depuis <a href="https://github.com/PowerShell/Win32-OpenSSH/releases" target="_blank" rel="noopener noreferrer" class="">https://github.com/PowerShell/Win32-OpenSSH/releases</a>. Une fois téléchargé, double-cliquez sur le fichier <code>.msi</code> pour lancer l'installation.</p></div></details>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="why-it-works">Pourquoi ça fonctionne<a href="#why-it-works" class="hash-link" aria-label="Lien direct vers Pourquoi ça fonctionne" title="Lien direct vers Pourquoi ça fonctionne" translate="no"></a></h2>
<ul>
<li class=""><strong>Deux extensions, une seule chaîne.</strong> Remote - SSH lance un processus VS Code Server sur l'host Linux. Dev Containers démarre ensuite un container sur <em>cet host</em> et y connecte VSCode — le container tourne sur le moteur Docker du serveur, pas sur celui de votre portable.</li>
<li class=""><strong>Le privilège réseau est hérité de l'host.</strong> La pile réseau du container vit sur le serveur Linux, donc chaque connexion sortante part de l'IP du serveur. Les services qui bloquent l'adresse de votre portable laissent passer le serveur.</li>
<li class=""><strong>ProxyJump délègue le relais à SSH.</strong> Avec <code>ProxyJump</code> dans <code>~/.ssh/config</code>, SSH ouvre une connexion vers la VM, puis y fait passer une seconde connexion SSH vers le serveur Linux. VSCode ne voit qu'un seul host et un seul clic.</li>
<li class=""><strong>Aucun transfert de fichiers.</strong> Les fichiers vivent sur le serveur. L'éditeur les lit et les écrit directement — pas de <code>scp</code>, pas de rsync, pas d'étape d'upload.</li>
</ul>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="configure-ssh">Configurer SSH<a href="#configure-ssh" class="hash-link" aria-label="Lien direct vers Configurer SSH" title="Lien direct vers Configurer SSH" translate="no"></a></h2>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="recommended-approach--proxyjump-from-your-laptop">Approche recommandée — ProxyJump depuis votre portable<a href="#recommended-approach--proxyjump-from-your-laptop" class="hash-link" aria-label="Lien direct vers Approche recommandée — ProxyJump depuis votre portable" title="Lien direct vers Approche recommandée — ProxyJump depuis votre portable" translate="no"></a></h3>
<h4 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="prerequisites">Prérequis<a href="#prerequisites" class="hash-link" aria-label="Lien direct vers Prérequis" title="Lien direct vers Prérequis" translate="no"></a></h4>
<p>Votre clé privée SSH (<code>~/.ssh/</code><code class="tokenInline_V0MS">id_ed25519</code>) doit se trouver sur votre portable — si vous n'en avez pas encore, <a href="/fr/blog/github-connect-using-ssh/">cet article détaille la génération d'une paire de clés ed25519</a>. ProxyJump fait passer l'authentification dans le tunnel — la clé de votre portable authentifie les deux hosts, donc elle n'a jamais besoin de quitter votre machine. L'authentification par mot de passe fonctionne aussi si l'authentification par clé n'est pas en place sur la VM <code class="tokenInline_V0MS">windows-vm-ip</code>.</p>
<h4 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="configure-ssh-on-your-laptop">Configurer SSH sur votre portable<a href="#configure-ssh-on-your-laptop" class="hash-link" aria-label="Lien direct vers Configurer SSH sur votre portable" title="Lien direct vers Configurer SSH sur votre portable" translate="no"></a></h4>
<p>Créez le fichier <code>C:\Users\your_laptop_user\.ssh\config</code> (Windows ; WSL inclus) ou <code>~/.ssh/config</code> (si vous êtes sous Linux/macOS) sur votre portable :</p>
<div class="language-txt codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-txt codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">Host linux-test</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    Hostname test.example.internal</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    User dev-user</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    ProxyJump vm-user@windows-vm-ip</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    IdentityFile ~/.ssh/id_ed25519</span><br></div></code></pre></div></div>
<p>La ligne <code>ProxyJump</code> indique à SSH d'atteindre <code class="tokenInline_V0MS">linux-test</code> en passant par la VM <code class="tokenInline_V0MS">windows-vm-ip</code>. À partir de là, <code>ssh </code><code class="tokenInline_V0MS">linux-test</code> depuis votre portable se connecte directement au serveur Linux — plus d'étape manuelle sur la VM.</p>
<h4 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="test-the-full-chain">Tester la chaîne complète<a href="#test-the-full-chain" class="hash-link" aria-label="Lien direct vers Tester la chaîne complète" title="Lien direct vers Tester la chaîne complète" translate="no"></a></h4>
<p>Lancez la commande avec l'alias nommé, maintenant que la configuration est en place :</p>
<div class="terminal_qOWF" style="cursor:pointer" title="Cliquez pour passer l'animation"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">laptop: ~</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><span class="terminal_ghost_aie1" aria-hidden="true"><p>$ ssh <span class="token_SjcP">linux-test</span> "whoami &amp;&amp; hostname"</p></span><span class="terminal_overlay_fznj"><span class="cursor_o7aQ" aria-hidden="true">▋</span></span></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>La première exécution ressemblera à ceci :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">laptop: ~</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb">$ ssh -J <span class="token_SjcP">vm-user</span>@<span class="token_SjcP">windows-vm-ip</span> <span class="token_SjcP">dev-user</span>@<span class="token_SjcP">test.example.internal</span> "whoami &amp;&amp; hostname"
The authenticity of host '<span class="token_SjcP">windows-vm-ip</span> (1.2.3.4)' can't be established.
ED25519 key fingerprint is SHA256:AbCdEfGhIjKlMnOpQrStUvWxYz0123456789ABCD.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '<span class="token_SjcP">windows-vm-ip</span>' (ED25519) to the known hosts.
<span class="token_SjcP">vm-user</span>@<span class="token_SjcP">windows-vm-ip</span>'s password:
The authenticity of host '<span class="token_SjcP">test.example.internal</span> (&lt;no hostip for proxy command&gt;)' can't be established.
ED25519 key fingerprint is SHA256:AbCdEfGhIjKlMnOpQrStUvWxYz0123456789BCDE.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '<span class="token_SjcP">test.example.internal</span>' (ED25519) to the known hosts.
<span class="token_SjcP">dev-user</span>@<span class="token_SjcP">test.example.internal</span>'s password:
<span class="token_SjcP">dev-user</span>
<span class="token_SjcP">test.example.internal</span>
</pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Deux choses se produisent ici, toutes deux normales et uniquement à la première fois :</p>
<ul>
<li class=""><strong>Les demandes de clé d'hôte</strong> (<code>The authenticity of host ... can't be established</code>) : SSH n'a jamais vu ces hosts. Tapez <code>yes</code> pour chacun — l'empreinte est stockée dans <code>~/.ssh/known_hosts</code> et la question ne revient plus.</li>
<li class=""><strong>Les demandes de mot de passe</strong> : une pour la VM et une pour le serveur Linux — la connexion fonctionne dans tous les cas. Pour la rendre totalement silencieuse, autorisez votre clé publique sur chaque host (voir ci-dessous).</li>
</ul>
<details class="details_rTsq"><summary>Optionnel : supprimer les demandes de mot de passe avec l'authentification par clé</summary><div class="content_OqVG"><p>Il y a deux sauts, donc deux endroits où autoriser votre clé. Faites-les dans l'ordre.</p><h5 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="step-1--authorize-your-key-on-the-windows-vm">Étape 1 — autoriser votre clé sur la VM Windows<a href="#step-1--authorize-your-key-on-the-windows-vm" class="hash-link" aria-label="Lien direct vers Étape 1 — autoriser votre clé sur la VM Windows" title="Lien direct vers Étape 1 — autoriser votre clé sur la VM Windows" translate="no"></a></h5><p>Pour les comptes admin sous Windows, OpenSSH lit un fichier commun au système — et <strong>pas</strong> le <code>~\.ssh\authorized_keys</code> de l'utilisateur. Le dossier <code>C:\ProgramData\ssh\</code> a des ACL restrictives : même un membre du groupe Administrateurs a besoin d'une session PowerShell <strong>réellement élevée</strong> (clic droit → <em>Exécuter en tant qu'administrateur</em>). Sur la VM :</p><p><strong>Étape 1a</strong> — sur votre portable, affichez votre clé publique et copiez la ligne entière :</p><div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">laptop: ~</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>$ cat ~/.ssh/<span class="token_SjcP">id_ed25519</span>.pub</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div><p><strong>Étape 1b</strong> — sur la VM (PowerShell en administrateur), ajoutez la clé copiée :</p><div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">vm-user@windows-vm-ip: PowerShell (Administrator)</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>$ Add-Content C:\ProgramData\ssh\administrators_authorized_keys "ssh-ed25519 AAAA...your-key-here"</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div><p>Relancez le test — la demande de mot de passe de la VM devrait avoir disparu. Le serveur Linux demandera encore un mot de passe ; c'est l'étape 2.</p><h5 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="step-2--authorize-your-key-on-the-linux-server">Étape 2 — autoriser votre clé sur le serveur Linux<a href="#step-2--authorize-your-key-on-the-linux-server" class="hash-link" aria-label="Lien direct vers Étape 2 — autoriser votre clé sur le serveur Linux" title="Lien direct vers Étape 2 — autoriser votre clé sur le serveur Linux" translate="no"></a></h5><p>Avec ProxyJump déjà configuré dans <code>~/.ssh/config</code>, <code>ssh-copy-id</code> fonctionne de façon transparente — il passe par la VM et copie votre clé sur le serveur Linux en une seule commande :</p><div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">laptop: ~</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb">$ ssh-copy-id <span class="token_SjcP">linux-test</span>
/usr/bin/ssh-copy-id: INFO: attempting to log in with the new key(s), to filter out any that are already installed
/usr/bin/ssh-copy-id: INFO: 1 key(s) remain to be installed -- if you are prompted now it is to install the new keys
<span class="token_SjcP">dev-user</span>@<span class="token_SjcP">linux-test</span>'s password:

Number of key(s) added: 1

Now try logging into the machine, with:   "ssh '<span class="token_SjcP">linux-test</span>'"
and check to make sure that only the key(s) you wanted were added.
</pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div><p>Il demande le mot de passe du serveur Linux une dernière fois, puis ajoute votre clé publique à <code>~/.ssh/authorized_keys</code> sur le serveur. Après ça, toute la chaîne est silencieuse — plus aucune question.</p></div></details>
<nav class="quickJump_q5pQ" aria-label="Quick Jump"><span class="title_Njuu mk-eyebrow">Quick Jump</span><ul class="list_A_9v"><li class="item_gTjz"><a href="#connect-with-vscode">SSH configuré — on continue avec VSCode</a></li></ul></nav>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="fallback-approach--vscode-on-the-windows-vm">Approche de repli — VSCode sur la VM Windows<a href="#fallback-approach--vscode-on-the-windows-vm" class="hash-link" aria-label="Lien direct vers Approche de repli — VSCode sur la VM Windows" title="Lien direct vers Approche de repli — VSCode sur la VM Windows" translate="no"></a></h3>
<p>Suivez ce chemin si l'approche recommandée n'est pas disponible (serveur SSH non installé sur la VM <code class="tokenInline_V0MS">windows-vm-ip</code>, ou VM non joignable en SSH depuis votre portable).</p>
<h4 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="create-the-ssh-config-file-on-the-vm">Créer le fichier de configuration SSH sur la VM<a href="#create-the-ssh-config-file-on-the-vm" class="hash-link" aria-label="Lien direct vers Créer le fichier de configuration SSH sur la VM" title="Lien direct vers Créer le fichier de configuration SSH sur la VM" translate="no"></a></h4>
<p>Ouvrez une session Powershell sur la VM <code class="tokenInline_V0MS">windows-vm-ip</code>. Si le sous-dossier <code>$HOME\.ssh</code> n'existe pas encore, créez-le : <code>New-Item -ItemType Directory -Path "$HOME\.ssh"</code>.</p>
<p>Ouvrez (ou créez) <code>C:\Users\</code><code class="tokenInline_V0MS">vm-user</code><code>\.ssh\config</code> dans Notepad. Le fichier doit s'appeler <code>config</code>, <strong>sans extension</strong> — <code>config.txt</code> est silencieusement ignoré par tous les clients SSH.</p>
<div class="language-txt codeBlockContainer_Ckt0 theme-code-block" style="--prism-color:#9CDCFE;--prism-background-color:#1E1E1E"><div class="codeBlockContent_QJqH"><pre tabindex="0" class="prism-code language-txt codeBlock_bY9V thin-scrollbar" style="color:#9CDCFE;background-color:#1E1E1E"><code class="codeBlockLines_e6Vv"><div class="token-line" style="color:#9CDCFE"><span class="token plain">Host linux-test</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    Hostname test.example.internal</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    User dev-user</span><br></div><div class="token-line" style="color:#9CDCFE"><span class="token plain">    IdentityFile ~/.ssh/id_ed25519</span><br></div></code></pre></div></div>
<h4 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="copy-your-ssh-private-key-to-the-vm">Copier votre clé privée SSH sur la VM<a href="#copy-your-ssh-private-key-to-the-vm" class="hash-link" aria-label="Lien direct vers Copier votre clé privée SSH sur la VM" title="Lien direct vers Copier votre clé privée SSH sur la VM" translate="no"></a></h4>
<p>Le client SSH de la VM a besoin de votre clé privée. Parce que vous aurez probablement aussi besoin de votre clef publique dans le futur, copiez les deux manuellement via l'Explorateur (allez dans votre home Linux sous <code>\\wsl$\</code> et copiez <code>~/.ssh/</code><code class="tokenInline_V0MS">id_ed25519</code> vers <code>C:\Users\</code><code class="tokenInline_V0MS">vm-user</code><code>\.ssh\</code>), ou lancez ce one-liner dans une session Powershell qui a accès à WSL :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">vm-user@windows-vm-ip: ~</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>$ wsl sh -c 'cp "$HOME/.ssh/<span class="token_SjcP">id_ed25519</span>.pub" "/mnt/c/Users/$(cmd.exe /c echo %USERNAME% | tr -d "\r")/.ssh/<span class="token_SjcP">id_ed25519</span>.pub"'</p><p>$ wsl sh -c 'cp "$HOME/.ssh/<span class="token_SjcP">id_ed25519</span>" "/mnt/c/Users/$(cmd.exe /c echo %USERNAME% | tr -d "\r")/.ssh/<span class="token_SjcP">id_ed25519</span>"'</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<h4 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="test-the-connection-from-the-vm">Tester la connexion depuis la VM<a href="#test-the-connection-from-the-vm" class="hash-link" aria-label="Lien direct vers Tester la connexion depuis la VM" title="Lien direct vers Tester la connexion depuis la VM" translate="no"></a></h4>
<p>Depuis Powershell sur la VM, vérifiez que l'alias se résout et que l'authentification passe :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">vm-user@windows-vm-ip: ~</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb">PS C:\Users\<span class="token_SjcP">vm-user</span>\.ssh&gt; ssh <span class="token_SjcP">linux-test</span>
No updates available to install

Last update check: 2026-09-04 00:00:01
Last login: Thu Sep  3 17:41:10 2026 from 10.x.x.x

[<span class="token_SjcP">dev-user</span>@linux-server-01 ~]$ whoami
<span class="token_SjcP">dev-user</span>

[<span class="token_SjcP">dev-user</span>@linux-server-01 ~]$ pwd
/home/<span class="token_SjcP">dev-user</span>

[<span class="token_SjcP">dev-user</span>@linux-server-01 ~]$ exit
logout
Connection to <span class="token_SjcP">test.example.internal</span> closed.
PS C:\Users\<span class="token_SjcP">vm-user</span>\.ssh&gt;
</pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="connect-with-vscode">Se connecter avec VSCode<a href="#connect-with-vscode" class="hash-link" aria-label="Lien direct vers Se connecter avec VSCode" title="Lien direct vers Se connecter avec VSCode" translate="no"></a></h2>
<p>SSH est maintenant configuré et la commande de test a renvoyé votre nom d'utilisateur — la connexion fonctionne de bout en bout. Que vous ayez suivi l'approche recommandée ou celle de repli, vous êtes au même point : prêt à ouvrir VSCode et à vous connecter au serveur Linux.</p>
<div class="alertBox_R1Ot mk-callout important_O5eU mk-callout--danger" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Utilisateurs WSL — n'utilisez pas `code .` pour cette étape</p><div class="content_gFLB"><p>Si vous travaillez exclusivement dans WSL, votre réflexe est <code>code .</code> depuis le terminal. <strong>Ne l'utilisez pas ici.</strong></p><p>Quand VSCode s'ouvre via <code>code .</code> depuis WSL, il se connecte à WSL comme host distant. Dans ce mode, le Remote Explorer n'affiche que <strong>WSL Targets</strong> et <strong>Dev Containers</strong> — la catégorie <strong>Remotes (Tunnels/SSH)</strong> n'apparaît jamais, quelles que soient les extensions installées.</p><p>Ouvrez plutôt VSCode à la manière Windows : depuis le <strong>menu Démarrer</strong>, la <strong>barre des tâches</strong>, ou un terminal PowerShell (juste <code>code</code>, sans argument). Vous obtenez une fenêtre locale, avec la liste complète du Remote Explorer — y compris <strong>Remotes (Tunnels/SSH)</strong>.</p></div></div></div>
<p>Donc, <strong>côté Windows, ouvrez une console Powershell</strong> et installez l'extension <strong><a href="https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-ssh" target="_blank" rel="noopener noreferrer" class="">Remote - SSH</a></strong> — c'est elle qui fournit la barre latérale Remote Explorer utilisée pour se connecter à l'host distant :</p>
<div class="wrapper_fGmj"><p class="name_d71T mk-eyebrow">Prérequis : <code>Remote - SSH</code></p><div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb">$ code --install-extension ms-vscode-remote.remote-ssh
Installing extension 'ms-vscode-remote.remote-ssh'...
Extension 'ms-vscode-remote.remote-ssh' v0.120.0 was successfully installed.</pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div><p class="verify_nunD mk-eyebrow">Vérifier :</p><div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb">$ code --list-extensions | grep remote-ssh
ms-vscode-remote.remote-ssh</pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div></div>
<div class="alertBox_R1Ot mk-callout tip_shCb mk-callout--tip" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Sous Powershell, utilisez findstr au lieu de grep</p><div class="content_gFLB"><div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">laptop: ~</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>$ code --list-extensions | grep remote-ssh</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div></div></div></div>
<p>Ouvrez VSCode — <strong>sur votre portable</strong> si vous avez suivi l'approche recommandée, ou <strong>sur la VM Windows</strong> si vous avez suivi l'approche de repli. Dans la barre latérale <strong>Remote Explorer</strong>, sélectionnez <strong>Remotes (Tunnels/SSH)</strong> dans la liste déroulante.</p>
<blockquote>
<p>Utilisateurs WSL : encore une fois, parce que c'est important : passez par le menu Démarrer de Windows et lancez VSCode depuis là.</p>
</blockquote>
<p>Repérez <code class="tokenInline_V0MS">linux-test</code> dans la liste et cliquez sur l'icône flèche à côté pour l'ouvrir dans une nouvelle fenêtre.</p>
<p>VSCode déploie un petit binaire serveur sur l'host Linux la première fois — surveillez la barre d'état en bas à gauche pour le message <em>Opening Remote...</em>. Dès que le nom de l'host y apparaît, vous êtes connecté.</p>
<div class="alertBox_R1Ot mk-callout tip_shCb mk-callout--tip" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">L'host n'apparaît pas dans la liste ?</p><div class="content_gFLB"><p>Deux choses à vérifier :</p><ol>
<li class="">
<p>L'extension du fichier de configuration. Ouvrez l'Explorateur, allez dans <code>C:\Users\</code><code class="tokenInline_V0MS">vm-user</code><code>\.ssh\</code>, et vérifiez que le fichier s'appelle <code>config</code>, pas <code>config.txt</code>. L'Explorateur Windows masque les extensions connues par défaut — activez « Extensions de noms de fichiers » dans le menu Affichage pour en être sûr.</p>
</li>
<li class="">
<p>Une extension obsolète. Si l'host était visible avant et a disparu, ouvrez le panneau Extensions et mettez Remote - SSH à jour. C'est arrivé au moins une fois après une mise à jour de VS Code.</p>
</li>
</ol></div></div></div>
<div class="alertBox_R1Ot mk-callout coreConcept_aIdi mk-callout--key" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Vous venez d'ouvrir un projet sur un serveur que votre portable ne peut pas joindre directement</p><div class="content_gFLB"><p>VSCode est connecté au serveur Linux. Vous pouvez déjà ouvrir des fichiers, lancer des terminaux, utiliser Git — tout fonctionne depuis votre portable, sur des fichiers qui vivent sur le serveur. L'endpoint SFTP et l'API sécurisée sont déjà joignables depuis le terminal intégré. Bravo !</p><p><strong>Le chapitre suivant est optionnel.</strong> Il ajoute un DevContainer par-dessus cette connexion — utile si votre projet nécessite un runtime spécifique ou un environnement isolé. Si vous n'avez besoin que de l'éditeur distant, c'est terminé.</p></div></div></div>
<h3 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="verify-the-connection">Vérifier la connexion<a href="#verify-the-connection" class="hash-link" aria-label="Lien direct vers Vérifier la connexion" title="Lien direct vers Vérifier la connexion" translate="no"></a></h3>
<p>Ouvrez un terminal dans VSCode (<strong>Terminal → New Terminal</strong>) et lancez :</p>
<div class="terminal_qOWF" style="cursor:pointer" title="Cliquez pour passer l'animation"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">linux-test: ~</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><span class="terminal_ghost_aie1" aria-hidden="true"><p>$ hostname
<span class="token_SjcP">test.example.internal</span></p></span><span class="terminal_overlay_fznj"><span class="cursor_o7aQ" aria-hidden="true">▋</span></span></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Le nom d'hôte correspond au serveur Linux — le terminal tourne sur la machine distante, pas sur votre portable.</p>
<p>C'est ce contexte réseau qui rend la suite possible. Les services invisibles depuis votre portable mais joignables depuis le serveur sont désormais accessibles depuis ce terminal. Si votre projet se connecte à un serveur SFTP, essayez tout de suite :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">linux-test: ~</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>$ sftp -i ~/.ssh/<span class="token_SjcP">id_ed25519</span> -P 22 <a href="mailto:user@sftp.example.internal" target="_blank" rel="noopener noreferrer" class="">user@sftp.example.internal</a>
Connected to sftp.example.internal.
sftp&gt;</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Vous êtes sur un prompt <code>sftp&gt;</code> depuis un terminal à l'intérieur de VSCode sur votre portable — le réseau du serveur Linux fait le routage, de façon invisible.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="open-the-devcontainer">Ouvrir le DevContainer<a href="#open-the-devcontainer" class="hash-link" aria-label="Lien direct vers Ouvrir le DevContainer" title="Lien direct vers Ouvrir le DevContainer" translate="no"></a></h2>
<p>Une fois connecté à l'host distant, installez l'extension <strong>Dev Containers</strong> pour ouvrir le projet dans un container qui tourne sur cet host :</p>
<div class="wrapper_fGmj"><p class="name_d71T mk-eyebrow">Prérequis : <code>Dev Containers</code></p><div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb">$ code --install-extension ms-vscode-remote.remote-containers
Installing extension 'ms-vscode-remote.remote-containers'...
Extension 'ms-vscode-remote.remote-containers' v0.395.0 was successfully installed.</pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div><p class="verify_nunD mk-eyebrow">Vérifier :</p><div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">user@machine: ~/yourproject</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb">$ code --list-extensions | grep remote-containers
ms-vscode-remote.remote-containers</pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div></div>
<p>Ouvrez le dossier du projet sur le serveur Linux, puis appuyez sur <kbd>F1</kbd> et lancez <strong>Dev Containers: Rebuild and Reopen in Container</strong> la première fois (ou <strong>Reopen in Container</strong> lors des connexions suivantes).</p>
<div class="alertBox_R1Ot mk-callout important_O5eU mk-callout--danger" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Docker doit être installé sur le serveur Linux</p><div class="content_gFLB"><p>Dev Containers nécessite Docker sur l'host distant, pas sur votre portable ni sur la VM. Si vous obtenez une erreur indiquant que Docker n'est pas trouvé, votre administrateur système doit l'installer, ou suivez le <a href="https://docs.docker.com/engine/install/" target="_blank" rel="noopener noreferrer" class="">guide d'installation officiel de Docker</a> pour la distribution de votre serveur.</p></div></div></div>
<p>Une fois le container démarré, votre prompt change pour refléter l'environnement du container. À partir de là, le serveur SFTP et l'API sécurisée sont joignables — les deux services invisibles depuis votre portable sont maintenant sur le réseau local du container.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="under-the-hood-skip-this-if-you-just-want-to-use-it">Sous le capot (à sauter si vous voulez juste l'utiliser)<a href="#under-the-hood-skip-this-if-you-just-want-to-use-it" class="hash-link" aria-label="Lien direct vers Sous le capot (à sauter si vous voulez juste l'utiliser)" title="Lien direct vers Sous le capot (à sauter si vous voulez juste l'utiliser)" translate="no"></a></h2>
<p><strong>Remote - SSH</strong> installe un processus <code>vscode-server</code> sur l'host Linux à la première connexion, réutilisé ensuite. Ce serveur gère toute la communication de l'éditeur — lecture de fichiers, I/O du terminal, language server protocol — à travers le tunnel SSH.</p>
<p><strong>Dev Containers</strong> dialogue ensuite avec la socket Docker de cet host <em>distant</em>. Il lit <code>devcontainer.json</code>, construit ou récupère l'image, et démarre le container. VSCode rebranche son protocole d'éditeur sur le runtime du container, en laissant le layer SSH intact en dessous. Résultat : deux connexions imbriquées : tunnel SSH → VS Code Server sur l'host → runtime du container.</p>
<p>Avec l'approche recommandée, il y a un troisième niveau : le tunnel SSH lui-même passe par la VM via ProxyJump. Le VS Code Server n'en sait rien — il sait seulement qu'il tourne sur l'host Linux. Le ProxyJump se joue entièrement au niveau SSH.</p>
<p>Les bind mounts du container référencent des chemins <em>sur le serveur Linux</em>, pas des chemins de votre portable ou de la VM — et c'est exactement ce qu'on veut.</p>
<div class="alertBox_R1Ot mk-callout tip_shCb mk-callout--tip" role="note"><span class="icon_phEV mk-dot" aria-hidden="true"></span><div class="body_qnop"><p class="title__icM mk-eyebrow">Cette commande de test est aussi une session SSH complète</p><div class="content_gFLB"><p>Vous vous souvenez de la commande utilisée plus haut pour vérifier si l'approche recommandée était disponible ?</p><div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">laptop: ~</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>ssh -J <span class="token_SjcP">vm-user</span>@<span class="token_SjcP">windows-vm-ip</span> <span class="token_SjcP">dev-user</span>@<span class="token_SjcP">test.example.internal</span> "whoami &amp;&amp; hostname"</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div><p>Retirez la commande entre guillemets à la fin et vous obtenez un one-liner autonome qui ouvre une session SSH interactive sur le serveur Linux — directement depuis votre portable, sans aucun fichier de configuration SSH :</p><div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">laptop: ~</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>ssh -J <span class="token_SjcP">vm-user</span>@<span class="token_SjcP">windows-vm-ip</span> <span class="token_SjcP">dev-user</span>@<span class="token_SjcP">test.example.internal</span></p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div><p>Maintenant que la configuration SSH est en place, <code>ssh </code><code class="tokenInline_V0MS">linux-test</code> fait la même chose en un mot. Mais la forme <code>-J</code> fonctionne sur n'importe quelle machine sans toucher à <code>~/.ssh/config</code> — pratique dès que vous avez besoin d'une session rapide depuis un poste inconnu.</p></div></div></div>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="bonus---ssh--j-one-liners">Bonus - one-liners ssh -J<a href="#bonus---ssh--j-one-liners" class="hash-link" aria-label="Lien direct vers Bonus - one-liners ssh -J" title="Lien direct vers Bonus - one-liners ssh -J" translate="no"></a></h2>
<p>Toutes les commandes ci-dessous fonctionnent depuis n'importe quelle machine sans toucher à <code>~/.ssh/config</code> — la chaîne complète est encodée dans le flag <code>-J</code>.</p>
<p><strong>Vérification rapide — exécute une commande et quitte :</strong></p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">laptop: ~</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>ssh -J <span class="token_SjcP">vm-user</span>@<span class="token_SjcP">windows-vm-ip</span> <span class="token_SjcP">dev-user</span>@<span class="token_SjcP">test.example.internal</span> "whoami &amp;&amp; hostname"</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p><strong>Shell interactif sur le serveur Linux :</strong></p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">laptop: ~</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>ssh -J <span class="token_SjcP">vm-user</span>@<span class="token_SjcP">windows-vm-ip</span> <span class="token_SjcP">dev-user</span>@<span class="token_SjcP">test.example.internal</span></p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Pour les sessions interactives qui lancent un programme précis, ajoutez <code>-t</code> afin d'allouer un pseudo-terminal. Sans lui, le programme distant démarre mais n'affiche aucun prompt — la session semble figée, alors qu'elle attend silencieusement une entrée.</p>
<p><strong>Session SFTP sur un serveur uniquement joignable depuis l'host Linux :</strong></p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">laptop: ~</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>ssh -t -J <span class="token_SjcP">vm-user</span>@<span class="token_SjcP">windows-vm-ip</span> <span class="token_SjcP">dev-user</span>@<span class="token_SjcP">test.example.internal</span> sftp -i ~/.ssh/<span class="token_SjcP">id_ed25519</span> <a href="mailto:user@sftp.example.internal" target="_blank" rel="noopener noreferrer" class="">user@sftp.example.internal</a></p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p><strong>Session PostgreSQL sur une base uniquement joignable depuis l'host Linux :</strong></p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">laptop: ~</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>ssh -t -J <span class="token_SjcP">vm-user</span>@<span class="token_SjcP">windows-vm-ip</span> <span class="token_SjcP">dev-user</span>@<span class="token_SjcP">test.example.internal</span> psql -h db.example.internal -U postgres</p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p><strong>Port forwarding — utilisez n'importe quel outil graphique depuis votre portable :</strong></p>
<p>Pour les clients graphiques Windows (HeidiSQL, DBeaver, TablePlus…) qui ne peuvent pas passer directement par SSH, le port forwarding est la réponse. Il expose le port distant comme <code>localhost</code> sur votre machine — l'outil graphique ne sait pas qu'il traverse deux sauts SSH :</p>
<div class="terminal_qOWF"><div class="terminal_header_VDXb"><div class="terminal_left_ZACC"><span class="terminal_title_boiB mk-eyebrow">laptop: ~</span></div><div class="terminal_controls_f0xU"><span class="dot_ZeWi red_RtX0"></span><span class="dot_ZeWi yellow_WRaI"></span><span class="dot_ZeWi green_HxWO"></span></div></div><pre class="terminal_body_SyZb"><p>ssh -fNL 5432<!-- -->:db<!-- -->.example.internal:5432 -J <span class="token_SjcP">vm-user</span>@<span class="token_SjcP">windows-vm-ip</span> <span class="token_SjcP">dev-user</span>@<span class="token_SjcP">test.example.internal</span></p></pre><button type="button" aria-label="Copier le code dans le presse-papiers" class="clean-btn button--sm button--secondary copyButton_Vsck"></button></div>
<p>Connectez ensuite HeidiSQL (ou n'importe quel client) à <code>Host: localhost</code>, <code>Port: 5432</code>. Le tunnel tourne silencieusement en arrière-plan (<code>-f</code>) sans commande distante (<code>-N</code>). Tuez le processus <code>ssh</code> quand vous avez fini.</p>
<h2 class="anchor anchorTargetHideOnScrollNavbar_vjPI" id="conclusion">Conclusion<a href="#conclusion" class="hash-link" aria-label="Lien direct vers Conclusion" title="Lien direct vers Conclusion" translate="no"></a></h2>
<p>La combinaison de VSCode Remote SSH et Dev Containers résout un problème qui demande sinon un workflow manuel pénible en plusieurs étapes : développer du code qui doit tourner — ou atteindre des services qui n'existent que — sur un serveur que votre portable ne peut pas joindre directement.</p>
<p>L'approche recommandée (ProxyJump) est la plus propre : une fois la configuration SSH en place, la connexion se fait en un clic et la VM Windows devient une infrastructure invisible. L'approche de repli est l'alternative fiable quand le serveur SSH de la VM n'est pas disponible.</p>
<p><a href="/fr/blog/vscode-remote-ssh/">Développement distant en SSH avec VSCode</a> est l'article compagnon si vous voulez partir d'un cas plus simple — éditer des fichiers sur un host distant sans devcontainer, avec un container Docker local comme terrain d'entraînement sûr avant de toucher à un vrai serveur.</p></div><hr><div class="seriesBlogPost_bjUd"><details class="details_rTsq"><summary class="seriesIntro_HIK1 mk-eyebrow">Cet article fait partie de la série <a href="/fr/series/ssh-from-your-first-key-to-remote-development/">SSH — de votre première clé au développement distant</a> :</summary><div class="content_OqVG"><ul><li><a href="/fr/blog/github-connect-using-ssh/">GitHub - Connectez votre compte en SSH et travaillez avec le protocole git@</a></li><li><a href="/fr/blog/docker-use-ssh-during-build/">Docker secrets : utiliser votre clé SSH pendant le build</a></li><li><a href="/fr/blog/linux-sftp-cli/">Utiliser sftp en ligne de commande, avec ou sans proxy</a></li><li><a href="/fr/blog/zsh-plugin-ssh-config-suggestions/">SSH - Autosuggestions avec ZSH</a></li><li><a href="/fr/blog/linux-ssh-scp/">SSH - Lancer un terminal sur votre session sans avoir à vous authentifier</a></li><li><a href="/fr/blog/connect-using-ssh-to-your-hosting-server/">Comment se connecter à votre serveur d'hébergement en SSH</a></li><li><a href="/fr/blog/vscode-remote-ssh/">Développement distant en SSH avec VSCode</a></li><li><a href="/fr/blog/vscode-remote-ssh-proxyjump-devcontainer/">Ouvrir un DevContainer sur un serveur distant avec VSCode Remote SSH et ProxyJump</a></li><li><a href="/fr/blog/ssh-config-tips/">Générateur de config SSH : stanzas wildcard, alias et connexions automatisées</a></li></ul></div></details></div></div><div class="relatedPosts_zJiB"><h3>Articles liés</h3></div><div class="container_mNR1"><span class="question_WdqX">Cet article vous a-t-il été utile ?</span><div class="buttons_vgpi"><button class="btn_svQp" aria-label="Oui, cet article m'a été utile"><img src="/fr/img/reaction-mascot-yes.webp" alt="" class="mascot_Fait">Utile</button><button class="btn_svQp btnNeutral_Iqek" aria-label="Non, cet article ne m'a pas été utile"><img src="/fr/img/reaction-mascot-no.webp" alt="" class="mascot_Fait">Pas vraiment</button></div></div><div class="container_IJwe"><span class="question_nb7b">Avez-vous suivi les étapes de cet article ?</span><div class="buttons_Xkzw"><button class="btn_RMiq" aria-label="Oui, j'ai essayé et ça a marché"><img src="/fr/img/like.webp" alt="" class="mascot_wB1d">Ça a marché !</button><button class="btn_RMiq btnNeutral_z71B" aria-label="Non, ça n'a pas marché pour moi"><img src="/fr/img/dislike.webp" alt="" class="mascot_wB1d">Ça n'a pas marché</button></div></div>]]></content:encoded>
            <author>Christophe</author>
            <author>Claude Code</author>
            <category>devcontainer</category>
            <category>docker</category>
            <category>linux</category>
            <category>ssh</category>
            <category>vscode</category>
            <enclosure length="0" type="image/webp" url="https://www.avonture.be/fr/img/v2/ssh-devcontainer.webp"/>
        <dc:creator><![CDATA[Christophe]]></dc:creator><dc:creator><![CDATA[Claude Code]]></dc:creator></item>
    </channel>
</rss>