Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/lib/places.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
5 changes: 3 additions & 2 deletions src/lib/places.ts
Original file line number Diff line number Diff line change
@@ -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`. */
Expand All @@ -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;
Expand All @@ -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;
}
}
Expand Down
17 changes: 17 additions & 0 deletions src/lib/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
7 changes: 4 additions & 3 deletions src/lib/search.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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));
}

/**
Expand All @@ -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.
Expand All @@ -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;

Expand Down
17 changes: 16 additions & 1 deletion src/lib/utils.test.ts
Original file line number Diff line number Diff line change
@@ -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)", () => {
Expand Down Expand Up @@ -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");
});
});
15 changes: 15 additions & 0 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = { ł: "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]);
}
Loading