@meeovi/layer-search 1.0.7 → 1.0.8
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 +31 -0
- package/app/components/features/autocomplete.vue +63 -0
- package/app/components/features/searchkitSearch.vue +115 -0
- package/app/components/molecules/pagination.vue +17 -0
- package/app/components/molecules/resultList.vue +25 -0
- package/app/composables/useSearchkit.ts +217 -0
- package/app/plugins/search.js +83 -0
- package/app/utils/env.ts +28 -0
- package/app/utils/search/client.ts +22 -7
- package/package.json +1 -2
- package/dist/app/composables/adapter/meilisearch.d.ts +0 -8
- package/dist/app/composables/adapter/meilisearch.js +0 -36
- package/dist/app/composables/adapter/mock.d.ts +0 -3
- package/dist/app/composables/adapter/mock.js +0 -19
- package/dist/app/composables/adapter/opensearch.d.ts +0 -8
- package/dist/app/composables/adapter/opensearch.js +0 -46
- package/dist/app/composables/adapter/types.d.ts +0 -12
- package/dist/app/composables/adapter/types.js +0 -1
- package/dist/app/composables/bridges/instantsearch.d.ts +0 -4
- package/dist/app/composables/bridges/instantsearch.js +0 -17
- package/dist/app/composables/bridges/react.d.ts +0 -12
- package/dist/app/composables/bridges/react.js +0 -34
- package/dist/app/composables/bridges/vue.d.ts +0 -9
- package/dist/app/composables/bridges/vue.js +0 -31
- package/dist/app/composables/cli.d.ts +0 -2
- package/dist/app/composables/cli.js +0 -69
- package/dist/app/composables/config/schema.d.ts +0 -5
- package/dist/app/composables/config/schema.js +0 -8
- package/dist/app/composables/config.d.ts +0 -7
- package/dist/app/composables/config.js +0 -11
- package/dist/app/composables/core/Facets.d.ts +0 -17
- package/dist/app/composables/core/Facets.js +0 -8
- package/dist/app/composables/core/Filters.d.ts +0 -18
- package/dist/app/composables/core/Filters.js +0 -11
- package/dist/app/composables/core/Normalizers.d.ts +0 -0
- package/dist/app/composables/core/Normalizers.js +0 -1
- package/dist/app/composables/core/Pipeline.d.ts +0 -8
- package/dist/app/composables/core/Pipeline.js +0 -16
- package/dist/app/composables/core/QueryBuilder.d.ts +0 -13
- package/dist/app/composables/core/QueryBuilder.js +0 -15
- package/dist/app/composables/core/SearchContext.d.ts +0 -18
- package/dist/app/composables/core/SearchContext.js +0 -38
- package/dist/app/composables/core/SearchManager.d.ts +0 -10
- package/dist/app/composables/core/SearchManager.js +0 -20
- package/dist/app/composables/events.d.ts +0 -11
- package/dist/app/composables/events.js +0 -1
- package/dist/app/composables/index.d.ts +0 -9
- package/dist/app/composables/index.js +0 -9
- package/dist/app/composables/module.d.ts +0 -17
- package/dist/app/composables/module.js +0 -39
- package/dist/app/composables/types/api/global-search.d.ts +0 -8
- package/dist/app/composables/types/api/global-search.js +0 -1
- package/dist/app/composables/utils/normalizers.d.ts +0 -1
- package/dist/app/composables/utils/normalizers.js +0 -6
- package/dist/app/utils/search/client.d.ts +0 -3
- package/dist/app/utils/search/client.js +0 -31
- package/dist/layers/search/app/composables/adapter/meilisearch.d.ts +0 -8
- package/dist/layers/search/app/composables/adapter/meilisearch.js +0 -36
- package/dist/layers/search/app/composables/adapter/mock.d.ts +0 -3
- package/dist/layers/search/app/composables/adapter/mock.js +0 -19
- package/dist/layers/search/app/composables/adapter/opensearch.d.ts +0 -8
- package/dist/layers/search/app/composables/adapter/opensearch.js +0 -46
- package/dist/layers/search/app/composables/adapter/types.d.ts +0 -12
- package/dist/layers/search/app/composables/adapter/types.js +0 -1
- package/dist/layers/search/app/composables/bridges/instantsearch.d.ts +0 -4
- package/dist/layers/search/app/composables/bridges/instantsearch.js +0 -17
- package/dist/layers/search/app/composables/bridges/react.d.ts +0 -12
- package/dist/layers/search/app/composables/bridges/react.js +0 -34
- package/dist/layers/search/app/composables/bridges/searchkit-server.d.ts +0 -3
- package/dist/layers/search/app/composables/bridges/searchkit-server.js +0 -44
- package/dist/layers/search/app/composables/bridges/searchkit.d.ts +0 -21
- package/dist/layers/search/app/composables/bridges/searchkit.js +0 -60
- package/dist/layers/search/app/composables/bridges/vue.d.ts +0 -9
- package/dist/layers/search/app/composables/bridges/vue.js +0 -31
- package/dist/layers/search/app/composables/cli.d.ts +0 -2
- package/dist/layers/search/app/composables/cli.js +0 -69
- package/dist/layers/search/app/composables/config/schema.d.ts +0 -5
- package/dist/layers/search/app/composables/config/schema.js +0 -8
- package/dist/layers/search/app/composables/config.d.ts +0 -7
- package/dist/layers/search/app/composables/config.js +0 -11
- package/dist/layers/search/app/composables/core/Facets.d.ts +0 -17
- package/dist/layers/search/app/composables/core/Facets.js +0 -8
- package/dist/layers/search/app/composables/core/Filters.d.ts +0 -18
- package/dist/layers/search/app/composables/core/Filters.js +0 -11
- package/dist/layers/search/app/composables/core/Normalizers.d.ts +0 -0
- package/dist/layers/search/app/composables/core/Normalizers.js +0 -1
- package/dist/layers/search/app/composables/core/Pipeline.d.ts +0 -8
- package/dist/layers/search/app/composables/core/Pipeline.js +0 -16
- package/dist/layers/search/app/composables/core/QueryBuilder.d.ts +0 -13
- package/dist/layers/search/app/composables/core/QueryBuilder.js +0 -15
- package/dist/layers/search/app/composables/core/SearchContext.d.ts +0 -18
- package/dist/layers/search/app/composables/core/SearchContext.js +0 -38
- package/dist/layers/search/app/composables/core/SearchManager.d.ts +0 -10
- package/dist/layers/search/app/composables/core/SearchManager.js +0 -20
- package/dist/layers/search/app/composables/events.d.ts +0 -11
- package/dist/layers/search/app/composables/events.js +0 -1
- package/dist/layers/search/app/composables/index.d.ts +0 -12
- package/dist/layers/search/app/composables/index.js +0 -12
- package/dist/layers/search/app/composables/module.d.ts +0 -17
- package/dist/layers/search/app/composables/module.js +0 -73
- package/dist/layers/search/app/composables/types/api/global-search.d.ts +0 -8
- package/dist/layers/search/app/composables/types/api/global-search.js +0 -1
- package/dist/layers/search/app/composables/utils/health.d.ts +0 -11
- package/dist/layers/search/app/composables/utils/health.js +0 -11
- package/dist/layers/search/app/composables/utils/normalizers.d.ts +0 -1
- package/dist/layers/search/app/composables/utils/normalizers.js +0 -6
- package/dist/layers/search/app/utils/search/client.d.ts +0 -3
- package/dist/layers/search/app/utils/search/client.js +0 -31
- package/dist/layers/search/nuxt.config.d.ts +0 -2
- package/dist/layers/search/nuxt.config.js +0 -7
- package/dist/layers/search/test/runtime-adapter.spec.d.ts +0 -1
- package/dist/layers/search/test/runtime-adapter.spec.js +0 -49
- package/dist/nuxt.config.d.ts +0 -2
- package/dist/nuxt.config.js +0 -7
- package/dist/packages/core/src/adapters/auth.d.ts +0 -9
- package/dist/packages/core/src/adapters/auth.js +0 -1
- package/dist/packages/core/src/adapters/cart.d.ts +0 -22
- package/dist/packages/core/src/adapters/cart.js +0 -1
- package/dist/packages/core/src/adapters/catalog.d.ts +0 -17
- package/dist/packages/core/src/adapters/catalog.js +0 -1
- package/dist/packages/core/src/adapters/common.d.ts +0 -9
- package/dist/packages/core/src/adapters/common.js +0 -1
- package/dist/packages/core/src/adapters/lists.d.ts +0 -17
- package/dist/packages/core/src/adapters/lists.js +0 -1
- package/dist/packages/core/src/adapters/search.d.ts +0 -21
- package/dist/packages/core/src/adapters/search.js +0 -1
- package/dist/packages/core/src/plugins/defineAdapter.d.ts +0 -2
- package/dist/packages/core/src/plugins/defineAdapter.js +0 -3
- package/dist/packages/core/src/plugins/defineModule.d.ts +0 -2
- package/dist/packages/core/src/plugins/defineModule.js +0 -3
- package/dist/packages/core/src/plugins/registry.d.ts +0 -14
- package/dist/packages/core/src/plugins/registry.js +0 -46
- package/dist/packages/core/src/runtime/app.d.ts +0 -2
- package/dist/packages/core/src/runtime/app.js +0 -20
- package/dist/packages/core/src/runtime/context.d.ts +0 -9
- package/dist/packages/core/src/runtime/context.js +0 -11
- package/dist/packages/core/src/runtime/hooks.d.ts +0 -5
- package/dist/packages/core/src/runtime/hooks.js +0 -18
- package/dist/packages/core/src/runtime/lifecycle.d.ts +0 -4
- package/dist/packages/core/src/runtime/lifecycle.js +0 -5
- package/dist/packages/core/src/types/adapters.d.ts +0 -14
- package/dist/packages/core/src/types/adapters.js +0 -1
- package/dist/packages/core/src/types/app.d.ts +0 -13
- package/dist/packages/core/src/types/app.js +0 -1
- package/dist/packages/core/src/types/config.d.ts +0 -8
- package/dist/packages/core/src/types/config.js +0 -1
- package/dist/packages/core/src/types/events.d.ts +0 -20
- package/dist/packages/core/src/types/events.js +0 -22
- package/dist/packages/core/src/types/module.d.ts +0 -15
- package/dist/packages/core/src/types/module.js +0 -1
package/README.md
CHANGED
|
@@ -118,6 +118,8 @@ Example `search` config in your app:
|
|
|
118
118
|
|
|
119
119
|
The module validates that `defaultProvider` and the referenced provider configuration exist, and that required fields for each adapter are present.
|
|
120
120
|
|
|
121
|
+
Note: this repository includes a top-level `.env.example` with recommended variables for Search and other providers; you can manage layer credentials from your main app's `.env` file. See `../.env.example`.
|
|
122
|
+
|
|
121
123
|
## UI Integrations (InstantSearch / Searchkit)
|
|
122
124
|
|
|
123
125
|
This layer includes bridges that let UI code use Algolia InstantSearch or Searchkit clients without coupling the UI to a particular backend provider. Use `createInstantSearchBridge` / `createSearchkitBridge` on the client, and `createSearchkitGraphQLHandler` on the server when exposing a GraphQL endpoint for Searchkit-server.
|
|
@@ -177,6 +179,35 @@ Environment variables supported (example for Meilisearch):
|
|
|
177
179
|
- `MEILI_INDEX=products`
|
|
178
180
|
- `MEILI_KEY=masterKey`
|
|
179
181
|
|
|
182
|
+
Searchkit / Search provider environment variables
|
|
183
|
+
|
|
184
|
+
- `SEARCHKIT_HOST` or `NUXT_PUBLIC_SEARCHKIT_HOST` — full host URL (e.g. `https://search.example.com`)
|
|
185
|
+
- Alternatively compose with:
|
|
186
|
+
- `SEARCHKIT_PROTOCOL` / `NUXT_PUBLIC_SEARCHKIT_PROTOCOL` (defaults to `http`)
|
|
187
|
+
- `SEARCHKIT_HOSTNAME` / `NUXT_PUBLIC_SEARCHKIT_HOSTNAME` (e.g. `search.example.com`)
|
|
188
|
+
- `SEARCHKIT_PORT` / `NUXT_PUBLIC_SEARCHKIT_PORT` (e.g. `9200`)
|
|
189
|
+
- Optional API key: `SEARCHKIT_API_KEY` or `NUXT_PUBLIC_SEARCHKIT_API_KEY`
|
|
190
|
+
|
|
191
|
+
Notes:
|
|
192
|
+
- Use the `NUXT_PUBLIC_` prefix for values that must be available in client-side code (public build). Keep API keys server-only when possible.
|
|
193
|
+
- The plugin logs a runtime validation message on startup if search provider configuration is missing, with examples of env vars to set.
|
|
194
|
+
|
|
195
|
+
Layer env conventions
|
|
196
|
+
|
|
197
|
+
- All layers use the same environment lookup convention: the code checks `KEY` and falls back to `NUXT_PUBLIC_KEY` when appropriate. This lets you manage provider credentials and endpoints centrally from your main application's `.env` file.
|
|
198
|
+
- Example `.env` entries in your main app to configure the Search layer:
|
|
199
|
+
|
|
200
|
+
```
|
|
201
|
+
SEARCHKIT_HOST=https://search.example.com
|
|
202
|
+
SEARCHKIT_API_KEY=server-only-key
|
|
203
|
+
# or compose:
|
|
204
|
+
SEARCHKIT_PROTOCOL=https
|
|
205
|
+
SEARCHKIT_HOSTNAME=search.example.com
|
|
206
|
+
SEARCHKIT_PORT=9200
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Because every layer follows this `KEY` / `NUXT_PUBLIC_KEY` pattern, you can place settings in the main app's `.env` and they will be available to the layer at runtime without editing layer files.
|
|
210
|
+
|
|
180
211
|
## Testing
|
|
181
212
|
|
|
182
213
|
Use the mock adapter in tests to avoid external dependencies:
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="autocomplete">
|
|
3
|
+
<input
|
|
4
|
+
v-model="input"
|
|
5
|
+
@input="onInput"
|
|
6
|
+
@keydown.enter.prevent="onSelectFirst"
|
|
7
|
+
:placeholder="placeholder"
|
|
8
|
+
class="autocomplete-input"
|
|
9
|
+
/>
|
|
10
|
+
<ul v-if="suggestions.length" class="autocomplete-list">
|
|
11
|
+
<li v-for="(s, i) in suggestions" :key="i" @click="select(s)">
|
|
12
|
+
<div class="title">{{ s.title || s.name || s.label || s.id }}</div>
|
|
13
|
+
<div class="subtitle" v-if="s.description">{{ s.description }}</div>
|
|
14
|
+
</li>
|
|
15
|
+
</ul>
|
|
16
|
+
</div>
|
|
17
|
+
</template>
|
|
18
|
+
|
|
19
|
+
<script setup lang="ts">
|
|
20
|
+
import { ref, watch } from 'vue'
|
|
21
|
+
import useSearchkit from '../../composables/useSearchkit'
|
|
22
|
+
|
|
23
|
+
const props = defineProps({ placeholder: { type: String, default: 'Search...' } })
|
|
24
|
+
const emits = defineEmits(['select', 'input'])
|
|
25
|
+
|
|
26
|
+
const { autocomplete } = useSearchkit()
|
|
27
|
+
const input = ref('')
|
|
28
|
+
const suggestions = ref<any[]>([])
|
|
29
|
+
let timer: any = null
|
|
30
|
+
|
|
31
|
+
async function onInput() {
|
|
32
|
+
emits('input', input.value)
|
|
33
|
+
clearTimeout(timer)
|
|
34
|
+
timer = setTimeout(async () => {
|
|
35
|
+
if (!input.value || input.value.length < 1) {
|
|
36
|
+
suggestions.value = []
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
suggestions.value = await autocomplete(input.value, 6)
|
|
40
|
+
}, 180)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function select(item: any) {
|
|
44
|
+
emits('select', item)
|
|
45
|
+
suggestions.value = []
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function onSelectFirst() {
|
|
49
|
+
if (suggestions.value.length) select(suggestions.value[0])
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
watch(input, (v) => {
|
|
53
|
+
if (!v) suggestions.value = []
|
|
54
|
+
})
|
|
55
|
+
</script>
|
|
56
|
+
|
|
57
|
+
<style scoped>
|
|
58
|
+
.autocomplete { position: relative; }
|
|
59
|
+
.autocomplete-list { position: absolute; left: 0; right: 0; background: white; z-index: 50; list-style: none; margin: 0; padding: 0; border: 1px solid #ddd; }
|
|
60
|
+
.autocomplete-list li { padding: 8px; cursor: pointer; }
|
|
61
|
+
.autocomplete-list li:hover { background: #f5f5f5; }
|
|
62
|
+
.autocomplete-input { width: 100%; padding: 8px; border: 1px solid #ccc; }
|
|
63
|
+
</style>
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="searchkit-search">
|
|
3
|
+
<div class="controls">
|
|
4
|
+
<SearchInput v-model="query" @search="onSearch" />
|
|
5
|
+
<autocomplete @input="onInput" @select="onSelect" />
|
|
6
|
+
|
|
7
|
+
<label>
|
|
8
|
+
<select v-model="ranking" @change="onRankingChange">
|
|
9
|
+
<option value="relevance">Relevance</option>
|
|
10
|
+
<option value="newest">Newest</option>
|
|
11
|
+
<option value="popularity">Popularity</option>
|
|
12
|
+
</select>
|
|
13
|
+
</label>
|
|
14
|
+
|
|
15
|
+
<label>
|
|
16
|
+
Semantic:
|
|
17
|
+
<input type="checkbox" v-model="semanticEnabled" @change="toggleSemantic" />
|
|
18
|
+
</label>
|
|
19
|
+
|
|
20
|
+
<label>
|
|
21
|
+
Per page:
|
|
22
|
+
<select v-model.number="perPage" @change="onPerPage">
|
|
23
|
+
<option :value="12">12</option>
|
|
24
|
+
<option :value="24">24</option>
|
|
25
|
+
<option :value="48">48</option>
|
|
26
|
+
</select>
|
|
27
|
+
</label>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<div class="filters">
|
|
31
|
+
<div v-for="(agg, key) in facets" :key="key" class="facet">
|
|
32
|
+
<strong>{{ key }}</strong>
|
|
33
|
+
<ul>
|
|
34
|
+
<li v-for="(b, idx) in bucketsFor(agg)" :key="idx">{{ b.key }} ({{ b.doc_count }})</li>
|
|
35
|
+
</ul>
|
|
36
|
+
</div>
|
|
37
|
+
</div>
|
|
38
|
+
|
|
39
|
+
<ResultList :hits="hits" :loading="loading">
|
|
40
|
+
<template #item="{ hit }">
|
|
41
|
+
<div>
|
|
42
|
+
<h3>{{ hit.title || hit.name }}</h3>
|
|
43
|
+
<p v-if="hit.description">{{ hit.description }}</p>
|
|
44
|
+
<small v-if="hit.location">{{ hit.location.lat }}, {{ hit.location.lon }}</small>
|
|
45
|
+
</div>
|
|
46
|
+
</template>
|
|
47
|
+
</ResultList>
|
|
48
|
+
|
|
49
|
+
<pagination :page="page" :totalPages="totalPages" @change="onPageChange" />
|
|
50
|
+
</div>
|
|
51
|
+
</template>
|
|
52
|
+
|
|
53
|
+
<script setup lang="ts">
|
|
54
|
+
import { watch, computed } from 'vue'
|
|
55
|
+
import useSearchkit from '../../composables/useSearchkit'
|
|
56
|
+
import SearchInput from '../molecules/SearchInput.vue'
|
|
57
|
+
import autocomplete from './autocomplete.vue'
|
|
58
|
+
import ResultList from '../molecules/resultList.vue'
|
|
59
|
+
import pagination from '../molecules/pagination.vue'
|
|
60
|
+
|
|
61
|
+
const { query, hits, loading, page, perPage, facets, totalPages, search, setPage, setPerPage, setSemantic, ranking } = useSearchkit()
|
|
62
|
+
|
|
63
|
+
const semanticEnabled = computed({ get: () => false, set: (v: boolean) => setSemantic(v) })
|
|
64
|
+
|
|
65
|
+
function onSearch() {
|
|
66
|
+
page.value = 1
|
|
67
|
+
return search()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function onInput(val: string) {
|
|
71
|
+
query.value = val
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function onSelect(item: any) {
|
|
75
|
+
// if suggestion contains a query text or id, perform targeted search
|
|
76
|
+
query.value = item.query || item.title || item.name || query.value
|
|
77
|
+
search()
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function onPageChange(p: number) {
|
|
81
|
+
return setPage(p)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function onPerPage(e: any) {
|
|
85
|
+
return setPerPage(parseInt(e.target.value || '12', 10))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function toggleSemantic(e: Event) {
|
|
89
|
+
const target = e.target as HTMLInputElement
|
|
90
|
+
return setSemantic(target.checked)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function onRankingChange(e: Event) {
|
|
94
|
+
const target = e.target as HTMLSelectElement
|
|
95
|
+
ranking.value = target.value as any
|
|
96
|
+
return search()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function bucketsFor(agg: any) {
|
|
100
|
+
return (agg && (agg.buckets || agg.terms || agg)) || []
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// initialize
|
|
104
|
+
search()
|
|
105
|
+
|
|
106
|
+
watch(query, () => {
|
|
107
|
+
// do not auto-search on every keystroke by default; controlled via SearchInput
|
|
108
|
+
})
|
|
109
|
+
</script>
|
|
110
|
+
|
|
111
|
+
<style scoped>
|
|
112
|
+
.controls { display:flex; gap:12px; align-items:center; flex-wrap:wrap }
|
|
113
|
+
.filters { margin-top:12px; display:flex; gap:16px }
|
|
114
|
+
.facet { background:#fafafa; padding:8px; border:1px solid #eee }
|
|
115
|
+
</style>
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<nav class="pagination" v-if="totalPages > 1">
|
|
3
|
+
<button :disabled="page <= 1" @click="$emit('change', page - 1)">Prev</button>
|
|
4
|
+
<span>Page {{ page }} / {{ totalPages }}</span>
|
|
5
|
+
<button :disabled="page >= totalPages" @click="$emit('change', page + 1)">Next</button>
|
|
6
|
+
</nav>
|
|
7
|
+
</template>
|
|
8
|
+
|
|
9
|
+
<script setup lang="ts">
|
|
10
|
+
const props = defineProps({ page: { type: Number, required: true }, totalPages: { type: Number, required: true } })
|
|
11
|
+
const emits = defineEmits(['change'])
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<style scoped>
|
|
15
|
+
.pagination { display:flex; gap:8px; align-items:center; }
|
|
16
|
+
.pagination button[disabled] { opacity: 0.5 }
|
|
17
|
+
</style>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="result-list">
|
|
3
|
+
<div v-if="loading">Loading…</div>
|
|
4
|
+
<div v-else>
|
|
5
|
+
<div class="hits" v-if="hits.length">
|
|
6
|
+
<div v-for="(hit, idx) in hits" :key="hit._meta || idx" class="hit-item">
|
|
7
|
+
<slot name="item" :hit="hit">
|
|
8
|
+
<h3>{{ hit.title || hit.name || hit.id }}</h3>
|
|
9
|
+
<p v-if="hit.description">{{ hit.description }}</p>
|
|
10
|
+
</slot>
|
|
11
|
+
</div>
|
|
12
|
+
</div>
|
|
13
|
+
<div v-else class="no-results">No results</div>
|
|
14
|
+
</div>
|
|
15
|
+
</div>
|
|
16
|
+
</template>
|
|
17
|
+
|
|
18
|
+
<script setup lang="ts">
|
|
19
|
+
const props = defineProps({ hits: { type: Array, default: () => [] }, loading: { type: Boolean, default: false } })
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<style scoped>
|
|
23
|
+
.hit-item { padding: 12px; border-bottom: 1px solid #eee; }
|
|
24
|
+
.no-results { color: #777; padding: 12px; }
|
|
25
|
+
</style>
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { ref, computed } from 'vue'
|
|
2
|
+
import { useNuxtApp } from 'nuxt/app'
|
|
3
|
+
|
|
4
|
+
export function useSearchkit() {
|
|
5
|
+
const nuxt = useNuxtApp() as any
|
|
6
|
+
const client: any = nuxt.$searchClient
|
|
7
|
+
const indexName: string = (nuxt.$searchIndexName as string) || 'default'
|
|
8
|
+
const helpers: any = nuxt.$searchHelpers || {}
|
|
9
|
+
|
|
10
|
+
const query = ref('')
|
|
11
|
+
const hits = ref<any[]>([])
|
|
12
|
+
const total = ref(0)
|
|
13
|
+
const loading = ref(false)
|
|
14
|
+
const page = ref(1)
|
|
15
|
+
const perPage = ref(12)
|
|
16
|
+
const sortBy = ref<string | null>(null)
|
|
17
|
+
const facets = ref<Record<string, any>>({})
|
|
18
|
+
const geo = ref<{ lat?: number; lon?: number; distanceKm?: number }>({})
|
|
19
|
+
const semantic = ref(false)
|
|
20
|
+
const ranking = ref<'relevance' | 'newest' | 'popularity' | 'custom'>('relevance')
|
|
21
|
+
|
|
22
|
+
async function search() {
|
|
23
|
+
if (!client || typeof client.search !== 'function') {
|
|
24
|
+
throw new Error('Search client is not available or does not implement `search`')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
loading.value = true
|
|
28
|
+
const from = (page.value - 1) * perPage.value
|
|
29
|
+
|
|
30
|
+
// Build params — aim for compatibility with Searchkit/Elasticsearch via instantsearch client.
|
|
31
|
+
const params: any = {
|
|
32
|
+
params: {
|
|
33
|
+
q: query.value || '*',
|
|
34
|
+
size: perPage.value,
|
|
35
|
+
from,
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Sorting strategies
|
|
40
|
+
if (ranking.value === 'newest') {
|
|
41
|
+
params.params.sort = 'created_at:desc'
|
|
42
|
+
} else if (ranking.value === 'popularity') {
|
|
43
|
+
params.params.sort = 'popularity:desc'
|
|
44
|
+
} else if (sortBy.value) {
|
|
45
|
+
params.params.sort = sortBy.value
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Geo filter: translate into an Elasticsearch geo_distance filter in body
|
|
49
|
+
if (geo.value.lat && geo.value.lon && geo.value.distanceKm) {
|
|
50
|
+
params.body = params.body || {}
|
|
51
|
+
params.body.query = params.body.query || { bool: { must: [{ match_all: {} }], filter: [] } }
|
|
52
|
+
const filter = { geo_distance: { distance: `${geo.value.distanceKm}km`, location: { lat: geo.value.lat, lon: geo.value.lon } } }
|
|
53
|
+
params.body.query.bool.filter.push(filter)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Semantic hint flag — some backends will honour this to run semantic/rerank
|
|
57
|
+
if (semantic.value) {
|
|
58
|
+
params.params.semantic = true
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const results = await client.search([{ indexName, ...params }])
|
|
63
|
+
const res = results && results[0]
|
|
64
|
+
if (!res) {
|
|
65
|
+
hits.value = []
|
|
66
|
+
total.value = 0
|
|
67
|
+
facets.value = {}
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Best-effort parsing for common instantsearch-style responses
|
|
72
|
+
const rawHits = (res.hits && res.hits.hits) || (res.hits && res.hits) || res.hits
|
|
73
|
+
let parsedHits: any[] = []
|
|
74
|
+
if (Array.isArray(rawHits)) {
|
|
75
|
+
// ES style hits.hits
|
|
76
|
+
parsedHits = rawHits.map((h: any) => (h._source ? { ...h._source, _meta: h._id } : h))
|
|
77
|
+
} else if (res.hits && res.hits.hits) {
|
|
78
|
+
parsedHits = res.hits.hits.map((h: any) => (h._source ? { ...h._source, _meta: h._id } : h))
|
|
79
|
+
} else if (res.hits) {
|
|
80
|
+
parsedHits = res.hits
|
|
81
|
+
} else {
|
|
82
|
+
parsedHits = []
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
total.value = (res.hits && (res.hits.total?.value ?? res.hits.total)) || res.nbHits || 0
|
|
86
|
+
|
|
87
|
+
// Aggregations / facets parsing (normalize via plugin helper when available)
|
|
88
|
+
const rawAggs = res.aggregations || res.facets || {}
|
|
89
|
+
if (helpers && typeof helpers.mapAggregations === 'function') {
|
|
90
|
+
try {
|
|
91
|
+
facets.value = helpers.mapAggregations(rawAggs)
|
|
92
|
+
} catch (e) {
|
|
93
|
+
facets.value = rawAggs
|
|
94
|
+
}
|
|
95
|
+
} else {
|
|
96
|
+
facets.value = rawAggs
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// If semantic rerank is enabled, attempt to call the plugin helper to reorder results.
|
|
100
|
+
if (semantic.value && helpers && typeof helpers.semanticRerank === 'function') {
|
|
101
|
+
try {
|
|
102
|
+
const reranked = await helpers.semanticRerank(parsedHits, query.value)
|
|
103
|
+
hits.value = Array.isArray(reranked) ? reranked : parsedHits
|
|
104
|
+
} catch (e) {
|
|
105
|
+
// fallback to parsedHits on error
|
|
106
|
+
hits.value = parsedHits
|
|
107
|
+
}
|
|
108
|
+
} else {
|
|
109
|
+
hits.value = parsedHits
|
|
110
|
+
}
|
|
111
|
+
} finally {
|
|
112
|
+
loading.value = false
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function autocomplete(input: string, limit = 6) {
|
|
117
|
+
if (!client) return []
|
|
118
|
+
try {
|
|
119
|
+
// Use a small search request to act as suggestions
|
|
120
|
+
const params = {
|
|
121
|
+
params: { q: input || '*', size: limit },
|
|
122
|
+
}
|
|
123
|
+
const result = await client.search([{ indexName, ...params }])
|
|
124
|
+
const r = result && result[0]
|
|
125
|
+
const suggestionHits = (r && ((r.hits && r.hits.hits) || r.hits)) || []
|
|
126
|
+
return Array.isArray(suggestionHits)
|
|
127
|
+
? suggestionHits.map((h: any) => (h._source ? h._source : h))
|
|
128
|
+
: []
|
|
129
|
+
} catch (e) {
|
|
130
|
+
return []
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function setPage(p: number) {
|
|
135
|
+
page.value = p
|
|
136
|
+
return search()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function setPerPage(n: number) {
|
|
140
|
+
perPage.value = n
|
|
141
|
+
page.value = 1
|
|
142
|
+
return search()
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function setSort(sort: string | null) {
|
|
146
|
+
sortBy.value = sort
|
|
147
|
+
return search()
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function setGeo(lat: number, lon: number, distanceKm = 50) {
|
|
151
|
+
geo.value = { lat, lon, distanceKm }
|
|
152
|
+
page.value = 1
|
|
153
|
+
return search()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function setSemantic(enabled: boolean) {
|
|
157
|
+
semantic.value = enabled
|
|
158
|
+
return search()
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const totalPages = computed(() => Math.max(1, Math.ceil((total.value || 0) / perPage.value)))
|
|
162
|
+
|
|
163
|
+
// Provide a normalized facets structure for consumers: { facetName: [{ key, doc_count }, ...] }
|
|
164
|
+
const normalizedFacets = computed(() => {
|
|
165
|
+
const out: Record<string, Array<{ key: string; doc_count: number }>> = {}
|
|
166
|
+
try {
|
|
167
|
+
for (const k of Object.keys(facets.value || {})) {
|
|
168
|
+
const v = (facets.value as any)[k]
|
|
169
|
+
if (!v) {
|
|
170
|
+
out[k] = []
|
|
171
|
+
continue
|
|
172
|
+
}
|
|
173
|
+
if (Array.isArray(v)) {
|
|
174
|
+
out[k] = v.map((b: any) => ({ key: b.key ?? b.value ?? String(b), doc_count: b.doc_count ?? b.count ?? 0 }))
|
|
175
|
+
} else if (v.buckets) {
|
|
176
|
+
out[k] = v.buckets.map((b: any) => ({ key: b.key ?? b.value, doc_count: b.doc_count ?? 0 }))
|
|
177
|
+
} else if (v.terms) {
|
|
178
|
+
out[k] = v.terms.map((b: any) => ({ key: b.key ?? b.value, doc_count: b.doc_count ?? 0 }))
|
|
179
|
+
} else {
|
|
180
|
+
// generic object — try to map entries
|
|
181
|
+
out[k] = Object.keys(v).map((kk) => ({ key: kk, doc_count: (v as any)[kk] }))
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
} catch (e) {
|
|
185
|
+
return {}
|
|
186
|
+
}
|
|
187
|
+
return out
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
return {
|
|
191
|
+
// state
|
|
192
|
+
query,
|
|
193
|
+
hits,
|
|
194
|
+
total,
|
|
195
|
+
loading,
|
|
196
|
+
page,
|
|
197
|
+
perPage,
|
|
198
|
+
sortBy,
|
|
199
|
+
facets,
|
|
200
|
+
geo,
|
|
201
|
+
semantic,
|
|
202
|
+
ranking,
|
|
203
|
+
totalPages,
|
|
204
|
+
|
|
205
|
+
// actions
|
|
206
|
+
search,
|
|
207
|
+
autocomplete,
|
|
208
|
+
setPage,
|
|
209
|
+
setPerPage,
|
|
210
|
+
setSort,
|
|
211
|
+
setGeo,
|
|
212
|
+
setSemantic,
|
|
213
|
+
normalizedFacets,
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export default useSearchkit
|
package/app/plugins/search.js
CHANGED
|
@@ -9,12 +9,95 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|
|
9
9
|
indexName = getIndexName()
|
|
10
10
|
} catch (e) {
|
|
11
11
|
// fallback placeholder to avoid build/runtime crash when not configured
|
|
12
|
+
// Provide a clearer validation message to help users wire env vars
|
|
12
13
|
// eslint-disable-next-line no-console
|
|
13
14
|
console.warn('Search client not configured:', e.message || e)
|
|
15
|
+
// Helpful runtime guidance
|
|
16
|
+
// eslint-disable-next-line no-console
|
|
17
|
+
console.info('Search layer requires configuring SEARCHKIT_HOST or SEARCHKIT_HOSTNAME/SEARCHKIT_PORT (or their NUXT_PUBLIC_ variants). Example:')
|
|
18
|
+
// eslint-disable-next-line no-console
|
|
19
|
+
console.info(' SEARCHKIT_HOST=https://search.example.com')
|
|
20
|
+
// eslint-disable-next-line no-console
|
|
21
|
+
console.info(' or:')
|
|
22
|
+
// eslint-disable-next-line no-console
|
|
23
|
+
console.info(' SEARCHKIT_PROTOCOL=https')
|
|
24
|
+
// eslint-disable-next-line no-console
|
|
25
|
+
console.info(' SEARCHKIT_HOSTNAME=search.example.com')
|
|
26
|
+
// eslint-disable-next-line no-console
|
|
27
|
+
console.info(' SEARCHKIT_PORT=9200')
|
|
14
28
|
searchClient = { _client: 'searchkit-fallback' }
|
|
15
29
|
indexName = 'default'
|
|
16
30
|
}
|
|
17
31
|
|
|
32
|
+
// Helper: normalize aggregations/ facets into { name: buckets[] }
|
|
33
|
+
function mapAggregations(aggs) {
|
|
34
|
+
const mapped = {}
|
|
35
|
+
if (!aggs) return mapped
|
|
36
|
+
for (const key of Object.keys(aggs)) {
|
|
37
|
+
const a = aggs[key]
|
|
38
|
+
if (!a) continue
|
|
39
|
+
if (Array.isArray(a)) mapped[key] = a
|
|
40
|
+
else if (a.buckets) mapped[key] = a.buckets
|
|
41
|
+
else if (a.terms) mapped[key] = a.terms
|
|
42
|
+
else mapped[key] = a
|
|
43
|
+
}
|
|
44
|
+
return mapped
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Basic suggestion helper that performs a small search to return candidate documents
|
|
48
|
+
async function suggest(q, opts = {}) {
|
|
49
|
+
if (!searchClient || typeof searchClient.search !== 'function') return []
|
|
50
|
+
const size = opts.size || 6
|
|
51
|
+
const params = { params: { q: q || '*', size } }
|
|
52
|
+
try {
|
|
53
|
+
const resArr = await searchClient.search([{ indexName, ...params }])
|
|
54
|
+
const res = resArr && resArr[0]
|
|
55
|
+
const hits = (res && ((res.hits && res.hits.hits) || res.hits)) || []
|
|
56
|
+
return Array.isArray(hits) ? hits.map((h) => (h._source ? h._source : h)) : []
|
|
57
|
+
} catch (e) {
|
|
58
|
+
// eslint-disable-next-line no-console
|
|
59
|
+
console.warn('Suggest failed', e && e.message ? e.message : e)
|
|
60
|
+
return []
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Server-side semantic reranking: call embeddings API if configured, otherwise noop.
|
|
65
|
+
// Expects an embeddings API that can accept { query, docs } and return either
|
|
66
|
+
// { ranked: [ids...] } or { scores: [number...] } aligned with docs.
|
|
67
|
+
async function semanticRerank(hitsArray, queryStr) {
|
|
68
|
+
const url = process.env.NUXT_PUBLIC_EMBEDDINGS_API_URL || process.env.EMBEDDINGS_API_URL
|
|
69
|
+
const key = process.env.NUXT_PUBLIC_EMBEDDINGS_API_KEY || process.env.EMBEDDINGS_API_KEY
|
|
70
|
+
if (!url || !key) return hitsArray
|
|
71
|
+
try {
|
|
72
|
+
const docs = hitsArray.slice(0, 100).map((h) => ({ id: h._meta || h.id || h._id, text: h.title || h.name || h.description || '' }))
|
|
73
|
+
const body = { query: queryStr, docs }
|
|
74
|
+
const resp = await (globalThis.fetch || fetch)(url, {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` },
|
|
77
|
+
body: JSON.stringify(body),
|
|
78
|
+
})
|
|
79
|
+
if (!resp.ok) return hitsArray
|
|
80
|
+
const json = await resp.json()
|
|
81
|
+
if (Array.isArray(json.ranked)) {
|
|
82
|
+
const idToHit = new Map(hitsArray.map((h) => [(h._meta || h.id || h._id), h]))
|
|
83
|
+
const ordered = json.ranked.map((id) => idToHit.get(id)).filter(Boolean)
|
|
84
|
+
const remaining = hitsArray.filter((h) => !ordered.includes(h))
|
|
85
|
+
return [...ordered, ...remaining]
|
|
86
|
+
}
|
|
87
|
+
if (Array.isArray(json.scores)) {
|
|
88
|
+
const paired = hitsArray.map((h, i) => ({ h, s: json.scores[i] ?? 0 }))
|
|
89
|
+
paired.sort((a, b) => b.s - a.s)
|
|
90
|
+
return paired.map((p) => p.h)
|
|
91
|
+
}
|
|
92
|
+
return hitsArray
|
|
93
|
+
} catch (e) {
|
|
94
|
+
// eslint-disable-next-line no-console
|
|
95
|
+
console.warn('semanticRerank failed', e && e.message ? e.message : e)
|
|
96
|
+
return hitsArray
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
18
100
|
nuxtApp.provide('searchClient', searchClient)
|
|
19
101
|
nuxtApp.provide('searchIndexName', indexName)
|
|
102
|
+
nuxtApp.provide('searchHelpers', { suggest, mapAggregations, semanticRerank })
|
|
20
103
|
})
|
package/app/utils/env.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
let shared: any = null
|
|
2
|
+
try {
|
|
3
|
+
// Try to use the centralized shared helper at runtime when available
|
|
4
|
+
// Use require to avoid TypeScript resolving the module at compile-time for this layer project
|
|
5
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
6
|
+
shared = require('../../../shared/app/utils/env')
|
|
7
|
+
} catch (e) {
|
|
8
|
+
shared = null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function getEnv(key: string, fallback?: string): string | undefined {
|
|
12
|
+
if (shared && typeof shared.getEnv === 'function') return shared.getEnv(key, fallback)
|
|
13
|
+
if (!key) return fallback
|
|
14
|
+
const direct = process.env[key]
|
|
15
|
+
if (direct !== undefined) return direct
|
|
16
|
+
const publicKey = `NUXT_PUBLIC_${key}`
|
|
17
|
+
if (process.env[publicKey] !== undefined) return process.env[publicKey]
|
|
18
|
+
return fallback
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getEnvBool(key: string, fallback = false): boolean {
|
|
22
|
+
const v = getEnv(key)
|
|
23
|
+
if (v === undefined) return fallback
|
|
24
|
+
const low = String(v).toLowerCase()
|
|
25
|
+
return ['1', 'true', 'yes', 'on'].includes(low)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export default { getEnv, getEnvBool }
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// This is intentionally minimal — it throws when no configuration is present
|
|
3
3
|
// so the caller can fall back gracefully.
|
|
4
4
|
import type { SearchClient } from 'instantsearch.js'
|
|
5
|
+
import { getEnv } from '../env'
|
|
5
6
|
|
|
6
7
|
export function getIndexName(): string {
|
|
7
8
|
return (
|
|
@@ -11,28 +12,42 @@ export function getIndexName(): string {
|
|
|
11
12
|
)
|
|
12
13
|
}
|
|
13
14
|
|
|
15
|
+
function buildHostFromParts(): string | null {
|
|
16
|
+
const explicit = getEnv('SEARCHKIT_HOST')
|
|
17
|
+
if (explicit) return explicit
|
|
18
|
+
|
|
19
|
+
// Allow composing host from protocol/hostname/port path
|
|
20
|
+
const protocol = (getEnv('SEARCHKIT_PROTOCOL') || 'http').replace(/:\/\//, '')
|
|
21
|
+
const hostname = getEnv('SEARCHKIT_HOSTNAME')
|
|
22
|
+
const port = getEnv('SEARCHKIT_PORT')
|
|
23
|
+
if (!hostname) return null
|
|
24
|
+
return `${protocol}://${hostname}${port ? `:${port}` : ''}`
|
|
25
|
+
}
|
|
26
|
+
|
|
14
27
|
export function getSearchClient(): SearchClient {
|
|
15
|
-
const host =
|
|
28
|
+
const host = buildHostFromParts()
|
|
16
29
|
if (!host) {
|
|
17
|
-
throw new Error('Searchkit host not configured via SEARCHKIT_HOST or NUXT_PUBLIC_SEARCHKIT_HOST')
|
|
30
|
+
throw new Error('Searchkit host not configured via SEARCHKIT_HOST, SEARCHKIT_HOSTNAME, or NUXT_PUBLIC_SEARCHKIT_HOST')
|
|
18
31
|
}
|
|
19
32
|
|
|
33
|
+
// Optionally pass an API key if configured
|
|
34
|
+
const apiKey = getEnv('SEARCHKIT_API_KEY')
|
|
35
|
+
|
|
20
36
|
// Defer importing heavy searchkit/instantsearch client until runtime.
|
|
21
37
|
// Consumers can replace this implementation with a provider-specific client.
|
|
22
38
|
// Here we attempt to use @searchkit/instantsearch-client if available.
|
|
23
39
|
// If not present, let the import fail so the plugin can fallback.
|
|
24
|
-
// The actual creation API differs between versions; adjust as needed.
|
|
25
40
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
26
41
|
const createClient = require('@searchkit/instantsearch-client')
|
|
27
42
|
if (!createClient) {
|
|
28
43
|
throw new Error('@searchkit/instantsearch-client is not installed')
|
|
29
44
|
}
|
|
30
45
|
|
|
31
|
-
// Create a minimal client. The factory API may vary;
|
|
32
|
-
// placeholder that should be adapted to your Searchkit configuration.
|
|
33
|
-
// If your Searchkit installation exposes a helper, prefer using that.
|
|
46
|
+
// Create a minimal client. The factory API may vary; adapt to your Searchkit config.
|
|
34
47
|
try {
|
|
35
|
-
|
|
48
|
+
const opts: any = { host }
|
|
49
|
+
if (apiKey) opts.apiKey = apiKey
|
|
50
|
+
return createClient(opts)
|
|
36
51
|
} catch (e: any) {
|
|
37
52
|
// rethrow with context
|
|
38
53
|
throw new Error('Failed to create Searchkit client: ' + (e?.message || e))
|