Mot-clef « Lunr »

Moteur de recherche dans un site Jekyll

La problématique

Dernier des gros points noirs relevés lors de ma bascule sur Jekyll : l’absence de moteur de recherche.

Forcément quand le site est statique, c’est plus compliqué de mettre en place un moteur de recherche. Il y a plusieurs approches possibles :

  • faire une partie dynamique côté serveur pour la recherche : bof, le but c’est d’avoir maintenance côté serveur, donc non
  • faire appel à une API tierce qui ferait l’indexation puis la recherche : bof aussi, ça permet certes de rester statique côté serveur mais délocaliser la chose n’est pas mieux (en plus ça veut dire maintenance sur l’utilisation de l’API) donc non
  • déléguer complètement à un moteur externe type Google Search : ça existe sur certains sites (quoique ça fait un moment que je l’ai plus vu) sous la forme d’un formulaire de recherche redirigeant sur Google avec une recherche de type site:monsite.com ...… c’est mieux côté maintenance mais pire côté délocalisation puisqu’on quitte carrément le site, donc non

Reste la dernière : faire la recherche directement dans le navigateur en JS à partir d’un index fourni, solution finalement retenue.

Bon, c’est loin d’être parfait comme concept : ça ne passe évidemment pas à l’échelle notamment mais ce site reste un blog perso donc la volumétrie de devrait pas exploser…

L’index fait environ 500ko avec mes 156 articles actuels (représentant un peu plus de 10 ans) donc ça reste jouable, même si j’ai augmenté mon rythme ces derniers temps (notamment à cause des republications). Dans tous les cas on reste loin des pages d’accueil avec des gros carrousels d’images à 2Mo chacune qu’on trouve sur certains sites (notamment e-commerce) et ce n’est de toutes façons chargé que sur la page de recherche.

Donc c’est réaliste dans mon cas et vous pouvez le tester via le champ de recherche dans la colonne de droite.

Solution existante et adaptations

Je suis parti de cet article proposant une solution basée sur la bibliothèque Lunr mais comme souvent c’était loin d’être satisfaisant de base :

  • forcément c’est en anglais, donc fallait au moins traduire
  • ça ne permet pas de placer le champ de recherche sur toutes les pages (à moins d’inclure le code de recherche sur toutes les pages aussi, ce qui est exclus)
  • visuellement le rendu ne me convenait pas du tout
  • toutes les pages sont indexées, y compris les pages de catégories par exemple qui ne sont que des regroupements d’articles

Du coup j’ai réorganisé les choses différemment en :

  • séparant l’index dans un fichier JS à part
  • modifiant le formulaire pour qu’il fasse un simple GET vers une page dédiée au résultat de recherche
  • modifiant le JS pour que le terme à rechercher soit cherché dans l’URL
  • ajoutant dans l’en-tête des pages devant être indexées un paramètre searchable: true

La section suivante détaille la marche à suivre pour intégrer tout ça sur votre site.

Mise en œuvre

La bibliothèque Lunr

À récupérer ici et à ajouter dans le dossier assets/js.

Le code JS utilisant Lunr

Dans un fichier assets/js/search.js :

