返回 CodeWhale
search-utils.ts
根目录 / web / lib / search-utils.ts
1 /**
2 * search-utils.ts — shared keyword-search utilities for docs and FAQ.
3 *
4 * Pure functions extracted from the client components so they can be unit-tested
5 * without a DOM. Used by DocsSearch and FaqSearch.
6 */
7
8 import type { DocTopic } from "./docs-map";
9
10 const CATEGORY_LABELS: Record<string, { en: string; zh: string }> = {
11 "getting-started": { en: "Getting started", zh: "入门" },
12 "core-concepts": { en: "Core concepts", zh: "核心概念" },
13 reference: { en: "Reference", zh: "参考" },
14 extending: { en: "Extending", zh: "扩展" },
15 operations: { en: "Operations & community", zh: "运维与社区" },
16 };
17
18 /**
19 * Build a lowercase haystack string for a DocTopic, searching across both
20 * locales, source files, category name, and id/slug.
21 */
22 export function docTopicHaystack(t: DocTopic): string {
23 const sources = Array.isArray(t.repoSource) ? t.repoSource : [t.repoSource];
24 const parts = [
25 t.id,
26 t.slug,
27 t.label.en,
28 t.label.zh,
29 t.description.en,
30 t.description.zh,
31 ...sources,
32 t.category,
33 CATEGORY_LABELS[t.category]?.en ?? "",
34 CATEGORY_LABELS[t.category]?.zh ?? "",
35 ];
36 if (t.id === "fleet") parts.push("pod");
37 return parts.join(" ").toLowerCase();
38 }
39
40 /**
41 * Filter DocTopics by keyword query. Returns indices into the input array.
42 * Empty/whitespace query returns all indices.
43 */
44 export function filterDocTopics(topics: DocTopic[], query: string): number[] {
45 const q = query.trim().toLowerCase();
46 if (!q) return topics.map((_, i) => i);
47 return topics
48 .map((t, i) => ({ i, hay: docTopicHaystack(t) }))
49 .filter(({ hay }) => hay.includes(q))
50 .map(({ i }) => i);
51 }
52
53 /**
54 * Normalize a query for matching.
55 */
56 export function normalizeQuery(query: string): string {
57 return query.trim().toLowerCase();
58 }
59
60 /**
61 * Check whether a query matches a haystack (case-insensitive substring).
62 */
63 export function matches(haystack: string, query: string): boolean {
64 const q = normalizeQuery(query);
65 if (!q) return true;
66 return haystack.toLowerCase().includes(q);
67 }
68
69 /** The three pieces a highlighted match splits a string into. */
70 export interface HighlightSpan {
71 before: string;
72 match: string;
73 after: string;
74 }
75
76 /**
77 * Locate `query` inside `text`, case-insensitively, in `text`'s own indices.
78 *
79 * The obvious form — `text.toLowerCase().indexOf(q)`, then slicing `text`
80 * with that index — assumes lowercasing preserves length. It does not:
81 * `"İ".toLowerCase()` is two code units, so every index after a dotted
82 * capital I in the haystack is off by one and the highlight lands on the
83 * wrong characters. Turkish is a routed locale, so this is reachable the
84 * moment localized copy enters the search haystack.
85 *
86 * Lowercasing character by character and keeping a position map costs one
87 * pass and keeps the three returned pieces exactly reassembling `text`.
88 * Returns null when there is no match (including an empty query).
89 */
90 export function highlightSpan(text: string, query: string): HighlightSpan | null {
91 const q = normalizeQuery(query);
92 if (!q) return null;
93
94 let lower = "";
95 // For each code unit of `lower`: where its source character starts and ends.
96 const sourceStart: number[] = [];
97 const sourceEnd: number[] = [];
98 for (let i = 0; i < text.length; ) {
99 const char = String.fromCodePoint(text.codePointAt(i)!);
100 const next = i + char.length;
101 const folded = char.toLowerCase();
102 for (let k = 0; k < folded.length; k++) {
103 sourceStart.push(i);
104 sourceEnd.push(next);
105 }
106 lower += folded;
107 i = next;
108 }
109
110 const idx = lower.indexOf(q);
111 if (idx === -1) return null;
112
113 const start = sourceStart[idx];
114 const stop = idx + q.length;
115 // A match ending inside one source character's expansion cannot claim half
116 // of that character; take the whole character rather than nothing.
117 const end = stop < lower.length ? Math.max(sourceStart[stop], sourceEnd[idx]) : text.length;
118
119 return {
120 before: text.slice(0, start),
121 match: text.slice(start, end),
122 after: text.slice(end),
123 };
124 }
125
125 lines TYPESCRIPT