Génération automatique des slugs pour articles Pelican

Un petit bout de Python pour nettoyer les sources du blog

Un slug est une chaîne de caractères lisible par les humains mais qui ne comporte pas de caractères exotiques — tels que les espaces, les accents ou la ponctuation. Il est souvent utilisé pour générer des URLs lisibles pour les humains, tout en étant conservateur sur les caractères utilisés — afin d’éviter l’encodage des espaces en %20 dans les URLs, par exemple.

Le slug de cet article est visible dans l’URL en haut [1] de votre navigateur : generation-automatique-slug-pelican.html. Ma problématique est que ce slug est actuellement renseigné manuellement dans tous les fichiers sources des articles de ce blog. On pourrait envisager de nommer correctement le fichier source de l’article, et de faire en sorte que ce nom soit utilisé pour générer le slug final du fichier HTML produit.

Étant donné le nombre d’articles à retravailler — et à éventuellement renommer — il va falloir automatiser tout ça à l’aide d’un programme de migration. C’est ce que nous allons explorer dans cet article.

[1]En espérant que votre navigateur l’affiche encore. Une tendance voudrait simplifier la navigation web en troquant le https:// pour un cadenas, ainsi qu’en affichant uniquement le nom de domaine et en masquant l’URL. Éspèrons que nous n’en arriverons pas là.

Le script de création d’article

Pour créer un nouvel article sur le blog, je fais appel à mon fidèle new_article.py qui m’accompagne depuis plusieurs années déjà. Celui-ci me demande interactivement les métadonnées essentielles de l’article :

  • slug ;
  • titre ;
  • description complémentaire ;
  • tags.

Il va utiliser la date du jour pour la date de l’article — même si en pratique il s’écoule plusieurs jours entre la rédaction du brouillon et la publication finale. Finalement, il s’occupe de créer le fichier correspondant au format restructuredText [2].

[2]si vous utilisez encore du flavored-watever-Markdown en 2019, je vous invite fortement à essayer, c’est beaucoup plus complet niveau rédaction.

Voici un exemple de rendu pour cet article même :

Génération automatique des slugs pour articles Pelican
######################################################

:date: 2019-10-19
:summary: Un petit bout de Python pour nettoyer les sources du blog
:tags: meta, blog, python, pelican

On notera que le champ :slug: n’apparait pas ici, car c’est en effet tout l’objet de cet article.

Intéressons nous maintenant à la version originale de new_article.py. Ce script date de la création du blog en 2014, il ne tire donc pas parti des superbes f-string que nous apporte Python 3.6. Voici son code :

#!/usr/bin/env python3

from datetime import datetime


def main():
    slug = input("Slug : ")
    title = input("Titre : ")
    summary = input("Résumé : ")
    tags = input("Tags (tag1, tag2…) : ")

    date = datetime.now()

    filename = 'content/article/{}.rst'.format(slug)

    with open(filename, 'w') as f:
        f.write(title + '\n')
        f.write('#' * len(title) + '\n\n')

        f.write(':slug: {}\n'.format(slug))
        f.write(':date: {}\n'.format(date.strftime('%Y-%m-%d')))
        f.write(':summary: {}\n'.format(summary))
        f.write(':tags: {}\n'.format(tags))

        f.write('\n\n')

if __name__ == "__main__":
    main()

Intéressons nous maintenant au problème de duplication.

Chasse à la redondance

Le problème du script de création d’article, c’est qu’il utilise le champ slug qu’on lui spécifie dans la session interactive pour :

  • créer le fichier avec la bonne extension mon-slug.rst ;
  • mais aussi insérer un champ :slug: mon-slug dans ce même fichier.

Or cette redondance n’est pas souhaitable. Avec l’expérience, on se rend compte qu’il est important de stocker à un seul et unique endroit une information — une donnée — sous peine de voir apparaître des incohérences au fil du temps. Cela apparaît déjà dans les articles : certains utilisent un champ de métadonnée :slug: différent du nom du fichier. À quelle source se fier ? C’est pour cela qu’il faut remédier à cette duplication d’information.

Pourquoi cette redondance ?

Par défaut, Pelican génère un slug pour chaque article en se basant sur son titre. Ainsi, si mon article s’appelle « Essai d’un article », alors le slug sera essai-dun-article.html. Il s’agit d’une option pratique, cependant celle-ci ne me convient pas. Je préfère moi-même choisir mes slugs en utilisant seulement les mots clés intéressants, et en supprimant les mots de liaison. Ainsi, un slug correct — selon mes critères et appliqué à cet exemple — serait essai-article.html.

C’est donc pour cela que le champ :slug: est rajouté dans chaque article : afin de pouvoir utiliser le nom que je souhaite pour le slug, plutôt qu’une génération automatique basé sur le titre.

Est-ce la meilleure solution ?

Et bien, il se trouve que non. Je n’ai pas forcément beaucoup cherché au début de la création de ce blog (en 2014 !). En creusant un peu, on peut trouver dans la documentation de Pelican l’option SLUGIFY_SOURCE :

