Formulaire de recherche
This commit is contained in:
496
assets/js/search-page.js
Normal file
496
assets/js/search-page.js
Normal file
@@ -0,0 +1,496 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* Lit la configuration exposee par le template Hugo.
|
||||
* @param {HTMLElement} root Element principal de la page.
|
||||
* @returns {{endpoint: string, indexUid: string, apiKey: string, limit: number, queryParam: string}}
|
||||
*/
|
||||
function readSearchConfig(root) {
|
||||
const endpoint = root.dataset.searchEndpoint;
|
||||
const indexUid = root.dataset.searchIndex;
|
||||
const apiKey = root.dataset.searchApiKey;
|
||||
const limitValue = root.dataset.searchLimit;
|
||||
const queryParam = root.dataset.searchParam;
|
||||
|
||||
if (typeof endpoint !== "string" || endpoint.length === 0) {
|
||||
throw new Error("Missing search endpoint");
|
||||
}
|
||||
|
||||
if (typeof indexUid !== "string" || indexUid.length === 0) {
|
||||
throw new Error("Missing search index");
|
||||
}
|
||||
|
||||
if (typeof apiKey !== "string") {
|
||||
throw new Error("Invalid search api key");
|
||||
}
|
||||
|
||||
if (typeof limitValue !== "string" || limitValue.length === 0) {
|
||||
throw new Error("Missing search limit");
|
||||
}
|
||||
|
||||
if (typeof queryParam !== "string" || queryParam.length === 0) {
|
||||
throw new Error("Missing search query parameter");
|
||||
}
|
||||
|
||||
const limit = Number.parseInt(limitValue, 10);
|
||||
if (Number.isNaN(limit) || limit < 1) {
|
||||
throw new Error("Invalid search limit");
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint,
|
||||
indexUid,
|
||||
apiKey,
|
||||
limit,
|
||||
queryParam,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lit la requete de recherche depuis l'URL.
|
||||
* @param {string} queryParam Nom du parametre d'URL.
|
||||
* @returns {string}
|
||||
*/
|
||||
function readSearchQuery(queryParam) {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const rawValue = params.get(queryParam);
|
||||
|
||||
if (rawValue === null) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return rawValue.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit le payload envoye a Meilisearch.
|
||||
* @param {string} query Texte recherche.
|
||||
* @param {number} limit Nombre de resultats par lot.
|
||||
* @param {number} offset Position de depart.
|
||||
* @returns {{q: string, limit: number, offset: number, attributesToRetrieve: string[]}}
|
||||
*/
|
||||
function buildSearchPayload(query, limit, offset) {
|
||||
return {
|
||||
q: query,
|
||||
limit,
|
||||
offset,
|
||||
attributesToRetrieve: ["title", "path", "published_at"],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Envoie une requete HTTP a Meilisearch pour un lot de resultats.
|
||||
* @param {{endpoint: string, indexUid: string, apiKey: string, limit: number}} config Configuration de recherche.
|
||||
* @param {string} query Texte recherche.
|
||||
* @param {number} offset Position de depart.
|
||||
* @returns {Promise<{ok: boolean, status: number, data: any}>}
|
||||
*/
|
||||
async function fetchSearchChunk(config, query, offset) {
|
||||
const endpoint = config.endpoint.replace(/\/+$/, "");
|
||||
const requestURL = endpoint + "/indexes/" + encodeURIComponent(config.indexUid) + "/search";
|
||||
const headers = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
if (config.apiKey.length > 0) {
|
||||
headers.Authorization = "Bearer " + config.apiKey;
|
||||
}
|
||||
|
||||
const response = await fetch(requestURL, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(buildSearchPayload(query, config.limit, offset)),
|
||||
});
|
||||
|
||||
if (response.ok === false) {
|
||||
return {
|
||||
ok: false,
|
||||
status: response.status,
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return {
|
||||
ok: true,
|
||||
status: response.status,
|
||||
data,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Valide et normalise le payload brut de Meilisearch.
|
||||
* @param {any} payload Donnees brutes.
|
||||
* @returns {{hits: any[], total: number}}
|
||||
*/
|
||||
function parseSearchPayload(payload) {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
throw new Error("Invalid search payload");
|
||||
}
|
||||
|
||||
if (!Array.isArray(payload.hits)) {
|
||||
throw new Error("Invalid hits payload");
|
||||
}
|
||||
|
||||
const total = Number.parseInt(String(payload.estimatedTotalHits), 10);
|
||||
if (Number.isNaN(total)) {
|
||||
throw new Error("Invalid total hits value");
|
||||
}
|
||||
|
||||
return {
|
||||
hits: payload.hits,
|
||||
total,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Vide tous les enfants d'un noeud.
|
||||
* @param {HTMLElement} node Noeud cible.
|
||||
*/
|
||||
function clearNode(node) {
|
||||
while (node.firstChild) {
|
||||
node.firstChild.remove();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Met a jour le message de statut.
|
||||
* @param {HTMLElement} statusNode Zone de statut.
|
||||
* @param {string} message Message a afficher.
|
||||
*/
|
||||
function updateStatus(statusNode, message) {
|
||||
statusNode.textContent = message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Affiche ou masque une section.
|
||||
* @param {HTMLElement} section Section cible.
|
||||
* @param {boolean} visible Etat de visibilite.
|
||||
*/
|
||||
function setSectionVisibility(section, visible) {
|
||||
if (visible) {
|
||||
section.removeAttribute("hidden");
|
||||
return;
|
||||
}
|
||||
|
||||
section.setAttribute("hidden", "hidden");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convertit un hit brut en resultat exploitable.
|
||||
* @param {any} hit Resultat brut.
|
||||
* @returns {{title: string, path: string, published_at: string} | null}
|
||||
*/
|
||||
function normalizeHit(hit) {
|
||||
if (typeof hit !== "object" || hit === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof hit.path !== "string" || hit.path.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let title = hit.path.trim();
|
||||
if (typeof hit.title === "string" && hit.title.trim().length > 0) {
|
||||
title = hit.title.trim();
|
||||
}
|
||||
|
||||
let publishedAt = "";
|
||||
if (typeof hit.published_at === "string" && hit.published_at.trim().length > 0) {
|
||||
publishedAt = hit.published_at.trim();
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
path: hit.path.trim(),
|
||||
published_at: publishedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Formate la date en ignorant l'heure de publication.
|
||||
* @param {unknown} rawDate Date brute.
|
||||
* @returns {{datetime: string, display: string}}
|
||||
*/
|
||||
function buildDateInfo(rawDate) {
|
||||
if (typeof rawDate !== "string") {
|
||||
return {
|
||||
datetime: "",
|
||||
display: "Date inconnue",
|
||||
};
|
||||
}
|
||||
|
||||
const match = rawDate.trim().match(/^(\d{4})-(\d{2})-(\d{2})(?:\s+\d{2}:\d{2}(?::\d{2})?)?$/);
|
||||
if (match === null) {
|
||||
return {
|
||||
datetime: "",
|
||||
display: "Date inconnue",
|
||||
};
|
||||
}
|
||||
|
||||
const year = match[1];
|
||||
const month = match[2];
|
||||
const day = match[3];
|
||||
|
||||
return {
|
||||
datetime: year + "-" + month + "-" + day,
|
||||
display: day + "/" + month + "/" + year,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrait un libelle de section a partir du chemin Hugo.
|
||||
* @param {string} path Chemin relatif de l'article.
|
||||
* @returns {string}
|
||||
*/
|
||||
function extractSectionLabel(path) {
|
||||
const segments = path.split("/").filter(Boolean);
|
||||
if (segments.length === 0) {
|
||||
return "Section inconnue";
|
||||
}
|
||||
|
||||
const firstSegment = segments[0];
|
||||
if (firstSegment === "collections") {
|
||||
return "Collections";
|
||||
}
|
||||
|
||||
if (firstSegment === "critiques") {
|
||||
return "Critiques";
|
||||
}
|
||||
|
||||
if (firstSegment === "interets") {
|
||||
return "Intérêts";
|
||||
}
|
||||
|
||||
if (firstSegment === "stats") {
|
||||
return "Statistiques";
|
||||
}
|
||||
|
||||
if (firstSegment === "taxonomies") {
|
||||
return "Taxonomies";
|
||||
}
|
||||
|
||||
if (firstSegment === "contact") {
|
||||
return "Contact";
|
||||
}
|
||||
|
||||
if (firstSegment === "manifeste") {
|
||||
return "Manifeste";
|
||||
}
|
||||
|
||||
if (firstSegment === "liens-morts") {
|
||||
return "Liens morts";
|
||||
}
|
||||
|
||||
if (firstSegment === "revue-de-presse") {
|
||||
return "Revue de presse";
|
||||
}
|
||||
|
||||
return firstSegment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construit un article de resultat simple.
|
||||
* @param {{title: string, path: string, published_at?: string}} hit Resultat.
|
||||
* @returns {HTMLElement}
|
||||
*/
|
||||
function createResultArticle(hit) {
|
||||
const article = document.createElement("article");
|
||||
|
||||
const title = document.createElement("h3");
|
||||
const link = document.createElement("a");
|
||||
link.href = hit.path;
|
||||
link.textContent = hit.title;
|
||||
title.append(link);
|
||||
article.append(title);
|
||||
|
||||
const meta = document.createElement("p");
|
||||
meta.className = "search-result-meta";
|
||||
|
||||
const dateInfo = buildDateInfo(hit.published_at);
|
||||
if (dateInfo.datetime.length > 0) {
|
||||
const time = document.createElement("time");
|
||||
time.dateTime = dateInfo.datetime;
|
||||
time.textContent = dateInfo.display;
|
||||
meta.append(time);
|
||||
} else {
|
||||
const dateText = document.createElement("span");
|
||||
dateText.textContent = dateInfo.display;
|
||||
meta.append(dateText);
|
||||
}
|
||||
|
||||
const separator = document.createElement("span");
|
||||
separator.textContent = "·";
|
||||
meta.append(separator);
|
||||
|
||||
const section = document.createElement("span");
|
||||
section.textContent = extractSectionLabel(hit.path);
|
||||
meta.append(section);
|
||||
|
||||
article.append(meta);
|
||||
|
||||
const path = document.createElement("p");
|
||||
path.className = "search-result-path";
|
||||
path.textContent = hit.path;
|
||||
article.append(path);
|
||||
|
||||
return article;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rend la liste des resultats.
|
||||
* @param {HTMLOListElement} listNode Liste cible.
|
||||
* @param {Array<{title: string, path: string, published_at?: string}>} hits Resultats a afficher.
|
||||
*/
|
||||
function renderListing(listNode, hits) {
|
||||
clearNode(listNode);
|
||||
|
||||
for (const hit of hits) {
|
||||
const item = document.createElement("li");
|
||||
item.append(createResultArticle(hit));
|
||||
listNode.append(item);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Recupere tous les resultats disponibles en enchainant les pages.
|
||||
* @param {{endpoint: string, indexUid: string, apiKey: string, limit: number}} config Configuration de recherche.
|
||||
* @param {string} query Texte recherche.
|
||||
* @returns {Promise<{ok: boolean, status: number, data: {hits: Array<{title: string, path: string, published_at: string}>, estimatedTotalHits: number} | null}>}
|
||||
*/
|
||||
async function fetchAllSearchResults(config, query) {
|
||||
const allHits = [];
|
||||
const seenPaths = new Set();
|
||||
let expectedTotal = 0;
|
||||
let offset = 0;
|
||||
let lastStatus = 200;
|
||||
|
||||
while (true) {
|
||||
const chunkResponse = await fetchSearchChunk(config, query, offset);
|
||||
lastStatus = chunkResponse.status;
|
||||
|
||||
if (chunkResponse.ok === false) {
|
||||
return {
|
||||
ok: false,
|
||||
status: chunkResponse.status,
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
const parsed = parseSearchPayload(chunkResponse.data);
|
||||
expectedTotal = parsed.total;
|
||||
|
||||
if (parsed.hits.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (const rawHit of parsed.hits) {
|
||||
const normalized = normalizeHit(rawHit);
|
||||
if (normalized === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seenPaths.has(normalized.path)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seenPaths.add(normalized.path);
|
||||
allHits.push(normalized);
|
||||
}
|
||||
|
||||
offset += parsed.hits.length;
|
||||
|
||||
if (offset >= expectedTotal) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (parsed.hits.length < config.limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
status: lastStatus,
|
||||
data: {
|
||||
hits: allHits,
|
||||
estimatedTotalHits: expectedTotal,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Lance la recherche au chargement de la page.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function initSearchPage() {
|
||||
const root = document.querySelector("main.search-page");
|
||||
if (!(root instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const status = root.querySelector("[data-search-status]");
|
||||
const listingSection = root.querySelector("[data-search-listing]");
|
||||
const results = root.querySelector("[data-search-results]");
|
||||
|
||||
if (!(status instanceof HTMLElement)) {
|
||||
throw new Error("Missing search status");
|
||||
}
|
||||
|
||||
if (!(listingSection instanceof HTMLElement)) {
|
||||
throw new Error("Missing listing section");
|
||||
}
|
||||
|
||||
if (!(results instanceof HTMLOListElement)) {
|
||||
throw new Error("Missing results list");
|
||||
}
|
||||
|
||||
const config = readSearchConfig(root);
|
||||
const query = readSearchQuery(config.queryParam);
|
||||
|
||||
const headerInput = document.getElementById("header-search-input");
|
||||
if (headerInput instanceof HTMLInputElement) {
|
||||
headerInput.value = query;
|
||||
}
|
||||
|
||||
if (query.length === 0) {
|
||||
updateStatus(status, "Utilisez le champ de recherche dans l'en-tete.");
|
||||
setSectionVisibility(listingSection, false);
|
||||
clearNode(results);
|
||||
return;
|
||||
}
|
||||
|
||||
updateStatus(status, "Recherche en cours...");
|
||||
|
||||
const response = await fetchAllSearchResults(config, query);
|
||||
if (response.ok === false) {
|
||||
updateStatus(status, "Erreur Meilisearch (HTTP " + String(response.status) + ").");
|
||||
setSectionVisibility(listingSection, false);
|
||||
clearNode(results);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = parseSearchPayload(response.data);
|
||||
const totalHits = payload.total;
|
||||
const displayedHits = payload.hits.length;
|
||||
|
||||
if (totalHits === 0 || displayedHits === 0) {
|
||||
updateStatus(status, "Aucun resultat.");
|
||||
setSectionVisibility(listingSection, false);
|
||||
clearNode(results);
|
||||
return;
|
||||
}
|
||||
|
||||
renderListing(results, payload.hits);
|
||||
setSectionVisibility(listingSection, true);
|
||||
|
||||
if (totalHits > displayedHits) {
|
||||
const message = String(totalHits) + " resultats trouves. " + String(displayedHits) + " affiches. Affinez votre recherche.";
|
||||
updateStatus(status, message);
|
||||
return;
|
||||
}
|
||||
|
||||
updateStatus(status, String(totalHits) + " resultats.");
|
||||
}
|
||||
|
||||
void initSearchPage();
|
||||
Reference in New Issue
Block a user