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.
- Si elle n’existe pas il cherche à construire toutes ses dépendances puis exécute la commande.
- Si la cible existe mais que certaines de ses dépendances n’existent pas il va les construire d’abord et passer au point suivant.
- Si la cible existe déjà et que les dépendances existent il vérifie si ses dépendances ont été modifiées après la dernière modification de la cible. Si oui alors make suppose que la cible n’est pas à jour (ses sources sont plus récentes qu’elle même) et make relance la commande.
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.
- Si make n’a pas d’argument, la première règle du fichier est déclenchée.
- Si la cible d’une règle n’existe pas, il va essayer de la créer. On peut donc en déduire que si la commande de la règle ne créer pas la cible, la règle sera toujours déclenchée.
- Make cherche d’abord à créer toutes les dépendances d’une cible avant de créer la cible.
On en conclu que si l’on écrit une règle qui ;
- Précède toutes les autres
- A une commande qui ne créé pas de fichier ayant pour chemin la cible (aucune commande fera très bien l’affaire)
- A pour dépendances toutes les cibles que l’on souhaite construire.
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 !
-
que l’on utilisera pour les exemples dans ce document ↩
-
documentée ici https://pubs.opengroup.org/onlinepubs/9699919799/utilities/V3_chap02.html#tag_18_07_04 ↩
-
oui le nom de la fonction est un point final
.
. ↩ -
quand bien même la syntaxe a été écrite de façon à ce qu’un·e humain·e puisse le comprendre ↩
-
Voir: https://www.in-ulm.de/~mascheck/various/shebang/#interpreter-script ou le commit 4bd895 ↩
-
auquel on a ajouté un : pour ressemble aux autres déclarations ↩
-
la ligne [ “$1” ] || set - que je ne m’explique pas ↩
-
même s’il est très improbable qu’un fichier all ou clean se faufile dans votre projet, on sait jamais. ↩