@igstack/app-catalog-shared-core 4.0.0-alpha-20260921191849 → 4.0.0-alpha-20260921211527
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.
package/dist/esm/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { nullToUndefined, undefinedToNull } from "./nullToUndefined.js";
|
|
2
|
-
import { searchResources, searchResourcesRanked, searchWithinApp } from "./searchResources.js";
|
|
2
|
+
import { cyrillicLayoutToLatin, searchResources, searchResourcesRanked, searchWithinApp } from "./searchResources.js";
|
|
3
3
|
export {
|
|
4
|
+
cyrillicLayoutToLatin,
|
|
4
5
|
nullToUndefined,
|
|
5
6
|
searchResources,
|
|
6
7
|
searchResourcesRanked,
|
|
@@ -35,6 +35,15 @@ export interface SearchResult<T extends SearchableResource> {
|
|
|
35
35
|
*/
|
|
36
36
|
match?: SearchMatch;
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Re-read a query as if it had been typed with the keyboard on the Cyrillic
|
|
40
|
+
* layout: each Cyrillic character becomes the Latin character on the same
|
|
41
|
+
* physical key, so `пфещк` reads back as `gator`. Anything with no Cyrillic key
|
|
42
|
+
* is passed through.
|
|
43
|
+
*
|
|
44
|
+
* Case is not preserved — callers search case-insensitively.
|
|
45
|
+
*/
|
|
46
|
+
export declare function cyrillicLayoutToLatin(query: string): string;
|
|
38
47
|
/**
|
|
39
48
|
* Search and rank resources by relevance, keeping the match info.
|
|
40
49
|
*
|
|
@@ -1,3 +1,47 @@
|
|
|
1
|
+
const LATIN_BY_CYRILLIC_KEY = {
|
|
2
|
+
й: "q",
|
|
3
|
+
ц: "w",
|
|
4
|
+
у: "e",
|
|
5
|
+
к: "r",
|
|
6
|
+
е: "t",
|
|
7
|
+
н: "y",
|
|
8
|
+
г: "u",
|
|
9
|
+
ш: "i",
|
|
10
|
+
щ: "o",
|
|
11
|
+
з: "p",
|
|
12
|
+
х: "[",
|
|
13
|
+
ъ: "]",
|
|
14
|
+
ф: "a",
|
|
15
|
+
ы: "s",
|
|
16
|
+
в: "d",
|
|
17
|
+
а: "f",
|
|
18
|
+
п: "g",
|
|
19
|
+
р: "h",
|
|
20
|
+
о: "j",
|
|
21
|
+
л: "k",
|
|
22
|
+
д: "l",
|
|
23
|
+
ж: ";",
|
|
24
|
+
э: "'",
|
|
25
|
+
я: "z",
|
|
26
|
+
ч: "x",
|
|
27
|
+
с: "c",
|
|
28
|
+
м: "v",
|
|
29
|
+
и: "b",
|
|
30
|
+
т: "n",
|
|
31
|
+
ь: "m",
|
|
32
|
+
б: ",",
|
|
33
|
+
ю: ".",
|
|
34
|
+
ё: "`"
|
|
35
|
+
};
|
|
36
|
+
const CYRILLIC = /[а-яё]/i;
|
|
37
|
+
function cyrillicLayoutToLatin(query) {
|
|
38
|
+
return Array.from(query).map((char) => LATIN_BY_CYRILLIC_KEY[char.toLowerCase()] ?? char).join("");
|
|
39
|
+
}
|
|
40
|
+
function layoutFallbackQuery(searchQuery, results) {
|
|
41
|
+
if (results.length > 0 || !CYRILLIC.test(searchQuery)) return null;
|
|
42
|
+
const latin = cyrillicLayoutToLatin(searchQuery);
|
|
43
|
+
return latin === searchQuery ? null : latin;
|
|
44
|
+
}
|
|
1
45
|
function allTermsMatcher(terms) {
|
|
2
46
|
return (text) => terms.every((term) => text.includes(term));
|
|
3
47
|
}
|
|
@@ -87,6 +131,8 @@ function searchResourcesRanked(resources, searchQuery) {
|
|
|
87
131
|
}
|
|
88
132
|
return a.result.app.displayName.localeCompare(b.result.app.displayName);
|
|
89
133
|
});
|
|
134
|
+
const fallback = layoutFallbackQuery(normalizedQuery, scoredApps);
|
|
135
|
+
if (fallback) return searchResourcesRanked(resources, fallback);
|
|
90
136
|
return scoredApps.map((item) => item.result);
|
|
91
137
|
}
|
|
92
138
|
function searchResources(resources, searchQuery) {
|
|
@@ -135,9 +181,12 @@ function searchWithinApp(resources, appSlug, searchQuery) {
|
|
|
135
181
|
scored.sort(
|
|
136
182
|
(a, b) => a.score !== b.score ? a.score - b.score : byName(a.result.app, b.result.app)
|
|
137
183
|
);
|
|
184
|
+
const fallback = layoutFallbackQuery(normalizedQuery, scored);
|
|
185
|
+
if (fallback) return searchWithinApp(resources, appSlug, fallback);
|
|
138
186
|
return scored.map((item) => item.result);
|
|
139
187
|
}
|
|
140
188
|
export {
|
|
189
|
+
cyrillicLayoutToLatin,
|
|
141
190
|
searchResources,
|
|
142
191
|
searchResourcesRanked,
|
|
143
192
|
searchWithinApp
|
|
@@ -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/** 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 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 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 * 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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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@igstack/app-catalog-shared-core",
|
|
3
|
-
"version": "4.0.0-alpha-
|
|
3
|
+
"version": "4.0.0-alpha-20260921211527",
|
|
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": "cde4f909b7e551b6440f5d4c524f9da02afeb699",
|
|
46
46
|
"scripts": {
|
|
47
47
|
"build": "vite build",
|
|
48
48
|
"clean": "premove ./dist ./coverage ./dist-ts",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
2
|
import type { SearchableResource } from '../searchResources'
|
|
3
3
|
import {
|
|
4
|
+
cyrillicLayoutToLatin,
|
|
4
5
|
searchResources,
|
|
5
6
|
searchResourcesRanked,
|
|
6
7
|
searchWithinApp,
|
|
@@ -221,3 +222,60 @@ describe('searchWithinApp', () => {
|
|
|
221
222
|
expect(searchWithinApp(resources, 'aws-console', 'zzzz')).toEqual([])
|
|
222
223
|
})
|
|
223
224
|
})
|
|
225
|
+
|
|
226
|
+
describe('cyrillicLayoutToLatin', () => {
|
|
227
|
+
it('maps Cyrillic keys to the Latin characters on the same physical keys', () => {
|
|
228
|
+
expect(cyrillicLayoutToLatin('пфещк')).toBe('gator')
|
|
229
|
+
expect(cyrillicLayoutToLatin('йцукен')).toBe('qwerty')
|
|
230
|
+
expect(cyrillicLayoutToLatin('фыв ячс')).toBe('asd zxc')
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it('leaves characters it has no key for untouched', () => {
|
|
234
|
+
expect(cyrillicLayoutToLatin('gator')).toBe('gator')
|
|
235
|
+
expect(cyrillicLayoutToLatin('п1ф')).toBe('g1a')
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
describe('Cyrillic-layout fallback', () => {
|
|
240
|
+
const apps: SearchableResource[] = [
|
|
241
|
+
makeApp({ slug: 'gator', displayName: 'Gator' }),
|
|
242
|
+
makeApp({ slug: 'taskflow', displayName: 'TaskFlow', abbreviation: 'tf' }),
|
|
243
|
+
]
|
|
244
|
+
|
|
245
|
+
it('finds the app typed on the wrong keyboard layout', () => {
|
|
246
|
+
const results = searchResources(apps, 'Пфещк')
|
|
247
|
+
expect(results.map((a) => a.slug)).toEqual(['gator'])
|
|
248
|
+
})
|
|
249
|
+
|
|
250
|
+
it('does not re-run when the query as typed already has results', () => {
|
|
251
|
+
const withCyrillicText = [
|
|
252
|
+
...apps,
|
|
253
|
+
makeApp({ slug: 'notes', displayName: 'Notes', description: 'пфещк' }),
|
|
254
|
+
]
|
|
255
|
+
expect(
|
|
256
|
+
searchResources(withCyrillicText, 'пфещк').map((a) => a.slug),
|
|
257
|
+
).toEqual(['notes'])
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('returns nothing for a Cyrillic query that maps to nothing', () => {
|
|
261
|
+
expect(searchResources(apps, 'щщщ')).toEqual([])
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('keeps the match info of the fallback pass', () => {
|
|
265
|
+
const results = searchResourcesRanked(apps, 'Пфещк')
|
|
266
|
+
expect(results[0]!.match).toEqual({ field: 'displayName', type: 'exact' })
|
|
267
|
+
})
|
|
268
|
+
|
|
269
|
+
it('applies inside one app too — the "<app>/<term>" form', () => {
|
|
270
|
+
const resources: SearchableResource[] = [
|
|
271
|
+
makeApp({ slug: 'aws-console', displayName: 'AWS Console' }),
|
|
272
|
+
makeChildResource({
|
|
273
|
+
slug: 'aws-prod',
|
|
274
|
+
displayName: 'prod-account',
|
|
275
|
+
parentSlug: 'aws-console',
|
|
276
|
+
}),
|
|
277
|
+
]
|
|
278
|
+
const results = searchWithinApp(resources, 'aws-console', 'ЗКЩВ')
|
|
279
|
+
expect(results.map((r) => r.app.slug)).toEqual(['aws-prod'])
|
|
280
|
+
})
|
|
281
|
+
})
|
package/src/searchResources.ts
CHANGED
|
@@ -48,6 +48,76 @@ export interface SearchResult<T extends SearchableResource> {
|
|
|
48
48
|
match?: SearchMatch
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* Latin character sitting on the same physical key as each Cyrillic one
|
|
53
|
+
* (ЙЦУКЕН against QWERTY).
|
|
54
|
+
*/
|
|
55
|
+
const LATIN_BY_CYRILLIC_KEY: Record<string, string> = {
|
|
56
|
+
й: 'q',
|
|
57
|
+
ц: 'w',
|
|
58
|
+
у: 'e',
|
|
59
|
+
к: 'r',
|
|
60
|
+
е: 't',
|
|
61
|
+
н: 'y',
|
|
62
|
+
г: 'u',
|
|
63
|
+
ш: 'i',
|
|
64
|
+
щ: 'o',
|
|
65
|
+
з: 'p',
|
|
66
|
+
х: '[',
|
|
67
|
+
ъ: ']',
|
|
68
|
+
ф: 'a',
|
|
69
|
+
ы: 's',
|
|
70
|
+
в: 'd',
|
|
71
|
+
а: 'f',
|
|
72
|
+
п: 'g',
|
|
73
|
+
р: 'h',
|
|
74
|
+
о: 'j',
|
|
75
|
+
л: 'k',
|
|
76
|
+
д: 'l',
|
|
77
|
+
ж: ';',
|
|
78
|
+
э: "'",
|
|
79
|
+
я: 'z',
|
|
80
|
+
ч: 'x',
|
|
81
|
+
с: 'c',
|
|
82
|
+
м: 'v',
|
|
83
|
+
и: 'b',
|
|
84
|
+
т: 'n',
|
|
85
|
+
ь: 'm',
|
|
86
|
+
б: ',',
|
|
87
|
+
ю: '.',
|
|
88
|
+
ё: '`',
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const CYRILLIC = /[а-яё]/i
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Re-read a query as if it had been typed with the keyboard on the Cyrillic
|
|
95
|
+
* layout: each Cyrillic character becomes the Latin character on the same
|
|
96
|
+
* physical key, so `пфещк` reads back as `gator`. Anything with no Cyrillic key
|
|
97
|
+
* is passed through.
|
|
98
|
+
*
|
|
99
|
+
* Case is not preserved — callers search case-insensitively.
|
|
100
|
+
*/
|
|
101
|
+
export function cyrillicLayoutToLatin(query: string): string {
|
|
102
|
+
return Array.from(query)
|
|
103
|
+
.map((char) => LATIN_BY_CYRILLIC_KEY[char.toLowerCase()] ?? char)
|
|
104
|
+
.join('')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The query to retry with when a search came back empty, or `null` when there
|
|
109
|
+
* is nothing else to try. Typing an English name with the keyboard left on the
|
|
110
|
+
* Cyrillic layout is the one case worth a second pass.
|
|
111
|
+
*/
|
|
112
|
+
function layoutFallbackQuery(
|
|
113
|
+
searchQuery: string,
|
|
114
|
+
results: readonly unknown[],
|
|
115
|
+
): string | null {
|
|
116
|
+
if (results.length > 0 || !CYRILLIC.test(searchQuery)) return null
|
|
117
|
+
const latin = cyrillicLayoutToLatin(searchQuery)
|
|
118
|
+
return latin === searchQuery ? null : latin
|
|
119
|
+
}
|
|
120
|
+
|
|
51
121
|
/** All terms appear in the text, order-independent (AND logic). */
|
|
52
122
|
function allTermsMatcher(terms: string[]): (text: string) => boolean {
|
|
53
123
|
return (text: string) => terms.every((term) => text.includes(term))
|
|
@@ -220,6 +290,9 @@ export function searchResourcesRanked<T extends SearchableResource>(
|
|
|
220
290
|
return a.result.app.displayName.localeCompare(b.result.app.displayName)
|
|
221
291
|
})
|
|
222
292
|
|
|
293
|
+
const fallback = layoutFallbackQuery(normalizedQuery, scoredApps)
|
|
294
|
+
if (fallback) return searchResourcesRanked(resources, fallback)
|
|
295
|
+
|
|
223
296
|
return scoredApps.map((item) => item.result)
|
|
224
297
|
}
|
|
225
298
|
|
|
@@ -312,5 +385,8 @@ export function searchWithinApp<T extends SearchableResource>(
|
|
|
312
385
|
: byName(a.result.app, b.result.app),
|
|
313
386
|
)
|
|
314
387
|
|
|
388
|
+
const fallback = layoutFallbackQuery(normalizedQuery, scored)
|
|
389
|
+
if (fallback) return searchWithinApp(resources, appSlug, fallback)
|
|
390
|
+
|
|
315
391
|
return scored.map((item) => item.result)
|
|
316
392
|
}
|