searchsocket 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +742 -507
- package/dist/cli.js +3504 -1412
- package/dist/client.cjs +41 -117
- package/dist/client.d.cts +3 -17
- package/dist/client.d.ts +3 -17
- package/dist/client.js +41 -117
- package/dist/index.cjs +2553 -1499
- package/dist/index.d.cts +133 -34
- package/dist/index.d.ts +133 -34
- package/dist/index.js +2551 -1494
- package/dist/plugin-C61L-ykY.d.ts +37 -0
- package/dist/plugin-DoBW1gkK.d.cts +37 -0
- package/dist/scroll.cjs +185 -0
- package/dist/scroll.d.cts +42 -0
- package/dist/scroll.d.ts +42 -0
- package/dist/scroll.js +183 -0
- package/dist/sveltekit.cjs +2769 -1389
- package/dist/sveltekit.d.cts +3 -43
- package/dist/sveltekit.d.ts +3 -43
- package/dist/sveltekit.js +2769 -1389
- package/dist/templates/search-dialog/SearchDialog.svelte +175 -0
- package/dist/templates/search-input/SearchInput.svelte +151 -0
- package/dist/templates/search-results/SearchResults.svelte +75 -0
- package/dist/{types-z2dw3H6E.d.cts → types-029hl6P2.d.cts} +210 -134
- package/dist/{types-z2dw3H6E.d.ts → types-029hl6P2.d.ts} +210 -134
- package/package.json +28 -3
- package/src/svelte/SearchSocket.svelte +35 -0
- package/src/svelte/index.svelte.ts +181 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
SearchDialog — Cmd+K search dialog for SearchSocket
|
|
3
|
+
Minimal Tailwind 4 starting point. Customize freely.
|
|
4
|
+
For non-SvelteKit apps, replace `goto` with `window.location.href = url`.
|
|
5
|
+
-->
|
|
6
|
+
<script lang="ts">
|
|
7
|
+
import { createSearch } from "searchsocket/svelte";
|
|
8
|
+
import { buildResultUrl } from "searchsocket/client";
|
|
9
|
+
import { goto } from "$app/navigation";
|
|
10
|
+
|
|
11
|
+
let {
|
|
12
|
+
open = $bindable(false),
|
|
13
|
+
endpoint = "/api/search",
|
|
14
|
+
placeholder = "Search...",
|
|
15
|
+
}: {
|
|
16
|
+
open?: boolean;
|
|
17
|
+
endpoint?: string;
|
|
18
|
+
placeholder?: string;
|
|
19
|
+
} = $props();
|
|
20
|
+
|
|
21
|
+
const search = createSearch({ endpoint, topK: 8, groupBy: "page" });
|
|
22
|
+
|
|
23
|
+
let activeIndex = $state(-1);
|
|
24
|
+
let inputEl = $state<HTMLInputElement | null>(null);
|
|
25
|
+
let listboxId = "ss-listbox";
|
|
26
|
+
|
|
27
|
+
// Reset active index when results change
|
|
28
|
+
$effect(() => {
|
|
29
|
+
search.results;
|
|
30
|
+
activeIndex = -1;
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// Focus input when dialog opens
|
|
34
|
+
$effect(() => {
|
|
35
|
+
if (open && inputEl) inputEl.focus();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// Lock body scroll when open
|
|
39
|
+
$effect(() => {
|
|
40
|
+
if (open) document.body.style.overflow = "hidden";
|
|
41
|
+
return () => { document.body.style.overflow = ""; };
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Global Cmd+K / Ctrl+K shortcut
|
|
45
|
+
$effect(() => {
|
|
46
|
+
function onKeydown(e: KeyboardEvent) {
|
|
47
|
+
if (e.key === "k" && (e.metaKey || e.ctrlKey)) {
|
|
48
|
+
e.preventDefault();
|
|
49
|
+
open = !open;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
document.addEventListener("keydown", onKeydown);
|
|
53
|
+
return () => document.removeEventListener("keydown", onKeydown);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Cleanup search state on destroy
|
|
57
|
+
$effect(() => {
|
|
58
|
+
return () => search.destroy();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
function activeOptionId(): string | undefined {
|
|
62
|
+
return activeIndex >= 0 && activeIndex < search.results.length
|
|
63
|
+
? `ss-option-${activeIndex}`
|
|
64
|
+
: undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
68
|
+
const count = search.results.length;
|
|
69
|
+
switch (e.key) {
|
|
70
|
+
case "ArrowDown":
|
|
71
|
+
e.preventDefault();
|
|
72
|
+
activeIndex = count > 0 ? (activeIndex + 1) % count : -1;
|
|
73
|
+
break;
|
|
74
|
+
case "ArrowUp":
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
activeIndex = count > 0 ? (activeIndex - 1 + count) % count : -1;
|
|
77
|
+
break;
|
|
78
|
+
case "Enter":
|
|
79
|
+
e.preventDefault();
|
|
80
|
+
if (activeIndex >= 0 && activeIndex < count) navigateTo(search.results[activeIndex]);
|
|
81
|
+
break;
|
|
82
|
+
case "Escape":
|
|
83
|
+
e.preventDefault();
|
|
84
|
+
open = false;
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function navigateTo(result: (typeof search.results)[number]) {
|
|
90
|
+
open = false;
|
|
91
|
+
search.query = "";
|
|
92
|
+
goto(buildResultUrl(result));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function highlightParts(text: string, query: string): Array<{ text: string; match: boolean }> {
|
|
96
|
+
if (!query.trim()) return [{ text, match: false }];
|
|
97
|
+
const escaped = query.trim().split(/\s+/).map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
|
|
98
|
+
const splitter = new RegExp(`(${escaped})`, "gi");
|
|
99
|
+
const tester = new RegExp(`^(?:${escaped})$`, "i");
|
|
100
|
+
return text.split(splitter).filter(Boolean).map((part) => ({ text: part, match: tester.test(part) }));
|
|
101
|
+
}
|
|
102
|
+
</script>
|
|
103
|
+
|
|
104
|
+
{#if open}
|
|
105
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
106
|
+
<div
|
|
107
|
+
class="fixed inset-0 z-50 flex items-start justify-center bg-black/50 pt-[15vh]"
|
|
108
|
+
onclick={() => (open = false)}
|
|
109
|
+
onkeydown={handleKeydown}
|
|
110
|
+
>
|
|
111
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
112
|
+
<div
|
|
113
|
+
class="flex w-full max-w-lg flex-col overflow-hidden rounded-lg border bg-white shadow-lg dark:border-neutral-700 dark:bg-neutral-900"
|
|
114
|
+
role="dialog"
|
|
115
|
+
aria-modal="true"
|
|
116
|
+
aria-label="Site search"
|
|
117
|
+
onclick={(e) => e.stopPropagation()}
|
|
118
|
+
>
|
|
119
|
+
<input
|
|
120
|
+
bind:this={inputEl}
|
|
121
|
+
type="text"
|
|
122
|
+
role="combobox"
|
|
123
|
+
aria-expanded={search.results.length > 0}
|
|
124
|
+
aria-haspopup="listbox"
|
|
125
|
+
aria-controls={listboxId}
|
|
126
|
+
aria-autocomplete="list"
|
|
127
|
+
aria-activedescendant={activeOptionId()}
|
|
128
|
+
{placeholder}
|
|
129
|
+
value={search.query}
|
|
130
|
+
oninput={(e) => (search.query = e.currentTarget.value)}
|
|
131
|
+
class="w-full border-b bg-transparent px-4 py-3 text-base outline-none dark:border-neutral-700"
|
|
132
|
+
/>
|
|
133
|
+
|
|
134
|
+
{#if search.loading}
|
|
135
|
+
<div class="px-4 py-3 text-sm text-neutral-500" aria-live="polite">Searching...</div>
|
|
136
|
+
{/if}
|
|
137
|
+
|
|
138
|
+
{#if search.error}
|
|
139
|
+
<div class="px-4 py-3 text-sm text-red-600" role="alert">{search.error.message}</div>
|
|
140
|
+
{/if}
|
|
141
|
+
|
|
142
|
+
{#if search.results.length > 0}
|
|
143
|
+
<ul role="listbox" id={listboxId} class="max-h-80 overflow-y-auto">
|
|
144
|
+
{#each search.results as result, i}
|
|
145
|
+
<li
|
|
146
|
+
role="option"
|
|
147
|
+
id="ss-option-{i}"
|
|
148
|
+
aria-selected={i === activeIndex}
|
|
149
|
+
class="flex cursor-pointer flex-col gap-0.5 px-4 py-2.5 {i === activeIndex ? 'bg-neutral-100 dark:bg-neutral-800' : ''}"
|
|
150
|
+
onclick={() => navigateTo(result)}
|
|
151
|
+
onmouseenter={() => (activeIndex = i)}
|
|
152
|
+
>
|
|
153
|
+
<span class="font-medium">
|
|
154
|
+
{#each highlightParts(result.title, search.query) as part}
|
|
155
|
+
{#if part.match}<mark class="rounded-sm bg-yellow-200 dark:bg-yellow-500/30">{part.text}</mark>{:else}{part.text}{/if}
|
|
156
|
+
{/each}
|
|
157
|
+
</span>
|
|
158
|
+
{#if result.snippet}
|
|
159
|
+
<span class="text-sm text-neutral-500 dark:text-neutral-400">
|
|
160
|
+
{#each highlightParts(result.snippet, search.query) as part}
|
|
161
|
+
{#if part.match}<mark class="rounded-sm bg-yellow-200 dark:bg-yellow-500/30">{part.text}</mark>{:else}{part.text}{/if}
|
|
162
|
+
{/each}
|
|
163
|
+
</span>
|
|
164
|
+
{/if}
|
|
165
|
+
</li>
|
|
166
|
+
{/each}
|
|
167
|
+
</ul>
|
|
168
|
+
{/if}
|
|
169
|
+
|
|
170
|
+
{#if search.query && !search.loading && search.results.length === 0 && !search.error}
|
|
171
|
+
<div class="px-4 py-3 text-sm text-neutral-500">No results found.</div>
|
|
172
|
+
{/if}
|
|
173
|
+
</div>
|
|
174
|
+
</div>
|
|
175
|
+
{/if}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
SearchInput — Inline search input with dropdown results for SearchSocket
|
|
3
|
+
Minimal Tailwind 4 starting point. Customize freely.
|
|
4
|
+
For non-SvelteKit apps, replace `goto` with `window.location.href = url`.
|
|
5
|
+
-->
|
|
6
|
+
<script lang="ts">
|
|
7
|
+
import { createSearch } from "searchsocket/svelte";
|
|
8
|
+
import { buildResultUrl } from "searchsocket/client";
|
|
9
|
+
import { goto } from "$app/navigation";
|
|
10
|
+
|
|
11
|
+
let {
|
|
12
|
+
endpoint = "/api/search",
|
|
13
|
+
placeholder = "Search...",
|
|
14
|
+
}: {
|
|
15
|
+
endpoint?: string;
|
|
16
|
+
placeholder?: string;
|
|
17
|
+
} = $props();
|
|
18
|
+
|
|
19
|
+
const search = createSearch({ endpoint, topK: 8, groupBy: "page" });
|
|
20
|
+
|
|
21
|
+
let activeIndex = $state(-1);
|
|
22
|
+
let inputEl = $state<HTMLInputElement | null>(null);
|
|
23
|
+
let containerEl = $state<HTMLDivElement | null>(null);
|
|
24
|
+
let dropdownOpen = $state(false);
|
|
25
|
+
let listboxId = "ss-inline-listbox";
|
|
26
|
+
|
|
27
|
+
let showDropdown = $derived(dropdownOpen && search.query.trim().length > 0);
|
|
28
|
+
|
|
29
|
+
// Reset active index when results change
|
|
30
|
+
$effect(() => {
|
|
31
|
+
search.results;
|
|
32
|
+
activeIndex = -1;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Cleanup search state on destroy
|
|
36
|
+
$effect(() => {
|
|
37
|
+
return () => search.destroy();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
function activeOptionId(): string | undefined {
|
|
41
|
+
return activeIndex >= 0 && activeIndex < search.results.length
|
|
42
|
+
? `ss-inline-option-${activeIndex}`
|
|
43
|
+
: undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
47
|
+
const count = search.results.length;
|
|
48
|
+
switch (e.key) {
|
|
49
|
+
case "ArrowDown":
|
|
50
|
+
e.preventDefault();
|
|
51
|
+
activeIndex = count > 0 ? (activeIndex + 1) % count : -1;
|
|
52
|
+
break;
|
|
53
|
+
case "ArrowUp":
|
|
54
|
+
e.preventDefault();
|
|
55
|
+
activeIndex = count > 0 ? (activeIndex - 1 + count) % count : -1;
|
|
56
|
+
break;
|
|
57
|
+
case "Enter":
|
|
58
|
+
e.preventDefault();
|
|
59
|
+
if (activeIndex >= 0 && activeIndex < count) navigateTo(search.results[activeIndex]);
|
|
60
|
+
break;
|
|
61
|
+
case "Escape":
|
|
62
|
+
e.preventDefault();
|
|
63
|
+
dropdownOpen = false;
|
|
64
|
+
inputEl?.blur();
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function handleFocusOut(e: FocusEvent) {
|
|
70
|
+
if (containerEl && !containerEl.contains(e.relatedTarget as Node)) {
|
|
71
|
+
dropdownOpen = false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function navigateTo(result: (typeof search.results)[number]) {
|
|
76
|
+
dropdownOpen = false;
|
|
77
|
+
search.query = "";
|
|
78
|
+
goto(buildResultUrl(result));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function highlightParts(text: string, query: string): Array<{ text: string; match: boolean }> {
|
|
82
|
+
if (!query.trim()) return [{ text, match: false }];
|
|
83
|
+
const escaped = query.trim().split(/\s+/).map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
|
|
84
|
+
const splitter = new RegExp(`(${escaped})`, "gi");
|
|
85
|
+
const tester = new RegExp(`^(?:${escaped})$`, "i");
|
|
86
|
+
return text.split(splitter).filter(Boolean).map((part) => ({ text: part, match: tester.test(part) }));
|
|
87
|
+
}
|
|
88
|
+
</script>
|
|
89
|
+
|
|
90
|
+
<div class="relative w-full" bind:this={containerEl} onfocusout={handleFocusOut}>
|
|
91
|
+
<input
|
|
92
|
+
bind:this={inputEl}
|
|
93
|
+
type="text"
|
|
94
|
+
role="combobox"
|
|
95
|
+
aria-expanded={showDropdown && search.results.length > 0}
|
|
96
|
+
aria-haspopup="listbox"
|
|
97
|
+
aria-controls={listboxId}
|
|
98
|
+
aria-autocomplete="list"
|
|
99
|
+
aria-activedescendant={activeOptionId()}
|
|
100
|
+
{placeholder}
|
|
101
|
+
value={search.query}
|
|
102
|
+
oninput={(e) => (search.query = e.currentTarget.value)}
|
|
103
|
+
onfocus={() => (dropdownOpen = true)}
|
|
104
|
+
onkeydown={handleKeydown}
|
|
105
|
+
class="w-full rounded-md border px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 dark:border-neutral-700 dark:bg-neutral-900"
|
|
106
|
+
/>
|
|
107
|
+
|
|
108
|
+
{#if showDropdown}
|
|
109
|
+
<div class="absolute top-full left-0 right-0 z-50 mt-1 overflow-hidden rounded-md border bg-white shadow-lg dark:border-neutral-700 dark:bg-neutral-900">
|
|
110
|
+
{#if search.loading}
|
|
111
|
+
<div class="px-3 py-2 text-sm text-neutral-500" aria-live="polite">Searching...</div>
|
|
112
|
+
{/if}
|
|
113
|
+
|
|
114
|
+
{#if search.error}
|
|
115
|
+
<div class="px-3 py-2 text-sm text-red-600" role="alert">{search.error.message}</div>
|
|
116
|
+
{/if}
|
|
117
|
+
|
|
118
|
+
{#if search.results.length > 0}
|
|
119
|
+
<ul role="listbox" id={listboxId} class="max-h-72 overflow-y-auto">
|
|
120
|
+
{#each search.results as result, i}
|
|
121
|
+
<li
|
|
122
|
+
role="option"
|
|
123
|
+
id="ss-inline-option-{i}"
|
|
124
|
+
aria-selected={i === activeIndex}
|
|
125
|
+
class="flex cursor-pointer flex-col gap-0.5 px-3 py-2 {i === activeIndex ? 'bg-neutral-100 dark:bg-neutral-800' : ''}"
|
|
126
|
+
onclick={() => navigateTo(result)}
|
|
127
|
+
onmouseenter={() => (activeIndex = i)}
|
|
128
|
+
>
|
|
129
|
+
<span class="font-medium">
|
|
130
|
+
{#each highlightParts(result.title, search.query) as part}
|
|
131
|
+
{#if part.match}<mark class="rounded-sm bg-yellow-200 dark:bg-yellow-500/30">{part.text}</mark>{:else}{part.text}{/if}
|
|
132
|
+
{/each}
|
|
133
|
+
</span>
|
|
134
|
+
{#if result.snippet}
|
|
135
|
+
<span class="text-sm text-neutral-500 dark:text-neutral-400">
|
|
136
|
+
{#each highlightParts(result.snippet, search.query) as part}
|
|
137
|
+
{#if part.match}<mark class="rounded-sm bg-yellow-200 dark:bg-yellow-500/30">{part.text}</mark>{:else}{part.text}{/if}
|
|
138
|
+
{/each}
|
|
139
|
+
</span>
|
|
140
|
+
{/if}
|
|
141
|
+
</li>
|
|
142
|
+
{/each}
|
|
143
|
+
</ul>
|
|
144
|
+
{/if}
|
|
145
|
+
|
|
146
|
+
{#if search.query && !search.loading && search.results.length === 0 && !search.error}
|
|
147
|
+
<div class="px-3 py-2 text-sm text-neutral-500">No results found.</div>
|
|
148
|
+
{/if}
|
|
149
|
+
</div>
|
|
150
|
+
{/if}
|
|
151
|
+
</div>
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
SearchResults — Standalone result list for SearchSocket
|
|
3
|
+
Minimal Tailwind 4 starting point. Customize freely.
|
|
4
|
+
Use this when you manage search state yourself and just need a result display.
|
|
5
|
+
-->
|
|
6
|
+
<script lang="ts">
|
|
7
|
+
import { buildResultUrl } from "searchsocket/client";
|
|
8
|
+
|
|
9
|
+
interface SearchResultItem {
|
|
10
|
+
url: string;
|
|
11
|
+
title: string;
|
|
12
|
+
sectionTitle?: string;
|
|
13
|
+
snippet: string;
|
|
14
|
+
score: number;
|
|
15
|
+
routeFile: string;
|
|
16
|
+
chunks?: { sectionTitle?: string; snippet: string; headingPath: string[]; score: number }[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
let {
|
|
20
|
+
results = [],
|
|
21
|
+
query = "",
|
|
22
|
+
loading = false,
|
|
23
|
+
error = null,
|
|
24
|
+
}: {
|
|
25
|
+
results?: SearchResultItem[];
|
|
26
|
+
query?: string;
|
|
27
|
+
loading?: boolean;
|
|
28
|
+
error?: Error | null;
|
|
29
|
+
} = $props();
|
|
30
|
+
|
|
31
|
+
function highlightParts(text: string, q: string): Array<{ text: string; match: boolean }> {
|
|
32
|
+
if (!q.trim()) return [{ text, match: false }];
|
|
33
|
+
const escaped = q.trim().split(/\s+/).map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
|
|
34
|
+
const splitter = new RegExp(`(${escaped})`, "gi");
|
|
35
|
+
const tester = new RegExp(`^(?:${escaped})$`, "i");
|
|
36
|
+
return text.split(splitter).filter(Boolean).map((part) => ({ text: part, match: tester.test(part) }));
|
|
37
|
+
}
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<div class="w-full">
|
|
41
|
+
{#if loading}
|
|
42
|
+
<div class="py-3 text-sm text-neutral-500" aria-live="polite">Searching...</div>
|
|
43
|
+
{/if}
|
|
44
|
+
|
|
45
|
+
{#if error}
|
|
46
|
+
<div class="py-3 text-sm text-red-600" role="alert">{error.message}</div>
|
|
47
|
+
{/if}
|
|
48
|
+
|
|
49
|
+
{#if results.length > 0}
|
|
50
|
+
<ul class="divide-y divide-neutral-200 dark:divide-neutral-700">
|
|
51
|
+
{#each results as result}
|
|
52
|
+
<li>
|
|
53
|
+
<a href={buildResultUrl(result)} class="flex flex-col gap-1 py-3 no-underline hover:opacity-80">
|
|
54
|
+
<span class="font-medium">
|
|
55
|
+
{#each highlightParts(result.title, query) as part}
|
|
56
|
+
{#if part.match}<mark class="rounded-sm bg-yellow-200 dark:bg-yellow-500/30">{part.text}</mark>{:else}{part.text}{/if}
|
|
57
|
+
{/each}
|
|
58
|
+
</span>
|
|
59
|
+
{#if result.snippet}
|
|
60
|
+
<span class="text-sm text-neutral-500 dark:text-neutral-400">
|
|
61
|
+
{#each highlightParts(result.snippet, query) as part}
|
|
62
|
+
{#if part.match}<mark class="rounded-sm bg-yellow-200 dark:bg-yellow-500/30">{part.text}</mark>{:else}{part.text}{/if}
|
|
63
|
+
{/each}
|
|
64
|
+
</span>
|
|
65
|
+
{/if}
|
|
66
|
+
</a>
|
|
67
|
+
</li>
|
|
68
|
+
{/each}
|
|
69
|
+
</ul>
|
|
70
|
+
{/if}
|
|
71
|
+
|
|
72
|
+
{#if query && !loading && results.length === 0 && !error}
|
|
73
|
+
<div class="py-3 text-sm text-neutral-500">No results found.</div>
|
|
74
|
+
{/if}
|
|
75
|
+
</div>
|