@igstack/app-catalog-shared-core 5.0.1 → 5.0.2-alpha-20260923221955
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,15 @@ 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 = compact(query) || query;
|
|
51
|
+
return {
|
|
52
|
+
exact: (name) => name === query || compact(name) === compactQuery,
|
|
53
|
+
prefix: (name) => name.startsWith(query) || compact(name).startsWith(compactQuery),
|
|
54
|
+
contains: (name) => name.includes(query) || compact(name).includes(compactQuery)
|
|
55
|
+
};
|
|
56
|
+
}
|
|
48
57
|
function searchResourcesRanked(resources, searchQuery) {
|
|
49
58
|
const normalizedQuery = searchQuery.trim().toLowerCase();
|
|
50
59
|
const rootResources = resources.filter((r) => !r.parentSlug);
|
|
@@ -53,6 +62,7 @@ function searchResourcesRanked(resources, searchQuery) {
|
|
|
53
62
|
}
|
|
54
63
|
const queryTerms = normalizedQuery.split(/\s+/).filter(Boolean);
|
|
55
64
|
const allTermsMatch = allTermsMatcher(queryTerms);
|
|
65
|
+
const is = nameMatchers(normalizedQuery);
|
|
56
66
|
const childrenByParent = /* @__PURE__ */ new Map();
|
|
57
67
|
for (const r of resources) {
|
|
58
68
|
if (r.parentSlug) {
|
|
@@ -70,22 +80,22 @@ function searchResourcesRanked(resources, searchQuery) {
|
|
|
70
80
|
const tags = ((_d = app.tags) == null ? void 0 : _d.join(" ").toLowerCase()) || "";
|
|
71
81
|
const teams = ((_e = app.teams) == null ? void 0 : _e.join(" ").toLowerCase()) || "";
|
|
72
82
|
const hit = (field, type, score) => ({ result: { app, match: { field, type } }, score });
|
|
73
|
-
if (abbreviation && abbreviation
|
|
83
|
+
if (abbreviation && is.exact(abbreviation)) {
|
|
74
84
|
return hit("abbreviation", "exact", 0);
|
|
75
85
|
}
|
|
76
|
-
if (name
|
|
86
|
+
if (is.exact(name)) {
|
|
77
87
|
return hit("displayName", "exact", 1);
|
|
78
88
|
}
|
|
79
|
-
if (nicknames.some(
|
|
89
|
+
if (nicknames.some(is.exact)) {
|
|
80
90
|
return hit("nicknames", "exact", 2);
|
|
81
91
|
}
|
|
82
|
-
if (abbreviation &&
|
|
92
|
+
if (abbreviation && is.prefix(abbreviation)) {
|
|
83
93
|
return hit("abbreviation", "prefix", 3);
|
|
84
94
|
}
|
|
85
|
-
if (
|
|
95
|
+
if (is.prefix(name)) {
|
|
86
96
|
return hit("displayName", "prefix", 4);
|
|
87
97
|
}
|
|
88
|
-
if (nicknames.some(
|
|
98
|
+
if (nicknames.some(is.prefix)) {
|
|
89
99
|
return hit("nicknames", "prefix", 5);
|
|
90
100
|
}
|
|
91
101
|
if ((_f = app.tags) == null ? void 0 : _f.some((tag) => tag.toLowerCase() === normalizedQuery)) {
|
|
@@ -94,13 +104,13 @@ function searchResourcesRanked(resources, searchQuery) {
|
|
|
94
104
|
if ((_g = app.tags) == null ? void 0 : _g.some((tag) => tag.toLowerCase().startsWith(normalizedQuery))) {
|
|
95
105
|
return hit("tags", "prefix", 7);
|
|
96
106
|
}
|
|
97
|
-
if (abbreviation &&
|
|
107
|
+
if (abbreviation && is.contains(abbreviation)) {
|
|
98
108
|
return hit("abbreviation", "contains", 8);
|
|
99
109
|
}
|
|
100
|
-
if (
|
|
110
|
+
if (is.contains(name)) {
|
|
101
111
|
return hit("displayName", "contains", 9);
|
|
102
112
|
}
|
|
103
|
-
if (nicknames.some(
|
|
113
|
+
if (nicknames.some(is.contains)) {
|
|
104
114
|
return hit("nicknames", "contains", 10);
|
|
105
115
|
}
|
|
106
116
|
if (tags.includes(normalizedQuery)) {
|
|
@@ -147,6 +157,7 @@ function searchWithinApp(resources, appSlug, searchQuery) {
|
|
|
147
157
|
}
|
|
148
158
|
const queryTerms = normalizedQuery.split(/\s+/).filter(Boolean);
|
|
149
159
|
const allTermsMatch = allTermsMatcher(queryTerms);
|
|
160
|
+
const is = nameMatchers(normalizedQuery);
|
|
150
161
|
const scored = children.map((app) => {
|
|
151
162
|
var _a, _b;
|
|
152
163
|
const name = app.displayName.toLowerCase();
|
|
@@ -154,18 +165,12 @@ function searchWithinApp(resources, appSlug, searchQuery) {
|
|
|
154
165
|
const aliases = ((_a = app.aliases) == null ? void 0 : _a.map((a) => a.toLowerCase())) ?? [];
|
|
155
166
|
const description = ((_b = app.description) == null ? void 0 : _b.toLowerCase()) ?? "";
|
|
156
167
|
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
|
-
}
|
|
168
|
+
if (is.exact(name)) return hit("displayName", "exact", 0);
|
|
169
|
+
if (is.exact(slug)) return hit("slug", "exact", 1);
|
|
170
|
+
if (aliases.some(is.exact)) return hit("aliases", "exact", 2);
|
|
171
|
+
if (is.prefix(name)) return hit("displayName", "prefix", 3);
|
|
172
|
+
if (is.prefix(slug)) return hit("slug", "prefix", 4);
|
|
173
|
+
if (aliases.some(is.prefix)) return hit("aliases", "prefix", 5);
|
|
169
174
|
if (allTermsMatch(name)) return hit("displayName", "contains", 6);
|
|
170
175
|
if (allTermsMatch(slug)) return hit("slug", "contains", 7);
|
|
171
176
|
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 // A query of pure punctuation compacts to '' and would match everything.\n const compactQuery = compact(query) || query\n return {\n exact: (name: string) => name === query || compact(name) === compactQuery,\n prefix: (name: string) =>\n name.startsWith(query) || compact(name).startsWith(compactQuery),\n contains: (name: string) =>\n name.includes(query) || compact(name).includes(compactQuery),\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-20260923221955",
|
|
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": "a1f1b238c79758447c81cdcdb569a6af7790062f",
|
|
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' }),
|
package/src/searchResources.ts
CHANGED
|
@@ -123,6 +123,25 @@ 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
|
+
// A query of pure punctuation compacts to '' and would match everything.
|
|
135
|
+
const compactQuery = compact(query) || query
|
|
136
|
+
return {
|
|
137
|
+
exact: (name: string) => name === query || compact(name) === compactQuery,
|
|
138
|
+
prefix: (name: string) =>
|
|
139
|
+
name.startsWith(query) || compact(name).startsWith(compactQuery),
|
|
140
|
+
contains: (name: string) =>
|
|
141
|
+
name.includes(query) || compact(name).includes(compactQuery),
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
126
145
|
/**
|
|
127
146
|
* Search and rank resources by relevance, keeping the match info.
|
|
128
147
|
*
|
|
@@ -167,6 +186,7 @@ export function searchResourcesRanked<T extends SearchableResource>(
|
|
|
167
186
|
// Split query into terms for multi-word matching (AND logic)
|
|
168
187
|
const queryTerms = normalizedQuery.split(/\s+/).filter(Boolean)
|
|
169
188
|
const allTermsMatch = allTermsMatcher(queryTerms)
|
|
189
|
+
const is = nameMatchers(normalizedQuery)
|
|
170
190
|
|
|
171
191
|
// Build children lookup: parentSlug -> T[]
|
|
172
192
|
const childrenByParent = new Map<string, T[]>()
|
|
@@ -195,24 +215,24 @@ export function searchResourcesRanked<T extends SearchableResource>(
|
|
|
195
215
|
) => ({ result: { app, match: { field, type } }, score })
|
|
196
216
|
|
|
197
217
|
// Check exact matches first - prioritize abbreviation over displayName
|
|
198
|
-
if (abbreviation && abbreviation
|
|
218
|
+
if (abbreviation && is.exact(abbreviation)) {
|
|
199
219
|
return hit('abbreviation', 'exact', 0)
|
|
200
220
|
}
|
|
201
|
-
if (name
|
|
221
|
+
if (is.exact(name)) {
|
|
202
222
|
return hit('displayName', 'exact', 1)
|
|
203
223
|
}
|
|
204
|
-
if (nicknames.some(
|
|
224
|
+
if (nicknames.some(is.exact)) {
|
|
205
225
|
return hit('nicknames', 'exact', 2)
|
|
206
226
|
}
|
|
207
227
|
|
|
208
228
|
// Check prefix matches
|
|
209
|
-
if (abbreviation &&
|
|
229
|
+
if (abbreviation && is.prefix(abbreviation)) {
|
|
210
230
|
return hit('abbreviation', 'prefix', 3)
|
|
211
231
|
}
|
|
212
|
-
if (
|
|
232
|
+
if (is.prefix(name)) {
|
|
213
233
|
return hit('displayName', 'prefix', 4)
|
|
214
234
|
}
|
|
215
|
-
if (nicknames.some(
|
|
235
|
+
if (nicknames.some(is.prefix)) {
|
|
216
236
|
return hit('nicknames', 'prefix', 5)
|
|
217
237
|
}
|
|
218
238
|
|
|
@@ -229,13 +249,13 @@ export function searchResourcesRanked<T extends SearchableResource>(
|
|
|
229
249
|
}
|
|
230
250
|
|
|
231
251
|
// Check contains matches - prioritize abbreviation over displayName
|
|
232
|
-
if (abbreviation &&
|
|
252
|
+
if (abbreviation && is.contains(abbreviation)) {
|
|
233
253
|
return hit('abbreviation', 'contains', 8)
|
|
234
254
|
}
|
|
235
|
-
if (
|
|
255
|
+
if (is.contains(name)) {
|
|
236
256
|
return hit('displayName', 'contains', 9)
|
|
237
257
|
}
|
|
238
|
-
if (nicknames.some(
|
|
258
|
+
if (nicknames.some(is.contains)) {
|
|
239
259
|
return hit('nicknames', 'contains', 10)
|
|
240
260
|
}
|
|
241
261
|
|
|
@@ -335,6 +355,7 @@ export function searchWithinApp<T extends SearchableResource>(
|
|
|
335
355
|
|
|
336
356
|
const queryTerms = normalizedQuery.split(/\s+/).filter(Boolean)
|
|
337
357
|
const allTermsMatch = allTermsMatcher(queryTerms)
|
|
358
|
+
const is = nameMatchers(normalizedQuery)
|
|
338
359
|
|
|
339
360
|
const scored = children
|
|
340
361
|
.map((app): { result: SearchResult<T>; score: number } | null => {
|
|
@@ -349,19 +370,13 @@ export function searchWithinApp<T extends SearchableResource>(
|
|
|
349
370
|
score: number,
|
|
350
371
|
) => ({ result: { app, match: { field, type } }, score })
|
|
351
372
|
|
|
352
|
-
if (name
|
|
353
|
-
if (slug
|
|
354
|
-
if (aliases.some(
|
|
355
|
-
return hit('aliases', 'exact', 2)
|
|
356
|
-
}
|
|
373
|
+
if (is.exact(name)) return hit('displayName', 'exact', 0)
|
|
374
|
+
if (is.exact(slug)) return hit('slug', 'exact', 1)
|
|
375
|
+
if (aliases.some(is.exact)) return hit('aliases', 'exact', 2)
|
|
357
376
|
|
|
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
|
-
}
|
|
377
|
+
if (is.prefix(name)) return hit('displayName', 'prefix', 3)
|
|
378
|
+
if (is.prefix(slug)) return hit('slug', 'prefix', 4)
|
|
379
|
+
if (aliases.some(is.prefix)) return hit('aliases', 'prefix', 5)
|
|
365
380
|
|
|
366
381
|
if (allTermsMatch(name)) return hit('displayName', 'contains', 6)
|
|
367
382
|
if (allTermsMatch(slug)) return hit('slug', 'contains', 7)
|