Table des matières

Comment vous aussi vous auriez pu construire catium

Imaginez vouloir construire une site et être doté.e d’un outil qui traduit le format dans lequel vous préférez écrire en html. Peut-être que vous aimez écrire en markdown et utilisez pandoc, cmark1 ou même le script initial de John Gruber. Peut-être que vous aimez écrire en asciidoc et utilisez asciidoctor. Peut-être que vous avez votre propre format avec votre propre traducteur ! Comment, avec l’aide des outils Unix, créer votre propre générateur de sites statiques ?

Générer une page

Les gabarits html et les métadonnées

Vous avez un fichier index.md comme ceci :

# Super document cool

Blablabla

  * super liste
  * trop cool

et pouvez le convertir en faisant cmark index.md :

<h1>Super document cool</h1>
<p>Blablabla</p>
<ul>
<li>super liste</li>
<li>trop cool</li>
</ul>

Premier problème, si vous avez pour ambition d’avoir un site décemment construit vous remarquerez qu’il manque de l’html. On pourrait vouloir ajouter la balise <html> et <meta> pour l’encodage utf-8 par exemple. Ici habituellement deux solutions s’offrent à vous. Soit vous comptez sur votre traducteur pour le faire. C’est le cas par exemple de lowdown avec son option -s (pour standalone) mais tous ne le font pas :

<<-. lowdown -s
# titre

[lien](katzele.netlib.re)
.

qui donne

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Untitled article</title>
</head>
<body>

<h1 id="titre">titre</h1>

<p><a href="katzele.netlib.re">lien</a></p>
</body>
</html>

soit vous utilisez un générateur de site existant. En réalité une troisième s’offre à vous ! Résoudre ce problème à l’aide d’un outil que vous avez déjà sous la main, le shell.

Il existe en shell posix une syntaxe nommée here-document ou heredoc2. Cette syntaxe permet de créer des sortes de gabarits (ou templates ou layouts) de la sorte :

title="machin"
<<delim cat
blabla
le titre : $title
blabla
delim

donne

blabla
le titre : machin
blabla

On notera que les variables shell appelées à l’intérieur sont étendues. Et bien c’est également le cas des captures de commandes type $(pwd) ! On peut donc imaginer l’utiliser pour insérer notre sortie de traducteur au sein d’un gabarit html :

title="Un article super"
<<delim cat
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>$title</title>
</head>
<body>
$(cmark index.md)
</body>
</html>
delim

qui donne

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Un article super</title>
</head>
<body>
<h1>Super document cool</h1>
<p>Blablabla</p>
<ul>
<li>super liste</li>
<li>trop cool</li>
</ul>
</body>
</html>

Hop pas besoin d’outils extérieur.
Si l’on fait de ce code un script on pourrait l’exécuter pour générer l’html. Evidemment ce serait limité puisque l’on aurait toujours la sortie du fichier index.md. On peut donc décider de passer en argument le nom du fichier markdown et le titre tant qu’à faire. On aurait donc un script du type :

title="$1"
<<delim cat
[...]
$(cmark "$2")
[...]
delim

Qu’on appelerait en faisant ./script "Un article super" ./index.md. Si nos besoins ne vont pas plus loin nous avons déjà quelque chose d’à peu près fonctionnel. Pour rendre tout cela simple d’usage et mieux encapsuler l’information nous allons pousser plus loin.

Vous remarquerez ici que l’information du titre réside dans votre tête, à l’écriture de la commande et non pas, comme il est préférable, dans le document lui-même. L’idéal serait qu’index.md puisse contenir toutes ses informations, le markdown du corps de l’article mais également les méta-données afférentes. C’est comme cela que fonctionnent la plupart des générateurs de site avec des syntaxes propres. On voudrait pouvoir, par exemple, écrire :

title: "Un super article"
----

# Super document cool
[...]

et que la variable title de notre script prenne pour valeur “Un super article”. Pour ce faire on pourrait modifier notre script pour parser index.md et en récupérer l’info que l’on souhaite. Par exemle

title=$(grep '^title:' | cut -d':' -f2)

donnerait la bonne valeur à la variable et

