Refactorisation d'un blog Jekyll

Pour des raisons diverses, j'ai eu besoin d'unifier le positionnement des sites suivants :

Considérant que mon blog.bgzo.cc existe depuis longtemps et est référencé sur V2EX, j'ai finalement décidé d'y héberger la majorité de mes articles. note.bgzo.cc se concentrera sur les réflexions éparses, tandis que bgzo.cc servira à l'exploration de projets personnels.

Jekyll et la gestion des types de fichiers Markdown personnalisés

Par défaut, Jekyll prend en charge les fichiers Markdown avec une structure front matter spécifique :

layout: post
title: XXX
updated: 2026-03-28

Le nom de fichier doit suivre le format AAAA-MM-JJ-titre.MARKUP, par exemple 2026-03-27-mon-article.md. Cependant, j'utilise Obsidian pour écrire mes notes, avec des noms de fichiers générés aléatoirement et des priorités différentes. Mon format front matter ressemble plutôt à ceci :

title: xxx
aliases:
  - xxx
created: 2026-03-28T13:57:04
modified: 2026-03-28T16:52:30
tags:
  - writing/lab

Par défaut, ces fichiers ne sont pas inclus dans la variable site.posts de Jekyll. Pour que Jekyll les prenne en compte, deux approches sont possibles :

  1. Modifier le script de synchronisation en aval pour renommer les fichiers en masse selon le standard Jekyll.
  2. Conserver les noms de fichiers actuels, ne pas utiliser le répertoire _posts par défaut, et configurer une nouvelle structure et un nouveau template pour la page d'accueil.

Note : La deuxième option est nécessaire car site.posts est codé en dur dans Jekyll et n'est pas configurable. Si le format de nommage n'est pas respecté, site.posts restera vide.

Comparaison site.posts Collection personnalisée
Répertoire source Uniquement _posts <<em>nom</em>>/, nom arbitraire
Exigence du nom de fichier Obligatoire : AAAA-MM-JJ-titre Aucune restriction
Analyse de date intégrée Extrait automatiquement du nom de fichier Doit être spécifiée dans le front matter (date/created)
output: true par défaut Oui Configuration explicite nécessaire

J'ai opté pour la deuxième solution.

Nous devons donc définir une collection personnalisée _articles et ajouter la configuration suivante :

collections: # Définit les collections Jekyll pour organiser le contenu
  posts: # Collection nommée "posts" (fichiers dans _posts/)
    output: false # Ne génère pas de pages indépendantes, juste une source de données
  articles: # Collection nommée "articles" pour les publications externes
    output: true # Génère une page HTML pour chaque document de cette collection
    permalink: /:title.html # Format de l'URL : basé sur le titre de l'article avec extension .html

Pour que la page d'accueil fonctionne correctement et affiche le CSS, nous devons ajouter la configuraton suivante pour fournir des valeurs par défaut :

defaults: # Applique des valeurs front matter par défaut à plusieurs documents
  - scope: # Portée des valeurs par défaut
      path: "" # Correspond à tous les chemins du site
      type: articles # S'applique uniquement aux documents de la collection "articles"
    values: # Champs front matter par défaut à injecter
      layout: post # Utilise le layout "post" (correspondant à _layouts/post.html)

Avec ces modifications, la page d'accueil et les articles sont configurés.

Génération du flux RSS

L'utilisation de la deuxième solution nous a fait abandonner site.posts, ce qui rendra le plugin RSS jekyll-feed inopérant. Nous devons donc générer le flux RSS manuellement :

---
layout: none
---
<?xml version="1.0" encoding="utf-8"??>
<feed xmlns="http://www.w3.org/2005/Atom">
  <generator uri="https://jekyllrb.com/" version="{{ jekyll.version }}">Jekyll</generator>
  <link href="{{ site.url }}/feed.xml" rel="self" type="application/atom+xml"></link>
  <link href="{{ site.url }}/" rel="alternate" type="text/html"></link>
  <updated>{{ site.time | date_to_xmlschema }}</updated>
  <id>{{ site.url }}/feed.xml</id>
  <title type="html">{{ site.title | xml_escape }}</title>
  <subtitle>{{ site.description | xml_escape }}</subtitle>
  {% if site.author %}
  <author>
    <name>{{ site.author | xml_escape }}</name>
  </author>
  {% endif %}
  {% assign articles = site.articles | sort: "created" | reverse %}
  {% for post in articles limit: 20 %}
  <entry>
    <title type="html">{{ post.title | xml_escape }}</title>
    <link href="{{ post.url | prepend: site.url }}"></link>
    <published>{{ post.created | date_to_xmlschema }}</published>
    <updated>{{ post.modified | default: post.created | date_to_xmlschema }}</updated>
    <id>{{ post.url | prepend: site.url }}</id>
    <content type="html" xml:base="{{ post.url | prepend: site.url }}">{{ post.content | xml_escape }}</content>
    {% if post.description %}
    <summary type="html">{{ post.description | xml_escape }}</summary>
    {% endif %}
  </entry>
  {% endfor %}
