recherche: barre écosystème dans le header + page /recherche propulsée par Trouve-Ka (pivot moteur Groupe KA)
5 changed files +437 −3
added
src/app/SearchBar.tsx
+84 −0
@@ -0,0 +1,84 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Barre de recherche « tout l'écosystème KA » — simple formulaire GET vers | |
| 3 | +// /recherche (rendu serveur, moteur Trouve·Ka). Deux tailles : compacte pour | |
| 4 | +// le header desktop (défaut), grande pour la page /recherche (hero). | |
| 5 | +"use client"; | |
| 6 | + | |
| 7 | +export default function SearchBar({ | |
| 8 | + size = "header", | |
| 9 | + defaultQuery = "", | |
| 10 | + site, | |
| 11 | +}: { | |
| 12 | + size?: "header" | "hero"; | |
| 13 | + defaultQuery?: string; | |
| 14 | + site?: string; | |
| 15 | +}) { | |
| 16 | + if (size === "hero") { | |
| 17 | + return ( | |
| 18 | + <form | |
| 19 | + action="/recherche" | |
| 20 | + role="search" | |
| 21 | + className="flex w-full max-w-xl items-center gap-2" | |
| 22 | + > | |
| 23 | + <input | |
| 24 | + type="search" | |
| 25 | + name="q" | |
| 26 | + defaultValue={defaultQuery} | |
| 27 | + className="input flex-1" | |
| 28 | + placeholder="Rechercher dans l'écosystème KA…" | |
| 29 | + aria-label="Recherche" | |
| 30 | + autoComplete="off" | |
| 31 | + /> | |
| 32 | + {site ? <input type="hidden" name="site" value={site} /> : null} | |
| 33 | + <button type="submit" className="btn btn-primary shrink-0"> | |
| 34 | + Rechercher | |
| 35 | + </button> | |
| 36 | + </form> | |
| 37 | + ); | |
| 38 | + } | |
| 39 | + // Header : la rangée de nav est déjà très chargée (12 items + compte + | |
| 40 | + // CTA). En dessous de ~1900 px, la barre complète pousserait le bouton | |
| 41 | + // compte et « Écrivez-nous » hors écran (mesuré Playwright 2026-08-23) : | |
| 42 | + // on affiche alors une icône-lien vers /recherche, et la barre complète | |
| 43 | + // seulement sur les très grands écrans. Affichage conditionnel en CSS pur | |
| 44 | + // (aucun réordonnancement entre breakpoints). | |
| 45 | + // NB : .btn est redéfini HORS layer dans globals.css (il bat les utilities | |
| 46 | + // Tailwind) → la visibilité responsive se pose sur des conteneurs neutres, | |
| 47 | + // jamais sur l'élément .btn lui-même. | |
| 48 | + return ( | |
| 49 | + <div className="flex items-center"> | |
| 50 | + <span className="min-[1950px]:hidden"> | |
| 51 | + <a | |
| 52 | + href="/recherche" | |
| 53 | + className="btn" | |
| 54 | + aria-label="Recherche dans l'écosystème KA" | |
| 55 | + title="Rechercher dans l'écosystème KA" | |
| 56 | + > | |
| 57 | + ⌕ | |
| 58 | + </a> | |
| 59 | + </span> | |
| 60 | + <form | |
| 61 | + action="/recherche" | |
| 62 | + role="search" | |
| 63 | + className="hidden items-center gap-1.5 min-[1950px]:flex" | |
| 64 | + > | |
| 65 | + <input | |
| 66 | + type="search" | |
| 67 | + name="q" | |
| 68 | + className="input w-40 px-3 text-[13.5px] min-[2100px]:w-56" | |
| 69 | + placeholder="Rechercher dans l'écosystème KA…" | |
| 70 | + aria-label="Recherche" | |
| 71 | + autoComplete="off" | |
| 72 | + /> | |
| 73 | + <button | |
| 74 | + type="submit" | |
| 75 | + className="btn shrink-0" | |
| 76 | + aria-label="Lancer la recherche" | |
| 77 | + title="Rechercher dans l'écosystème KA" | |
| 78 | + > | |
| 79 | + ⌕ | |
| 80 | + </button> | |
| 81 | + </form> | |
| 82 | + </div> | |
| 83 | + ); | |
| 84 | +} | |
modified
src/app/layout.tsx
+17 −3
@@ -2,6 +2,7 @@ | ||
| 2 | 2 | import type { Metadata, Viewport } from "next"; |
| 3 | 3 | import { Inter, JetBrains_Mono, Space_Grotesk } from "next/font/google"; |
| 4 | 4 | import UserButton from "./UserButton"; |
| 5 | +import SearchBar from "./SearchBar"; | |
| 5 | 6 | import { getLiveMetrics, fmtDec, fmtInt, fmtPlus, type LiveMetrics } from "@/lib/live"; |
| 6 | 7 | import "./globals.css"; |
| 7 | 8 | |
@@ -126,6 +127,13 @@ const NAV_ITEMS = [ | ||
| 126 | 127 | { href: "/#contact", label: "Contact" }, |
| 127 | 128 | ]; |
| 128 | 129 | |
| 130 | +// Nav mobile = NAV_ITEMS + « Recherche » (sur desktop, la barre de recherche | |
| 131 | +// du header suffit — pas d'item redondant dans la nav). | |
| 132 | +const MOBILE_NAV_ITEMS = [ | |
| 133 | + { href: "/recherche", label: "Recherche" }, | |
| 134 | + ...NAV_ITEMS, | |
| 135 | +]; | |
| 136 | + | |
| 129 | 137 | // Ticker construit sur les métriques LIVE des plateformes (src/lib/live.ts). |
| 130 | 138 | const tickerItems = (m: LiveMetrics): string[] => [ |
| 131 | 139 | "·Ka = agréger — mille sites, un seul endroit", |
@@ -191,17 +199,23 @@ export default async function RootLayout({ | ||
| 191 | 199 | <Wordmark /> |
| 192 | 200 | </a> |
| 193 | 201 | <nav aria-label="Navigation principale" className="hidden xl:block"> |
| 194 | − <ul className="flex items-center gap-1"> | |
| 202 | + {/* gap-0.5 + px-2.5 (au lieu de gap-1 + px-3) : la rangée était | |
| 203 | + déjà plus large que le conteneur max-w-6xl sous ~1660 px — | |
| 204 | + resserrée pour faire place à la recherche sans rien perdre. */} | |
| 205 | + <ul className="flex items-center gap-0.5"> | |
| 195 | 206 | {NAV_ITEMS.map((item) => ( |
| 196 | 207 | <li key={item.href}> |
| 197 | 208 | <a |
| 198 | 209 | href={item.href} |
| 199 | − className="gk-display inline-block rounded-full border-[1.5px] border-transparent px-3 py-[7px] text-[13.5px] font-bold whitespace-nowrap text-ink-2 transition-colors hover:border-ink hover:text-ink" | |
| 210 | + className="gk-display inline-block rounded-full border-[1.5px] border-transparent px-2.5 py-[7px] text-[13.5px] font-bold whitespace-nowrap text-ink-2 transition-colors hover:border-ink hover:text-ink" | |
| 200 | 211 | > |
| 201 | 212 | {item.label} |
| 202 | 213 | </a> |
| 203 | 214 | </li> |
| 204 | 215 | ))} |
| 216 | + <li className="ml-1 hidden xl:flex"> | |
| 217 | + <SearchBar /> | |
| 218 | + </li> | |
| 205 | 219 | <li className="ml-1"> |
| 206 | 220 | <UserButton /> |
| 207 | 221 | </li> |
@@ -250,7 +264,7 @@ export default async function RootLayout({ | ||
| 250 | 264 | </div> |
| 251 | 265 | <nav aria-label="Menu mobile" className="min-h-0 flex-1 overflow-y-auto px-6 pt-8 pb-10"> |
| 252 | 266 | <ul> |
| 253 | − {NAV_ITEMS.map((item, i) => ( | |
| 267 | + {MOBILE_NAV_ITEMS.map((item, i) => ( | |
| 254 | 268 | <li |
| 255 | 269 | key={item.href} |
| 256 | 270 | className="border-b border-dashed border-[rgba(20,24,20,0.25)]" |
added
src/app/recherche/page.tsx
+233 −0
@@ -0,0 +1,233 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// /recherche — la recherche « tout l'écosystème KA », propulsée par le | |
| 3 | +// moteur Trouve·Ka (pivot 2026-08 : Trouve·Ka devient le moteur de recherche | |
| 4 | +// du Groupe KA et n'indexe plus que les 14 sites de l'écosystème). | |
| 5 | +// Rendu 100 % serveur : formulaire GET, pagination et filtres par site en | |
| 6 | +// liens. Échec réseau = repli poli, jamais de page cassée. | |
| 7 | +import type { Metadata } from "next"; | |
| 8 | +import SearchBar from "../SearchBar"; | |
| 9 | +import { decodeEntities, sanitizeSnippet, searchTrouveKa } from "@/lib/search"; | |
| 10 | +import { fmtInt } from "@/lib/live"; | |
| 11 | + | |
| 12 | +export const metadata: Metadata = { | |
| 13 | + title: { absolute: "Recherche — Groupe KA" }, | |
| 14 | + description: | |
| 15 | + "Recherchez dans tout l'écosystème KA — logements, propriétés, autos, produits d'ici, épicerie, restos, sorties, créateurs et emplois — avec le moteur Trouve·Ka.", | |
| 16 | +}; | |
| 17 | + | |
| 18 | +// Les 14 sites de l'écosystème (filtre par domaine du moteur). | |
| 19 | +const SITES: [string, string][] = [ | |
| 20 | + ["www.lou-ka.com", "Lou·Ka"], | |
| 21 | + ["www.immo-ka.com", "Immo·Ka"], | |
| 22 | + ["www.vrai-prix.com", "Vrai-Prix"], | |
| 23 | + ["www.toit-ka.com", "Toit·Ka"], | |
| 24 | + ["www.auto-ka.com", "Auto·Ka"], | |
| 25 | + ["www.fabri-ka.com", "Fabri·Ka"], | |
| 26 | + ["www.food-ka.com", "Food·Ka"], | |
| 27 | + ["www.resto-ka.com", "Resto·Ka"], | |
| 28 | + ["www.sorti-ka.com", "Sorti·Ka"], | |
| 29 | + ["www.crea-ka.com", "Créa·Ka"], | |
| 30 | + ["www.job-ka.com", "Job·Ka"], | |
| 31 | + ["www.valoplex.com", "Valoplex"], | |
| 32 | + ["www.groupe-ka.com", "Groupe KA"], | |
| 33 | + ["www.trouve-ka.com", "Trouve·Ka"], | |
| 34 | +]; | |
| 35 | + | |
| 36 | +const PoweredBy = () => ( | |
| 37 | + <p className="gk-mono mt-4 text-[11.5px] text-ink-3"> | |
| 38 | + Propulsé par{" "} | |
| 39 | + <a | |
| 40 | + href="https://www.trouve-ka.com" | |
| 41 | + target="_blank" | |
| 42 | + rel="noopener noreferrer" | |
| 43 | + className="font-bold text-green underline-offset-4 hover:underline" | |
| 44 | + > | |
| 45 | + Trouve·Ka | |
| 46 | + </a>{" "} | |
| 47 | + — le moteur de recherche du Groupe KA. | |
| 48 | + </p> | |
| 49 | +); | |
| 50 | + | |
| 51 | +export default async function RecherchePage({ | |
| 52 | + searchParams, | |
| 53 | +}: { | |
| 54 | + searchParams: Promise<{ q?: string; page?: string; site?: string }>; | |
| 55 | +}) { | |
| 56 | + const sp = await searchParams; | |
| 57 | + const q = (sp.q ?? "").trim(); | |
| 58 | + const page = Math.max(1, Number.parseInt(sp.page ?? "1", 10) || 1); | |
| 59 | + const site = SITES.some(([d]) => d === sp.site) ? sp.site : undefined; | |
| 60 | + | |
| 61 | + // Lien interne en conservant q / site / page voulus. | |
| 62 | + const href = (over: { page?: number; site?: string | null }) => { | |
| 63 | + const u = new URLSearchParams({ q }); | |
| 64 | + const s = over.site === undefined ? site : (over.site ?? undefined); | |
| 65 | + if (s) u.set("site", s); | |
| 66 | + if (over.page && over.page > 1) u.set("page", String(over.page)); | |
| 67 | + return `/recherche?${u.toString()}`; | |
| 68 | + }; | |
| 69 | + | |
| 70 | + /* ---- État d'accueil (pas de requête) ---- */ | |
| 71 | + if (!q) { | |
| 72 | + return ( | |
| 73 | + <main className="mx-auto max-w-6xl px-4 py-14 sm:px-6"> | |
| 74 | + <p className="kicker">Recherche · tout l’écosystème</p> | |
| 75 | + <h1 className="gk-display mt-3 max-w-3xl text-[clamp(28px,4.5vw,44px)] leading-[1.02] font-bold tracking-[-0.035em] uppercase"> | |
| 76 | + Rechercher dans tout <span className="hl">l’écosystème KA</span> | |
| 77 | + </h1> | |
| 78 | + <p className="mt-4 max-w-2xl text-[14.5px] text-ink-2"> | |
| 79 | + Un seul champ pour chercher sur les 14 sites du Groupe KA : | |
| 80 | + logements, propriétés, autos, produits d’ici, épicerie, restos, | |
| 81 | + sorties, créateurs et emplois. | |
| 82 | + </p> | |
| 83 | + <div className="mt-8"> | |
| 84 | + <SearchBar size="hero" /> | |
| 85 | + </div> | |
| 86 | + <PoweredBy /> | |
| 87 | + </main> | |
| 88 | + ); | |
| 89 | + } | |
| 90 | + | |
| 91 | + /* ---- Résultats ---- */ | |
| 92 | + const outcome = await searchTrouveKa(q, { page, site }); | |
| 93 | + | |
| 94 | + return ( | |
| 95 | + <main className="mx-auto max-w-6xl px-4 py-12 sm:px-6"> | |
| 96 | + <p className="kicker">Recherche · tout l’écosystème</p> | |
| 97 | + <h1 className="gk-display mt-3 text-[clamp(24px,4vw,36px)] leading-[1.05] font-bold tracking-[-0.035em] uppercase"> | |
| 98 | + Résultats pour « {q} » | |
| 99 | + </h1> | |
| 100 | + | |
| 101 | + <div className="mt-6"> | |
| 102 | + <SearchBar size="hero" defaultQuery={q} site={site} /> | |
| 103 | + </div> | |
| 104 | + | |
| 105 | + {/* Filtres par site */} | |
| 106 | + <div className="mt-6 flex flex-wrap gap-2" aria-label="Filtrer par site"> | |
| 107 | + <a | |
| 108 | + href={href({ site: null })} | |
| 109 | + className={!site ? "chip chip-accent" : "chip"} | |
| 110 | + aria-current={!site ? "true" : undefined} | |
| 111 | + > | |
| 112 | + Tous | |
| 113 | + </a> | |
| 114 | + {SITES.map(([domain, label]) => ( | |
| 115 | + <a | |
| 116 | + key={domain} | |
| 117 | + href={href({ site: domain })} | |
| 118 | + className={site === domain ? "chip chip-accent" : "chip"} | |
| 119 | + aria-current={site === domain ? "true" : undefined} | |
| 120 | + > | |
| 121 | + {label} | |
| 122 | + </a> | |
| 123 | + ))} | |
| 124 | + </div> | |
| 125 | + | |
| 126 | + {!outcome.ok ? ( | |
| 127 | + /* Repli : moteur injoignable — la page reste servie. */ | |
| 128 | + <div className="card mt-8 max-w-2xl p-6"> | |
| 129 | + <p className="gk-display text-[18px] font-bold"> | |
| 130 | + Le moteur de recherche est momentanément indisponible. | |
| 131 | + </p> | |
| 132 | + <p className="mt-2 text-[14px] text-ink-2"> | |
| 133 | + Réessayez dans un instant, ou cherchez directement sur{" "} | |
| 134 | + <a | |
| 135 | + href={`https://www.trouve-ka.com/?q=${encodeURIComponent(q)}`} | |
| 136 | + target="_blank" | |
| 137 | + rel="noopener noreferrer" | |
| 138 | + className="font-bold text-green underline-offset-4 hover:underline" | |
| 139 | + > | |
| 140 | + trouve-ka.com | |
| 141 | + </a> | |
| 142 | + . | |
| 143 | + </p> | |
| 144 | + </div> | |
| 145 | + ) : ( | |
| 146 | + <> | |
| 147 | + <p className="gk-mono mt-6 text-[12px] text-ink-2"> | |
| 148 | + {fmtInt(outcome.data.total)}{" "} | |
| 149 | + {outcome.data.total > 1 ? "résultats" : "résultat"} ( | |
| 150 | + {fmtInt(outcome.data.took_ms)} ms) | |
| 151 | + </p> | |
| 152 | + | |
| 153 | + {outcome.data.results.length === 0 ? ( | |
| 154 | + <div className="card mt-6 max-w-2xl p-6"> | |
| 155 | + <p className="gk-display text-[18px] font-bold"> | |
| 156 | + Aucun résultat{site ? " sur ce site" : ""} pour « {q} ». | |
| 157 | + </p> | |
| 158 | + <p className="mt-2 text-[14px] text-ink-2"> | |
| 159 | + Essayez d’autres mots-clés{site ? ( | |
| 160 | + <> | |
| 161 | + {" "} | |
| 162 | + ou{" "} | |
| 163 | + <a | |
| 164 | + href={href({ site: null, page: 1 })} | |
| 165 | + className="font-bold text-green underline-offset-4 hover:underline" | |
| 166 | + > | |
| 167 | + cherchez sur tous les sites | |
| 168 | + </a> | |
| 169 | + </> | |
| 170 | + ) : null} | |
| 171 | + . | |
| 172 | + </p> | |
| 173 | + </div> | |
| 174 | + ) : ( | |
| 175 | + <ol className="mt-6 flex max-w-3xl flex-col gap-4"> | |
| 176 | + {outcome.data.results.map((r) => ( | |
| 177 | + <li key={r.url} className="card p-5"> | |
| 178 | + <p className="gk-mono text-[11px] break-all text-ink-3"> | |
| 179 | + {r.display_url} | |
| 180 | + </p> | |
| 181 | + <h2 className="mt-1"> | |
| 182 | + <a | |
| 183 | + href={r.url} | |
| 184 | + target="_blank" | |
| 185 | + rel="noopener noreferrer" | |
| 186 | + className="gk-display text-[18px] leading-snug font-bold underline-offset-4 hover:underline" | |
| 187 | + > | |
| 188 | + {r.title ? decodeEntities(r.title) : r.url} | |
| 189 | + </a> | |
| 190 | + </h2> | |
| 191 | + {r.snippet ? ( | |
| 192 | + <p | |
| 193 | + className="mt-2 text-[13.5px] leading-relaxed text-ink-2 [&_em]:rounded-[3px] [&_em]:bg-[var(--lime)] [&_em]:px-[3px] [&_em]:font-semibold [&_em]:not-italic [&_em]:text-ink" | |
| 194 | + dangerouslySetInnerHTML={{ | |
| 195 | + __html: sanitizeSnippet(r.snippet), | |
| 196 | + }} | |
| 197 | + /> | |
| 198 | + ) : null} | |
| 199 | + <p className="mt-3"> | |
| 200 | + <span className="chip chip-soft">{r.domain}</span> | |
| 201 | + </p> | |
| 202 | + </li> | |
| 203 | + ))} | |
| 204 | + </ol> | |
| 205 | + )} | |
| 206 | + | |
| 207 | + {/* Pagination */} | |
| 208 | + <nav | |
| 209 | + className="mt-8 flex max-w-3xl items-center justify-between gap-4" | |
| 210 | + aria-label="Pagination des résultats" | |
| 211 | + > | |
| 212 | + {page > 1 ? ( | |
| 213 | + <a href={href({ page: page - 1 })} className="btn"> | |
| 214 | + ← Précédent | |
| 215 | + </a> | |
| 216 | + ) : ( | |
| 217 | + <span aria-hidden="true" /> | |
| 218 | + )} | |
| 219 | + {page * outcome.data.limit < outcome.data.total ? ( | |
| 220 | + <a href={href({ page: page + 1 })} className="btn"> | |
| 221 | + Suivant → | |
| 222 | + </a> | |
| 223 | + ) : ( | |
| 224 | + <span aria-hidden="true" /> | |
| 225 | + )} | |
| 226 | + </nav> | |
| 227 | + </> | |
| 228 | + )} | |
| 229 | + | |
| 230 | + <PoweredBy /> | |
| 231 | + </main> | |
| 232 | + ); | |
| 233 | +} | |
modified
src/app/sitemap.ts
+1 −0
@@ -14,6 +14,7 @@ export default function sitemap(): MetadataRoute.Sitemap { | ||
| 14 | 14 | priority: number; |
| 15 | 15 | }[] = [ |
| 16 | 16 | { path: "/", changeFrequency: "daily", priority: 1 }, |
| 17 | + { path: "/recherche", changeFrequency: "weekly", priority: 0.7 }, | |
| 17 | 18 | { path: "/stats", changeFrequency: "daily", priority: 0.8 }, |
| 18 | 19 | { path: "/rapports", changeFrequency: "daily", priority: 0.7 }, |
| 19 | 20 | { path: "/status", changeFrequency: "daily", priority: 0.6 }, |
added
src/lib/search.ts
+102 −0
@@ -0,0 +1,102 @@ | ||
| 1 | +// Auteur : Simon-Pierre Boucher — contact@spboucher.ai | |
| 2 | +// Client du moteur Trouve·Ka — la recherche de tout l'écosystème Groupe KA. | |
| 3 | +// Contrat public : GET /api/search?q=…&page=…&limit=…&site=<domaine> → | |
| 4 | +// { query, total, took_ms, page, limit, related[], results[] }. | |
| 5 | +// Jamais de throw : tout échec (réseau, HTTP, JSON invalide) retourne | |
| 6 | +// { ok: false } et la page affiche un repli poli — la recherche ne doit | |
| 7 | +// JAMAIS faire planter le site mère. | |
| 8 | +// ⚠️ Le snippet contient du HTML de surlignage (<em>) : il DOIT passer par | |
| 9 | +// sanitizeSnippet() avant tout dangerouslySetInnerHTML. | |
| 10 | + | |
| 11 | +export type SearchResult = { | |
| 12 | + title: string; | |
| 13 | + url: string; | |
| 14 | + display_url: string; | |
| 15 | + snippet: string; | |
| 16 | + domain: string; | |
| 17 | + language: string; | |
| 18 | + quebec_score: number; | |
| 19 | + badges: string[]; | |
| 20 | + published_at: string | null; | |
| 21 | + image: string | null; | |
| 22 | +}; | |
| 23 | + | |
| 24 | +export type SearchData = { | |
| 25 | + query: string; | |
| 26 | + total: number; | |
| 27 | + took_ms: number; | |
| 28 | + page: number; | |
| 29 | + limit: number; | |
| 30 | + related: string[]; | |
| 31 | + results: SearchResult[]; | |
| 32 | +}; | |
| 33 | + | |
| 34 | +export type SearchOutcome = { ok: true; data: SearchData } | { ok: false }; | |
| 35 | + | |
| 36 | +// URL de base surchargée en environnement (tests, bascule d'infra) : | |
| 37 | +// TROUVEKA_SEARCH_URL=https://…/api/search | |
| 38 | +const BASE_URL = | |
| 39 | + process.env.TROUVEKA_SEARCH_URL || "https://www.trouve-ka.com/api/search"; | |
| 40 | + | |
| 41 | +export async function searchTrouveKa( | |
| 42 | + q: string, | |
| 43 | + opts?: { page?: number; site?: string }, | |
| 44 | +): Promise<SearchOutcome> { | |
| 45 | + try { | |
| 46 | + const url = new URL(BASE_URL); | |
| 47 | + url.searchParams.set("q", q); | |
| 48 | + url.searchParams.set("page", String(opts?.page ?? 1)); | |
| 49 | + url.searchParams.set("limit", "10"); | |
| 50 | + // Filtre par domaine (en cours d'ajout côté moteur — inoffensif s'il | |
| 51 | + // est ignoré : on filtre alors « tous les sites »). | |
| 52 | + if (opts?.site) url.searchParams.set("site", opts.site); | |
| 53 | + const res = await fetch(url.toString(), { | |
| 54 | + cache: "no-store", | |
| 55 | + signal: AbortSignal.timeout(6000), | |
| 56 | + headers: { accept: "application/json" }, | |
| 57 | + }); | |
| 58 | + if (!res.ok) return { ok: false }; | |
| 59 | + const data = (await res.json()) as SearchData; | |
| 60 | + if (!data || !Array.isArray(data.results)) return { ok: false }; | |
| 61 | + return { ok: true, data }; | |
| 62 | + } catch { | |
| 63 | + return { ok: false }; | |
| 64 | + } | |
| 65 | +} | |
| 66 | + | |
| 67 | +/** | |
| 68 | + * Décode les entités HTML déjà présentes dans les textes du moteur | |
| 69 | + * (', &, <…) vers du texte brut. Sans ce décodage, le | |
| 70 | + * ré-échappement de sanitizeSnippet doublerait l'encodage et afficherait | |
| 71 | + * « ' » littéralement (observé en prod 2026-08-23). | |
| 72 | + */ | |
| 73 | +export function decodeEntities(s: string): string { | |
| 74 | + const cp = (n: number) => | |
| 75 | + Number.isFinite(n) && n >= 0 && n <= 0x10ffff ? String.fromCodePoint(n) : ""; | |
| 76 | + return s | |
| 77 | + .replace(/&#x([0-9a-f]+);/gi, (_, h: string) => cp(parseInt(h, 16))) | |
| 78 | + .replace(/&#(\d+);/g, (_, d: string) => cp(parseInt(d, 10))) | |
| 79 | + .replaceAll("<", "<") | |
| 80 | + .replaceAll(">", ">") | |
| 81 | + .replaceAll(""", '"') | |
| 82 | + .replaceAll("'", "'") | |
| 83 | + .replaceAll(" ", " ") | |
| 84 | + .replaceAll("&", "&"); | |
| 85 | +} | |
| 86 | + | |
| 87 | +/** | |
| 88 | + * Assainit un snippet du moteur : décode les entités existantes, échappe | |
| 89 | + * TOUT le HTML (&, <, >, guillemets) puis ne ré-autorise que les balises de | |
| 90 | + * surlignage <em>/</em> ajoutées par Trouve·Ka. Résultat sûr pour | |
| 91 | + * dangerouslySetInnerHTML. | |
| 92 | + */ | |
| 93 | +export function sanitizeSnippet(html: string): string { | |
| 94 | + return decodeEntities(html) | |
| 95 | + .replaceAll("&", "&") | |
| 96 | + .replaceAll("<", "<") | |
| 97 | + .replaceAll(">", ">") | |
| 98 | + .replaceAll('"', """) | |
| 99 | + .replaceAll("'", "'") | |
| 100 | + .replaceAll("<em>", "<em>") | |
| 101 | + .replaceAll("</em>", "</em>"); | |
| 102 | +} | |
| 103 | ||