cursedbelt-core 1.0.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/LICENSE +15 -0
- package/dist/client/wsWire.d.ts +49 -0
- package/dist/client/wsWire.js +57 -0
- package/dist/config/appConfig.d.ts +361 -0
- package/dist/config/appConfig.js +308 -0
- package/dist/core/activity/index.d.ts +8 -0
- package/dist/core/activity/index.js +7 -0
- package/dist/core/activity/model.d.ts +115 -0
- package/dist/core/activity/model.js +63 -0
- package/dist/core/analytics/index.d.ts +18 -0
- package/dist/core/analytics/index.js +18 -0
- package/dist/core/analytics/range.d.ts +186 -0
- package/dist/core/analytics/range.js +271 -0
- package/dist/core/analytics/schedule.d.ts +301 -0
- package/dist/core/analytics/schedule.js +372 -0
- package/dist/core/category/index.d.ts +3 -0
- package/dist/core/category/index.js +6 -0
- package/dist/core/category/memoryStore.d.ts +21 -0
- package/dist/core/category/memoryStore.js +97 -0
- package/dist/core/category/tree.d.ts +26 -0
- package/dist/core/category/tree.js +113 -0
- package/dist/core/category/types.d.ts +48 -0
- package/dist/core/category/types.js +8 -0
- package/dist/core/color.d.ts +14 -0
- package/dist/core/color.js +32 -0
- package/dist/core/colorScheme/colorSchemeStore.d.ts +70 -0
- package/dist/core/colorScheme/colorSchemeStore.js +123 -0
- package/dist/core/colorScheme/index.d.ts +7 -0
- package/dist/core/colorScheme/index.js +7 -0
- package/dist/core/domainOwners.d.ts +19 -0
- package/dist/core/domainOwners.js +15 -0
- package/dist/core/download.d.ts +14 -0
- package/dist/core/download.js +37 -0
- package/dist/core/events/ccEventBus.d.ts +44 -0
- package/dist/core/events/ccEventBus.js +70 -0
- package/dist/core/file-tree/fileTreeModel.d.ts +276 -0
- package/dist/core/file-tree/fileTreeModel.js +312 -0
- package/dist/core/folder-tree/actions.d.ts +84 -0
- package/dist/core/folder-tree/actions.js +119 -0
- package/dist/core/folder-tree/index.d.ts +3 -0
- package/dist/core/folder-tree/index.js +20 -0
- package/dist/core/folder-tree/path.d.ts +76 -0
- package/dist/core/folder-tree/path.js +146 -0
- package/dist/core/folder-tree/tree.d.ts +86 -0
- package/dist/core/folder-tree/tree.js +184 -0
- package/dist/core/layout/index.d.ts +1 -0
- package/dist/core/layout/index.js +7 -0
- package/dist/core/master-lock/index.d.ts +14 -0
- package/dist/core/master-lock/index.js +14 -0
- package/dist/core/master-lock/kdf.d.ts +63 -0
- package/dist/core/master-lock/kdf.js +117 -0
- package/dist/core/master-lock/policy.d.ts +32 -0
- package/dist/core/master-lock/policy.js +52 -0
- package/dist/core/master-lock/presence.d.ts +52 -0
- package/dist/core/master-lock/presence.js +61 -0
- package/dist/core/master-lock/wire.d.ts +139 -0
- package/dist/core/master-lock/wire.js +81 -0
- package/dist/core/media/bgRemoval.d.ts +27 -0
- package/dist/core/media/bgRemoval.js +101 -0
- package/dist/core/media/ffmpeg.d.ts +57 -0
- package/dist/core/media/ffmpeg.js +126 -0
- package/dist/core/media/format.d.ts +20 -0
- package/dist/core/media/format.js +54 -0
- package/dist/core/media/index.d.ts +12 -0
- package/dist/core/media/index.js +18 -0
- package/dist/core/media/mediaItem.d.ts +15 -0
- package/dist/core/media/mediaItem.js +34 -0
- package/dist/core/media/renderPlan.d.ts +63 -0
- package/dist/core/media/renderPlan.js +232 -0
- package/dist/core/media/segment.d.ts +79 -0
- package/dist/core/media/segment.js +247 -0
- package/dist/core/media/size.d.ts +1 -0
- package/dist/core/media/size.js +3 -0
- package/dist/core/media/trim.d.ts +21 -0
- package/dist/core/media/trim.js +40 -0
- package/dist/core/media/types.d.ts +148 -0
- package/dist/core/media/types.js +11 -0
- package/dist/core/media/uploadLimits.d.ts +7 -0
- package/dist/core/media/uploadLimits.js +16 -0
- package/dist/core/media/validate.d.ts +9 -0
- package/dist/core/media/validate.js +121 -0
- package/dist/core/media/videoCuts.d.ts +152 -0
- package/dist/core/media/videoCuts.js +284 -0
- package/dist/core/navigation/breadcrumbCompat.d.ts +24 -0
- package/dist/core/navigation/breadcrumbCompat.js +21 -0
- package/dist/core/navigation/coverageTest.d.ts +24 -0
- package/dist/core/navigation/coverageTest.js +48 -0
- package/dist/core/navigation/index.d.ts +6 -0
- package/dist/core/navigation/index.js +10 -0
- package/dist/core/navigation/migrateNavPreferences.d.ts +55 -0
- package/dist/core/navigation/migrateNavPreferences.js +63 -0
- package/dist/core/navigation/navigationModel.d.ts +165 -0
- package/dist/core/navigation/navigationModel.js +14 -0
- package/dist/core/navigation/navigationSelectors.d.ts +63 -0
- package/dist/core/navigation/navigationSelectors.js +167 -0
- package/dist/core/navigation/searchCatalogue.d.ts +23 -0
- package/dist/core/navigation/searchCatalogue.js +76 -0
- package/dist/core/net/reconnectSchedule.d.ts +46 -0
- package/dist/core/net/reconnectSchedule.js +38 -0
- package/dist/core/palette/ShortcutRegistry.d.ts +33 -0
- package/dist/core/palette/ShortcutRegistry.js +52 -0
- package/dist/core/palette/fuzzyRank.d.ts +18 -0
- package/dist/core/palette/fuzzyRank.js +73 -0
- package/dist/core/palette/hotkeys.d.ts +28 -0
- package/dist/core/palette/hotkeys.js +118 -0
- package/dist/core/palette/index.d.ts +3 -0
- package/dist/core/palette/index.js +8 -0
- package/dist/core/schema/projection.d.ts +84 -0
- package/dist/core/schema/projection.js +184 -0
- package/dist/core/session/claims.d.ts +145 -0
- package/dist/core/session/claims.js +157 -0
- package/dist/core/sharing/index.d.ts +5 -0
- package/dist/core/sharing/index.js +4 -0
- package/dist/core/sharing/model.d.ts +195 -0
- package/dist/core/sharing/model.js +150 -0
- package/dist/core/site/siteCopy.d.ts +92 -0
- package/dist/core/site/siteCopy.js +91 -0
- package/dist/core/slots.d.ts +22 -0
- package/dist/core/slots.js +31 -0
- package/dist/core/styles/constructableSheet.d.ts +51 -0
- package/dist/core/styles/constructableSheet.js +80 -0
- package/dist/core/tokens/registry.d.ts +119 -0
- package/dist/core/tokens/registry.js +447 -0
- package/dist/core/uiScale/index.d.ts +8 -0
- package/dist/core/uiScale/index.js +8 -0
- package/dist/core/uiScale/scale.d.ts +36 -0
- package/dist/core/uiScale/scale.js +47 -0
- package/dist/core/uiScale/uiScaleStore.d.ts +47 -0
- package/dist/core/uiScale/uiScaleStore.js +63 -0
- package/dist/core/wire/__fixtures__/rowsMarshalWorker.d.ts +1 -0
- package/dist/core/wire/__fixtures__/rowsMarshalWorker.js +23 -0
- package/dist/core/wire/index.d.ts +17 -0
- package/dist/core/wire/index.js +17 -0
- package/dist/core/wire/ingress.d.ts +44 -0
- package/dist/core/wire/ingress.js +134 -0
- package/dist/core/wire/marshalWorker.d.ts +70 -0
- package/dist/core/wire/marshalWorker.js +273 -0
- package/dist/core/wire/marshalling.d.ts +122 -0
- package/dist/core/wire/marshalling.js +126 -0
- package/dist/core/wire/wireHub.d.ts +80 -0
- package/dist/core/wire/wireHub.js +116 -0
- package/dist/ctgr/base64url.d.ts +8 -0
- package/dist/ctgr/base64url.js +39 -0
- package/dist/ctgr/chunkAccumulator.d.ts +214 -0
- package/dist/ctgr/chunkAccumulator.js +307 -0
- package/dist/ctgr/chunkStore.d.ts +24 -0
- package/dist/ctgr/chunkStore.js +125 -0
- package/dist/ctgr/codec.d.ts +44 -0
- package/dist/ctgr/codec.js +153 -0
- package/dist/ctgr/compress.d.ts +2 -0
- package/dist/ctgr/compress.js +20 -0
- package/dist/ctgr/crc32.d.ts +2 -0
- package/dist/ctgr/crc32.js +23 -0
- package/dist/ctgr/digest.d.ts +1 -0
- package/dist/ctgr/digest.js +15 -0
- package/dist/ctgr/index.d.ts +9 -0
- package/dist/ctgr/index.js +17 -0
- package/dist/ctgr/types.d.ts +64 -0
- package/dist/ctgr/types.js +21 -0
- package/dist/ctgr/v0compat.d.ts +27 -0
- package/dist/ctgr/v0compat.js +128 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +17 -0
- package/dist/shared/errors.d.ts +101 -0
- package/dist/shared/errors.js +99 -0
- package/dist/testing/bytes.d.ts +32 -0
- package/dist/testing/bytes.js +33 -0
- package/dist/testing/domInspect.d.ts +59 -0
- package/dist/testing/domInspect.js +115 -0
- package/dist/testing/fakeFetch.d.ts +23 -0
- package/dist/testing/fakeFetch.js +21 -0
- package/dist/testing/index.d.ts +3 -0
- package/dist/testing/index.js +11 -0
- package/package.json +373 -0
- package/src/client/wsWire.ts +83 -0
- package/src/config/appConfig.spec.ts +158 -0
- package/src/config/appConfig.ts +372 -0
- package/src/core/activity/index.ts +21 -0
- package/src/core/activity/model.ts +160 -0
- package/src/core/analytics/index.ts +18 -0
- package/src/core/analytics/range.spec.ts +280 -0
- package/src/core/analytics/range.ts +383 -0
- package/src/core/analytics/schedule.spec.ts +311 -0
- package/src/core/analytics/schedule.ts +624 -0
- package/src/core/category/category.spec.ts +144 -0
- package/src/core/category/index.ts +19 -0
- package/src/core/category/memoryStore.ts +104 -0
- package/src/core/category/tree.ts +118 -0
- package/src/core/category/types.ts +59 -0
- package/src/core/color.spec.ts +35 -0
- package/src/core/color.ts +35 -0
- package/src/core/colorScheme/colorScheme.spec.ts +127 -0
- package/src/core/colorScheme/colorSchemeStore.ts +184 -0
- package/src/core/colorScheme/index.ts +23 -0
- package/src/core/domainOwners.ts +32 -0
- package/src/core/download.spec.ts +38 -0
- package/src/core/download.ts +35 -0
- package/src/core/events/ccEventBus.spec.ts +90 -0
- package/src/core/events/ccEventBus.ts +120 -0
- package/src/core/file-tree/fileTreeModel.spec.ts +462 -0
- package/src/core/file-tree/fileTreeModel.ts +511 -0
- package/src/core/folder-tree/actions.spec.ts +103 -0
- package/src/core/folder-tree/actions.ts +153 -0
- package/src/core/folder-tree/index.ts +46 -0
- package/src/core/folder-tree/path.spec.ts +113 -0
- package/src/core/folder-tree/path.ts +148 -0
- package/src/core/folder-tree/tree.spec.ts +157 -0
- package/src/core/folder-tree/tree.ts +244 -0
- package/src/core/layout/index.ts +7 -0
- package/src/core/master-lock/index.ts +50 -0
- package/src/core/master-lock/kdf.spec.ts +95 -0
- package/src/core/master-lock/kdf.ts +145 -0
- package/src/core/master-lock/policy.ts +55 -0
- package/src/core/master-lock/presence.spec.ts +73 -0
- package/src/core/master-lock/presence.ts +71 -0
- package/src/core/master-lock/wire.ts +155 -0
- package/src/core/media/bgRemoval.spec.ts +120 -0
- package/src/core/media/bgRemoval.ts +123 -0
- package/src/core/media/ffmpeg.spec.ts +135 -0
- package/src/core/media/ffmpeg.ts +162 -0
- package/src/core/media/format.spec.ts +41 -0
- package/src/core/media/format.ts +58 -0
- package/src/core/media/index.ts +105 -0
- package/src/core/media/mediaItem.spec.ts +46 -0
- package/src/core/media/mediaItem.ts +40 -0
- package/src/core/media/renderPlan.spec.ts +140 -0
- package/src/core/media/renderPlan.ts +318 -0
- package/src/core/media/segment.spec.ts +159 -0
- package/src/core/media/segment.ts +352 -0
- package/src/core/media/size.spec.ts +19 -0
- package/src/core/media/size.ts +3 -0
- package/src/core/media/trim.spec.ts +75 -0
- package/src/core/media/trim.ts +60 -0
- package/src/core/media/types.ts +147 -0
- package/src/core/media/uploadLimits.ts +18 -0
- package/src/core/media/validate.spec.ts +106 -0
- package/src/core/media/validate.ts +131 -0
- package/src/core/media/videoCuts.spec.ts +327 -0
- package/src/core/media/videoCuts.ts +355 -0
- package/src/core/navigation/breadcrumbCompat.ts +44 -0
- package/src/core/navigation/coverageTest.ts +54 -0
- package/src/core/navigation/index.ts +50 -0
- package/src/core/navigation/migrateNavPreferences.ts +119 -0
- package/src/core/navigation/navigation.spec.ts +388 -0
- package/src/core/navigation/navigationModel.ts +185 -0
- package/src/core/navigation/navigationSelectors.ts +206 -0
- package/src/core/navigation/searchCatalogue.ts +89 -0
- package/src/core/net/reconnectSchedule.spec.ts +49 -0
- package/src/core/net/reconnectSchedule.ts +78 -0
- package/src/core/palette/ShortcutRegistry.ts +69 -0
- package/src/core/palette/fuzzyRank.ts +78 -0
- package/src/core/palette/hotkeys.ts +132 -0
- package/src/core/palette/index.ts +22 -0
- package/src/core/palette/palette.spec.ts +160 -0
- package/src/core/schema/projection.spec.ts +122 -0
- package/src/core/schema/projection.ts +225 -0
- package/src/core/session/claims.ts +181 -0
- package/src/core/sharing/index.ts +33 -0
- package/src/core/sharing/model.spec.ts +104 -0
- package/src/core/sharing/model.ts +296 -0
- package/src/core/site/siteCopy.ts +115 -0
- package/src/core/slots.ts +38 -0
- package/src/core/styles/constructableSheet.ts +86 -0
- package/src/core/tokens/registry.spec.ts +160 -0
- package/src/core/tokens/registry.ts +536 -0
- package/src/core/uiScale/index.ts +32 -0
- package/src/core/uiScale/scale.ts +63 -0
- package/src/core/uiScale/uiScale.spec.ts +77 -0
- package/src/core/uiScale/uiScaleStore.ts +100 -0
- package/src/core/wire/__fixtures__/rowsMarshalWorker.ts +24 -0
- package/src/core/wire/index.ts +56 -0
- package/src/core/wire/ingress.spec.ts +276 -0
- package/src/core/wire/ingress.ts +182 -0
- package/src/core/wire/marshalWorker.spec.ts +196 -0
- package/src/core/wire/marshalWorker.ts +361 -0
- package/src/core/wire/marshalling.spec.ts +139 -0
- package/src/core/wire/marshalling.ts +238 -0
- package/src/core/wire/wireHub.spec.ts +137 -0
- package/src/core/wire/wireHub.ts +213 -0
- package/src/ctgr/base64url.ts +44 -0
- package/src/ctgr/chunkAccumulator.ts +403 -0
- package/src/ctgr/chunkStore.spec.ts +101 -0
- package/src/ctgr/chunkStore.ts +168 -0
- package/src/ctgr/codec.spec.ts +208 -0
- package/src/ctgr/codec.ts +202 -0
- package/src/ctgr/compress.ts +21 -0
- package/src/ctgr/crc32.ts +23 -0
- package/src/ctgr/digest.ts +14 -0
- package/src/ctgr/index.ts +39 -0
- package/src/ctgr/types.ts +80 -0
- package/src/ctgr/v0compat.spec.ts +73 -0
- package/src/ctgr/v0compat.ts +156 -0
- package/src/declaredDepsAreImported.spec.ts +87 -0
- package/src/index.ts +25 -0
- package/src/leafSubpathsImportNothing.spec.ts +287 -0
- package/src/lockfileIsTracked.spec.ts +50 -0
- package/src/namedSubpathsResolve.spec.ts +122 -0
- package/src/noPathDeps.spec.ts +24 -0
- package/src/publishShape.spec.ts +89 -0
- package/src/shared/errors.ts +153 -0
- package/src/skippedTestsAreAnswered.spec.ts +221 -0
- package/src/testTmpRoot.spec.ts +102 -0
- package/src/testing/bytes.ts +38 -0
- package/src/testing/domInspect.spec.ts +79 -0
- package/src/testing/domInspect.ts +127 -0
- package/src/testing/fakeFetch.ts +26 -0
- package/src/testing/index.ts +11 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Pure derivations off the {@link NavigationModel}. Every nav surface (side nav,
|
|
2
|
+
// hub tiles, breadcrumbs, search catalogue, palette, the coverage test) reads the
|
|
3
|
+
// model exclusively through these — there is no second list to keep in sync. They
|
|
4
|
+
// import nothing from React, so they run identically in the client, in a server
|
|
5
|
+
// test, and in the CI coverage check.
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
allowAllGate,
|
|
9
|
+
type GateFn,
|
|
10
|
+
type NavDomain,
|
|
11
|
+
type NavGroup,
|
|
12
|
+
type NavItem,
|
|
13
|
+
type NavigationModel,
|
|
14
|
+
} from './navigationModel';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Normalize a path for route *matching* — drop the query string, the hash (the
|
|
18
|
+
* `#tab=…` in-page target), and a trailing slash. `'/'` stays `'/'`. Used so a tab
|
|
19
|
+
* variant (`/tools#tab=curl`) matches the base route (`/tools`) and `/lists/`
|
|
20
|
+
* matches `/lists`.
|
|
21
|
+
*/
|
|
22
|
+
export function normalizeNavPath(path: string): string {
|
|
23
|
+
// Null-safe: a nav selector must never throw (a breadcrumb derivation crashing
|
|
24
|
+
// white-screens the whole page). Coerce a missing/non-string path to ''.
|
|
25
|
+
const s = typeof path === 'string' ? path : '';
|
|
26
|
+
let p = (s.split('#')[0] ?? '').split('?')[0]?.trim() ?? '';
|
|
27
|
+
if (p.length > 1 && p.endsWith('/')) p = p.slice(0, -1);
|
|
28
|
+
return p || '/';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A flattened, gated catalogue entry — a leaf plus where it lives (for grouping). */
|
|
32
|
+
export interface SearchCatalogueEntry extends NavItem {
|
|
33
|
+
/** Owning domain id (absent for ungrouped items). */
|
|
34
|
+
domainId?: string;
|
|
35
|
+
/** Owning domain label — groups search results + is itself matched. */
|
|
36
|
+
domainLabel?: string;
|
|
37
|
+
/** Owning group id (absent for domain landings + ungrouped items). */
|
|
38
|
+
groupId?: string;
|
|
39
|
+
/** Owning group label. */
|
|
40
|
+
groupLabel?: string;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One link in a breadcrumb trail — a coerced domain or item (always navigable). */
|
|
44
|
+
export interface NavCrumb {
|
|
45
|
+
id: string;
|
|
46
|
+
label: string;
|
|
47
|
+
path: string;
|
|
48
|
+
icon?: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const domainCrumb = (d: NavDomain): NavCrumb => ({
|
|
52
|
+
id: d.id,
|
|
53
|
+
label: d.label,
|
|
54
|
+
path: d.path,
|
|
55
|
+
icon: d.icon,
|
|
56
|
+
});
|
|
57
|
+
const itemCrumb = (it: NavItem): NavCrumb => ({
|
|
58
|
+
id: it.id,
|
|
59
|
+
label: it.label,
|
|
60
|
+
path: it.path,
|
|
61
|
+
icon: it.icon,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
/** The ordered domains visible under `gate` — the side-nav rail backbone. */
|
|
65
|
+
export function deriveSideNavDomains(
|
|
66
|
+
model: NavigationModel,
|
|
67
|
+
gate: GateFn = allowAllGate,
|
|
68
|
+
): NavDomain[] {
|
|
69
|
+
return (model?.domains ?? []).filter((d) => gate(d.gate));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Every visible (gated, non-`deepLinkOnly`) item of `domain`, flattened across its
|
|
74
|
+
* groups — for a simple HubTileGrid. Returns `[]` if the domain itself is gated out.
|
|
75
|
+
*/
|
|
76
|
+
export function deriveDomainItems(domain: NavDomain, gate: GateFn = allowAllGate): NavItem[] {
|
|
77
|
+
if (!gate(domain.gate)) return [];
|
|
78
|
+
return domain.groups.flatMap((g) => g.items).filter((it) => !it.deepLinkOnly && gate(it.gate));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The visible groups of `domain` (each with its visible items; empty groups
|
|
83
|
+
* dropped) — for a *grouped* hub landing that shows section headings. Returns `[]`
|
|
84
|
+
* if the domain is gated out.
|
|
85
|
+
*/
|
|
86
|
+
export function deriveDomainGroups(domain: NavDomain, gate: GateFn = allowAllGate): NavGroup[] {
|
|
87
|
+
if (!gate(domain.gate)) return [];
|
|
88
|
+
return domain.groups
|
|
89
|
+
.map((g) => ({ ...g, items: g.items.filter((it) => !it.deepLinkOnly && gate(it.gate)) }))
|
|
90
|
+
.filter((g) => g.items.length > 0);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Look up a domain by id (gated). */
|
|
94
|
+
export function deriveDomain(
|
|
95
|
+
model: NavigationModel,
|
|
96
|
+
domainId: string,
|
|
97
|
+
gate: GateFn = allowAllGate,
|
|
98
|
+
): NavDomain | undefined {
|
|
99
|
+
const d = (model?.domains ?? []).find((x) => x.id === domainId);
|
|
100
|
+
return d && gate(d.gate) ? d : undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The ancestor chain for `path` — for Breadcrumbs. A domain landing yields
|
|
105
|
+
* `[domain]`; an item yields `[domain, item]`; an ungrouped item yields `[item]`.
|
|
106
|
+
* Matches the raw path first (so a `#tab=…` variant resolves to its own leaf), then
|
|
107
|
+
* falls back to the normalized base path. Returns `[]` when nothing matches.
|
|
108
|
+
*/
|
|
109
|
+
export function deriveBreadcrumbTrail(
|
|
110
|
+
model: NavigationModel,
|
|
111
|
+
path: string,
|
|
112
|
+
gate: GateFn = allowAllGate,
|
|
113
|
+
): NavCrumb[] {
|
|
114
|
+
// Null-safe (see normalizeNavPath): a consumer that hasn't wired the model/path yet
|
|
115
|
+
// must get an empty trail, never a crash — a breadcrumb derivation throwing
|
|
116
|
+
// white-screens the whole page. Degrade gracefully on a missing model or path.
|
|
117
|
+
if (!model || !Array.isArray(model?.domains ?? [])) return [];
|
|
118
|
+
const safe = typeof path === 'string' ? path : '';
|
|
119
|
+
const raw = safe.trim();
|
|
120
|
+
const target = normalizeNavPath(safe);
|
|
121
|
+
|
|
122
|
+
// Two passes so an EXACT path wins over a merely-normalized one: `/tools#tab=curl`
|
|
123
|
+
// must resolve to the Curl tab leaf, not be shadowed by the `/tools` domain landing
|
|
124
|
+
// (which also normalizes to `/tools`). Within a pass, the domain landing is checked
|
|
125
|
+
// before its items so the bare `/tools` route yields `[Tools]`, not a tab leaf.
|
|
126
|
+
const find = (match: (p: string) => boolean): NavCrumb[] | null => {
|
|
127
|
+
for (const domain of model?.domains ?? []) {
|
|
128
|
+
if (!gate(domain.gate)) continue;
|
|
129
|
+
if (match(domain.path)) return [domainCrumb(domain)];
|
|
130
|
+
for (const group of domain.groups) {
|
|
131
|
+
for (const item of group.items) {
|
|
132
|
+
if (gate(item.gate) && match(item.path)) return [domainCrumb(domain), itemCrumb(item)];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
for (const item of model.ungrouped ?? []) {
|
|
137
|
+
if (gate(item.gate) && match(item.path)) return [itemCrumb(item)];
|
|
138
|
+
}
|
|
139
|
+
return null;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
return find((p) => p === raw) ?? find((p) => normalizeNavPath(p) === target) ?? [];
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* The flat list of searchable destinations — every visible (gated,
|
|
147
|
+
* non-`deepLinkOnly`) domain landing + item, each tagged with its domain/group so
|
|
148
|
+
* results can be grouped. Drives HeaderSearch's instant pages layer *and* the
|
|
149
|
+
* command palette; replaces every hand-listed search catalogue (so a feature is
|
|
150
|
+
* findable the moment it's registered).
|
|
151
|
+
*/
|
|
152
|
+
export function deriveSearchCatalogue(
|
|
153
|
+
model: NavigationModel,
|
|
154
|
+
gate: GateFn = allowAllGate,
|
|
155
|
+
): SearchCatalogueEntry[] {
|
|
156
|
+
const out: SearchCatalogueEntry[] = [];
|
|
157
|
+
for (const domain of model?.domains ?? []) {
|
|
158
|
+
if (!gate(domain.gate)) continue;
|
|
159
|
+
out.push({
|
|
160
|
+
id: domain.id,
|
|
161
|
+
path: domain.path,
|
|
162
|
+
label: domain.label,
|
|
163
|
+
icon: domain.icon,
|
|
164
|
+
// A domain landing is a destination like any other, so it carries the same
|
|
165
|
+
// search metadata an item does — without these, an app whose places are
|
|
166
|
+
// leaf-shaped domains could only ever be found by exact label.
|
|
167
|
+
...(domain.description === undefined ? {} : { description: domain.description }),
|
|
168
|
+
...(domain.keywords === undefined ? {} : { keywords: domain.keywords }),
|
|
169
|
+
domainId: domain.id,
|
|
170
|
+
domainLabel: domain.label,
|
|
171
|
+
});
|
|
172
|
+
for (const group of domain.groups) {
|
|
173
|
+
for (const item of group.items) {
|
|
174
|
+
if (item.deepLinkOnly || !gate(item.gate)) continue;
|
|
175
|
+
out.push({
|
|
176
|
+
...item,
|
|
177
|
+
domainId: domain.id,
|
|
178
|
+
domainLabel: domain.label,
|
|
179
|
+
groupId: group.id,
|
|
180
|
+
groupLabel: group.label,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
for (const item of model.ungrouped ?? []) {
|
|
186
|
+
if (item.deepLinkOnly || !gate(item.gate)) continue;
|
|
187
|
+
out.push({ ...item });
|
|
188
|
+
}
|
|
189
|
+
return out;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Every path registered in the model (domain landings + all items + ungrouped),
|
|
194
|
+
* INCLUDING `deepLinkOnly` items — that's the point: a `deepLinkOnly` route is still
|
|
195
|
+
* "registered", so it satisfies coverage. De-duplicated. Consumed by the coverage
|
|
196
|
+
* test ({@link createCoverageTest}).
|
|
197
|
+
*/
|
|
198
|
+
export function deriveAllPaths(model: NavigationModel): string[] {
|
|
199
|
+
const paths = new Set<string>();
|
|
200
|
+
for (const domain of model?.domains ?? []) {
|
|
201
|
+
paths.add(domain.path);
|
|
202
|
+
for (const group of domain.groups) for (const item of group.items) paths.add(item.path);
|
|
203
|
+
}
|
|
204
|
+
for (const item of model.ungrouped ?? []) paths.add(item.path);
|
|
205
|
+
return [...paths];
|
|
206
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// Pure matcher + grouper for the instant client-side "pages" search layer. Ranks a
|
|
2
|
+
// derived catalogue ({@link deriveSearchCatalogue}) against a query and buckets the
|
|
3
|
+
// hits by their owning domain. Kept pure + framework-free so it's unit-testable and
|
|
4
|
+
// shared by HeaderSearch and (task 14) the command palette. The palette upgrades the
|
|
5
|
+
// *ranking* to uFuzzy; this is the dependency-free baseline both can fall back to.
|
|
6
|
+
|
|
7
|
+
import type { SearchCatalogueEntry } from './navigationSelectors';
|
|
8
|
+
|
|
9
|
+
/** A query hit with its computed relevance score (higher = better). */
|
|
10
|
+
export interface CatalogueHit extends SearchCatalogueEntry {
|
|
11
|
+
score: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Hits bucketed under a domain (or under "" for ungrouped), preserving rank order. */
|
|
15
|
+
export interface CatalogueResultGroup {
|
|
16
|
+
/** Domain id, or `''` for ungrouped destinations. */
|
|
17
|
+
key: string;
|
|
18
|
+
/** Domain label, or `'Pages'` for ungrouped. */
|
|
19
|
+
label: string;
|
|
20
|
+
items: CatalogueHit[];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const norm = (s: string) => s.toLowerCase().trim();
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Score one entry against a normalized query. Returns `0` for no match. Ranking
|
|
27
|
+
* favors, in order: exact label, label prefix, label word-boundary, label
|
|
28
|
+
* substring, then keyword / group / domain / path / description hits — so "env"
|
|
29
|
+
* surfaces "Env Sets" above a page that merely mentions environments.
|
|
30
|
+
*/
|
|
31
|
+
function scoreEntry(entry: SearchCatalogueEntry, q: string): number {
|
|
32
|
+
const label = norm(entry.label);
|
|
33
|
+
if (label === q) return 100;
|
|
34
|
+
if (label.startsWith(q)) return 80;
|
|
35
|
+
// word-boundary: a query that starts any word in the label ("col" → "Format **Col**…")
|
|
36
|
+
if (label.split(/[\s/&·-]+/).some((w) => w.startsWith(q))) return 65;
|
|
37
|
+
if (label.includes(q)) return 50;
|
|
38
|
+
|
|
39
|
+
for (const kw of entry.keywords ?? []) {
|
|
40
|
+
const k = norm(kw);
|
|
41
|
+
if (k === q || k.startsWith(q)) return 45;
|
|
42
|
+
if (k.includes(q)) return 35;
|
|
43
|
+
}
|
|
44
|
+
if (entry.groupLabel && norm(entry.groupLabel).includes(q)) return 25;
|
|
45
|
+
if (entry.domainLabel && norm(entry.domainLabel).includes(q)) return 20;
|
|
46
|
+
if (norm(entry.path).includes(q)) return 15;
|
|
47
|
+
if (entry.description && norm(entry.description).includes(q)) return 10;
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Filter + rank a catalogue by `query`. Empty/blank query → `[]` (the instant layer
|
|
53
|
+
* shows nothing until the user types). Stable: equal scores keep catalogue order.
|
|
54
|
+
*/
|
|
55
|
+
export function rankCatalogue(catalogue: SearchCatalogueEntry[], query: string): CatalogueHit[] {
|
|
56
|
+
const q = norm(query);
|
|
57
|
+
if (!q) return [];
|
|
58
|
+
const scored: CatalogueHit[] = [];
|
|
59
|
+
for (const entry of catalogue) {
|
|
60
|
+
const score = scoreEntry(entry, q);
|
|
61
|
+
if (score > 0) scored.push({ ...entry, score });
|
|
62
|
+
}
|
|
63
|
+
// Stable sort by score desc — Array.prototype.sort is stable in modern engines, so
|
|
64
|
+
// equal scores retain catalogue (authoring) order.
|
|
65
|
+
return scored.sort((a, b) => b.score - a.score);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Rank + bucket hits by owning domain (ungrouped under a "Pages" group), capping the
|
|
70
|
+
* total to `limit`. Groups appear in first-hit order; within a group, by rank.
|
|
71
|
+
*/
|
|
72
|
+
export function groupCatalogueHits(
|
|
73
|
+
catalogue: SearchCatalogueEntry[],
|
|
74
|
+
query: string,
|
|
75
|
+
limit = 20,
|
|
76
|
+
): CatalogueResultGroup[] {
|
|
77
|
+
const hits = rankCatalogue(catalogue, query).slice(0, limit);
|
|
78
|
+
const groups = new Map<string, CatalogueResultGroup>();
|
|
79
|
+
for (const hit of hits) {
|
|
80
|
+
const key = hit.domainId ?? '';
|
|
81
|
+
let bucket = groups.get(key);
|
|
82
|
+
if (!bucket) {
|
|
83
|
+
bucket = { key, label: hit.domainLabel ?? 'Pages', items: [] };
|
|
84
|
+
groups.set(key, bucket);
|
|
85
|
+
}
|
|
86
|
+
bucket.items.push(hit);
|
|
87
|
+
}
|
|
88
|
+
return [...groups.values()];
|
|
89
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
createReconnectSchedule,
|
|
4
|
+
RECONNECT_CAP_MS,
|
|
5
|
+
retriesAreJittered,
|
|
6
|
+
} from './reconnectSchedule';
|
|
7
|
+
|
|
8
|
+
describe('reconnect backoff with full jitter (J5-2)', () => {
|
|
9
|
+
it('doubles the ceiling per attempt up to the cap', () => {
|
|
10
|
+
const schedule = createReconnectSchedule({ random: () => 1 });
|
|
11
|
+
expect(schedule.currentCeilingMs()).toBe(500);
|
|
12
|
+
schedule.nextDelayMs();
|
|
13
|
+
expect(schedule.currentCeilingMs()).toBe(1_000);
|
|
14
|
+
schedule.nextDelayMs();
|
|
15
|
+
expect(schedule.currentCeilingMs()).toBe(2_000);
|
|
16
|
+
for (let i = 0; i < 20; i++) schedule.nextDelayMs();
|
|
17
|
+
expect(schedule.currentCeilingMs()).toBe(RECONNECT_CAP_MS);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('draws the delay from [0, ceiling) — full jitter, not a fixed interval', () => {
|
|
21
|
+
const lowest = createReconnectSchedule({ random: () => 0 });
|
|
22
|
+
const highest = createReconnectSchedule({ random: () => 0.999 });
|
|
23
|
+
expect(lowest.nextDelayMs()).toBe(0);
|
|
24
|
+
expect(highest.nextDelayMs()).toBeLessThan(500);
|
|
25
|
+
expect(highest.nextDelayMs()).toBeGreaterThan(500); // second attempt, larger window
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('spreads a synchronized wake across three clients', () => {
|
|
29
|
+
const clients = [0.05, 0.4, 0.95].map((seed) => createReconnectSchedule({ random: () => seed }));
|
|
30
|
+
const delays = clients.map((client) => client.nextDelayMs());
|
|
31
|
+
expect(retriesAreJittered(delays)).toBe(true);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('flags a fixed-interval retry loop as a stampede', () => {
|
|
35
|
+
expect(retriesAreJittered([1000, 1001, 1002])).toBe(false);
|
|
36
|
+
expect(retriesAreJittered([1000])).toBe(true); // a single client cannot stampede
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('resets only after the connection has been stable long enough', () => {
|
|
40
|
+
const schedule = createReconnectSchedule({ random: () => 0.5 });
|
|
41
|
+
schedule.nextDelayMs();
|
|
42
|
+
schedule.nextDelayMs();
|
|
43
|
+
expect(schedule.attempt()).toBe(2);
|
|
44
|
+
schedule.onConnected(10_000);
|
|
45
|
+
expect(schedule.attempt()).toBe(2); // a brief connection is not stability
|
|
46
|
+
schedule.onConnected(60_000);
|
|
47
|
+
expect(schedule.attempt()).toBe(0);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reconnect discipline for every persistent client connection (03 §6.5, J5-2).
|
|
3
|
+
*
|
|
4
|
+
* A server restart or a laptop resume wakes every client at once. With a fixed
|
|
5
|
+
* retry interval they all reconnect in the same millisecond and the app
|
|
6
|
+
* self-DDoSes its own Hono listener — the more clients, the harder the stampede.
|
|
7
|
+
*
|
|
8
|
+
* So: exponential backoff with FULL jitter — `delay = random(0, min(cap, base ·
|
|
9
|
+
* 2^attempt))`. Full jitter (rather than "±10%") is what actually spreads a
|
|
10
|
+
* synchronized wake across the window. A stable connection resets the attempt
|
|
11
|
+
* counter, so a flaky link does not inherit yesterday's backoff.
|
|
12
|
+
*/
|
|
13
|
+
export interface ReconnectScheduleOptions {
|
|
14
|
+
baseMs?: number;
|
|
15
|
+
capMs?: number;
|
|
16
|
+
/** Connected for this long ⇒ the next drop starts from attempt 0 again. */
|
|
17
|
+
stableResetMs?: number;
|
|
18
|
+
/** Injected for tests; defaults to Math.random. */
|
|
19
|
+
random?: () => number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const RECONNECT_BASE_MS = 500;
|
|
23
|
+
export const RECONNECT_CAP_MS = 30_000;
|
|
24
|
+
export const RECONNECT_STABLE_RESET_MS = 60_000;
|
|
25
|
+
|
|
26
|
+
export interface ReconnectSchedule {
|
|
27
|
+
/** Delay for the next attempt, and advance the attempt counter. */
|
|
28
|
+
nextDelayMs(): number;
|
|
29
|
+
/** Call when a connection has been up; resets the counter once it has been stable. */
|
|
30
|
+
onConnected(uptimeMs: number): void;
|
|
31
|
+
/**
|
|
32
|
+
* Drop the accumulated backoff outright — for a DELIBERATE re-open (a
|
|
33
|
+
* credential change), which is not a failure and must not inherit the penalty
|
|
34
|
+
* the last outage earned. Never call this on a retry path: that is exactly how
|
|
35
|
+
* a fixed interval creeps back in.
|
|
36
|
+
*/
|
|
37
|
+
reset(): void;
|
|
38
|
+
attempt(): number;
|
|
39
|
+
/** The deterministic ceiling for the current attempt — what the jitter is drawn from. */
|
|
40
|
+
currentCeilingMs(): number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createReconnectSchedule(options: ReconnectScheduleOptions = {}): ReconnectSchedule {
|
|
44
|
+
const base = options.baseMs ?? RECONNECT_BASE_MS;
|
|
45
|
+
const cap = options.capMs ?? RECONNECT_CAP_MS;
|
|
46
|
+
const stableReset = options.stableResetMs ?? RECONNECT_STABLE_RESET_MS;
|
|
47
|
+
const random = options.random ?? Math.random;
|
|
48
|
+
let attempt = 0;
|
|
49
|
+
|
|
50
|
+
const ceiling = (n: number): number => Math.min(cap, base * 2 ** n);
|
|
51
|
+
|
|
52
|
+
return {
|
|
53
|
+
nextDelayMs() {
|
|
54
|
+
const delay = random() * ceiling(attempt);
|
|
55
|
+
attempt += 1;
|
|
56
|
+
return delay;
|
|
57
|
+
},
|
|
58
|
+
onConnected(uptimeMs) {
|
|
59
|
+
if (uptimeMs >= stableReset) attempt = 0;
|
|
60
|
+
},
|
|
61
|
+
reset() {
|
|
62
|
+
attempt = 0;
|
|
63
|
+
},
|
|
64
|
+
attempt: () => attempt,
|
|
65
|
+
currentCeilingMs: () => ceiling(attempt),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Did a set of observed retry timestamps actually spread? Used by V7.11: clients
|
|
71
|
+
* clustered inside one 50ms bucket beyond chance means someone reintroduced a
|
|
72
|
+
* fixed interval.
|
|
73
|
+
*/
|
|
74
|
+
export function retriesAreJittered(timestampsMs: readonly number[], bucketMs = 50): boolean {
|
|
75
|
+
if (timestampsMs.length < 2) return true;
|
|
76
|
+
const buckets = new Set(timestampsMs.map((t) => Math.floor(t / bucketMs)));
|
|
77
|
+
return buckets.size > 1;
|
|
78
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// A tiny global registry of the app's keyboard shortcuts — the single source of truth
|
|
2
|
+
// that BOTH the keybinding layer (`useHotkeys`) and the command palette read from. An
|
|
3
|
+
// app registers a shortcut once; the palette can then list "Board → ⌥B" in its
|
|
4
|
+
// cold-state reference without the binding and its documentation drifting apart.
|
|
5
|
+
//
|
|
6
|
+
// Register in a mount effect (never at module load) so the palette's list reflects the
|
|
7
|
+
// shortcuts actually wired up for the current screen; the registry notifies subscribers
|
|
8
|
+
// on every change so a mounted palette stays live as shortcuts come and go.
|
|
9
|
+
|
|
10
|
+
/** A registered keyboard shortcut — its binding plus how it reads in the palette. */
|
|
11
|
+
export interface ShortcutEntry {
|
|
12
|
+
/** Short name of the thing it acts on (e.g. `'Board'`). */
|
|
13
|
+
label: string;
|
|
14
|
+
/** Binding in tinykeys format (`'$mod+k'`, `'alt+b'`) — also the registry key. */
|
|
15
|
+
keys: string;
|
|
16
|
+
/** What the shortcut does, shown as the palette row's description (e.g. `'Open the board'`). */
|
|
17
|
+
action: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
type Listener = () => void;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Keyed by `keys`, so registering the same binding twice replaces it (last write wins
|
|
24
|
+
* — no duplicate rows) and a feature can re-register on every mount idempotently.
|
|
25
|
+
*/
|
|
26
|
+
export class ShortcutRegistry {
|
|
27
|
+
private entries = new Map<string, ShortcutEntry>();
|
|
28
|
+
private listeners = new Set<Listener>();
|
|
29
|
+
// A frozen snapshot, rebuilt only on mutation, so `getAll()` is referentially stable
|
|
30
|
+
// between changes — required for `useSyncExternalStore` to not loop.
|
|
31
|
+
private snapshot: ShortcutEntry[] = [];
|
|
32
|
+
|
|
33
|
+
/** Add or replace the shortcut for `entry.keys`. */
|
|
34
|
+
register(entry: ShortcutEntry): void {
|
|
35
|
+
this.entries.set(entry.keys, entry);
|
|
36
|
+
this.commit();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Remove the shortcut bound to `keys` (no-op if absent). */
|
|
40
|
+
unregister(keys: string): void {
|
|
41
|
+
if (this.entries.delete(keys)) this.commit();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Every registered shortcut, in insertion order. Stable until the next mutation. */
|
|
45
|
+
getAll(): ShortcutEntry[] {
|
|
46
|
+
return this.snapshot;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Subscribe to registry changes (returns an unsubscribe). Powers the live palette list. */
|
|
50
|
+
subscribe(listener: Listener): () => void {
|
|
51
|
+
this.listeners.add(listener);
|
|
52
|
+
return () => this.listeners.delete(listener);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Drop every shortcut (mainly for tests / teardown). */
|
|
56
|
+
clear(): void {
|
|
57
|
+
if (this.entries.size === 0) return;
|
|
58
|
+
this.entries.clear();
|
|
59
|
+
this.commit();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private commit(): void {
|
|
63
|
+
this.snapshot = [...this.entries.values()];
|
|
64
|
+
for (const listener of this.listeners) listener();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The app-wide shortcut registry singleton. */
|
|
69
|
+
export const shortcutRegistry = new ShortcutRegistry();
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
// The command-palette ranking layer — uFuzzy over the nav catalogue. This is the
|
|
2
|
+
// *upgrade* HeaderSearch's `rankCatalogue` (a substring scorer) cannot match: uFuzzy
|
|
3
|
+
// scores by contiguous-match proximity, so abbreviated/typeahead input ranks the
|
|
4
|
+
// right page first. Pure + synchronous + React-free (uFuzzy is regex-based, no DOM),
|
|
5
|
+
// so it runs in the client, a server test, or the kitchen-sink demo identically.
|
|
6
|
+
//
|
|
7
|
+
// Why `intraMode: 1` (SingleError, contiguous) over `0` (MultiInsert, scattered):
|
|
8
|
+
// scattered mode treats every needle char as "appears somewhere later", which is far
|
|
9
|
+
// too permissive for short nav labels (every query matches half the menu). Contiguous
|
|
10
|
+
// mode keeps results tight — the documented tradeoff is lower typo tolerance, which is
|
|
11
|
+
// the right call for a fixed, known set of navigation labels.
|
|
12
|
+
//
|
|
13
|
+
// The catch: contiguous mode means a bare initialism ("qb") would NOT reach "Query
|
|
14
|
+
// Builder" (the chars aren't adjacent). So each haystack string is augmented with the
|
|
15
|
+
// label's **initials** as an explicit token ("Query Builder" → "query builder qb") —
|
|
16
|
+
// now "qb" matches that token contiguously, giving free initialism support without
|
|
17
|
+
// loosening the matcher. Keywords (synonyms / old names / abbreviations) are appended
|
|
18
|
+
// too, so "mandelbrot" still finds "Fractal".
|
|
19
|
+
|
|
20
|
+
import uFuzzy from '@leeoniya/ufuzzy';
|
|
21
|
+
import type { NavItem } from '../navigation';
|
|
22
|
+
|
|
23
|
+
// One singleton instance — uFuzzy compiles regexes from its options on construction,
|
|
24
|
+
// so re-creating it per query would be wasteful. `intraMode: 1` = contiguous match.
|
|
25
|
+
const uf = new uFuzzy({ intraMode: 1 });
|
|
26
|
+
|
|
27
|
+
/** Split a label into words on whitespace and the separators nav labels actually use. */
|
|
28
|
+
const WORD_SPLIT = /[\s/&·,_-]+/;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The initials of a multi-word label (`'Query Builder'` → `'qb'`). Single-word labels
|
|
32
|
+
* return `''` (their prefix already matches via the label itself). Powers initialism
|
|
33
|
+
* matching ("sh" → "System Health") under the strict contiguous matcher.
|
|
34
|
+
*/
|
|
35
|
+
export function searchInitials(label: string): string {
|
|
36
|
+
const words = label.split(WORD_SPLIT).filter(Boolean);
|
|
37
|
+
if (words.length < 2) return '';
|
|
38
|
+
// Lower-cased for a predictable token; uFuzzy matches case-insensitively anyway.
|
|
39
|
+
return words.map((w) => (w[0] ?? '').toLowerCase()).join('');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Build the per-item haystack string: label + its initials + any keywords, space-joined. */
|
|
43
|
+
function haystackFor(item: NavItem): string {
|
|
44
|
+
return [item.label, searchInitials(item.label), ...(item.keywords ?? [])]
|
|
45
|
+
.filter(Boolean)
|
|
46
|
+
.join(' ');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Rank `items` against `query`, best match first; non-matches dropped. Generic over the
|
|
51
|
+
* item type (so callers can pass plain {@link NavItem}s *or* richer catalogue entries
|
|
52
|
+
* that carry domain/group tags for grouping — the extra fields ride straight through).
|
|
53
|
+
* An empty/blank query returns `[]` (the palette shows its cold state instead).
|
|
54
|
+
*
|
|
55
|
+
* Returns *all* matches in rank order; callers cap the count for display (the palette
|
|
56
|
+
* shows 30). uFuzzy's contiguous scorer means "frac" ranks "Fractal" above "Format
|
|
57
|
+
* Lab", and the initials augmentation means "qb" surfaces "Query Builder".
|
|
58
|
+
*/
|
|
59
|
+
export function fuzzyRankItems<T extends NavItem>(query: string, items: T[]): T[] {
|
|
60
|
+
const q = query.trim();
|
|
61
|
+
if (!q || items.length === 0) return [];
|
|
62
|
+
|
|
63
|
+
const haystack = items.map(haystackFor);
|
|
64
|
+
const [idxs, , order] = uf.search(haystack, q);
|
|
65
|
+
// `idxs` is null when nothing matched and an empty array when filtered to zero; both
|
|
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;
|
|
71
|
+
|
|
72
|
+
const out: T[] = [];
|
|
73
|
+
for (const haystackIdx of ranked) {
|
|
74
|
+
const item = items[haystackIdx];
|
|
75
|
+
if (item) out.push(item);
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|