SLUGIFY_SOURCE = 'title'

Specifies where you want the slug to be automatically generated from. Can be set to title to use the ‘Title:’ metadata tag or basename to use the article’s file name when creating the slug.

C’est exactement ce qu’il nous fallait ! On peut donc dans un premier temps ajouter l’option suivante dans notre pelicanconf.py afin d’utiliser le nom du fichier — plutôt que le titre — pour générer les slugs :

SLUGIFY_SOURCE = 'basename'

Rattraper le tir

Il faut maintenant nous rattraper, en renommant tous les fichiers dont le champ :slug: ne correspond pas au nom du fichier. Ensuite, nous serrons en mesure de supprimer tous ces champs, pour que Pelican ne se base que sur le nom du fichier à chaque génération.

Ne plus générer de doublons

Avant tout, assurons nous de ne plus rajouter de champs :slug: lors de la création de nouveaux articles :

diff --git a/scripts/new_article.py b/scripts/new_article.py
index 85c3883..8a8d91e 100755
--- a/scripts/new_article.py
+++ b/scripts/new_article.py
@@ -17,7 +17,6 @@ def main():
         f.write(title + '\n')
         f.write('#' * len(title) + '\n\n')

-        f.write(':slug: {}\n'.format(slug))
         f.write(':date: {}\n'.format(date.strftime('%Y-%m-%d')))
         f.write(':summary: {}\n'.format(summary))
         f.write(':tags: {}\n'.format(tags))

Nous voilà débarrassés de ce générateur de doublons. Seul le nom du fichier suffit désormais.

Nettoyage automatique

Il nous reste à écrire un programme de migration afin de renommer les fichiers qui en ont besoin, puis de supprimer ces champs redondants. Voici le contenu du script que j’ai pondu en une quinzaine de minutes :

#!/usr/bin/env python3

import sys
import os
import re
import fileinput
from tempfile import NamedTemporaryFile


SLUG_REGEX = re.compile('^:slug: (.*)$')

def find_text_slug(path):
    with open(path) as f:
        for line in f.readlines():
            match = SLUG_REGEX.match(line)
            if match:
                return match.group(1)

def remove_slug_field(path):
    with NamedTemporaryFile(delete=False) as outfile:
        with open(path) as f:
            for line in fileinput.input(path, inplace=True):
                if not SLUG_REGEX.match(line):
                    outfile.write(line.encode('utf-8'))

        os.rename(outfile.name, path)

def remove_slug(path):
    dirname = os.path.dirname(path)
    path_slug = os.path.splitext(os.path.basename(path))[0]

    text_slug = find_text_slug(path)

    print(f"Path slug: {path_slug}")
    print(f"Text slug: {text_slug}")

    if text_slug is None:
        print("No text slug to be removed.")
        return

    if path_slug == text_slug:
        print("Slugs are the same, we can remove the text slug.")
        remove_slug_field(path)
        return

    dest_path = os.path.join(dirname, f"{text_slug}.rst")

    print(f"Renaming {path} to {dest_path}")
    os.rename(path, dest_path)

    print(f"Removing slug field in {dest_path}")
    remove_slug_field(dest_path)


def main(args):
    for arg in args:
        remove_slug(arg)


if __name__ == '__main__':
    main(sys.argv[1:])

Afin de supprimer les champs des fichiers, la fonction remove_slug_field crée un fichier temporaire, et y écrit toutes les lignes ne correspondant pas à l’expression régulière employée. Ensuite, ce fichier temporaire est déplacé vers le fichier original. C’est pour cela que l’on demande à ce que ce fichier ne soit pas supprimé à la fin du bloc with — car il n’existera plus une fois que nous l’aurons déplacé.

Cette implémentation laisse à désirer : en cas de plantage, le fichier temporaire restera présent ad vitam aeternam. Une solution plus propre consisterait à copier le fichier temporaire sur le fichier source, puis de laisser au bloc with le soin de supprimer ce fichier — même lors de l’apparition d’une exception.

On utilse find pour nous trouver tous les fichiers .rst du blog, et on les passe ensuite à notre script de ménage en utilisant xargs :

$ find content -type f -name '*.rst' | xargs ./scripts/remove_slug.py

Oui, j’aurai pû utiliser le module glob de Python. Je deviens un vrai script shell kiddie à force de faire du cheu à longueur de journée.

Conclusion

Le résultat final est celui attendu. Tous les articles sont désormais libres de tout champ :slug: — en plus d’être correctement nommés. Après la passe consistant à supprimer la dépendance à Mediagoblin, on a désormais débarrassé les articles de ce champ inutile et redondant.

Qui a dit que le ménage était limité au printemps ?

Une question ou remarque ? N'hésitez pas à me contacter en envoyant un mail à microjoe, suivi d'un arobase, puis encore microjoe et enfin un « point org ».