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 toute façon 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.


Cartes Magic perso : Cycle Demwen

On continue la série de republications de cartes Magic perso avec cette fois un cycle de 5 cartes réalisées à l'époque dans le cadre d'un jeu FC sur l'Assemblée de Funomanciens (je n'ai plus retrouvé le sujet exact du défi mais il fallait qu'on propose un extrait d'histoire qui justifie la ou les cartes), avec leur commentaire d'origine. L'ensemble a été finalisé à l'époque de façon un peu précipitée sans que je le retravaille ensuite, contrairement à d'autres cartes, ce qui explique certains passages du commentaire. De même l'histoire mériterait sans doute d'être un peu retravaillée.

Demwen, fillette captive Demwen, enfant sauvage Demwen, adolescente ardente Demwen, femme cruelle Demwen, esprit apaisé
Demwen, fillette captive (HD) / Demwen, enfant sauvage (HD) / Demwen, adolescente ardente (HD) / Demwen, femme cruelle (HD) / Demwen, esprit apaisé (HD)

En cherchant des illustrations, l'idée m'est venue de faire un cycle reprenant les étapes de la vie d'un personnage (chose qui n'existe pas encore à ce jour dans les cartes officielles il me semble). Les deux premières illustrations collaient plutôt bien à l'idée et trainaient dans ma sélection sur deviantART depuis un bout de temps, c'était donc l'occasion de les exploiter ^^

On a donc l'histoire d'un ange, Demwen. Comme souvent quand je manque d'inspiration pour un nom, j'ai pioché dans le vocabulaire elfique élaboré par Tolkien (le Sindarin en l’occurrence). On a donc dem qui signifie triste et le suffixe -wen signifiant fille, vierge, jeune fille d'après le dictionnaire proposé par le site Ambar Eldaron. Vous l'aurez compris, notre amie Demwen va bien entendu jouir d'une vie heureuse et dans la joie et l'allégresse.

Ou pas.

Durant les premières années de sa vie, Demwen fut prisonnière, enchaînée, ses ailes entravées. N'apercevant le jour qu'à travers les barreaux de sa cellule, elle rêvait du jour où, enfin libérée, elle parcourrait les cieux. Priant Serra, la divinité angélique d'un lointain passé dont les légendes louaient la bonté et la perfection, elle savourait chaque nouvelle aube comme un nouvel espoir. Mais chaque jour avait également son crépuscule et les années passèrent, lui semblant une éternité.

Puis le jour vint ou ses prières furent exaucées. Dans le fracas des combats d'une guerre dont elle ne savait rien, les chaines qui l'emprisonnaient furent brisées et le mur de sa triste geôle s'effondra. Terrifiée par les combats, Demwen prit la fuite, se réfugiant dans la forêt. Perdue dans ce monde étranger, ses ailes atrophiées par la captivité ne pouvant la porter bien longtemps, la jeune évadée resta dans cette forêt sombre et hostile.

Elle regorgeait de prédateurs, mais la fillette apprit à éviter leurs pièges et à lutter pour survivre. Le temps passa et l'ancienne captive, devenue enfant sauvage, grandit. Devenant plus forte, de gibier, elle devint prédateur. Ses ailes s'étaient renforcées et étaient devenues un atout, même dans cette forêt emplie d'obstacles.

Puis revint à nouveau la guerre. La forêt fut dévorée par les flammes, mais l'adolescente survécut une fois de plus, apprivoisant le feu, s'en faisant une nouvelle arme. Mais il ne restait rien de ce qui était devenue sa maison et Demwen prit son envol parcourant Ulgrotha, recherchant un nouveau lieu où vivre.

Elle finit par échouer dans la sombre baronnie de la famille Sengir. Où elle défit le bras droit du maitre des lieux en combat singulier qui périt brûlé vif. Le Baron, impressionné par ses aptitudes la prit sous son aile et elle intégra les troupes sengiennes, en lieu et place du lieutenant déchu. Les années passèrent ainsi et la jeune femme qu'elle était devenue sombrait toujours plus dans la noirceur et la cruauté.

