grim7reaper

Un artisan du code

Migration du blog sur Nanoc

Comme vous l’avez très certainement remarqué, le blog a changé d’apparence : c’est dû à la migration du blog sur Nanoc.

Pourquoi cette migration ?

Plusieurs raisons m’ont donné envie de quitter Octopress :

Quand j’ai décidé de migrer, en août 2017 (oui, il y a eu un looooong délai entre l’envie et la réalisation concrète), j’ai demandé à kAworu ce qu’il utilisait et la réponse fut Nanoc. Après avoir fait quelques recherches dessus, j’ai décidé d’utiliser Nanoc moi aussi.
Voici quelques points qui ont fait pencher la balance en sa faveur :

Changements visibles

Le changement le plus visible est bien évidemment le thème du blog : j’utilise maintenant une CSS faite maison. Cependant, étant donné mes connaissances limitées dans ce domaine, je suis parti sur un thème très simple. Malgré cela, j’ai essayé de rendre le résultat responsive (du moins en partie…).
Notez également que, pour épargner vos yeux (mon mauvais goût en matière d’associations de couleurs étant légendaire), je me suis limité aux couleurs de Solarized.

Au niveau de la structure, là aussi j’ai beaucoup simplifié :

En ce qui concerne les changements moins visibles :

Sous le capot

Le langage source

Le premier changement interne est le passage de Markdown à eRuby pour la rédaction des articles.

Le principal problème de Markdown est que le format est limité et il faut souvent utiliser des dialectes (comme le GitHub Flavored Markdown) pour avoir plus de fonctionnalités. Par exemple, Octopress utilise kramdown (qui est déjà un Markdown enrichi) couplé avec Liquid.

En utilisant eRuby, j’ai toutes les fonctionnalités nécessaires avec un seul langage, sans avoir à recourir à des extensions ou dialectes. Et en bonus j’ai un contrôle beaucoup plus fin sur le HTML généré (au contraire de Markdown), ce qui est très pratique.

La coloration syntaxique

Pour la coloration syntaxique des blocs de code, il n’y a rien de prévu par défaut dans Nanoc (au contraire d’Octopress). Je suis donc allé faire un tour sur le blog de kAworu pour voir ce qu’il utilisait : Rouge via un filtre Nanoc fait maison.

Je me suis donc très fortement inspiré de son approche, avec toutefois quelques petites simplifications (étant donné que je n’avais pas besoin de certaines fonctionnalités).

highlight.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
require 'json'

require 'rouge'

Nanoc::Filter.define(:highlight) do |content, _params|
  content.gsub(/```(?<options>\{[^}]*\})?\n(?<code>.+?)```$/m) do
    # Get options.
    options = JSON.parse(Regexp.last_match(:options) || '{}')
    lang    = options.fetch('lang', 'text')
    title   = options.fetch('title', '')
    linenos = options.fetch('linenos', true)
    # Highlight code.
    formatter = Rouge::Formatters::HTML.new
    formatter = Rouge::Formatters::HTMLTable.new(formatter) if linenos
    lexer = Rouge::Lexer.find(lang)
    code  = formatter.format(lexer.lex(Regexp.last_match(:code)))
    # Wrap the result.
    title = "<figcaption>#{title}</figcaption>" unless title.empty?
    code  = "<pre>#{code}</pre>" unless linenos
    <<-HTML
    <figure class="code">
      #{title}
      <div class="highlight">
        #{code}
      </div>
    </figure>
    HTML
  end
end

La gestion des catégories

Nanoc fourni déjà quelques fonctions pour gérer les catégories sur les articles. Malheureusement ces fonctions ne gèrent pas très bien les catégories qui contiennent des espaces (« Trucs et astuces » par exemple), et cela pose problème si l’on souhaite utiliser une catégorie en tant qu’ancre ou ID HTML.

J’ai donc réimplémenté moi-même certaines de ces fonctions :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Return the articles tagged with `tag`, sorted by descending creation date.
def articles_with_tag(tag)
  sorted_articles.select { |article| article.fetch(:tags, []).include?(tag) }
end

