From e0d21a6d4f729fb452abd184fe86e4f7026ca070 Mon Sep 17 00:00:00 2001 From: Paul Jankowski <8bittitan@gmail.com> Date: Thu, 16 Jul 2026 11:04:20 -0400 Subject: [PATCH] feat(js): Split JS bundles for search only (#2920) --- bundlesize.config.json | 4 + examples/demo-js/src/main.ts | 9 +- packages/docsearch-js/README.md | 39 +++++- packages/docsearch-js/package.json | 4 + packages/docsearch-js/src/createDocSearch.tsx | 122 +++++++++++++++++ packages/docsearch-js/src/docsearch.ts | 2 + packages/docsearch-js/src/docsearch.tsx | 124 ------------------ packages/docsearch-js/src/docsearchAi.tsx | 17 +++ .../docsearch-js/src/docsearchComponent.tsx | 16 +++ packages/docsearch-js/src/index.ts | 9 +- packages/docsearch-js/tsdown.config.mts | 24 +++- 11 files changed, 237 insertions(+), 133 deletions(-) create mode 100644 packages/docsearch-js/src/createDocSearch.tsx create mode 100644 packages/docsearch-js/src/docsearch.ts delete mode 100644 packages/docsearch-js/src/docsearch.tsx create mode 100644 packages/docsearch-js/src/docsearchAi.tsx create mode 100644 packages/docsearch-js/src/docsearchComponent.tsx diff --git a/bundlesize.config.json b/bundlesize.config.json index 60193234..5daaf707 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -10,6 +10,10 @@ }, { "path": "packages/docsearch-js/dist/umd/index.js", + "maxSize": "180 kB" + }, + { + "path": "packages/docsearch-js/dist/umd/docsearch.js", "maxSize": "100 kB" }, { diff --git a/examples/demo-js/src/main.ts b/examples/demo-js/src/main.ts index 3adf3777..7c8e630a 100644 --- a/examples/demo-js/src/main.ts +++ b/examples/demo-js/src/main.ts @@ -1,5 +1,3 @@ -import type { AutocompleteState } from '@algolia/autocomplete-core'; -import type { InitialAskAiMessage } from '@docsearch/core'; import docsearch, { type DocSearchInstance, type TemplateHelpers } from '@docsearch/js'; import sidepanel, { type SidepanelInstance } from '@docsearch/sidepanel-js'; @@ -72,7 +70,10 @@ docsearchInstance = docsearch({ indexName: 'docsearch', appId: 'PMZUYBQDAK', apiKey: '24b09689d5b4223813d9b8e48563c8f6', - interceptAskAiEvent: (initialMessage: InitialAskAiMessage) => { + askAi: { + assistantId: 'ccdec697-e3fe-465b-a1c3-657e7bf18aef', + }, + interceptAskAiEvent: (initialMessage) => { docsearchInstance?.close(); sidepanelInstance.open(initialMessage); return true; @@ -90,7 +91,7 @@ docsearchInstance = docsearch({ // eslint-disable-next-line no-console console.log('[demo-js] docsearch onClose()'); }, - resultsFooterComponent: ({ state }: { state: AutocompleteState }, helpers?: TemplateHelpers) => { + resultsFooterComponent: ({ state }, helpers?: TemplateHelpers) => { const { html } = helpers || {}; if (!html) return null; diff --git a/packages/docsearch-js/README.md b/packages/docsearch-js/README.md index d26b4e95..8c6f6125 100644 --- a/packages/docsearch-js/README.md +++ b/packages/docsearch-js/README.md @@ -5,7 +5,7 @@ JavaScript package for [DocSearch](http://docsearch.algolia.com/), the best sear ## Installation ```sh -npm install @docsearch/js@4 +npm install @docsearch/js@5 ``` ## Get started @@ -13,7 +13,7 @@ npm install @docsearch/js@4 If you don’t want to use a package manager, you can use a standalone endpoint: ```html - + ``` To get started, you need a [`container`](https://docsearch.algolia.com/docs/api#container) for your DocSearch component to go in. If you don’t have one already, you can insert one into your markup: @@ -39,6 +39,41 @@ docsearch({ }); ``` +The default entry includes keyword search and Ask AI. Configure `askAi` or call `openAskAi()` on the returned instance when using Ask AI. + +## Keyword-only entry + +Use the `docsearch` subpath when your integration only needs keyword search. This entry excludes Ask AI code. + +```js app.js +import docsearch from '@docsearch/js/docsearch'; + +import '@docsearch/css'; + +docsearch({ + container: '#docsearch', + appId: 'YOUR_APP_ID', + indexName: 'YOUR_INDEX_NAME', + apiKey: 'YOUR_SEARCH_API_KEY', +}); +``` + +For a standalone keyword-only script, load the explicit UMD file: + +```html + + +``` + +Both UMD entries expose callable `window.docsearch`. Load only one of them: the package default loads the AI-capable `dist/umd/index.js`, while `dist/umd/docsearch.js` is keyword-only. + ## Documentation [Read documentation →](https://docsearch.algolia.com/docs/docsearch-v3) diff --git a/packages/docsearch-js/package.json b/packages/docsearch-js/package.json index e0c1ec37..c8fb8a62 100644 --- a/packages/docsearch-js/package.json +++ b/packages/docsearch-js/package.json @@ -17,6 +17,10 @@ "files": [ "dist/" ], + "exports": { + ".": "./dist/esm/index.js", + "./docsearch": "./dist/esm/docsearch.js" + }, "source": "src/index.ts", "types": "dist/esm/index.d.ts", "module": "dist/esm/index.js", diff --git a/packages/docsearch-js/src/createDocSearch.tsx b/packages/docsearch-js/src/createDocSearch.tsx new file mode 100644 index 00000000..71b693da --- /dev/null +++ b/packages/docsearch-js/src/createDocSearch.tsx @@ -0,0 +1,122 @@ +import type { DocSearchRef, InitialAskAiMessage } from '@docsearch/core'; +import htm from 'htm'; +import type { ComponentType, JSX, Attributes } from 'preact'; +import { createElement, createRef, isValidElement, render, unmountComponentAtNode } from 'preact/compat'; + +export interface DocSearchInstance { + readonly isReady: boolean; + readonly isOpen: boolean; + open(): void; + close(): void; + openAskAi(initialMessage?: InitialAskAiMessage): void; + destroy(): void; +} + +export interface DocSearchCallbacks { + onReady?: () => void; + onOpen?: () => void; + onClose?: () => void; + interceptAskAiEvent?: (initialMessage: InitialAskAiMessage) => boolean | void; +} + +export type DocSearchProps = DocSearchCallbacks & + Omit & { + container: HTMLElement | string; + environment?: typeof window; + }; + +function getHTMLElement(value: HTMLElement | string, env: typeof window | undefined): HTMLElement { + if (typeof value !== 'string') return value; + if (!env) throw new Error('Cannot resolve a selector without a browser environment.'); + + const element = env.document.querySelector(value); + if (!element) throw new Error(`Container selector did not match any element: "${value}"`); + + return element; +} + +const html = htm.bind(createElement) as unknown as (strings: TemplateStringsArray, ...values: unknown[]) => JSX.Element; + +export type TemplateHelpers = Record & { html: typeof html }; + +function createTemplateFunction

, R = JSX.Element | string | (() => JSX.Element)>( + original: ((props: P, helpers?: TemplateHelpers) => R) | undefined, +): ((props: P) => JSX.Element) | undefined { + if (!original) return undefined; + + return (props: P) => { + const output = original(props, { html }); + + if (isValidElement(output)) return output; + if (typeof output === 'function') return output(props); + if (typeof output === 'string') return createElement('span', null, output); + + return output as JSX.Element; + }; +} + +interface ComponentProps { + hitComponent?: (props: Record, helpers?: TemplateHelpers) => JSX.Element; + resultsFooterComponent?: (props: Record, helpers?: TemplateHelpers) => JSX.Element | null; + transformSearchClient?: (searchClient: unknown) => unknown; +} + +export function createDocSearch( + Component: ComponentType, + version: string, +): (allProps: DocSearchProps) => DocSearchInstance { + return (allProps) => { + const input = allProps as unknown as DocSearchProps; + const { container, environment, transformSearchClient, hitComponent, resultsFooterComponent, ...rest } = input; + const containerElement = getHTMLElement( + container, + environment || (typeof window !== 'undefined' ? window : undefined), + ); + const ref = createRef(); + let isReady = false; + + const props: TComponentProps = { + ...rest, + ref, + hitComponent: createTemplateFunction(hitComponent), + resultsFooterComponent: createTemplateFunction(resultsFooterComponent), + transformSearchClient: (searchClient: unknown): unknown => { + if ( + typeof searchClient === 'object' && + searchClient !== null && + 'addAlgoliaAgent' in searchClient && + typeof searchClient.addAlgoliaAgent === 'function' + ) { + searchClient.addAlgoliaAgent('docsearch.js', version); + } + + return typeof transformSearchClient === 'function' ? transformSearchClient(searchClient) : searchClient; + }, + } as unknown as TComponentProps; + + render(createElement(Component, props as Attributes & TComponentProps), containerElement); + isReady = true; + + return { + open(): void { + ref.current?.open(); + }, + close(): void { + ref.current?.close(); + }, + openAskAi(initialMessage?: InitialAskAiMessage): void { + ref.current?.openAskAi(initialMessage); + }, + get isReady(): boolean { + return isReady; + }, + get isOpen(): boolean { + return ref.current?.isOpen ?? false; + }, + destroy(): void { + unmountComponentAtNode(containerElement); + isReady = false; + }, + }; + }; +} diff --git a/packages/docsearch-js/src/docsearch.ts b/packages/docsearch-js/src/docsearch.ts new file mode 100644 index 00000000..9b86a6f1 --- /dev/null +++ b/packages/docsearch-js/src/docsearch.ts @@ -0,0 +1,2 @@ +export { docsearch as default } from './docsearchComponent'; +export type { DocSearchCallbacks, DocSearchInstance, DocSearchProps, TemplateHelpers } from './docsearchComponent'; diff --git a/packages/docsearch-js/src/docsearch.tsx b/packages/docsearch-js/src/docsearch.tsx deleted file mode 100644 index 6cace5bf..00000000 --- a/packages/docsearch-js/src/docsearch.tsx +++ /dev/null @@ -1,124 +0,0 @@ -import type { DocSearchRef, InitialAskAiMessage } from '@docsearch/core'; -import type { DocSearchProps as DocSearchComponentProps } from '@docsearch/react'; -import { DocSearch, version as docSearchVersion } from '@docsearch/react'; -import htm from 'htm'; -import type { JSX } from 'preact'; -import { createElement, render, isValidElement, unmountComponentAtNode, createRef } from 'preact/compat'; - -/** - * Instance returned by docsearch() for programmatic control. - */ -export interface DocSearchInstance { - /** Returns true once the component is mounted and ready. */ - readonly isReady: boolean; - /** Returns true if the modal is currently open. */ - readonly isOpen: boolean; - /** Opens the search modal. */ - open(): void; - /** Closes the search modal. */ - close(): void; - /** Opens Ask AI mode (modal). */ - openAskAi(initialMessage?: InitialAskAiMessage): void; - /** Unmounts the DocSearch component and cleans up. */ - destroy(): void; -} - -/** - * Lifecycle callbacks for the DocSearch instance. - */ -export interface DocSearchCallbacks { - /** Called once DocSearch is mounted and ready for interaction. */ - onReady?: () => void; - /** Called when the modal opens. */ - onOpen?: () => void; - /** Called when the modal closes. */ - onClose?: () => void; - interceptAskAiEvent?: (initialMessage: InitialAskAiMessage) => boolean | void; -} - -export type DocSearchProps = DocSearchCallbacks & - Omit & { - container: HTMLElement | string; - environment?: typeof window; - }; - -function getHTMLElement(value: HTMLElement | string, env: typeof window | undefined): HTMLElement { - if (typeof value !== 'string') return value; - if (!env) throw new Error('Cannot resolve a selector without a browser environment.'); - const el = env.document.querySelector(value); - if (!el) throw new Error(`Container selector did not match any element: "${value}"`); - return el; -} - -// Tiny `html` helper bound to Preact createElement -const html = htm.bind(createElement) as unknown as (strings: TemplateStringsArray, ...values: unknown[]) => JSX.Element; - -export type TemplateHelpers = Record & { html: typeof html }; - -function createTemplateFunction

, R = JSX.Element | string | (() => JSX.Element)>( - original: ((props: P, helpers?: TemplateHelpers) => R) | undefined, -): ((props: P) => JSX.Element) | undefined { - if (!original) return undefined; - return (props: P) => { - const out = original(props, { html }); - - // Element, return as is - if (isValidElement(out)) return out; - - // Component function, call with same props - if (typeof out === 'function') return out(props); - - // String, render as plain text to avoid XSS - if (typeof out === 'string') return createElement('span', null, out); - - // Fallback - return out as JSX.Element; - }; -} - -export function docsearch(allProps: DocSearchProps): DocSearchInstance { - const { container, environment, transformSearchClient, hitComponent, resultsFooterComponent, ...rest } = allProps; - const containerEl = getHTMLElement(container, environment || (typeof window !== 'undefined' ? window : undefined)); - const ref = createRef(); - let isReady = false; - - const props = { - ...rest, - ref, - hitComponent: createTemplateFunction(hitComponent), - resultsFooterComponent: createTemplateFunction(resultsFooterComponent), - transformSearchClient: (searchClient: any): any => { - if (searchClient?.addAlgoliaAgent) { - searchClient.addAlgoliaAgent('docsearch.js', docSearchVersion); - } - return typeof transformSearchClient === 'function' ? transformSearchClient(searchClient) : searchClient; - }, - } satisfies DocSearchComponentProps & { ref: typeof ref }; - - render(createElement(DocSearch, props), containerEl); - - // Mark as ready after render completes - isReady = true; - - return { - open(): void { - ref.current?.open(); - }, - close(): void { - ref.current?.close(); - }, - openAskAi(initialMessage?: InitialAskAiMessage): void { - ref.current?.openAskAi(initialMessage); - }, - get isReady(): boolean { - return isReady; - }, - get isOpen(): boolean { - return ref.current?.isOpen ?? false; - }, - destroy(): void { - unmountComponentAtNode(containerEl); - isReady = false; - }, - }; -} diff --git a/packages/docsearch-js/src/docsearchAi.tsx b/packages/docsearch-js/src/docsearchAi.tsx new file mode 100644 index 00000000..64264934 --- /dev/null +++ b/packages/docsearch-js/src/docsearchAi.tsx @@ -0,0 +1,17 @@ +import type { DocSearchAIProps as DocSearchComponentProps } from '@docsearch/react/docsearchAi'; +import { DocSearchAI } from '@docsearch/react/docsearchAi'; +import { version } from '@docsearch/react/version'; + +import { + createDocSearch, + type DocSearchInstance, + type DocSearchProps as CreateDocSearchProps, +} from './createDocSearch'; + +export type { DocSearchCallbacks, DocSearchInstance, TemplateHelpers } from './createDocSearch'; +export type DocSearchAIProps = CreateDocSearchProps; + +export const docsearchAi: (allProps: DocSearchAIProps) => DocSearchInstance = createDocSearch( + DocSearchAI, + version, +); diff --git a/packages/docsearch-js/src/docsearchComponent.tsx b/packages/docsearch-js/src/docsearchComponent.tsx new file mode 100644 index 00000000..034027a7 --- /dev/null +++ b/packages/docsearch-js/src/docsearchComponent.tsx @@ -0,0 +1,16 @@ +import type { DocSearchProps as DocSearchComponentProps } from '@docsearch/react'; +import { DocSearch, version } from '@docsearch/react'; + +import { + createDocSearch, + type DocSearchInstance, + type DocSearchProps as CreateDocSearchProps, +} from './createDocSearch'; + +export type { DocSearchCallbacks, DocSearchInstance, TemplateHelpers } from './createDocSearch'; +export type DocSearchProps = CreateDocSearchProps; + +export const docsearch: (allProps: DocSearchProps) => DocSearchInstance = createDocSearch( + DocSearch, + version, +); diff --git a/packages/docsearch-js/src/index.ts b/packages/docsearch-js/src/index.ts index e2800792..cb66f3a1 100644 --- a/packages/docsearch-js/src/index.ts +++ b/packages/docsearch-js/src/index.ts @@ -1,2 +1,7 @@ -export { docsearch as default } from './docsearch'; -export type { DocSearchProps, DocSearchInstance, DocSearchCallbacks, TemplateHelpers } from './docsearch'; +export { docsearchAi as default } from './docsearchAi'; +export type { + DocSearchAIProps as DocSearchProps, + DocSearchInstance, + DocSearchCallbacks, + TemplateHelpers, +} from './docsearchAi'; diff --git a/packages/docsearch-js/tsdown.config.mts b/packages/docsearch-js/tsdown.config.mts index 74746008..13167732 100644 --- a/packages/docsearch-js/tsdown.config.mts +++ b/packages/docsearch-js/tsdown.config.mts @@ -7,7 +7,6 @@ import { defines } from '../../tsdown.base.ts'; import pkg from './package.json' with { type: 'json' }; const sharedConfig: UserConfig = { - entry: 'src/index.ts', platform: 'browser', target: 'es2017', define: defines, @@ -32,6 +31,7 @@ const sharedConfig: UserConfig = { export default defineConfig([ { ...sharedConfig, + entry: 'src/index.ts', dts: true, format: 'esm', outDir: 'dist/esm', @@ -39,6 +39,28 @@ export default defineConfig([ }, { ...sharedConfig, + clean: false, + entry: 'src/docsearch.ts', + dts: true, + format: 'esm', + outDir: 'dist/esm', + minify: false, + }, + { + ...sharedConfig, + entry: 'src/index.ts', + dts: false, + globalName: 'docsearch', + outDir: 'dist/umd', + outputOptions: { + entryFileNames: '[name].js', + }, + format: 'umd', + minify: true, + }, + { + ...sharedConfig, + entry: 'src/docsearch.ts', dts: false, globalName: 'docsearch', outDir: 'dist/umd',