Jusqu'au jour où, au gré des combats, elle se retrouva face à face à l'une de ses sœurs de race. L'ange était pure autan que Demwen était souillée et leur duel fut violent et sanglant. Les deux championnes périrent, entrainant avec elles une grande partie de leurs alliés.

Mais si son corps fut détruit, l'esprit de l'ancienne captive survécut. Absorbant les restes de l'âme déchirée de son adversaire, elle put se défaire de la souillure qui envahissait la sienne, retrouvant la pureté angélique que sa dure existence lui avait enlevée. Sous son influence protectrice, l'ancien champ de bataille devint un havre, un refuge pour les voyageurs égarés.

Jusqu'au jour où un arpenteur échoua en Ulgrotha. Comme c'est trop souvent le cas avec ces magiciens voyageurs des plans, il était avide de nouveau pouvoir et, à l'aide d'un puissant sortilège, il s'appropria les pouvoirs de Demwen. Mais cette nouvelle puissance qu'il venait d'acquérir était liée à elle. Si son âme s'éteignait, elle s'éteindrait avec elle. Il lui fallait un corps pour lui permettre de se régénérer. La privant de ses souvenirs, il la plaça dans le corps d'une enfant, l'enchaîna et l'enferma dans une prison où elle vivrait captive mais protégée.

Donc voilà voilà, que du bonheur, comme on dit sur TF1.

On a donc les cinq étapes de l'existence de Demwen, avec à chaque fois une capacité permettant de passer à l'étape suivante (en sacrifiant l'actuelle et en cherchant la suivante dans sa bibliothèque), conditionnée par un critère en restreignant l'usage. Chaque version est un ange et a donc le vol, comme tous les anges de Magic me semble-t-il. Elle a également une deuxième capacité à mot-clé correspondant à cette étape, ainsi qu'une capacité déclenchée lorsqu'elle meurt, liée à la condition de déclenchement du changement d'étape précédent.

Les cinq étapes sont les suivantes :

  • la fillette captive ne peut logiquement pas attaquer, d'où le défenseur. La carte piochée représente les possibilités offertes par la liberté qu'elle acquiert et la condition de passage (une créature de force 3 ou plus) symbolise la créature qui, dans le feu de l'action et sans le faire exprès, l'a libérée de sa prison
  • l'enfant sauvage non seulement peut attaquer, mais a le contact mortel : la proie des bêtes sauvages est devenue chasseresse. Le boostage fourni en quittant le champ de bataille représente le regain d'énergie gagné en apprivoisant le feu et la condition de passage représente l'attaque de la forêt qui a déclenché le changement
  • l'adolescente ardente a est une voyageuse rapide et féroce, d'où la célérité. Les trois blessures infligées en quittant le champ de bataille rappellent le lieutenant du Baron, brûlé vif et la condition de passage à l'étape suivante représente la mort du lieutenant et de deux autre soldats qui étaient au mauvais endroit au mauvais moment (bon OK, me rattrape aux branches basses, mais il est déjà presque 22h30 et j'ai la flemme de reprendre ma storyline pour introduire cet élément oublié :p)
  • la femme cruelle est donc la nouvelle championne du Baron. Intégrant l'armée d'un comte vampire, c'est fort logiquement qu'elle acquiert donc la soif de sang. La destruction de trois créatures ciblées (notons qu'il n'y a pas le choix : si l'adversaire n'a pas de créatures à tuer, il faut tuer les siennes) représente les dégâts occasionnés par le duel, tandis que la condition d'activation fait écho à l’absorption de l'âme de son adversaire (oui bon, d'accord, j'ai brodé les détails de ma storyline bien après avoir finalisé les cartes, donc y a deux-trois petits problèmes de chronologie :oops:)
  • enfin l'esprit apaisé est puissant et protège le lieu de la bataille qui lui a coûté la vie et ce lieu la renforce et la protège en retour, d'où la défense talismanique. Les points de vie gagnés représentent le pouvoir acquis par l'arpenteur et les trois cartes en main les trois sortilèges qu'il lance pour lui prendre son pouvoir, lui donner son nouveau corps et priver de ses souvenirs en la scellant dans sa nouvelle prison