# Return a formatted list of tags for the given item as a string.
def tags_for(item, separator: ', ')
  base_url = '/blog/tags/#'
  item[:tags].map do |tag|
    %(<a href="#{base_url}#{to_html_id(tag)}" rel="tag">#{tag}</a>)
  end.join(separator)
end

# Return a version of `str` that can be used as an HTML ID.
def to_html_id(str)
  str.downcase.tr(' ', '-')
end

Au passage, j’ai également dû coder une fonction qui me renvoie la liste des catégories triée par ordre alphabétique. Le fait que j’utilise le français implique que je ne peux pas utiliser la fonction sort de Ruby car elle ne gère pas correctement l’ordre des caractères non ASCII.

['Développement', 'Divers', 'Design Patterns'].sort
=> ["Design Patterns", "Divers", "Développement"]

On voit que est placé après Di, ce qui est incorrect : il devrait être placé avant Di (mais bien après De). Pour pallier au problème, j’ai utilisé la gem sort_alphabetical :

['Développement', 'Divers', 'Design Patterns'].sort_alphabetical
=> ["Design Patterns", "Développement", "Divers"]

Cette fois, l’ordre est correct. Il suffit ensuite d’utiliser cette fonction sur la liste des catégories :

1
2
3
4
5
6
7
8
require 'sort_alphabetical'

# Return a list of existing tags, sorted alphabetically.
def tags
  @items.inject(Set.new) do |tags, item|
    tags.merge(item.fetch(:tags, []))
  end.to_a.sort_alphabetical
end

Les vérifications automatisées

Nanoc propose un système de validation qui permet d’exécuter (manuellement ou avant de déployer le site) des étapes de vérifications sur le contenu du site généré. C’est extrêmement pratique pour détecter des erreurs (telles que des liens cassés) lors du développement/avant de déployer le site.

J’ai donc mit à jour mon nanoc.yaml pour activer les vérifications suivantes :

external_links, internal_links et stale sont fournis par Nanoc. Les deux premiers vérifient que les liens utilisés sont valides et le dernier vérifie qu’il n’y a pas de fichiers inattendu dans le répertoire qui est copié lors du déploiement.

HTML et CSS

css_local et html_local vérifient que le CSS et le HTML sont bien valides. Nanoc propose déjà ce genre de vérification via css et html, mais ils ne me convenaient pas pour les raisons suivantes :

C’est pourquoi j’ai implémenté css_local et html_local qui utilisent un validatornu installé localement.
Un autre avantage à cela, en plus de fonctionner hors-ligne, est que je peux maintenant avoir accès aux avertissements (et ignorer ceux qui me semblent non pertinents).

css_local
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Nanoc::Check.define(:css_local) do
  require 'json'
  require 'open3'

  @output_filenames.each do |filename|
    next unless File.extname(filename) == '.css'

    command = %W[validatornu --css --format json #{filename}]
    output, _ = Open3.capture2e(*command)
    next if output.empty?

    JSON.parse(output)['messages'].each do |error|
      reason = error['message'].delete_prefix('CSS: ')
      errmsg = "l.#{error['lastLine']} - #{reason}"
      add_issue(errmsg, subject: filename)
    end
  end
end
html_local
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Nanoc::Check.define(:html_local) do
  require 'json'
  require 'open3'

  IGNORE_LIST = [
    # Nope, that's not the HTML5 way.
    'Consider using the “h1” element as a top-level heading only',
    # My <article> use h1, they have header…
    'Article lacks heading. Consider using “h2”-“h6”'
  ].freeze

  @output_filenames.each do |filename|
    next unless File.extname(filename) == '.html'

    command = %W[validatornu --html --format json #{filename}]
    output, _ = Open3.capture2e(*command)
    next if output.empty?

    JSON.parse(output)['messages'].each do |error|
      errmsg = error['message']
      next if IGNORE_LIST.any? { |str| errmsg.include?(str) }

      add_issue("l.#{error['lastLine']} - #{errmsg}", subject: filename)
    end
  end
end

Sitemap

sitemap vérifie, à l’aide de l’excellente bibiothèque Nokogiri, que le sitemap.xml généré est un XML valide ET qu’il respecte bien le schéma attendu (j’ai récupéré le sitemap.xsd dans les sources du blog de kAworu).

sitemap
1
2
3
4
5
6
7
8
9
10
11
12
13
Nanoc::Check.define(:sitemap) do
  require 'nokogiri'

  @output_filenames.each do |filename|
    next unless File.basename(filename) == 'sitemap.xml'

    xsd = Nokogiri::XML::Schema(File.read('misc/sitemap.xsd'))
    sitemap = Nokogiri::XML(File.read(filename))
    xsd.validate(sitemap).each do |error|
      add_issue(error.message, subject: filename)
    end
  end
end

Atom

Pour atom c’est le même principe que sitemap : on utilise Nokogiri pour valider le XML de atom.xml, ainsi que la validité du schéma.

atom
1
2
3
4
5
6
7
8
9
10
11
12
13
Nanoc::Check.define(:atom) do
  require 'nokogiri'

  @output_filenames.each do |filename|
    next unless File.basename(filename) == 'atom.xml'

    rng = Nokogiri::XML::RelaxNG(File.read('misc/atom.rng'))
    sitemap = Nokogiri::XML(File.read(filename))
    rng.validate(sitemap).each do |error|
      add_issue(error.message, subject: filename)
    end
  end
end

On remarquera que cette fois le schéma n’est pas défini par un fichier .xsd. En effet, il n’y a pas de XSD (XML Schema Definition) officielle pour le format Atom. La RFC 4287 fourni une spécification Relax NG (Regular Language for XML Next Generation). Cependant il y a un problème : la RFC utilise la syntaxe compacte, et cette syntaxe ne peut pas être utilisée par Nokogiri. Heureusement, il est possible de la convertir en syntaxe standard (utilisable par Nokogiri) avec trang.

Conclusion

La migration fut relativement simple, je n’ai pas eu trop de glue à faire moi-même. Seule la conversion des articles en Markdown vers eRuby a été un peu laborieuse, mais ça m’a permis de fixer deux ou trois trucs au passage, donc plutôt positif au final.