</feed>

Le flux RSS sera ainsi généré correctement.

Si votre site contient des modèles Liquid dans le contenu des articles, cela pourrait causer des problèmes lors du rendu. Pour éviter cela, ajoutez la configuration suivante :

defaults: # Applique des valeurs front matter par défaut
  - scope:
      path: ""
      type: articles
    values:
      layout: post
+     render_with_liquid: false # Désactive le rendu Liquid pour le contenu des articles

Une autre méthode consiste à encadrer le contenu potentiellement problématique dans des balises {% raw %}...{% endraw %} dans vos articles.

Note : Une petite subtilité est apparue. Si c'est la première fois que vous construisez le site, vous pouvez ignorer la section suivante pour l'instant.

Malgré ces ajustements, j'ai constaté que deux sites qui indexaient mon blog n'avaient pas mis à jour leur contenu (après plus de 12 heures) :

J'ai d'abord vérifié l'adresse du flux RSS avec validator.w3.org/feed/check.cgi1, qui n'a signalé aucun problème.

Ensuite, j'ai examiné les anciennes snapshots Vercel pour trouver la source fournie précédemment :

J'ai identifié les différences suivantes :

  1. [Cause la plus probable] L'attribut rel et type manquait sur la balise <link>. ```

    
    > **Note :** La spécification Atom exige qu'un élément `<entry>` contienne au moins un lien avec `rel="alternate"`. De nombreux agrégateurs de flux s'appuient sur cet atttribut pour identifier les liens des articles. Sans lui, ils ne peuvent pas trouver l'URL de l'élément, ce qui entraîne une non-mise à jour ou un non-affichage.
    
  2. Le contenu était échappé avec xml_escape au lieu d'utiliser CDATA. ```

    • <content ...>
    • <content ...><p>...</p>
    
    

J'ai donc apporté les modifications suivantes :

- <link href="{{ post.url | prepend: site.url }}"/>
+ <link href="{{ post.url | prepend: site.url }}" rel="alternate" type="text/html" title="{{ post.title | xml_escape }}"/>

- <content type="html" xml:base="{{ post.url | prepend: site.url }}">{{ post.content | xml_escape }}</content>
+ <content type="html" xml:base="{{ post.url | prepend: site.url }}"><![CDATA[{{ post.content }}]]></content>

Avec ces changements, le format est maintenant cohérent. Il ne reste plus qu'à observer les mises à jour.

Intégration des syntaxes Obsidian pour YouTube et Twitter dans Jekyll

Les syntaxes comme celles-ci sont désormais prises en charge :

