"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} */ 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();