Et voilà que la boucle est bouclée.

Comme ce cycle doit être joué ensemble pour exploiter tout son potentiel, j'avais le choix entre utiliser peu de mana coloré et cette autre solution que j'ai retenue de donner le choix entre du mana coloré et le double de mana générique, ce qui me semblait un bon compromis : on peut garder des coûts raisonnables en coloré et laisser un peu de souplesse en autorisant un paiement dans une autre couleur (moyennant une rallonge budgétaire).

Reste à parler un peu de la réalisation :

  • comme toujours, la carte est réalisée sous GIMP avec le XCF de Sovelis et NorthNikko, rien de nouveau sous le soleil.
  • la texture est réalisée sous Photoshop Elements et se base sur celle-ci, complétée de plumes... qu'on ne voit plus du tout sur le résultat final. Pas plus qu'on ne voit que les textures des 5 cartes n'en forment qu'une seule... mais bon, tant pis :o
  • les illustrations des 5 cartes sont respectivement les suivantes :

Quelques trucs sur Jekyll #2

Lisibilité des erreurs

Par défaut, le serveur de développement de Jekyll remonte des erreurs assez sommaires en ne précisant que le template concerné. C’est inutilisable quand on débugue un template (même si un numéro de ligne ne serait pas du luxe) mais quand l’erreur vient d’un plugin c’est plutôt limité.

Pour un affichage un peu plus détaillé avec une trace d’exécution permettant de déterminer quelle ligne de quel plugin est en cause, il faut utiliser l’option --trace ou -t.

bundle exec jekyll serve --drafts -t

Fichiers sitemap.xml et robots.txt

Il existe un plugin pour générer le fichier sitemap.xml, par contre, je n’ai rien trouvé pour gérer son intégration au fichier robots.txt. On peut certes faire un fichier robots.txt à la main mais le lien est censé être absolu, donc ce n’est pas super propre de le mettre en dur.

Finalement je m’en suis sorti en ajoutant un fichier robots.html avec le contenu suivant :

---
layout: null
permalink: robots.txt
---

Sitemap: {{ site.url }}/sitemap.xml

La directive permalink: robots.txt permet de forcer le bon nom de fichier, layout: null dit que le contenu ne doit pas du tout être habillé et comme on est dans une page normale, on a accès aux variables globales, notamment site.url.

Éviter la profusion d’espaces dans les pages

Le moteur de template de Jekyll fonctionne en ajoutant des pseudo-balises dans le code HTML notamment pour indiquer les structures de contrôles, boucles, affectations de variables, etc.

Pour que le code reste lisible, on fait des retours à la ligne entre ces différentes balises et on indente. Ceci aboutit à de très nombreuses lignes uniquement peuplées d’espaces (ceux de l’indentation).

Exemple :

<h2>Catégories</h2>
{% assign groupedCategories = site.categories | group_categories %}
{% for group in groupedCategories %}
<h3>{{ group[0] }}</h3>
  <ul>
    {% assign categories = group[1] | sort_by_keys %}
    {% for category in categories %}
      <li><a href="/categories/{{ category[0]|slugify:'latin' }}/">{{ category[0] }}</a> ({{ category[1].size }})</li>
    {% endfor %}
  </ul>
{% endfor %}

Il est possible d’y remédier en utilisant un caractère - dans les balises. Chaque - suivant une ouverture de balise ({% devient {%-) indique que les espaces précédents doivent être ignorés et sur les fermetures ( (%} devient -%})) que les espaces suivants doivent être ignorés.

L’exemple précédent devient alors :

<h2>Catégories</h2>
{%- assign groupedCategories = site.categories | group_categories -%}
{% for group in groupedCategories %}
<h3>{{ group[0] }}</h3>
  <ul>
    {%- assign categories = group[1] | sort_by_keys -%}
    {% for category in categories %}
      <li><a href="/categories/{{ category[0]|slugify:'latin' }}/">{{ category[0] }}</a> ({{ category[1].size }})</li>
    {%- endfor %}
  </ul>
{% endfor %}