(function (window) {
	window.onload = function () {
		var idx = lunr(function () {
			this.ref('id');
			this.field('title');
			this.field('body');

			window.searchIndex.forEach(function (doc) {
				this.add(doc)
			}, this)
		});

		var resultsElement = document.getElementById('search-results');
		var HTML = '';
		var term = getQueryString('q');
		if (term) {
			document.getElementById('search-field').value = term;
			var results = idx.search(term);
			if (results.length > 0) {
				var plural = results.length > 1;
				HTML += '<p>' + results.length + ' résultat' + (plural ? 's' : '') + ' trouvé' + (plural ? 's' : '') + ' pour la recherche « ' + term + ' » :</p> <ul>';
				for (var i = 0; i < results.length; i++) {
					var result = window.searchIndex[results[i]['ref']];
					var url = result['url'];
					var title = result['title'];
					var date = result['date'];
					var body = result['body'].substring(0,160) + '...';
					var type = result.type === 'post' ? 'Article' : 'Page';

					HTML += '<li class="search-result"><p class="metadata"><a href="' + url + '" class="title">' + title + '</a>';
					HTML += ' <span class="type">' + type + '</span>';
					if (date) {
						HTML += ' <span class="date">publié le ' + date + '</span>';
					}
					HTML += '</p><p class="summary">' + body + '</p></li>';
				}
				HTML += '</ul>';
			}
			else {
				HTML += '<p>Aucun résultat trouvé poru la recherche « ' + term + ' ».</p>';
			}
		}
		else {
			HTML += '<p>Aucun terme à rechercher.</p>';
		}
		resultsElement.innerHTML = HTML;
	};

	/**
	 * Get the value of a query string.
	 * @param {string} field The field to get the value of
	 * @param {string=} url The URL to get the value from (optional)
	 * @return {string} The field value
	 */
	var getQueryString = function (field, url) {
		var href = url ? url : window.location.href;
		var reg = new RegExp( '[?&]' + field + '=([^&#]*)', 'i' );
		var string = reg.exec(href);
		return string ? string[1] : null;
	};
})(window);

Je n’ai pas cherché à extraire le rendu HTML du résultat de recherche, donc il faudra directement modifier ce fichier pour l’arranger.

Le fichier d’index : search-index.js

Dans un fichier search-index.html à la racine du projet :

---
layout: null
permalink: search-index.js
---

(function (window) {
  {%- assign counter = 0 %}
  window.searchIndex = [{% for page in site.pages %}{% if page.searchable %}{
    "id": {{ counter }},
    "url": "{{ site.url }}{{ page.url }}",
    "type": "page",
    "title": "{{ page.title | replace: '"', ' ' }}",
    "body": "{{ page.content | markdownify | replace: '.', '. ' | replace: '</h2>', ': ' | replace: '</h3>', ': ' | replace: '</h4>', ': ' | replace: '</p>', ' ' | strip_html | strip_newlines | replace: '  ', ' ' | replace: '"', ' ' }}"{% assign counter = counter | plus: 1 %}
  }, {% endif %}{% endfor %}{% for page in site.posts %}{
    "id": {{ counter }},
    "url": "{{ site.url }}{{ page.url }}",
    "type": "post",
    "date": "{{ page.date | date: '%d/%m/%Y à %R' }}",
    "title": "{{ page.title | replace: '"', ' ' }}",
    "body": "{{ page.content | markdownify | replace: '.', '. ' | replace: '</h2>', ': ' | replace: '</h3>', ': ' | replace: '</h4>', ': ' | replace: '</p>', ' ' | strip_html | strip_newlines | replace: '  ', ' ' | replace: '"', ' ' }}"{% assign counter = counter | plus: 1 %}
  }{% if forloop.last %}{% else %}, {% endif %}{% endfor %}];
})(window);

Il est possible de modifier ce fichier si vous voulez ajouter des informations à l’index (par exemple des données complémentaires pour l’affichage des résultats).

La page de résultat de recherche

Dans un fichier search.md à la racine du projet :

---
layout: page
title: Recherche
---

<script src="/assets/js/lunr.js?v={{ site.time | date: '%s' }}" type="application/javascript" async="async"></script>
<script src="/assets/js/search.js?v={{ site.time | date: '%s' }}" type="application/javascript" async="async"></script>
<script src="/search-index.js?v={{ site.time | date: '%s' }}" type="application/javascript" async="async"></script>

<div id="search-results">Recherche en cours...</div>

L’entrée layout est à adapter en fonction de ce que vous avez défini sur votre site.

Le formulaire de recherche

Il reste à intégrer le formulaire de recherche là où vous souhaitez de voir apparaitre :

<form action="/search.html" method="get" class="search-form">
  <p>
    <input id="search-field" type="text" name="q" maxlength="255" value="" />
    <button type="submit" title="Lancer la recherche"><img src="/assets/img/search.svg" alt="Lancer la recherche" /></button>
  </p>
</form>

Pages indexables

En l’état, seuls les articles seront indexés. Il reste donc à ajouter searchable: true dans l’en-tête de chaque page que vous souhaitez indexer.