From 3a9ce933148331094d7e6b18a9c3c1981de11c20 Mon Sep 17 00:00:00 2001 From: uzumakienes Date: Fri, 23 Oct 2026 16:44:55 +0300 Subject: [PATCH] fix(search): match places regardless of accents --- src/lib/places.test.ts | 8 ++++++++ src/lib/places.ts | 5 +++-- src/lib/search.test.ts | 17 +++++++++++++++++ src/lib/search.ts | 7 ++++--- src/lib/utils.test.ts | 17 ++++++++++++++++- src/lib/utils.ts | 15 +++++++++++++++ 6 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/lib/places.test.ts b/src/lib/places.test.ts index 90f630d..d930950 100644 --- a/src/lib/places.test.ts +++ b/src/lib/places.test.ts @@ -93,6 +93,14 @@ describe("filterPlaces", () => { it("returns an empty array when nothing matches", () => { expect(filterPlaces(FIXTURE, { query: "does not exist" })).toEqual([]); }); + + it("matches a query against the place name, ignoring accents", () => { + const places = [ + place({ id: "esb", name: "Esenboğa International Airport", city: "ankara" }), + ]; + expect(filterPlaces(places, { query: "esenboga" }).map((p) => p.id)).toEqual(["esb"]); + expect(filterPlaces(places, { query: "Esenboğa" }).map((p) => p.id)).toEqual(["esb"]); + }); }); describe("getCities", () => { diff --git a/src/lib/places.ts b/src/lib/places.ts index 3bb739f..5a2d8b2 100644 --- a/src/lib/places.ts +++ b/src/lib/places.ts @@ -1,4 +1,5 @@ import type { City, Place, PlaceType } from "@/lib/types"; +import { foldText } from "@/lib/utils"; import studyMapConfig from "../../studymap.config"; /** All public places, sourced from `studymap.config.ts`. */ @@ -10,7 +11,7 @@ export function filterPlaces( places: Place[], opts: { types?: PlaceType[]; city?: City | null; query?: string }, ): Place[] { - const q = opts.query?.trim().toLowerCase() ?? ""; + const q = foldText(opts.query?.trim() ?? ""); return places.filter((place) => { if (opts.types && opts.types.length > 0 && !opts.types.includes(place.type)) { return false; @@ -20,7 +21,7 @@ export function filterPlaces( } if (q) { const cityNorm = place.city.replace(/_/g, " "); - if (!place.name.toLowerCase().includes(q) && !cityNorm.includes(q)) { + if (!foldText(place.name).includes(q) && !cityNorm.includes(q)) { return false; } } diff --git a/src/lib/search.test.ts b/src/lib/search.test.ts index 0e87148..97f8f85 100644 --- a/src/lib/search.test.ts +++ b/src/lib/search.test.ts @@ -103,6 +103,23 @@ describe("searchSite", () => { expect(searchSiteTotal(upper)).toBeGreaterThan(0); }); + it("ignores accents in the query and the data", () => { + const data: SearchSiteData = { + ...DATA, + places: [ + place({ id: "krk", name: "Kraków Public Library", city: "krakow" }), + place({ id: "fr", name: "Alliance Française de Toronto", city: "toronto" }), + ], + }; + const ids = (q: string) => + searchSite(q, data) + .find((g) => g.type === "place") + ?.results.map((r) => r.id); + expect(ids("krakow")).toEqual(["krk"]); + expect(ids("francaise")).toEqual(["fr"]); + expect(ids("Française")).toEqual(["fr"]); + }); + it("matches a place by its humanized city", () => { const groups = searchSite("thane", DATA); const places = groups.find((g) => g.type === "place"); diff --git a/src/lib/search.ts b/src/lib/search.ts index 6f0e57b..ebf50a2 100644 --- a/src/lib/search.ts +++ b/src/lib/search.ts @@ -1,5 +1,6 @@ import { humanizeCity, PLACE_TYPE_LABELS } from "@/lib/types"; import type { Competition, Place } from "@/lib/types"; +import { foldText } from "@/lib/utils"; /** The one place a doc entry's shape is imported for search, decoupled from `docsNav`'s own type. */ export interface SearchableDoc { @@ -43,7 +44,7 @@ export interface SearchSiteOptions { const DEFAULT_LIMIT = 5; function includesQuery(query: string, ...fields: (string | undefined)[]): boolean { - return fields.some((field) => !!field && field.toLowerCase().includes(query)); + return fields.some((field) => !!field && foldText(field).includes(query)); } /** @@ -52,7 +53,7 @@ function includesQuery(query: string, ...fields: (string | undefined)[]): boolea * run entirely client-side once the caller has the three datasets in hand * (`getPlaces()`, `getCompetitions()`, `docsNav`). * - * Matching is a plain case-insensitive substring test per field (the same + * Matching is a plain case- and accent-insensitive substring test per field (the same * approach as `filterPlaces` and the awesome-list docs browser) — no fuzzy * matching or ranking, since none of the three datasets are large enough to * need it and it keeps the behavior easy to reason about. @@ -65,7 +66,7 @@ export function searchSite( data: SearchSiteData, opts: SearchSiteOptions = {}, ): SearchGroup[] { - const q = query.trim().toLowerCase(); + const q = foldText(query.trim()); if (!q) return []; const limit = opts.limit ?? DEFAULT_LIMIT; diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts index 56d5b5f..84d83d7 100644 --- a/src/lib/utils.test.ts +++ b/src/lib/utils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { cn, isMissingTableError } from "@/lib/utils"; +import { cn, foldText, isMissingTableError } from "@/lib/utils"; describe("isMissingTableError", () => { it("is true for PostgREST's schema-cache-miss code (PGRST205)", () => { @@ -49,3 +49,18 @@ describe("cn", () => { expect(cn("base", { active: true, hidden: false })).toBe("base active"); }); }); + +describe("foldText", () => { + it("lower-cases and strips accents", () => { + expect(foldText("Esenboğa")).toBe("esenboga"); + expect(foldText("Kraków")).toBe("krakow"); + expect(foldText("Française")).toBe("francaise"); + }); + + it("folds letters that NFD does not decompose", () => { + expect(foldText("Łódź")).toBe("lodz"); + expect(foldText("Øresund")).toBe("oresund"); + expect(foldText("Đà Nẵng")).toBe("da nang"); + expect(foldText("Işık")).toBe("isik"); + }); +}); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index 93d7bfb..53fc632 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -14,3 +14,18 @@ export function isMissingTableError(err: unknown): boolean { (err as { code?: unknown }).code === "PGRST205" ); } + +/** Letters that `normalize("NFD")` does not split into a base letter and a mark. */ +const UNDECOMPOSED_LETTERS: Record = { ł: "l", ø: "o", đ: "d", ı: "i" }; + +/** + * Lower-cases `text` and strips accents, so `Esenboğa`, `Kraków` and `Française` + * compare equal to `esenboga`, `krakow` and `francaise` in search. + */ +export function foldText(text: string): string { + return text + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[łøđı]/g, (letter) => UNDECOMPOSED_LETTERS[letter]); +}