@mvarble/mesearch-ui 0.0.0-stage → 0.1.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 +84 -2
- package/dist/ambient.d.ts +2 -0
- package/dist/components/Article.svelte +226 -0
- package/dist/components/Article.svelte.d.ts +16 -0
- package/dist/components/ArticleHeader.svelte +134 -0
- package/dist/components/ArticleHeader.svelte.d.ts +19 -0
- package/dist/components/Chips.svelte +22 -0
- package/dist/components/Chips.svelte.d.ts +8 -0
- package/dist/components/Graph.svelte +727 -0
- package/dist/components/Graph.svelte.d.ts +16 -0
- package/dist/components/GraphPanel.svelte +328 -0
- package/dist/components/GraphPanel.svelte.d.ts +17 -0
- package/dist/components/Icon.svelte +67 -0
- package/dist/components/Icon.svelte.d.ts +8 -0
- package/dist/components/Index.svelte +283 -0
- package/dist/components/Index.svelte.d.ts +9 -0
- package/dist/components/Pager.svelte +71 -0
- package/dist/components/Pager.svelte.d.ts +12 -0
- package/dist/components/Palette.svelte +333 -0
- package/dist/components/Palette.svelte.d.ts +9 -0
- package/dist/components/Parts.svelte +62 -0
- package/dist/components/Parts.svelte.d.ts +11 -0
- package/dist/components/Proof.svelte +39 -0
- package/dist/components/Proof.svelte.d.ts +8 -0
- package/dist/components/Rail.svelte +186 -0
- package/dist/components/Rail.svelte.d.ts +13 -0
- package/dist/components/References.svelte +94 -0
- package/dist/components/References.svelte.d.ts +8 -0
- package/dist/components/SequenceTrack.svelte +186 -0
- package/dist/components/SequenceTrack.svelte.d.ts +11 -0
- package/dist/components/Shell.svelte +82 -0
- package/dist/components/Shell.svelte.d.ts +14 -0
- package/dist/components/Statement.svelte +105 -0
- package/dist/components/Statement.svelte.d.ts +11 -0
- package/dist/components/ThemeToggle.svelte +19 -0
- package/dist/components/ThemeToggle.svelte.d.ts +3 -0
- package/dist/components/Toc.svelte +145 -0
- package/dist/components/Toc.svelte.d.ts +8 -0
- package/dist/fonts.d.ts +4 -0
- package/dist/fonts.js +6 -0
- package/dist/fuzzy.d.ts +12 -0
- package/dist/fuzzy.js +57 -0
- package/dist/graph-layout.d.ts +35 -0
- package/dist/graph-layout.js +98 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +30 -0
- package/dist/reading.svelte.d.ts +3 -0
- package/dist/reading.svelte.js +4 -0
- package/dist/server.d.ts +26 -0
- package/dist/server.js +115 -0
- package/dist/settings.d.ts +10 -0
- package/dist/settings.js +28 -0
- package/dist/styles/base.css +136 -0
- package/dist/styles/index.css +6 -0
- package/dist/styles/prose.css +203 -0
- package/dist/styles/tokens.css +111 -0
- package/dist/theme.svelte.d.ts +12 -0
- package/dist/theme.svelte.js +66 -0
- package/dist/types.d.ts +84 -0
- package/dist/types.js +3 -0
- package/package.json +66 -3
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { bestMatch, highlight } from '../fuzzy.js';
|
|
3
|
+
import { formatDate, isoDate, kindLabel } from '../settings.js';
|
|
4
|
+
import type { IndexEntry, Kind } from '../types.js';
|
|
5
|
+
|
|
6
|
+
// Every concept and writeup, as a ledger: newest change first, with a
|
|
7
|
+
// fuzzy filter over titles and summaries. Without JavaScript it is the
|
|
8
|
+
// same list in the same order, minus the controls.
|
|
9
|
+
//
|
|
10
|
+
// `kinds` are the kinds a reader can narrow the list to; with fewer than
|
|
11
|
+
// two there is nothing to choose, and the control is left out.
|
|
12
|
+
let {
|
|
13
|
+
entries,
|
|
14
|
+
kinds = [
|
|
15
|
+
['concept', 'Concepts'],
|
|
16
|
+
['writeup', 'Writeups'],
|
|
17
|
+
],
|
|
18
|
+
empty = 'Nothing has been written yet.',
|
|
19
|
+
}: { entries: IndexEntry[]; kinds?: Array<[Kind, string]>; empty?: string } = $props();
|
|
20
|
+
|
|
21
|
+
type Sort = 'updated' | 'created' | 'title';
|
|
22
|
+
type Filter = 'all' | Kind;
|
|
23
|
+
let query = $state('');
|
|
24
|
+
let sort = $state<Sort>('updated');
|
|
25
|
+
let filter = $state<Filter>('all');
|
|
26
|
+
|
|
27
|
+
const sorts: Array<[Sort, string]> = [
|
|
28
|
+
['updated', 'Updated'],
|
|
29
|
+
['created', 'Created'],
|
|
30
|
+
['title', 'A–Z'],
|
|
31
|
+
];
|
|
32
|
+
let filters = $derived<Array<[Filter, string]>>([['all', 'All'], ...kinds]);
|
|
33
|
+
|
|
34
|
+
let shown = $derived.by(() => {
|
|
35
|
+
const kept = entries.filter((entry) => filter == 'all' || entry.kind == filter);
|
|
36
|
+
if (query.trim()) {
|
|
37
|
+
const matched = kept
|
|
38
|
+
.map((entry) => ({
|
|
39
|
+
entry,
|
|
40
|
+
match: bestMatch(query, [
|
|
41
|
+
[entry.title, 1.6],
|
|
42
|
+
[entry.summary, 0.6],
|
|
43
|
+
]),
|
|
44
|
+
}))
|
|
45
|
+
.filter(({ match }) => match);
|
|
46
|
+
// Once something matches well, scattered letters elsewhere are noise.
|
|
47
|
+
const best = Math.max(0, ...matched.map(({ match }) => match!.score));
|
|
48
|
+
return matched
|
|
49
|
+
.filter(({ match }) => match!.score >= best * 0.35)
|
|
50
|
+
.sort((a, b) => b.match!.score - a.match!.score)
|
|
51
|
+
.map(({ entry, match }) => ({
|
|
52
|
+
entry,
|
|
53
|
+
indices: match!.field == 0 ? match!.indices : [],
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
const order = {
|
|
57
|
+
updated: (a: IndexEntry, b: IndexEntry) => b.updated.valueOf() - a.updated.valueOf(),
|
|
58
|
+
created: (a: IndexEntry, b: IndexEntry) => b.created.valueOf() - a.created.valueOf(),
|
|
59
|
+
title: (a: IndexEntry, b: IndexEntry) => a.title.localeCompare(b.title),
|
|
60
|
+
}[sort];
|
|
61
|
+
return kept.toSorted(order).map((entry) => ({ entry, indices: [] as number[] }));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const dateOf = (entry: IndexEntry) => (sort == 'created' ? entry.created : entry.updated);
|
|
65
|
+
</script>
|
|
66
|
+
|
|
67
|
+
<div class="index">
|
|
68
|
+
<div class="controls needs-js">
|
|
69
|
+
<label class="search">
|
|
70
|
+
<span class="visually-hidden">Filter the index</span>
|
|
71
|
+
<input
|
|
72
|
+
bind:value={query}
|
|
73
|
+
type="search"
|
|
74
|
+
placeholder="Filter by title or summary…"
|
|
75
|
+
autocomplete="off"
|
|
76
|
+
/>
|
|
77
|
+
</label>
|
|
78
|
+
<div class="toggles">
|
|
79
|
+
{#if kinds.length > 1}
|
|
80
|
+
<div class="segmented" role="radiogroup" aria-label="Show">
|
|
81
|
+
{#each filters as [value, label] (value)}
|
|
82
|
+
<button
|
|
83
|
+
type="button"
|
|
84
|
+
role="radio"
|
|
85
|
+
aria-checked={filter == value}
|
|
86
|
+
onclick={() => (filter = value)}
|
|
87
|
+
>
|
|
88
|
+
{label}
|
|
89
|
+
</button>
|
|
90
|
+
{/each}
|
|
91
|
+
</div>
|
|
92
|
+
{/if}
|
|
93
|
+
<div class="segmented" role="radiogroup" aria-label="Sort by">
|
|
94
|
+
{#each sorts as [value, label] (value)}
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
role="radio"
|
|
98
|
+
aria-checked={sort == value}
|
|
99
|
+
disabled={!!query.trim()}
|
|
100
|
+
onclick={() => (sort = value)}
|
|
101
|
+
>
|
|
102
|
+
{label}
|
|
103
|
+
</button>
|
|
104
|
+
{/each}
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
|
|
109
|
+
<ol class="entries">
|
|
110
|
+
{#each shown as { entry, indices } (entry.key)}
|
|
111
|
+
<li class="kind-{entry.kind}">
|
|
112
|
+
<time class="date" datetime={isoDate(dateOf(entry))}
|
|
113
|
+
>{formatDate(dateOf(entry))}</time
|
|
114
|
+
>
|
|
115
|
+
<div class="text">
|
|
116
|
+
<p class="eyebrow kind">
|
|
117
|
+
{kindLabel(entry.kind)}{#if entry.readingMinutes}<span class="minutes">
|
|
118
|
+
· {entry.readingMinutes} min</span
|
|
119
|
+
>{/if}
|
|
120
|
+
</p>
|
|
121
|
+
<a class="title" href={entry.url}>
|
|
122
|
+
{#if indices.length}
|
|
123
|
+
{#each highlight(entry.title, indices) as part, i (i)}
|
|
124
|
+
{#if part.hit}<mark>{part.text}</mark>{:else}{part.text}{/if}
|
|
125
|
+
{/each}
|
|
126
|
+
{:else}
|
|
127
|
+
{@html entry.titleHtml}
|
|
128
|
+
{/if}
|
|
129
|
+
</a>
|
|
130
|
+
{#if entry.summaryHtml}<p class="summary">{@html entry.summaryHtml}</p>{/if}
|
|
131
|
+
</div>
|
|
132
|
+
</li>
|
|
133
|
+
{:else}
|
|
134
|
+
<li class="none">
|
|
135
|
+
{entries.length ? `Nothing matches “${query}”.` : empty}
|
|
136
|
+
</li>
|
|
137
|
+
{/each}
|
|
138
|
+
</ol>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
<style>
|
|
142
|
+
.controls {
|
|
143
|
+
display: flex;
|
|
144
|
+
flex-wrap: wrap;
|
|
145
|
+
align-items: center;
|
|
146
|
+
gap: 0.75rem 1rem;
|
|
147
|
+
margin-bottom: 1.25rem;
|
|
148
|
+
font-family: var(--font-ui);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.search {
|
|
152
|
+
flex: 1 1 16rem;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
input {
|
|
156
|
+
width: 100%;
|
|
157
|
+
padding: 0.6rem 0.9rem;
|
|
158
|
+
border: 1px solid var(--rule-strong);
|
|
159
|
+
border-radius: 10px;
|
|
160
|
+
background: var(--paper-raised);
|
|
161
|
+
color: var(--ink);
|
|
162
|
+
font: 400 0.95rem / 1.3 var(--font-ui);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
input:focus {
|
|
166
|
+
outline: none;
|
|
167
|
+
border-color: var(--accent);
|
|
168
|
+
box-shadow: 0 0 0 3px var(--accent-soft);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
.toggles {
|
|
172
|
+
display: flex;
|
|
173
|
+
flex-wrap: wrap;
|
|
174
|
+
gap: 0.6rem;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.segmented {
|
|
178
|
+
display: inline-flex;
|
|
179
|
+
padding: 3px;
|
|
180
|
+
border: 1px solid var(--rule);
|
|
181
|
+
border-radius: 10px;
|
|
182
|
+
background: var(--paper-sunken);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
.segmented button {
|
|
186
|
+
border: none;
|
|
187
|
+
border-radius: 7px;
|
|
188
|
+
padding: 0.32rem 0.7rem;
|
|
189
|
+
background: transparent;
|
|
190
|
+
color: var(--muted);
|
|
191
|
+
font: 500 0.8rem / 1.2 var(--font-ui);
|
|
192
|
+
cursor: pointer;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
.segmented button[aria-checked='true'] {
|
|
196
|
+
background: var(--paper-raised);
|
|
197
|
+
color: var(--ink);
|
|
198
|
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
.segmented button:disabled {
|
|
202
|
+
opacity: 0.5;
|
|
203
|
+
cursor: default;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
.entries {
|
|
207
|
+
list-style: none;
|
|
208
|
+
margin: 0;
|
|
209
|
+
padding: 0;
|
|
210
|
+
border-top: 1px solid var(--rule);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
.entries li {
|
|
214
|
+
display: grid;
|
|
215
|
+
grid-template-columns: 7.5rem 1fr;
|
|
216
|
+
gap: 1.25rem;
|
|
217
|
+
padding: 1.1rem 0;
|
|
218
|
+
border-bottom: 1px solid var(--rule);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.date {
|
|
222
|
+
font: 500 0.8rem / 2.1 var(--font-ui);
|
|
223
|
+
color: var(--muted);
|
|
224
|
+
font-variant-numeric: tabular-nums;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.kind {
|
|
228
|
+
margin: 0 0 0.2rem;
|
|
229
|
+
color: var(--kind);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
.minutes {
|
|
233
|
+
color: var(--faint);
|
|
234
|
+
letter-spacing: 0.04em;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
.title {
|
|
238
|
+
font: 600 1.18rem / 1.3 var(--font-serif);
|
|
239
|
+
color: var(--ink);
|
|
240
|
+
text-decoration: none;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
.title:hover {
|
|
244
|
+
color: var(--kind);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
mark {
|
|
248
|
+
background: var(--kind-soft);
|
|
249
|
+
color: inherit;
|
|
250
|
+
border-radius: 2px;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
.summary {
|
|
254
|
+
margin: 0.35rem 0 0;
|
|
255
|
+
color: var(--ink-soft);
|
|
256
|
+
font-size: 0.98rem;
|
|
257
|
+
line-height: 1.6;
|
|
258
|
+
display: -webkit-box;
|
|
259
|
+
-webkit-line-clamp: 3;
|
|
260
|
+
line-clamp: 3;
|
|
261
|
+
-webkit-box-orient: vertical;
|
|
262
|
+
overflow: hidden;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.none {
|
|
266
|
+
display: block !important;
|
|
267
|
+
color: var(--muted);
|
|
268
|
+
font-family: var(--font-ui);
|
|
269
|
+
text-align: center;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
@media (max-width: 560px) {
|
|
273
|
+
.entries li {
|
|
274
|
+
grid-template-columns: 1fr;
|
|
275
|
+
gap: 0.2rem;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
.date {
|
|
279
|
+
line-height: 1.4;
|
|
280
|
+
order: 2;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
</style>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { IndexEntry, Kind } from '../types.js';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
entries: IndexEntry[];
|
|
4
|
+
kinds?: Array<[Kind, string]>;
|
|
5
|
+
empty?: string;
|
|
6
|
+
};
|
|
7
|
+
declare const Index: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
8
|
+
type Index = ReturnType<typeof Index>;
|
|
9
|
+
export default Index;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import Icon from './Icon.svelte';
|
|
3
|
+
|
|
4
|
+
// The previous and next stops of a sequence, as two cards.
|
|
5
|
+
type Stop = { url: string; titleHtml: string };
|
|
6
|
+
let {
|
|
7
|
+
previous,
|
|
8
|
+
next,
|
|
9
|
+
label = 'Sequence',
|
|
10
|
+
}: { previous?: Stop; next?: Stop; label?: string } = $props();
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
{#if previous || next}
|
|
14
|
+
<nav class="pager kind-sequence" aria-label={label}>
|
|
15
|
+
{#if previous}
|
|
16
|
+
<a class="previous" href={previous.url}>
|
|
17
|
+
<span class="eyebrow"><Icon name="arrow-left" size={14} /> Previous</span>
|
|
18
|
+
<span class="title">{@html previous.titleHtml}</span>
|
|
19
|
+
</a>
|
|
20
|
+
{/if}
|
|
21
|
+
{#if next}
|
|
22
|
+
<a class="next" href={next.url}>
|
|
23
|
+
<span class="eyebrow">Next <Icon name="arrow-right" size={14} /></span>
|
|
24
|
+
<span class="title">{@html next.titleHtml}</span>
|
|
25
|
+
</a>
|
|
26
|
+
{/if}
|
|
27
|
+
</nav>
|
|
28
|
+
{/if}
|
|
29
|
+
|
|
30
|
+
<style>
|
|
31
|
+
.pager {
|
|
32
|
+
display: grid;
|
|
33
|
+
grid-template-columns: 1fr 1fr;
|
|
34
|
+
gap: 1rem;
|
|
35
|
+
font-family: var(--font-ui);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
a {
|
|
39
|
+
display: flex;
|
|
40
|
+
flex-direction: column;
|
|
41
|
+
gap: 0.35rem;
|
|
42
|
+
padding: 1rem 1.1rem;
|
|
43
|
+
border: 1px solid var(--rule);
|
|
44
|
+
border-radius: var(--radius);
|
|
45
|
+
background: var(--paper-raised);
|
|
46
|
+
color: inherit;
|
|
47
|
+
text-decoration: none;
|
|
48
|
+
transition: border-color 120ms;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
a:hover {
|
|
52
|
+
border-color: var(--kind);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.eyebrow {
|
|
56
|
+
display: inline-flex;
|
|
57
|
+
align-items: center;
|
|
58
|
+
gap: 0.3rem;
|
|
59
|
+
color: var(--kind);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.next {
|
|
63
|
+
grid-column: 2;
|
|
64
|
+
text-align: right;
|
|
65
|
+
align-items: flex-end;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.title {
|
|
69
|
+
font: 600 1rem / 1.3 var(--font-serif);
|
|
70
|
+
}
|
|
71
|
+
</style>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
type Stop = {
|
|
2
|
+
url: string;
|
|
3
|
+
titleHtml: string;
|
|
4
|
+
};
|
|
5
|
+
type $$ComponentProps = {
|
|
6
|
+
previous?: Stop;
|
|
7
|
+
next?: Stop;
|
|
8
|
+
label?: string;
|
|
9
|
+
};
|
|
10
|
+
declare const Pager: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
11
|
+
type Pager = ReturnType<typeof Pager>;
|
|
12
|
+
export default Pager;
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { bestMatch, highlight } from '../fuzzy.js';
|
|
3
|
+
import { kindLabel } from '../settings.js';
|
|
4
|
+
import type { SearchEntry } from '../types.js';
|
|
5
|
+
|
|
6
|
+
// Search across the whole library from anywhere: `/` or Ctrl/⌘-K opens
|
|
7
|
+
// it, arrows move, Enter goes, Escape closes. Documents match on their
|
|
8
|
+
// titles and summaries; a heading inside one is its own result, so a
|
|
9
|
+
// search can land on a section.
|
|
10
|
+
//
|
|
11
|
+
// `index` is where the entries come from: the URL of a JSON array of
|
|
12
|
+
// them, fetched the first time the palette opens, or a function that
|
|
13
|
+
// returns them. `navigate` follows a result --- SvelteKit's `goto`, say;
|
|
14
|
+
// without it the browser navigates the ordinary way.
|
|
15
|
+
let {
|
|
16
|
+
index,
|
|
17
|
+
navigate = (url: string) => location.assign(url),
|
|
18
|
+
open = $bindable(false),
|
|
19
|
+
}: {
|
|
20
|
+
index: string | (() => Promise<SearchEntry[]>);
|
|
21
|
+
navigate?: (url: string) => unknown;
|
|
22
|
+
open?: boolean;
|
|
23
|
+
} = $props();
|
|
24
|
+
|
|
25
|
+
let entries = $state<SearchEntry[]>([]);
|
|
26
|
+
let loading: Promise<void> | undefined;
|
|
27
|
+
$effect(() => {
|
|
28
|
+
if (open && !loading) {
|
|
29
|
+
const load =
|
|
30
|
+
typeof index == 'string'
|
|
31
|
+
? () => fetch(index).then((response) => response.json())
|
|
32
|
+
: index;
|
|
33
|
+
loading = load()
|
|
34
|
+
.then((loaded: SearchEntry[]) => void (entries = loaded))
|
|
35
|
+
.catch(() => (loading = undefined));
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
let dialog: HTMLDialogElement | undefined = $state();
|
|
40
|
+
let input: HTMLInputElement | undefined = $state();
|
|
41
|
+
let query = $state('');
|
|
42
|
+
let active = $state(0);
|
|
43
|
+
|
|
44
|
+
interface Result {
|
|
45
|
+
key: string;
|
|
46
|
+
kind: SearchEntry['kind'];
|
|
47
|
+
title: string;
|
|
48
|
+
context?: string;
|
|
49
|
+
contextHtml?: string;
|
|
50
|
+
url: string;
|
|
51
|
+
score: number;
|
|
52
|
+
indices: number[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let results = $derived.by((): Result[] => {
|
|
56
|
+
if (!query.trim()) {
|
|
57
|
+
return entries.slice(0, 8).map((entry) => ({
|
|
58
|
+
key: entry.key,
|
|
59
|
+
kind: entry.kind,
|
|
60
|
+
title: entry.title,
|
|
61
|
+
contextHtml: entry.summaryHtml,
|
|
62
|
+
url: entry.url,
|
|
63
|
+
score: 0,
|
|
64
|
+
indices: [],
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
const out: Result[] = [];
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
const match = bestMatch(query, [
|
|
70
|
+
[entry.title, 1.6],
|
|
71
|
+
[entry.summary, 0.5],
|
|
72
|
+
]);
|
|
73
|
+
if (match) {
|
|
74
|
+
out.push({
|
|
75
|
+
key: entry.key,
|
|
76
|
+
kind: entry.kind,
|
|
77
|
+
title: entry.title,
|
|
78
|
+
contextHtml: entry.summaryHtml,
|
|
79
|
+
url: entry.url,
|
|
80
|
+
score: match.score,
|
|
81
|
+
indices: match.field == 0 ? match.indices : [],
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
for (const heading of entry.headings) {
|
|
85
|
+
const hit = bestMatch(query, [[heading.title, 1.1]]);
|
|
86
|
+
if (hit) {
|
|
87
|
+
out.push({
|
|
88
|
+
key: `${entry.key}#${heading.slug}`,
|
|
89
|
+
kind: entry.kind,
|
|
90
|
+
title: heading.title,
|
|
91
|
+
context: entry.title,
|
|
92
|
+
url: `${entry.url}#${heading.slug}`,
|
|
93
|
+
score: hit.score,
|
|
94
|
+
indices: hit.indices,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
// Once something matches well, scattered letters elsewhere are noise.
|
|
100
|
+
const best = Math.max(0, ...out.map((result) => result.score));
|
|
101
|
+
return out
|
|
102
|
+
.filter((result) => result.score >= best * 0.35)
|
|
103
|
+
.sort((a, b) => b.score - a.score)
|
|
104
|
+
.slice(0, 12);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
$effect(() => {
|
|
108
|
+
void results;
|
|
109
|
+
active = 0;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
$effect(() => {
|
|
113
|
+
if (!dialog) return;
|
|
114
|
+
if (open && !dialog.open) {
|
|
115
|
+
dialog.showModal();
|
|
116
|
+
input?.select();
|
|
117
|
+
} else if (!open && dialog.open) {
|
|
118
|
+
dialog.close();
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
function go(result: Result | undefined) {
|
|
123
|
+
if (!result) return;
|
|
124
|
+
open = false;
|
|
125
|
+
query = '';
|
|
126
|
+
void navigate(result.url);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function onkeydown(event: KeyboardEvent) {
|
|
130
|
+
if (event.key == 'ArrowDown') {
|
|
131
|
+
active = Math.min(active + 1, results.length - 1);
|
|
132
|
+
event.preventDefault();
|
|
133
|
+
} else if (event.key == 'ArrowUp') {
|
|
134
|
+
active = Math.max(active - 1, 0);
|
|
135
|
+
event.preventDefault();
|
|
136
|
+
} else if (event.key == 'Enter') {
|
|
137
|
+
go(results[active]);
|
|
138
|
+
event.preventDefault();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Opening shortcuts, from anywhere but a text field.
|
|
143
|
+
function onwindowkeydown(event: KeyboardEvent) {
|
|
144
|
+
const target = event.target as HTMLElement | null;
|
|
145
|
+
const typing = target?.closest('input, textarea, [contenteditable="true"]');
|
|
146
|
+
if ((event.key == 'k' || event.key == 'K') && (event.metaKey || event.ctrlKey)) {
|
|
147
|
+
open = !open;
|
|
148
|
+
event.preventDefault();
|
|
149
|
+
} else if (event.key == '/' && !typing && !open) {
|
|
150
|
+
open = true;
|
|
151
|
+
event.preventDefault();
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
</script>
|
|
155
|
+
|
|
156
|
+
<svelte:window onkeydown={onwindowkeydown} />
|
|
157
|
+
|
|
158
|
+
<dialog
|
|
159
|
+
bind:this={dialog}
|
|
160
|
+
class="palette"
|
|
161
|
+
aria-label="Search the library"
|
|
162
|
+
onclose={() => (open = false)}
|
|
163
|
+
onclick={(event) => {
|
|
164
|
+
if (event.target == dialog) open = false;
|
|
165
|
+
}}
|
|
166
|
+
>
|
|
167
|
+
<div class="box">
|
|
168
|
+
<label class="field">
|
|
169
|
+
<span class="visually-hidden">Search</span>
|
|
170
|
+
<input
|
|
171
|
+
bind:this={input}
|
|
172
|
+
bind:value={query}
|
|
173
|
+
{onkeydown}
|
|
174
|
+
type="search"
|
|
175
|
+
placeholder="Search titles, summaries and sections…"
|
|
176
|
+
autocomplete="off"
|
|
177
|
+
spellcheck="false"
|
|
178
|
+
role="combobox"
|
|
179
|
+
aria-expanded="true"
|
|
180
|
+
aria-controls="palette-results"
|
|
181
|
+
aria-activedescendant={results[active] ? `palette-${active}` : undefined}
|
|
182
|
+
/>
|
|
183
|
+
<kbd>esc</kbd>
|
|
184
|
+
</label>
|
|
185
|
+
<ul id="palette-results" class="results" role="listbox">
|
|
186
|
+
{#each results as result, i (result.key)}
|
|
187
|
+
<li
|
|
188
|
+
id={`palette-${i}`}
|
|
189
|
+
role="option"
|
|
190
|
+
aria-selected={i == active}
|
|
191
|
+
class:active={i == active}
|
|
192
|
+
class="kind-{result.kind}"
|
|
193
|
+
>
|
|
194
|
+
<a
|
|
195
|
+
href={result.url}
|
|
196
|
+
onclick={(event) => {
|
|
197
|
+
event.preventDefault();
|
|
198
|
+
go(result);
|
|
199
|
+
}}
|
|
200
|
+
onmousemove={() => (active = i)}
|
|
201
|
+
>
|
|
202
|
+
<span class="kind">{kindLabel(result.kind)}</span>
|
|
203
|
+
<span class="title">
|
|
204
|
+
{#each highlight(result.title, result.indices) as part, j (j)}
|
|
205
|
+
{#if part.hit}<mark>{part.text}</mark>{:else}{part.text}{/if}
|
|
206
|
+
{/each}
|
|
207
|
+
</span>
|
|
208
|
+
{#if result.contextHtml}
|
|
209
|
+
<span class="context">{@html result.contextHtml}</span>
|
|
210
|
+
{:else if result.context}
|
|
211
|
+
<span class="context">{result.context}</span>
|
|
212
|
+
{/if}
|
|
213
|
+
</a>
|
|
214
|
+
</li>
|
|
215
|
+
{:else}
|
|
216
|
+
<li class="empty">{entries.length ? `Nothing matches “${query}”.` : 'Loading…'}</li>
|
|
217
|
+
{/each}
|
|
218
|
+
</ul>
|
|
219
|
+
</div>
|
|
220
|
+
</dialog>
|
|
221
|
+
|
|
222
|
+
<style>
|
|
223
|
+
.palette {
|
|
224
|
+
width: min(40rem, calc(100vw - 2rem));
|
|
225
|
+
max-height: min(34rem, calc(100vh - 6rem));
|
|
226
|
+
margin: 12vh auto auto;
|
|
227
|
+
padding: 0;
|
|
228
|
+
border: 1px solid var(--rule-strong);
|
|
229
|
+
border-radius: var(--radius-large);
|
|
230
|
+
background: var(--paper-raised);
|
|
231
|
+
color: var(--ink);
|
|
232
|
+
box-shadow: var(--shadow-strong);
|
|
233
|
+
font-family: var(--font-ui);
|
|
234
|
+
overflow: hidden;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
.palette::backdrop {
|
|
238
|
+
background: rgba(20, 16, 10, 0.32);
|
|
239
|
+
backdrop-filter: blur(2px);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
.box {
|
|
243
|
+
display: flex;
|
|
244
|
+
flex-direction: column;
|
|
245
|
+
max-height: inherit;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
.field {
|
|
249
|
+
display: flex;
|
|
250
|
+
align-items: center;
|
|
251
|
+
gap: 0.75rem;
|
|
252
|
+
padding: 0.9rem 1.1rem;
|
|
253
|
+
border-bottom: 1px solid var(--rule);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
input {
|
|
257
|
+
flex: 1;
|
|
258
|
+
border: none;
|
|
259
|
+
outline: none;
|
|
260
|
+
background: transparent;
|
|
261
|
+
color: var(--ink);
|
|
262
|
+
font: 400 1.05rem / 1.4 var(--font-ui);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
input::-webkit-search-cancel-button {
|
|
266
|
+
display: none;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
kbd {
|
|
270
|
+
font: 500 0.7rem / 1 var(--font-ui);
|
|
271
|
+
color: var(--muted);
|
|
272
|
+
border: 1px solid var(--rule-strong);
|
|
273
|
+
border-radius: 4px;
|
|
274
|
+
padding: 0.2rem 0.35rem;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
.results {
|
|
278
|
+
list-style: none;
|
|
279
|
+
margin: 0;
|
|
280
|
+
padding: 0.4rem;
|
|
281
|
+
overflow-y: auto;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
.results a {
|
|
285
|
+
display: grid;
|
|
286
|
+
grid-template-columns: 5.5rem 1fr;
|
|
287
|
+
grid-template-rows: auto auto;
|
|
288
|
+
column-gap: 0.75rem;
|
|
289
|
+
padding: 0.55rem 0.7rem;
|
|
290
|
+
border-radius: 8px;
|
|
291
|
+
color: inherit;
|
|
292
|
+
text-decoration: none;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
.active a {
|
|
296
|
+
background: var(--kind-soft);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
.kind {
|
|
300
|
+
grid-row: span 2;
|
|
301
|
+
font: 600 0.66rem / 1.9 var(--font-ui);
|
|
302
|
+
letter-spacing: 0.1em;
|
|
303
|
+
text-transform: uppercase;
|
|
304
|
+
color: var(--kind);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
.title {
|
|
308
|
+
font-weight: 550;
|
|
309
|
+
font-size: 0.95rem;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
mark {
|
|
313
|
+
background: none;
|
|
314
|
+
color: var(--kind);
|
|
315
|
+
text-decoration: underline;
|
|
316
|
+
text-underline-offset: 0.15em;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
.context {
|
|
320
|
+
font-size: 0.8rem;
|
|
321
|
+
color: var(--muted);
|
|
322
|
+
white-space: nowrap;
|
|
323
|
+
overflow: hidden;
|
|
324
|
+
text-overflow: ellipsis;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
.empty {
|
|
328
|
+
padding: 1.5rem;
|
|
329
|
+
text-align: center;
|
|
330
|
+
color: var(--muted);
|
|
331
|
+
font-size: 0.9rem;
|
|
332
|
+
}
|
|
333
|
+
</style>
|