Sphinx Doc and Pagefind

Joachim Robert -
Cet article est aussi disponible en français : Sphinx Doc et Pagefind

They didn’t tell us that integrating Pagefind into a Sphinx-powered documentation site was impossible, so we did it. To be fair, they also didn’t tell us that it was not impossible.

Sphinx (website) is a documentation generator in Python. At Gandi, Sphinx powers most of our internal documentation needs. Like Hugo, it is a static site generator. Basically, we write our documentation files in Markdown or ReST formats, and it gives us HTML files, along with assets like CSS, JS, etc. Sphinx can also output our docs in PDF or ePub formats, but that won’t concern us today.

Pagefind (website), which we use on this blog, is a front-end search library for static websites. It works by generating an index of the website just after it has been built to HTML, and providing that index to JavaScript files that are served with the website.

Sphinx is great but its search functionalities are not to everybody’s tastes in the team. Let’s see how Sphinx and Pagefind can work together.

Here’s how it works

First, we’ll need to index the contents of the website. In order to do that, I added a Pagefind config file (pagefind.yml) at the root of the project, where I specified the source directory to browse, the root_selector in the HTML files in which it would find the indexable content, and an exclude_selectors to disable the indexing of the hash permalink on titles.

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

The indexing is then launched with the following command:

npx pagefind

Now I have to display the Pagefind UI inside Sphinx’s search page.

A new template page is needed. It contains the page structure (extending the base page for the current theme, furo) and the specific Pagefind code. It is located in one of the directories set as templates_path in Sphinx’s config file docs/conf.py. Here, it’s located in 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', () => {
  	// Loading Pagefind UI
    new PagefindUI({
      element: "#search",
      showImages: false,
    });
  });
</script>
{%- endblock scripts %}

To overwrite the default Sphinx search page, I’ll add this code in Sphinx’s config file:

# docs/conf.py
html_additional_pages = {
  'search': 'search.html', # relative to the docs/_templates directory
}

Now, we compile the docs, and with the command npx pagefind --serve we can see the result at http://localhost:1414/search.html.

Yay, it works! Congrats Joachim, you nailed it, beers are on me later at the pub.

But wait! When I use the search input in Sphinx’s sidebar, how cool would it be if it filled the input in Pagefind’s search interface? Yes, it would be cool. In order to do that, we’ll have to wait for Pagefind’s UI to load, and then we’ll get the value of the q parameter in the URL, we’ll inject it in the input, and we’ll trigger the event that Pagefind is waiting for. Let’s check out how it looks like in JS:

window.addEventListener('DOMContentLoaded', () => {
  // Load the Pagefind UI
  new PagefindUI({
    element: "#search",
    showImages: false,
  });

  // Get the URL params
  const urlParams = new URLSearchParams(window.location.search);
  // Get the value of `q`
  const query = urlParams.get('q');
  // Get the input element for Pagefind’s UI
  const searchInputElement = document.querySelector("#search form[role=search] input");

  // Create a new event for the input element
  const inputChangeEvent = new Event('input');
  // Change the value of the input element
  searchInputElement.value = query;
  // Dispatch the input event for Pagefind
  searchInputElement?.dispatchEvent(inputChangeEvent);
  // And change the page focus to the input element
  searchInputElement?.focus();
});

Great! The interactivity’s all working. Now let’s add some basic styles to the input:

#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;
  /* we’re using CSS Custom Proprerties from the theme */
  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);
}

Wrapping it up

The final code for the search page now looks like this:

<!-- 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 %}

Recompile the docs, reload http://localhost:1414/search.html, and voilà!

Screenshot of a Sphinx documentation search page, powered by Pagefind