C’est la même syntaxe que l’on retrouve dans d’autres moteurs de templates comme Twig pour PHP (la plupart des syntaxes sont assez proches en fait entre ces deux moteurs).

Remarque : cette syntaxe semble ne pas bien fonctionner avec le tag endraw, en effet j’ai des erreurs dès que je place un tiret un en début de ce tag (alors qu’à la fin et sur le tag raw ça marche)…


Carte Magic perso : PIOU PIOU PIOU

On continue la série de republications de cartes Magic perso avec une plutôt orientée humour débile.

NB : Comme d'habitude, ne comptez pas sur moi pour utiliser le terme anglais "planeswalker". Le seul intérêt de cette anglicisation d'un terme existant est purement marketing, or le marketing ne nous concerne pas ici.

PIOU PIOU PIOU
PIOU PIOU PIOU

Une autre carte que j'ai pour l'essentiel réalisée à la même période que Engeance des poulaillers et qui est inspirée de la même chanson : Le Laridé du Poulet.

La chanson s'inspire de l'un des épisodes de la saison 1 du Donjon de Naheulbeuk où les aventuriers croisent un poulet au détour d'un couloir et débattent pendant cinq minutes pour décider de s'il ne s'agit bien que d'un poulet ou bien d'un horrible changeforme sanguinaire.

L'idée est d'avoir un gallinacé arpenteur qui n'ait l'air de rien au début (donc pas cher) mais qui soit radical au final : un ou plusieurs jours perdent la partie directement. Tout ça en conservant la forte de dose de hasard inhérente au poulet qu'on ne peut pas différencier de prime abord du monstre camouflé.

De là, on a l'ultime à Symbole : loyauté -6 qui fait jeter un dé à chaque joueur et élimine tous ceux ayant obtenu le score le plus petit.

Comme c'est un arpenteur, il fallait une capacité permettant de gagner des marqueur loyauté, j'ai donc pris une version allégée de l'ultime qui ne fait que deux blessures : on titille le monstre qui balance des petits coups puis au bout d'un moment, il perd patience et *PAF* l'ultime :smile:

Pour le nom, j'ai repris le "PIOU PIOU PIOU" de la chanson, sans chercher beaucoup plus loin : la carte ayant une vocation humoristique (sans être totalement délirante non plus), ça me semblait approprié ^^

Initialement, j'en avais fait une carte noire pure (notamment parce que j'étais parti de cette illustration, mais comme son auteur dit explicitement qu'on n'est pas censés la réutiliser, j'en ai pris une autre au dernier moment) mais comme on me l'a fait remarquer lorsque je l'ai postée sur le forum à l'époque, on a du hasard et des blessures, ça fait très rouge à la base. Et au final, ça cadre bien mieux avec la nouvelle illustration.

Crédits :

J'ai également repris les commentaires de l'époque. Je vais aussi les reprendre progressivement sur les autres cartes déjà publiées.


Quelques trucs sur UNIX/Linux #5

Générer un mot de passe aléatoire

Pour générer une chaîne aléatoire, pouvant par exemple servir de mot de passe, dans un terminal entrer la ligne suivante :

< /dev/urandom tr -dc _A-Z-a-z-0-9 | head -c 10; echo ""

Il est possible de modifier les caractères autorisés via l’option -dc de urandom et le nombre de caractères via l’option -c de head.

Le echo "" sert juste à forcer un retour à la ligne.

Correction d’une commande

Dans le terminal, il est possible de la ré-exécuter la dernière commande en remplaçant la première occurrence d’une chaîne par une autre.

Exemple :

# Affiche 'toto'
echo 'toto'
# Affiche 'tato'
^to^ta^

Ceci peut être utile pour corriger une faute de frappe. Attention, seule la première occurrence est remplacée et la nouvelle commande est exécutée dans la foulée sans demande de confirmation.

Revenir en arrière dans les répertoires

La commande cd permet de revenir au précédent répertoire où l’on était via cd -.

Attention, cela fait bien retourner au dernier dossier où l’on était et pas remonter d’un cran dans l’arborescence (qui se fait via cd ..).