@mframework/layer-search 0.0.1

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.
Files changed (42) hide show
  1. package/README.md +269 -0
  2. package/app/components/README.md +3 -0
  3. package/app/components/features/autocomplete.vue +63 -0
  4. package/app/components/features/searchkitSearch.vue +115 -0
  5. package/app/components/filters/filters.vue +0 -0
  6. package/app/components/molecules/SearchInput.vue +39 -0
  7. package/app/components/molecules/pagination.vue +21 -0
  8. package/app/components/molecules/resultList.vue +48 -0
  9. package/app/components/search.vue +87 -0
  10. package/app/composables/adapter/mock.ts +26 -0
  11. package/app/composables/adapter/types.ts +21 -0
  12. package/app/composables/bridges/instantsearch.ts +21 -0
  13. package/app/composables/bridges/react.ts +39 -0
  14. package/app/composables/bridges/searchkit-server.ts +51 -0
  15. package/app/composables/bridges/searchkit.ts +88 -0
  16. package/app/composables/bridges/vue.ts +38 -0
  17. package/app/composables/cli.ts +70 -0
  18. package/app/composables/config/schema.ts +16 -0
  19. package/app/composables/config.ts +20 -0
  20. package/app/composables/core/Facets.ts +9 -0
  21. package/app/composables/core/Filters.ts +13 -0
  22. package/app/composables/core/Pipeline.ts +20 -0
  23. package/app/composables/core/QueryBuilder.ts +27 -0
  24. package/app/composables/core/SearchContext.ts +54 -0
  25. package/app/composables/core/SearchManager.ts +26 -0
  26. package/app/composables/events.ts +5 -0
  27. package/app/composables/index.ts +12 -0
  28. package/app/composables/module.ts +48 -0
  29. package/app/composables/types/api/global-search.ts +8 -0
  30. package/app/composables/types.d.ts +12 -0
  31. package/app/composables/useSearchkit.ts +218 -0
  32. package/app/composables/utils/health.ts +13 -0
  33. package/app/composables/utils/normalizers.ts +6 -0
  34. package/app/pages/results.vue +85 -0
  35. package/app/plugins/instantsearch.js +35 -0
  36. package/app/plugins/search.js +103 -0
  37. package/app/plugins/searchClient.ts +108 -0
  38. package/app/utils/env.ts +28 -0
  39. package/app/utils/search/client.ts +53 -0
  40. package/nuxt.config.ts +11 -0
  41. package/package.json +36 -0
  42. package/tsconfig.json +15 -0
