@igstack/app-catalog-shared-core 5.0.1 → 5.0.2-alpha-20260923230903
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -45,6 +45,25 @@ function layoutFallbackQuery(searchQuery, results) {
|
|
|
45
45
|
function allTermsMatcher(terms) {
|
|
46
46
|
return (text) => terms.every((term) => text.includes(term));
|
|
47
47
|
}
|
|
48
|
+
function nameMatchers(query) {
|
|
49
|
+
const compact = (s) => s.replace(/[^\p{L}\p{N}]+/gu, "");
|
|
50
|
+
const compactQuery = /^[\p{L}\p{N}\s]+$/u.test(query) && query.split(/\s+/).every((word) => word.length >= 2) ? compact(query) : null;
|
|
51
|
+
const both = (raw, compacted) => (name) => raw(name) || compactQuery !== null && compacted(compact(name));
|
|
52
|
+
return {
|
|
53
|
+
exact: both(
|
|
54
|
+
(name) => name === query,
|
|
55
|
+
(name) => name === compactQuery
|
|
56
|
+
),
|
|
57
|
+
prefix: both(
|
|
58
|
+
(name) => name.startsWith(query),
|
|
59
|
+
(name) => name.startsWith(compactQuery)
|
|
60
|
+
),
|
|
61
|
+
contains: both(
|
|
62
|
+
(name) => name.includes(query),
|
|
63
|
+
(name) => name.includes(compactQuery)
|
|
64
|
+
)
|
|
65
|
+
};
|
|
66
|
+
}
|
|
48
67
|
function searchResourcesRanked(resources, searchQuery) {
|
|
49
68
|
const normalizedQuery = searchQuery.trim().toLowerCase();
|
|
50
69
|
const rootResources = resources.filter((r) => !r.parentSlug);
|
|
@@ -53,6 +72,7 @@ function searchResourcesRanked(resources, searchQuery) {
|
|
|
53
72
|
}
|
|
54
73
|
const queryTerms = normalizedQuery.split(/\s+/).filter(Boolean);
|
|
55
74
|
const allTermsMatch = allTermsMatcher(queryTerms);
|
|
75
|
+
const is = nameMatchers(normalizedQuery);
|
|
56
76
|
const childrenByParent = /* @__PURE__ */ new Map();
|
|
57
77
|
for (const r of resources) {
|
|
58
78
|
if (r.parentSlug) {
|
|
@@ -70,22 +90,22 @@ function searchResourcesRanked(resources, searchQuery) {
|
|
|
70
90
|
const tags = ((_d = app.tags) == null ? void 0 : _d.join(" ").toLowerCase()) || "";
|
|
71
91
|
const teams = ((_e = app.teams) == null ? void 0 : _e.join(" ").toLowerCase()) || "";
|
|
72
92
|
const hit = (field, type, score) => ({ result: { app, match: { field, type } }, score });
|
|
73
|
-
if (abbreviation && abbreviation
|
|
93
|
+
if (abbreviation && is.exact(abbreviation)) {
|
|
74
94
|
return hit("abbreviation", "exact", 0);
|
|
75
95
|
}
|
|
76
|
-
if (name
|
|
96
|
+
if (is.exact(name)) {
|
|
77
97
|
return hit("displayName", "exact", 1);
|
|
78
98
|
}
|
|
79
|
-
if (nicknames.some(
|
|
99
|
+
if (nicknames.some(is.exact)) {
|
|
80
100
|
return hit("nicknames", "exact", 2);
|
|
81
101
|
}
|
|
82
|
-
if (abbreviation &&
|
|
102
|
+
if (abbreviation && is.prefix(abbreviation)) {
|
|
83
103
|
return hit("abbreviation", "prefix", 3);
|
|
84
104
|
}
|
|
85
|
-
if (
|
|
105
|
+
if (is.prefix(name)) {
|
|
86
106
|
return hit("displayName", "prefix", 4);
|
|
87
107
|
}
|
|
88
|
-
if (nicknames.some(
|
|
108
|
+
if (nicknames.some(is.prefix)) {
|
|
89
109
|
return hit("nicknames", "prefix", 5);
|
|
90
110
|
}
|
|
91
111
|
if ((_f = app.tags) == null ? void 0 : _f.some((tag) => tag.toLowerCase() === normalizedQuery)) {
|
|
@@ -94,13 +114,13 @@ function searchResourcesRanked(resources, searchQuery) {
|
|
|
94
114
|
if ((_g = app.tags) == null ? void 0 : _g.some((tag) => tag.toLowerCase().startsWith(normalizedQuery))) {
|
|
95
115
|
return hit("tags", "prefix", 7);
|
|
96
116
|
}
|
|
97
|
-
if (abbreviation &&
|
|
117
|
+
if (abbreviation && is.contains(abbreviation)) {
|
|
98
118
|
return hit("abbreviation", "contains", 8);
|
|
99
119
|
}
|
|
100
|
-
if (
|
|
120
|
+
if (is.contains(name)) {
|
|
101
121
|
return hit("displayName", "contains", 9);
|
|
102
122
|
}
|
|
103
|
-
if (nicknames.some(
|
|
123
|
+
if (nicknames.some(is.contains)) {
|
|
104
124
|
return hit("nicknames", "contains", 10);
|
|
105
125
|
}
|
|
106
126
|
if (tags.includes(normalizedQuery)) {
|
|
@@ -147,6 +167,7 @@ function searchWithinApp(resources, appSlug, searchQuery) {
|
|
|
147
167
|
}
|
|
148
168
|
const queryTerms = normalizedQuery.split(/\s+/).filter(Boolean);
|
|
149
169
|
const allTermsMatch = allTermsMatcher(queryTerms);
|
|
170
|
+
const is = nameMatchers(normalizedQuery);
|
|
150
171
|
const scored = children.map((app) => {
|
|
151
172
|
var _a, _b;
|
|
152
173
|
const name = app.displayName.toLowerCase();
|
|
@@ -154,18 +175,12 @@ function searchWithinApp(resources, appSlug, searchQuery) {
|
|
|
154
175
|
const aliases = ((_a = app.aliases) == null ? void 0 : _a.map((a) => a.toLowerCase())) ?? [];
|
|
155
176
|
const description = ((_b = app.description) == null ? void 0 : _b.toLowerCase()) ?? "";
|
|
156
177
|
const hit = (field, type, score) => ({ result: { app, match: { field, type } }, score });
|
|
157
|
-
if (name
|
|
158
|
-
if (slug
|
|
159
|
-
if (aliases.some(
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
if (
|
|
163
|
-
return hit("displayName", "prefix", 3);
|
|
164
|
-
}
|
|
165
|
-
if (slug.startsWith(normalizedQuery)) return hit("slug", "prefix", 4);
|
|
166
|
-
if (aliases.some((a) => a.startsWith(normalizedQuery))) {
|
|
167
|
-
return hit("aliases", "prefix", 5);
|
|
168
|
-
}
|
|
178
|
+
if (is.exact(name)) return hit("displayName", "exact", 0);
|
|
179
|
+
if (is.exact(slug)) return hit("slug", "exact", 1);
|
|
180
|
+
if (aliases.some(is.exact)) return hit("aliases", "exact", 2);
|
|
181
|
+
if (is.prefix(name)) return hit("displayName", "prefix", 3);
|
|
182
|
+
if (is.prefix(slug)) return hit("slug", "prefix", 4);
|
|
183
|
+
if (aliases.some(is.prefix)) return hit("aliases", "prefix", 5);
|
|
169
184
|
if (allTermsMatch(name)) return hit("displayName", "contains", 6);
|
|
170
185
|
if (allTermsMatch(slug)) return hit("slug", "contains", 7);
|
|
171
186
|
if (aliases.some((a) => allTermsMatch(a))) {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"searchResources.js","sources":["../../src/searchResources.ts"],"sourcesContent":["/**\n * Relevance ranking for catalog resources.\n *\n * Lives in shared-core so that every consumer — the catalog grid and the MCP\n * server — ranks identically and cannot drift. It must NOT import `Resource`\n * from backend-core: backend-core already depends on shared-core, so that would\n * be a cycle. Instead the functions are generic over a structural\n * `SearchableResource`, which `Resource` satisfies; each caller gets its own\n * type back.\n */\n\n/** The subset of a resource this engine reads. */\nexport interface SearchableResource {\n slug: string\n displayName: string\n abbreviation?: string\n nicknames?: string[]\n aliases?: string[]\n description?: string\n tags?: string[]\n teams?: string[]\n /** Undefined for top-level apps; the parent app's slug for sub-resources. */\n parentSlug?: string\n}\n\nexport interface SearchMatch {\n /** Field where the match occurred */\n field:\n | 'displayName'\n | 'abbreviation'\n | 'nicknames'\n | 'slug'\n | 'aliases'\n | 'tags'\n | 'teams'\n | 'description'\n | 'subResource'\n /** Type of match */\n type: 'exact' | 'prefix' | 'contains'\n}\n\nexport interface SearchResult<T extends SearchableResource> {\n app: T\n /**\n * Which field matched and how. Absent only for the empty-query browse case,\n * where nothing was matched against.\n */\n match?: SearchMatch\n}\n\n/**\n * Latin character sitting on the same physical key as each Cyrillic one\n * (ЙЦУКЕН against QWERTY).\n */\nconst LATIN_BY_CYRILLIC_KEY: Record<string, string> = {\n й: 'q',\n ц: 'w',\n у: 'e',\n к: 'r',\n е: 't',\n н: 'y',\n г: 'u',\n ш: 'i',\n щ: 'o',\n з: 'p',\n х: '[',\n ъ: ']',\n ф: 'a',\n ы: 's',\n в: 'd',\n а: 'f',\n п: 'g',\n р: 'h',\n о: 'j',\n л: 'k',\n д: 'l',\n ж: ';',\n э: \"'\",\n я: 'z',\n ч: 'x',\n с: 'c',\n м: 'v',\n и: 'b',\n т: 'n',\n ь: 'm',\n б: ',',\n ю: '.',\n ё: '`',\n}\n\nconst CYRILLIC = /[а-яё]/i\n\n/**\n * Re-read a query as if it had been typed with the keyboard on the Cyrillic\n * layout: each Cyrillic character becomes the Latin character on the same\n * physical key, so `пфещк` reads back as `gator`. Anything with no Cyrillic key\n * is passed through.\n *\n * Case is not preserved — callers search case-insensitively.\n */\nexport function cyrillicLayoutToLatin(query: string): string {\n return Array.from(query)\n .map((char) => LATIN_BY_CYRILLIC_KEY[char.toLowerCase()] ?? char)\n .join('')\n}\n\n/**\n * The query to retry with when a search came back empty, or `null` when there\n * is nothing else to try. Typing an English name with the keyboard left on the\n * Cyrillic layout is the one case worth a second pass.\n */\nfunction layoutFallbackQuery(\n searchQuery: string,\n results: readonly unknown[],\n): string | null {\n if (results.length > 0 || !CYRILLIC.test(searchQuery)) return null\n const latin = cyrillicLayoutToLatin(searchQuery)\n return latin === searchQuery ? null : latin\n}\n\n/** All terms appear in the text, order-independent (AND logic). */\nfunction allTermsMatcher(terms: string[]): (text: string) => boolean {\n return (text: string) => terms.every((term) => text.includes(term))\n}\n\n/**\n * Search and rank resources by relevance, keeping the match info.\n *\n * Only root resources (no `parentSlug`) are scored and returned. Child\n * resources contribute to their parent's score — a query matching a child's\n * displayName, alias or description surfaces the parent.\n *\n * Priority order:\n * 0. Exact match in abbreviation\n * 1. Exact match in displayName\n * 2. Exact match in nickname\n * 3. Prefix match in abbreviation\n * 4. Prefix match in displayName\n * 5. Prefix match in nickname\n * 6. Exact match in tags\n * 7. Prefix match in tags\n * 8. Contains match in abbreviation\n * 9. Contains match in displayName\n * 10. Contains match in nickname\n * 11. Contains match in tags\n * 12. Teams\n * 13. Sub-resource\n * 14. Description\n *\n * @param resources - Array of all resources (roots + children)\n * @param searchQuery - Search query string\n * @returns Filtered and sorted root resources with their match info\n */\nexport function searchResourcesRanked<T extends SearchableResource>(\n resources: T[],\n searchQuery: string,\n): SearchResult<T>[] {\n const normalizedQuery = searchQuery.trim().toLowerCase()\n\n // Separate root resources from children\n const rootResources = resources.filter((r) => !r.parentSlug)\n\n if (normalizedQuery === '') {\n return rootResources.map((app) => ({ app }))\n }\n\n // Split query into terms for multi-word matching (AND logic)\n const queryTerms = normalizedQuery.split(/\\s+/).filter(Boolean)\n const allTermsMatch = allTermsMatcher(queryTerms)\n\n // Build children lookup: parentSlug -> T[]\n const childrenByParent = new Map<string, T[]>()\n for (const r of resources) {\n if (r.parentSlug) {\n const list = childrenByParent.get(r.parentSlug) ?? []\n list.push(r)\n childrenByParent.set(r.parentSlug, list)\n }\n }\n\n // Filter and score root resources\n const scoredApps = rootResources\n .map((app): { result: SearchResult<T>; score: number } | null => {\n const name = app.displayName.toLowerCase()\n const abbreviation = app.abbreviation?.toLowerCase() || ''\n const nicknames = app.nicknames?.map((n) => n.toLowerCase()) || []\n const description = app.description?.toLowerCase() || ''\n const tags = app.tags?.join(' ').toLowerCase() || ''\n const teams = app.teams?.join(' ').toLowerCase() || ''\n\n const hit = (\n field: SearchMatch['field'],\n type: SearchMatch['type'],\n score: number,\n ) => ({ result: { app, match: { field, type } }, score })\n\n // Check exact matches first - prioritize abbreviation over displayName\n if (abbreviation && abbreviation === normalizedQuery) {\n return hit('abbreviation', 'exact', 0)\n }\n if (name === normalizedQuery) {\n return hit('displayName', 'exact', 1)\n }\n if (nicknames.some((n) => n === normalizedQuery)) {\n return hit('nicknames', 'exact', 2)\n }\n\n // Check prefix matches\n if (abbreviation && abbreviation.startsWith(normalizedQuery)) {\n return hit('abbreviation', 'prefix', 3)\n }\n if (name.startsWith(normalizedQuery)) {\n return hit('displayName', 'prefix', 4)\n }\n if (nicknames.some((n) => n.startsWith(normalizedQuery))) {\n return hit('nicknames', 'prefix', 5)\n }\n\n // Check exact match in tags (any tag exactly matches query)\n if (app.tags?.some((tag) => tag.toLowerCase() === normalizedQuery)) {\n return hit('tags', 'exact', 6)\n }\n\n // Check tags - prefix match (any tag starts with query)\n if (\n app.tags?.some((tag) => tag.toLowerCase().startsWith(normalizedQuery))\n ) {\n return hit('tags', 'prefix', 7)\n }\n\n // Check contains matches - prioritize abbreviation over displayName\n if (abbreviation && abbreviation.includes(normalizedQuery)) {\n return hit('abbreviation', 'contains', 8)\n }\n if (name.includes(normalizedQuery)) {\n return hit('displayName', 'contains', 9)\n }\n if (nicknames.some((n) => n.includes(normalizedQuery))) {\n return hit('nicknames', 'contains', 10)\n }\n\n // Check tags - contains match\n if (tags.includes(normalizedQuery)) {\n return hit('tags', 'contains', 11)\n }\n\n // Check teams (multi-word)\n if (allTermsMatch(teams)) {\n return hit('teams', 'contains', 12)\n }\n\n // Check description (multi-word)\n if (allTermsMatch(description)) {\n return hit('description', 'contains', 14)\n }\n\n // Check child resources (name, aliases, description) — supports\n // multi-word queries. Alias matching is what makes a bare numeric\n // identifier find its parent app in deployments where sub-resources\n // carry numeric aliases.\n const children = childrenByParent.get(app.slug)\n if (children) {\n const subMatch = children.some(\n (r) =>\n allTermsMatch(r.displayName.toLowerCase()) ||\n (r.aliases ?? []).some((a) => allTermsMatch(a.toLowerCase())) ||\n (r.description\n ? allTermsMatch(r.description.toLowerCase())\n : false),\n )\n if (subMatch) {\n return hit('subResource', 'contains', 13)\n }\n }\n\n // No match found\n return null\n })\n .filter(\n (item): item is { result: SearchResult<T>; score: number } =>\n item !== null,\n )\n\n // Sort by score (ascending - lower score = higher priority)\n scoredApps.sort((a, b) => {\n if (a.score !== b.score) {\n return a.score - b.score\n }\n // If same score, sort alphabetically by display name\n return a.result.app.displayName.localeCompare(b.result.app.displayName)\n })\n\n const fallback = layoutFallbackQuery(normalizedQuery, scoredApps)\n if (fallback) return searchResourcesRanked(resources, fallback)\n\n return scoredApps.map((item) => item.result)\n}\n\n/**\n * Search and sort resources by relevance — the roots-only roll-up the catalog\n * grid uses. Same ranking as {@link searchResourcesRanked}, without the match\n * info.\n */\nexport function searchResources<T extends SearchableResource>(\n resources: T[],\n searchQuery: string,\n): T[] {\n return searchResourcesRanked(resources, searchQuery).map((r) => r.app)\n}\n\n/**\n * Ranked search *within* one app's sub-resources — the `\"<app>/<term>\"` form.\n *\n * Matches a child's `displayName`, `slug`, `aliases` and `description`. Alias\n * matching is load-bearing: in a deployment where every sub-resource carries a\n * numeric alias, searching that bare number must find the sub-resource.\n *\n * @param resources - Array of all resources (roots + children)\n * @param appSlug - Parent app slug whose children to search\n * @param searchQuery - Search query string; empty returns every child\n */\nexport function searchWithinApp<T extends SearchableResource>(\n resources: T[],\n appSlug: string,\n searchQuery: string,\n): SearchResult<T>[] {\n const children = resources.filter((r) => r.parentSlug === appSlug)\n const normalizedQuery = searchQuery.trim().toLowerCase()\n\n const byName = (a: T, b: T) => a.displayName.localeCompare(b.displayName)\n\n if (normalizedQuery === '') {\n return [...children].sort(byName).map((app) => ({ app }))\n }\n\n const queryTerms = normalizedQuery.split(/\\s+/).filter(Boolean)\n const allTermsMatch = allTermsMatcher(queryTerms)\n\n const scored = children\n .map((app): { result: SearchResult<T>; score: number } | null => {\n const name = app.displayName.toLowerCase()\n const slug = app.slug.toLowerCase()\n const aliases = app.aliases?.map((a) => a.toLowerCase()) ?? []\n const description = app.description?.toLowerCase() ?? ''\n\n const hit = (\n field: SearchMatch['field'],\n type: SearchMatch['type'],\n score: number,\n ) => ({ result: { app, match: { field, type } }, score })\n\n if (name === normalizedQuery) return hit('displayName', 'exact', 0)\n if (slug === normalizedQuery) return hit('slug', 'exact', 1)\n if (aliases.some((a) => a === normalizedQuery)) {\n return hit('aliases', 'exact', 2)\n }\n\n if (name.startsWith(normalizedQuery)) {\n return hit('displayName', 'prefix', 3)\n }\n if (slug.startsWith(normalizedQuery)) return hit('slug', 'prefix', 4)\n if (aliases.some((a) => a.startsWith(normalizedQuery))) {\n return hit('aliases', 'prefix', 5)\n }\n\n if (allTermsMatch(name)) return hit('displayName', 'contains', 6)\n if (allTermsMatch(slug)) return hit('slug', 'contains', 7)\n if (aliases.some((a) => allTermsMatch(a))) {\n return hit('aliases', 'contains', 8)\n }\n if (description && allTermsMatch(description)) {\n return hit('description', 'contains', 9)\n }\n\n return null\n })\n .filter(\n (item): item is { result: SearchResult<T>; score: number } =>\n item !== null,\n )\n\n scored.sort((a, b) =>\n a.score !== b.score\n ? a.score - b.score\n : byName(a.result.app, b.result.app),\n )\n\n const fallback = layoutFallbackQuery(normalizedQuery, scored)\n if (fallback) return searchWithinApp(resources, appSlug, fallback)\n\n return scored.map((item) => item.result)\n}\n"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"searchResources.js","sources":["../../src/searchResources.ts"],"sourcesContent":["/**\n * Relevance ranking for catalog resources.\n *\n * Lives in shared-core so that every consumer — the catalog grid and the MCP\n * server — ranks identically and cannot drift. It must NOT import `Resource`\n * from backend-core: backend-core already depends on shared-core, so that would\n * be a cycle. Instead the functions are generic over a structural\n * `SearchableResource`, which `Resource` satisfies; each caller gets its own\n * type back.\n */\n\n/** The subset of a resource this engine reads. */\nexport interface SearchableResource {\n slug: string\n displayName: string\n abbreviation?: string\n nicknames?: string[]\n aliases?: string[]\n description?: string\n tags?: string[]\n teams?: string[]\n /** Undefined for top-level apps; the parent app's slug for sub-resources. */\n parentSlug?: string\n}\n\nexport interface SearchMatch {\n /** Field where the match occurred */\n field:\n | 'displayName'\n | 'abbreviation'\n | 'nicknames'\n | 'slug'\n | 'aliases'\n | 'tags'\n | 'teams'\n | 'description'\n | 'subResource'\n /** Type of match */\n type: 'exact' | 'prefix' | 'contains'\n}\n\nexport interface SearchResult<T extends SearchableResource> {\n app: T\n /**\n * Which field matched and how. Absent only for the empty-query browse case,\n * where nothing was matched against.\n */\n match?: SearchMatch\n}\n\n/**\n * Latin character sitting on the same physical key as each Cyrillic one\n * (ЙЦУКЕН against QWERTY).\n */\nconst LATIN_BY_CYRILLIC_KEY: Record<string, string> = {\n й: 'q',\n ц: 'w',\n у: 'e',\n к: 'r',\n е: 't',\n н: 'y',\n г: 'u',\n ш: 'i',\n щ: 'o',\n з: 'p',\n х: '[',\n ъ: ']',\n ф: 'a',\n ы: 's',\n в: 'd',\n а: 'f',\n п: 'g',\n р: 'h',\n о: 'j',\n л: 'k',\n д: 'l',\n ж: ';',\n э: \"'\",\n я: 'z',\n ч: 'x',\n с: 'c',\n м: 'v',\n и: 'b',\n т: 'n',\n ь: 'm',\n б: ',',\n ю: '.',\n ё: '`',\n}\n\nconst CYRILLIC = /[а-яё]/i\n\n/**\n * Re-read a query as if it had been typed with the keyboard on the Cyrillic\n * layout: each Cyrillic character becomes the Latin character on the same\n * physical key, so `пфещк` reads back as `gator`. Anything with no Cyrillic key\n * is passed through.\n *\n * Case is not preserved — callers search case-insensitively.\n */\nexport function cyrillicLayoutToLatin(query: string): string {\n return Array.from(query)\n .map((char) => LATIN_BY_CYRILLIC_KEY[char.toLowerCase()] ?? char)\n .join('')\n}\n\n/**\n * The query to retry with when a search came back empty, or `null` when there\n * is nothing else to try. Typing an English name with the keyboard left on the\n * Cyrillic layout is the one case worth a second pass.\n */\nfunction layoutFallbackQuery(\n searchQuery: string,\n results: readonly unknown[],\n): string | null {\n if (results.length > 0 || !CYRILLIC.test(searchQuery)) return null\n const latin = cyrillicLayoutToLatin(searchQuery)\n return latin === searchQuery ? null : latin\n}\n\n/** All terms appear in the text, order-independent (AND logic). */\nfunction allTermsMatcher(terms: string[]): (text: string) => boolean {\n return (text: string) => terms.every((term) => text.includes(term))\n}\n\n/**\n * Name comparisons that also ignore whitespace and punctuation, so a CamelCase\n * name typed as two words (`acme pro` → `AcmePro`) or a dotted abbreviation\n * (`lis` → `L.I.S.`) still hits the name tiers instead of sinking to the\n * description. Both sides are expected lower-cased already.\n */\nfunction nameMatchers(query: string) {\n const compact = (s: string) => s.replace(/[^\\p{L}\\p{N}]+/gu, '')\n // Only a plain-words query is compacted, and only when every word has at\n // least two characters: `c#`, `r&d` or `a b` would otherwise shrink to a\n // letter or two and prefix-match half the catalog. Anything else compares\n // raw, exactly as before the compact form existed.\n const compactQuery =\n /^[\\p{L}\\p{N}\\s]+$/u.test(query) &&\n query.split(/\\s+/).every((word) => word.length >= 2)\n ? compact(query)\n : null\n const both =\n (raw: (name: string) => boolean, compacted: (name: string) => boolean) =>\n (name: string) =>\n raw(name) || (compactQuery !== null && compacted(compact(name)))\n return {\n exact: both(\n (name) => name === query,\n (name) => name === compactQuery,\n ),\n prefix: both(\n (name) => name.startsWith(query),\n (name) => name.startsWith(compactQuery!),\n ),\n contains: both(\n (name) => name.includes(query),\n (name) => name.includes(compactQuery!),\n ),\n }\n}\n\n/**\n * Search and rank resources by relevance, keeping the match info.\n *\n * Only root resources (no `parentSlug`) are scored and returned. Child\n * resources contribute to their parent's score — a query matching a child's\n * displayName, alias or description surfaces the parent.\n *\n * Priority order:\n * 0. Exact match in abbreviation\n * 1. Exact match in displayName\n * 2. Exact match in nickname\n * 3. Prefix match in abbreviation\n * 4. Prefix match in displayName\n * 5. Prefix match in nickname\n * 6. Exact match in tags\n * 7. Prefix match in tags\n * 8. Contains match in abbreviation\n * 9. Contains match in displayName\n * 10. Contains match in nickname\n * 11. Contains match in tags\n * 12. Teams\n * 13. Sub-resource\n * 14. Description\n *\n * @param resources - Array of all resources (roots + children)\n * @param searchQuery - Search query string\n * @returns Filtered and sorted root resources with their match info\n */\nexport function searchResourcesRanked<T extends SearchableResource>(\n resources: T[],\n searchQuery: string,\n): SearchResult<T>[] {\n const normalizedQuery = searchQuery.trim().toLowerCase()\n\n // Separate root resources from children\n const rootResources = resources.filter((r) => !r.parentSlug)\n\n if (normalizedQuery === '') {\n return rootResources.map((app) => ({ app }))\n }\n\n // Split query into terms for multi-word matching (AND logic)\n const queryTerms = normalizedQuery.split(/\\s+/).filter(Boolean)\n const allTermsMatch = allTermsMatcher(queryTerms)\n const is = nameMatchers(normalizedQuery)\n\n // Build children lookup: parentSlug -> T[]\n const childrenByParent = new Map<string, T[]>()\n for (const r of resources) {\n if (r.parentSlug) {\n const list = childrenByParent.get(r.parentSlug) ?? []\n list.push(r)\n childrenByParent.set(r.parentSlug, list)\n }\n }\n\n // Filter and score root resources\n const scoredApps = rootResources\n .map((app): { result: SearchResult<T>; score: number } | null => {\n const name = app.displayName.toLowerCase()\n const abbreviation = app.abbreviation?.toLowerCase() || ''\n const nicknames = app.nicknames?.map((n) => n.toLowerCase()) || []\n const description = app.description?.toLowerCase() || ''\n const tags = app.tags?.join(' ').toLowerCase() || ''\n const teams = app.teams?.join(' ').toLowerCase() || ''\n\n const hit = (\n field: SearchMatch['field'],\n type: SearchMatch['type'],\n score: number,\n ) => ({ result: { app, match: { field, type } }, score })\n\n // Check exact matches first - prioritize abbreviation over displayName\n if (abbreviation && is.exact(abbreviation)) {\n return hit('abbreviation', 'exact', 0)\n }\n if (is.exact(name)) {\n return hit('displayName', 'exact', 1)\n }\n if (nicknames.some(is.exact)) {\n return hit('nicknames', 'exact', 2)\n }\n\n // Check prefix matches\n if (abbreviation && is.prefix(abbreviation)) {\n return hit('abbreviation', 'prefix', 3)\n }\n if (is.prefix(name)) {\n return hit('displayName', 'prefix', 4)\n }\n if (nicknames.some(is.prefix)) {\n return hit('nicknames', 'prefix', 5)\n }\n\n // Check exact match in tags (any tag exactly matches query)\n if (app.tags?.some((tag) => tag.toLowerCase() === normalizedQuery)) {\n return hit('tags', 'exact', 6)\n }\n\n // Check tags - prefix match (any tag starts with query)\n if (\n app.tags?.some((tag) => tag.toLowerCase().startsWith(normalizedQuery))\n ) {\n return hit('tags', 'prefix', 7)\n }\n\n // Check contains matches - prioritize abbreviation over displayName\n if (abbreviation && is.contains(abbreviation)) {\n return hit('abbreviation', 'contains', 8)\n }\n if (is.contains(name)) {\n return hit('displayName', 'contains', 9)\n }\n if (nicknames.some(is.contains)) {\n return hit('nicknames', 'contains', 10)\n }\n\n // Check tags - contains match\n if (tags.includes(normalizedQuery)) {\n return hit('tags', 'contains', 11)\n }\n\n // Check teams (multi-word)\n if (allTermsMatch(teams)) {\n return hit('teams', 'contains', 12)\n }\n\n // Check description (multi-word)\n if (allTermsMatch(description)) {\n return hit('description', 'contains', 14)\n }\n\n // Check child resources (name, aliases, description) — supports\n // multi-word queries. Alias matching is what makes a bare numeric\n // identifier find its parent app in deployments where sub-resources\n // carry numeric aliases.\n const children = childrenByParent.get(app.slug)\n if (children) {\n const subMatch = children.some(\n (r) =>\n allTermsMatch(r.displayName.toLowerCase()) ||\n (r.aliases ?? []).some((a) => allTermsMatch(a.toLowerCase())) ||\n (r.description\n ? allTermsMatch(r.description.toLowerCase())\n : false),\n )\n if (subMatch) {\n return hit('subResource', 'contains', 13)\n }\n }\n\n // No match found\n return null\n })\n .filter(\n (item): item is { result: SearchResult<T>; score: number } =>\n item !== null,\n )\n\n // Sort by score (ascending - lower score = higher priority)\n scoredApps.sort((a, b) => {\n if (a.score !== b.score) {\n return a.score - b.score\n }\n // If same score, sort alphabetically by display name\n return a.result.app.displayName.localeCompare(b.result.app.displayName)\n })\n\n const fallback = layoutFallbackQuery(normalizedQuery, scoredApps)\n if (fallback) return searchResourcesRanked(resources, fallback)\n\n return scoredApps.map((item) => item.result)\n}\n\n/**\n * Search and sort resources by relevance — the roots-only roll-up the catalog\n * grid uses. Same ranking as {@link searchResourcesRanked}, without the match\n * info.\n */\nexport function searchResources<T extends SearchableResource>(\n resources: T[],\n searchQuery: string,\n): T[] {\n return searchResourcesRanked(resources, searchQuery).map((r) => r.app)\n}\n\n/**\n * Ranked search *within* one app's sub-resources — the `\"<app>/<term>\"` form.\n *\n * Matches a child's `displayName`, `slug`, `aliases` and `description`. Alias\n * matching is load-bearing: in a deployment where every sub-resource carries a\n * numeric alias, searching that bare number must find the sub-resource.\n *\n * @param resources - Array of all resources (roots + children)\n * @param appSlug - Parent app slug whose children to search\n * @param searchQuery - Search query string; empty returns every child\n */\nexport function searchWithinApp<T extends SearchableResource>(\n resources: T[],\n appSlug: string,\n searchQuery: string,\n): SearchResult<T>[] {\n const children = resources.filter((r) => r.parentSlug === appSlug)\n const normalizedQuery = searchQuery.trim().toLowerCase()\n\n const byName = (a: T, b: T) => a.displayName.localeCompare(b.displayName)\n\n if (normalizedQuery === '') {\n return [...children].sort(byName).map((app) => ({ app }))\n }\n\n const queryTerms = normalizedQuery.split(/\\s+/).filter(Boolean)\n const allTermsMatch = allTermsMatcher(queryTerms)\n const is = nameMatchers(normalizedQuery)\n\n const scored = children\n .map((app): { result: SearchResult<T>; score: number } | null => {\n const name = app.displayName.toLowerCase()\n const slug = app.slug.toLowerCase()\n const aliases = app.aliases?.map((a) => a.toLowerCase()) ?? []\n const description = app.description?.toLowerCase() ?? ''\n\n const hit = (\n field: SearchMatch['field'],\n type: SearchMatch['type'],\n score: number,\n ) => ({ result: { app, match: { field, type } }, score })\n\n if (is.exact(name)) return hit('displayName', 'exact', 0)\n if (is.exact(slug)) return hit('slug', 'exact', 1)\n if (aliases.some(is.exact)) return hit('aliases', 'exact', 2)\n\n if (is.prefix(name)) return hit('displayName', 'prefix', 3)\n if (is.prefix(slug)) return hit('slug', 'prefix', 4)\n if (aliases.some(is.prefix)) return hit('aliases', 'prefix', 5)\n\n if (allTermsMatch(name)) return hit('displayName', 'contains', 6)\n if (allTermsMatch(slug)) return hit('slug', 'contains', 7)\n if (aliases.some((a) => allTermsMatch(a))) {\n return hit('aliases', 'contains', 8)\n }\n if (description && allTermsMatch(description)) {\n return hit('description', 'contains', 9)\n }\n\n return null\n })\n .filter(\n (item): item is { result: SearchResult<T>; score: number } =>\n item !== null,\n )\n\n scored.sort((a, b) =>\n a.score !== b.score\n ? a.score - b.score\n : byName(a.result.app, b.result.app),\n )\n\n const fallback = layoutFallbackQuery(normalizedQuery, scored)\n if (fallback) return searchWithinApp(resources, appSlug, fallback)\n\n return scored.map((item) => item.result)\n}\n"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@igstack/app-catalog-shared-core",
|
|
3
|
-
"version": "5.0.
|
|
3
|
+
"version": "5.0.2-alpha-20260923230903",
|
|
4
4
|
"description": "Shared core utilities for App Catalog",
|
|
5
5
|
"homepage": "https://github.com/lislon/app-catalog",
|
|
6
6
|
"repository": {
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
"engines": {
|
|
43
43
|
"node": ">=16"
|
|
44
44
|
},
|
|
45
|
-
"gitHead": "
|
|
45
|
+
"gitHead": "09c014d88a7f77afe6e9d0a5d776351e7552295c",
|
|
46
46
|
"scripts": {
|
|
47
47
|
"build": "vite build",
|
|
48
48
|
"clean": "premove ./dist ./coverage ./dist-ts",
|
|
@@ -125,6 +125,88 @@ describe('searchResources', () => {
|
|
|
125
125
|
})
|
|
126
126
|
})
|
|
127
127
|
|
|
128
|
+
describe('whitespace-insensitive name matching', () => {
|
|
129
|
+
const apps: SearchableResource[] = [
|
|
130
|
+
makeApp({
|
|
131
|
+
slug: 'acme-portals',
|
|
132
|
+
displayName: 'Acme Portals',
|
|
133
|
+
description: 'Landing page for every portal',
|
|
134
|
+
}),
|
|
135
|
+
makeApp({
|
|
136
|
+
slug: 'ledger',
|
|
137
|
+
displayName: 'Ledger',
|
|
138
|
+
description: 'Bookkeeping for the acme pro programme',
|
|
139
|
+
}),
|
|
140
|
+
makeApp({
|
|
141
|
+
slug: 'acme-pro',
|
|
142
|
+
displayName: 'AcmePro / Partner Portal',
|
|
143
|
+
description: 'Partner-facing ordering portal',
|
|
144
|
+
}),
|
|
145
|
+
makeApp({ slug: 'labvantage', displayName: 'LabVantage' }),
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
it('ranks a CamelCase name typed as two words above description hits', () => {
|
|
149
|
+
const results = searchResourcesRanked(apps, 'acme pro')
|
|
150
|
+
expect(
|
|
151
|
+
results.map((r) => [r.app.slug, r.match?.field, r.match?.type]),
|
|
152
|
+
).toEqual([
|
|
153
|
+
['acme-pro', 'displayName', 'prefix'],
|
|
154
|
+
['ledger', 'description', 'contains'],
|
|
155
|
+
])
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('does not let the two-word query prefix-match a different name', () => {
|
|
159
|
+
expect(searchResources(apps, 'acme pro').map((a) => a.slug)).not.toContain(
|
|
160
|
+
'acme-portals',
|
|
161
|
+
)
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('matches exact and prefix ignoring whitespace', () => {
|
|
165
|
+
expect(searchResourcesRanked(apps, 'lab vantage')[0]!.match).toEqual({
|
|
166
|
+
field: 'displayName',
|
|
167
|
+
type: 'exact',
|
|
168
|
+
})
|
|
169
|
+
expect(searchResourcesRanked(apps, 'lab vant')[0]!.match).toEqual({
|
|
170
|
+
field: 'displayName',
|
|
171
|
+
type: 'prefix',
|
|
172
|
+
})
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('applies to abbreviations and nicknames too', () => {
|
|
176
|
+
const withAliases = [
|
|
177
|
+
...apps,
|
|
178
|
+
makeApp({
|
|
179
|
+
slug: 'lims',
|
|
180
|
+
displayName: 'Laboratory Information System',
|
|
181
|
+
abbreviation: 'L.I.S.',
|
|
182
|
+
nicknames: ['LabInfo'],
|
|
183
|
+
}),
|
|
184
|
+
]
|
|
185
|
+
expect(searchResourcesRanked(withAliases, 'lis')[0]!.match).toEqual({
|
|
186
|
+
field: 'abbreviation',
|
|
187
|
+
type: 'exact',
|
|
188
|
+
})
|
|
189
|
+
expect(searchResourcesRanked(withAliases, 'lab info')[0]!.match).toEqual({
|
|
190
|
+
field: 'nicknames',
|
|
191
|
+
type: 'exact',
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
it('applies inside one app too', () => {
|
|
196
|
+
const resources: SearchableResource[] = [
|
|
197
|
+
makeApp({ slug: 'cloud', displayName: 'Cloud' }),
|
|
198
|
+
makeChildResource({
|
|
199
|
+
slug: 'cloud-prod',
|
|
200
|
+
displayName: 'ProdAccount',
|
|
201
|
+
parentSlug: 'cloud',
|
|
202
|
+
}),
|
|
203
|
+
]
|
|
204
|
+
expect(
|
|
205
|
+
searchWithinApp(resources, 'cloud', 'prod account')[0]!.match,
|
|
206
|
+
).toEqual({ field: 'displayName', type: 'exact' })
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
|
|
128
210
|
describe('searchResourcesRanked', () => {
|
|
129
211
|
const apps: SearchableResource[] = [
|
|
130
212
|
makeApp({ slug: 'taskflow', displayName: 'TaskFlow' }),
|
|
@@ -279,3 +361,29 @@ describe('Cyrillic-layout fallback', () => {
|
|
|
279
361
|
expect(results.map((r) => r.app.slug)).toEqual(['aws-prod'])
|
|
280
362
|
})
|
|
281
363
|
})
|
|
364
|
+
|
|
365
|
+
describe('punctuated and one-letter queries compare raw, not compacted', () => {
|
|
366
|
+
const apps: SearchableResource[] = [
|
|
367
|
+
makeApp({ slug: 'csharp-tools', displayName: 'C# Tools' }),
|
|
368
|
+
makeApp({ slug: 'cpp-builder', displayName: 'C++ Builder' }),
|
|
369
|
+
makeApp({ slug: 'rnd-portal', displayName: 'R&D Portal' }),
|
|
370
|
+
makeApp({ slug: 'cloud-console', displayName: 'Cloud Console' }),
|
|
371
|
+
makeApp({ slug: 'confluence', displayName: 'Confluence' }),
|
|
372
|
+
makeApp({ slug: 'dashboard', displayName: 'Dashboard' }),
|
|
373
|
+
makeApp({ slug: 'labvantage', displayName: 'LabVantage' }),
|
|
374
|
+
]
|
|
375
|
+
const slugs = (q: string) => searchResources(apps, q).map((a) => a.slug)
|
|
376
|
+
|
|
377
|
+
it('keeps `c#` and `c++` from prefix-matching every name starting with c', () => {
|
|
378
|
+
expect(slugs('c#')).toEqual(['csharp-tools'])
|
|
379
|
+
expect(slugs('c++')).toEqual(['cpp-builder'])
|
|
380
|
+
})
|
|
381
|
+
|
|
382
|
+
it('does not let `r&d` match a name merely containing "rd"', () => {
|
|
383
|
+
expect(slugs('r&d')).toEqual(['rnd-portal'])
|
|
384
|
+
})
|
|
385
|
+
|
|
386
|
+
it('does not let one-letter tokens (`a b`) match a name containing "ab"', () => {
|
|
387
|
+
expect(slugs('a b')).not.toContain('labvantage')
|
|
388
|
+
})
|
|
389
|
+
})
|
package/src/searchResources.ts
CHANGED
|
@@ -123,6 +123,43 @@ function allTermsMatcher(terms: string[]): (text: string) => boolean {
|
|
|
123
123
|
return (text: string) => terms.every((term) => text.includes(term))
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
/**
|
|
127
|
+
* Name comparisons that also ignore whitespace and punctuation, so a CamelCase
|
|
128
|
+
* name typed as two words (`acme pro` → `AcmePro`) or a dotted abbreviation
|
|
129
|
+
* (`lis` → `L.I.S.`) still hits the name tiers instead of sinking to the
|
|
130
|
+
* description. Both sides are expected lower-cased already.
|
|
131
|
+
*/
|
|
132
|
+
function nameMatchers(query: string) {
|
|
133
|
+
const compact = (s: string) => s.replace(/[^\p{L}\p{N}]+/gu, '')
|
|
134
|
+
// Only a plain-words query is compacted, and only when every word has at
|
|
135
|
+
// least two characters: `c#`, `r&d` or `a b` would otherwise shrink to a
|
|
136
|
+
// letter or two and prefix-match half the catalog. Anything else compares
|
|
137
|
+
// raw, exactly as before the compact form existed.
|
|
138
|
+
const compactQuery =
|
|
139
|
+
/^[\p{L}\p{N}\s]+$/u.test(query) &&
|
|
140
|
+
query.split(/\s+/).every((word) => word.length >= 2)
|
|
141
|
+
? compact(query)
|
|
142
|
+
: null
|
|
143
|
+
const both =
|
|
144
|
+
(raw: (name: string) => boolean, compacted: (name: string) => boolean) =>
|
|
145
|
+
(name: string) =>
|
|
146
|
+
raw(name) || (compactQuery !== null && compacted(compact(name)))
|
|
147
|
+
return {
|
|
148
|
+
exact: both(
|
|
149
|
+
(name) => name === query,
|
|
150
|
+
(name) => name === compactQuery,
|
|
151
|
+
),
|
|
152
|
+
prefix: both(
|
|
153
|
+
(name) => name.startsWith(query),
|
|
154
|
+
(name) => name.startsWith(compactQuery!),
|
|
155
|
+
),
|
|
156
|
+
contains: both(
|
|
157
|
+
(name) => name.includes(query),
|
|
158
|
+
(name) => name.includes(compactQuery!),
|
|
159
|
+
),
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
126
163
|
/**
|
|
127
164
|
* Search and rank resources by relevance, keeping the match info.
|
|
128
165
|
*
|
|
@@ -167,6 +204,7 @@ export function searchResourcesRanked<T extends SearchableResource>(
|
|
|
167
204
|
// Split query into terms for multi-word matching (AND logic)
|
|
168
205
|
const queryTerms = normalizedQuery.split(/\s+/).filter(Boolean)
|
|
169
206
|
const allTermsMatch = allTermsMatcher(queryTerms)
|
|
207
|
+
const is = nameMatchers(normalizedQuery)
|
|
170
208
|
|
|
171
209
|
// Build children lookup: parentSlug -> T[]
|
|
172
210
|
const childrenByParent = new Map<string, T[]>()
|
|
@@ -195,24 +233,24 @@ export function searchResourcesRanked<T extends SearchableResource>(
|
|
|
195
233
|
) => ({ result: { app, match: { field, type } }, score })
|
|
196
234
|
|
|
197
235
|
// Check exact matches first - prioritize abbreviation over displayName
|
|
198
|
-
if (abbreviation && abbreviation
|
|
236
|
+
if (abbreviation && is.exact(abbreviation)) {
|
|
199
237
|
return hit('abbreviation', 'exact', 0)
|
|
200
238
|
}
|
|
201
|
-
if (name
|
|
239
|
+
if (is.exact(name)) {
|
|
202
240
|
return hit('displayName', 'exact', 1)
|
|
203
241
|
}
|
|
204
|
-
if (nicknames.some(
|
|
242
|
+
if (nicknames.some(is.exact)) {
|
|
205
243
|
return hit('nicknames', 'exact', 2)
|
|
206
244
|
}
|
|
207
245
|
|
|
208
246
|
// Check prefix matches
|
|
209
|
-
if (abbreviation &&
|
|
247
|
+
if (abbreviation && is.prefix(abbreviation)) {
|
|
210
248
|
return hit('abbreviation', 'prefix', 3)
|
|
211
249
|
}
|
|
212
|
-
if (
|
|
250
|
+
if (is.prefix(name)) {
|
|
213
251
|
return hit('displayName', 'prefix', 4)
|
|
214
252
|
}
|
|
215
|
-
if (nicknames.some(
|
|
253
|
+
if (nicknames.some(is.prefix)) {
|
|
216
254
|
return hit('nicknames', 'prefix', 5)
|
|
217
255
|
}
|
|
218
256
|
|
|
@@ -229,13 +267,13 @@ export function searchResourcesRanked<T extends SearchableResource>(
|
|
|
229
267
|
}
|
|
230
268
|
|
|
231
269
|
// Check contains matches - prioritize abbreviation over displayName
|
|
232
|
-
if (abbreviation &&
|
|
270
|
+
if (abbreviation && is.contains(abbreviation)) {
|
|
233
271
|
return hit('abbreviation', 'contains', 8)
|
|
234
272
|
}
|
|
235
|
-
if (
|
|
273
|
+
if (is.contains(name)) {
|
|
236
274
|
return hit('displayName', 'contains', 9)
|
|
237
275
|
}
|
|
238
|
-
if (nicknames.some(
|
|
276
|
+
if (nicknames.some(is.contains)) {
|
|
239
277
|
return hit('nicknames', 'contains', 10)
|
|
240
278
|
}
|
|
241
279
|
|
|
@@ -335,6 +373,7 @@ export function searchWithinApp<T extends SearchableResource>(
|
|
|
335
373
|
|
|
336
374
|
const queryTerms = normalizedQuery.split(/\s+/).filter(Boolean)
|
|
337
375
|
const allTermsMatch = allTermsMatcher(queryTerms)
|
|
376
|
+
const is = nameMatchers(normalizedQuery)
|
|
338
377
|
|
|
339
378
|
const scored = children
|
|
340
379
|
.map((app): { result: SearchResult<T>; score: number } | null => {
|
|
@@ -349,19 +388,13 @@ export function searchWithinApp<T extends SearchableResource>(
|
|
|
349
388
|
score: number,
|
|
350
389
|
) => ({ result: { app, match: { field, type } }, score })
|
|
351
390
|
|
|
352
|
-
if (name
|
|
353
|
-
if (slug
|
|
354
|
-
if (aliases.some(
|
|
355
|
-
return hit('aliases', 'exact', 2)
|
|
356
|
-
}
|
|
391
|
+
if (is.exact(name)) return hit('displayName', 'exact', 0)
|
|
392
|
+
if (is.exact(slug)) return hit('slug', 'exact', 1)
|
|
393
|
+
if (aliases.some(is.exact)) return hit('aliases', 'exact', 2)
|
|
357
394
|
|
|
358
|
-
if (
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
if (slug.startsWith(normalizedQuery)) return hit('slug', 'prefix', 4)
|
|
362
|
-
if (aliases.some((a) => a.startsWith(normalizedQuery))) {
|
|
363
|
-
return hit('aliases', 'prefix', 5)
|
|
364
|
-
}
|
|
395
|
+
if (is.prefix(name)) return hit('displayName', 'prefix', 3)
|
|
396
|
+
if (is.prefix(slug)) return hit('slug', 'prefix', 4)
|
|
397
|
+
if (aliases.some(is.prefix)) return hit('aliases', 'prefix', 5)
|
|
365
398
|
|
|
366
399
|
if (allTermsMatch(name)) return hit('displayName', 'contains', 6)
|
|
367
400
|
if (allTermsMatch(slug)) return hit('slug', 'contains', 7)
|