Migration : de Dotclear à Wordpress à Jekyll

Publié le 27 décembre 2017

Tags : Jekyll Wordpress Dotclear Performance Web

Ce blog utilise désormais Jekyll. J’ai pioché à droite à gauche pour lui faire faire ce que je voulais, je documente donc ici pour celleux que ça intéresse.

Au tout début, ce blog utilisait Dotclear. Une architecture logicielle magnifique, bien au-dessus de ce qui existait à l’époque. Mais le choix malheureux de gabarits en pseudo-code, donc : moins puissant que du code, une documentation bien plus réduite, une courbe d’apprentissage inutile, et la nécessité d’invalider le cache quand on développe. De plus, Dotclear n’avait qu’une petite communauté donc peu de plugins et de thèmes, et l’API était bien conçue mais difficile à manipuler pour développer un petit plugin vite fait.

Je suis donc ensuite passé à Wordpress pour son API hyper intuitive et très très bien documentée, et pour les thèmes et plugins par milliers. Mais à force, j’ai eu marre de vivre dans la crainte qu’une faille de sécurité ne soit utilisée sur mon blog, du coup il fallait mettre à jour sans cesse. Par ailleurs les milliers de plugins pas maintenus, en doublon et/ou mal écrit ont rendu le moindre choix pénible. Enfin, les mises à jour et sauvegardes sont rendues compliquées par la présence d’une base de donnée. Ça rend compliqué de modifier le site en local avant de le publier, aussi.

Pour réduire le temps et la complexité de maintenance, j’ai testé plusieurs CMS sans base de données :

Voici donc comment j’ai configuré mon blog Jekyll pour avoir un site qui se charge vite, avec un formulaire de recherche, un formulaire de contact, le tout hébergé gratuitement (mais pas sur Github).

Hébergement

J’étais chez Gandi dont le service est très bien, mais un poil complexe mine de rien pour un simple blog. Quand j’ai vu que Alwaysdata proposait une offre gratuite pour les moins de 100Mo j’ai sauté dessus pour faire des économies. Il faut un peu s’habituer à leur interface, qui est bien conçue mais diffère de Gandi et de Dreamhost où j’avais mes habitudes.

J’apprécie au passage de pouvoir facilement mettre un certificat Let’s Encrypt, ce n’est pas hyper intuitif au premier abord (il faut en créer un pour chaque sous-domaine dans un écran séparé) mais ça marche bien. Je vais attendre de voir s’ils se renouvellent bien tout seuls, ça se serait chouette. 🙂

Un CMS sans base de données

Niveau sécurité, on ne peut pas faire plus simple : ce site est construit en local et le serveur ne reçoit que des fichiers HTML à afficher. Ça veut dire aussi que les backups sont ultra-simples à faire, et qu’il devient possible de stocker et versionner tout son site via Git, ce que j’apprécie beaucoup.

Par contre je perds la possibilité de bloguer sans avoir accès à mon ordinateur (ou alors il faut télécharger tout le dépôt, configurer l’environnement de travail, etc…). Tant pis, de toute façon je ne blogue pas souvent et je peux toujours écrire une note et la publier ensuite, j’ai le temps.

Formulaire de contact

L’envers de la médaille c’est que les formulaires, sur des sites statiques, c’est tout-de-suite une autre affaire. Pour les commentaires j’ai lâché l’affaire, j’en reçois peu de toute façon, et je n’aime pas les solutions externes telle que Disqus.

Je voulais qu’on puisse au moins me contacter —même si les 2/3 des messages reçus par ce biais sont inutiles, je trouve que c’est plus poli. J’ai donc regardé différentes solutions et fini par retenir la plus simple (même si ça repose sur un service externe) : FormSpree. Il suffit de mettre <form action="https://formspree.io/[votre email]"> et d’autoriser l’adresse où sera affiché le formulaire, et voilà ! Ça marche avec n’importe quelle site, statique ou non, d’ailleurs.

Formulaire de recherche

J’étais parti pour utiliser Algolia, qui fournit un plugin Jekyll gratuit en échange d’un placement de leur logo. Mais entre la création du compte, de clé d’interrogation, la synchronisation de l’index, et tout, j’ai fini par jeter l’éponge.

