Chirpy site statique dossier 'notes'

Chirpy site statique dossier 'notes'

Notes

Générer un site statique à partir de dossiers contenant des fichiers markdown .md, garder les liens vers les images

Se connecter sur la VM jekyll (vm105)

Chirpy

Sauvegardes

Sauvegarde le dossier ~/chirpy

1
cp -a ~/chirpy /sharenfs/vm105/chirpy2026-09-30

Arrêt chirpy

Arrêt du service jekyll chirpy, script /usr/local/bin/stop-chirpy

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
30
31
32
33
34
#!/usr/bin/env bash
set -euo pipefail

SERVICE_NAME="chirpy.service"
PATH_NAME="chirpy-rsync.path"
SITE_DIR="$HOME/chirpy"
JEKYLL_BIN="/home/debjek/gems/bin/jekyll"
RSYNC_SRC="/home/debjek/chirpy/_site/"
RSYNC_DEST="yick@192.168.0.205:/sharenfs/rnmkcy/chirpy/"
SSH_OPTS="-p 55205 -i /home/debjek/.ssh/cwwk-ed25519 -o StrictHostKeyChecking=accept-new"

echo "[$(date '+%F %T')] Passage dans $SITE_DIR"
cd "$SITE_DIR"

export PATH="/home/debjek/gems/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
export GEM_HOME="/home/debjek/gems"
export GEM_PATH="/home/debjek/gems"

echo "[$(date '+%F %T')] Arrêt path utilisateur $PATH_NAME"
systemctl --user stop "$PATH_NAME" || true

echo "[$(date '+%F %T')] Arrêt service utilisateur $SERVICE_NAME"
systemctl --user stop "$SERVICE_NAME" || true

echo "[$(date '+%F %T')] Attente que $SERVICE_NAME soit vraiment stoppé"
for _ in {1..30}; do
  if systemctl --user is-active --quiet "$SERVICE_NAME"; then
    sleep 1
  else
    break
  fi
done
echo "[$(date '+%F %T')] Supprimer dossier _site"
sudo rm -r /home/debjek/chirpy/_site

Le rendre exécutable

1
sudo chmod +x /usr/local/bin/stop-chirpy

Exécuter: stop-chirpy

1
2
3
4
5
[2026-09-30 17:11:33] Passage dans /home/debjek/chirpy
[2026-09-30 17:11:33] Arrêt path utilisateur chirpy-rsync.path
[2026-09-30 17:11:33] Arrêt service utilisateur chirpy.service
[2026-09-30 17:11:33] Attente que chirpy.service soit vraiment stoppé
[2026-09-30 17:11:33] Supprimer dossier _site

Modifier chirpy

Configuration _config.yml

Modifier le fichier $HOME/chirpy/_config.yml

1
2
3
4
5
6
7
markdown_folders:
  dirs: [_notes]         # plusieurs dossiers possibles, ils sont fusionnés dans une seule arborescence
  base_url: /notes
  root_title: Notes      # titre de l'index racine
  breadcrumb: true       # fil d'Ariane dans le contenu
  defaults:
    layout: page

Dossier _notes

Créer le dossier _notes

1
mkdir -p $HOME/chirpy/_notes

Copier quelques dossiers et fichiers dans ce répertoire

Plugin markdown_folders.rb

Voici la version complète du plugin. Elle génère un index par dossier (sous-dossiers et notes) et un fil d'Ariane sur chaque page.

Créer le plugin _plugins/markdown_folders.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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
# frozen_string_literal: true

require 'yaml'
require 'date'

