1

Formulaire de recherche

This commit is contained in:
2026-02-23 15:10:11 +01:00
parent a90ea13be9
commit d33d7459ef
10 changed files with 703 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();