![](https://x.com/Enter_Apps/status/1768669206826926292)
![](https://twitter.com/Enter_Apps/status/1768669206826926292)
![](https://www.youtube.com/watch?v=p485kUNpPvE)

Pour cela, j'ai ajouté le script JavaScript suivant :

// Remplace les images de type ![]( URL ) par des embeds pour Twitter/X et YouTube
<script>
  (function () {
    var YT_RE =
      /(?:youtube\.com\/watch\?(?:.*&)?v=|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
    var YT_SHORT_RE = /youtube\.com\/shorts\/([a-zA-Z0-9_-]{11})/;
    var TW_RE = /(?:twitter\.com|x\.com)\/\w+\/status\/(\d+)/;

    function replaceImg(img, embed) {
      var parent = img.parentNode;
      // kramdown enveloppe les ![]() autonomes dans des <p> ; on les sort pour que les éléments de bloc soient valides
      if (parent && parent.tagName === "P" && parent.childNodes.length === 1) {
        parent.parentNode.replaceChild(embed, parent);
      } else {
        parent.replaceChild(embed, img);
      }
    }

    function makeYouTubeEmbed(videoId) {
      var wrapper = document.createElement("div");
      wrapper.className = "embed-youtube";
      var iframe = document.createElement("iframe");
      iframe.src = "https://www.youtube.com/embed/" + videoId;
      iframe.title = "YouTube video player";
      iframe.frameBorder = "0";
      iframe.allow =
        "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share";
      iframe.allowFullscreen = true;
      iframe.setAttribute("loading", "lazy");
      wrapper.appendChild(iframe);
      return wrapper;
    }

    function makeTwitterEmbed(tweetUrl) {
      var blockquote = document.createElement("blockquote");
      blockquote.className = "twitter-tweet";
      var a = document.createElement("a");
      a.href = tweetUrl;
      a.textContent = tweetUrl;
      blockquote.appendChild(a);
      return blockquote;
    }

    var imgs = Array.from(document.querySelectorAll("article img"));
    var hasTweet = false;

    imgs.forEach(function (img) {
      var src = img.getAttribute("src") || "";

      var ytMatch = src.match(YT_RE) || src.match(YT_SHORT_RE);
      if (ytMatch) {
        replaceImg(img, makeYouTubeEmbed(ytMatch[1]));
        return;
      }

      var twMatch = src.match(TW_RE);
      if (twMatch) {
        // Le script widgets.js de Twitter n'accepte que les URLs twitter.com, donc normalisation de x.com → twitter.com
        var tweetUrl = src.replace(/^(https?:\/\/)x\.com\//, "$1twitter.com/");
        replaceImg(img, makeTwitterEmbed(tweetUrl));
        hasTweet = true;
        return;
      }
    });
    if (hasTweet) {
      var s = document.createElement("script");
      s.src = "https://platform.twitter.com/widgets.js";
      s.async = true;
      s.charset = "utf-8";
      document.head.appendChild(s);
    }
  })();
</script>
</p>

Au cours de ce processus, j'ai découvert que les liens x.com ne pouvaient pas être intégrés car le script injecté utilisait l'ancien domaine twitter.com. J'ai donc corrigé la logique pour normaliser les URLs :

      var twMatch = src.match(TW_RE);
      if (twMatch) {
        // Le script widgets.js de Twitter n'accepte que les URLs twitter.com, donc normalisation de x.com → twitter.com
        var tweetUrl = src.replace(/^(https?:\/\/)x\.com\//, "$1twitter.com/");
        replaceImg(img, makeTwitterEmbed(tweetUrl));
        hasTweet = true;
        return;
      }

Les liens YouTube et X peuvent maintenant être intégrés correctement.

Support des Callouts (Encadrés) dans Jekyll

Jekyll et kramdown ne prenant pas en charge nativement ces syntaxes étendues, l'approche consiste à intercepter ces éléments avant le rendu HTML pour les transformer. J'ai implémenté un composant pour cela :

// Transforme les callouts de style GFM : > [!NOTE], > [!TIP], > [!WARNING], > [!IMPORTANT], > [!CAUTION]
<script>
  (function () {
    var TYPES = {
      NOTE: "Note",
      TIP: "Tip",
      WARNING: "Warning",
      IMPORTANT: "Important",
      CAUTION: "Caution",
    };

    // --- Chemins des icônes SVG --------------------------------------------------
    // Chaque valeur correspond à l'attribut `d` d'un seul élément <path> dans une viewBox 24x24.
    // Laisser une chaîne vide pour ne pas afficher d'icône pour ce type.
    var SVG_PATHS = {
      NOTE: "M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm8-6.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13ZM6.5 7.75A.75.75 0 0 1 7.25 7h1a.75.75 0 0 1 .75.75v2.75h.25a.75.75 0 0 1 0 1.5h-2a.75.75 0 0 1 0-1.5h.25v-2h-.25a.75.75 0 0 1-.75-.75ZM8 6a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z",
      TIP: "M8 1.5c-2.363 0-4 1.69-4 3.75 0 .984.424 1.625.984 2.304l.214.253c.223.264.47.556.673.848.284.411.537.896.621 1.49a.75.75 0 0 1-1.484.211c-.04-.282-.163-.547-.37-.847a8.456 8.456 0 0 0-.542-.68c-.084-.1-.173-.205-.268-.32C3.201 7.75 2.5 6.766 2.5 5.25 2.5 2.31 4.863 0 8 0s5.5 2.31 5.5 5.25c0 1.516-.701 2.5-1.328 3.259-.095.115-.184.22-.268.319-.207.245-.383.453-.541.681-.208.3-.33.565-.37.847a.751.751 0 0 1-1.485-.212c.084-.593.337-1.078.621-1.489.203-.292.45-.584.673-.848.075-.088.147-.173.213-.253.561-.679.985-1.32.985-2.304 0-2.06-1.637-3.75-4-3.75ZM5.75 12h4.5a.75.75 0 0 1 0 1.5h-4.5a.75.75 0 0 1 0-1.5ZM6 15.25a.75.75 0 0 1 .75-.75h2.5a.75.75 0 0 1 0 1.5h-2.5a.75.75 0 0 1-.75-.75Z",
      WARNING:
        "M6.457 1.047c.659-1.234 2.427-1.234 3.086 0l6.082 11.378A1.75 1.75 0 0 1 14.082 15H1.918a1.75 1.75 0 0 1-1.543-2.575Zm1.763.707a.25.25 0 0 0-.44 0L1.698 13.132a.25.25 0 0 0 .22.368h12.164a.25.25 0 0 0 .22-.368Zm.53 3.996v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 11a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z",
      IMPORTANT:
        "M0 1.75C0 .784.784 0 1.75 0h12.5C15.216 0 16 .784 16 1.75v9.5A1.75 1.75 0 0 1 14.25 13H8.06l-2.573 2.573A1.458 1.458 0 0 1 3 14.543V13H1.75A1.75 1.75 0 0 1 0 11.25Zm1.75-.25a.25.25 0 0 0-.25.25v9.5c0 .138.112.25.25.25h2a.75.75 0 0 1 .75.75v2.19l2.72-2.72a.749.749 0 0 1 .53-.22h6.5a.25.25 0 0 0 .25-.25v-9.5a.25.25 0 0 0-.25-.25Zm7 2.25v2.5a.75.75 0 0 1-1.5 0v-2.5a.75.75 0 0 1 1.5 0ZM9 9a1 1 0 1 1-2 0 1 1 0 0 1 2 0Z",
      CAUTION:
        "M4.47.22A.749.749 0 0 1 5 0h6c.199 0 .389.079.53.22l4.25 4.25c.141.14.22.331.22.53v6a.749.749 0 0 1-.22.53l-4.25 4.25A.749.749 0 0 1 11 16H5a.749.749 0 0 1-.53-.22L.22 11.53A.749.749 0 0 1 0 11V5c0-.199.079-.389.22-.53Zm.84 1.28L1.5 5.31v5.38l3.81 3.81h5.38l3.81-3.81V5.31L10.69 1.5ZM8 4a.75.75 0 0 1 .75.75v3.5a.75.75 0 0 1-1.5 0v-3.5A.75.75 0 0 1 8 4Zm0 8a1 1 0 1 1 0-2 1 1 0 0 1 0 2Z",
    };
    // ---------------------------------------------------------------------------

    var SVG_NS = "http://www.w3.org/2000/svg";
    var RE = /^\[!(NOTE|TIP|WARNING|IMPORTANT|CAUTION)\]\[ \t]*/i;

    function makeIcon(type) {
      var d = SVG_PATHS[type];
      var svg = document.createElementNS(SVG_NS, "svg");
      svg.setAttribute("viewBox", "0 0 16 16");
      svg.setAttribute("fill", "currentColor");
      svg.setAttribute("aria-hidden", "true");
      if (d) {
        var path = document.createElementNS(SVG_NS, "path");
        path.setAttribute("d", d);
        svg.appendChild(path);
      }
      return svg;
    }

    document
      .querySelectorAll("article blockquote:not([class])")
      .forEach(function (bq) {
        var firstP = bq.querySelector("p:first-child");
        if (!firstP) return;

        var firstText = firstP.firstChild;
        if (!firstText || firstText.nodeType !== 3 /* TEXT_NODE */) return;

        var m = firstText.nodeValue.match(RE);
        if (!m) return;

        var type = m[1].toUpperCase();

        // Supprime le préfixe "[!TYPE] " du nœud de texte d'ouverture
        firstText.nodeValue = firstText.nodeValue.slice(m[0].length);

        // Si le nœud de texte est maintenant vide, le supprimer ainsi que le saut de ligne forcé <br></br> suivant
        if (firstText.nodeValue === "") {
          var next = firstText.nextSibling;
          firstP.removeChild(firstText);
          if (next && next.nodeName === "BR") firstP.removeChild(next);
        }

        // Si le premier <p> est maintenant vide, le supprimer entièrement
        if (
          firstP.childNodes.length === 0 ||
          firstP.textContent.trim() === ""
        ) {
          bq.removeChild(firstP);
        }

        // Construit l'élément de titre du callout
        var titleEl = document.createElement("p");
        titleEl.className = "callout-title";
        titleEl.setAttribute("aria-label", TYPES[type]);

        var iconSpan = document.createElement("span");
        iconSpan.className = "callout-icon";
        iconSpan.appendChild(makeIcon(type));

        var labelSpan = document.createElement("span");
        labelSpan.textContent = TYPES[type];

        titleEl.appendChild(iconSpan);
        titleEl.appendChild(document.createTextNode("\u00a0"));
        titleEl.appendChild(labelSpan);

        bq.insertBefore(titleEl, bq.firstChild);
        bq.classList.add("callout", "callout-" + type.toLowerCase());
      });
  })();
</script>
</p></path>

Ce composant s'inspire des styles et couleurs de GitHub et supporte les mêmes types de callouts :

NOTE : Met en évidence les informations que les utilisateurs doivent prendre en compte, même en parcourant rapidement le texte.

TIP : Informations optionnelles pour aider l'utilisateur à réussir.

IMPORTANT : Informations cruciales nécessaires à la réussite de l'utilisateur.

WARNING : Contenu critique exigeant une attention immédiate de l'utilisateur en raison de risques potentiels.

CAUTION : Conséquences négatives potentielles d'une action.

Les styles CSS correspondants ne sont pas inclus ici, mais vous pouvez les retrouver dans la partie CSS de ce blog.

Introduction du fil d'actualité SEED

J'ai récemment souhaité synchroniser mes pensées éparses avec mon blog en introduisant deux catégories supplémentaires :

  • SEEDS
  • STORIES

La distinction ? STORIES se concentre sur les événements personnels (adoption d'un chat, bannissement d'un compte de jeu, vie universitaire, etc.). SEEDS est une catégorie plus générale, destinée à contenir tout ce qui ne rentre pas dans LABS, THOUGHTS ou STORIES. L'idée est qu'ils pourront évoluer vers l'une de ces catégories.

En plus d'ajouter une nouvelle section, deux modifications ont été nécessaires :

Filtrage du flux RSS

Pour exclure la catégorie SEEDS du flux RSS, j'ai modifié le code comme suit :

{% assign rss_articles = site.articles
| where_exp: "item", "item.path contains '_articles/labs/' or item.path contains '_articles/thoughts/' or item.path contains '_articles/stories/'"
| sort: "created"
| reverse %}

Pagination du fil d'actualité

Deux approches de pagination sont possibles :

  1. Pagination entièrement statique : chaque page de pagination est une page statique.
  2. Pagination dynamique : génération d'un JSON de pagination pour un rendu dynamique.

Pour des données dynamiques comme celles-ci, je préfère l'approche JSON. La pagination statique, bien que facile à indexer par les moteurs de recherche, présente souvent des informations obsolètes et peut générer de nombreuses pages "poubelles". Si vous avez ce besoin, vous pouvez consulter le tutoriel officiel pour implémenter jekyll-paginate-v2.

La deuxième approche ne dispose pas de plugin prêt à l'emploi, nécessitant donc un développement personnalisé. Voici une première version du template pour générer un JSON sérialisé :

---
layout: none
permalink: /posts.json
---
{%- assign all_posts = site.articles | sort: "created" | reverse -%}
{%- assign filtered = "" | split: "" -%}
{%- for post in all_posts -%}
  {%- unless post.path contains '_articles/archives/' -%}
    {%- assign filtered = filtered | push: post -%}
  {%- endunless -%}
{%- endfor -%}
[{%- for post in filtered -%}
{"title":{{ post.title | jsonify }},"url":{{ post.url | relative_url | jsonify }},"date":"{{ post.created | date: '%Y/%m/%d' }}","datetime":"{{ post.created | date: '%F' }}","desc":{{ post.description | default: "" | truncate: 200 | jsonify }}}{% unless forloop.last %},{% endunless %}
{%- endfor -%}]

Cette version simple renvoie toutes les données en une seule fois, ce qui constitue une fausse pagination. Avec environ 87 articles, le fichier généré pèse environ 57 Ko (environ 12 Ko après compression GZIP). Si le nombre d'articles devait atteindre 10 000, le fichier pourait peser environ 5 Mo (1,3 Mo compressé), ce qui pourrait devenir problématique à long terme.

Nombre d'articles Taille originale Taille après GZIP
87 57 Ko ~12 Ko
1000 ~655 Ko ~130 Ko
10000 ~6.5 Mo ~1.3 Mo

Bien que cette approche soit fonctionnelle, ma propre exigence m'a poussé à explorer une solution plus robuste. Jekyll offre des API (Hooks) qui permettent d'exécuter des scripts Ruby pour générer un système de pagination plus performant. Ce script est placé dans _plugins/posts_api.rb :

require 'json'
require 'fileutils'

module PostsApiGenerator
  def self.format_date(val, fmt)
    return '' unless val
    val.respond_to?(:strftime) ? val.strftime(fmt) : val.to_s
  end
end

Jekyll::Hooks.register :site, :post_write do |site|
  per_page = 5

  all_docs = site.collections['articles']&.docs
  next unless all_docs

  posts = all_docs
    .reject { |doc| doc.data['archive'] }
    .sort_by { |doc| doc.data['created'].to_s }
    .reverse

  pages = posts.each_slice(per_page).to_a
  total_pages = pages.length

  dir = File.join(site.dest, 'api', 'posts')
  FileUtils.mkdir_p(dir)

  pages.each_with_index do |batch, i|
    page_num  = i + 1
    next_page = page_num < total_pages ? page_num + 1 : nil

    data = {
      'posts' => batch.map do |doc|
        desc = (doc.data['description'] || '').to_s
        desc = desc.length > 200 ? "#{desc[0, 197]}..." : desc
        {
          'title'    => doc.data['title'].to_s,
          'url'      => doc.url,
          'date'     => PostsApiGenerator.format_date(doc.data['created'], '%Y/%m/%d'),
          'datetime' => PostsApiGenerator.format_date(doc.data['created'], '%F'),
          'desc'     => desc
        }
      end,
      'total_pages'  => total_pages,
      'current_page' => page_num,
      'next_page'    => next_page
    }

    File.write(File.join(dir, "#{page_num}.json"), JSON.generate(data))
  end

  Jekyll.logger.info 'Posts API:', "Generated #{total_pages} page(s) → /api/posts/{1..#{total_pages}}.json"
end

La variable site fournie par Jekyll offre de nombreuses données et chemins utiles :

  • site.source : Répertoire source.
  • site.dest : Répertoire de destination.
  • site.collections['articles'].docs : Objets des documents de la collection.
  • doc.data : Données du front matter (par ex. created, archive).
  • doc.url : URL finale de l'article.
  • Jekyll.logger : Journalisation pendant la construction.

Essentiellement, Jekyll fournit des points d'accroche (Hooks) dans son cycle de vie pour permettre l'exécution de notre propre code.

Correction du Favicon.ico

Certains services récupèrent l'icône d'un site en requêtant directement /favicon.ico. Or, de nombreux sites Jekyll ne placent pas ce fichier à la racine. Nous pouvons utiliser la même capacité de Hook que pour la génération de l'API pour copier le favicon au bon endroit :

 # Copie favicon.ico dans le répertoire racine
  source_favicon = File.join(site.source, 'assets', 'favicons', 'favicon.ico')
  target_favicon = File.join(site.dest, 'favicon.ico')

  if File.exist?(source_favicon)
    FileUtils.cp(source_favicon, target_favicon)
    Jekyll.logger.info 'Favicon:', 'Copied /assets/favicons/favicon.ico -> /favicon.ico'
  else
    Jekyll.logger.warn 'Favicon:', "Source file not found: #{source_favicon}"
  end

Étiquettes: Jekyll blog Configuration rss atom

Publié le 22 juillet à 07h31