@igstack/app-catalog-shared-core 5.0.2-alpha-20260923221955 → 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.
@@ -47,11 +47,21 @@ function allTermsMatcher(terms) {
47
47
  }
48
48
  function nameMatchers(query) {
49
49
  const compact = (s) => s.replace(/[^\p{L}\p{N}]+/gu, "");
50
- const compactQuery = compact(query) || query;
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));
51
52
  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)
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
+ )
55
65
  };
56
66
  }
57
67
  function searchResourcesRanked(resources, searchQuery) {
@@ -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 * 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":"AAsDA,MAAM,wBAAgD;AAAA,EACpD,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AAAA,EACH,GAAG;AACL;AAEA,MAAM,WAAW;AAUV,SAAS,sBAAsB,OAAuB;AAC3D,SAAO,MAAM,KAAK,KAAK,EACpB,IAAI,CAAC,SAAS,sBAAsB,KAAK,aAAa,KAAK,IAAI,EAC/D,KAAK,EAAE;AACZ;AAOA,SAAS,oBACP,aACA,SACe;AACf,MAAI,QAAQ,SAAS,KAAK,CAAC,SAAS,KAAK,WAAW,EAAG,QAAO;AAC9D,QAAM,QAAQ,sBAAsB,WAAW;AAC/C,SAAO,UAAU,cAAc,OAAO;AACxC;AAGA,SAAS,gBAAgB,OAA4C;AACnE,SAAO,CAAC,SAAiB,MAAM,MAAM,CAAC,SAAS,KAAK,SAAS,IAAI,CAAC;AACpE;AAQA,SAAS,aAAa,OAAe;AACnC,QAAM,UAAU,CAAC,MAAc,EAAE,QAAQ,oBAAoB,EAAE;AAE/D,QAAM,eAAe,QAAQ,KAAK,KAAK;AACvC,SAAO;AAAA,IACL,OAAO,CAAC,SAAiB,SAAS,SAAS,QAAQ,IAAI,MAAM;AAAA,IAC7D,QAAQ,CAAC,SACP,KAAK,WAAW,KAAK,KAAK,QAAQ,IAAI,EAAE,WAAW,YAAY;AAAA,IACjE,UAAU,CAAC,SACT,KAAK,SAAS,KAAK,KAAK,QAAQ,IAAI,EAAE,SAAS,YAAY;AAAA,EAAA;AAEjE;AA8BO,SAAS,sBACd,WACA,aACmB;AACnB,QAAM,kBAAkB,YAAY,KAAA,EAAO,YAAA;AAG3C,QAAM,gBAAgB,UAAU,OAAO,CAAC,MAAM,CAAC,EAAE,UAAU;AAE3D,MAAI,oBAAoB,IAAI;AAC1B,WAAO,cAAc,IAAI,CAAC,SAAS,EAAE,MAAM;AAAA,EAC7C;AAGA,QAAM,aAAa,gBAAgB,MAAM,KAAK,EAAE,OAAO,OAAO;AAC9D,QAAM,gBAAgB,gBAAgB,UAAU;AAChD,QAAM,KAAK,aAAa,eAAe;AAGvC,QAAM,uCAAuB,IAAA;AAC7B,aAAW,KAAK,WAAW;AACzB,QAAI,EAAE,YAAY;AAChB,YAAM,OAAO,iBAAiB,IAAI,EAAE,UAAU,KAAK,CAAA;AACnD,WAAK,KAAK,CAAC;AACX,uBAAiB,IAAI,EAAE,YAAY,IAAI;AAAA,IACzC;AAAA,EACF;AAGA,QAAM,aAAa,cAChB,IAAI,CAAC,QAA2D;AApJrE;AAqJM,UAAM,OAAO,IAAI,YAAY,YAAA;AAC7B,UAAM,iBAAe,SAAI,iBAAJ,mBAAkB,kBAAiB;AACxD,UAAM,cAAY,SAAI,cAAJ,mBAAe,IAAI,CAAC,MAAM,EAAE,YAAA,OAAkB,CAAA;AAChE,UAAM,gBAAc,SAAI,gBAAJ,mBAAiB,kBAAiB;AACtD,UAAM,SAAO,SAAI,SAAJ,mBAAU,KAAK,KAAK,kBAAiB;AAClD,UAAM,UAAQ,SAAI,UAAJ,mBAAW,KAAK,KAAK,kBAAiB;AAEpD,UAAM,MAAM,CACV,OACA,MACA,WACI,EAAE,QAAQ,EAAE,KAAK,OAAO,EAAE,OAAO,KAAA,EAAK,GAAK,MAAA;AAGjD,QAAI,gBAAgB,GAAG,MAAM,YAAY,GAAG;AAC1C,aAAO,IAAI,gBAAgB,SAAS,CAAC;AAAA,IACvC;AACA,QAAI,GAAG,MAAM,IAAI,GAAG;AAClB,aAAO,IAAI,eAAe,SAAS,CAAC;AAAA,IACtC;AACA,QAAI,UAAU,KAAK,GAAG,KAAK,GAAG;AAC5B,aAAO,IAAI,aAAa,SAAS,CAAC;AAAA,IACpC;AAGA,QAAI,gBAAgB,GAAG,OAAO,YAAY,GAAG;AAC3C,aAAO,IAAI,gBAAgB,UAAU,CAAC;AAAA,IACxC;AACA,QAAI,GAAG,OAAO,IAAI,GAAG;AACnB,aAAO,IAAI,eAAe,UAAU,CAAC;AAAA,IACvC;AACA,QAAI,UAAU,KAAK,GAAG,MAAM,GAAG;AAC7B,aAAO,IAAI,aAAa,UAAU,CAAC;AAAA,IACrC;AAGA,SAAI,SAAI,SAAJ,mBAAU,KAAK,CAAC,QAAQ,IAAI,kBAAkB,kBAAkB;AAClE,aAAO,IAAI,QAAQ,SAAS,CAAC;AAAA,IAC/B;AAGA,SACE,SAAI,SAAJ,mBAAU,KAAK,CAAC,QAAQ,IAAI,cAAc,WAAW,eAAe,IACpE;AACA,aAAO,IAAI,QAAQ,UAAU,CAAC;AAAA,IAChC;AAGA,QAAI,gBAAgB,GAAG,SAAS,YAAY,GAAG;AAC7C,aAAO,IAAI,gBAAgB,YAAY,CAAC;AAAA,IAC1C;AACA,QAAI,GAAG,SAAS,IAAI,GAAG;AACrB,aAAO,IAAI,eAAe,YAAY,CAAC;AAAA,IACzC;AACA,QAAI,UAAU,KAAK,GAAG,QAAQ,GAAG;AAC/B,aAAO,IAAI,aAAa,YAAY,EAAE;AAAA,IACxC;AAGA,QAAI,KAAK,SAAS,eAAe,GAAG;AAClC,aAAO,IAAI,QAAQ,YAAY,EAAE;AAAA,IACnC;AAGA,QAAI,cAAc,KAAK,GAAG;AACxB,aAAO,IAAI,SAAS,YAAY,EAAE;AAAA,IACpC;AAGA,QAAI,cAAc,WAAW,GAAG;AAC9B,aAAO,IAAI,eAAe,YAAY,EAAE;AAAA,IAC1C;AAMA,UAAM,WAAW,iBAAiB,IAAI,IAAI,IAAI;AAC9C,QAAI,UAAU;AACZ,YAAM,WAAW,SAAS;AAAA,QACxB,CAAC,MACC,cAAc,EAAE,YAAY,YAAA,CAAa,MACxC,EAAE,WAAW,CAAA,GAAI,KAAK,CAAC,MAAM,cAAc,EAAE,aAAa,CAAC,MAC3D,EAAE,cACC,cAAc,EAAE,YAAY,YAAA,CAAa,IACzC;AAAA,MAAA;AAER,UAAI,UAAU;AACZ,eAAO,IAAI,eAAe,YAAY,EAAE;AAAA,MAC1C;AAAA,IACF;AAGA,WAAO;AAAA,EACT,CAAC,EACA;AAAA,IACC,CAAC,SACC,SAAS;AAAA,EAAA;AAIf,aAAW,KAAK,CAAC,GAAG,MAAM;AACxB,QAAI,EAAE,UAAU,EAAE,OAAO;AACvB,aAAO,EAAE,QAAQ,EAAE;AAAA,IACrB;AAEA,WAAO,EAAE,OAAO,IAAI,YAAY,cAAc,EAAE,OAAO,IAAI,WAAW;AAAA,EACxE,CAAC;AAED,QAAM,WAAW,oBAAoB,iBAAiB,UAAU;AAChE,MAAI,SAAU,QAAO,sBAAsB,WAAW,QAAQ;AAE9D,SAAO,WAAW,IAAI,CAAC,SAAS,KAAK,MAAM;AAC7C;AAOO,SAAS,gBACd,WACA,aACK;AACL,SAAO,sBAAsB,WAAW,WAAW,EAAE,IAAI,CAAC,MAAM,EAAE,GAAG;AACvE;AAaO,SAAS,gBACd,WACA,SACA,aACmB;AACnB,QAAM,WAAW,UAAU,OAAO,CAAC,MAAM,EAAE,eAAe,OAAO;AACjE,QAAM,kBAAkB,YAAY,KAAA,EAAO,YAAA;AAE3C,QAAM,SAAS,CAAC,GAAM,MAAS,EAAE,YAAY,cAAc,EAAE,WAAW;AAExE,MAAI,oBAAoB,IAAI;AAC1B,WAAO,CAAC,GAAG,QAAQ,EAAE,KAAK,MAAM,EAAE,IAAI,CAAC,SAAS,EAAE,IAAA,EAAM;AAAA,EAC1D;AAEA,QAAM,aAAa,gBAAgB,MAAM,KAAK,EAAE,OAAO,OAAO;AAC9D,QAAM,gBAAgB,gBAAgB,UAAU;AAChD,QAAM,KAAK,aAAa,eAAe;AAEvC,QAAM,SAAS,SACZ,IAAI,CAAC,QAA2D;AAlTrE;AAmTM,UAAM,OAAO,IAAI,YAAY,YAAA;AAC7B,UAAM,OAAO,IAAI,KAAK,YAAA;AACtB,UAAM,YAAU,SAAI,YAAJ,mBAAa,IAAI,CAAC,MAAM,EAAE,YAAA,OAAkB,CAAA;AAC5D,UAAM,gBAAc,SAAI,gBAAJ,mBAAiB,kBAAiB;AAEtD,UAAM,MAAM,CACV,OACA,MACA,WACI,EAAE,QAAQ,EAAE,KAAK,OAAO,EAAE,OAAO,KAAA,EAAK,GAAK,MAAA;AAEjD,QAAI,GAAG,MAAM,IAAI,UAAU,IAAI,eAAe,SAAS,CAAC;AACxD,QAAI,GAAG,MAAM,IAAI,UAAU,IAAI,QAAQ,SAAS,CAAC;AACjD,QAAI,QAAQ,KAAK,GAAG,KAAK,EAAG,QAAO,IAAI,WAAW,SAAS,CAAC;AAE5D,QAAI,GAAG,OAAO,IAAI,UAAU,IAAI,eAAe,UAAU,CAAC;AAC1D,QAAI,GAAG,OAAO,IAAI,UAAU,IAAI,QAAQ,UAAU,CAAC;AACnD,QAAI,QAAQ,KAAK,GAAG,MAAM,EAAG,QAAO,IAAI,WAAW,UAAU,CAAC;AAE9D,QAAI,cAAc,IAAI,UAAU,IAAI,eAAe,YAAY,CAAC;AAChE,QAAI,cAAc,IAAI,UAAU,IAAI,QAAQ,YAAY,CAAC;AACzD,QAAI,QAAQ,KAAK,CAAC,MAAM,cAAc,CAAC,CAAC,GAAG;AACzC,aAAO,IAAI,WAAW,YAAY,CAAC;AAAA,IACrC;AACA,QAAI,eAAe,cAAc,WAAW,GAAG;AAC7C,aAAO,IAAI,eAAe,YAAY,CAAC;AAAA,IACzC;AAEA,WAAO;AAAA,EACT,CAAC,EACA;AAAA,IACC,CAAC,SACC,SAAS;AAAA,EAAA;AAGf,SAAO;AAAA,IAAK,CAAC,GAAG,MACd,EAAE,UAAU,EAAE,QACV,EAAE,QAAQ,EAAE,QACZ,OAAO,EAAE,OAAO,KAAK,EAAE,OAAO,GAAG;AAAA,EAAA;AAGvC,QAAM,WAAW,oBAAoB,iBAAiB,MAAM;AAC5D,MAAI,SAAU,QAAO,gBAAgB,WAAW,SAAS,QAAQ;AAEjE,SAAO,OAAO,IAAI,CAAC,SAAS,KAAK,MAAM;AACzC;"}
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.2-alpha-20260923221955",
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": "a1f1b238c79758447c81cdcdb569a6af7790062f",
45
+ "gitHead": "09c014d88a7f77afe6e9d0a5d776351e7552295c",
46
46
  "scripts": {
47
47
  "build": "vite build",
48
48
  "clean": "premove ./dist ./coverage ./dist-ts",
@@ -361,3 +361,29 @@ describe('Cyrillic-layout fallback', () => {
361
361
  expect(results.map((r) => r.app.slug)).toEqual(['aws-prod'])
362
362
  })
363
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
+ })
@@ -131,14 +131,32 @@ function allTermsMatcher(terms: string[]): (text: string) => boolean {
131
131
  */
132
132
  function nameMatchers(query: string) {
133
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
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)))
136
147
  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),
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
+ ),
142
160
  }
143
161
  }
144
162