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
|
||||
pageRef: /index.xml
|
||||
parent: Accueil
|
||||
- name: IRC
|
||||
title: Venez discuter en direct avec moi
|
||||
pageRef: https://irc.dern.ovh
|
||||
parent: Accueil
|
||||
- name: Taxonomies
|
||||
title: "Classification de mes articles"
|
||||
pageRef: /taxonomies/
|
||||
|
||||
@@ -3,3 +3,11 @@ favicon: /favicon.png
|
||||
# Et les placer dans /assets et non dans /static
|
||||
logo: logo-large.png
|
||||
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).
|
||||
|
||||
### 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 ❤️
|
||||
|
||||
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 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.
|
||||
|
||||
[^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.
|
||||
|
||||
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.
|
||||
|
||||
### 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
|
||||
|
||||
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 {
|
||||
font: inherit;
|
||||
color: var(--color-heading);
|
||||
background-image: none;
|
||||
padding: 0;
|
||||
box-decoration-break: slice;
|
||||
-webkit-box-decoration-break: slice;
|
||||
}
|
||||
|
||||
.article-header .cover-meta > details {
|
||||
|
||||
@@ -64,7 +64,7 @@ h3 {
|
||||
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;
|
||||
color: var(--color-heading);
|
||||
text-shadow: none;
|
||||
|
||||
@@ -817,7 +817,8 @@ body:has(> header.article-header) > main > aside.article-toc > details.article-t
|
||||
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;
|
||||
width: 2.25rem;
|
||||
min-height: 7.2rem;
|
||||
@@ -827,7 +828,8 @@ body:has(> header.article-header) > main > aside.article-toc > details.article-t
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1017,7 +1019,8 @@ body:has(> header.article-header) > main > aside.article-toc nav[aria-label="Som
|
||||
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;
|
||||
min-height: 6.1rem;
|
||||
}
|
||||
|
||||
@@ -45,6 +45,44 @@ body > header > section:first-of-type > a img {
|
||||
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 {
|
||||
margin-top: var(--space-5);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
@import "article-header.css";
|
||||
@import "content.css";
|
||||
@import "list.css";
|
||||
@import "search.css";
|
||||
@import "home.css";
|
||||
@import "footer.css";
|
||||
@import "responsive.css";
|
||||
|
||||
@@ -136,6 +136,17 @@
|
||||
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 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -356,6 +367,28 @@
|
||||
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 {
|
||||
font-size: clamp(1.6rem, 8.2vw, 2.25rem);
|
||||
}
|
||||
@@ -418,7 +451,8 @@
|
||||
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;
|
||||
min-height: 2.35rem;
|
||||
padding: 0.45rem 0.72rem;
|
||||
@@ -426,7 +460,8 @@
|
||||
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;
|
||||
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) "" -}}
|
||||
{{- $pageLinksMarkup := partial "page-links.html" (dict
|
||||
"Page" .
|
||||
"CommentsURL" .Params.comments_url
|
||||
"CommentsLabel" "Commentaires"
|
||||
"LinkClass" "ui-button"
|
||||
"Links" $remainingLinks
|
||||
) -}}
|
||||
|
||||
@@ -2,13 +2,27 @@
|
||||
{{- $hasTOC := gt (len (findRE "<li>" $toc)) 0 -}}
|
||||
{{- $dossierSummary := partial "dossier-summary.html" (dict "Page" .) -}}
|
||||
{{- $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 := "" -}}
|
||||
{{- if $hasTOC -}}
|
||||
{{- $tocMarkup = replace $toc `<nav id="TableOfContents">` `<div class="article-toc-list">` -}}
|
||||
{{- $tocMarkup = replace $tocMarkup `</nav>` `</div>` -}}
|
||||
{{- end -}}
|
||||
<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 -}}
|
||||
<details class="article-toc-drawer article-toc-drawer-dossier">
|
||||
<summary class="ui-button ui-button--vertical"><span>Dossier</span></summary>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<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>
|
||||
|
||||
@@ -11,3 +11,11 @@
|
||||
{{- end -}}
|
||||
<strong>{{ $site.Title }}</strong>
|
||||
</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