@bysivin/jumpbar 0.0.0-stage → 0.2.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 +21 -0
- package/README.md +241 -2
- package/dist/core.js +148 -0
- package/dist/core.js.map +1 -0
- package/dist/index.js +583 -0
- package/dist/index.js.map +1 -0
- package/package.json +70 -4
- package/tailwind.cjs +56 -0
- package/theme.css +33 -0
- package/types/core.d.ts +76 -0
- package/types/index.d.ts +77 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sivin Varghese
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,242 @@
|
|
|
1
|
-
#
|
|
1
|
+
# jumpbar
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A lightweight command palette for Vue 3. The ranking core is plain JavaScript
|
|
4
|
+
with no dependencies (about 1 KB gzipped); the shell is a native `<dialog>`
|
|
5
|
+
rendered with Tailwind utilities (about 5 KB gzipped with the core).
|
|
6
|
+
|
|
7
|
+
- Fuzzy matching with accent folding, sections, nested pages and prefixes
|
|
8
|
+
- Frecency so the things you use rise to the top
|
|
9
|
+
- Sync `commands` sources ranked on every keystroke, async `search` sources
|
|
10
|
+
with per-source debounce, minimum query length and `AbortSignal`
|
|
11
|
+
- Keyboard and pointer handling, open/close motion, skeleton rows while
|
|
12
|
+
remote results load, RTL and IME aware
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
pnpm add @bysivin/jumpbar
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The package ships compiled ES modules with TypeScript declarations; Vue
|
|
21
|
+
stays external. `vue` 3.4 or newer is needed for the shell.
|
|
22
|
+
|
|
23
|
+
### Any framework
|
|
24
|
+
|
|
25
|
+
The ranking core has no framework dependency. React, Svelte, Solid or plain
|
|
26
|
+
JavaScript projects import it from `@bysivin/jumpbar/core` and render their
|
|
27
|
+
own list:
|
|
28
|
+
|
|
29
|
+
```js
|
|
30
|
+
import { rank, groupBySection, createFrecency } from '@bysivin/jumpbar/core';
|
|
31
|
+
|
|
32
|
+
const ranked = rank(items, { text: query, scopes: ['conversation'] });
|
|
33
|
+
const sections = groupBySection(ranked);
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The `CommandBar` shell and `useCommandBar` state are Vue 3.
|
|
37
|
+
|
|
38
|
+
## Styling
|
|
39
|
+
|
|
40
|
+
The shell is built from Tailwind utilities and reads every colour from a
|
|
41
|
+
small set of semantic tokens, so it adapts to any design system.
|
|
42
|
+
|
|
43
|
+
1. Add the preset and scan the package so its classes are generated:
|
|
44
|
+
|
|
45
|
+
```js
|
|
46
|
+
// tailwind.config.js
|
|
47
|
+
module.exports = {
|
|
48
|
+
presets: [require('@bysivin/jumpbar/tailwind')],
|
|
49
|
+
content: ['./node_modules/@bysivin/jumpbar/dist/*.js'],
|
|
50
|
+
};
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
The preset registers the `jumpbar-*` colours as CSS variables and the
|
|
54
|
+
open and close keyframes.
|
|
55
|
+
|
|
56
|
+
2. Theme it. Either import the neutral defaults and override what you like
|
|
57
|
+
(dark values apply under a `dark` class on the root element):
|
|
58
|
+
|
|
59
|
+
```js
|
|
60
|
+
import '@bysivin/jumpbar/theme.css';
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
```css
|
|
64
|
+
:root {
|
|
65
|
+
--jumpbar-primary: #2781f6;
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
or skip the import and map the tokens onto your existing Tailwind colours,
|
|
70
|
+
which also carries your dark mode along:
|
|
71
|
+
|
|
72
|
+
```js
|
|
73
|
+
theme: {
|
|
74
|
+
extend: {
|
|
75
|
+
colors: {
|
|
76
|
+
jumpbar: {
|
|
77
|
+
background: colors.surface,
|
|
78
|
+
foreground: colors.text,
|
|
79
|
+
primary: colors.brand,
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
| Token | Used for |
|
|
87
|
+
| ------------------- | ------------------------------------------------- |
|
|
88
|
+
| `background` | Panel |
|
|
89
|
+
| `foreground` | Titles and input text |
|
|
90
|
+
| `muted-foreground` | Subtitles, section labels, breadcrumbs, badges |
|
|
91
|
+
| `subtle-foreground` | Placeholder, icons, hints, chevrons |
|
|
92
|
+
| `muted` | Active row, chips, avatar fallback, skeleton rows |
|
|
93
|
+
| `subtle` | Key caps, lighter skeleton bar |
|
|
94
|
+
| `border` | Dividers and key cap outline |
|
|
95
|
+
| `ring` | Panel outline |
|
|
96
|
+
| `primary` | Active row marker, page chip text |
|
|
97
|
+
| `primary-soft` | Page chip background |
|
|
98
|
+
| `overlay` | Backdrop |
|
|
99
|
+
| `online` `busy` `offline` | Presence dot |
|
|
100
|
+
|
|
101
|
+
3. Provide the icon classes `i-lucide-search`, `i-lucide-loader-circle` and
|
|
102
|
+
`i-lucide-chevron-right` (for example through `@egoist/tailwindcss-icons`
|
|
103
|
+
with the Lucide set). Items bring their own icon class.
|
|
104
|
+
|
|
105
|
+
## Usage
|
|
106
|
+
|
|
107
|
+
```vue
|
|
108
|
+
<script setup>
|
|
109
|
+
import { CommandBar, createFrecency, useCommandBar } from '@bysivin/jumpbar';
|
|
110
|
+
|
|
111
|
+
const bar = useCommandBar();
|
|
112
|
+
|
|
113
|
+
bar.configure({
|
|
114
|
+
prefixes: ['@', '#'],
|
|
115
|
+
context: () => ({ scopes: ['conversation'] }),
|
|
116
|
+
frecency: createFrecency({ storage: localStorage, key: 'palette-usage' }),
|
|
117
|
+
onSelect: item => track(item),
|
|
118
|
+
onClose: () => {},
|
|
119
|
+
onError: (error, source) => report(error),
|
|
120
|
+
limit: 100,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
bar.register({
|
|
124
|
+
id: 'navigation',
|
|
125
|
+
commands: () => [
|
|
126
|
+
{
|
|
127
|
+
id: 'inbox',
|
|
128
|
+
title: 'Go to inbox',
|
|
129
|
+
section: 'Navigation',
|
|
130
|
+
icon: 'i-lucide-inbox',
|
|
131
|
+
shortcut: ['⌥', 'I'],
|
|
132
|
+
run: () => router.push('/inbox'),
|
|
133
|
+
},
|
|
134
|
+
],
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
bar.register({
|
|
138
|
+
id: 'contacts',
|
|
139
|
+
minQuery: 3,
|
|
140
|
+
debounce: 300,
|
|
141
|
+
when: ({ page }) => !page,
|
|
142
|
+
search: async ({ text, signal }) => {
|
|
143
|
+
const contacts = await api.search(text, { signal });
|
|
144
|
+
return contacts.map(contact => ({
|
|
145
|
+
id: `contact-${contact.id}`,
|
|
146
|
+
title: contact.name,
|
|
147
|
+
subtitle: contact.email,
|
|
148
|
+
section: 'Contacts',
|
|
149
|
+
avatar: { name: contact.name, src: contact.thumbnail },
|
|
150
|
+
run: () => router.push(`/contacts/${contact.id}`),
|
|
151
|
+
}));
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
</script>
|
|
155
|
+
|
|
156
|
+
<template>
|
|
157
|
+
<CommandBar
|
|
158
|
+
placeholder="Search or jump to"
|
|
159
|
+
empty-text="No matches"
|
|
160
|
+
:hints="[
|
|
161
|
+
{ keys: ['↑', '↓'], label: 'navigate' },
|
|
162
|
+
{ keys: ['↵'], label: 'select' },
|
|
163
|
+
{ keys: ['esc'], label: 'close' },
|
|
164
|
+
]"
|
|
165
|
+
/>
|
|
166
|
+
</template>
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Bind the global shortcut yourself and call `bar.toggle()`. `bar.open({ page,
|
|
170
|
+
query })` opens straight into a page, for example a snooze picker.
|
|
171
|
+
|
|
172
|
+
`CommandBar` props: `placeholder`, `emptyText`, `scopeLabel` (a chip before the
|
|
173
|
+
input, for example the selected record), `hints` and `dir` (`ltr` or `rtl`).
|
|
174
|
+
|
|
175
|
+
## Items
|
|
176
|
+
|
|
177
|
+
Sources return plain objects:
|
|
178
|
+
|
|
179
|
+
| Field | Meaning |
|
|
180
|
+
| ---------------- | ------------------------------------------------------------------------ |
|
|
181
|
+
| `id` | Unique across all sources. Frecency and dedupe key on it. |
|
|
182
|
+
| `title` | Row label. Children are matched through `parentTitle title` too. |
|
|
183
|
+
| `subtitle` | Muted text after the title. Truncates before the title does. |
|
|
184
|
+
| `section` | Group header. Groups keep the order of their first item. |
|
|
185
|
+
| `icon` | CSS class rendered as the leading glyph. |
|
|
186
|
+
| `avatar` | `{ name, src, status }` instead of the icon, initials as fallback; `status` is `online`, `busy` or `offline`. |
|
|
187
|
+
| `badge` | Short trailing text. |
|
|
188
|
+
| `shortcut` | Trailing key labels, for example `['⌥', 'E']`. |
|
|
189
|
+
| `keywords` | Extra words the matcher sees (word-start matches only). |
|
|
190
|
+
| `scopes` | Ranks first when one matches `context.scopes`. |
|
|
191
|
+
| `prefix` | Typing this character first lists only items carrying it. |
|
|
192
|
+
| `weight` | Static boost between 0 and 1. |
|
|
193
|
+
| `place` | `first` or `last`: sorts around every other match, in declared order. |
|
|
194
|
+
| `searchFromRoot` | `false` keeps a child out of root searches (prefix and page only). |
|
|
195
|
+
| `page` | Selecting opens a page listing the items whose `parent` is its id. |
|
|
196
|
+
| `placeholder` | Input placeholder while that page is open. |
|
|
197
|
+
| `parent` | Id of the page this item lives under. |
|
|
198
|
+
| `run` | Called with the context as the bar starts closing; the dialog leaves the top layer about 150 ms later. Rows without it are inert. |
|
|
199
|
+
|
|
200
|
+
## Sources
|
|
201
|
+
|
|
202
|
+
```js
|
|
203
|
+
bar.register({
|
|
204
|
+
id: 'conversations',
|
|
205
|
+
minQuery: 2,
|
|
206
|
+
debounce: 300,
|
|
207
|
+
when: ({ page }) => !page,
|
|
208
|
+
commands: request => [],
|
|
209
|
+
search: async request => [],
|
|
210
|
+
});
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
`commands` runs on every keystroke and is ranked. `search` runs after the
|
|
214
|
+
debounce with an `AbortSignal`; its results keep their own order, append after
|
|
215
|
+
the ranked commands at the root, and replace the static children on a page.
|
|
216
|
+
The request carries `query`, `text`, `prefix`, `page`, `context` and, for
|
|
217
|
+
`search`, `matches` (how many commands matched locally) and `signal`.
|
|
218
|
+
`register` returns a function that removes the source again. A searcher that
|
|
219
|
+
throws has its rows dropped and `onError(error, source)` called; the default
|
|
220
|
+
rethrows. Text queries show at most `limit` ranked commands (default 100).
|
|
221
|
+
|
|
222
|
+
## Ranking
|
|
223
|
+
|
|
224
|
+
Titles match by exact, prefix, word start, every-word-starts-a-word, then
|
|
225
|
+
scattered characters. Subtitles and keywords only match on word starts, so a
|
|
226
|
+
long description never floods the list. Items in `context.scopes` get a boost,
|
|
227
|
+
recently used items another, `weight` a small static one. `place: 'first'`
|
|
228
|
+
and `place: 'last'` sit outside the scoring entirely.
|
|
229
|
+
|
|
230
|
+
## Keyboard
|
|
231
|
+
|
|
232
|
+
`↑` `↓` move, `Enter` selects, `Esc` closes, `Backspace` on an empty query goes
|
|
233
|
+
back one page. Hover only changes the selection when the pointer really moves,
|
|
234
|
+
so arrow keys keep working under a resting cursor.
|
|
235
|
+
|
|
236
|
+
## Development
|
|
237
|
+
|
|
238
|
+
```sh
|
|
239
|
+
pnpm install
|
|
240
|
+
pnpm test
|
|
241
|
+
pnpm build
|
|
242
|
+
```
|
package/dist/core.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
const COMBINING_MARKS = /[\u0300-\u036f]/g;
|
|
2
|
+
const EXACT = 1;
|
|
3
|
+
const PREFIX = 0.9;
|
|
4
|
+
const WORD_START = 0.8;
|
|
5
|
+
const ALL_WORDS = 0.7;
|
|
6
|
+
const SUBSEQUENCE_MAX = 0.6;
|
|
7
|
+
const SUBSEQUENCE_MIN = 0.2;
|
|
8
|
+
const NORMALIZED_CACHE_LIMIT = 5e3;
|
|
9
|
+
const normalized = /* @__PURE__ */ new Map();
|
|
10
|
+
const normalize = (value) => {
|
|
11
|
+
const raw = String(value ?? "");
|
|
12
|
+
const cached = normalized.get(raw);
|
|
13
|
+
if (cached !== void 0) return cached;
|
|
14
|
+
if (normalized.size >= NORMALIZED_CACHE_LIMIT) normalized.clear();
|
|
15
|
+
const result = raw.normalize("NFD").replace(COMBINING_MARKS, "").toLowerCase().trim();
|
|
16
|
+
normalized.set(raw, result);
|
|
17
|
+
return result;
|
|
18
|
+
};
|
|
19
|
+
const tightestSpan = (query, text) => {
|
|
20
|
+
let best = Infinity;
|
|
21
|
+
for (let start = text.indexOf(query[0]); start !== -1; start = text.indexOf(query[0], start + 1)) {
|
|
22
|
+
let cursor = start;
|
|
23
|
+
let matched = true;
|
|
24
|
+
for (let i = 1; i < query.length; i += 1) {
|
|
25
|
+
cursor = text.indexOf(query[i], cursor + 1);
|
|
26
|
+
if (cursor === -1) {
|
|
27
|
+
matched = false;
|
|
28
|
+
break;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (matched) best = Math.min(best, cursor - start + 1);
|
|
32
|
+
}
|
|
33
|
+
return best;
|
|
34
|
+
};
|
|
35
|
+
const fuzzyScore = (query, text, { subsequence = true } = {}) => {
|
|
36
|
+
const needle = normalize(query);
|
|
37
|
+
if (!needle) return EXACT;
|
|
38
|
+
const haystack = normalize(text);
|
|
39
|
+
if (haystack === needle) return EXACT;
|
|
40
|
+
if (haystack.startsWith(needle)) return PREFIX;
|
|
41
|
+
const words = haystack.split(/\s+/);
|
|
42
|
+
if (words.some((word) => word.startsWith(needle))) return WORD_START;
|
|
43
|
+
const needleWords = needle.split(/\s+/);
|
|
44
|
+
if (needleWords.every((part) => words.some((word) => word.startsWith(part)))) {
|
|
45
|
+
return ALL_WORDS;
|
|
46
|
+
}
|
|
47
|
+
if (!subsequence) return 0;
|
|
48
|
+
const compact = needle.replace(/\s+/g, "");
|
|
49
|
+
const span = tightestSpan(compact, haystack);
|
|
50
|
+
if (span === Infinity) return 0;
|
|
51
|
+
return SUBSEQUENCE_MIN + (SUBSEQUENCE_MAX - SUBSEQUENCE_MIN) * (compact.length / span);
|
|
52
|
+
};
|
|
53
|
+
const parseQuery = (input, prefixes = []) => {
|
|
54
|
+
const trimmed = String(input ?? "").trim();
|
|
55
|
+
const prefix = prefixes.includes(trimmed[0]) ? trimmed[0] : null;
|
|
56
|
+
const text = prefix ? trimmed.slice(1).trim() : trimmed;
|
|
57
|
+
return { prefix, text };
|
|
58
|
+
};
|
|
59
|
+
const DAY_MS = 24 * 60 * 60 * 1e3;
|
|
60
|
+
const HALF_LIFE_DAYS = 7;
|
|
61
|
+
const USES_FOR_FULL_SCORE = 3;
|
|
62
|
+
const MAX_IDLE_DAYS = 30;
|
|
63
|
+
const load = (storage, key) => {
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(storage.getItem(key)) ?? {};
|
|
66
|
+
} catch {
|
|
67
|
+
return {};
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const createFrecency = ({
|
|
71
|
+
storage,
|
|
72
|
+
key,
|
|
73
|
+
now = Date.now,
|
|
74
|
+
limit = 10
|
|
75
|
+
}) => {
|
|
76
|
+
const uses = load(storage, key);
|
|
77
|
+
const record = (id) => {
|
|
78
|
+
const at = now();
|
|
79
|
+
uses[id] = [...uses[id] ?? [], at].slice(-limit);
|
|
80
|
+
Object.keys(uses).forEach((other) => {
|
|
81
|
+
if (at - uses[other].at(-1) > MAX_IDLE_DAYS * DAY_MS) delete uses[other];
|
|
82
|
+
});
|
|
83
|
+
storage.setItem(key, JSON.stringify(uses));
|
|
84
|
+
};
|
|
85
|
+
const score = (id) => {
|
|
86
|
+
const total = (uses[id] ?? []).reduce(
|
|
87
|
+
(sum, usedAt) => sum + 0.5 ** ((now() - usedAt) / DAY_MS / HALF_LIFE_DAYS),
|
|
88
|
+
0
|
|
89
|
+
);
|
|
90
|
+
return Math.min(1, total / USES_FOR_FULL_SCORE);
|
|
91
|
+
};
|
|
92
|
+
return { record, score };
|
|
93
|
+
};
|
|
94
|
+
const SCOPE_BOOST = 0.3;
|
|
95
|
+
const FRECENCY_BOOST = 0.2;
|
|
96
|
+
const WEIGHT_BOOST = 0.1;
|
|
97
|
+
const NO_FRECENCY = { score: () => 0 };
|
|
98
|
+
const TIERS = { first: 0, last: 2 };
|
|
99
|
+
const tierOf = (item) => TIERS[item.place] ?? 1;
|
|
100
|
+
const visibleItems = (items, { text, prefix, page }) => {
|
|
101
|
+
if (page) return items.filter((item) => item.parent === page);
|
|
102
|
+
if (prefix) return items.filter((item) => item.prefix === prefix);
|
|
103
|
+
if (!text) return items.filter((item) => !item.parent);
|
|
104
|
+
return items.filter((item) => item.searchFromRoot !== false);
|
|
105
|
+
};
|
|
106
|
+
const detailText = (item) => [item.subtitle, ...item.keywords ?? []].filter(Boolean).join(" ");
|
|
107
|
+
const matchScore = (text, item, parentTitle) => Math.max(
|
|
108
|
+
fuzzyScore(text, item.title),
|
|
109
|
+
parentTitle ? fuzzyScore(text, `${parentTitle} ${item.title}`) : 0,
|
|
110
|
+
fuzzyScore(text, detailText(item), { subsequence: false })
|
|
111
|
+
);
|
|
112
|
+
const rank = (items, { text = "", prefix = null, page = null, scopes = [], frecency = NO_FRECENCY }) => {
|
|
113
|
+
const titles = new Map(items.map((item) => [item.id, item.title]));
|
|
114
|
+
return visibleItems(items, { text, prefix, page }).map((item) => {
|
|
115
|
+
var _a;
|
|
116
|
+
const parentTitle = item.parent && !page ? titles.get(item.parent) : void 0;
|
|
117
|
+
const match = text ? matchScore(text, item, parentTitle) : 1;
|
|
118
|
+
const inScope = ((_a = item.scopes) == null ? void 0 : _a.some((scope) => scopes.includes(scope))) ? 1 : 0;
|
|
119
|
+
const score = match + SCOPE_BOOST * inScope + FRECENCY_BOOST * frecency.score(item.id) + WEIGHT_BOOST * (item.weight ?? 0);
|
|
120
|
+
return {
|
|
121
|
+
item: parentTitle ? { ...item, parentTitle } : item,
|
|
122
|
+
match,
|
|
123
|
+
score
|
|
124
|
+
};
|
|
125
|
+
}).filter((entry) => entry.match > 0).sort((a, b) => {
|
|
126
|
+
const tier = tierOf(a.item) - tierOf(b.item);
|
|
127
|
+
if (tier) return tier;
|
|
128
|
+
return a.item.place ? 0 : b.score - a.score;
|
|
129
|
+
}).map((entry) => entry.item);
|
|
130
|
+
};
|
|
131
|
+
const groupBySection = (items) => {
|
|
132
|
+
const groups = /* @__PURE__ */ new Map();
|
|
133
|
+
items.forEach((item) => {
|
|
134
|
+
const section = item.section ?? null;
|
|
135
|
+
if (!groups.has(section)) groups.set(section, []);
|
|
136
|
+
groups.get(section).push(item);
|
|
137
|
+
});
|
|
138
|
+
return [...groups].map(([section, members]) => ({ section, items: members }));
|
|
139
|
+
};
|
|
140
|
+
export {
|
|
141
|
+
createFrecency,
|
|
142
|
+
fuzzyScore,
|
|
143
|
+
groupBySection,
|
|
144
|
+
normalize,
|
|
145
|
+
parseQuery,
|
|
146
|
+
rank
|
|
147
|
+
};
|
|
148
|
+
//# sourceMappingURL=core.js.map
|
package/dist/core.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"core.js","sources":["../src/core/match.js","../src/core/parse.js","../src/core/frecency.js","../src/core/rank.js"],"sourcesContent":["const COMBINING_MARKS = /[\\u0300-\\u036f]/g;\n\nconst EXACT = 1;\nconst PREFIX = 0.9;\nconst WORD_START = 0.8;\nconst ALL_WORDS = 0.7;\nconst SUBSEQUENCE_MAX = 0.6;\nconst SUBSEQUENCE_MIN = 0.2;\n\nconst NORMALIZED_CACHE_LIMIT = 5000;\nconst normalized = new Map();\n\nexport const normalize = value => {\n const raw = String(value ?? '');\n const cached = normalized.get(raw);\n if (cached !== undefined) return cached;\n if (normalized.size >= NORMALIZED_CACHE_LIMIT) normalized.clear();\n const result = raw\n .normalize('NFD')\n .replace(COMBINING_MARKS, '')\n .toLowerCase()\n .trim();\n normalized.set(raw, result);\n return result;\n};\n\nconst tightestSpan = (query, text) => {\n let best = Infinity;\n for (\n let start = text.indexOf(query[0]);\n start !== -1;\n start = text.indexOf(query[0], start + 1)\n ) {\n let cursor = start;\n let matched = true;\n for (let i = 1; i < query.length; i += 1) {\n cursor = text.indexOf(query[i], cursor + 1);\n if (cursor === -1) {\n matched = false;\n break;\n }\n }\n if (matched) best = Math.min(best, cursor - start + 1);\n }\n return best;\n};\n\nexport const fuzzyScore = (query, text, { subsequence = true } = {}) => {\n const needle = normalize(query);\n if (!needle) return EXACT;\n const haystack = normalize(text);\n if (haystack === needle) return EXACT;\n if (haystack.startsWith(needle)) return PREFIX;\n\n const words = haystack.split(/\\s+/);\n if (words.some(word => word.startsWith(needle))) return WORD_START;\n\n const needleWords = needle.split(/\\s+/);\n if (needleWords.every(part => words.some(word => word.startsWith(part)))) {\n return ALL_WORDS;\n }\n\n if (!subsequence) return 0;\n const compact = needle.replace(/\\s+/g, '');\n const span = tightestSpan(compact, haystack);\n if (span === Infinity) return 0;\n return (\n SUBSEQUENCE_MIN +\n (SUBSEQUENCE_MAX - SUBSEQUENCE_MIN) * (compact.length / span)\n );\n};\n","export const parseQuery = (input, prefixes = []) => {\n const trimmed = String(input ?? '').trim();\n const prefix = prefixes.includes(trimmed[0]) ? trimmed[0] : null;\n const text = prefix ? trimmed.slice(1).trim() : trimmed;\n return { prefix, text };\n};\n","const DAY_MS = 24 * 60 * 60 * 1000;\nconst HALF_LIFE_DAYS = 7;\nconst USES_FOR_FULL_SCORE = 3;\nconst MAX_IDLE_DAYS = 30;\n\nconst load = (storage, key) => {\n try {\n return JSON.parse(storage.getItem(key)) ?? {};\n } catch {\n return {};\n }\n};\n\nexport const createFrecency = ({\n storage,\n key,\n now = Date.now,\n limit = 10,\n}) => {\n const uses = load(storage, key);\n\n const record = id => {\n const at = now();\n uses[id] = [...(uses[id] ?? []), at].slice(-limit);\n Object.keys(uses).forEach(other => {\n if (at - uses[other].at(-1) > MAX_IDLE_DAYS * DAY_MS) delete uses[other];\n });\n storage.setItem(key, JSON.stringify(uses));\n };\n\n const score = id => {\n const total = (uses[id] ?? []).reduce(\n (sum, usedAt) =>\n sum + 0.5 ** ((now() - usedAt) / DAY_MS / HALF_LIFE_DAYS),\n 0\n );\n return Math.min(1, total / USES_FOR_FULL_SCORE);\n };\n\n return { record, score };\n};\n","import { fuzzyScore } from './match';\n\nconst SCOPE_BOOST = 0.3;\nconst FRECENCY_BOOST = 0.2;\nconst WEIGHT_BOOST = 0.1;\n\nconst NO_FRECENCY = { score: () => 0 };\nconst TIERS = { first: 0, last: 2 };\nconst tierOf = item => TIERS[item.place] ?? 1;\n\nconst visibleItems = (items, { text, prefix, page }) => {\n if (page) return items.filter(item => item.parent === page);\n if (prefix) return items.filter(item => item.prefix === prefix);\n if (!text) return items.filter(item => !item.parent);\n return items.filter(item => item.searchFromRoot !== false);\n};\n\nconst detailText = item =>\n [item.subtitle, ...(item.keywords ?? [])].filter(Boolean).join(' ');\n\nconst matchScore = (text, item, parentTitle) =>\n Math.max(\n fuzzyScore(text, item.title),\n parentTitle ? fuzzyScore(text, `${parentTitle} ${item.title}`) : 0,\n fuzzyScore(text, detailText(item), { subsequence: false })\n );\n\nexport const rank = (\n items,\n { text = '', prefix = null, page = null, scopes = [], frecency = NO_FRECENCY }\n) => {\n const titles = new Map(items.map(item => [item.id, item.title]));\n\n return visibleItems(items, { text, prefix, page })\n .map(item => {\n const parentTitle =\n item.parent && !page ? titles.get(item.parent) : undefined;\n const match = text ? matchScore(text, item, parentTitle) : 1;\n const inScope = item.scopes?.some(scope => scopes.includes(scope))\n ? 1\n : 0;\n const score =\n match +\n SCOPE_BOOST * inScope +\n FRECENCY_BOOST * frecency.score(item.id) +\n WEIGHT_BOOST * (item.weight ?? 0);\n return {\n item: parentTitle ? { ...item, parentTitle } : item,\n match,\n score,\n };\n })\n .filter(entry => entry.match > 0)\n .sort((a, b) => {\n const tier = tierOf(a.item) - tierOf(b.item);\n if (tier) return tier;\n return a.item.place ? 0 : b.score - a.score;\n })\n .map(entry => entry.item);\n};\n\nexport const groupBySection = items => {\n const groups = new Map();\n items.forEach(item => {\n const section = item.section ?? null;\n if (!groups.has(section)) groups.set(section, []);\n groups.get(section).push(item);\n });\n return [...groups].map(([section, members]) => ({ section, items: members }));\n};\n"],"names":[],"mappings":"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"}
|