Compare commits
3 Commits
main
...
208a873d80
| Author | SHA1 | Date | |
|---|---|---|---|
| 208a873d80 | |||
| 5e00c0c7c4 | |||
| 7f614688d0 |
496
assets/js/search-page.js
Normal file
496
assets/js/search-page.js
Normal file
@@ -0,0 +1,496 @@
|
|||||||
|
"use strict";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lit la configuration exposee par le template Hugo.
|
||||||
|
* @param {HTMLElement} root Element principal de la page.
|
||||||
|
* @returns {{endpoint: string, indexUid: string, apiKey: string, limit: number, queryParam: string}}
|
||||||
|
*/
|
||||||
|
function readSearchConfig(root) {
|
||||||
|
const endpoint = root.dataset.searchEndpoint;
|
||||||
|
const indexUid = root.dataset.searchIndex;
|
||||||
|
const apiKey = root.dataset.searchApiKey;
|
||||||
|
const limitValue = root.dataset.searchLimit;
|
||||||
|
const queryParam = root.dataset.searchParam;
|
||||||
|
|
||||||
|
if (typeof endpoint !== "string" || endpoint.length === 0) {
|
||||||
|
throw new Error("Missing search endpoint");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof indexUid !== "string" || indexUid.length === 0) {
|
||||||
|
throw new Error("Missing search index");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof apiKey !== "string") {
|
||||||
|
throw new Error("Invalid search api key");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof limitValue !== "string" || limitValue.length === 0) {
|
||||||
|
throw new Error("Missing search limit");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof queryParam !== "string" || queryParam.length === 0) {
|
||||||
|
throw new Error("Missing search query parameter");
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = Number.parseInt(limitValue, 10);
|
||||||
|
if (Number.isNaN(limit) || limit < 1) {
|
||||||
|
throw new Error("Invalid search limit");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
endpoint,
|
||||||
|
indexUid,
|
||||||
|
apiKey,
|
||||||
|
limit,
|
||||||
|
queryParam,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lit la requete de recherche depuis l'URL.
|
||||||
|
* @param {string} queryParam Nom du parametre d'URL.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function readSearchQuery(queryParam) {
|
||||||
|
const params = new URLSearchParams(window.location.search);
|
||||||
|
const rawValue = params.get(queryParam);
|
||||||
|
|
||||||
|
if (rawValue === null) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawValue.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit le payload envoye a Meilisearch.
|
||||||
|
* @param {string} query Texte recherche.
|
||||||
|
* @param {number} limit Nombre de resultats par lot.
|
||||||
|
* @param {number} offset Position de depart.
|
||||||
|
* @returns {{q: string, limit: number, offset: number, attributesToRetrieve: string[]}}
|
||||||
|
*/
|
||||||
|
function buildSearchPayload(query, limit, offset) {
|
||||||
|
return {
|
||||||
|
q: query,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
attributesToRetrieve: ["title", "path", "published_at"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envoie une requete HTTP a Meilisearch pour un lot de resultats.
|
||||||
|
* @param {{endpoint: string, indexUid: string, apiKey: string, limit: number}} config Configuration de recherche.
|
||||||
|
* @param {string} query Texte recherche.
|
||||||
|
* @param {number} offset Position de depart.
|
||||||
|
* @returns {Promise<{ok: boolean, status: number, data: any}>}
|
||||||
|
*/
|
||||||
|
async function fetchSearchChunk(config, query, offset) {
|
||||||
|
const endpoint = config.endpoint.replace(/\/+$/, "");
|
||||||
|
const requestURL = endpoint + "/indexes/" + encodeURIComponent(config.indexUid) + "/search";
|
||||||
|
const headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (config.apiKey.length > 0) {
|
||||||
|
headers.Authorization = "Bearer " + config.apiKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(requestURL, {
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(buildSearchPayload(query, config.limit, offset)),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok === false) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: response.status,
|
||||||
|
data: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: response.status,
|
||||||
|
data,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Valide et normalise le payload brut de Meilisearch.
|
||||||
|
* @param {any} payload Donnees brutes.
|
||||||
|
* @returns {{hits: any[], total: number}}
|
||||||
|
*/
|
||||||
|
function parseSearchPayload(payload) {
|
||||||
|
if (typeof payload !== "object" || payload === null) {
|
||||||
|
throw new Error("Invalid search payload");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(payload.hits)) {
|
||||||
|
throw new Error("Invalid hits payload");
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = Number.parseInt(String(payload.estimatedTotalHits), 10);
|
||||||
|
if (Number.isNaN(total)) {
|
||||||
|
throw new Error("Invalid total hits value");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
hits: payload.hits,
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vide tous les enfants d'un noeud.
|
||||||
|
* @param {HTMLElement} node Noeud cible.
|
||||||
|
*/
|
||||||
|
function clearNode(node) {
|
||||||
|
while (node.firstChild) {
|
||||||
|
node.firstChild.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Met a jour le message de statut.
|
||||||
|
* @param {HTMLElement} statusNode Zone de statut.
|
||||||
|
* @param {string} message Message a afficher.
|
||||||
|
*/
|
||||||
|
function updateStatus(statusNode, message) {
|
||||||
|
statusNode.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Affiche ou masque une section.
|
||||||
|
* @param {HTMLElement} section Section cible.
|
||||||
|
* @param {boolean} visible Etat de visibilite.
|
||||||
|
*/
|
||||||
|
function setSectionVisibility(section, visible) {
|
||||||
|
if (visible) {
|
||||||
|
section.removeAttribute("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
section.setAttribute("hidden", "hidden");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convertit un hit brut en resultat exploitable.
|
||||||
|
* @param {any} hit Resultat brut.
|
||||||
|
* @returns {{title: string, path: string, published_at: string} | null}
|
||||||
|
*/
|
||||||
|
function normalizeHit(hit) {
|
||||||
|
if (typeof hit !== "object" || hit === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof hit.path !== "string" || hit.path.trim().length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let title = hit.path.trim();
|
||||||
|
if (typeof hit.title === "string" && hit.title.trim().length > 0) {
|
||||||
|
title = hit.title.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
let publishedAt = "";
|
||||||
|
if (typeof hit.published_at === "string" && hit.published_at.trim().length > 0) {
|
||||||
|
publishedAt = hit.published_at.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
path: hit.path.trim(),
|
||||||
|
published_at: publishedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formate la date en ignorant l'heure de publication.
|
||||||
|
* @param {unknown} rawDate Date brute.
|
||||||
|
* @returns {{datetime: string, display: string}}
|
||||||
|
*/
|
||||||
|
function buildDateInfo(rawDate) {
|
||||||
|
if (typeof rawDate !== "string") {
|
||||||
|
return {
|
||||||
|
datetime: "",
|
||||||
|
display: "Date inconnue",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = rawDate.trim().match(/^(\d{4})-(\d{2})-(\d{2})(?:\s+\d{2}:\d{2}(?::\d{2})?)?$/);
|
||||||
|
if (match === null) {
|
||||||
|
return {
|
||||||
|
datetime: "",
|
||||||
|
display: "Date inconnue",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const year = match[1];
|
||||||
|
const month = match[2];
|
||||||
|
const day = match[3];
|
||||||
|
|
||||||
|
return {
|
||||||
|
datetime: year + "-" + month + "-" + day,
|
||||||
|
display: day + "/" + month + "/" + year,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extrait un libelle de section a partir du chemin Hugo.
|
||||||
|
* @param {string} path Chemin relatif de l'article.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function extractSectionLabel(path) {
|
||||||
|
const segments = path.split("/").filter(Boolean);
|
||||||
|
if (segments.length === 0) {
|
||||||
|
return "Section inconnue";
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstSegment = segments[0];
|
||||||
|
if (firstSegment === "collections") {
|
||||||
|
return "Collections";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstSegment === "critiques") {
|
||||||
|
return "Critiques";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstSegment === "interets") {
|
||||||
|
return "Intérêts";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstSegment === "stats") {
|
||||||
|
return "Statistiques";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstSegment === "taxonomies") {
|
||||||
|
return "Taxonomies";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstSegment === "contact") {
|
||||||
|
return "Contact";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstSegment === "manifeste") {
|
||||||
|
return "Manifeste";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstSegment === "liens-morts") {
|
||||||
|
return "Liens morts";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstSegment === "revue-de-presse") {
|
||||||
|
return "Revue de presse";
|
||||||
|
}
|
||||||
|
|
||||||
|
return firstSegment;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Construit un article de resultat simple.
|
||||||
|
* @param {{title: string, path: string, published_at?: string}} hit Resultat.
|
||||||
|
* @returns {HTMLElement}
|
||||||
|
*/
|
||||||
|
function createResultArticle(hit) {
|
||||||
|
const article = document.createElement("article");
|
||||||
|
|
||||||
|
const title = document.createElement("h3");
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = hit.path;
|
||||||
|
link.textContent = hit.title;
|
||||||
|
title.append(link);
|
||||||
|
article.append(title);
|
||||||
|
|
||||||
|
const meta = document.createElement("p");
|
||||||
|
meta.className = "search-result-meta";
|
||||||
|
|
||||||
|
const dateInfo = buildDateInfo(hit.published_at);
|
||||||
|
if (dateInfo.datetime.length > 0) {
|
||||||
|
const time = document.createElement("time");
|
||||||
|
time.dateTime = dateInfo.datetime;
|
||||||
|
time.textContent = dateInfo.display;
|
||||||
|
meta.append(time);
|
||||||
|
} else {
|
||||||
|
const dateText = document.createElement("span");
|
||||||
|
dateText.textContent = dateInfo.display;
|
||||||
|
meta.append(dateText);
|
||||||
|
}
|
||||||
|
|
||||||
|
const separator = document.createElement("span");
|
||||||
|
separator.textContent = "·";
|
||||||
|
meta.append(separator);
|
||||||
|
|
||||||
|
const section = document.createElement("span");
|
||||||
|
section.textContent = extractSectionLabel(hit.path);
|
||||||
|
meta.append(section);
|
||||||
|
|
||||||
|
article.append(meta);
|
||||||
|
|
||||||
|
const path = document.createElement("p");
|
||||||
|
path.className = "search-result-path";
|
||||||
|
path.textContent = hit.path;
|
||||||
|
article.append(path);
|
||||||
|
|
||||||
|
return article;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rend la liste des resultats.
|
||||||
|
* @param {HTMLOListElement} listNode Liste cible.
|
||||||
|
* @param {Array<{title: string, path: string, published_at?: string}>} hits Resultats a afficher.
|
||||||
|
*/
|
||||||
|
function renderListing(listNode, hits) {
|
||||||
|
clearNode(listNode);
|
||||||
|
|
||||||
|
for (const hit of hits) {
|
||||||
|
const item = document.createElement("li");
|
||||||
|
item.append(createResultArticle(hit));
|
||||||
|
listNode.append(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recupere tous les resultats disponibles en enchainant les pages.
|
||||||
|
* @param {{endpoint: string, indexUid: string, apiKey: string, limit: number}} config Configuration de recherche.
|
||||||
|
* @param {string} query Texte recherche.
|
||||||
|
* @returns {Promise<{ok: boolean, status: number, data: {hits: Array<{title: string, path: string, published_at: string}>, estimatedTotalHits: number} | null}>}
|
||||||
|
*/
|
||||||
|
async function fetchAllSearchResults(config, query) {
|
||||||
|
const allHits = [];
|
||||||
|
const seenPaths = new Set();
|
||||||
|
let expectedTotal = 0;
|
||||||
|
let offset = 0;
|
||||||
|
let lastStatus = 200;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const chunkResponse = await fetchSearchChunk(config, query, offset);
|
||||||
|
lastStatus = chunkResponse.status;
|
||||||
|
|
||||||
|
if (chunkResponse.ok === false) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: chunkResponse.status,
|
||||||
|
data: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseSearchPayload(chunkResponse.data);
|
||||||
|
expectedTotal = parsed.total;
|
||||||
|
|
||||||
|
if (parsed.hits.length === 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rawHit of parsed.hits) {
|
||||||
|
const normalized = normalizeHit(rawHit);
|
||||||
|
if (normalized === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seenPaths.has(normalized.path)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
seenPaths.add(normalized.path);
|
||||||
|
allHits.push(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
offset += parsed.hits.length;
|
||||||
|
|
||||||
|
if (offset >= expectedTotal) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.hits.length < config.limit) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
status: lastStatus,
|
||||||
|
data: {
|
||||||
|
hits: allHits,
|
||||||
|
estimatedTotalHits: expectedTotal,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lance la recherche au chargement de la page.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function initSearchPage() {
|
||||||
|
const root = document.querySelector("main.search-page");
|
||||||
|
if (!(root instanceof HTMLElement)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = root.querySelector("[data-search-status]");
|
||||||
|
const listingSection = root.querySelector("[data-search-listing]");
|
||||||
|
const results = root.querySelector("[data-search-results]");
|
||||||
|
|
||||||
|
if (!(status instanceof HTMLElement)) {
|
||||||
|
throw new Error("Missing search status");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(listingSection instanceof HTMLElement)) {
|
||||||
|
throw new Error("Missing listing section");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(results instanceof HTMLOListElement)) {
|
||||||
|
throw new Error("Missing results list");
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = readSearchConfig(root);
|
||||||
|
const query = readSearchQuery(config.queryParam);
|
||||||
|
|
||||||
|
const headerInput = document.getElementById("header-search-input");
|
||||||
|
if (headerInput instanceof HTMLInputElement) {
|
||||||
|
headerInput.value = query;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query.length === 0) {
|
||||||
|
updateStatus(status, "Utilisez le champ de recherche dans l'en-tete.");
|
||||||
|
setSectionVisibility(listingSection, false);
|
||||||
|
clearNode(results);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus(status, "Recherche en cours...");
|
||||||
|
|
||||||
|
const response = await fetchAllSearchResults(config, query);
|
||||||
|
if (response.ok === false) {
|
||||||
|
updateStatus(status, "Erreur Meilisearch (HTTP " + String(response.status) + ").");
|
||||||
|
setSectionVisibility(listingSection, false);
|
||||||
|
clearNode(results);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = parseSearchPayload(response.data);
|
||||||
|
const totalHits = payload.total;
|
||||||
|
const displayedHits = payload.hits.length;
|
||||||
|
|
||||||
|
if (totalHits === 0 || displayedHits === 0) {
|
||||||
|
updateStatus(status, "Aucun resultat.");
|
||||||
|
setSectionVisibility(listingSection, false);
|
||||||
|
clearNode(results);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderListing(results, payload.hits);
|
||||||
|
setSectionVisibility(listingSection, true);
|
||||||
|
|
||||||
|
if (totalHits > displayedHits) {
|
||||||
|
const message = String(totalHits) + " resultats trouves. " + String(displayedHits) + " affiches. Affinez votre recherche.";
|
||||||
|
updateStatus(status, message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStatus(status, String(totalHits) + " resultats.");
|
||||||
|
}
|
||||||
|
|
||||||
|
void initSearchPage();
|
||||||
@@ -11,6 +11,10 @@ main:
|
|||||||
title: Restez au courant de mes publications grâce à mon flux RSS
|
title: Restez au courant de mes publications grâce à mon flux RSS
|
||||||
pageRef: /index.xml
|
pageRef: /index.xml
|
||||||
parent: Accueil
|
parent: Accueil
|
||||||
|
- name: IRC
|
||||||
|
title: Venez discuter en direct avec moi
|
||||||
|
pageRef: https://irc.dern.ovh
|
||||||
|
parent: Accueil
|
||||||
- name: Taxonomies
|
- name: Taxonomies
|
||||||
title: "Classification de mes articles"
|
title: "Classification de mes articles"
|
||||||
pageRef: /taxonomies/
|
pageRef: /taxonomies/
|
||||||
|
|||||||
@@ -3,3 +3,11 @@ favicon: /favicon.png
|
|||||||
# Et les placer dans /assets et non dans /static
|
# Et les placer dans /assets et non dans /static
|
||||||
logo: logo-large.png
|
logo: logo-large.png
|
||||||
description: "et ses opinions impopulaires"
|
description: "et ses opinions impopulaires"
|
||||||
|
search:
|
||||||
|
action: /recherche/
|
||||||
|
param: q
|
||||||
|
meilisearch:
|
||||||
|
endpoint: /api/search
|
||||||
|
indexUid: blog_posts
|
||||||
|
apiKey: "cf49bcdb1b08e5c502c41956d38ce0be80d5e79e9f084e6f15f4485f87d63c30"
|
||||||
|
hitsPerPage: 20
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ Si vous m'envoyez un email depuis une adresse hébergée par l'un ou l'autre, il
|
|||||||
|
|
||||||
Si vous avez choisi une adresse hébergée par Microsoft ou Google (et probablement d'autres fournisseurs tiers), vous devez changer pour un fournisseur de messagerie respectueux de la [neutralité du Net](https://fr.wikipedia.org/wiki/Neutralité_du_réseau).
|
Si vous avez choisi une adresse hébergée par Microsoft ou Google (et probablement d'autres fournisseurs tiers), vous devez changer pour un fournisseur de messagerie respectueux de la [neutralité du Net](https://fr.wikipedia.org/wiki/Neutralité_du_réseau).
|
||||||
|
|
||||||
|
### Messagerie instantanée
|
||||||
|
|
||||||
|
Pour me contacter plus rapidement, vous pouvez me trouver sur IRC sous le pseudo "Richard", sur le serveur `irc.dern.ovh:6697`, dans le salon `#lounge`, ou en cliquant sur [ce lien](https://irc.dern.ovh/).
|
||||||
|
Indiquez simplement le "_Nick_" de votre choix avant de vous connecter.
|
||||||
|
|
||||||
## Montrer votre ❤️
|
## Montrer votre ❤️
|
||||||
|
|
||||||
Si vous aimez ce que je fais, et que vous souhaitez faire quelque chose pour moi, j'ai plusieurs options à vous proposer.
|
Si vous aimez ce que je fais, et que vous souhaitez faire quelque chose pour moi, j'ai plusieurs options à vous proposer.
|
||||||
@@ -39,4 +44,4 @@ Si vous aimez ce que je fais, et que vous souhaitez faire quelque chose pour moi
|
|||||||
- Vous pouvez m'offrir un jeu de ma [wishlist Steam](https://store.steampowered.com/wishlist/id/richarddern/#sort=order) ou [Gog](https://www.gog.com/fr/u/RichardDern/wishlist)
|
- Vous pouvez m'offrir un jeu de ma [wishlist Steam](https://store.steampowered.com/wishlist/id/richarddern/#sort=order) ou [Gog](https://www.gog.com/fr/u/RichardDern/wishlist)
|
||||||
- Vous pouvez m'offrir un produit de ma [wishlist Amazon](https://www.amazon.fr/hz/wishlist/ls/24XQEFC7L3GQB)
|
- Vous pouvez m'offrir un produit de ma [wishlist Amazon](https://www.amazon.fr/hz/wishlist/ls/24XQEFC7L3GQB)
|
||||||
|
|
||||||
Vous pouvez également acheter mes livres :
|
Vous pouvez également acheter mes livres :
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#title: ""
|
||||||
|
#attribution: ""
|
||||||
|
description: "J'ai du mal à me décider : on a évolué un peu ou pas du tout ?"
|
||||||
|
#prompt: ""
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#title: ""
|
||||||
|
#attribution: ""
|
||||||
|
# description: ""
|
||||||
|
#prompt: ""
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#title: ""
|
||||||
|
attribution: "ChatGPT 5.2"
|
||||||
|
description: "L'artisan persiste à vouloir exercer son métier dans un monde qui l'a rendu obsolète."
|
||||||
|
prompt: "The image shows an experienced farrier, kneeling on the pavement by a modern city garage at dusk. He is focused on fitting a horseshoe to the hoof of a dark brown horse while his worn gear and a toolbox rest beside him, set against the bustling urban backdrop of reflective buildings, cars, pedestrians, and illuminated billboards."
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#title: ""
|
||||||
|
#attribution: ""
|
||||||
|
description: "L'interface de connexion de _The Lounge_."
|
||||||
|
#prompt: ""
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
#title: ""
|
||||||
|
#attribution: ""
|
||||||
|
description: "Je sais, c'est choquant."
|
||||||
|
#prompt: ""
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 47 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.6 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 476 KiB |
171
content/interets/divers/2026/02/24/nouveautes-du-blog/index.md
Normal file
171
content/interets/divers/2026/02/24/nouveautes-du-blog/index.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
---
|
||||||
|
title: Nouveautés du blog
|
||||||
|
date: "2026-02-24 00:15:30"
|
||||||
|
cover: images/cover.png
|
||||||
|
tags:
|
||||||
|
- Vibe coding
|
||||||
|
- Intelligence Artificielle
|
||||||
|
- Design
|
||||||
|
- Blog
|
||||||
|
- IRC
|
||||||
|
- Nostalgeek
|
||||||
|
entreprises:
|
||||||
|
- OpenAI
|
||||||
|
weather:
|
||||||
|
temperature: 8.88888888888889
|
||||||
|
humidity: 99
|
||||||
|
pressure: 1022.01209165491
|
||||||
|
precipitations: false
|
||||||
|
wind_speed: 4.1842944
|
||||||
|
wind_direction: 232
|
||||||
|
illuminance: 0
|
||||||
|
source:
|
||||||
|
- influxdb
|
||||||
|
comments_url: https://com.richard-dern.fr/post/476
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nouveau design
|
||||||
|
|
||||||
|
Comme presque chaque année depuis 2021, j'ai créé un nouveau design pour le blog.
|
||||||
|
Il est toujours **totalement dépourvu de javascript**, le CSS fait toujours moins de 15ko, et la page d'accueil pèse un peu moins de 1.5Mo.
|
||||||
|
Je suis plutôt content du résultat, surtout que mes blocs "spotlight" ("À la une" et les critiques sur la page d'accueil) fonctionnent plutôt bien et apportent une dynamique que j'estime bienvenue.
|
||||||
|
|
||||||
|
La différence par rapport aux années passées est que je me suis laissé tenter par le [_vibe coding_](https://fr.wikipedia.org/wiki/Vibe_coding).
|
||||||
|
Mais avant de fermer l'onglet de votre navigateur et de vous dire que plus jamais vous ne visiterez mon site parce que je suis un suppôt de Satan, laissez-moi une chance de m'exprimer.
|
||||||
|
|
||||||
|
Il y a fort longtemps, avant l'avènement des méthodes dites "[agiles](https://fr.wikipedia.org/wiki/Méthode_agile)" et leur exploitation massive (avant la fin des années 2000), les développeurs avaient une autre façon de travailler : ils rédigeaient **un cahier des charges**.
|
||||||
|
Les développeurs étaient au contact du client qui exprimait son besoin.
|
||||||
|
Leur rôle était de traduire la pensée humaine abstraite du client en une application mathématique concrète.
|
||||||
|
Essayez de vous souvenir : c'était l'époque où, lorsqu'un éditeur d'application ou de jeux-vidéo sortait un nouveau produit, les clients n'avaient pas forcément un Internet haut-débit pour télécharger des mises à jour régulières.
|
||||||
|
C'était l'époque où, quand un produit était commercialisé, il était réputé **fini**.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Lorsque "tout le monde" informatique s'est mis aux méthodes agiles, on ne faisait plus de cahier des charges.
|
||||||
|
On faisait des _specs_, courtes et ciblées, que les commerciaux ou le service après-vente (ou autre dénomination élogieuse) communiquaient aux développeurs.
|
||||||
|
Ces derniers furent isolés du client final, et perdaient au passage la vision d'ensemble du produit.
|
||||||
|
Le client fut alors mis dans une position où toute modification de son idée initiale devait faire l'objet d'un ticket, puis d'un sprint.
|
||||||
|
Plus personne ne réfléchit au **projet**, mais on avait tous gagné en "agilité".
|
||||||
|
|
||||||
|
Lorsque le client a besoin d'une nouvelle fonctionnalité (ou décide qu'il veut décaler une image d'un pixel vers la gauche), il déclenche en réalité une longue procédure (simplifiée ici) :
|
||||||
|
|
||||||
|
- Le client interpelle le prestataire.
|
||||||
|
- Le prestataire envoie la demande au chef de projet.
|
||||||
|
- Le chef de projet chiffre la demande en moyens humains, financiers et temporels.
|
||||||
|
- Le prestataire compile ces informations et voit avec la direction des finances.
|
||||||
|
- Les finances approuvent ou rejettent la demande.
|
||||||
|
- On demande au chef de projet de faire des compromis.
|
||||||
|
- Le chef de projet crée un ticket, qui n'est **pas** la demande initiale du client, mais un **compromis** de cette demande.
|
||||||
|
- Les développeurs sont affectés au ticket.
|
||||||
|
- Les développeurs soumettent le code "approprié".
|
||||||
|
- Les modifications passent par une batterie de tests automatisés.
|
||||||
|
- On déploie une infrastructure de tests que l'on est censés imaginer aussi proche que possible de l'infra de déploiement, sauf que personne ne la connait.
|
||||||
|
- On affecte des membres du service support aux tests manuels.
|
||||||
|
- On déploie le patch en production.
|
||||||
|
- Ça ne convient pas au client : on recommence.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
L'Intelligence Artificielle est ensuite venue combler le déficit évident de cette "méthode" : le client demande directement à l'IA de faire ce qu'il veut.
|
||||||
|
Si le résultat n'est pas à la hauteur de ce qu'il voulait, le client ne peut s'en prendre qu'à lui-même : c'est lui qui s'est mal exprimé.
|
||||||
|
Il reformule alors, corrige, modélise sa propre application avec l'aide de l'IA.
|
||||||
|
C'est ce que proposent désormais [Wix](https://fr.wix.com/ai-website-builder) ou [Wordpress](https://wordpress.com/fr/createur-de-site-web-ia/), pour ne citer qu'eux.
|
||||||
|
|
||||||
|
Il est loin le temps où le client savait ce qu'il voulait, où il était capable d'aligner sa vision dans un document complet, conjointement avec les développeurs grâce à un lien social, et où le livrable était considéré comme **définitif**.
|
||||||
|
|
||||||
|
Je n'ai jamais adhéré à ce schéma.
|
||||||
|
C'est peut-être pour ça que j'ai connu tant d'échecs professionnels : parce que j'avais l'intuition que cela allait nous conduire, vingt ans plus tard, à l'[extinction du métier de développeur](/interets/liens-interessants/2026/02/09/28a32442/), alors que [j'espérais pendant longtemps](/interets/informatique/2023/02/10/faire-du-developpement-un-artisanat/) qu'on en ferait un artisanat, dans le sens noble du terme.
|
||||||
|
|
||||||
|
Donc, si je ne suis plus le développeur, et que je ne suis pas celui qui vend un produit, je suis forcément le client.
|
||||||
|
Mieux : je suis un client exigeant qui sait exactement ce qu'il veut et qui est capable de le formaliser dans un cahier des charges exhaustif.
|
||||||
|
|
||||||
|
J'ai rédigé mon cahier des charges pour mon nouveau design au poil de cul.
|
||||||
|
Tout y était, tout ce que je voulais, à l'exception des mockups que j'ai remplacé par des descriptions textuelles claires et complètes parce que je n'ai aucun talent artistique.
|
||||||
|
Et au moment de me lancer dans l'implémentation de ce cahier des charges, mon cerveau a gelé.
|
||||||
|
Le burn-out, tapis dans l'ombre, et la dépression temporairement mise en sourdine, tout cela s'est ranimé d'un coup, en même temps qu'une épiphanie : si je ne peux plus coder, mais que je peux encore écrire un cahier des charges, alors je vais laisser [ChatGPT](https://chatgpt.com/) "exécuter ma vision".
|
||||||
|
|
||||||
|
Et le fait est que ChatGPT s'est bien débrouillé.
|
||||||
|
Le code était immonde, pas optimisé, avec des effets de bord monstrueux que je n'aurais jamais pu tolérer dans ma carrière, mais le résultat visuel et fonctionnel était conforme à mes exigences.
|
||||||
|
Ça a durement mis à l'épreuve ma capacité – virtuellement inexistante – à lâcher prise.
|
||||||
|
Pourtant, il m'a suffi de quelques instructions supplémentaires dans un fichier [`AGENTS.md`](https://agents.md) pour corriger le tir et forcer ChatGPT à refactoriser et produire un résultat un peu meilleur.
|
||||||
|
À ce jour, tout n'est pas parfait, des optimisations sont encore possibles, et c'est une bonne chose : je ne suis plus capable de développer, mais je suis parfaitement capable d'expliquer à une IA comment travailler correctement.
|
||||||
|
Et les [skills](https://developers.openai.com/cookbook/examples/skills_in_api) devraient faire partie de ma boîte à outils dans un avenir proche.
|
||||||
|
|
||||||
|
Au final, ce n'est pas vraiment du _vibe coding_ _stricto sensu_.
|
||||||
|
C'est de la modélisation logicielle par LLM, mais au lieu de partir de petites specs "au feeling" ([à l'arrache...](https://www.la-rache.com/)), je pars sur un cahier des charges dûment rempli, comme au bon vieux temps.
|
||||||
|
Encore une fois, ce n'est pas parfait, mais je suis satisfait dans la mesure où j'ai avancé sans avoir eu besoin de m'arracher les cheveux.
|
||||||
|
|
||||||
|
## Recherche
|
||||||
|
|
||||||
|
J'ai dit, au début de cet article, que le blog est toujours totalement dépourvu de javascript.
|
||||||
|
En réalité, il y a un fichier de javascript, mais il n'est chargé et utilisé que sur la nouvelle [page de recherche](/recherche/).
|
||||||
|
Cette fonctionnalité sur mon site est, là encore, une affaire de lâcher-prise, à petite dose bien contrôlée.
|
||||||
|
|
||||||
|
J'approche des 500 publications, et ma rédaction se densifie et se diversifie.
|
||||||
|
Je pense donc qu'il n'est plus déraisonnable d'envisager la possibilité de caresser l'idée qu'éventuellement il soit potentiellement possible[^humour] de faire une recherche.
|
||||||
|
|
||||||
|
[^humour]: Humour par accumulation, dérision et absurdité.
|
||||||
|
|
||||||
|
Pour ceux que ça intéresse, et qui ont tenu le coup jusque-là, c'est [MeiliSearch](https://www.meilisearch.com) qui fait tourner le moteur.
|
||||||
|
Je l'ai choisi parce qu'il est disponible nativement sous NixOS et parce qu'il fait le job sans me prendre la tête.
|
||||||
|
|
||||||
|
## Chat
|
||||||
|
|
||||||
|
Encore un point sur lequel je me suis – un peu – ravisé : l'interaction avec mes visiteurs.
|
||||||
|
À la base, [mon manifeste](/manifeste/) indiquait mon refus de l'interaction immédiate.
|
||||||
|
Au fil des évolutions de mon blog, je me suis ouvert au fediverse, à [Matrix](/interets/informatique/2023/03/24/matrix-c-est-fini/), à [mastodon](/interets/informatique/2023/06/26/de-retour-sur-le-fediverse/) et dérivés, et même à [instagram](/interets/informatique/2025/12/10/j-ai-quitte-instagram/).
|
||||||
|
Aucune de ces plateformes ne m'a convenu, pour différentes raisons.
|
||||||
|
[J'en suis toujours revenu](/interets/informatique/2023/08/07/j-abandonne-le-fediverse/), et j'ai toujours privilégié le [contact](/contact/) par email.
|
||||||
|
|
||||||
|
Finalement, cela fait depuis deux mois que [je fais tourner](/interets/informatique/2025/12/13/commentez-mes-articles-sur-lemmy/) une instance de [lemmy](https://join-lemmy.org/), et elle est assez docile pour que j'oublie presque son existence ; c'est une chose que j'apprécie chez un serveur.
|
||||||
|
Du coup, j'avais envie de réaliser un vieux rêve : recréer chez moi l'expérience des messageries instantanées multi-utilisateurs des années 2000.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Les plus jeunes ne comprendront pas ma référence.
|
||||||
|
Les plus vieux ne comprendront pas pourquoi j'ai mis autant de temps avant de m'y mettre.
|
||||||
|
Figurez-vous que, comme pour ActivityPub, j'ai déjà essayé plusieurs fois, sans jamais y trouver mon compte.
|
||||||
|
J'ai eu mon lot d'installations du couple [anope](https://www.anope.org/)/epona, d'[UnrealIRCd](https://www.unrealircd.org/), et compagnie.
|
||||||
|
Trop de boulot pour pas grand-chose ; à l'époque où je les avais testés, en tout cas.
|
||||||
|
|
||||||
|
Je rajoute que ce sont aussi les solutions web qui ne m'ont jamais convenu.
|
||||||
|
À mes yeux, les clients pour [IRC](https://fr.wikipedia.org/wiki/Internet_Relay_Chat) sont austères et peu engageants, ce qui m'avait conduit, à une époque, à essayer [Movim](https://movim.eu/), visuellement plus chaleureux, mais comme toute stack [XMPP](https://xmpp.org), s'est révélé une plaie à maintenir.
|
||||||
|
Et comme je ne veux plus m'encombrer d'applications non-web, les choix étaient limités.
|
||||||
|
|
||||||
|
Or, j'ai pris connaissance de [The Lounge](https://thelounge.chat), client IRC basé sur le web, moderne et agréable à utiliser.
|
||||||
|
Bien qu'il ne soit pas parfait, il représente bien l'esthétique (modernisée) de "la belle époque" du web – toujours selon moi – au contraire de [gamja](https://codeberg.org/emersion/gamja).
|
||||||
|
Le plus gros reproche que j'ai à lui faire concerne le formulaire d'entrée, que j'espérais pouvoir limiter au choix d'un pseudo ("_Nick_"), et l'absence de traduction en français qui pourrait dissuader certains lecteurs de me rendre visite.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Côté serveur, ChatGPT m'a suggéré [ergo](https://ergo.chat/), là encore pour son installation native sur NixOS.
|
||||||
|
J'ai eu quelques soucis avec les certificats, comme je m'y attendais, compte tenu de l'architecture de mon réseau, mais au final, tout fonctionne.
|
||||||
|
|
||||||
|
Et, effectivement, malgré quelques défauts, je retrouve un peu ce ressenti que j'avais à l'époque des guerres de bots à coup de _revenge-kick-ban_, des répondeurs, des robots conversationnels et autres [eggdrops](https://fr.wikipedia.org/wiki/Eggdrop).
|
||||||
|
Ça m'a rappelé que c'est comme ça que j'avais commencé à développer sur Internet : un chatbot sur IRC.
|
||||||
|
Ça fait vibrer ma corde nostalgique, et j'ai l'espoir d'attirer sur mon serveur deux catégories de visiteurs :
|
||||||
|
|
||||||
|
- les utilisateurs habituels d'IRC qui pourront accéder à mon salon (`irc.dern.ovh:6697`, `#lounge`) par leurs propres moyens,
|
||||||
|
- les visiteurs occasionnels qui ne seront pas réfractaires à un peu d'anglais pour saisir un "_Nick_" qui leur convient dans [l'interface web](https://irc.dern.ovh/) que je mets à disposition.
|
||||||
|
|
||||||
|
Et d'ailleurs, en parlant de bots, j'en ai _vibe codé_ deux :
|
||||||
|
|
||||||
|
- _WeatherBot_, qui vous accueillera avec ma météo du moment, fournie par [ma propre station](/interets/meteorologie/2023/09/15/mise-a-jour-de-ma-station-meteo/) via [Home Assistant](https://www.home-assistant.io)
|
||||||
|
- _BlogBot_, qui annoncera la publication de nouveaux articles, et offre une commande `!blog` (dont je vous laisse découvrir l'usage)
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
Cette nouvelle version de mon blog m'a conduit à explorer des terres inconnues.
|
||||||
|
J'ai traversé le clivage causé par l'IA, et j'ai fini par embrasser le _vibe coding_ pour moderniser mes expériences d'antan.
|
||||||
|
Malgré ses défauts (et ceux de ses utilisateurs, concepteurs, promoteurs), l'informatique a ceci de particulier qu'elle comprime le temps : son évolution à une échelle sans précédent facilite la nostalgie rapide.
|
||||||
|
En somme, elle nous fait vieillir plus vite.
|
||||||
|
|
||||||
|
De plus, le fait d'avoir traversé un nouveau siècle, mais aussi un nouveau millénaire, lui confère des propriétés uniques dans notre esprit : des usages très récents à l'échelle de l'histoire humaine passent du statut de standard mondial à l'obsolescence en moins de dix ans.
|
||||||
|
Et l'IA met encore un coup d'accélérateur.
|
||||||
|
|
||||||
|
Je n'ai rien fait de spécial : j'ai mis à jour mon blog, comme des milliers de blogueurs à travers le monde à chaque instant.
|
||||||
|
J'ai pourtant l'impression d'avoir relié mon passé et mon avenir.
|
||||||
|
Je ne sais pas si l'IA causera notre perte ou sera notre salut, et le fait-même de se poser la question est troublant.
|
||||||
|
Je n'aurais pas cru être amené à tant de métaphysique, simplement en mettant mon site à jour.
|
||||||
|
|
||||||
|
Je suis juste content de ne pas m'être laissé dépasser par l'IA.
|
||||||
@@ -143,6 +143,7 @@ Il n’embarque aucun JavaScript[^1], ne dépose aucun cookie, ne charge aucune
|
|||||||
Il est entièrement construit en HTML et CSS, sans framework, sans surcharge, sans effets visuels inutiles.
|
Il est entièrement construit en HTML et CSS, sans framework, sans surcharge, sans effets visuels inutiles.
|
||||||
|
|
||||||
[^1]: J’ai choisi de ne pas utiliser de JavaScript côté client, car il est aujourd’hui sur-utilisé pour des tâches qui relèvent du HTML ou du CSS. Cela limite la consommation de ressources, renforce la lisibilité, et garantit une accessibilité maximale.
|
[^1]: J’ai choisi de ne pas utiliser de JavaScript côté client, car il est aujourd’hui sur-utilisé pour des tâches qui relèvent du HTML ou du CSS. Cela limite la consommation de ressources, renforce la lisibilité, et garantit une accessibilité maximale.
|
||||||
|
|
||||||
[^2]: Le contenu est servi depuis mon propre hébergement. Cela évite toute dépendance à des infrastructures externes, et garantit que les pages resteront accessibles tant que mon serveur fonctionne.
|
[^2]: Le contenu est servi depuis mon propre hébergement. Cela évite toute dépendance à des infrastructures externes, et garantit que les pages resteront accessibles tant que mon serveur fonctionne.
|
||||||
|
|
||||||
Il s’affiche rapidement, quelle que soit la machine ou la connexion.
|
Il s’affiche rapidement, quelle que soit la machine ou la connexion.
|
||||||
@@ -176,25 +177,6 @@ Je préfère un message écrit, un échange humain, une prise de contact choisie
|
|||||||
|
|
||||||
Ce site n’est pas conçu pour susciter l’interaction ; il doit créer la relation.
|
Ce site n’est pas conçu pour susciter l’interaction ; il doit créer la relation.
|
||||||
|
|
||||||
### Le rejet des commentaires
|
|
||||||
|
|
||||||
Il n’y a pas de section commentaires sur ce site.
|
|
||||||
Ce n’est pas un oubli : c’est un choix.
|
|
||||||
|
|
||||||
Je ne veux pas animer une communauté, ni modérer des réactions, ni entretenir une zone d’expression ouverte.
|
|
||||||
Je ne veux pas transformer chaque article en terrain de débat.
|
|
||||||
Je veux préserver un espace de lecture — un espace de silence.
|
|
||||||
|
|
||||||
Cela ne signifie pas que je refuse la discussion, mais je refuse qu’elle soit automatique, attendue, exposée.
|
|
||||||
|
|
||||||
Si vous souhaitez réagir à un texte, vous pouvez [m’écrire](/contact/).
|
|
||||||
Je lis tous les messages.
|
|
||||||
Mais je n’ai pas vocation à répondre à tout, ni à transformer ce site en messagerie publique.
|
|
||||||
|
|
||||||
Ce site est un monologue ouvert.
|
|
||||||
Pas une agora, ni un forum.
|
|
||||||
Un lieu où l’on peut lire sans être interrompu, penser sans être pressé, écrire sans être commenté.
|
|
||||||
|
|
||||||
### Pourquoi j’écris tout, tout seul ou presque
|
### Pourquoi j’écris tout, tout seul ou presque
|
||||||
|
|
||||||
Ce site est le mien, du code au point-virgule.
|
Ce site est le mien, du code au point-virgule.
|
||||||
|
|||||||
3
content/recherche/index.md
Normal file
3
content/recherche/index.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
---
|
||||||
|
title: Recherche
|
||||||
|
---
|
||||||
44
layouts/recherche/single.html
Normal file
44
layouts/recherche/single.html
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{{ define "main" }}
|
||||||
|
{{- $search := .Site.Params.search -}}
|
||||||
|
{{- $meili := $search.meilisearch -}}
|
||||||
|
<header class="article-header">
|
||||||
|
{{ partialCached "header-brand.html" .Site .Site.Title (.Site.Params.logo | default "logo-large.png") }}
|
||||||
|
<h1>{{ .Title }}</h1>
|
||||||
|
</header>
|
||||||
|
<main
|
||||||
|
class="search-page"
|
||||||
|
data-search-endpoint="{{ $meili.endpoint }}"
|
||||||
|
data-search-index="{{ $meili.indexUid }}"
|
||||||
|
data-search-api-key="{{ $meili.apiKey }}"
|
||||||
|
data-search-limit="{{ $meili.hitsPerPage }}"
|
||||||
|
data-search-param="{{ $search.param }}"
|
||||||
|
>
|
||||||
|
<section class="listing-search">
|
||||||
|
<p class="search-page-status" data-search-status aria-live="polite"></p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="listing-articles" data-search-listing hidden>
|
||||||
|
<header>
|
||||||
|
<h2>Résultats</h2>
|
||||||
|
</header>
|
||||||
|
<nav class="search-results" aria-label="Résultats de recherche">
|
||||||
|
<ol data-search-results></ol>
|
||||||
|
</nav>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<noscript>
|
||||||
|
<section class="listing-articles">
|
||||||
|
<p>Cette page nécessite JavaScript pour afficher les résultats.</p>
|
||||||
|
</section>
|
||||||
|
</noscript>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{{- $searchJS := resources.Get "js/search-page.js" -}}
|
||||||
|
{{- if eq hugo.Environment "development" -}}
|
||||||
|
<script src="{{ $searchJS.RelPermalink }}" defer></script>
|
||||||
|
{{- else -}}
|
||||||
|
{{- with $searchJS | minify | fingerprint -}}
|
||||||
|
<script src="{{ .RelPermalink }}" integrity="{{ .Data.Integrity }}" crossorigin="anonymous" defer></script>
|
||||||
|
{{- end -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{ end }}
|
||||||
@@ -257,10 +257,6 @@ main > article figure.figure-media > figcaption.cover-meta .cover-attribution {
|
|||||||
main > article figure.figure-media > figcaption.cover-meta .cover-attribution > strong {
|
main > article figure.figure-media > figcaption.cover-meta .cover-attribution > strong {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
color: var(--color-heading);
|
color: var(--color-heading);
|
||||||
background-image: none;
|
|
||||||
padding: 0;
|
|
||||||
box-decoration-break: slice;
|
|
||||||
-webkit-box-decoration-break: slice;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.article-header .cover-meta > details {
|
.article-header .cover-meta > details {
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ h3 {
|
|||||||
font-size: clamp(1.05rem, 1.7vw, 1.35rem);
|
font-size: clamp(1.05rem, 1.7vw, 1.35rem);
|
||||||
}
|
}
|
||||||
|
|
||||||
main > article > :not(footer.article-footer) :is(strong, b) {
|
body:has(> header.article-header) > main > article > :is(p, ul, ol, blockquote, table, dl, details) :is(strong, b) {
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: var(--color-heading);
|
color: var(--color-heading);
|
||||||
text-shadow: none;
|
text-shadow: none;
|
||||||
|
|||||||
@@ -817,7 +817,8 @@ body:has(> header.article-header) > main > aside.article-toc > details.article-t
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
body:has(> header.article-header) > main > aside.article-toc > details.article-toc-drawer > summary {
|
body:has(> header.article-header) > main > aside.article-toc > details.article-toc-drawer > summary,
|
||||||
|
body:has(> header.article-header) > main > aside.article-toc > a.article-toc-link {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
width: 2.25rem;
|
width: 2.25rem;
|
||||||
min-height: 7.2rem;
|
min-height: 7.2rem;
|
||||||
@@ -827,7 +828,8 @@ body:has(> header.article-header) > main > aside.article-toc > details.article-t
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
body:has(> header.article-header) > main > aside.article-toc > details.article-toc-drawer > summary > span {
|
body:has(> header.article-header) > main > aside.article-toc > details.article-toc-drawer > summary > span,
|
||||||
|
body:has(> header.article-header) > main > aside.article-toc > a.article-toc-link > span {
|
||||||
color: var(--color-heading);
|
color: var(--color-heading);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1017,7 +1019,8 @@ body:has(> header.article-header) > main > aside.article-toc nav[aria-label="Som
|
|||||||
gap: var(--space-3);
|
gap: var(--space-3);
|
||||||
}
|
}
|
||||||
|
|
||||||
body:has(> header.article-header) > main > aside.article-toc > details.article-toc-drawer > summary.ui-button--vertical {
|
body:has(> header.article-header) > main > aside.article-toc > details.article-toc-drawer > summary.ui-button--vertical,
|
||||||
|
body:has(> header.article-header) > main > aside.article-toc > a.article-toc-link.ui-button--vertical {
|
||||||
width: 2rem;
|
width: 2rem;
|
||||||
min-height: 6.1rem;
|
min-height: 6.1rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,44 @@ body > header > section:first-of-type > a img {
|
|||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body > header > section:first-of-type > form.site-search {
|
||||||
|
margin-left: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
body > header > section:first-of-type > form.site-search > input[type="search"] {
|
||||||
|
width: min(22rem, 36vw);
|
||||||
|
min-width: 12rem;
|
||||||
|
min-height: 2.35rem;
|
||||||
|
padding: 0.45rem 0.65rem;
|
||||||
|
border: 1px solid var(--color-border-strong);
|
||||||
|
background-color: color-mix(in srgb, var(--color-background-alt) 88%, #000000 12%);
|
||||||
|
color: var(--color-heading);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body > header > section:first-of-type > form.site-search > input[type="search"]:focus-visible {
|
||||||
|
border-color: var(--color-accent-1);
|
||||||
|
outline: 2px solid color-mix(in srgb, var(--color-accent-1) 78%, transparent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body > header > section:first-of-type > form.site-search > button.ui-button {
|
||||||
|
width: 2.35rem;
|
||||||
|
min-height: 2.35rem;
|
||||||
|
min-width: 2.35rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body > header > section:first-of-type > form.site-search > button.ui-button > svg {
|
||||||
|
width: 1.05rem;
|
||||||
|
height: 1.05rem;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
|
||||||
body > header > section.site-stats {
|
body > header > section.site-stats {
|
||||||
margin-top: var(--space-5);
|
margin-top: var(--space-5);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
@import "article-header.css";
|
@import "article-header.css";
|
||||||
@import "content.css";
|
@import "content.css";
|
||||||
@import "list.css";
|
@import "list.css";
|
||||||
|
@import "search.css";
|
||||||
@import "home.css";
|
@import "home.css";
|
||||||
@import "footer.css";
|
@import "footer.css";
|
||||||
@import "responsive.css";
|
@import "responsive.css";
|
||||||
|
|||||||
@@ -136,6 +136,17 @@
|
|||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body > header > section:first-of-type > form.site-search {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body > header > section:first-of-type > form.site-search > input[type="search"] {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
main nav.articles-list > ol {
|
main nav.articles-list > ol {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -356,6 +367,28 @@
|
|||||||
letter-spacing: 0.02em;
|
letter-spacing: 0.02em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body > header > section:first-of-type > form.site-search {
|
||||||
|
gap: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
body > header > section:first-of-type > form.site-search > input[type="search"] {
|
||||||
|
min-height: 2.2rem;
|
||||||
|
padding: 0.4rem 0.55rem;
|
||||||
|
font-size: 0.98rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
body > header > section:first-of-type > form.site-search > button.ui-button {
|
||||||
|
width: 2.2rem;
|
||||||
|
min-height: 2.2rem;
|
||||||
|
min-width: 2.2rem;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body > header > section:first-of-type > form.site-search > button.ui-button > svg {
|
||||||
|
width: 0.95rem;
|
||||||
|
height: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
body > header:has(> h1) > h1 {
|
body > header:has(> h1) > h1 {
|
||||||
font-size: clamp(1.6rem, 8.2vw, 2.25rem);
|
font-size: clamp(1.6rem, 8.2vw, 2.25rem);
|
||||||
}
|
}
|
||||||
@@ -418,7 +451,8 @@
|
|||||||
z-index: 40;
|
z-index: 40;
|
||||||
}
|
}
|
||||||
|
|
||||||
body:has(> header.article-header) > main > aside.article-toc > details.article-toc-drawer > summary.ui-button--vertical {
|
body:has(> header.article-header) > main > aside.article-toc > details.article-toc-drawer > summary.ui-button--vertical,
|
||||||
|
body:has(> header.article-header) > main > aside.article-toc > a.article-toc-link.ui-button--vertical {
|
||||||
width: auto;
|
width: auto;
|
||||||
min-height: 2.35rem;
|
min-height: 2.35rem;
|
||||||
padding: 0.45rem 0.72rem;
|
padding: 0.45rem 0.72rem;
|
||||||
@@ -426,7 +460,8 @@
|
|||||||
letter-spacing: 0.05em;
|
letter-spacing: 0.05em;
|
||||||
}
|
}
|
||||||
|
|
||||||
body:has(> header.article-header) > main > aside.article-toc > details.article-toc-drawer > summary.ui-button--vertical > span {
|
body:has(> header.article-header) > main > aside.article-toc > details.article-toc-drawer > summary.ui-button--vertical > span,
|
||||||
|
body:has(> header.article-header) > main > aside.article-toc > a.article-toc-link.ui-button--vertical > span {
|
||||||
transform: none;
|
transform: none;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|||||||
72
themes/2026/assets/css/search.css
Normal file
72
themes/2026/assets/css/search.css
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
body > main.search-page {
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
body > main.search-page > section.listing-search {
|
||||||
|
border-top: var(--border-width-regular) solid var(--color-accent-1);
|
||||||
|
padding-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
body > main.search-page .search-page-status {
|
||||||
|
margin-top: 0;
|
||||||
|
min-height: 1.4rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
body > main.search-page > section.listing-articles > nav.search-results {
|
||||||
|
margin-top: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
body > main.search-page > section.listing-articles > nav.search-results > ol {
|
||||||
|
list-style: none;
|
||||||
|
display: grid;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
body > main.search-page > section.listing-articles > nav.search-results > ol > li {
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
padding-top: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
body > main.search-page > section.listing-articles > nav.search-results > ol > li:first-child {
|
||||||
|
border-top: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body > main.search-page > section.listing-articles > nav.search-results article {
|
||||||
|
margin-top: 0;
|
||||||
|
border-top: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body > main.search-page > section.listing-articles > nav.search-results article h3 {
|
||||||
|
font-size: clamp(1.14rem, 1.9vw, 1.55rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
body > main.search-page > section.listing-articles > nav.search-results article h3 > a {
|
||||||
|
color: var(--color-heading);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
body > main.search-page > section.listing-articles > nav.search-results article h3 > a:is(:hover, :focus-visible) {
|
||||||
|
color: var(--color-link-hover);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
body > main.search-page > section.listing-articles > nav.search-results article p.search-result-meta {
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-1);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-family: var(--font-heading);
|
||||||
|
}
|
||||||
|
|
||||||
|
body > main.search-page > section.listing-articles > nav.search-results article p.search-result-path {
|
||||||
|
margin-top: var(--space-1);
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
@@ -93,8 +93,6 @@
|
|||||||
{{- $hasBreadcrumbs := ne (strings.TrimSpace $breadcrumbsMarkup) "" -}}
|
{{- $hasBreadcrumbs := ne (strings.TrimSpace $breadcrumbsMarkup) "" -}}
|
||||||
{{- $pageLinksMarkup := partial "page-links.html" (dict
|
{{- $pageLinksMarkup := partial "page-links.html" (dict
|
||||||
"Page" .
|
"Page" .
|
||||||
"CommentsURL" .Params.comments_url
|
|
||||||
"CommentsLabel" "Commentaires"
|
|
||||||
"LinkClass" "ui-button"
|
"LinkClass" "ui-button"
|
||||||
"Links" $remainingLinks
|
"Links" $remainingLinks
|
||||||
) -}}
|
) -}}
|
||||||
|
|||||||
@@ -2,13 +2,27 @@
|
|||||||
{{- $hasTOC := gt (len (findRE "<li>" $toc)) 0 -}}
|
{{- $hasTOC := gt (len (findRE "<li>" $toc)) 0 -}}
|
||||||
{{- $dossierSummary := partial "dossier-summary.html" (dict "Page" .) -}}
|
{{- $dossierSummary := partial "dossier-summary.html" (dict "Page" .) -}}
|
||||||
{{- $hasDossierSummary := ne (strings.TrimSpace $dossierSummary) "" -}}
|
{{- $hasDossierSummary := ne (strings.TrimSpace $dossierSummary) "" -}}
|
||||||
{{- if or $hasDossierSummary $hasTOC -}}
|
{{- $commentsURL := "" -}}
|
||||||
|
{{- with .Params.comments_url -}}
|
||||||
|
{{- $commentsURL = strings.TrimSpace . -}}
|
||||||
|
{{- end -}}
|
||||||
|
{{- $hasCommentsLink := ne $commentsURL "" -}}
|
||||||
|
{{- if or $hasDossierSummary $hasTOC $hasCommentsLink -}}
|
||||||
{{- $tocMarkup := "" -}}
|
{{- $tocMarkup := "" -}}
|
||||||
{{- if $hasTOC -}}
|
{{- if $hasTOC -}}
|
||||||
{{- $tocMarkup = replace $toc `<nav id="TableOfContents">` `<div class="article-toc-list">` -}}
|
{{- $tocMarkup = replace $toc `<nav id="TableOfContents">` `<div class="article-toc-list">` -}}
|
||||||
{{- $tocMarkup = replace $tocMarkup `</nav>` `</div>` -}}
|
{{- $tocMarkup = replace $tocMarkup `</nav>` `</div>` -}}
|
||||||
{{- end -}}
|
{{- end -}}
|
||||||
<aside class="article-toc">
|
<aside class="article-toc">
|
||||||
|
{{- if $hasCommentsLink -}}
|
||||||
|
{{- partial "render/link.html" (dict
|
||||||
|
"Destination" $commentsURL
|
||||||
|
"Title" "Voir les commentaires"
|
||||||
|
"Text" "<span>Commentaires</span>"
|
||||||
|
"Class" "ui-button ui-button--vertical article-toc-link article-toc-link-comments"
|
||||||
|
"Page" .
|
||||||
|
) -}}
|
||||||
|
{{- end -}}
|
||||||
{{- if $hasDossierSummary -}}
|
{{- if $hasDossierSummary -}}
|
||||||
<details class="article-toc-drawer article-toc-drawer-dossier">
|
<details class="article-toc-drawer article-toc-drawer-dossier">
|
||||||
<summary class="ui-button ui-button--vertical"><span>Dossier</span></summary>
|
<summary class="ui-button ui-button--vertical"><span>Dossier</span></summary>
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
<section>
|
<section>
|
||||||
{{ partialCached "site-title.html" (dict "Site" .) .Title (.Params.logo | default "logo-large.png") }}
|
{{ partialCached "site-title.html" (dict "Site" .) .Title (.Params.logo | default "logo-large.png") .Params.search.action .Params.search.param }}
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -11,3 +11,11 @@
|
|||||||
{{- end -}}
|
{{- end -}}
|
||||||
<strong>{{ $site.Title }}</strong>
|
<strong>{{ $site.Title }}</strong>
|
||||||
</a>
|
</a>
|
||||||
|
<form class="site-search" role="search" method="get" action="{{ $site.Params.search.action | relURL }}" aria-label="Recherche">
|
||||||
|
<input id="header-search-input" type="search" name="{{ $site.Params.search.param }}" required aria-label="Recherche">
|
||||||
|
<button type="submit" class="ui-button" aria-label="Lancer la recherche" title="Lancer la recherche">
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="M11 4a7 7 0 1 0 4.9 12l4.5 4.5 1.4-1.4-4.5-4.5A7 7 0 0 0 11 4Zm0 2a5 5 0 1 1 0 10 5 5 0 0 1 0-10Z"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|||||||
Reference in New Issue
Block a user