<!--
  canonical-url: https://www.avonture.be/fr/blog/docusaurus-easter-eggs
  published:     2026-07-13
  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.
-->

# Espiègleries de suricate : semer des easter eggs partout sur mon blog

> Tour d'horizon de huit petits easter eggs à découvrir sur ce blog Docusaurus — un suricate en ASCII art caché dans le source de la page, un sprite lancé par le code Konami, un clin d'œil en console.log, un changement de favicon quand on quitte l'onglet, des messages 404 aléatoires, un commentaire caché dans le sitemap et plus encore — ainsi que les vrais bugs (répétition de touche, incompatibilité clavier AZERTY, mauvais emoji, race condition dans postBuild, favicon flou) débusqués en chemin.

---
![Espiègleries de suricate : semer des easter eggs partout sur mon blog](/img/v2/easter_eggs.webp)**TL;DR**Au-delà de la mascotte suricate déjà visible sur ce blog, celui-ci cache huit petits easter eggs volontairement trouvables : un suricate en ASCII art posé directement dans le source de la page, un clin d'œil en `console.log`, des messages 404 aléatoires, un sprite lancé par le code Konami, un changement de titre et de favicon quand on quitte l'onglet, un en-tête HTTP `X-Powered-By`, une feuille de style d'impression et un commentaire caché dans `sitemap.xml`. Cet article les passe tous en revue avec le code source réel et — plus utile encore — les vrais bugs que nous avons livrés puis corrigés en chemin : une race condition de répétition de touche dans le handler Konami, une incompatibilité clavier AZERTY trouvée par un vrai utilisateur (pas par nos tests), un mauvais codepoint d'emoji, un piège de concurrence dans le `postBuild` de Docusaurus, et un favicon illisible jusqu'à ce qu'on en redessine le cadrage.Vous savez déjà que ce site a une mascotte : un suricate qui apparaît sur la page 404, chevauche le bouton « remonter en haut » et se cache dans un [commentaire HTML en ASCII art](/blog/docusaurus-ascii-art) sur chaque page. C'est ce dernier qui m'a fait réfléchir : si un commentaire dans le source de la page est amusant, sur quoi d'autre un visiteur curieux pourrait-il tomber ?Alors, ce week-end, je me suis posé — et j'ai posé à [Claude Code](/blog/claude-ia-spare-tokens) — une question simple : jusqu'où peut-on aller sans que ça devienne pénible ? La réponse a donné huit petits easter eggs — le commentaire en ASCII art revu en détail, plus sept nouveautés — chacun suivant la même règle : **si vous ne le cherchez pas, vous ne le verrez jamais.** Pas de pop-up, pas de confettis au chargement, rien qui gêne la lecture. Juste de discrètes récompenses pour les curieux.> **La règle unique derrière tout ça:**
>
> Chaque easter egg de cette liste vit dans la console, le source, les en-têtes de réponse, ou dans un état que le visiteur doit déclencher volontairement (quitter l'onglet, imprimer une page, taper un code secret). Aucun n'apparaît jamais dans le flux de lecture normal.## 1. Celui par lequel tout a commencé : un suricate en ASCII art dans le source de la page {#1-the-one-that-started-it-all-an-ascii-art-meerkat-in-the-page-source}C'est l'easter egg qui a inspiré tous les autres, il mérite donc la première place. Appuyez sur `CTRL`+`U` sur n'importe quelle page de ce blog — ou faites un clic droit et choisissez « Afficher le code source de la page » — et juste après la ligne d'ouverture `<!doctype html>`, avant qu'un seul pixel visible n'ait été décrit, vous trouverez un suricate dessiné entièrement en caractères ASCII, à l'intérieur d'un commentaire HTML :> Screenshot — view-source:https\://www\.avonture.be/blog/ripgrep/
>
> ![CTRL-U - Afficher le source](/img/v2/suricate_ctrl_u.webp)Il est invisible dans la page rendue — un commentaire HTML n'atteint jamais l'écran — et ne coûte pas un octet de mise en page ni une ligne de markup visible. C'est purement une récompense pour qui est assez curieux pour regarder la réponse brute.Sous le capot, un petit plugin `postBuild` (`plugins/ascii-injector/index.mjs`) parcourt chaque fichier HTML généré après la fin de `yarn docusaurus build` et insère le contenu de `src/data/banner.txt` sous forme de commentaire juste après la balise `<!doctype html>`. *Le plugin lui-même est construit pas à pas dans [Inject ASCII Art in any HTML pages rendered by Docusaurus](/blog/docusaurus-ascii-art), et la bannière provient du même générateur que celui de [Bash - ASCII art](/blog/bash-ascii-art) :*```bash
$ yarn docusaurus build
$ grep -A2 doctype build/index.html
<!doctype html>
<!--
```> **Déjà traité en détail:**
>
> Celui-ci a son propre article dédié — [Inject ASCII Art in any HTML pages rendered by Docusaurus](/blog/docusaurus-ascii-art) — avec le source complet du plugin, comment convertir votre propre logo en ASCII art, et une réserve propre au `postBuild` (il n'apparaîtra pas pendant un `yarn start` en mode dev, uniquement sur un vrai build). Nous n'allons pas tout répéter ici ; cette entrée existe surtout pour que l'easter egg ait sa juste place dans cette liste.## 2. Un clin d'œil en console.log pour les visiteurs des DevTools {#2-a-consolelog-wink-for-devtools-visitors}Le plus facile. Si vous ouvrez la console des DevTools de votre navigateur sur ce blog, vous verrez ceci :**src/theme/Root.js (excerpt)**```javascript
// Excerpt from src/theme/Root.js

// Console easter egg: a little wink for visitors who open DevTools.
// Runs once per full page load, not on every client-side route change.
useEffect(() => {
  console.log(
    '%c \u{1F9AB} Curious, aren\'t you?',
    'font-size:18px;font-weight:bold;color:#e8871e;',
  );
  console.log(
    '%cThe meerkat sentry is watching this site too. If you enjoy digging around in the source, try the Konami code somewhere on this page...',
    'font-size:12px;color:#888;',
  );
}, []);
```Rien de sophistiqué : un appel `console.log` stylé (la directive `%c` permet d'appliquer du CSS à la sortie console) déclenché une fois par chargement complet de page. Il sert aussi d'indice, en orientant les lecteurs curieux vers le code Konami ci-dessous.![Console.log()](./images/console_log.webp)> **Pourquoi useEffect(..., \[]):**
>
> Un tableau de dépendances vide signifie que cet effet s'exécute une fois, au montage du composant — pas à chaque changement de route côté client. Comme `Root` englobe toute l'application et survit à la navigation entre pages, c'est le bon endroit pour un easter egg « une fois par visite ».## 3. Une page 404 avec le sens de l'humour {#3-a-404-page-with-a-sense-of-humor}La page 404 affichait déjà une illustration de suricate et un message. Nous avons transformé le message en un petit pool de cinq, tiré au hasard à chaque chargement :**src/theme/NotFound/index.js**```javascript
import React, { useEffect, useState } from "react";
import Translate, { translate } from "@docusaurus/Translate";
import useBaseUrl from "@docusaurus/useBaseUrl";
import { PageMetadata } from "@docusaurus/theme-common";
import { useLocation } from "@docusaurus/router";
import Layout from "@theme/Layout";
import Link from "@docusaurus/Link";
import { openPalette } from "@site/src/components/CommandPalette/paletteBus";
import styles from "./styles.module.css";

/** Turns a failed URL's last segment into a plausible search query — "docker-cs-fixer" → "docker cs fixer". */
function guessQueryFromPath(pathname) {
  const segment = pathname.split("/").filter(Boolean).pop() ?? "";
  return segment.replace(/[-_]+/g, " ").trim();
}

/**
 * The five wordings, one picked at random per visit.
 *
 * Listed one `translate()` call at a time rather than mapped over an array of strings:
 * `yarn write-translations` reads the AST, so each `id`/`message` pair has to be a literal at
 * its own call site or the key is never extracted and the French page keeps the English text.
 */
function lostMessages() {
  return [
    translate({
      id: "theme.NotFound.lost.companions",
      message:
        "Don't worry, your meerkat companions are pointing the way back to the main burrow.",
    }),
    translate({
      id: "theme.NotFound.lost.sentries",
      message:
        "Our sentries scanned the horizon and found... nothing. This page doesn't exist.",
    }),
    translate({
      id: "theme.NotFound.lost.dug",
      message: "The meerkat colony dug everywhere and still couldn't find this page.",
    }),
    translate({
      id: "theme.NotFound.lost.tiptoes",
      message:
        "Even standing on their tiptoes, the meerkats can't spot this page anywhere.",
    }),
    translate({
      id: "theme.NotFound.lost.burrow",
      message: "This burrow is empty. The meerkats suggest heading back home.",
    }),
  ];
}

export default function NotFound() {
  const title = translate({
    id: "theme.NotFound.title",
    message: "Page Not Found",
  });
  const [message] = useState(() => {
    const messages = lostMessages();
    return messages[Math.floor(Math.random() * messages.length)];
  });
  // Front-matter-style static path: it carries no locale prefix of its own, so under `fr` a bare
  // "/img/404.webp" resolves to the SPA fallback and the illustration never paints.
  const imageUrl = useBaseUrl("/img/404.webp");
  const location = useLocation();
  const query = guessQueryFromPath(location.pathname);

  // The moment a visitor most needs the search tool is right here — open the command
  // palette pre-filled with the failed URL instead of waiting for them to find `Ctrl+K`.
  useEffect(() => {
    openPalette(query);
  }, [query]);

  return (
    <>
      <PageMetadata title={title} />
      <Layout>
        <main className="container margin-vert--xl">
          <div className="row">
            <div className="col col--6 col--offset-3 text--center">
              <img
                src={imageUrl}
                alt={translate({
                  id: "theme.NotFound.imageAlt",
                  message: "A group of meerkats looking confused.",
                })}
                className={styles.notFoundImg}
              />
              {/* Its own id: this used to reuse `theme.NotFound.title`, the <meta> title above.
                  English never showed it — a missing key falls back to each call's own
                  `message` — but as soon as French supplied one, both rendered "Page
                  introuvable" and the heading lost its wording. */}
              <h1 className="hero__title">
                {translate({
                  id: "theme.NotFound.heading",
                  message: "Oh no! It looks like you're lost.",
                })}
              </h1>
              <p>{message}</p>
              <Link to="/" className="button button--primary button--lg">
                {translate({
                  id: "theme.NotFound.backToHome",
                  message: "Take me back to the homepage",
                })}
              </Link>{" "}
              <button
                type="button"
                className="button button--secondary button--lg"
                onClick={() => openPalette(query)}
              >
                <Translate id="theme.NotFound.search">Search the site</Translate>
              </button>
            </div>
          </div>
        </main>
      </Layout>
    </>
  );
}
````useState(() => …)` avec un initialiseur sous forme de fonction garantit que le tirage aléatoire se fait exactement une fois par montage, pas à chaque re-rendu — important ici puisque `NotFound` n'a pas besoin de retirer un message pendant que l'utilisateur le lit./* Volontairement une balise <a> brute et non un lien Markdown : cette URL doit rester
  morte pour que la démo fonctionne, et le vérificateur de liens cassés de Docusaurus
  (`onBrokenLinks`) ne collecte que les éléments <Link> — ce en quoi un lien Markdown
  est compilé. Écrit sous la forme `[…](…)`, il ferait échouer chaque build. */Essayez : cliquez sur ce lien [inexisting\_page](/blog/inexisting_page) puis appuyez sur `F5` pour obtenir un message aléatoire.## 4. La vedette du spectacle : un easter egg au code Konami {#4-the-star-of-the-show-a-konami-code-easter-egg}Tapez `↑ ↑ ↓ ↓ ← → ← → B A` n'importe où sur ce site (hors champ de saisie) et un suricate traverse votre écran en courant. C'est celui qui mérite le plus de détails, parce que c'est aussi celui qui nous a le plus appris.Voici notre suricate courant ; essayez !![Suricate courant](./images/konami.webp)Envie d'en savoir plus sur le code Konami ? Consultez [cette page](https://en.wikipedia.org/wiki/Konami_Code) sur Wikipédia.### Le composant {#the-component}**src/components/KonamiEasterEgg/index.tsx**```typescript
import { useEffect, useState, useRef, useCallback, type JSX } from "react";
import runningImg from "@site/static/img/meerkat/suricate_running.webp";
import styles from "./styles.module.css";
import { translate } from "@docusaurus/Translate";

const KONAMI_CODE = [
  "ArrowUp",
  "ArrowUp",
  "ArrowDown",
  "ArrowDown",
  "ArrowLeft",
  "ArrowRight",
  "ArrowLeft",
  "ArrowRight",
  "b",
  "a",
];

const ARROW_KEYS = new Set(["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]);

// Holding Shift to type "B"/"A" as capitals fires its own keydown for the
// modifier itself first (event.key === "Shift"). Left unguarded, that keydown
// doesn't match whatever arrow is next expected and resets progress to 0
// before the "B"/"A" keydown even arrives — so the last two letters could
// never be reached the moment someone shift-presses instead of typing plain
// lowercase b/a.
const MODIFIER_KEYS = new Set([
  "Alt",
  "AltGraph",
  "CapsLock",
  "Control",
  "Fn",
  "FnLock",
  "Meta",
  "NumLock",
  "ScrollLock",
  "Shift",
  "Symbol",
  "SymbolLock",
]);

// event.code reflects the physical key position on a QWERTY reference
// layout, so on an AZERTY keyboard the key printed "A" reports 'KeyQ'.
// event.key reflects the actual character produced, which matches what
// players see printed on their own keycaps regardless of layout.
const normalizeKey = (key: string) => (key.length === 1 ? key.toLowerCase() : key);

const RUN_DURATION_MS = 3000;

export default function KonamiEasterEgg(): JSX.Element | null {
  const [isRunning, setIsRunning] = useState(false);
  const progressRef = useRef(0);

  const triggerRun = useCallback(() => {
    setIsRunning(true);
    console.log(
      "%c\u{1F9AB} Konami code accepted! Run, meerkat, run!",
      "font-size:14px;font-weight:bold;color:#e8871e;",
    );
    window.setTimeout(() => setIsRunning(false), RUN_DURATION_MS);
  }, []);

  useEffect(() => {
    const handleKeyDown = (event: KeyboardEvent) => {
      // Ignore auto-repeated keydowns fired while a key is held down: a
      // slightly-too-long "ArrowUp" press would otherwise inject extra
      // events and break a sequence that has two of the same key in a row.
      if (event.repeat) return;
      // event.key can be undefined at runtime (IME composition, synthetic events,
      // older browser bugs) despite TypeScript's DOM types declaring it string.
      if (!event.key) return;
      if (MODIFIER_KEYS.has(event.key)) return;

      const key = normalizeKey(event.key);
      const expectedKey = KONAMI_CODE[progressRef.current];
      if (key === expectedKey) {
        if (ARROW_KEYS.has(key)) {
          // Stop the arrow keys from scrolling the page mid-sequence.
          event.preventDefault();
        }
        progressRef.current += 1;
        if (progressRef.current === KONAMI_CODE.length) {
          progressRef.current = 0;
          triggerRun();
        }
      } else {
        progressRef.current = key === KONAMI_CODE[0] ? 1 : 0;
      }
    };

    window.addEventListener("keydown", handleKeyDown);
    return () => window.removeEventListener("keydown", handleKeyDown);
  }, [triggerRun]);

  if (!isRunning) {
    return null;
  }

  return (
    <img
      src={runningImg}
      alt={translate({
        id: "konamiEasterEgg.alt",
        message: "A meerkat sprinting across the screen",
      })}
      className={styles.runner}
    />
  );
}
```La logique conserve un curseur `useRef` dans un tableau `KONAMI_CODE` de valeurs [`KeyboardEvent.code`](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code) (pas `.key` — `.code` reflète la position physique de la touche, donc la séquence fonctionne pareil sur un clavier AZERTY et sur un QWERTY). Chaque keydown fait avancer le curseur, le remet à zéro, ou — en cas de correspondance complète — monte une `<img>` de coureur pendant 3 secondes :**src/components/KonamiEasterEgg/styles.module.css**```css
.runner {
  position: fixed;
  bottom: 24px;
  left: -160px;
  width: 140px;
  height: auto;
  z-index: 9999;
  pointer-events: none;
  animation: run-across 3s linear forwards;
}

@keyframes run-across {
  from {
    left: -160px;
  }
  to {
    left: 100vw;
  }
}

@media (prefers-reduced-motion: reduce) {
  .runner {
    animation: none;
    left: 50vw;
  }
}
```Il est monté une seule fois, globalement, depuis `src/theme/Root.js` :```jsx title="src/theme/Root.js (excerpt)"
import KonamiEasterEgg from '@site/src/components/KonamiEasterEgg';

// ...

return (
  <>
    {children}
    <KonamiEasterEgg />
  </>
);
```> **Essayez tout de suite:**
>
> Sérieusement — cliquez n'importe où sur cette page pour lui donner le focus, puis tapez `↑ ↑ ↓ ↓ ← → ← → B A`. Ce composant exact tourne sur la page que vous lisez.### Le bug qui a failli tuer le plaisir {#the-bug-that-nearly-killed-the-fun}Après la mise en ligne, les tests en conditions réelles ont révélé un vrai bug : tapée lentement et posément, la séquence ne se déclenchait parfois... pas du tout. La cause : la répétition automatique. Maintenez `ArrowUp` une fraction de seconde de trop — ce qui est très naturel quand on se remémore soigneusement une séquence — et le navigateur émet plusieurs événements `keydown` avec `repeat: true` avant que vous ne relâchiez la touche. Comme la séquence a besoin de `ArrowUp` **deux fois d'affilée**, un appui maintenu peut injecter un événement `ArrowUp` supplémentaire au moment précis où le handler attend `ArrowDown`, réinitialisant tout en silence.Le correctif tient en une clause de garde :```jsx title="src/components/KonamiEasterEgg/index.tsx (excerpt)"
const handleKeyDown = (event) => {
  // Ignore auto-repeated keydowns fired while a key is held down: a
  // slightly-too-long "ArrowUp" press would otherwise inject extra
  // events and break a sequence that has two of the same key in a row.
  if (event.repeat) return;

  // ...
};
```Nous avons vérifié le correctif avec [Playwright](https://playwright.dev/) plutôt qu'à l'œil — en envoyant un `keydown` synthétique avec `repeat: true`, nous avons confirmé que le handler l'ignore désormais, et une exécution complète de la séquence en appuis brefs déclenche toujours l'animation. Si votre easter egg (ou n'importe quel raccourci clavier, en fait) implique deux touches identiques à la suite, ne sautez pas cette vérification.### Le bug qu'un vrai utilisateur a trouvé et que les tests automatisés ont raté {#the-bug-a-real-user-found-that-automated-testing-missed}Le correctif de répétition de touche était livré, les tests étaient au vert — et la fonctionnalité ne marchait toujours pas pour un vrai visiteur. Le signalement : « J'essaie la combinaison mais rien ne se passe. » La cause venait du clavier, pas du code : sur un clavier AZERTY belge, la touche physique marquée **A** se trouve exactement là où un clavier QWERTY a **Q** (et inversement). L'implémentation d'origine comparait [`event.code`](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/code), qui rapporte la *position physique* de la touche sur une disposition QWERTY de référence — donc appuyer sur la touche marquée « A » d'un clavier AZERTY produisait `code: 'KeyQ'`, pas `'KeyA'`. Le diagnostic de l'utilisateur lui-même a tapé dans le mille : taper ce qui ressemblait à « B, Q » sur son clavier fonctionnait, parce que cette position physique correspond à `KeyA`.Le correctif consiste à comparer [`event.key`](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key) à la place — le caractère réellement produit, qui respecte la disposition configurée par le visiteur :```jsx title="src/components/KonamiEasterEgg/index.tsx (excerpt)"
// event.code reflects the physical key position on a QWERTY reference
// layout, so on an AZERTY keyboard the key printed "A" reports 'KeyQ'.
// event.key reflects the actual character produced, which matches what
// players see printed on their own keycaps regardless of layout.
const normalizeKey = (key) => (key.length === 1 ? key.toLowerCase() : key);

// ...

const key = normalizeKey(event.key);
if (key === expectedKey) { /* ... */ }
```Les touches fléchées n'ont jamais été affectées — `ArrowUp`, `ArrowDown`, `ArrowLeft`, `ArrowRight` sont des chaînes identiques pour `.code` et `.key` sur toutes les dispositions. Le bug ne touchait que les deux lettres à la fin de la séquence, et uniquement pour les [15–20 % de claviers dans le monde](https://en.wikipedia.org/wiki/Keyboard_layout) qui ne sont pas QWERTY.> **Leçon:**
>
> Notre test Playwright a attrapé la régression de répétition de touche parce qu'il envoyait de vrais événements séquentiels — mais il n'a jamais attrapé le bug de disposition, parce que le clavier virtuel de Playwright est en QWERTY par défaut. Les tests automatisés valident la logique que vous avez pensé à tester ; ils ne remplacent pas un humain avec un matériel différent. Si une fonctionnalité implique une saisie au clavier, `.key` est presque toujours le bon choix face à `.code` — réservez `.code` aux cas où vous voulez délibérément la position physique de la touche (les commandes de déplacement dans un jeu, par exemple, où ZQSD/WASD doit rester au même endroit physique quelle que soit la disposition).## 5. Easter egg de changement d'onglet : titre et favicon {#5-tab-away-easter-egg-title-and-favicon-swap}Quittez cet onglet puis revenez — le titre devient « Come back, the meerkat is on watch! 👀 » et le favicon se transforme en suricate assoupi. Les deux reviennent à la normale dès que l'onglet reprend le focus :![Onglet inactif](./images/inactive_tab.webp)**src/theme/Root.js (excerpt)**```javascript
// Excerpt from src/theme/Root.js

const sleepingFaviconUrl = useBaseUrl('/img/favicon-sleeping.png');

// Title bar easter egg: greet visitors who leave the tab and come back,
// swapping the favicon for a dozing meerkat while they're away.
useEffect(() => {
  const originalTitle = document.title;
  const faviconLink = document.querySelector('link[rel="icon"]');
  const originalFavicon = faviconLink?.href;

  const handleVisibilityChange = () => {
    if (document.hidden) {
      document.title = 'Come back, the meerkat is on watch! \u{1F440}';
      if (faviconLink) faviconLink.href = sleepingFaviconUrl;
    } else {
      document.title = originalTitle;
      if (faviconLink && originalFavicon) faviconLink.href = originalFavicon;
    }
  };

  document.addEventListener('visibilitychange', handleVisibilityChange);
  return () =>
    document.removeEventListener('visibilitychange', handleVisibilityChange);
}, [location, sleepingFaviconUrl]);
```L'événement `visibilitychange` et `document.hidden` sont la manière standard de détecter ça — pas besoin de polling. `useBaseUrl` (un hook Docusaurus) résout correctement le chemin du favicon quelle que soit l'URL de base configurée du site, le même helper utilisé ailleurs sur ce site pour les sources d'images.## 6. Un clin d'œil dans les en-têtes HTTP {#6-a-wink-in-the-http-headers}Celui-ci est invisible sauf si vous regardez l'onglet Network ou lancez `curl -I`. Il vit dans `nginx.conf`, en plein dans le bloc serveur HTTPS :**nginx.conf**```conf
# nginx.conf

# 1. HTTP to HTTPS Redirection (Optional but recommended)
server {
    listen 80;
    server_name localhost;
    # Redirect all HTTP traffic to HTTPS, preserving the path and query string
    return 301 https://$host$request_uri;
}

# 2. HTTPS Server Block (The main site configuration)
server {
    listen 443 ssl http2;

    # Root directory where Docusaurus files are located
    root /usr/share/nginx/html;

    # Index file
    index index.html index.htm;

    server_name localhost;

    # SSL Configuration — mount real certs at runtime:
    # docker run -v /path/to/certs:/etc/nginx/certs:ro blog-docusaurus:production
    ssl_certificate /etc/nginx/certs/fullchain.pem;
    ssl_certificate_key /etc/nginx/certs/privkey.pem;

    # Basic SSL settings (Recommended)
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_prefer_server_ciphers on;
    ssl_ciphers HIGH:!aNULL:!MD5;

    # Easter egg: a little wink for anyone inspecting the response headers.
    add_header X-Powered-By "Meerkat-Sentry/1.0" always;

    # --- Docusaurus Home Page Redirection ---
    # When a user hits the root (https://localhost/), redirect them to /blog
    location = / {
        return 301 /blog;
    }

    # Mirrors static/.htaccess, which is what actually serves the site today.
    #
    # The classic "rewrite everything to index.html" SPA fallback is deliberately
    # NOT used: Docusaurus pre-renders its routes to files, so that fallback
    # catches almost nothing real and just makes the server answer 200 to every
    # URL on earth. That is a soft 404 — Google treats it as a quality signal, no
    # dead-link checker can work against a server that never says no, and a feed
    # reader polling a removed /blog/tags/<slug>/rss.xml would get HTML with a
    # 200 instead of a clean 404.
    #
    # What survives of it is this narrow safety net for the two route families
    # that are generated rather than authored — the tag and series pages
    # registered by plugins/docusaurus-plugin-tag-route and
    # docusaurus-plugin-series-route. When a per-slug file exists in the build,
    # try_files serves it and the fallback never fires; it only takes over when
    # the file is missing, which is exactly when the React router still knows how
    # to render the page and a 404 would be wrong.
    #
    # One path segment only, on purpose: /blog/tags/tags/<slug> is the blog
    # plugin's own listing and a real directory, /blog/tags/a/b is not a route at
    # all, and /blog/tags/<slug>/rss.xml is a file — all three must fall through
    # to the 404 below rather than be answered with index.html.
    location ~ ^/(series|blog/tags)/[^/]+/?$ {
        try_files $uri $uri/ /index.html;
    }

    # Anything else: a file on disk, or a real 404. Docusaurus already builds
    # 404.html from the theme, so the reader gets the site's own styled error
    # page rather than nginx's grey default.
    location / {
        try_files $uri $uri/ =404;
    }

    error_page 404 /404.html;

    location = /404.html {
        internal;
    }

    # Cache control for static assets (optional, but good practice)
    location ~* \.(?:css|js|jpe?g|gif|png|ico|woff2?|ttf|eot|svg)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        try_files $uri =404;
    }
}
````add_header X-Powered-By "Meerkat-Sentry/1.0" always;` — un petit en-tête inoffensif pour qui inspecte les en-têtes de réponse par habitude.## 7. Easter egg dans la feuille de style d'impression {#7-print-stylesheet-easter-egg}Imprimez un article (`Ctrl+P`) et une petite note de bas de page apparaît sur la page imprimée :**src/css/custom.css (excerpt)**```css
/* Excerpt from src/css/custom.css */

/* --- PRINT EASTER EGG --- */
/* A small nod to the meerkat mascot for anyone who prints an article. */

@media print {
  body::after {
    content: "\1F9AB Printed from avonture.be \2014 mascot-approved reading material.";
    position: fixed;
    bottom: 10px;
    left: 0;
    right: 0;
    text-align: center;
    font-size: 9px;
    color: #999;
  }
}
````@media print` n'applique ses règles que lorsque le navigateur génère un aperçu avant impression ou une impression réelle — c'est invisible en navigation normale, et c'est exactement le but.## 8. Sitemap.xml reçoit aussi son commentaire {#8-sitemapxml-gets-a-comment-too}Le moins susceptible d'être trouvé un jour, et celui qui a produit le bug le plus intéressant. L'idée : glisser un petit commentaire XML comme premier enfant de `<urlset>` dans le `sitemap.xml` généré. Un commentaire à cet endroit est du XML valide et ignoré par tous les parseurs de sitemap et crawlers, il ne peut donc pas casser l'indexation.**plugins/sitemap-easter-egg/index.mjs**```mjs
/**
 * Docusaurus Plugin: sitemap-easter-egg
 *
 * Purpose:
 * Slips a small, valid XML comment right after the <urlset> opening tag of the
 * generated sitemap.xml, for the rare visitor who reads sitemaps for fun.
 *
 * A comment placed as the first child of <urlset> is valid XML and is ignored
 * by every sitemap parser/crawler, so it can't break indexing.
 *
 * Usage:
 * - Save this plugin as `plugins/sitemap-easter-egg/index.mjs`
 * - Add it to `docusaurus.config.js` plugins array.
 *
 * Note: Docusaurus runs every plugin's postBuild hook concurrently, so the
 * official sitemap plugin may not have written sitemap.xml yet when this
 * hook starts. We poll briefly for the file before giving up.
 */

import { promises as fs } from "node:fs";
import path from "node:path";

const POLL_ATTEMPTS = 15;
const POLL_INTERVAL_MS = 300;

async function readWhenReady(filePath) {
  for (let attempt = 0; attempt < POLL_ATTEMPTS; attempt += 1) {
    try {
      return await fs.readFile(filePath, "utf8");
    } catch (err) {
      if (err.code !== "ENOENT" || attempt === POLL_ATTEMPTS - 1) {
        throw err;
      }
      await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS));
    }
  }
  return undefined;
}

