cursedbelt-core 2.12.0 → 2.13.0
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/README.md +21 -0
- package/dist/core/navigation/navigationModel.d.ts +9 -0
- package/dist/core/navigation/navigationSelectors.d.ts +4 -3
- package/dist/core/navigation/navigationSelectors.js +8 -5
- package/dist/core/palette/fuzzyRank.d.ts +5 -0
- package/dist/core/palette/fuzzyRank.js +30 -10
- package/package.json +5 -1
- package/src/core/navigation/navigation.spec.ts +25 -0
- package/src/core/navigation/navigationModel.ts +9 -0
- package/src/core/navigation/navigationSelectors.ts +9 -5
- package/src/core/palette/fuzzyRank.ts +31 -9
- package/src/core/palette/palette.spec.ts +15 -0
package/README.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# cursedbelt-core
|
|
2
|
+
|
|
3
|
+
Framework-agnostic core of the cursedbelt split — isomorphic domain logic, types, schemas and
|
|
4
|
+
wire shapes both sides share. `cwip` is below it; `cursedbelt-server` and `cursedbelt` (the
|
|
5
|
+
React design system) sit above it.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
bun add cursedbelt-core
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## The root export is a BARREL — import the leaf
|
|
12
|
+
|
|
13
|
+
`import … from "cursedbelt-core"` re-exports everything below it. Import the subpath that owns
|
|
14
|
+
the symbol instead — `cursedbelt-core/navigation`, `cursedbelt-core/media/ffmpeg`,
|
|
15
|
+
`cursedbelt-core/master-lock`, and so on. `package.json`'s `exports` map is the full list of
|
|
16
|
+
subpaths; there is no separate reference doc to keep in sync with it.
|
|
17
|
+
|
|
18
|
+
## Docs in this repo
|
|
19
|
+
|
|
20
|
+
[`docs/skipped-tests.md`](docs/skipped-tests.md) — the table `src/skippedTestsAreAnswered.spec.ts`
|
|
21
|
+
keeps honest against every skip site in `src`.
|
|
@@ -40,6 +40,15 @@ export interface NavItem {
|
|
|
40
40
|
* a way to dodge the coverage test for a real, discoverable feature.
|
|
41
41
|
*/
|
|
42
42
|
deepLinkOnly?: true;
|
|
43
|
+
/**
|
|
44
|
+
* Found by search and the palette, but **never drawn** as a side-nav child row or a hub
|
|
45
|
+
* tile — the half of a page a reader types for rather than browses to: a tab (`?tab=agents`),
|
|
46
|
+
* a filter, a section of a long page. Without it, every such address either became a rail
|
|
47
|
+
* row (the sidebar grew with every tab) or was not findable by name at all (owner,
|
|
48
|
+
* 2026-10-09: *"Station is getting complicated to find things in with its dozens of sidebar
|
|
49
|
+
* items"*, and in the same breath, a search that finds a page by what it holds).
|
|
50
|
+
*/
|
|
51
|
+
searchOnly?: true;
|
|
43
52
|
/** Extra match terms for search / palette (synonyms, old names, abbreviations). */
|
|
44
53
|
keywords?: string[];
|
|
45
54
|
}
|
|
@@ -33,8 +33,9 @@ export interface NavCrumb {
|
|
|
33
33
|
/** The ordered domains visible under `gate` — the side-nav rail backbone. */
|
|
34
34
|
export declare function deriveSideNavDomains(model: NavigationModel, gate?: GateFn): NavDomain[];
|
|
35
35
|
/**
|
|
36
|
-
* Every visible (gated, non-`deepLinkOnly`) item of `domain`, flattened
|
|
37
|
-
* groups — for a simple HubTileGrid. Returns `[]` if
|
|
36
|
+
* Every visible (gated, non-`deepLinkOnly`, non-`searchOnly`) item of `domain`, flattened
|
|
37
|
+
* across its groups — for a simple HubTileGrid and the side nav's child rows. Returns `[]` if
|
|
38
|
+
* the domain itself is gated out.
|
|
38
39
|
*/
|
|
39
40
|
export declare function deriveDomainItems(domain: NavDomain, gate?: GateFn): NavItem[];
|
|
40
41
|
/**
|
|
@@ -54,7 +55,7 @@ export declare function deriveDomain(model: NavigationModel, domainId: string, g
|
|
|
54
55
|
export declare function deriveBreadcrumbTrail(model: NavigationModel, path: string, gate?: GateFn): NavCrumb[];
|
|
55
56
|
/**
|
|
56
57
|
* The flat list of searchable destinations — every visible (gated,
|
|
57
|
-
* non-`deepLinkOnly`) domain landing + item, each tagged with its domain/group so
|
|
58
|
+
* non-`deepLinkOnly`) domain landing + item, `searchOnly` ones included, each tagged with its domain/group so
|
|
58
59
|
* results can be grouped. Drives HeaderSearch's instant pages layer *and* the
|
|
59
60
|
* command palette; replaces every hand-listed search catalogue (so a feature is
|
|
60
61
|
* findable the moment it's registered).
|
|
@@ -45,14 +45,17 @@ const itemCrumb = (it) => ({
|
|
|
45
45
|
export function deriveSideNavDomains(model, gate = allowAllGate) {
|
|
46
46
|
return (model?.domains ?? []).filter((d) => gate(d.gate));
|
|
47
47
|
}
|
|
48
|
+
/** An item a reader can BROWSE to — drawn as a row or a tile, not only found by search. */
|
|
49
|
+
const isBrowsable = (it, gate) => !it.deepLinkOnly && !it.searchOnly && gate(it.gate);
|
|
48
50
|
/**
|
|
49
|
-
* Every visible (gated, non-`deepLinkOnly`) item of `domain`, flattened
|
|
50
|
-
* groups — for a simple HubTileGrid. Returns `[]` if
|
|
51
|
+
* Every visible (gated, non-`deepLinkOnly`, non-`searchOnly`) item of `domain`, flattened
|
|
52
|
+
* across its groups — for a simple HubTileGrid and the side nav's child rows. Returns `[]` if
|
|
53
|
+
* the domain itself is gated out.
|
|
51
54
|
*/
|
|
52
55
|
export function deriveDomainItems(domain, gate = allowAllGate) {
|
|
53
56
|
if (!gate(domain.gate))
|
|
54
57
|
return [];
|
|
55
|
-
return domain.groups.flatMap((g) => g.items).filter((it) =>
|
|
58
|
+
return domain.groups.flatMap((g) => g.items).filter((it) => isBrowsable(it, gate));
|
|
56
59
|
}
|
|
57
60
|
/**
|
|
58
61
|
* The visible groups of `domain` (each with its visible items; empty groups
|
|
@@ -63,7 +66,7 @@ export function deriveDomainGroups(domain, gate = allowAllGate) {
|
|
|
63
66
|
if (!gate(domain.gate))
|
|
64
67
|
return [];
|
|
65
68
|
return domain.groups
|
|
66
|
-
.map((g) => ({ ...g, items: g.items.filter((it) =>
|
|
69
|
+
.map((g) => ({ ...g, items: g.items.filter((it) => isBrowsable(it, gate)) }))
|
|
67
70
|
.filter((g) => g.items.length > 0);
|
|
68
71
|
}
|
|
69
72
|
/** Look up a domain by id (gated). */
|
|
@@ -113,7 +116,7 @@ export function deriveBreadcrumbTrail(model, path, gate = allowAllGate) {
|
|
|
113
116
|
}
|
|
114
117
|
/**
|
|
115
118
|
* The flat list of searchable destinations — every visible (gated,
|
|
116
|
-
* non-`deepLinkOnly`) domain landing + item, each tagged with its domain/group so
|
|
119
|
+
* non-`deepLinkOnly`) domain landing + item, `searchOnly` ones included, each tagged with its domain/group so
|
|
117
120
|
* results can be grouped. Drives HeaderSearch's instant pages layer *and* the
|
|
118
121
|
* command palette; replaces every hand-listed search catalogue (so a feature is
|
|
119
122
|
* findable the moment it's registered).
|
|
@@ -11,6 +11,11 @@ export declare function searchInitials(label: string): string;
|
|
|
11
11
|
* that carry domain/group tags for grouping — the extra fields ride straight through).
|
|
12
12
|
* An empty/blank query returns `[]` (the palette shows its cold state instead).
|
|
13
13
|
*
|
|
14
|
+
* Two tiers, the first always ahead of the second: an item whose NAME matches (label,
|
|
15
|
+
* initials, keywords), then one that matches only by what it HOLDS (description, group,
|
|
16
|
+
* place). So "frac" ranks Fractal first however many descriptions mention fractals, while
|
|
17
|
+
* a word that names no page still finds the pages that are about it.
|
|
18
|
+
*
|
|
14
19
|
* Returns *all* matches in rank order; callers cap the count for display (the palette
|
|
15
20
|
* shows 30). uFuzzy's contiguous scorer means "frac" ranks "Fractal" above "Format
|
|
16
21
|
* Lab", and the initials augmentation means "qb" surfaces "Query Builder".
|
|
@@ -40,12 +40,38 @@ function haystackFor(item) {
|
|
|
40
40
|
.filter(Boolean)
|
|
41
41
|
.join(' ');
|
|
42
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* The SECOND tier: what a destination holds rather than what it is called — its description,
|
|
45
|
+
* and the group and place it sits under when the entry carries them (catalogue entries do).
|
|
46
|
+
* Owner, 2026-10-09: *"I may not know where to go but typing something like 'api' brings up the
|
|
47
|
+
* relevant page links quickly"* — a page found only by its exact name is not found by someone
|
|
48
|
+
* who does not know the name.
|
|
49
|
+
*/
|
|
50
|
+
function secondaryHaystackFor(item) {
|
|
51
|
+
return [item.description, item.groupLabel, item.domainLabel].filter(Boolean).join(' ');
|
|
52
|
+
}
|
|
53
|
+
/** uFuzzy's ranked indexes into `haystack` for `q`, `[]` when nothing matched. */
|
|
54
|
+
function rankedIndexes(haystack, q) {
|
|
55
|
+
const [idxs, , order] = uf.search(haystack, q);
|
|
56
|
+
// `idxs` is null when nothing matched and an empty array when filtered to zero; both
|
|
57
|
+
// mean "no results". `order` (the ranked iteration order into `idxs`) is present
|
|
58
|
+
// whenever there are matches under uFuzzy's info threshold (1e3 — far above any nav
|
|
59
|
+
// set); fall back to raw `idxs` order if it's ever absent.
|
|
60
|
+
if (!idxs || idxs.length === 0)
|
|
61
|
+
return [];
|
|
62
|
+
return order ? order.map((o) => idxs[o]) : [...idxs];
|
|
63
|
+
}
|
|
43
64
|
/**
|
|
44
65
|
* Rank `items` against `query`, best match first; non-matches dropped. Generic over the
|
|
45
66
|
* item type (so callers can pass plain {@link NavItem}s *or* richer catalogue entries
|
|
46
67
|
* that carry domain/group tags for grouping — the extra fields ride straight through).
|
|
47
68
|
* An empty/blank query returns `[]` (the palette shows its cold state instead).
|
|
48
69
|
*
|
|
70
|
+
* Two tiers, the first always ahead of the second: an item whose NAME matches (label,
|
|
71
|
+
* initials, keywords), then one that matches only by what it HOLDS (description, group,
|
|
72
|
+
* place). So "frac" ranks Fractal first however many descriptions mention fractals, while
|
|
73
|
+
* a word that names no page still finds the pages that are about it.
|
|
74
|
+
*
|
|
49
75
|
* Returns *all* matches in rank order; callers cap the count for display (the palette
|
|
50
76
|
* shows 30). uFuzzy's contiguous scorer means "frac" ranks "Fractal" above "Format
|
|
51
77
|
* Lab", and the initials augmentation means "qb" surfaces "Query Builder".
|
|
@@ -54,17 +80,11 @@ export function fuzzyRankItems(query, items) {
|
|
|
54
80
|
const q = query.trim();
|
|
55
81
|
if (!q || items.length === 0)
|
|
56
82
|
return [];
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
// mean "no results". `order` (the ranked iteration order into `idxs`) is present
|
|
61
|
-
// whenever there are matches under uFuzzy's info threshold (1e3 — far above any nav
|
|
62
|
-
// set); fall back to raw `idxs` order if it's ever absent.
|
|
63
|
-
if (!idxs || idxs.length === 0)
|
|
64
|
-
return [];
|
|
65
|
-
const ranked = order ? order.map((o) => idxs[o]) : idxs;
|
|
83
|
+
const primary = rankedIndexes(items.map(haystackFor), q);
|
|
84
|
+
const named = new Set(primary);
|
|
85
|
+
const secondary = rankedIndexes(items.map(secondaryHaystackFor), q).filter((i) => !named.has(i));
|
|
66
86
|
const out = [];
|
|
67
|
-
for (const haystackIdx of
|
|
87
|
+
for (const haystackIdx of [...primary, ...secondary]) {
|
|
68
88
|
const item = items[haystackIdx];
|
|
69
89
|
if (item)
|
|
70
90
|
out.push(item);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cursedbelt-core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.13.0",
|
|
4
4
|
"license": "ISC",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Framework-agnostic core of the cursedbelt split — isomorphic domain logic. cwip below it, cursedbelt-server and cursedbelt (react) above it.",
|
|
@@ -424,5 +424,9 @@
|
|
|
424
424
|
"why": "build starts with `rm -rf dist`, and leafSubpathsImportNothing / shippedFilesAreTracked read dist/; racing it they read a directory that is not there yet"
|
|
425
425
|
}
|
|
426
426
|
]
|
|
427
|
+
},
|
|
428
|
+
"repository": {
|
|
429
|
+
"type": "git",
|
|
430
|
+
"url": "git+https://github.com/wizardmordamir/cursedforge-cursedbelt-core.git"
|
|
427
431
|
}
|
|
428
432
|
}
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
groupCatalogueHits,
|
|
14
14
|
type LegacyNavPrefs,
|
|
15
15
|
migrateNavPreferences,
|
|
16
|
+
type NavDomain,
|
|
16
17
|
type NavGate,
|
|
17
18
|
type NavigationModel,
|
|
18
19
|
normalizeNavPath,
|
|
@@ -180,6 +181,30 @@ describe('deriveDomainItems / deriveDomainGroups', () => {
|
|
|
180
181
|
const ctgrGate: GateFn = (g?: NavGate) => !g?.hiddenInCtgr;
|
|
181
182
|
expect(deriveDomainItems(admin, ctgrGate).map((i) => i.id)).toEqual(['admin-users']);
|
|
182
183
|
});
|
|
184
|
+
test('a searchOnly item is found by search but never drawn as a row or a tile', () => {
|
|
185
|
+
const perf: NavDomain = {
|
|
186
|
+
id: 'perf',
|
|
187
|
+
label: 'Performance',
|
|
188
|
+
icon: 'activity',
|
|
189
|
+
path: '/performance',
|
|
190
|
+
groups: [
|
|
191
|
+
{
|
|
192
|
+
id: 'perf-pages',
|
|
193
|
+
label: 'Pages',
|
|
194
|
+
items: [
|
|
195
|
+
{ id: 'perf-overview', label: 'Overview', path: '/performance/overview' },
|
|
196
|
+
{ id: 'perf-agents', label: 'Agents', path: '/performance?tab=agents', searchOnly: true },
|
|
197
|
+
],
|
|
198
|
+
},
|
|
199
|
+
{ id: 'perf-tabs', label: 'Tabs', items: [{ id: 'perf-cost', label: 'Cost', path: '/performance?tab=cost', searchOnly: true }] },
|
|
200
|
+
],
|
|
201
|
+
};
|
|
202
|
+
expect(deriveDomainItems(perf).map((i) => i.id)).toEqual(['perf-overview']);
|
|
203
|
+
// A group whose every item is searchOnly is not a heading on a hub landing.
|
|
204
|
+
expect(deriveDomainGroups(perf).map((g) => g.id)).toEqual(['perf-pages']);
|
|
205
|
+
const found = deriveSearchCatalogue({ domains: [perf] }).map((e) => e.id);
|
|
206
|
+
expect(found).toEqual(['perf', 'perf-overview', 'perf-agents', 'perf-cost']);
|
|
207
|
+
});
|
|
183
208
|
});
|
|
184
209
|
|
|
185
210
|
describe('deriveBreadcrumbTrail', () => {
|
|
@@ -54,6 +54,15 @@ export interface NavItem {
|
|
|
54
54
|
* a way to dodge the coverage test for a real, discoverable feature.
|
|
55
55
|
*/
|
|
56
56
|
deepLinkOnly?: true;
|
|
57
|
+
/**
|
|
58
|
+
* Found by search and the palette, but **never drawn** as a side-nav child row or a hub
|
|
59
|
+
* tile — the half of a page a reader types for rather than browses to: a tab (`?tab=agents`),
|
|
60
|
+
* a filter, a section of a long page. Without it, every such address either became a rail
|
|
61
|
+
* row (the sidebar grew with every tab) or was not findable by name at all (owner,
|
|
62
|
+
* 2026-10-09: *"Station is getting complicated to find things in with its dozens of sidebar
|
|
63
|
+
* items"*, and in the same breath, a search that finds a page by what it holds).
|
|
64
|
+
*/
|
|
65
|
+
searchOnly?: true;
|
|
57
66
|
/** Extra match terms for search / palette (synonyms, old names, abbreviations). */
|
|
58
67
|
keywords?: string[];
|
|
59
68
|
}
|
|
@@ -79,13 +79,17 @@ export function deriveSideNavDomains(
|
|
|
79
79
|
return (model?.domains ?? []).filter((d) => gate(d.gate));
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/** An item a reader can BROWSE to — drawn as a row or a tile, not only found by search. */
|
|
83
|
+
const isBrowsable = (it: NavItem, gate: GateFn): boolean => !it.deepLinkOnly && !it.searchOnly && gate(it.gate);
|
|
84
|
+
|
|
82
85
|
/**
|
|
83
|
-
* Every visible (gated, non-`deepLinkOnly`) item of `domain`, flattened
|
|
84
|
-
* groups — for a simple HubTileGrid. Returns `[]` if
|
|
86
|
+
* Every visible (gated, non-`deepLinkOnly`, non-`searchOnly`) item of `domain`, flattened
|
|
87
|
+
* across its groups — for a simple HubTileGrid and the side nav's child rows. Returns `[]` if
|
|
88
|
+
* the domain itself is gated out.
|
|
85
89
|
*/
|
|
86
90
|
export function deriveDomainItems(domain: NavDomain, gate: GateFn = allowAllGate): NavItem[] {
|
|
87
91
|
if (!gate(domain.gate)) return [];
|
|
88
|
-
return domain.groups.flatMap((g) => g.items).filter((it) =>
|
|
92
|
+
return domain.groups.flatMap((g) => g.items).filter((it) => isBrowsable(it, gate));
|
|
89
93
|
}
|
|
90
94
|
|
|
91
95
|
/**
|
|
@@ -96,7 +100,7 @@ export function deriveDomainItems(domain: NavDomain, gate: GateFn = allowAllGate
|
|
|
96
100
|
export function deriveDomainGroups(domain: NavDomain, gate: GateFn = allowAllGate): NavGroup[] {
|
|
97
101
|
if (!gate(domain.gate)) return [];
|
|
98
102
|
return domain.groups
|
|
99
|
-
.map((g) => ({ ...g, items: g.items.filter((it) =>
|
|
103
|
+
.map((g) => ({ ...g, items: g.items.filter((it) => isBrowsable(it, gate)) }))
|
|
100
104
|
.filter((g) => g.items.length > 0);
|
|
101
105
|
}
|
|
102
106
|
|
|
@@ -154,7 +158,7 @@ export function deriveBreadcrumbTrail(
|
|
|
154
158
|
|
|
155
159
|
/**
|
|
156
160
|
* The flat list of searchable destinations — every visible (gated,
|
|
157
|
-
* non-`deepLinkOnly`) domain landing + item, each tagged with its domain/group so
|
|
161
|
+
* non-`deepLinkOnly`) domain landing + item, `searchOnly` ones included, each tagged with its domain/group so
|
|
158
162
|
* results can be grouped. Drives HeaderSearch's instant pages layer *and* the
|
|
159
163
|
* command palette; replaces every hand-listed search catalogue (so a feature is
|
|
160
164
|
* findable the moment it's registered).
|
|
@@ -46,12 +46,39 @@ function haystackFor(item: NavItem): string {
|
|
|
46
46
|
.join(' ');
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* The SECOND tier: what a destination holds rather than what it is called — its description,
|
|
51
|
+
* and the group and place it sits under when the entry carries them (catalogue entries do).
|
|
52
|
+
* Owner, 2026-10-09: *"I may not know where to go but typing something like 'api' brings up the
|
|
53
|
+
* relevant page links quickly"* — a page found only by its exact name is not found by someone
|
|
54
|
+
* who does not know the name.
|
|
55
|
+
*/
|
|
56
|
+
function secondaryHaystackFor(item: NavItem & { groupLabel?: string; domainLabel?: string }): string {
|
|
57
|
+
return [item.description, item.groupLabel, item.domainLabel].filter(Boolean).join(' ');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** uFuzzy's ranked indexes into `haystack` for `q`, `[]` when nothing matched. */
|
|
61
|
+
function rankedIndexes(haystack: string[], q: string): number[] {
|
|
62
|
+
const [idxs, , order] = uf.search(haystack, q);
|
|
63
|
+
// `idxs` is null when nothing matched and an empty array when filtered to zero; both
|
|
64
|
+
// mean "no results". `order` (the ranked iteration order into `idxs`) is present
|
|
65
|
+
// whenever there are matches under uFuzzy's info threshold (1e3 — far above any nav
|
|
66
|
+
// set); fall back to raw `idxs` order if it's ever absent.
|
|
67
|
+
if (!idxs || idxs.length === 0) return [];
|
|
68
|
+
return order ? order.map((o) => idxs[o] as number) : [...idxs];
|
|
69
|
+
}
|
|
70
|
+
|
|
49
71
|
/**
|
|
50
72
|
* Rank `items` against `query`, best match first; non-matches dropped. Generic over the
|
|
51
73
|
* item type (so callers can pass plain {@link NavItem}s *or* richer catalogue entries
|
|
52
74
|
* that carry domain/group tags for grouping — the extra fields ride straight through).
|
|
53
75
|
* An empty/blank query returns `[]` (the palette shows its cold state instead).
|
|
54
76
|
*
|
|
77
|
+
* Two tiers, the first always ahead of the second: an item whose NAME matches (label,
|
|
78
|
+
* initials, keywords), then one that matches only by what it HOLDS (description, group,
|
|
79
|
+
* place). So "frac" ranks Fractal first however many descriptions mention fractals, while
|
|
80
|
+
* a word that names no page still finds the pages that are about it.
|
|
81
|
+
*
|
|
55
82
|
* Returns *all* matches in rank order; callers cap the count for display (the palette
|
|
56
83
|
* shows 30). uFuzzy's contiguous scorer means "frac" ranks "Fractal" above "Format
|
|
57
84
|
* Lab", and the initials augmentation means "qb" surfaces "Query Builder".
|
|
@@ -60,17 +87,12 @@ export function fuzzyRankItems<T extends NavItem>(query: string, items: T[]): T[
|
|
|
60
87
|
const q = query.trim();
|
|
61
88
|
if (!q || items.length === 0) return [];
|
|
62
89
|
|
|
63
|
-
const
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
// mean "no results". `order` (the ranked iteration order into `idxs`) is present
|
|
67
|
-
// whenever there are matches under uFuzzy's info threshold (1e3 — far above any nav
|
|
68
|
-
// set); fall back to raw `idxs` order if it's ever absent.
|
|
69
|
-
if (!idxs || idxs.length === 0) return [];
|
|
70
|
-
const ranked = order ? order.map((o) => idxs[o]) : idxs;
|
|
90
|
+
const primary = rankedIndexes(items.map(haystackFor), q);
|
|
91
|
+
const named = new Set(primary);
|
|
92
|
+
const secondary = rankedIndexes(items.map(secondaryHaystackFor), q).filter((i) => !named.has(i));
|
|
71
93
|
|
|
72
94
|
const out: T[] = [];
|
|
73
|
-
for (const haystackIdx of
|
|
95
|
+
for (const haystackIdx of [...primary, ...secondary]) {
|
|
74
96
|
const item = items[haystackIdx];
|
|
75
97
|
if (item) out.push(item);
|
|
76
98
|
}
|
|
@@ -66,6 +66,21 @@ describe('fuzzyRankItems', () => {
|
|
|
66
66
|
expect(fuzzyRankItems('zzqzzx', ITEMS)).toEqual([]);
|
|
67
67
|
});
|
|
68
68
|
|
|
69
|
+
test('what a page HOLDS finds it: a description-only word ranks after every name match', () => {
|
|
70
|
+
const pages: (NavItem & { domainLabel?: string })[] = [
|
|
71
|
+
{ ...item('Connections', ['google', 'github']), description: 'The sign-ins and API tokens your apps run on' },
|
|
72
|
+
{ ...item('Traffic'), description: 'What this Mac served' },
|
|
73
|
+
{ ...item('Agents'), domainLabel: 'Performance', description: 'What each agent cost' },
|
|
74
|
+
{ ...item('API keys') },
|
|
75
|
+
];
|
|
76
|
+
// "api" names "API keys" — that is first — and is only HELD by Connections, which follows.
|
|
77
|
+
expect(labels(fuzzyRankItems('api', pages))).toEqual(['API keys', 'Connections']);
|
|
78
|
+
// The place an entry sits under counts as what it holds: "performance" finds its tab.
|
|
79
|
+
expect(labels(fuzzyRankItems('performance', pages))).toEqual(['Agents']);
|
|
80
|
+
// Never the same item twice when it matches in both tiers.
|
|
81
|
+
expect(labels(fuzzyRankItems('agent', pages))).toEqual(['Agents']);
|
|
82
|
+
});
|
|
83
|
+
|
|
69
84
|
test('carries through extra fields on richer entry types (domain tags)', () => {
|
|
70
85
|
type Tagged = NavItem & { domainId: string };
|
|
71
86
|
const tagged: Tagged[] = [{ ...item('Fractal'), domainId: 'play' }];
|