Blog de développement

52 – Granulosearch : une recherche plein texte 100 % locale pour un site statique

Dernière modification : 2026-07-15

Le miroir français du site Topas que j’héberge sur granuloshop.com/TO/ avait tout… sauf une recherche. La fonction d’origine (ke_search, TYPO3) interrogeait le serveur du fabricant : inutilisable sur un site statique, elle avait été débranchée dès la construction du miroir, avec ce commentaire dans le code : « la fonction sera réactivée quand une recherche locale sera développée ». Ce billet raconte cette réactivation : Granulosearch, une recherche plein texte qui tourne entièrement dans le navigateur.

Le cahier des charges tenait en trois lignes : aucun service externe (ni CDN, ni API), aucun cookie, aucun terme de recherche transmis à quiconque — et une intégration invisible dans la charte du site. Le moteur retenu : Pagefind →, qui indexe le site au moment de sa construction et exécute la recherche en WebAssembly, en chargeant à la demande de petits fragments d’index.

Le périmètre par attributs, pas par listes noires

Pagefind a une règle élégante : dès qu’une page porte l’attribut data-pagefind-body, toutes celles qui ne le portent pas sortent de l’index. En posant l’attribut sur le conteneur de contenu principal de chaque page, on exclut d’un coup les menus, pieds de page et pages utilitaires — sans aucune liste à maintenir. Deux raffinements font la différence sur un site produit : ignorer les blocs « produits similaires » (sinon chaque référence croisée matche partout) et porter le code produit en métadonnée, affichée sous chaque résultat. Deux filtres (type de page, catégorie) complètent l’interface.

Premier piège : IIS et le 404 déguisé

Symptôme : la recherche « mouline », puis Pagefind: WASM Error (No pointer). Cause : IIS refuse de servir les extensions inconnues du bundle (.pf_meta, .pf_fragment…), et la page 404 personnalisée du site répond… du HTML, que le moteur tente de décoder comme un index binaire. Piège dans le piège : .pf_filter n’apparaît que lorsqu’on ajoute des filtres — tout marche, puis on enrichit l’interface, et une 404 surgit. Solution : un web.config de types MIME dans le dossier du bundle, recopié automatiquement par le script d’indexation (l’option --clean rase le dossier à chaque build), et versionné par un en-tête HTTP maison — un curl -I dit alors quelle version est réellement en ligne.

Deuxième piège : la CSP… et le Worker qui n’hérite de rien

Sous CSP stricte, WebAssembly exige 'wasm-unsafe-eval' (qui n’autorise que le WASM, pas eval()). Réflexe sain : la scoper à la seule page de recherche par un <location>. Et là, surprise : l’erreur persiste alors que curl montre le bon en-tête sur la page. Explication : Pagefind tourne dans un Web Worker, et un worker obéit à la CSP livrée avec la réponse HTTP de son propre script, pas à celle du document. La solution élégante : porter la CSP enrichie par le web.config du dossier du bundle — le même fichier que les MIME — qui accompagne alors le worker dans tous les environnements. Le reste du site garde sa CSP stricte à l’identique.

Troisième piège : le mode « bouton » et le debounce qui avale tout

Je voulais une recherche déclenchée au clic ou à Entrée, pas à chaque frappe. Idée naïve : pousser le debounceTimeoutMs de l’UI à une valeur énorme et appeler triggerSearch() depuis un bouton. Résultat : clic → rien. Ni erreur, ni requête. La lecture du source minifié de pagefind-ui donne la preuve :

I>0&&Q ? (clearTimeout(de), de=setTimeout(Y,I), ...) : Y()

Dès que le debounce est supérieur à zéro, toute recherche passe par le minuteur — triggerSearch() compris. Mon clic était programmé pour 24 heures plus tard, en silence parfait. Le vrai mode bouton s’obtient à l’envers : debounceTimeoutMs: 0 (la même ligne montre qu’à zéro la recherche est immédiate), champ interne de l’UI masqué, champ visible découplé, bouton et touche Entrée qui appellent triggerSearch().

Intégration : Granulosearch©

Le reste est de l’intégration soignée : page de recherche reconstruite dans le gabarit du site (couleurs et angles relevés dans les CSS de la charte), bouton au style maison avec survol inversé, surlignage des extraits repris du motif du site, sélecteur de langues piloté par un fichier de paramètres (prêt pour NL/EN/DE), et une loupe réinjectée en fin de barre de navigation sur les ~250 pages — là où vivait celle d’origine. Le tout est une étape bloquante du pipeline de publication : si l’index échoue, rien ne part en ligne.

Valider sans navigateur : lire l’index lui-même

Les fragments du bundle sont du gzip précédé d’un petit préfixe : une fois décodés, ils exposent l’URL, le texte et les métadonnées de chaque page. De quoi bâtir un audit statique complet (34 contrôles : requêtes clés, fiches attendues, aucune URL de préproduction dans l’index…) sans lancer un navigateur — complété par des tests Playwright en conditions réelles : position des fiches exactes, clics en HTTP 200, mobile, et surtout zéro requête sortante pendant la recherche, vérifié run après run.

Bilan

Une recherche instantanée, entièrement servie par son propre domaine, qui ne transmet rien à personne — et un moteur qui n’a plus de secret pour son exploitant. Les quatre pièges du chemin (MIME, Worker, langue, debounce) sont détaillés, avec leurs solutions prêtes à l’emploi, dans le PDF compagnon. Prochaines étapes envisagées : l’indexation de la documentation PDF et des vignettes dans les résultats — matière à un futur billet.