From d33d7459efe9aa416bc2f5a53381a84bc790a2d5 Mon Sep 17 00:00:00 2001 From: Richard Dern Date: Mon, 23 Feb 2026 15:10:11 +0100 Subject: [PATCH] Formulaire de recherche --- assets/js/search-page.js | 496 ++++++++++++++++++ config/_default/params.yaml | 7 + content/recherche/index.md | 3 + layouts/recherche/single.html | 44 ++ themes/2026/assets/css/header.css | 38 ++ themes/2026/assets/css/index.css | 1 + themes/2026/assets/css/responsive.css | 33 ++ themes/2026/assets/css/search.css | 72 +++ .../2026/layouts/_partials/header-brand.html | 2 +- themes/2026/layouts/_partials/site-title.html | 8 + 10 files changed, 703 insertions(+), 1 deletion(-) create mode 100644 assets/js/search-page.js create mode 100644 content/recherche/index.md create mode 100644 layouts/recherche/single.html create mode 100644 themes/2026/assets/css/search.css diff --git a/assets/js/search-page.js b/assets/js/search-page.js new file mode 100644 index 00000000..2552c5e7 --- /dev/null +++ b/assets/js/search-page.js @@ -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} + */ +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(); diff --git a/config/_default/params.yaml b/config/_default/params.yaml index b3d71bd9..32624461 100644 --- a/config/_default/params.yaml +++ b/config/_default/params.yaml @@ -3,3 +3,10 @@ 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 + hitsPerPage: 20 diff --git a/content/recherche/index.md b/content/recherche/index.md new file mode 100644 index 00000000..b2c03c8d --- /dev/null +++ b/content/recherche/index.md @@ -0,0 +1,3 @@ +--- +title: Recherche +--- diff --git a/layouts/recherche/single.html b/layouts/recherche/single.html new file mode 100644 index 00000000..32753928 --- /dev/null +++ b/layouts/recherche/single.html @@ -0,0 +1,44 @@ +{{ define "main" }} +{{- $search := .Site.Params.search -}} +{{- $meili := $search.meilisearch -}} +
+ {{ partialCached "header-brand.html" .Site .Site.Title (.Site.Params.logo | default "logo-large.png") }} +

{{ .Title }}

+
+
+ + + + + +
+ +{{- $searchJS := resources.Get "js/search-page.js" -}} +{{- if eq hugo.Environment "development" -}} + +{{- else -}} +{{- with $searchJS | minify | fingerprint -}} + +{{- end -}} +{{- end -}} +{{ end }} diff --git a/themes/2026/assets/css/header.css b/themes/2026/assets/css/header.css index f6266b12..88653563 100644 --- a/themes/2026/assets/css/header.css +++ b/themes/2026/assets/css/header.css @@ -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); } diff --git a/themes/2026/assets/css/index.css b/themes/2026/assets/css/index.css index 783c4bd6..a0b88edf 100644 --- a/themes/2026/assets/css/index.css +++ b/themes/2026/assets/css/index.css @@ -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"; diff --git a/themes/2026/assets/css/responsive.css b/themes/2026/assets/css/responsive.css index 03157a3f..8e67d56c 100644 --- a/themes/2026/assets/css/responsive.css +++ b/themes/2026/assets/css/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); } diff --git a/themes/2026/assets/css/search.css b/themes/2026/assets/css/search.css new file mode 100644 index 00000000..9d58299c --- /dev/null +++ b/themes/2026/assets/css/search.css @@ -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; +} diff --git a/themes/2026/layouts/_partials/header-brand.html b/themes/2026/layouts/_partials/header-brand.html index a7e48b59..9e41d8dc 100644 --- a/themes/2026/layouts/_partials/header-brand.html +++ b/themes/2026/layouts/_partials/header-brand.html @@ -1,3 +1,3 @@
- {{ 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 }}
diff --git a/themes/2026/layouts/_partials/site-title.html b/themes/2026/layouts/_partials/site-title.html index 3f9e421c..ae2683a7 100644 --- a/themes/2026/layouts/_partials/site-title.html +++ b/themes/2026/layouts/_partials/site-title.html @@ -11,3 +11,11 @@ {{- end -}} {{ $site.Title }} +