···11+/**
22+ * Text normalization for search
33+ *
44+ * Strips diacritical marks from text for search matching (ö→o, û→u, etc.)
55+ *
66+ * IMPORTANT: This uses the Unicode "Combining Diacritical Marks" block
77+ * (U+0300-U+036F) which specifically targets Latin script diacritics.
88+ * Other scripts (Arabic, Hebrew, CJK, Cyrillic) are unaffected.
99+ *
1010+ * This is appropriate for English MTG card names which may contain borrowed
1111+ * diacritics (Jötun, Lim-Dûl, Dandân). If we ever index non-English printings
1212+ * (using `printed_name`), we'd need to reconsider script handling.
1313+ */
1414+1515+/**
1616+ * Strip diacritical marks from text for search normalization
1717+ */
1818+export function stripDiacritics(text: string): string {
1919+ return text.normalize("NFD").replace(/[\u0300-\u036f]/g, "");
2020+}
+36
src/lib/search/__tests__/integration.test.ts
···5656 expect(result.value.match(bolt)).toBe(true);
5757 }
5858 });
5959+6060+ it("matches names with diacritics using ASCII equivalents", async () => {
6161+ const nazgul = await cards.get("Nazgûl");
6262+6363+ // Should match without the diacritic
6464+ const withoutDiacritic = search("nazgul");
6565+ expect(withoutDiacritic.ok).toBe(true);
6666+ if (withoutDiacritic.ok) {
6767+ expect(withoutDiacritic.value.match(nazgul)).toBe(true);
6868+ }
6969+7070+ // Should also match with the diacritic
7171+ const withDiacritic = search("nazgûl");
7272+ expect(withDiacritic.ok).toBe(true);
7373+ if (withDiacritic.ok) {
7474+ expect(withDiacritic.value.match(nazgul)).toBe(true);
7575+ }
7676+ });
7777+7878+ it("exact name match works with diacritics", async () => {
7979+ const nazgul = await cards.get("Nazgûl");
8080+8181+ // ASCII equivalent should match exactly
8282+ const ascii = search('!"Nazgul"');
8383+ expect(ascii.ok).toBe(true);
8484+ if (ascii.ok) {
8585+ expect(ascii.value.match(nazgul)).toBe(true);
8686+ }
8787+8888+ // With diacritic should also match
8989+ const diacritic = search('!"Nazgûl"');
9090+ expect(diacritic.ok).toBe(true);
9191+ if (diacritic.ok) {
9292+ expect(diacritic.value.match(nazgul)).toBe(true);
9393+ }
9494+ });
5995 });
60966197 describe("type matching", () => {
+15-7
src/lib/search/matcher.ts
···44 * Compiles a SearchNode AST into a function that tests cards.
55 */
6677+import { stripDiacritics } from "../normalize-text";
78import { type CardPredicate, compileField } from "./fields";
89import type { CompileError, Result, SearchNode } from "./types";
910import { ok } from "./types";
···8283}
83848485/**
8686+ * Normalize text for name comparison (lowercase + strip diacritics)
8787+ */
8888+function normalizeName(text: string): string {
8989+ return stripDiacritics(text).toLowerCase();
9090+}
9191+9292+/**
8593 * Compile name search - substring or regex match
8694 */
8795function compileName(value: string, pattern: RegExp | null): CardPredicate {
···101109 };
102110 }
103111104104- // Substring match (case-insensitive)
105105- const lower = value.toLowerCase();
112112+ // Substring match (case-insensitive, diacritic-insensitive)
113113+ const normalized = normalizeName(value);
106114 return (card) => {
107115 // Match against main name
108108- if (card.name.toLowerCase().includes(lower)) return true;
116116+ if (normalizeName(card.name).includes(normalized)) return true;
109117110118 // Match against card face names for multi-face cards
111119 if (card.card_faces) {
112120 for (const face of card.card_faces) {
113113- if (face.name.toLowerCase().includes(lower)) return true;
121121+ if (normalizeName(face.name).includes(normalized)) return true;
114122 }
115123 }
116124···122130 * Compile exact name match
123131 */
124132function compileExactName(value: string): CardPredicate {
125125- const lower = value.toLowerCase();
133133+ const normalized = normalizeName(value);
126134 return (card) => {
127135 // Match against main name exactly
128128- if (card.name.toLowerCase() === lower) return true;
136136+ if (normalizeName(card.name) === normalized) return true;
129137130138 // Match against card face names for multi-face cards
131139 if (card.card_faces) {
132140 for (const face of card.card_faces) {
133133- if (face.name.toLowerCase() === lower) return true;
141141+ if (normalizeName(face.name) === normalized) return true;
134142 }
135143 }
136144
+15
src/workers/cards.worker.ts
···99import MiniSearch from "minisearch";
1010import { CARD_CHUNKS, CARD_INDEXES, CARD_VOLATILE } from "../lib/card-manifest";
1111import { LRUCache } from "../lib/lru-cache";
1212+import { stripDiacritics } from "../lib/normalize-text";
1213import type {
1314 CardDataOutput,
1415 ManaColor,
···304305305306 // Build fuzzy search index
306307 console.log("[CardsWorker] Building search index...");
308308+309309+ // MiniSearch's default tokenizer splits on /[\n\r\p{Z}\p{P}]+/u (Unicode
310310+ // separators and punctuation), which strips "&" from card names like
311311+ // "Minsc & Boo". We use a negated version of the same pattern but carve out
312312+ // "&" so processTerm can normalize it to "and". This is uglier than a
313313+ // positive match like /[\p{L}\p{N}\p{M}]+|&/gu but correct by construction
314314+ // (guaranteed to match exactly what the default tokenizer would, plus &).
315315+ const SEARCH_TOKEN = /&|[^\n\r\p{Z}\p{P}]+/gu;
316316+307317 this.searchIndex = new MiniSearch<Card>({
308318 fields: ["name"],
309319 storeFields: ["id", "oracle_id", "name"],
320320+ tokenize: (text) => text.match(SEARCH_TOKEN) ?? [],
321321+ processTerm: (term) => {
322322+ if (term === "&") return "and";
323323+ return stripDiacritics(term).toLowerCase();
324324+ },
310325 searchOptions: {
311326 prefix: true, // "bol" matches "bolt"
312327 fuzzy: 0.3, // ~2 char tolerance