Formulaire de recherche

This commit is contained in:
2026-02-23 15:10:11 +01:00
parent a90ea13be9
commit 7f614688d0
10 changed files with 704 additions and 1 deletions

496
assets/js/search-page.js Normal file
View 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();

View File

@@ -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

View File

@@ -0,0 +1,3 @@
---
title: Recherche
---

View 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 }}

View File

@@ -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);
}

View File

@@ -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";

View File

@@ -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);
}

View 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;
}

View File

@@ -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>

View File

@@ -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>