diff --git a/web/components/docs-search.tsx b/web/components/docs-search.tsx
index 34fb6db55..adc666e13 100644
--- a/web/components/docs-search.tsx
+++ b/web/components/docs-search.tsx
@@ -8,7 +8,7 @@ import {
docTopicIsExternal,
type DocTopic,
} from "@/lib/docs-map";
-import { docTopicHaystack } from "@/lib/search-utils";
+import { docTopicHaystack, highlightSpan } from "@/lib/search-utils";
/* ------------------------------------------------------------------ */
/* Locale-aware strings */
@@ -43,16 +43,16 @@ const topicHaystack = docTopicHaystack;
/* ------------------------------------------------------------------ */
function highlight(text: string, query: string): React.ReactNode {
- const q = query.trim().toLowerCase();
- if (!q) return text;
- const lower = text.toLowerCase();
- const idx = lower.indexOf(q);
- if (idx === -1) return text;
+ // Index arithmetic lives in search-utils: lowercasing can change a
+ // string's length, so `text` cannot be sliced with indices taken from
+ // its lowercased copy.
+ const span = highlightSpan(text, query);
+ if (!span) return text;
return (
<>
- {text.slice(0, idx)}
- {text.slice(idx, idx + q.length)}
- {text.slice(idx + q.length)}
+ {span.before}
+ {span.match}
+ {span.after}
>
);
}
diff --git a/web/components/faq-search.tsx b/web/components/faq-search.tsx
index 1937378a8..e09426feb 100644
--- a/web/components/faq-search.tsx
+++ b/web/components/faq-search.tsx
@@ -3,6 +3,7 @@
import { useState, useMemo, useRef, useCallback, useEffect } from "react";
import { faqSourceHref } from "@/lib/faq-source";
import { extractText } from "@/lib/react-text";
+import { highlightSpan } from "@/lib/search-utils";
export interface FaqSearchItem {
q: string;
@@ -15,16 +16,16 @@ export interface FaqSearchItem {
/* ------------------------------------------------------------------ */
function highlight(text: string, query: string): React.ReactNode {
- const q = query.trim().toLowerCase();
- if (!q) return text;
- const lower = text.toLowerCase();
- const idx = lower.indexOf(q);
- if (idx === -1) return text;
+ // Index arithmetic lives in search-utils: lowercasing can change a
+ // string's length, so `text` cannot be sliced with indices taken from
+ // its lowercased copy.
+ const span = highlightSpan(text, query);
+ if (!span) return text;
return (
<>
- {text.slice(0, idx)}
- {text.slice(idx, idx + q.length)}
- {text.slice(idx + q.length)}
+ {span.before}
+ {span.match}
+ {span.after}
>
);
}
diff --git a/web/lib/search-utils.test.ts b/web/lib/search-utils.test.ts
index f1f7dc71d..a83b28d66 100644
--- a/web/lib/search-utils.test.ts
+++ b/web/lib/search-utils.test.ts
@@ -1,7 +1,9 @@
import { describe, it, expect } from "vitest";
+import { readFileSync } from "node:fs";
import {
docTopicHaystack,
filterDocTopics,
+ highlightSpan,
normalizeQuery,
matches,
} from "./search-utils";
@@ -129,3 +131,49 @@ describe("filterDocTopics", () => {
expect(ids).toContain("install");
});
});
+
+describe("highlightSpan", () => {
+ const webRoot = new URL("../", import.meta.url);
+ const read = (p: string) => readFileSync(new URL(p, webRoot), "utf8");
+
+ it("splits a plain match into three reassembling pieces", () => {
+ const span = highlightSpan("Install Guide", "install")!;
+ expect(span).toEqual({ before: "", match: "Install", after: " Guide" });
+ expect(span.before + span.match + span.after).toBe("Install Guide");
+ });
+
+ it("returns null for an empty query or no match", () => {
+ expect(highlightSpan("Install", "")).toBeNull();
+ expect(highlightSpan("Install", " ")).toBeNull();
+ expect(highlightSpan("Install", "docker")).toBeNull();
+ expect(highlightSpan("", "install")).toBeNull();
+ });
+
+ it("keeps indices in the source string when lowercasing changes length", () => {
+ // "İ".toLowerCase() is two code units, so a lowercased copy of this
+ // string is one longer than the string itself. Index arithmetic done on
+ // the copy highlighted "tanbul " instead of "stanbul".
+ const text = "İstanbul kurulumu";
+ expect(text.toLowerCase().length).toBe(text.length + 1);
+ const span = highlightSpan(text, "stanbul")!;
+ expect(span.match).toBe("stanbul");
+ expect(span.before).toBe("İ");
+ expect(span.after).toBe(" kurulumu");
+ expect(span.before + span.match + span.after).toBe(text);
+ });
+
+ it("never claims half of a source character", () => {
+ const span = highlightSpan("İstanbul", "i")!;
+ expect(span.match).toBe("İ");
+ expect(span.before + span.match + span.after).toBe("İstanbul");
+ });
+
+ it("is the one match rule both search surfaces use", () => {
+ for (const file of ["components/docs-search.tsx", "components/faq-search.tsx"]) {
+ const source = read(file);
+ expect(source, file).toContain("highlightSpan(text, query)");
+ expect(source, file).not.toContain("text.toLowerCase()");
+ expect(source, file).not.toContain("text.slice(");
+ }
+ });
+});
diff --git a/web/lib/search-utils.ts b/web/lib/search-utils.ts
index 0428e38de..f14895194 100644
--- a/web/lib/search-utils.ts
+++ b/web/lib/search-utils.ts
@@ -64,3 +64,60 @@ export function matches(haystack: string, query: string): boolean {
if (!q) return true;
return haystack.toLowerCase().includes(q);
}
+
+/** The three pieces a highlighted match splits a string into. */
+export interface HighlightSpan {
+ before: string;
+ match: string;
+ after: string;
+}
+
+/**
+ * Locate `query` inside `text`, case-insensitively, in `text`'s own indices.
+ *
+ * The obvious form — `text.toLowerCase().indexOf(q)`, then slicing `text`
+ * with that index — assumes lowercasing preserves length. It does not:
+ * `"İ".toLowerCase()` is two code units, so every index after a dotted
+ * capital I in the haystack is off by one and the highlight lands on the
+ * wrong characters. Turkish is a routed locale, so this is reachable the
+ * moment localized copy enters the search haystack.
+ *
+ * Lowercasing character by character and keeping a position map costs one
+ * pass and keeps the three returned pieces exactly reassembling `text`.
+ * Returns null when there is no match (including an empty query).
+ */
+export function highlightSpan(text: string, query: string): HighlightSpan | null {
+ const q = normalizeQuery(query);
+ if (!q) return null;
+
+ let lower = "";
+ // For each code unit of `lower`: where its source character starts and ends.
+ const sourceStart: number[] = [];
+ const sourceEnd: number[] = [];
+ for (let i = 0; i < text.length; ) {
+ const char = String.fromCodePoint(text.codePointAt(i)!);
+ const next = i + char.length;
+ const folded = char.toLowerCase();
+ for (let k = 0; k < folded.length; k++) {
+ sourceStart.push(i);
+ sourceEnd.push(next);
+ }
+ lower += folded;
+ i = next;
+ }
+
+ const idx = lower.indexOf(q);
+ if (idx === -1) return null;
+
+ const start = sourceStart[idx];
+ const stop = idx + q.length;
+ // A match ending inside one source character's expansion cannot claim half
+ // of that character; take the whole character rather than nothing.
+ const end = stop < lower.length ? Math.max(sourceStart[stop], sourceEnd[idx]) : text.length;
+
+ return {
+ before: text.slice(0, start),
+ match: text.slice(start, end),
+ after: text.slice(end),
+ };
+}