diff --git a/adapters/docusaurus-theme-search-algolia/.gitignore b/adapters/docusaurus-theme-search-algolia/.gitignore new file mode 100644 index 0000000000..c1ac5932f7 --- /dev/null +++ b/adapters/docusaurus-theme-search-algolia/.gitignore @@ -0,0 +1,3 @@ +lib +libtmp +*.tsbuildinfo diff --git a/adapters/docusaurus-theme-search-algolia/.npmignore b/adapters/docusaurus-theme-search-algolia/.npmignore new file mode 100644 index 0000000000..03c9ae1e1b --- /dev/null +++ b/adapters/docusaurus-theme-search-algolia/.npmignore @@ -0,0 +1,3 @@ +.tsbuildinfo* +tsconfig* +__tests__ diff --git a/adapters/docusaurus-theme-search-algolia/README.md b/adapters/docusaurus-theme-search-algolia/README.md new file mode 100644 index 0000000000..974060465f --- /dev/null +++ b/adapters/docusaurus-theme-search-algolia/README.md @@ -0,0 +1,27 @@ +# `@docsearch/docusaurus-adapter` + +Algolia search component for Docusaurus. + +## Usage + +Prefer configuring the adapter with `themeConfig.docsearch`: + +```js +// docusaurus.config.js +export default { + // ... + themeConfig: { + docsearch: { + appId: 'APP_ID', + apiKey: 'SEARCH_API_KEY', + indexName: 'INDEX_NAME', + askAi: { + assistantId: 'ASSISTANT_ID', + sidePanel: true, + }, + }, + }, +}; +``` + +`themeConfig.algolia` is still supported as a backward-compatible alias. diff --git a/adapters/docusaurus-theme-search-algolia/package.json b/adapters/docusaurus-theme-search-algolia/package.json new file mode 100644 index 0000000000..a366303ff4 --- /dev/null +++ b/adapters/docusaurus-theme-search-algolia/package.json @@ -0,0 +1,68 @@ +{ + "name": "@docsearch/docusaurus-adapter", + "version": "4.5.4", + "description": "Algolia search component for Docusaurus.", + "main": "lib/index.js", + "sideEffects": [ + "*.css" + ], + "exports": { + "./client": { + "types": "./lib/client/index.d.ts", + "default": "./lib/client/index.js" + }, + ".": { + "types": "./src/theme-search-algolia.d.ts", + "default": "./lib/index.js" + } + }, + "types": "src/theme-search-algolia.d.ts", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/algolia/docsearch.git", + "directory": "adapters/docusaurus-theme-search-algolia" + }, + "license": "MIT", + "scripts": { + "build": "yarn exec tsc --build --force && node ./scripts/copy-assets.mjs && node ./scripts/format-theme.mjs", + "build:clean": "yarn clean && yarn build", + "clean": "rm -rf lib tsconfig.*.tsbuildinfo", + "watch": "run-p -c copy:watch build:watch", + "build:watch": "yarn exec tsc --build --watch", + "copy:watch": "node ./scripts/copy-assets.mjs --watch" + }, + "dependencies": { + "@docsearch/react": "^4.5.3", + "@docusaurus/core": "^3.9.2", + "@docusaurus/plugin-content-docs": "^3.9.2", + "@docusaurus/theme-common": "^3.9.2", + "@docusaurus/theme-translations": "^3.9.2", + "algoliasearch": "^5.37.0", + "algoliasearch-helper": "^3.26.0", + "clsx": "^2.0.0", + "eta": "^2.2.0", + "fs-extra": "^11.1.1", + "joi": "^17.9.2", + "lodash": "^4.17.21", + "tslib": "^2.6.0", + "utility-types": "^3.10.0" + }, + "devDependencies": { + "@docusaurus/core": "^3.9.2", + "@docusaurus/module-type-aliases": "3.9.2", + "@docusaurus/theme-classic": "3.9.2", + "@types/fs-extra": "^11.0.4", + "@types/lodash": "^4.17.10", + "typescript": "5.7.3" + }, + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "engines": { + "node": ">=20.0" + } +} diff --git a/adapters/docusaurus-theme-search-algolia/scripts/copy-assets.mjs b/adapters/docusaurus-theme-search-algolia/scripts/copy-assets.mjs new file mode 100644 index 0000000000..c05f5e299c --- /dev/null +++ b/adapters/docusaurus-theme-search-algolia/scripts/copy-assets.mjs @@ -0,0 +1,73 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +import fse from 'fs-extra'; + +const WATCH_FLAG = '--watch'; +const ASSET_EXTENSIONS = new Set(['.css']); +const IGNORED_EXTENSIONS = new Set(['.ts', '.tsx', '.d.ts']); + +const cwd = process.cwd(); +const srcRoot = path.join(cwd, 'src', 'theme'); +const destRoot = path.join(cwd, 'lib', 'theme'); + +async function copyAssetFile(filePath) { + const relativePath = path.relative(srcRoot, filePath); + const destPath = path.join(destRoot, relativePath); + + await fse.ensureDir(path.dirname(destPath)); + await fse.copyFile(filePath, destPath); +} + +async function copyAssetsOnce() { + if (!(await fse.pathExists(srcRoot))) { + return; + } + + const entries = await fse.readdir(srcRoot, { recursive: true }); + const filePaths = entries + .filter((entry) => typeof entry === 'string') + .map((entry) => path.join(srcRoot, entry)) + .filter((entryPath) => fs.statSync(entryPath).isFile()); + + await Promise.all( + filePaths + .filter((filePath) => { + const extension = path.extname(filePath); + if (IGNORED_EXTENSIONS.has(extension)) { + return false; + } + return ASSET_EXTENSIONS.has(extension); + }) + .map((filePath) => copyAssetFile(filePath)), + ); +} + +function watchAssets() { + if (!fs.existsSync(srcRoot)) { + return; + } + + copyAssetsOnce(); + + fs.watch(srcRoot, { recursive: true }, (_eventType, filename) => { + if (!filename) { + return; + } + const filePath = path.join(srcRoot, filename); + const extension = path.extname(filePath); + if (IGNORED_EXTENSIONS.has(extension) || !ASSET_EXTENSIONS.has(extension)) { + return; + } + if (!fs.existsSync(filePath)) { + return; + } + copyAssetFile(filePath); + }); +} + +if (process.argv.includes(WATCH_FLAG)) { + watchAssets(); +} else { + copyAssetsOnce(); +} diff --git a/adapters/docusaurus-theme-search-algolia/scripts/format-theme.mjs b/adapters/docusaurus-theme-search-algolia/scripts/format-theme.mjs new file mode 100644 index 0000000000..d8d1b7d697 --- /dev/null +++ b/adapters/docusaurus-theme-search-algolia/scripts/format-theme.mjs @@ -0,0 +1,16 @@ +import { execSync } from 'node:child_process'; + +import { glob } from 'glob'; + +const pattern = 'lib/theme/**/*.js'; +const files = glob.sync(pattern); + +if (files.length > 0) { + try { + execSync(`prettier --config ../../.prettierrc --write "${pattern}"`, { + stdio: 'inherit', + }); + } catch (error) { + throw new Error(`Prettier failed: ${error instanceof Error ? error.message : String(error)}`); + } +} diff --git a/adapters/docusaurus-theme-search-algolia/src/__tests__/utils.test.ts b/adapters/docusaurus-theme-search-algolia/src/__tests__/utils.test.ts new file mode 100644 index 0000000000..5f1018c132 --- /dev/null +++ b/adapters/docusaurus-theme-search-algolia/src/__tests__/utils.test.ts @@ -0,0 +1,44 @@ +/** + * Copyright (c) Facebook, Inc. And its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import assert from 'node:assert/strict'; + +import { describe, it } from 'vitest'; + +import { mergeFacetFilters } from '../client/utils'; + +describe('mergeFacetFilters', () => { + it('merges [string,string]', () => { + assert.deepStrictEqual(mergeFacetFilters('f1', 'f2'), ['f1', 'f2']); + }); + + it('merges [string,array]', () => { + assert.deepStrictEqual(mergeFacetFilters('f1', ['f2', 'f3']), ['f1', 'f2', 'f3']); + }); + + it('merges [string,undefined]', () => { + assert.deepStrictEqual(mergeFacetFilters('f1', undefined), 'f1'); + }); + + it('merges [undefined,string]', () => { + assert.deepStrictEqual(mergeFacetFilters(undefined, 'f1'), 'f1'); + }); + + it('merges [array,undefined]', () => { + assert.deepStrictEqual(mergeFacetFilters(['f1', 'f2'], undefined), ['f1', 'f2']); + }); + + it('merges [undefined,array]', () => { + assert.deepStrictEqual(mergeFacetFilters(undefined, ['f1', 'f2']), ['f1', 'f2']); + }); + + it('merges [array,array]', () => { + assert.deepStrictEqual(mergeFacetFilters(['f1'], ['f2']), ['f1', 'f2']); + + assert.deepStrictEqual(mergeFacetFilters(['f1', 'f2'], ['f3', 'f4']), ['f1', 'f2', 'f3', 'f4']); + }); +}); diff --git a/adapters/docusaurus-theme-search-algolia/src/__tests__/validateThemeConfig.test.ts b/adapters/docusaurus-theme-search-algolia/src/__tests__/validateThemeConfig.test.ts new file mode 100644 index 0000000000..4e1df27d2f --- /dev/null +++ b/adapters/docusaurus-theme-search-algolia/src/__tests__/validateThemeConfig.test.ts @@ -0,0 +1,699 @@ +/** + * Copyright (c) Facebook, Inc. And its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type { ThemeConfig, UserThemeConfig } from '@docsearch/docusaurus-adapter'; +import type Joi from 'joi'; +import { describe, expect, it, vi } from 'vitest'; + +import { DEFAULT_CONFIG, validateThemeConfig } from '../validateThemeConfig'; + +// mock DocSearch to a v4.5 version to allow AskAI sidepanel tests to pass +vi.mock('@docsearch/react', () => ({ version: '4.5.3' })); + +type AlgoliaInput = UserThemeConfig['algolia']; +type DocSearchInput = UserThemeConfig['docsearch']; + +function testValidateThemeConfigWithUserThemeConfig(themeConfig: UserThemeConfig) { + function validate(schema: Joi.ObjectSchema<{ [key: string]: unknown }>, cfg: { [key: string]: unknown }) { + const { value, error } = schema.validate(cfg, { + convert: false, + }); + if (error) { + throw error; + } + return value as ThemeConfig; + } + + return validateThemeConfig({ + themeConfig: themeConfig as ThemeConfig, + validate, + }); +} + +function testValidateThemeConfig(algolia: AlgoliaInput) { + return testValidateThemeConfigWithUserThemeConfig(algolia ? { algolia } : {}); +} + +function testValidateThemeConfigDocSearch(docsearch: DocSearchInput) { + return testValidateThemeConfigWithUserThemeConfig(docsearch ? { docsearch } : {}); +} + +function expectThrowMessage(fn: () => unknown, message: string): void { + let thrownError: unknown; + try { + fn(); + } catch (error) { + thrownError = error; + } + + expect(thrownError).toBeDefined(); + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toBe(message); +} + +describe('validateThemeConfig', () => { + it('minimal config', () => { + const algolia: AlgoliaInput = { + indexName: 'index', + apiKey: 'apiKey', + appId: 'BH4D9OD16A', + }; + expect(testValidateThemeConfig(algolia)).toEqual({ + algolia: { + ...DEFAULT_CONFIG, + ...algolia, + }, + }); + }); + + it('unknown attributes', () => { + const algolia: AlgoliaInput = { + indexName: 'index', + apiKey: 'apiKey', + // @ts-expect-error: expected type error! + unknownKey: 'unknownKey', + appId: 'BH4D9OD16A', + }; + expect(testValidateThemeConfig(algolia)).toEqual({ + algolia: { + ...DEFAULT_CONFIG, + ...algolia, + }, + }); + }); + + it('undefined config', () => { + const algolia = undefined; + expectThrowMessage(() => testValidateThemeConfig(algolia), '"themeConfig.algolia" is required'); + }); + + it('empty config', () => { + expectThrowMessage( + () => + testValidateThemeConfig( + // @ts-expect-error: expected type error! + {}, + ), + `"algolia.appId" is required. If you haven't migrated to the new DocSearch infra, please refer to the blog post for instructions: https://docusaurus.io/blog/2021/11/21/algolia-docsearch-migration`, + ); + }); + + it('missing indexName config', () => { + // @ts-expect-error: expected type error! + const algolia: AlgoliaInput = { + apiKey: 'apiKey', + appId: 'BH4D9OD16A', + }; + expectThrowMessage(() => testValidateThemeConfig(algolia), '"algolia.indexName" is required'); + }); + + it('missing apiKey config', () => { + // @ts-expect-error: expected type error! + const algolia: AlgoliaInput = { + indexName: 'indexName', + appId: 'BH4D9OD16A', + }; + expectThrowMessage(() => testValidateThemeConfig(algolia), '"algolia.apiKey" is required'); + }); + + it('missing appId config', () => { + // @ts-expect-error: expected type error! + const algolia: AlgoliaInput = { + indexName: 'indexName', + apiKey: 'apiKey', + }; + expectThrowMessage( + () => testValidateThemeConfig(algolia), + `"algolia.appId" is required. If you haven't migrated to the new DocSearch infra, please refer to the blog post for instructions: https://docusaurus.io/blog/2021/11/21/algolia-docsearch-migration`, + ); + }); + + it('contextualSearch config', () => { + const algolia: AlgoliaInput = { + appId: 'BH4D9OD16A', + indexName: 'index', + apiKey: 'apiKey', + contextualSearch: true, + }; + expect(testValidateThemeConfig(algolia)).toEqual({ + algolia: { + ...DEFAULT_CONFIG, + ...algolia, + }, + }); + }); + + it('externalUrlRegex config', () => { + const algolia: AlgoliaInput = { + appId: 'BH4D9OD16A', + indexName: 'index', + apiKey: 'apiKey', + externalUrlRegex: 'http://external-domain.com', + }; + expect(testValidateThemeConfig(algolia)).toEqual({ + algolia: { + ...DEFAULT_CONFIG, + ...algolia, + }, + }); + }); + + describe('replaceSearchResultPathname', () => { + it('escapes from string', () => { + const algolia: AlgoliaInput = { + appId: 'BH4D9OD16A', + indexName: 'index', + apiKey: 'apiKey', + replaceSearchResultPathname: { + from: '/docs/some-\\special-.[regexp]{chars*}', + to: '/abc', + }, + }; + expect(testValidateThemeConfig(algolia)).toEqual({ + algolia: { + ...DEFAULT_CONFIG, + ...algolia, + replaceSearchResultPathname: { + from: '/docs/some\\x2d\\\\special\\x2d\\.\\[regexp\\]\\{chars\\*\\}', + to: '/abc', + }, + }, + }); + }); + + it('converts from regexp to string', () => { + const algolia: AlgoliaInput = { + appId: 'BH4D9OD16A', + indexName: 'index', + apiKey: 'apiKey', + replaceSearchResultPathname: { + // @ts-expect-error: test regexp input + from: /^\/docs\/(?:1\.0|next)/, + to: '/abc', + }, + }; + + expect(testValidateThemeConfig(algolia)).toEqual({ + algolia: { + ...DEFAULT_CONFIG, + ...algolia, + replaceSearchResultPathname: { + from: '^\\/docs\\/(?:1\\.0|next)', + to: '/abc', + }, + }, + }); + }); + }); + + it('searchParameters.facetFilters search config', () => { + const algolia: AlgoliaInput = { + appId: 'BH4D9OD16A', + indexName: 'index', + apiKey: 'apiKey', + searchParameters: { + facetFilters: ['version:1.0'], + }, + }; + expect(testValidateThemeConfig(algolia)).toEqual({ + algolia: { + ...DEFAULT_CONFIG, + ...algolia, + }, + }); + }); + + describe('askAi config validation', () => { + it('accepts string format (assistantId)', () => { + const algolia: AlgoliaInput = { + appId: 'BH4D9OD16A', + indexName: 'index', + apiKey: 'apiKey', + askAi: 'my-assistant-id', + }; + expect(testValidateThemeConfig(algolia)).toEqual({ + algolia: { + ...DEFAULT_CONFIG, + ...algolia, + askAi: { + assistantId: 'my-assistant-id', + indexName: algolia.indexName, + apiKey: algolia.apiKey, + appId: algolia.appId, + }, + }, + }); + }); + + it('accepts minimal object format', () => { + const algolia: AlgoliaInput = { + appId: 'BH4D9OD16A', + indexName: 'index', + apiKey: 'apiKey', + askAi: { + assistantId: 'my-assistant-id', + }, + }; + expect(testValidateThemeConfig(algolia)).toEqual({ + algolia: { + ...DEFAULT_CONFIG, + ...algolia, + askAi: { + assistantId: 'my-assistant-id', + indexName: algolia.indexName, + apiKey: algolia.apiKey, + appId: algolia.appId, + }, + }, + }); + }); + + it('accepts sidePanel as true', () => { + const algolia: AlgoliaInput = { + appId: 'BH4D9OD16A', + indexName: 'index', + apiKey: 'apiKey', + askAi: { + assistantId: 'my-assistant-id', + sidePanel: true, + }, + }; + expect(testValidateThemeConfig(algolia)).toEqual({ + algolia: { + ...DEFAULT_CONFIG, + ...algolia, + askAi: { + assistantId: 'my-assistant-id', + indexName: algolia.indexName, + apiKey: algolia.apiKey, + appId: algolia.appId, + sidePanel: true, + }, + }, + }); + }); + + it('accepts sidePanel as object', () => { + const algolia: AlgoliaInput = { + appId: 'BH4D9OD16A', + indexName: 'index', + apiKey: 'apiKey', + askAi: { + assistantId: 'my-assistant-id', + sidePanel: { + keyboardShortcuts: { + 'Ctrl/Cmd+I': false, + }, + variant: 'inline', + side: 'left', + width: '420px', + expandedWidth: 640, + pushSelector: '#__docusaurus', + }, + }, + }; + const sidePanelValue = ( + (algolia as NonNullable).askAi as Exclude['askAi'], string> + )?.sidePanel; + expect(testValidateThemeConfig(algolia)).toEqual({ + algolia: { + ...DEFAULT_CONFIG, + ...algolia, + askAi: { + assistantId: 'my-assistant-id', + indexName: algolia.indexName, + apiKey: algolia.apiKey, + appId: algolia.appId, + sidePanel: sidePanelValue, + }, + }, + }); + }); + + it('accepts sidePanel.hideButton as true', () => { + const algolia: AlgoliaInput = { + appId: 'BH4D9OD16A', + indexName: 'index', + apiKey: 'apiKey', + askAi: { + assistantId: 'my-assistant-id', + sidePanel: { + hideButton: true, + }, + }, + }; + expect(testValidateThemeConfig(algolia)).toEqual({ + algolia: { + ...DEFAULT_CONFIG, + ...algolia, + askAi: { + assistantId: 'my-assistant-id', + indexName: algolia.indexName, + apiKey: algolia.apiKey, + appId: algolia.appId, + sidePanel: { + hideButton: true, + }, + }, + }, + }); + }); + + it('accepts full object format', () => { + const algolia: AlgoliaInput = { + appId: 'BH4D9OD16A', + indexName: 'index', + apiKey: 'apiKey', + askAi: { + indexName: 'ai-index', + apiKey: 'ai-apiKey', + appId: 'ai-appId', + assistantId: 'my-assistant-id', + }, + }; + expect(testValidateThemeConfig(algolia)).toEqual({ + algolia: { + ...DEFAULT_CONFIG, + ...algolia, + }, + }); + }); + + it('accepts agentStudio=true with per-index searchParameters', () => { + const algolia: AlgoliaInput = { + appId: 'BH4D9OD16A', + indexName: 'index', + apiKey: 'apiKey', + askAi: { + assistantId: 'my-assistant-id', + agentStudio: true, + searchParameters: { + index: { + distinct: false, + }, + }, + }, + }; + expect(testValidateThemeConfig(algolia)).toEqual({ + algolia: { + ...DEFAULT_CONFIG, + ...algolia, + askAi: { + assistantId: 'my-assistant-id', + indexName: algolia.indexName, + apiKey: algolia.apiKey, + appId: algolia.appId, + agentStudio: true, + searchParameters: { + index: { + distinct: false, + }, + }, + }, + }, + }); + }); + + it('rejects invalid type', () => { + const algolia: AlgoliaInput = { + appId: 'BH4D9OD16A', + indexName: 'index', + apiKey: 'apiKey', + // @ts-expect-error: expected type error + askAi: 123, // Invalid: should be string or object + }; + expectThrowMessage( + () => testValidateThemeConfig(algolia), + 'askAi must be either a string (assistantId) or an object with indexName, apiKey, appId, and assistantId', + ); + }); + + it('rejects empty askAi', () => { + const algolia: AlgoliaInput = { + appId: 'BH4D9OD16A', + indexName: 'index', + apiKey: 'apiKey', + // @ts-expect-error: expected type error: missing mandatory fields + askAi: {}, + }; + expectThrowMessage(() => testValidateThemeConfig(algolia), '"algolia.askAi.assistantId" is required'); + }); + + it('accepts undefined askAi', () => { + const algolia: AlgoliaInput = { + appId: 'BH4D9OD16A', + indexName: 'index', + apiKey: 'apiKey', + }; + expect(testValidateThemeConfig(algolia)).toEqual({ + algolia: { + ...DEFAULT_CONFIG, + ...algolia, + }, + }); + }); + + describe('Ask AI search parameters', () => { + it('accepts Ask AI facet filters', () => { + const algolia = { + appId: 'BH4D9OD16A', + indexName: 'index', + apiKey: 'apiKey', + askAi: { + indexName: 'ai-index', + apiKey: 'ai-apiKey', + appId: 'ai-appId', + assistantId: 'my-assistant-id', + searchParameters: { + facetFilters: ['version:1.0'], + }, + }, + } satisfies AlgoliaInput; + + expect(testValidateThemeConfig(algolia)).toEqual({ + algolia: { + ...DEFAULT_CONFIG, + ...algolia, + }, + }); + }); + + it('accepts distinct Ask AI / algolia facet filters', () => { + const algolia = { + appId: 'BH4D9OD16A', + indexName: 'index', + apiKey: 'apiKey', + searchParameters: { + facetFilters: ['version:algolia'], + }, + askAi: { + indexName: 'ai-index', + apiKey: 'ai-apiKey', + appId: 'ai-appId', + assistantId: 'my-assistant-id', + searchParameters: { + facetFilters: ['version:askAi'], + }, + }, + } satisfies AlgoliaInput; + + expect(testValidateThemeConfig(algolia)).toEqual({ + algolia: { + ...DEFAULT_CONFIG, + ...algolia, + }, + }); + }); + + it('falls back to algolia facet filters', () => { + const algolia = { + appId: 'BH4D9OD16A', + indexName: 'index', + apiKey: 'apiKey', + searchParameters: { + facetFilters: ['version:1.0'], + }, + askAi: { + indexName: 'ai-index', + apiKey: 'ai-apiKey', + appId: 'ai-appId', + assistantId: 'my-assistant-id', + searchParameters: {}, + }, + } satisfies AlgoliaInput; + + expect(testValidateThemeConfig(algolia)).toEqual({ + algolia: { + ...DEFAULT_CONFIG, + ...algolia, + askAi: { + ...algolia.askAi, + searchParameters: { + facetFilters: ['version:1.0'], + }, + }, + }, + }); + }); + + it('falls back to algolia facet filters with AskAI string format (assistantId)', () => { + const algolia = { + appId: 'BH4D9OD16A', + indexName: 'index', + apiKey: 'apiKey', + searchParameters: { + facetFilters: ['version:1.0'], + }, + askAi: 'my-assistant-id', + } satisfies AlgoliaInput; + + expect(testValidateThemeConfig(algolia)).toEqual({ + algolia: { + ...DEFAULT_CONFIG, + ...algolia, + askAi: { + indexName: algolia.indexName, + apiKey: algolia.apiKey, + appId: algolia.appId, + assistantId: 'my-assistant-id', + searchParameters: { + facetFilters: ['version:1.0'], + }, + }, + }, + }); + }); + }); + + describe('Ask AI suggestedQuestions', () => { + it('accepts suggestedQuestions as true', () => { + const algolia = { + appId: 'BH4D9OD16A', + indexName: 'index', + apiKey: 'apiKey', + askAi: { + assistantId: 'my-assistant-id', + suggestedQuestions: true, + }, + } satisfies AlgoliaInput; + + expect(testValidateThemeConfig(algolia)).toEqual({ + algolia: { + ...DEFAULT_CONFIG, + ...algolia, + askAi: { + indexName: algolia.indexName, + apiKey: algolia.apiKey, + appId: algolia.appId, + assistantId: 'my-assistant-id', + suggestedQuestions: true, + }, + }, + }); + }); + + it('accepts suggestedQuestions as false', () => { + const algolia = { + appId: 'BH4D9OD16A', + indexName: 'index', + apiKey: 'apiKey', + askAi: { + assistantId: 'my-assistant-id', + suggestedQuestions: false, + }, + } satisfies AlgoliaInput; + + expect(testValidateThemeConfig(algolia)).toEqual({ + algolia: { + ...DEFAULT_CONFIG, + ...algolia, + askAi: { + indexName: algolia.indexName, + apiKey: algolia.apiKey, + appId: algolia.appId, + assistantId: 'my-assistant-id', + suggestedQuestions: false, + }, + }, + }); + }); + + it('rejects invalid suggestedQuestions type', () => { + const algolia: AlgoliaInput = { + appId: 'BH4D9OD16A', + indexName: 'index', + apiKey: 'apiKey', + askAi: { + assistantId: 'my-assistant-id', + // @ts-expect-error: expected type error + suggestedQuestions: 'invalid-string', + }, + }; + expectThrowMessage( + () => testValidateThemeConfig(algolia), + '"algolia.askAi.suggestedQuestions" must be a boolean', + ); + }); + + it('rejects suggestedQuestions as number', () => { + const algolia: AlgoliaInput = { + appId: 'BH4D9OD16A', + indexName: 'index', + apiKey: 'apiKey', + askAi: { + assistantId: 'my-assistant-id', + // @ts-expect-error: expected type error + suggestedQuestions: 123, + }, + }; + expectThrowMessage( + () => testValidateThemeConfig(algolia), + '"algolia.askAi.suggestedQuestions" must be a boolean', + ); + }); + }); + }); + + describe('theme config keys', () => { + it('accepts themeConfig.docsearch (preferred)', () => { + const docsearch: DocSearchInput = { + appId: 'BH4D9OD16A', + indexName: 'index', + apiKey: 'apiKey', + }; + + expect(testValidateThemeConfigDocSearch(docsearch)).toEqual({ + docsearch: { + ...DEFAULT_CONFIG, + ...docsearch, + }, + }); + }); + + it('rejects defining both themeConfig.docsearch and themeConfig.algolia', () => { + const docsearch: DocSearchInput = { + appId: 'BH4D9OD16A', + indexName: 'index', + apiKey: 'apiKey', + }; + const algolia: AlgoliaInput = { + appId: 'BH4D9OD16A', + indexName: 'index', + apiKey: 'apiKey', + }; + + expectThrowMessage( + () => + testValidateThemeConfigWithUserThemeConfig({ + docsearch, + algolia, + }), + 'Please provide either "themeConfig.docsearch" (preferred) or "themeConfig.algolia" (legacy), but not both.', + ); + }); + }); +}); diff --git a/adapters/docusaurus-theme-search-algolia/src/client/index.ts b/adapters/docusaurus-theme-search-algolia/src/client/index.ts new file mode 100644 index 0000000000..53644f1216 --- /dev/null +++ b/adapters/docusaurus-theme-search-algolia/src/client/index.ts @@ -0,0 +1,16 @@ +/** + * Copyright (c) Facebook, Inc. And its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +export { useAlgoliaThemeConfig } from './useAlgoliaThemeConfig'; +export { + useAlgoliaContextualFacetFilters, + useAlgoliaContextualFacetFiltersIfEnabled, +} from './useAlgoliaContextualFacetFilters'; +export { useSearchResultUrlProcessor } from './useSearchResultUrlProcessor'; +export { useAlgoliaAskAi } from './useAlgoliaAskAi'; +export { useAlgoliaAskAiSidepanel } from './useAlgoliaAskAiSidepanel'; +export { mergeFacetFilters } from './utils'; diff --git a/adapters/docusaurus-theme-search-algolia/src/client/useAlgoliaAskAi.ts b/adapters/docusaurus-theme-search-algolia/src/client/useAlgoliaAskAi.ts new file mode 100644 index 0000000000..d968ec3dcb --- /dev/null +++ b/adapters/docusaurus-theme-search-algolia/src/client/useAlgoliaAskAi.ts @@ -0,0 +1,121 @@ +/** + * Copyright (c) Facebook, Inc. And its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type { AskAiConfig } from '@docsearch/docusaurus-adapter'; +import type { DocSearchModalProps, DocSearchTranslations } from '@docsearch/react'; +import translations from '@theme/SearchTranslations'; +import type { FacetFilters } from 'algoliasearch/lite'; +import { useCallback, useMemo, useState } from 'react'; + +import { useAlgoliaContextualFacetFiltersIfEnabled } from './useAlgoliaContextualFacetFilters'; +import { mergeFacetFilters } from './utils'; + +// The minimal props the hook needs from DocSearch +interface DocSearchPropsLite { + indexName: string; + apiKey: string; + appId: string; + placeholder?: string; + translations?: DocSearchTranslations; + searchParameters?: DocSearchModalProps['searchParameters']; + askAi?: AskAiConfig; +} + +type OnAskAiToggle = NonNullable; +type AskAiConfigWithoutSidePanel = Omit; +type DocSearchAskAi = Exclude; +type DocSearchModalPropsLite = Partial>; + +type UseAskAiResult = { + canHandleAskAi: boolean; + isAskAiActive: boolean; + currentPlaceholder: string | undefined; + onAskAiToggle: OnAskAiToggle; + askAi?: AskAiConfig; + extraAskAiProps: DocSearchModalPropsLite & { + askAi?: DocSearchAskAi; + canHandleAskAi?: boolean; + isAskAiActive?: boolean; + onAskAiToggle?: OnAskAiToggle; + }; +}; + +// We need to apply contextualSearch facetFilters to AskAI filters +// This can't be done at config normalization time because contextual filters +// can only be determined at runtime +function applyAskAiContextualSearch( + askAi: AskAiConfig | undefined, + contextualSearchFilters: FacetFilters | undefined, +): AskAiConfig | undefined { + if (!askAi) { + return undefined; + } + if (askAi.agentStudio === true) { + return askAi; + } + if (!contextualSearchFilters) { + return askAi; + } + const askAiFacetFilters = askAi.searchParameters?.facetFilters; + return { + ...askAi, + searchParameters: { + ...askAi.searchParameters, + facetFilters: mergeFacetFilters(askAiFacetFilters, contextualSearchFilters), + }, + }; +} + +export function useAlgoliaAskAi(props: DocSearchPropsLite): UseAskAiResult { + const [isAskAiActive, setIsAskAiActive] = useState(false); + const contextualSearchFilters = useAlgoliaContextualFacetFiltersIfEnabled(); + + const askAi = useMemo(() => { + return applyAskAiContextualSearch(props.askAi, contextualSearchFilters); + }, [props.askAi, contextualSearchFilters]); + + const askAiWithoutSidePanel = useMemo(() => { + if (!askAi) { + return undefined; + } + const { sidePanel: _sidePanel, ...docsearchAskAi } = askAi; + return docsearchAskAi; + }, [askAi]); + + const modalAskAi = useMemo(() => { + if (!askAiWithoutSidePanel) { + return undefined; + } + return askAiWithoutSidePanel as DocSearchAskAi; + }, [askAiWithoutSidePanel]); + + const canHandleAskAi = Boolean(askAi); + + const currentPlaceholder = isAskAiActive + ? translations.modal?.searchBox?.placeholderTextAskAi + : translations.modal?.searchBox?.placeholderText || props?.placeholder; + + const onAskAiToggle = useCallback((askAiToggle: boolean) => { + setIsAskAiActive(askAiToggle); + }, []); + + const extraAskAiProps: UseAskAiResult['extraAskAiProps'] = { + askAi: modalAskAi, + canHandleAskAi, + isAskAiActive, + onAskAiToggle, + }; + + return { + canHandleAskAi, + isAskAiActive, + currentPlaceholder, + onAskAiToggle, + askAi, + extraAskAiProps, + }; +} diff --git a/adapters/docusaurus-theme-search-algolia/src/client/useAlgoliaAskAiSidepanel.ts b/adapters/docusaurus-theme-search-algolia/src/client/useAlgoliaAskAiSidepanel.ts new file mode 100644 index 0000000000..b1127f631e --- /dev/null +++ b/adapters/docusaurus-theme-search-algolia/src/client/useAlgoliaAskAiSidepanel.ts @@ -0,0 +1,118 @@ +/** + * Copyright (c) Facebook, Inc. And its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type { AskAiConfig } from '@docsearch/docusaurus-adapter'; +import { useCallback, useMemo, useRef, useState } from 'react'; + +type AskAiTogglePayload = { + query: string; + messageId?: string; + suggestedQuestionId?: string; +}; + +type SidepanelOptions = Exclude, boolean>; + +type UseAlgoliaAskAiSidepanelParams = { + askAiConfig?: AskAiConfig; + importSidepanel: () => Promise; +}; + +type UseAlgoliaAskAiSidepanelResult = { + sidePanelEnabled: boolean; + showSidepanelButton: boolean; + sidePanelOptions?: SidepanelOptions; + sidePanelAgentStudio: boolean; + sidepanelPortalContainer: HTMLElement | null; + isSidepanelOpen: boolean; + sidepanelInitialMessage?: AskAiTogglePayload; + openSidepanel: (payload?: AskAiTogglePayload) => void; + closeSidepanel: () => void; + toggleSidepanel: () => void; + handleSidepanelOpen: () => void; + loadSidepanel: () => Promise; +}; + +export function useAlgoliaAskAiSidepanel({ + askAiConfig, + importSidepanel, +}: UseAlgoliaAskAiSidepanelParams): UseAlgoliaAskAiSidepanelResult { + const [isSidepanelOpen, setIsSidepanelOpen] = useState(false); + const [sidepanelInitialMessage, setSidepanelInitialMessage] = useState(undefined); + const openRequestId = useRef(0); + + const sidePanelConfig = askAiConfig?.sidePanel; + const sidePanelEnabled = Boolean(sidePanelConfig); + const sidePanelOptions = typeof sidePanelConfig === 'object' ? sidePanelConfig : undefined; + const showSidepanelButton = sidePanelEnabled && sidePanelOptions?.hideButton !== true; + const sidePanelAgentStudio = askAiConfig?.agentStudio ?? false; + + const sidepanelPortalContainer = useMemo(() => { + return typeof document !== 'undefined' ? document.body : null; + }, []); + + const loadSidepanel = useCallback(() => { + return importSidepanel(); + }, [importSidepanel]); + + const openSidepanel = useCallback( + (payload?: AskAiTogglePayload) => { + if (!sidePanelEnabled || !askAiConfig) { + return; + } + const initialMessage = + payload?.query && payload.query.length > 0 + ? { + query: payload.query, + messageId: payload.messageId, + suggestedQuestionId: payload.suggestedQuestionId, + } + : undefined; + const requestId = openRequestId.current + 1; + openRequestId.current = requestId; + setSidepanelInitialMessage(initialMessage); + loadSidepanel().then(() => { + if (openRequestId.current === requestId) { + setIsSidepanelOpen(true); + } + }); + }, + [askAiConfig, loadSidepanel, sidePanelEnabled], + ); + + const closeSidepanel = useCallback(() => { + openRequestId.current += 1; + setIsSidepanelOpen(false); + setSidepanelInitialMessage(undefined); + }, []); + + const toggleSidepanel = useCallback(() => { + if (isSidepanelOpen) { + closeSidepanel(); + return; + } + openSidepanel(); + }, [closeSidepanel, isSidepanelOpen, openSidepanel]); + + const handleSidepanelOpen = useCallback(() => { + setIsSidepanelOpen(true); + }, []); + + return { + sidePanelEnabled, + showSidepanelButton, + sidePanelOptions, + sidePanelAgentStudio, + sidepanelPortalContainer, + isSidepanelOpen, + sidepanelInitialMessage, + openSidepanel, + closeSidepanel, + toggleSidepanel, + handleSidepanelOpen, + loadSidepanel, + }; +} diff --git a/adapters/docusaurus-theme-search-algolia/src/client/useAlgoliaContextualFacetFilters.ts b/adapters/docusaurus-theme-search-algolia/src/client/useAlgoliaContextualFacetFilters.ts new file mode 100644 index 0000000000..60c2543a7c --- /dev/null +++ b/adapters/docusaurus-theme-search-algolia/src/client/useAlgoliaContextualFacetFilters.ts @@ -0,0 +1,56 @@ +/** + * Copyright (c) Facebook, Inc. And its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { useDocsContextualSearchTags } from '@docusaurus/plugin-content-docs/client'; +import { DEFAULT_SEARCH_TAG } from '@docusaurus/theme-common/internal'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import type { FacetFilters } from 'algoliasearch/lite'; + +import { useAlgoliaThemeConfig } from './useAlgoliaThemeConfig'; + +function useSearchTags() { + try { + // only docs have custom search tags per version + const docsTags = useDocsContextualSearchTags(); + return [DEFAULT_SEARCH_TAG, ...docsTags]; + } catch (error) { + // In monorepo setups, duplicated docs plugin instances can cause + // React context lookup to fail during SSG/runtime. Disable contextual + // filters in that case instead of crashing or over-filtering results. + if (error instanceof Error && error.name === 'ReactContextError') { + return undefined; + } + throw error; + } +} + +// Translate search-engine agnostic search tags to Algolia search filters +export function useAlgoliaContextualFacetFilters(): FacetFilters { + const locale = useDocusaurusContext().i18n.currentLocale; + const tags = useSearchTags(); + + if (!tags) { + return []; + } + + // Seems safe to convert locale->language, see AlgoliaSearchMetadata comment + const languageFilter = `language:${locale}`; + + const tagsFilter = tags.map((tag) => `docusaurus_tag:${tag}`); + + return [languageFilter, tagsFilter]; +} + +export function useAlgoliaContextualFacetFiltersIfEnabled(): FacetFilters | undefined { + const { contextualSearch } = useAlgoliaThemeConfig(); + const facetFilters = useAlgoliaContextualFacetFilters(); + if (contextualSearch) { + return facetFilters; + } + + return undefined; +} diff --git a/adapters/docusaurus-theme-search-algolia/src/client/useAlgoliaThemeConfig.ts b/adapters/docusaurus-theme-search-algolia/src/client/useAlgoliaThemeConfig.ts new file mode 100644 index 0000000000..9131f55f6b --- /dev/null +++ b/adapters/docusaurus-theme-search-algolia/src/client/useAlgoliaThemeConfig.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) Facebook, Inc. And its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ +import type { ThemeConfig, ThemeConfigAlgolia } from '@docsearch/docusaurus-adapter'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; + +import { getDocSearchConfig } from '../getDocSearchConfig'; + +export function useAlgoliaThemeConfig(): ThemeConfigAlgolia { + const { + siteConfig: { themeConfig }, + } = useDocusaurusContext(); + return getDocSearchConfig(themeConfig as ThemeConfig); +} diff --git a/adapters/docusaurus-theme-search-algolia/src/client/useSearchResultUrlProcessor.ts b/adapters/docusaurus-theme-search-algolia/src/client/useSearchResultUrlProcessor.ts new file mode 100644 index 0000000000..17652ebacd --- /dev/null +++ b/adapters/docusaurus-theme-search-algolia/src/client/useSearchResultUrlProcessor.ts @@ -0,0 +1,48 @@ +/** + * Copyright (c) Facebook, Inc. And its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type { ThemeConfigAlgolia } from '@docsearch/docusaurus-adapter'; +import { isRegexpStringMatch } from '@docusaurus/theme-common'; +import { useBaseUrlUtils } from '@docusaurus/useBaseUrl'; +import { useCallback } from 'react'; + +import { useAlgoliaThemeConfig } from './useAlgoliaThemeConfig'; + +function replacePathname( + pathname: string, + replaceSearchResultPathname: ThemeConfigAlgolia['replaceSearchResultPathname'], +): string { + return replaceSearchResultPathname + ? pathname.replaceAll(new RegExp(replaceSearchResultPathname.from, 'g'), replaceSearchResultPathname.to) + : pathname; +} + +/** + * Process the search result url from Algolia to its final form, ready to be + * navigated to or used as a link. + */ +export function useSearchResultUrlProcessor(): (url: string) => string { + const { withBaseUrl } = useBaseUrlUtils(); + const { externalUrlRegex, replaceSearchResultPathname } = useAlgoliaThemeConfig(); + + return useCallback( + (url: string) => { + const parsedURL = new URL(url); + + // Algolia contains an external domain => navigate to URL + if (isRegexpStringMatch(externalUrlRegex, parsedURL.href)) { + return url; + } + + // Otherwise => transform to relative URL for SPA navigation + const relativeUrl = `${parsedURL.pathname}${parsedURL.search}${parsedURL.hash}`; + + return withBaseUrl(replacePathname(relativeUrl, replaceSearchResultPathname)); + }, + [withBaseUrl, externalUrlRegex, replaceSearchResultPathname], + ); +} diff --git a/adapters/docusaurus-theme-search-algolia/src/client/utils.ts b/adapters/docusaurus-theme-search-algolia/src/client/utils.ts new file mode 100644 index 0000000000..a6581f4511 --- /dev/null +++ b/adapters/docusaurus-theme-search-algolia/src/client/utils.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) Facebook, Inc. And its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type { FacetFilters } from 'algoliasearch/lite'; + +export function mergeFacetFilters(f1: FacetFilters, f2: FacetFilters): FacetFilters; + +export function mergeFacetFilters(f1: FacetFilters | undefined, f2: FacetFilters | undefined): FacetFilters | undefined; + +export function mergeFacetFilters( + f1: FacetFilters | undefined, + f2: FacetFilters | undefined, +): FacetFilters | undefined { + if (f1 === undefined) { + return f2; + } + if (f2 === undefined) { + return f1; + } + + const normalize = (f: FacetFilters): FacetFilters => (typeof f === 'string' ? [f] : f); + return [...normalize(f1), ...normalize(f2)]; +} diff --git a/adapters/docusaurus-theme-search-algolia/src/deps.d.ts b/adapters/docusaurus-theme-search-algolia/src/deps.d.ts new file mode 100644 index 0000000000..1c99c65e10 --- /dev/null +++ b/adapters/docusaurus-theme-search-algolia/src/deps.d.ts @@ -0,0 +1,19 @@ +/** + * Copyright (c) Facebook, Inc. And its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +declare module '@docsearch/react/modal'; +declare module '@docsearch/react/style'; +declare module '@docsearch/react/style/sidepanel'; + +declare module 'eta' { + export const defaultConfig: Record; + + export function compile( + template: string, + options?: Record, + ): (data: Record, config: Record) => string; +} diff --git a/adapters/docusaurus-theme-search-algolia/src/docSearchVersion.ts b/adapters/docusaurus-theme-search-algolia/src/docSearchVersion.ts new file mode 100644 index 0000000000..091fb6c060 --- /dev/null +++ b/adapters/docusaurus-theme-search-algolia/src/docSearchVersion.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) Facebook, Inc. And its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { version as docSearchVersion } from '@docsearch/react'; + +export const docSearchVersionString = docSearchVersion; diff --git a/adapters/docusaurus-theme-search-algolia/src/getDocSearchConfig.ts b/adapters/docusaurus-theme-search-algolia/src/getDocSearchConfig.ts new file mode 100644 index 0000000000..95e678eb27 --- /dev/null +++ b/adapters/docusaurus-theme-search-algolia/src/getDocSearchConfig.ts @@ -0,0 +1,26 @@ +/** + * Copyright (c) Facebook, Inc. And its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type { ThemeConfig, ThemeConfigAlgolia } from '@docsearch/docusaurus-adapter'; + +export function hasLegacyAlgoliaConfig(themeConfig: ThemeConfig): boolean { + return Boolean(themeConfig.algolia); +} + +export function getDocSearchConfig(themeConfig: ThemeConfig): ThemeConfigAlgolia { + if (themeConfig.docsearch) { + return themeConfig.docsearch; + } + + if (themeConfig.algolia) { + return themeConfig.algolia; + } + + throw new Error( + 'No DocSearch config found. Please provide "themeConfig.docsearch" (preferred) or "themeConfig.algolia" (legacy).', + ); +} diff --git a/adapters/docusaurus-theme-search-algolia/src/index.ts b/adapters/docusaurus-theme-search-algolia/src/index.ts new file mode 100644 index 0000000000..9aea5e3148 --- /dev/null +++ b/adapters/docusaurus-theme-search-algolia/src/index.ts @@ -0,0 +1,84 @@ +/** + * Copyright (c) Facebook, Inc. And its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type { ThemeConfig } from '@docsearch/docusaurus-adapter'; +import { readDefaultCodeTranslationMessages } from '@docusaurus/theme-translations'; +import type { LoadContext, Plugin } from '@docusaurus/types'; + +import { getDocSearchConfig, hasLegacyAlgoliaConfig } from './getDocSearchConfig'; +import { createOpenSearchFile, createOpenSearchHeadTags, shouldCreateOpenSearchFile } from './opensearch'; +import { normalizeUrl } from './utils'; + +function hasClassicPreset(context: LoadContext): boolean { + return (context.siteConfig.presets ?? []).some((preset) => { + if (typeof preset === 'string') { + return preset === 'classic' || preset === '@docusaurus/preset-classic'; + } + + if (Array.isArray(preset)) { + return preset[0] === 'classic' || preset[0] === '@docusaurus/preset-classic'; + } + + return false; + }); +} + +export default function themeSearchAlgolia(context: LoadContext): Plugin { + const { + baseUrl, + siteConfig: { themeConfig }, + i18n: { currentLocale }, + } = context; + const { searchPagePath } = getDocSearchConfig(themeConfig as ThemeConfig); + const classicPresetWithLegacyAlgoliaConfig = + hasClassicPreset(context) && hasLegacyAlgoliaConfig(themeConfig as ThemeConfig); + + return { + name: 'docsearch-docusaurus-algolia-search', + + getThemePath() { + return '../lib/theme'; + }, + getTypeScriptThemePath() { + return '../src/theme'; + }, + + getDefaultCodeTranslationMessages() { + return readDefaultCodeTranslationMessages({ + locale: currentLocale, + name: 'theme-search-algolia', + }); + }, + + contentLoaded({ actions: { addRoute } }) { + // The classic preset adds /search through @docusaurus/theme-search-algolia, + // but only when the legacy "themeConfig.algolia" key is used. + if (searchPagePath && !classicPresetWithLegacyAlgoliaConfig) { + addRoute({ + path: normalizeUrl([baseUrl, searchPagePath]), + component: '@theme/SearchPage', + exact: true, + }); + } + }, + + async postBuild() { + if (shouldCreateOpenSearchFile({ context })) { + await createOpenSearchFile({ context }); + } + }, + + injectHtmlTags() { + if (shouldCreateOpenSearchFile({ context })) { + return { headTags: createOpenSearchHeadTags({ context }) }; + } + return {}; + }, + }; +} + +export { validateThemeConfig } from './validateThemeConfig'; diff --git a/adapters/docusaurus-theme-search-algolia/src/opensearch.ts b/adapters/docusaurus-theme-search-algolia/src/opensearch.ts new file mode 100644 index 0000000000..64b2459a9e --- /dev/null +++ b/adapters/docusaurus-theme-search-algolia/src/opensearch.ts @@ -0,0 +1,101 @@ +/** + * Copyright (c) Facebook, Inc. And its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import path from 'path'; + +import type { ThemeConfig } from '@docsearch/docusaurus-adapter'; +import type { HtmlTags, LoadContext } from '@docusaurus/types'; +import { defaultConfig, compile } from 'eta'; +import fs from 'fs-extra'; +import _ from 'lodash'; + +import { getDocSearchConfig } from './getDocSearchConfig'; +import openSearchTemplate from './templates/opensearch'; +import { normalizeUrl } from './utils'; + +const getCompiledOpenSearchTemplate = _.memoize(() => compile(openSearchTemplate.trim())); + +function renderOpenSearchTemplate(data: { + title: string; + siteUrl: string; + searchUrl: string; + faviconUrl: string | null; +}) { + const compiled = getCompiledOpenSearchTemplate(); + return compiled(data, defaultConfig); +} + +const OPEN_SEARCH_FILENAME = 'opensearch.xml'; + +export function shouldCreateOpenSearchFile({ context }: { context: LoadContext }): boolean { + const { + siteConfig: { + themeConfig, + future: { experimental_router: router }, + }, + } = context; + const { searchPagePath } = getDocSearchConfig(themeConfig as ThemeConfig); + + return Boolean(searchPagePath) && router !== 'hash'; +} + +function createOpenSearchFileContent({ + context, + searchPagePath, +}: { + context: LoadContext; + searchPagePath: string; +}): string { + const { + baseUrl, + siteConfig: { title, url, favicon }, + } = context; + + const siteUrl = normalizeUrl([url, baseUrl]); + + return renderOpenSearchTemplate({ + title, + siteUrl, + searchUrl: normalizeUrl([siteUrl, searchPagePath]), + faviconUrl: favicon ? normalizeUrl([siteUrl, favicon]) : null, + }); +} + +export async function createOpenSearchFile({ context }: { context: LoadContext }): Promise { + const { + outDir, + siteConfig: { themeConfig }, + } = context; + const { searchPagePath } = getDocSearchConfig(themeConfig as ThemeConfig); + if (!searchPagePath) { + throw new Error('no searchPagePath provided in themeConfig.docsearch or themeConfig.algolia'); + } + const fileContent = createOpenSearchFileContent({ context, searchPagePath }); + try { + await fs.writeFile(path.join(outDir, OPEN_SEARCH_FILENAME), fileContent); + } catch (err) { + const error = new Error('Generating OpenSearch file failed.'); + (error as Error & { cause?: unknown }).cause = err; + throw error; + } +} + +export function createOpenSearchHeadTags({ context }: { context: LoadContext }): HtmlTags { + const { + baseUrl, + siteConfig: { title }, + } = context; + return { + tagName: 'link', + attributes: { + rel: 'search', + type: 'application/opensearchdescription+xml', + title, + href: normalizeUrl([baseUrl, OPEN_SEARCH_FILENAME]), + }, + }; +} diff --git a/adapters/docusaurus-theme-search-algolia/src/templates/opensearch.ts b/adapters/docusaurus-theme-search-algolia/src/templates/opensearch.ts new file mode 100644 index 0000000000..0705a0032c --- /dev/null +++ b/adapters/docusaurus-theme-search-algolia/src/templates/opensearch.ts @@ -0,0 +1,22 @@ +/** + * Copyright (c) Facebook, Inc. And its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +export default ` + + + <%= it.title %> + Search <%= it.title %> + UTF-8 + <% if (it.faviconUrl) { _%> + <%= it.faviconUrl %> + <% } _%> + + + <%= it.siteUrl %> + +`; diff --git a/adapters/docusaurus-theme-search-algolia/src/theme-search-algolia.d.ts b/adapters/docusaurus-theme-search-algolia/src/theme-search-algolia.d.ts new file mode 100644 index 0000000000..76ce35bc45 --- /dev/null +++ b/adapters/docusaurus-theme-search-algolia/src/theme-search-algolia.d.ts @@ -0,0 +1,117 @@ +/** + * Copyright (c) Facebook, Inc. And its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +declare module '@docsearch/docusaurus-adapter' { + import type { DocSearchProps } from '@docsearch/react'; + import type { SidepanelProps } from '@docsearch/react/sidepanel'; + import type { FacetFilters } from 'algoliasearch/lite'; + import type { DeepPartial, Overwrite, Optional } from 'utility-types'; + + type AskAiSearchParameters = { + facetFilters?: FacetFilters; + filters?: string; + attributesToRetrieve?: string[]; + restrictSearchableAttributes?: string[]; + distinct?: boolean | number | string; + }; + + type AgentStudioSearchParameters = Record>; + + // The config after normalization (e.g. AskAI string -> object) + // This matches DocSearch v4.3+ AskAi configuration + export type AskAiConfig = { + indexName: string; + apiKey: string; + appId: string; + assistantId: string; + suggestedQuestions?: boolean; + useStagingEnv?: boolean; + sidePanel?: boolean | (SidepanelProps & { hideButton?: boolean }); + } & ( + | { + agentStudio: false; + searchParameters?: AskAiSearchParameters; + } + | { + agentStudio: true; + searchParameters?: AgentStudioSearchParameters; + } + | { + agentStudio?: never; + searchParameters?: AskAiSearchParameters; + } + ); + + // DocSearch props that Docusaurus exposes directly through props forwarding + type DocusaurusDocSearchProps = Pick< + DocSearchProps, + 'apiKey' | 'appId' | 'indexName' | 'initialQuery' | 'insights' | 'placeholder' | 'searchParameters' | 'translations' + > & { + // Docusaurus normalizes the AskAI config to an object + askAi?: AskAiConfig; + }; + + export type ThemeConfigAlgolia = DocusaurusDocSearchProps & { + indexName: string; + + // Docusaurus custom options, not coming from DocSearch + contextualSearch: boolean; + externalUrlRegex?: string; + searchPagePath: string | false | null; + replaceSearchResultPathname?: { + from: string; + to: string; + }; + }; + + type UserDocSearchConfig = Overwrite< + DeepPartial, + { + // Required fields: + appId: ThemeConfigAlgolia['appId']; + apiKey: ThemeConfigAlgolia['apiKey']; + indexName: ThemeConfigAlgolia['indexName']; + // askAi also accepts a shorter string form + askAi?: Optional | string; + } + >; + + export type ThemeConfig = { + // Preferred key. + docsearch?: ThemeConfigAlgolia; + // Backward-compatible alias. + algolia?: ThemeConfigAlgolia; + }; + + export type UserThemeConfig = { + // Preferred key. + docsearch?: UserDocSearchConfig; + // Backward-compatible alias. + algolia?: UserDocSearchConfig; + }; +} + +declare module '@theme/SearchPage' { + import type { ReactNode } from 'react'; + + export default function SearchPage(): ReactNode; +} + +declare module '@theme/SearchBar' { + import type { ReactNode } from 'react'; + + export default function SearchBar(): ReactNode; +} + +declare module '@theme/SearchTranslations' { + import type { DocSearchTranslations } from '@docsearch/react'; + + const translations: DocSearchTranslations & { + placeholder: string; + }; + export default translations; +} diff --git a/adapters/docusaurus-theme-search-algolia/src/theme/SearchBar/index.tsx b/adapters/docusaurus-theme-search-algolia/src/theme/SearchBar/index.tsx new file mode 100644 index 0000000000..6b952a66e6 --- /dev/null +++ b/adapters/docusaurus-theme-search-algolia/src/theme/SearchBar/index.tsx @@ -0,0 +1,412 @@ +/** + * Copyright (c) Facebook, Inc. And its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type { AutocompleteState } from '@algolia/autocomplete-core'; +import type { ThemeConfigAlgolia } from '@docsearch/docusaurus-adapter'; +import type { + InternalDocSearchHit, + DocSearchModal as DocSearchModalType, + DocSearchModalProps, + StoredDocSearchHit, + DocSearchTransformClient, + DocSearchHit, + DocSearchTranslations, +} from '@docsearch/react'; +import { DocSearchButton } from '@docsearch/react/button'; +import { SidepanelButton } from '@docsearch/react/sidepanel'; +import type { Sidepanel as SidepanelType } from '@docsearch/react/sidepanel'; +import { useDocSearchKeyboardEvents } from '@docsearch/react/useDocSearchKeyboardEvents'; +import Head from '@docusaurus/Head'; +import Link from '@docusaurus/Link'; +import { useHistory } from '@docusaurus/router'; +import { isRegexpStringMatch } from '@docusaurus/theme-common'; +import Translate from '@docusaurus/Translate'; +import useBaseUrl from '@docusaurus/useBaseUrl'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import translations from '@theme/SearchTranslations'; +import type { FacetFilters } from 'algoliasearch/lite'; +import React, { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; +import { createPortal } from 'react-dom'; + +import { + useAlgoliaContextualFacetFilters, + useAlgoliaThemeConfig, + useSearchResultUrlProcessor, + useAlgoliaAskAi, + useAlgoliaAskAiSidepanel, + mergeFacetFilters, +} from '../../client'; + +type DocSearchProps = Omit & { + contextualSearch?: string; + externalUrlRegex?: string; + searchPagePath: boolean | string; + askAi?: Exclude<(DocSearchModalProps & { askAi: unknown })['askAi'], string | undefined>; +}; + +type AskAiTogglePayload = { + query: string; + messageId?: string; + suggestedQuestionId?: string; +}; + +type OnAskAiToggle = (toggle: boolean, payload?: AskAiTogglePayload) => void; +type NavigatorNavigateParams = Parameters['navigate']>>[0]; + +interface AlgoliaSearchBarProps extends Omit { + indexName: string; + askAi?: ThemeConfigAlgolia['askAi']; + translations?: DocSearchTranslations; +} + +let DocSearchModal: typeof DocSearchModalType | null = null; +let DocSearchSidepanel: typeof SidepanelType | null = null; + +function importDocSearchModalIfNeeded(): Promise { + if (DocSearchModal) { + return Promise.resolve(); + } + + // eslint-disable-next-line import/dynamic-import-chunkname + return Promise.all([import('@docsearch/react/modal'), import('@docsearch/react/style'), import('./styles.css')]).then( + ([{ DocSearchModal: Modal }]) => { + DocSearchModal = Modal; + }, + ); +} + +async function importDocSearchSidepanelIfNeeded(): Promise { + await importDocSearchModalIfNeeded(); + if (DocSearchSidepanel) { + return Promise.resolve(); + } + + // eslint-disable-next-line import/dynamic-import-chunkname + return Promise.all([import('@docsearch/react/sidepanel'), import('@docsearch/react/style/sidepanel')]).then( + ([{ Sidepanel }]) => { + DocSearchSidepanel = Sidepanel; + }, + ); +} + +function useNavigator({ + externalUrlRegex, +}: Pick): DocSearchModalProps['navigator'] { + const history = useHistory(); + const [navigator] = useState(() => { + return { + navigate(params: NavigatorNavigateParams) { + // Algolia results could contain URL's from other domains which cannot + // be served through history and should navigate with window.location + if (isRegexpStringMatch(externalUrlRegex, params.itemUrl)) { + window.location.href = params.itemUrl; + } else { + history.push(params.itemUrl); + } + }, + }; + }); + return navigator; +} + +function useTransformSearchClient(): DocSearchModalProps['transformSearchClient'] { + const { + siteMetadata: { docusaurusVersion }, + } = useDocusaurusContext(); + return useCallback( + (searchClient: DocSearchTransformClient) => { + searchClient.addAlgoliaAgent('docusaurus', docusaurusVersion); + return searchClient; + }, + [docusaurusVersion], + ); +} + +function useTransformItems(props: Pick) { + const processSearchResultUrl = useSearchResultUrlProcessor(); + const [transformItems] = useState(() => { + return (items: DocSearchHit[]) => + props.transformItems + ? // Custom transformItems + props.transformItems(items) + : // Default transformItems + items.map((item) => ({ + ...item, + url: processSearchResultUrl(item.url), + })); + }); + return transformItems; +} + +function useResultsFooterComponent({ + closeModal, + searchPagePath, +}: { + closeModal: () => void; + searchPagePath?: string; +}): DocSearchProps['resultsFooterComponent'] { + return useMemo( + () => + searchPagePath + ? ({ state }) => + : undefined, + [closeModal, searchPagePath], + ); +} + +function Hit({ hit, children }: { hit: InternalDocSearchHit | StoredDocSearchHit; children: ReactNode }) { + return {children}; +} + +type ResultsFooterProps = { + state: AutocompleteState; + onClose: () => void; + searchPagePath: string; +}; + +function ResultsFooter({ state, onClose, searchPagePath }: ResultsFooterProps) { + const searchPageLink = useBaseUrl(searchPagePath); + const nbHits = (state.context as { nbHits?: number }).nbHits ?? 0; + const searchLink = state.query + ? `${searchPageLink}${searchPageLink.includes('?') ? '&' : '?'}q=${encodeURIComponent(state.query)}` + : searchPageLink; + + return ( + + + {'See all {count} results'} + + + ); +} + +function useSearchParameters({ contextualSearch, ...props }: DocSearchProps): DocSearchProps['searchParameters'] { + const contextualSearchFacetFilters = useAlgoliaContextualFacetFilters(); + + const configFacetFilters: FacetFilters = props.searchParameters?.facetFilters ?? []; + + const facetFilters: FacetFilters = contextualSearch + ? // Merge contextual search filters with config filters + mergeFacetFilters(contextualSearchFacetFilters, configFacetFilters) + : // ... or use config facetFilters + configFacetFilters; + + // We let users override default searchParameters if they want to + return { + ...props.searchParameters, + facetFilters, + }; +} + +function DocSearch({ externalUrlRegex, ...props }: AlgoliaSearchBarProps) { + const navigator = useNavigator({ externalUrlRegex }); + const searchParameters = useSearchParameters({ ...props } as DocSearchProps); + const transformItems = useTransformItems(props); + const transformSearchClient = useTransformSearchClient(); + + const searchContainer = useRef(null); + const searchButtonRef = useRef(null); + const [isOpen, setIsOpen] = useState(false); + const [initialQuery, setInitialQuery] = useState(undefined); + + const { isAskAiActive, currentPlaceholder, onAskAiToggle, extraAskAiProps, askAi } = useAlgoliaAskAi(props); + const { + sidePanelEnabled, + showSidepanelButton, + sidePanelOptions, + sidePanelAgentStudio, + sidepanelPortalContainer, + isSidepanelOpen, + sidepanelInitialMessage, + openSidepanel, + closeSidepanel, + toggleSidepanel, + handleSidepanelOpen, + loadSidepanel, + } = useAlgoliaAskAiSidepanel({ + askAiConfig: askAi, + importSidepanel: importDocSearchSidepanelIfNeeded, + }); + + const prepareSearchContainer = useCallback(() => { + if (!searchContainer.current) { + const divElement = document.createElement('div'); + searchContainer.current = divElement; + document.body.insertBefore(divElement, document.body.firstChild); + } + }, []); + + const openModal = useCallback(() => { + prepareSearchContainer(); + importDocSearchModalIfNeeded().then(() => setIsOpen(true)); + }, [prepareSearchContainer]); + + const closeModal = useCallback(() => { + setIsOpen(false); + searchButtonRef.current?.focus(); + setInitialQuery(undefined); + onAskAiToggle(false); + }, [onAskAiToggle]); + + const handleAskAiToggle = useCallback( + (active, payload) => { + if (active && sidePanelEnabled) { + closeModal(); + openSidepanel(payload); + return; + } + onAskAiToggle(active); + }, + [closeModal, onAskAiToggle, openSidepanel, sidePanelEnabled], + ); + + // cleanup search container + useEffect(() => { + return () => { + if (searchContainer.current) { + searchContainer.current.remove(); + searchContainer.current = null; + } + }; + }, []); + + const handleInput = useCallback( + (event: KeyboardEvent) => { + if (event.key === 'f' && (event.metaKey || event.ctrlKey)) { + // ignore browser's ctrl+f + return; + } + // prevents duplicate key insertion in the modal input + event.preventDefault(); + setInitialQuery(event.key); + openModal(); + }, + [openModal], + ); + + const resultsFooterSearchPagePath = typeof props.searchPagePath === 'string' ? props.searchPagePath : undefined; + const resultsFooterComponent = useResultsFooterComponent({ + closeModal, + searchPagePath: resultsFooterSearchPagePath, + }); + + useDocSearchKeyboardEvents({ + isOpen, + onOpen: openModal, + onClose: closeModal, + onInput: handleInput, + searchButtonRef, + isAskAiActive: isAskAiActive ?? false, + onAskAiToggle: onAskAiToggle ?? (() => {}), + }); + + return ( + <> + + {/* This hints the browser that the website will load data from Algolia, + and allows it to preconnect to the DocSearch cluster. It makes the first + query faster, especially on mobile. */} + + + +
+ + {showSidepanelButton && ( + + )} +
+ + {isOpen && + DocSearchModal && + searchContainer.current && + createPortal( + { + if (!sidePanelEnabled) { + return false; + } + closeModal(); + openSidepanel(payload); + return true; + }} + onClose={closeModal} + {...(resultsFooterSearchPagePath && { + resultsFooterComponent, + })} + placeholder={currentPlaceholder} + {...(props as DocSearchProps)} + translations={props.translations?.modal ?? translations.modal} + searchParameters={searchParameters} + {...extraAskAiProps} + isHybridModeSupported={sidePanelEnabled} + onAskAiToggle={handleAskAiToggle as DocSearchModalProps['onAskAiToggle']} + />, + searchContainer.current, + )} + + {sidePanelEnabled && + DocSearchSidepanel && + askAi && + sidepanelPortalContainer && + createPortal( + , + sidepanelPortalContainer, + )} + + ); +} + +export default function SearchBar(props: Partial): ReactNode { + const themeConfig = useAlgoliaThemeConfig(); + + const docSearchProps: AlgoliaSearchBarProps = { + ...(themeConfig as unknown as AlgoliaSearchBarProps), + // Let props override theme config + // See https://github.com/facebook/docusaurus/pull/11581 + ...props, + }; + + return ; +} diff --git a/adapters/docusaurus-theme-search-algolia/src/theme/SearchBar/styles.css b/adapters/docusaurus-theme-search-algolia/src/theme/SearchBar/styles.css new file mode 100644 index 0000000000..6eb23aa177 --- /dev/null +++ b/adapters/docusaurus-theme-search-algolia/src/theme/SearchBar/styles.css @@ -0,0 +1,119 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +:root { + --docsearch-primary-color: var(--ifm-color-primary); + --docsearch-text-color: var(--ifm-font-color-base); + --docsearch-border-radius: var(--ifm-global-radius); + --docsearch-highlight-color: var(--ifm-color-primary); + --docsearch-soft-primary-color: color-mix( + in srgb, + var(--ifm-color-primary) 12%, + transparent + ); + --docsearch-focus-color: var(--ifm-color-primary-dark); + --docsearch-subtle-color: var(--ifm-color-emphasis-200); + --docsearch-secondary-text-color: var(--ifm-color-emphasis-600); + --docsearch-icon-color: var(--ifm-color-emphasis-600); + --docsearch-muted-color: var(--ifm-color-emphasis-600); + --docsearch-container-background: rgb(0 0 0 / 60%); + --docsearch-background-color: var(--ifm-color-emphasis-200); + /* Modal */ + --docsearch-modal-background: var(--ifm-color-emphasis-100); + --docsearch-modal-shadow: var(--ifm-global-shadow-md); + /* Button */ + --docsearch-search-button-background: var(--ifm-background-color); + --docsearch-search-button-text-color: var(--ifm-color-emphasis-600); + /* Search box */ + --docsearch-searchbox-background: var(--ifm-background-color); + --docsearch-searchbox-focus-background: var(--ifm-background-surface-color); + /* Hit */ + --docsearch-hit-color: var(--ifm-font-color-base); + --docsearch-hit-highlight-color: color-mix( + in srgb, + var(--ifm-color-primary) 12%, + transparent + ); + --docsearch-hit-active-color: var(--ifm-color-white); + --docsearch-hit-background: var(--ifm-background-surface-color); + --docsearch-key-background: var(--ifm-color-emphasis-200); + --docsearch-key-color: var(--ifm-color-emphasis-600); + --docsearch-key-pressed-shadow: inset 0 2px 4px rgb(0 0 0 / 12%); + /* Footer */ + --docsearch-footer-background: var(--ifm-background-surface-color); + --docsearch-footer-shadow: 0 -1px 0 0 var(--ifm-color-emphasis-200); + --docsearch-key-gradient: linear-gradient( + -26.5deg, + var(--ifm-color-emphasis-300) 0%, + var(--ifm-color-emphasis-200) 100% + ); + + /* Sidepanel */ + --docsearch-sidepanel-background: var(--ifm-color-emphasis-100); + --docsearch-sidepanel-background-dark: var(--docsearch-searchbox-background); + --docsearch-sidepanel-white: var(--ifm-background-color); + --docsearch-sidepanel-primary: var(--docsearch-primary-color); + --docsearch-sidepanel-primary-dark: var(--ifm-color-primary-dark); + --docsearch-sidepanel-primary-disabled: var(--ifm-color-primary-lightest); + --docsearch-sidepanel-accent: var(--docsearch-highlight-color); + --docsearch-sidepanel-accent-muted: var(--ifm-color-primary-lightest); + --docsearch-sidepanel-border: var(--ifm-color-emphasis-200); + --docsearch-sidepanel-text-base: var(--docsearch-text-color); + --docsearch-sidepanel-text-muted: var(--docsearch-secondary-text-color); + --docsearch-sidepanel-scrollbar-color: var(--docsearch-muted-color); + --docsearch-sidepanel-scrollbar-bg: var(--docsearch-modal-background); + --docsearch-sidepanel-hit-background: var(--docsearch-hit-background); + --docsearch-sidepanel-hit-color: var(--docsearch-hit-color); + --docsearch-sidepanel-hit-highlight-color: var( + --docsearch-hit-highlight-color + ); +} + +.DocSearch-SearchBar { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.DocSearch-Button { + border-radius: 0.5rem; +} + +.DocSearch-Sidepanel-Header { + height: 3.8rem; +} + +.DocSearch-Sidepanel-Title { + margin: 0; +} + +.DocSearch-Sidepanel-Header-TitleIcon { + display: none; +} + +.DocSearch-SidepanelButton.inline { + background-color: var(--ifm-background-color); +} + +/* Match inline panel borders with app borders */ +.DocSearch-Sidepanel-Container.inline { + box-shadow: 0 0 0 1px var(--docsearch-sidepanel-border); +} + +/* is aesthetically better to have no border radius */ +@media screen and (min-width: 769px) { + .DocSearch-Sidepanel-Container.inline.side-right { + border-radius: 0 !important; + } + .DocSearch-Sidepanel-Container.inline.side-left { + border-radius: 0 !important; + } +} + +.DocSearch-Container { + z-index: calc(var(--ifm-z-index-fixed) + 1); +} diff --git a/adapters/docusaurus-theme-search-algolia/src/theme/SearchPage/index.tsx b/adapters/docusaurus-theme-search-algolia/src/theme/SearchPage/index.tsx new file mode 100644 index 0000000000..325e9cc43c --- /dev/null +++ b/adapters/docusaurus-theme-search-algolia/src/theme/SearchPage/index.tsx @@ -0,0 +1,525 @@ +/** + * Copyright (c) Facebook, Inc. And its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/* eslint-disable jsx-a11y/no-autofocus */ + +import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; +import Head from '@docusaurus/Head'; +import Link from '@docusaurus/Link'; +import { useAllDocsData } from '@docusaurus/plugin-content-docs/client'; +import { HtmlClassNameProvider, useEvent, usePluralForm, useSearchQueryString } from '@docusaurus/theme-common'; +import Translate, { translate } from '@docusaurus/Translate'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import Heading from '@theme/Heading'; +import Layout from '@theme/Layout'; +import { liteClient } from 'algoliasearch/lite'; +import algoliaSearchHelper from 'algoliasearch-helper'; +import clsx from 'clsx'; +import React, { type ReactNode, useEffect, useReducer, useRef, useState } from 'react'; + +import { useAlgoliaThemeConfig, useSearchResultUrlProcessor } from '../../client'; + +import styles from './styles.module.css'; + +// Very simple pluralization: probably good enough for now +function useDocumentsFoundPlural() { + const { selectMessage } = usePluralForm(); + return (count: number) => + selectMessage( + count, + translate( + { + id: 'theme.SearchPage.documentsFound.plurals', + description: + 'Pluralized label for "{count} documents found". Use as much plural forms (separated by "|") as your language support (see https://www.unicode.org/cldr/cldr-aux/charts/34/supplemental/language_plural_rules.html)', + message: 'One document found|{count} documents found', + }, + { count }, + ), + ); +} + +function useDocsSearchVersionsHelpers() { + const allDocsData = useAllDocsData(); + + // State of the version select menus / algolia facet filters + // docsPluginId -> versionName map + const [searchVersions, setSearchVersions] = useState<{ + [pluginId: string]: string; + }>(() => + Object.entries(allDocsData).reduce( + (acc, [pluginId, pluginData]) => ({ + ...acc, + [pluginId]: pluginData.versions[0]!.name, + }), + {}, + ), + ); + + // Set the value of a single select menu + const setSearchVersion = (pluginId: string, searchVersion: string) => + setSearchVersions((s) => ({ ...s, [pluginId]: searchVersion })); + + const versioningEnabled = Object.values(allDocsData).some((docsData) => docsData.versions.length > 1); + + return { + allDocsData, + versioningEnabled, + searchVersions, + setSearchVersion, + }; +} + +// We want to display one select per versioned docs plugin instance +function SearchVersionSelectList({ + docsSearchVersionsHelpers, +}: { + docsSearchVersionsHelpers: ReturnType; +}) { + const versionedPluginEntries = Object.entries(docsSearchVersionsHelpers.allDocsData) + // Do not show a version select for unversioned docs plugin instances + .filter(([, docsData]) => docsData.versions.length > 1); + + return ( +
+ {versionedPluginEntries.map(([pluginId, docsData]) => { + const labelPrefix = versionedPluginEntries.length > 1 ? `${pluginId}: ` : ''; + return ( + + ); + })} +
+ ); +} + +function AlgoliaLogo(): ReactNode { + return ( + + + + + + + + + + + + + + + ); +} + +type ResultDispatcherState = { + items: Array<{ + title: string; + url: string; + summary: string; + breadcrumbs: string[]; + }>; + query: string | null; + totalResults: number | null; + totalPages: number | null; + lastPage: number | null; + hasMore: boolean | null; + loading: boolean | null; +}; + +type ResultDispatcher = + | { type: 'advance'; value?: undefined } + | { type: 'loading'; value?: undefined } + | { type: 'reset'; value?: undefined } + | { type: 'update'; value: ResultDispatcherState }; + +function getSearchPageTitle(searchQuery: string | undefined): string { + return searchQuery + ? translate( + { + id: 'theme.SearchPage.existingResultsTitle', + message: 'Search results for "{query}"', + description: 'The search page title for non-empty query', + }, + { + query: searchQuery, + }, + ) + : translate({ + id: 'theme.SearchPage.emptyResultsTitle', + message: 'Search the documentation', + description: 'The search page title for empty query', + }); +} + +function SearchPageContent(): ReactNode { + const { + i18n: { currentLocale }, + } = useDocusaurusContext(); + const { appId, apiKey, indexName, contextualSearch } = useAlgoliaThemeConfig(); + const processSearchResultUrl = useSearchResultUrlProcessor(); + const documentsFoundPlural = useDocumentsFoundPlural(); + + const docsSearchVersionsHelpers = useDocsSearchVersionsHelpers(); + const [searchQuery, setSearchQuery] = useSearchQueryString(); + const pageTitle = getSearchPageTitle(searchQuery); + + const initialSearchResultState: ResultDispatcherState = { + items: [], + query: null, + totalResults: null, + totalPages: null, + lastPage: null, + hasMore: null, + loading: null, + }; + const [searchResultState, searchResultStateDispatcher] = useReducer( + (prevState: ResultDispatcherState, data: ResultDispatcher) => { + switch (data.type) { + case 'reset': { + return initialSearchResultState; + } + case 'loading': { + return { ...prevState, loading: true }; + } + case 'update': { + if (searchQuery !== data.value.query) { + return prevState; + } + + return { + ...data.value, + items: data.value.lastPage === 0 ? data.value.items : prevState.items.concat(data.value.items), + }; + } + case 'advance': { + const hasMore = prevState.totalPages! > prevState.lastPage! + 1; + + return { + ...prevState, + lastPage: hasMore ? prevState.lastPage! + 1 : prevState.lastPage, + hasMore, + }; + } + default: + return prevState; + } + }, + initialSearchResultState, + ); + + // respect settings from the theme config for facets + const disjunctiveFacets = contextualSearch ? ['language', 'docusaurus_tag'] : []; + + const algoliaClient = liteClient(appId, apiKey); + const algoliaHelper = algoliaSearchHelper(algoliaClient, indexName, { + hitsPerPage: 15, + advancedSyntax: true, + disjunctiveFacets, + }); + + algoliaHelper.on('result', ({ results: { query, hits, page, nbHits, nbPages } }) => { + if (query === '' || !Array.isArray(hits)) { + searchResultStateDispatcher({ type: 'reset' }); + return; + } + + const sanitizeValue = (value: string) => + value.replace(/algolia-docsearch-suggestion--highlight/g, 'search-result-match'); + + const items = hits.map( + ({ + url, + _highlightResult: { hierarchy }, + _snippetResult: snippet = {}, + }: { + url: string; + _highlightResult: { hierarchy: { [key: string]: { value: string } } }; + _snippetResult: { content?: { value: string } }; + }) => { + const titles = Object.keys(hierarchy).map((key) => sanitizeValue(hierarchy[key]!.value)); + return { + title: titles.pop()!, + url: processSearchResultUrl(url), + summary: snippet.content ? `${sanitizeValue(snippet.content.value)}...` : '', + breadcrumbs: titles, + }; + }, + ); + + searchResultStateDispatcher({ + type: 'update', + value: { + items, + query, + totalResults: nbHits, + totalPages: nbPages, + lastPage: page, + hasMore: nbPages > page + 1, + loading: false, + }, + }); + }); + + const [loaderRef, setLoaderRef] = useState(null); + const prevY = useRef(0); + const observer = useRef( + ExecutionEnvironment.canUseIntersectionObserver && + new IntersectionObserver( + (entries) => { + const { + isIntersecting, + boundingClientRect: { y: currentY }, + } = entries[0]!; + + if (isIntersecting && prevY.current > currentY) { + searchResultStateDispatcher({ type: 'advance' }); + } + + prevY.current = currentY; + }, + { threshold: 1 }, + ), + ); + + const makeSearch = useEvent((page: number = 0) => { + if (contextualSearch) { + algoliaHelper.addDisjunctiveFacetRefinement('docusaurus_tag', 'default'); + algoliaHelper.addDisjunctiveFacetRefinement('language', currentLocale); + + Object.entries(docsSearchVersionsHelpers.searchVersions).forEach(([pluginId, searchVersion]) => { + algoliaHelper.addDisjunctiveFacetRefinement('docusaurus_tag', `docs-${pluginId}-${searchVersion}`); + }); + } + + algoliaHelper.setQuery(searchQuery).setPage(page).search(); + }); + + useEffect(() => { + if (!loaderRef) { + return undefined; + } + const currentObserver = observer.current; + if (currentObserver) { + currentObserver.observe(loaderRef); + return () => currentObserver.unobserve(loaderRef); + } + return () => true; + }, [loaderRef]); + + useEffect(() => { + searchResultStateDispatcher({ type: 'reset' }); + + if (!searchQuery) { + return undefined; + } + + searchResultStateDispatcher({ type: 'loading' }); + + const searchTimeoutId = setTimeout(() => { + makeSearch(); + }, 300); + + return () => { + clearTimeout(searchTimeoutId); + }; + }, [searchQuery, docsSearchVersionsHelpers.searchVersions, makeSearch]); + + useEffect(() => { + if (!searchResultState.lastPage || searchResultState.lastPage === 0) { + return; + } + + makeSearch(searchResultState.lastPage); + }, [makeSearch, searchResultState.lastPage]); + + return ( + + + {pageTitle} + {/* + We should not index search pages + See https://github.com/facebook/docusaurus/pull/3233 + */} + + + +
+ {pageTitle} + +
e.preventDefault()}> +
+ setSearchQuery(e.target.value)} + /> +
+ + {contextualSearch && docsSearchVersionsHelpers.versioningEnabled && ( + + )} + + +
+
+ {searchResultState.totalResults !== null && documentsFoundPlural(searchResultState.totalResults)} +
+ +
+ + {translate({ + id: 'theme.SearchPage.algoliaLabel', + message: 'Powered by', + description: 'The text explain that the search powered by Algolia', + })} + + + + +
+
+ + {searchResultState.items.length > 0 ? ( +
+ {searchResultState.items.map(({ title, url, summary, breadcrumbs }, i) => ( + // eslint-disable-next-line react/no-array-index-key +
+ + + + + {breadcrumbs.length > 0 && ( + + )} + + {summary && ( +

+ )} +

+ ))} +
+ ) : ( + [ + searchQuery && !searchResultState.loading && ( +

+ + No results were found + +

+ ), + Boolean(searchResultState.loading) &&
, + ] + )} + + {searchResultState.hasMore && ( +
+ + Fetching new results... + +
+ )} +
+ + ); +} + +export default function SearchPage(): ReactNode { + return ( + + + + ); +} diff --git a/adapters/docusaurus-theme-search-algolia/src/theme/SearchPage/styles.module.css b/adapters/docusaurus-theme-search-algolia/src/theme/SearchPage/styles.module.css new file mode 100644 index 0000000000..29c4389f55 --- /dev/null +++ b/adapters/docusaurus-theme-search-algolia/src/theme/SearchPage/styles.module.css @@ -0,0 +1,127 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +.searchQueryInput, +.searchVersionInput { + border-radius: var(--ifm-global-radius); + border: 2px solid var(--ifm-toc-border-color); + font: var(--ifm-font-size-base) var(--ifm-font-family-base); + padding: 0.8rem; + width: 100%; + background: var(--docsearch-searchbox-focus-background); + color: var(--docsearch-text-color); + margin-bottom: 0.5rem; + transition: border var(--ifm-transition-fast) ease; +} + +.searchQueryInput:focus, +.searchVersionInput:focus { + border-color: var(--docsearch-primary-color); + outline: none; +} + +.searchQueryInput::placeholder { + color: var(--docsearch-muted-color); +} + +.searchResultsColumn { + font-size: 0.9rem; + font-weight: bold; +} + +.searchLogoColumn { + display: flex; + align-items: center; + gap: 0.5rem; + justify-content: flex-end; +} + +.searchLogoColumn a { + display: flex; +} + +.searchLogoColumn span { + color: var(--docsearch-muted-color); + font-weight: normal; +} + +.searchResultItem { + padding: 1rem 0; + border-bottom: 1px solid var(--ifm-toc-border-color); +} + +.searchResultItemHeading { + font-weight: 400; + margin-bottom: 0; +} + +.searchResultItemPath { + font-size: 0.8rem; + color: var(--ifm-color-content-secondary); + --ifm-breadcrumb-separator-size-multiplier: 1; +} + +.searchResultItemSummary { + margin: 0.5rem 0 0; + font-style: italic; +} + +@media only screen and (max-width: 996px) { + .searchQueryColumn { + max-width: 60% !important; + } + + .searchVersionColumn { + max-width: 40% !important; + } + + .searchResultsColumn { + max-width: 60% !important; + } + + .searchLogoColumn { + max-width: 40% !important; + padding-left: 0 !important; + } +} + +@media screen and (max-width: 576px) { + .searchQueryColumn { + max-width: 100% !important; + } + + .searchVersionColumn { + max-width: 100% !important; + padding-left: var(--ifm-spacing-horizontal) !important; + } +} + +.loadingSpinner { + width: 3rem; + height: 3rem; + border: 0.4em solid #eee; + border-top-color: var(--ifm-color-primary); + border-radius: 50%; + animation: loading-spin 1s linear infinite; + margin: 0 auto; +} + +@keyframes loading-spin { + 100% { + transform: rotate(360deg); + } +} + +.loader { + margin-top: 2rem; +} + +:global(.search-result-match) { + color: var(--docsearch-hit-color); + background: rgb(255 215 142 / 25%); + padding: 0.09em 0; +} diff --git a/adapters/docusaurus-theme-search-algolia/src/theme/SearchTranslations/index.ts b/adapters/docusaurus-theme-search-algolia/src/theme/SearchTranslations/index.ts new file mode 100644 index 0000000000..13d5fe5e06 --- /dev/null +++ b/adapters/docusaurus-theme-search-algolia/src/theme/SearchTranslations/index.ts @@ -0,0 +1,368 @@ +/** + * Copyright (c) Facebook, Inc. And its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type { DocSearchTranslations } from '@docsearch/react'; +import { translate } from '@docusaurus/Translate'; + +const translations: DocSearchTranslations & { + placeholder: string; + modal: { + searchBox: { + placeholderText: string; + placeholderTextAskAi: string; + placeholderTextAskAiStreaming: string; + enterKeyHintAskAi: string; + searchInputLabel: string; + backToKeywordSearchButtonText: string; + backToKeywordSearchButtonAriaLabel: string; + enterKeyHint: string; + clearButtonTitle: string; + clearButtonAriaLabel: string; + closeButtonText: string; + resetButtonTitle: string; + resetButtonAriaLabel: string; + cancelButtonText: string; + cancelButtonAriaLabel: string; + closeButtonAriaLabel: string; + }; + startScreen: { + recentConversationsTitle: string; + removeRecentConversationButtonTitle: string; + }; + resultsScreen: { + askAiPlaceholder: string; + }; + askAiScreen: { + disclaimerText: string; + relatedSourcesText: string; + thinkingText: string; + copyButtonText: string; + copyButtonCopiedText: string; + copyButtonTitle: string; + likeButtonTitle: string; + dislikeButtonTitle: string; + thanksForFeedbackText: string; + preToolCallText: string; + duringToolCallText: string; + afterToolCallText: string; + }; + footer: { + submitQuestionText: string; + poweredByText: string; + backToSearchText: string; + searchByText: string; + }; + }; +} = { + button: { + buttonText: translate({ + id: 'theme.SearchBar.label', + message: 'Search', + description: 'The ARIA label and placeholder for search button', + }), + buttonAriaLabel: translate({ + id: 'theme.SearchBar.label', + message: 'Search', + description: 'The ARIA label and placeholder for search button', + }), + }, + modal: { + searchBox: { + resetButtonTitle: translate({ + id: 'theme.SearchModal.searchBox.resetButtonTitle', + message: 'Clear the query', + description: 'The label and ARIA label for search box reset button', + }), + resetButtonAriaLabel: translate({ + id: 'theme.SearchModal.searchBox.resetButtonTitle', + message: 'Clear the query', + description: 'The label and ARIA label for search box reset button', + }), + cancelButtonText: translate({ + id: 'theme.SearchModal.searchBox.cancelButtonText', + message: 'Cancel', + description: 'The label and ARIA label for search box cancel button', + }), + cancelButtonAriaLabel: translate({ + id: 'theme.SearchModal.searchBox.cancelButtonText', + message: 'Cancel', + description: 'The label and ARIA label for search box cancel button', + }), + + // v4 + clearButtonTitle: translate({ + id: 'theme.SearchModal.searchBox.resetButtonTitle', + message: 'Clear the query', + description: 'The label and ARIA label for search box reset button', + }), + clearButtonAriaLabel: translate({ + id: 'theme.SearchModal.searchBox.resetButtonTitle', + message: 'Clear the query', + description: 'The label and ARIA label for search box reset button', + }), + closeButtonText: translate({ + id: 'theme.SearchModal.searchBox.cancelButtonText', + message: 'Cancel', + description: 'The label and ARIA label for search box cancel button', + }), + closeButtonAriaLabel: translate({ + id: 'theme.SearchModal.searchBox.cancelButtonText', + message: 'Cancel', + description: 'The label and ARIA label for search box cancel button', + }), + placeholderText: translate({ + id: 'theme.SearchModal.searchBox.placeholderText', + message: 'Search docs', + description: 'The placeholder text for the main search input field', + }), + placeholderTextAskAi: translate({ + id: 'theme.SearchModal.searchBox.placeholderTextAskAi', + message: 'Ask another question...', + description: 'The placeholder text when in AI question mode', + }), + placeholderTextAskAiStreaming: translate({ + id: 'theme.SearchModal.searchBox.placeholderTextAskAiStreaming', + message: 'Answering...', + description: 'The placeholder text for search box when AI is streaming an answer', + }), + enterKeyHint: translate({ + id: 'theme.SearchModal.searchBox.enterKeyHint', + message: 'search', + description: 'The hint for the search box enter key text', + }), + enterKeyHintAskAi: translate({ + id: 'theme.SearchModal.searchBox.enterKeyHintAskAi', + message: 'enter', + description: 'The hint for the Ask AI search box enter key text', + }), + searchInputLabel: translate({ + id: 'theme.SearchModal.searchBox.searchInputLabel', + message: 'Search', + description: 'The ARIA label for search input', + }), + backToKeywordSearchButtonText: translate({ + id: 'theme.SearchModal.searchBox.backToKeywordSearchButtonText', + message: 'Back to keyword search', + description: 'The text for back to keyword search button', + }), + backToKeywordSearchButtonAriaLabel: translate({ + id: 'theme.SearchModal.searchBox.backToKeywordSearchButtonAriaLabel', + message: 'Back to keyword search', + description: 'The ARIA label for back to keyword search button', + }), + }, + startScreen: { + recentSearchesTitle: translate({ + id: 'theme.SearchModal.startScreen.recentSearchesTitle', + message: 'Recent', + description: 'The title for recent searches', + }), + noRecentSearchesText: translate({ + id: 'theme.SearchModal.startScreen.noRecentSearchesText', + message: 'No recent searches', + description: 'The text when there are no recent searches', + }), + saveRecentSearchButtonTitle: translate({ + id: 'theme.SearchModal.startScreen.saveRecentSearchButtonTitle', + message: 'Save this search', + description: 'The title for save recent search button', + }), + removeRecentSearchButtonTitle: translate({ + id: 'theme.SearchModal.startScreen.removeRecentSearchButtonTitle', + message: 'Remove this search from history', + description: 'The title for remove recent search button', + }), + favoriteSearchesTitle: translate({ + id: 'theme.SearchModal.startScreen.favoriteSearchesTitle', + message: 'Favorite', + description: 'The title for favorite searches', + }), + removeFavoriteSearchButtonTitle: translate({ + id: 'theme.SearchModal.startScreen.removeFavoriteSearchButtonTitle', + message: 'Remove this search from favorites', + description: 'The title for remove favorite search button', + }), + recentConversationsTitle: translate({ + id: 'theme.SearchModal.startScreen.recentConversationsTitle', + message: 'Recent conversations', + description: 'The title for recent conversations', + }), + removeRecentConversationButtonTitle: translate({ + id: 'theme.SearchModal.startScreen.removeRecentConversationButtonTitle', + message: 'Remove this conversation from history', + description: 'The title for remove recent conversation button', + }), + }, + errorScreen: { + titleText: translate({ + id: 'theme.SearchModal.errorScreen.titleText', + message: 'Unable to fetch results', + description: 'The title for error screen', + }), + helpText: translate({ + id: 'theme.SearchModal.errorScreen.helpText', + message: 'You might want to check your network connection.', + description: 'The help text for error screen', + }), + }, + resultsScreen: { + askAiPlaceholder: translate({ + id: 'theme.SearchModal.resultsScreen.askAiPlaceholder', + message: 'Ask AI: ', + description: 'The placeholder text for Ask AI input', + }), + }, + askAiScreen: { + disclaimerText: translate({ + id: 'theme.SearchModal.askAiScreen.disclaimerText', + message: 'Answers are generated with AI which can make mistakes. Verify responses.', + description: 'The disclaimer text for AI answers', + }), + relatedSourcesText: translate({ + id: 'theme.SearchModal.askAiScreen.relatedSourcesText', + message: 'Related sources', + description: 'The text for related sources', + }), + thinkingText: translate({ + id: 'theme.SearchModal.askAiScreen.thinkingText', + message: 'Thinking...', + description: 'The text when AI is thinking', + }), + copyButtonText: translate({ + id: 'theme.SearchModal.askAiScreen.copyButtonText', + message: 'Copy', + description: 'The text for copy button', + }), + copyButtonCopiedText: translate({ + id: 'theme.SearchModal.askAiScreen.copyButtonCopiedText', + message: 'Copied!', + description: 'The text for copy button when copied', + }), + copyButtonTitle: translate({ + id: 'theme.SearchModal.askAiScreen.copyButtonTitle', + message: 'Copy', + description: 'The title for copy button', + }), + likeButtonTitle: translate({ + id: 'theme.SearchModal.askAiScreen.likeButtonTitle', + message: 'Like', + description: 'The title for like button', + }), + dislikeButtonTitle: translate({ + id: 'theme.SearchModal.askAiScreen.dislikeButtonTitle', + message: 'Dislike', + description: 'The title for dislike button', + }), + thanksForFeedbackText: translate({ + id: 'theme.SearchModal.askAiScreen.thanksForFeedbackText', + message: 'Thanks for your feedback!', + description: 'The text for thanks for feedback', + }), + preToolCallText: translate({ + id: 'theme.SearchModal.askAiScreen.preToolCallText', + message: 'Searching...', + description: 'The text before tool call', + }), + duringToolCallText: translate({ + id: 'theme.SearchModal.askAiScreen.duringToolCallText', + message: 'Searching for ', + description: 'The text during tool call', + }), + afterToolCallText: translate({ + id: 'theme.SearchModal.askAiScreen.afterToolCallText', + message: 'Searched for', + description: 'The text after tool call', + }), + }, + footer: { + selectText: translate({ + id: 'theme.SearchModal.footer.selectText', + message: 'Select', + description: 'The select text for footer', + }), + submitQuestionText: translate({ + id: 'theme.SearchModal.footer.submitQuestionText', + message: 'Submit question', + description: 'The submit question text for footer', + }), + selectKeyAriaLabel: translate({ + id: 'theme.SearchModal.footer.selectKeyAriaLabel', + message: 'Enter key', + description: 'The ARIA label for select key in footer', + }), + navigateText: translate({ + id: 'theme.SearchModal.footer.navigateText', + message: 'Navigate', + description: 'The navigate text for footer', + }), + navigateUpKeyAriaLabel: translate({ + id: 'theme.SearchModal.footer.navigateUpKeyAriaLabel', + message: 'Arrow up', + description: 'The ARIA label for navigate up key in footer', + }), + navigateDownKeyAriaLabel: translate({ + id: 'theme.SearchModal.footer.navigateDownKeyAriaLabel', + message: 'Arrow down', + description: 'The ARIA label for navigate down key in footer', + }), + closeText: translate({ + id: 'theme.SearchModal.footer.closeText', + message: 'Close', + description: 'The close text for footer', + }), + closeKeyAriaLabel: translate({ + id: 'theme.SearchModal.footer.closeKeyAriaLabel', + message: 'Escape key', + description: 'The ARIA label for close key in footer', + }), + poweredByText: translate({ + id: 'theme.SearchModal.footer.searchByText', + message: 'Powered by', + description: "The 'Powered by' text for footer", + }), + searchByText: translate({ + id: 'theme.SearchModal.footer.searchByText', + message: 'Powered by', + description: "The 'Powered by' text for footer", + }), + backToSearchText: translate({ + id: 'theme.SearchModal.footer.backToSearchText', + message: 'Back to search', + description: 'The back to search text for footer', + }), + }, + noResultsScreen: { + noResultsText: translate({ + id: 'theme.SearchModal.noResultsScreen.noResultsText', + message: 'No results found for', + description: 'The text when there are no results', + }), + suggestedQueryText: translate({ + id: 'theme.SearchModal.noResultsScreen.suggestedQueryText', + message: 'Try searching for', + description: 'The text for suggested query', + }), + reportMissingResultsText: translate({ + id: 'theme.SearchModal.noResultsScreen.reportMissingResultsText', + message: 'Believe this query should return results?', + description: 'The text for reporting missing results', + }), + reportMissingResultsLinkText: translate({ + id: 'theme.SearchModal.noResultsScreen.reportMissingResultsLinkText', + message: 'Let us know.', + description: 'The link text for reporting missing results', + }), + }, + }, + placeholder: translate({ + id: 'theme.SearchModal.placeholder', + message: 'Search docs', + description: 'The placeholder of the input of the DocSearch pop-up modal', + }), +}; + +export default translations; diff --git a/adapters/docusaurus-theme-search-algolia/src/types.d.ts b/adapters/docusaurus-theme-search-algolia/src/types.d.ts new file mode 100644 index 0000000000..9e945888cb --- /dev/null +++ b/adapters/docusaurus-theme-search-algolia/src/types.d.ts @@ -0,0 +1,10 @@ +/** + * Copyright (c) Facebook, Inc. And its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/// +/// +/// diff --git a/adapters/docusaurus-theme-search-algolia/src/utils/escapeRegexp.ts b/adapters/docusaurus-theme-search-algolia/src/utils/escapeRegexp.ts new file mode 100644 index 0000000000..d744bf0be4 --- /dev/null +++ b/adapters/docusaurus-theme-search-algolia/src/utils/escapeRegexp.ts @@ -0,0 +1,17 @@ +/** + * Copyright (c) Facebook, Inc. And its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Escapes special characters in a string for use in a regular expression. + * Based on escape-string-regexp package. + */ +export function escapeRegexp(string: string): string { + // Escape characters with special meaning either inside or outside character + // sets. Use a simple backslash escape when it's always valid, and a `\xnn` + // escape when the simpler form would be disallowed by stricter unicodeness. + return string.replace(/[|\\{}()[\]^$+*?.]/g, '\\$&').replace(/-/g, '\\x2d'); +} diff --git a/adapters/docusaurus-theme-search-algolia/src/utils/index.ts b/adapters/docusaurus-theme-search-algolia/src/utils/index.ts new file mode 100644 index 0000000000..9e7d78ea03 --- /dev/null +++ b/adapters/docusaurus-theme-search-algolia/src/utils/index.ts @@ -0,0 +1,9 @@ +/** + * Copyright (c) Facebook, Inc. And its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +export { normalizeUrl } from './normalizeUrl'; +export { escapeRegexp } from './escapeRegexp'; diff --git a/adapters/docusaurus-theme-search-algolia/src/utils/normalizeUrl.ts b/adapters/docusaurus-theme-search-algolia/src/utils/normalizeUrl.ts new file mode 100644 index 0000000000..800cdb2348 --- /dev/null +++ b/adapters/docusaurus-theme-search-algolia/src/utils/normalizeUrl.ts @@ -0,0 +1,104 @@ +/** + * Copyright (c) Facebook, Inc. And its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Much like `path.join`, but much better. Takes an array of URL segments, and + * joins them into a reasonable URL. + * + * - `["file:", "/home", "/user/", "website"]` => `file:///home/user/website` + * - `["file://", "home", "/user/", "website"]` => `file://home/user/website` (relative!) + * - Remove trailing slash before parameters or hash. + * - Replace `?` in query parameters with `&`. + * - Dedupe forward slashes in the entire path, avoiding protocol slashes. + * + * @throws {TypeError} If any of the URL segment is not a string, this throws. + */ +export function normalizeUrl(rawUrls: string[]): string { + const urls = [...rawUrls]; + const resultArray: string[] = []; + + let hasStartingSlash = false; + let hasEndingSlash = false; + + const isNonEmptyArray = (arr: string[]): arr is [string, ...string[]] => arr.length > 0; + + if (!isNonEmptyArray(urls)) { + return ''; + } + + // If the first part is a plain protocol, we combine it with the next part. + if (urls[0].match(/^[^/:]+:\/*$/) && urls.length > 1) { + const first = urls.shift()!; + if (first.startsWith('file:') && urls[0].startsWith('/')) { + // Force a double slash here, else we lose the information that the next + // segment is an absolute path + urls[0] = `${first}//${urls[0]}`; + } else { + urls[0] = first + urls[0]; + } + } + + // There must be two or three slashes in the file protocol, + // two slashes in anything else. + const replacement = urls[0].match(/^file:\/\/\//) ? '$1:///' : '$1://'; + urls[0] = urls[0].replace(/^(?[^/:]+):\/*/, replacement); + + for (let i = 0; i < urls.length; i += 1) { + let component = urls[i]; + + if (typeof component !== 'string') { + throw new TypeError(`Url must be a string. Received ${typeof component}`); + } + + if (component === '') { + if (i === urls.length - 1 && hasEndingSlash) { + resultArray.push('/'); + } + // eslint-disable-next-line no-continue + continue; + } + + if (component !== '/') { + if (i > 0) { + // Removing the starting slashes for each component but the first. + component = component.replace( + /^\/+/, + // Special case where the first element of rawUrls is empty + // ["", "/hello"] => /hello + component.startsWith('/') && !hasStartingSlash ? '/' : '', + ); + } + + hasEndingSlash = component.endsWith('/'); + // Removing the ending slashes for each component but the last. For the + // last component we will combine multiple slashes to a single one. + component = component.replace(/\/+$/, i < urls.length - 1 ? '' : '/'); + } + + hasStartingSlash = true; + resultArray.push(component); + } + + let str = resultArray.join('/'); + // Each input component is now separated by a single slash except the possible + // first plain protocol part. + + // Remove trailing slash before parameters or hash. + str = str.replace(/\/(?\?|&|#[^!/])/g, '$1'); + + // Replace ? in parameters with &. + const parts = str.split('?'); + str = parts.shift()! + (parts.length > 0 ? '?' : '') + parts.join('&'); + + // Dedupe forward slashes in the entire path, avoiding protocol slashes. + str = str.replace(/(?[^:/]\/)\/+/g, '$1'); + + // Dedupe forward slashes at the beginning of the path. + str = str.replace(/^\/+/g, '/'); + + return str; +} diff --git a/adapters/docusaurus-theme-search-algolia/src/validateThemeConfig.ts b/adapters/docusaurus-theme-search-algolia/src/validateThemeConfig.ts new file mode 100644 index 0000000000..bb17d25536 --- /dev/null +++ b/adapters/docusaurus-theme-search-algolia/src/validateThemeConfig.ts @@ -0,0 +1,223 @@ +/** + * Copyright (c) Facebook, Inc. And its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import type { ThemeConfig, ThemeConfigAlgolia } from '@docsearch/docusaurus-adapter'; +import type { ThemeConfigValidationContext } from '@docusaurus/types'; +import Joi from 'joi'; + +import { docSearchVersionString } from './docSearchVersion'; +import { getDocSearchConfig } from './getDocSearchConfig'; +import { escapeRegexp } from './utils'; + +export const DEFAULT_CONFIG = { + // Enabled by default, as it makes sense in most cases + // see also https://github.com/facebook/docusaurus/issues/5880 + contextualSearch: true, + searchParameters: {}, + searchPagePath: 'search', +} satisfies Partial; + +const FacetFiltersSchema = Joi.array().items(Joi.alternatives().try(Joi.string(), Joi.array().items(Joi.string()))); + +const AskAiSearchParametersSchema = Joi.object({ + facetFilters: FacetFiltersSchema.optional(), + filters: Joi.string().optional(), + attributesToRetrieve: Joi.array().items(Joi.string()).optional(), + restrictSearchableAttributes: Joi.array().items(Joi.string()).optional(), + distinct: Joi.alternatives().try(Joi.boolean(), Joi.number(), Joi.string()).optional(), +}).unknown(); + +const SidePanelKeyboardShortcutsSchema = Joi.object({ + 'Ctrl/Cmd+I': Joi.boolean().optional(), +}); + +const SidePanelSchema = Joi.object({ + keyboardShortcuts: SidePanelKeyboardShortcutsSchema.optional(), + variant: Joi.string().valid('floating', 'inline').optional(), + side: Joi.string().valid('left', 'right').optional(), + width: Joi.alternatives().try(Joi.number(), Joi.string()).optional(), + expandedWidth: Joi.alternatives().try(Joi.number(), Joi.string()).optional(), + pushSelector: Joi.string().optional(), + suggestedQuestions: Joi.boolean().optional(), + translations: Joi.object().optional().unknown(), + hideButton: Joi.boolean().optional(), +}); + +export const Schema = Joi.object({ + algolia: Joi.object({ + // Docusaurus attributes + contextualSearch: Joi.boolean().default(DEFAULT_CONFIG.contextualSearch), + externalUrlRegex: Joi.string().optional(), + // Algolia attributes + appId: Joi.string().required().messages({ + 'any.required': + '"algolia.appId" is required. If you haven\'t migrated to the new DocSearch infra, please refer to the blog post for instructions: https://docusaurus.io/blog/2021/11/21/algolia-docsearch-migration', + }), + apiKey: Joi.string().required(), + indexName: Joi.string().required(), + searchParameters: Joi.object({ + facetFilters: FacetFiltersSchema.optional(), + }) + .default(DEFAULT_CONFIG.searchParameters) + .unknown(), + searchPagePath: Joi.alternatives() + .try(Joi.boolean().invalid(true), Joi.string()) + .allow(null) + .default(DEFAULT_CONFIG.searchPagePath), + replaceSearchResultPathname: Joi.object({ + from: Joi.custom((from) => { + if (typeof from === 'string') { + return escapeRegexp(from); + } + if (from instanceof RegExp) { + return from.source; + } + throw new Error(`it should be a RegExp or a string, but received ${from}`); + }).required(), + to: Joi.string().required(), + }).optional(), + // Ask AI configuration (DocSearch v4 only) + askAi: Joi.alternatives() + .try( + // Simple string format (assistantId only) + Joi.string(), + // Full configuration object + Joi.object({ + assistantId: Joi.string().required(), + // Optional Ask AI configuration + indexName: Joi.string().optional(), + apiKey: Joi.string().optional(), + appId: Joi.string().optional(), + agentStudio: Joi.boolean().optional(), + searchParameters: AskAiSearchParametersSchema, + suggestedQuestions: Joi.boolean().optional(), + sidePanel: Joi.alternatives().try(Joi.boolean(), SidePanelSchema).optional(), + }), + ) + .custom((askAiInput: ThemeConfigAlgolia['askAi'] | string | undefined, helpers) => { + if (!askAiInput) { + return askAiInput; + } + const algolia: ThemeConfigAlgolia = helpers.state.ancestors[0]; + const algoliaFacetFilters = algolia.searchParameters?.facetFilters; + if (typeof askAiInput === 'string') { + return { + assistantId: askAiInput, + indexName: algolia.indexName, + apiKey: algolia.apiKey, + appId: algolia.appId, + ...(algoliaFacetFilters + ? { + searchParameters: { + facetFilters: algoliaFacetFilters, + }, + } + : {}), + } satisfies ThemeConfigAlgolia['askAi']; + } + + // Fill in missing fields with the top-level Algolia config + const normalizedAskAi = { ...askAiInput }; + normalizedAskAi.indexName = normalizedAskAi.indexName ?? algolia.indexName; + normalizedAskAi.apiKey = normalizedAskAi.apiKey ?? algolia.apiKey; + normalizedAskAi.appId = normalizedAskAi.appId ?? algolia.appId; + if ( + normalizedAskAi.agentStudio !== true && + normalizedAskAi.searchParameters?.facetFilters === undefined && + algoliaFacetFilters + ) { + normalizedAskAi.searchParameters = { + ...(normalizedAskAi.searchParameters ?? {}), + facetFilters: algoliaFacetFilters, + }; + } + + return normalizedAskAi; + }) + .optional() + .messages({ + 'alternatives.types': + 'askAi must be either a string (assistantId) or an object with indexName, apiKey, appId, and assistantId', + }), + }) + .label('themeConfig.algolia') + .required() + .unknown(), +}); + +function ensureSidepanelSupported(themeConfig: ThemeConfig) { + const docsearch = getDocSearchConfig(themeConfig); + const sidePanelEnabled = docsearch.askAi && typeof docsearch.askAi === 'object' && Boolean(docsearch.askAi.sidePanel); + + if (!sidePanelEnabled) { + return; + } + + const isSidepanelSupported = (() => { + const match = docSearchVersionString.match(/^(?\d+)\.(?\d+)/); + if (!match?.groups) { + return false; + } + const major = Number(match.groups.major); + const minor = Number(match.groups.minor); + return major > 4 || (major === 4 && minor >= 5); + })(); + + if (!isSidepanelSupported) { + throw new Error( + 'The askAi.sidePanel feature is only supported in DocSearch v4.5+. ' + + 'Please upgrade to DocSearch v4.5+ or remove the askAi.sidePanel configuration.', + ); + } +} + +function hasConfigValue(value: TValue | null | undefined): value is TValue { + return value !== undefined && value !== null; +} + +function getThemeConfigSource(themeConfig: ThemeConfig): 'algolia' | 'docsearch' | null { + const hasDocsearch = hasConfigValue(themeConfig.docsearch); + const hasAlgolia = hasConfigValue(themeConfig.algolia); + + if (hasDocsearch && hasAlgolia) { + throw new Error( + 'Please provide either "themeConfig.docsearch" (preferred) or "themeConfig.algolia" (legacy), but not both.', + ); + } + + if (hasDocsearch) { + return 'docsearch'; + } + + if (hasAlgolia) { + return 'algolia'; + } + + return null; +} + +export function validateThemeConfig({ + validate, + themeConfig: themeConfigInput, +}: ThemeConfigValidationContext): ThemeConfig { + const source = getThemeConfigSource(themeConfigInput); + + if (!source) { + return validate(Schema, {}); + } + + const validated = validate(Schema, { + algolia: source === 'docsearch' ? themeConfigInput.docsearch : themeConfigInput.algolia, + }) as ThemeConfig; + + const themeConfig: ThemeConfig = { + [source]: validated.algolia, + }; + + ensureSidepanelSupported(themeConfig); + return themeConfig; +} diff --git a/adapters/docusaurus-theme-search-algolia/tsconfig.client.json b/adapters/docusaurus-theme-search-algolia/tsconfig.client.json new file mode 100644 index 0000000000..861085c324 --- /dev/null +++ b/adapters/docusaurus-theme-search-algolia/tsconfig.client.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.client.json", + "compilerOptions": { + "outDir": "lib", + "rootDir": "src", + "composite": true + }, + "include": ["src/theme", "src/client", "src/getDocSearchConfig.ts", "src/*.d.ts"], + "exclude": ["**/__tests__/**"] +} diff --git a/adapters/docusaurus-theme-search-algolia/tsconfig.json b/adapters/docusaurus-theme-search-algolia/tsconfig.json new file mode 100644 index 0000000000..1ca89a8713 --- /dev/null +++ b/adapters/docusaurus-theme-search-algolia/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.base.json", + "references": [{"path": "./tsconfig.client.json"}], + "compilerOptions": { + "noEmit": false, + "outDir": "lib", + "rootDir": "src" + }, + "include": ["src"], + "exclude": ["src/client", "src/theme", "**/__tests__/**"] +} diff --git a/eslint.config.mjs b/eslint.config.mjs index 29d48e3cc2..3db74898e0 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -64,6 +64,7 @@ export default [ // TMP 'react/function-component-definition': ['off'], + '@typescript-eslint/explicit-function-return-type': ['off'], 'react/jsx-filename-extension': ['off'], 'jsdoc/check-examples': ['off'], }, @@ -82,7 +83,7 @@ export default [ }, }, { - files: ['packages/website/**/*'], + files: ['packages/website/**/*', 'adapters/**/*'], rules: { 'import/no-unresolved': 0, 'import/no-extraneous-dependencies': 0, diff --git a/lerna.json b/lerna.json index 0f0eddbd3b..0993504671 100644 --- a/lerna.json +++ b/lerna.json @@ -1,8 +1,9 @@ { "packages": [ + "adapters/*", "packages/*", "examples/*" ], "version": "4.5.4", "npmClient": "yarn" -} \ No newline at end of file +} diff --git a/package.json b/package.json index db28c21da0..c77084b2cd 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "@docsearch/monorepo", "private": true, "workspaces": [ + "adapters/*", "packages/*", "examples/*" ], @@ -14,7 +15,7 @@ "cy:run:chrome": "yarn run cy:run --browser chrome", "cy:run:edge": "yarn run cy:run --browser edge", "cy:run:firefox": "yarn run cy:run --browser firefox", - "cy:run": "start-server-and-test 'yarn website:test' http://localhost:3000 'cypress run --headless'", + "cy:run": "start-server-and-test 'yarn workspace @docsearch/docusaurus-adapter build && yarn website:test' http://localhost:3000 'cypress run --headless'", "cy:verify": "cypress verify", "lint:css": "stylelint **/src/**/*.css", "lint": "eslint .", diff --git a/packages/docsearch-core/package.json b/packages/docsearch-core/package.json index 2a507af45a..58ccd47c07 100644 --- a/packages/docsearch-core/package.json +++ b/packages/docsearch-core/package.json @@ -3,6 +3,7 @@ "description": "Core package logic for DocSearch", "version": "4.5.4", "license": "MIT", + "type": "module", "homepage": "https://docsearch.algolia.com", "repository": { "type": "git", diff --git a/packages/docsearch-css/src/button.css b/packages/docsearch-css/src/button.css index c8af627baf..3865835b71 100644 --- a/packages/docsearch-css/src/button.css +++ b/packages/docsearch-css/src/button.css @@ -1,7 +1,7 @@ .DocSearch-Button { align-items: center; border: 1px solid var(--docsearch-subtle-color); - border-radius: 4px; + border-radius: .5rem; background-color: var(--docsearch-search-button-background); color: var(--docsearch-search-button-text-color); cursor: pointer; diff --git a/packages/docsearch-css/src/modal.css b/packages/docsearch-css/src/modal.css index 4deaf5a274..8084e60cd8 100644 --- a/packages/docsearch-css/src/modal.css +++ b/packages/docsearch-css/src/modal.css @@ -93,7 +93,7 @@ font-weight: 300; height: 100%; outline: none; - padding-block-start: 4px; + padding-block-start: 0px; padding-inline-start: 8px; width: 80%; line-height: 1.4; diff --git a/packages/docsearch-css/src/sidepanel.css b/packages/docsearch-css/src/sidepanel.css index 16645d1745..c88dbbc4d3 100644 --- a/packages/docsearch-css/src/sidepanel.css +++ b/packages/docsearch-css/src/sidepanel.css @@ -339,6 +339,7 @@ html[data-theme='dark'] .DocSearch-SidepanelButton.inline:hover { font-weight: 600; line-height: 1.25rem; color: var(--docsearch-sidepanel-text-base); + margin: 0; } .DocSearch-Sidepanel-Content { @@ -537,6 +538,12 @@ html[data-theme="dark"] .DocSearch-Sidepanel-Prompt--stop:hover { padding-top: 1rem; } +.DocSearch-Sidepanel-ConversationHistoryScreen ul { + list-style-type: none; + padding: 0; + margin: 0; +} + .DocSearch-Sidepanel-RecentConversation { border-radius: 0.25rem; display: flex; diff --git a/packages/docsearch-modal/src/DocSearchModal.tsx b/packages/docsearch-modal/src/DocSearchModal.tsx index f5879eeb16..a8ec43c38c 100644 --- a/packages/docsearch-modal/src/DocSearchModal.tsx +++ b/packages/docsearch-modal/src/DocSearchModal.tsx @@ -1,12 +1,12 @@ import { useDocSearch } from '@docsearch/core'; -import type { DocSearchModalProps as ModalProps } from '@docsearch/react/modal'; +import type { DocSearchModalProps as ReactDocSearchModalProps } from '@docsearch/react'; import { DocSearchModal as Modal } from '@docsearch/react/modal'; import type { JSX } from 'react'; import React from 'react'; import { createPortal } from 'react-dom'; export type DocSearchModalProps = Omit< - ModalProps, + ReactDocSearchModalProps, | 'initialScrollY' | 'isAskAiActive' | 'isHybridModeSupported' @@ -28,7 +28,7 @@ export function DocSearchModal(props: DocSearchModalProps): JSX.Element | null { registerView('modal'); }, [registerView]); - const modalProps: ModalProps = React.useMemo( + const modalProps: ReactDocSearchModalProps = React.useMemo( () => ({ ...props, isAskAiActive, diff --git a/packages/website/docs/docsearch.mdx b/packages/website/docs/docsearch.mdx index 4eef87fbba..e36965ff2b 100644 --- a/packages/website/docs/docsearch.mdx +++ b/packages/website/docs/docsearch.mdx @@ -15,6 +15,10 @@ DocSearch v4 provides a significant upgrade over previous versions, offering enh DocSearch packages are available on the [npm registry][10]. +### Docusaurus users + +If your docs site is powered by Docusaurus, use [`@docsearch/docusaurus-adapter`](/docs/docusaurus-adapter) for the latest DocSearch features (including new Ask AI capabilities such as sidepanel support), while keeping `@docusaurus/preset-classic`. + 0.5%", diff --git a/packages/website/sidebars.js b/packages/website/sidebars.js index 66a74ea93a..a708f51620 100644 --- a/packages/website/sidebars.js +++ b/packages/website/sidebars.js @@ -19,7 +19,7 @@ export default { { type: 'category', label: 'DocSearch v4', - items: ['docsearch', 'composable-api', 'styling', 'api', 'examples', 'migrating-from-v3'], + items: ['docsearch', 'docusaurus-adapter', 'composable-api', 'styling', 'api', 'examples', 'migrating-from-v3'], }, { type: 'category', diff --git a/packages/website/src/theme/Root.js b/packages/website/src/theme/Root.js deleted file mode 100644 index 25e818fa58..0000000000 --- a/packages/website/src/theme/Root.js +++ /dev/null @@ -1,101 +0,0 @@ -import { DocSearch } from '@docsearch/core'; -import { DocSearchButton, DocSearchModal } from '@docsearch/modal'; -import { Sidepanel, SidepanelButton } from '@docsearch/sidepanel'; -import BrowserOnly from '@docusaurus/BrowserOnly'; -import { useHistory } from '@docusaurus/router'; -import React from 'react'; -import { createPortal } from 'react-dom'; - -import '@docsearch/css/dist/sidepanel.css'; - -const APP_ID = 'PMZUYBQDAK'; -const API_KEY = '24b09689d5b4223813d9b8e48563c8f6'; -const ASSISTANT_ID = 'askAIDemo'; -const ASK_AI_INDEX_NAME = 'docsearch-markdown'; - -function useSearchResultUrlProcessor() { - return React.useCallback((url) => { - const parsedURL = new URL(url); - - // Otherwise => transform to relative URL for SPA navigation - const relativeUrl = `${parsedURL.pathname + parsedURL.hash}`; - - return relativeUrl; - }, []); -} - -function useNavigator() { - const history = useHistory(); - const [navigator] = React.useState(() => { - return { - navigate(params) { - history.push(params.itemUrl); - }, - }; - }); - return navigator; -} - -function useTransformItems() { - const processSearchResultUrl = useSearchResultUrlProcessor(); - const [transformItems] = React.useState(() => { - return (items) => - items.map((item) => ({ - ...item, - url: processSearchResultUrl(item.url), - })); - }); - return transformItems; -} - -export default function Root({ children }) { - const navigator = useNavigator(); - const transformItems = useTransformItems(); - - return ( - <> - {children} - - - {() => ( - - {createPortal( - <> - - - , - document.querySelector('.navbarSearchContainer_Bca1'), - )} - - - - - )} - - - ); -} diff --git a/ship.config.mjs b/ship.config.mjs index af95946dd6..50f07ca2c7 100644 --- a/ship.config.mjs +++ b/ship.config.mjs @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; const packages = [ + 'adapters/docusaurus-theme-search-algolia', 'packages/docsearch-css', 'packages/docsearch-react', 'packages/docsearch-js', diff --git a/tsconfig.base.client.json b/tsconfig.base.client.json new file mode 100644 index 0000000000..484ac83e76 --- /dev/null +++ b/tsconfig.base.client.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.base.json", + "compilerOptions": { + "moduleResolution": "bundler", + "module": "ESNext", + "target": "ESNext" + } +} diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000000..bade77370d --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "rootDir": "${configDir}/src", + "outDir": "${configDir}/lib", + "composite": true, + "declaration": true, + "target": "ES2021", + "lib": ["ES2021", "DOM"], + "jsx": "react-jsx", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "resolveJsonModule": true, + "allowSyntheticDefaultImports": true, + "esModuleInterop": true, + "skipLibCheck": true, + "strict": true + } +} diff --git a/yarn.lock b/yarn.lock index 8a45ea912a..bc346ff08d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2384,6 +2384,35 @@ __metadata: languageName: unknown linkType: soft +"@docsearch/docusaurus-adapter@workspace:*, @docsearch/docusaurus-adapter@workspace:adapters/docusaurus-theme-search-algolia": + version: 0.0.0-use.local + resolution: "@docsearch/docusaurus-adapter@workspace:adapters/docusaurus-theme-search-algolia" + dependencies: + "@docsearch/react": "npm:^4.5.3" + "@docusaurus/core": "npm:^3.9.2" + "@docusaurus/module-type-aliases": "npm:3.9.2" + "@docusaurus/plugin-content-docs": "npm:^3.9.2" + "@docusaurus/theme-classic": "npm:3.9.2" + "@docusaurus/theme-common": "npm:^3.9.2" + "@docusaurus/theme-translations": "npm:^3.9.2" + "@types/fs-extra": "npm:^11.0.4" + "@types/lodash": "npm:^4.17.10" + algoliasearch: "npm:^5.37.0" + algoliasearch-helper: "npm:^3.26.0" + clsx: "npm:^2.0.0" + eta: "npm:^2.2.0" + fs-extra: "npm:^11.1.1" + joi: "npm:^17.9.2" + lodash: "npm:^4.17.21" + tslib: "npm:^2.6.0" + typescript: "npm:5.7.3" + utility-types: "npm:^3.10.0" + peerDependencies: + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + languageName: unknown + linkType: soft + "@docsearch/js-example@workspace:examples/demo-js": version: 0.0.0-use.local resolution: "@docsearch/js-example@workspace:examples/demo-js" @@ -2508,7 +2537,7 @@ __metadata: languageName: unknown linkType: soft -"@docsearch/react@npm:4.5.4, @docsearch/react@workspace:*, @docsearch/react@workspace:packages/docsearch-react": +"@docsearch/react@npm:4.5.4, @docsearch/react@npm:^4.5.3, @docsearch/react@workspace:*, @docsearch/react@workspace:packages/docsearch-react": version: 0.0.0-use.local resolution: "@docsearch/react@workspace:packages/docsearch-react" dependencies: @@ -2618,6 +2647,7 @@ __metadata: dependencies: "@docsearch/core": "workspace:*" "@docsearch/css": "workspace:*" + "@docsearch/docusaurus-adapter": "workspace:*" "@docsearch/react": "workspace:*" "@docsearch/sidepanel": "workspace:*" "@docusaurus/core": "npm:3.9.2" @@ -2711,7 +2741,7 @@ __metadata: languageName: node linkType: hard -"@docusaurus/core@npm:3.9.2": +"@docusaurus/core@npm:3.9.2, @docusaurus/core@npm:^3.9.2": version: 3.9.2 resolution: "@docusaurus/core@npm:3.9.2" dependencies: @@ -2872,7 +2902,7 @@ __metadata: languageName: node linkType: hard -"@docusaurus/plugin-content-docs@npm:3.9.2": +"@docusaurus/plugin-content-docs@npm:3.9.2, @docusaurus/plugin-content-docs@npm:^3.9.2": version: 3.9.2 resolution: "@docusaurus/plugin-content-docs@npm:3.9.2" dependencies: @@ -3097,7 +3127,7 @@ __metadata: languageName: node linkType: hard -"@docusaurus/theme-common@npm:3.9.2": +"@docusaurus/theme-common@npm:3.9.2, @docusaurus/theme-common@npm:^3.9.2": version: 3.9.2 resolution: "@docusaurus/theme-common@npm:3.9.2" dependencies: @@ -3148,7 +3178,7 @@ __metadata: languageName: node linkType: hard -"@docusaurus/theme-translations@npm:3.9.2": +"@docusaurus/theme-translations@npm:3.9.2, @docusaurus/theme-translations@npm:^3.9.2": version: 3.9.2 resolution: "@docusaurus/theme-translations@npm:3.9.2" dependencies: @@ -7332,6 +7362,16 @@ __metadata: languageName: node linkType: hard +"@types/fs-extra@npm:^11.0.4": + version: 11.0.4 + resolution: "@types/fs-extra@npm:11.0.4" + dependencies: + "@types/jsonfile": "npm:*" + "@types/node": "npm:*" + checksum: 10c0/9e34f9b24ea464f3c0b18c3f8a82aefc36dc524cc720fc2b886e5465abc66486ff4e439ea3fb2c0acebf91f6d3f74e514f9983b1f02d4243706bdbb7511796ad + languageName: node + linkType: hard + "@types/glob@npm:^7.1.1": version: 7.2.0 resolution: "@types/glob@npm:7.2.0" @@ -7434,6 +7474,15 @@ __metadata: languageName: node linkType: hard +"@types/jsonfile@npm:*": + version: 6.1.4 + resolution: "@types/jsonfile@npm:6.1.4" + dependencies: + "@types/node": "npm:*" + checksum: 10c0/b12d068b021e4078f6ac4441353965769be87acf15326173e2aea9f3bf8ead41bd0ad29421df5bbeb0123ec3fc02eb0a734481d52903704a1454a1845896b9eb + languageName: node + linkType: hard + "@types/keyv@npm:^3.1.1": version: 3.1.4 resolution: "@types/keyv@npm:3.1.4" @@ -7443,6 +7492,13 @@ __metadata: languageName: node linkType: hard +"@types/lodash@npm:^4.17.10": + version: 4.17.23 + resolution: "@types/lodash@npm:4.17.23" + checksum: 10c0/9d9cbfb684e064a2b78aab9e220d398c9c2a7d36bc51a07b184ff382fa043a99b3d00c16c7f109b4eb8614118f4869678dbae7d5c6700ed16fb9340e26cc0bf6 + languageName: node + linkType: hard + "@types/mdast@npm:^4.0.0, @types/mdast@npm:^4.0.2": version: 4.0.4 resolution: "@types/mdast@npm:4.0.4"