Chirpy : recherche dans l'article et dans les notes
La recherche de Chirpy (search.json + simple-jekyll-search) est globale : elle ne porte que sur les articles et ne sait pas chercher dans la page ouverte. Voici deux ajouts, sans dépendance :
- Recherche dans l’article ouvert, avec surlignage, compteur et navigation.
- Recherche dans toutes les notes du dossier
_notes(dossier, titre et contenu), avec surlignage dans les résultats puis dans la note ouverte.
Fichiers
| Fichier | Rôle |
|---|---|
_includes/metadata-hook.html |
charge les scripts selon la page |
assets/js/post-search.js |
recherche dans la page ouverte |
assets/js/data/notes-search.json |
index des notes (généré par Jekyll) |
assets/js/notes-search.js |
recherche dans toutes les notes |
1. Charger les scripts
Chirpy appelle _includes/metadata-hook.html dans le <head>. Le fichier est vide par défaut : créez-le à la racine du site.
1
2
3
4
5
6
7
8
{% if page.layout == 'post' or page.url contains '/notes/' %}
<script src="{{ '/assets/js/post-search.js' | relative_url }}" defer></script>
{% endif %}
{% if page.url contains '/notes/' %}
<script src="{{ '/assets/js/notes-search.js' | relative_url }}"
data-json="{{ '/assets/js/data/notes-search.json' | relative_url }}" defer></script>
{% endif %}
L’attribut defer est nécessaire : le script du <head> doit attendre que la page soit construite avant de chercher article .content.
2. Recherche dans la page ouverte
assets/js/post-search.js utilise l’API CSS Custom Highlight, qui surligne sans modifier le DOM.
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
(() => {
const root = document.querySelector('article .content');
if (!root || !window.CSS || !CSS.highlights) return;
const style = document.createElement('style');
style.textContent = `
::highlight(post-search) { background: #ffe066; color: #000; }
::highlight(post-search-current) { background: #ff922b; color: #000; }
#post-search-toggle { position: fixed; right: 1rem; bottom: 5.5rem; z-index: 1000;
width: 2.8rem; height: 2.8rem; border-radius: 50%; cursor: pointer;
display: flex; align-items: center; justify-content: center;
background: var(--main-bg, #fff); color: var(--text-color, #333);
border: 1px solid var(--main-border-color, #ccc);
box-shadow: 0 2px 8px rgba(0,0,0,.2); }
#post-search { position: fixed; left: 1rem; right: 1rem; bottom: 1rem; z-index: 1001;
display: none; gap: .4rem; align-items: center; padding: .4rem .6rem;
background: var(--main-bg, #fff); border: 1px solid var(--main-border-color, #ccc);
border-radius: .5rem; box-shadow: 0 2px 8px rgba(0,0,0,.2); }
#post-search.open { display: flex; }
#post-search input { flex: 1; min-width: 0; }
#post-search button { min-width: 2rem; cursor: pointer; }
@media (min-width: 768px) {
#post-search { left: auto; width: 24rem; }
}
`;
document.head.appendChild(style);
// Bouton flottant
const toggle = document.createElement('button');
toggle.id = 'post-search-toggle';
toggle.type = 'button';
toggle.setAttribute('aria-label', "Rechercher dans l'article");
toggle.innerHTML = '<i class="fas fa-search"></i>';
document.body.appendChild(toggle);
// Barre de recherche
const bar = document.createElement('div');
bar.id = 'post-search';
bar.innerHTML = `<input type="search" placeholder="Rechercher dans l'article…">
<span class="count"></span>
<button type="button" data-d="-1" aria-label="Précédent">↑</button>
<button type="button" data-d="1" aria-label="Suivant">↓</button>
<button type="button" data-close aria-label="Fermer">✕</button>`;
document.body.appendChild(bar);
const input = bar.querySelector('input');
const count = bar.querySelector('.count');
let ranges = [], idx = 0;
const norm = (s) => s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
const collect = (needles) => {
ranges = [];
const ns = needles.filter((w) => w.length >= 2).map(norm);
if (!ns.length) return;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
for (let n; (n = walker.nextNode()); ) {
const raw = n.textContent;
let text = norm(raw);
if (text.length !== raw.length) text = raw.toLowerCase(); // sécurité
ns.forEach((needle) => {
for (let i = text.indexOf(needle); i !== -1; i = text.indexOf(needle, i + needle.length)) {
const r = new Range();
r.setStart(n, i);
r.setEnd(n, i + needle.length);
ranges.push(r);
}
});
}
ranges.sort((a, b) => a.compareBoundaryPoints(Range.START_TO_START, b));
};
const render = (scroll = true) => {
CSS.highlights.set('post-search', new Highlight(...ranges));
if (ranges.length) {
CSS.highlights.set('post-search-current', new Highlight(ranges[idx]));
if (scroll) ranges[idx].startContainer.parentElement.scrollIntoView({ block: 'center' });
} else {
CSS.highlights.delete('post-search-current');
}
count.textContent = ranges.length ? `${idx + 1}/${ranges.length}` : (input.value.length >= 2 ? '0' : '');
};
const step = (d) => {
if (!ranges.length) return;
idx = (idx + d + ranges.length) % ranges.length;
render();
};
const open = () => {
bar.classList.add('open');
toggle.style.display = 'none';
input.focus();
input.select();
};
const close = () => {
bar.classList.remove('open');
toggle.style.display = '';
CSS.highlights.clear();
};
input.addEventListener('input', () => { collect([input.value]); idx = 0; render(); });
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') step(e.shiftKey ? -1 : 1);
if (e.key === 'Escape') close();
});
bar.addEventListener('click', (e) => {
const btn = e.target.closest('button');
if (!btn) return;
if (btn.dataset.d) step(Number(btn.dataset.d));
if ('close' in btn.dataset) close();
});
toggle.addEventListener('click', open);
document.addEventListener('keydown', (e) => {
if (e.ctrlKey && e.shiftKey && e.key.toLowerCase() === 'f') {
e.preventDefault();
open();
}
});
// Arrivée depuis la recherche dans les notes : ?q=mot1 mot2
const q = new URLSearchParams(location.search).get('q');
if (q && q.trim()) {
const run = () => {
bar.classList.add('open');
toggle.style.display = 'none';
input.value = q.trim();
collect(q.trim().split(/\s+/));
idx = 0;
render();
};
if (document.readyState === 'complete') run();
else window.addEventListener('load', run);
}
})();
Utilisation : Ctrl+Maj+F ou la loupe pour ouvrir, Entrée / Maj+Entrée pour naviguer, Échap ou ✕ pour fermer. Ctrl+F reste au navigateur.
3. Indexer les notes
Dans mon cas, _notes n’est pas une collection Jekyll : les fichiers markdown n’ont pas de front matter et sont transformés en pages par un plugin local. Elles se retrouvent donc dans site.pages. Si vous utilisez une vraie collection, remplacez site.pages par site.notes et supprimez la condition p.url contains '/notes/'.
assets/js/data/notes-search.json :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
---
layout: null
---
[
{% assign first = true %}
{% for p in site.pages %}
{% if p.url contains '/notes/' %}
{% unless first %},{% endunless %}
{% assign first = false %}
{
"title": {{ p.title | default: p.name | jsonify }},
"url": {{ p.url | relative_url | jsonify }},
"folder": {{ p.url | remove_first: '/notes/' | jsonify }},
"content": {{ p.content | strip_html | normalize_whitespace | jsonify }}
}
{% endif %}
{% endfor %}
]
Le drapeau first évite une virgule finale, qui rendrait le JSON invalide.
4. Recherche dans toutes les notes
assets/js/notes-search.js charge l’index à la première ouverture, cherche sans tenir compte de la casse ni des accents (tous les mots doivent être présents) et surligne les mots trouvés. Chaque lien transmet la recherche dans ?q=, que post-search.js reprend pour surligner la note ouverte.
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
(() => {
const jsonUrl = (document.currentScript && document.currentScript.dataset.json)
|| '/assets/js/data/notes-search.json';
// Normalisation : minuscules et sans accents
const norm = (s) => s.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '');
const style = document.createElement('style');
style.textContent = `
#notes-search-toggle { position: fixed; right: 1rem; bottom: 9.5rem; z-index: 1000;
width: 2.8rem; height: 2.8rem; border-radius: 50%; cursor: pointer;
display: flex; align-items: center; justify-content: center;
background: var(--main-bg, #fff); color: var(--text-color, #333);
border: 1px solid var(--main-border-color, #ccc);
box-shadow: 0 2px 8px rgba(0,0,0,.2); }
#notes-search { position: fixed; left: 1rem; right: 1rem; bottom: 1rem; z-index: 1002;
display: none; flex-direction: column; gap: .5rem; padding: .6rem;
max-height: 70vh; background: var(--main-bg, #fff); color: var(--text-color, #333);
border: 1px solid var(--main-border-color, #ccc); border-radius: .5rem;
box-shadow: 0 2px 8px rgba(0,0,0,.2); }
#notes-search.open { display: flex; }
#notes-search .row { display: flex; gap: .4rem; align-items: center; }
#notes-search input { flex: 1; min-width: 0; }
#notes-search ul { list-style: none; margin: 0; padding: 0; overflow-y: auto; }
#notes-search li { padding: .4rem 0; border-top: 1px solid var(--main-border-color, #ddd); }
#notes-search li small { display: block; opacity: .7; }
#notes-search li .snip { font-size: .85rem; }
#notes-search mark { background: #ffe066; color: #000; padding: 0 .1em; border-radius: 2px; }
@media (min-width: 768px) { #notes-search { left: auto; width: 32rem; } }
`;
document.head.appendChild(style);
const toggle = document.createElement('button');
toggle.id = 'notes-search-toggle';
toggle.type = 'button';
toggle.title = 'Rechercher dans toutes les notes';
toggle.setAttribute('aria-label', 'Rechercher dans toutes les notes');
toggle.innerHTML = '<i class="fas fa-book"></i>';
document.body.appendChild(toggle);
const panel = document.createElement('div');
panel.id = 'notes-search';
panel.innerHTML = `<div class="row">
<input type="search" placeholder="Rechercher dans les notes…">
<button type="button" aria-label="Fermer">✕</button>
</div><ul></ul>`;
document.body.appendChild(panel);
const input = panel.querySelector('input');
const list = panel.querySelector('ul');
let data = null;
const load = async () => {
if (data) return;
try {
const res = await fetch(jsonUrl);
data = (await res.json()).map((n) => ({
...n,
hay: norm(n.title + ' ' + n.folder + ' ' + n.content),
nc: norm(n.content),
}));
} catch (e) {
data = [];
list.textContent = 'Index des notes introuvable.';
}
};
// Extrait centré sur le premier mot présent dans le texte
const snippet = (n, words) => {
const w = words.find((x) => n.nc.includes(x));
if (!w) return n.content.slice(0, 120);
const i = n.nc.indexOf(w);
const a = Math.max(0, i - 50);
return (a ? '…' : '') + n.content.slice(a, i + 90) + '…';
};
// Dossier parent : "a/b/note/" -> "a / b", racine -> "Notes"
const parentFolder = (folder) =>
folder.replace(/\/$/, '').split('/').slice(0, -1).join(' / ') || 'Notes';
// Ajoute `text` à `el` en entourant les mots trouvés d'un <mark>
const appendHighlighted = (el, text, words) => {
const t = norm(text);
if (t.length !== text.length) { el.textContent = text; return; } // sécurité
const ranges = [];
words.forEach((w) => {
for (let i = t.indexOf(w); i !== -1; i = t.indexOf(w, i + w.length)) {
ranges.push([i, i + w.length]);
}
});
ranges.sort((a, b) => a[0] - b[0]);
const merged = [];
ranges.forEach((r) => {
const last = merged[merged.length - 1];
if (last && r[0] <= last[1]) last[1] = Math.max(last[1], r[1]);
else merged.push([r[0], r[1]]);
});
let pos = 0;
merged.forEach(([s, e]) => {
if (s > pos) el.append(text.slice(pos, s));
const m = document.createElement('mark');
m.textContent = text.slice(s, e);
el.append(m);
pos = e;
});
if (pos < text.length) el.append(text.slice(pos));
};
const search = () => {
list.textContent = '';
const words = norm(input.value).split(/\s+/).filter((w) => w.length >= 2);
if (!words.length || !data) return;
const hits = data.filter((n) => words.every((w) => n.hay.includes(w)));
if (!hits.length) { list.textContent = 'Aucun résultat.'; return; }
hits.forEach((n) => {
const li = document.createElement('li');
const a = document.createElement('a');
a.href = n.url + '?q=' + encodeURIComponent(input.value.trim());
appendHighlighted(a, n.title, words);
const f = document.createElement('small');
appendHighlighted(f, parentFolder(n.folder), words);
const s = document.createElement('div');
s.className = 'snip';
appendHighlighted(s, snippet(n, words), words);
li.append(a, f, s);
list.appendChild(li);
});
};
const open = async () => {
panel.classList.add('open');
toggle.style.display = 'none';
input.focus();
await load();
search();
};
const close = () => {
panel.classList.remove('open');
toggle.style.display = '';
};
toggle.addEventListener('click', open);
panel.querySelector('button').addEventListener('click', close);
input.addEventListener('input', search);
input.addEventListener('keydown', (e) => { if (e.key === 'Escape') close(); });
})();
Résultat
- Sur un article : loupe (et
Ctrl+Maj+F) pour chercher dans la page. - Sur une note : en plus, un bouton livre qui cherche dans toutes les notes (dossier, titre, contenu), plusieurs mots combinés, sans tenir compte des accents.
- Un clic sur un résultat ouvre la note avec les mots déjà surlignés.
Dépannage
- Pas de bouton : vérifier avec
Ctrl+Uque la balise<script>est dans la page, et ouvrir l’URL du script directement (une 404 signifie un mauvais emplacement ou un site non régénéré). - Résultats vides : ouvrir
/assets/js/data/notes-search.json; un[]signifie que les notes ne sont pas danssite.pages(ou que la collection n’est pas déclarée). - Ancien comportement après modification : rechargement forcé (
Ctrl+Maj+R), et désactiver le service worker de la PWA sipwa.enabled: true. - Loupe sans effet sur une page : le sélecteur
article .contentne correspond pas à ce layout, à adapter danspost-search.js.