@@ -0,0 +1,218 @@
1
+ import { ref, computed } from 'vue'
2
+ export type SearchHit = Record<string, any>
3
+ import { useNuxtApp } from 'nuxt/app'
4
+
5
+ export function useSearchkit() {
6
+ const nuxt = useNuxtApp() as any
7
+ const client: any = nuxt.$searchClient
8
+ const indexName: string = (nuxt.$searchIndexName as string) || 'default'
9
+ const helpers: any = nuxt.$searchHelpers || {}
10
+
11
+ const query = ref('')
12
+ const hits = ref<SearchHit[]>([])
13
+ const total = ref(0)
14
+ const loading = ref(false)
15
+ const page = ref(1)
16
+ const perPage = ref(12)
17
+ const sortBy = ref<string | null>(null)
18
+ const facets = ref<Record<string, any>>({})
19
+ const geo = ref<{ lat?: number; lon?: number; distanceKm?: number }>({})
20
+ const semantic = ref(false)
21
+ const ranking = ref<'relevance' | 'newest' | 'popularity' | 'custom'>('relevance')
22
+
23
+ async function search() {
24
+ if (!client || typeof client.search !== 'function') {
25
+ throw new Error('Search client is not available or does not implement `search`')
26
+ }
27
+
28
+ loading.value = true
29
+ const from = (page.value - 1) * perPage.value
30
+
31
+ // Build params — aim for compatibility with Searchkit/Elasticsearch via instantsearch client.
32
+ const params: any = {
33
+ params: {
34
+ q: query.value || '*',
35
+ size: perPage.value,
36
+ from,
37
+ },
38
+ }
39
+
40
+ // Sorting strategies
41
+ if (ranking.value === 'newest') {
42
+ params.params.sort = 'created_at:desc'
43
+ } else if (ranking.value === 'popularity') {
44
+ params.params.sort = 'popularity:desc'
45
+ } else if (sortBy.value) {
46
+ params.params.sort = sortBy.value
47
+ }
48
+
49
+ // Geo filter: translate into an Elasticsearch geo_distance filter in body
50
+ if (geo.value.lat && geo.value.lon && geo.value.distanceKm) {
51
+ params.body = params.body || {}
52
+ params.body.query = params.body.query || { bool: { must: [{ match_all: {} }], filter: [] } }
53
+ const filter = { geo_distance: { distance: `${geo.value.distanceKm}km`, location: { lat: geo.value.lat, lon: geo.value.lon } } }
54
+ params.body.query.bool.filter.push(filter)
55
+ }
56
+
57
+ // Semantic hint flag — some backends will honour this to run semantic/rerank
58
+ if (semantic.value) {
59
+ params.params.semantic = true
60
+ }
61
+
62
+ try {
63
+ const results = await client.search([{ indexName, ...params }])
64
+ const res = results && results[0]
65
+ if (!res) {
66
+ hits.value = []
67
+ total.value = 0
68
+ facets.value = {}
69
+ return
70
+ }
71
+
72
+ // Best-effort parsing for common instantsearch-style responses
73
+ const rawHits = (res.hits && res.hits.hits) || (res.hits && res.hits) || res.hits
74
+ let parsedHits: any[] = []
75
+ if (Array.isArray(rawHits)) {
76
+ // ES style hits.hits
77
+ parsedHits = rawHits.map((h: any) => (h._source ? { ...h._source, _meta: h._id } : h))
78
+ } else if (res.hits && res.hits.hits) {
79
+ parsedHits = res.hits.hits.map((h: any) => (h._source ? { ...h._source, _meta: h._id } : h))
80
+ } else if (res.hits) {
81
+ parsedHits = res.hits
82
+ } else {
83
+ parsedHits = []
84
+ }
85
+
86
+ total.value = (res.hits && (res.hits.total?.value ?? res.hits.total)) || res.nbHits || 0
87
+
88
+ // Aggregations / facets parsing (normalize via plugin helper when available)
89
+ const rawAggs = res.aggregations || res.facets || {}
90
+ if (helpers && typeof helpers.mapAggregations === 'function') {
91
+ try {
92
+ facets.value = helpers.mapAggregations(rawAggs)
93
+ } catch (e) {
94
+ facets.value = rawAggs
95
+ }
96
+ } else {
97
+ facets.value = rawAggs
98
+ }
99
+
100
+ // If semantic rerank is enabled, attempt to call the plugin helper to reorder results.
101
+ if (semantic.value && helpers && typeof helpers.semanticRerank === 'function') {
102
+ try {
103
+ const reranked = await helpers.semanticRerank(parsedHits, query.value)
104
+ hits.value = Array.isArray(reranked) ? reranked : parsedHits
105
+ } catch (e) {
106
+ // fallback to parsedHits on error
107
+ hits.value = parsedHits
108
+ }
109
+ } else {
110
+ hits.value = parsedHits
111
+ }
112
+ } finally {
113
+ loading.value = false
114
+ }
115
+ }
116
+
117
+ async function autocomplete(input: string, limit = 6) {
118
+ if (!client) return []
119
+ try {
120
+ // Use a small search request to act as suggestions
121
+ const params = {
122
+ params: { q: input || '*', size: limit },
123
+ }
124
+ const result = await client.search([{ indexName, ...params }])
125
+ const r = result && result[0]
126
+ const suggestionHits = (r && ((r.hits && r.hits.hits) || r.hits)) || []
127
+ return Array.isArray(suggestionHits)
128
+ ? suggestionHits.map((h: any) => (h._source ? h._source : h))
129
+ : []
130
+ } catch (e) {
131
+ return []
132
+ }
133
+ }
134
+
135
+ function setPage(p: number) {
136
+ page.value = p
137
+ return search()
138
+ }
139
+
140
+ function setPerPage(n: number) {
141
+ perPage.value = n
142
+ page.value = 1
143
+ return search()
144
+ }
145
+
146
+ function setSort(sort: string | null) {
147
+ sortBy.value = sort
148
+ return search()
149
+ }
150
+
151
+ function setGeo(lat: number, lon: number, distanceKm = 50) {
152
+ geo.value = { lat, lon, distanceKm }
153
+ page.value = 1
154
+ return search()
155
+ }
156
+
157
+ function setSemantic(enabled: boolean) {
158
+ semantic.value = enabled
159
+ return search()
160
+ }
161
+
162
+ const totalPages = computed(() => Math.max(1, Math.ceil((total.value || 0) / perPage.value)))
163
+
164
+ // Provide a normalized facets structure for consumers: { facetName: [{ key, doc_count }, ...] }
165
+ const normalizedFacets = computed(() => {
166
+ const out: Record<string, Array<{ key: string; doc_count: number }>> = {}
167
+ try {
168
+ for (const k of Object.keys(facets.value || {})) {
169
+ const v = (facets.value as any)[k]
170
+ if (!v) {
171
+ out[k] = []
172
+ continue
173
+ }
174
+ if (Array.isArray(v)) {
175
+ out[k] = v.map((b: any) => ({ key: b.key ?? b.value ?? String(b), doc_count: b.doc_count ?? b.count ?? 0 }))
176
+ } else if (v.buckets) {
177
+ out[k] = v.buckets.map((b: any) => ({ key: b.key ?? b.value, doc_count: b.doc_count ?? 0 }))
178
+ } else if (v.terms) {
179
+ out[k] = v.terms.map((b: any) => ({ key: b.key ?? b.value, doc_count: b.doc_count ?? 0 }))
180
+ } else {
181
+ // generic object — try to map entries
182
+ out[k] = Object.keys(v).map((kk) => ({ key: kk, doc_count: (v as any)[kk] }))
183
+ }
184
+ }
185
+ } catch (e) {
186
+ return {}
187
+ }
188
+ return out
189
+ })
190
+
191
+ return {
192
+ // state
193
+ query,
194
+ hits,
195
+ total,
196
+ loading,
197
+ page,
198
+ perPage,
199
+ sortBy,
200
+ facets,
201
+ geo,
202
+ semantic,
203
+ ranking,
204
+ totalPages,
205
+
206
+ // actions
207
+ search,
208
+ autocomplete,
209
+ setPage,
210
+ setPerPage,
211
+ setSort,
212
+ setGeo,
213
+ setSemantic,
214
+ normalizedFacets,
215
+ }
216
+ }
217
+
218
+ export default useSearchkit
@@ -0,0 +1,13 @@
1
+ import type { MeeoviSearchAdapter } from '../adapter/types'
2
+
3
+ export async function checkAdapterHealth(adapter?: MeeoviSearchAdapter) {
4
+ if (!adapter) return { ok: false, error: 'no adapter' }
5
+ try {
6
+ const res = await adapter.search({ term: '', page: 1, pageSize: 1, filters: {} } as any)
7
+ return { ok: true, total: res?.total ?? null }
8
+ } catch (e: any) {
9
+ return { ok: false, error: e?.message || String(e) }
10
+ }
11
+ }
12
+
13
+ export default checkAdapterHealth
@@ -0,0 +1,6 @@
1
+ export function normalizeOpenSearchHit(hit: any) {
2
+ return {
3
+ id: hit._id,
4
+ ...hit._source
5
+ }
6
+ }
@@ -0,0 +1,85 @@
1
+ <template>
2
+ <div class="resultsPage">
3
+ <ais-instant-search :search-client="searchClient" :index-name="indexName">
4
+ <header class="search-header">
5
+ <ais-search-box />
6
+ <ais-stats />
7
+ <ais-sort-by :items="sortingOptions" />
8
+ </header>
9
+
10
+ <div class="search-body">
11
+ <aside class="filters">
12
+ <ais-refinement-list attribute="category" />
13
+ <ais-refinement-list attribute="brand" />
14
+ <ais-range-input attribute="price" />
15
+ <ais-rating-menu attribute="rating_rounded" />
16
+ </aside>
17
+
18
+ <main class="results">
19
+ <ais-hits>
20
+ <template #item="{ item }">
21
+ <article @click="openResult(item)" class="hit">
22
+ <h3><ais-highlight attribute="title" :hit="item" /></h3>
23
+ <p><ais-snippet attribute="plot" :hit="item" /></p>
24
+ </article>
25
+ </template>
26
+ </ais-hits>
27
+
28
+ <ais-pagination />
29
+ </main>
30
+ </div>
31
+ </ais-instant-search>
32
+ </div>
33
+ </template>
34
+
35
+ <script setup>
36
+ import { ref } from 'vue'
37
+ import { useRouter, useRoute } from 'vue-router'
38
+ import { useRuntimeConfig } from '#imports'
39
+ import Client from '@searchkit/instantsearch-client'
40
+ import { getSearchClient, getIndexName } from '../utils/search/client'
41
+
42
+ import 'instantsearch.css/themes/satellite-min.css'
43
+
44
+ // initialize search client using existing helper (falls back safely)
45
+ let searchClient
46
+ let indexName = 'default'
47
+ try {
48
+ searchClient = getSearchClient()
49
+ indexName = getIndexName()
50
+ } catch (e) {
51
+ // fallback placeholder
52
+ // eslint-disable-next-line no-console
53
+ console.warn('Search client unavailable:', e.message || e)
54
+ searchClient = { _client: 'searchkit-fallback' }
55
+ }
56
+
57
+ const router = useRouter()
58
+ const route = useRoute()
59
+
60
+ function openResult(item) {
61
+ const id = item._id ?? item.id ?? ''
62
+ const title = item.title ?? item.name ?? ''
63
+ router.push({ path: route.path, query: { ...route.query, id, title } })
64
+ }
65
+
66
+ const sortingOptions = [
67
+ { value: indexName, label: 'Featured' },
68
+ { value: `${indexName}:price:asc`, label: 'Price: Low to High' },
69
+ { value: `${indexName}:price:desc`, label: 'Price: High to Low' },
70
+ { value: `${indexName}:rating:desc`, label: 'Rating: High to Low' }
71
+ ]
72
+
73
+ definePageMeta({ layout: 'nolive' })
74
+ useHead({ title: 'Search Results' })
75
+ </script>
76
+
77
+ <style scoped>
78
+ .search-body {
79
+ display: flex;
80
+ gap: 1rem;
81
+ }
82
+ .filters { width: 260px }
83
+ .results { flex: 1 }
84
+ .hit { padding: 0.75rem; border-bottom: 1px solid #eee }
85
+ </style>
@@ -0,0 +1,35 @@
1
+ import { h } from 'vue'
2
+ import { defineNuxtPlugin } from '#imports'
3
+ import InstantSearch from 'vue-instantsearch/vue3/es'
4
+
5
+ export default defineNuxtPlugin(nuxtApp => {
6
+ // Register the original InstantSearch plugin (registers other components)
7
+ nuxtApp.vueApp.use(InstantSearch)
8
+ // Capture the original registered component (if the plugin registered it)
9
+ const OriginalInstantSearch = nuxtApp.vueApp.component('ais-instant-search') || InstantSearch
10
+
11
+ // Override `ais-instant-search` with a small wrapper that sets
12
+ // future.preserveSharedStateOnUnmount = true by default to silence
13
+ // the deprecation warning and adopt the new behavior.
14
+ const AisInstantSearchWrapper = {
15
+ name: 'AisInstantSearch',
16
+ inheritAttrs: false,
17
+ setup(_, { attrs, slots }) {
18
+ const mergedAttrs = {
19
+ ...attrs,
20
+ future: {
21
+ preserveSharedStateOnUnmount: true,
22
+ ...(attrs.future || {})
23
+ }
24
+ }
25
+ if (process.env.NODE_ENV !== 'production') {
26
+ // helpful debug when running locally
27
+ // eslint-disable-next-line no-console
28
+ console.debug('[instantsearch] merged future options', mergedAttrs.future)
29
+ }
30
+ return () => h(OriginalInstantSearch, mergedAttrs, slots)
31
+ }
32
+ }
33
+
34
+ nuxtApp.vueApp.component('ais-instant-search', AisInstantSearchWrapper)
35
+ })
@@ -0,0 +1,103 @@
1
+ import { defineNuxtPlugin } from '#imports'
2
+ import { getSearchClient, getIndexName } from '../utils/search/client'
3
+
4
+ export default defineNuxtPlugin((nuxtApp) => {
5
+ let searchClient
6
+ let indexName
7
+ try {
8
+ searchClient = getSearchClient()
9
+ indexName = getIndexName()
10
+ } catch (e) {
11
+ // fallback placeholder to avoid build/runtime crash when not configured
12
+ // Provide a clearer validation message to help users wire env vars
13
+ // eslint-disable-next-line no-console
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')
28
+ searchClient = { _client: 'searchkit-fallback' }
29
+ indexName = 'default'
30
+ }
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
+
100
+ nuxtApp.provide('searchClient', searchClient)
101
+ nuxtApp.provide('searchIndexName', indexName)
102
+ nuxtApp.provide('searchHelpers', { suggest, mapAggregations, semanticRerank })
103
+ })
@@ -0,0 +1,108 @@
1
+ import { defineNuxtPlugin, useRuntimeConfig } from 'nuxt/app'
2
+
3
+ export default defineNuxtPlugin(() => {
4
+ const config = useRuntimeConfig() as any
5
+ const endpoint: string | undefined = config.search?.endpoint || process.env.SEARCH_ENDPOINT
6
+ const indexName: string = config.search?.indexName || process.env.SEARCH_INDEX_NAME || 'default'
7
+
8
+ // Simple helpers that consumers may rely on
9
+ const helpers = {
10
+ mapAggregations(aggs: any) {
11
+ return aggs || {}
12
+ },
13
+ async semanticRerank(hits: any[], _query: string) {
14
+ // no-op rerank: return input
15
+ return hits
16
+ },
17
+ }
18
+
19
+ // HTTP-backed ES-compatible client when endpoint is configured
20
+ const httpClient = endpoint
21
+ ? {
22
+ async search(requests: Array<any>) {
23
+ const base = endpoint.replace(/\/+$/, '')
24
+ return Promise.all(
25
+ requests.map(async (req: any) => {
26
+ const idx = req.indexName || indexName
27
+ // Build a basic ES query body from params
28
+ let body: any = {}
29
+
30
+ // If params contains q/size/from, map to simple query_string
31
+ if (req.params) {
32
+ const p = req.params
33
+ const q = (p.q || (p.params && p.params.q)) || '*'
34
+ const size = p.size || (p.params && p.params.size) || 10
35
+ const from = p.from || 0
36
+ if (!req.body) body = {}
37
+ if (q && q !== '*') {
38
+ body.query = { query_string: { query: q } }
39
+ } else if (!req.body) {
40
+ body.query = { match_all: {} }
41
+ }
42
+ body.size = size
43
+ body.from = from
44
+ }
45
+
46
+ if (req.body) {
47
+ body = { ...body, ...req.body }
48
+ }
49
+
50
+ // fetch with retries and exponential backoff
51
+ async function fetchWithRetry(url: string, init: RequestInit, retries = 2, backoff = 200): Promise<any> {
52
+ try {
53
+ const res = await fetch(url, init)
54
+ if (!res.ok) {
55
+ const text = await res.text().catch(() => '')
56
+ if (retries > 0) {
57
+ await new Promise((r) => setTimeout(r, backoff))
58
+ return fetchWithRetry(url, init, retries - 1, backoff * 2)
59
+ }
60
+ return { hits: [], error: `HTTP ${res.status}: ${text}` }
61
+ }
62
+ return await res.json()
63
+ } catch (err) {
64
+ if (retries > 0) {
65
+ await new Promise((r) => setTimeout(r, backoff))
66
+ return fetchWithRetry(url, init, retries - 1, backoff * 2)
67
+ }
68
+ return { hits: [], error: String(err) }
69
+ }
70
+ }
71
+
72
+ const resJson = await fetchWithRetry(`${base}/${encodeURIComponent(idx)}/_search`, {
73
+ method: 'POST',
74
+ headers: { 'content-type': 'application/json' },
75
+ body: JSON.stringify(body),
76
+ })
77
+ // Ensure a consistent shape on error
78
+ if (!resJson) return { hits: [] }
79
+ return resJson
80
+ })
81
+ )
82
+ },
83
+ }
84
+ : null
85
+
86
+ // In-memory fallback client for environments without an endpoint
87
+ const stubClient = {
88
+ async search(requests: Array<any>) {
89
+ return requests.map((req: any) => {
90
+ const q = (req.params && (req.params.q || (req.params.params && req.params.params.q))) || '*'
91
+ const hits = [
92
+ { _source: { title: q && q !== '*' ? `Result for ${q}` : 'Sample result', description: 'Stubbed result' } },
93
+ ]
94
+ return { hits: { hits }, aggregations: {}, nbHits: hits.length }
95
+ })
96
+ },
97
+ }
98
+
99
+ const client = httpClient || stubClient
100
+
101
+ return {
102
+ provide: {
103
+ $searchClient: client,
104
+ $searchIndexName: indexName,
105
+ $searchHelpers: helpers,
106
+ },
107
+ }
108
+ })
@@ -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 }
@@ -0,0 +1,53 @@
1
+ // Lightweight helper to obtain a search client and index name for InstantSearch
2
+ // This version NEVER throws — it returns null when search is not configured.
3
+
4
+ import type { SearchClient } from 'instantsearch.js'
5
+ import { getEnv } from '../env'
6
+
7
+ export function getIndexName(): string {
8
+ return (
9
+ process.env.NUXT_PUBLIC_SEARCH_INDEX ||
10
+ process.env.SEARCH_INDEX ||
11
+ 'default'
12
+ )
13
+ }
14
+
15
+ function buildHostFromParts(): string | null {
16
+ const explicit = getEnv('SEARCHKIT_HOST')
17
+ if (explicit) return explicit
18
+
19
+ const protocol = (getEnv('SEARCHKIT_PROTOCOL') || 'http').replace(/:\/\//, '')
20
+ const hostname = getEnv('SEARCHKIT_HOSTNAME')
21
+ const port = getEnv('SEARCHKIT_PORT')
22
+
23
+ if (!hostname) return null
24
+
25
+ return `${protocol}://${hostname}${port ? `:${port}` : ''}`
26
+ }
27
+
28
+ export function getSearchClient(): SearchClient | null {
29
+ const host = buildHostFromParts()
30
+ if (!host) {
31
+ console.warn('[search] Searchkit host not configured — search disabled')
32
+ return null
33
+ }
34
+
35
+ let createClient: any
36
+ try {
37
+ createClient = require('@searchkit/instantsearch-client')
38
+ } catch {
39
+ console.warn('[search] @searchkit/instantsearch-client not installed — search disabled')
40
+ return null
41
+ }
42
+
43
+ try {
44
+ const opts: any = { host }
45
+ const apiKey = getEnv('SEARCHKIT_API_KEY')
46
+ if (apiKey) opts.apiKey = apiKey
47
+
48
+ return createClient(opts)
49
+ } catch (e: any) {
50
+ console.warn('[search] Failed to create Searchkit client:', e?.message || e)
51
+ return null
52
+ }
53
+ }
package/nuxt.config.ts ADDED
@@ -0,0 +1,11 @@
1
+ import {
2
+ defineNuxtConfig
3
+ } from 'nuxt/config'
4
+
5
+ export default defineNuxtConfig({
6
+ $meta: {
7
+ name: 'search',
8
+ },
9
+
10
+ runtimeConfig: {}
11
+ })