Je parle beaucoup des générateurs de site statique, mais je parle toujours de comment utiliser des générateurs de site statique. Ils sont souvent perçus comme une boîte noire. Je crée un modèle, j'écris un peu de Markdown et hop j'obtiens une page entièrement formatée en HTML. C’est magique !
Mais qu'est-ce vraiment un générateur de site statique ? Qu’y a-t-il dans cette boîte noire ? De quelle magie Vaudou s'agit-il ?
Dans cet article, nous allons examiner les différentes parties qui constituent un générateur de site statique. Premièrement, nous parlerons des généralités et ensuite nous regarderons de plus près un vrai bout de code source en plongeant au plus profond d’HarpJS. Alors, enfilez votre casquette d’aventurier et partons en exploration.
Pourquoi Harp ? Pour deux raisons. La première est qu'HarpJS est, de par sa conception, un générateur de site statique très simple. Il ne possède pas beaucoup de fonctionnalités qui feraient que nous nous perdrions en route en voulant explorer un générateur de site statique plus complet (comme Jekyll par exemple). La deuxième raison à cela, bien plus pragmatique, est que je connais bien JavaScript alors que je ne connais pas très bien Ruby.
Les rudiments d’un générateur de site statique
En vérité, un générateur de site statique c'est un concept très simple. Les ingrédients clefs d’un générateur de site statique sont typiquement :
- Un (ou des) langage(s) de gabarit pour créer les modèles de pages/articles,
- Un langage de balisage léger (en général Markdown) pour rédiger le contenu,
- Un langage de balisage structurel (souvent YAML) pour définir la configuration et les métadonnées (par exemple "front matter"),
- Un ensemble de règles et de structure pour organiser et nommer les fichiers
qui seront exportés/compilés, les fichiers qui ne le seront pas et comment ces
fichiers seront traités (par exemple préfixer un fichier ou un fichier avec un
tiret bas(
_
) signifie qu'il ne sera pas recopié avec les fichiers du site final ou encore tous les articles vont dans un dossierposts
), - Un moyen de compiler les modèles et le balisage en HTML (le support pour des préprocesseurs CSS ou JavaScript est également fréquemment inclus),
- Un serveur local pour tester.
C’est tout. Si vous vous dites "Hé… mais je pourrais en développer un !" vous avez sûrement raison. Les choses se compliquent quand vous commencez à ajouter des fonctionnalités, comme la plupart des générateurs de site statique le font.
Regardons donc maintenant comment Harp gère tout cela.
Allons au cœur du problème
Voyons comment Harp fait pour gérer les ingrédients clefs décris ci-dessus. Harp offre plus que cet ensemble de fonctionnalités, mais pour l’examen qui nous intéresse, nous nous limiterons à ces éléments.
Premièrement, parlons des rudiments de Harp.
Les rudiments de Harp
Harp supporte Jade et EJS (pour les modèles) et Markdown comme langage de balisage léger (pour le contenu). Remarquez que bien que Jade s’appelle maintenant Pug, Harp n'a pas officiellement mis à jour sa documentation ou son code, donc nous parlerons encore de Jade dans cet article. Harp offre aussi le support pour d’autres préprocesseurs comme Less, Sass et Stylus pour CSS et CoffeeScript pour JavaScript.
Par défaut, Harp n'a pas trop besoin de configuration ou de métadonnées. Il tend
à favoriser
une convention plutôt que de la configuration.
Toutefois, il permet de configurer ou d’ajouter des métadonnées à l’aide de
fichiers JSON. Il se distingue de beaucoup d’autres générateurs de site statique,
car le fichier de métadonnées est stocké en dehors du fichier le concernant dans
un fichier _data.json
.
Bien qu'il soit configurable jusqu'à un certain point, Harp a établi des règles
strictes sur la manière de structurer les fichiers. Typiquement, comme dans
beaucoup d’applications, les fichiers qui sont servis sont stockés dans un
dossier public
. Et tout fichier ou dossier préfixé d’un tiret bas(_
) ne sera
pas servi.
Enfin, Harp intègre un serveur web local basique de test qui offre quelques options de configuration. Et bien entendu, Harp va compresser les fichiers HTML, CSS et JavaScript finaux pour le déploiement.
Regardons donc le code source de Harp
Comme l’essentiel de ce qui fait un générateur de site statique ce sont les règles et les conventions, le code s'occupe principalement de servir et de compiler (en grande partie). Fouillons tout ça.
La fonction serveur
Dans Harp, servir votre projet se fait généralement en lançant la commande
harp server
depuis votre terminal. Regardons le
code for cette fonction:
exports.server = function (dirPath, options, callback) {
var app = connect();
app.use(middleware.regProjectFinder(dirPath));
app.use(middleware.setup);
app.use(middleware.basicAuth);
app.use(middleware.underscore);
app.use(middleware.mwl);
app.use(middleware.static);
app.use(middleware.poly);
app.use(middleware.process);
app.use(middleware.fallback);
return app.listen(options.port || 9966, options.ip, function () {
app.projectPath = dirPath;
callback.apply(app, arguments);
});
};
Bien que la fonction ait l’air simple, il y a manifestement un paquet de choses qui se passent dans middleware et qui n'est pas visible ici.
Le reste de cette fonction lance un serveur avec les options spécifiées (s'il y
en a). Ces options comprennent un port, une adresse IP à laquelle se connecter
et un répertoire. Par défaut le port est le 9000 (et pas le 9966 comme pourrait
le laisser penser le code), le répertoire est le répertoire courant (celui dans
lequel Harp est lancé) et l’adresse IP est 0.0.0.0
.
Ces options par défaut sont détaillées dans le code source de l’exécutable en ligne de commande.
La fonction de compilation
Restons dans le fichier
index.js et jetons
maintenant un coup d’œil à la fonction suivante compile
.
exports.compile = function (projectPath, outputPath, callback) {
/**
* Both projectPath and outputPath are optional
*/
if (!callback) {
callback = outputPath;
outputPath = "www";
}
if (!outputPath) {
outputPath = "www";
}
/**
* Setup all the paths and collect all the data
*/
try {
outputPath = path.resolve(projectPath, outputPath);
var setup = helpers.setup(projectPath, "production");
var terra = terraform.root(setup.publicPath, setup.config.globals);
} catch (err) {
return callback(err);
}
/**
* Protect the user (as much as possible) from compiling up the tree
* resulting in the project deleting its own source code.
*/
if (!helpers.willAllow(projectPath, outputPath)) {
return callback({
type: "Invalid Output Path",
message:
"Output path cannot be greater then one level up from project path and must be in directory starting with `_` (underscore).",
projectPath: projectPath,
outputPath: outputPath,
});
}
/**
* Compile and save file
*/
var compileFile = function (file, done) {
process.nextTick(function () {
terra.render(file, function (error, body) {
if (error) {
done(error);
} else {
if (body) {
var dest = path.resolve(
outputPath,
terraform.helpers.outputPath(file)
);
fs.mkdirp(path.dirname(dest), function (err) {
fs.writeFile(dest, body, done);
});
} else {
done();
}
}
});
});
};
/**
* Copy File
*
* TODO: reference ignore extensions from a terraform helper.
*/
var copyFile = function (file, done) {
var ext = path.extname(file);
if (
!terraform.helpers.shouldIgnore(file) &&
[
".jade",
".ejs",
".md",
".styl",
".less",
".scss",
".sass",
".coffee",
].indexOf(ext) === -1
) {
var localPath = path.resolve(outputPath, file);
fs.mkdirp(path.dirname(localPath), function (err) {
fs.copy(path.resolve(setup.publicPath, file), localPath, done);
});
} else {
done();
}
};
/**
* Scan dir, Compile Less and Jade, Copy the others
*/
helpers.prime(outputPath, { ignore: projectPath }, function (err) {
if (err) console.log(err);
helpers.ls(setup.publicPath, function (err, results) {
async.each(results, compileFile, function (err) {
if (err) {
callback(err);
} else {
async.each(results, copyFile, function (err) {
setup.config["harp_version"] = pkg.version;
delete setup.config.globals;
callback(null, setup.config);
});
}
});
});
});
};
La première portion de code définit le chemin de destination passé en argument
de l’appel à harp compile
via la ligne de commande
(le source est ici). Par
défaut, comme vous pouvez le voir c'est www
. callback
est une fonction de
callback passée en argument de la ligne de commande qui n'est pas configurable.
La portion suivante commence par appeler la fonction setup
du
module helpers.
Pour faire court, nous n'irons pas examiner le code de la fonction (libre à vous
d’aller regarder par vous-même), mais en gros ça lit la configuration du site
(C’est-à-dire le fichier harp.json
).
Vous aurez peut-être remarqué un appel à un truc qui s'appelle terraform
. Cela
revient ensuite de nouveau au sein de cette fonction.
Terraform est en fait projet séparé dont
Harp dépend pour la gestion du
traitement des assets.
C’est lors du traitement des assets qu'est fait tout le difficile travail de
compilation et de génération du site final (nous irons jeter un œil au code de
Terraform sous peu).
La portion de code suivante, comme elle l’indique, essaie de vous empêcher de définir un dossier de destination qui écraserait votre code source (ce qui serait malheureux puisque vous perdriez tout votre travail depuis votre dernier commit).
Les fonctions compileFile
et copyFile
parlent d’elles-mêmes. La fonction
compileFile
se repose sur Terraform pour effectuer la compilation. Ces deux
fonctions permettent à la fonction prime
qui s'aide de la fonction (fs
) pour
parcourir les dossiers, compiler ou copier les fichiers nécessaires pendant le
processus.
Terraform
Comme je l’ai dit, Terraform fait le sale travail pour compiler les fichiers Jade, Markdown, Sass et CoffeeScript en HTML, CSS et JavaScript (et pour assembler ces pièces comme voulu par Harp). Terraform est constitué d’un nombre de fichiers qui définissent ces processeurs pour JavaScript, CSS/feuilles de style et modèles (qui ici comprennent Markdown).
Dans chacun de ces dossiers se trouve un dossier processors
qui renferme le
code pour chacun des processeurs spécifiques que Terraform (C’est-à-dire Harp)
supporte. Par exemple, dans le dossier des modèles se trouvent les fichiers qui
permettent de compiler les fichiers EJS, Jade, and Markdown.
Je ne vais pas aller creuser le code de chacun d’entre eux, mais pour la plupart, ils se reposent sur des modules npm externes qui gèrent le processeur supporté. Par exemple, le support de Markdown dépend de Marked.
Le cœur de la logique de Terraform se trouve dans sa fonction render
.
/**
* Render
*
* This is the main method to to render a view. This function is
* responsible to for figuring out the layout to use and sets the
* `current` object.
*
*/
render: function(filePath, locals, callback){
// get rid of leading slash (windows)
filePath = filePath.replace(/^\\/g, '')
// locals are optional
if(!callback){
callback = locals
locals = {}
}
/**
* We ignore files that start with underscore
*/
if(helpers.shouldIgnore(filePath)) return callback(null, null)
/**
* If template file we need to set current and other locals
*/
if(helpers.isTemplate(filePath)) {
/**
* Current
*/
locals._ = lodash
locals.current = helpers.getCurrent(filePath)
/**
* Layout Priority:
*
* 1. passed into partial() function.
* 2. in `_data.json` file.
* 3. default layout.
* 4. no layout
*/
// 1. check for layout passed in
if(!locals.hasOwnProperty('layout')){
// 2. _data.json layout
// TODO: Change this lookup relative to path.
var templateLocals = helpers.walkData(locals.current.path, data)
if(templateLocals && templateLocals.hasOwnProperty('layout')){
if(templateLocals['layout'] === false){
locals['layout'] = null
} else if(templateLocals['layout'] !== true){
// relative path
var dirname = path.dirname(filePath)
var layoutPriorityList = helpers.buildPriorityList(path.join(dirname, templateLocals['layout'] || ""))
// absolute path (fallback)
layoutPriorityList.push(templateLocals['layout'])
// return first existing file
// TODO: Throw error if null
locals['layout'] = helpers.findFirstFile(root, layoutPriorityList)
}
}
// 3. default _layout file
if(!locals.hasOwnProperty('layout')){
locals['layout'] = helpers.findDefaultLayout(root, filePath)
}
// 4. no layout (do nothing)
}
/**
* TODO: understand again why we are doing this.
*/
try{
var error = null
var output = template(root, templateObject).partial(filePath, locals)
}catch(e){
var error = e
var output = null
}finally{
callback(error, output)
}
}else if(helpers.isStylesheet(filePath)){
stylesheet(root, filePath, callback)
}else if(helpers.isJavaScript(filePath)){
javascript(root, filePath, callback)
}else{
callback(null, null)
}
}
(Si vous avez bien lu l’intégralité de ce code, vous aurez peut-être relevé quelques TODO et un commentaire assez drôle "comprendre à nouveau pourquoi nous faisons cela". C’est ça le code dans la vraie vie!)
La grande partie du code dans la fonction render
s'occupe de la gestion des
modèles. Des trucs comme CoffeeScript et Sass génèrent fondamentalement un seul
fichier. Par exemple, style.scss
donnera un fichier style.css
. Même s'il y a
des includes c'est géré par le préprocesseur. La toute fin de la fonction
render
s'occupe de ces types de fichiers.
Les modèles dans Harp, à l’opposé,
sont imbriqués les uns dans les autres de différentes manières qui peuvent aussi
dépendre de la configuration. Par exemple, le fichier about.md
peut être rendu
par le modèle _layout.jade
(ce qui, pour être précis, est défini par
l’utilisation de != yield
à l’intérieur du modèle). Toutefois, _layout.jade
pourrait également lui-même faire appel à plusieurs autres modèles en son sein
grâce au support des fichiers
partiels dans Harp.
Les fichiers partiels sont une façon de découper un modèle en plusieurs
fichiers. Ils sont particulièrement utiles pour réutiliser du code. Par exemple,
j'aurais pu mettre l’entête du site dans un fichier partiel. Les fichiers
partiels sont importants pour rendre la gestion des modèles plus maintenable,
mais ils ajoutent aussi un bon degré de complexité dans la compilation des
modèles. Cette complexité est gérée à l’intérieur de la fonction partial
du
traitement des modèles.
Enfin, nous pourrions écraser le modèle par défaut en définissant un modèle
spécifique ou pas de modèle du tout pour un fichier particulier à l’intérieur du
fichier de configuration _data.json
. Tous ces scénarios sont gérés (et même
numérotés) dans la logique de la fonction render
.
C’est pas si compliqué, non ?
Pour rendre tout cela digeste, j'ai fait l’impasse sur pas mal de détails. À la base, chaque générateur de site statique que j'ai utilisé (et j'en ai utilisé un paquet) fonctionne de manière similaire : un ensemble de règles, de conventions et de configuration qui sont traitées à travers des compilateurs de langages variés. C’est peut-être pour ça qu'il y a un nombre ridicule de générateurs de site statique dans la nature.
Ceci étant dit, je ne me lancerais pas dans le développement du mien!
Mon rapport et mon livre
Si vous voulez apprendre comment faire des sites à l’aide d’un générateur de site statique, J’ai écrit un rapport et coécrit un livre pour O'Reilly qui pourrait vous intéresser. Mon rapport, simplement intitulé Les générateurs de site statique est gratuit et essaie d’aborder l’historique, le paysage actuel et les fondamentaux des générateurs de site statique.
Le livre que j'ai coécrit avec Raymond Camden s'appelle Travailler avec les sites statiques et est disponible en prépublication, mais devrait bientôt être disponible en version papier.