@igstack/app-catalog-shared-core 2.0.1-alpha-20260228221600 → 2.0.1-alpha-20260915180957
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__/aiSdkPins.test.d.ts +1 -0
- package/dist/esm/__tests__/searchResources.test.d.ts +1 -0
- package/dist/esm/appCatalogTypes.d.ts +11 -11
- 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/nullToUndefined.d.ts +2 -2
- package/dist/esm/nullToUndefined.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 +6 -3
- package/src/__tests__/aiSdkPins.test.ts +90 -0
- package/src/__tests__/searchResources.test.ts +223 -0
- package/src/appCatalogTypes.ts +10 -10
- package/src/index.ts +1 -0
- package/src/nullToUndefined.ts +4 -4
- package/src/searchResources.ts +316 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -20,19 +20,19 @@ export type ApprovalType = 'none' | 'service' | 'person' | 'other' | 'unknown';
|
|
|
20
20
|
/**
|
|
21
21
|
* Approval type options for UI display
|
|
22
22
|
*/
|
|
23
|
-
export declare const APPROVAL_TYPES:
|
|
23
|
+
export declare const APPROVAL_TYPES: readonly {
|
|
24
24
|
value: ApprovalType;
|
|
25
25
|
label: string;
|
|
26
|
-
}
|
|
26
|
+
}[];
|
|
27
27
|
/**
|
|
28
28
|
* Common fields for all approval method types
|
|
29
29
|
*/
|
|
30
30
|
export interface BaseApprovalMethod {
|
|
31
31
|
comment?: string;
|
|
32
|
-
roles?:
|
|
32
|
+
roles?: AppRole[];
|
|
33
33
|
approvalPolicy?: string;
|
|
34
34
|
postApprovalInstructions?: string;
|
|
35
|
-
seeMoreUrls?:
|
|
35
|
+
seeMoreUrls?: string[];
|
|
36
36
|
}
|
|
37
37
|
/**
|
|
38
38
|
* Service-based approval (bot or ticketing system)
|
|
@@ -106,22 +106,22 @@ export interface AppForCatalog {
|
|
|
106
106
|
displayName: string;
|
|
107
107
|
description?: string;
|
|
108
108
|
approvalMethod?: ApprovalMethod;
|
|
109
|
-
teams?:
|
|
109
|
+
teams?: string[];
|
|
110
110
|
notes?: string;
|
|
111
|
-
tags?:
|
|
111
|
+
tags?: string[];
|
|
112
112
|
appUrl?: string;
|
|
113
|
-
links?:
|
|
113
|
+
links?: {
|
|
114
114
|
url: string;
|
|
115
115
|
title?: string;
|
|
116
|
-
}
|
|
116
|
+
}[];
|
|
117
117
|
iconName?: string;
|
|
118
|
-
screenshotIds?:
|
|
118
|
+
screenshotIds?: string[];
|
|
119
119
|
}
|
|
120
120
|
export interface AppCategory {
|
|
121
121
|
id: string;
|
|
122
122
|
name: string;
|
|
123
123
|
}
|
|
124
124
|
export interface AppCatalogData {
|
|
125
|
-
apps:
|
|
126
|
-
categories:
|
|
125
|
+
apps: AppForCatalog[];
|
|
126
|
+
categories: AppCategory[];
|
|
127
127
|
}
|
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":";;"}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { Primitive } from 'type-fest';
|
|
2
|
-
export type NullToUndefined<T> = T extends null ? undefined : T extends Primitive | Function | Date | RegExp ? T : T extends
|
|
2
|
+
export type NullToUndefined<T> = T extends null ? undefined : T extends Primitive | Function | Date | RegExp ? T : T extends (infer U)[] ? NullToUndefined<U>[] : T extends Map<infer K, infer V> ? Map<K, NullToUndefined<V>> : T extends Set<infer U> ? Set<NullToUndefined<U>> : T extends object ? {
|
|
3
3
|
[K in keyof T]: NullToUndefined<T[K]>;
|
|
4
4
|
} : unknown;
|
|
5
|
-
export type UndefinedToNull<T> = T extends undefined ? null : T extends Primitive | Function | Date | RegExp ? T : T extends
|
|
5
|
+
export type UndefinedToNull<T> = T extends undefined ? null : T extends Primitive | Function | Date | RegExp ? T : T extends (infer U)[] ? UndefinedToNull<U>[] : T extends Map<infer K, infer V> ? Map<K, UndefinedToNull<V>> : T extends Set<infer U> ? Set<NullToUndefined<U>> : T extends object ? {
|
|
6
6
|
[K in keyof T]: UndefinedToNull<T[K]>;
|
|
7
7
|
} : unknown;
|
|
8
8
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"nullToUndefined.js","sources":["../../src/nullToUndefined.ts"],"sourcesContent":["import type { Primitive } from 'type-fest'\n\n/*\n Taken from https://gist.github.com/tkrotoff/a6baf96eb6b61b445a9142e5555511a0\n * [Generic way to convert all instances of null to undefined in TypeScript](https://stackoverflow.com/q/50374869)\n *\n * This only works with JS objects hence the file name *Object*Values\n *\n * [\"I intend to stop using `null` in my JS code in favor of `undefined`\"](https://github.com/sindresorhus/meta/discussions/7)\n * [Proposal: NullToUndefined and UndefinedToNull](https://github.com/sindresorhus/type-fest/issues/603)\n *\n * Types implementation inspired by:\n * - https://github.com/sindresorhus/type-fest/blob/v2.12.2/source/delimiter-cased-properties-deep.d.ts\n * - https://github.com/sindresorhus/type-fest/blob/v2.12.2/source/readonly-deep.d.ts\n *\n * https://gist.github.com/tkrotoff/a6baf96eb6b61b445a9142e5555511a0\n */\nexport type NullToUndefined<T> = T extends null\n ? undefined\n : T extends Primitive | Function | Date | RegExp\n ? T\n : T extends
|
|
1
|
+
{"version":3,"file":"nullToUndefined.js","sources":["../../src/nullToUndefined.ts"],"sourcesContent":["import type { Primitive } from 'type-fest'\n\n/*\n Taken from https://gist.github.com/tkrotoff/a6baf96eb6b61b445a9142e5555511a0\n * [Generic way to convert all instances of null to undefined in TypeScript](https://stackoverflow.com/q/50374869)\n *\n * This only works with JS objects hence the file name *Object*Values\n *\n * [\"I intend to stop using `null` in my JS code in favor of `undefined`\"](https://github.com/sindresorhus/meta/discussions/7)\n * [Proposal: NullToUndefined and UndefinedToNull](https://github.com/sindresorhus/type-fest/issues/603)\n *\n * Types implementation inspired by:\n * - https://github.com/sindresorhus/type-fest/blob/v2.12.2/source/delimiter-cased-properties-deep.d.ts\n * - https://github.com/sindresorhus/type-fest/blob/v2.12.2/source/readonly-deep.d.ts\n *\n * https://gist.github.com/tkrotoff/a6baf96eb6b61b445a9142e5555511a0\n */\nexport type NullToUndefined<T> = T extends null\n ? undefined\n : T extends Primitive | Function | Date | RegExp\n ? T\n : T extends (infer U)[]\n ? NullToUndefined<U>[]\n : T extends Map<infer K, infer V>\n ? Map<K, NullToUndefined<V>>\n : T extends Set<infer U>\n ? Set<NullToUndefined<U>>\n : T extends object\n ? { [K in keyof T]: NullToUndefined<T[K]> }\n : unknown\n\nexport type UndefinedToNull<T> = T extends undefined\n ? null\n : T extends Primitive | Function | Date | RegExp\n ? T\n : T extends (infer U)[]\n ? UndefinedToNull<U>[]\n : T extends Map<infer K, infer V>\n ? Map<K, UndefinedToNull<V>>\n : T extends Set<infer U>\n ? Set<NullToUndefined<U>>\n : T extends object\n ? { [K in keyof T]: UndefinedToNull<T[K]> }\n : unknown\n\nfunction _nullToUndefined<T>(obj: T): NullToUndefined<T> {\n if (obj === null) {\n return undefined as any\n }\n\n if (typeof obj === 'object') {\n if (obj instanceof Map) {\n obj.forEach((value, key) => obj.set(key, _nullToUndefined(value)))\n } else {\n for (const key in obj) {\n obj[key] = _nullToUndefined(obj[key]) as any\n }\n }\n }\n\n return obj as any\n}\n\n/**\n * Recursively converts all null values to undefined.\n *\n * @param obj object to convert\n * @returns a copy of the object with all its null values converted to undefined\n */\nexport function nullToUndefined<\n T,\n // Can cause: \"Type instantiation is excessively deep and possibly infinite.\"\n // extends Jsonifiable\n>(obj: T) {\n return _nullToUndefined(structuredClone(obj))\n}\n\nfunction _undefinedToNull<T>(obj: T): UndefinedToNull<T> {\n if (obj === undefined) {\n return null as any\n }\n\n if (typeof obj === 'object') {\n if (obj instanceof Map) {\n obj.forEach((value, key) => obj.set(key, _undefinedToNull(value)))\n } else {\n for (const key in obj) {\n obj[key] = _undefinedToNull(obj[key]) as any\n }\n }\n }\n\n return obj as any\n}\n\n/**\n * Recursively converts all undefined values to null.\n *\n * @param obj object to convert\n * @returns a copy of the object with all its undefined values converted to null\n */\nexport function undefinedToNull<\n T,\n // Can cause: \"Type instantiation is excessively deep and possibly infinite.\"\n // extends Jsonifiable\n>(obj: T) {\n return _undefinedToNull(structuredClone(obj))\n}\n"],"names":[],"mappings":"AA6CA,SAAS,iBAAoB,KAA4B;AACvD,MAAI,QAAQ,MAAM;AAChB,WAAO;AAAA,EACT;AAEA,MAAI,OAAO,QAAQ,UAAU;AAC3B,QAAI,eAAe,KAAK;AACtB,UAAI,QAAQ,CAAC,OAAO,QAAQ,IAAI,IAAI,KAAK,iBAAiB,KAAK,CAAC,CAAC;AAAA,IACnE,OAAO;AACL,iBAAW,OAAO,KAAK;AACrB,YAAI,GAAG,IAAI,iBAAiB,IAAI,GAAG,CAAC;AAAA,MACtC;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;AAQO,SAAS,gBAId,KAAQ;AACR,SAAO,iBAAiB,gBAAgB,GAAG,CAAC;AAC9C;AAEA,SAAS,iBAAoB,KAA4B;AACvD,MAAI,QAAQ,QAAW;AACrB,WAAO;AAAA,EACT;AAEA,MAAI,OAAO,QAAQ,UAAU;AAC3B,QAAI,eAAe,KAAK;AACtB,UAAI,QAAQ,CAAC,OAAO,QAAQ,IAAI,IAAI,KAAK,iBAAiB,KAAK,CAAC,CAAC;AAAA,IACnE,OAAO;AACL,iBAAW,OAAO,KAAK;AACrB,YAAI,GAAG,IAAI,iBAAiB,IAAI,GAAG,CAAC;AAAA,MACtC;AAAA,IACF;AAAA,EACF;AAEA,SAAO;AACT;AAQO,SAAS,gBAId,KAAQ;AACR,SAAO,iBAAiB,gBAAgB,GAAG,CAAC;AAC9C;"}
|
|
@@ -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": "2.0.1-alpha-
|
|
3
|
+
"version": "2.0.1-alpha-20260915180957",
|
|
4
4
|
"description": "Shared core utilities for App Catalog",
|
|
5
5
|
"homepage": "https://github.com/lislon/app-catalog",
|
|
6
6
|
"repository": {
|
|
@@ -37,17 +37,20 @@
|
|
|
37
37
|
"@types/node": "^24.3.0",
|
|
38
38
|
"esbuild": "^0.25.5",
|
|
39
39
|
"tsx": "^4.19.4",
|
|
40
|
-
"typescript": "5.9.
|
|
40
|
+
"typescript": "5.9.3"
|
|
41
41
|
},
|
|
42
42
|
"engines": {
|
|
43
43
|
"node": ">=16"
|
|
44
44
|
},
|
|
45
|
+
"gitHead": "d8bd5031b4323b32134aed1176b81a5a9a20a8b6",
|
|
45
46
|
"scripts": {
|
|
46
47
|
"build": "vite build",
|
|
47
48
|
"clean": "premove ./dist ./coverage ./dist-ts",
|
|
48
49
|
"compile": "tsc --build",
|
|
49
50
|
"dev": "tsx watch src/index.ts",
|
|
50
|
-
"
|
|
51
|
+
"fix": "prettier --write ./src && eslint --fix ./src",
|
|
52
|
+
"test:eslint": "eslint ./src --max-warnings=0",
|
|
53
|
+
"test:types": "tsc --noEmit",
|
|
51
54
|
"test:unit": "vitest",
|
|
52
55
|
"test:unit:dev": "pnpm run test:unit --watch"
|
|
53
56
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
import { describe, expect, it } from 'vitest'
|
|
4
|
+
|
|
5
|
+
// The @ai-sdk packages publish many times a day and hard-pin each other to exact
|
|
6
|
+
// versions. A floating range therefore resolves to a release that may be minutes
|
|
7
|
+
// old, and npm's registry metadata is not consistent that fast — a fresh install
|
|
8
|
+
// intermittently fails with ERR_PNPM_NO_MATCHING_VERSION on a transitive @ai-sdk
|
|
9
|
+
// dependency. Downstream consumers install from the registry without a lockfile,
|
|
10
|
+
// so an exact version in a *published* dependency field is what protects them.
|
|
11
|
+
|
|
12
|
+
// Walk up from the vitest cwd rather than resolving import.meta.url, which vitest
|
|
13
|
+
// serves through its /@fs/ prefix.
|
|
14
|
+
const findRepoRoot = () => {
|
|
15
|
+
let dir = process.cwd()
|
|
16
|
+
while (!existsSync(join(dir, 'pnpm-workspace.yaml'))) {
|
|
17
|
+
const parent = dirname(dir)
|
|
18
|
+
if (parent === dir) throw new Error('workspace root not found above cwd')
|
|
19
|
+
dir = parent
|
|
20
|
+
}
|
|
21
|
+
return dir
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const repoRoot = findRepoRoot()
|
|
25
|
+
const workspaceDirs = ['packages', 'examples']
|
|
26
|
+
const dependencyFields = [
|
|
27
|
+
'dependencies',
|
|
28
|
+
'devDependencies',
|
|
29
|
+
'peerDependencies',
|
|
30
|
+
'optionalDependencies',
|
|
31
|
+
] as const
|
|
32
|
+
|
|
33
|
+
const isAiSdkPackage = (name: string) =>
|
|
34
|
+
name === 'ai' || name.startsWith('@ai-sdk/')
|
|
35
|
+
|
|
36
|
+
const isExactVersion = (range: string) => /^\d+\.\d+\.\d+/.test(range)
|
|
37
|
+
|
|
38
|
+
const collectPackageJsonPaths = () => {
|
|
39
|
+
const paths = [join(repoRoot, 'package.json')]
|
|
40
|
+
for (const dir of workspaceDirs) {
|
|
41
|
+
for (const entry of readdirSync(join(repoRoot, dir), {
|
|
42
|
+
withFileTypes: true,
|
|
43
|
+
})) {
|
|
44
|
+
if (entry.isDirectory()) {
|
|
45
|
+
paths.push(join(repoRoot, dir, entry.name, 'package.json'))
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return paths
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const collectAiSdkRanges = () => {
|
|
53
|
+
const found: { where: string; name: string; range: string }[] = []
|
|
54
|
+
for (const path of collectPackageJsonPaths()) {
|
|
55
|
+
let raw: string
|
|
56
|
+
try {
|
|
57
|
+
raw = readFileSync(path, 'utf8')
|
|
58
|
+
} catch {
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
const pkg = JSON.parse(raw) as Record<string, Record<string, string>>
|
|
62
|
+
for (const field of dependencyFields) {
|
|
63
|
+
for (const [name, range] of Object.entries(pkg[field] ?? {})) {
|
|
64
|
+
if (isAiSdkPackage(name)) {
|
|
65
|
+
found.push({
|
|
66
|
+
where: `${path.slice(repoRoot.length + 1)} (${field})`,
|
|
67
|
+
name,
|
|
68
|
+
range,
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return found
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
describe('@ai-sdk dependency pins', () => {
|
|
78
|
+
it('finds the @ai-sdk dependencies it is meant to guard', () => {
|
|
79
|
+
expect(collectAiSdkRanges().length).toBeGreaterThan(0)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('declares every ai / @ai-sdk dependency as an exact version', () => {
|
|
83
|
+
const floating = collectAiSdkRanges().filter(
|
|
84
|
+
({ range }) => !isExactVersion(range),
|
|
85
|
+
)
|
|
86
|
+
expect(
|
|
87
|
+
floating.map(({ where, name, range }) => `${where}: ${name}@${range}`),
|
|
88
|
+
).toEqual([])
|
|
89
|
+
})
|
|
90
|
+
})
|
|
@@ -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/appCatalogTypes.ts
CHANGED
|
@@ -31,10 +31,10 @@ export type ApprovalType = 'none' | 'service' | 'person' | 'other' | 'unknown'
|
|
|
31
31
|
/**
|
|
32
32
|
* Approval type options for UI display
|
|
33
33
|
*/
|
|
34
|
-
export const APPROVAL_TYPES:
|
|
34
|
+
export const APPROVAL_TYPES: readonly {
|
|
35
35
|
value: ApprovalType
|
|
36
36
|
label: string
|
|
37
|
-
}
|
|
37
|
+
}[] = [
|
|
38
38
|
{ value: 'none', label: 'None' },
|
|
39
39
|
{ value: 'service', label: 'Service' },
|
|
40
40
|
{ value: 'person', label: 'Person' },
|
|
@@ -47,10 +47,10 @@ export const APPROVAL_TYPES: ReadonlyArray<{
|
|
|
47
47
|
*/
|
|
48
48
|
export interface BaseApprovalMethod {
|
|
49
49
|
comment?: string
|
|
50
|
-
roles?:
|
|
50
|
+
roles?: AppRole[]
|
|
51
51
|
approvalPolicy?: string
|
|
52
52
|
postApprovalInstructions?: string
|
|
53
|
-
seeMoreUrls?:
|
|
53
|
+
seeMoreUrls?: string[]
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
/**
|
|
@@ -152,13 +152,13 @@ export interface AppForCatalog {
|
|
|
152
152
|
displayName: string
|
|
153
153
|
description?: string
|
|
154
154
|
approvalMethod?: ApprovalMethod
|
|
155
|
-
teams?:
|
|
155
|
+
teams?: string[] // Teams that use this app
|
|
156
156
|
notes?: string
|
|
157
|
-
tags?:
|
|
157
|
+
tags?: string[]
|
|
158
158
|
appUrl?: string
|
|
159
|
-
links?:
|
|
159
|
+
links?: { url: string; title?: string }[]
|
|
160
160
|
iconName?: string
|
|
161
|
-
screenshotIds?:
|
|
161
|
+
screenshotIds?: string[]
|
|
162
162
|
}
|
|
163
163
|
|
|
164
164
|
// Derived catalog data returned by backend
|
|
@@ -168,6 +168,6 @@ export interface AppCategory {
|
|
|
168
168
|
}
|
|
169
169
|
|
|
170
170
|
export interface AppCatalogData {
|
|
171
|
-
apps:
|
|
172
|
-
categories:
|
|
171
|
+
apps: AppForCatalog[]
|
|
172
|
+
categories: AppCategory[]
|
|
173
173
|
}
|
package/src/index.ts
CHANGED
package/src/nullToUndefined.ts
CHANGED
|
@@ -19,8 +19,8 @@ export type NullToUndefined<T> = T extends null
|
|
|
19
19
|
? undefined
|
|
20
20
|
: T extends Primitive | Function | Date | RegExp
|
|
21
21
|
? T
|
|
22
|
-
: T extends
|
|
23
|
-
?
|
|
22
|
+
: T extends (infer U)[]
|
|
23
|
+
? NullToUndefined<U>[]
|
|
24
24
|
: T extends Map<infer K, infer V>
|
|
25
25
|
? Map<K, NullToUndefined<V>>
|
|
26
26
|
: T extends Set<infer U>
|
|
@@ -33,8 +33,8 @@ export type UndefinedToNull<T> = T extends undefined
|
|
|
33
33
|
? null
|
|
34
34
|
: T extends Primitive | Function | Date | RegExp
|
|
35
35
|
? T
|
|
36
|
-
: T extends
|
|
37
|
-
?
|
|
36
|
+
: T extends (infer U)[]
|
|
37
|
+
? UndefinedToNull<U>[]
|
|
38
38
|
: T extends Map<infer K, infer V>
|
|
39
39
|
? Map<K, UndefinedToNull<V>>
|
|
40
40
|
: T extends Set<infer U>
|
|
@@ -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
|
+
}
|