Compare commits
7 Commits
main
...
267f580648
| Author | SHA1 | Date | |
|---|---|---|---|
| 267f580648 | |||
| 188e996581 | |||
| 6efbecc07a | |||
| 6745469841 | |||
| 208a873d80 | |||
| 5e00c0c7c4 | |||
| 7f614688d0 |
@@ -83,7 +83,7 @@ Il s'agit d'un site statique généré avec Hugo, dont la version peut être vé
|
||||
|
||||
## Instructions relatives aux articles
|
||||
|
||||
- Ne pas utiliser les apostrophes et les guillemets typographiques dans le markdown (mais garder les caractères accentués)
|
||||
- Utiliser exclusivement les apostrophes (`'`) et les guillemets (`"`) non-typographiques
|
||||
- Une phrase par ligne
|
||||
- Ne jamais terminer une ligne par un double-espace
|
||||
|
||||
@@ -162,13 +162,10 @@ Il s'agit d'un site statique généré avec Hugo, dont la version peut être vé
|
||||
- `title` : Titre que je donne à mon article
|
||||
- `date` : Si possible avec l'heure, mais la majorité de mes articles actuels ne contiennent que la date
|
||||
- Le frontmatter peut contenir d'autres attributs optionnels, autres que les taxonomies :
|
||||
|
||||
- `cover` : Chemin vers l'image illustrant l'article. Cette image doit être stockée dans le dossier `{chemin du bundle}/images/`. Elle sera utilisée pour générer la vignette de l'article, et affichée en en-tête de l'article. Je la désigne par _cover_, _image de couverture_, ou _image d'en-tête_
|
||||
- `dossier` : Plusieurs bundles partageant une même valeur pour l'attribut `dossier` seront regroupés dans un _dossier virtuel_ tout en résidant physiquement dans leurs dossiers _physiques_ respectifs
|
||||
|
||||
- Exemple : `dossier: [ "Exploitation de mes données météo" ]`
|
||||
- Cet attribut devrait être complété par un attribut `weight`
|
||||
|
||||
- Par exemple :
|
||||
|
||||
```json
|
||||
|
||||
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();
|
||||
@@ -1,6 +1,9 @@
|
||||
goldmark:
|
||||
parser:
|
||||
wrapStandAloneImageWithinParagraph: false
|
||||
attribute:
|
||||
block: true
|
||||
title: true
|
||||
renderer:
|
||||
unsafe: true
|
||||
tableOfContents:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: ""
|
||||
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 476 KiB |
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.
|
||||
@@ -152,6 +152,6 @@ J’ai abandonné la souris pour un TrackPad.
|
||||
|
||||
## Consommation électrique
|
||||
|
||||
Je dispose d’un onduleur Eaton Ellipse 1200 Pro qui présente la caractéristique intéressante de me fournir la consommation réelle de ce qui est branché dessus. Ainsi, je peux dire que l’ensemble de mon réseau consomment au total moins de 40W en moyenne. Je trouve que c’est très satisfaisant. L’onduleur pourrait ainsi fonctionner pendant deux heures sur batterie si une coupure de courant devait survenir. Largement de quoi voir venir…
|
||||
Je dispose d’un onduleur Eaton Ellipse 1200 Pro qui présente la caractéristique intéressante de me fournir la consommation réelle de ce qui est branché dessus. Ainsi, je peux dire que l’ensemble de mon réseau consomme au total moins de 40W en moyenne. Je trouve que c’est très satisfaisant. L’onduleur pourrait ainsi fonctionner pendant deux heures sur batterie si une coupure de courant devait survenir. Largement de quoi voir venir…
|
||||
|
||||
[^deadlink-1]: Lien inaccessible depuis le 1 novembre 2025
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
#title: ""
|
||||
attribution: "ChatGPT 5.3"
|
||||
description: "ChatGPT a été mon seul espoir."
|
||||
prompt: "Une illustration numérique vivante racontant une histoire de chaos numérique puis de sauvetage. À gauche, un enchevêtrement de câbles Ethernet serpente et étouffe une Freebox abîmée tandis que l’inscription IPv6 apparaît comme une tempête de feu au-dessus. Le logo flocon de NixOS est en train de sombrer dans une mer agitée faite de vagues et de pluie de code binaire. La scène est sombre et chaotique. À droite, la situation est lumineuse et paisible : un robot amical représentant ChatGPT, baigné de lumière, tend la main pour aider une personne soulagée qui sort de l’eau. Un arc-en-ciel traverse le ciel et une petite île technologique stable avec un symbole NixOS apparaît en arrière-plan. Dans le ciel est écrit ‘Merci ChatGPT !’. Style illustration numérique détaillée, composition narrative gauche-chaos / droite-sauvetage."
|
||||
@@ -0,0 +1,4 @@
|
||||
#title: ""
|
||||
#attribution: ""
|
||||
description: "On reporte alors cette adresse de lien local dans le premier **Préfixe secondaire** de la Freebox"
|
||||
#prompt: ""
|
||||
@@ -0,0 +1,4 @@
|
||||
#title: ""
|
||||
#attribution: ""
|
||||
#description: ""
|
||||
#prompt: ""
|
||||
@@ -0,0 +1,4 @@
|
||||
#title: ""
|
||||
#attribution: ""
|
||||
#description: ""
|
||||
#prompt: ""
|
||||
@@ -0,0 +1,4 @@
|
||||
#title: ""
|
||||
#attribution: ""
|
||||
#description: ""
|
||||
#prompt: ""
|
||||
@@ -0,0 +1,4 @@
|
||||
#title: ""
|
||||
#attribution: ""
|
||||
#description: ""
|
||||
#prompt: ""
|
||||
@@ -0,0 +1,4 @@
|
||||
#title: ""
|
||||
#attribution: ""
|
||||
#description: ""
|
||||
#prompt: ""
|
||||
@@ -0,0 +1,4 @@
|
||||
#title: ""
|
||||
#attribution: ""
|
||||
description: "Obtention de l'adresse de lien local depuis l'interface d'OPNsense. C'est la deuxième adresse de la ligne `IPv6 Addresses`, qui commence toujours par `fe80`."
|
||||
#prompt: ""
|
||||
@@ -0,0 +1,4 @@
|
||||
#title: ""
|
||||
#attribution: ""
|
||||
#description: ""
|
||||
#prompt: ""
|
||||
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 593 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 166 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 238 KiB |
|
After Width: | Height: | Size: 419 KiB |
|
After Width: | Height: | Size: 165 KiB |
@@ -0,0 +1,321 @@
|
||||
---
|
||||
title: Free, IPv6 et OPNsense
|
||||
date: "2026-03-04 16:50:00"
|
||||
cover: images/cover.png
|
||||
tags:
|
||||
- Administration réseau
|
||||
- OPNsense
|
||||
- ChatGPT
|
||||
- Intelligence artificielle
|
||||
- IPv6
|
||||
weather:
|
||||
temperature: 14.1666666666667
|
||||
humidity: 52
|
||||
pressure: 1025.73711915929
|
||||
illuminance: 11254.8
|
||||
wind_speed: 3.8624256
|
||||
wind_direction: 89
|
||||
precipitations: false
|
||||
source:
|
||||
- influxdb
|
||||
comments_url: https://com.richard-dern.fr/post/478
|
||||
---
|
||||
|
||||
## Contexte
|
||||
|
||||
Bien conscient de la [pénurie d'adresses IPv4](https://fr.wikipedia.org/wiki/Épuisement_des_adresses_IPv4) depuis très longtemps, cela fait des années que j'essayais de mettre en place [IPv6](https://fr.wikipedia.org/wiki/IPv6) sur mon réseau, sans que j'y parvienne.
|
||||
Et très honnêtement, c'est largement à cause de ma méconnaissance des implications techniques d'IPv6 qui me faisait craindre pour la sécurité de mon réseau si je m'y prenais mal.
|
||||
|
||||
J'ai finalement décidé de donner les clés de mon château ([OPNsense](https://opnsense.org)) à [ChatGPT](https://chatgpt.com), qui s'est occupé de tout pour moi [^1].
|
||||
Et je ne regrette pas un instant de l'avoir fait : il a **tout** mis en place, et tout fonctionne parfaitement bien.
|
||||
|
||||
[^1]:
|
||||
J'ai créé un utilisateur sur mon serveur principal à usage exclusif de _ChatGPT-5.3-Codex_, créé une paire de clés envoyée à mon routeur, et démarré une session `codex` à partir de laquelle ChatGPT pouvait se connecter en `root` via SSH vers le routeur.
|
||||
Une fois qu'il a accompli ses objectifs, il n'y avait plus qu'à supprimer l'utilisateur dédié et les clés SSH associées sur le routeur.
|
||||
|
||||
## Objectifs
|
||||
|
||||
Mes objectifs sont de fournir la double connectivité IPv4 et IPv6 aux clients de mon _LAN_, et que mes serveurs et services soient joignables en IPv4 ou IPv6.
|
||||
|
||||
## Mon réseau
|
||||
|
||||
Je le détaille dans [cet article](/interets/informatique/2021/03/09/mon-reseau/), mais en substance :
|
||||
|
||||
- J'ai une [Freebox Pop](https://www.free.fr/freebox/freebox-pop) Fibre en mode _bridge_
|
||||
- Un réseau local
|
||||
- Un routeur sous OPNsense qui fait le lien entre les deux
|
||||
|
||||
J'ai installé le [plugin](https://docs.opnsense.org/manual/how-tos/caddy.html) [caddy](https://caddyserver.com) sur OPNsense : il fait office de _reverse-proxy_ pour tout service que je veux exposer.
|
||||
Les services qui ne passent pas par un serveur web font l'objet de règles de [NAT](https://fr.wikipedia.org/wiki/Network_address_translation).
|
||||
|
||||
## Mise en place
|
||||
|
||||
Par souci de confidentialité, j'ai anonymisé les adresses IP et les identifiants matériels dans les extraits et les captures d'écran.
|
||||
|
||||

|
||||
|
||||
### Freebox
|
||||
|
||||
Dans [l'interface de configuration de la Freebox](http://mafreebox.freebox.fr/), on active le _Next Hop_ IPv6 du **premier préfixe secondaire** en indiquant l'adresse _lien local_ du routeur.
|
||||
|
||||
> [Une adresse IPv6](https://fr.wikipedia.org/wiki/Adresse_IPv6) de lien-local (préfixe `fe80::/10`) est une adresse valable uniquement sur le lien où elle a été émise, et qui n'est pas routée sur Internet. Une machine a une adresse de lien-local par interface, ce qui explique pourquoi on parle de l'adresse lien-local de l'interface _WAN_.
|
||||
|
||||
> Un préfixe IPv6 (notation `.../64`) désigne un bloc d'adresses, l'équivalent d'un réseau en IPv4.
|
||||
|
||||
> Le _Next Hop_ (le "prochain saut") revient à indiquer à la Freebox vers quel voisin elle doit [envoyer les paquets](https://fr.wikipedia.org/wiki/Table_de_routage) destinés à ce préfixe.
|
||||
|
||||

|
||||
|
||||
Mon erreur était de renseigner l'adresse _lien local_ de l'interface _WAN_ de mon routeur comme _Next Hop_ du préfixe _principal_, et l'adresse _lien local_ de l'interface _LAN_ de mon routeur dans le _Next Hop_ du premier préfixe secondaire.
|
||||
Je me suis laissé influencer [par ce post](https://lafibre.info/remplacer-freebox/freebox-delta-pop-mode-bridge-onpsense-freeplayer/), dont je n'ai lu que les images et pas le texte...
|
||||
|
||||
Il n'y a rien de plus à faire sur la Freebox.
|
||||
|
||||
En pratique, je devrais activer le serveur DHCPv6 pour que mon serveur se voit attribuer une adresse fixe, mais pour le moment, je veux juste m'assurer que tout fonctionne avant d'activer des options.
|
||||
|
||||
### OPNsense
|
||||
|
||||
C'est à partir de là que ChatGPT a pris le contrôle de mon routeur.
|
||||
|
||||
Je tiens à souligner que je l'ai laissé en totale autonomie après lui avoir donné mes instructions, et qu'à aucun moment je n'ai subi de déconnexion intempestive.
|
||||
Il a assuré, du début à la fin, en procédant à des sauvegardes avant chaque commande potentiellement destructive.
|
||||
Ma session de codex me permettait de voir en direct toutes les commandes qu'il exécutait, d'où la confiance que je lui ai accordée.
|
||||
Les commandes utilisées pour la vérification sont données plus loin à titre informatif, mais ces vérifications peuvent aussi être effectuées directement dans l'interface web de OPNsense.
|
||||
|
||||
Je précise aussi que je ne sauve pas des vies : si mon blog est hors-ligne pendant une grosse demi-heure (le temps de restaurer ma configuration initiale), ça ne va gêner personne, en particulier en plein milieu de la nuit.
|
||||
|
||||
#### Activation IPv6 WAN/LAN
|
||||
|
||||
Dans _Interfaces_, _[WAN]_, on active le mode `SLAAC` pour l'interface _WAN_.
|
||||
|
||||
[`SLAAC`](https://en.wikipedia.org/wiki/Stateless_address_autoconfiguration) (_Stateless Address Autoconfiguration_) est le mécanisme standard d'auto-configuration IPv6 à partir des messages _Router Advertisement_.
|
||||
Je le choisis sur le _WAN_ car la Freebox annonce déjà ce qu'il faut (route par défaut et préfixe), et cela simplifie le diagnostic tant que la connectivité de base n'est pas validée.
|
||||
|
||||

|
||||
|
||||
Dans _Interfaces_, _[LAN]_, on configure une IPv6 statique pour l'interface _LAN_ dans le préfixe secondaire.
|
||||
Traditionnellement, c'est le préfixe IPv6, suivi par `::1` (un peu comme `10.0.0.1` en IPv4).
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
Et on applique la configuration.
|
||||
|
||||
#### Router Advertisements (RA) sur LAN
|
||||
|
||||
Dans _Services_, _Router Advertisements_, on active _RA_ pour le _LAN_.
|
||||
|
||||

|
||||
|
||||
Les [_Router Advertisements_](https://fr.wikipedia.org/wiki/Radvd) (RA) sont des messages envoyés par le routeur aux clients pour annoncer la passerelle par défaut, les préfixes disponibles, et les indicateurs liés à DHCPv6.
|
||||
Sans RA, un client ne peut pas apprendre sa route par défaut IPv6, même si un serveur DHCPv6 est présent.
|
||||
|
||||
Les modes de RA d'OPNsense servent essentiellement à indiquer aux clients s'ils doivent utiliser `SLAAC`, [`DHCPv6`](https://fr.wikipedia.org/wiki/DHCPv6), ou une combinaison des deux.
|
||||
Ils correspondent à des combinaisons de drapeaux standards des RA, notamment `AdvManagedFlag` (le drapeau `M`) et `AdvOtherConfigFlag` (le drapeau `O`), ainsi que le drapeau "autonomous" associé au préfixe qui autorise `SLAAC`.
|
||||
La [documentation](https://docs.opnsense.org/manual/radvd.html) d'OPNsense récapitule précisément ces modes et leurs drapeaux.
|
||||
|
||||
- `Router only` : le routeur n'annonce que la passerelle par défaut, ce qui suppose que les clients aient déjà une adresse IPv6 (par exemple via une configuration statique).
|
||||
- `Unmanaged` : les clients obtiennent leur adresse via `SLAAC`, sans que les RA ne signalent de service DHCPv6.
|
||||
- `Managed` : les clients sont censés obtenir leur adresse via DHCPv6 (configuration _stateful_), ce qui implique de ne pas utiliser `SLAAC` pour l'adressage.
|
||||
- `Assisted` : les clients peuvent obtenir une adresse via DHCPv6 tout en pouvant aussi utiliser `SLAAC`, ce qui peut aboutir à plusieurs adresses IPv6 globales sur une même interface.
|
||||
- `Stateless` : les clients utilisent `SLAAC` pour l'adressage, et peuvent utiliser DHCPv6 uniquement pour des informations "annexes" (par exemple les serveurs DNS).
|
||||
|
||||
Dans un premier temps, on le configure en `Unmanaged`.
|
||||
Une fois DHCPv6 (Kea) activé et le DNS correctement publié, je basculerai en `Assisted` afin que RA et DHCPv6 puissent cohabiter sans ambiguïté (expliqué plus bas).
|
||||
|
||||
#### DHCPv6 (Kea) sur LAN
|
||||
|
||||
Dans _Services_, _Kea DHCP_, _Kea DHCPv6_, on active DHCPv6 dans Kea pour le _LAN_.
|
||||
|
||||

|
||||
|
||||
On enregistre le _subnet_ (celui du préfixe pour lequel on a ajouté un _Next Hop_ dans la Freebox).
|
||||
|
||||

|
||||
|
||||
Je prévois ici d'utiliser l'adresse `...::53` pour mon serveur DNS, avec _fallback_ sur le serveur DNS du routeur (`...::1`).
|
||||
|
||||
#### Réservation IPv6 stable pour le serveur DNS
|
||||
|
||||
À ce stade, j'ai demandé à ChatGPT de fiabiliser l'adresse IPv6 de mon serveur DNS.
|
||||
Il s'agit de récupérer son _DUID_ depuis les baux déjà attribués dynamiquement et de l'ajouter aux réservations.
|
||||
C'est le même principe que pour IPv4, sauf que le _DUID_ est plus fiable que l'adresse _MAC_.
|
||||
|
||||
Après un redémarrage de mon pi-hole (`dns-1`), j'obtiens bien l'adresse en `...::53` qui a été configurée dans les baux statiques.
|
||||
|
||||
#### Mise à jour du RA
|
||||
|
||||
Dans _Services_, _Router Advertisements_, on peut maintenant passer au mode `Assisted`.
|
||||
|
||||

|
||||
|
||||
#### Vérifications techniques
|
||||
|
||||
##### Connectivité IPv6 depuis dns-1
|
||||
|
||||
`dns-1` a bien reçu l'IPv6 réservée :
|
||||
|
||||
```bash
|
||||
inet6 ...::53/128 scope global dynamic noprefixroute
|
||||
```
|
||||
|
||||
##### Résolution DNS via dns-1 en IPv6
|
||||
|
||||
Depuis OPNsense :
|
||||
|
||||
```bash
|
||||
drill @...::53 one.one.one.one AAAA
|
||||
```
|
||||
|
||||
Résultat attendu : réponse `NOERROR` avec des `AAAA` (ex. : Cloudflare).
|
||||
|
||||
##### Vérifier les annonces RA
|
||||
|
||||
```bash
|
||||
grep -nE 'RDNSS|AdvManagedFlag|AdvOtherConfigFlag' /var/etc/radvd.conf
|
||||
```
|
||||
|
||||
Résultat attendu :
|
||||
|
||||
- `AdvManagedFlag on`
|
||||
- `AdvOtherConfigFlag on`
|
||||
- `RDNSS ...::53 ...::1`
|
||||
|
||||
##### Vérifier les annonces DHCPv6
|
||||
|
||||
```bash
|
||||
sed -n '30,55p' /usr/local/etc/kea/kea-dhcp6.conf
|
||||
```
|
||||
|
||||
Résultat attendu :
|
||||
|
||||
- Serveurs DNS : `...::53,...::1`
|
||||
- Réservation `...::53` pour `dns-1`
|
||||
|
||||
##### Vérifier les règles IPv6 WAN (phase LAN uniquement)
|
||||
|
||||
Ici, "phase LAN uniquement" signifie que je valide d'abord l'IPv6 sortant pour les machines du _LAN_, sans encore exposer de services en IPv6 vers Internet.
|
||||
Je m'attends donc à ne voir, sur _WAN_, que les règles indispensables au fonctionnement côté routeur, et aucune règle `pass` destinée à des services.
|
||||
|
||||
```bash
|
||||
pfctl -sr | awk 'tolower($0) ~ /pass/ && $0 ~ / on igc1 / && tolower($0) ~ /inet6/ {print}'
|
||||
```
|
||||
|
||||
Résultat attendu : uniquement les règles DHCPv6 client.
|
||||
|
||||
À ce stade, n'importe quel client devrait pouvoir faire un [test d'IPv6](https://test-ipv6.com/) et obtenir un 10/10.
|
||||
|
||||

|
||||
|
||||
### Publication d'un site web en IPv6
|
||||
|
||||
#### Créer le AAAA public
|
||||
|
||||
Ajouter une entrée `AAAA` chez mon registrar ([OVH](https://www.ovhcloud.com/fr/)) avec l'adresse IP globale de l'interface _WAN_ obtenue depuis la Freebox.
|
||||
Cette adresse IP est dans le préfixe principal (celui dont le _Next Hop_ reste vide).
|
||||
|
||||
#### Ouvrir le firewall WAN en IPv6 pour le web
|
||||
|
||||
Ajouter l'équivalent IPv6 des règles IPv4 existantes, autrement dit :
|
||||
|
||||
- WAN `inet6` TCP `80` vers `wanip`
|
||||
- WAN `inet6` TCP `443` vers `wanip`
|
||||
|
||||
Rien de compliqué ici, c'est rigoureusement la même chose que pour IPv4.
|
||||
|
||||
#### Recharger et vérifier
|
||||
|
||||
Recharger le filtre :
|
||||
|
||||
```bash
|
||||
configctl filter reload
|
||||
```
|
||||
|
||||
Vérifier les règles actives :
|
||||
|
||||
```bash
|
||||
pfctl -sr | grep -E 'inet6 proto tcp.*port = http|inet6 proto tcp.*port = https'
|
||||
```
|
||||
|
||||
Résultat attendu : deux règles `pass in` WAN en IPv6 pour `80` et `443`.
|
||||
|
||||
Vérifier la résolution DNS AAAA :
|
||||
|
||||
```bash
|
||||
drill @1.1.1.1 AAAA richard-dern.fr
|
||||
```
|
||||
|
||||
Résultat attendu :
|
||||
|
||||
- `richard-dern.fr. ... AAAA ...:6789`
|
||||
|
||||
Vérifier l'accès HTTPS en IPv6 :
|
||||
|
||||
```bash
|
||||
curl -6 -I https://richard-dern.fr
|
||||
```
|
||||
|
||||
Résultat attendu : réponse `HTTP/2 200` (ou redirection HTTP vers HTTPS selon la politique du site).
|
||||
|
||||
### IRC en IPv6 : NAT ou routage ?
|
||||
|
||||
En IPv4, la publication d'un service passe très souvent par une [redirection de port](https://fr.wikipedia.org/wiki/Network_address_translation) (NAT) depuis l'IP publique du routeur vers une IP privée du serveur.
|
||||
En IPv6, un serveur du _LAN_ peut avoir une adresse globale routable, et la publication devient principalement une question de DNS (`AAAA`) et de filtrage firewall, pas de traduction d'adresse.
|
||||
|
||||
Autrement dit, ce qui "protège" le _LAN_ n'est pas le NAT, mais le fait que, par défaut, rien n'est autorisé en entrée tant qu'aucune règle `pass` n'est ajoutée sur _WAN_.
|
||||
Dans certains cas, on peut néanmoins conserver une logique de redirection IPv6 -> IPv6 (NAT66), par exemple quand un même nom de domaine doit pointer vers le routeur pour le web (reverse-proxy) tout en exposant d'autres ports vers un hôte interne.
|
||||
|
||||
Dans mon cas, je veux que `irc.dern.ovh` pointe vers le routeur pour servir le [client web](https://irc.dern.ovh/) via Caddy, et je redirige les ports [IRC](https://fr.wikipedia.org/wiki/Internet_Relay_Chat) vers `server-main` (la machine sur laquelle le serveur IRC est hébergé).
|
||||
|
||||
#### Pré-requis côté hôte (`server-main` sous NixOS)
|
||||
|
||||
Activer IPv6 dans la config NixOS de l'hôte :
|
||||
|
||||
```nix
|
||||
{
|
||||
networking = {
|
||||
enableIPv6 = true;
|
||||
tempAddresses = "disabled";
|
||||
};
|
||||
|
||||
systemd.services.dhcpcd.serviceConfig.SystemCallFilter = lib.mkForce [
|
||||
"@system-service"
|
||||
"~@aio"
|
||||
"~@keyring"
|
||||
"~@memlock"
|
||||
"~@mount"
|
||||
"~@resources"
|
||||
];
|
||||
}
|
||||
```
|
||||
|
||||
Ces lignes sont un ajustement **spécifique à ma configuration**, et non un prérequis générique à IPv6.
|
||||
Dans mon cas, `dhcpcd` était trop durci côté `systemd`, ce qui l'empêchait d'appliquer correctement la configuration réseau (adresses et routes).
|
||||
Forcer un `SystemCallFilter` compatible a permis à `dhcpcd` de fonctionner, tout en conservant des restrictions sur des familles d'appels système inutiles ici (comme `@mount`).
|
||||
|
||||
#### Mise en place
|
||||
|
||||
J'ai affecté un nom de domaine à l'usage d'IRC : `irc.dern.ovh`.
|
||||
Or, cet usage est double :
|
||||
|
||||
- c'est l'adresse du serveur IRC
|
||||
- c'est aussi l'adresse que l'on peut ouvrir dans un navigateur pour accéder au client web
|
||||
|
||||
C'est un cas d'usage idéal pour justifier le NAT en IPv6.
|
||||
Donc, lorsque j'ai créé une entrée DNS `AAAA` pour `irc.dern.ovh`, je l'ai fait pointer non pas vers mon serveur IRC mais _vers mon routeur_, puis j'ai créé une règle de NAT pour le serveur IRC.
|
||||
De cette façon, le reverse-proxy répond pour servir le client web, et on NAT vers le serveur IRC quand on cherche à joindre le port approprié.
|
||||
|
||||

|
||||
|
||||
## Conclusion
|
||||
|
||||
Je le redis : j'ai laissé les clés du château à ChatGPT.
|
||||
Je lui ai fait confiance, et j'ai eu raison de le faire.
|
||||
Il m'a épargné des jours de prises de tête pour une tâche face à laquelle j'ai souvent échoué.
|
||||
|
||||
En outre, au cours de nos échanges, il m'a expliqué ce que je ne comprenais pas.
|
||||
Et comme je suis chiant, je lui ai posé plein de questions.
|
||||
|
||||
Maintenant, j'ai un réseau IPv6 fonctionnel et j'ai compris ce que j'ai fait.
|
||||
Merci ChatGPT, et **merci à ceux qui ont permis que ces informations se retrouvent dans ce qu'il m'a proposé**.
|
||||
@@ -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
@@ -0,0 +1,3 @@
|
||||
---
|
||||
title: Recherche
|
||||
---
|
||||
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;
|
||||
|
||||
@@ -671,14 +671,14 @@ main > article figure.figure-media {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
main > article > figure.figure-media.figure-media-with-meta:nth-of-type(odd) {
|
||||
main > article > figure.figure-media.figure-media-with-meta:not(.figure-media-centered):nth-of-type(odd) {
|
||||
--figure-media-column: 2;
|
||||
--figure-meta-column: 1;
|
||||
--figure-media-justify: start;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1.7fr);
|
||||
}
|
||||
|
||||
main > article > figure.figure-media.figure-media-with-meta:nth-of-type(odd) > figcaption.figure-media-meta {
|
||||
main > article > figure.figure-media.figure-media-with-meta:not(.figure-media-centered):nth-of-type(odd) > figcaption.figure-media-meta {
|
||||
text-align: right;
|
||||
border-left: 0;
|
||||
padding-left: 0;
|
||||
@@ -726,16 +726,33 @@ main > article figure.figure-media.figure-media-without-meta > :is(video, img, a
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
main > article figure.figure-media.figure-media-centered {
|
||||
--figure-media-column: 1;
|
||||
--figure-meta-column: 1;
|
||||
--figure-media-justify: center;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
main > article figure.figure-media.figure-media-centered > :is(video, img, a) {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
main > article figure.figure-media > figcaption.figure-media-meta {
|
||||
grid-column: var(--figure-meta-column);
|
||||
grid-row: 1;
|
||||
margin-top: 0;
|
||||
border-top: 1px solid var(--color-border-strong);
|
||||
border-left: 1px solid var(--color-border-strong);
|
||||
padding-left: var(--space-6);
|
||||
padding-top: var(--space-2);
|
||||
padding-bottom: var(--space-2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100%;
|
||||
gap: var(--space-2);
|
||||
background: color-mix(in srgb, var(--color-background-alt) 82%, transparent);
|
||||
}
|
||||
|
||||
main > article figure.figure-media > figcaption.figure-media-meta > .figure-media-meta-main {
|
||||
@@ -752,19 +769,62 @@ main > article figure.figure-media > figcaption.figure-media-meta > .figure-medi
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
main > article figure.figure-media > figcaption.figure-media-meta > .figure-media-meta-extra > p.cover-attribution {
|
||||
border-top: var(--border-width-regular) solid var(--color-accent-2);
|
||||
padding-top: var(--space-4);
|
||||
}
|
||||
|
||||
main > article figure.figure-media > figcaption.figure-media-meta p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
main > article > figure.figure-media.figure-media-with-meta:nth-of-type(odd) > figcaption.figure-media-meta > .figure-media-meta-extra > p.cover-attribution {
|
||||
main > article figure.figure-media > figcaption.figure-media-meta .cover-title {
|
||||
font-size: clamp(0.98rem, 1.15vw, 1.06rem);
|
||||
letter-spacing: 0.03em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
main > article figure.figure-media > figcaption.figure-media-meta .cover-description {
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
main > article figure.figure-media > figcaption.figure-media-meta > .figure-media-meta-extra > p.cover-attribution {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: var(--space-2);
|
||||
}
|
||||
|
||||
main > article > figure.figure-media.figure-media-with-meta:not(.figure-media-centered):nth-of-type(odd) > figcaption.figure-media-meta > .figure-media-meta-extra > p.cover-attribution {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
main > article figure.figure-media.figure-media-centered > figcaption.figure-media-meta {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
width: min(100%, 72ch);
|
||||
margin-inline: auto;
|
||||
min-height: 0;
|
||||
text-align: center;
|
||||
border-left: 0;
|
||||
border-right: 0;
|
||||
padding-left: var(--space-2);
|
||||
padding-right: var(--space-2);
|
||||
}
|
||||
|
||||
main > article figure.figure-media.figure-media-centered > figcaption.figure-media-meta > .figure-media-meta-main,
|
||||
main > article figure.figure-media.figure-media-centered > figcaption.figure-media-meta > .figure-media-meta-extra {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
main > article figure.figure-media.figure-media-centered > figcaption.figure-media-meta > .figure-media-meta-main > :is(.cover-title, .cover-description),
|
||||
main > article figure.figure-media.figure-media-centered > figcaption.figure-media-meta > .figure-media-meta-extra > :is(.cover-attribution, details) {
|
||||
width: min(100%, 68ch);
|
||||
}
|
||||
|
||||
main > article figure.figure-media.figure-media-centered > figcaption.figure-media-meta > .figure-media-meta-extra > p.cover-attribution {
|
||||
width: min(100%, 68ch);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
main > article figure.figure-media.figure-media-centered > figcaption.figure-media-meta > .figure-media-meta-extra > details {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
main > article figure.figure-media > figcaption.figure-media-meta > .figure-media-meta-extra > details {
|
||||
margin-top: 0;
|
||||
}
|
||||
@@ -817,7 +877,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 +888,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);
|
||||
}
|
||||
|
||||
@@ -979,13 +1041,17 @@ body:has(> header.article-header) > main > aside.article-toc nav[aria-label="Som
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
main > article > figure.figure-media.figure-media-with-meta:nth-of-type(odd) {
|
||||
main > article > figure.figure-media.figure-media-with-meta:not(.figure-media-centered):nth-of-type(odd) {
|
||||
--figure-media-column: 1;
|
||||
--figure-meta-column: 1;
|
||||
--figure-media-justify: start;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
main > article figure.figure-media.figure-media-centered {
|
||||
--figure-media-justify: center;
|
||||
}
|
||||
|
||||
main > article figure.figure-media > :is(video, img, a),
|
||||
main > article figure.figure-media > figcaption.figure-media-meta {
|
||||
grid-column: 1;
|
||||
@@ -1004,11 +1070,11 @@ body:has(> header.article-header) > main > aside.article-toc nav[aria-label="Som
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
main > article > figure.figure-media.figure-media-with-meta:nth-of-type(odd) > figcaption.figure-media-meta {
|
||||
main > article > figure.figure-media.figure-media-with-meta:not(.figure-media-centered):nth-of-type(odd) > figcaption.figure-media-meta {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
main > article > figure.figure-media.figure-media-with-meta:nth-of-type(odd) > figcaption.figure-media-meta > .figure-media-meta-extra > p.cover-attribution {
|
||||
main > article > figure.figure-media.figure-media-with-meta:not(.figure-media-centered):nth-of-type(odd) > figcaption.figure-media-meta > .figure-media-meta-extra > p.cover-attribution {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
@@ -1017,7 +1083,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
@@ -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>
|
||||
|
||||
@@ -19,11 +19,46 @@
|
||||
{{- end -}}
|
||||
{{- $metaTitle := index $data "title" -}}
|
||||
{{- $hasMeta := or $metaTitle (or $description (or $data.attribution $data.prompt)) -}}
|
||||
<figure class="figure-media{{ if $hasMeta }} figure-media-with-meta{{ else }} figure-media-without-meta{{ end }}">
|
||||
{{- $attributes := .Attributes | default dict -}}
|
||||
{{- $imageClasses := "" -}}
|
||||
{{- with index $attributes "class" -}}
|
||||
{{- $imageClasses = lower (trim . " ") -}}
|
||||
{{- end -}}
|
||||
{{- $layout := "" -}}
|
||||
{{- with index $attributes "layout" -}}
|
||||
{{- $layout = lower (trim . " ") -}}
|
||||
{{- end -}}
|
||||
{{- $hasCenterClass := gt (len (findRE `(^|\\s)(center|centered)(\\s|$)` $imageClasses)) 0 -}}
|
||||
{{- $hasFullClass := gt (len (findRE `(^|\\s)(full|full-width|wide)(\\s|$)` $imageClasses)) 0 -}}
|
||||
{{- $forceCentered := false -}}
|
||||
{{- if or (in (slice "center" "centered") $layout) $hasCenterClass -}}
|
||||
{{- $forceCentered = true -}}
|
||||
{{- end -}}
|
||||
{{- if or (in (slice "full" "full-width" "wide") $layout) $hasFullClass -}}
|
||||
{{- $forceCentered = true -}}
|
||||
{{- end -}}
|
||||
{{- $forceWithoutMeta := false -}}
|
||||
{{- if or (in (slice "full" "full-width" "wide") $layout) $hasFullClass -}}
|
||||
{{- $forceWithoutMeta = true -}}
|
||||
{{- end -}}
|
||||
{{- $showMeta := and $hasMeta (not $forceWithoutMeta) -}}
|
||||
{{- $figureClasses := slice "figure-media" -}}
|
||||
{{- if $showMeta -}}
|
||||
{{- $figureClasses = $figureClasses | append "figure-media-with-meta" -}}
|
||||
{{- else -}}
|
||||
{{- $figureClasses = $figureClasses | append "figure-media-without-meta" -}}
|
||||
{{- end -}}
|
||||
{{- if $forceCentered -}}
|
||||
{{- $figureClasses = $figureClasses | append "figure-media-centered" -}}
|
||||
{{- end -}}
|
||||
{{- with $imageClasses -}}
|
||||
{{- $figureClasses = $figureClasses | append . -}}
|
||||
{{- end -}}
|
||||
<figure class="{{ delimit $figureClasses " " }}">
|
||||
<a href="{{ $image.RelPermalink }}" title="Cliquez pour agrandir l'image">
|
||||
<img src="{{ $display.RelPermalink }}" alt="{{ $alt }}" title="{{ $title }}">
|
||||
</a>
|
||||
{{- if $hasMeta -}}
|
||||
{{- if $showMeta -}}
|
||||
<figcaption class="figure-media-meta cover-meta">
|
||||
<div class="figure-media-meta-main">
|
||||
{{- with $metaTitle -}}
|
||||
|
||||
@@ -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>
|
||||
|
||||