Aller au contenu principal

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.

· 14 minutes de lecture
Voir la source
Christophe
Amoureux de Markdown, WSL et Docker ~ Curieux insatiable.
Claude Code
Assistant IA
Cet article existe aussi en anglais — lire la version originale.

Traduction automatique

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, consultez l'article de référence en anglais — c'est lui qui fait foi.
Cet article fait partie de la série Créer des composants Docusaurus :

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

⚡
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 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 — 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​

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 :

view-source:https://www.avonture.be/blog/ripgrep/

CTRL-U - Afficher le source

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 Injecter de l'ASCII art dans toutes les pages HTML générées par Docusaurus, et la bannière provient du même générateur que celui de Bash - ASCII art :

>user@machine: ~/blog

$ 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é — Injecter de l'ASCII art dans toutes les pages HTML générées par Docusaurus — 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​

Le plus facile. Si vous ouvrez la console des DevTools de votre navigateur sur ce blog, vous verrez ceci :

// 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;',
);
}, []);

Ce code utilise un hook useEffect pour afficher un message ludique dans la console du navigateur quand un développeur ouvre les DevTools. Le message s'affiche une seule fois au chargement de la page, pas à chaque changement de route côté client, et invite l'utilisateur à découvrir un code secret (Konami code) sur le site.

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()

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​

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 :

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.

Essayez : cliquez sur ce lien inexisting_page puis appuyez sur F5 pour obtenir un message aléatoire.

4. La vedette du spectacle : un easter egg au code Konami​

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

Envie d'en savoir plus sur le code Konami ? Consultez cette page sur Wikipédia.

Le composant​

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

This component detects when a user types the famous Konami code (up, up, down, down, left, right, left, right, B, A) and triggers a fun animation of a meerkat running across the screen for three seconds. It handles keyboard input carefully to ignore key repeats and modifier keys that could interrupt the sequence, normalizing key values to work across different keyboard layouts.

La logique conserve un curseur useRef dans un tableau KONAMI_CODE de valeurs 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 :

Il est monté une seule fois, globalement, depuis src/theme/Root.js :

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​

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 :

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 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é​

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, 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 à la place — le caractère réellement produit, qui respecte la disposition configurée par le visiteur :

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 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​

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

// 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]);

Ce code crée un effet amusant : quand un utilisateur quitte l'onglet du navigateur, le titre et l'icône changent pour montrer une suricate endormie, puis reviennent à la normale quand l'utilisateur revient. L'effet s'active en écoutant l'événement visibilitychange du navigateur et se nettoie correctement quand le composant se démonte.

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​

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 :

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​

Imprimez un article (Ctrl+P) et une petite note de bas de page apparaît sur la page imprimée :

@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​

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.

/**
* 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");
},
};
}

This Docusaurus plugin adds a hidden Easter egg comment into the generated sitemap.xml file. It waits for the sitemap to be created, then injects a witty comment right after the opening <urlset> tag—a spot that XML parsers ignore, so it won't break search engine indexing.

Enregistré de la même façon que l'injecteur d'ASCII art :

Le résultat final​

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


Cet article fait partie de la série Créer des composants Docusaurus :
🦋 Rejoignez la conversation sur BlueskyUn compte Bluesky gratuit est nécessaire pour aimer, repartager ou commenter. Ce bloc interroge l'API publique de Bluesky directement depuis votre navigateur (sans cookie, sans connexion) — votre adresse IP parvient donc aux serveurs de Bluesky dès que cette section se charge.

Chargement des commentaires…

Cet article vous a-t-il été utile ?
Avez-vous suivi les étapes de cet article ?