Sphinx Doc et Pagefind

Joachim Robert -
This article is also available in English: Sphinx Doc and Pagefind

On ne nous a pas dit qu’intégrer Pagefind dans une documentation Sphinx était impossible, donc on l’a fait. Bon, on ne nous a pas non plus dit que ça n’était pas impossible.

Sphinx (site) est un générateur de documentation en Python. À Gandi, on l’utilise pour la plupart de nos documentations internes. Comme Hugo, c’est un générateur de site statique. En gros, ça veut dire qu’on écrit notre documentationdans dans des fichiers au format Markdown ou ReST, et Sphinx nous donne un site en HTML, avec les fichiers CSS, JS, etc. Sphinx peut aussi générer la doc en PDF ou en ePub, mais ça n’est pas le propos du jour.

Pagefind (site), qu’on utilise sur ce blog, est une bibliothèque front-end de recherche de contenus pour sites statiques. Ça fonctionne en générant un index du contenu juste après la génération du site en HTML, puis en mettant cet index à disposition du navigateur, via des fichiers JavaScript servis avec le site.

Sphinx est super mais ses fonctionnalités de recherche n’ont pas convaincu tout le monde dans l’équipe. Voyons ce que donne Pagefind dans Sphinx.

Voilà comment ça marche

Tout d’abord, on aura besoin d’indexer les contenus du site web. Pour ce faire, j’ai ajouté un fichier de config de Pagefind (pagefind.yml) à la racine du projet, où j’ai spécifié le répertoire source à parcourir, le sélecteur root_selector dans les fichiers HTML pour indiquer où trouver le contenu pertinent, et un sélecteur exclude_selectors qui exclut de l’index les permaliens ajoutés dans les titres par Sphinx.

# pagefind.yml
source: public
root_selector: article[role=main]
exclude_selectors:
  - a.headerlink

L’indexation est ensuite lancée avec la commande suivante :

npx pagefind

Maintenant, il faut afficher l’interface de Pagefind dans la page de recherche de Sphinx.

Un nouveau gabarit est nécessaire. Il contient la structure de la page (une extension de la page de base du thème courant, furo), et le code spécifique à Pagefind. Je dois placer ce gabarit dans un des répertoires définis dans la propriété templates_path du fichier de configuration de Sphinx (docs/conf.py). Dans mon cas, c’est dans docs/_templates.

<!-- docs/_templates/search.html -->
{% extends "page.html" %}

{%- block htmltitle -%}
<title>{{ _("Search") }} - {{ docstitle }}</title>
{%- endblock htmltitle -%}

{% block content %}
<h1>{{ _("Search") }}</h1>
<div id="search"></div>
{% endblock %}

{% block scripts -%}
{{ super() }}
<script src="/_pagefind/pagefind-ui.js" type="text/javascript"></script>
<script>
  window.addEventListener('DOMContentLoaded', () => {
  	// Chargement de l’interface de Pagefind
    new PagefindUI({
      element: "#search",
      showImages: false,
    });
  });
</script>
{%- endblock scripts %}

Pour remplacer la page de recherche par défaut de Sphinx, je dois aussi ajouter ce code dans le fichier de config de Sphinx :

# docs/conf.py
html_additional_pages = {
  'search': 'search.html', # chemin relatif au répertoire docs/_templates
}

On peut compiler la documentation, et avec la commande npx pagefind --serve on peut voir le résultat à l’adresse http://localhost:1414/search.html.

Cool, ça marche ! Bravo Joachim, t’as assuré, je paye mon coup au bar après le boulot.

Mais attends ! Quand j’utilise le champ de recherche dans la barre de menu de Sphinx, ça serait pas super bien si ça renseignait automatiquement le champ de recherche de l’interface de Pagefind ? Ouais, ça serait vraiment super bien. Pour que ça fonctionne, il faut d’abord qu’on attende le chargement de l’interface de Pagefind, puis on récupère la valeur du paramètre q transmis par le champ de recherche de Sphinx via l’URL, on l’injecte dans le champ de recherche, et on déclenche l’événement que Pagefind attend pour charger les résultats. Voilà comment ça marche en JS :

window.addEventListener('DOMContentLoaded', () => {
  // Chargement de l’interface de Pagefind
  new PagefindUI({
    element: "#search",
    showImages: false,
  });

  // On récupère les paramètres
  const urlParams = new URLSearchParams(window.location.search);
  // On récupère la valeur de `q`
  const query = urlParams.get('q');
  // On identifie le champ dans l’interface de Pagefind
  const searchInputElement = document.querySelector("#search form[role=search] input");

  // On crée un nouvel événement `input` pour le champ
  const inputChangeEvent = new Event('input');
  // On change la valeur du champ
  searchInputElement.value = query;
  // On déclenche l’événement `input` pour Pagefind
  searchInputElement?.dispatchEvent(inputChangeEvent);
  // Et on déplace le focus de la page vers le champ de recherche
  searchInputElement?.focus();
});

Super ! L’interactivité fonctionne bien. Maintenant on peut ajouter quelques styles basiques sur le champ :

#search form input[type="text"] {
  width: 100%;
  box-sizing: border-box;
  line-height: 2em;
  padding-inline: 0.6em;
  font-size: 1.2rem;
  border-radius: 0.25rem;
  /* Les CSS Custom Properties proviennent du thème Sphinx */
  border: 2px solid var(--color-foreground-border);
  border-bottom-color: 2px solid var(--color-foreground-secondary);
  transition: border-color 20ms ease;
}
#search form input[type="text"]:focus {
  border-color: var(--color-foreground-primary);
}

Résultat

Le code final de la page de recherche ressemble maintenant à ça :

<!-- docs/_templates/search.html -->
{% extends "page.html" %}

{%- block htmltitle -%}
<title>{{ _("Search") }} - {{ docstitle }}</title>
{%- endblock htmltitle -%}

{% block content %}
<h1>{{ _("Search") }}</h1>
<div id="search"></div>
{% endblock %}

{% block scripts -%}
{{ super() }}
<script src="/_pagefind/pagefind-ui.js" type="text/javascript"></script>
<script>
  window.addEventListener('DOMContentLoaded', () => {
    new PagefindUI({
      element: "#search",
      showImages: false,
    });

    const urlParams = new URLSearchParams(window.location.search);
    const query = urlParams.get('q');
    const searchInputElement = document.querySelector("#search form[role=search] input");

    const inputChangeEvent = new Event('input');
    searchInputElement.value = query;
    searchInputElement?.dispatchEvent(inputChangeEvent);
    searchInputElement?.focus();
  });
</script>
{%- endblock scripts %}

{% block extra_styles -%}
{{ super() }}
  <style type="text/css">
    #search form input[type="text"] {
      box-sizing: border-box;
      width: 100%;
      line-height: 2em;
      padding-inline: 0.6em;
      font-size: 1.2rem;
      border-radius: 0.25rem;
      border: 2px solid var(--color-foreground-border);
      border-bottom-color: 2px solid var(--color-foreground-secondary);
      transition: border-color 20ms ease;
    }
    #search form input[type="text"]:focus {
      border-color: var(--color-foreground-primary);
    }
  </style>
{%- endblock extra_styles %}

On peut recompiler la doc, recharger http://localhost:1414/search.html, and voilà !

Capture d’écran de la page de recherche d’une documentation Sphinx, qui fonctionne avec Pagefind