diff --git a/.circleci/config.yml b/.circleci/config.yml
index d2f2832b55..6fa3763c76 100644
--- a/.circleci/config.yml
+++ b/.circleci/config.yml
@@ -245,7 +245,7 @@ jobs:
path: junit/jest/
- run:
name: Type Checking
- command: yarn run type-check
+ command: yarn run type-check:v4
vue v3:
<<: *defaults
diff --git a/examples/js/algolia-experiences/favicon.png b/examples/js/algolia-experiences/favicon.png
new file mode 100644
index 0000000000..e681c65988
Binary files /dev/null and b/examples/js/algolia-experiences/favicon.png differ
diff --git a/examples/js/algolia-experiences/index.html b/examples/js/algolia-experiences/index.html
new file mode 100644
index 0000000000..ad90bc8257
--- /dev/null
+++ b/examples/js/algolia-experiences/index.html
@@ -0,0 +1,73 @@
+
+
+
+
+ Algolia Experiences demo
+
+
+
+
+
+
+
+ Buy historical books now!
+
+
+
+
diff --git a/examples/js/algolia-experiences/local.html b/examples/js/algolia-experiences/local.html
new file mode 100644
index 0000000000..36a7249aa3
--- /dev/null
+++ b/examples/js/algolia-experiences/local.html
@@ -0,0 +1,73 @@
+
+
+
+
+ Algolia Experiences local demo
+
+
+
+
+
+
+
+ Buy historical books now!
+
+
+
+
diff --git a/examples/js/algolia-experiences/package.json b/examples/js/algolia-experiences/package.json
new file mode 100644
index 0000000000..2d0c8b644d
--- /dev/null
+++ b/examples/js/algolia-experiences/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "example-algolia-experiences",
+ "version": "1.0.0",
+ "private": true,
+ "scripts": {
+ "start": "BABEL_ENV=parcel parcel index.html local.html",
+ "build": "BABEL_ENV=parcel parcel build index.html local.html --public-url .",
+ "website:examples": "BABEL_ENV=parcel parcel build index.html local.html --public-url . --dist-dir=../../../website/examples/js/algolia-experiences"
+ },
+ "browserslist": "firefox 68, chrome 78, IE 11",
+ "devDependencies": {
+ "@babel/core": "7.15.5",
+ "@parcel/core": "2.10.0",
+ "@parcel/packager-raw-url": "2.10.0",
+ "@parcel/transformer-webmanifest": "2.10.0",
+ "parcel": "2.10.0",
+ "typescript": "5.5.2"
+ }
+}
diff --git a/jest.config.js b/jest.config.js
index d30318f795..934bb71cc4 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -1,5 +1,11 @@
// @ts-check
+/** @type {any} */
+const packagejson = require('./package.json');
+/** @type {'3' | '4' | '5'} */
+const algoliaSearchMajor =
+ packagejson.devDependencies.algoliasearch.split('.')[0];
+
/** @type {import('@jest/types').Config.InitialOptions} */
const config = {
rootDir: process.cwd(),
@@ -17,7 +23,8 @@ const config = {
'/packages/react-instantsearch-router-nextjs/__tests__',
'/packages/react-instantsearch-nextjs/__tests__',
'/__utils__/',
- ],
+ algoliaSearchMajor !== '5' && '/packages/algolia-experiences',
+ ].filter((x) => x !== false),
watchPathIgnorePatterns: [
'/packages/*/cjs',
'/packages/*/dist',
@@ -30,7 +37,9 @@ const config = {
'jest-watch-typeahead/filename',
'jest-watch-typeahead/testname',
],
- transformIgnorePatterns: ['node_modules/(?!(search-insights)/)'],
+ transformIgnorePatterns: [
+ 'node_modules/(?!(search-insights|algoliasearch)/)',
+ ],
transform: {
'^.+\\.(j|t)sx?$': 'babel-jest',
'^.+\\.vue$': '@vue/vue2-jest',
diff --git a/package.json b/package.json
index 291a2efac3..218db12963 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"lint:fix": "eslint --ext .js,.ts,.tsx,.vue --fix .",
"type-check": "tsc && lerna run type-check",
"type-check:v3": "tsc --project tsconfig.v3.json",
+ "type-check:v4": "tsc --project tsconfig.v4.json",
"test": "jest && lerna run test",
"test:ci": "./scripts/retry.sh 3 'jest --maxWorkers=4 --ci' && lerna run test --concurrency=1",
"test:ci:v4": "./scripts/retry.sh 3 'jest --maxWorkers=4 --ci' && lerna run test:v4 --concurrency=1",
diff --git a/packages/algolia-experiences/README.md b/packages/algolia-experiences/README.md
new file mode 100644
index 0000000000..f54d72f776
--- /dev/null
+++ b/packages/algolia-experiences/README.md
@@ -0,0 +1,30 @@
+# `algolia-experiences`
+
+This package allows you to use Algolia without code, by creating experiences in the Algolia dashboard and embedding them in your website.
+
+## Usage
+
+To get started, load the script tag and configuration in your HTML file, and add a `data-experience-id` attribute to the container where you want to render the UI.
+
+```html
+
+
+
+
+
+
+
+
+
+
+```
+
+The `data-experience-id` attribute should be set to the experience ID you want to use. You can find the experience ID in the dashboard.
+
+For styling, you can use the [instantsearch.css](https://www.npmjs.com/package/instantsearch.css) package.
diff --git a/packages/algolia-experiences/package.json b/packages/algolia-experiences/package.json
new file mode 100644
index 0000000000..e9011b016e
--- /dev/null
+++ b/packages/algolia-experiences/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "algolia-experiences",
+ "license": "MIT",
+ "version": "1.0.2",
+ "main": "src/index.ts",
+ "jsdelivr": "dist/algolia-experiences.production.min.js",
+ "unpkg": "dist/algolia-experiences.production.min.js",
+ "files": [
+ "dist"
+ ],
+ "dependencies": {
+ "instantsearch.js": "4.74.0",
+ "algoliasearch": "5.1.1"
+ },
+ "devDependencies": {
+ "@instantsearch/testutils": "1.44.0"
+ },
+ "scripts": {
+ "build": "rollup -c rollup.config.js"
+ }
+}
diff --git a/packages/algolia-experiences/rollup.config.js b/packages/algolia-experiences/rollup.config.js
new file mode 100644
index 0000000000..258242f17d
--- /dev/null
+++ b/packages/algolia-experiences/rollup.config.js
@@ -0,0 +1,89 @@
+import path from 'path';
+
+import babel from 'rollup-plugin-babel';
+import commonjs from 'rollup-plugin-commonjs';
+import resolve from 'rollup-plugin-node-resolve';
+import replace from 'rollup-plugin-replace';
+import { uglify } from 'rollup-plugin-uglify';
+
+import packageJson from '../../package.json';
+
+const version =
+ process.env.NODE_ENV === 'production'
+ ? packageJson.version
+ : `UNRELEASED (${new Date().toUTCString()})`;
+const algolia = '© Algolia, Inc. and contributors; MIT License';
+const link = 'https://github.com/algolia/instantsearch';
+const license = `/*! algolia-experiences ${version} | ${algolia} | ${link} */`;
+
+const plugins = [
+ {
+ /**
+ * This plugin is a workaround for the fact that the `algoliasearch/lite`
+ * package resolves to the UMD by default in this version of rollup.
+ * Revisit when rollup > 1.
+ */
+ name: 'handle-algoliasearch-lite',
+ resolveId(source) {
+ if (source !== 'algoliasearch/lite') return null;
+ return path.join(
+ path.dirname(path.resolve(require.resolve('algoliasearch'))),
+ 'lite',
+ 'lite.esm.browser.js'
+ );
+ },
+ },
+ resolve({
+ browser: true,
+ preferBuiltins: false,
+ extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json'],
+ }),
+ babel({
+ rootMode: 'upward',
+ runtimeHelpers: true,
+ exclude: /node_modules|algoliasearch-helper/,
+ extensions: ['.js', '.ts', '.tsx'],
+ }),
+ commonjs(),
+];
+
+const createConfiguration = ({ mode, filename }) => ({
+ input: 'src/index.ts',
+ output: {
+ file: `dist/${filename}`,
+ name: 'instantsearch',
+ format: 'umd',
+ banner: license,
+ sourcemap: true,
+ },
+ onwarn(warning, warn) {
+ if (warning.code === 'CIRCULAR_DEPENDENCY')
+ throw new Error(warning.message);
+
+ warn(warning);
+ },
+ plugins: [
+ ...plugins,
+ replace({
+ __DEV__: mode === 'development',
+ 'process.env.NODE_ENV': JSON.stringify('production'),
+ }),
+ mode === 'production' &&
+ uglify({
+ output: {
+ preamble: license,
+ },
+ }),
+ ].filter(Boolean),
+});
+
+export default [
+ createConfiguration({
+ mode: 'development',
+ filename: 'algolia-experiences.development.js',
+ }),
+ createConfiguration({
+ mode: 'production',
+ filename: 'algolia-experiences.production.min.js',
+ }),
+];
diff --git a/packages/algolia-experiences/src/__tests__/get-information.test.ts b/packages/algolia-experiences/src/__tests__/get-information.test.ts
new file mode 100644
index 0000000000..34f896da0d
--- /dev/null
+++ b/packages/algolia-experiences/src/__tests__/get-information.test.ts
@@ -0,0 +1,47 @@
+/**
+ * @jest-environment jsdom
+ */
+import { getElements, getSettings } from '../get-information';
+
+beforeEach(() => {
+ document.head.innerHTML = '';
+ document.body.innerHTML = '';
+});
+
+describe('getSettings', () => {
+ test('should return the settings', () => {
+ document.head.innerHTML = `
+
+ `;
+
+ expect(getSettings()).toEqual({ appId: 'appId', apiKey: 'apiKey' });
+ });
+
+ test('should throw if no meta tag found', () => {
+ document.head.innerHTML = '';
+
+ expect(() => getSettings()).toThrow('No meta tag found');
+ });
+});
+
+describe('getElements', () => {
+ test('should return the elements', () => {
+ document.body.innerHTML = `
+
+
+ `;
+
+ expect(getElements()).toEqual(
+ new Map([
+ ['1', document.querySelector('[data-experience-id="1"]')!],
+ ['2', document.querySelector('[data-experience-id="2"]')!],
+ ])
+ );
+ });
+
+ test('should return an empty map if no elements found', () => {
+ document.body.innerHTML = '';
+
+ expect(getElements()).toEqual(new Map());
+ });
+});
diff --git a/packages/algolia-experiences/src/__tests__/render.test.ts b/packages/algolia-experiences/src/__tests__/render.test.ts
new file mode 100644
index 0000000000..1271bfde58
--- /dev/null
+++ b/packages/algolia-experiences/src/__tests__/render.test.ts
@@ -0,0 +1,479 @@
+/**
+ * @jest-environment jsdom
+ */
+import {
+ createMultiSearchResponse,
+ createSearchClient,
+} from '@instantsearch/mocks';
+import { wait } from '@instantsearch/testutils';
+import instantsearch from 'instantsearch.js';
+
+import { configToIndex, injectStyles } from '../render';
+
+import type { Configuration } from '../types';
+
+describe('injectStyles', () => {
+ it('should inject styles', () => {
+ injectStyles();
+ const style = document.head.querySelector('style');
+ expect(style).toBeDefined();
+ expect(style?.textContent).not.toHaveLength(0);
+ });
+});
+
+describe('configToIndex', () => {
+ const error = jest.spyOn(console, 'error').mockImplementation(() => {});
+ beforeEach(() => {
+ error.mockClear();
+ });
+
+ it('errors if element not found', () => {
+ const elements = new Map();
+ const config = { id: 'foo', indexName: 'bar', name: 'Foo', blocks: [] };
+ const result = configToIndex(config, elements);
+ expect(result).toEqual([]);
+ expect(error).toHaveBeenCalledTimes(1);
+ expect(error).toHaveBeenCalledWith(
+ '[Algolia Experiences] Element with id foo not found'
+ );
+ });
+
+ it('returns index with widgets', () => {
+ const elements = new Map([
+ ['foo', document.createElement('div')],
+ ]);
+ const config: Configuration = {
+ id: 'foo',
+ indexName: 'bar',
+ name: 'Foo',
+ blocks: [{ type: 'ais.hits', parameters: {}, children: [] }],
+ };
+ const result = configToIndex(config, elements);
+ expect(result).toHaveLength(1);
+ expect(result[0].getIndexName()).toEqual('bar');
+ expect(result[0].getIndexId()).toEqual('foo');
+ expect(result[0].getWidgets()).toHaveLength(1);
+ expect(result[0].getWidgets()[0].$$type).toBe('ais.hits');
+ });
+
+ it('maps to the right widget types', () => {
+ const elements = new Map([
+ ['foo', document.createElement('div')],
+ ]);
+ const config: Configuration = {
+ id: 'foo',
+ indexName: 'bar',
+ name: 'Foo',
+ blocks: [
+ { type: 'ais.hits', parameters: {}, children: [] },
+ { type: 'ais.infiniteHits', parameters: {}, children: [] },
+ {
+ type: 'ais.frequentlyBoughtTogether',
+ parameters: { objectIDs: [''] },
+ children: [],
+ },
+ {
+ type: 'ais.lookingSimilar',
+ parameters: { objectIDs: [''] },
+ children: [],
+ },
+ {
+ type: 'ais.relatedProducts',
+ parameters: { objectIDs: [''] },
+ children: [],
+ },
+ { type: 'ais.trendingItems', parameters: {}, children: [] },
+ {
+ type: 'ais.refinementList',
+ parameters: { attribute: 's' },
+ },
+ {
+ type: 'ais.menu',
+ parameters: { attribute: 's' },
+ },
+ {
+ type: 'ais.hierarchicalMenu',
+ parameters: { attributes: ['s'] },
+ },
+ {
+ type: 'ais.breadcrumb',
+ parameters: { attributes: ['s'] },
+ },
+ {
+ type: 'ais.numericMenu',
+ parameters: { attribute: 's', items: [{ label: '1', end: 0 }] },
+ },
+ {
+ type: 'ais.rangeInput',
+ parameters: { attribute: 's' },
+ },
+ {
+ type: 'ais.rangeSlider',
+ parameters: { attribute: 's' },
+ },
+ {
+ type: 'ais.ratingMenu',
+ parameters: { attribute: 's' },
+ },
+ {
+ type: 'ais.toggleRefinement',
+ parameters: { attribute: 's' },
+ },
+ ],
+ };
+ const result = configToIndex(config, elements);
+ expect(result).toHaveLength(1);
+ expect(result[0].getWidgets()).toHaveLength(config.blocks.length);
+ expect(result[0].getWidgets().map((w) => w.$$type)).toEqual([
+ 'ais.hits',
+ 'ais.infiniteHits',
+ 'ais.frequentlyBoughtTogether',
+ 'ais.lookingSimilar',
+ 'ais.relatedProducts',
+ 'ais.trendingItems',
+ 'ais.refinementList',
+ 'ais.menu',
+ 'ais.hierarchicalMenu',
+ 'ais.breadcrumb',
+ 'ais.numericMenu',
+ 'ais.rangeInput',
+ 'ais.rangeSlider',
+ 'ais.ratingMenu',
+ 'ais.toggleRefinement',
+ ]);
+ });
+
+ describe('template widgets', () => {
+ it('maps children to item template', async () => {
+ const searchClient = createSearchClient({
+ search: () =>
+ Promise.resolve(
+ createMultiSearchResponse({
+ hits: [
+ {
+ objectID: 'foo',
+ name: 'foo',
+ image: 'bar',
+ _highlightResult: {
+ name: {
+ value: 'fo__ais-highlight__o__/ais-highlight__',
+ matchLevel: 'full' as const,
+ matchedWords: [],
+ },
+ },
+ } as any,
+ ],
+ })
+ ),
+ });
+ const search = instantsearch({ searchClient });
+ const elements = new Map([
+ ['foo', document.createElement('div')],
+ ]);
+ const config: Configuration = {
+ id: 'foo',
+ indexName: 'bar',
+ name: 'Foo',
+ blocks: [
+ {
+ type: 'ais.hits',
+ parameters: {},
+ children: [
+ {
+ type: 'div',
+ parameters: {
+ class: [
+ { type: 'string', value: 'item-' },
+ { type: 'attribute', path: ['objectID'] },
+ ],
+ },
+ children: [
+ {
+ type: 'paragraph',
+ parameters: {
+ text: [
+ { type: 'string', value: 'cols: ' },
+ { type: 'highlight', path: ['name'] },
+ ],
+ },
+ },
+ {
+ type: 'image',
+ parameters: {
+ src: [{ type: 'attribute', path: ['image'] }],
+ alt: [{ type: 'string', value: 'alt value' }],
+ },
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ };
+ const result = configToIndex(config, elements);
+ expect(result).toHaveLength(1);
+ expect(result[0].getWidgets()).toHaveLength(1);
+
+ expect(elements.get('foo')?.innerHTML).toMatchInlineSnapshot(`
+
+
+ `);
+
+ search.addWidgets(result);
+ search.start();
+ await wait(100);
+
+ expect(elements.get('foo')?.innerHTML).toMatchInlineSnapshot(`
+
+
+
+ -
+
+
+ cols:
+
+
+ fo
+
+
+ o
+
+
+
+

+
+
+
+
+
+ `);
+ });
+ });
+
+ describe('panel widgets', () => {
+ it('maps header parameter to panel header', async () => {
+ const searchClient = createSearchClient({
+ search: () =>
+ Promise.resolve(
+ createMultiSearchResponse({
+ hits: [
+ {
+ objectID: 'foo',
+ name: 'foo',
+ image: 'bar',
+ _highlightResult: {
+ name: {
+ value: 'fo__ais-highlight__o__/ais-highlight__',
+ matchLevel: 'full' as const,
+ matchedWords: [],
+ },
+ },
+ } as any,
+ ],
+ })
+ ),
+ });
+ const search = instantsearch({ searchClient });
+ const elements = new Map([
+ ['foo', document.createElement('div')],
+ ]);
+
+ const config: Configuration = {
+ id: 'foo',
+ indexName: 'bar',
+ name: 'Foo',
+ blocks: [
+ {
+ type: 'ais.refinementList',
+ parameters: { attribute: 'a', header: 'header text' },
+ },
+ ],
+ };
+
+ const result = configToIndex(config, elements);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].getWidgets()).toHaveLength(1);
+
+ expect(elements.get('foo')?.innerHTML).toMatchInlineSnapshot(`
+
+
+ `);
+
+ search.addWidgets(result);
+ search.start();
+ await wait(100);
+
+ expect(elements.get('foo')?.innerHTML).toMatchInlineSnapshot(`
+
+ `);
+ });
+ });
+
+ describe('configure', () => {
+ it('applies configure widget', async () => {
+ const searchClient = createSearchClient({
+ search: jest.fn(() =>
+ Promise.resolve(
+ createMultiSearchResponse({
+ hits: [
+ {
+ objectID: 'foo',
+ name: 'foo',
+ image: 'bar',
+ _highlightResult: {
+ name: {
+ value: 'fo__ais-highlight__o__/ais-highlight__',
+ matchLevel: 'full' as const,
+ matchedWords: [],
+ },
+ },
+ } as any,
+ ],
+ })
+ )
+ ),
+ });
+ const search = instantsearch({ searchClient });
+ const elements = new Map([
+ ['foo', document.createElement('div')],
+ ]);
+
+ const config: Configuration = {
+ id: 'foo',
+ indexName: 'bar',
+ name: 'Foo',
+ blocks: [
+ {
+ type: 'ais.configure',
+ parameters: { hitsPerPage: 5 },
+ },
+ ],
+ };
+
+ const result = configToIndex(config, elements);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].getWidgets()).toHaveLength(1);
+
+ expect(elements.get('foo')).toBeEmptyDOMElement();
+
+ search.addWidgets(result);
+ search.start();
+ await wait(100);
+
+ expect(searchClient.search).toHaveBeenCalledWith([
+ {
+ indexName: 'bar',
+ params: {
+ hitsPerPage: 5,
+ },
+ },
+ ]);
+ });
+ });
+
+ describe('other widgets', () => {
+ it('renders pagination', async () => {
+ const searchClient = createSearchClient({
+ search: () =>
+ Promise.resolve(
+ createMultiSearchResponse({
+ hits: [],
+ })
+ ),
+ });
+ const search = instantsearch({ searchClient });
+ const elements = new Map([
+ ['foo', document.createElement('div')],
+ ]);
+
+ const config: Configuration = {
+ id: 'foo',
+ indexName: 'bar',
+ name: 'Foo',
+ blocks: [
+ {
+ type: 'ais.pagination',
+ parameters: {},
+ },
+ ],
+ };
+
+ const result = configToIndex(config, elements);
+
+ expect(result).toHaveLength(1);
+ expect(result[0].getWidgets()).toHaveLength(1);
+
+ expect(elements.get('foo')?.innerHTML).toMatchInlineSnapshot(`
+
+
+ `);
+
+ search.addWidgets(result);
+ search.start();
+ await wait(100);
+
+ expect(elements.get('foo')?.innerHTML).toMatchInlineSnapshot(`
+
+
+
+ `);
+ });
+ });
+});
diff --git a/packages/algolia-experiences/src/__tests__/setup-instantsearch.test.ts b/packages/algolia-experiences/src/__tests__/setup-instantsearch.test.ts
new file mode 100644
index 0000000000..2c6c93e23a
--- /dev/null
+++ b/packages/algolia-experiences/src/__tests__/setup-instantsearch.test.ts
@@ -0,0 +1,115 @@
+/**
+ * @jest-environment jsdom
+ */
+import { castToJestMock, wait } from '@instantsearch/testutils';
+
+import { fetchConfiguration } from '../get-configuration';
+import { setupInstantSearch } from '../setup-instantsearch';
+
+jest.mock('../get-configuration', () => {
+ const actual = jest.requireActual('../get-configuration');
+ return {
+ ...actual,
+ fetchConfiguration: jest.fn(() => Promise.resolve([])),
+ };
+});
+
+describe('setup of InstantSearch', () => {
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
+ beforeEach(() => {
+ errorSpy.mockReset();
+ document.head.innerHTML = '';
+ document.body.innerHTML = '';
+ });
+
+ test('error logged if no meta tag found', () => {
+ document.head.innerHTML = '';
+
+ setupInstantSearch();
+
+ expect(errorSpy).toHaveBeenCalledWith(
+ '[Algolia Experiences] No meta tag found'
+ );
+ });
+
+ test('no error logged if no elements found', () => {
+ document.head.innerHTML = `
+
+ `;
+
+ setupInstantSearch();
+
+ expect(errorSpy).not.toHaveBeenCalled();
+ });
+
+ test('search is exposed on the window', () => {
+ document.head.innerHTML = `
+
+ `;
+
+ setupInstantSearch();
+
+ expect(window.__search).toBeDefined();
+ });
+
+ test('styles are injected', () => {
+ document.head.innerHTML = `
+
+ `;
+
+ setupInstantSearch();
+
+ expect(
+ document.head.querySelectorAll('[data-source=instantsearch]')
+ ).toHaveLength(1);
+ });
+
+ test('configuration is fetched and rendered', async () => {
+ castToJestMock(fetchConfiguration).mockImplementationOnce(() =>
+ Promise.resolve([
+ {
+ id: 'fake-configuration',
+ name: 'Fake Configuration',
+ indexName: 'fake-index-name',
+ blocks: [
+ {
+ type: 'grid',
+ children: [],
+ },
+ ],
+ createdAt: '2021-01-01',
+ updatedAt: '2021-01-01',
+ },
+ ])
+ );
+
+ document.head.innerHTML = `
+
+ `;
+ document.body.innerHTML = `
+
+ `;
+
+ setupInstantSearch();
+
+ expect(
+ document.querySelector('[data-experience-id="fake-configuration"]')!
+ .children
+ ).toHaveLength(0);
+
+ await wait(0);
+
+ expect(
+ document.querySelector('[data-experience-id="fake-configuration"]')!
+ .children
+ ).not.toHaveLength(0);
+
+ expect(
+ document.querySelector('[data-experience-id="fake-configuration"]')!
+ .innerHTML
+ ).toMatchInlineSnapshot(`
+
+
+ `);
+ });
+});
diff --git a/packages/algolia-experiences/src/get-configuration.ts b/packages/algolia-experiences/src/get-configuration.ts
new file mode 100644
index 0000000000..77c1bd84a2
--- /dev/null
+++ b/packages/algolia-experiences/src/get-configuration.ts
@@ -0,0 +1,120 @@
+import type { Settings } from './get-information';
+import type { Block, Configuration } from './types';
+
+export function fetchConfiguration(
+ ids: string[],
+ settings: Settings
+): Promise {
+ return Promise.all(
+ ids.map((id) =>
+ getExperience({
+ id,
+ ...settings,
+ })
+ )
+ );
+}
+
+export type Experience = {
+ id: string;
+ name: string;
+ indexName: string;
+ blocks: Block[];
+ createdAt: string;
+ updatedAt: string;
+};
+
+const LOCAL = false;
+const API_BASE = LOCAL
+ ? 'http://localhost:3000/1'
+ : 'https://experiences-main.platform.algolia.net/1';
+
+type ApiParams = {
+ appId: string;
+ apiKey: string;
+} & TEndpointParams;
+
+type RequestParams = ApiParams<{
+ endpoint: string;
+ method?: Request['method'];
+ data?: Record;
+}>;
+
+export type DeleteExperienceParams = ApiParams>;
+export function deleteExperience({
+ id,
+ appId,
+ apiKey,
+}: DeleteExperienceParams) {
+ return buildRequest({
+ appId,
+ apiKey,
+ endpoint: `collections/${id}`,
+ method: 'DELETE',
+ });
+}
+
+export type GetExperienceParams = ApiParams>;
+export function getExperience({
+ id,
+ appId,
+ apiKey,
+}: GetExperienceParams): Promise {
+ return buildRequest({
+ appId,
+ apiKey,
+ endpoint: `collections/${id}`,
+ });
+}
+
+export type UpsertExperienceParams = ApiParams<{ experience: Configuration }>;
+export function upsertExperience({
+ experience,
+ appId,
+ apiKey,
+}: UpsertExperienceParams): Promise> {
+ return buildRequest({
+ appId,
+ apiKey,
+ endpoint: `collections`,
+ method: 'POST',
+ data: experience,
+ });
+}
+
+export type ListExperiencesParams = ApiParams>;
+export function listExperiences({
+ appId,
+ apiKey,
+}: ListExperiencesParams): Promise {
+ return buildRequest({
+ appId,
+ apiKey,
+ endpoint: 'collections',
+ });
+}
+
+function buildRequest({
+ appId,
+ apiKey,
+ endpoint,
+ method = 'GET',
+ data,
+}: RequestParams) {
+ return fetch(`${API_BASE}/${endpoint}`, {
+ method,
+ headers: {
+ 'X-Algolia-Application-ID': appId,
+ 'X-Algolia-API-Key': apiKey,
+ },
+ body: data ? JSON.stringify(data) : undefined,
+ })
+ .then((res) => {
+ if (!res.ok) {
+ throw new Error(res.statusText);
+ }
+
+ return res;
+ })
+ .then((res) => res.json());
+}
diff --git a/packages/algolia-experiences/src/get-information.ts b/packages/algolia-experiences/src/get-information.ts
new file mode 100644
index 0000000000..ba6fa00f33
--- /dev/null
+++ b/packages/algolia-experiences/src/get-information.ts
@@ -0,0 +1,34 @@
+export type Settings = {
+ appId: string;
+ apiKey: string;
+};
+
+export function getSettings(): Settings {
+ const metaConfiguration = document.querySelector(
+ 'meta[name="algolia-configuration"]'
+ );
+
+ if (!metaConfiguration || !metaConfiguration.content) {
+ throw new Error('No meta tag found');
+ }
+
+ const { appId, apiKey } = JSON.parse(metaConfiguration.content);
+
+ if (!appId || !apiKey) {
+ throw new Error('Missing appId or apiKey in the meta tag');
+ }
+
+ return { appId, apiKey };
+}
+
+export function getElements() {
+ const elements = new Map();
+ document
+ .querySelectorAll('[data-experience-id]')
+ .forEach((element) => {
+ const id = element.dataset.experienceId!;
+ elements.set(id, element);
+ });
+
+ return elements;
+}
diff --git a/packages/algolia-experiences/src/index.ts b/packages/algolia-experiences/src/index.ts
new file mode 100644
index 0000000000..a7ea3bab18
--- /dev/null
+++ b/packages/algolia-experiences/src/index.ts
@@ -0,0 +1,5 @@
+import { setupInstantSearch } from './setup-instantsearch';
+
+if (typeof window === 'object') {
+ document.addEventListener('DOMContentLoaded', setupInstantSearch);
+}
diff --git a/packages/algolia-experiences/src/render.tsx b/packages/algolia-experiences/src/render.tsx
new file mode 100644
index 0000000000..6cf219a389
--- /dev/null
+++ b/packages/algolia-experiences/src/render.tsx
@@ -0,0 +1,257 @@
+/** @jsx h */
+import { getPropertyByPath } from 'instantsearch.js/es/lib/utils';
+import { index, panel } from 'instantsearch.js/es/widgets';
+import { h, Fragment } from 'preact';
+
+import { error } from './util';
+import { widgets } from './widgets';
+
+import type {
+ Block,
+ Configuration,
+ PanelWidget,
+ PanelWidgetTypes,
+ TemplateAttribute,
+ TemplateChild,
+ TemplateText,
+ TemplateWidgetTypes,
+} from './types';
+import type { Widget } from 'instantsearch.js';
+import type { ComponentChildren, JSX } from 'preact';
+
+export function injectStyles() {
+ const style = document.createElement('style');
+ style.dataset.source = 'instantsearch';
+
+ // @TODO: decide if this should be for all columns or only a specific type
+ style.textContent = `
+ .ais-Grid {
+ display: grid;
+ grid-template-columns: minmax(min-content, 200px) 1fr;
+ gap: 1em;
+ }
+ `;
+ document.head.appendChild(style);
+}
+
+export function configToIndex(
+ config: Configuration,
+ elements: Map
+) {
+ const container = elements.get(config.id);
+ if (!container) {
+ error(`Element with id ${config.id} not found`);
+ return [];
+ }
+
+ return [
+ index({
+ indexName: config.indexName,
+ indexId: config.id,
+ }).addWidgets(
+ config.blocks.flatMap((block) => blockToWidget(block, container))
+ ),
+ ];
+}
+
+const hitWidgets = new Set([
+ 'ais.hits',
+ 'ais.infiniteHits',
+ 'ais.frequentlyBoughtTogether',
+ 'ais.lookingSimilar',
+ 'ais.relatedProducts',
+ 'ais.trendingItems',
+]);
+function isTemplateWidget(
+ child: Block
+): child is Block & { children: TemplateChild[] } {
+ return hitWidgets.has(child.type as any);
+}
+
+const panelWidgets = new Set([
+ 'ais.refinementList',
+ 'ais.menu',
+ 'ais.hierarchicalMenu',
+ 'ais.breadcrumb',
+ 'ais.numericMenu',
+ 'ais.rangeInput',
+ 'ais.rangeSlider',
+ 'ais.ratingMenu',
+ 'ais.toggleRefinement',
+]);
+function isPanelWidget(child: Block): child is PanelWidget {
+ return panelWidgets.has(child.type as any);
+}
+
+const tagNames = new Map(
+ Object.entries({
+ paragraph: 'p',
+ span: 'span',
+ h2: 'h2',
+ div: 'div',
+ link: 'a',
+ image: 'img',
+ })
+);
+
+function renderText(text: TemplateText[number], hit: any, components: any) {
+ if (text.type === 'string') {
+ return text.value;
+ }
+
+ if (text.type === 'attribute') {
+ return getPropertyByPath(hit, text.path);
+ }
+
+ if (text.type === 'highlight') {
+ return components.Highlight({
+ hit,
+ attribute: text.path,
+ });
+ }
+
+ if (text.type === 'snippet') {
+ return components.Snippet({
+ hit,
+ attribute: text.path,
+ });
+ }
+
+ return null;
+}
+
+function renderAttribute(text: TemplateAttribute[number], hit: any) {
+ if (text.type === 'string') {
+ return text.value;
+ }
+
+ if (text.type === 'attribute') {
+ return getPropertyByPath(hit, text.path);
+ }
+
+ return null;
+}
+
+function blockToWidget(child: Block, container: HTMLElement): Widget[] {
+ if (child.type === 'ais.configure') {
+ return [widgets[child.type]({ ...child.parameters })];
+ }
+
+ const widgetContainer = container.appendChild(document.createElement('div'));
+
+ if (child.type === 'grid') {
+ widgetContainer.classList.add('ais-Grid');
+
+ return child.children
+ .map((column) => {
+ return blockToWidget(column, widgetContainer);
+ })
+ .flat(1);
+ }
+
+ if (child.type === 'column') {
+ widgetContainer.classList.add('ais-Column');
+
+ return child.children
+ .map((column) => {
+ return blockToWidget(column, widgetContainer);
+ })
+ .flat(1);
+ }
+
+ if (isTemplateWidget(child)) {
+ // type cast is needed here because the spread adding `container` and `templates` loses the type discriminant
+ const parameters = child.parameters as Parameters<
+ typeof widgets['ais.hits']
+ >[0];
+ const widget = widgets[child.type] as typeof widgets['ais.hits'];
+
+ return [
+ widget({
+ ...parameters,
+ container: widgetContainer,
+ templates: {
+ item: (hit: any, { components }) => {
+ if (!child.children.length) {
+ return no item template given
;
+ }
+
+ function renderChild(ch: TemplateChild) {
+ const Tag = tagNames.get(ch.type) as keyof JSX.IntrinsicElements;
+ if (!Tag) {
+ return ;
+ }
+
+ let children: ComponentChildren = null;
+ if ('text' in ch.parameters) {
+ children = ch.parameters.text.map((text) =>
+ renderText(text, hit, components)
+ );
+ } else if ('children' in ch) {
+ children = ch.children.map(renderChild);
+ }
+
+ const attributes = Object.fromEntries(
+ Object.entries(ch.parameters)
+ .filter(
+ (tuple): tuple is [string, TemplateAttribute] =>
+ tuple[0] !== 'text'
+ )
+ .map(([key, value]) => [
+ key,
+ value.map((item) => renderAttribute(item, hit)).join(''),
+ ])
+ );
+
+ return {children};
+ }
+
+ return child.children.map(renderChild);
+ },
+ },
+ }),
+ ];
+ }
+
+ if (isPanelWidget(child)) {
+ // type cast is needed here because the spread adding `container` loses the type discriminant
+ const {
+ header,
+ collapsed: defaultCollapsed,
+ ...parameters
+ } = child.parameters as Parameters<
+ typeof widgets['ais.refinementList']
+ >[0] & { header: string; collapsed: boolean };
+ const widget = widgets[child.type] as typeof widgets['ais.refinementList'];
+ return [
+ panel({
+ templates: {
+ header,
+ collapseButtonText: ({ collapsed }) => (
+ // @TODO: put this style in a stylesheet
+ {collapsed ? '+' : '-'}
+ ),
+ },
+ collapsed:
+ typeof defaultCollapsed === 'undefined'
+ ? undefined
+ : () => defaultCollapsed,
+ })(widget)({
+ ...parameters,
+ container: widgetContainer,
+ }),
+ ];
+ }
+
+ // type cast is needed here because the spread adding `container` loses the type discriminant
+ const parameters = child.parameters as Parameters<
+ typeof widgets['ais.pagination']
+ >[0];
+ const widget = widgets[child.type] as typeof widgets['ais.pagination'];
+ return [
+ widget({
+ ...parameters,
+ container: widgetContainer,
+ }),
+ ];
+}
diff --git a/packages/algolia-experiences/src/setup-instantsearch.ts b/packages/algolia-experiences/src/setup-instantsearch.ts
new file mode 100644
index 0000000000..d969618228
--- /dev/null
+++ b/packages/algolia-experiences/src/setup-instantsearch.ts
@@ -0,0 +1,41 @@
+/** @jsx h */
+
+import { liteClient as algoliasearch } from 'algoliasearch/lite';
+import InstantSearch from 'instantsearch.js/es/lib/InstantSearch';
+
+import { fetchConfiguration } from './get-configuration';
+import { getElements, getSettings } from './get-information';
+import { configToIndex, injectStyles } from './render';
+import { error } from './util';
+
+declare global {
+ interface Window {
+ __search: InstantSearch;
+ }
+}
+
+export function setupInstantSearch() {
+ try {
+ const settings = getSettings();
+
+ const searchClient = algoliasearch(settings.appId, settings.apiKey);
+ const search = new InstantSearch({
+ searchClient,
+ });
+ window.__search = search;
+
+ const elements = getElements();
+
+ injectStyles();
+
+ fetchConfiguration([...elements.keys()], settings).then((configuration) => {
+ search
+ .addWidgets(
+ configuration.flatMap((config) => configToIndex(config, elements))
+ )
+ .start();
+ });
+ } catch (err) {
+ error((err as Error).message);
+ }
+}
diff --git a/packages/algolia-experiences/src/types.ts b/packages/algolia-experiences/src/types.ts
new file mode 100644
index 0000000000..9670f162f8
--- /dev/null
+++ b/packages/algolia-experiences/src/types.ts
@@ -0,0 +1,109 @@
+import type { widgets } from './widgets';
+
+type StaticString = { type: 'string'; value: string };
+type Attribute = { type: 'attribute'; path: string[] };
+type Highlight = { type: 'highlight' | 'snippet'; path: string[] };
+export type TemplateText = Array;
+export type TemplateAttribute = Array;
+type RegularParameters = {
+ class?: TemplateAttribute;
+};
+export type TemplateChild =
+ | {
+ type: 'paragraph' | 'span' | 'h2';
+ parameters: {
+ text: TemplateText;
+ } & RegularParameters;
+ }
+ | {
+ type: 'div';
+ parameters: RegularParameters;
+ children: TemplateChild[];
+ }
+ | {
+ type: 'image';
+ parameters: {
+ src: TemplateAttribute;
+ alt: TemplateAttribute;
+ } & RegularParameters;
+ }
+ | {
+ type: 'link';
+ parameters: {
+ href: TemplateAttribute;
+ } & RegularParameters;
+ children: TemplateChild[];
+ };
+
+export type TemplateWidgetTypes =
+ | 'ais.hits'
+ | 'ais.infiniteHits'
+ | 'ais.frequentlyBoughtTogether'
+ | 'ais.lookingSimilar'
+ | 'ais.relatedProducts'
+ | 'ais.trendingItems';
+
+export type TemplateWidget<
+ TKeys extends TemplateWidgetTypes = TemplateWidgetTypes
+> = {
+ [key in TKeys]: {
+ type: key;
+ parameters: Omit<
+ Parameters[0],
+ 'container' | 'templates'
+ >;
+ children: TemplateChild[];
+ };
+}[TKeys];
+
+export type PanelWidgetTypes =
+ | 'ais.refinementList'
+ | 'ais.menu'
+ | 'ais.hierarchicalMenu'
+ | 'ais.breadcrumb'
+ | 'ais.numericMenu'
+ | 'ais.rangeInput'
+ | 'ais.rangeSlider'
+ | 'ais.ratingMenu'
+ | 'ais.toggleRefinement';
+export type PanelWidget = {
+ [key in TKeys]: {
+ type: key;
+ parameters: Omit[0], 'container'> & {
+ header?: string;
+ collapsed?: boolean;
+ };
+ };
+}[TKeys];
+
+type RegularWidget =
+ {
+ [key in TKeys]: {
+ type: key;
+ parameters: Omit[0], 'container'>;
+ };
+ }[TKeys];
+
+export type Block =
+ | {
+ [key in keyof typeof widgets]: key extends TemplateWidgetTypes
+ ? TemplateWidget
+ : key extends PanelWidgetTypes
+ ? PanelWidget
+ : RegularWidget;
+ }[keyof typeof widgets]
+ | {
+ type: 'grid';
+ children: Block[];
+ }
+ | {
+ type: 'column';
+ children: Block[];
+ };
+
+export type Configuration = {
+ id: string;
+ name: string;
+ indexName: string;
+ blocks: Block[];
+};
diff --git a/packages/algolia-experiences/src/util.ts b/packages/algolia-experiences/src/util.ts
new file mode 100644
index 0000000000..caf9aa3f90
--- /dev/null
+++ b/packages/algolia-experiences/src/util.ts
@@ -0,0 +1,9 @@
+// @TODO: hook up to some way it can be set runtime, maybe query params
+const VERBOSE = true;
+
+export function error(message: string) {
+ if (VERBOSE) {
+ // eslint-disable-next-line no-console
+ console.error(`[Algolia Experiences] ${message}`);
+ }
+}
diff --git a/packages/algolia-experiences/src/widgets.ts b/packages/algolia-experiences/src/widgets.ts
new file mode 100644
index 0000000000..5ea95f293d
--- /dev/null
+++ b/packages/algolia-experiences/src/widgets.ts
@@ -0,0 +1,51 @@
+import {
+ breadcrumb,
+ clearRefinements,
+ configure,
+ currentRefinements,
+ frequentlyBoughtTogether,
+ hierarchicalMenu,
+ hits,
+ hitsPerPage,
+ infiniteHits,
+ lookingSimilar,
+ menu,
+ numericMenu,
+ pagination,
+ rangeInput,
+ rangeSlider,
+ ratingMenu,
+ refinementList,
+ relatedProducts,
+ searchBox,
+ sortBy,
+ stats,
+ toggleRefinement,
+ trendingItems,
+} from 'instantsearch.js/es/widgets';
+
+export const widgets = {
+ 'ais.breadcrumb': breadcrumb,
+ 'ais.clearRefinements': clearRefinements,
+ 'ais.configure': configure,
+ 'ais.currentRefinements': currentRefinements,
+ 'ais.frequentlyBoughtTogether': frequentlyBoughtTogether,
+ 'ais.hierarchicalMenu': hierarchicalMenu,
+ 'ais.hits': hits,
+ 'ais.hitsPerPage': hitsPerPage,
+ 'ais.infiniteHits': infiniteHits,
+ 'ais.lookingSimilar': lookingSimilar,
+ 'ais.menu': menu,
+ 'ais.numericMenu': numericMenu,
+ 'ais.pagination': pagination,
+ 'ais.rangeInput': rangeInput,
+ 'ais.rangeSlider': rangeSlider,
+ 'ais.ratingMenu': ratingMenu,
+ 'ais.refinementList': refinementList,
+ 'ais.relatedProducts': relatedProducts,
+ 'ais.searchBox': searchBox,
+ 'ais.sortBy': sortBy,
+ 'ais.stats': stats,
+ 'ais.toggleRefinement': toggleRefinement,
+ 'ais.trendingItems': trendingItems,
+};
diff --git a/tsconfig.v3.json b/tsconfig.v3.json
index 67aa42c367..d08df32654 100644
--- a/tsconfig.v3.json
+++ b/tsconfig.v3.json
@@ -5,6 +5,7 @@
"es",
"tests/e2e",
"packages/create-instantsearch-app/src/templates/**/*",
+ "packages/algolia-experiences",
// this test has specific code for v3 and v4, so already checked in the v4 test
"packages/instantsearch.js/src/middlewares/__tests__/createMetadataMiddleware.ts",
diff --git a/tsconfig.v4.json b/tsconfig.v4.json
new file mode 100644
index 0000000000..1f194eaf41
--- /dev/null
+++ b/tsconfig.v4.json
@@ -0,0 +1,12 @@
+{
+ "extends": "./tsconfig",
+ "exclude": [
+ "**/es",
+ "**/dist",
+ "tests/e2e",
+ "examples/react/next/next-env.d.ts",
+ "examples/react/react-native",
+ "packages/create-instantsearch-app/src/templates/**/*",
+ "packages/algolia-experiences"
+ ]
+}