$(sed -n '/----/,$ p` "$1" | grep -v '^----' | cmark)

parserait uniquement la partie markdown en ométtant les méta-données. C’est pourtant sur une autre solution que nous allons partir, une solution qui nous permettera plus de flexibilité par la suite et surtout la possibilité de dynamiquement créer certaines parties du contenu markdown.

le format “à la catium” et la prolifération des scripts shell

Cette autre solution consiste à faire du document index.md lui même un script. Et oui, dans catium, si c’est pas une makefile, c’est un script shell. Comme le dit le meme :

shshsh  shshsh  shshsh  sh   sh  shshsh  shshsh  sh  sh  sh  sh
sh      sh  sh  sh  sh  shsh sh  sh        sh    sh  sh  shshsh
shsh    shshsh  shshsh  sh shsh  sh        sh    sh  sh  sh  sh
sh      shsh    sh  sh  sh  shs  sh        sh    sh  sh  sh  sh
sh      sh  sh  sh  sh  sh   sh  shshsh  shshsh  shshsh  sh  sh

                                                              Wait it's all shell scripts ?
                                                             /
                                                            🚶🔫🚶 - Always has been

A ce stade on peut renommer index.md en index.sh, ça fera plus sens. L’idée est dorénavant que notre script devienne une sorte d’interpréteur d’index.sh. On l’appelera dorénavant “page”. Il devra préparer le nécessaire pour que la syntaxe de la page index.sh instancie les bonnes variables et génère le nécessaire pour l’affichage final de l’html.

Par exemple, on pourrait grâce à un alias, cacher derrière la syntaxe title: "Un super article" une fonction qui créé la variable title. Pour cela on peut écrire :

alias title:="title"
title() title="$*"

Ainsi, dans index.sh :

title: "Un super article"
# devient
title "Un super article"
# devient
title="Un super article"

index.sh a créé sa propre variable. Ce système est généralisable à n’importe quelle métadonnée. Si vous testez cette articulation tel quel vous rencontrerez un problème. En effet, l’alias déclaré dans l’interpréteur page n’est pas disponible dans le script index.sh si on l’exécute en écrivant ./$1 et en faisant ./page index.sh par exemple. C’est parce que en le faisant de cette manière là, index.sh est exécuté dans un sub-shell, sur un processus différent ne partageant aucune variable, alias ou fonction en commun avec son parent. Pour contourner ce problème on peut utiliser le built-in shell .3 qui prend en argument le chemin d’un fichier. Cette fonction va dérouler le code du fichier dans le script courant et l’exécuter comme s’il y avait été écrit. Le code dans index.sh aura donc bien accès à ce qui a été déclaré dans l’interpréteur page. Ainsi ./page index.sh fera :

alias title:="title"
title() title="$*"

. "$1"

<<delim cat
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>$title</title>
[...]
delim

deviendra

alias title:="title"
title() title="$*"

title: "Un super article"

[...]

<<delim cat
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>$title</title>
[...]
delim

et title: "Un super article" se déroulera comme décrit précedemment et ainsi de suite.

Super ! On sait comment gérer les métadonnées. En réalité on vient d’inventer une sorte de DSL qui ressemble volontairement beaucoup à du YAML très simple. Cette syntaxe est devenu très commune dans les générateurs de sites statiques.

Maintenant qu’on a trouvé notre alternative à title=$(grep '^title:' | cut -d':' -f2) il nous faut trouver celle à $(sed -n '/----/,$ p “$1” | grep -v ‘—-’ | cmark). Si on ne le fait pas l’exécution d’index.sh aura vite fait de nous faire remarquer que le premier mot de notre page n’est pas une commande.

Pour cela utilisons la même logique que pour les métadonnées. Imaginons une syntaxe qui nous convienne et créons, en amont dans l’interpréteur le nécessaire pour que cette syntaxe soit comprise et génère le bon contenu. Pour pouvoir dire “attention, ce qui suit c’est du markdown” on peut réutiliser les heredoc. Quelque chose de la sorte devrait générer l’html :

title: "Un article super"

<<delim cmark
# Ceci est du markdown

blabla
delim

Sauf qu’il nous faut capturer la sortie de cmark pour pouvoir ensuite l’afficher au bon endroit dans le heredoc template qui suit dans l’interpréteur page. On pourrait le mettre dans une variable en l’entourant d’une capture de commande :

corps=$(<<delim cmark
# Ceci est du markdown

blabla
delim
)

et dans le template faire

<main>
    $(echo "$corps")
</main>

mais vous conviendrez que ça n’est pas très élégant (et peut-être sensible au quoting hell ?). On va donc opter pour mettre le résultat dans un fichier temporaire.

<<delim cmark > A
# Ceci est du markdown

blabla
delim
[..]
<main>
    $(cat A)
</main>

ce qui permet d’adopter une syntaxe plus sympathique à l’aide d’un nouvel alias :

section="<<endsection cmark > A"
[...]
section
# Ceci est du markdown

blabla
endsection

En ajoutant un petit rm A à la fin du script page on peut générer notre page html en faisant ./page index.sh sans souci. index.sh pourra se lire convenablement bien et contiendra les infos à son propos en son sein. La totalité du fichier jusqu’ici est :

title: "Un super titre"

section
# Ceci est du markdown

blabla
endsection

Avec ces éléments nous avons, à mon avis, tous les éléments d’un générateur de site très simples. Afin d’organiser un peu les choses on pourrait déporter le heredoc du layout dans un fichier à part, déclarant une fonction layout, exécuté avec le builtin . et dont la fonction serait appelé à la fin. Cela permet de séparer le layout du reste, le “fond” et la “forme” d’une certaine manière.

On a donc un fichier de layout, nommé html et dans le dossier layouts :

layout {
<<@@ cat
<!DOCTYPE html>
<html>
    <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width,initial-scale=1" />
        <title>$title</title>
    </head>
    <body>
        $(cat A)
    </body>
</html>
@@
}

Le fichier index.sh comme décrit juste au dessus et le script page :

alias title:="title"
title() title="$*"

. "$1"

. layout/html
layout
rm A

Si les sources sont dans contents et les fichiers cibles dans public il suffirait ensuite de créer un script qui appellerait ./page X.sh > X.html sur toutes les pages sh nécessaires, avec X le nom du fichier pour, construire le site.

Il nous reste quelques améliorations à apporter à notre générateur.

L’encapsulation et les sections

Premièrement, il manque une information dans le fichier index.sh, celle de son interpréteur. De fait, le fichier est un script ne s’exécutant correctement que s’il est passé en argument d’un autre4. Il serait bon de renseigner cette information dans le fichier. Pour ce faire nous allons lui ajouter le shebang :

#! page

Ainsi il est possible de l’exécuter en faisant directement ./index.sh ce qui en réalité, du fait du fonctionnement du shebang, fera ./page ./index.sh ce qui revient à ce que nous faisions à la main jusque là. Cette façon de faire est, selon moi, plus élégante, mais permet également de savoir que cette page a été écrite pour être interprétée par un script nommé page ce qui, à défaut de l’avoir, est toujours une bonne information à prendre. Ce système peut être utilisé pour créer des typologies de pages (article, notes etc) qui seraient générées de manières différentes.

En réalité dans catium c’est le shebang

#! /usr/bin/env ./page

qui est utilisé pour des raisons obscures de compatibilité avec les *BSD et les mac5.

Deuxièmement, comment faire si l’on souhaite, sur une même page, écrire deux blocs de markdown qui doivent aller à deux endroits différents dans le layout. Ce besoin est très courant dans les pages avec une structure plus complexe, du type à avoir des div un peu partout, de façon à avoir une présentation moins linéaire à l’aide de css. Pour répondre à ce besoin que l’on a estimé être suffisamment important pour être intégré au générateur dans sa version la plus simple il faut ajouter un peu de complexité dans l’interpréteur page.

On souhaiterait pouvoir écrire dans index.sh :

section: truc
## Ce markdown va aller dans la section truc du layout
endsection

section: bidule
## Ce markdown va aller dans la section bidule du layout
endsection

et par exemple dans le layout :

layout {
<<@@ cat
[...]
<div id="joli-id-pour-du-css">
    $(show truc)
</div>
[...]
<div id="autre-id-pour-du-css">
    $(show bidule)
</div>
@@
}

On sent intuitivement qu’il faut passer une sorte d’argument à notre alias section6. Il faudrait pouvoir, sur la base de cet argument, stocker les données de chacune des sections pour pouvoir les appeler séparément dans le layout. Pour pouvoir traiter cette donnée comme un argument l’alias ne suffira plus, il faudra passer par une fonction qu’on pourrait par exemple nommer save :

alias section:="<<endsection save"
save() cat >> "$1"

Ainsi dans index.sh :

section: truc
blabla
endsection

# Devient

<<endsection save truc
blabla
endsection

# Puis

<<endsection cat >> truc
blabla
endsection

On peut ainsi ouvrir et fermer autant de sections que l’on veut, y compris plusieurs fois la même grâce à l’opérateur de redirection >> qui concatène plutôt que ne remplace.
Il faut dorénavant que la fonction que l’on appelle dans le layout pour “injecter” les données d’une section à un endroit particulier fasse la traduction du format vers de l’html. On écrit donc une fonction show comme ceci :

show() cmark "$1"

Cette fonction va donner tout le contenu enregistré dans la section qu’on lui passe en argument (main, footer, comme vous voulez) à cmark et en ressortira de l’HTML.

Afin d’éviter que les fichiers ne se marchent sur les pieds si l’on a plusieurs articles, de polluer l’espace de travail et de potentiellement engorger la mémoire de votre ordinateur on peut faire en sorte que tout cela se produise dans un dossier temporaire, sur des fichiers temporaires tous détruits une fois la génération finie. Pour cela on créé au début de page un dossier temporaire de travail avec mktemp, on dit au shell de le supprimer si le processus reçoit le signal EXIT (une fois qu’il termine quoi) :

tmpdir=$(mktemp -d)
trap "rm -rf $tmpdir" EXIT

Il ne nous manque plus qu’à adapter save et show pour prendre en compte ce nouveau dossier :

save() cat >> "$tmpdir/$1"
show() cmark "$tmpdir/$1"

Et voilà, à une exception près7 vous avez recréé absolument tout Catium dans sa version non étendue. Bravo !

Automatiser la génération : le makefile

Il y aurait pleins de manières de partir de cet existant et d’automatiser la création d’un site de plusieurs fichiers .sh. Un simple script shell pourrait faire l’affaire. Cependant, pour des raisons pédagogique et, potentiellement de performances, nous avons opté pour make.

Make est un programme spécifiquement créé pour gérer la construction de logiciels. Initialement fait pour compiler des programmes en C à une époque où les ressources étaient assez rares, make permet à l’utilisateurice de déclarer les règles de dépendances entre les différents éléments de son logiciel. make créera avec ces règles un graph de dépendances lui permettant, lorsqu’il est appelé, de ne recréer que le strict nécessaire ainsi économisant du temps de calcul. Sur le principe la compilation de logiciels et la construction d’un site statique ne sont pas différents. On peut donc faire usage de make pour orchestrer la génération de notre site.

Tout le nécessaire pour que make puisse fonctionner doit être inscrit dans un fichier nommé makefile à la racine du projet.

Catium utilise Gnu Make et quelques syntaxes lui sont très spécifiques. A l’avenir un effort sera peut-être consenti pour génériciser la syntaxe.

Le but

Le but est que make fasse automatiquement les appels aux articles et place leurs sorties dans les bons fichiers de destinations. Par exemple,

./contents/index.sh > public/index.html

Et ainsi pour tous les fichiers .sh qui existent dans l’arborescence à l’intérieur de contents.

Dans un second temps on souhaite qu’il copie bêtement tous les autres fichiers présents dans l’arborescence comme les images :

cp contents/super_image.jpg public/super_image.jpg

Dans notre cas tout simple les fichiers .css et le favicon seront gérés de la même manière que tous les images n’étant pas des .sh.

Lister nos fichiers sources

La première étape est de lister toutes les sources de notre site. On souhaite faire des choses différentes selon que le fichier soit un .sh ou pas alors on créé deux variables. Dans sources on met la liste des fichiers sh, dans annexes les autres :

sources != find contents -type f      -name '*.sh'
annexes != find contents -type f -not -name '*.sh'

La syntaxe != dit à make que ce qui suit est une commande à faire exécuter par le shell et non pas des fonctions make. On utilise donc ici le find que vous pourriez utiliser dans votre terminal en temps normal.

Créer les cibles

Une fois ces deux listes obtenues, on cherche à créer deux nouvelles listes contenant les chemins des fichiers correspondants à faire servir par le serveur web. On les appellera fichiers cibles. Par exemple, si l’on a le chemin “contents/blog/article.sh” dans la variable sources, il faudra créer le chemin “public/blog/article.html”. Autrement dit on remplace contents par public et l’extension .sh par l’extension .html. Pour les autres fichiers seul le premier dossier du chemin est à changer.

Pour le faire on fait usage des “substitutions references” de GMake dont la syntaxe est :

variablecréée = ${variableinitiale:abc%uvw=efg%xyz}

Ici, pour toutes les lignes dans la variableinitiale, make cherchera les caractères abc et uvw et attribuera tout ce qu’il y a entre à %. Il remplacera ensuite tous les abc par efg et les uvw par xyz en conservant la partie %. Ainsi, si l’on reprend notre exemple précédent on identifie que la partie commune (%) est blog/article et qu’il faut changer ce qu’il y a avant et après. On peut donc écrire :

pageshtml         = ${sources:contents/%.sh=public/%.html}
annexescibles     = ${annexfiles:contents/%=public/%}

Vous voyez que pour les annexes pas besoin de modifier l’extension donc pas besoin de la partie après le %. On a donc dorénavant dans pageshtml et annexescibles la liste des chemins des cibles.

Écrire les règles

C’est maintenant que la partie intéressante de make commence, les règles ! La syntaxe générale d’une règle est la suivante :

cible : liste dépendances ; commandes

ou

cible : liste dépendances
   commandes

Pour chacun des chemins de cibles construits précédemment make va chercher une règle permettant de construire le fichier en question. Pour cela il cherche une règle ayant pour cible le même chemin. Une fois qu’il a trouvé une règle qui correspond, il vérifie si la cible existe.

A noter que tout cela se fait de façon récursive. Quand make tente de créer une dépendance il suit les mêmes règles. Si la dépendance n’existe pas il faut donc qu’il existe une règle pour la créer.

Pour créer une page html la règle pourrait être la suivante :

public/blog/article.html : contents/blog/article.sh layouts/html
    @mkdir -p public/blog
    contents/blog/article.sh > public/blog/article.html

Vous noterez que la cible dépend de layouts/html de façon à ce qu’elle soit reconstruite si le layout change. Ajouter un @ devant une commande permet de ne pas l’afficher dans la sortie lorsqu’on lancera make.

Vous pourriez penser “mais on ne va pas écrire les règles pour toutes les pages non ?” et vous auriez raison. Il est possible d’écrire des règles génériques pouvant matcher plusieurs chemins de plusieurs cibles. Par exemple, la règle suivante conviendra pour créer tous les fichiers html :

public/%.html : contents/%.sh layouts/html
    @mkdir -p $(shell dirname $@)
    $< > $@

De la même manière qu’auparavant on généricise les chemins à l’aide de %. On récupère le chemin du dossier du fichier cible avec dirname et on fait usage des variables automatiques $< et $@ faisant références respectivement à la première dépendance et la cible. Pour éviter que la commande de création du dossier de destination s’affiche dans la console lorsque l’on construira le site on peut la préfixer d’un @.

Pour les autres fichiers, de façon similaire :

public/% : contents/%
    @mkdir -p $(shell dirname $@)
    cp $< $@

Si vous écrivez un makefile de la sorte et tenter de l’exécuter vous vourrez qu’il ne créera pas tout le site. Make créé les cibles qu’on lui donne en argument. make public/index.html déclenchera la règle correspondant à cette cible si elle existe. Cela dit sans argument make créé uniquement la cible de la première règle qu’il rencontre dans le makefile, dans l’ordre du fichier. Comment, en sachant cela, forcer la création de toutes les cibles dans les variables pageshtml et annexescibles ?

En utilisant les propriétés des règles décrites précédemment.

On en conclu que si l’on écrit une règle qui ;

Alors lorsque l’on lancera make, il cherchera à construire toutes ces cibles à chaque fois.

Cette règle est très courante dans les makefiles et est généralement nommée par convention “all”, mais elle pourrait s’appeler “toto”. Pour lister les dépendances on peut utiliser les variables déjà existantes :

all: ${pageshtml} ${annexescibles}

En utilisant les mêmes propriétés nous allons écrire une règle qu’il faudra appeler volontairement en passant sa cible en argument à make, se déclenchera toujours, et exécutera une commande. Cette règle permettra de nettoyer le dossier de destination “public” :

clean:; rm -r public/*

Si elle n’est pas première dans le fichier et que la cible clean n’est une dépendance d’aucune autre règle, elle ne se déclenchera qu’en faisant make clean. Puisque la commande ne créé pas de fichier ayant pour chemin la cible alors elle se déclenchera toujours. Cela dit, puisqu’elle n’a pas non plus de dépendances, s’il se trouve qu’un jour un fichier “clean” existe à la racine du projet, il sera considéré comme toujours à jour. Aucune dépendance ne pourra être potentiellement plus récente que lui et la règle ne se déclenchera plus. C’est pour palier ce genre de scénarios qu’il existe une directive .PHONY qui permet de lister ce genre de règles8. Ainsi make saura qu’il faut, pour ces règles, ignorer la préexistence de la cible.

Et voilà, vous avez réécrit le makefile de catium tel qu’il existe aujourd’hui !


  1. que l’on utilisera pour les exemples dans ce document 

  2. documentée ici https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_07_04 

  3. oui le nom de la fonction est un point final .

  4. quand bien même la syntaxe a été écrite de façon à ce qu’un·e humain·e puisse le comprendre 

  5. Voir: https://www.in-ulm.de/~mascheck/various/shebang/#interpreter-script ou le commit 4bd895 

  6. auquel on a ajouté un : pour ressemble aux autres déclarations 

  7. la ligne [ “$1” ] || set - que je ne m’explique pas 

  8. même s’il est très improbable qu’un fichier all ou clean se faufile dans votre projet, on sait jamais.