Du coup je suis passé par un script basé sur Lunr. La page de recherche contient un formulaire get basique qui renvoie sur elle-même, une balise <script> dans laquelle je génère un JSON de tous les billets, et deux scripts. Le premier c’est une version minifiée de Lunr, que j’auto-héberge pour être indépendant et réduire le nombre de requêtes DNS. Le deuxième c’est une fonction anonyme (IIFE) qui sert à isoler et regrouper les différentes actions à faire :

  1. Transformer le JSON en index utilisable par Lunr.
  2. Intercepter l’événement submit du formulaire.
  3. Récupérer les paramètres de la requête via l’URL de la page (c’est pour ça qu’on est en get), s’il y en a.
  4. Si on envoie le formulaire, ou que l’URL contient une recherche, lancer la suite :
  5. Interroger l’index Lunr.
  6. Formater les résultats : récupération du fragment pertinent, mise en évidence des termes, génération du HTML.
  7. Et sans oublier de placer le focus au bon endroit, pour l’accessibilité.

Ça surligne la phrase recherchée plutôt que les termes individuellement, je n’ai pas voulu y passer toute la nuit vu le peu d’usage qui sera fait de cette page 🙂

Coloration syntaxique des exemples de code

Ça faisait longtemps que je voulais le faire, même si je n’écris plus très souvent de billet à propos du code. Au passage, c’est tellement plus facile de saisir du code en markdown qu’en HTML ! Je me rappelle d’avoir des fois été obligé de modifier mes billets à la main dans la base de données parce que Wordpress avait mangé les balises…

Ici j’utilise l’incontournable gem Rouge de Jeanine Adkisson et le thème Solarized Dark. C’est intégré dans ma CSS principale pour n’avoir qu’un seul fichier à télécharger (12ko minifié).

Gestion du flux de syndication (Atom)

gem 'jekyll-feed', group: jekyll_plugins

J’utiliser le plugin jekyll-feed et c’est super ! Il suffit d’inclure {% raw %}{% feed_meta %}{% endraw %} dans _includes/head.html pour que les liens soient automatiquement insérés avec la bonne syntaxe. C’est ultra-simple, on aurait tort de s’en priver 🙂

Ajout du 17 novembre 2018 : Romy m’a demandé s’il y avait un flux de syndication pour les recettes, le plugin ne le proposait pas nativement donc je m’en suis occupé, c’est en attente de validation par les gestionnaires du plugin.

Pour m’en servir tout de suite, mon _includes/head.html contient désormais {% raw %}{% feed_meta include: all %}{% endraw %} et je charge la gem modifiée par mes soins en modifiant mon fichier Gemfile :

gem 'jekyll-feed', :github => 'goulvench/jekyll-feed', branch: 'improve-collection-and-category-management'

Regénération à la volée (live-reload)

Pareil, Jekyll rend ça super simple : gem 'jekyll-livereload', group: jekyll_plugins dans le Gemfile et livereload: true dans _config.yml et on n’en parle plus ! Attention cependant, si on l’utilise il faut générer une version basique du site avant d’uploader sinon le script essaie de se charger sur le serveur et on a une erreur 404. Ce n’est pas critique mais c’est pas compliqué à corriger alors on y pense.

Localisation

gem 'i18n'

Ça m’enquiquinait d’avoir des dates en anglais dans un blog en français, alors j’ai mis le code suivant dans _plugins/i18n_filter.rb :

# Originally from https://github.com/nelsonsar/jekyll-i18n-filter/blob/master/i18n_filter.rb
require 'i18n'

LOCALE = 'fr'

module Jekyll
  module I18nFilter
    def localize(input, format=nil)
      load_translations
      format = (format =~ /^:(\w+)/) ? $1.to_sym : format
      # Prevent Object must be a Date, DateTime or Time object errors
      # Liquid does it
      input = Time.parse(input) if input.is_a?(String)
      I18n.l input, :format => format, :locale => LOCALE
    end

    def load_translations
      unless I18n::backend.instance_variable_get(:@translations) and locale_file_exists
        I18n.backend.load_translations Dir[File.join(File.dirname(__FILE__),'../_locales/*.yml')]
      end
    end

    def locale_file_exists
      file_name = File.join(LOCALE,'.yml')
      return (locale_file_exists_in_current_dir(file_name) and locale_exists_in_locales_dir(file_name))
    end

    def locale_file_exists_in_current_dir(file_name)
        return File.exists?(File.join(File.dirname(__FILE__),file_name))
    end

    def locale_exists_in_locales_dir(file_name)
        return File.exists?(File.join('../_locales/',file_name))
    end
  end
end

Liquid::Template.register_filter(Jekyll::I18nFilter)

Pour les traductions, on ne se casse pas la nénette, on copie le contenu du fichier de localisation de Rails et on le colle dans _locales/fr.yml.