export default function sitemapEasterEggPlugin() {
  return {
    name: "sitemap-easter-egg-plugin",

    async postBuild({ outDir, siteConfig }) {
      const sitemapPath = path.resolve(
        outDir,
        siteConfig?.presets?.[0]?.[1]?.sitemap?.filename || "sitemap.xml",
      );

      let xml;
      try {
        xml = await readWhenReady(sitemapPath);
      } catch (err) {
        console.warn(
          `[sitemap-easter-egg] sitemap.xml not found, skipping: ${err.message}`,
        );
        return;
      }

      const comment =
        "<!-- Hi there! A meerkat sentry reviewed every URL in this sitemap. -->";

      if (xml.includes(comment)) {
        return;
      }

      const urlsetTag = /<urlset[^>]*>/i;
      if (!urlsetTag.test(xml)) {
        console.warn("[sitemap-easter-egg] <urlset> tag not found, skipping.");
        return;
      }

      const updated = xml.replace(urlsetTag, (m) => `${m}${comment}`);
      await fs.writeFile(sitemapPath, updated, "utf8");
      console.log("[sitemap-easter-egg] Comment injected into sitemap.xml");
    },
  };
}
```Enregistré de la même façon que l'[injecteur d'ASCII art](/blog/docusaurus-ascii-art) :**docusaurus.config.js (excerpt)**```javascript
// [...]
plugins: [
    // [...]
    [
        "./plugins/ascii-injector/index.mjs",
        { bannerPath: "src/data/banner.txt" },
    ],
    "./plugins/sitemap-easter-egg/index.mjs",
],
```## Le résultat final {#what-we-ended-up-with}**Les huit easter eggs**- **Suricate en ASCII art** — caché dans le source de la page, juste après `<!doctype html>`

- **Clin d'œil console.log** — un message stylé pour qui a les DevTools ouverts

- **Messages 404 tournants** — cinq variantes aléatoires aux couleurs de la mascotte

- **Code Konami** — `↑ ↑ ↓ ↓ ← → ← → B A` lance un suricate à travers l'écran

- **Changement à la sortie d'onglet** — titre et favicon changent quand l'onglet est masqué, et reviennent au retour

- **En-tête X-Powered-By** — un clin d'œil dans les en-têtes de réponse HTTP

- **Feuille de style d'impression** — une petite note de bas de page sur les pages imprimées

- **Commentaire dans sitemap.xml** — un commentaire caché et valide XML comme premier enfant de `<urlset>`Aucun de ces éléments ne change la façon dont un lecteur lit un article. C'était tout l'objectif — le travail d'un blog reste d'être lu, pas de faire des tours de magie à la figure du lecteur. Mais pour les développeurs, les crawlers, les curieux des DevTools et les gens qui impriment vraiment des articles ou inspectent les en-têtes de réponse, il y a désormais un peu plus de personnalité cachée juste sous la surface. Sympa, non ?Cette liste s'est depuis agrandie d'une unité : chaque easter egg ci-dessus suppose un clavier ou un panneau DevTools ouvert, ce qui ne sert à rien à quelqu'un qui lit sur son téléphone — alors [secouer le téléphone](/blog/docusaurus-shake-easter-egg) fait maintenant sursauter la mascotte en plein écran pendant quelques secondes, sans le moindre clavier.Si vous gérez votre propre blog Docusaurus et que vous avez une mascotte, un logo, ou même juste une couleur préférée : piochez-en un ou deux — ils sont tous petits, autonomes, et aucun ne demande de toucher à votre contenu. Commencez par celui qui correspond à l'endroit où vos lecteurs se trouvent déjà (la console DevTools si votre audience est composée de développeurs, les en-têtes HTTP si ce sont d'autres blogueurs qui aiment fouiller, le CSS d'impression si les gens impriment vos recettes ou tutoriels) et développez à partir de là.
