@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,21 @@
1
+ import type { BuiltSearchQuery } from '../core/QueryBuilder'
2
+
3
+ export interface MeeoviSearchItem {
4
+ id: string
5
+ title: string
6
+ description?: string
7
+ price?: number
8
+ [key: string]: any
9
+ }
10
+
11
+ export type MeeoviSearchAdapter = {
12
+ id?: string
13
+ type?: string
14
+ config?: any
15
+ search(query: BuiltSearchQuery | any): Promise<{
16
+ items: MeeoviSearchItem[]
17
+ total: number
18
+ page?: number
19
+ pageSize?: number
20
+ }>
21
+ }
@@ -0,0 +1,21 @@
1
+ import type { SearchManager } from '../core/SearchManager'
2
+ import type { MeeoviSearchItem } from '../adapter/types'
3
+
4
+ export function createInstantSearchBridge(manager: SearchManager) {
5
+ return {
6
+ async searchFunction(helper: any) {
7
+ manager.context.setQuery(helper.state.query || '')
8
+ manager.context.setPage(helper.state.page || 1)
9
+ // map filters if needed from helper.state
10
+
11
+ return manager.search().then((result: any) => {
12
+ helper.setResults({
13
+ hits: result.items,
14
+ nbHits: result.total,
15
+ page: result.page - 1,
16
+ hitsPerPage: result.pageSize
17
+ })
18
+ })
19
+ }
20
+ }
21
+ }
@@ -0,0 +1,39 @@
1
+ import { useState, useCallback } from 'react'
2
+ import type { SearchManager } from '../core/SearchManager'
3
+
4
+ // React bridge: accept a SearchManager instance to stay adapter-agnostic.
5
+ export function useReactSearch(manager?: SearchManager) {
6
+ if (!manager) throw new Error('SearchManager instance is required for React bridge')
7
+
8
+ const [query, setQuery] = useState(manager.context.state.query)
9
+ const [page, setPage] = useState(manager.context.state.page)
10
+ const [pageSize, setPageSize] = useState(manager.context.state.pageSize)
11
+ const [results, setResults] = useState<any[]>([])
12
+ const [total, setTotal] = useState(0)
13
+ const [loading, setLoading] = useState(false)
14
+
15
+ const search = useCallback(async () => {
16
+ setLoading(true)
17
+ manager.context.setQuery(query)
18
+ manager.context.setPage(page)
19
+ manager.context.setPageSize(pageSize)
20
+
21
+ const res = await manager.search()
22
+ setResults(res.items)
23
+ setTotal(res.total)
24
+ setLoading(false)
25
+ }, [query, page, pageSize, manager])
26
+
27
+ return {
28
+ query,
29
+ setQuery,
30
+ page,
31
+ setPage,
32
+ pageSize,
33
+ setPageSize,
34
+ results,
35
+ total,
36
+ loading,
37
+ search
38
+ }
39
+ }
@@ -0,0 +1,51 @@
1
+ import { graphql, buildSchema } from 'graphql'
2
+ import type { SearchManager } from '../core/SearchManager'
3
+
4
+ const schema = buildSchema(`
5
+ type Hit { id: ID, title: String, description: String, price: Float }
6
+ type SearchResult {
7
+ items: [Hit]
8
+ total: Int
9
+ page: Int
10
+ pageSize: Int
11
+ }
12
+ type Query {
13
+ search(term: String, page: Int, pageSize: Int, filters: String): SearchResult
14
+ }
15
+ `)
16
+
17
+ function parseFilters(filters?: string) {
18
+ if (!filters) return {}
19
+ const entries = String(filters).split(' AND ').map((s) => s.split(':'))
20
+ return Object.fromEntries(entries.map(([k, v]) => [k, v?.replace(/^"|"$/g, '')]))
21
+ }
22
+
23
+ export function createSearchkitGraphQLHandler(manager: SearchManager) {
24
+ const root: any = {
25
+ search: async ({ term, page, pageSize, filters }: any) => {
26
+ manager.context.setQuery(term || '')
27
+ manager.context.setPage(page || 1)
28
+ manager.context.setPageSize(pageSize || manager.context.state.pageSize)
29
+ if (filters) {
30
+ manager.context.state.filters = parseFilters(filters) as any
31
+ }
32
+
33
+ const res = await manager.search()
34
+
35
+ return {
36
+ items: res.items,
37
+ total: res.total,
38
+ page: res.page,
39
+ pageSize: res.pageSize
40
+ }
41
+ }
42
+ }
43
+
44
+ return async function handler(req: any, res: any) {
45
+ const { query, variables } = req.body || {}
46
+ const result = await graphql({ schema, source: query, rootValue: root, variableValues: variables })
47
+ res.json(result)
48
+ }
49
+ }
50
+
51
+ export default createSearchkitGraphQLHandler
@@ -0,0 +1,88 @@
1
+ import type { SearchManager } from '../core/SearchManager'
2
+ import type { BuiltSearchQuery } from '../core/QueryBuilder'
3
+
4
+ type InstantSearchRequest = {
5
+ indexName: string
6
+ params: Record<string, any>
7
+ }
8
+
9
+ type InstantSearchResult = {
10
+ results: {
11
+ hits: any[]
12
+ nbHits: number
13
+ page: number
14
+ hitsPerPage: number
15
+ }
16
+ }
17
+
18
+ function mapRequestToQuery(params: Record<string, any>, existing?: Partial<BuiltSearchQuery>) {
19
+ const q: Partial<BuiltSearchQuery> = existing ?? {}
20
+
21
+ if (typeof params.query === 'string') q.term = params.query
22
+ if (typeof params.page === 'number') q.page = params.page + 1 // instantsearch pages are 0-based
23
+ if (typeof params.hitsPerPage === 'number') q.pageSize = params.hitsPerPage
24
+
25
+ // Map simple filters from InstantSearch `facets` or `filters` param
26
+ if (params.filters && typeof params.filters === 'string') {
27
+ // basic parsing: "field:value AND other:value"
28
+ const entries = params.filters.split(' AND ').map((s: string) => s.split(':'))
29
+ q.filters = Object.fromEntries(entries.map(([k, v]) => [k, (v ?? '').replace(/^"|"$/g, '')]))
30
+ }
31
+
32
+ return q as BuiltSearchQuery
33
+ }
34
+
35
+ export function createSearchkitBridge(manager: SearchManager) {
36
+ return {
37
+ // InstantSearch client "search" method
38
+ async search(requests: InstantSearchRequest[]): Promise<InstantSearchResult[]> {
39
+ // Support single-index or multi-index by mapping each request to a search call
40
+ const results: InstantSearchResult[] = []
41
+
42
+ for (const req of requests) {
43
+ const params = req.params || {}
44
+
45
+ const built = mapRequestToQuery(params)
46
+
47
+ // apply to manager context
48
+ manager.context.setQuery(built.term || '')
49
+ manager.context.setPage(built.page || 1)
50
+ manager.context.setPageSize(built.pageSize || manager.context.state.pageSize)
51
+ if (built.filters) {
52
+ // clear and set simple filters
53
+ manager.context.state.filters = built.filters as any
54
+ }
55
+
56
+ const res = await manager.search()
57
+
58
+ results.push({
59
+ results: {
60
+ hits: res.items,
61
+ nbHits: res.total,
62
+ page: (res.page || 1) - 1,
63
+ hitsPerPage: res.pageSize || manager.context.state.pageSize
64
+ }
65
+ })
66
+ }
67
+
68
+ return results
69
+ },
70
+
71
+ // Minimal support for Searchkit/InstantSearch facet search
72
+ async searchForFacetValues(indexName: string, params: any) {
73
+ // Map to a query with term for facet matching
74
+ const built = mapRequestToQuery({ query: params.facetQuery })
75
+
76
+ manager.context.setQuery(built.term || '')
77
+ manager.context.setPage(1)
78
+ const res = await manager.search()
79
+
80
+ return {
81
+ facetHits: (res.items || []).map((item: any) => ({ value: item[params.attribute], count: 0 })),
82
+ exhaustiveFacetsCount: true
83
+ }
84
+ }
85
+ }
86
+ }
87
+
88
+ export default createSearchkitBridge
@@ -0,0 +1,38 @@
1
+ import { ref, computed } from 'vue'
2
+ import type { SearchManager } from '../core/SearchManager'
3
+
4
+ // Vue bridge: accept a SearchManager instance to stay adapter-agnostic.
5
+ export function useSearch(manager?: SearchManager) {
6
+ if (!manager) throw new Error('SearchManager instance is required for Vue bridge')
7
+ const mgr = manager as SearchManager
8
+
9
+ const query = ref(mgr.context.state.query)
10
+ const page = ref(mgr.context.state.page)
11
+ const pageSize = ref(mgr.context.state.pageSize)
12
+
13
+ const results = ref<any[]>([])
14
+ const total = ref(0)
15
+ const loading = ref(false)
16
+
17
+ async function run() {
18
+ loading.value = true
19
+ mgr.context.setQuery(query.value)
20
+ mgr.context.setPage(page.value)
21
+ mgr.context.setPageSize(pageSize.value)
22
+
23
+ const res = await mgr.search()
24
+ results.value = res.items
25
+ total.value = res.total
26
+ loading.value = false
27
+ }
28
+
29
+ return {
30
+ query,
31
+ page,
32
+ pageSize,
33
+ results: computed(() => results.value),
34
+ total: computed(() => total.value),
35
+ loading: computed(() => loading.value),
36
+ search: run
37
+ }
38
+ }
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'node:fs'
4
+ import path from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import searchModule from './module'
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url))
9
+
10
+ async function run() {
11
+ const [,, command, arg] = process.argv
12
+
13
+ const defaultProvider = process.env.SEARCH_PROVIDER === 'meilisearch' ? 'meilisearch' : 'opensearch'
14
+
15
+ // Minimal CLI behaviour: prefer direct provider endpoints via env vars
16
+ const provider = defaultProvider
17
+ let search: any = null
18
+ // If a provider-specific client is available via env vars, use it; otherwise exit.
19
+ if (provider === 'meilisearch' && process.env.MEILI_HOST) {
20
+ search = { id: 'search:meilisearch', type: 'search' }
21
+ } else if (provider === 'opensearch' && process.env.OPENSEARCH_ENDPOINT) {
22
+ search = { id: 'search:opensearch', type: 'search' }
23
+ }
24
+
25
+ if (!search) {
26
+ console.error('No search provider configured via environment')
27
+ process.exit(1)
28
+ }
29
+
30
+ if (command === 'warmup') {
31
+ console.log('Warming up search provider...')
32
+ await search.search({ term: 'warmup' })
33
+ console.log('Warmup complete')
34
+ return
35
+ }
36
+
37
+ if (command === 'index') {
38
+ if (!arg) {
39
+ console.error('Missing file path: meeovi-search index <file.json>')
40
+ process.exit(1)
41
+ }
42
+
43
+ const filePath = path.resolve(process.cwd(), arg)
44
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf8'))
45
+
46
+ console.log(`Indexing ${data.length} items...`)
47
+
48
+ if (search.id === 'search:opensearch') {
49
+ // TODO: implement bulk indexing
50
+ }
51
+
52
+ if (search.id === 'search:meilisearch') {
53
+ await fetch(`${process.env.MEILI_HOST}/indexes/${process.env.MEILI_INDEX}/documents`, {
54
+ method: 'POST',
55
+ headers: {
56
+ 'Content-Type': 'application/json',
57
+ Authorization: `Bearer ${process.env.MEILI_KEY}`
58
+ },
59
+ body: JSON.stringify(data)
60
+ })
61
+ }
62
+
63
+ console.log('Indexing complete')
64
+ return
65
+ }
66
+
67
+ console.log(`Unknown command: ${command}`)
68
+ }
69
+
70
+ run()
@@ -0,0 +1,16 @@
1
+ export interface SearchModuleConfig {
2
+ defaultProvider: 'opensearch' | 'meilisearch'
3
+ providers: Record<string, unknown>
4
+ }
5
+
6
+ export function validateSearchConfig(config: SearchModuleConfig) {
7
+ if (!config.defaultProvider) {
8
+ throw new Error('[@mframework/search] Missing defaultProvider')
9
+ }
10
+
11
+ if (!config.providers[config.defaultProvider]) {
12
+ throw new Error(
13
+ `[@mframework/search] Provider "${config.defaultProvider}" not found in config.providers`
14
+ )
15
+ }
16
+ }
@@ -0,0 +1,20 @@
1
+
2
+ export interface SearchConfig {
3
+ searchProvider: string
4
+ searchUrl?: string
5
+ apiKey?: string
6
+ }
7
+
8
+ let config: SearchConfig = {
9
+ searchProvider: 'searchkit',
10
+ searchUrl: '',
11
+ apiKey: ''
12
+ }
13
+
14
+ export function setSearchConfig(newConfig: Partial<SearchConfig>) {
15
+ config = { ...config, ...newConfig }
16
+ }
17
+
18
+ export function getSearchConfig(): SearchConfig {
19
+ return config
20
+ }
@@ -0,0 +1,9 @@
1
+ export const Facets = {
2
+ terms(field: string) {
3
+ return { type: 'terms', field }
4
+ },
5
+
6
+ range(field: string, ranges: { from?: number; to?: number }[]) {
7
+ return { type: 'range', field, ranges }
8
+ }
9
+ }
@@ -0,0 +1,13 @@
1
+ export const Filters = {
2
+ term(field: string, value: string) {
3
+ return { type: 'term', field, value }
4
+ },
5
+
6
+ range(field: string, min?: number, max?: number) {
7
+ return { type: 'range', field, min, max }
8
+ },
9
+
10
+ boolean(field: string, value: boolean) {
11
+ return { type: 'boolean', field, value }
12
+ }
13
+ }
@@ -0,0 +1,20 @@
1
+ export class SearchPipeline {
2
+ private before: ((q: any) => any)[] = []
3
+ private after: ((r: any) => any)[] = []
4
+
5
+ useBefore(fn: (query: any) => any) {
6
+ this.before.push(fn)
7
+ }
8
+
9
+ useAfter(fn: (result: any) => any) {
10
+ this.after.push(fn)
11
+ }
12
+
13
+ runBefore(query: any) {
14
+ return this.before.reduce((q, fn) => fn(q), query)
15
+ }
16
+
17
+ runAfter(result: any) {
18
+ return this.after.reduce((r, fn) => fn(r), result)
19
+ }
20
+ }
@@ -0,0 +1,27 @@
1
+ import { SearchContext } from './SearchContext'
2
+
3
+ export interface BuiltSearchQuery {
4
+ term: string
5
+ page: number
6
+ pageSize: number
7
+ sort?: string
8
+ filters: Record<string, any>
9
+ }
10
+
11
+ export class QueryBuilder {
12
+ private context: SearchContext
13
+
14
+ constructor(context: SearchContext) {
15
+ this.context = context
16
+ }
17
+
18
+ build(): BuiltSearchQuery {
19
+ return {
20
+ term: this.context.state.query,
21
+ page: this.context.state.page,
22
+ pageSize: this.context.state.pageSize,
23
+ sort: this.context.state.sort,
24
+ filters: this.context.state.filters
25
+ }
26
+ }
27
+ }
@@ -0,0 +1,54 @@
1
+ export interface SearchContextState {
2
+ query: string
3
+ page: number
4
+ pageSize: number
5
+ sort?: string
6
+ filters: Record<string, any>
7
+ }
8
+
9
+ export class SearchContext {
10
+ state: SearchContextState
11
+
12
+ constructor(initial?: Partial<SearchContextState>) {
13
+ this.state = {
14
+ query: '',
15
+ page: 1,
16
+ pageSize: 20,
17
+ filters: {},
18
+ ...initial
19
+ }
20
+ }
21
+
22
+ setQuery(query: string) {
23
+ this.state.query = query
24
+ }
25
+
26
+ setPage(page: number) {
27
+ this.state.page = page
28
+ }
29
+
30
+ setPageSize(size: number) {
31
+ this.state.pageSize = size
32
+ }
33
+
34
+ setSort(sort: string | undefined) {
35
+ this.state.sort = sort
36
+ }
37
+
38
+ setFilter(key: string, value: any) {
39
+ this.state.filters[key] = value
40
+ }
41
+
42
+ removeFilter(key: string) {
43
+ delete this.state.filters[key]
44
+ }
45
+
46
+ reset() {
47
+ this.state = {
48
+ query: '',
49
+ page: 1,
50
+ pageSize: 20,
51
+ filters: {}
52
+ }
53
+ }
54
+ }
@@ -0,0 +1,26 @@
1
+ import { SearchContext, type SearchContextState } from './SearchContext'
2
+ import { QueryBuilder } from './QueryBuilder'
3
+ import { SearchPipeline } from './Pipeline'
4
+
5
+ export class SearchManager {
6
+ context: SearchContext
7
+ pipeline: SearchPipeline
8
+ adapter: any
9
+
10
+ constructor(adapter: any, initial?: Partial<SearchContextState>) {
11
+ this.context = new SearchContext(initial)
12
+ this.pipeline = new SearchPipeline()
13
+ this.adapter = adapter
14
+ }
15
+
16
+ async search() {
17
+ const builder = new QueryBuilder(this.context)
18
+ let query = builder.build()
19
+
20
+ query = this.pipeline.runBefore(query)
21
+
22
+ const result = await this.adapter.search(query as any)
23
+
24
+ return this.pipeline.runAfter(result)
25
+ }
26
+ }
@@ -0,0 +1,5 @@
1
+ // Search-specific event types
2
+ export interface SearchEvents {
3
+ 'search:query': { term: string }
4
+ 'search:results': { term: string; total: number }
5
+ }
@@ -0,0 +1,12 @@
1
+ export * from './module'
2
+ export * from './adapter/types'
3
+ export * from './core/SearchManager'
4
+ export * from './core/SearchContext'
5
+ export * from './core/Filters'
6
+ export * from './core/Facets'
7
+ export * from './bridges/instantsearch'
8
+ export * from './bridges/vue'
9
+ export * from './bridges/react'
10
+ export * from './bridges/searchkit'
11
+ export * from './bridges/searchkit-server'
12
+ export * from './utils/health'
@@ -0,0 +1,48 @@
1
+ import { validateSearchConfig, type SearchModuleConfig } from './config/schema'
2
+ import { SearchManager } from './core/SearchManager'
3
+
4
+ // Minimal, runtime-agnostic module shim. The real runtime may provide
5
+ // adapters via ctx.getAdapter('search'), in which case we create a SearchManager.
6
+ export default {
7
+ id: 'search',
8
+ adapters: {},
9
+
10
+ async setup(ctx: any) {
11
+ const config: SearchModuleConfig | undefined = ctx?.config?.search
12
+ if (!config) return
13
+
14
+ // validate when possible
15
+ try {
16
+ validateSearchConfig(config)
17
+ } catch (e) {
18
+ // Ignore validation errors at runtime — config may be partial during startup.
19
+ }
20
+
21
+ const adapter = ctx?.getAdapter ? ctx.getAdapter('search') : undefined
22
+ if (adapter) {
23
+ ctx.searchManager = new SearchManager(adapter)
24
+ }
25
+
26
+ // Listen for app readiness or adapter registration when an event bus exists
27
+ const bus = ctx?.eventBus
28
+ if (bus && typeof bus.on === 'function') {
29
+ bus.on('app:ready', () => {
30
+ if (!ctx.searchManager) {
31
+ const runtimeAdapter = ctx.getAdapter('search')
32
+ if (runtimeAdapter) ctx.searchManager = new SearchManager(runtimeAdapter)
33
+ }
34
+ })
35
+
36
+ bus.on('adapter:registered', (payload: any) => {
37
+ try {
38
+ if (payload?.key === 'search' && !ctx.searchManager) {
39
+ const runtimeAdapter = ctx.getAdapter('search')
40
+ if (runtimeAdapter) ctx.searchManager = new SearchManager(runtimeAdapter)
41
+ }
42
+ } catch (e) {
43
+ /* noop */
44
+ }
45
+ })
46
+ }
47
+ }
48
+ }
@@ -0,0 +1,8 @@
1
+ export interface GlobalSearchResult {
2
+ id?: string;
3
+ title?: string;
4
+ type?: string;
5
+ description?: string;
6
+ image?: string;
7
+ url?: string;
8
+ }
@@ -0,0 +1,12 @@
1
+ declare module '#imports' {
2
+ export function useRuntimeConfig(): { public?: { searchUrl?: string } }
3
+ }
4
+
5
+ declare module '@searchkit/client' {
6
+ export class SearchkitClient {
7
+ constructor(config?: { url?: string; host?: string })
8
+ query(...args: unknown[]): unknown
9
+ fetch(...args: unknown[]): unknown
10
+ }
11
+ export type SearchkitClientConfig = { url?: string; host?: string }
12
+ }