Grâce à tout ça dans mes templates je peux désormais écrire {% raw %}{{ page.date | localize: '%d %B %Y' }}{% endraw %} et ça s’affiche en français.

Attention j’ai modifié le fichier pour ne pas avoir à convertir les dates, c’est commenté. J’ai proposé la modification à l’auteur du plugin pour que tout le monde en profite.

Images responsive

gem 'jekyll-responsive-image', group: jekyll_plugins

J’utilise le plugin jekyll-responsive-image qui redimensionne les images et permet d’utiliser l’attribut src-set afin que les navigateurs chargent l’image la plus adaptée sur mobile. J’ai juste supprimé les espaces inutiles générés par _includes/responsive-image.html, il suffit de remplacer les balises {% raw %}{% capture/if/else/endif/etc %}{% endraw %} par {% raw %}{%- capture/if/else/endif/etc -%}{% endraw %}. C’est pas compliqué et ça rend le code source plus léger et plus facile à lire. Voici le code :

{% raw %}

{%- capture srcset -%}
  {%- for i in resized -%}
      /{{ i.path }} {{ i.width }}w,
  {%- endfor -%}
{%- endcapture -%}
{%- assign largest = resized | sort: 'width' | last -%}
<img src="/{{ largest.path }}" alt="{{ alt }}" srcset="{{ srcset | strip_newlines }}" class="{{ classes }}">

{% endraw %}

J’aurai juste préféré que le plugin filtre les images plutôt que d’obliger à utiliser une nouvelle syntaxe, mais il y a peut-être une bonne raison pour ça.

Smileys (ajouté le 25 octobre 2018)

Ça faisait longtemps que je me demandais comment ajouter la transformation automatique des smileys. J’ai profité d’un moment libre pour regarder un peu la syntaxe et bricoler un petit filtre. A posteriori je me dis que j’aurai aussi pu utiliser jekyll-emoji en ajoutant des alias pour les smileys que j’utilise, mais je voulais faire au plus simple et, surtout, que ça utilise le caractère unicode plutôt qu’une image (parce que les lecteurs RSS ne sont pas forcément très bons pour gérer les images à l’intérieur du texte).

Voici le code du plugin minimaliste que je me suis créé, en m’inspirant de Jekyll-emoji :

# _plugins/smiley_filter.rb
module Jekyll
  module SmileyFilter

    def replacements
      {
        ':-)' => '🙂',
        ':-D' => '😁',
        ':-P' => '😋',
        ':-/' => '😕',
        ':-x' => '😚',
        ":'(" => '😢'
      }
    end

    def smilify(content)
      return false unless content
      expression = Regexp.new(replacements.keys.map { |key| Regexp.escape(key) }.join("|"))
      content.to_str.gsub(expression, replacements)
    end
  end
end

Liquid::Template.register_filter(Jekyll::SmileyFilter)

Contenus externes

J’aimais bien pouvoir coller une URL Twitter et la voir transformée automatiquement dans Wordpress. Comme j’ai quelques tweets qui trainent j’ai ajouté gem 'jekyll-twitter-plugin', group: jekyll_plugins au Gemfile et ça tourne tout seul, c’est pratique.

