@igstack/app-catalog-shared-core 1.0.0-alpha-20260913172344 → 1.0.0-alpha-20260914153414
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/__tests__/searchResources.test.d.ts +1 -0
- package/dist/esm/index.d.ts +1 -0
- package/dist/esm/index.js +4 -0
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/searchResources.d.ts +84 -0
- package/dist/esm/searchResources.js +145 -0
- package/dist/esm/searchResources.js.map +1 -0
- package/package.json +2 -2
- package/src/__tests__/searchResources.test.ts +223 -0
- package/src/index.ts +1 -0
- package/src/searchResources.ts +316 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/esm/index.d.ts
CHANGED
package/dist/esm/index.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { nullToUndefined, undefinedToNull } from "./nullToUndefined.js";
|
|
2
|
+
import { searchResources, searchResourcesRanked, searchWithinApp } from "./searchResources.js";
|
|
2
3
|
export {
|
|
3
4
|
nullToUndefined,
|
|
5
|
+
searchResources,
|
|
6
|
+
searchResourcesRanked,
|
|
7
|
+
searchWithinApp,
|
|
4
8
|
undefinedToNull
|
|
5
9
|
};
|
|
6
10
|
//# sourceMappingURL=index.js.map
|
package/dist/esm/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;"}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relevance ranking for catalog resources.
|
|
3
|
+
*
|
|
4
|
+
* Lives in shared-core so that every consumer — the catalog grid and the MCP
|
|
5
|
+
* server — ranks identically and cannot drift. It must NOT import `Resource`
|
|
6
|
+
* from backend-core: backend-core already depends on shared-core, so that would
|
|
7
|
+
* be a cycle. Instead the functions are generic over a structural
|
|
8
|
+
* `SearchableResource`, which `Resource` satisfies; each caller gets its own
|
|
9
|
+
* type back.
|
|
10
|
+
*/
|
|
11
|
+
/** The subset of a resource this engine reads. */
|
|
12
|
+
export interface SearchableResource {
|
|
13
|
+
slug: string;
|
|
14
|
+
displayName: string;
|
|
15
|
+
abbreviation?: string;
|
|
16
|
+
nicknames?: string[];
|
|
17
|
+
aliases?: string[];
|
|
18
|
+
description?: string;
|
|
19
|
+
tags?: string[];
|
|
20
|
+
teams?: string[];
|
|
21
|
+
/** Undefined for top-level apps; the parent app's slug for sub-resources. */
|
|
22
|
+
parentSlug?: string;
|
|
23
|
+
}
|
|
24
|
+
export interface SearchMatch {
|
|
25
|
+
/** Field where the match occurred */
|
|
26
|
+
field: 'displayName' | 'abbreviation' | 'nicknames' | 'slug' | 'aliases' | 'tags' | 'teams' | 'description' | 'subResource';
|
|
27
|
+
/** Type of match */
|
|
28
|
+
type: 'exact' | 'prefix' | 'contains';
|
|
29
|
+
}
|
|
30
|
+
export interface SearchResult<T extends SearchableResource> {
|
|
31
|
+
app: T;
|
|
32
|
+
/**
|
|
33
|
+
* Which field matched and how. Absent only for the empty-query browse case,
|
|
34
|
+
* where nothing was matched against.
|
|
35
|
+
*/
|
|
36
|
+
match?: SearchMatch;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Search and rank resources by relevance, keeping the match info.
|
|
40
|
+
*
|
|
41
|
+
* Only root resources (no `parentSlug`) are scored and returned. Child
|
|
42
|
+
* resources contribute to their parent's score — a query matching a child's
|
|
43
|
+
* displayName, alias or description surfaces the parent.
|
|
44
|
+
*
|
|
45
|
+
* Priority order:
|
|
46
|
+
* 0. Exact match in abbreviation
|
|
47
|
+
* 1. Exact match in displayName
|
|
48
|
+
* 2. Exact match in nickname
|
|
49
|
+
* 3. Prefix match in abbreviation
|
|
50
|
+
* 4. Prefix match in displayName
|
|
51
|
+
* 5. Prefix match in nickname
|
|
52
|
+
* 6. Exact match in tags
|
|
53
|
+
* 7. Prefix match in tags
|
|
54
|
+
* 8. Contains match in abbreviation
|
|
55
|
+
* 9. Contains match in displayName
|
|
56
|
+
* 10. Contains match in nickname
|
|
57
|
+
* 11. Contains match in tags
|
|
58
|
+
* 12. Teams
|
|
59
|
+
* 13. Sub-resource
|
|
60
|
+
* 14. Description
|
|
61
|
+
*
|
|
62
|
+
* @param resources - Array of all resources (roots + children)
|
|
63
|
+
* @param searchQuery - Search query string
|
|
64
|
+
* @returns Filtered and sorted root resources with their match info
|
|
65
|
+
*/
|
|
66
|
+
export declare function searchResourcesRanked<T extends SearchableResource>(resources: T[], searchQuery: string): SearchResult<T>[];
|
|
67
|
+
/**
|
|
68
|
+
* Search and sort resources by relevance — the roots-only roll-up the catalog
|
|
69
|
+
* grid uses. Same ranking as {@link searchResourcesRanked}, without the match
|
|
70
|
+
* info.
|
|
71
|
+
*/
|
|
72
|
+
export declare function searchResources<T extends SearchableResource>(resources: T[], searchQuery: string): T[];
|
|
73
|
+
/**
|
|
74
|
+
* Ranked search *within* one app's sub-resources — the `"<app>/<term>"` form.
|
|
75
|
+
*
|
|
76
|
+
* Matches a child's `displayName`, `slug`, `aliases` and `description`. Alias
|
|
77
|
+
* matching is load-bearing: in a deployment where every sub-resource carries a
|
|
78
|
+
* numeric alias, searching that bare number must find the sub-resource.
|
|
79
|
+
*
|
|
80
|
+
* @param resources - Array of all resources (roots + children)
|
|
81
|
+
* @param appSlug - Parent app slug whose children to search
|
|
82
|
+
* @param searchQuery - Search query string; empty returns every child
|
|
83
|
+
*/
|
|
84
|
+
export declare function searchWithinApp<T extends SearchableResource>(resources: T[], appSlug: string, searchQuery: string): SearchResult<T>[];
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
function allTermsMatcher(terms) {
|
|
2
|
+
return (text) => terms.every((term) => text.includes(term));
|
|
3
|
+
}
|
|
4
|
+
function searchResourcesRanked(resources, searchQuery) {
|
|
5
|
+
const normalizedQuery = searchQuery.trim().toLowerCase();
|
|
6
|
+
const rootResources = resources.filter((r) => !r.parentSlug);
|
|
7
|
+
if (normalizedQuery === "") {
|
|
8
|
+
return rootResources.map((app) => ({ app }));
|
|
9
|
+
}
|
|
10
|
+
const queryTerms = normalizedQuery.split(/\s+/).filter(Boolean);
|
|
11
|
+
const allTermsMatch = allTermsMatcher(queryTerms);
|
|
12
|
+
const childrenByParent = /* @__PURE__ */ new Map();
|
|
13
|
+
for (const r of resources) {
|
|
14
|
+
if (r.parentSlug) {
|
|
15
|
+
const list = childrenByParent.get(r.parentSlug) ?? [];
|
|
16
|
+
list.push(r);
|
|
17
|
+
childrenByParent.set(r.parentSlug, list);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const scoredApps = rootResources.map((app) => {
|
|
21
|
+
var _a, _b, _c, _d, _e, _f, _g;
|
|
22
|
+
const name = app.displayName.toLowerCase();
|
|
23
|
+
const abbreviation = ((_a = app.abbreviation) == null ? void 0 : _a.toLowerCase()) || "";
|
|
24
|
+
const nicknames = ((_b = app.nicknames) == null ? void 0 : _b.map((n) => n.toLowerCase())) || [];
|
|
25
|
+
const description = ((_c = app.description) == null ? void 0 : _c.toLowerCase()) || "";
|
|
26
|
+
const tags = ((_d = app.tags) == null ? void 0 : _d.join(" ").toLowerCase()) || "";
|
|
27
|
+
const teams = ((_e = app.teams) == null ? void 0 : _e.join(" ").toLowerCase()) || "";
|
|
28
|
+
const hit = (field, type, score) => ({ result: { app, match: { field, type } }, score });
|
|
29
|
+
if (abbreviation && abbreviation === normalizedQuery) {
|
|
30
|
+
return hit("abbreviation", "exact", 0);
|
|
31
|
+
}
|
|
32
|
+
if (name === normalizedQuery) {
|
|
33
|
+
return hit("displayName", "exact", 1);
|
|
34
|
+
}
|
|
35
|
+
if (nicknames.some((n) => n === normalizedQuery)) {
|
|
36
|
+
return hit("nicknames", "exact", 2);
|
|
37
|
+
}
|
|
38
|
+
if (abbreviation && abbreviation.startsWith(normalizedQuery)) {
|
|
39
|
+
return hit("abbreviation", "prefix", 3);
|
|
40
|
+
}
|
|
41
|
+
if (name.startsWith(normalizedQuery)) {
|
|
42
|
+
return hit("displayName", "prefix", 4);
|
|
43
|
+
}
|
|
44
|
+
if (nicknames.some((n) => n.startsWith(normalizedQuery))) {
|
|
45
|
+
return hit("nicknames", "prefix", 5);
|
|
46
|
+
}
|
|
47
|
+
if ((_f = app.tags) == null ? void 0 : _f.some((tag) => tag.toLowerCase() === normalizedQuery)) {
|
|
48
|
+
return hit("tags", "exact", 6);
|
|
49
|
+
}
|
|
50
|
+
if ((_g = app.tags) == null ? void 0 : _g.some((tag) => tag.toLowerCase().startsWith(normalizedQuery))) {
|
|
51
|
+
return hit("tags", "prefix", 7);
|
|
52
|
+
}
|
|
53
|
+
if (abbreviation && abbreviation.includes(normalizedQuery)) {
|
|
54
|
+
return hit("abbreviation", "contains", 8);
|
|
55
|
+
}
|
|
56
|
+
if (name.includes(normalizedQuery)) {
|
|
57
|
+
return hit("displayName", "contains", 9);
|
|
58
|
+
}
|
|
59
|
+
if (nicknames.some((n) => n.includes(normalizedQuery))) {
|
|
60
|
+
return hit("nicknames", "contains", 10);
|
|
61
|
+
}
|
|
62
|
+
if (tags.includes(normalizedQuery)) {
|
|
63
|
+
return hit("tags", "contains", 11);
|
|
64
|
+
}
|
|
65
|
+
if (allTermsMatch(teams)) {
|
|
66
|
+
return hit("teams", "contains", 12);
|
|
67
|
+
}
|
|
68
|
+
if (allTermsMatch(description)) {
|
|
69
|
+
return hit("description", "contains", 14);
|
|
70
|
+
}
|
|
71
|
+
const children = childrenByParent.get(app.slug);
|
|
72
|
+
if (children) {
|
|
73
|
+
const subMatch = children.some(
|
|
74
|
+
(r) => allTermsMatch(r.displayName.toLowerCase()) || (r.aliases ?? []).some((a) => allTermsMatch(a.toLowerCase())) || (r.description ? allTermsMatch(r.description.toLowerCase()) : false)
|
|
75
|
+
);
|
|
76
|
+
if (subMatch) {
|
|
77
|
+
return hit("subResource", "contains", 13);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}).filter(
|
|
82
|
+
(item) => item !== null
|
|
83
|
+
);
|
|
84
|
+
scoredApps.sort((a, b) => {
|
|
85
|
+
if (a.score !== b.score) {
|
|
86
|
+
return a.score - b.score;
|
|
87
|
+
}
|
|
88
|
+
return a.result.app.displayName.localeCompare(b.result.app.displayName);
|
|
89
|
+
});
|
|
90
|
+
return scoredApps.map((item) => item.result);
|
|
91
|
+
}
|
|
92
|
+
function searchResources(resources, searchQuery) {
|
|
93
|
+
return searchResourcesRanked(resources, searchQuery).map((r) => r.app);
|
|
94
|
+
}
|
|
95
|
+
function searchWithinApp(resources, appSlug, searchQuery) {
|
|
96
|
+
const children = resources.filter((r) => r.parentSlug === appSlug);
|
|
97
|
+
const normalizedQuery = searchQuery.trim().toLowerCase();
|
|
98
|
+
const byName = (a, b) => a.displayName.localeCompare(b.displayName);
|
|
99
|
+
if (normalizedQuery === "") {
|
|
100
|
+
return [...children].sort(byName).map((app) => ({ app }));
|
|
101
|
+
}
|
|
102
|
+
const queryTerms = normalizedQuery.split(/\s+/).filter(Boolean);
|
|
103
|
+
const allTermsMatch = allTermsMatcher(queryTerms);
|
|
104
|
+
const scored = children.map((app) => {
|
|
105
|
+
var _a, _b;
|
|
106
|
+
const name = app.displayName.toLowerCase();
|
|
107
|
+
const slug = app.slug.toLowerCase();
|
|
108
|
+
const aliases = ((_a = app.aliases) == null ? void 0 : _a.map((a) => a.toLowerCase())) ?? [];
|
|
109
|
+
const description = ((_b = app.description) == null ? void 0 : _b.toLowerCase()) ?? "";
|
|
110
|
+
const hit = (field, type, score) => ({ result: { app, match: { field, type } }, score });
|
|
111
|
+
if (name === normalizedQuery) return hit("displayName", "exact", 0);
|
|
112
|
+
if (slug === normalizedQuery) return hit("slug", "exact", 1);
|
|
113
|
+
if (aliases.some((a) => a === normalizedQuery)) {
|
|
114
|
+
return hit("aliases", "exact", 2);
|
|
115
|
+
}
|
|
116
|
+
if (name.startsWith(normalizedQuery)) {
|
|
117
|
+
return hit("displayName", "prefix", 3);
|
|
118
|
+
}
|
|
119
|
+
if (slug.startsWith(normalizedQuery)) return hit("slug", "prefix", 4);
|
|
120
|
+
if (aliases.some((a) => a.startsWith(normalizedQuery))) {
|
|
121
|
+
return hit("aliases", "prefix", 5);
|
|
122
|
+
}
|
|
123
|
+
if (allTermsMatch(name)) return hit("displayName", "contains", 6);
|
|
124
|
+
if (allTermsMatch(slug)) return hit("slug", "contains", 7);
|
|
125
|
+
if (aliases.some((a) => allTermsMatch(a))) {
|
|
126
|
+
return hit("aliases", "contains", 8);
|
|
127
|
+
}
|
|
128
|
+
if (description && allTermsMatch(description)) {
|
|
129
|
+
return hit("description", "contains", 9);
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}).filter(
|
|
133
|
+
(item) => item !== null
|
|
134
|
+
);
|
|
135
|
+
scored.sort(
|
|
136
|
+
(a, b) => a.score !== b.score ? a.score - b.score : byName(a.result.app, b.result.app)
|
|
137
|
+
);
|
|
138
|
+
return scored.map((item) => item.result);
|
|
139
|
+
}
|
|
140
|
+
export {
|
|
141
|
+
searchResources,
|
|
142
|
+
searchResourcesRanked,
|
|
143
|
+
searchWithinApp
|
|
144
|
+
};
|
|
145
|
+
//# sourceMappingURL=searchResources.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"searchResources.js","sources":["../../src/searchResources.ts"],"sourcesContent":["/**\n * Relevance ranking for catalog resources.\n *\n * Lives in shared-core so that every consumer — the catalog grid and the MCP\n * server — ranks identically and cannot drift. It must NOT import `Resource`\n * from backend-core: backend-core already depends on shared-core, so that would\n * be a cycle. Instead the functions are generic over a structural\n * `SearchableResource`, which `Resource` satisfies; each caller gets its own\n * type back.\n */\n\n/** The subset of a resource this engine reads. */\nexport interface SearchableResource {\n slug: string\n displayName: string\n abbreviation?: string\n nicknames?: string[]\n aliases?: string[]\n description?: string\n tags?: string[]\n teams?: string[]\n /** Undefined for top-level apps; the parent app's slug for sub-resources. */\n parentSlug?: string\n}\n\nexport interface SearchMatch {\n /** Field where the match occurred */\n field:\n | 'displayName'\n | 'abbreviation'\n | 'nicknames'\n | 'slug'\n | 'aliases'\n | 'tags'\n | 'teams'\n | 'description'\n | 'subResource'\n /** Type of match */\n type: 'exact' | 'prefix' | 'contains'\n}\n\nexport interface SearchResult<T extends SearchableResource> {\n app: T\n /**\n * Which field matched and how. Absent only for the empty-query browse case,\n * where nothing was matched against.\n */\n match?: SearchMatch\n}\n\n/** All terms appear in the text, order-independent (AND logic). */\nfunction allTermsMatcher(terms: string[]): (text: string) => boolean {\n return (text: string) => terms.every((term) => text.includes(term))\n}\n\n/**\n * Search and rank resources by relevance, keeping the match info.\n *\n * Only root resources (no `parentSlug`) are scored and returned. Child\n * resources contribute to their parent's score — a query matching a child's\n * displayName, alias or description surfaces the parent.\n *\n * Priority order:\n * 0. Exact match in abbreviation\n * 1. Exact match in displayName\n * 2. Exact match in nickname\n * 3. Prefix match in abbreviation\n * 4. Prefix match in displayName\n * 5. Prefix match in nickname\n * 6. Exact match in tags\n * 7. Prefix match in tags\n * 8. Contains match in abbreviation\n * 9. Contains match in displayName\n * 10. Contains match in nickname\n * 11. Contains match in tags\n * 12. Teams\n * 13. Sub-resource\n * 14. Description\n *\n * @param resources - Array of all resources (roots + children)\n * @param searchQuery - Search query string\n * @returns Filtered and sorted root resources with their match info\n */\nexport function searchResourcesRanked<T extends SearchableResource>(\n resources: T[],\n searchQuery: string,\n): SearchResult<T>[] {\n const normalizedQuery = searchQuery.trim().toLowerCase()\n\n // Separate root resources from children\n const rootResources = resources.filter((r) => !r.parentSlug)\n\n if (normalizedQuery === '') {\n return rootResources.map((app) => ({ app }))\n }\n\n // Split query into terms for multi-word matching (AND logic)\n const queryTerms = normalizedQuery.split(/\\s+/).filter(Boolean)\n const allTermsMatch = allTermsMatcher(queryTerms)\n\n // Build children lookup: parentSlug -> T[]\n const childrenByParent = new Map<string, T[]>()\n for (const r of resources) {\n if (r.parentSlug) {\n const list = childrenByParent.get(r.parentSlug) ?? []\n list.push(r)\n childrenByParent.set(r.parentSlug, list)\n }\n }\n\n // Filter and score root resources\n const scoredApps = rootResources\n .map((app): { result: SearchResult<T>; score: number } | null => {\n const name = app.displayName.toLowerCase()\n const abbreviation = app.abbreviation?.toLowerCase() || ''\n const nicknames = app.nicknames?.map((n) => n.toLowerCase()) || []\n const description = app.description?.toLowerCase() || ''\n const tags = app.tags?.join(' ').toLowerCase() || ''\n const teams = app.teams?.join(' ').toLowerCase() || ''\n\n const hit = (\n field: SearchMatch['field'],\n type: SearchMatch['type'],\n score: number,\n ) => ({ result: { app, match: { field, type } }, score })\n\n // Check exact matches first - prioritize abbreviation over displayName\n if (abbreviation && abbreviation === normalizedQuery) {\n return hit('abbreviation', 'exact', 0)\n }\n if (name === normalizedQuery) {\n return hit('displayName', 'exact', 1)\n }\n if (nicknames.some((n) => n === normalizedQuery)) {\n return hit('nicknames', 'exact', 2)\n }\n\n // Check prefix matches\n if (abbreviation && abbreviation.startsWith(normalizedQuery)) {\n return hit('abbreviation', 'prefix', 3)\n }\n if (name.startsWith(normalizedQuery)) {\n return hit('displayName', 'prefix', 4)\n }\n if (nicknames.some((n) => n.startsWith(normalizedQuery))) {\n return hit('nicknames', 'prefix', 5)\n }\n\n // Check exact match in tags (any tag exactly matches query)\n if (app.tags?.some((tag) => tag.toLowerCase() === normalizedQuery)) {\n return hit('tags', 'exact', 6)\n }\n\n // Check tags - prefix match (any tag starts with query)\n if (\n app.tags?.some((tag) => tag.toLowerCase().startsWith(normalizedQuery))\n ) {\n return hit('tags', 'prefix', 7)\n }\n\n // Check contains matches - prioritize abbreviation over displayName\n if (abbreviation && abbreviation.includes(normalizedQuery)) {\n return hit('abbreviation', 'contains', 8)\n }\n if (name.includes(normalizedQuery)) {\n return hit('displayName', 'contains', 9)\n }\n if (nicknames.some((n) => n.includes(normalizedQuery))) {\n return hit('nicknames', 'contains', 10)\n }\n\n // Check tags - contains match\n if (tags.includes(normalizedQuery)) {\n return hit('tags', 'contains', 11)\n }\n\n // Check teams (multi-word)\n if (allTermsMatch(teams)) {\n return hit('teams', 'contains', 12)\n }\n\n // Check description (multi-word)\n if (allTermsMatch(description)) {\n return hit('description', 'contains', 14)\n }\n\n // Check child resources (name, aliases, description) — supports\n // multi-word queries. Alias matching is what makes a bare numeric\n // identifier find its parent app in deployments where sub-resources\n // carry numeric aliases.\n const children = childrenByParent.get(app.slug)\n if (children) {\n const subMatch = children.some(\n (r) =>\n allTermsMatch(r.displayName.toLowerCase()) ||\n (r.aliases ?? []).some((a) => allTermsMatch(a.toLowerCase())) ||\n (r.description\n ? allTermsMatch(r.description.toLowerCase())\n : false),\n )\n if (subMatch) {\n return hit('subResource', 'contains', 13)\n }\n }\n\n // No match found\n return null\n })\n .filter(\n (item): item is { result: SearchResult<T>; score: number } =>\n item !== null,\n )\n\n // Sort by score (ascending - lower score = higher priority)\n scoredApps.sort((a, b) => {\n if (a.score !== b.score) {\n return a.score - b.score\n }\n // If same score, sort alphabetically by display name\n return a.result.app.displayName.localeCompare(b.result.app.displayName)\n })\n\n return scoredApps.map((item) => item.result)\n}\n\n/**\n * Search and sort resources by relevance — the roots-only roll-up the catalog\n * grid uses. Same ranking as {@link searchResourcesRanked}, without the match\n * info.\n */\nexport function searchResources<T extends SearchableResource>(\n resources: T[],\n searchQuery: string,\n): T[] {\n return searchResourcesRanked(resources, searchQuery).map((r) => r.app)\n}\n\n/**\n * Ranked search *within* one app's sub-resources — the `\"<app>/<term>\"` form.\n *\n * Matches a child's `displayName`, `slug`, `aliases` and `description`. Alias\n * matching is load-bearing: in a deployment where every sub-resource carries a\n * numeric alias, searching that bare number must find the sub-resource.\n *\n * @param resources - Array of all resources (roots + children)\n * @param appSlug - Parent app slug whose children to search\n * @param searchQuery - Search query string; empty returns every child\n */\nexport function searchWithinApp<T extends SearchableResource>(\n resources: T[],\n appSlug: string,\n searchQuery: string,\n): SearchResult<T>[] {\n const children = resources.filter((r) => r.parentSlug === appSlug)\n const normalizedQuery = searchQuery.trim().toLowerCase()\n\n const byName = (a: T, b: T) => a.displayName.localeCompare(b.displayName)\n\n if (normalizedQuery === '') {\n return [...children].sort(byName).map((app) => ({ app }))\n }\n\n const queryTerms = normalizedQuery.split(/\\s+/).filter(Boolean)\n const allTermsMatch = allTermsMatcher(queryTerms)\n\n const scored = children\n .map((app): { result: SearchResult<T>; score: number } | null => {\n const name = app.displayName.toLowerCase()\n const slug = app.slug.toLowerCase()\n const aliases = app.aliases?.map((a) => a.toLowerCase()) ?? []\n const description = app.description?.toLowerCase() ?? ''\n\n const hit = (\n field: SearchMatch['field'],\n type: SearchMatch['type'],\n score: number,\n ) => ({ result: { app, match: { field, type } }, score })\n\n if (name === normalizedQuery) return hit('displayName', 'exact', 0)\n if (slug === normalizedQuery) return hit('slug', 'exact', 1)\n if (aliases.some((a) => a === normalizedQuery)) {\n return hit('aliases', 'exact', 2)\n }\n\n if (name.startsWith(normalizedQuery)) {\n return hit('displayName', 'prefix', 3)\n }\n if (slug.startsWith(normalizedQuery)) return hit('slug', 'prefix', 4)\n if (aliases.some((a) => a.startsWith(normalizedQuery))) {\n return hit('aliases', 'prefix', 5)\n }\n\n if (allTermsMatch(name)) return hit('displayName', 'contains', 6)\n if (allTermsMatch(slug)) return hit('slug', 'contains', 7)\n if (aliases.some((a) => allTermsMatch(a))) {\n return hit('aliases', 'contains', 8)\n }\n if (description && allTermsMatch(description)) {\n return hit('description', 'contains', 9)\n }\n\n return null\n })\n .filter(\n (item): item is { result: SearchResult<T>; score: number } =>\n item !== null,\n )\n\n scored.sort((a, b) =>\n a.score !== b.score\n ? a.score - b.score\n : byName(a.result.app, b.result.app),\n )\n\n return scored.map((item) => item.result)\n}\n"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@igstack/app-catalog-shared-core",
|
|
3
|
-
"version": "1.0.0-alpha-
|
|
3
|
+
"version": "1.0.0-alpha-20260914153414",
|
|
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": "c7c6a2375602656dffd01e81b151f3b9adcdc97f",
|
|
46
46
|
"scripts": {
|
|
47
47
|
"build": "vite build",
|
|
48
48
|
"clean": "premove ./dist ./coverage ./dist-ts",
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { SearchableResource } from '../searchResources'
|
|
3
|
+
import {
|
|
4
|
+
searchResources,
|
|
5
|
+
searchResourcesRanked,
|
|
6
|
+
searchWithinApp,
|
|
7
|
+
} from '../searchResources'
|
|
8
|
+
|
|
9
|
+
function makeApp(
|
|
10
|
+
overrides: Partial<SearchableResource> & { slug: string },
|
|
11
|
+
): SearchableResource {
|
|
12
|
+
return {
|
|
13
|
+
displayName: overrides.slug,
|
|
14
|
+
...overrides,
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function makeChildResource(
|
|
19
|
+
overrides: Partial<SearchableResource> & {
|
|
20
|
+
slug: string
|
|
21
|
+
parentSlug: string
|
|
22
|
+
},
|
|
23
|
+
): SearchableResource {
|
|
24
|
+
return {
|
|
25
|
+
displayName: overrides.slug,
|
|
26
|
+
aliases: [],
|
|
27
|
+
...overrides,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe('searchResources', () => {
|
|
32
|
+
const apps: SearchableResource[] = [
|
|
33
|
+
makeApp({
|
|
34
|
+
slug: 'taskflow',
|
|
35
|
+
displayName: 'TaskFlow',
|
|
36
|
+
description: 'Issue tracker',
|
|
37
|
+
}),
|
|
38
|
+
makeApp({
|
|
39
|
+
slug: 'aws-console',
|
|
40
|
+
displayName: 'AWS Console',
|
|
41
|
+
description: 'Cloud management',
|
|
42
|
+
}),
|
|
43
|
+
makeApp({
|
|
44
|
+
slug: 'teamchat',
|
|
45
|
+
displayName: 'TeamChat',
|
|
46
|
+
description: 'Messaging',
|
|
47
|
+
}),
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
it('returns all root apps when query is empty', () => {
|
|
51
|
+
expect(searchResources(apps, '')).toHaveLength(3)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('finds app by displayName', () => {
|
|
55
|
+
const results = searchResources(apps, 'taskflow')
|
|
56
|
+
expect(results).toHaveLength(1)
|
|
57
|
+
expect(results[0]!.slug).toBe('taskflow')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('finds app by nickname and by abbreviation', () => {
|
|
61
|
+
const withAliases = [
|
|
62
|
+
...apps,
|
|
63
|
+
makeApp({
|
|
64
|
+
slug: 'lab-system',
|
|
65
|
+
displayName: 'Laboratory System',
|
|
66
|
+
abbreviation: 'LS',
|
|
67
|
+
nicknames: ['labsys'],
|
|
68
|
+
}),
|
|
69
|
+
]
|
|
70
|
+
expect(searchResources(withAliases, 'labsys')[0]!.slug).toBe('lab-system')
|
|
71
|
+
expect(searchResources(withAliases, 'ls')[0]!.slug).toBe('lab-system')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('child resource search', () => {
|
|
75
|
+
const childResources: SearchableResource[] = [
|
|
76
|
+
makeChildResource({
|
|
77
|
+
slug: 'aws-acme-pipelines-dev',
|
|
78
|
+
displayName: 'acme-pipelines-data-analytics-dev',
|
|
79
|
+
parentSlug: 'aws-console',
|
|
80
|
+
aliases: ['000000000001'],
|
|
81
|
+
}),
|
|
82
|
+
makeChildResource({
|
|
83
|
+
slug: 'aws-acme-infosec-dev',
|
|
84
|
+
displayName: 'acme-infosec-dev',
|
|
85
|
+
parentSlug: 'aws-console',
|
|
86
|
+
aliases: [],
|
|
87
|
+
}),
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
const allResources = [...apps, ...childResources]
|
|
91
|
+
|
|
92
|
+
it('finds app by child resource displayName', () => {
|
|
93
|
+
const results = searchResources(allResources, 'pipelines data-analytics')
|
|
94
|
+
expect(results).toHaveLength(1)
|
|
95
|
+
expect(results[0]!.slug).toBe('aws-console')
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('finds app by child resource alias (numeric account identifier)', () => {
|
|
99
|
+
const results = searchResources(allResources, '000000000001')
|
|
100
|
+
expect(results).toHaveLength(1)
|
|
101
|
+
expect(results[0]!.slug).toBe('aws-console')
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('does not match child resources when none provided', () => {
|
|
105
|
+
const results = searchResources(apps, '000000000001')
|
|
106
|
+
expect(results).toHaveLength(0)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('direct app match ranks higher than child resource match', () => {
|
|
110
|
+
const results = searchResources(allResources, 'aws')
|
|
111
|
+
// 'aws-console' matches by displayName, should appear
|
|
112
|
+
expect(results.length).toBeGreaterThanOrEqual(1)
|
|
113
|
+
expect(results[0]!.slug).toBe('aws-console')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('never returns children from the roots-only roll-up', () => {
|
|
117
|
+
const results = searchResources(allResources, '')
|
|
118
|
+
expect(results.map((r) => r.slug)).toEqual([
|
|
119
|
+
'taskflow',
|
|
120
|
+
'aws-console',
|
|
121
|
+
'teamchat',
|
|
122
|
+
])
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
describe('searchResourcesRanked', () => {
|
|
128
|
+
const apps: SearchableResource[] = [
|
|
129
|
+
makeApp({ slug: 'taskflow', displayName: 'TaskFlow' }),
|
|
130
|
+
makeApp({
|
|
131
|
+
slug: 'teamchat',
|
|
132
|
+
displayName: 'TeamChat',
|
|
133
|
+
description: 'taskflow rival',
|
|
134
|
+
}),
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
it('reports which field matched and how', () => {
|
|
138
|
+
const results = searchResourcesRanked(apps, 'taskflow')
|
|
139
|
+
expect(
|
|
140
|
+
results.map((r) => [r.app.slug, r.match?.field, r.match?.type]),
|
|
141
|
+
).toEqual([
|
|
142
|
+
['taskflow', 'displayName', 'exact'],
|
|
143
|
+
['teamchat', 'description', 'contains'],
|
|
144
|
+
])
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('returns the same order as searchResources', () => {
|
|
148
|
+
expect(searchResourcesRanked(apps, 'taskflow').map((r) => r.app)).toEqual(
|
|
149
|
+
searchResources(apps, 'taskflow'),
|
|
150
|
+
)
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
describe('searchWithinApp', () => {
|
|
155
|
+
const resources: SearchableResource[] = [
|
|
156
|
+
makeApp({ slug: 'aws-console', displayName: 'AWS Console' }),
|
|
157
|
+
makeApp({ slug: 'taskflow', displayName: 'TaskFlow' }),
|
|
158
|
+
makeChildResource({
|
|
159
|
+
slug: 'aws-acme-biomarkers-prod',
|
|
160
|
+
displayName: 'acme-biomarkers-prod',
|
|
161
|
+
parentSlug: 'aws-console',
|
|
162
|
+
aliases: ['000000000001'],
|
|
163
|
+
description: 'Cloud account: acme-biomarkers-prod',
|
|
164
|
+
}),
|
|
165
|
+
makeChildResource({
|
|
166
|
+
slug: 'aws-acme-biomarkers-dev',
|
|
167
|
+
displayName: 'acme-biomarkers-dev',
|
|
168
|
+
parentSlug: 'aws-console',
|
|
169
|
+
aliases: ['000000000002'],
|
|
170
|
+
}),
|
|
171
|
+
makeChildResource({
|
|
172
|
+
slug: 'aws-acme-infosec-dev',
|
|
173
|
+
displayName: 'acme-infosec-dev',
|
|
174
|
+
parentSlug: 'aws-console',
|
|
175
|
+
aliases: ['000000000003'],
|
|
176
|
+
}),
|
|
177
|
+
makeChildResource({
|
|
178
|
+
slug: 'tf-child',
|
|
179
|
+
displayName: 'biomarkers-board',
|
|
180
|
+
parentSlug: 'taskflow',
|
|
181
|
+
aliases: [],
|
|
182
|
+
}),
|
|
183
|
+
]
|
|
184
|
+
|
|
185
|
+
it('searches only the named app’s children', () => {
|
|
186
|
+
const results = searchWithinApp(resources, 'aws-console', 'biomarkers')
|
|
187
|
+
expect(results.map((r) => r.app.slug)).toEqual([
|
|
188
|
+
'aws-acme-biomarkers-dev',
|
|
189
|
+
'aws-acme-biomarkers-prod',
|
|
190
|
+
])
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
it('matches a bare numeric alias — the account-number lookup', () => {
|
|
194
|
+
const results = searchWithinApp(resources, 'aws-console', '000000000001')
|
|
195
|
+
expect(results).toHaveLength(1)
|
|
196
|
+
expect(results[0]!.app.slug).toBe('aws-acme-biomarkers-prod')
|
|
197
|
+
expect(results[0]!.match).toEqual({ field: 'aliases', type: 'exact' })
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
it('matches on slug and on description', () => {
|
|
201
|
+
expect(
|
|
202
|
+
searchWithinApp(resources, 'aws-console', 'aws-acme-infosec-dev'),
|
|
203
|
+
).toHaveLength(1)
|
|
204
|
+
expect(
|
|
205
|
+
searchWithinApp(resources, 'aws-console', 'cloud account'),
|
|
206
|
+
).toHaveLength(1)
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it('returns every child for an empty query, alphabetically', () => {
|
|
210
|
+
expect(
|
|
211
|
+
searchWithinApp(resources, 'aws-console', '').map((r) => r.app.slug),
|
|
212
|
+
).toEqual([
|
|
213
|
+
'aws-acme-biomarkers-dev',
|
|
214
|
+
'aws-acme-biomarkers-prod',
|
|
215
|
+
'aws-acme-infosec-dev',
|
|
216
|
+
])
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('returns nothing for an app without children or a miss', () => {
|
|
220
|
+
expect(searchWithinApp(resources, 'nope', 'biomarkers')).toEqual([])
|
|
221
|
+
expect(searchWithinApp(resources, 'aws-console', 'zzzz')).toEqual([])
|
|
222
|
+
})
|
|
223
|
+
})
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relevance ranking for catalog resources.
|
|
3
|
+
*
|
|
4
|
+
* Lives in shared-core so that every consumer — the catalog grid and the MCP
|
|
5
|
+
* server — ranks identically and cannot drift. It must NOT import `Resource`
|
|
6
|
+
* from backend-core: backend-core already depends on shared-core, so that would
|
|
7
|
+
* be a cycle. Instead the functions are generic over a structural
|
|
8
|
+
* `SearchableResource`, which `Resource` satisfies; each caller gets its own
|
|
9
|
+
* type back.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** The subset of a resource this engine reads. */
|
|
13
|
+
export interface SearchableResource {
|
|
14
|
+
slug: string
|
|
15
|
+
displayName: string
|
|
16
|
+
abbreviation?: string
|
|
17
|
+
nicknames?: string[]
|
|
18
|
+
aliases?: string[]
|
|
19
|
+
description?: string
|
|
20
|
+
tags?: string[]
|
|
21
|
+
teams?: string[]
|
|
22
|
+
/** Undefined for top-level apps; the parent app's slug for sub-resources. */
|
|
23
|
+
parentSlug?: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SearchMatch {
|
|
27
|
+
/** Field where the match occurred */
|
|
28
|
+
field:
|
|
29
|
+
| 'displayName'
|
|
30
|
+
| 'abbreviation'
|
|
31
|
+
| 'nicknames'
|
|
32
|
+
| 'slug'
|
|
33
|
+
| 'aliases'
|
|
34
|
+
| 'tags'
|
|
35
|
+
| 'teams'
|
|
36
|
+
| 'description'
|
|
37
|
+
| 'subResource'
|
|
38
|
+
/** Type of match */
|
|
39
|
+
type: 'exact' | 'prefix' | 'contains'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface SearchResult<T extends SearchableResource> {
|
|
43
|
+
app: T
|
|
44
|
+
/**
|
|
45
|
+
* Which field matched and how. Absent only for the empty-query browse case,
|
|
46
|
+
* where nothing was matched against.
|
|
47
|
+
*/
|
|
48
|
+
match?: SearchMatch
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** All terms appear in the text, order-independent (AND logic). */
|
|
52
|
+
function allTermsMatcher(terms: string[]): (text: string) => boolean {
|
|
53
|
+
return (text: string) => terms.every((term) => text.includes(term))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Search and rank resources by relevance, keeping the match info.
|
|
58
|
+
*
|
|
59
|
+
* Only root resources (no `parentSlug`) are scored and returned. Child
|
|
60
|
+
* resources contribute to their parent's score — a query matching a child's
|
|
61
|
+
* displayName, alias or description surfaces the parent.
|
|
62
|
+
*
|
|
63
|
+
* Priority order:
|
|
64
|
+
* 0. Exact match in abbreviation
|
|
65
|
+
* 1. Exact match in displayName
|
|
66
|
+
* 2. Exact match in nickname
|
|
67
|
+
* 3. Prefix match in abbreviation
|
|
68
|
+
* 4. Prefix match in displayName
|
|
69
|
+
* 5. Prefix match in nickname
|
|
70
|
+
* 6. Exact match in tags
|
|
71
|
+
* 7. Prefix match in tags
|
|
72
|
+
* 8. Contains match in abbreviation
|
|
73
|
+
* 9. Contains match in displayName
|
|
74
|
+
* 10. Contains match in nickname
|
|
75
|
+
* 11. Contains match in tags
|
|
76
|
+
* 12. Teams
|
|
77
|
+
* 13. Sub-resource
|
|
78
|
+
* 14. Description
|
|
79
|
+
*
|
|
80
|
+
* @param resources - Array of all resources (roots + children)
|
|
81
|
+
* @param searchQuery - Search query string
|
|
82
|
+
* @returns Filtered and sorted root resources with their match info
|
|
83
|
+
*/
|
|
84
|
+
export function searchResourcesRanked<T extends SearchableResource>(
|
|
85
|
+
resources: T[],
|
|
86
|
+
searchQuery: string,
|
|
87
|
+
): SearchResult<T>[] {
|
|
88
|
+
const normalizedQuery = searchQuery.trim().toLowerCase()
|
|
89
|
+
|
|
90
|
+
// Separate root resources from children
|
|
91
|
+
const rootResources = resources.filter((r) => !r.parentSlug)
|
|
92
|
+
|
|
93
|
+
if (normalizedQuery === '') {
|
|
94
|
+
return rootResources.map((app) => ({ app }))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Split query into terms for multi-word matching (AND logic)
|
|
98
|
+
const queryTerms = normalizedQuery.split(/\s+/).filter(Boolean)
|
|
99
|
+
const allTermsMatch = allTermsMatcher(queryTerms)
|
|
100
|
+
|
|
101
|
+
// Build children lookup: parentSlug -> T[]
|
|
102
|
+
const childrenByParent = new Map<string, T[]>()
|
|
103
|
+
for (const r of resources) {
|
|
104
|
+
if (r.parentSlug) {
|
|
105
|
+
const list = childrenByParent.get(r.parentSlug) ?? []
|
|
106
|
+
list.push(r)
|
|
107
|
+
childrenByParent.set(r.parentSlug, list)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Filter and score root resources
|
|
112
|
+
const scoredApps = rootResources
|
|
113
|
+
.map((app): { result: SearchResult<T>; score: number } | null => {
|
|
114
|
+
const name = app.displayName.toLowerCase()
|
|
115
|
+
const abbreviation = app.abbreviation?.toLowerCase() || ''
|
|
116
|
+
const nicknames = app.nicknames?.map((n) => n.toLowerCase()) || []
|
|
117
|
+
const description = app.description?.toLowerCase() || ''
|
|
118
|
+
const tags = app.tags?.join(' ').toLowerCase() || ''
|
|
119
|
+
const teams = app.teams?.join(' ').toLowerCase() || ''
|
|
120
|
+
|
|
121
|
+
const hit = (
|
|
122
|
+
field: SearchMatch['field'],
|
|
123
|
+
type: SearchMatch['type'],
|
|
124
|
+
score: number,
|
|
125
|
+
) => ({ result: { app, match: { field, type } }, score })
|
|
126
|
+
|
|
127
|
+
// Check exact matches first - prioritize abbreviation over displayName
|
|
128
|
+
if (abbreviation && abbreviation === normalizedQuery) {
|
|
129
|
+
return hit('abbreviation', 'exact', 0)
|
|
130
|
+
}
|
|
131
|
+
if (name === normalizedQuery) {
|
|
132
|
+
return hit('displayName', 'exact', 1)
|
|
133
|
+
}
|
|
134
|
+
if (nicknames.some((n) => n === normalizedQuery)) {
|
|
135
|
+
return hit('nicknames', 'exact', 2)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Check prefix matches
|
|
139
|
+
if (abbreviation && abbreviation.startsWith(normalizedQuery)) {
|
|
140
|
+
return hit('abbreviation', 'prefix', 3)
|
|
141
|
+
}
|
|
142
|
+
if (name.startsWith(normalizedQuery)) {
|
|
143
|
+
return hit('displayName', 'prefix', 4)
|
|
144
|
+
}
|
|
145
|
+
if (nicknames.some((n) => n.startsWith(normalizedQuery))) {
|
|
146
|
+
return hit('nicknames', 'prefix', 5)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Check exact match in tags (any tag exactly matches query)
|
|
150
|
+
if (app.tags?.some((tag) => tag.toLowerCase() === normalizedQuery)) {
|
|
151
|
+
return hit('tags', 'exact', 6)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Check tags - prefix match (any tag starts with query)
|
|
155
|
+
if (
|
|
156
|
+
app.tags?.some((tag) => tag.toLowerCase().startsWith(normalizedQuery))
|
|
157
|
+
) {
|
|
158
|
+
return hit('tags', 'prefix', 7)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Check contains matches - prioritize abbreviation over displayName
|
|
162
|
+
if (abbreviation && abbreviation.includes(normalizedQuery)) {
|
|
163
|
+
return hit('abbreviation', 'contains', 8)
|
|
164
|
+
}
|
|
165
|
+
if (name.includes(normalizedQuery)) {
|
|
166
|
+
return hit('displayName', 'contains', 9)
|
|
167
|
+
}
|
|
168
|
+
if (nicknames.some((n) => n.includes(normalizedQuery))) {
|
|
169
|
+
return hit('nicknames', 'contains', 10)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Check tags - contains match
|
|
173
|
+
if (tags.includes(normalizedQuery)) {
|
|
174
|
+
return hit('tags', 'contains', 11)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Check teams (multi-word)
|
|
178
|
+
if (allTermsMatch(teams)) {
|
|
179
|
+
return hit('teams', 'contains', 12)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Check description (multi-word)
|
|
183
|
+
if (allTermsMatch(description)) {
|
|
184
|
+
return hit('description', 'contains', 14)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Check child resources (name, aliases, description) — supports
|
|
188
|
+
// multi-word queries. Alias matching is what makes a bare numeric
|
|
189
|
+
// identifier find its parent app in deployments where sub-resources
|
|
190
|
+
// carry numeric aliases.
|
|
191
|
+
const children = childrenByParent.get(app.slug)
|
|
192
|
+
if (children) {
|
|
193
|
+
const subMatch = children.some(
|
|
194
|
+
(r) =>
|
|
195
|
+
allTermsMatch(r.displayName.toLowerCase()) ||
|
|
196
|
+
(r.aliases ?? []).some((a) => allTermsMatch(a.toLowerCase())) ||
|
|
197
|
+
(r.description
|
|
198
|
+
? allTermsMatch(r.description.toLowerCase())
|
|
199
|
+
: false),
|
|
200
|
+
)
|
|
201
|
+
if (subMatch) {
|
|
202
|
+
return hit('subResource', 'contains', 13)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// No match found
|
|
207
|
+
return null
|
|
208
|
+
})
|
|
209
|
+
.filter(
|
|
210
|
+
(item): item is { result: SearchResult<T>; score: number } =>
|
|
211
|
+
item !== null,
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
// Sort by score (ascending - lower score = higher priority)
|
|
215
|
+
scoredApps.sort((a, b) => {
|
|
216
|
+
if (a.score !== b.score) {
|
|
217
|
+
return a.score - b.score
|
|
218
|
+
}
|
|
219
|
+
// If same score, sort alphabetically by display name
|
|
220
|
+
return a.result.app.displayName.localeCompare(b.result.app.displayName)
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
return scoredApps.map((item) => item.result)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Search and sort resources by relevance — the roots-only roll-up the catalog
|
|
228
|
+
* grid uses. Same ranking as {@link searchResourcesRanked}, without the match
|
|
229
|
+
* info.
|
|
230
|
+
*/
|
|
231
|
+
export function searchResources<T extends SearchableResource>(
|
|
232
|
+
resources: T[],
|
|
233
|
+
searchQuery: string,
|
|
234
|
+
): T[] {
|
|
235
|
+
return searchResourcesRanked(resources, searchQuery).map((r) => r.app)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Ranked search *within* one app's sub-resources — the `"<app>/<term>"` form.
|
|
240
|
+
*
|
|
241
|
+
* Matches a child's `displayName`, `slug`, `aliases` and `description`. Alias
|
|
242
|
+
* matching is load-bearing: in a deployment where every sub-resource carries a
|
|
243
|
+
* numeric alias, searching that bare number must find the sub-resource.
|
|
244
|
+
*
|
|
245
|
+
* @param resources - Array of all resources (roots + children)
|
|
246
|
+
* @param appSlug - Parent app slug whose children to search
|
|
247
|
+
* @param searchQuery - Search query string; empty returns every child
|
|
248
|
+
*/
|
|
249
|
+
export function searchWithinApp<T extends SearchableResource>(
|
|
250
|
+
resources: T[],
|
|
251
|
+
appSlug: string,
|
|
252
|
+
searchQuery: string,
|
|
253
|
+
): SearchResult<T>[] {
|
|
254
|
+
const children = resources.filter((r) => r.parentSlug === appSlug)
|
|
255
|
+
const normalizedQuery = searchQuery.trim().toLowerCase()
|
|
256
|
+
|
|
257
|
+
const byName = (a: T, b: T) => a.displayName.localeCompare(b.displayName)
|
|
258
|
+
|
|
259
|
+
if (normalizedQuery === '') {
|
|
260
|
+
return [...children].sort(byName).map((app) => ({ app }))
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const queryTerms = normalizedQuery.split(/\s+/).filter(Boolean)
|
|
264
|
+
const allTermsMatch = allTermsMatcher(queryTerms)
|
|
265
|
+
|
|
266
|
+
const scored = children
|
|
267
|
+
.map((app): { result: SearchResult<T>; score: number } | null => {
|
|
268
|
+
const name = app.displayName.toLowerCase()
|
|
269
|
+
const slug = app.slug.toLowerCase()
|
|
270
|
+
const aliases = app.aliases?.map((a) => a.toLowerCase()) ?? []
|
|
271
|
+
const description = app.description?.toLowerCase() ?? ''
|
|
272
|
+
|
|
273
|
+
const hit = (
|
|
274
|
+
field: SearchMatch['field'],
|
|
275
|
+
type: SearchMatch['type'],
|
|
276
|
+
score: number,
|
|
277
|
+
) => ({ result: { app, match: { field, type } }, score })
|
|
278
|
+
|
|
279
|
+
if (name === normalizedQuery) return hit('displayName', 'exact', 0)
|
|
280
|
+
if (slug === normalizedQuery) return hit('slug', 'exact', 1)
|
|
281
|
+
if (aliases.some((a) => a === normalizedQuery)) {
|
|
282
|
+
return hit('aliases', 'exact', 2)
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (name.startsWith(normalizedQuery)) {
|
|
286
|
+
return hit('displayName', 'prefix', 3)
|
|
287
|
+
}
|
|
288
|
+
if (slug.startsWith(normalizedQuery)) return hit('slug', 'prefix', 4)
|
|
289
|
+
if (aliases.some((a) => a.startsWith(normalizedQuery))) {
|
|
290
|
+
return hit('aliases', 'prefix', 5)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (allTermsMatch(name)) return hit('displayName', 'contains', 6)
|
|
294
|
+
if (allTermsMatch(slug)) return hit('slug', 'contains', 7)
|
|
295
|
+
if (aliases.some((a) => allTermsMatch(a))) {
|
|
296
|
+
return hit('aliases', 'contains', 8)
|
|
297
|
+
}
|
|
298
|
+
if (description && allTermsMatch(description)) {
|
|
299
|
+
return hit('description', 'contains', 9)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return null
|
|
303
|
+
})
|
|
304
|
+
.filter(
|
|
305
|
+
(item): item is { result: SearchResult<T>; score: number } =>
|
|
306
|
+
item !== null,
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
scored.sort((a, b) =>
|
|
310
|
+
a.score !== b.score
|
|
311
|
+
? a.score - b.score
|
|
312
|
+
: byName(a.result.app, b.result.app),
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
return scored.map((item) => item.result)
|
|
316
|
+
}
|