├── docs ├── .npmrc ├── content │ ├── 2.routing │ │ ├── _dir.yml │ │ └── 1.index.md │ ├── 5.roadmap │ │ ├── _dir.yml │ │ └── 1.index.md │ ├── 3.components │ │ ├── _dir.yml │ │ ├── 06.ais-stats.md │ │ ├── 09.ais-search-box.md │ │ ├── 03.ais-hits.md │ │ ├── 01.ais-instant-search.md │ │ ├── 14.ais-pagination.md │ │ ├── 02.ais-infinite-hits.md │ │ ├── 08.ais-configure.md │ │ ├── 07.ais-toggle-refinement.md │ │ ├── 05.ais-sort-by.md │ │ ├── 10.ais-index.md │ │ ├── 16.ais-menu-select.md │ │ ├── 04.ais-refinement-list.md │ │ ├── 11.ais-range-input.md │ │ ├── 16.ais-menu.md │ │ ├── 15.ais-hierarchical-menu.md │ │ ├── 12.ais-clear-refinements.md │ │ ├── 16.ais-autocomplete.md │ │ ├── 17.ais-rating-menu.md │ │ ├── 13.ais-current-refinements.md │ │ ├── 17.ais-query-rule-custom-data.md │ │ ├── 20.ais-panel.md │ │ └── 18.ais-numeric-menu.md │ ├── 4.composables │ │ ├── _dir.yml │ │ └── 1.use-ais-instant-search.md │ ├── 1.getting-started │ │ ├── _dir.yml │ │ ├── 2.installation.md │ │ ├── 1.index.md │ │ └── 3.usage.md │ └── index.yml ├── .eslintignore ├── renovate.json ├── server │ ├── tsconfig.json │ └── api │ │ └── search.json.get.ts ├── bun.lockb ├── public │ ├── favicon.ico │ ├── social-card.png │ ├── swiftsearch-dark.svg │ └── swiftsearch.svg ├── tsconfig.json ├── .env.example ├── .editorconfig ├── .gitignore ├── .eslintrc.cjs ├── layouts │ └── docs.vue ├── components │ ├── Footer.vue │ ├── OgImage │ │ └── OgImageDocs.vue │ └── Header.vue ├── tailwind.config.ts ├── package.json ├── error.vue ├── nuxt.config.ts ├── app.vue ├── pages │ ├── index.vue │ └── [...slug].vue ├── app.config.ts └── README.md ├── .eslintignore ├── .npmrc ├── tsconfig.json ├── playground ├── tsconfig.json ├── server │ ├── tsconfig.json │ └── api │ │ └── testApi.ts ├── bun.lockb ├── nuxt.config.ts ├── app │ └── router.options.ts ├── components │ └── Product.server.vue ├── pages │ ├── nosearch2.vue │ ├── nosearch.vue │ ├── [brand].vue │ ├── instantsearch.vue │ ├── pagination │ │ ├── index.vue │ │ └── page │ │ │ └── [page].vue │ ├── autocomplete.vue │ ├── search.vue │ ├── test │ │ └── [...catchall].vue │ └── index.vue ├── app.vue ├── package.json └── composables │ ├── useStateMapping.ts │ └── useCustomRouting.ts ├── bun.lockb ├── test ├── fixtures │ ├── parity │ │ ├── tsconfig.json │ │ ├── package.json │ │ ├── app.vue │ │ ├── nuxt.config.ts │ │ ├── app │ │ │ └── router.options.ts │ │ └── pages │ │ │ ├── swift │ │ │ ├── router │ │ │ │ ├── [brand].vue │ │ │ │ └── index.vue │ │ │ └── full.vue │ │ │ └── original │ │ │ └── full.vue │ └── basic │ │ ├── package.json │ │ ├── app.vue │ │ ├── nuxt.config.ts │ │ └── pages │ │ ├── instantsearch.vue │ │ └── swiftsearch.vue ├── snapshots │ ├── infinitehits │ └── refinementlist ├── utils │ └── html.ts ├── parity.test.ts └── routing.test.ts ├── .eslintrc ├── .editorconfig ├── vitest.config.ts ├── src ├── runtime │ ├── composables │ │ ├── useAisIndex.ts │ │ ├── useAisConfigure.ts │ │ ├── useSuit.ts │ │ ├── useAisStats.ts │ │ ├── useAisSortBy.ts │ │ ├── useAisQueryRuleCustomData.ts │ │ ├── useAisHits.ts │ │ ├── useAisSearchBox.ts │ │ ├── useAisPagination.ts │ │ ├── useAisAutocomplete.ts │ │ ├── useAisClearRefinements.ts │ │ ├── useAisToggleRefinement.ts │ │ ├── useAisCurrentRefinements.ts │ │ ├── useAisInfiniteHits.ts │ │ ├── useAisRatingMenu.ts │ │ ├── useAisNumericMenu.ts │ │ ├── useAisMenu.ts │ │ ├── useAisRangeInput.ts │ │ ├── useAisRefinementList.ts │ │ ├── useAisStatefulCache.ts │ │ ├── useAisHierarchicalMenu.ts │ │ ├── useAisWidget.ts │ │ ├── useAisRouter.ts │ │ ├── useAisInfiniteHitsStatefulCache.ts │ │ └── useInstantSearch.ts │ ├── components │ │ ├── AisIndex.vue │ │ ├── Configure.vue │ │ ├── AisQueryRuleCustomData.vue │ │ ├── Highlight.vue │ │ ├── Hits.vue │ │ ├── ClearRefinements.vue │ │ ├── Autocomplete.vue │ │ ├── SortBy.vue │ │ ├── HierarchicalMenuList.vue │ │ ├── ToggleRefinement.vue │ │ ├── Panel.vue │ │ ├── Stats.vue │ │ ├── MenuSelect.vue │ │ ├── InstantSearch.vue │ │ ├── NumericMenu.vue │ │ ├── StateResults.vue │ │ ├── RatingMenu.vue │ │ ├── HierarchicalMenu.vue │ │ ├── Highlighter.js │ │ ├── Menu.vue │ │ ├── InfiniteHits.vue │ │ ├── SearchBox.vue │ │ ├── CurrentRefinements.vue │ │ ├── RangeInput.vue │ │ └── RefinementList.vue │ └── utils │ │ ├── unescape.ts │ │ └── parseAlgoliaHit.ts └── module.ts ├── .gitignore ├── .github └── workflows │ └── ci.yml ├── LICENSE ├── package.json └── README.md /docs/.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | -------------------------------------------------------------------------------- /docs/content/2.routing/_dir.yml: -------------------------------------------------------------------------------- 1 | title: Routing 2 | -------------------------------------------------------------------------------- /docs/content/5.roadmap/_dir.yml: -------------------------------------------------------------------------------- 1 | title: Roadmap 2 | -------------------------------------------------------------------------------- /docs/content/3.components/_dir.yml: -------------------------------------------------------------------------------- 1 | title: Components 2 | -------------------------------------------------------------------------------- /docs/content/4.composables/_dir.yml: -------------------------------------------------------------------------------- 1 | title: Composables 2 | -------------------------------------------------------------------------------- /docs/content/1.getting-started/_dir.yml: -------------------------------------------------------------------------------- 1 | title: Getting Started -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | shamefully-hoist=true 2 | strict-peer-dependencies=false 3 | -------------------------------------------------------------------------------- /docs/.eslintignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .output 4 | .nuxt 5 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.nuxt/tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /docs/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "@nuxtjs" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /playground/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.nuxt/tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atoms-studio/nuxt-swiftsearch/HEAD/bun.lockb -------------------------------------------------------------------------------- /docs/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json" 3 | } 4 | -------------------------------------------------------------------------------- /docs/bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atoms-studio/nuxt-swiftsearch/HEAD/docs/bun.lockb -------------------------------------------------------------------------------- /playground/server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json" 3 | } 4 | -------------------------------------------------------------------------------- /playground/bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atoms-studio/nuxt-swiftsearch/HEAD/playground/bun.lockb -------------------------------------------------------------------------------- /test/fixtures/parity/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../../playground/.nuxt/tsconfig.json" 3 | } 4 | -------------------------------------------------------------------------------- /docs/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atoms-studio/nuxt-swiftsearch/HEAD/docs/public/favicon.ico -------------------------------------------------------------------------------- /test/fixtures/basic/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "basic", 4 | "type": "module" 5 | } 6 | -------------------------------------------------------------------------------- /test/fixtures/parity/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "parity", 4 | "type": "module" 5 | } 6 | -------------------------------------------------------------------------------- /docs/public/social-card.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/atoms-studio/nuxt-swiftsearch/HEAD/docs/public/social-card.png -------------------------------------------------------------------------------- /docs/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /test/fixtures/basic/app.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /test/fixtures/parity/app.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": ["@nuxt/eslint-config"], 4 | "rules": { 5 | "@typescript-eslint/no-unused-vars": "off", 6 | "vue/multi-word-component-names": "off" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /playground/server/api/testApi.ts: -------------------------------------------------------------------------------- 1 | export default defineEventHandler(async () => { 2 | await new Promise((resolve) => { 3 | setTimeout(() => resolve(true), 1000); 4 | }); 5 | return "test"; 6 | }); 7 | -------------------------------------------------------------------------------- /test/fixtures/basic/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import { defineNuxtConfig } from "nuxt/config"; 2 | import Swiftsearch from "../../../src/module"; 3 | 4 | export default defineNuxtConfig({ 5 | modules: [Swiftsearch], 6 | }); 7 | -------------------------------------------------------------------------------- /docs/.env.example: -------------------------------------------------------------------------------- 1 | # Production license for @nuxt/ui-pro, get one at https://ui.nuxt.com/pro/purchase 2 | NUXT_UI_PRO_LICENSE= 3 | 4 | # Public URL, used for OG Image when running nuxt generate 5 | NUXT_PUBLIC_SITE_URL= 6 | -------------------------------------------------------------------------------- /test/fixtures/parity/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | import { defineNuxtConfig } from "nuxt/config"; 2 | import Swiftsearch from "../../../src/module"; 3 | 4 | export default defineNuxtConfig({ 5 | modules: [Swiftsearch], 6 | }); 7 | -------------------------------------------------------------------------------- /docs/server/api/search.json.get.ts: -------------------------------------------------------------------------------- 1 | import { serverQueryContent } from '#content/server' 2 | 3 | export default eventHandler(async (event) => { 4 | return serverQueryContent(event).where({ _type: 'markdown', navigation: { $ne: false } }).find() 5 | }) -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_size = 2 5 | indent_style = space 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /playground/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | export default defineNuxtConfig({ 2 | modules: ["../src/module"], 3 | devtools: { enabled: true }, 4 | experimental: { 5 | componentIslands: true, 6 | }, 7 | compatibilityDate: "2025-09-26", 8 | }); 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /playground/app/router.options.ts: -------------------------------------------------------------------------------- 1 | import type { RouterConfig } from "@nuxt/schema"; 2 | import qs from "qs"; 3 | 4 | // https://router.vuejs.org/api/interfaces/routeroptions.html 5 | export default { 6 | parseQuery: qs.parse, 7 | stringifyQuery: qs.stringify, 8 | }; 9 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineVitestConfig } from "@nuxt/test-utils/config"; 2 | import "dotenv/config"; 3 | 4 | export default defineVitestConfig({ 5 | test: { 6 | testTimeout: 120000, 7 | sequence: { 8 | concurrent: false, 9 | }, 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /docs/.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_size = 2 6 | indent_style = space 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /playground/components/Product.server.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /playground/pages/nosearch2.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/runtime/composables/useAisIndex.ts: -------------------------------------------------------------------------------- 1 | import { index } from "instantsearch.js/es/widgets"; 2 | import type { IndexWidgetParams } from "instantsearch.js/es/widgets/index/index"; 3 | 4 | export const useAisIndex = (widgetParams: IndexWidgetParams) => { 5 | return index(widgetParams); 6 | }; 7 | -------------------------------------------------------------------------------- /test/fixtures/parity/app/router.options.ts: -------------------------------------------------------------------------------- 1 | import type { RouterConfig } from "@nuxt/schema"; 2 | import qs from "qs"; 3 | 4 | // https://router.vuejs.org/api/interfaces/routeroptions.html 5 | export default { 6 | parseQuery: qs.parse, 7 | stringifyQuery: qs.stringify, 8 | }; 9 | -------------------------------------------------------------------------------- /playground/app.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /src/runtime/components/AisIndex.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 16 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | # Nuxt dev/build outputs 2 | .output 3 | .data 4 | .nuxt 5 | .nitro 6 | .cache 7 | dist 8 | 9 | # Node dependencies 10 | node_modules 11 | 12 | # Logs 13 | logs 14 | *.log 15 | 16 | # Misc 17 | .DS_Store 18 | .fleet 19 | .idea 20 | 21 | # Local env files 22 | .env 23 | .env.* 24 | !.env.example 25 | 26 | # VSC 27 | .history 28 | -------------------------------------------------------------------------------- /playground/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "name": "my-module-playground", 4 | "type": "module", 5 | "scripts": { 6 | "dev": "nuxi dev", 7 | "build": "nuxi build", 8 | "generate": "nuxi generate" 9 | }, 10 | "dependencies": { 11 | "nuxt": "^3.19.2" 12 | }, 13 | "devDependencies": { 14 | "typescript": "^5.9.2" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /docs/content/4.composables/1.use-ais-instant-search.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "useAisInstantSearch" 3 | description: Instant Search composable 4 | --- 5 | 6 | ## Usage 7 | 8 | ```vue [MyComponent.vue] 9 | 12 | ``` 13 | 14 | Access current InstantSearchInstance and tap into its reactive state in real time 15 | -------------------------------------------------------------------------------- /docs/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: [ 4 | '@nuxt/eslint-config' 5 | ], 6 | rules: { 7 | // Global 8 | semi: ['error', 'never'], 9 | quotes: ['error', 'single'], 10 | 'quote-props': ['error', 'as-needed'], 11 | // Vue 12 | 'vue/multi-word-component-names': 0, 13 | 'vue/max-attributes-per-line': 'off', 14 | 'vue/no-v-html': 0 15 | } 16 | } -------------------------------------------------------------------------------- /docs/layouts/docs.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 20 | -------------------------------------------------------------------------------- /docs/content/5.roadmap/1.index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Roadmap 3 | --- 4 | 5 | ## Help us if you can! 6 | 7 | - Implementing missing widgets from vue-instantsearch 8 | - Adding custom functionalities not present in vue instansearch leveraging nuxt capabilities 9 | - Integrating with `@nuxtjs/algolia` module 10 | - Better documentation with relevant links to the algolia docs website 11 | - Document usage of provided composables 12 | - Add guides for common use cases 13 | - Better docs 14 | 15 | Feel free to open issues and/or discussions :) 16 | -------------------------------------------------------------------------------- /docs/content/1.getting-started/2.installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Installation 3 | --- 4 | 5 | ## Quick Start 6 | 7 | 1. Add `@atoms-studio/nuxt-swiftsearch` dependency to your project 8 | 9 | ```bash [Terminal] 10 | npx nuxi@latest module add swiftsearch 11 | ``` 12 | 13 | 2. Add `@atoms-studio/nuxt-swiftsearch` to the `modules` section of `nuxt.config.ts` 14 | 15 | ```js 16 | export default defineNuxtConfig({ 17 | modules: ["@atoms-studio/nuxt-swiftsearch"], 18 | }); 19 | ``` 20 | 21 | That's it! You can now use Nuxt Swiftsearch in your Nuxt app ✨ 22 | -------------------------------------------------------------------------------- /docs/content/3.components/06.ais-stats.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "" 3 | description: Stats widget 4 | --- 5 | 6 | ## Usage 7 | 8 | ```vue [MySearchExperience.vue] 9 | 16 | 17 | 20 | ``` 21 | 22 | Slots, props and widget connector params are all typed! 23 | More thorough documentation coming soon :) 24 | -------------------------------------------------------------------------------- /docs/content/3.components/09.ais-search-box.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "" 3 | description: Searchbox widget 4 | --- 5 | 6 | ## Usage 7 | 8 | ```vue [MySearchExperience.vue] 9 | 16 | 17 | 20 | ``` 21 | 22 | Slots, props and widget connector params are all typed! 23 | More thorough documentation coming soon :) 24 | -------------------------------------------------------------------------------- /docs/components/Footer.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 24 | -------------------------------------------------------------------------------- /playground/pages/nosearch.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /docs/content/3.components/03.ais-hits.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "" 3 | description: Hits widget 4 | --- 5 | 6 | ## Usage 7 | 8 | ```vue [MySearchExperience.vue] 9 | 16 | 17 | 25 | ``` 26 | 27 | Slots, props and widget connector params are all typed! 28 | More thorough documentation coming soon :) 29 | -------------------------------------------------------------------------------- /src/runtime/components/Configure.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /docs/content/3.components/01.ais-instant-search.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "" 3 | description: Wrapper of your search experience 4 | --- 5 | 6 | ## Usage 7 | 8 | The `AisInstantSerch` components is the entry point of your search experience, it automatically handles all the data hydration and SSR logic for you 9 | 10 | ```vue [MySearchExperience.vue] 11 | 16 | ``` 17 | 18 | ## Props 19 | 20 | - `widgets`: Array of Ais Widgets 21 | - `configuration`: InstantSearch configuration 22 | - `middlewares`: Optional array of middlewares 23 | -------------------------------------------------------------------------------- /docs/components/OgImage/OgImageDocs.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 30 | -------------------------------------------------------------------------------- /docs/content/3.components/14.ais-pagination.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "" 3 | description: Pagination widget 4 | --- 5 | 6 | ## Usage 7 | 8 | ```vue [MySearchExperience.vue] 9 | 16 | 17 | 23 | ``` 24 | Slots, props and widget connector params are all typed! 25 | More thorough documentation coming soon :) 26 | -------------------------------------------------------------------------------- /docs/content/3.components/02.ais-infinite-hits.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "" 3 | description: Infinite Hits widget 4 | --- 5 | 6 | ## Usage 7 | 8 | ```vue [MySearchExperience.vue] 9 | 16 | 17 | 25 | ``` 26 | 27 | Slots, props and widget connector params are all typed! 28 | More thorough documentation coming soon :) 29 | -------------------------------------------------------------------------------- /docs/content/3.components/08.ais-configure.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "" 3 | description: Configure widget 4 | --- 5 | 6 | ## Usage 7 | 8 | ```vue [MySearchExperience.vue] 9 | 14 | 15 | 24 | ``` 25 | 26 | ::callout{type="info"} 27 | Configure widget doesn't need nor have a UI component 28 | :: 29 | 30 | Slots, props and widget connector params are all typed! 31 | More thorough documentation coming soon :) 32 | -------------------------------------------------------------------------------- /docs/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss' 2 | import defaultTheme from 'tailwindcss/defaultTheme' 3 | 4 | export default >{ 5 | theme: { 6 | extend: { 7 | fontFamily: { 8 | sans: ['DM Sans', 'DM Sans fallback', ...defaultTheme.fontFamily.sans] 9 | }, 10 | colors: { 11 | green: { 12 | 50: '#EFFDF5', 13 | 100: '#D9FBE8', 14 | 200: '#B3F5D1', 15 | 300: '#75EDAE', 16 | 400: '#00DC82', 17 | 500: '#00C16A', 18 | 600: '#00A155', 19 | 700: '#007F45', 20 | 800: '#016538', 21 | 900: '#0A5331', 22 | 950: '#052e16' 23 | } 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /docs/content/3.components/07.ais-toggle-refinement.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "" 3 | description: ToggleRefinement Widget 4 | --- 5 | 6 | ## Usage 7 | 8 | ```vue [MySearchExperience.vue] 9 | 16 | 17 | 24 | ``` 25 | 26 | ::callout{type="warning"} 27 | ⚠️ Always pass the attribute parameter and prop. 28 | :: 29 | 30 | Slots, props and widget connector params are all typed! 31 | More thorough documentation coming soon :) 32 | -------------------------------------------------------------------------------- /docs/public/swiftsearch-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /docs/public/swiftsearch.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules 3 | 4 | # Logs 5 | *.log* 6 | 7 | # Temp directories 8 | .temp 9 | .tmp 10 | .cache 11 | 12 | # Yarn 13 | **/.yarn/cache 14 | **/.yarn/*state* 15 | 16 | # Generated dirs 17 | dist 18 | 19 | # Nuxt 20 | .nuxt 21 | .output 22 | .data 23 | .vercel_build_output 24 | .build-* 25 | .netlify 26 | 27 | # Env 28 | .env 29 | 30 | # Testing 31 | reports 32 | coverage 33 | *.lcov 34 | .nyc_output 35 | 36 | # VSCode 37 | .vscode/* 38 | !.vscode/settings.json 39 | !.vscode/tasks.json 40 | !.vscode/launch.json 41 | !.vscode/extensions.json 42 | !.vscode/*.code-snippets 43 | 44 | # Intellij idea 45 | *.iml 46 | .idea 47 | 48 | # OSX 49 | .DS_Store 50 | .AppleDouble 51 | .LSOverride 52 | .AppleDB 53 | .AppleDesktop 54 | Network Trash Folder 55 | Temporary Items 56 | .apdisk 57 | -------------------------------------------------------------------------------- /docs/content/3.components/05.ais-sort-by.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "" 3 | description: Sort by widget 4 | --- 5 | 6 | ## Usage 7 | 8 | ```vue [MySearchExperience.vue] 9 | 16 | 17 | 28 | ``` 29 | 30 | Slots, props and widget connector params are all typed! 31 | More thorough documentation coming soon :) 32 | -------------------------------------------------------------------------------- /src/runtime/components/AisQueryRuleCustomData.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 31 | -------------------------------------------------------------------------------- /test/fixtures/basic/pages/instantsearch.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /playground/pages/[brand].vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | test: 11 | name: Browser tests 12 | runs-on: ubuntu-latest 13 | env: 14 | CI: true 15 | BROWSERSLIST_IGNORE_OLD_DATA: 1 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v4 19 | 20 | - name: Set up Bun 21 | uses: actions/setup-node@v5 22 | with: 23 | node-version: latest 24 | 25 | - name: Install dependencies 26 | run: npm install --frozen-lockfile 27 | 28 | - name: Prepare repository 29 | run: npm run dev:prepare 30 | 31 | - name: Install Playwright browsers 32 | run: npx playwright install --with-deps chromium 33 | 34 | - name: Run Vitest browser suite 35 | run: npm run test 36 | -------------------------------------------------------------------------------- /playground/pages/instantsearch.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/runtime/composables/useAisConfigure.ts: -------------------------------------------------------------------------------- 1 | import { connectConfigure } from "instantsearch.js/es/connectors"; 2 | import type { 3 | ConfigureConnectorParams, 4 | ConfigureRenderState, 5 | } from "instantsearch.js/es/connectors/configure/connectConfigure"; 6 | import type { Renderer } from "instantsearch.js/es/types"; 7 | 8 | export const useAisConfigure = (widgetParams: ConfigureConnectorParams) => { 9 | // 1. Create a render function 10 | const renderConfigure: Renderer< 11 | ConfigureRenderState, 12 | ConfigureConnectorParams 13 | > = (_, __) => { 14 | // render nothing, provide render state 15 | return () => { }; 16 | }; 17 | 18 | // 2. Create the custom widget 19 | const customConfigure = connectConfigure(renderConfigure); 20 | 21 | // 3. Instantiate 22 | return { ...customConfigure(widgetParams), $$widgetParams: widgetParams}; 23 | }; 24 | -------------------------------------------------------------------------------- /docs/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nuxt-ui-pro-template-docs", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "build": "nuxt build", 7 | "dev": "nuxt dev", 8 | "generate": "nuxt generate", 9 | "preview": "nuxt preview", 10 | "postinstall": "nuxt prepare", 11 | "lint": "eslint .", 12 | "typecheck": "nuxt typecheck" 13 | }, 14 | "dependencies": { 15 | "@iconify-json/heroicons": "^1.1.20", 16 | "@iconify-json/simple-icons": "^1.1.92", 17 | "@nuxt/content": "^2.12.0", 18 | "@nuxt/ui-pro": "^1.0.0", 19 | "@nuxtjs/fontaine": "^0.4.1", 20 | "@nuxtjs/google-fonts": "^3.1.3", 21 | "nuxt": "^3.10.3", 22 | "nuxt-og-image": "^2.2.4" 23 | }, 24 | "devDependencies": { 25 | "@nuxt/eslint-config": "^0.2.0", 26 | "@nuxthq/studio": "^1.0.11", 27 | "eslint": "^8.56.0", 28 | "vue-tsc": "^1.8.27" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/runtime/components/Highlight.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 31 | -------------------------------------------------------------------------------- /docs/content/3.components/10.ais-index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "" 3 | description: Index widget 4 | --- 5 | 6 | ## Usage 7 | 8 | ```vue [MySearchExperience.vue] 9 | 19 | 20 | 28 | ``` 29 | 30 | Add multi index search by creating an `AisWidget` index and adding it's own children widgets to it! 31 | 32 | Slots, props and widget connector params are all typed! 33 | More thorough documentation coming soon :) 34 | -------------------------------------------------------------------------------- /docs/content/3.components/16.ais-menu-select.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "" 3 | description: Menu Select Widget 4 | --- 5 | 6 | ## Usage 7 | 8 | ```vue [MySearchExperience.vue] 9 | 16 | 17 | 34 | ``` 35 | 36 | Slots, props and widget connector params are all typed! 37 | More thorough documentation coming soon :) 38 | -------------------------------------------------------------------------------- /docs/content/3.components/04.ais-refinement-list.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "" 3 | description: Refinement List Widget 4 | --- 5 | 6 | ## Usage 7 | 8 | ```vue [MySearchExperience.vue] 9 | 17 | 18 | 28 | ``` 29 | 30 | ::callout{type="warning"} 31 | ⚠️ Always pass the attribute parameter and prop. 32 | :: 33 | 34 | Slots, props and widget connector params are all typed! 35 | More thorough documentation coming soon :) 36 | -------------------------------------------------------------------------------- /src/runtime/composables/useSuit.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Create class names like ais-widgetName-element--modifier 3 | * 4 | * @param {string} widgetName first part 5 | * @param {string} element part separated by - 6 | * @param {string} modifier final part, separated by -- 7 | * 8 | * @returns {string} the composed class name 9 | */ 10 | const _suit = (widgetName: string, element?: string, modifier?: string) => { 11 | if (!widgetName) { 12 | throw new Error("You need to provide `widgetName` in your data"); 13 | } 14 | 15 | const elements = [`ais-${widgetName}`]; 16 | 17 | if (element) { 18 | elements.push(`-${element}`); 19 | } 20 | 21 | if (modifier) { 22 | elements.push(`--${modifier}`); 23 | } 24 | 25 | return elements.join(""); 26 | }; 27 | 28 | export const useSuit = 29 | (name: string) => (element?: string, modifier?: string) => { 30 | return _suit(name, element, modifier); 31 | }; 32 | -------------------------------------------------------------------------------- /src/runtime/components/Hits.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 35 | -------------------------------------------------------------------------------- /docs/content/3.components/11.ais-range-input.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "" 3 | description: RangeInput Widget 4 | --- 5 | 6 | ## Usage 7 | 8 | ```vue [MySearchExperience.vue] 9 | 16 | 17 | 25 | ``` 26 | 27 | ::callout{type="warning"} 28 | ⚠️ Always pass the attribute parameter and prop. 29 | :: 30 | 31 | ::callout{type="warning"} 32 | ⚠️ If you're passing the precision property, be mindful to pass the same value to the connector and the component 33 | :: 34 | 35 | Slots, props and widget connector params are all typed! 36 | More thorough documentation coming soon :) 37 | -------------------------------------------------------------------------------- /docs/content/3.components/16.ais-menu.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "" 3 | description: Menu Widget 4 | --- 5 | 6 | ## Usage 7 | 8 | ```vue [MySearchExperience.vue] 9 | 16 | 17 | 36 | ``` 37 | 38 | Slots, props and widget connector params are all typed! 39 | More thorough documentation coming soon :) 40 | -------------------------------------------------------------------------------- /test/fixtures/basic/pages/swiftsearch.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /docs/content/3.components/15.ais-hierarchical-menu.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "" 3 | description: Hierarchical Menu Widget 4 | --- 5 | 6 | ## Usage 7 | 8 | ```vue [MySearchExperience.vue] 9 | 18 | 19 | 30 | ``` 31 | 32 | ::callout{type="warning"} 33 | ⚠️ Always pass the attribute parameter to the component. It should match the first element in the attributes list. 34 | :: 35 | 36 | Slots, props and widget connector params are all typed! 37 | More thorough documentation coming soon :) 38 | -------------------------------------------------------------------------------- /src/runtime/composables/useAisStats.ts: -------------------------------------------------------------------------------- 1 | import { connectStats } from "instantsearch.js/es/connectors"; 2 | import type { 3 | StatsConnectorParams, 4 | StatsRenderState, 5 | } from "instantsearch.js/es/connectors/stats/connectStats"; 6 | import type { Renderer } from "instantsearch.js/es/types"; 7 | import { provide, ref } from "vue"; 8 | 9 | export const useAisStats = (widgetParams: StatsConnectorParams, id: string = "") => { 10 | const stateRef = ref(); 11 | // 1. Create a render function 12 | const renderStats: Renderer = (renderState, isFirstRender) => { 13 | stateRef.value = renderState; 14 | // render nothing, provide render state 15 | if (isFirstRender) { 16 | provide(`stats-${id}`, stateRef); 17 | } 18 | // render nothing 19 | return () => { }; 20 | }; 21 | 22 | // 2. Create the custom widget 23 | const customStats = connectStats(renderStats); 24 | 25 | // 3. Instantiate 26 | return { ...customStats(widgetParams), $$widgetParams: widgetParams, $$widgetId: id }; 27 | }; 28 | -------------------------------------------------------------------------------- /src/runtime/composables/useAisSortBy.ts: -------------------------------------------------------------------------------- 1 | import { connectSortBy } from "instantsearch.js/es/connectors"; 2 | import type { 3 | SortByConnectorParams, 4 | SortByRenderState, 5 | } from "instantsearch.js/es/connectors/sort-by/connectSortBy"; 6 | import type { Renderer } from "instantsearch.js/es/types"; 7 | import { provide, ref } from "vue"; 8 | 9 | export const useAisSortBy = (widgetParams: SortByConnectorParams, id: string = "") => { 10 | const stateRef = ref(); 11 | // 1. Create a render function 12 | const renderSortBy: Renderer = (renderState, isFirstRender) => { 13 | stateRef.value = renderState; 14 | // render nothing, provide render state 15 | if (isFirstRender) { 16 | provide(`sortBy-${id}`, stateRef); 17 | } 18 | // render nothing 19 | return () => { }; 20 | }; 21 | 22 | // 2. Create the custom widget 23 | const customSortBy = connectSortBy(renderSortBy); 24 | 25 | // 3. Instantiate 26 | return { ...customSortBy(widgetParams), $$widgetParams: widgetParams, $$widgetId: id, 27 | }; 28 | }; 29 | -------------------------------------------------------------------------------- /src/runtime/composables/useAisQueryRuleCustomData.ts: -------------------------------------------------------------------------------- 1 | import { connectQueryRules } from "instantsearch.js/es/connectors"; 2 | import type { 3 | QueryRulesRenderState, 4 | QueryRulesConnectorParams, 5 | } from "instantsearch.js/es/connectors/query-rules/connectQueryRules"; 6 | import type { Renderer } from "instantsearch.js/es/types"; 7 | import { provide, ref } from "vue"; 8 | 9 | export const useAisQueryRuleCustomData = ( 10 | widgetParams: QueryRulesConnectorParams = {}, 11 | id: string = "" 12 | ) => { 13 | const stateRef = ref(); 14 | 15 | const renderQueryRules: Renderer = ( 16 | renderState, 17 | isFirstRender 18 | ) => { 19 | stateRef.value = renderState; 20 | 21 | if (isFirstRender) { 22 | provide(`queryRules-${id}`, stateRef); 23 | } 24 | 25 | return () => { }; 26 | }; 27 | 28 | const customQueryRules = connectQueryRules(renderQueryRules); 29 | 30 | return { 31 | ...customQueryRules(widgetParams), 32 | $$widgetParams: widgetParams, 33 | $$widgetId: id, 34 | }; 35 | }; 36 | -------------------------------------------------------------------------------- /src/runtime/components/ClearRefinements.vue: -------------------------------------------------------------------------------- 1 | 24 | 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 atoms 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/runtime/composables/useAisHits.ts: -------------------------------------------------------------------------------- 1 | import { connectHitsWithInsights } from "instantsearch.js/es/connectors"; 2 | import type { 3 | HitsConnectorParams, 4 | HitsRenderState, 5 | } from "instantsearch.js/es/connectors/hits/connectHits"; 6 | import type { Renderer } from "instantsearch.js/es/types"; 7 | import { provide, ref } from "vue"; 8 | 9 | export const useAisHits = ( 10 | widgetParams: HitsConnectorParams, 11 | id: string = "", 12 | ) => { 13 | const stateRef = ref(); 14 | // 1. Create a render function 15 | const renderHits: Renderer< 16 | HitsRenderState, 17 | HitsConnectorParams 18 | > = (renderState, isFirstRender) => { 19 | stateRef.value = renderState; 20 | // render nothing, provide render state 21 | if (isFirstRender) { 22 | provide(`hits-${id}`, stateRef); 23 | } 24 | // render nothing 25 | return () => { }; 26 | }; 27 | 28 | // 2. Create the custom widget 29 | const customHits = 30 | connectHitsWithInsights(renderHits); 31 | 32 | // 3. Instantiate 33 | return { ...customHits(widgetParams), $$widgetParams: widgetParams, $$widgetId: id }; 34 | }; 35 | -------------------------------------------------------------------------------- /src/runtime/components/Autocomplete.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 38 | -------------------------------------------------------------------------------- /docs/content/1.getting-started/1.index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Welcome to Nuxt Swiftsearch documentation 3 | --- 4 | 5 | ## What happened with Vue Instantsearch, and why another module? 6 | 7 | Instantsearch provides a set of _very convenient_ tools for building your search UI with algolia in a fast way and with great DX. 8 | So why building a nuxt module? 9 | Well, vue-instantsearch approach for managing state does not play well with the SSR approach, basically this is what happens: 10 | 11 | - Components are inserted inside a slot (the `AisInstantSearchComponent`) 12 | - At the `setup` (or `created`) step in the Vue lifecycle they subscribe themselves to the Instantsearch instance 13 | - The instantsearch instance, when all components have been subscribed, makes a request 14 | - Components gets hydrated 15 | 16 | This provides the best DX for the end user, but _there's a catch_: in SSR the components can't be hydrated because everything gets done in a single tick. 17 | 18 | This package re-implements Vue Instantsearch components so that they read and write to a `centralized` instance, that gets injected with widgets beforehand so that Nuxt can handle everything in the same tick. 19 | -------------------------------------------------------------------------------- /src/runtime/composables/useAisSearchBox.ts: -------------------------------------------------------------------------------- 1 | import { connectSearchBox } from "instantsearch.js/es/connectors"; 2 | import type { 3 | SearchBoxConnectorParams, 4 | SearchBoxRenderState, 5 | } from "instantsearch.js/es/connectors/search-box/connectSearchBox"; 6 | import type { Renderer } from "instantsearch.js/es/types"; 7 | import { provide, ref } from "vue"; 8 | 9 | export const useAisSearchBox = (widgetParams: SearchBoxConnectorParams, id: string = "") => { 10 | const stateRef = ref(); 11 | // 1. Create a render function 12 | const renderSearchBox: Renderer< 13 | SearchBoxRenderState, 14 | SearchBoxConnectorParams 15 | > = (renderState, isFirstRender) => { 16 | stateRef.value = renderState; 17 | // render nothing, provide render state 18 | if (isFirstRender) { 19 | provide(`searchBox-${id}`, stateRef); 20 | } 21 | // render nothing 22 | return () => null; 23 | }; 24 | 25 | // 2. Create the custom widget 26 | const customSearchBox = connectSearchBox(renderSearchBox); 27 | 28 | // 3. Instantiate 29 | return { ...customSearchBox(widgetParams), $$widgetParams: widgetParams, $$widgetId: id }; 30 | }; 31 | -------------------------------------------------------------------------------- /src/runtime/components/SortBy.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 40 | -------------------------------------------------------------------------------- /playground/composables/useStateMapping.ts: -------------------------------------------------------------------------------- 1 | import type { IndexUiState, UiState, StateMapping } from "instantsearch.js"; 2 | 3 | export const useStateMapping = () => { 4 | function getIndexStateWithoutConfigure( 5 | uiState: TIndexUiState, 6 | ): TIndexUiState { 7 | const { configure, ...trackedUiState } = uiState; 8 | return trackedUiState as TIndexUiState; 9 | } 10 | 11 | function customStateMapping( 12 | indexName: keyof TUiState, 13 | ): StateMapping { 14 | return { 15 | $$type: "ais.singleIndex", 16 | stateToRoute(uiState) { 17 | const stateWithoutConfigure = getIndexStateWithoutConfigure( 18 | uiState[indexName] || {}, 19 | ); 20 | return stateWithoutConfigure as unknown as TUiState; 21 | }, 22 | routeToState(routeState = {} as TUiState) { 23 | const stateWithoutConfigure = getIndexStateWithoutConfigure(routeState); 24 | return { 25 | [indexName]: stateWithoutConfigure, 26 | } as unknown as TUiState; 27 | }, 28 | }; 29 | } 30 | 31 | return customStateMapping; 32 | }; -------------------------------------------------------------------------------- /docs/error.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 53 | -------------------------------------------------------------------------------- /docs/nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // https://nuxt.com/docs/api/configuration/nuxt-config 2 | export default defineNuxtConfig({ 3 | extends: ['@nuxt/ui-pro'], 4 | modules: [ 5 | '@nuxt/content', 6 | '@nuxt/ui', 7 | '@nuxthq/studio', 8 | '@nuxtjs/fontaine', 9 | '@nuxtjs/google-fonts', 10 | 'nuxt-og-image', 11 | ], 12 | hooks: { 13 | // Define `@nuxt/ui` components as global to use them in `.md` (feel free to add those you need) 14 | 'components:extend': (components) => { 15 | const globals = components.filter((c) => 16 | ['UButton', 'UIcon'].includes(c.pascalName), 17 | ) 18 | 19 | globals.forEach((c) => (c.global = true)) 20 | }, 21 | }, 22 | ui: { 23 | icons: ['heroicons', 'simple-icons'], 24 | }, 25 | // Fonts 26 | fontMetrics: { 27 | fonts: ['DM Sans'], 28 | }, 29 | googleFonts: { 30 | display: 'swap', 31 | download: true, 32 | families: { 33 | 'DM+Sans': [400, 500, 600, 700], 34 | }, 35 | }, 36 | uiPro: { 37 | license: 'oss', 38 | }, 39 | routeRules: { 40 | '/**': { prerender: true} 41 | }, 42 | devtools: { 43 | enabled: true, 44 | }, 45 | typescript: { 46 | strict: false, 47 | }, 48 | }) 49 | -------------------------------------------------------------------------------- /src/runtime/composables/useAisPagination.ts: -------------------------------------------------------------------------------- 1 | import { connectPagination } from "instantsearch.js/es/connectors"; 2 | import type { 3 | PaginationConnectorParams, 4 | PaginationRenderState, 5 | } from "instantsearch.js/es/connectors/pagination/connectPagination"; 6 | import type { Renderer } from "instantsearch.js/es/types"; 7 | import { provide, ref } from "vue"; 8 | 9 | export const useAisPagination = ( 10 | widgetParams: PaginationConnectorParams, 11 | id: string = "", 12 | ) => { 13 | const stateRef = ref(); 14 | // 1. Create a render function 15 | const renderPagination: Renderer< 16 | PaginationRenderState, 17 | PaginationConnectorParams 18 | > = (renderState, isFirstRender) => { 19 | stateRef.value = renderState; 20 | // render nothing, provide render state 21 | if (isFirstRender) { 22 | provide(`pagination-${id}`, stateRef); 23 | } 24 | // render nothing 25 | return () => { }; 26 | }; 27 | 28 | // 2. Create the custom widget 29 | const customPagination = 30 | connectPagination(renderPagination); 31 | 32 | // 3. Instantiate 33 | return { ...customPagination(widgetParams), $$widgetParams: widgetParams, $$widgetId: id }; 34 | }; 35 | -------------------------------------------------------------------------------- /src/runtime/composables/useAisAutocomplete.ts: -------------------------------------------------------------------------------- 1 | import { connectAutocomplete } from "instantsearch.js/es/connectors"; 2 | import type { 3 | AutocompleteConnectorParams, 4 | AutocompleteRenderState, 5 | } from "instantsearch.js/es/connectors/autocomplete/connectAutocomplete"; 6 | import type { Renderer } from "instantsearch.js/es/types"; 7 | import { provide, ref } from "vue"; 8 | 9 | export const useAisAutocomplete = (widgetParams: AutocompleteConnectorParams, id: string = "") => { 10 | const stateRef = ref(); 11 | // 1. Create a render function 12 | const renderAutocomplete: Renderer< 13 | AutocompleteRenderState, 14 | AutocompleteConnectorParams 15 | > = (renderState, isFirstRender) => { 16 | stateRef.value = renderState; 17 | // render nothing, provide render state 18 | if (isFirstRender) { 19 | provide(`autocomplete-${id}`, stateRef); 20 | } 21 | // render nothing 22 | return () => null; 23 | }; 24 | 25 | // 2. Create the custom widget 26 | const customAutocomplete = connectAutocomplete(renderAutocomplete); 27 | 28 | // 3. Instantiate 29 | return { ...customAutocomplete(widgetParams), $$widgetParams: widgetParams, $$widgetId: id }; 30 | }; 31 | -------------------------------------------------------------------------------- /docs/content/2.routing/1.index.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Routing 3 | description: Nuxt Swiftsearch provide an out of the box routing implementation 4 | --- 5 | 6 | ## Preparation 7 | 8 | In order for Vue Router to be able to digest deep queries, install `qs` and implement it like so in the `app/router.options.ts` file in your project: 9 | 10 | ```ts [app/router.options.ts] 11 | import type { RouterConfig } from "@nuxt/schema"; 12 | import qs from "qs"; 13 | 14 | // https://router.vuejs.org/api/interfaces/routeroptions.html 15 | export default { 16 | parseQuery: qs.parse, 17 | stringifyQuery: qs.stringify, 18 | }; 19 | ``` 20 | 21 | ## Usage 22 | 23 | Inject the default router configuration in your instant search config prop 24 | 25 | ```vue [MySearchExperience.vue] 26 | 31 | 32 | 45 | ``` -------------------------------------------------------------------------------- /docs/app.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 53 | -------------------------------------------------------------------------------- /src/runtime/composables/useAisClearRefinements.ts: -------------------------------------------------------------------------------- 1 | import { connectClearRefinements } from "instantsearch.js/es/connectors"; 2 | import type { 3 | ClearRefinementsConnectorParams, 4 | ClearRefinementsRenderState, 5 | } from "instantsearch.js/es/connectors/clear-refinements/connectClearRefinements"; 6 | import type { Renderer } from "instantsearch.js/es/types"; 7 | import { provide, ref } from "vue"; 8 | 9 | export const useAisClearRefinements = ( 10 | widgetParams: ClearRefinementsConnectorParams, 11 | id: string = "", 12 | ) => { 13 | const stateRef = ref(); 14 | // 1. Create a render function 15 | const renderClearRefinements: Renderer< 16 | ClearRefinementsRenderState, 17 | ClearRefinementsConnectorParams 18 | > = (renderState, isFirstRender) => { 19 | stateRef.value = renderState; 20 | // render nothing, provide render state 21 | if (isFirstRender) { 22 | provide(`clearRefinements-${id}`, stateRef); 23 | } 24 | return () => null; 25 | }; 26 | 27 | // 2. Create the custom widget 28 | const customClearRefinements = connectClearRefinements( 29 | renderClearRefinements, 30 | ); 31 | 32 | // 3. Instantiate 33 | return { 34 | ...customClearRefinements(widgetParams), 35 | $$widgetParams: widgetParams, 36 | $$widgetId: id, 37 | }; 38 | }; 39 | -------------------------------------------------------------------------------- /src/runtime/composables/useAisToggleRefinement.ts: -------------------------------------------------------------------------------- 1 | import { connectToggleRefinement } from "instantsearch.js/es/connectors"; 2 | import type { 3 | ToggleRefinementConnectorParams, 4 | ToggleRefinementRenderState, 5 | } from "instantsearch.js/es/connectors/toggle-refinement/connectToggleRefinement"; 6 | import type { Renderer } from "instantsearch.js/es/types"; 7 | import { provide, ref } from "vue"; 8 | 9 | export const useAisToggleRefinement = ( 10 | widgetParams: ToggleRefinementConnectorParams, 11 | id: string = "" 12 | ) => { 13 | const stateRef = ref(); 14 | // 1. Create a render function 15 | const renderToggleRefinement: Renderer< 16 | ToggleRefinementRenderState, 17 | ToggleRefinementConnectorParams 18 | > = (renderState, isFirstRender) => { 19 | stateRef.value = renderState; 20 | // render nothing, provide render state 21 | if (isFirstRender) { 22 | provide(`toggleRefinements-${id}`, stateRef); 23 | } 24 | // render nothing 25 | return () => { }; 26 | }; 27 | 28 | // 2. Create the custom widget 29 | const customToggleRefinement = connectToggleRefinement( 30 | renderToggleRefinement, 31 | ); 32 | 33 | // 3. Instantiate 34 | return { 35 | ...customToggleRefinement(widgetParams), 36 | $$widgetParams: widgetParams, 37 | $$widgetId: id 38 | }; 39 | }; 40 | -------------------------------------------------------------------------------- /src/runtime/components/HierarchicalMenuList.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 48 | -------------------------------------------------------------------------------- /src/runtime/composables/useAisCurrentRefinements.ts: -------------------------------------------------------------------------------- 1 | import { connectCurrentRefinements } from "instantsearch.js/es/connectors"; 2 | import type { 3 | CurrentRefinementsConnectorParams, 4 | CurrentRefinementsRenderState, 5 | } from "instantsearch.js/es/connectors/current-refinements/connectCurrentRefinements"; 6 | import type { Renderer } from "instantsearch.js/es/types"; 7 | import { provide, ref } from "vue"; 8 | 9 | export const useAisCurrentRefinements = ( 10 | widgetParams: CurrentRefinementsConnectorParams, 11 | id: string = "", 12 | ) => { 13 | const stateRef = ref(); 14 | // 1. Create a render function 15 | const renderCurrentRefinements: Renderer< 16 | CurrentRefinementsRenderState, 17 | CurrentRefinementsConnectorParams 18 | > = (renderState, isFirstRender) => { 19 | stateRef.value = renderState; 20 | // render nothing, provide render state 21 | if (isFirstRender) { 22 | provide(`currentRefinements-${id}`, stateRef); 23 | } 24 | // render nothing 25 | return () => null; 26 | }; 27 | 28 | // 2. Create the custom widget 29 | const customCurrentRefinements = connectCurrentRefinements( 30 | renderCurrentRefinements, 31 | ); 32 | 33 | // 3. Instantiate 34 | return { 35 | ...customCurrentRefinements(widgetParams), 36 | $$widgetParams: widgetParams, 37 | $$widgetId: id, 38 | }; 39 | }; 40 | -------------------------------------------------------------------------------- /docs/components/Header.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 48 | -------------------------------------------------------------------------------- /src/module.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defineNuxtModule, 3 | addImportsDir, 4 | createResolver, 5 | addComponentsDir, 6 | assertNuxtCompatibility, 7 | } from "@nuxt/kit"; 8 | 9 | // Module options TypeScript interface definition 10 | export interface ModuleOptions { } 11 | 12 | export default defineNuxtModule({ 13 | meta: { 14 | name: "@atoms-studio/nuxt-swiftsearch", 15 | configKey: "swiftsearch", 16 | }, 17 | // Default configuration options of the Nuxt module 18 | defaults: {}, 19 | setup(_, nuxt) { 20 | assertNuxtCompatibility({ nuxt: ">=3.10" }, nuxt); 21 | const resolver = createResolver(import.meta.url); 22 | 23 | // Do not add the extension since the `.ts` will be transpiled to `.mjs` after `npm run prepack` 24 | addImportsDir([ 25 | resolver.resolve("./runtime/composables"), 26 | resolver.resolve("./runtime/utils"), 27 | ]); 28 | addComponentsDir({ 29 | path: resolver.resolve("./runtime/components"), 30 | prefix: "Ais", 31 | }); 32 | // transpiling modules 33 | nuxt.options.vite ??= {}; 34 | nuxt.options.vite.optimizeDeps ??= {}; 35 | nuxt.options.vite.optimizeDeps.include ??= []; 36 | nuxt.options.vite.optimizeDeps.include.push( 37 | ...[ 38 | "algoliasearch-helper", 39 | "@algolia/events", 40 | "hogan.js", 41 | "qs", 42 | ], 43 | ); 44 | }, 45 | }); 46 | -------------------------------------------------------------------------------- /playground/pages/pagination/index.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 55 | 56 | 57 | 58 | -------------------------------------------------------------------------------- /src/runtime/composables/useAisInfiniteHits.ts: -------------------------------------------------------------------------------- 1 | import { connectInfiniteHitsWithInsights } from "instantsearch.js/es/connectors"; 2 | import type { 3 | InfiniteHitsConnectorParams, 4 | InfiniteHitsRenderState, 5 | } from "instantsearch.js/es/connectors/infinite-hits/connectInfiniteHits"; 6 | import type { Renderer } from "instantsearch.js/es/types"; 7 | import { provide, ref } from "vue"; 8 | 9 | export const useAisInfiniteHits = ( 10 | widgetParams: InfiniteHitsConnectorParams, 11 | id: string = "", 12 | ) => { 13 | const stateRef = ref(); 14 | // 1. Create a render function 15 | const renderInfiniteHits: Renderer< 16 | InfiniteHitsRenderState, 17 | InfiniteHitsConnectorParams 18 | > = (renderState, isFirstRender) => { 19 | stateRef.value = renderState; 20 | // render nothing, provide render state 21 | if (isFirstRender) { 22 | provide(`infiniteHits-${id}`, stateRef); 23 | } 24 | // render nothing 25 | return () => {}; 26 | }; 27 | 28 | // 2. Create the custom widget 29 | const customInfiniteHits = 30 | connectInfiniteHitsWithInsights(renderInfiniteHits); 31 | 32 | const significantParams = { 33 | ...widgetParams, 34 | cache: false, 35 | transformItems: null, 36 | }; 37 | // 3. Instantiate 38 | return { 39 | ...customInfiniteHits(widgetParams), 40 | $$widgetParams: significantParams, 41 | $$widgetId: id, 42 | }; 43 | }; 44 | -------------------------------------------------------------------------------- /test/snapshots/infinitehits: -------------------------------------------------------------------------------- 1 |
  1. objectID: 5477500, index: 0
  2. objectID: 4397400, index: 1
  3. objectID: 5588602, index: 2
  4. objectID: 5578851, index: 3
  5. objectID: 6443034, index: 4
  6. objectID: 4863102, index: 5
  7. objectID: 5578849, index: 6
  8. objectID: 6848136, index: 7
  9. objectID: 4374300, index: 8
  10. objectID: 5723538, index: 9
  11. objectID: 5723537, index: 10
  12. objectID: 5513500, index: 11
  13. objectID: 5723529, index: 12
  14. objectID: 5723700, index: 13
  15. objectID: 5407064, index: 14
  16. objectID: 4743301, index: 15
  17. objectID: 4806800, index: 16
  18. objectID: 5606300, index: 17
  19. objectID: 5723548, index: 18
  20. objectID: 4984700, index: 19
-------------------------------------------------------------------------------- /src/runtime/composables/useAisRatingMenu.ts: -------------------------------------------------------------------------------- 1 | import { connectRatingMenu } from "instantsearch.js/es/connectors"; 2 | import type { 3 | RatingMenuRenderState, 4 | RatingMenuConnectorParams, 5 | } from "instantsearch.js/es/connectors/rating-menu/connectRatingMenu"; 6 | import type { Renderer } from "instantsearch.js/es/types"; 7 | import { useState } from "#app"; 8 | import { provide, ref } from "vue"; 9 | 10 | export const useAisRatingMenuRenderState = (key: string = "") => 11 | useState>( 12 | `ais_rating_menu_render_state${key}`, 13 | () => ({}) 14 | ); 15 | export const useAisRatingMenu = ( 16 | widgetParams: RatingMenuConnectorParams, 17 | id: string = "" 18 | ) => { 19 | const stateRef = ref(); 20 | const ratingMenuRenderState = useAisRatingMenuRenderState(id); 21 | const renderRatingMenu: Renderer = ( 22 | renderState, 23 | isFirstRender 24 | ) => { 25 | stateRef.value = renderState; 26 | if (import.meta.client) { 27 | ratingMenuRenderState.value[widgetParams.attribute] = renderState; 28 | } 29 | if (isFirstRender) { 30 | provide(`ratingMenu-${id}`, stateRef); 31 | } 32 | return () => { }; 33 | }; 34 | const customConfigure = connectRatingMenu(renderRatingMenu); 35 | return { 36 | ...customConfigure(widgetParams), 37 | $$widgetParams: widgetParams, 38 | }; 39 | }; 40 | -------------------------------------------------------------------------------- /src/runtime/composables/useAisNumericMenu.ts: -------------------------------------------------------------------------------- 1 | import { connectNumericMenu } from "instantsearch.js/es/connectors"; 2 | import type { 3 | NumericMenuRenderState, 4 | NumericMenuConnectorParams, 5 | } from "instantsearch.js/es/connectors/numeric-menu/connectNumericMenu"; 6 | import type { Renderer } from "instantsearch.js/es/types"; 7 | import { useState } from "#app"; 8 | import { provide, ref } from "vue"; 9 | 10 | export const useAisNumericMenuRenderState = (key: string = "") => 11 | useState>( 12 | `ais_numeric_menu_render_state${key}`, 13 | () => ({}) 14 | ); 15 | export const useAisNumericMenu = ( 16 | widgetParams: NumericMenuConnectorParams, 17 | id: string = "" 18 | ) => { 19 | const stateRef = ref(); 20 | const numericMenuRenderState = useAisNumericMenuRenderState(id); 21 | const renderNumericMenu: Renderer = ( 22 | renderState, 23 | isFirstRender 24 | ) => { 25 | stateRef.value = renderState; 26 | if (import.meta.client) { 27 | numericMenuRenderState.value[widgetParams.attribute] = renderState; 28 | } 29 | if (isFirstRender) { 30 | provide(`numericMenu-${id}`, stateRef); 31 | } 32 | return () => { }; 33 | }; 34 | const customConfigure = connectNumericMenu(renderNumericMenu); 35 | return { 36 | ...customConfigure(widgetParams), 37 | $$widgetParams: widgetParams, 38 | }; 39 | }; 40 | -------------------------------------------------------------------------------- /docs/content/3.components/12.ais-clear-refinements.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "" 3 | description: Clear refinements widget 4 | --- 5 | 6 | ## Usage 7 | 8 | ```vue [MySearchExperience.vue] 9 | 24 | 25 | 38 | ``` 39 | 40 | ::callout{type="warning"} 41 | ⚠️ Always setting ID to widget and component\'s prop if you use more ClearRefinements components inside the same index 42 | :: 43 | 44 | ::callout{type="warning"} 45 | ⚠️ This component does not allow the simultaneous use of the **included-attributes** and **excluded-attributes** props 46 | :: 47 | 48 | Slots, props and widget connector params are all typed! 49 | More thorough documentation coming soon :) 50 | -------------------------------------------------------------------------------- /src/runtime/utils/unescape.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This implementation is taken from Lodash implementation. 3 | * See: https://github.com/lodash/lodash/blob/4.17.11-npm/unescape.js 4 | */ 5 | 6 | /** Used to map HTML entities to characters. */ 7 | const htmlUnescapes = { 8 | "&": "&", 9 | "<": "<", 10 | ">": ">", 11 | """: '"', 12 | "'": "'", 13 | } as const; 14 | 15 | /** Used to match HTML entities and HTML characters. */ 16 | const reEscapedHtml = /&(?:amp|lt|gt|quot|#39);/g; 17 | const reHasEscapedHtml = RegExp(reEscapedHtml.source); 18 | 19 | /** 20 | * The inverse of `_.escape`; this method converts the HTML entities 21 | * `&`, `<`, `>`, `"`, and `'` in `string` to 22 | * their corresponding characters. 23 | * 24 | * **Note:** No other HTML entities are unescaped. To unescape additional 25 | * HTML entities use a third-party library like [_he_](https://mths.be/he). 26 | * 27 | * @static 28 | * @memberOf _ 29 | * @since 0.6.0 30 | * @category String 31 | * @param {string} [string=''] The string to unescape. 32 | * @returns {string} Returns the unescaped string. 33 | * @example 34 | * 35 | * _.unescape('fred, barney, & pebbles'); 36 | * // => 'fred, barney, & pebbles' 37 | */ 38 | export function unescape(string: string) { 39 | return string && reHasEscapedHtml.test(string) 40 | ? string.replace( 41 | reEscapedHtml, 42 | (character) => htmlUnescapes[character as keyof typeof htmlUnescapes], 43 | ) 44 | : string; 45 | } 46 | -------------------------------------------------------------------------------- /docs/pages/index.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 41 | -------------------------------------------------------------------------------- /test/utils/html.ts: -------------------------------------------------------------------------------- 1 | import type { NuxtPage } from '@nuxt/test-utils' 2 | export const stripHtmlComments = (html: string) => { 3 | return html.replaceAll('', '').replaceAll('', '').replaceAll('', '') 4 | }; 5 | 6 | export const normalizeWhitespace = (html: string) => { 7 | return html 8 | // collapse whitespace between tags 9 | .replace(/>\s+<") 10 | // trim leading whitespace at the start of any text node: ...> foo 11 | .replace(/>\s+/g, ">") 12 | // trim trailing whitespace at the end of any text node: foo <... 13 | .replace(/\s+ { 19 | return normalizeWhitespace(stripHtmlComments(html)).replace(/\u00a0/g, ""); 20 | }; 21 | 22 | export const extractTestIdInnerHtml = (markup: string, testId: string) => { 23 | const pattern = new RegExp( 24 | `<[^>]*data-testid=["']${testId}["'][^>]*>([\\s\\S]*?)]+>`, 25 | "i", 26 | ); 27 | const match = markup.match(pattern); 28 | return match ? normalizeHtml(match[1]) : null; 29 | }; 30 | 31 | export const collectNormalizedMarkup = async (page: NuxtPage, testIds: string[]) => { 32 | const result: Record = {}; 33 | 34 | for (const testId of testIds) { 35 | const element = page.getByTestId(testId); 36 | const markup = await element.innerHTML(); 37 | result[testId] = normalizeHtml(markup); 38 | } 39 | 40 | return result; 41 | }; 42 | -------------------------------------------------------------------------------- /src/runtime/composables/useAisMenu.ts: -------------------------------------------------------------------------------- 1 | import { connectMenu } from "instantsearch.js/es/connectors"; 2 | import type { 3 | MenuRenderState, 4 | MenuConnectorParams, 5 | } from "instantsearch.js/es/connectors/menu/connectMenu"; 6 | import type { Renderer } from "instantsearch.js/es/types"; 7 | import { useState } from "#app"; 8 | import { provide, ref } from "vue"; 9 | 10 | export const useAisMenuRenderState = (key: string = "") => 11 | useState>( 12 | `ais_menu_render_state${key}`, 13 | () => ({}) 14 | ); 15 | export const useAisMenu = ( 16 | widgetParams: MenuConnectorParams, 17 | id: string = "" 18 | ) => { 19 | const stateRef = ref(); 20 | const menuRenderState = useAisMenuRenderState(id); 21 | // 1. Create a render function 22 | const renderMenu: Renderer = ( 23 | renderState, 24 | isFirstRender 25 | ) => { 26 | stateRef.value = renderState; 27 | // save renderState 28 | if (import.meta.client) { 29 | menuRenderState.value[widgetParams.attribute] = renderState; 30 | } 31 | // render nothing, provide render state 32 | if (isFirstRender) { 33 | provide(`menu-${id}`, stateRef); 34 | } 35 | // render nothing, provide render state 36 | return () => { }; 37 | }; 38 | // 2. Create the custom widget 39 | const customConfigure = connectMenu(renderMenu); 40 | // 3. Instantiate 41 | return { 42 | ...customConfigure(widgetParams), 43 | $$widgetParams: widgetParams, 44 | }; 45 | }; 46 | -------------------------------------------------------------------------------- /src/runtime/composables/useAisRangeInput.ts: -------------------------------------------------------------------------------- 1 | import { connectRange } from "instantsearch.js/es/connectors"; 2 | import type { 3 | RangeRenderState, 4 | RangeConnectorParams, 5 | } from "instantsearch.js/es/connectors/range/connectRange"; 6 | import type { Renderer } from "instantsearch.js/es/types"; 7 | import { useState } from "nuxt/app"; 8 | import { provide, ref } from "vue"; 9 | 10 | export const useAisRangeInputRenderState = (key: string = "") => 11 | useState>( 12 | `ais_range_render_state${key}`, 13 | () => ({}), 14 | ); 15 | export const useAisRangeInput = ( 16 | widgetParams: RangeConnectorParams, 17 | id: string = "" 18 | ) => { 19 | const stateRef = ref(); 20 | const rangeRenderState = useAisRangeInputRenderState(id); 21 | // 1. Create a render function 22 | const renderRange: Renderer< 23 | RangeRenderState, 24 | RangeConnectorParams 25 | > = (renderState, isFirstRender) => { 26 | stateRef.value = renderState; 27 | // save renderState 28 | if (import.meta.client) { 29 | rangeRenderState.value[widgetParams.attribute] = renderState; 30 | } 31 | if (isFirstRender) { 32 | provide(`rangeInput-${id}`, stateRef); 33 | } 34 | // render nothing 35 | return () => null; 36 | }; 37 | 38 | // 2. Create the custom widget 39 | const customRange = connectRange(renderRange); 40 | 41 | // 3. Instantiate 42 | return { 43 | ...customRange(widgetParams), 44 | $$widgetParams: widgetParams, 45 | $$widgetId: id 46 | }; 47 | }; 48 | -------------------------------------------------------------------------------- /src/runtime/components/ToggleRefinement.vue: -------------------------------------------------------------------------------- 1 | 33 | 34 | 50 | -------------------------------------------------------------------------------- /playground/pages/pagination/page/[page].vue: -------------------------------------------------------------------------------- 1 | 25 | 26 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /docs/content/1.getting-started/3.usage.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Usage 3 | description: Learn how to implement your first search UI with Nuxt Swiftsearch 4 | --- 5 | 6 | ## The starting point 7 | 8 | Create your wrapper component by passing `widgets` and the instantsearch `configuration` to the `AisInstantSearch` wrapper component, 9 | then use actual widgets anywhere inside the wrapper! 10 | 11 | ```vue [MySearchExperience.vue] 12 | 20 | 21 | 44 | ``` 45 | 46 | ::callout{type="warning"} 47 | ⚠️ To avoid SSR errors, due to the fact that algoliasearch client default requester is based on XMLHttpRequest, use the Fetch requester as shown above (e.g.: Cloudflare), be wary to install @algolia/requester-fetch@4.\* 48 | :: 49 | 50 | ## Out of the box routing 51 | 52 | If you want, you can use the provided router out of the box, take a look at the [Routing section](/routing) -------------------------------------------------------------------------------- /docs/content/index.yml: -------------------------------------------------------------------------------- 1 | title: "Nuxt Swiftsearch" 2 | description: "Nuxt Swiftsearch - a tailor made implementation of instantsearch for Nuxt" 3 | navigation: false 4 | hero: 5 | title: "Nuxt Swiftsearch" 6 | description: "A tailor made implementation of instantsearch for Nuxt" 7 | orientation: horizontal 8 | headline: 9 | label: Made with Nuxt UI Pro 10 | to: https://ui.nuxt.com/pro 11 | icon: i-heroicons-arrow-top-right-on-square-20-solid 12 | links: 13 | - label: Get started 14 | icon: i-heroicons-arrow-right-20-solid 15 | trailing: true 16 | to: "/getting-started" 17 | size: lg 18 | code: | 19 | ```bash [Terminal] 20 | npx nuxi@latest module add swiftsearch 21 | ``` 22 | features: 23 | items: 24 | - title: "Made with Nuxt, for Nuxt" 25 | description: "Powered by Nuxt 3 for optimal SSR performances and SEO." 26 | icon: "i-simple-icons-nuxtdotjs" 27 | to: "https://nuxt.com" 28 | target: "_blank" 29 | - title: "SSR First" 30 | description: "Works in SSR mode out of the box, add to opt-out!" 31 | icon: "i-heroicons-sparkles-20-solid" 32 | to: "https://content.nuxt.com" 33 | target: "_blank" 34 | - title: "Vue Instantsearch compatible" 35 | description: "Compatible with Vue Instantsearch almost out of the box!" 36 | icon: "i-simple-icons-algolia" 37 | to: "https://www.algolia.com/doc/guides/building-search-ui/what-is-instantsearch/vue/" 38 | target: "_blank" 39 | - title: "Typed components" 40 | description: "A fully typed development experience." 41 | icon: "i-simple-icons-typescript" 42 | to: "https://www.typescriptlang.org" 43 | target: "_blank" 44 | -------------------------------------------------------------------------------- /src/runtime/components/Panel.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 62 | -------------------------------------------------------------------------------- /src/runtime/components/Stats.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 64 | -------------------------------------------------------------------------------- /docs/content/3.components/16.ais-autocomplete.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "" 3 | description: Autocomplete Widget 4 | --- 5 | 6 | ## Usage 7 | 8 | ```vue [MySearchExperience.vue] 9 | 50 | 51 | 56 | ``` 57 | 58 | Slots, props and widget connector params are all typed! 59 | More thorough documentation coming soon :) 60 | -------------------------------------------------------------------------------- /src/runtime/composables/useAisRefinementList.ts: -------------------------------------------------------------------------------- 1 | import { connectRefinementList } from "instantsearch.js/es/connectors"; 2 | import type { 3 | RefinementListRenderState, 4 | RefinementListConnectorParams, 5 | } from "instantsearch.js/es/connectors/refinement-list/connectRefinementList"; 6 | import type { Renderer } from "instantsearch.js/es/types"; 7 | import { useState } from "nuxt/app"; 8 | import { provide, ref } from "vue"; 9 | 10 | export const useAisRefinementListRenderState = (key: string = "") => 11 | useState>( 12 | `ais_refinement_render_state${key}`, 13 | () => ({}), 14 | ); 15 | export const useAisRefinementList = ( 16 | widgetParams: RefinementListConnectorParams, 17 | id: string = "", 18 | ) => { 19 | const stateRef = ref(); 20 | const refinementRenderState = useAisRefinementListRenderState(id); 21 | // 1. Create a render function 22 | const renderRefinementList: Renderer< 23 | RefinementListRenderState, 24 | RefinementListConnectorParams 25 | > = (renderState, isFirstRender) => { 26 | stateRef.value = renderState; 27 | // save renderState 28 | if (import.meta.client) { 29 | refinementRenderState.value[widgetParams.attribute] = renderState; 30 | } 31 | // render nothing, provide render state 32 | if (isFirstRender) { 33 | provide(`refinementList-${id}`, stateRef); 34 | } 35 | // render nothing 36 | return () => null; 37 | }; 38 | 39 | // 2. Create the custom widget 40 | const customRefinementList = connectRefinementList(renderRefinementList); 41 | 42 | // 3. Instantiate 43 | return { 44 | ...customRefinementList(widgetParams), 45 | $$widgetParams: widgetParams, 46 | $$widgetId: id 47 | }; 48 | }; 49 | -------------------------------------------------------------------------------- /src/runtime/composables/useAisStatefulCache.ts: -------------------------------------------------------------------------------- 1 | import type { Cache, CacheEvents } from "@algolia/client-common"; 2 | import { useState } from "#app"; 3 | 4 | export const useAisStatefulCache = (key?: string) => { 5 | const options = { serializable: false }; 6 | const cache = useState>( 7 | key ?? "swiftsearch_cache_stateful", 8 | () => ({}), 9 | ); 10 | 11 | return { 12 | get( 13 | key: object | string, 14 | defaultValue: () => Readonly>, 15 | events: CacheEvents = { 16 | miss: () => Promise.resolve(), 17 | }, 18 | ): Readonly> { 19 | const keyAsString = JSON.stringify(key); 20 | 21 | if (keyAsString in cache.value) { 22 | return Promise.resolve( 23 | options.serializable 24 | ? JSON.parse(cache.value[keyAsString]) 25 | : cache.value[keyAsString], 26 | ); 27 | } 28 | 29 | const promise = defaultValue(); 30 | const miss = (events && events.miss) || (() => Promise.resolve()); 31 | 32 | return promise.then((value: TValue) => miss(value)).then(() => promise); 33 | }, 34 | 35 | set( 36 | key: object | string, 37 | value: TValue, 38 | ): Readonly> { 39 | cache.value[JSON.stringify(key)] = options.serializable 40 | ? JSON.stringify(value) 41 | : value; 42 | 43 | return Promise.resolve(value); 44 | }, 45 | 46 | delete(key: object | string): Readonly> { 47 | delete cache.value[JSON.stringify(key)]; 48 | 49 | return Promise.resolve(); 50 | }, 51 | 52 | clear(): Readonly> { 53 | cache.value = {}; 54 | 55 | return Promise.resolve(); 56 | }, 57 | } as Cache; 58 | }; 59 | -------------------------------------------------------------------------------- /src/runtime/composables/useAisHierarchicalMenu.ts: -------------------------------------------------------------------------------- 1 | import { connectHierarchicalMenu } from "instantsearch.js/es/connectors"; 2 | import type { 3 | HierarchicalMenuRenderState, 4 | HierarchicalMenuConnectorParams, 5 | } from "instantsearch.js/es/connectors/hierarchical-menu/connectHierarchicalMenu"; 6 | import type { Renderer } from "instantsearch.js/es/types"; 7 | import { useState } from "nuxt/app"; 8 | import { provide, ref } from "vue"; 9 | 10 | export const useAisHierarchicalMenuRenderState = (key: string = "") => 11 | useState>( 12 | `ais_hierarchical_menu_render_state${key}`, 13 | () => ({}), 14 | ); 15 | export const useAisHierarchicalMenu = ( 16 | widgetParams: HierarchicalMenuConnectorParams, 17 | id: string = "", 18 | ) => { 19 | const stateRef = ref(); 20 | const hierarchicalRenderState = useAisHierarchicalMenuRenderState(id); 21 | // 1. Create a render function 22 | const renderHierarchicalMenu: Renderer< 23 | HierarchicalMenuRenderState, 24 | HierarchicalMenuConnectorParams 25 | > = (renderState, isFirstRender) => { 26 | stateRef.value = renderState; 27 | // save renderState 28 | if (import.meta.client) { 29 | hierarchicalRenderState.value[widgetParams.attributes[0]] = renderState; 30 | } 31 | // render nothing, provide render state 32 | if (isFirstRender) { 33 | provide(`hierarchical-menu-${id}`, stateRef); 34 | } 35 | // render nothing 36 | return () => null; 37 | }; 38 | 39 | // 2. Create the custom widget 40 | const customHierarchicalMenu = connectHierarchicalMenu( 41 | renderHierarchicalMenu, 42 | ); 43 | 44 | // 3. Instantiate 45 | return { 46 | ...customHierarchicalMenu(widgetParams), 47 | $$widgetParams: widgetParams, 48 | $$widgetId: id, 49 | }; 50 | }; 51 | -------------------------------------------------------------------------------- /docs/content/3.components/17.ais-rating-menu.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "" 3 | description: Rating Menu Widget 4 | --- 5 | 6 | ## Usage 7 | 8 | ```vue [MySearchExperience.vue] 9 | 16 | 17 | 25 | ``` 26 | 27 | ## Props 28 | 29 | | Prop | Type | Default | Description | 30 | |------|------|---------|-------------| 31 | | `attribute` | `string` | - | **Required.** The name of the attribute in the records | 32 | | `max` | `number` | `5` | The maximum rating value (number of stars) | 33 | 34 | ## Features 35 | 36 | - **Visual Star Rating**: Displays ratings as filled (★) and empty (☆) stars 37 | - **Interactive Filtering**: Click on any rating to filter results 38 | - **Count Display**: Shows the number of results for each rating level 39 | - **Accessibility**: Proper semantic HTML structure with links 40 | 41 | ## Example Output 42 | 43 | The component renders ratings in the format: 44 | - `★★★★☆ & Up (16074)` - 4+ star ratings 45 | - `★★★☆☆ & Up (17696)` - 3+ star ratings 46 | - `★★☆☆☆ & Up (17890)` - 2+ star ratings 47 | - `★☆☆☆☆ & Up (18046)` - 1+ star ratings 48 | 49 | 50 | ## Slots 51 | 52 | The component provides a default slot with the following props: 53 | 54 | - `items` - Array of rating items 55 | - `can-refine` - Boolean indicating if refinement is possible 56 | - `refine` - Function to refine by rating value 57 | - `create-u-r-l` - Function to create URLs for rating values 58 | - `send-event` - Function to send analytics events 59 | - `max` - Maximum rating value 60 | 61 | Slots, props and widget connector params are all typed! 62 | -------------------------------------------------------------------------------- /src/runtime/components/MenuSelect.vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 66 | -------------------------------------------------------------------------------- /src/runtime/composables/useAisWidget.ts: -------------------------------------------------------------------------------- 1 | import type { RenderState } from "instantsearch.js"; 2 | import { computed, inject, watch, ref, type Ref, triggerRef } from "vue"; 3 | import { useInstantSearch } from "./useInstantSearch"; 4 | import { useState } from "nuxt/app"; 5 | 6 | export const useAisWidget = ( 7 | widgetName: TWidget, 8 | id?: string, // is used for getting a widget with multiple widget instances like clearRefinements 9 | ) => { 10 | const { getInstance } = useInstantSearch(); 11 | const instance = getInstance(); 12 | 13 | const maybeInjectedIndex = inject("index", undefined); 14 | 15 | const index = maybeInjectedIndex ?? instance.value.indexName; 16 | type _TWidgetRenderState = 17 | (typeof instance.value.renderState)[typeof index][typeof widgetName]; 18 | 19 | type TWidgetRenderState = Ref>; 20 | const _state = ( 21 | id 22 | ? inject(`${widgetName}-${id}`, undefined) 23 | : ref(instance.value.renderState[index][widgetName]!) 24 | ) as TWidgetRenderState; 25 | 26 | // cache injected values on client via useState 27 | const state = import.meta.server 28 | ? _state 29 | : id 30 | ? useState(`${widgetName}-${id}`, () => _state) 31 | : _state; 32 | watch( 33 | instance, 34 | () => { 35 | if (!id) { 36 | // @ts-ignore 37 | state.value = instance.value.renderState[index][widgetName]!; 38 | } else { 39 | triggerRef(state); 40 | } 41 | }, 42 | { deep: true }, 43 | ); 44 | 45 | if (!state.value) 46 | throw new Error( 47 | `Connector for component ${widgetName} not found, did you forget to add the proper widget?`, 48 | ); 49 | 50 | const widgetParams = computed(() => state.value!.widgetParams); 51 | 52 | return { 53 | instance, 54 | state, 55 | widgetParams, 56 | }; 57 | }; 58 | -------------------------------------------------------------------------------- /test/fixtures/parity/pages/swift/router/[brand].vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 65 | -------------------------------------------------------------------------------- /playground/pages/autocomplete.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /test/parity.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect } from "vitest"; 2 | import { fileURLToPath } from 'node:url' 3 | import { setup, createPage } from "@nuxt/test-utils/e2e"; 4 | import { 5 | collectNormalizedMarkup, 6 | } from "./utils/html"; 7 | 8 | const PORT = 7780; 9 | const getTestUrl = (route: string) => `http://127.0.0.1:${PORT}${route}`; 10 | 11 | 12 | 13 | const widgetTestIds = [ 14 | "searchbox", 15 | // "stats", => flaky data inside, maybe needs a dedicated test, maybe test is useless since UI is simple 16 | "currentrefinements", 17 | "clearrefinements", 18 | "sortby", 19 | "togglerefinement", 20 | "refinementlist", 21 | "menu", 22 | "menuselect", 23 | "numericmenu", 24 | // "ratingmenu", => will need dedicated test since we went without svg for stars 25 | "hierarchicalmenu", 26 | "rangeinput", 27 | // "autocomplete", => will need dedicated test 28 | "hits", 29 | "infinitehits", 30 | "pagination", 31 | "panel", 32 | "index-hits", 33 | "index-refinementlist", 34 | ]; 35 | 36 | 37 | 38 | const captureState = async (route: string) => { 39 | const page = await createPage('/'); 40 | await page.goto(getTestUrl(route), { waitUntil: 'hydration' }) 41 | const initial = await collectNormalizedMarkup(page, widgetTestIds); 42 | await page.close(); 43 | 44 | return { 45 | initial, 46 | }; 47 | }; 48 | 49 | describe("swiftsearch parity", async () => { 50 | await setup({ 51 | rootDir: fileURLToPath(new URL('./fixtures/parity', import.meta.url)), 52 | browser: true, 53 | server: true, 54 | port: PORT, 55 | }); 56 | 57 | it("matches vue-instantsearch markup before and after interactions", async () => { 58 | const originalState = await captureState("/original/full"); 59 | const swiftState = await captureState("/swift/full"); 60 | 61 | for (const testId of widgetTestIds) { 62 | expect(swiftState.initial[testId]).toBe(originalState.initial[testId]); 63 | } 64 | }); 65 | 66 | 67 | }); 68 | -------------------------------------------------------------------------------- /test/fixtures/parity/pages/swift/router/index.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 62 | -------------------------------------------------------------------------------- /docs/content/3.components/13.ais-current-refinements.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "" 3 | description: Current refinements widget 4 | --- 5 | 6 | ## Usage 7 | 8 | ```vue [MySearchExperience.vue] 9 | 42 | 43 | 50 | ``` 51 | 52 | ::callout{type="warning"} 53 | ⚠️ Always setting ID to widget and component\'s prop if you use more CurrentRefinements components inside the same index 54 | :: 55 | 56 | ::callout{type="warning"} 57 | ⚠️ This component does not allow the simultaneous use of the **included-attributes** and **excluded-attributes** props 58 | :: 59 | 60 | Slots, props and widget connector params are all typed! 61 | More thorough documentation coming soon :) 62 | -------------------------------------------------------------------------------- /src/runtime/components/InstantSearch.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /docs/content/3.components/17.ais-query-rule-custom-data.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "" 3 | description: Query Rule Custom Data Widget 4 | --- 5 | 6 | ## Usage 7 | 8 | ```vue [MyQueryRuleCustomData.vue] 9 | 38 | 39 | 74 | 75 | ``` 76 | 77 | Slots, props and widget connector params are all typed! 78 | More thorough documentation coming soon :) 79 | -------------------------------------------------------------------------------- /src/runtime/components/NumericMenu.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | 66 | -------------------------------------------------------------------------------- /src/runtime/components/StateResults.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 74 | -------------------------------------------------------------------------------- /src/runtime/components/RatingMenu.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 69 | -------------------------------------------------------------------------------- /src/runtime/components/HierarchicalMenu.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 72 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@atoms-studio/nuxt-swiftsearch", 3 | "version": "0.9.4", 4 | "description": "A tailor made implementation of algolia instantsearch for nuxt 3", 5 | "repository": "@atoms-studio/nuxt-swiftsearch", 6 | "license": "MIT", 7 | "type": "module", 8 | "exports": { 9 | ".": { 10 | "types": "./dist/types.d.mts", 11 | "import": "./dist/module.mjs" 12 | } 13 | }, 14 | "main": "./dist/module.mjs", 15 | "typesVersions": { 16 | "*": { 17 | ".": [ 18 | "./dist/types.d.mts" 19 | ], 20 | "utils": [ 21 | "./dist/utils.d.mts" 22 | ] 23 | } 24 | }, 25 | "files": [ 26 | "dist" 27 | ], 28 | "scripts": { 29 | "prepack": "nuxt-module-build build", 30 | "dev": "nuxi dev playground", 31 | "dev:build": "nuxi build playground", 32 | "dev:prepare": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxi prepare playground", 33 | "release": "npm run lint --fix && npm run prepack && changelogen --release && npm publish --access public && git push --follow-tags", 34 | "release-minor": "npm run lint --fix && npm run prepack && changelogen --release --minor && npm publish --access public && git push --follow-tags", 35 | "release-major": "npm run lint --fix && npm run prepack && changelogen --release --major && npm publish --access public && git push --follow-tags", 36 | "lint": "eslint .", 37 | "test": "vitest run", 38 | "test:watch": "vitest watch" 39 | }, 40 | "dependencies": { 41 | "@babel/runtime": "^7.23.9", 42 | "@nuxt/kit": "^3.19.2", 43 | "algoliasearch": "^5.38.0", 44 | "instantsearch.js": "^4.80.0", 45 | "ohash": "1.1.6", 46 | "picoquery": "^2.4.0" 47 | }, 48 | "devDependencies": { 49 | "@nuxt/devtools": "latest", 50 | "@nuxt/eslint-config": "^0.2.0", 51 | "@nuxt/module-builder": "^1.0.2", 52 | "@nuxt/test-utils": "^3.19.2", 53 | "@types/node": "^20.11.16", 54 | "@vitest/browser": "^3.2.0", 55 | "changelogen": "^0.5.5", 56 | "eslint": "^8.56.0", 57 | "happy-dom": "^18.0.1", 58 | "nuxt": "^3.19.2", 59 | "playwright": "^1.55.0", 60 | "playwright-core": "^1.42.1", 61 | "typescript": "^5.9.2", 62 | "vitest": "^3.2.0", 63 | "vue-instantsearch": "^4.21.0", 64 | "vue-tsc": "^3.0.7" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /docs/pages/[...slug].vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 71 | -------------------------------------------------------------------------------- /playground/pages/search.vue: -------------------------------------------------------------------------------- 1 | 35 | 36 | 80 | 81 | -------------------------------------------------------------------------------- /playground/composables/useCustomRouting.ts: -------------------------------------------------------------------------------- 1 | import type { RouterProps } from "instantsearch.js/es/middlewares"; 2 | export const useCustomRouting = () => { 3 | const router = useRouter(); 4 | const algoliaRouter: Ref, "router">> = ref({ 5 | router: { 6 | read() { 7 | const query = router.currentRoute.value.query; 8 | const isPagedRoute = 9 | router.currentRoute.value.name === "pagination-page-page"; 10 | 11 | const queryObj = Array.isArray(query) ? query[0] : query; 12 | return isPagedRoute 13 | ? { ...queryObj, page: parseInt(router.currentRoute.value.params.page! as string) } 14 | : queryObj; 15 | }, 16 | write(routeState) { 17 | console.log(routeState, "write"); 18 | // if I have a page 19 | const iHavePage = !!routeState?.page; 20 | console.log(iHavePage); 21 | iHavePage 22 | ? router.push({ 23 | name: "pagination-page-page", 24 | query: { ...routeState, page: undefined }, 25 | params: { page: routeState.page as string }, 26 | }) 27 | : // @ts-ignore 28 | router.push({ query: { ...routeState } }); 29 | }, 30 | createURL(routeState) { 31 | console.log(routeState, "createUrl"); 32 | return router.resolve({ 33 | // @ts-ignore see comment above 34 | query: routeState, 35 | }).href; 36 | }, 37 | onUpdate(cb: any) { 38 | if (typeof window === "undefined") return; 39 | // @ts-ignore 40 | this._removeAfterEach = router.afterEach((to, from) => { 41 | cb(this.read()); 42 | }); 43 | 44 | // @ts-ignore 45 | this._onPopState = () => { 46 | cb(this.read()); 47 | }; 48 | // @ts-ignore 49 | window.addEventListener("popstate", this._onPopState); 50 | }, 51 | dispose() { 52 | if (typeof window === "undefined") { 53 | return; 54 | } 55 | // @ts-ignore 56 | if (this._onPopState) { 57 | // @ts-ignore 58 | window.removeEventListener("popstate", this._onPopState); 59 | } 60 | // @ts-ignore 61 | if (this._removeAfterEach) { 62 | // @ts-ignore 63 | this._removeAfterEach(); 64 | } 65 | }, 66 | }, 67 | }); 68 | 69 | return algoliaRouter; 70 | }; -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nuxt Swiftsearch 2 | 3 | [![npm version][npm-version-src]][npm-version-href] 4 | [![npm downloads][npm-downloads-src]][npm-downloads-href] 5 | [![License][license-src]][license-href] 6 | [![Nuxt][nuxt-src]][nuxt-href] 7 | 8 | A tailor made implementation of algolia instantsearch for nuxt 3. 9 | 10 | - [✨  Release Notes](/CHANGELOG.md) 11 | 12 | - [📖  Documentation](https://nuxt-swiftsearch.netlify.app) 13 | 14 | ## Features 15 | 16 | - 🍀  SSR First, client only on demand, as is any other nuxt component 17 | - 🗼  Centralized state, you can tap into it from anywhere in your app 18 | - 🌲  99% compatible with vue-instantsearch current implementation 19 | - 👮  Typed components 20 | 21 | ## Quick Setup 22 | 23 | 1. Add `@atoms-studio/nuxt-swiftsearch` dependency to your project 24 | 25 | ```bash 26 | npx nuxi@latest module add swiftsearch 27 | ``` 28 | 29 | 2. Add `@atoms-studio/nuxt-swiftsearch` to the `modules` section of `nuxt.config.ts` 30 | 31 | ```js 32 | export default defineNuxtConfig({ 33 | modules: ["@atoms-studio/nuxt-swiftsearch"], 34 | }); 35 | ``` 36 | 37 | That's it! You can now use Nuxt Swiftsearch in your Nuxt app ✨ 38 | 39 | ## Development 40 | 41 | ```bash 42 | # Install dependencies 43 | npm install 44 | 45 | # Generate type stubs 46 | npm run dev:prepare 47 | 48 | # Develop with the playground 49 | npm run dev 50 | 51 | # Build the playground 52 | npm run dev:build 53 | 54 | # Run ESLint 55 | npm run lint 56 | 57 | # Run Vitest 58 | npm run test 59 | npm run test:watch 60 | 61 | # Release new version 62 | npm run release 63 | ``` 64 | 65 | 66 | 67 | [npm-version-src]: https://img.shields.io/npm/v/@atoms-studio/nuxt-swiftsearch/latest.svg?style=flat&colorA=18181B&colorB=28CF8D 68 | [npm-version-href]: https://npmjs.com/package/@atoms-studio/nuxt-swiftsearch 69 | [npm-downloads-src]: https://img.shields.io/npm/dm/@atoms-studio/nuxt-swiftsearch.svg?style=flat&colorA=18181B&colorB=28CF8D 70 | [npm-downloads-href]: https://npmjs.com/package/@atoms-studio/nuxt-swiftsearch 71 | [license-src]: https://img.shields.io/npm/l/@atoms-studio/nuxt-swiftsearch.svg?style=flat&colorA=18181B&colorB=28CF8D 72 | [license-href]: https://npmjs.com/package/@atoms-studio/nuxt-swiftsearch 73 | [nuxt-src]: https://img.shields.io/badge/Nuxt-18181B?logo=nuxt.js 74 | [nuxt-href]: https://nuxt.com 75 | -------------------------------------------------------------------------------- /docs/app.config.ts: -------------------------------------------------------------------------------- 1 | export default defineAppConfig({ 2 | ui: { 3 | primary: 'green', 4 | gray: 'slate', 5 | footer: { 6 | bottom: { 7 | left: 'text-sm text-gray-500 dark:text-gray-400', 8 | wrapper: 'border-t border-gray-200 dark:border-gray-800', 9 | }, 10 | }, 11 | }, 12 | seo: { 13 | siteName: 'Nuxt Swiftsearch - Docs', 14 | }, 15 | header: { 16 | logo: { 17 | alt: '', 18 | light: 'swiftsearch.svg', 19 | dark: 'swiftsearch-dark.svg', 20 | }, 21 | search: true, 22 | colorMode: true, 23 | links: [ 24 | { 25 | icon: 'i-simple-icons-github', 26 | to: 'https://github.com/atoms-studio/nuxt-swiftsearch', 27 | target: '_blank', 28 | 'aria-label': 'Instantsearch for nuxt', 29 | }, 30 | ], 31 | }, 32 | footer: { 33 | credits: 'Copyright © 2023', 34 | colorMode: false, 35 | links: [ 36 | { 37 | icon: 'i-simple-icons-nuxtdotjs', 38 | to: 'https://nuxt.com', 39 | target: '_blank', 40 | 'aria-label': 'Nuxt Website', 41 | }, 42 | { 43 | icon: 'i-simple-icons-discord', 44 | to: 'https://discord.com/invite/ps2h6QT', 45 | target: '_blank', 46 | 'aria-label': 'Nuxt UI on Discord', 47 | }, 48 | { 49 | icon: 'i-simple-icons-x', 50 | to: 'https://x.com/nuxt_js', 51 | target: '_blank', 52 | 'aria-label': 'Nuxt on X', 53 | }, 54 | { 55 | icon: 'i-simple-icons-github', 56 | to: 'https://github.com/nuxt/ui', 57 | target: '_blank', 58 | 'aria-label': 'Nuxt UI on GitHub', 59 | }, 60 | ], 61 | }, 62 | toc: { 63 | title: 'Table of Contents', 64 | bottom: { 65 | title: 'Community', 66 | edit: 'https://github.com/nuxt-ui-pro/docs/edit/main/content', 67 | links: [ 68 | { 69 | icon: 'i-heroicons-star', 70 | label: 'Star on GitHub', 71 | to: 'https://github.com/nuxt/ui', 72 | target: '_blank', 73 | }, 74 | { 75 | icon: 'i-heroicons-book-open', 76 | label: 'Nuxt UI Pro docs', 77 | to: 'https://ui.nuxt.com/pro/guide', 78 | target: '_blank', 79 | }, 80 | { 81 | icon: 'i-simple-icons-nuxtdotjs', 82 | label: 'Purchase a license', 83 | to: 'https://ui.nuxt.com/pro/purchase', 84 | target: '_blank', 85 | }, 86 | ], 87 | }, 88 | }, 89 | }) 90 | -------------------------------------------------------------------------------- /docs/content/3.components/20.ais-panel.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "" 3 | description: Panel Widget 4 | --- 5 | 6 | The `AisPanel` component is a wrapper that organizes Algolia InstantSearch refinement widgets into a panel structure with header, body, and footer. It provides control over display based on refinement state and allows full customization through slots and CSS classes. 7 | 8 | ## Props 9 | 10 | | Prop | Description | 11 | |--------------|--------------------------------------------------------------------------| 12 | | `component` | The type of widget component (e.g., "refinementList", "toggleRefinement")| 13 | | `attribute` | The index attribute to be used for refinements | 14 | | `classNames` | Custom CSS classes for different parts of the panel | 15 | 16 | ## Slots 17 | **** 18 | ### `default` 19 | Main slot containing the refinement widget. 20 | 21 | **Available props:** 22 | - `hasRefinements: boolean` - Indicates if there are active refinements 23 | 24 | ### `header` 25 | Slot for the panel header. 26 | 27 | **Available props:** 28 | - `hasRefinements: boolean` - Indicates if there are active refinements 29 | 30 | ### `footer` 31 | Slot for the panel footer. 32 | 33 | **Available props:** 34 | - `hasRefinements: boolean` - Indicates if there are active refinements 35 | 36 | ## CSS Classes 37 | 38 | The component uses the SUIT CSS class system with the following base classes: 39 | 40 | - `.ais-Panel` - Root panel class 41 | - `.ais-Panel--noRefinement` - Applied when there are no active refinements 42 | - `.ais-Panel-header` - Header class 43 | - `.ais-Panel-body` - Main body class 44 | - `.ais-Panel-footer` - Footer class 45 | 46 | ## Usage 47 | ```vue [MySearchExperience.vue] 48 | 59 | 62 | 65 | 68 | 69 | ``` 70 | 71 | Slots, props and widget connector params are all typed! 72 | More thorough documentation coming soon :) 73 | -------------------------------------------------------------------------------- /src/runtime/components/Highlighter.js: -------------------------------------------------------------------------------- 1 | import { createHighlightComponent } from 'instantsearch-ui-components'; 2 | import { 3 | getHighlightedParts, 4 | getPropertyByPath, 5 | unescape, 6 | } from 'instantsearch.js/es/lib/utils'; 7 | import { h, Fragment } from 'vue' 8 | 9 | const createElement = (tag, props, children) => { 10 | if (!children) { 11 | return h(tag, props); 12 | } 13 | 14 | if (tag === Fragment) { 15 | return h(tag, Array.isArray(children) ? children : [children]); 16 | } 17 | 18 | // It does work to just pass a string but outputs a warning about performance issues 19 | const newChildren = 20 | typeof children === 'string' ? { default: () => children } : children; 21 | // Passing a `children` prop to a DOM element outputs a warning 22 | const newProps = 23 | typeof tag === 'string' ? props : Object.assign(props, { children }); 24 | 25 | return h(tag, newProps, newChildren); 26 | }; 27 | 28 | const Highlight = createHighlightComponent({ createElement, Fragment }); 29 | 30 | export default { 31 | name: 'AisHighlighter', 32 | props: { 33 | hit: { 34 | type: Object, 35 | required: true, 36 | }, 37 | attribute: { 38 | type: String, 39 | required: true, 40 | }, 41 | highlightedTagName: { 42 | type: String, 43 | default: 'mark', 44 | }, 45 | suit: { 46 | type: Function, 47 | required: true, 48 | }, 49 | highlightProperty: { 50 | type: String, 51 | required: true, 52 | }, 53 | preTag: { 54 | type: String, 55 | required: true, 56 | }, 57 | postTag: { 58 | type: String, 59 | required: true, 60 | }, 61 | }, 62 | render() { 63 | const property = 64 | getPropertyByPath(this.hit[this.highlightProperty], this.attribute) || []; 65 | const properties = Array.isArray(property) ? property : [property]; 66 | 67 | const parts = properties.map((singleValue) => 68 | getHighlightedParts(unescape(singleValue.value || '')).map( 69 | ({ value, isHighlighted }) => ({ 70 | // We have to do this because Vue gets rid of TextNodes with a single white space 71 | value: value === ' ' ? ' ' : value, 72 | isHighlighted, 73 | }) 74 | ) 75 | ); 76 | 77 | return createElement(Highlight, { 78 | classNames: { 79 | root: this.suit(), 80 | highlighted: this.suit('highlighted'), 81 | }, 82 | highlightedTagName: this.highlightedTagName, 83 | nonHighlightedTagName: Fragment, 84 | parts, 85 | }); 86 | }, 87 | }; 88 | -------------------------------------------------------------------------------- /docs/content/3.components/18.ais-numeric-menu.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "" 3 | description: Numeric Menu Widget 4 | --- 5 | 6 | ## Usage 7 | 8 | ```vue [MySearchExperience.vue] 9 | 23 | 24 | 38 | ``` 39 | 40 | ## Props 41 | 42 | | Prop | Type | Default | Description | 43 | |------|------|---------|-------------| 44 | | `attribute` | `string` | - | **Required.** The name of the attribute in the records | 45 | | `items` | `NumericMenuConnectorParamsItem[]` | - | **Required.** Array of numeric menu items with label, start, and end values | 46 | 47 | ## Features 48 | 49 | - **Numeric Range Filtering**: Filter results by numeric ranges (e.g., price ranges) 50 | - **Radio Button Interface**: Clean radio button selection for single range choice 51 | - **Flexible Range Definition**: Support for open-ended ranges (start only, end only, or both) 52 | - **Visual Selection State**: Clear indication of selected range with styling 53 | - **Accessibility**: Proper semantic HTML structure with labels and radio inputs 54 | 55 | ## Example Output 56 | 57 | The component renders numeric ranges in the format: 58 | - `All` - Show all results (no filtering) 59 | - `<= 10$` - Results with values up to 10 60 | - `10$ - 100$` - Results with values between 10 and 100 61 | - `100$ - 500$` - Results with values between 100 and 500 62 | - `>= 500$` - Results with values 500 and above 63 | 64 | ## Slots 65 | 66 | The component provides a default slot with the following props: 67 | 68 | - `items` - Array of numeric menu items with label, value, isRefined, and count 69 | - `can-refine` - Boolean indicating if refinement is possible 70 | - `refine` - Function to refine by numeric range value 71 | - `create-u-r-l` - Function to create URLs for numeric range values 72 | - `send-event` - Function to send analytics events 73 | 74 | Slots, props and widget connector params are all typed! 75 | -------------------------------------------------------------------------------- /src/runtime/composables/useAisRouter.ts: -------------------------------------------------------------------------------- 1 | import { useRouter, useNuxtApp } from "nuxt/app"; 2 | import { ref } from "vue"; 3 | import type { Ref } from "vue"; 4 | import type { RouterProps } from "instantsearch.js/es/middlewares"; 5 | 6 | function stripUndefined(obj: Record) { 7 | return Object.fromEntries( 8 | Object.entries(obj).filter(([k, v]) => v !== undefined), 9 | ); 10 | } 11 | export const useAisRouter = () => { 12 | const router = useRouter(); 13 | const app = useNuxtApp(); 14 | const prevState = ref>({}) 15 | const algoliaRouter: Ref, "router">> = ref({ 16 | router: { 17 | read() { 18 | const query = router.currentRoute.value.query; 19 | const normalizedQuery = Array.isArray(query) ? query[0] : query; 20 | return stripUndefined(normalizedQuery); 21 | }, 22 | write(routeState) { 23 | // strip routeState and query from possible undefined values 24 | const currentQueryState = this.read(); 25 | if ( 26 | JSON.stringify(currentQueryState) === 27 | JSON.stringify(stripUndefined(routeState)) 28 | ) { 29 | prevState.value = stripUndefined(routeState) 30 | return; 31 | } 32 | const prevStateKeys = Object.keys(prevState.value) 33 | const queryStateToAppend = Object.fromEntries(Object.entries(currentQueryState).filter(([k, v]) => !prevStateKeys.includes(k))) 34 | 35 | // @ts-ignore ignoring because uiState is compatible with query after introducing qs as a query param parser 36 | router.push({ 37 | query: { ...queryStateToAppend, ...stripUndefined(routeState) }, 38 | }); 39 | // saving previous state 40 | prevState.value = stripUndefined(routeState) 41 | }, 42 | createURL(routeState) { 43 | return router.resolve({ 44 | // @ts-ignore see comment above 45 | query: routeState, 46 | }).href; 47 | }, 48 | onUpdate(cb: any) { 49 | if (typeof window === "undefined") return; 50 | // @ts-ignore 51 | this._removeAfterEach = router.afterEach((to, from) => { 52 | if (to.path === from.path) cb(this.read()); 53 | }); 54 | app.hook("page:finish", () => { 55 | cb(this.read()); 56 | }); 57 | }, 58 | dispose() { 59 | if (typeof window === "undefined") { 60 | return; 61 | } 62 | // @ts-ignore 63 | if (this._removeAfterEach) { 64 | // @ts-ignore 65 | this._removeAfterEach(); 66 | } 67 | }, 68 | }, 69 | }); 70 | 71 | return algoliaRouter; 72 | }; 73 | -------------------------------------------------------------------------------- /src/runtime/components/Menu.vue: -------------------------------------------------------------------------------- 1 | 52 | 53 | 87 | -------------------------------------------------------------------------------- /src/runtime/components/InfiniteHits.vue: -------------------------------------------------------------------------------- 1 | 74 | 75 | 94 | -------------------------------------------------------------------------------- /docs/README.md: -------------------------------------------------------------------------------- 1 | ![nuxt-ui-docs-social-card](https://github.com/nuxt-ui-pro/docs/assets/739984/f64e13d9-9ae0-4e03-bf7f-6be4c36cd9ba) 2 | 3 | # Nuxt UI Pro - Docs template 4 | 5 | [![Nuxt UI Pro](https://img.shields.io/badge/Made%20with-Nuxt%20UI%20Pro-00DC82?logo=nuxt.js&labelColor=020420)](https://ui.nuxt.com/pro) 6 | [![Nuxt Studio](https://img.shields.io/badge/Open%20in%20Nuxt%20Studio-18181B?&logo=nuxt.js&logoColor=3BB5EC)](https://nuxt.studio/themes/docs) 7 | 8 | - [Live demo](https://docs-template.nuxt.dev/) 9 | - [Play on Stackblitz](https://stackblitz.com/github/nuxt-ui-pro/docs) 10 | - [Documentation](https://ui.nuxt.com/pro/getting-started) 11 | - [Clone on Nuxt Studio](https://nuxt.studio/themes/docs) 12 | 13 | ## Quick Start 14 | 15 | ```bash [Terminal] 16 | npx nuxi init -t github:nuxt-ui-pro/docs 17 | ``` 18 | 19 | ## Setup 20 | 21 | Make sure to install the dependencies: 22 | 23 | ```bash 24 | # npm 25 | npm install 26 | 27 | # pnpm 28 | pnpm install 29 | 30 | # yarn 31 | yarn install 32 | 33 | # bun 34 | bun install 35 | ``` 36 | 37 | ## Development Server 38 | 39 | Start the development server on `http://localhost:3000`: 40 | 41 | ```bash 42 | # npm 43 | npm run dev 44 | 45 | # pnpm 46 | pnpm run dev 47 | 48 | # yarn 49 | yarn dev 50 | 51 | # bun 52 | bun run dev 53 | ``` 54 | 55 | ## Production 56 | 57 | Build the application for production: 58 | 59 | ```bash 60 | # npm 61 | npm run build 62 | 63 | # pnpm 64 | pnpm run build 65 | 66 | # yarn 67 | yarn build 68 | 69 | # bun 70 | bun run build 71 | ``` 72 | 73 | Locally preview production build: 74 | 75 | ```bash 76 | # npm 77 | npm run preview 78 | 79 | # pnpm 80 | pnpm run preview 81 | 82 | # yarn 83 | yarn preview 84 | 85 | # bun 86 | bun run preview 87 | ``` 88 | 89 | Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information. 90 | 91 | ## Nuxt Studio integration 92 | 93 | Add `@nuxthq/studio` dependency to your package.json: 94 | 95 | ```bash 96 | # npm 97 | npm install --save-dev @nuxthq/studio 98 | 99 | # pnpm 100 | pnpm add -D @nuxthq/studio 101 | 102 | # yarn 103 | yarn add -D @nuxthq/studio 104 | 105 | # bun 106 | bun add -d @nuxthq/studio 107 | ``` 108 | 109 | Add this module to your `nuxt.config.ts`: 110 | 111 | ```ts 112 | export default defineNuxtConfig({ 113 | ... 114 | modules: [ 115 | ... 116 | '@nuxthq/studio' 117 | ] 118 | }) 119 | ``` 120 | 121 | Read more on [Nuxt Studio docs](https://nuxt.studio/docs/projects/setup). 122 | 123 | ## Renovate integration 124 | 125 | Install [Renovate GitHub app](https://github.com/apps/renovate/installations/select_target) on your repository and you are good to go. 126 | -------------------------------------------------------------------------------- /src/runtime/components/SearchBox.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 96 | -------------------------------------------------------------------------------- /src/runtime/components/CurrentRefinements.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 88 | -------------------------------------------------------------------------------- /src/runtime/composables/useAisInfiniteHitsStatefulCache.ts: -------------------------------------------------------------------------------- 1 | import { isEqual } from "ohash"; 2 | import { useState } from "#app"; 3 | import { ref } from "vue"; 4 | 5 | import type { 6 | InfiniteHitsCache, 7 | InfiniteHitsCachedHits, 8 | } from "instantsearch.js/es/connectors/infinite-hits/connectInfiniteHits"; 9 | import type { PlainSearchParameters } from "algoliasearch-helper"; 10 | import type { BaseHit } from "instantsearch.js"; 11 | 12 | function getStateWithoutPage(state: PlainSearchParameters) { 13 | const { page, ...rest } = state || {}; 14 | return rest; 15 | } 16 | 17 | const KEY = "ais.infiniteHits"; 18 | 19 | function getKeyFromState(state: PlainSearchParameters, key?: string) { 20 | return `${JSON.stringify(getStateWithoutPage(state))}${key ?? KEY}`; 21 | } 22 | 23 | const _storage = () => 24 | import.meta.server 25 | ? ref>({}) 26 | : useState>("_instantsearchStorageState", () => ({})); 27 | 28 | export const useAisInfiniteHitsStatefulCache = (key?: string) => { 29 | const ssrPage = useState(`${key ?? KEY}-server-page`, () => 0); 30 | const sessionStorage: Omit = { 31 | getItem(key: string) { 32 | return _storage().value[key]; 33 | }, 34 | setItem(key: string, value: any) { 35 | return (_storage().value[key] = value); 36 | }, 37 | removeItem(key: string) { 38 | if (_storage().value?.[key]) delete _storage().value[key]; 39 | }, 40 | }; 41 | const cache: InfiniteHitsCache = { 42 | read: ({ state }) => { 43 | try { 44 | const cache = sessionStorage.getItem(getKeyFromState(state, key)); 45 | const maxPage = Math.max(ssrPage.value, state.page ?? 0); 46 | return cache && isEqual(cache.state, getStateWithoutPage(state)) 47 | ? (Object.fromEntries( 48 | Object.entries(cache.hits).slice(0, maxPage), 49 | ) as InfiniteHitsCachedHits) 50 | : null; 51 | } catch (error) { 52 | console.error(error); 53 | if (error instanceof SyntaxError) { 54 | try { 55 | sessionStorage.removeItem(KEY); 56 | } catch (err) { 57 | // do nothing 58 | } 59 | } 60 | return null; 61 | } 62 | }, 63 | write: ({ state, hits }) => { 64 | if (import.meta.server) ssrPage.value = state.page!; 65 | try { 66 | const currentHits = 67 | sessionStorage.getItem(getKeyFromState(state, key))?.hits ?? {}; 68 | const hitsToWrite = { ...currentHits, ...hits }; 69 | sessionStorage.setItem(getKeyFromState(state, key), { 70 | state: getStateWithoutPage(state), 71 | hits: hitsToWrite, 72 | }); 73 | } catch (error) { 74 | console.error(error); 75 | // do nothing 76 | } 77 | }, 78 | }; 79 | return cache; 80 | }; 81 | -------------------------------------------------------------------------------- /playground/pages/test/[...catchall].vue: -------------------------------------------------------------------------------- 1 | 45 | 46 | 98 | 99 | 100 | -------------------------------------------------------------------------------- /playground/pages/index.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 109 | 110 | 111 | -------------------------------------------------------------------------------- /test/routing.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, beforeAll } from "vitest"; 2 | import { setup, createPage, $fetch } from "@nuxt/test-utils/e2e"; 3 | 4 | const PORT = 7781; 5 | const getTestUrl = (route: string) => `http://127.0.0.1:${PORT}${route}`; 6 | const fixtureRoot = decodeURIComponent( 7 | new URL("./fixtures/parity", import.meta.url).pathname, 8 | ); 9 | 10 | const decodeUrl = (url: string) => decodeURIComponent(url); 11 | 12 | describe("swiftsearch routing", async () => { 13 | await setup({ 14 | rootDir: fixtureRoot, 15 | browser: true, 16 | server: true, 17 | dev: false, 18 | port: PORT, 19 | }); 20 | 21 | it("hydrates search parameters from the incoming URL", async () => { 22 | const route = "/swift/router?instant_search%5Bquery%5D=Samsung"; 23 | const html = await $fetch(route); 24 | expect(html).toContain("value=\"Samsung\""); 25 | 26 | const page = await createPage('/'); 27 | await page.goto(getTestUrl(route), { waitUntil: 'hydration' }) 28 | 29 | await page.waitForLoadState("networkidle"); 30 | const searchValue = await page 31 | .getByTestId("router-searchbox") 32 | .locator("input[type='search'], input[type='text']") 33 | .inputValue(); 34 | expect(searchValue).toBe("Samsung"); 35 | await page.close(); 36 | }); 37 | 38 | it("syncs refinements into the URL and restores them on reload", async () => { 39 | const page = await createPage('/'); 40 | await page.goto(getTestUrl("/swift/router"), { waitUntil: 'hydration' }) 41 | 42 | await page.waitForLoadState("networkidle"); 43 | 44 | const searchInput = page 45 | .getByTestId("router-searchbox") 46 | .locator("input[type='search'], input[type='text']") 47 | .first(); 48 | await searchInput.fill("tv"); 49 | await searchInput.press("Enter"); 50 | await page.waitForTimeout(600); 51 | 52 | const toggle = page 53 | .getByTestId("router-togglerefinement") 54 | .locator("input[type='checkbox'], button") 55 | .first(); 56 | await toggle.click(); 57 | await page.waitForTimeout(600); 58 | 59 | const refinedUrl = decodeUrl(page.url()); 60 | expect(refinedUrl).toContain("instant_search[toggle][free_shipping]=true"); 61 | expect(refinedUrl).toContain("instant_search[query]=tv"); 62 | 63 | await page.reload({ waitUntil: "networkidle" }); 64 | const currentRefinements = await page 65 | .getByTestId("router-currentrefinements") 66 | .innerHTML(); 67 | expect(currentRefinements).toContain("Free_shipping"); 68 | await page.close(); 69 | }); 70 | 71 | it("keeps InstantSearch state across route navigation", async () => { 72 | const page = await createPage('/'); 73 | await page.goto(getTestUrl("/swift/router"), { waitUntil: 'hydration' }) 74 | 75 | await page.waitForLoadState("networkidle"); 76 | 77 | await page.getByText("Samsung route").click(); 78 | await page.waitForLoadState("networkidle"); 79 | 80 | const heading = await page.getByTestId("brand-heading").textContent(); 81 | expect(heading).toBe("Samsung"); 82 | expect(decodeUrl(page.url())).toContain("/swift/router/Samsung"); 83 | 84 | await page.goBack(); 85 | await page.waitForLoadState("networkidle"); 86 | expect(decodeUrl(page.url())).toContain("/swift/router"); 87 | 88 | const currentRefinements = await page 89 | .getByTestId("router-currentrefinements") 90 | .innerHTML(); 91 | expect(currentRefinements).not.toBe(""); 92 | await page.close(); 93 | }); 94 | }); 95 | -------------------------------------------------------------------------------- /src/runtime/components/RangeInput.vue: -------------------------------------------------------------------------------- 1 | 51 | 123 | -------------------------------------------------------------------------------- /src/runtime/utils/parseAlgoliaHit.ts: -------------------------------------------------------------------------------- 1 | // copied from React InstantSearch 2 | import { getPropertyByPath } from "instantsearch.js/es/lib/utils"; 3 | 4 | const TAG_PLACEHOLDER = { 5 | highlightPreTag: "__ais-highlight__", 6 | highlightPostTag: "__/ais-highlight__", 7 | } as const; 8 | 9 | /** 10 | * Parses an highlighted attribute into an array of objects with the string value, and 11 | * a boolean that indicated if this part is highlighted. 12 | * 13 | * @param {string} preTag - string used to identify the start of an highlighted value 14 | * @param {string} postTag - string used to identify the end of an highlighted value 15 | * @param {string} highlightedValue - highlighted attribute as returned by Algolia highlight feature 16 | * @return {object[]} - An array of {value: string, isHighlighted: boolean}. 17 | */ 18 | function parseHighlightedAttribute({ 19 | preTag, 20 | postTag, 21 | highlightedValue = "", 22 | }: { 23 | preTag: string; 24 | postTag: string; 25 | highlightedValue: string; 26 | }) { 27 | const splitByPreTag = highlightedValue.split(preTag); 28 | const firstValue = splitByPreTag.shift(); 29 | const elements = 30 | firstValue === "" ? [] : [{ value: firstValue, isHighlighted: false }]; 31 | 32 | if (postTag === preTag) { 33 | let isHighlighted = true; 34 | splitByPreTag.forEach((split) => { 35 | elements.push({ value: split, isHighlighted }); 36 | isHighlighted = !isHighlighted; 37 | }); 38 | } else { 39 | splitByPreTag.forEach((split) => { 40 | const splitByPostTag = split.split(postTag); 41 | 42 | elements.push({ 43 | value: splitByPostTag[0], 44 | isHighlighted: true, 45 | }); 46 | 47 | if (splitByPostTag[1] !== "") { 48 | elements.push({ 49 | // Vue removes nodes which are just a single space (vuejs/vue#9208), 50 | // we replace this by two spaces, which does not have an impact, 51 | // unless someone would have `white-space: pre` on the highlights 52 | value: splitByPostTag[1] === " " ? " " : splitByPostTag[1], 53 | isHighlighted: false, 54 | }); 55 | } 56 | }); 57 | } 58 | 59 | return elements; 60 | } 61 | 62 | /** 63 | * Find an highlighted attribute given an `attribute` and an `highlightProperty`, parses it, 64 | * and provided an array of objects with the string value and a boolean if this 65 | * value is highlighted. 66 | * 67 | * In order to use this feature, highlight must be activated in the configuration of 68 | * the index. The `preTag` and `postTag` attributes are respectively highlightPreTag and 69 | * highlightPostTag in Algolia configuration. 70 | * 71 | * @param {string} preTag - string used to identify the start of an highlighted value 72 | * @param {string} postTag - string used to identify the end of an highlighted value 73 | * @param {string} highlightProperty - the property that contains the highlight structure in the results 74 | * @param {string} attribute - the highlighted attribute to look for 75 | * @param {object} hit - the actual hit returned by Algolia. 76 | * @return {object[]} - An array of {value: string, isHighlighted: boolean}. 77 | */ 78 | export function parseAlgoliaHit({ 79 | preTag = TAG_PLACEHOLDER.highlightPreTag, 80 | postTag = TAG_PLACEHOLDER.highlightPostTag, 81 | highlightProperty, 82 | attribute, 83 | hit, 84 | }: { 85 | preTag: string; 86 | postTag: string; 87 | highlightProperty: string; 88 | attribute: string; 89 | hit: Record; 90 | }) { 91 | if (!hit) throw new Error("`hit`, the matching record, must be provided"); 92 | 93 | const highlightObject = 94 | getPropertyByPath(hit[highlightProperty], attribute) || {}; 95 | 96 | if (Array.isArray(highlightObject)) { 97 | return highlightObject.map((item) => 98 | parseHighlightedAttribute({ 99 | preTag, 100 | postTag, 101 | highlightedValue: unescape(item.value), 102 | }), 103 | ); 104 | } 105 | 106 | return parseHighlightedAttribute({ 107 | preTag, 108 | postTag, 109 | highlightedValue: unescape(highlightObject.value), 110 | }); 111 | } 112 | -------------------------------------------------------------------------------- /test/fixtures/parity/pages/original/full.vue: -------------------------------------------------------------------------------- 1 | 88 | 89 | 141 | -------------------------------------------------------------------------------- /src/runtime/composables/useInstantSearch.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | IndexWidget, 3 | InstantSearch, 4 | Widget, 5 | } from "instantsearch.js/es/types"; 6 | import { isEqual } from 'ohash' 7 | import { 8 | waitForResults, 9 | getInitialResults, 10 | } from "instantsearch.js/es/lib/server"; 11 | import { 12 | clearRefinements, 13 | getRefinements, 14 | } from "instantsearch.js/es/lib/utils"; 15 | import { computed, triggerRef, inject, nextTick, type Ref } from "vue"; 16 | import { useState, createError } from "nuxt/app"; 17 | 18 | import { type InitialResults } from "instantsearch.js/es"; 19 | 20 | export const useInstantSearch = (instance?: Ref | null) => { 21 | const _searchInstance = 22 | instance ?? 23 | (inject>("searchInstance") as Ref); 24 | 25 | const getInstance = () => { 26 | if (!_searchInstance || _searchInstance.value === null) { 27 | throw new Error("instantiate instantsearch first"); 28 | } 29 | return _searchInstance as Ref; 30 | }; 31 | 32 | const parentIndex = computed(() => { 33 | return getInstance().value.mainIndex; 34 | }); 35 | const setup = async (widgets: Array, instanceKey: string = '') => { 36 | const _results = useState( 37 | `instantsearch_ssr_results_${instanceKey}`, 38 | ); 39 | const instance = getInstance(); 40 | // adding widgets to instance if not presents (new instance) 41 | if (!instance.value.mainIndex.getWidgets().length) { 42 | instance.value.addWidgets(widgets); 43 | // } 44 | } else { 45 | const oldWidgets = instance.value.mainIndex.getWidgets(); 46 | // compare widgets 47 | const widgetsToAdd = widgets.filter( 48 | (newW) => 49 | !oldWidgets.some( 50 | // @ts-ignore 51 | (oldW) => isEqual(oldW.$$widgetParams, newW.$$widgetParams), 52 | ), 53 | ); 54 | const widgetsToRemove = oldWidgets.filter( 55 | (oldW) => 56 | !widgets.some((newW) => 57 | // @ts-ignore 58 | isEqual(oldW.$$widgetParams, newW.$$widgetParams), 59 | ), 60 | ); 61 | 62 | if (widgetsToAdd.length || widgetsToRemove.length) { 63 | // clear refinements 64 | const refs = getRefinements( 65 | instance.value.mainIndex.getScopedResults()![0].results, 66 | instance.value.mainIndex!.getScopedResults()![0].helper.state, 67 | true, 68 | ); 69 | 70 | instance.value.helper!.setState( 71 | clearRefinements({ 72 | helper: instance.value.mainHelper!, 73 | attributesToClear: refs.map((refinement) => refinement.attribute), 74 | }), 75 | ); 76 | } 77 | 78 | if (widgetsToRemove.length) instance.value.removeWidgets(widgetsToRemove); 79 | if (widgetsToAdd.length) instance.value.addWidgets(widgetsToAdd); 80 | } 81 | 82 | if (!instance.value.started && !_results.value) { 83 | instance.value.start(); 84 | instance.value.started = false; 85 | const params = await waitForResults(instance.value); 86 | _results.value = getInitialResults(instance.value.mainIndex, params); 87 | } 88 | if (!_results.value && import.meta.client) { 89 | // navigating to another page client side 90 | // can i await results? 91 | // awaiting for search queue empty before page change 92 | await nextTick(async () => { 93 | await new Promise((resolve) => { 94 | if (!instance.value.mainHelper!.hasPendingRequests()) resolve(true); 95 | instance.value.mainHelper!.once("searchQueueEmpty", () => 96 | resolve(true), 97 | ); 98 | }); 99 | }); 100 | } 101 | // if on client and we find results from server 102 | if (_results.value && import.meta.client) { 103 | instance.value._initialResults = _results.value; 104 | // clear results in case of page change 105 | _results.value = null; 106 | } 107 | 108 | if (!instance.value.started && import.meta.client) { 109 | nextTick(async () => { 110 | instance.value.mainHelper?.on("searchQueueEmpty", () => { 111 | instance.value.once("render", () => { 112 | triggerRef(_searchInstance); 113 | }); 114 | }); 115 | }); 116 | 117 | instance.value.on("error", ({ error }) => { 118 | throw createError({ 119 | statusCode: 500, 120 | statusMessage: error, 121 | }); 122 | }); 123 | instance.value.start(); 124 | } 125 | }; 126 | 127 | return { 128 | getInstance, 129 | parentIndex, 130 | setup, 131 | }; 132 | }; 133 | -------------------------------------------------------------------------------- /test/snapshots/refinementlist: -------------------------------------------------------------------------------- 1 |
-------------------------------------------------------------------------------- /test/fixtures/parity/pages/swift/full.vue: -------------------------------------------------------------------------------- 1 | 83 | 84 | 157 | -------------------------------------------------------------------------------- /src/runtime/components/RefinementList.vue: -------------------------------------------------------------------------------- 1 | 97 | 155 | --------------------------------------------------------------------------------