@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 === normalizedQuery) {
93
+ if (abbreviation && is.exact(abbreviation)) {
74
94
  return hit("abbreviation", "exact", 0);
75
95
  }
76
- if (name === normalizedQuery) {
96
+ if (is.exact(name)) {
77
97
  return hit("displayName", "exact", 1);
78
98
  }
79
- if (nicknames.some((n) => n === normalizedQuery)) {
99
+ if (nicknames.some(is.exact)) {
80
100
  return hit("nicknames", "exact", 2);
81
101
  }
82
- if (abbreviation && abbreviation.startsWith(normalizedQuery)) {
102
+ if (abbreviation && is.prefix(abbreviation)) {
83
103
  return hit("abbreviation", "prefix", 3);
84
104
  }
85
- if (name.startsWith(normalizedQuery)) {
105
+ if (is.prefix(name)) {
86
106
  return hit("displayName", "prefix", 4);
87
107
  }
88
- if (nicknames.some((n) => n.startsWith(normalizedQuery))) {
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 && abbreviation.includes(normalizedQuery)) {
117
+ if (abbreviation && is.contains(abbreviation)) {
98
118
  return hit("abbreviation", "contains", 8);
99
119
  }
100
- if (name.includes(normalizedQuery)) {
120
+ if (is.contains(name)) {
101
121
  return hit("displayName", "contains", 9);
102
122
  }
103
- if (nicknames.some((n) => n.includes(normalizedQuery))) {
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 === normalizedQuery) return hit("displayName", "exact", 0);
158
- if (slug === normalizedQuery) return hit("slug", "exact", 1);
159
- if (aliases.some((a) => a === normalizedQuery)) {
160
- return hit("aliases", "exact", 2);
161
- }
162
- if (name.startsWith(normalizedQuery)) {
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.1",
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": "80adf209743ee4fdef02b31172c0eed9b56374b8",
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
+ })
@@ -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 === normalizedQuery) {
236
+ if (abbreviation && is.exact(abbreviation)) {
199
237
  return hit('abbreviation', 'exact', 0)
200
238
  }
201
- if (name === normalizedQuery) {
239
+ if (is.exact(name)) {
202
240
  return hit('displayName', 'exact', 1)
203
241
  }
204
- if (nicknames.some((n) => n === normalizedQuery)) {
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 && abbreviation.startsWith(normalizedQuery)) {
247
+ if (abbreviation && is.prefix(abbreviation)) {
210
248
  return hit('abbreviation', 'prefix', 3)
211
249
  }
212
- if (name.startsWith(normalizedQuery)) {
250
+ if (is.prefix(name)) {
213
251
  return hit('displayName', 'prefix', 4)
214
252
  }
215
- if (nicknames.some((n) => n.startsWith(normalizedQuery))) {
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 && abbreviation.includes(normalizedQuery)) {
270
+ if (abbreviation && is.contains(abbreviation)) {
233
271
  return hit('abbreviation', 'contains', 8)
234
272
  }
235
- if (name.includes(normalizedQuery)) {
273
+ if (is.contains(name)) {
236
274
  return hit('displayName', 'contains', 9)
237
275
  }
238
- if (nicknames.some((n) => n.includes(normalizedQuery))) {
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 === normalizedQuery) return hit('displayName', 'exact', 0)
353
- if (slug === normalizedQuery) return hit('slug', 'exact', 1)
354
- if (aliases.some((a) => a === normalizedQuery)) {
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 (name.startsWith(normalizedQuery)) {
359
- return hit('displayName', 'prefix', 3)
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)