Pour les conférences j’ai intégré les diaporamas stockés sur Speakerdeck en copiant-collant leur script embed à la main. J’ai regardé le plugin OEmbed de Tammo van Lessen mais il ne semblait pas gérer Speakerdeck (ou alors c’est juste la documentation qui n’est pas à jour, parce que la gem oembed intègre ce fournisseur. Vu le temps que ça prend, j’ai fait au plus simple : un champ embed en haut des pages de conférences et on n’en parle plus !

Performances Web

Les pages sont plutôt légères : un seul fichier CSS, pas de JS, pas de cookies, pas de polices non-natives, quasiment pas d’images. On descend à moins de 100ko, c’est rare ! Les pages les plus lourdes sont celles où il y a des photos ou un support de conférence inclus par JavaScript.

Dareboost préconise quelques optimisations, principalement concernant la sécurité, donc voici le paramétrage htaccess qui permet de les appliquer :

# Hide server signature from requests (superseded by AlwaysData configuration)
ServerSignature Off

# Set the correct Char set for text/plain and text/html
AddDefaultCharset utf-8
# Ensure css, js, etc. get it too
AddCharset utf-8 .htm .html .js .css

<IfModule mod_headers.c>
 Header always set X-UA-Compatible "IE=edge"
 Header always set X-FRAME-OPTIONS "DENY"
 Header always set X-XSS-Protection "1; mode=block"
 Header always set X-Content-Type-Options "nosniff"
 Header set Content-Security-Policy "default-src 'self' speakerdeck.com www.youtube-nocookie.com chrome-extension; base-uri 'self'; script-src 'self' 'unsafe-inline' speakerdeck.com; style-src 'self' 'unsafe-inline'; form-action 'self';
 Header always set Strict-Transport-Security "max-age=63072000; includeSubDomains"
</IfModule>

Configuration Apache

  1. Je génère des ETags indépendants du serveur. Le site n’est pas sensé être servi par une armée de serveurs, mais bon…
  2. Je spécifie les dates d’expiration pour les différents types de fichiers.
  3. Je dis au serveur d’afficher le contenu de ma page d’erreur customisée s’il ne trouve pas l’adresse demandée.

Voici le contenu de mon fichier .htaccess :

# Create the ETag (entity tag) response header field
FileETag MTime Size

<IfModule mod_expires.c>
 ExpiresActive On
 ExpiresDefault "access plus 7200 seconds"
 ExpiresByType image/jpg "access plus 2592000 seconds"
 ExpiresByType image/jpeg "access plus 2592000 seconds"
 ExpiresByType image/png "access plus 2592000 seconds"
 ExpiresByType image/gif "access plus 2592000 seconds"
 AddType image/x-icon .ico
 ExpiresByType image/ico "access plus 2592000 seconds"
 ExpiresByType image/icon "access plus 2592000 seconds"
 ExpiresByType image/x-icon "access plus 2592000 seconds"
 ExpiresByType text/css "access plus 2592000 seconds"
 ExpiresByType text/javascript "access plus 2592000 seconds"
 ExpiresByType text/html "access plus 7200 seconds"
 ExpiresByType application/xhtml+xml "access plus 7200 seconds"
 ExpiresByType application/javascript A2592000
 ExpiresByType application/x-javascript "access plus 2592000 seconds"
</IfModule>

ErrorDocument 404 /404/index.html

Optimisation des images

Quand j’ai vu la complexité de jekyll-assets je me suis dit que j’allais faire plus simple. Vu que les pages sont légères et la CSS déjà compressée, il n’y avait besoin que de compresser les images. Au final vu le temps que j’y ai passé j’aurai mieux été inspiré d’utiliser jekyll-assets je pense.

Dans mon Gemfile j’ai ajouté gem 'image_optim', group: :jekyll_plugins et gem 'image_optim_pack', group: :jekyll_plugins. Puis j’ai écrit un plugin de filtre Jekyll dont voici le code complet :

Mise à jour le 26 octobre 2018 : j'ai (enfin !) rajouté une vérification qui fait que ça n'optimise que les nouveaux fichiers. On économise plusieurs minutes avec ça !

# _plugins/optimize_images.rb
Jekyll::Hooks.register :site, :post_render do |site|
  # Skip when live-serving because it makes generating the pages much slower
  next if site.config.fetch('serving')
  paths = Dir["#{site.dest}/assets/**/*.*"]
  # Last run time
  last_run = Time.at(File.read('.optimize_last_run').chomp.to_i) if File.exist?('.optimize_last_run')
  # Filter images that have not been modified since
  paths = paths.reject { |path| File.mtime(path) < last_run } if last_run
  # If no images remain, no need to go any further
  next if paths.length.zero?
  # Start optimizing
  optim = ::ImageOptim.new
  optim.optimize_images! paths do |unoptimized, optimized|
    if optimized
      puts "Optimized #{optimized}, (#{optimized.original_size - optimized.size} bytes shaved)"
    end
  end
  # Store current run time to only optimize new files next time
  File.write('.optimize_last_run', Time.new.to_i)
end
  1. J’attache le filtre après l’écriture des fichiers pour être sûr que tout soit présent, je gagnerai peut-être en l’attachant à :post_render plutôt que :post_write, je n’ai pas trop saisi la nuance.
  2. Je récupère ensuite tous les fichiers du répertoire _site/assets et une instance d’optimiseur.
  3. Je lance ensuite l’optimisation des fichiers (le ! signifie qu’on les modifie, sinon ça ne fait de renvoyer une version optimisée, c’est une convention pratique en Ruby)
  4. Je termine en affichant le gain obtenu pour chaque fichier optimisé.

Ce que j’aimerai bien faire un jour ou l’autre

Page précédente Page précédente Page suivante Page suivante