module Jekyll
  # Transforme des dossiers de .md (avec ou sans front matter) en pages,
  # avec une page d'index automatique par dossier et un fil d'Ariane.
  class MarkdownFoldersGenerator < Generator
    safe true
    priority :low

    Note = Struct.new(:names, :slugs, :folder_names, :folder_slugs, :index, :page,
                      keyword_init: true)

    def generate(site)
      cfg         = site.config['markdown_folders'] || {}
      @site       = site
      @base_url   = (cfg['base_url'] || '/notes').chomp('/')
      @defaults   = cfg['defaults'] || { 'layout' => 'page' }
      @root_title = cfg['root_title'] || 'Notes'
      @breadcrumb = cfg.fetch('breadcrumb', true)

      notes = Array(cfg['dirs'] || '_notes').flat_map { |d| load_dir(d) }
      return if notes.empty?

      folders = collect_folders(notes)
      indexed = notes.select(&:index).map { |n| n.folder_slugs.join('/') }

      pages = notes.map(&:page)
      folders.each do |key, folder|
        next if indexed.include?(key)

        pages << build_index(folder, notes, folders)
      end

      pages.each { |p| site.pages << p }
    end

    private

    # --- Lecture des fichiers -------------------------------------------

    def load_dir(dir)
      root = File.join(@site.source, dir)
      return [] unless Dir.exist?(root)

      Dir.glob(File.join(root, '**', '*.md')).sort.filter_map do |path|
        names = path.sub("#{root}/", '').sub(/\.md\z/, '').split('/')
        next if names.any? { |s| s.start_with?('.') } # .obsidian, .trash…

        build_note(path, names)
      end
    end

    def build_note(path, names)
      raw  = File.read(path, mode: 'r:bom|utf-8')
      data = {}
      body = raw

      if (m = Jekyll::Document::YAML_FRONT_MATTER_REGEXP.match(raw))
        begin
          data = YAML.safe_load(m[1], permitted_classes: [Date, Time]) || {}
          body = m.post_match
        rescue Psych::SyntaxError => e
          Jekyll.logger.warn 'markdown_folders:', "front matter invalide dans #{path} (#{e.message})"
        end
      end

      slugs        = names.map { |s| Utils.slugify(s, mode: 'latin') }
      folder_names = names[0..-2]
      folder_slugs = slugs[0..-2]
      index        = names.last.casecmp?('index')

      crumb   = breadcrumb(folder_names, folder_slugs, link_last: !index)
      content = fix_images(body)
      content = "#{crumb}\n\n#{content}" unless crumb.empty?

      if index
        permalink = url(folder_slugs)
        title     = folder_names.last || @root_title
        name      = folder_slugs.last || 'index'
      else
        permalink = url(slugs)
        title     = names.last
        name      = slugs.last
      end

      page = make_page(name, permalink, title, content, data)
      Note.new(names: names, slugs: slugs, folder_names: folder_names,
               folder_slugs: folder_slugs, index: index, page: page)
    end

    # --- Index de dossiers ----------------------------------------------

    def collect_folders(notes)
      folders = {}
      notes.each do |n|
        (0..n.folder_slugs.size).each do |i|
          slugs = n.folder_slugs[0...i]
          folders[slugs.join('/')] ||= { names: n.folder_names[0...i], slugs: slugs }
        end
      end
      folders
    end

    def build_index(folder, notes, folders)
      names = folder[:names]
      slugs = folder[:slugs]
      depth = slugs.size

      subs = folders.values
                    .select { |f| f[:slugs].size == depth + 1 && f[:slugs][0...depth] == slugs }
                    .sort_by { |f| f[:names].last.downcase }
      files = notes.reject(&:index)
                   .select { |n| n.folder_slugs == slugs }
                   .sort_by { |n| n.names.last.downcase }

      lines = []
      crumb = breadcrumb(names, slugs, link_last: false)
      lines << crumb << '' unless crumb.empty?

      unless subs.empty?
        lines << '## Dossiers' << ''
        subs.each { |f| lines << "- [#{f[:names].last}](#{href(f[:slugs])})" }
        lines << ''
      end

      unless files.empty?
        lines << '## Notes' << ''
        files.each { |n| lines << "- [#{n.names.last}](#{href(n.slugs)})" }
        lines << ''
      end

      name = slugs.last || 'index'
      make_page(name, url(slugs), names.last || @root_title, lines.join("\n"), {})
    end

    # --- Utilitaires ----------------------------------------------------

    def make_page(name, permalink, title, content, data)
      page = PageWithoutAFile.new(@site, @site.source, '', "#{name}.md")
      page.content = content
      page.data.merge!(@defaults)
      page.data['title']     = title
      page.data['permalink'] = permalink
      page.data.merge!(data) # le front matter, s'il existe, l'emporte
      page
    end

    def url(slugs)
      slugs.empty? ? "#{@base_url}/" : "#{@base_url}/#{slugs.join('/')}/"
    end

    def href(slugs)
      "#{@site.baseurl}#{url(slugs)}"
    end

    def breadcrumb(names, slugs, link_last:)
      return '' unless @breadcrumb

      items = [[@root_title, []]] + names.each_index.map { |i| [names[i], slugs[0..i]] }
      return '' if items.size == 1 && !link_last

      items.each_with_index.map do |(label, s), i|
        i == items.size - 1 && !link_last ? label : "[#{label}](#{href(s)})"
      end.join(' › ')
    end

    # kramdown ne comprend pas les espaces dans ![](/images/Nom%20Image.png)
    def fix_images(text)
      text.gsub(%r{(!\[[^\]]*\]\()(/images/[^)"]*)\)}) do
        "#{Regexp.last_match(1)}#{Regexp.last_match(2).gsub(' ', '%20')})"
      end
    end
  end
end

Dans Chirpy, les entrées de la sidebar sont les fichiers du dossier _tabs/, classés selon le champ order. Accueil est codé en dur et reste toujours en premier.

1- Créer l’onglet

_tabs/notes.md :

1
2
3
4
5
6
---
title: Notes
icon: fas fa-book
order: 3
permalink: /notes/
---

L’icône est n’importe quelle icône Font Awesome gratuite (fas fa-book, fas fa-folder-open, fas fa-sticky-note…).

2- Décaler les autres onglets

Par défaut, Chirpy utilise Catégories = 1, Tags = 2, Archives = 3, À propos = 4. Pour obtenir l’ordre voulu :

Fichier order
_tabs/categories.md 1
_tabs/tags.md 2
_tabs/notes.md 3
_tabs/archives.md 4
_tabs/about.md 5

3- Éviter le conflit avec l’index généré

L’onglet a le permalink /notes/, comme l’index racine créé par le plugin. Il faut donc que le plugin ne crée pas de page en double et injecte la liste des dossiers et des notes dans l’onglet. Dans generate, remplace la boucle finale par :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
      pages = notes.map(&:page)
      tabs  = site.collections.values.flat_map(&:docs).to_h { |d| [d.url, d] }

      folders.each do |key, folder|
        next if indexed.include?(key)

        page = build_index(folder, notes, folders)
        if (doc = tabs[page.data['permalink']])
          doc.content = "#{page.content}\n\n#{doc.content}" # l'onglet existant reçoit la liste
        else
          pages << page
        end
      end

      pages.each { |p| site.pages << p }

Le corps de _tabs/notes.md peut rester vide, ou contenir un texte d’introduction qui sera placé sous la liste.

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
# frozen_string_literal: true

require 'yaml'
require 'date'

module Jekyll
  # Transforme des dossiers de .md (avec ou sans front matter) en pages,
  # avec une page d'index automatique par dossier et un fil d'Ariane.
  class MarkdownFoldersGenerator < Generator
    safe true
    priority :low

    Note = Struct.new(:names, :slugs, :folder_names, :folder_slugs, :index, :page,
                      keyword_init: true)

    def generate(site)
      cfg         = site.config['markdown_folders'] || {}
      @site       = site
      @base_url   = (cfg['base_url'] || '/notes').chomp('/')
      @defaults   = cfg['defaults'] || { 'layout' => 'page' }
      @root_title = cfg['root_title'] || 'Notes'
      @breadcrumb = cfg.fetch('breadcrumb', true)

      notes = Array(cfg['dirs'] || '_notes').flat_map { |d| load_dir(d) }
      return if notes.empty?

      folders = collect_folders(notes)
      indexed = notes.select(&:index).map { |n| n.folder_slugs.join('/') }

      pages = notes.map(&:page)
      tabs  = site.collections.values.flat_map(&:docs).to_h { |d| [d.url, d] }

      folders.each do |key, folder|
        next if indexed.include?(key)

        page = build_index(folder, notes, folders)
        if (doc = tabs[page.data['permalink']])
          doc.content = "#{page.content}\n\n#{doc.content}" # l'onglet existant reçoit la liste
        else
          pages << page
        end
      end

      pages.each { |p| site.pages << p }
    end

    private

    # --- Lecture des fichiers -------------------------------------------

    def load_dir(dir)
      root = File.join(@site.source, dir)
      return [] unless Dir.exist?(root)

      Dir.glob(File.join(root, '**', '*.md')).sort.filter_map do |path|
        names = path.sub("#{root}/", '').sub(/\.md\z/, '').split('/')
        next if names.any? { |s| s.start_with?('.') } # .obsidian, .trash…

        build_note(path, names)
      end
    end

    def build_note(path, names)
      raw  = File.read(path, mode: 'r:bom|utf-8')
      data = {}
      body = raw

      if (m = Jekyll::Document::YAML_FRONT_MATTER_REGEXP.match(raw))
        begin
          data = YAML.safe_load(m[1], permitted_classes: [Date, Time]) || {}
          body = m.post_match
        rescue Psych::SyntaxError => e
          Jekyll.logger.warn 'markdown_folders:', "front matter invalide dans #{path} (#{e.message})"
        end
      end

      slugs        = names.map { |s| Utils.slugify(s, mode: 'latin') }
      folder_names = names[0..-2]
      folder_slugs = slugs[0..-2]
      index        = names.last.casecmp?('index')

      crumb   = breadcrumb(folder_names, folder_slugs, link_last: !index)
      content = fix_images(body)
      content = "#{crumb}\n\n#{content}" unless crumb.empty?

      if index
        permalink = url(folder_slugs)
        title     = folder_names.last || @root_title
        name      = folder_slugs.last || 'index'
      else
        permalink = url(slugs)
        title     = names.last
        name      = slugs.last
      end

      page = make_page(name, permalink, title, content, data)
      Note.new(names: names, slugs: slugs, folder_names: folder_names,
               folder_slugs: folder_slugs, index: index, page: page)
    end

    # --- Index de dossiers ----------------------------------------------

    def collect_folders(notes)
      folders = {}
      notes.each do |n|
        (0..n.folder_slugs.size).each do |i|
          slugs = n.folder_slugs[0...i]
          folders[slugs.join('/')] ||= { names: n.folder_names[0...i], slugs: slugs }
        end
      end
      folders
    end

    def build_index(folder, notes, folders)
      names = folder[:names]
      slugs = folder[:slugs]
      depth = slugs.size

      subs = folders.values
                    .select { |f| f[:slugs].size == depth + 1 && f[:slugs][0...depth] == slugs }
                    .sort_by { |f| f[:names].last.downcase }
      files = notes.reject(&:index)
                   .select { |n| n.folder_slugs == slugs }
                   .sort_by { |n| n.names.last.downcase }

      lines = []
      crumb = breadcrumb(names, slugs, link_last: false)
      lines << crumb << '' unless crumb.empty?

      unless subs.empty?
        lines << '## Dossiers' << ''
        subs.each { |f| lines << "- [#{f[:names].last}](#{href(f[:slugs])})" }
        lines << ''
      end

      unless files.empty?
        lines << '## Notes' << ''
        files.each { |n| lines << "- [#{n.names.last}](#{href(n.slugs)})" }
        lines << ''
      end

      name = slugs.last || 'index'
      make_page(name, url(slugs), names.last || @root_title, lines.join("\n"), {})
    end

    # --- Utilitaires ----------------------------------------------------

    def make_page(name, permalink, title, content, data)
      page = PageWithoutAFile.new(@site, @site.source, '', "#{name}.md")
      page.content = content
      page.data.merge!(@defaults)
      page.data['title']     = title
      page.data['permalink'] = permalink
      page.data.merge!(data) # le front matter, s'il existe, l'emporte
      page
    end

    def url(slugs)
      slugs.empty? ? "#{@base_url}/" : "#{@base_url}/#{slugs.join('/')}/"
    end

    def href(slugs)
      "#{@site.baseurl}#{url(slugs)}"
    end

    def breadcrumb(names, slugs, link_last:)
      return '' unless @breadcrumb

      items = [[@root_title, []]] + names.each_index.map { |i| [names[i], slugs[0..i]] }
      return '' if items.size == 1 && !link_last

      items.each_with_index.map do |(label, s), i|
        i == items.size - 1 && !link_last ? label : "[#{label}](#{href(s)})"
      end.join(' › ')
    end

    # kramdown ne comprend pas les espaces dans ![](/images/Nom%20Image.png)
    def fix_images(text)
      text.gsub(%r{(!\[[^\]]*\]\()(/images/[^)"]*)\)}) do
        "#{Regexp.last_match(1)}#{Regexp.last_match(2).gsub(' ', '%20')})"
      end
    end
  end
end

À savoir

  • Le libellé vient du title du fichier. Chirpy le remplace par la traduction du fichier de locale s’il en existe une pour ce nom d’onglet, ce qui n’est pas le cas de notes.
  • Je n’ai pas testé l’injection dans le document d’onglet. Si doc.url ne correspond pas, la page d’index sera créée en double : le build affichera alors un avertissement de permalink dupliqué.
  • Un onglet sans ce plugin (lien direct vers un autre site) n’est pas possible dans Chirpy sans modifier _includes/sidebar.html. Ici ce n’est pas nécessaire, puisque /notes/ est une page du site.

Lancement service chirpy

Lancement du service jekyll chirpy, script /usr/local/bin/start-chirpy

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
30
31
32
33
34
35
#!/usr/bin/env bash
set -euo pipefail

SERVICE_NAME="chirpy.service"
PATH_NAME="chirpy-rsync.path"
SITE_DIR="$HOME/chirpy"
JEKYLL_BIN="/home/debjek/gems/bin/jekyll"
RSYNC_SRC="/home/debjek/chirpy/_site/"
RSYNC_DEST="yick@192.168.0.205:/sharenfs/rnmkcy/chirpy/"
SSH_OPTS="-p 55205 -i /home/debjek/.ssh/cwwk-ed25519 -o StrictHostKeyChecking=accept-new"

echo "[$(date '+%F %T')] Passage dans $SITE_DIR"
cd "$SITE_DIR"

export PATH="/home/debjek/gems/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
export GEM_HOME="/home/debjek/gems"
export GEM_PATH="/home/debjek/gems"

echo "[$(date '+%F %T')] Construction: jekyll build"
"$JEKYLL_BIN" build

echo "[$(date '+%F %T')] Démarrage path utilisateur $PATH_NAME"
systemctl --user start "$PATH_NAME" || true

echo "[$(date '+%F %T')] Démarrage service utilisateur $SERVICE_NAME"
systemctl --user start "$SERVICE_NAME" || true

echo "[$(date '+%F %T')] Fin $0"

exit 0

echo "[$(date '+%F %T')] rsync vers $RSYNC_DEST"
echo "rsync -a --delete -e \"ssh $SSH_OPTS\" \"$RSYNC_SRC\" \"$RSYNC_DEST\""
rsync -a --delete -e "ssh $SSH_OPTS" "$RSYNC_SRC" "$RSYNC_DEST"

Le rendre exécutable

1
sudo chmod +x /usr/local/bin/start-chirpy

Exécuter: start-chirpy


Ajout tri ordre alphabétique ou plus récent

Voici la modification : un ordre par défaut réglé dans _config.yml, et deux boutons A → Z / Plus récent sur chaque page d’index pour changer d’avis à la volée.

1. _config.yml

1
2
3
4
5
6
7
8
9
markdown_folders:
  dirs: [_notes]
  base_url: /notes
  root_title: Notes
  breadcrumb: true
  sort: alpha          # ordre initial : alpha | recent
  sort_switch: true    # affiche les boutons de tri sur les index
  defaults:
    layout: page

2. Plugin _plugins/markdown_folders.rb

En haut du fichier, ajouter time :

1
2
3
require 'yaml'
require 'date'
require 'time'

La structure Note reçoit une date :

1
2
    Note = Struct.new(:names, :slugs, :folder_names, :folder_slugs, :index, :page, :date,
                      keyword_init: true)

Dans generate, lire les deux options (après @breadcrumb = ...) :

1
2
      @sort       = cfg['sort'].to_s == 'recent' ? 'recent' : 'alpha'
      @switch     = cfg.fetch('sort_switch', true)

Dans build_note, la date de la note (la dernière ligne remplace l’ancien Note.new) :

1
2
3
4
      page = make_page(name, permalink, title, content, data)
      Note.new(names: names, slugs: slugs, folder_names: folder_names,
               folder_slugs: folder_slugs, index: index, page: page,
               date: note_date(data, path))

collect_folders : chaque dossier prend la date de sa note la plus récente.

1
2
3
4
5
6
7
8
9
10
11
    def collect_folders(notes)
      folders = {}
      notes.each do |n|
        (0..n.folder_slugs.size).each do |i|
          slugs = n.folder_slugs[0...i]
          f = (folders[slugs.join('/')] ||= { names: n.folder_names[0...i], slugs: slugs, date: n.date })
          f[:date] = n.date if n.date > f[:date]
        end
      end
      folders
    end

build_index : remplacer entièrement la méthode.

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
30
    def build_index(folder, notes, folders)
      names = folder[:names]
      slugs = folder[:slugs]
      depth = slugs.size

      subs = folders.values
                    .select { |f| f[:slugs].size == depth + 1 && f[:slugs][0...depth] == slugs }
                    .map { |f| [f[:names].last, href(f[:slugs]), f[:date]] }
      files = notes.reject(&:index)
                   .select { |n| n.folder_slugs == slugs }
                   .map { |n| [n.names.last, href(n.slugs), n.date] }
      subs  = sort_entries(subs)
      files = sort_entries(files)

      lines = []
      crumb = breadcrumb(names, slugs, link_last: false)
      lines << crumb << '' unless crumb.empty?
      lines << sort_switch << '' if @switch && subs.size + files.size > 1

      unless subs.empty?
        lines << '## Dossiers' << '' << html_list(subs) << ''
      end

      unless files.empty?
        lines << '## Notes' << '' << html_list(files) << ''
      end

      name = slugs.last || 'index'
      make_page(name, url(slugs), names.last || @root_title, lines.join("\n"), {})
    end

Nouvelles méthodes, à ajouter dans la section « Utilitaires » :

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
    # Date d'une note : last_modified_at, puis date du front matter, puis mtime du fichier
    def note_date(data, path)
      d = data['last_modified_at'] || data['date']
      case d
      when Time   then d
      when Date   then d.to_time
      when String then (Time.parse(d) rescue File.mtime(path))
      else File.mtime(path)
      end
    end

    # entries = [[libellé, lien, date], ...]
    def sort_entries(entries)
      if @sort == 'recent'
        entries.sort_by { |label, _, date| [-date.to_f, label.downcase] }
      else
        entries.sort_by { |label, _, _| label.downcase }
      end
    end

    def html_list(entries)
      items = entries.map do |label, link, date|
        %(<li data-name="#{CGI.escapeHTML(label.downcase)}" data-date="#{date.to_i}">) +
          %(<a href="#{link}">#{CGI.escapeHTML(label)}</a></li>)
      end
      %(<ul class="md-sortable">\n#{items.join("\n")}\n</ul>)
    end

    def sort_switch
      <<~HTML
        <style>.md-sort button{margin-right:.4rem;padding:.15rem .7rem;border:1px solid var(--main-border-color,#ccc);border-radius:.4rem;background:none;color:inherit;cursor:pointer}.md-sort button.active{font-weight:bold;border-color:currentColor}</style>
        <p class="md-sort">Tri : <button type="button" data-sort="alpha">A → Z</button><button type="button" data-sort="recent">Plus récent</button></p>
        <script>
        (function () {
          var KEY = 'md-sort', mode = '#{@sort}';
          try { mode = localStorage.getItem(KEY) || mode; } catch (e) {}
          function apply(m) {
            document.querySelectorAll('ul.md-sortable').forEach(function (ul) {
              var li = [].slice.call(ul.children);
              li.sort(function (a, b) {
                if (m === 'recent') { var d = b.dataset.date - a.dataset.date; if (d) return d; }
                return a.dataset.name.localeCompare(b.dataset.name);
              });
              li.forEach(function (x) { ul.appendChild(x); });
            });
            document.querySelectorAll('.md-sort button').forEach(function (b) {
              b.classList.toggle('active', b.dataset.sort === m);
            });
            try { localStorage.setItem(KEY, m); } catch (e) {}
          }
          document.querySelectorAll('.md-sort button').forEach(function (b) {
            b.addEventListener('click', function () { apply(b.dataset.sort); });
          });
          apply(mode);
        })();
        </script>
      HTML
    end

3. Le plugin ruby complet _plugins/markdown_folders.rb

Modifier le 30 octobre 2026

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
# frozen_string_literal: true

require 'yaml'
require 'date'
require 'time'

module Jekyll
  # Transforme des dossiers de .md (avec ou sans front matter) en pages,
  # avec une page d'index automatique par dossier et un fil d'Ariane.
  class MarkdownFoldersGenerator < Generator
    safe true
    priority :low

    Note = Struct.new(:names, :slugs, :folder_names, :folder_slugs, :index, :page, :date,
                      keyword_init: true)
    def generate(site)
      cfg         = site.config['markdown_folders'] || {}
      @site       = site
      @base_url   = (cfg['base_url'] || '/notes').chomp('/')
      @defaults   = cfg['defaults'] || { 'layout' => 'page' }
      @root_title = cfg['root_title'] || 'Notes'
      @breadcrumb = cfg.fetch('breadcrumb', true)
      @sort       = cfg['sort'].to_s == 'recent' ? 'recent' : 'alpha'
      @switch     = cfg.fetch('sort_switch', true)

      notes = Array(cfg['dirs'] || '_notes').flat_map { |d| load_dir(d) }
      return if notes.empty?

      folders = collect_folders(notes)
      indexed = notes.select(&:index).map { |n| n.folder_slugs.join('/') }

      pages = notes.map(&:page)
      tabs  = site.collections.values.flat_map(&:docs).to_h { |d| [d.url, d] }

      folders.each do |key, folder|
        next if indexed.include?(key)

        page = build_index(folder, notes, folders)
        if (doc = tabs[page.data['permalink']])
          doc.content = "#{page.content}\n\n#{doc.content}" # l'onglet existant reçoit la liste
        else
          pages << page
        end
      end

      pages.each { |p| site.pages << p }
    end

    private

    # --- Lecture des fichiers -------------------------------------------

    def load_dir(dir)
      root = File.join(@site.source, dir)
      return [] unless Dir.exist?(root)

      Dir.glob(File.join(root, '**', '*.md')).sort.filter_map do |path|
        names = path.sub("#{root}/", '').sub(/\.md\z/, '').split('/')
        next if names.any? { |s| s.start_with?('.') } # .obsidian, .trash…

        build_note(path, names)
      end
    end

    def build_note(path, names)
      raw  = File.read(path, mode: 'r:bom|utf-8')
      data = {}
      body = raw

      if (m = Jekyll::Document::YAML_FRONT_MATTER_REGEXP.match(raw))
        begin
          data = YAML.safe_load(m[1], permitted_classes: [Date, Time]) || {}
          body = m.post_match
        rescue Psych::SyntaxError => e
          Jekyll.logger.warn 'markdown_folders:', "front matter invalide dans #{path} (#{e.message})"
        end
      end

      slugs        = names.map { |s| Utils.slugify(s, mode: 'latin') }
      folder_names = names[0..-2]
      folder_slugs = slugs[0..-2]
      index        = names.last.casecmp?('index')

      crumb   = breadcrumb(folder_names, folder_slugs, link_last: !index)
      content = fix_images(body)
      content = "#{crumb}\n\n#{content}" unless crumb.empty?

      if index
        permalink = url(folder_slugs)
        title     = folder_names.last || @root_title
        name      = folder_slugs.last || 'index'
      else
        permalink = url(slugs)
        title     = names.last
        name      = slugs.last
      end

      page = make_page(name, permalink, title, content, data)
      Note.new(names: names, slugs: slugs, folder_names: folder_names,
               folder_slugs: folder_slugs, index: index, page: page,
               date: note_date(data, path))
      end

    # --- Index de dossiers ----------------------------------------------

    def collect_folders(notes)
      folders = {}
      notes.each do |n|
        (0..n.folder_slugs.size).each do |i|
          slugs = n.folder_slugs[0...i]
          f = (folders[slugs.join('/')] ||= { names: n.folder_names[0...i], slugs: slugs, date: n.date })
          f[:date] = n.date if n.date > f[:date]
        end
      end
      folders
    end

    def build_index(folder, notes, folders)
      names = folder[:names]
      slugs = folder[:slugs]
      depth = slugs.size

      subs = folders.values
                    .select { |f| f[:slugs].size == depth + 1 && f[:slugs][0...depth] == slugs }
                    .map { |f| [f[:names].last, href(f[:slugs]), f[:date]] }
      files = notes.reject(&:index)
                   .select { |n| n.folder_slugs == slugs }
                   .map { |n| [n.names.last, href(n.slugs), n.date] }
      subs  = sort_entries(subs)
      files = sort_entries(files)

      lines = []
      crumb = breadcrumb(names, slugs, link_last: false)
      lines << crumb << '' unless crumb.empty?
      lines << sort_switch << '' if @switch && subs.size + files.size > 1

      unless subs.empty?
        lines << '## Dossiers' << '' << html_list(subs) << ''
      end

      unless files.empty?
        lines << '## Notes' << '' << html_list(files) << ''
      end

      name = slugs.last || 'index'
      make_page(name, url(slugs), names.last || @root_title, lines.join("\n"), {})
    end

    # --- Utilitaires ----------------------------------------------------

    # Date d'une note : last_modified_at, puis date du front matter, puis mtime du fichier
    def note_date(data, path)
      d = data['last_modified_at'] || data['date']
      case d
      when Time   then d
      when Date   then d.to_time
      when String then (Time.parse(d) rescue File.mtime(path))
      else File.mtime(path)
      end
    end

    # entries = [[libellé, lien, date], ...]
    def sort_entries(entries)
      if @sort == 'recent'
        entries.sort_by { |label, _, date| [-date.to_f, label.downcase] }
      else
        entries.sort_by { |label, _, _| label.downcase }
      end
    end

    def html_list(entries)
      items = entries.map do |label, link, date|
        %(<li data-name="#{CGI.escapeHTML(label.downcase)}" data-date="#{date.to_i}">) +
          %(<a href="#{link}">#{CGI.escapeHTML(label)}</a></li>)
      end
      %(<ul class="md-sortable">\n#{items.join("\n")}\n</ul>)
    end

    def sort_switch
      <<~HTML
        <style>.md-sort button{margin-right:.4rem;padding:.15rem .7rem;border:1px solid var(--main-border-color,#ccc);border-radius:.4rem;background:none;color:inherit;cursor:pointer}.md-sort button.active{font-weight:bold;border-color:currentColor}</style>
        <p class="md-sort">Tri : <button type="button" data-sort="alpha">A → Z</button><button type="button" data-sort="recent">Plus récent</button></p>
        <script>
        (function () {
          var KEY = 'md-sort', mode = '#{@sort}';
          try { mode = localStorage.getItem(KEY) || mode; } catch (e) {}
          function apply(m) {
            document.querySelectorAll('ul.md-sortable').forEach(function (ul) {
              var li = [].slice.call(ul.children);
              li.sort(function (a, b) {
                if (m === 'recent') { var d = b.dataset.date - a.dataset.date; if (d) return d; }
                return a.dataset.name.localeCompare(b.dataset.name);
              });
              li.forEach(function (x) { ul.appendChild(x); });
            });
            document.querySelectorAll('.md-sort button').forEach(function (b) {
              b.classList.toggle('active', b.dataset.sort === m);
            });
            try { localStorage.setItem(KEY, m); } catch (e) {}
          }
          document.querySelectorAll('.md-sort button').forEach(function (b) {
            b.addEventListener('click', function () { apply(b.dataset.sort); });
          });
          apply(mode);
        })();
        </script>
      HTML
    end

    def make_page(name, permalink, title, content, data)
      page = PageWithoutAFile.new(@site, @site.source, '', "#{name}.md")
      page.content = content
      page.data.merge!(@defaults)
      page.data['title']     = title
      page.data['permalink'] = permalink
      page.data.merge!(data) # le front matter, s'il existe, l'emporte
      page
    end

    def url(slugs)
      slugs.empty? ? "#{@base_url}/" : "#{@base_url}/#{slugs.join('/')}/"
    end

    def href(slugs)
      "#{@site.baseurl}#{url(slugs)}"
    end

    def breadcrumb(names, slugs, link_last:)
      return '' unless @breadcrumb

      items = [[@root_title, []]] + names.each_index.map { |i| [names[i], slugs[0..i]] }
      return '' if items.size == 1 && !link_last

      items.each_with_index.map do |(label, s), i|
        i == items.size - 1 && !link_last ? label : "[#{label}](#{href(s)})"
      end.join(' › ')
    end

    # kramdown ne comprend pas les espaces dans ![](/images/Nom%20Image.png)
    def fix_images(text)
      text.gsub(%r{(!\[[^\]]*\]\()(/images/[^)"]*)\)}) do
        "#{Regexp.last_match(1)}#{Regexp.last_match(2).gsub(' ', '%20')})"
      end
    end
  end
end

Comportement

  • Modification du 30 octobre 2026, les index sont triés par ordre alphabétique ou du plus récent, au choix.
  • Le tri est fait côté serveur selon sort (l’ordre reste correct sans JavaScript). Les boutons le modifient ensuite dans le navigateur, et le choix est mémorisé (localStorage) pour tous les index.
  • Pour une note, la date utilisée est last_modified_at, sinon date du front matter, sinon la date de modification du fichier. Pour un dossier, c’est la date de sa note la plus récente, sous-dossiers compris.
  • À égalité de date, l’ordre alphabétique départage.
  • Si tu clones le dépôt ou fais un rsync sans -t, la date de modification des fichiers est réinitialisée et le tri « récent » devient inutile pour les notes sans front matter. Dans ce cas, ajoute un date: aux notes, ou préserve les dates (rsync -a les conserve).
  • Mets sort_switch: false pour supprimer les boutons et ne garder que l’ordre du _config.yml.
  • Comme les listes sont maintenant en HTML (nécessaire pour porter les attributs de tri), elles prennent le style ul du contenu Chirpy, qui est identique à celui des listes Markdown.