1
0
Fork 0

feat: promote DocSearch v5 to main (#2968)

* feat(askai): add compatibility with algolia mcp search tool [DASH-2294] (#2862)

## Summary
Fixes DASH-2294
Add compatibility with the Algolia MCP search tool (`algolia_search_index_${string}`) in AskAI.

## Changes
- Add `AlgoliaMCPSearchTool` type to handle the Algolia MCP server search tool
- Refactor how number of hits are retrieved in `ToolCall` into a `getNumberOfHits` helper

## Test plan
- Added unit tests for modified code 

* chore: Update to use tsdown for build system (#2824)

* chore: Update to use tsdown for build system

* fix: docsearch-react build

* fix: lint

* fix: glob resolved to incorrect version

* chore: migrate to from yarn, lerna and shipjs to bun & changesets (#2827)

* chore: add tool-versions file for node and bun versions (#2866)

* chore: watch in parallel (#2867)

* feat: agent studio feedback integration (#2868)

* feat(askai): Split Ask AI modal into own component (#2884)

* feat(askai): Split Ask AI modal into own component

* refactor(react): share modal utilities

* refactor(react): share search box form

* refactor(react): extract start screen sections

* refactor(react): extract shared modal hooks

* fix: lint adapter

* refactor(react): reorganize modal files

* fix: type error in examples

* fix: remove ai modal from adapter for now, fix import paths of react package

* feat(askai): Agent Studio core tools (#2886)

* feat(askai): Implement dynamic tool calls

* move ToolCall to components dir

* Converge Agent Studio search tools to same definition, fix client side tools breaking UI state

* add examples for custom tools

* fix: lint & types

* feat(askai): add Agent Studio memory support (#2888)

* feat(askai): remove Ask AI transport layer (#2889)

* feat(askai): add Agent Studio memory support

* refactor(askai): remove Ask AI transport abstraction

* feat(askai): Feedback notes and tags (#2890)

* feat(askai): add Agent Studio memory support

* refactor(askai): remove Ask AI transport abstraction

* feat(askai): Feedback notes and tags

* fix: bump css bundle size limit

* move feedback actions to components

* chore: fix deploys for v5 branch

* feat(askai): Aggregate MCP search tool calls (#2891)

* feat(askai): add Agent Studio memory support

* refactor(askai): remove Ask AI transport abstraction

* feat(askai): Feedback notes and tags

* fix: bump css bundle size limit

* move feedback actions to components

* feat(askai): Aggregate MCP search tool calls

* feat(askai): Allow dynamic indices for Agent Studio (#2893)

* feat(v5): UI updates (#2896)

* feat(v5): UI updates

* fix: css file size

* fix: e2e tests

* fix: e2e tests

* fix: e2e tests

* chore: add theme toggle to react demo example

* Update sources panel display, update dark theme

* fix: lint

* fix(askai): address ui review feedback

* fix: pin icon positioning

* fix(askai): improve a11y and dark-mode shimmer for thinking and error states

- add role=alert/status and aria-hidden on error/thinking UI
- support dark-mode shimmer gradients via CSS variables
- respect prefers-reduced-motion for shimmer
- handle null date in useRelativeFormattedDate with fallback translation

* feat(v5): Add hit breadcrumbs (#2897)

* feat(v5): UI updates

* fix: css file size

* fix: e2e tests

* fix: e2e tests

* fix: e2e tests

* chore: add theme toggle to react demo example

* Update sources panel display, update dark theme

* fix: lint

* fix(askai): address ui review feedback

* fix: pin icon positioning

* fix(askai): improve a11y and dark-mode shimmer for thinking and error states

- add role=alert/status and aria-hidden on error/thinking UI
- support dark-mode shimmer gradients via CSS variables
- respect prefers-reduced-motion for shimmer
- handle null date in useRelativeFormattedDate with fallback translation

* feat(v5): Add hit breadcrumbs

* fix: bump css bundle size limit

* Fix after conflicts

* chore: move CSS building to lightning css (#2898)

* feat: Facet filters for search (#2899)

* feat(v5): Initial facet filters work

* Perf updates, dark theme, facet chips, a11y improvements

* fix: bump css bundle size limit

* Dedupe facet filters, refetch facets on searchParameters changes

* Add chevron flourish

* fix(askai): Fix new conversation causing thread depth errors (#2900)

* feat(v5): Add hit result badge (#2901)

* feat(v5): Add hit result badge

* Add background to hit result badge

* feat(v5): Add follow up prompt suggestions (#2902)

* feat(v5): Add follow up prompt suggestions

* fix: bump css bundle size limit

* docs(agents): document Cursor Cloud dev environment setup for v5 (Bun) (#2903)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>

* feat(mcp): setup mcp plugins (#2895)

* feat(v5): Add prompt suggestions to keyword search (#2912)

* feat(v5): Add prompt suggestions to keyword search

* cleanup: Move consistent object to reusable constant

* chore(v5): Split Ask AI related CSS into own bundle (#2913)

* chore(v5): Split Ask AI related CSS into own bundle

* move style.css to include modal and askai

* fix: Ensure stage level and watch level scripts use bun runtime (#2915)

* fix: Ensure stage level and watch level scripts use bun runtime

* chore: move to node@24 update imports

* fix: lint

* feat(js): Document JS based hybrid mode, fix JS packages (#2916)

* feat(js): Document JS based hybrid mode, fix JS packages

* update: add model onOpen to docs

* feat(cli): add @docsearch/cli for MCP setup and search (#2911)

* chore(tsdown): Bump to latest tsdown version (#2918)

* chore(tsdown): Bump to latest tsdown version

* fix: bump nvmrc node version

* fix: cli tsconfig

* fix: website build

* fix: example build

* fix: circleci install bun

* fix: lint

* fix: circleci install bun

* fix: circleci install bun

* fix: circleci install bun

* refactor(docusaurus-adapter): rework theme config for v5 and modularize SearchPage (#2904)

Co-authored-by: Paul Jankowski <8BitTitan@gmail.com>

* feat(askai): Move askai related props under root askai (#2919)

* feat(askai): Move askai related props under root askai

* fix: playwright test case

* fix(docusaurus): validate Ask AI options

* feat(js): Split JS bundles for search only (#2920)

* chore: Move to oxlint and oxfmt (#2923)

* chore: Get NPM OIDC token before publishing (#2924)

* chore: Enter v5 beta (#2925)

* chore: Enter pre release mode for v5

* chore: update release summary

* chore: version bump

* fix: Remove NPM_ID_TOKEN for release

* fix: Try setting blank NPM_TOKEN

* fix: Try blank NPM_AUTH_TOKEN

* chore: bump node and npm for release job

* docs(mcp): add service disclaimer (#2921)

* fix: Agent Studio MCP search tool (#2927)

* fix: Agent Studio MCP search tool

* Add changeset

* chore: Update stylelint (#2926)

* chore: stylelint update

* bun.lock

* Add changeset

* fix(website): use bare @import for tailwindcss (#2933)

Tailwind's build-time `@import` cannot be written with `url()` notation,
so `@import url('tailwindcss')` was passed through as a plain CSS import
instead of being processed by Tailwind.

Also syncs bun.lock with the 5.0.0-beta.0 versions already committed to
package.json.

* chore: version v5.0.0-beta.1 (#2932)

* feat(react): remove deprecated index props (#2936)

* feat(mcp): add ChatGPT and Codex DocSearch plugin package (#2938)

* fix: cleanup claude

* feat: website redesign (#2930)

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix: lint

* fix: crash on the demo (#2940)

* feat(docs): Document v5 beta (#2935)

* chore(docs): v5 documentation

* Writing style clean up

* fix: website after conflicts

* fix: reported issues on mobile (#2944)

* chore: Introduce a11y smoke tests (#2943)

* chore: Add Lorris as codeowner (#2946)

* fix(askai): Ask AI fixes for v5 (#2945)

* fix: General v5 fixes (#2947)

- Fix `ref` console error for a `FacetMenu`
- Whitespace only search/conversation input does not trigger requests
- Fix flash of no results page on search

* feat: v5 general improvements (#2948)

* feat(v5): General fixes and improvements

* add changeset

* fix: bundlesize

* feat(v5): UI and DX improvements (#2949)

* feat: Rename assistantId to agentId

* feat: Allow reading default facet values from index searchParameters

* feat: Remove indexName prop from Sidepanel, cleanup documentation pages

* feat: Move appId and apiKey up into @docsearch/core

* feat: Add back nested grouping of search results

* add changeset

* revert changes to example demo

* fix: e2e tests

* chore: push git tags on version release (#2951)

* chore: release v5.0.0-beta.2 (#2950)

* chore: Fix pushing git tags (#2953)

* fix(askai): sanitize markdown HTML in v5 (#2954)

Backport of #2929.\n\nOriginal commit: 681cbfec03

Co-authored-by: Vasco Bettencourt <32492444+vascobettencourt@users.noreply.github.com>

* fix(v5): stop truncating mobile snippets (#2958)

* fix(v5): stop truncating mobile snippets

Backport of #2907.\n\nOriginal commit: 9ad6d169fe

* fix(v5): allow mobile hit text to wrap

Completes the v5 adaptation of #2907 by overriding later v5 child-level truncation rules.\n\nOriginal commit: 9ad6d169fe

* chore(v5): account for mobile wrapping CSS

Updates the CSS size budget for the v5 adaptation of #2907.\n\nOriginal commit: 9ad6d169fe

---------

Co-authored-by: Divyansh Singh <40380293+brc-dd@users.noreply.github.com>

* feat(v5): Add new footerAction prop (#2952)

* feat(v5): Add new footerAction prop

* Resolve PR comments

* fix(v5): recognize conversation depth errors (#2957)

Backport of #2881.\n\nOriginal commit: f68e52251c

Co-authored-by: Felipe Bermudez <felipeberm@gmail.com>

* fix(v5): expose Sidepanel search parameter types (#2956)

* fix(v5): expose Sidepanel search parameter types

Backport of #2906.\n\nOriginal commit: 4710d0ca77

* Delete sidepanel.test.ts

Had a pointless test case in it.

---------

Co-authored-by: Divyansh Singh <40380293+brc-dd@users.noreply.github.com>

* fix(v5): ignore slash shortcut on focused buttons (#2955)

Backport of #2871.\n\nOriginal commit: 0e41a78c44

Co-authored-by: Sigmabro <122412346+Sigmabrogz@users.noreply.github.com>

* fix(agentStudio): agents dynamic mode enabled (#2959)

* fix(agentStudio): agents dynamic mode enabled

* fix(askai): use string[] for dynamic agentStudio indices

* feat(docs): add Ask AI to Agent Studio migration guide (#2931)

* feat(docs): add Ask AI to Agent Studio migration guide

* feat(docs): agentStudio migrating from askAI

* feat(docs): renaming agentId

* feat(agentStudio): dynamic mode indices updated

* fix: Docusaurus adapter styling, DocSearch website fixes (#2960)

* chore: release v5.0.0-beta.3 (#2961)

* feat(website): Launch updates (#2964)

* fix(website): Fix font loading (#2966)

* feat(v5): Back port cost control errors (#2965)

* chore: release v5.0.0-beta.4 (#2967)

---------

Co-authored-by: Vincent Lemeunier <vincentlemeunier+git@gmail.com>
Co-authored-by: Dylan Tientcheu <dylan.tientcheu@algolia.com>
Co-authored-by: Lorris Saint-Genez <lorrissaintgenez@gmail.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Dylan Tientcheu <dylantientcheu@gmail.com>
Co-authored-by: Vasco Bettencourt <32492444+vascobettencourt@users.noreply.github.com>
Co-authored-by: Divyansh Singh <40380293+brc-dd@users.noreply.github.com>
Co-authored-by: Felipe Bermudez <felipeberm@gmail.com>
Co-authored-by: Sigmabro <122412346+Sigmabrogz@users.noreply.github.com>
This commit is contained in:
Paul Jankowski 2026-08-06 15:31:45 -04:00 committed by GitHub
parent 9378e83941
commit ecd905d440
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
586 changed files with 49203 additions and 41727 deletions

18
.changeset/README.md Normal file
View file

@ -0,0 +1,18 @@
# Changesets
Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works with multi-package repos, or single-package repos to help you version and publish your code. You can find the full documentation for it [in the changesets repo](https://github.com/changesets/changesets).
## How to contribute a changeset
Before merging your PR, you should add a changeset if your change affects any of the published packages.
1. Run `bun run changeset` in the root of the repository
2. Select the packages that are affected by your change
3. Choose the type of version bump (major, minor, or patch)
4. Write a summary of your changes that will be included in the changelog
All published `@docsearch/*` packages use a single version (fixed versioning). This means when one package is bumped, all packages get the same version.
## Releasing
When changesets are present in the `main` branch, the CI will create a "Version Packages" PR that bumps versions and updates changelogs. Merging that PR will trigger the publish workflow.

View file

@ -0,0 +1,30 @@
---
"@docsearch/docusaurus-adapter": patch
"@docsearch/react": patch
"@docsearch/css": patch
---
feat(v5): General UI styling updates and fixes
- New `--docsearch-font-family` variable, used by the search button, keyboard
keys, modal, and sidepanel. It replaces the system font stacks that were
duplicated across `sidepanel.css` and `button.css`, so overriding one
variable now themes every DocSearch surface
- The search input and modal heading are `1rem` at every breakpoint
(previously `0.875rem` with a mobile-only override)
- `.DocSearch-Title` uses `line-height: 1.5em` instead of `0.5em` and adds
`overflow-wrap: anywhere`, so long titles wrap instead of overlapping (#2908)
- Hit icon `svg` sizing moved into `.DocSearch-Hit-icon`. The
`.DocSearch-Hit-icon--small` modifier is replaced by
`.DocSearch-Hit-icon--start`, which top-aligns the icon; recent
conversations use it
- The Ask AI button icon centers with `display: inline-flex` instead of
`margin-block-start`/`align-self` overrides
- Removed the 2px offset on the Ask AI sources action text
- `SourcesPanel` accepts `pluralTitleText`. `titleText` is now the singular
label, and the trigger renders `{count} {label}`
- `AskAiScreenTranslations` and `ConversationScreenTranslations` expose
`relatedSourcesTextPlural`
- Docusaurus adapter: `theme.SearchModal.askAiScreen.relatedSourcesText` is
now the singular "Source", and the new
`theme.SearchModal.askAiScreen.relatedSourcesTextPlural` provides "Sources"

View file

@ -0,0 +1,15 @@
---
"@docsearch/docusaurus-adapter": patch
"@docsearch/sidepanel": patch
"@docsearch/modal": patch
"@docsearch/react": patch
"@docsearch/core": patch
---
feat(v5): UI and DX updates
- Rename the Ask AI assistantId option to agentId (adapter theme.SearchModal.askAi.assistantId → agentId)
- appId and apiKey moved up into @docsearch/core, so they're configured once and shared
- Removed the indexName prop from the Sidepanel
- Facet defaults can now be read from the index searchParameters
- Restored nested grouping of search results

View file

@ -0,0 +1,6 @@
---
'@docsearch/css': patch
'@docsearch/react': patch
---
Surface Agent Studio cost-control errors and block prompts until the user can recover. [#2878](https://github.com/algolia/docsearch/pull/2878)

View file

@ -0,0 +1,6 @@
---
"@docsearch/core": patch
"@docsearch/react": patch
---
Prevent the slash search shortcut from intercepting key events on focused buttons.

26
.changeset/config.json Normal file
View file

@ -0,0 +1,26 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.0.0/schema.json",
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [
[
"@docsearch/css",
"@docsearch/core",
"@docsearch/js",
"@docsearch/modal",
"@docsearch/react",
"@docsearch/sidepanel",
"@docsearch/sidepanel-js",
"@docsearch/docusaurus-adapter"
]
],
"linked": [],
"access": "public",
"baseBranch": "v5",
"updateInternalDependencies": "patch",
"ignore": [
"@docsearch/website",
"@docsearch/react-example",
"@docsearch/js-example"
]
}

View file

@ -0,0 +1,5 @@
---
"@docsearch/react": patch
---
Recognize current Agent Studio conversation-depth error messages.

View file

@ -0,0 +1,6 @@
---
"@docsearch/react": patch
"@docsearch/docusaurus-adapter": patch
---
Align Ask AI dynamic indices with Agent Studio completions: `askAi.indices` is now `string[]` (index names only). Use `askAi.searchParameters` for per-index runtime overrides.

View file

@ -0,0 +1,5 @@
---
"@docsearch/docusaurus-adapter": patch
---
fix(docusaurus-adapter): Docusaurus styling cleanup

View file

@ -0,0 +1,6 @@
---
"@docsearch/react": patch
"@docsearch/css": patch
---
Stop over-truncating mobile snippets and allow long search hits to wrap.

35
.changeset/pre.json Normal file
View file

@ -0,0 +1,35 @@
{
"mode": "pre",
"tag": "beta",
"initialVersions": {
"@docsearch/docusaurus-adapter": "4.6.0",
"@docsearch/js-example": "4.6.0",
"@docsearch/react-example": "4.6.0",
"@docsearch/cli": "0.0.2",
"@docsearch/core": "4.6.0",
"@docsearch/css": "4.6.0",
"@docsearch/js": "4.6.0",
"@docsearch/modal": "4.6.0",
"@docsearch/react": "4.6.0",
"@docsearch/sidepanel": "4.6.0",
"@docsearch/sidepanel-js": "4.6.0",
"@docsearch/website": "4.6.0"
},
"changesets": [
"better-cars-smell",
"brave-things-join",
"bright-errors-block",
"calm-buttons-listen",
"deep-conversations-start",
"dynamic-indices-strings",
"kind-foxes-give",
"long-snippets-wrap",
"ready-clubs-serve",
"safe-answers-smile",
"sharp-donkeys-search",
"sixty-lights-try",
"tidy-moons-argue",
"typed-panels-wave",
"vast-numbers-read"
]
}

View file

@ -0,0 +1,20 @@
---
"@docsearch/react": patch
"@docsearch/css": patch
---
fix(askai): Ask AI fixes for v5
- `getMessageContent` now joins every assistant text part instead of stopping
at the first one, so copying an answer that interleaves text with tool calls
returns the complete response (#2782)
- Key Ask AI conversation storage by `appId` instead of `indexName`, fixes
triggering a new conversation in hybrid mode
- Negative feedback panel is labelled with `aria-labelledby` and reason chips
expose selection via `aria-pressed` (styling moved off the
`--selected` modifier)
- `Popover.Trigger` forwards refs, fixes sources panel not showing in some cases
- Add `--docsearch-error-soft-color`, `--docsearch-error-text-color`, and
`--docsearch-code-block-background` variables; restyle the Ask AI error panel
and markdown code blocks to use them
```

View file

@ -0,0 +1,5 @@
---
"@docsearch/react": patch
---
Sanitize Ask AI Markdown HTML and URLs before rendering to prevent XSS.

View file

@ -0,0 +1,20 @@
---
"@docsearch/react": patch
---
fix(askai): support Agent Studio's batched MCP search tool
The Algolia MCP search tool can now issue multiple queries in a single tool
call (`queries[]`), which previously rendered as an empty query and broke
consecutive tool-call aggregation.
- `AlgoliaMCPSearchTool["input"]` accepts both the legacy single-query shape
and the new batched `queries[]` shape, and tolerates a missing `output`
- New `getSearchToolQueries()` helper normalizes query extraction across
`searchIndex`, batched MCP, and legacy MCP tool parts
- `ToolCall` renders one tool state per query for both `input-available` and
`output-available` states
- `groupConsecutiveToolResults` reuses the shared helper so batched calls
aggregate correctly
- Fix `AggregatedSearchBlock` keyboard handler comparing `e.key === 'enter'`,
which never matched and made query chips unusable via keyboard

View file

@ -0,0 +1,14 @@
---
"@docsearch/react": patch
"@docsearch/css": patch
"@docsearch/js": patch
---
feat(v5): add customizable footer action
- New `footerAction` prop renders a custom action in the modal footer,
before the Algolia logo, inside `.DocSearch-Footer-Action`
- `@docsearch/js` supports `footerAction` via template patterns (html helper,
JSX, or function-based)
- Fixes typing differences between `@docsearch/react` and `@docsearch/js`
- Restyle the footer with a `.DocSearch-Footer-Actions` wrapper

View file

@ -0,0 +1,20 @@
---
"@docsearch/docusaurus-adapter": patch
"@docsearch/css": patch
"@docsearch/react": patch
---
Align CSS class and animation names with the `DocSearch-` naming convention.
- The `shimmer` utility class is now `DocSearch-shimmer`
- Fixed a typo in `DocSearck-AskAiScreen-MessageContent-Stopped`, now
`DocSearch-AskAiScreen-MessageContent-Stopped`
- Keyframes renamed to kebab-case: `shimmerText``shimmer-text`,
`slideDown``slide-down`, `fadeIn``fade-in`
- Dark theme selectors use `:root[data-theme='dark']` instead of
`html[data-theme='dark']`
- Dropped redundant type qualifiers from `.DocSearch-Hits-padded` and
`.DocSearch-Hit-Select-Icon` selectors, slightly lowering their specificity
If you override these classes or keyframes in custom styles, update your
selectors accordingly.

View file

@ -0,0 +1,5 @@
---
"@docsearch/sidepanel-js": patch
---
Expose Agent Studio search parameters in the public Sidepanel JavaScript props.

View file

@ -0,0 +1,46 @@
---
"@docsearch/docusaurus-adapter": major
"@docsearch/core": major
"@docsearch/css": major
"@docsearch/js": major
"@docsearch/modal": major
"@docsearch/react": major
"@docsearch/sidepanel": major
"@docsearch/sidepanel-js": major
---
# DocSearch v5-beta
DocSearch v5 is a major release that introduces AI-powered answers via Agent
Studio, a refreshed search UI, and a modernized package architecture.
## Ask AI & Agent Studio
- Agent Studio integration with core tools and dynamic tool calls
- Conversation memory support
- Compatibility with the Algolia MCP search tool, with aggregated MCP search
tool calls
- Dynamic index selection for Agent Studio
- Feedback integration, including feedback notes and tags
- Prompt suggestions in keyword search and follow-up prompt suggestions
- Ask AI modal split into its own component and Ask AI transport layer removed
## Search UI
- Refreshed v5 UI with improved dark theme, sources panel, and accessibility
- Faceted search with filter chips
- Hit breadcrumbs and result badges
## Packaging & architecture
- New `@docsearch/cli` package for MCP setup and search
- MCP plugin support
- Split JS bundles for search-only usage and JS-based hybrid mode
- Migrated the build system to tsdown
- Migrated CSS building to LightningCSS
## Breaking changes
- Ask AI related props are now nested under a single root `askai` option
- The Ask AI transport layer has been removed

View file

@ -1,21 +1,26 @@
version: 2.1
aliases:
- &restore_yarn_cache
name: Restore Yarn cache
- &install_bun
name: Install Bun
command: |
npm install --global --prefix "$HOME/.local" bun@1.3.10
echo 'export PATH="$HOME/.local/bin:$PATH"' >> "$BASH_ENV"
- &restore_bun_cache
name: Restore Bun cache
keys:
- yarn-packages-{{ checksum "yarn.lock" }}
- bun-packages-{{ checksum "bun.lock" }}
- &save_yarn_cache
name: Save Yarn cache
key: yarn-packages-{{ checksum "yarn.lock" }}
- &save_bun_cache
name: Save Bun cache
key: bun-packages-{{ checksum "bun.lock" }}
paths:
- .yarn/cache
- .yarn/unplugged
- ~/.bun/install/cache
- &run_yarn_install
- &run_bun_install
name: Install dependencies
command: yarn install --immutable
command: bun install --frozen-lockfile
- &restore_dist_folders
name: Restore dist folders
@ -29,6 +34,8 @@ aliases:
mkdir -p packages/docsearch-modal/dist
mkdir -p packages/docsearch-sidepanel/dist
mkdir -p packages/docsearch-sidepanel-js/dist
mkdir -p packages/docsearch-cli/dist
mkdir -p adapters/docusaurus-theme-search-algolia/lib
cp -R /tmp/workspace/packages/docsearch-react/dist packages/docsearch-react
cp -R /tmp/workspace/packages/docsearch-js/dist packages/docsearch-js
@ -37,16 +44,18 @@ aliases:
cp -R /tmp/workspace/packages/docsearch-modal/dist packages/docsearch-modal
cp -R /tmp/workspace/packages/docsearch-sidepanel/dist packages/docsearch-sidepanel
cp -R /tmp/workspace/packages/docsearch-sidepanel-js/dist packages/docsearch-sidepanel-js
cp -R /tmp/workspace/packages/docsearch-cli/dist packages/docsearch-cli
cp -R /tmp/workspace/adapters/docusaurus-theme-search-algolia/lib adapters/docusaurus-theme-search-algolia
defaults: &defaults
working_directory: ~/docsearch
docker:
- image: cimg/node:22.15.0
- image: cimg/node:24.13.1
cypress: &cypress
playwright: &playwright
working_directory: ~/docsearch
docker:
- image: cypress/browsers:node-22.15.0-chrome-136.0.7103.113-1-ff-138.0.3-edge-136.0.3240.64-1
- image: mcr.microsoft.com/playwright:v1.49.1-noble
environment:
## this enables colors in the output
TERM: xterm
@ -62,13 +71,15 @@ jobs:
<<: *defaults
steps:
- checkout
- restore_cache: *restore_yarn_cache
- run: *run_yarn_install
- save_cache: *save_yarn_cache
- run: *install_bun
- restore_cache: *restore_bun_cache
- run: *run_bun_install
- save_cache: *save_bun_cache
- run:
name: Build and Size
command: |
yarn run build
bun run build
bun run --filter @docsearch/cli test:pack
- run:
name: Move dist folders to workspace
command: |
@ -81,6 +92,8 @@ jobs:
mkdir -p /tmp/workspace/packages/docsearch-modal/dist
mkdir -p /tmp/workspace/packages/docsearch-sidepanel/dist
mkdir -p /tmp/workspace/packages/docsearch-sidepanel-js/dist
mkdir -p /tmp/workspace/packages/docsearch-cli/dist
mkdir -p /tmp/workspace/adapters/docusaurus-theme-search-algolia/lib
cp -R packages/docsearch-react/dist /tmp/workspace/packages/docsearch-react
cp -R packages/docsearch-js/dist /tmp/workspace/packages/docsearch-js
@ -89,66 +102,78 @@ jobs:
cp -R packages/docsearch-modal/dist /tmp/workspace/packages/docsearch-modal
cp -R packages/docsearch-sidepanel/dist /tmp/workspace/packages/docsearch-sidepanel
cp -R packages/docsearch-sidepanel-js/dist /tmp/workspace/packages/docsearch-sidepanel-js
cp -R packages/docsearch-cli/dist /tmp/workspace/packages/docsearch-cli
cp -R adapters/docusaurus-theme-search-algolia/lib /tmp/workspace/adapters/docusaurus-theme-search-algolia
- persist_to_workspace:
root: *workspace_root
paths:
- packages
- adapters
test_lint:
<<: *defaults
steps:
- checkout
- *attach_workspace
- restore_cache: *restore_yarn_cache
- run: *run_yarn_install
- save_cache: *save_yarn_cache
- run: *install_bun
- restore_cache: *restore_bun_cache
- run: *run_bun_install
- save_cache: *save_bun_cache
- run: *restore_dist_folders
- run:
name: Linting
command: yarn run lint
command: |
bun run lint --quiet --format=stylish
bun run lint:css
test_types:
<<: *defaults
steps:
- checkout
- *attach_workspace
- restore_cache: *restore_yarn_cache
- run: *run_yarn_install
- save_cache: *save_yarn_cache
- run: *install_bun
- restore_cache: *restore_bun_cache
- run: *run_bun_install
- save_cache: *save_bun_cache
- run: *restore_dist_folders
- run:
name: Type checking
command: yarn run test:types
command: bun run test:types
test_size:
<<: *defaults
steps:
- checkout
- *attach_workspace
- restore_cache: *restore_yarn_cache
- run: *run_yarn_install
- save_cache: *save_yarn_cache
- run: *install_bun
- restore_cache: *restore_bun_cache
- run: *run_bun_install
- save_cache: *save_bun_cache
- run: *restore_dist_folders
- run:
name: Size checking
command: yarn run test:size
command: bun run test:size
test_unit:
<<: *defaults
steps:
- checkout
- *attach_workspace
- restore_cache: *restore_yarn_cache
- run: *run_yarn_install
- save_cache: *save_yarn_cache
- run: *install_bun
- restore_cache: *restore_bun_cache
- run: *run_bun_install
- save_cache: *save_bun_cache
- run: *restore_dist_folders
- run:
name: Unit tests
command: yarn run test
command: bun run test
release:
<<: *defaults
working_directory: ~/docsearch
docker:
- image: cimg/node:24.16
steps:
- checkout
- *attach_workspace
- restore_cache: *restore_yarn_cache
- run: *run_yarn_install
- save_cache: *save_yarn_cache
- run: *install_bun
- restore_cache: *restore_bun_cache
- run: *run_bun_install
- save_cache: *save_bun_cache
- run: *restore_dist_folders
- run:
name: Setup git user
@ -157,19 +182,29 @@ jobs:
git config --global user.name "algolia-bot"
- run:
name: Release if needed
command: yarn run shipjs trigger
test_cypress:
<<: *cypress
command: |
export NPM_ID_TOKEN="$(circleci run oidc get --claims '{"aud": "npm:registry.npmjs.org"}')"
bun run release
git push --follow-tags origin "HEAD:${CIRCLE_BRANCH}"
test_playwright:
<<: *playwright
steps:
- checkout
- *attach_workspace
- restore_cache: *restore_yarn_cache
- run: *run_yarn_install
- save_cache: *save_yarn_cache
- run: *install_bun
- restore_cache: *restore_bun_cache
- run: *run_bun_install
- save_cache: *save_bun_cache
- run: *restore_dist_folders
- run:
name: Cypress test Actions
command: yarn run cy:run
name: Playwright E2E tests
command: bun run pw:run
- store_artifacts:
path: playwright-report
destination: playwright-report
- store_artifacts:
path: test-results
destination: test-results
workflows:
version: 2
@ -188,7 +223,7 @@ workflows:
- test_unit:
requires:
- build
- test_cypress:
- test_playwright:
requires:
- build
- release:

8
.cursor/mcp.json Normal file
View file

@ -0,0 +1,8 @@
{
"mcpServers": {
"algolia-docsearch": {
"type": "http",
"url": "https://mcp.algolia.com/1/docsearch/mcp"
}
}
}

View file

@ -0,0 +1 @@
../../mcp/plugins/docsearch/cursor/algolia-docsearch/rules/use-algolia-docsearch.mdc

1
.cursor/settings.json Normal file
View file

@ -0,0 +1 @@
{}

View file

@ -1,4 +1,3 @@
GITHUB_TOKEN=
NPM_AUTH_TOKEN=
CYPRESS_RECORD_KEY=
PERCY_TOKEN=
PERCY_TOKEN=

6
.gitignore vendored
View file

@ -21,9 +21,9 @@ dist/
# Environment files
.env
# Cypress Video and Screenshots output
cypress/screenshots/
cypress/videos/
# Playwright output
playwright-report/
test-results/
**/.yarn/*
!**/.yarn/releases

2
.nvmrc
View file

@ -1 +1 @@
22.15.0
24.13.1

23
.oxfmtrc.json Normal file
View file

@ -0,0 +1,23 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"proseWrap": "never",
"singleQuote": true,
"trailingComma": "es5",
"printWidth": 80,
"sortPackageJson": false,
"sortImports": {
"groups": ["builtin", "external", "parent", "sibling", "index"],
"newlinesBetween": true
},
"jsdoc": true,
"ignorePatterns": [
"node_modules",
"dist",
"build",
".cache",
"**/*.mdx",
"**/*.md",
"adapters/docusaurus-theme-search-algolia/lib",
"!adapters/docusaurus-theme-search-algolia/lib/theme/**/*"
]
}

341
.oxlintrc.json Normal file
View file

@ -0,0 +1,341 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": [
"import",
"jsdoc",
"unicorn",
"react",
"jsx-a11y",
"typescript",
"react-perf",
"oxc",
"eslint",
"vitest"
],
"jsPlugins": [
"@stylistic/eslint-plugin",
"@eslint-community/eslint-plugin-eslint-comments"
],
"env": {
"builtin": true,
"es2026": true,
"commonjs": true
},
"ignorePatterns": [
"**/node_modules/",
"**/dist/",
"**/build/",
"**/.docusaurus",
"adapters/**/lib/**",
"examples/**/*"
],
"rules": {
"react/react-compiler": "warn",
"constructor-super": ["error"],
"for-direction": "error",
"getter-return": [
"error",
{
"allowImplicit": true
}
],
"no-console": ["error"],
"no-promise-executor-return": ["error"],
"no-template-curly-in-string": ["error"],
"accessor-pairs": ["error"],
"array-callback-return": ["error"],
"block-scoped-var": ["error"],
"default-case": ["error"],
"default-case-last": ["error"],
"default-param-last": ["error"],
"eqeqeq": ["error"],
"grouped-accessor-pairs": ["error"],
"guard-for-in": ["error"],
"max-classes-per-file": ["warn", 1],
"no-alert": ["error"],
"no-caller": ["error"],
"no-constructor-return": ["error"],
"no-div-regex": ["error"],
"no-else-return": [
"error",
{
"allowElseIf": false
}
],
"no-eq-null": ["error"],
"no-eval": ["error"],
"no-extend-native": ["error"],
"no-extra-bind": ["error"],
"no-extra-label": ["error"],
"no-implicit-coercion": [
"error",
{
"string": false
}
],
"no-implicit-globals": ["error"],
"no-implied-eval": ["error"],
"no-iterator": ["error"],
"no-labels": ["error"],
"no-lone-blocks": ["error"],
"no-loop-func": ["error"],
"no-multi-assign": ["error"],
"no-multi-str": ["error"],
"no-new": ["error"],
"no-new-func": ["error"],
"no-new-wrappers": ["error"],
"no-proto": ["error"],
"no-return-assign": ["error"],
"no-script-url": ["error"],
"no-self-compare": ["error"],
"no-sequences": ["error"],
"no-throw-literal": ["error"],
"no-unmodified-loop-condition": ["error"],
"no-useless-call": ["error"],
"no-useless-concat": ["error"],
"no-useless-return": ["error"],
"no-void": ["error"],
"no-warning-comments": ["error"],
"prefer-promise-reject-errors": ["error"],
"radix": ["error"],
"require-await": ["error"],
"vars-on-top": ["error"],
"yoda": ["error"],
"no-label-var": ["error"],
"no-restricted-globals": ["error", "event"],
"no-shadow": ["error"],
"no-use-before-define": [
"error",
{
"functions": false
}
],
"@stylistic/spaced-comment": ["error"],
"max-depth": ["error"],
"max-nested-callbacks": ["error"],
"max-params": ["error", 5],
"new-cap": ["error"],
"no-array-constructor": ["error"],
"no-bitwise": ["error"],
"no-lonely-if": ["error"],
"no-nested-ternary": ["error"],
"no-unneeded-ternary": ["error"],
"unicode-bom": ["error"],
"no-useless-computed-key": ["error"],
"no-useless-constructor": ["error"],
"no-useless-rename": ["error"],
"no-var": ["error"],
"object-shorthand": ["error"],
"prefer-const": ["error"],
"prefer-rest-params": ["error"],
"prefer-spread": ["error"],
"symbol-description": ["error"],
"@eslint-community/eslint-comments/disable-enable-pair": [
"error",
{
"allowWholeFile": true
}
],
"@eslint-community/eslint-comments/no-aggregating-enable": ["error"],
"@eslint-community/eslint-comments/no-duplicate-disable": ["error"],
"@eslint-community/eslint-comments/no-unlimited-disable": ["error"],
"@eslint-community/eslint-comments/no-unused-disable": ["error"],
"@eslint-community/eslint-comments/no-unused-enable": ["error"],
"import/first": "error",
"import/newline-after-import": "error",
"import/no-amd": ["error"],
"import/no-commonjs": ["error"],
"import/no-duplicates": ["error"],
"import/no-named-as-default": "error",
"jsdoc/check-tag-names": [
"error",
{
"definedTags": ["required", "swagger"]
}
],
"jsdoc/implements-on-classes": ["error"],
"jsdoc/require-param-description": ["error"],
"jsdoc/require-param-name": ["error"],
"jsdoc/require-returns-description": ["error"],
"react/jsx-key": ["error"],
"react/jsx-no-comment-textnodes": "error",
"react/jsx-no-duplicate-props": "error",
"react/jsx-no-target-blank": ["error"],
"react/jsx-no-undef": "error",
"react/no-children-prop": "error",
"react/no-danger-with-children": "error",
"react/no-direct-mutation-state": ["error"],
"react/no-find-dom-node": ["error"],
"react/no-is-mounted": ["error"],
"react/no-render-return-value": "error",
"react/no-string-refs": ["error"],
"react/no-unknown-property": "error",
"react/react-in-jsx-scope": "error",
"react/button-has-type": ["error"],
"react/jsx-boolean-value": ["error", "always"],
"react/jsx-pascal-case": ["error"],
"react/jsx-fragments": ["error"],
"react/jsx-handler-names": ["error"],
"react/no-array-index-key": ["warn"],
"react/no-did-mount-set-state": ["warn"],
"react/no-did-update-set-state": ["warn"],
"react/no-multi-comp": [
"warn",
{
"ignoreStateless": true
}
],
"react/no-redundant-should-component-update": ["error"],
"react/no-this-in-sfc": ["error"],
"react/self-closing-comp": [
"error",
{
"html": false
}
],
"jsx-a11y/alt-text": ["error"],
"jsx-a11y/anchor-is-valid": ["error"],
"jsx-a11y/anchor-has-content": "error",
"jsx-a11y/aria-activedescendant-has-tabindex": ["error"],
"jsx-a11y/aria-props": ["error"],
"jsx-a11y/aria-proptypes": ["error"],
"jsx-a11y/aria-role": [
"error",
{
"ignoreNonDOM": false
}
],
"jsx-a11y/html-has-lang": ["error"],
"jsx-a11y/iframe-has-title": ["error"],
"jsx-a11y/interactive-supports-focus": ["error"],
"jsx-a11y/lang": ["error"],
"jsx-a11y/media-has-caption": ["error"],
"jsx-a11y/mouse-events-have-key-events": ["error"],
"jsx-a11y/no-access-key": ["error"],
"jsx-a11y/no-autofocus": [
"error",
{
"ignoreNonDOM": true
}
],
"jsx-a11y/no-distracting-elements": [
"error",
{
"elements": ["marquee", "blink"]
}
],
"jsx-a11y/no-interactive-element-to-noninteractive-role": ["error"],
"jsx-a11y/no-noninteractive-element-interactions": ["error"],
"jsx-a11y/no-noninteractive-element-to-interactive-role": ["error"],
"jsx-a11y/no-noninteractive-tabindex": ["error"],
"jsx-a11y/no-redundant-roles": ["error"],
"jsx-a11y/no-static-element-interactions": ["error"],
"jsx-a11y/role-has-required-aria-props": ["error"],
"jsx-a11y/role-supports-aria-props": ["error"],
"jsx-a11y/scope": ["error"],
"jsx-a11y/tabindex-no-positive": ["error"],
"@stylistic/comma-spacing": ["error"],
"@stylistic/func-call-spacing": ["error"],
"@stylistic/type-annotation-spacing": ["error"],
"react/rules-of-hooks": "error",
"react/exhaustive-deps": "error",
"typescript/adjacent-overload-signatures": ["error"],
"typescript/array-type": [
"error",
{
"default": "array-simple"
}
],
"typescript/no-restricted-types": [
"error",
{
"types": {
"String": {
"message": "Use `string` instead.",
"fixWith": "string"
},
"Number": {
"message": "Use `number` instead.",
"fixWith": "number"
},
"Boolean": {
"message": "Use `boolean` instead.",
"fixWith": "boolean"
},
"Symbol": {
"message": "Use `symbol` instead.",
"fixWith": "symbol"
},
"Object": {
"message": "The `Object` type is mostly the same as `unknown`. You probably want `Record<string, unknown>` instead. See https://github.com/typescript-eslint/typescript-eslint/pull/848",
"fixWith": "Record<string, unknown>"
},
"{}": {
"message": "The `{}` type is mostly the same as `unknown`. You probably want `Record<string, unknown>` instead.",
"fixWith": "Record<string, unknown>"
},
"object": {
"message": "The `object` type is hard to use. Use `Record<string, unknown>` instead. See: https://github.com/typescript-eslint/typescript-eslint/pull/848",
"fixWith": "Record<string, unknown>"
},
"Function": "Use a specific function type instead, like `() => void`."
}
}
],
"typescript/consistent-type-imports": [
"error",
{
"prefer": "type-imports"
}
],
"typescript/consistent-type-assertions": [
"error",
{
"assertionStyle": "as",
"objectLiteralTypeAssertions": "allow-as-parameter"
}
],
"typescript/no-empty-interface": ["error"],
"typescript/no-misused-new": ["error"],
"typescript/no-namespace": [
"error",
{
"allowDeclarations": true
}
],
"typescript/triple-slash-reference": ["error"],
"typescript/no-confusing-non-null-assertion": ["error"],
"typescript/no-extra-non-null-assertion": ["error"],
"typescript/no-non-null-asserted-optional-chain": ["error"],
"typescript/no-unnecessary-type-constraint": ["error"],
"typescript/no-var-requires": ["error"],
"typescript/prefer-as-const": ["error"],
"typescript/prefer-enum-initializers": ["error"],
"typescript/prefer-function-type": ["error"],
"typescript/prefer-literal-enum-member": ["error"],
"typescript/prefer-namespace-keyword": ["error"],
"typescript/prefer-ts-expect-error": ["error"],
"typescript/unified-signatures": ["error"],
"vitest/require-mock-type-parameters": "off",
"no-debugger": ["error"]
},
"overrides": [
{
"files": ["**/*.ts", "**/*.tsx"],
"rules": {
"typescript/explicit-member-accessibility": [
"error",
{
"accessibility": "no-public"
}
]
}
},
{
"files": ["packages/website/**/*"],
"rules": {
"react/react-compiler": "off"
}
}
]
}

View file

@ -1,4 +0,0 @@
node_modules
dist
build
.cache

View file

@ -1,5 +0,0 @@
{
"proseWrap": "never",
"singleQuote": true,
"trailingComma": "es5"
}

View file

@ -1,35 +0,0 @@
{
"plugins": [
"stylelint-prettier",
"stylelint-no-unsupported-browser-features"
],
"extends": [
"stylelint-config-standard",
"stylelint-config-sass-guidelines",
"stylelint-order",
"stylelint-prettier/recommended"
],
"rules": {
"selector-class-pattern": [
"^DocSearch-[A-Za-z0-9-]*$"
],
"prettier/prettier": true,
"max-nesting-depth": [
2,
{
"ignore": [
"pseudo-classes"
],
"ignoreAtRules": [
"media"
]
}
],
"plugin/no-unsupported-browser-features": [
null,
{
"severity": "warning"
}
]
}
}

2
.tool-versions Normal file
View file

@ -0,0 +1,2 @@
bun 1.3.10
nodejs 24.13.1

File diff suppressed because one or more lines are too long

View file

@ -1,5 +0,0 @@
nodeLinker: node-modules
enableGlobalCache: false
yarnPath: .yarn/releases/yarn-4.6.0.cjs

102
AGENTS.md
View file

@ -4,7 +4,7 @@ This document provides guidelines for AI agents working on the DocSearch codebas
## Project Overview
DocSearch is an Algolia-powered search widget for documentation sites. It's a TypeScript/React monorepo using Yarn workspaces with Lerna for orchestration.
DocSearch is an Algolia-powered search widget for documentation sites. It's a TypeScript/React monorepo using Bun workspaces.
### Packages
@ -21,98 +21,72 @@ DocSearch is an Algolia-powered search widget for documentation sites. It's a Ty
```bash
# Install dependencies
yarn install
bun install
# Build all packages
yarn build
bun run build
# Build specific package
yarn workspace @docsearch/react build
bun run --filter @docsearch/react build
# Watch mode (all packages)
yarn watch
# Clean builds
yarn build:clean
bun run watch
```
## Test Commands
```bash
# Run all unit tests
yarn test
bun run test
# Run a single test file
yarn test packages/docsearch-react/src/__tests__/utils.test.ts
# Run tests matching a pattern
yarn test --testNamePattern="extractLinksFromText"
# Run tests in watch mode
yarn test --watch
bun run test --run packages/docsearch-react/src/__tests__/utils.test.ts
# Type checking
yarn test:types
bun run test:types
# Bundle size check
yarn test:size
bun run test:size
```
When running tests, prefer to run specific files with the `--run` flag to prevent running with watch mode.
## Lint Commands
```bash
# Run ESLint
yarn lint
# Run oxlint
bun run lint --format=agent
# Run CSS linting
yarn lint:css
# Perform oxfmt formatting
bun run fmt
# Lint CSS
bun run lint:css
```
## E2E Testing (Cypress)
## E2E Testing (Playwright)
```bash
# Run Cypress tests
yarn cy:run
bun run pw:run
# Run with specific browser
yarn cy:run:chrome
yarn cy:run:firefox
bun run pw:run:chromium
bun run pw:run:firefox
bun run pw:run:webkit
```
## Code Style Guidelines
### Imports
Imports must be ordered alphabetically with newlines between groups:
1. Built-in modules
2. External dependencies
3. Parent directory imports
4. Sibling imports
5. Index imports
Internal `@/**/*` paths go before parent imports.
```typescript
// Correct order
import type { AutocompleteOptions } from '@algolia/autocomplete-core';
import React, { type JSX } from 'react';
import { DocSearchButton } from './DocSearchButton';
import type { DocSearchHit } from './types';
```
### TypeScript
- Use `type` imports for type-only imports: `import type { Foo } from './types'`
- Prefer interfaces for object shapes, types for unions/primitives
- Explicit return types on exported functions
- Use `declare type` for exported type declarations
- Avoid `any`; use `unknown` when type is truly unknown
```typescript
// Good
export declare type DocSearchHit = {
export type DocSearchHit = {
objectID: string;
content: string | null;
};
@ -130,7 +104,7 @@ function createStorage<TItem>(key: string): StorageInterface<TItem> {
- **Utilities**: camelCase (`removeHighlightTags.ts`)
- **Types**: PascalCase (`InternalDocSearchHit`)
- **Constants**: SCREAMING_SNAKE_CASE (`MAX_QUERY_SIZE`)
- **CSS classes**: `DocSearch-` prefix with PascalCase (`DocSearch-Modal`)
- **CSS classes**: `DocSearch-` prefix
### React Components
@ -140,6 +114,14 @@ function createStorage<TItem>(key: string): StorageInterface<TItem> {
- Use `React.useMemo` for expensive computations
- Prefer destructuring props in function signature
### React Component Structure
- `src/components/ui/` contains reusable rendering components.
- Prefer domain-light primitives in `src/components/ui/` when possible.
- Feature-scoped UI components may live in `src/components/ui/` when their feature scope is explicit in the filename, such as `RecentConversationsResults.tsx`.
- `src/components/` contains scoped composition components that own feature flow, branching, and state orchestration.
- Keep AI-specific behavior out of generic UI primitives. If a UI component is AI-specific, make that scope clear in its name.
```typescript
function DocSearchComponent(
props: DocSearchProps,
@ -169,7 +151,7 @@ try {
}
```
### Formatting (Prettier)
### Formatting (oxfmt)
- Single quotes for strings
- Trailing commas (ES5 style)
@ -226,6 +208,8 @@ describe('ComponentName', () => {
packages/
docsearch-react/
src/
components/ # Scoped React composition components
ui/ # Reusable rendering components and explicitly scoped UI pieces
__tests__/ # Test files
icons/ # Icon components
types/ # Type definitions
@ -243,3 +227,17 @@ packages/
- `marked` - Markdown rendering
- `rollup` - Build bundling
- `vitest` - Test runner
## Cursor Cloud specific instructions
Toolchain is pinned in `.tool-versions`: Node `24.13.1` (managed via `fnm`) and Bun `1.3.10`. These are preinstalled in the Cloud VM and available on `PATH` in new shells; the startup update script only runs `bun install`.
Non-obvious caveats:
- **To run/demo the widget, use the React playground:** `bun run playground:start` serves at `http://localhost:5173` (Vite). `bun run playground-js:start` serves the vanilla-JS demo. These connect to Algolia's hosted index using public credentials baked into the demo, so **outbound internet is required** for live search results.
- Run unit tests non-interactively with `bun run test --run` (plain `bun run test` starts Vitest watch mode).
- `bun run lint:css` reports many pre-existing CSS lint violations in the repo; these are not environment problems.
## Documentation
- When writing or working on the documentation website (`packages/website`), MUST adhere to the writing guidelines in @packages/website/WRITING_GUIDE.md

View file

@ -1,55 +1,3 @@
# [4.7.0](https://github.com/algolia/docsearch/compare/v4.6.3...v4.7.0) (2026-07-27)
### Bug Fixes
* **agentStudio:** thread depth error message ([#2881](https://github.com/algolia/docsearch/issues/2881)) ([f68e522](https://github.com/algolia/docsearch/commit/f68e52251ce464e199f12b7a1ac907de61982993))
* **askai:** copy the full answer instead of the first text part ([#2910](https://github.com/algolia/docsearch/issues/2910)) ([96476dd](https://github.com/algolia/docsearch/commit/96476dd6f152b0aea134112f5e25afe6b1cc84f8))
* **core:** prevent search button from intercepting browser shortcuts when focused ([#2871](https://github.com/algolia/docsearch/issues/2871)) ([0e41a78](https://github.com/algolia/docsearch/commit/0e41a78c44e9f731d825a75957e5668aeae30d69))
* **docs:** fix spelling, grammar, and formatting across docs ([#2882](https://github.com/algolia/docsearch/issues/2882)) ([047a769](https://github.com/algolia/docsearch/commit/047a769791fddc31d4d6d3c535f41901a7fcd4c5))
* increase line height to avoid overlap ([#2908](https://github.com/algolia/docsearch/issues/2908)) ([2f63848](https://github.com/algolia/docsearch/commit/2f638487804d9a1e753231dba4c02634c79be217))
* **modal:** stop over-truncating snippets on mobile and wrap long hits ([#2907](https://github.com/algolia/docsearch/issues/2907)) ([9ad6d16](https://github.com/algolia/docsearch/commit/9ad6d169fee62cb9558e40aa8475f99ecd41e7fe))
* sanitize Ask AI markdown HTML to prevent XSS ([#2929](https://github.com/algolia/docsearch/issues/2929)) ([681cbfe](https://github.com/algolia/docsearch/commit/681cbfec03d742cd61b9dfa47b9dbd113f6bce8e))
* **sidepanel-js:** expose Agent Studio types in SidepanelProps ([#2906](https://github.com/algolia/docsearch/issues/2906)) ([4710d0c](https://github.com/algolia/docsearch/commit/4710d0ca77e0f12adc720a7a94b0c65a1c01b029))
### Features
* **askai:** Ask AI deprecation warning ([#2885](https://github.com/algolia/docsearch/issues/2885)) ([60a2c7b](https://github.com/algolia/docsearch/commit/60a2c7b918a39dfa31f8a540408fc4113420e5dd))
* **chore:** one trust cookie consent ([#2887](https://github.com/algolia/docsearch/issues/2887)) ([17751d3](https://github.com/algolia/docsearch/commit/17751d3bc95cf92d0feab5c7781c2ae311c67bfc))
* show page path breadcrumbs in Docusaurus search ([#2790](https://github.com/algolia/docsearch/issues/2790)) ([d3d707d](https://github.com/algolia/docsearch/commit/d3d707d8752e054daff1c1e9c559a582ce4d8ee5))
## [4.6.3](https://github.com/algolia/docsearch/compare/v4.6.2...v4.6.3) (2026-04-27)
### Features
* **agentStudio:** cost controls errors surface ([#2878](https://github.com/algolia/docsearch/issues/2878)) ([166b76d](https://github.com/algolia/docsearch/commit/166b76de2f7e728c7dd9994257fb7c6d2497a9c6))
* **agentStudio:** surface thread depth error ([#2877](https://github.com/algolia/docsearch/issues/2877)) ([cf42ab5](https://github.com/algolia/docsearch/commit/cf42ab519b7f4b80a1ae7c8425846bceab1b7bd2))
## [4.6.2](https://github.com/algolia/docsearch/compare/v4.6.1...v4.6.2) (2026-03-26)
### Bug Fixes
* rollup version ([eac7019](https://github.com/algolia/docsearch/commit/eac70196eb38794aff8c8e1256a77ad8086c4288))
### Features
* agent studio feedback integration ([#2868](https://github.com/algolia/docsearch/issues/2868)) ([f83f5a9](https://github.com/algolia/docsearch/commit/f83f5a911c84ec86dee3db167567679a092abd32))
* **askai:** add compatibility with algolia mcp search tool [DASH-2294] ([#2862](https://github.com/algolia/docsearch/issues/2862)) ([e20d30d](https://github.com/algolia/docsearch/commit/e20d30de7b2cbd297529e9b0449b1d8147858a9b))
## [4.6.1](https://github.com/algolia/docsearch/compare/v4.6.0...v4.6.1) (2026-03-24)
# [4.6.0](https://github.com/algolia/docsearch/compare/v4.5.4...v4.6.0) (2026-02-17)

View file

@ -1,4 +1,4 @@
# This file list code owners for the repository, or part of it.
# See https://help.github.com/articles/about-code-owners/
* @shortcuts @8bittitan @dylantientcheu @NatanTechofNY @vascobettencourt @pipeline1987
* @shortcuts @8bittitan @dylantientcheu @NatanTechofNY @LorrisSaintGenez @pipeline1987

View file

@ -60,12 +60,43 @@ Some examples of valid commit messages (used as first lines):
To run this project, you will need:
- Node.js ≥ 18 [nvm](https://github.com/nvm-sh/nvm#install-script) is recommended
- [Yarn](https://yarnpkg.com)
- [Bun](https://bun.sh) our package manager and build tool
## Getting started
```sh
# Install dependencies
bun install
# Build all packages
bun run build
# Run tests
bun run test
# Start development mode (watch + playground)
bun run dev
```
## Changesets
This project uses [Changesets](https://github.com/changesets/changesets) for versioning and publishing.
If your PR includes changes that should be released, add a changeset:
```sh
bun run changeset
```
Select the packages affected by your change, choose the version bump type (major/minor/patch), and write a summary that will appear in the changelog.
## Release
```sh
yarn run release
```
When changesets are merged to `main`, a "Version Packages" PR will be automatically created (or updated). Merging that PR triggers the release workflow:
It will create a pull request for the next release. When it's reviewed, approved and merged, then CircleCI will automatically publish it to npm.
1. Package versions are bumped
2. CHANGELOGs are updated
3. Packages are published to npm
4. Git tags are created
All `@docsearch/*` packages share the same version number (fixed versioning).

View file

@ -33,7 +33,7 @@ DocSearch crawls your documentation, pushes the content to an Algolia index and
#### Installation
```sh
yarn add @docsearch/js@4
bun add @docsearch/js@4
# or
npm install @docsearch/js@4
```
@ -64,7 +64,7 @@ import '@docsearch/css';
docsearch({
container: '#docsearch',
appId: 'YOUR_APP_ID',
indexName: 'YOUR_INDEX_NAME',
indices: ['YOUR_INDEX_NAME'],
apiKey: 'YOUR_SEARCH_API_KEY',
});
```
@ -74,7 +74,7 @@ docsearch({
#### Installation
```bash
yarn add @docsearch/react@4
bun add @docsearch/react@4
# or
npm install @docsearch/react@4
```
@ -111,6 +111,10 @@ export default App;
[Read documentation →](https://docsearch.algolia.com/docs/styling)
## MCP plugins
Client plugins for the public DocSearch MCP endpoint are in [`mcp/plugins/docsearch`](mcp/plugins/docsearch). They connect ChatGPT, Codex, Cursor, and Claude Code to `https://mcp.algolia.com/1/docsearch/mcp` for current public developer documentation.
## Related projects
DocSearch is made of the following repositories:

View file

@ -0,0 +1,146 @@
# @docsearch/docusaurus-adapter
## 5.0.0-beta.4
### Patch Changes
- Updated dependencies [ee9fddb]
- @docsearch/react@5.0.0-beta.4
- @docsearch/sidepanel@5.0.0-beta.4
- @docsearch/modal@5.0.0-beta.4
- @docsearch/core@5.0.0-beta.4
## 5.0.0-beta.3
### Patch Changes
- fe4d0c2: Align Ask AI dynamic indices with Agent Studio completions: `askAi.indices` is now `string[]` (index names only). Use `askAi.searchParameters` for per-index runtime overrides.
- c6a1ce1: fix(docusaurus-adapter): Docusaurus styling cleanup
- Updated dependencies [3f74c33]
- Updated dependencies [b33a7f7]
- Updated dependencies [fe4d0c2]
- Updated dependencies [4e44b55]
- Updated dependencies [9a1b3e4]
- Updated dependencies [b28bc84]
- @docsearch/core@5.0.0-beta.3
- @docsearch/react@5.0.0-beta.3
- @docsearch/modal@5.0.0-beta.3
- @docsearch/sidepanel@5.0.0-beta.3
## 5.0.0-beta.2
### Patch Changes
- 5eac1fd: feat(v5): General UI styling updates and fixes
- New `--docsearch-font-family` variable, used by the search button, keyboard
keys, modal, and sidepanel. It replaces the system font stacks that were
duplicated across `sidepanel.css` and `button.css`, so overriding one
variable now themes every DocSearch surface
- The search input and modal heading are `1rem` at every breakpoint
(previously `0.875rem` with a mobile-only override)
- `.DocSearch-Title` uses `line-height: 1.5em` instead of `0.5em` and adds
`overflow-wrap: anywhere`, so long titles wrap instead of overlapping (#2908)
- Hit icon `svg` sizing moved into `.DocSearch-Hit-icon`. The
`.DocSearch-Hit-icon--small` modifier is replaced by
`.DocSearch-Hit-icon--start`, which top-aligns the icon; recent
conversations use it
- The Ask AI button icon centers with `display: inline-flex` instead of
`margin-block-start`/`align-self` overrides
- Removed the 2px offset on the Ask AI sources action text
- `SourcesPanel` accepts `pluralTitleText`. `titleText` is now the singular
label, and the trigger renders `{count} {label}`
- `AskAiScreenTranslations` and `ConversationScreenTranslations` expose
`relatedSourcesTextPlural`
- Docusaurus adapter: `theme.SearchModal.askAiScreen.relatedSourcesText` is
now the singular "Source", and the new
`theme.SearchModal.askAiScreen.relatedSourcesTextPlural` provides "Sources"
- 4f6b5b1: feat(v5): UI and DX updates
- Rename the Ask AI assistantId option to agentId (adapter theme.SearchModal.askAi.assistantId → agentId)
- appId and apiKey moved up into @docsearch/core, so they're configured once and shared
- Removed the indexName prop from the Sidepanel
- Facet defaults can now be read from the index searchParameters
- Restored nested grouping of search results
- Updated dependencies [5eac1fd]
- Updated dependencies [4f6b5b1]
- Updated dependencies [9fe6738]
- @docsearch/react@5.0.0-beta.2
- @docsearch/sidepanel@5.0.0-beta.2
- @docsearch/modal@5.0.0-beta.2
- @docsearch/core@5.0.0-beta.2
## 5.0.0-beta.1
### Patch Changes
- 09861c8: Align CSS class and animation names with the `DocSearch-` naming convention.
- The `shimmer` utility class is now `DocSearch-shimmer`
- Fixed a typo in `DocSearck-AskAiScreen-MessageContent-Stopped`, now
`DocSearch-AskAiScreen-MessageContent-Stopped`
- Keyframes renamed to kebab-case: `shimmerText``shimmer-text`,
`slideDown``slide-down`, `fadeIn``fade-in`
- Dark theme selectors use `:root[data-theme='dark']` instead of
`html[data-theme='dark']`
- Dropped redundant type qualifiers from `.DocSearch-Hits-padded` and
`.DocSearch-Hit-Select-Icon` selectors, slightly lowering their specificity
If you override these classes or keyframes in custom styles, update your
selectors accordingly.
- Updated dependencies [f8e0678]
- Updated dependencies [09861c8]
- @docsearch/react@5.0.0-beta.1
- @docsearch/modal@5.0.0-beta.1
- @docsearch/sidepanel@5.0.0-beta.1
- @docsearch/core@5.0.0-beta.1
## 5.0.0-beta.0
### Major Changes
- a8ed1ea: # DocSearch v5-beta
DocSearch v5 is a major release that introduces AI-powered answers via Agent
Studio, a refreshed search UI, and a modernized package architecture.
## Ask AI & Agent Studio
- Agent Studio integration with core tools and dynamic tool calls
- Conversation memory support
- Compatibility with the Algolia MCP search tool, with aggregated MCP search
tool calls
- Dynamic index selection for Agent Studio
- Feedback integration, including feedback notes and tags
- Prompt suggestions in keyword search and follow-up prompt suggestions
- Ask AI modal split into its own component and Ask AI transport layer removed
## Search UI
- Refreshed v5 UI with improved dark theme, sources panel, and accessibility
- Faceted search with filter chips
- Hit breadcrumbs and result badges
## Packaging & architecture
- New `@docsearch/cli` package for MCP setup and search
- MCP plugin support
- Split JS bundles for search-only usage and JS-based hybrid mode
- Migrated the build system to tsdown
- Migrated CSS building to LightningCSS
## Breaking changes
- Ask AI related props are now nested under a single root `askai` option
- The Ask AI transport layer has been removed
### Patch Changes
- Updated dependencies [a8ed1ea]
- @docsearch/core@5.0.0-beta.0
- @docsearch/modal@5.0.0-beta.0
- @docsearch/react@5.0.0-beta.0
- @docsearch/sidepanel@5.0.0-beta.0

View file

@ -14,14 +14,14 @@ export default {
docsearch: {
appId: 'APP_ID',
apiKey: 'SEARCH_API_KEY',
indexName: 'INDEX_NAME',
indices: [{ name: 'INDEX_NAME' }],
askAi: {
assistantId: 'ASSISTANT_ID',
sidePanel: true,
},
sidePanel: true,
},
},
};
```
`themeConfig.algolia` is still supported as a backward-compatible alias.
Only `themeConfig.docsearch` is supported.

View file

@ -1,6 +1,6 @@
{
"name": "@docsearch/docusaurus-adapter",
"version": "4.7.0",
"version": "5.0.0-beta.4",
"description": "Algolia search component for Docusaurus.",
"main": "lib/index.js",
"sideEffects": [
@ -27,35 +27,39 @@
},
"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",
"build": "bun x tsc --build --force && node ./scripts/copy-assets.mjs && oxfmt --config ../../.oxfmtrc.json ./lib/theme/**/*",
"build:clean": "bun run clean && bun run build",
"clean": "rm -rf lib tsconfig.*.tsbuildinfo",
"watch": "run-p -c copy:watch build:watch",
"build:watch": "yarn exec tsc --build --watch",
"build:watch": "bun x tsc --build --watch",
"copy:watch": "node ./scripts/copy-assets.mjs --watch"
},
"dependencies": {
"@docsearch/react": "4.7.0",
"@docusaurus/core": "^3.9.2",
"@docusaurus/plugin-content-docs": "^3.9.2",
"@docusaurus/theme-common": "^3.9.2",
"@docusaurus/theme-translations": "^3.9.2",
"@algolia/autocomplete-core": "1.19.2",
"@docsearch/core": "workspace:*",
"@docsearch/modal": "workspace:*",
"@docsearch/react": "workspace:*",
"@docsearch/sidepanel": "workspace:*",
"@docusaurus/core": "^3.10.2",
"@docusaurus/plugin-content-docs": "^3.10.2",
"@docusaurus/theme-common": "^3.10.2",
"@docusaurus/theme-translations": "^3.10.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"
"search-insights": "^2.17.3",
"tslib": "^2.6.0"
},
"devDependencies": {
"@docusaurus/core": "^3.9.2",
"@docusaurus/module-type-aliases": "3.9.2",
"@docusaurus/theme-classic": "3.9.2",
"@docusaurus/core": "^3.10.2",
"@docusaurus/module-type-aliases": "3.10.2",
"@docusaurus/theme-classic": "3.10.2",
"@docusaurus/types": "^3.10.2",
"@types/fs-extra": "^11.0.4",
"@types/lodash": "^4.17.10",
"glob": "^13.0.6",
"typescript": "5.7.3"
},
"peerDependencies": {

View file

@ -39,7 +39,7 @@ async function copyAssetsOnce() {
}
return ASSET_EXTENSIONS.has(extension);
})
.map((filePath) => copyAssetFile(filePath)),
.map((filePath) => copyAssetFile(filePath))
);
}

View file

@ -1,16 +0,0 @@
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)}`);
}
}

View file

@ -1,8 +1,8 @@
/**
* 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.
* 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';
@ -17,7 +17,11 @@ describe('mergeFacetFilters', () => {
});
it('merges [string,array]', () => {
assert.deepStrictEqual(mergeFacetFilters('f1', ['f2', 'f3']), ['f1', 'f2', 'f3']);
assert.deepStrictEqual(mergeFacetFilters('f1', ['f2', 'f3']), [
'f1',
'f2',
'f3',
]);
});
it('merges [string,undefined]', () => {
@ -29,16 +33,27 @@ describe('mergeFacetFilters', () => {
});
it('merges [array,undefined]', () => {
assert.deepStrictEqual(mergeFacetFilters(['f1', 'f2'], undefined), ['f1', 'f2']);
assert.deepStrictEqual(mergeFacetFilters(['f1', 'f2'], undefined), [
'f1',
'f2',
]);
});
it('merges [undefined,array]', () => {
assert.deepStrictEqual(mergeFacetFilters(undefined, ['f1', 'f2']), ['f1', 'f2']);
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']);
assert.deepStrictEqual(mergeFacetFilters(['f1', 'f2'], ['f3', 'f4']), [
'f1',
'f2',
'f3',
'f4',
]);
});
});

View file

@ -1,8 +1,8 @@
/**
* 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.
* 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';

View file

@ -1,121 +1,112 @@
/**
* 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.
* 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 { DocSearchAskAi, DocSearchProps } from '@docsearch/react';
import type { FacetFilters } from 'algoliasearch/lite';
import { useCallback, useMemo, useState } from 'react';
import { useMemo } from 'react';
import { useAlgoliaContextualFacetFiltersIfEnabled } from './useAlgoliaContextualFacetFilters';
import { mergeFacetFilters } from './utils';
type AskAiOptions = AskAiConfig & Pick<DocSearchAskAi, 'tools'>;
// 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;
indices: NonNullable<DocSearchProps['indices']>;
askAi?: AskAiOptions;
}
type OnAskAiToggle = NonNullable<DocSearchModalProps['onAskAiToggle']>;
type AskAiConfigWithoutSidePanel = Omit<AskAiConfig, 'sidePanel'>;
type DocSearchAskAi = Exclude<DocSearchModalProps['askAi'], string | undefined>;
type DocSearchModalPropsLite = Partial<Omit<DocSearchModalProps, 'askAi'>>;
type UseAskAiResult = {
canHandleAskAi: boolean;
isAskAiActive: boolean;
currentPlaceholder: string | undefined;
onAskAiToggle: OnAskAiToggle;
askAi?: AskAiConfig;
extraAskAiProps: DocSearchModalPropsLite & {
askAi?: DocSearchAskAi;
canHandleAskAi?: boolean;
isAskAiActive?: boolean;
onAskAiToggle?: OnAskAiToggle;
modalAskAi?: DocSearchAskAi;
sidePanelAskAi?: DocSearchAskAi & {
apiKey: string;
appId: string;
indexName: string;
};
};
function getIndexName(
index: NonNullable<DocSearchProps['indices']>[number]
): string {
return typeof index === 'string' ? index : index.name;
}
function getAskAiIndexName(
askAi: AskAiConfig,
indices: NonNullable<DocSearchProps['indices']>
): string {
return askAi.indices?.[0] ?? getIndexName(indices[0]!);
}
function facetFiltersToFilterString(facetFilters: FacetFilters): string {
const items = Array.isArray(facetFilters) ? facetFilters : [facetFilters];
return items
.map((item) =>
Array.isArray(item) ? `(${item.join(' OR ')})` : String(item)
)
.join(' AND ');
}
function mergeFilters(existing: string | undefined, added: string): string {
return existing ? `(${existing}) AND (${added})` : added;
}
// 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
// can only be determined at runtime. Agent Studio accepts them via
// askAi.searchParameters[index].filters, keyed by dynamic index names.
function applyAskAiContextualSearch(
askAi: AskAiConfig | undefined,
contextualSearchFilters: FacetFilters | undefined,
): AskAiConfig | undefined {
if (!askAi) {
return undefined;
}
if (askAi.agentStudio === true) {
askAi: AskAiOptions | undefined,
contextualSearchFilters: FacetFilters | undefined
): AskAiOptions | undefined {
if (!askAi || !contextualSearchFilters || !askAi.indices?.length) {
return askAi;
}
if (!contextualSearchFilters) {
return askAi;
const contextualFilters = facetFiltersToFilterString(contextualSearchFilters);
const searchParameters = { ...askAi.searchParameters };
for (const indexName of askAi.indices) {
const current = searchParameters[indexName] ?? {};
searchParameters[indexName] = {
...current,
filters: mergeFilters(current.filters, contextualFilters),
};
}
const askAiFacetFilters = askAi.searchParameters?.facetFilters;
return {
...askAi,
searchParameters: {
...askAi.searchParameters,
facetFilters: mergeFacetFilters(askAiFacetFilters, contextualSearchFilters),
},
searchParameters,
};
}
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<AskAiConfigWithoutSidePanel | undefined>(() => {
const resolvedAskAi = useMemo<UseAskAiResult['sidePanelAskAi']>(() => {
if (!askAi) {
return undefined;
}
const { sidePanel: _sidePanel, ...docsearchAskAi } = askAi;
return docsearchAskAi;
}, [askAi]);
const modalAskAi = useMemo<DocSearchAskAi | undefined>(() => {
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<OnAskAiToggle>((askAiToggle: boolean) => {
setIsAskAiActive(askAiToggle);
}, []);
const extraAskAiProps: UseAskAiResult['extraAskAiProps'] = {
askAi: modalAskAi,
canHandleAskAi,
isAskAiActive,
onAskAiToggle,
};
return {
...askAi,
apiKey: props.apiKey,
appId: props.appId,
indexName: getAskAiIndexName(askAi, props.indices),
};
}, [askAi, props.apiKey, props.appId, props.indices]);
return {
canHandleAskAi,
isAskAiActive,
currentPlaceholder,
onAskAiToggle,
askAi,
extraAskAiProps,
modalAskAi: resolvedAskAi,
sidePanelAskAi: resolvedAskAi,
};
}

View file

@ -1,118 +1,42 @@
/**
* 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.
* 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';
import type { ThemeConfigDocSearch } from '@docsearch/docusaurus-adapter';
import { useMemo } from 'react';
type AskAiTogglePayload = {
query: string;
messageId?: string;
suggestedQuestionId?: string;
};
type SidepanelOptions = Exclude<NonNullable<AskAiConfig['sidePanel']>, boolean>;
type SidepanelOptions = Exclude<
NonNullable<ThemeConfigDocSearch['sidePanel']>,
boolean
>;
type UseAlgoliaAskAiSidepanelParams = {
askAiConfig?: AskAiConfig;
importSidepanel: () => Promise<void>;
sidePanel?: ThemeConfigDocSearch['sidePanel'];
};
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<void>;
};
export function useAlgoliaAskAiSidepanel({
askAiConfig,
importSidepanel,
sidePanel,
}: UseAlgoliaAskAiSidepanelParams): UseAlgoliaAskAiSidepanelResult {
const [isSidepanelOpen, setIsSidepanelOpen] = useState(false);
const [sidepanelInitialMessage, setSidepanelInitialMessage] = useState<AskAiTogglePayload | undefined>(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 sidePanelOptions = useMemo(
() => (typeof sidePanel === 'object' ? sidePanel : undefined),
[sidePanel]
);
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);
}, []);
const sidePanelEnabled = Boolean(sidePanel);
const showSidepanelButton =
sidePanelEnabled && sidePanelOptions?.hideButton !== true;
return {
sidePanelEnabled,
showSidepanelButton,
sidePanelOptions,
sidePanelAgentStudio,
sidepanelPortalContainer,
isSidepanelOpen,
sidepanelInitialMessage,
openSidepanel,
closeSidepanel,
toggleSidepanel,
handleSidepanelOpen,
loadSidepanel,
};
}

View file

@ -1,8 +1,8 @@
/**
* 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.
* 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';
@ -45,7 +45,9 @@ export function useAlgoliaContextualFacetFilters(): FacetFilters {
return [languageFilter, tagsFilter];
}
export function useAlgoliaContextualFacetFiltersIfEnabled(): FacetFilters | undefined {
export function useAlgoliaContextualFacetFiltersIfEnabled():
| FacetFilters
| undefined {
const { contextualSearch } = useAlgoliaThemeConfig();
const facetFilters = useAlgoliaContextualFacetFilters();
if (contextualSearch) {

View file

@ -1,15 +1,18 @@
import type {
ThemeConfig,
ThemeConfigDocSearch,
} from '@docsearch/docusaurus-adapter';
/**
* 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.
* 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 {
export function useAlgoliaThemeConfig(): ThemeConfigDocSearch {
const {
siteConfig: { themeConfig },
} = useDocusaurusContext();

View file

@ -1,11 +1,11 @@
/**
* 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.
* 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 type { ThemeConfigDocSearch } from '@docsearch/docusaurus-adapter';
import { isRegexpStringMatch } from '@docusaurus/theme-common';
import { useBaseUrlUtils } from '@docusaurus/useBaseUrl';
import { useCallback } from 'react';
@ -14,10 +14,13 @@ import { useAlgoliaThemeConfig } from './useAlgoliaThemeConfig';
function replacePathname(
pathname: string,
replaceSearchResultPathname: ThemeConfigAlgolia['replaceSearchResultPathname'],
replaceSearchResultPathname: ThemeConfigDocSearch['replaceSearchResultPathname']
): string {
return replaceSearchResultPathname
? pathname.replaceAll(new RegExp(replaceSearchResultPathname.from, 'g'), replaceSearchResultPathname.to)
? pathname.replaceAll(
new RegExp(replaceSearchResultPathname.from, 'g'),
replaceSearchResultPathname.to
)
: pathname;
}
@ -27,7 +30,8 @@ function replacePathname(
*/
export function useSearchResultUrlProcessor(): (url: string) => string {
const { withBaseUrl } = useBaseUrlUtils();
const { externalUrlRegex, replaceSearchResultPathname } = useAlgoliaThemeConfig();
const { externalUrlRegex, replaceSearchResultPathname } =
useAlgoliaThemeConfig();
return useCallback(
(url: string) => {
@ -41,8 +45,10 @@ export function useSearchResultUrlProcessor(): (url: string) => string {
// Otherwise => transform to relative URL for SPA navigation
const relativeUrl = `${parsedURL.pathname}${parsedURL.search}${parsedURL.hash}`;
return withBaseUrl(replacePathname(relativeUrl, replaceSearchResultPathname));
return withBaseUrl(
replacePathname(relativeUrl, replaceSearchResultPathname)
);
},
[withBaseUrl, externalUrlRegex, replaceSearchResultPathname],
[withBaseUrl, externalUrlRegex, replaceSearchResultPathname]
);
}

View file

@ -1,19 +1,25 @@
/**
* 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.
* 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,
f2: FacetFilters
): FacetFilters;
export function mergeFacetFilters(
f1: FacetFilters | undefined,
f2: FacetFilters | undefined,
f2: FacetFilters | undefined
): FacetFilters | undefined;
export function mergeFacetFilters(
f1: FacetFilters | undefined,
f2: FacetFilters | undefined
): FacetFilters | undefined {
if (f1 === undefined) {
return f2;
@ -22,6 +28,7 @@ export function mergeFacetFilters(
return f1;
}
const normalize = (f: FacetFilters): FacetFilters => (typeof f === 'string' ? [f] : f);
const normalize = (f: FacetFilters): FacetFilters =>
typeof f === 'string' ? [f] : f;
return [...normalize(f1), ...normalize(f2)];
}

View file

@ -1,11 +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.
* 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';
@ -14,6 +13,6 @@ declare module 'eta' {
export function compile(
template: string,
options?: Record<string, unknown>,
options?: Record<string, unknown>
): (data: Record<string, unknown>, config: Record<string, unknown>) => string;
}

View file

@ -1,10 +0,0 @@
/**
* 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;

View file

@ -1,26 +1,23 @@
/**
* 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.
* 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 {
ThemeConfig,
ThemeConfigDocSearch,
} from '@docsearch/docusaurus-adapter';
export function hasLegacyAlgoliaConfig(themeConfig: ThemeConfig): boolean {
return Boolean(themeConfig.algolia);
}
export function getDocSearchConfig(themeConfig: ThemeConfig): ThemeConfigAlgolia {
export function getDocSearchConfig(
themeConfig: ThemeConfig
): ThemeConfigDocSearch {
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).',
'No DocSearch config found. Please provide "themeConfig.docsearch".'
);
}

View file

@ -1,41 +1,30 @@
/**
* 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.
* 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 { getDocSearchConfig } 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<void> {
const {
baseUrl,
siteConfig: { themeConfig },
i18n: { currentLocale },
} = context;
const { searchPagePath } = getDocSearchConfig(themeConfig as ThemeConfig);
const classicPresetWithLegacyAlgoliaConfig =
hasClassicPreset(context) && hasLegacyAlgoliaConfig(themeConfig as ThemeConfig);
const { searchPage } = getDocSearchConfig(themeConfig as ThemeConfig);
const searchPagePath = searchPage === false ? false : searchPage.path;
return {
name: 'docsearch-docusaurus-algolia-search',
@ -55,9 +44,7 @@ export default function themeSearchAlgolia(context: LoadContext): Plugin<void> {
},
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) {
if (searchPagePath) {
addRoute({
path: normalizeUrl([baseUrl, searchPagePath]),
component: '@theme/SearchPage',

View file

@ -1,8 +1,8 @@
/**
* 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.
* 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';
@ -11,13 +11,20 @@ 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()));
let compiledOpenSearchTemplate: ReturnType<typeof compile> | null = null;
function getCompiledOpenSearchTemplate(): ReturnType<typeof compile> {
if (!compiledOpenSearchTemplate) {
compiledOpenSearchTemplate = compile(openSearchTemplate.trim());
}
return compiledOpenSearchTemplate;
}
function renderOpenSearchTemplate(data: {
title: string;
@ -31,14 +38,19 @@ function renderOpenSearchTemplate(data: {
const OPEN_SEARCH_FILENAME = 'opensearch.xml';
export function shouldCreateOpenSearchFile({ context }: { context: LoadContext }): boolean {
export function shouldCreateOpenSearchFile({
context,
}: {
context: LoadContext;
}): boolean {
const {
siteConfig: {
themeConfig,
future: { experimental_router: router },
},
} = context;
const { searchPagePath } = getDocSearchConfig(themeConfig as ThemeConfig);
const { searchPage } = getDocSearchConfig(themeConfig as ThemeConfig);
const searchPagePath = searchPage === false ? false : searchPage.path;
return Boolean(searchPagePath) && router !== 'hash';
}
@ -65,14 +77,19 @@ function createOpenSearchFileContent({
});
}
export async function createOpenSearchFile({ context }: { context: LoadContext }): Promise<void> {
export async function createOpenSearchFile({
context,
}: {
context: LoadContext;
}): Promise<void> {
const {
outDir,
siteConfig: { themeConfig },
} = context;
const { searchPagePath } = getDocSearchConfig(themeConfig as ThemeConfig);
const { searchPage } = getDocSearchConfig(themeConfig as ThemeConfig);
const searchPagePath = searchPage === false ? false : searchPage.path;
if (!searchPagePath) {
throw new Error('no searchPagePath provided in themeConfig.docsearch or themeConfig.algolia');
throw new Error('no searchPage.path provided in themeConfig.docsearch');
}
const fileContent = createOpenSearchFileContent({ context, searchPagePath });
try {
@ -84,7 +101,11 @@ export async function createOpenSearchFile({ context }: { context: LoadContext }
}
}
export function createOpenSearchHeadTags({ context }: { context: LoadContext }): HtmlTags {
export function createOpenSearchHeadTags({
context,
}: {
context: LoadContext;
}): HtmlTags {
const {
baseUrl,
siteConfig: { title },

View file

@ -1,8 +1,8 @@
/**
* 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.
* This source code is licensed under the MIT license found in the LICENSE file
* in the root directory of this source tree.
*/
export default `

View file

@ -1,97 +1,120 @@
/**
* 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.
* 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 {
AgentStudioSearchParameters,
DocSearchAskAi,
DocSearchProps,
ToolCalls,
} 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 DocusaurusSidePanelConfig =
| boolean
| (Omit<SidepanelProps, 'tools'> & { hideButton?: boolean });
type DocusaurusSearchBarSidePanelProps =
| boolean
| (Omit<SidepanelProps, 'tools'> & {
hideButton?: boolean;
tools?: ToolCalls;
});
type SearchPageFacetConfig = {
/**
* Algolia attribute to build a refinement list from (e.g.
* `hierarchy.lvl0`).
*/
attribute: string;
/** Human-readable label displayed above the refinement list. */
label?: string;
};
type AgentStudioSearchParameters = Record<string, Omit<AskAiSearchParameters, 'facetFilters'>>;
type SearchPageConfig =
| false
| {
path?: string;
/**
* Facets exposed as refinement lists in the search page sidebar.
* Defaults to a single "Section" facet built from `hierarchy.lvl0`.
*/
facets?: SearchPageFacetConfig[];
};
// 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;
}
);
agentId: DocSearchAskAi['agentId'];
suggestedQuestions?: DocSearchAskAi['suggestedQuestions'];
searchParameters?: AgentStudioSearchParameters;
indices?: string[];
memory?: DocSearchAskAi['memory'];
promptSuggestions?: DocSearchAskAi['promptSuggestions'];
};
export type DocusaurusSearchBarAskAiProps = AskAiConfig &
Pick<DocSearchAskAi, 'tools'>;
// DocSearch props that Docusaurus exposes directly through props forwarding
type DocusaurusDocSearchProps = Pick<
DocSearchProps,
'apiKey' | 'appId' | 'indexName' | 'initialQuery' | 'insights' | 'placeholder' | 'searchParameters' | 'translations'
| 'apiKey'
| 'appId'
| 'disableUserPersonalization'
| 'facets'
| 'getMissingResultsUrl'
| 'indices'
| 'initialQuery'
| 'insights'
| 'keyboardShortcuts'
| 'maxResultsPerGroup'
| 'placeholder'
| 'recentSearchesLimit'
| 'recentSearchesWithFavoritesLimit'
| 'resultBadgeKey'
| 'translations'
> & {
// Docusaurus normalizes the AskAI config to an object
indices: NonNullable<DocSearchProps['indices']>;
askAi?: AskAiConfig;
sidePanel?: DocusaurusSidePanelConfig;
};
export type ThemeConfigAlgolia = DocusaurusDocSearchProps & {
indexName: string;
export type ThemeConfigDocSearch = DocusaurusDocSearchProps & {
// Docusaurus custom options, not coming from DocSearch
contextualSearch: boolean;
externalUrlRegex?: string;
searchPagePath: string | false | null;
searchPage: SearchPageConfig;
replaceSearchResultPathname?: {
from: string;
to: string;
};
};
type UserDocSearchConfig = Overwrite<
DeepPartial<ThemeConfigAlgolia>,
{
// Required fields:
appId: ThemeConfigAlgolia['appId'];
apiKey: ThemeConfigAlgolia['apiKey'];
indexName: ThemeConfigAlgolia['indexName'];
// askAi also accepts a shorter string form
askAi?: Optional<AskAiConfig, 'apiKey' | 'appId' | 'indexName'> | string;
export type DocusaurusSearchBarProps = Partial<
Omit<ThemeConfigDocSearch, 'askAi' | 'sidePanel'> & {
askAi?: DocusaurusSearchBarAskAiProps;
sidePanel?: DocusaurusSearchBarSidePanelProps;
}
>;
type UserDocSearchConfig = Omit<
Partial<ThemeConfigDocSearch>,
'apiKey' | 'appId' | 'askAi' | 'indices'
> & {
appId: ThemeConfigDocSearch['appId'];
apiKey: ThemeConfigDocSearch['apiKey'];
indices: ThemeConfigDocSearch['indices'];
askAi?: AskAiConfig;
};
export type ThemeConfig = {
// Preferred key.
docsearch?: ThemeConfigAlgolia;
// Backward-compatible alias.
algolia?: ThemeConfigAlgolia;
docsearch?: ThemeConfigDocSearch;
};
export type UserThemeConfig = {
// Preferred key.
docsearch?: UserDocSearchConfig;
// Backward-compatible alias.
algolia?: UserDocSearchConfig;
};
}
@ -102,9 +125,12 @@ declare module '@theme/SearchPage' {
}
declare module '@theme/SearchBar' {
import type { DocusaurusSearchBarProps } from '@docsearch/docusaurus-adapter';
import type { ReactNode } from 'react';
export default function SearchBar(): ReactNode;
export default function SearchBar(
props?: DocusaurusSearchBarProps
): ReactNode;
}
declare module '@theme/SearchTranslations' {

View file

@ -1,25 +1,34 @@
/* eslint-disable import/dynamic-import-chunkname */
/**
* 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.
* 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 { DocSearch as DocSearchProvider, useDocSearch } from '@docsearch/core';
import type {
InternalDocSearchHit,
DocSearchModal as DocSearchModalType,
DocSearchModalProps,
StoredDocSearchHit,
DocSearchTransformClient,
DocusaurusSearchBarAskAiProps,
ThemeConfigDocSearch,
} from '@docsearch/docusaurus-adapter';
import type { DocSearchAskAiModal as DocSearchAskAiModalType } from '@docsearch/modal/askai';
import { DocSearchButton } from '@docsearch/modal/button';
import type { DocSearchModal as DocSearchModalType } from '@docsearch/modal/modal';
import type {
DocSearchAskAiModalProps,
DocSearchHit,
DocSearchModalProps,
DocSearchProps,
DocSearchTransformClient,
DocSearchTranslations,
InternalDocSearchHit,
StoredDocSearchHit,
ToolCalls,
} 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 { SidepanelButton } from '@docsearch/sidepanel/button';
import type { Sidepanel as SidepanelType } from '@docsearch/sidepanel/sidepanel';
import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
import Head from '@docusaurus/Head';
import Link from '@docusaurus/Link';
import { useHistory } from '@docusaurus/router';
@ -28,74 +37,100 @@ 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 React, {
useCallback,
useEffect,
useMemo,
useState,
type JSX,
type ReactNode,
} from 'react';
import {
mergeFacetFilters,
useAlgoliaAskAi,
useAlgoliaAskAiSidepanel,
useAlgoliaContextualFacetFilters,
useAlgoliaThemeConfig,
useSearchResultUrlProcessor,
useAlgoliaAskAi,
useAlgoliaAskAiSidepanel,
mergeFacetFilters,
} from '../../client';
type DocSearchProps = Omit<DocSearchModalProps, 'initialScrollY' | 'onClose'> & {
contextualSearch?: string;
type NavigatorNavigateParams = Parameters<
NonNullable<NonNullable<DocSearchModalProps['navigator']>['navigate']>
>[0];
type SidePanelOptions = Exclude<
NonNullable<ThemeConfigDocSearch['sidePanel']>,
boolean
> & { tools?: ToolCalls };
type SidePanelPanelOptions = Omit<
SidePanelOptions,
'hideButton' | 'keyboardShortcuts'
>;
type AskAiOptions = NonNullable<ThemeConfigDocSearch['askAi']> &
Pick<DocusaurusSearchBarAskAiProps, 'tools'>;
type AdapterDocSearchProps = Omit<
DocSearchAskAiModalProps,
| 'askAi'
| 'initialScrollY'
| 'isAskAiActive'
| 'isHybridModeSupported'
| 'onAskAiToggle'
| 'onClose'
> & {
appId: DocSearchProps['appId'];
apiKey: DocSearchProps['apiKey'];
askAi?: AskAiOptions;
contextualSearch?: boolean;
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<NonNullable<NonNullable<DocSearchModalProps['navigator']>['navigate']>>[0];
interface AlgoliaSearchBarProps extends Omit<DocSearchProps, 'askAi'> {
indexName: string;
askAi?: ThemeConfigAlgolia['askAi'];
indices: NonNullable<DocSearchProps['indices']>;
searchPage: ThemeConfigDocSearch['searchPage'];
sidePanel?: SidePanelOptions | boolean;
translations?: DocSearchTranslations;
}
};
let DocSearchModal: typeof DocSearchModalType | null = null;
let DocSearchAskAiModal: typeof DocSearchAskAiModalType | null = null;
let DocSearchSidepanel: typeof SidepanelType | null = null;
function importDocSearchModalIfNeeded(): Promise<void> {
if (DocSearchModal) {
if (DocSearchModal && DocSearchAskAiModal) {
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 }]) => {
return Promise.all([
import('@docsearch/modal/modal'),
import('@docsearch/modal/askai'),
import('@docsearch/react/style'),
import('./styles.css'),
]).then(
([{ DocSearchModal: Modal }, { DocSearchAskAiModal: AskAiModal }]) => {
DocSearchModal = Modal;
},
DocSearchAskAiModal = AskAiModal;
}
);
}
async function importDocSearchSidepanelIfNeeded(): Promise<void> {
await importDocSearchModalIfNeeded();
function importDocSearchSidepanelIfNeeded(): Promise<void> {
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;
},
);
return Promise.all([
import('@docsearch/sidepanel/sidepanel'),
import('@docsearch/react/style/sidepanel'),
]).then(([{ Sidepanel }]) => {
DocSearchSidepanel = Sidepanel;
});
}
function useNavigator({
externalUrlRegex,
}: Pick<DocSearchProps, 'externalUrlRegex'>): DocSearchModalProps['navigator'] {
}: Pick<
AdapterDocSearchProps,
'externalUrlRegex'
>): DocSearchModalProps['navigator'] {
const history = useHistory();
const [navigator] = useState<DocSearchModalProps['navigator']>(() => {
return {
@ -122,23 +157,27 @@ function useTransformSearchClient(): DocSearchModalProps['transformSearchClient'
searchClient.addAlgoliaAgent('docusaurus', docusaurusVersion);
return searchClient;
},
[docusaurusVersion],
[docusaurusVersion]
);
}
function useTransformItems(props: Pick<DocSearchProps, 'transformItems'>) {
function useTransformItems(
props: Pick<AdapterDocSearchProps, 'transformItems'>
): DocSearchModalProps['transformItems'] {
const processSearchResultUrl = useSearchResultUrlProcessor();
const [transformItems] = useState<DocSearchModalProps['transformItems']>(() => {
return (items: DocSearchHit[]) =>
props.transformItems
? // Custom transformItems
props.transformItems(items)
: // Default transformItems
items.map((item) => ({
...item,
url: processSearchResultUrl(item.url),
}));
});
const [transformItems] = useState<DocSearchModalProps['transformItems']>(
() => {
return (items: DocSearchHit[]) =>
props.transformItems
? // Custom transformItems
props.transformItems(items)
: // Default transformItems
items.map((item) => ({
...item,
url: processSearchResultUrl(item.url),
}));
}
);
return transformItems;
}
@ -148,17 +187,29 @@ function useResultsFooterComponent({
}: {
closeModal: () => void;
searchPagePath?: string;
}): DocSearchProps['resultsFooterComponent'] {
}): DocSearchModalProps['resultsFooterComponent'] {
return useMemo(
() =>
searchPagePath
? ({ state }) => <ResultsFooter state={state} searchPagePath={searchPagePath} onClose={closeModal} />
? ({ state }) => (
<ResultsFooter
state={state}
searchPagePath={searchPagePath}
onClose={closeModal}
/>
)
: undefined,
[closeModal, searchPagePath],
[closeModal, searchPagePath]
);
}
function Hit({ hit, children }: { hit: InternalDocSearchHit | StoredDocSearchHit; children: ReactNode }) {
function Hit({
hit,
children,
}: {
hit: InternalDocSearchHit | StoredDocSearchHit;
children: ReactNode;
}): JSX.Element {
return <Link to={hit.url}>{children}</Link>;
}
@ -168,7 +219,11 @@ type ResultsFooterProps = {
searchPagePath: string;
};
function ResultsFooter({ state, onClose, searchPagePath }: ResultsFooterProps) {
function ResultsFooter({
state,
onClose,
searchPagePath,
}: ResultsFooterProps): JSX.Element {
const searchPageLink = useBaseUrl(searchPagePath);
const nbHits = (state.context as { nbHits?: number }).nbHits ?? 0;
const searchLink = state.query
@ -184,125 +239,140 @@ function ResultsFooter({ state, onClose, searchPagePath }: ResultsFooterProps) {
);
}
function useSearchParameters({ contextualSearch, ...props }: DocSearchProps): DocSearchProps['searchParameters'] {
function useSearchIndices({
contextualSearch,
indices,
}: Pick<
AdapterDocSearchProps,
'contextualSearch' | 'indices'
>): AdapterDocSearchProps['indices'] {
const contextualSearchFacetFilters = useAlgoliaContextualFacetFilters();
const configFacetFilters: FacetFilters = props.searchParameters?.facetFilters ?? [];
return useMemo(() => {
if (!contextualSearch) {
return indices;
}
const facetFilters: FacetFilters = contextualSearch
? // Merge contextual search filters with config filters
mergeFacetFilters(contextualSearchFacetFilters, configFacetFilters)
: // ... or use config facetFilters
configFacetFilters;
return indices.map((index) => {
if (typeof index === 'string') {
return {
name: index,
searchParameters: {
facetFilters: contextualSearchFacetFilters,
},
};
}
// We let users override default searchParameters if they want to
return {
...props.searchParameters,
facetFilters,
};
return {
...index,
searchParameters: {
...index.searchParameters,
facetFilters: mergeFacetFilters(
index.searchParameters?.facetFilters,
contextualSearchFacetFilters
),
},
};
});
}, [contextualSearch, contextualSearchFacetFilters, indices]);
}
function DocSearch({ externalUrlRegex, ...props }: AlgoliaSearchBarProps) {
function getSearchPagePath(
searchPage: ThemeConfigDocSearch['searchPage']
): string | undefined {
return searchPage === false ? undefined : searchPage.path;
}
function getSidePanelPanelOptions(
sidePanelOptions?: SidePanelOptions
): SidePanelPanelOptions {
if (!sidePanelOptions) {
return {};
}
const {
hideButton: _hideButton,
keyboardShortcuts: _keyboardShortcuts,
...panelOptions
} = sidePanelOptions;
return panelOptions;
}
function DocSearch({
askAi,
contextualSearch,
externalUrlRegex,
searchPage,
sidePanel,
...props
}: AdapterDocSearchProps) {
const navigator = useNavigator({ externalUrlRegex });
const searchParameters = useSearchParameters({ ...props } as DocSearchProps);
const indices = useSearchIndices({
contextualSearch,
indices: props.indices,
});
const transformItems = useTransformItems(props);
const transformSearchClient = useTransformSearchClient();
const searchContainer = useRef<HTMLDivElement | null>(null);
const searchButtonRef = useRef<HTMLButtonElement | null>(null);
const [isOpen, setIsOpen] = useState(false);
const [initialQuery, setInitialQuery] = useState<string | undefined>(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 { closeModal, isModalActive } = useDocSearch();
const [modalLoaded, setModalLoaded] = useState(
Boolean(DocSearchModal && DocSearchAskAiModal)
);
const [sidepanelLoaded, setSidepanelLoaded] = useState(
Boolean(DocSearchSidepanel)
);
const { modalAskAi, sidePanelAskAi } = useAlgoliaAskAi({
appId: props.appId,
apiKey: props.apiKey,
indices: props.indices,
askAi,
});
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<OnAskAiToggle>(
(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 { sidePanelEnabled, showSidepanelButton, sidePanelOptions } =
useAlgoliaAskAiSidepanel({
sidePanel,
});
const searchPagePath = getSearchPagePath(searchPage);
const resultsFooterComponent = useResultsFooterComponent({
closeModal,
searchPagePath: resultsFooterSearchPagePath,
searchPagePath,
});
useDocSearchKeyboardEvents({
isOpen,
onOpen: openModal,
onClose: closeModal,
onInput: handleInput,
searchButtonRef,
isAskAiActive: isAskAiActive ?? false,
onAskAiToggle: onAskAiToggle ?? (() => {}),
});
const loadModal = useCallback(() => {
return importDocSearchModalIfNeeded().then(() => setModalLoaded(true));
}, []);
const loadSidepanel = useCallback(() => {
return importDocSearchSidepanelIfNeeded().then(() =>
setSidepanelLoaded(true)
);
}, []);
useEffect(() => {
if (isModalActive) {
loadModal();
}
}, [isModalActive, loadModal]);
useEffect(() => {
if (sidePanelEnabled && sidePanelAskAi) {
loadSidepanel();
}
}, [loadSidepanel, sidePanelAskAi, sidePanelEnabled]);
const modalProps = {
...props,
navigator,
transformItems,
hitComponent: Hit,
transformSearchClient,
...(searchPagePath && {
resultsFooterComponent,
}),
translations: props.translations?.modal ?? translations.modal,
indices,
};
const panelOptions = getSidePanelPanelOptions(
typeof sidePanel === 'object' ? sidePanel : undefined
);
return (
<>
@ -310,103 +380,87 @@ function DocSearch({ externalUrlRegex, ...props }: AlgoliaSearchBarProps) {
{/* 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. */}
<link rel="preconnect" href={`https://${props.appId}-dsn.algolia.net`} crossOrigin="anonymous" />
<link
rel="preconnect"
href={`https://${props.appId}-dsn.algolia.net`}
crossOrigin="anonymous"
/>
</Head>
<div className="DocSearch-SearchBar">
<DocSearchButton
ref={searchButtonRef}
translations={props.translations?.button ?? translations.button}
onTouchStart={importDocSearchModalIfNeeded}
onFocus={importDocSearchModalIfNeeded}
onMouseOver={importDocSearchModalIfNeeded}
onClick={openModal}
onTouchStart={loadModal}
onFocus={loadModal}
onMouseOver={loadModal}
/>
{showSidepanelButton && (
<SidepanelButton
translations={{
buttonText: '',
buttonAriaLabel: 'Ask AI',
}}
variant={sidePanelOptions?.variant ?? 'inline'}
keyboardShortcuts={sidePanelOptions?.keyboardShortcuts}
onTouchStart={loadSidepanel}
onFocus={loadSidepanel}
onMouseOver={loadSidepanel}
onClick={toggleSidepanel}
/>
)}
{ExecutionEnvironment.canUseDOM &&
showSidepanelButton &&
sidePanelAskAi && (
<SidepanelButton
translations={{
buttonText: '',
buttonAriaLabel: 'Ask AI',
}}
variant={sidePanelOptions?.variant ?? 'inline'}
keyboardShortcuts={sidePanelOptions?.keyboardShortcuts}
/>
)}
</div>
{isOpen &&
DocSearchModal &&
searchContainer.current &&
createPortal(
<DocSearchModal
initialScrollY={window.scrollY}
initialQuery={initialQuery}
navigator={navigator}
transformItems={transformItems}
hitComponent={Hit}
transformSearchClient={transformSearchClient}
interceptAskAiEvent={(payload) => {
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,
)}
{modalLoaded &&
(modalAskAi && DocSearchAskAiModal ? (
<DocSearchAskAiModal {...modalProps} askAi={modalAskAi} />
) : (
DocSearchModal && <DocSearchModal {...modalProps} />
))}
{sidePanelEnabled &&
DocSearchSidepanel &&
askAi &&
sidepanelPortalContainer &&
createPortal(
{ExecutionEnvironment.canUseDOM &&
sidePanelEnabled &&
sidepanelLoaded &&
sidePanelAskAi &&
DocSearchSidepanel && (
<DocSearchSidepanel
{...sidePanelOptions}
variant={sidePanelOptions?.variant ?? 'inline'}
pushSelector={sidePanelOptions?.pushSelector ?? '#__docusaurus'}
assistantId={askAi.assistantId}
apiKey={askAi.apiKey}
appId={askAi.appId}
indexName={askAi.indexName}
agentStudio={sidePanelAgentStudio}
suggestedQuestions={sidePanelOptions?.suggestedQuestions ?? askAi.suggestedQuestions}
isOpen={isSidepanelOpen}
initialMessage={sidepanelInitialMessage}
onOpen={handleSidepanelOpen}
onClose={closeSidepanel}
/>,
sidepanelPortalContainer,
{...panelOptions}
variant={panelOptions.variant ?? 'inline'}
pushSelector={panelOptions.pushSelector ?? '#__docusaurus'}
agentId={sidePanelAskAi.agentId}
apiKey={sidePanelAskAi.apiKey}
appId={sidePanelAskAi.appId}
searchParameters={sidePanelAskAi.searchParameters}
indices={panelOptions.indices ?? sidePanelAskAi.indices}
suggestedQuestions={
panelOptions.suggestedQuestions ??
sidePanelAskAi.suggestedQuestions
}
tools={panelOptions.tools ?? sidePanelAskAi.tools}
memory={panelOptions.memory ?? sidePanelAskAi.memory}
/>
)}
</>
);
}
export default function SearchBar(props: Partial<AlgoliaSearchBarProps>): ReactNode {
export default function SearchBar(
props: Partial<AdapterDocSearchProps>
): ReactNode {
const themeConfig = useAlgoliaThemeConfig();
const docSearchProps: AlgoliaSearchBarProps = {
...(themeConfig as unknown as AlgoliaSearchBarProps),
const docSearchProps: AdapterDocSearchProps = {
...(themeConfig as AdapterDocSearchProps),
// Let props override theme config
// See https://github.com/facebook/docusaurus/pull/11581
...props,
};
return <DocSearch {...docSearchProps} />;
return (
<DocSearchProvider
appId={docSearchProps.appId}
apiKey={docSearchProps.apiKey}
initialQuery={docSearchProps.initialQuery}
keyboardShortcuts={docSearchProps.keyboardShortcuts}
>
<DocSearch {...docSearchProps} />
</DocSearchProvider>
);
}

View file

@ -6,54 +6,55 @@
*/
:root {
--docsearch-font-family: var(--ifm-font-family-base);
--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-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-secondary-text-color: var(--ifm-color-secondary-darker);
--docsearch-icon-color: var(--ifm-color-secondary-darker);
--docsearch-muted-color: var(--ifm-color-secondary-darker);
--docsearch-container-background: rgb(0 0 0 / 60%);
--docsearch-background-color: var(--ifm-color-emphasis-200);
--docsearch-background-color: var(--ifm-color-secondary);
/* Modal */
--docsearch-modal-background: var(--ifm-color-emphasis-100);
--docsearch-modal-background: var(--ifm-background-color);
--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);
--docsearch-search-button-text-color: var(--ifm-color-secondary-darker);
/* Search box */
--docsearch-searchbox-background: var(--ifm-background-color);
--docsearch-searchbox-focus-background: var(--ifm-background-surface-color);
--docsearch-searchbox-focus-background: var(--ifm-background-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-highlight-color: color-mix(in srgb,
var(--ifm-color-primary) 12%,
transparent);
--docsearch-hit-background: var(--ifm-background-color);
--docsearch-hit-focus-background: var(--ifm-color-secondary);
--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-color: var(--ifm-color-secondary-darker);
--docsearch-key-pressed-shadow: inset 0 2px 4px rgb(0 0 0 / 12%);
/* Footer */
--docsearch-footer-background: var(--ifm-background-surface-color);
--docsearch-footer-background: var(--ifm-background-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%
);
--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: var(--ifm-background-color);
--docsearch-sidepanel-background-dark: var(--docsearch-searchbox-background);
--docsearch-sidepanel-white: var(--ifm-background-color);
--docsearch-sidepanel-primary: var(--docsearch-primary-color);
@ -68,15 +69,13 @@
--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-sidepanel-hit-highlight-color: var(--docsearch-hit-highlight-color);
}
.DocSearch-SearchBar {
display: flex;
align-items: center;
gap: 0.5rem;
gap: 1.5rem;
}
.DocSearch-Button {
@ -95,6 +94,10 @@
display: none;
}
.DocSearch-Sidepanel .DocSearch-AskAiScreen-Query {
margin: 0;
}
.DocSearch-SidepanelButton.inline {
background-color: var(--ifm-background-color);
}
@ -105,15 +108,20 @@
}
/* is aesthetically better to have no border radius */
@media screen and (min-width: 769px) {
@media screen and (width >= 769px) {
.DocSearch-Sidepanel-Container.inline.side-right {
border-radius: 0 !important;
}
.DocSearch-Sidepanel-Container.inline.side-left {
border-radius: 0 !important;
}
}
.DocSearch-AskAiScreen-Query {
margin-bottom: 0;
}
.DocSearch-Container {
z-index: calc(var(--ifm-z-index-fixed) + 1);
}

View file

@ -0,0 +1,63 @@
/**
* 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 { translate } from '@docusaurus/Translate';
import React, { type ReactNode } from 'react';
import styles from '../styles.module.css';
import type { Refinements } from '../types';
type ActiveRefinementsProps = {
refinements: Refinements;
onRemove: (attribute: string, value: string) => void;
};
export function ActiveRefinements({
refinements,
onRemove,
}: ActiveRefinementsProps): ReactNode {
const entries = Object.entries(refinements).flatMap(([attribute, values]) =>
values.map((value) => ({ attribute, value }))
);
if (entries.length === 0) {
return null;
}
return (
<div className={styles.activeRefinements}>
{entries.map(({ attribute, value }) => (
<button
key={`${attribute}:${value}`}
type="button"
className={styles.activeRefinement}
aria-label={translate(
{
id: 'theme.SearchPage.removeFilter',
message: 'Remove filter {value}',
description:
'The label for the button that removes an active filter',
},
{ value }
)}
onClick={() => onRemove(attribute, value)}
>
<span>{value}</span>
<svg width="14" height="14" viewBox="0 0 20 20" aria-hidden="true">
<path
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
d="M5 5l10 10M15 5 5 15"
/>
</svg>
</button>
))}
</div>
);
}

View file

@ -0,0 +1,96 @@
/**
* 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 Link from '@docusaurus/Link';
import { translate } from '@docusaurus/Translate';
import React, { type ReactNode } from 'react';
import styles from '../styles.module.css';
function AlgoliaLogo(): ReactNode {
return (
<svg
width="80"
height="24"
aria-label="Algolia"
role="img"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 2196.2 500"
className={styles.algoliaLogo}
>
<defs>
<style>{`.cls-1,.cls-2{fill:#003dff}.cls-2{fill-rule:evenodd}`}</style>
</defs>
<path
className="cls-2"
d="M1070.38,275.3V5.91c0-3.63-3.24-6.39-6.82-5.83l-50.46,7.94c-2.87,.45-4.99,2.93-4.99,5.84l.17,273.22c0,12.92,0,92.7,95.97,95.49,3.33,.1,6.09-2.58,6.09-5.91v-40.78c0-2.96-2.19-5.51-5.12-5.84-34.85-4.01-34.85-47.57-34.85-54.72Z"
/>
<rect
className="cls-1"
x="1845.88"
y="104.73"
width="62.58"
height="277.9"
rx="5.9"
ry="5.9"
/>
<path
className="cls-2"
d="M1851.78,71.38h50.77c3.26,0,5.9-2.64,5.9-5.9V5.9c0-3.62-3.24-6.39-6.82-5.83l-50.77,7.95c-2.87,.45-4.99,2.92-4.99,5.83v51.62c0,3.26,2.64,5.9,5.9,5.9Z"
/>
<path
className="cls-2"
d="M1764.03,275.3V5.91c0-3.63-3.24-6.39-6.82-5.83l-50.46,7.94c-2.87,.45-4.99,2.93-4.99,5.84l.17,273.22c0,12.92,0,92.7,95.97,95.49,3.33,.1,6.09-2.58,6.09-5.91v-40.78c0-2.96-2.19-5.51-5.12-5.84-34.85-4.01-34.85-47.57-34.85-54.72Z"
/>
<path
className="cls-2"
d="M1631.95,142.72c-11.14-12.25-24.83-21.65-40.78-28.31-15.92-6.53-33.26-9.85-52.07-9.85-18.78,0-36.15,3.17-51.92,9.85-15.59,6.66-29.29,16.05-40.76,28.31-11.47,12.23-20.38,26.87-26.76,44.03-6.38,17.17-9.24,37.37-9.24,58.36,0,20.99,3.19,36.87,9.55,54.21,6.38,17.32,15.14,32.11,26.45,44.36,11.29,12.23,24.83,21.62,40.6,28.46,15.77,6.83,40.12,10.33,52.4,10.48,12.25,0,36.78-3.82,52.7-10.48,15.92-6.68,29.46-16.23,40.78-28.46,11.29-12.25,20.05-27.04,26.25-44.36,6.22-17.34,9.24-33.22,9.24-54.21,0-20.99-3.34-41.19-10.03-58.36-6.38-17.17-15.14-31.8-26.43-44.03Zm-44.43,163.75c-11.47,15.75-27.56,23.7-48.09,23.7-20.55,0-36.63-7.8-48.1-23.7-11.47-15.75-17.21-34.01-17.21-61.2,0-26.89,5.59-49.14,17.06-64.87,11.45-15.75,27.54-23.52,48.07-23.52,20.55,0,36.63,7.78,48.09,23.52,11.47,15.57,17.36,37.98,17.36,64.87,0,27.19-5.72,45.3-17.19,61.2Z"
/>
<path
className="cls-2"
d="M894.42,104.73h-49.33c-48.36,0-90.91,25.48-115.75,64.1-14.52,22.58-22.99,49.63-22.99,78.73,0,44.89,20.13,84.92,51.59,111.1,2.93,2.6,6.05,4.98,9.31,7.14,12.86,8.49,28.11,13.47,44.52,13.47,1.23,0,2.46-.03,3.68-.09,.36-.02,.71-.05,1.07-.07,.87-.05,1.75-.11,2.62-.2,.34-.03,.68-.08,1.02-.12,.91-.1,1.82-.21,2.73-.34,.21-.03,.42-.07,.63-.1,32.89-5.07,61.56-30.82,70.9-62.81v57.83c0,3.26,2.64,5.9,5.9,5.9h50.42c3.26,0,5.9-2.64,5.9-5.9V110.63c0-3.26-2.64-5.9-5.9-5.9h-56.32Zm0,206.92c-12.2,10.16-27.97,13.98-44.84,15.12-.16,.01-.33,.03-.49,.04-1.12,.07-2.24,.1-3.36,.1-42.24,0-77.12-35.89-77.12-79.37,0-10.25,1.96-20.01,5.42-28.98,11.22-29.12,38.77-49.74,71.06-49.74h49.33v142.83Z"
/>
<path
className="cls-2"
d="M2133.97,104.73h-49.33c-48.36,0-90.91,25.48-115.75,64.1-14.52,22.58-22.99,49.63-22.99,78.73,0,44.89,20.13,84.92,51.59,111.1,2.93,2.6,6.05,4.98,9.31,7.14,12.86,8.49,28.11,13.47,44.52,13.47,1.23,0,2.46-.03,3.68-.09,.36-.02,.71-.05,1.07-.07,.87-.05,1.75-.11,2.62-.2,.34-.03,.68-.08,1.02-.12,.91-.1,1.82-.21,2.73-.34,.21-.03,.42-.07,.63-.1,32.89-5.07,61.56-30.82,70.9-62.81v57.83c0,3.26,2.64,5.9,5.9,5.9h50.42c3.26,0,5.9-2.64,5.9-5.9V110.63c0-3.26-2.64-5.9-5.9-5.9h-56.32Zm0,206.92c-12.2,10.16-27.97,13.98-44.84,15.12-.16,.01-.33,.03-.49,.04-1.12,.07-2.24,.1-3.36,.1-42.24,0-77.12-35.89-77.12-79.37,0-10.25,1.96-20.01,5.42-28.98,11.22-29.12,38.77-49.74,71.06-49.74h49.33v142.83Z"
/>
<path
className="cls-2"
d="M1314.05,104.73h-49.33c-48.36,0-90.91,25.48-115.75,64.1-11.79,18.34-19.6,39.64-22.11,62.59-.58,5.3-.88,10.68-.88,16.14s.31,11.15,.93,16.59c4.28,38.09,23.14,71.61,50.66,94.52,2.93,2.6,6.05,4.98,9.31,7.14,12.86,8.49,28.11,13.47,44.52,13.47h0c17.99,0,34.61-5.93,48.16-15.97,16.29-11.58,28.88-28.54,34.48-47.75v50.26h-.11v11.08c0,21.84-5.71,38.27-17.34,49.36-11.61,11.08-31.04,16.63-58.25,16.63-11.12,0-28.79-.59-46.6-2.41-2.83-.29-5.46,1.5-6.27,4.22l-12.78,43.11c-1.02,3.46,1.27,7.02,4.83,7.53,21.52,3.08,42.52,4.68,54.65,4.68,48.91,0,85.16-10.75,108.89-32.21,21.48-19.41,33.15-48.89,35.2-88.52V110.63c0-3.26-2.64-5.9-5.9-5.9h-56.32Zm0,64.1s.65,139.13,0,143.36c-12.08,9.77-27.11,13.59-43.49,14.7-.16,.01-.33,.03-.49,.04-1.12,.07-2.24,.1-3.36,.1-1.32,0-2.63-.03-3.94-.1-40.41-2.11-74.52-37.26-74.52-79.38,0-10.25,1.96-20.01,5.42-28.98,11.22-29.12,38.77-49.74,71.06-49.74h49.33Z"
/>
<path
className="cls-1"
d="M249.83,0C113.3,0,2,110.09,.03,246.16c-2,138.19,110.12,252.7,248.33,253.5,42.68,.25,83.79-10.19,120.3-30.03,3.56-1.93,4.11-6.83,1.08-9.51l-23.38-20.72c-4.75-4.21-11.51-5.4-17.36-2.92-25.48,10.84-53.17,16.38-81.71,16.03-111.68-1.37-201.91-94.29-200.13-205.96,1.76-110.26,92-199.41,202.67-199.41h202.69V407.41l-115-102.18c-3.72-3.31-9.42-2.66-12.42,1.31-18.46,24.44-48.53,39.64-81.93,37.34-46.33-3.2-83.87-40.5-87.34-86.81-4.15-55.24,39.63-101.52,94-101.52,49.18,0,89.68,37.85,93.91,85.95,.38,4.28,2.31,8.27,5.52,11.12l29.95,26.55c3.4,3.01,8.79,1.17,9.63-3.3,2.16-11.55,2.92-23.58,2.07-35.92-4.82-70.34-61.8-126.93-132.17-131.26-80.68-4.97-148.13,58.14-150.27,137.25-2.09,77.1,61.08,143.56,138.19,145.26,32.19,.71,62.03-9.41,86.14-26.95l150.26,133.2c6.44,5.71,16.61,1.14,16.61-7.47V9.48C499.66,4.25,495.42,0,490.18,0H249.83Z"
/>
</svg>
);
}
export function PoweredByAlgolia(): ReactNode {
return (
<div className={styles.poweredBy}>
<span className={styles.poweredByLabel}>
{translate({
id: 'theme.SearchPage.algoliaLabel',
message: 'Powered by',
description: 'The text explain that the search powered by Algolia',
})}
</span>
<Link
to="https://www.algolia.com/"
className={styles.poweredByLink}
aria-label={translate({
id: 'theme.SearchPage.algoliaLabel',
message: 'Powered by Algolia',
description: 'The description label for Algolia mention',
})}
>
<AlgoliaLogo />
</Link>
</div>
);
}

View file

@ -0,0 +1,58 @@
/**
* 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 React, { type ReactNode } from 'react';
import styles from '../styles.module.css';
// `content` hits point at a paragraph within a page; every other type is a
// heading/section anchor.
function isContentType(type: string): boolean {
return type === 'content';
}
export function ResultIcon({ type }: { type: string }): ReactNode {
if (isContentType(type)) {
return (
<svg
className={styles.resultIcon}
width="20"
height="20"
viewBox="0 0 20 20"
aria-hidden="true"
>
<path
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
d="M4 4.5h12M4 8h12M4 11.5h8M4 15h6"
/>
</svg>
);
}
return (
<svg
className={styles.resultIcon}
width="20"
height="20"
viewBox="0 0 20 20"
aria-hidden="true"
>
<path
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
d="M7.5 3.5 6 16.5M14 3.5l-1.5 13M3.5 7h13M3 13h13"
/>
</svg>
);
}

View file

@ -0,0 +1,157 @@
/**
* 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 { translate } from '@docusaurus/Translate';
import React, { type ReactNode } from 'react';
import styles from '../styles.module.css';
import type { FacetValueItem } from '../types';
type SearchEmptyStateProps = {
recentSearches: string[];
onSelectRecent: (query: string) => void;
onRemoveRecent: (query: string) => void;
onClearRecent: () => void;
topSections: FacetValueItem[];
onSelectSection: (section: string) => void;
};
export function SearchEmptyState({
recentSearches,
onSelectRecent,
onRemoveRecent,
onClearRecent,
topSections,
onSelectSection,
}: SearchEmptyStateProps): ReactNode {
const hasContent = recentSearches.length > 0 || topSections.length > 0;
return (
<div className={styles.emptyState}>
{recentSearches.length > 0 && (
<section className={styles.emptySection}>
<div className={styles.emptySectionHeader}>
<h2 className={styles.emptySectionTitle}>
{translate({
id: 'theme.SearchPage.recentSearches',
message: 'Recent searches',
description: 'The heading for the list of recent searches',
})}
</h2>
<button
type="button"
className={styles.emptySectionAction}
onClick={onClearRecent}
>
{translate({
id: 'theme.SearchPage.clearRecentSearches',
message: 'Clear',
description:
'The label for the button that clears recent searches',
})}
</button>
</div>
<ul className={styles.recentList}>
{recentSearches.map((query) => (
<li key={query} className={styles.recentItem}>
<button
type="button"
className={styles.recentButton}
onClick={() => onSelectRecent(query)}
>
<svg
width="16"
height="16"
viewBox="0 0 20 20"
aria-hidden="true"
>
<path
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
d="M10 5.5V10l3 2m4-2a7 7 0 1 1-14 0 7 7 0 0 1 14 0Z"
/>
</svg>
<span>{query}</span>
</button>
<button
type="button"
className={styles.recentRemove}
aria-label={translate(
{
id: 'theme.SearchPage.removeRecentSearch',
message: 'Remove "{query}" from history',
description:
'The label for the button that removes a recent search',
},
{ query }
)}
onClick={() => onRemoveRecent(query)}
>
<svg
width="14"
height="14"
viewBox="0 0 20 20"
aria-hidden="true"
>
<path
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
d="M5 5l10 10M15 5 5 15"
/>
</svg>
</button>
</li>
))}
</ul>
</section>
)}
{topSections.length > 0 && (
<section className={styles.emptySection}>
<h2 className={styles.emptySectionTitle}>
{translate({
id: 'theme.SearchPage.browseSections',
message: 'Browse by section',
description:
'The heading for the list of documentation sections to browse',
})}
</h2>
<div className={styles.sectionChips}>
{topSections.map((section) => (
<button
key={section.name}
type="button"
className={styles.sectionChip}
onClick={() => onSelectSection(section.name)}
>
<span>{section.name}</span>
<span className={styles.sectionChipCount}>{section.count}</span>
</button>
))}
</div>
</section>
)}
{!hasContent && (
<p className={styles.emptyHint}>
{translate({
id: 'theme.SearchPage.emptyHint',
message: 'Start typing to search the documentation.',
description:
'The hint shown on the search page before the user types anything',
})}
</p>
)}
</div>
);
}

View file

@ -0,0 +1,212 @@
/**
* 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 { translate } from '@docusaurus/Translate';
import clsx from 'clsx';
import React, { useState, type ReactNode } from 'react';
import type { DocsSearchVersionsHelpers } from '../hooks/useDocsSearchVersions';
import styles from '../styles.module.css';
import type { FacetGroup, Refinements } from '../types';
type SearchFacetsProps = {
facets: FacetGroup[];
refinements: Refinements;
onToggle: (attribute: string, value: string) => void;
onClear: () => void;
hasActiveRefinements: boolean;
showVersionSelects: boolean;
versionHelpers: DocsSearchVersionsHelpers;
};
function VersionSelects({
versionHelpers,
}: {
versionHelpers: DocsSearchVersionsHelpers;
}): ReactNode {
const versionedPluginEntries = Object.entries(
versionHelpers.allDocsData
).filter(([, docsData]) => docsData.versions.length > 1);
if (versionedPluginEntries.length === 0) {
return null;
}
return (
<div className={styles.facetGroup}>
<h3 className={styles.facetLabel}>
{translate({
id: 'theme.SearchPage.versionLabel',
message: 'Version',
description: 'The label above the documentation version selectors',
})}
</h3>
{versionedPluginEntries.map(([pluginId, docsData]) => {
const labelPrefix =
versionedPluginEntries.length > 1 ? `${pluginId}: ` : '';
return (
<select
key={pluginId}
defaultValue={versionHelpers.searchVersions[pluginId]}
className={styles.versionSelect}
onBlur={(event) =>
versionHelpers.setSearchVersion(pluginId, event.target.value)
}
>
{docsData.versions.map((version) => (
<option
key={version.name}
label={`${labelPrefix}${version.label}`}
value={version.name}
/>
))}
</select>
);
})}
</div>
);
}
export function SearchFacets({
facets,
refinements,
onToggle,
onClear,
hasActiveRefinements,
showVersionSelects,
versionHelpers,
}: SearchFacetsProps): ReactNode {
const [open, setOpen] = useState(false);
const activeCount = Object.values(refinements).reduce(
(sum, values) => sum + values.length,
0
);
return (
<aside
className={styles.sidebar}
aria-label={translate({
id: 'theme.SearchPage.filtersLabel',
message: 'Search filters',
description: 'The ARIA label for the search filters sidebar',
})}
>
<button
type="button"
className={clsx(styles.filtersToggle, open && styles.filtersToggleOpen)}
aria-expanded={open}
aria-controls="search-page-filters-panel"
onClick={() => setOpen((current) => !current)}
>
<span className={styles.filtersToggleLabel}>
{translate({
id: 'theme.SearchPage.filtersToggle',
message: 'Filters',
description: 'The label for the mobile filters toggle button',
})}
{activeCount > 0 && (
<span className={styles.filtersToggleBadge}>{activeCount}</span>
)}
</span>
<svg
className={styles.filtersToggleChevron}
width="16"
height="16"
viewBox="0 0 16 16"
aria-hidden="true"
>
<path
d="M4 6l4 4 4-4"
fill="none"
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
/>
</svg>
</button>
<div
id="search-page-filters-panel"
className={clsx(styles.sidebarPanel, open && styles.sidebarPanelOpen)}
>
{hasActiveRefinements && (
<div className={styles.sidebarHeaderMobileClear}>
<button
type="button"
className={styles.clearFacetsButton}
onClick={onClear}
>
{translate({
id: 'theme.SearchPage.clearFilters',
message: 'Clear all',
description:
'The label for the button that clears all active filters',
})}
</button>
</div>
)}
<div className={styles.sidebarHeaderDesktop}>
<span className={styles.sidebarTitle}>
{translate({
id: 'theme.SearchPage.filtersTitle',
message: 'Filters',
description: 'The title of the search filters sidebar',
})}
</span>
{hasActiveRefinements && (
<button
type="button"
className={styles.clearFacetsButton}
onClick={onClear}
>
{translate({
id: 'theme.SearchPage.clearFilters',
message: 'Clear all',
description:
'The label for the button that clears all active filters',
})}
</button>
)}
</div>
{showVersionSelects && (
<VersionSelects versionHelpers={versionHelpers} />
)}
{facets.map((group) => (
<div key={group.attribute} className={styles.facetGroup}>
<h3 className={styles.facetLabel}>{group.label}</h3>
<ul className={styles.facetList}>
{group.items.map((value) => {
const isChecked =
refinements[group.attribute]?.includes(value.name) ??
value.isRefined;
return (
<li key={value.name}>
<label className={styles.facetItem}>
<input
type="checkbox"
className={styles.facetCheckbox}
checked={isChecked}
onChange={() => onToggle(group.attribute, value.name)}
/>
<span className={styles.facetName}>{value.name}</span>
<span className={styles.facetCount}>{value.count}</span>
</label>
</li>
);
})}
</ul>
</div>
))}
</div>
</aside>
);
}

View file

@ -0,0 +1,91 @@
/* eslint-disable jsx-a11y/no-autofocus */
/**
* 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 { translate } from '@docusaurus/Translate';
import React, { type ReactNode } from 'react';
import styles from '../styles.module.css';
type SearchInputProps = {
value: string;
onChange: (value: string) => void;
onClear: () => void;
autoFocus?: boolean;
};
export function SearchInput({
value,
onChange,
onClear,
autoFocus = false,
}: SearchInputProps): ReactNode {
return (
<div className={styles.searchBox}>
<svg
className={styles.searchBoxIcon}
width="20"
height="20"
viewBox="0 0 20 20"
aria-hidden="true"
>
<path
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
d="M14.386 14.386 18 18m-2-8a6 6 0 1 1-12 0 6 6 0 0 1 12 0Z"
/>
</svg>
<input
autoFocus={autoFocus}
type="search"
name="q"
className={styles.searchInput}
placeholder={translate({
id: 'theme.SearchPage.inputPlaceholder',
message: 'Type your search here',
description: 'The placeholder for search page input',
})}
aria-label={translate({
id: 'theme.SearchPage.inputLabel',
message: 'Search',
description: 'The ARIA label for search page input',
})}
value={value}
autoComplete="off"
onChange={(event) => onChange(event.target.value)}
/>
{value.length > 0 && (
<button
type="button"
className={styles.searchBoxClear}
aria-label={translate({
id: 'theme.SearchPage.clearInputLabel',
message: 'Clear the search query',
description:
'The label for the button that clears the search query',
})}
onClick={onClear}
>
<svg width="18" height="18" viewBox="0 0 20 20" aria-hidden="true">
<path
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
d="M5 5l10 10M15 5 5 15"
/>
</svg>
</button>
)}
</div>
);
}

View file

@ -0,0 +1,95 @@
/**
* 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 Link from '@docusaurus/Link';
import React, { type ReactNode } from 'react';
import styles from '../styles.module.css';
import type { SearchResultItem as SearchResultItemType } from '../types';
import { ResultIcon } from './ResultIcon';
type SearchResultItemProps = {
item: SearchResultItemType;
position: number;
onSelect: (item: SearchResultItemType, position: number) => void;
};
export function SearchResultItem({
item,
position,
onSelect,
}: SearchResultItemProps): ReactNode {
return (
<li className={styles.resultItem}>
<Link
to={item.url}
className={styles.resultLink}
onClick={() => onSelect(item, position)}
>
<span className={styles.resultIconWrapper}>
<ResultIcon type={item.type} />
</span>
<span className={styles.resultContent}>
{item.breadcrumbs.length > 0 && (
<span className={styles.resultBreadcrumbs}>
{item.breadcrumbs.map((crumb, index) => (
// eslint-disable-next-line react/no-array-index-key
<React.Fragment key={index}>
{index > 0 && (
<span
className={styles.resultBreadcrumbSeparator}
aria-hidden="true"
>
</span>
)}
{/* Highlighted HTML comes from Algolia. */}
{/* eslint-disable-next-line react/no-danger */}
<span dangerouslySetInnerHTML={{ __html: crumb }} />
</React.Fragment>
))}
</span>
)}
{/* Highlighted HTML comes from Algolia. */}
{/* eslint-disable-next-line react/no-danger */}
<span
className={styles.resultTitle}
dangerouslySetInnerHTML={{ __html: item.title }}
/>
{item.summary && (
// eslint-disable-next-line react/no-danger
<span
className={styles.resultSummary}
dangerouslySetInnerHTML={{ __html: item.summary }}
/>
)}
</span>
<svg
className={styles.resultArrow}
width="20"
height="20"
viewBox="0 0 20 20"
aria-hidden="true"
>
<path
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
d="M7.5 4.5 13 10l-5.5 5.5"
/>
</svg>
</Link>
</li>
);
}

View file

@ -0,0 +1,36 @@
/**
* 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 React, { type ReactNode } from 'react';
import styles from '../styles.module.css';
import type { SearchResultItem as SearchResultItemType } from '../types';
import { SearchResultItem } from './SearchResultItem';
type SearchResultsProps = {
items: SearchResultItemType[];
onSelect: (item: SearchResultItemType, position: number) => void;
};
export function SearchResults({
items,
onSelect,
}: SearchResultsProps): ReactNode {
return (
<ol className={styles.resultList}>
{items.map((item, index) => (
<SearchResultItem
key={item.objectID}
item={item}
position={index + 1}
onSelect={onSelect}
/>
))}
</ol>
);
}

View file

@ -0,0 +1,49 @@
/**
* 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 { translate } from '@docusaurus/Translate';
import clsx from 'clsx';
import React, { type ReactNode } from 'react';
import styles from '../styles.module.css';
export function SearchResultsSkeleton({
count = 5,
}: {
count?: number;
}): ReactNode {
return (
<div
className={styles.skeletonList}
aria-busy="true"
aria-label={translate({
id: 'theme.SearchPage.loadingResults',
message: 'Loading search results',
description:
'The ARIA label announced while search results are loading',
})}
>
{Array.from({ length: count }).map((_, index) => (
// eslint-disable-next-line react/no-array-index-key
<div key={index} className={styles.skeletonItem}>
<div className={styles.skeletonIcon} />
<div className={styles.skeletonBody}>
<div
className={clsx(styles.skeletonLine, styles.skeletonLineShort)}
/>
<div
className={clsx(styles.skeletonLine, styles.skeletonLineTitle)}
/>
<div
className={clsx(styles.skeletonLine, styles.skeletonLineWide)}
/>
</div>
</div>
))}
</div>
);
}

View file

@ -0,0 +1,34 @@
/**
* 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 { SearchPageFacetConfig } from './types';
export const HITS_PER_PAGE = 15;
// `content:<words>` snippet, keeps result cards scannable
export const SNIPPET_ATTRIBUTES = ['content:30'];
export const SNIPPET_ELLIPSIS_TEXT = '…';
export const SEARCH_DEBOUNCE_MS = 300;
export const RECENT_SEARCHES_KEY = 'docsearch:search-page:recent';
export const RECENT_SEARCHES_LIMIT = 6;
// Facets rendered in the sidebar when the user doesn't configure their own.
// `hierarchy.lvl0` is the top-level documentation section, which is the most
// useful, least noisy facet for a docs search page.
export const DEFAULT_FACETS: SearchPageFacetConfig[] = [
{ attribute: 'hierarchy.lvl0', label: 'Section' },
];
export const FACET_VALUES_LIMIT = 12;
// Number of "Browse by section" chips shown on the empty state.
export const TOP_SECTIONS_LIMIT = 8;
export const TOP_SECTIONS_FACET = 'hierarchy.lvl0';
export const FACET_SORT_BY = ['isRefined:desc', 'count:desc', 'name:asc'];

View file

@ -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 { useAllDocsData } from '@docusaurus/plugin-content-docs/client';
import { useState } from 'react';
export type DocsSearchVersionsHelpers = {
allDocsData: ReturnType<typeof useAllDocsData>;
versioningEnabled: boolean;
searchVersions: { [pluginId: string]: string };
setSearchVersion: (pluginId: string, searchVersion: string) => void;
};
export function useDocsSearchVersions(): DocsSearchVersionsHelpers {
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,
}),
{}
)
);
const setSearchVersion = (pluginId: string, searchVersion: string): void =>
setSearchVersions((s) => ({ ...s, [pluginId]: searchVersion }));
const versioningEnabled = Object.values(allDocsData).some(
(docsData) => docsData.versions.length > 1
);
return {
allDocsData,
versioningEnabled,
searchVersions,
setSearchVersion,
};
}

View file

@ -0,0 +1,98 @@
/**
* 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 ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
import { useCallback, useEffect, useState } from 'react';
import { RECENT_SEARCHES_KEY, RECENT_SEARCHES_LIMIT } from '../constants';
function readRecentSearches(): string[] {
if (!ExecutionEnvironment.canUseDOM) {
return [];
}
try {
const raw = window.localStorage.getItem(RECENT_SEARCHES_KEY);
if (!raw) {
return [];
}
const parsed = JSON.parse(raw);
return Array.isArray(parsed)
? parsed.filter((item): item is string => typeof item === 'string')
: [];
} catch {
return [];
}
}
function writeRecentSearches(searches: string[]): void {
if (!ExecutionEnvironment.canUseDOM) {
return;
}
try {
window.localStorage.setItem(RECENT_SEARCHES_KEY, JSON.stringify(searches));
} catch {
// Ignore write failures (private mode, quota, etc.): recent searches are
// a non-critical enhancement.
}
}
type UseRecentSearches = {
recentSearches: string[];
addRecentSearch: (query: string) => void;
removeRecentSearch: (query: string) => void;
clearRecentSearches: () => void;
};
export function useRecentSearches(): UseRecentSearches {
// Start empty so the server and first client render match, then hydrate from
// localStorage after mount to avoid hydration mismatches.
const [recentSearches, setRecentSearches] = useState<string[]>([]);
useEffect(() => {
setRecentSearches(readRecentSearches());
}, []);
const persist = useCallback((next: string[]) => {
setRecentSearches(next);
writeRecentSearches(next);
}, []);
const addRecentSearch = useCallback((query: string) => {
const trimmed = query.trim();
if (!trimmed) {
return;
}
setRecentSearches((prev) => {
const next = [trimmed, ...prev.filter((item) => item !== trimmed)].slice(
0,
RECENT_SEARCHES_LIMIT
);
writeRecentSearches(next);
return next;
});
}, []);
const removeRecentSearch = useCallback(
(query: string) => {
persist(recentSearches.filter((item) => item !== query));
},
[persist, recentSearches]
);
const clearRecentSearches = useCallback(() => {
persist([]);
}, [persist]);
return {
recentSearches,
addRecentSearch,
removeRecentSearch,
clearRecentSearches,
};
}

View file

@ -0,0 +1,517 @@
/* eslint-disable import/dynamic-import-chunkname */
/**
* 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 ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
import { useHistory } from '@docusaurus/router';
import {
useEvent,
useHistorySelector,
useSearchQueryString,
} from '@docusaurus/theme-common';
import useDocusaurusContext from '@docusaurus/useDocusaurusContext';
import type algoliaSearchHelper from 'algoliasearch-helper';
import createAlgoliaSearchHelper from 'algoliasearch-helper';
import { liteClient } from 'algoliasearch/lite';
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react';
import type { InsightsClient } from 'search-insights';
import {
useAlgoliaThemeConfig,
useSearchResultUrlProcessor,
} from '../../../client';
import {
DEFAULT_FACETS,
FACET_SORT_BY,
FACET_VALUES_LIMIT,
HITS_PER_PAGE,
SEARCH_DEBOUNCE_MS,
SNIPPET_ATTRIBUTES,
SNIPPET_ELLIPSIS_TEXT,
TOP_SECTIONS_FACET,
} from '../constants';
import type {
FacetGroup,
FacetValueItem,
Refinements,
SearchPageFacetConfig,
SearchResultItem,
} from '../types';
import { getIndexName, getIndexSearchParameters } from '../utils';
import {
useDocsSearchVersions,
type DocsSearchVersionsHelpers,
} from './useDocsSearchVersions';
type AlgoliaDocHit = {
objectID: string;
url: string;
type?: string;
_highlightResult?: { hierarchy?: { [key: string]: { value: string } } };
_snippetResult?: { content?: { value: string } };
};
type SearchResultUpdate = {
items: SearchResultItem[];
query: string;
queryID: string | undefined;
totalResults: number;
totalPages: number;
lastPage: number;
hasMore: boolean;
facets: FacetGroup[];
};
type SearchState = {
items: SearchResultItem[];
query: string | null;
queryID: string | undefined;
totalResults: number | null;
totalPages: number | null;
lastPage: number | null;
hasMore: boolean;
loading: boolean;
loadingMore: boolean;
error: boolean;
facets: FacetGroup[];
};
type SearchAction =
| { type: 'advance' }
| { type: 'error' }
| { type: 'loading' }
| { type: 'reset' }
| { type: 'update'; value: SearchResultUpdate };
const initialSearchState: SearchState = {
items: [],
query: null,
queryID: undefined,
totalResults: null,
totalPages: null,
lastPage: null,
hasMore: false,
loading: false,
loadingMore: false,
error: false,
facets: [],
};
function searchReducer(
prevState: SearchState,
action: SearchAction
): SearchState {
switch (action.type) {
case 'reset': {
return initialSearchState;
}
case 'loading': {
return { ...prevState, loading: true, error: false };
}
case 'error': {
return { ...prevState, loading: false, loadingMore: false, error: true };
}
case 'advance': {
const hasMore =
(prevState.totalPages ?? 0) > (prevState.lastPage ?? 0) + 1;
return {
...prevState,
lastPage: hasMore ? (prevState.lastPage ?? 0) + 1 : prevState.lastPage,
hasMore,
loadingMore: hasMore,
};
}
case 'update': {
const { value } = action;
return {
...prevState,
items:
value.lastPage === 0
? value.items
: prevState.items.concat(value.items),
query: value.query,
queryID: value.queryID,
totalResults: value.totalResults,
totalPages: value.totalPages,
lastPage: value.lastPage,
hasMore: value.hasMore,
facets: value.facets,
loading: false,
loadingMore: false,
error: false,
};
}
default: {
const exhaustiveCheck: never = action;
return exhaustiveCheck;
}
}
}
// DocSearch-scraped records use a legacy highlight tag. Map it onto a class we
// can style on the search page.
function sanitizeHighlight(value: string): string {
return value.replace(
/algolia-docsearch-suggestion--highlight/g,
'search-result-match'
);
}
function mapHitToResultItem(
hit: AlgoliaDocHit,
processUrl: (url: string) => string
): SearchResultItem {
const hierarchy = hit._highlightResult?.hierarchy ?? {};
const titles = Object.keys(hierarchy).map((key) =>
sanitizeHighlight(hierarchy[key]!.value)
);
const snippet = hit._snippetResult?.content?.value;
return {
objectID: hit.objectID,
title: titles.pop() ?? '',
url: processUrl(hit.url),
summary: snippet ? sanitizeHighlight(snippet) : '',
breadcrumbs: titles,
type: hit.type ?? '',
};
}
function toFacetValues(
raw: ReturnType<algoliaSearchHelper.SearchResults['getFacetValues']>
): FacetValueItem[] {
if (!Array.isArray(raw)) {
return [];
}
return raw.slice(0, FACET_VALUES_LIMIT).map((value) => ({
name: value.name,
count: value.count,
isRefined: value.isRefined,
}));
}
export type UseSearchPage = {
searchQuery: string;
setSearchQuery: (query: string) => void;
items: SearchResultItem[];
totalResults: number | null;
loading: boolean;
loadingMore: boolean;
error: boolean;
hasMore: boolean;
facets: FacetGroup[];
refinements: Refinements;
toggleRefinement: (attribute: string, value: string) => void;
clearRefinements: () => void;
hasActiveRefinements: boolean;
loadMore: () => void;
sendResultClick: (item: SearchResultItem, position: number) => void;
versionHelpers: DocsSearchVersionsHelpers;
};
export function useSearchPage(): UseSearchPage {
const {
i18n: { currentLocale },
} = useDocusaurusContext();
const { appId, apiKey, indices, contextualSearch, insights, searchPage } =
useAlgoliaThemeConfig();
const insightsEnabled = Boolean(insights);
const searchIndex = indices[0]!;
const indexName = getIndexName(searchIndex);
const indexSearchParameters = useMemo(
() => getIndexSearchParameters(searchIndex),
[searchIndex]
);
const processSearchResultUrl = useSearchResultUrlProcessor();
const facetConfig = useMemo<SearchPageFacetConfig[]>(() => {
const configured = searchPage === false ? undefined : searchPage.facets;
return configured && configured.length > 0 ? configured : DEFAULT_FACETS;
}, [searchPage]);
const facetAttributes = useMemo(
() => facetConfig.map((facet) => facet.attribute),
[facetConfig]
);
// Attributes we can refine on via the URL. Always include the "browse by
// section" facet so empty-state section chips work even if the user
// customized `facets`.
const refinableAttributes = useMemo(
() => Array.from(new Set([...facetAttributes, TOP_SECTIONS_FACET])),
[facetAttributes]
);
const versionHelpers = useDocsSearchVersions();
const [searchQuery, setSearchQuery] = useSearchQueryString();
// Facet refinements live in the URL so filtered searches are shareable and
// work with browser back/forward.
const history = useHistory();
const refinementsJson = useHistorySelector((historyState) => {
const params = new URLSearchParams(historyState.location.search);
const result: Refinements = {};
refinableAttributes.forEach((attribute) => {
const values = params.getAll(attribute);
if (values.length > 0) {
result[attribute] = values;
}
});
return JSON.stringify(result);
});
const refinements = useMemo(
() => JSON.parse(refinementsJson) as Refinements,
[refinementsJson]
);
const hasActiveRefinements = Object.keys(refinements).length > 0;
const setAttributeValues = useEvent((attribute: string, values: string[]) => {
const params = new URLSearchParams(history.location.search);
params.delete(attribute);
values.forEach((value) => params.append(attribute, value));
history.replace({ search: params.toString() });
});
const toggleRefinement = useEvent((attribute: string, value: string) => {
const current = refinements[attribute] ?? [];
const next = current.includes(value)
? current.filter((item) => item !== value)
: [...current, value];
setAttributeValues(attribute, next);
});
const clearRefinements = useEvent(() => {
const params = new URLSearchParams(history.location.search);
refinableAttributes.forEach((attribute) => params.delete(attribute));
history.replace({ search: params.toString() });
});
const [state, dispatch] = useReducer(searchReducer, initialSearchState);
// Create the Algolia client + helper once, rather than on every render.
const helper = useMemo(() => {
const client = liteClient(appId, apiKey);
const disjunctiveFacets = Array.from(
new Set([
...(contextualSearch ? ['language', 'docusaurus_tag'] : []),
...refinableAttributes,
])
);
return createAlgoliaSearchHelper(client, indexName, {
...indexSearchParameters,
hitsPerPage: HITS_PER_PAGE,
advancedSyntax: true,
attributesToSnippet: SNIPPET_ATTRIBUTES,
snippetEllipsisText: SNIPPET_ELLIPSIS_TEXT,
disjunctiveFacets,
...(insightsEnabled ? { clickAnalytics: true } : {}),
});
}, [
appId,
apiKey,
indexName,
contextualSearch,
insightsEnabled,
indexSearchParameters,
refinableAttributes,
]);
useEffect(() => {
const handleResult = ({
results,
}: {
results: algoliaSearchHelper.SearchResults;
}): void => {
const { query, hits, page, nbHits, nbPages, queryID } = results;
// Ignore stale responses for a previous query.
if (query !== searchQuery) {
return;
}
// An empty query with active refinements is a valid "browse by facet"
// search; only reset when there is genuinely nothing to search.
if (!Array.isArray(hits) || (query === '' && !hasActiveRefinements)) {
dispatch({ type: 'reset' });
return;
}
const items = (hits as AlgoliaDocHit[]).map((hit) =>
mapHitToResultItem(hit, processSearchResultUrl)
);
const facets = facetConfig
.map((facet) => ({
attribute: facet.attribute,
label: facet.label ?? facet.attribute,
items: toFacetValues(
results.getFacetValues(facet.attribute, { sortBy: FACET_SORT_BY })
),
}))
.filter((group) => group.items.length > 0);
dispatch({
type: 'update',
value: {
items,
query,
queryID,
totalResults: nbHits,
totalPages: nbPages,
lastPage: page,
hasMore: nbPages > page + 1,
facets,
},
});
};
const handleError = (): void => {
dispatch({ type: 'error' });
};
helper.on('result', handleResult);
helper.on('error', handleError);
return () => {
helper.removeListener('result', handleResult);
helper.removeListener('error', handleError);
};
}, [
helper,
searchQuery,
hasActiveRefinements,
facetConfig,
processSearchResultUrl,
]);
const makeSearch = useEvent((page: number = 0) => {
// Rebuild refinements from scratch each search, since the helper is stable.
helper.clearRefinements();
if (contextualSearch) {
helper.addDisjunctiveFacetRefinement('docusaurus_tag', 'default');
helper.addDisjunctiveFacetRefinement('language', currentLocale);
Object.entries(versionHelpers.searchVersions).forEach(
([pluginId, searchVersion]) => {
helper.addDisjunctiveFacetRefinement(
'docusaurus_tag',
`docs-${pluginId}-${searchVersion}`
);
}
);
}
Object.entries(refinements).forEach(([attribute, values]) => {
values.forEach((value) =>
helper.addDisjunctiveFacetRefinement(attribute, value)
);
});
helper.setQuery(searchQuery).setPage(page).search();
});
useEffect(() => {
dispatch({ type: 'reset' });
// Search when there is a query, or when browsing by facet with no query.
if (!searchQuery && !hasActiveRefinements) {
return undefined;
}
dispatch({ type: 'loading' });
const searchTimeoutId = setTimeout(() => {
makeSearch();
}, SEARCH_DEBOUNCE_MS);
return () => {
clearTimeout(searchTimeoutId);
};
}, [
searchQuery,
hasActiveRefinements,
refinements,
versionHelpers.searchVersions,
makeSearch,
]);
useEffect(() => {
if (!state.lastPage || state.lastPage === 0) {
return;
}
makeSearch(state.lastPage);
}, [makeSearch, state.lastPage]);
const loadMore = useCallback(() => {
dispatch({ type: 'advance' });
}, []);
// Lazily load Algolia Insights only when analytics is enabled, mirroring the
// DocSearch modal (which also gates click analytics behind `insights`).
const insightsRef = useRef<InsightsClient | null>(null);
useEffect(() => {
if (!insightsEnabled || !ExecutionEnvironment.canUseDOM) {
return undefined;
}
let cancelled = false;
import('search-insights')
.then(({ default: aa }) => {
if (cancelled) {
return;
}
aa('init', { appId, apiKey });
insightsRef.current = aa;
})
.catch(() => {
// Analytics is a non-critical enhancement; ignore load failures.
});
return () => {
cancelled = true;
};
}, [insightsEnabled, appId, apiKey]);
const sendResultClick = useEvent(
(item: SearchResultItem, position: number) => {
const aa = insightsRef.current;
if (!insightsEnabled || !aa || !state.queryID) {
return;
}
aa('clickedObjectIDsAfterSearch', {
eventName: 'Item Selected',
index: indexName,
queryID: state.queryID,
objectIDs: [item.objectID],
positions: [position],
});
}
);
return {
searchQuery,
setSearchQuery,
items: state.items,
totalResults: state.totalResults,
loading: state.loading,
loadingMore: state.loadingMore,
error: state.error,
hasMore: state.hasMore,
facets: state.facets,
refinements,
toggleRefinement,
clearRefinements,
hasActiveRefinements,
loadMore,
sendResultClick,
versionHelpers,
};
}

View file

@ -0,0 +1,83 @@
/**
* 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 ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
import { liteClient } from 'algoliasearch/lite';
import { useEffect, useMemo, useState } from 'react';
import {
useAlgoliaContextualFacetFiltersIfEnabled,
useAlgoliaThemeConfig,
} from '../../../client';
import { TOP_SECTIONS_FACET, TOP_SECTIONS_LIMIT } from '../constants';
import type { FacetValueItem } from '../types';
import { getIndexName } from '../utils';
/**
* Fetches the most common top-level sections (`hierarchy.lvl0`) so the empty
* state can offer "Browse by section" shortcuts. Runs a single facet-only query
* (no hits) on mount.
*/
export function useTopSections(): FacetValueItem[] {
const { appId, apiKey, indices } = useAlgoliaThemeConfig();
const facetFilters = useAlgoliaContextualFacetFiltersIfEnabled();
const searchIndex = indices[0]!;
const indexName = getIndexName(searchIndex);
const facetFiltersKey = useMemo(
() => JSON.stringify(facetFilters ?? null),
[facetFilters]
);
const [sections, setSections] = useState<FacetValueItem[]>([]);
useEffect(() => {
if (!ExecutionEnvironment.canUseDOM) {
return undefined;
}
let cancelled = false;
const client = liteClient(appId, apiKey);
const parsedFacetFilters = JSON.parse(facetFiltersKey);
client
.search({
requests: [
{
indexName,
query: '',
hitsPerPage: 0,
facets: [TOP_SECTIONS_FACET],
maxValuesPerFacet: TOP_SECTIONS_LIMIT,
...(parsedFacetFilters ? { facetFilters: parsedFacetFilters } : {}),
},
],
})
.then((response) => {
if (cancelled) {
return;
}
const firstResult = response.results[0] as {
facets?: { [facet: string]: { [value: string]: number } };
};
const values = firstResult?.facets?.[TOP_SECTIONS_FACET] ?? {};
const items = Object.entries(values)
.map(([name, count]) => ({ name, count, isRefined: false }))
.sort((a, b) => b.count - a.count)
.slice(0, TOP_SECTIONS_LIMIT);
setSections(items);
})
.catch(() => {
// Browse shortcuts are a non-critical enhancement; ignore failures.
});
return () => {
cancelled = true;
};
}, [appId, apiKey, indexName, facetFiltersKey]);
return sections;
}

View file

@ -1,32 +1,36 @@
/**
* 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.
* 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 { HtmlClassNameProvider, usePluralForm } 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 React, { type ReactNode } from 'react';
import { useAlgoliaThemeConfig, useSearchResultUrlProcessor } from '../../client';
import { useAlgoliaThemeConfig } from '../../client';
import { ActiveRefinements } from './components/ActiveRefinements';
import { PoweredByAlgolia } from './components/PoweredByAlgolia';
import { SearchEmptyState } from './components/SearchEmptyState';
import { SearchFacets } from './components/SearchFacets';
import { SearchInput } from './components/SearchInput';
import { SearchResults } from './components/SearchResults';
import { SearchResultsSkeleton } from './components/SearchResultsSkeleton';
import { TOP_SECTIONS_FACET } from './constants';
import { useRecentSearches } from './hooks/useRecentSearches';
import { useSearchPage } from './hooks/useSearchPage';
import { useTopSections } from './hooks/useTopSections';
import styles from './styles.module.css';
import type { SearchResultItem } from './types';
// Very simple pluralization: probably good enough for now
function useDocumentsFoundPlural() {
function useDocumentsFoundPlural(): (count: number) => string {
const { selectMessage } = usePluralForm();
return (count: number) =>
selectMessage(
@ -38,146 +42,11 @@ function useDocumentsFoundPlural() {
'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 },
),
{ 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<typeof useDocsSearchVersionsHelpers>;
}) {
const versionedPluginEntries = Object.entries(docsSearchVersionsHelpers.allDocsData)
// Do not show a version select for unversioned docs plugin instances
.filter(([, docsData]) => docsData.versions.length > 1);
return (
<div className={clsx('col', 'col--3', 'padding-left--none', styles.searchVersionColumn)}>
{versionedPluginEntries.map(([pluginId, docsData]) => {
const labelPrefix = versionedPluginEntries.length > 1 ? `${pluginId}: ` : '';
return (
<select
key={pluginId}
defaultValue={docsSearchVersionsHelpers.searchVersions[pluginId]}
className={styles.searchVersionInput}
onBlur={(e) => docsSearchVersionsHelpers.setSearchVersion(pluginId, e.target.value)}
>
{docsData.versions.map((version, i) => (
// eslint-disable-next-line react/no-array-index-key
<option key={i} label={`${labelPrefix}${version.label}`} value={version.name} />
))}
</select>
);
})}
</div>
);
}
function AlgoliaLogo(): ReactNode {
return (
<svg
width="80"
height="24"
aria-label="Algolia"
role="img"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 2196.2 500"
style={{ maxWidth: '150px' }}
>
<defs>
<style>{`.cls-1,.cls-2{fill:#003dff}.cls-2{fill-rule:evenodd}`}</style>
</defs>
<path
className="cls-2"
d="M1070.38,275.3V5.91c0-3.63-3.24-6.39-6.82-5.83l-50.46,7.94c-2.87,.45-4.99,2.93-4.99,5.84l.17,273.22c0,12.92,0,92.7,95.97,95.49,3.33,.1,6.09-2.58,6.09-5.91v-40.78c0-2.96-2.19-5.51-5.12-5.84-34.85-4.01-34.85-47.57-34.85-54.72Z"
/>
<rect className="cls-1" x="1845.88" y="104.73" width="62.58" height="277.9" rx="5.9" ry="5.9" />
<path
className="cls-2"
d="M1851.78,71.38h50.77c3.26,0,5.9-2.64,5.9-5.9V5.9c0-3.62-3.24-6.39-6.82-5.83l-50.77,7.95c-2.87,.45-4.99,2.92-4.99,5.83v51.62c0,3.26,2.64,5.9,5.9,5.9Z"
/>
<path
className="cls-2"
d="M1764.03,275.3V5.91c0-3.63-3.24-6.39-6.82-5.83l-50.46,7.94c-2.87,.45-4.99,2.93-4.99,5.84l.17,273.22c0,12.92,0,92.7,95.97,95.49,3.33,.1,6.09-2.58,6.09-5.91v-40.78c0-2.96-2.19-5.51-5.12-5.84-34.85-4.01-34.85-47.57-34.85-54.72Z"
/>
<path
className="cls-2"
d="M1631.95,142.72c-11.14-12.25-24.83-21.65-40.78-28.31-15.92-6.53-33.26-9.85-52.07-9.85-18.78,0-36.15,3.17-51.92,9.85-15.59,6.66-29.29,16.05-40.76,28.31-11.47,12.23-20.38,26.87-26.76,44.03-6.38,17.17-9.24,37.37-9.24,58.36,0,20.99,3.19,36.87,9.55,54.21,6.38,17.32,15.14,32.11,26.45,44.36,11.29,12.23,24.83,21.62,40.6,28.46,15.77,6.83,40.12,10.33,52.4,10.48,12.25,0,36.78-3.82,52.7-10.48,15.92-6.68,29.46-16.23,40.78-28.46,11.29-12.25,20.05-27.04,26.25-44.36,6.22-17.34,9.24-33.22,9.24-54.21,0-20.99-3.34-41.19-10.03-58.36-6.38-17.17-15.14-31.8-26.43-44.03Zm-44.43,163.75c-11.47,15.75-27.56,23.7-48.09,23.7-20.55,0-36.63-7.8-48.1-23.7-11.47-15.75-17.21-34.01-17.21-61.2,0-26.89,5.59-49.14,17.06-64.87,11.45-15.75,27.54-23.52,48.07-23.52,20.55,0,36.63,7.78,48.09,23.52,11.47,15.57,17.36,37.98,17.36,64.87,0,27.19-5.72,45.3-17.19,61.2Z"
/>
<path
className="cls-2"
d="M894.42,104.73h-49.33c-48.36,0-90.91,25.48-115.75,64.1-14.52,22.58-22.99,49.63-22.99,78.73,0,44.89,20.13,84.92,51.59,111.1,2.93,2.6,6.05,4.98,9.31,7.14,12.86,8.49,28.11,13.47,44.52,13.47,1.23,0,2.46-.03,3.68-.09,.36-.02,.71-.05,1.07-.07,.87-.05,1.75-.11,2.62-.2,.34-.03,.68-.08,1.02-.12,.91-.1,1.82-.21,2.73-.34,.21-.03,.42-.07,.63-.1,32.89-5.07,61.56-30.82,70.9-62.81v57.83c0,3.26,2.64,5.9,5.9,5.9h50.42c3.26,0,5.9-2.64,5.9-5.9V110.63c0-3.26-2.64-5.9-5.9-5.9h-56.32Zm0,206.92c-12.2,10.16-27.97,13.98-44.84,15.12-.16,.01-.33,.03-.49,.04-1.12,.07-2.24,.1-3.36,.1-42.24,0-77.12-35.89-77.12-79.37,0-10.25,1.96-20.01,5.42-28.98,11.22-29.12,38.77-49.74,71.06-49.74h49.33v142.83Z"
/>
<path
className="cls-2"
d="M2133.97,104.73h-49.33c-48.36,0-90.91,25.48-115.75,64.1-14.52,22.58-22.99,49.63-22.99,78.73,0,44.89,20.13,84.92,51.59,111.1,2.93,2.6,6.05,4.98,9.31,7.14,12.86,8.49,28.11,13.47,44.52,13.47,1.23,0,2.46-.03,3.68-.09,.36-.02,.71-.05,1.07-.07,.87-.05,1.75-.11,2.62-.2,.34-.03,.68-.08,1.02-.12,.91-.1,1.82-.21,2.73-.34,.21-.03,.42-.07,.63-.1,32.89-5.07,61.56-30.82,70.9-62.81v57.83c0,3.26,2.64,5.9,5.9,5.9h50.42c3.26,0,5.9-2.64,5.9-5.9V110.63c0-3.26-2.64-5.9-5.9-5.9h-56.32Zm0,206.92c-12.2,10.16-27.97,13.98-44.84,15.12-.16,.01-.33,.03-.49,.04-1.12,.07-2.24,.1-3.36,.1-42.24,0-77.12-35.89-77.12-79.37,0-10.25,1.96-20.01,5.42-28.98,11.22-29.12,38.77-49.74,71.06-49.74h49.33v142.83Z"
/>
<path
className="cls-2"
d="M1314.05,104.73h-49.33c-48.36,0-90.91,25.48-115.75,64.1-11.79,18.34-19.6,39.64-22.11,62.59-.58,5.3-.88,10.68-.88,16.14s.31,11.15,.93,16.59c4.28,38.09,23.14,71.61,50.66,94.52,2.93,2.6,6.05,4.98,9.31,7.14,12.86,8.49,28.11,13.47,44.52,13.47h0c17.99,0,34.61-5.93,48.16-15.97,16.29-11.58,28.88-28.54,34.48-47.75v50.26h-.11v11.08c0,21.84-5.71,38.27-17.34,49.36-11.61,11.08-31.04,16.63-58.25,16.63-11.12,0-28.79-.59-46.6-2.41-2.83-.29-5.46,1.5-6.27,4.22l-12.78,43.11c-1.02,3.46,1.27,7.02,4.83,7.53,21.52,3.08,42.52,4.68,54.65,4.68,48.91,0,85.16-10.75,108.89-32.21,21.48-19.41,33.15-48.89,35.2-88.52V110.63c0-3.26-2.64-5.9-5.9-5.9h-56.32Zm0,64.1s.65,139.13,0,143.36c-12.08,9.77-27.11,13.59-43.49,14.7-.16,.01-.33,.03-.49,.04-1.12,.07-2.24,.1-3.36,.1-1.32,0-2.63-.03-3.94-.1-40.41-2.11-74.52-37.26-74.52-79.38,0-10.25,1.96-20.01,5.42-28.98,11.22-29.12,38.77-49.74,71.06-49.74h49.33Z"
/>
<path
className="cls-1"
d="M249.83,0C113.3,0,2,110.09,.03,246.16c-2,138.19,110.12,252.7,248.33,253.5,42.68,.25,83.79-10.19,120.3-30.03,3.56-1.93,4.11-6.83,1.08-9.51l-23.38-20.72c-4.75-4.21-11.51-5.4-17.36-2.92-25.48,10.84-53.17,16.38-81.71,16.03-111.68-1.37-201.91-94.29-200.13-205.96,1.76-110.26,92-199.41,202.67-199.41h202.69V407.41l-115-102.18c-3.72-3.31-9.42-2.66-12.42,1.31-18.46,24.44-48.53,39.64-81.93,37.34-46.33-3.2-83.87-40.5-87.34-86.81-4.15-55.24,39.63-101.52,94-101.52,49.18,0,89.68,37.85,93.91,85.95,.38,4.28,2.31,8.27,5.52,11.12l29.95,26.55c3.4,3.01,8.79,1.17,9.63-3.3,2.16-11.55,2.92-23.58,2.07-35.92-4.82-70.34-61.8-126.93-132.17-131.26-80.68-4.97-148.13,58.14-150.27,137.25-2.09,77.1,61.08,143.56,138.19,145.26,32.19,.71,62.03-9.41,86.14-26.95l150.26,133.2c6.44,5.71,16.61,1.14,16.61-7.47V9.48C499.66,4.25,495.42,0,490.18,0H249.83Z"
/>
</svg>
);
}
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(
@ -188,7 +57,7 @@ function getSearchPageTitle(searchQuery: string | undefined): string {
},
{
query: searchQuery,
},
}
)
: translate({
id: 'theme.SearchPage.emptyResultsTitle',
@ -199,184 +68,148 @@ function getSearchPageTitle(searchQuery: string | undefined): string {
function SearchPageContent(): ReactNode {
const {
i18n: { currentLocale },
} = useDocusaurusContext();
const { appId, apiKey, indexName, contextualSearch } = useAlgoliaThemeConfig();
const processSearchResultUrl = useSearchResultUrlProcessor();
searchQuery,
setSearchQuery,
items,
totalResults,
loading,
loadingMore,
error,
hasMore,
facets,
refinements,
toggleRefinement,
clearRefinements,
hasActiveRefinements,
loadMore,
sendResultClick,
versionHelpers,
} = useSearchPage();
const { contextualSearch } = useAlgoliaThemeConfig();
const documentsFoundPlural = useDocumentsFoundPlural();
const {
recentSearches,
addRecentSearch,
removeRecentSearch,
clearRecentSearches,
} = useRecentSearches();
const topSections = useTopSections();
const docsSearchVersionsHelpers = useDocsSearchVersionsHelpers();
const [searchQuery, setSearchQuery] = useSearchQueryString();
const pageTitle = getSearchPageTitle(searchQuery);
const showVersionSelects =
contextualSearch && versionHelpers.versioningEnabled;
const hasSidebar = showVersionSelects || facets.length > 0;
const isSearching = Boolean(searchQuery) || hasActiveRefinements;
const initialSearchResultState: ResultDispatcherState = {
items: [],
query: null,
totalResults: null,
totalPages: null,
lastPage: null,
hasMore: null,
loading: null,
const handleSelectResult = (
item: SearchResultItem,
position: number
): void => {
sendResultClick(item, position);
if (searchQuery) {
addRecentSearch(searchQuery);
}
};
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;
function renderMainContent(): ReactNode {
if (!isSearching) {
return (
<SearchEmptyState
recentSearches={recentSearches}
topSections={topSections}
onSelectRecent={setSearchQuery}
onRemoveRecent={removeRecentSearch}
onClearRecent={clearRecentSearches}
onSelectSection={(section) =>
toggleRefinement(TOP_SECTIONS_FACET, section)
}
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');
if (error) {
return (
<div className={styles.stateMessage}>
<Translate
id="theme.SearchPage.errorText"
description="The paragraph shown when the search request fails"
>
Something went wrong while searching. Please try again.
</Translate>
</div>
);
}
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,
};
},
if (loading && items.length === 0) {
return <SearchResultsSkeleton />;
}
if (items.length > 0) {
return (
<>
<SearchResults items={items} onSelect={handleSelectResult} />
{hasMore && (
<div className={styles.loadMoreWrapper}>
<button
type="button"
className={clsx(
'button',
'button--secondary',
'button--lg',
styles.loadMoreButton
)}
disabled={loadingMore}
onClick={loadMore}
>
{loadingMore ? (
<Translate
id="theme.SearchPage.fetchingNewResults"
description="The label for the load-more button while fetching new results"
>
Fetching new results...
</Translate>
) : (
<Translate
id="theme.SearchPage.loadMore"
description="The label for the button that loads more search results"
>
Load more results
</Translate>
)}
</button>
</div>
)}
</>
);
}
return (
<div className={styles.stateMessage}>
<p>
<Translate
id="theme.SearchPage.noResultsText"
description="The paragraph for empty search result"
>
No results were found
</Translate>
</p>
{hasActiveRefinements && (
<button
type="button"
className={clsx('button', 'button--secondary')}
onClick={clearRefinements}
>
<Translate
id="theme.SearchPage.clearFiltersFromNoResults"
description="The label for the button that clears filters when there are no results"
>
Clear all filters
</Translate>
</button>
)}
</div>
);
searchResultStateDispatcher({
type: 'update',
value: {
items,
query,
totalResults: nbHits,
totalPages: nbPages,
lastPage: page,
hasMore: nbPages > page + 1,
loading: false,
},
});
});
const [loaderRef, setLoaderRef] = useState<HTMLDivElement | null>(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 (
<Layout>
@ -390,127 +223,62 @@ function SearchPageContent(): ReactNode {
</Head>
<div className="container margin-vert--lg">
<Heading as="h1">{pageTitle}</Heading>
<header className={styles.pageHeader}>
<Heading as="h1" className={styles.pageTitle}>
{pageTitle}
</Heading>
<PoweredByAlgolia />
</header>
<form className="row" onSubmit={(e) => e.preventDefault()}>
<div
className={clsx('col', styles.searchQueryColumn, {
'col--9': docsSearchVersionsHelpers.versioningEnabled,
'col--12': !docsSearchVersionsHelpers.versioningEnabled,
})}
>
<input
autoFocus={true}
type="search"
name="q"
className={styles.searchQueryInput}
placeholder={translate({
id: 'theme.SearchPage.inputPlaceholder',
message: 'Type your search here',
description: 'The placeholder for search page input',
})}
aria-label={translate({
id: 'theme.SearchPage.inputLabel',
message: 'Search',
description: 'The ARIA label for search page input',
})}
value={searchQuery}
autoComplete="off"
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
{contextualSearch && docsSearchVersionsHelpers.versioningEnabled && (
<SearchVersionSelectList docsSearchVersionsHelpers={docsSearchVersionsHelpers} />
)}
<form
className={styles.searchForm}
role="search"
onSubmit={(event) => event.preventDefault()}
>
<SearchInput
autoFocus={true}
value={searchQuery}
onChange={setSearchQuery}
onClear={() => setSearchQuery('')}
/>
</form>
<div className="row">
<div className={clsx('col', 'col--8', styles.searchResultsColumn)}>
{searchResultState.totalResults !== null && documentsFoundPlural(searchResultState.totalResults)}
</div>
<div
className={clsx(
styles.layout,
hasSidebar && styles.layoutWithSidebar
)}
>
{hasSidebar && (
<SearchFacets
facets={facets}
refinements={refinements}
hasActiveRefinements={hasActiveRefinements}
showVersionSelects={showVersionSelects}
versionHelpers={versionHelpers}
onToggle={toggleRefinement}
onClear={clearRefinements}
/>
)}
<div className={clsx('col', 'col--4', styles.searchLogoColumn)}>
<span>
{translate({
id: 'theme.SearchPage.algoliaLabel',
message: 'Powered by',
description: 'The text explain that the search powered by Algolia',
})}
</span>
<Link
to="https://www.algolia.com/"
aria-label={translate({
id: 'theme.SearchPage.algoliaLabel',
message: 'Powered by Algolia',
description: 'The description label for Algolia mention',
})}
>
<AlgoliaLogo />
</Link>
<div className={styles.mainColumn}>
{isSearching && (totalResults !== null || hasActiveRefinements) && (
<div className={styles.resultsMeta}>
{totalResults !== null && (
<span className={styles.resultsCount}>
{documentsFoundPlural(totalResults)}
</span>
)}
<ActiveRefinements
refinements={refinements}
onRemove={toggleRefinement}
/>
</div>
)}
{renderMainContent()}
</div>
</div>
{searchResultState.items.length > 0 ? (
<main>
{searchResultState.items.map(({ title, url, summary, breadcrumbs }, i) => (
// eslint-disable-next-line react/no-array-index-key
<article key={i} className={styles.searchResultItem}>
<Heading as="h2" className={styles.searchResultItemHeading}>
<Link to={url} dangerouslySetInnerHTML={{ __html: title }} />
</Heading>
{breadcrumbs.length > 0 && (
<nav aria-label="breadcrumbs">
<ul className={clsx('breadcrumbs', styles.searchResultItemPath)}>
{breadcrumbs.map((html, index) => (
<li
// eslint-disable-next-line react/no-array-index-key
key={index}
className="breadcrumbs__item"
// Developer provided the HTML, so assume it's safe.
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: html }}
/>
))}
</ul>
</nav>
)}
{summary && (
<p
className={styles.searchResultItemSummary}
// Developer provided the HTML, so assume it's safe.
// eslint-disable-next-line react/no-danger
dangerouslySetInnerHTML={{ __html: summary }}
/>
)}
</article>
))}
</main>
) : (
[
searchQuery && !searchResultState.loading && (
<p key="no-results">
<Translate id="theme.SearchPage.noResultsText" description="The paragraph for empty search result">
No results were found
</Translate>
</p>
),
Boolean(searchResultState.loading) && <div key="spinner" className={styles.loadingSpinner} />,
]
)}
{searchResultState.hasMore && (
<div className={styles.loader} ref={setLoaderRef}>
<Translate
id="theme.SearchPage.fetchingNewResults"
description="The paragraph for fetching new search results"
>
Fetching new results...
</Translate>
</div>
)}
</div>
</Layout>
);

View file

@ -1,3 +1,5 @@
/* stylelint-disable selector-class-pattern -- CSS module class names are camelCased locals */
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
@ -5,123 +7,680 @@
* 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;
/* Header */
.pageHeader {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.5rem;
}
.searchQueryInput:focus,
.searchVersionInput:focus {
border-color: var(--docsearch-primary-color);
outline: none;
.pageTitle {
margin: 0;
}
.searchQueryInput::placeholder {
color: var(--docsearch-muted-color);
}
.searchResultsColumn {
font-size: 0.9rem;
font-weight: bold;
}
.searchLogoColumn {
.poweredBy {
display: flex;
align-items: center;
gap: 0.5rem;
justify-content: flex-end;
}
.searchLogoColumn a {
.poweredByLabel {
color: var(--ifm-color-content-secondary);
font-size: 0.8rem;
}
.poweredByLink {
display: flex;
}
.searchLogoColumn span {
color: var(--docsearch-muted-color);
font-weight: normal;
.algoliaLogo {
max-width: 120px;
}
.searchResultItem {
padding: 1rem 0;
border-bottom: 1px solid var(--ifm-toc-border-color);
/* Search box */
.searchForm {
margin-bottom: 1.5rem;
}
.searchResultItemHeading {
font-weight: 400;
.searchBox {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 0.85rem;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: var(--ifm-global-radius);
background: var(--ifm-background-surface-color);
transition: border-color var(--ifm-transition-fast) ease;
}
.searchBox:focus-within {
border-color: var(--ifm-color-primary);
}
.searchBoxIcon {
flex-shrink: 0;
color: var(--ifm-color-content-secondary);
}
.searchInput {
flex: 1;
min-width: 0;
padding: 0.85rem 0.25rem;
border: 0;
background: transparent;
color: var(--ifm-font-color-base);
font: inherit;
font-size: 1rem;
}
.searchInput:focus {
outline: none;
}
.searchInput::placeholder {
color: var(--ifm-color-content-secondary);
}
.searchBoxClear {
display: flex;
flex-shrink: 0;
padding: 0.25rem;
border: 0;
border-radius: 50%;
background: transparent;
color: var(--ifm-color-content-secondary);
cursor: pointer;
}
.searchBoxClear:hover {
color: var(--ifm-font-color-base);
background: var(--ifm-color-emphasis-200);
}
/* Layout */
.layout {
display: block;
}
.mainColumn {
min-width: 0;
}
/* Results meta / active refinements */
.resultsMeta {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 1rem;
}
.resultsCount {
color: var(--ifm-color-content-secondary);
font-size: 0.9rem;
font-weight: 600;
}
.activeRefinements {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.activeRefinement {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.25rem 0.6rem;
border: 0;
border-radius: 2rem;
background: var(--ifm-color-emphasis-200);
color: var(--ifm-font-color-base);
font-size: 0.8rem;
cursor: pointer;
}
.activeRefinement:hover {
background: var(--ifm-color-emphasis-300);
}
/* Result cards */
.resultList {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0;
padding: 0;
list-style: none;
}
.resultLink {
display: flex;
gap: 1rem;
padding: 1rem;
border: 1px solid transparent;
border-radius: var(--ifm-global-radius);
color: inherit;
text-decoration: none;
transition:
background-color var(--ifm-transition-fast) ease,
border-color var(--ifm-transition-fast) ease;
}
.resultLink:hover {
border-color: var(--ifm-color-emphasis-200);
background: var(--ifm-color-emphasis-100);
text-decoration: none;
}
.resultIconWrapper {
display: flex;
flex-shrink: 0;
padding-top: 0.15rem;
color: var(--ifm-color-content-secondary);
}
.resultIcon {
display: block;
}
.resultContent {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.35rem;
min-width: 0;
}
.resultBreadcrumbs {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
color: var(--ifm-color-content-secondary);
font-size: 0.8rem;
}
.resultBreadcrumbSeparator {
color: var(--ifm-color-emphasis-500);
}
.resultTitle {
color: var(--ifm-heading-color);
font-size: 1.05rem;
font-weight: 600;
}
.resultSummary {
display: -webkit-box;
overflow: hidden;
margin: 0;
color: var(--ifm-color-content-secondary);
font-size: 0.9rem;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.resultArrow {
flex-shrink: 0;
align-self: center;
color: var(--ifm-color-content-secondary);
opacity: 0;
transition:
opacity var(--ifm-transition-fast) ease,
transform var(--ifm-transition-fast) ease;
}
.resultLink:hover .resultArrow {
opacity: 1;
transform: translateX(0.15rem);
}
/* Sidebar / facets */
.sidebar {
min-width: 0;
}
.filtersToggle {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
width: 100%;
margin-bottom: 1rem;
padding: 0.65rem 0.85rem;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: var(--ifm-global-radius);
background: var(--ifm-background-surface-color);
color: var(--ifm-font-color-base);
font-weight: 600;
cursor: pointer;
transition:
border-color var(--ifm-transition-fast) ease,
border-radius var(--ifm-transition-fast) ease,
margin-bottom var(--ifm-transition-fast) ease;
}
.filtersToggleLabel {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.filtersToggleChevron {
flex-shrink: 0;
color: var(--ifm-color-content-secondary);
transition: transform var(--ifm-transition-fast) ease;
}
.filtersToggleOpen {
margin-bottom: 0;
border-bottom: 0;
border-bottom-left-radius: 0;
border-bottom-right-radius: 0;
}
.filtersToggleOpen .filtersToggleChevron {
transform: rotate(180deg);
}
.filtersToggleBadge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.4rem;
padding: 0 0.4rem;
border-radius: 2rem;
background: var(--ifm-color-primary);
color: var(--ifm-color-white);
font-size: 0.75rem;
}
.sidebarPanel {
display: none;
padding: 1.25rem;
border: 1px solid var(--ifm-color-emphasis-200);
border-radius: var(--ifm-global-radius);
background: var(--ifm-background-surface-color);
}
/* Higher specificity so this wins over `.sidebarPanel { display: none }` after CSS bundling. */
.sidebarPanel.sidebarPanelOpen {
display: block;
border-top: 0;
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.sidebarHeaderDesktop {
display: none;
align-items: center;
justify-content: space-between;
margin-bottom: 1rem;
}
.sidebarHeaderMobileClear {
display: flex;
justify-content: flex-end;
margin-bottom: 0.75rem;
}
.sidebarTitle {
font-size: 0.95rem;
font-weight: 700;
}
.clearFacetsButton {
padding: 0;
border: 0;
background: none;
color: var(--ifm-color-primary);
font-size: 0.8rem;
cursor: pointer;
}
.clearFacetsButton:hover {
text-decoration: underline;
}
.facetGroup {
margin-bottom: 1.5rem;
}
.facetGroup:last-child {
margin-bottom: 0;
}
.searchResultItemPath {
font-size: 0.8rem;
.facetLabel {
margin: 0 0 0.6rem;
color: var(--ifm-color-content-secondary);
--ifm-breadcrumb-separator-size-multiplier: 1;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.searchResultItemSummary {
margin: 0.5rem 0 0;
font-style: italic;
.facetList {
display: flex;
flex-direction: column;
gap: 0.15rem;
margin: 0;
padding: 0;
list-style: none;
}
@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;
}
.facetItem {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.35rem 0.4rem;
border-radius: var(--ifm-global-radius);
font-size: 0.9rem;
cursor: pointer;
}
@media screen and (max-width: 576px) {
.searchQueryColumn {
max-width: 100% !important;
}
.searchVersionColumn {
max-width: 100% !important;
padding-left: var(--ifm-spacing-horizontal) !important;
}
.facetItem:hover {
background: var(--ifm-color-emphasis-100);
}
.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;
.facetCheckbox {
flex-shrink: 0;
margin: 0;
accent-color: var(--ifm-color-primary);
}
@keyframes loading-spin {
.facetName {
flex: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.facetCount {
flex-shrink: 0;
padding: 0 0.4rem;
border-radius: var(--ifm-global-radius);
background: var(--ifm-color-emphasis-100);
color: var(--ifm-color-content-secondary);
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
}
.versionSelect {
width: 100%;
margin-bottom: 0.5rem;
padding: 0.5rem;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: var(--ifm-global-radius);
background: var(--ifm-background-color);
color: var(--ifm-font-color-base);
font: inherit;
}
.versionSelect:last-child {
margin-bottom: 0;
}
/* Skeleton */
.skeletonList {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.skeletonItem {
display: flex;
gap: 1rem;
padding: 1rem;
}
.skeletonIcon {
flex-shrink: 0;
width: 20px;
height: 20px;
border-radius: var(--ifm-global-radius);
background: var(--ifm-color-emphasis-200);
animation: search-page-pulse 1.4s ease infinite;
}
.skeletonBody {
display: flex;
flex: 1;
flex-direction: column;
gap: 0.6rem;
}
.skeletonLine {
height: 0.75rem;
border-radius: var(--ifm-global-radius);
background: var(--ifm-color-emphasis-200);
animation: search-page-pulse 1.4s ease infinite;
}
.skeletonLineShort {
width: 30%;
}
.skeletonLineTitle {
width: 55%;
height: 1rem;
}
.skeletonLineWide {
width: 90%;
}
@keyframes search-page-pulse {
0%,
100% {
transform: rotate(360deg);
opacity: 1;
}
50% {
opacity: 0.4;
}
}
.loader {
margin-top: 2rem;
/* Empty state */
.emptyState {
display: flex;
flex-direction: column;
gap: 2rem;
padding-block: 1rem;
}
:global(.search-result-match) {
color: var(--docsearch-hit-color);
background: rgb(255 215 142 / 25%);
padding: 0.09em 0;
.emptySectionHeader {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.emptySectionTitle {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.emptySectionAction {
padding: 0;
border: 0;
background: none;
color: var(--ifm-color-primary);
font-size: 0.85rem;
cursor: pointer;
}
.emptySectionAction:hover {
text-decoration: underline;
}
.recentList {
display: flex;
flex-direction: column;
gap: 0.15rem;
margin: 0;
padding: 0;
list-style: none;
}
.recentItem {
display: flex;
align-items: center;
gap: 0.25rem;
}
.recentButton {
display: inline-flex;
flex: 1;
align-items: center;
gap: 0.6rem;
padding: 0.5rem;
border: 0;
border-radius: var(--ifm-global-radius);
background: none;
color: var(--ifm-font-color-base);
font-size: 0.95rem;
text-align: left;
cursor: pointer;
}
.recentButton:hover {
background: var(--ifm-color-emphasis-100);
}
.recentButton svg {
flex-shrink: 0;
color: var(--ifm-color-content-secondary);
}
.recentRemove {
display: flex;
flex-shrink: 0;
padding: 0.35rem;
border: 0;
border-radius: 50%;
background: none;
color: var(--ifm-color-content-secondary);
cursor: pointer;
}
.recentRemove:hover {
background: var(--ifm-color-emphasis-200);
color: var(--ifm-font-color-base);
}
.sectionChips {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.sectionChip {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.85rem;
border: 1px solid var(--ifm-color-emphasis-300);
border-radius: 2rem;
background: var(--ifm-background-surface-color);
color: var(--ifm-font-color-base);
font-size: 0.9rem;
cursor: pointer;
transition:
border-color var(--ifm-transition-fast) ease,
color var(--ifm-transition-fast) ease;
}
.sectionChip:hover {
border-color: var(--ifm-color-primary);
color: var(--ifm-color-primary);
}
.sectionChipCount {
color: var(--ifm-color-content-secondary);
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
}
.emptyHint {
color: var(--ifm-color-content-secondary);
}
/* States */
.stateMessage {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 1rem;
padding: 2rem 0;
color: var(--ifm-color-content-secondary);
}
.loadMoreWrapper {
display: flex;
justify-content: center;
margin-top: 1.5rem;
}
.loadMoreButton {
min-width: 12rem;
}
/* Highlighting */
/* stylelint-disable-next-line selector-pseudo-class-no-unknown */
:global(.search-result-match) {
padding: 0.09em 0;
border-radius: 2px;
background: rgb(255 215 142 / 25%);
color: var(--docsearch-hit-color, inherit);
}
/* Responsive: two columns and always-visible sidebar on desktop */
@media (width >= 997px) {
.filtersToggle {
display: none;
}
.sidebarPanel {
display: block;
border-top: 1px solid var(--ifm-color-emphasis-200);
border-top-left-radius: var(--ifm-global-radius);
border-top-right-radius: var(--ifm-global-radius);
}
.sidebarHeaderDesktop {
display: flex;
}
.sidebarHeaderMobileClear {
display: none;
}
.layoutWithSidebar {
display: grid;
grid-template-columns: 16rem minmax(0, 1fr);
gap: 2rem;
align-items: start;
}
.sidebar {
position: sticky;
top: calc(var(--ifm-navbar-height) + 1rem);
}
}

View file

@ -0,0 +1,35 @@
/**
* 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 type SearchPageFacetConfig = {
attribute: string;
label?: string;
};
export type SearchResultItem = {
objectID: string;
title: string;
url: string;
summary: string;
breadcrumbs: string[];
type: string;
};
export type FacetValueItem = {
name: string;
count: number;
isRefined: boolean;
};
export type FacetGroup = {
attribute: string;
label: string;
items: FacetValueItem[];
};
// attribute -> selected facet values
export type Refinements = Record<string, string[]>;

View file

@ -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 { ThemeConfigDocSearch } from '@docsearch/docusaurus-adapter';
import type algoliaSearchHelper from 'algoliasearch-helper';
type SearchIndex = ThemeConfigDocSearch['indices'][number];
type PlainSearchParameters = NonNullable<
Parameters<typeof algoliaSearchHelper>[2]
>;
export function getIndexName(index: SearchIndex): string {
return typeof index === 'string' ? index : index.name;
}
export function getIndexSearchParameters(
index: SearchIndex
): PlainSearchParameters {
return (
typeof index === 'string' ? {} : (index.searchParameters ?? {})
) as PlainSearchParameters;
}

View file

@ -1,8 +1,8 @@
/**
* 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.
* 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';
@ -39,6 +39,7 @@ const translations: DocSearchTranslations & {
askAiScreen: {
disclaimerText: string;
relatedSourcesText: string;
relatedSourcesTextPlural: string;
thinkingText: string;
copyButtonText: string;
copyButtonCopiedText: string;
@ -46,6 +47,17 @@ const translations: DocSearchTranslations & {
likeButtonTitle: string;
dislikeButtonTitle: string;
thanksForFeedbackText: string;
feedbackPanelTitle: string;
feedbackDetailsPlaceholder: string;
feedbackDisclaimerText: string;
feedbackSubmitButtonText: string;
feedbackCloseButtonTitle: string;
feedbackTagIncorrect: string;
feedbackTagNotWhatIAsked: string;
feedbackTagSlowOrBuggy: string;
feedbackTagStyleOrTone: string;
feedbackTagSafetyOrLegal: string;
feedbackTagOther: string;
preToolCallText: string;
duringToolCallText: string;
afterToolCallText: string;
@ -127,7 +139,8 @@ const translations: DocSearchTranslations & {
placeholderTextAskAiStreaming: translate({
id: 'theme.SearchModal.searchBox.placeholderTextAskAiStreaming',
message: 'Answering...',
description: 'The placeholder text for search box when AI is streaming an answer',
description:
'The placeholder text for search box when AI is streaming an answer',
}),
enterKeyHint: translate({
id: 'theme.SearchModal.searchBox.enterKeyHint',
@ -219,13 +232,19 @@ const translations: DocSearchTranslations & {
askAiScreen: {
disclaimerText: translate({
id: 'theme.SearchModal.askAiScreen.disclaimerText',
message: 'Answers are generated with AI which can make mistakes. Verify responses.',
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',
message: 'Source',
description: 'Text shown describing a singular related source',
}),
relatedSourcesTextPlural: translate({
id: 'theme.SearchModal.askAiScreen.relatedSourcesTextPlural',
message: 'Sources',
description: 'Text shown describing multiple related sources',
}),
thinkingText: translate({
id: 'theme.SearchModal.askAiScreen.thinkingText',
@ -262,6 +281,67 @@ const translations: DocSearchTranslations & {
message: 'Thanks for your feedback!',
description: 'The text for thanks for feedback',
}),
feedbackPanelTitle: translate({
id: 'theme.SearchModal.askAiScreen.feedbackPanelTitle',
message: 'What went wrong? (optional)',
description: 'The title of the negative feedback note panel',
}),
feedbackDetailsPlaceholder: translate({
id: 'theme.SearchModal.askAiScreen.feedbackDetailsPlaceholder',
message: 'Share some details...',
description:
'The placeholder for the negative feedback details textarea',
}),
feedbackDisclaimerText: translate({
id: 'theme.SearchModal.askAiScreen.feedbackDisclaimerText',
message: 'A copy of this chat will be included with your feedback.',
description:
'The disclaimer shown inside the negative feedback note panel',
}),
feedbackSubmitButtonText: translate({
id: 'theme.SearchModal.askAiScreen.feedbackSubmitButtonText',
message: 'Submit',
description:
'The submit button text for the negative feedback note panel',
}),
feedbackCloseButtonTitle: translate({
id: 'theme.SearchModal.askAiScreen.feedbackCloseButtonTitle',
message: 'Close',
description:
'The accessible title for the negative feedback note panel close button',
}),
feedbackTagIncorrect: translate({
id: 'theme.SearchModal.askAiScreen.feedbackTagIncorrect',
message: 'Incorrect or incomplete',
description: 'The "incorrect" reason chip for negative feedback',
}),
feedbackTagNotWhatIAsked: translate({
id: 'theme.SearchModal.askAiScreen.feedbackTagNotWhatIAsked',
message: 'Not what I asked for',
description:
'The "not what I asked for" reason chip for negative feedback',
}),
feedbackTagSlowOrBuggy: translate({
id: 'theme.SearchModal.askAiScreen.feedbackTagSlowOrBuggy',
message: 'Slow or buggy',
description: 'The "slow or buggy" reason chip for negative feedback',
}),
feedbackTagStyleOrTone: translate({
id: 'theme.SearchModal.askAiScreen.feedbackTagStyleOrTone',
message: 'Style or tone',
description: 'The "style or tone" reason chip for negative feedback',
}),
feedbackTagSafetyOrLegal: translate({
id: 'theme.SearchModal.askAiScreen.feedbackTagSafetyOrLegal',
message: 'Safety or legal concern',
description:
'The "safety or legal concern" reason chip for negative feedback',
}),
feedbackTagOther: translate({
id: 'theme.SearchModal.askAiScreen.feedbackTagOther',
message: 'Other',
description: 'The "other" reason chip for negative feedback',
}),
preToolCallText: translate({
id: 'theme.SearchModal.askAiScreen.preToolCallText',
message: 'Searching...',

View file

@ -1,8 +1,8 @@
/**
* 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.
* This source code is licensed under the MIT license found in the LICENSE file
* in the root directory of this source tree.
*/
/// <reference types="@docusaurus/module-type-aliases" />

View file

@ -1,13 +1,13 @@
/**
* 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.
* 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.
* 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

View file

@ -1,8 +1,8 @@
/**
* 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.
* 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';

View file

@ -1,8 +1,8 @@
/**
* 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.
* This source code is licensed under the MIT license found in the LICENSE file
* in the root directory of this source tree.
*/
/**
@ -10,7 +10,8 @@
* joins them into a reasonable URL.
*
* - `["file:", "/home", "/user/", "website"]` => `file:///home/user/website`
* - `["file://", "home", "/user/", "website"]` => `file://home/user/website` (relative!)
* - `["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.
@ -24,7 +25,8 @@ export function normalizeUrl(rawUrls: string[]): string {
let hasStartingSlash = false;
let hasEndingSlash = false;
const isNonEmptyArray = (arr: string[]): arr is [string, ...string[]] => arr.length > 0;
const isNonEmptyArray = (arr: string[]): arr is [string, ...string[]] =>
arr.length > 0;
if (!isNonEmptyArray(urls)) {
return '';
@ -69,7 +71,7 @@ export function normalizeUrl(rawUrls: string[]): string {
/^\/+/,
// Special case where the first element of rawUrls is empty
// ["", "/hello"] => /hello
component.startsWith('/') && !hasStartingSlash ? '/' : '',
component.startsWith('/') && !hasStartingSlash ? '/' : ''
);
}

View file

@ -1,39 +1,50 @@
/**
* 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.
* 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 {
ThemeConfig,
ThemeConfigDocSearch,
} 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<ThemeConfigAlgolia>;
searchPage: {
path: 'search',
},
} satisfies Partial<ThemeConfigDocSearch>;
const FacetFiltersSchema = Joi.array().items(Joi.alternatives().try(Joi.string(), Joi.array().items(Joi.string())));
const FacetFiltersSchema = Joi.array().items(
Joi.alternatives().try(Joi.string(), Joi.array().items(Joi.string()))
);
const AskAiSearchParametersSchema = Joi.object({
const SearchParametersSchema = 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(),
distinct: Joi.alternatives()
.try(Joi.boolean(), Joi.number(), Joi.string())
.optional(),
}).unknown();
const AskAiMemorySchema = Joi.object({
enabled: Joi.bool().optional().default(false),
userToken: Joi.string().optional(),
}).unknown(false);
const SidePanelKeyboardShortcutsSchema = Joi.object({
'Ctrl/Cmd+I': Joi.boolean().optional(),
});
}).unknown(false);
const SidePanelSchema = Joi.object({
keyboardShortcuts: SidePanelKeyboardShortcutsSchema.optional(),
@ -45,179 +56,220 @@ const SidePanelSchema = Joi.object({
suggestedQuestions: Joi.boolean().optional(),
translations: Joi.object().optional().unknown(),
hideButton: Joi.boolean().optional(),
});
portalContainer: Joi.object().optional().unknown(),
indices: Joi.array().items(Joi.string().min(1)).min(1).optional(),
memory: AskAiMemorySchema.optional(),
}).unknown(false);
export const Schema = Joi.object<ThemeConfig>({
algolia: Joi.object<ThemeConfigAlgolia>({
// 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'];
}
const KeyboardShortcutsSchema = Joi.object({
'Ctrl/Cmd+K': Joi.boolean().optional(),
'/': Joi.boolean().optional(),
'Ctrl/Cmd+I': Joi.boolean().optional(),
}).unknown(false);
// 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,
};
}
const IndexSchema = Joi.alternatives().try(
Joi.string(),
Joi.object({
name: Joi.string().required(),
searchParameters: SearchParametersSchema.optional(),
}).unknown(false)
);
return normalizedAskAi;
})
.optional()
.messages({
'alternatives.types':
'askAi must be either a string (assistantId) or an object with indexName, apiKey, appId, and assistantId',
}),
const AskAiPromptSuggestionsSchema = Joi.object({
indexName: Joi.string().min(1).required(),
hitsPerPage: Joi.number().positive().optional().default(3),
}).unknown(false);
const AskAiSchema = Joi.object({
agentId: Joi.string().required(),
suggestedQuestions: Joi.boolean().optional(),
searchParameters: Joi.object()
.pattern(Joi.string(), SearchParametersSchema)
.optional(),
indices: Joi.array().items(Joi.string().min(1)).min(1).optional(),
memory: AskAiMemorySchema.optional(),
promptSuggestions: AskAiPromptSuggestionsSchema.optional(),
}).unknown(false);
const SearchPageFacetSchema = Joi.object({
attribute: Joi.string().required(),
label: Joi.string().optional(),
}).unknown(false);
const SearchPageSchema = Joi.alternatives()
.try(
Joi.boolean().valid(false),
Joi.object({
path: Joi.string().default(DEFAULT_CONFIG.searchPage.path),
facets: Joi.array().items(SearchPageFacetSchema).optional(),
}).unknown(false)
)
.default(DEFAULT_CONFIG.searchPage);
const DocSearchSchema = Joi.object<ThemeConfigDocSearch>({
contextualSearch: Joi.boolean().default(DEFAULT_CONFIG.contextualSearch),
externalUrlRegex: Joi.string().optional(),
appId: Joi.string().required().messages({
'any.required':
'"docsearch.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(),
indices: Joi.array().items(IndexSchema).min(1).required(),
facets: Joi.array()
.items(
Joi.object({
key: Joi.string().required(),
label: Joi.string().optional(),
}).unknown(false)
)
.optional(),
initialQuery: Joi.string().optional(),
insights: Joi.alternatives()
.try(Joi.boolean(), Joi.object().unknown())
.optional(),
placeholder: Joi.string().optional(),
translations: Joi.object().optional().unknown(),
maxResultsPerGroup: Joi.number().optional(),
disableUserPersonalization: Joi.boolean().optional(),
getMissingResultsUrl: Joi.function().optional(),
keyboardShortcuts: KeyboardShortcutsSchema.optional(),
recentSearchesLimit: Joi.number().optional(),
recentSearchesWithFavoritesLimit: Joi.number().optional(),
resultBadgeKey: Joi.string().optional(),
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(),
})
.label('themeConfig.algolia')
.required()
.unknown(),
});
.unknown(false)
.optional(),
searchPage: SearchPageSchema,
askAi: AskAiSchema.optional(),
sidePanel: Joi.alternatives().try(Joi.boolean(), SidePanelSchema).optional(),
})
.label('themeConfig.docsearch')
.unknown(false);
function ensureSidepanelSupported(themeConfig: ThemeConfig) {
const docsearch = getDocSearchConfig(themeConfig);
const sidePanelEnabled = docsearch.askAi && typeof docsearch.askAi === 'object' && Boolean(docsearch.askAi.sidePanel);
const Schema = Joi.object<ThemeConfig>({
docsearch: DocSearchSchema.required(),
}).unknown(false);
if (!sidePanelEnabled) {
function assertNoRemovedKeys(themeConfig: ThemeConfig): void {
const themeConfigRecord = themeConfig as Record<string, unknown>;
if (themeConfigRecord.algolia !== undefined) {
throw new Error(
'`themeConfig.algolia` is no longer supported by @docsearch/docusaurus-adapter v5. Move the configuration to `themeConfig.docsearch`.'
);
}
const docsearch = themeConfigRecord.docsearch;
if (!docsearch || typeof docsearch !== 'object') {
return;
}
const isSidepanelSupported = (() => {
const match = docSearchVersionString.match(/^(?<major>\d+)\.(?<minor>\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);
})();
const docsearchRecord = docsearch as Record<string, unknown>;
if (!isSidepanelSupported) {
if (docsearchRecord.indexName !== undefined) {
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.',
'`themeConfig.docsearch.indexName` was removed. Use `themeConfig.docsearch.indices` instead.'
);
}
if (docsearchRecord.searchParameters !== undefined) {
throw new Error(
'`themeConfig.docsearch.searchParameters` was removed. Configure `searchParameters` on each `themeConfig.docsearch.indices` entry instead.'
);
}
if (docsearchRecord.searchPagePath !== undefined) {
throw new Error(
'`themeConfig.docsearch.searchPagePath` was removed. Use `themeConfig.docsearch.searchPage` instead.'
);
}
const sidePanel = docsearchRecord.sidePanel;
if (
sidePanel &&
typeof sidePanel === 'object' &&
(sidePanel as Record<string, unknown>).tools !== undefined
) {
throw new Error(
'`themeConfig.docsearch.sidePanel.tools` is not supported because Docusaurus removes function values when serializing theme config. Pass custom tools through a swizzled `@theme/SearchBar` component instead: use `askAi` for the modal and `sidePanel` for the side panel.'
);
}
const askAi = docsearchRecord.askAi;
if (typeof askAi === 'string') {
throw new Error(
'`themeConfig.docsearch.askAi` must be an object with `agentId`.'
);
}
if (!askAi || typeof askAi !== 'object') {
return;
}
const askAiRecord = askAi as Record<string, unknown>;
if (askAiRecord.agentStudio !== undefined) {
throw new Error(
'`themeConfig.docsearch.askAi.agentStudio` was removed. The adapter now only supports Agent Studio.'
);
}
if (
askAiRecord.indexName !== undefined ||
askAiRecord.apiKey !== undefined ||
askAiRecord.appId !== undefined
) {
throw new Error(
'`themeConfig.docsearch.askAi.indexName`, `apiKey`, and `appId` were removed. Use the top-level DocSearch credentials instead.'
);
}
if (askAiRecord.sidePanel !== undefined) {
throw new Error(
'`themeConfig.docsearch.askAi.sidePanel` was removed. Use `themeConfig.docsearch.sidePanel` instead.'
);
}
if (askAiRecord.tools !== undefined) {
throw new Error(
'`themeConfig.docsearch.askAi.tools` is not supported because Docusaurus removes function values when serializing theme config. Pass custom tools through a swizzled `@theme/SearchBar` component instead: use `askAi` for the modal and `sidePanel` for the side panel.'
);
}
}
function hasConfigValue<TValue>(value: TValue | null | undefined): value is TValue {
return value !== undefined && value !== null;
}
function ensureSidePanelHasAskAi(themeConfig: ThemeConfig): void {
const { docsearch } = themeConfig;
function getThemeConfigSource(themeConfig: ThemeConfig): 'algolia' | 'docsearch' | null {
const hasDocsearch = hasConfigValue(themeConfig.docsearch);
const hasAlgolia = hasConfigValue(themeConfig.algolia);
if (hasDocsearch && hasAlgolia) {
if (docsearch?.sidePanel && !docsearch.askAi) {
throw new Error(
'Please provide either "themeConfig.docsearch" (preferred) or "themeConfig.algolia" (legacy), but not both.',
'`themeConfig.docsearch.sidePanel` requires `themeConfig.docsearch.askAi`.'
);
}
if (hasDocsearch) {
return 'docsearch';
}
if (hasAlgolia) {
return 'algolia';
}
return null;
}
export function validateThemeConfig({
validate,
themeConfig: themeConfigInput,
}: ThemeConfigValidationContext<ThemeConfig>): ThemeConfig {
const source = getThemeConfigSource(themeConfigInput);
assertNoRemovedKeys(themeConfigInput);
if (!source) {
return validate(Schema, {});
}
const validated = validate(Schema, {
algolia: source === 'docsearch' ? themeConfigInput.docsearch : themeConfigInput.algolia,
const themeConfig = validate(Schema, {
docsearch: themeConfigInput.docsearch,
}) as ThemeConfig;
const themeConfig: ThemeConfig = {
[source]: validated.algolia,
};
ensureSidepanelSupported(themeConfig);
ensureSidePanelHasAskAi(themeConfig);
return themeConfig;
}

View file

@ -5,6 +5,11 @@
"rootDir": "src",
"composite": true
},
"include": ["src/theme", "src/client", "src/getDocSearchConfig.ts", "src/*.d.ts"],
"include": [
"src/theme",
"src/client",
"src/getDocSearchConfig.ts",
"src/*.d.ts"
],
"exclude": ["**/__tests__/**"]
}

View file

@ -1,6 +1,6 @@
{
"extends": "../../tsconfig.base.json",
"references": [{"path": "./tsconfig.client.json"}],
"references": [{ "path": "./tsconfig.client.json" }],
"compilerOptions": {
"noEmit": false,
"outDir": "lib",

5225
bun.lock Normal file

File diff suppressed because it is too large Load diff

View file

@ -2,15 +2,19 @@
"files": [
{
"path": "packages/docsearch-css/dist/style.css",
"maxSize": "6 kB"
"maxSize": "8.2 kB"
},
{
"path": "packages/docsearch-react/dist/umd/index.js",
"maxSize": "115 kB"
"maxSize": "160 kB"
},
{
"path": "packages/docsearch-js/dist/umd/index.js",
"maxSize": "128 kB"
"maxSize": "180 kB"
},
{
"path": "packages/docsearch-js/dist/umd/docsearch.js",
"maxSize": "100 kB"
},
{
"path": "packages/docsearch-core/dist/umd/index.js",
@ -19,6 +23,14 @@
{
"path": "packages/docsearch-modal/dist/umd/index.js",
"maxSize": "113.5 kB"
},
{
"path": "packages/docsearch-sidepanel/dist/umd/index.js",
"maxSize": "2 kB"
},
{
"path": "packages/docsearch-sidepanel-js/dist/umd/index.js",
"maxSize": "140 kB"
}
]
}

View file

@ -1,9 +0,0 @@
import { defineConfig } from 'cypress';
export default defineConfig({
projectId: 'nf9rdc',
e2e: {
baseUrl: 'http://localhost:3000',
specPattern: 'cypress/e2e/**/*.{js,jsx,ts,tsx}',
},
});

Binary file not shown.

View file

@ -1,48 +0,0 @@
/// <reference path="../support/commands.d.ts" />
describe.skip('Ask AI', () => {
beforeEach(() => {
cy.visit(Cypress.config().baseUrl!);
cy.openModal();
});
it('Results are displayed after a query', () => {
cy.typeQueryMatching();
cy.get('.DocSearch-Hits').should('be.visible');
});
it('Shows Ask AI as a hit on search', () => {
cy.typeQueryMatching();
cy.get('.DocSearch-AskAi-Section').should('be.visible');
});
it('Opens Ask AI on enter key', () => {
cy.typeQueryMatching();
cy.get('.DocSearch-Input').type('{enter}');
cy.get('.DocSearch-AskAiScreen').should('be.visible');
});
it('Opens Ask AI on click', () => {
cy.typeQueryMatching();
cy.get('#docsearch-askAI-item-0').click();
cy.get('.DocSearch-AskAiScreen').should('be.visible');
});
it('Streams response after query', () => {
cy.goToAskAi();
cy.get('.DocSearch-AskAiScreen-Query').should('be.visible');
cy.get('.DocSearch-AskAiScreen-Message').should('be.visible');
});
it('Copy button copies the response to the clipboard', () => {
cy.goToAskAi();
cy.get('.DocSearch-AskAiScreen-Response').should('be.visible');
cy.window().its('navigator.permissions').invoke('query', { name: 'clipboard-read' }).its('state').then(cy.log);
cy.window()
.its('navigator.clipboard')
.invoke('readText')
.then((text) => {
expect(text.length).to.be.greaterThan(0);
});
});
});

View file

@ -1,153 +0,0 @@
/// <reference path="../support/commands.d.ts" />
describe('Start', () => {
beforeEach(() => {
cy.visit(Cypress.config().baseUrl!);
cy.waitLoad();
});
it('Open modal on search button click', () => {
cy.openModal();
cy.modalIsVisibleAndFocused();
// check that the scrollbar offset is compensated
cy.get('body').should('have.css', 'overflow', 'hidden');
cy.get('.DocSearch-Modal').should('be.visible');
});
it('Open modal with key shortcut on Windows/Linux', () => {
cy.get('body').type('{ctrl}k');
cy.modalIsVisibleAndFocused();
});
it('Open modal with key shortcut on Windows/Linux when caps lock is on', () => {
cy.get('body').type('{ctrl}K');
cy.modalIsVisibleAndFocused();
});
it('Open modal with key shortcut on macOS', () => {
cy.get('body').type('{meta}k');
cy.modalIsVisibleAndFocused();
});
it('Open modal with key shortcut on macOS when caps lock is on', () => {
cy.get('body').type('{meta}K');
cy.modalIsVisibleAndFocused();
});
it('Open modal with forward slash key shortcut', () => {
cy.get('body').wait(1000).type('/');
cy.modalIsVisibleAndFocused();
});
});
describe('End', () => {
beforeEach(() => {
cy.visit(Cypress.config().baseUrl!);
cy.openModal();
});
it('Close modal with Esc key', () => {
cy.closeModal();
cy.modalIsNotVisible();
});
it('Close modal by clicking outside its container', () => {
cy.get('body').click(0, 0);
cy.modalIsNotVisible();
});
it('Close modal with key shortcut on Windows/Linux', () => {
cy.get('body').type('{ctrl}k');
cy.modalIsNotVisible();
});
it('Close modal with key shortcut on macOS', () => {
cy.get('body').type('{meta}k');
cy.modalIsNotVisible();
});
});
describe('Search', () => {
beforeEach(() => {
cy.visit(Cypress.config().baseUrl!);
cy.openModal();
});
it('Results are displayed after a query', () => {
cy.typeQueryMatching();
cy.get('.DocSearch-Hits').should('be.visible');
});
it('Query can be cleared', () => {
cy.typeQueryMatching();
cy.get('.DocSearch-Clear').click();
cy.get('.DocSearch-Hits').should('not.exist');
});
it('Keyboard navigation leads to result', () => {
const currentURL = cy.url();
cy.typeQueryMatching();
cy.get('.DocSearch-Input').type('{downArrow}{downArrow}{upArrow}');
cy.get('.DocSearch-Input').type('{enter}');
cy.on('url:changed', (newUrl) => {
expect(newUrl).not.equal(currentURL);
});
});
it('Pointer navigation leads to result', () => {
const currentURL = cy.url();
cy.typeQueryMatching();
cy.get('#docsearch-hits_docsearch_0-item-1 > a').click({
force: true,
});
cy.on('url:changed', (newUrl) => {
expect(newUrl).not.equal(currentURL);
});
});
it("No results are displayed if query doesn't match", () => {
cy.typeQueryNotMatching();
cy.contains('No results found for').should('be.visible');
});
it('Should not refer to Recent/Favorite in aria-controls', () => {
cy.get('.DocSearch-Input').should('not.have.attr', 'aria-controls');
});
});
describe('Recent and Favorites', () => {
beforeEach(() => {
cy.visit(Cypress.config().baseUrl!);
cy.openModal();
cy.typeQueryMatching();
cy.get('#docsearch-hits_docsearch_0-item-1 > a').click({ force: true }).wait(1000);
cy.openModal();
cy.contains('Recent').should('be.visible');
});
it('Recent search is displayed after visiting a result', () => {
cy.clearSearch();
cy.get('#docsearch-recentSearches-item-0').should('be.visible');
});
it('Recent search can be deleted', () => {
cy.get('#docsearch-recentSearches-item-0').find('[title="Remove this search from history"]').trigger('click');
cy.get('.DocSearch-Hits').should('not.exist');
});
it('Recent search can be favorited', () => {
cy.get('#docsearch-recentSearches-item-0').find('[title="Save this search"]').trigger('click');
cy.contains('Favorite').should('be.visible');
cy.get('#docsearch-favoriteSearches-item-0').should('be.visible');
});
it('Favorite can be deleted', () => {
cy.get('#docsearch-recentSearches-item-0').find('[title="Save this search"]').trigger('click');
cy.contains('Favorite').should('be.visible');
cy.get('#docsearch-favoriteSearches-item-0').find('[title="Remove this search from favorites"]').trigger('click');
cy.get('.DocSearch-Hits').should('not.exist');
});
});

View file

@ -1,55 +0,0 @@
/// <reference types="cypress" />
declare namespace Cypress {
interface Chainable {
/**
* Ensures the modal is visible and focused.
*/
modalIsVisibleAndFocused: () => void;
/**
* Ensures the modal not visible.
*/
modalIsNotVisible: () => void;
/**
* Toggles the dark mode on the preview website.
*/
darkmode: () => void;
/**
* Wait for the page to load.
*/
waitLoad: () => void;
/**
* Opens the DocSearch modal.
*/
openModal: () => void;
/**
* Closes the DocSearch modal.
*/
closeModal: () => void;
/**
* Search for a given query.
*/
search: (query: string) => void;
/**
* Types a query that returns results.
*/
typeQueryMatching: () => void;
/**
* Clears the search input.
*/
clearSearch: () => void;
/**
* Types a query that returns no results.
*/
typeQueryNotMatching: () => void;
/**
* Returns to the keyword search screen.
*/
returnToSearch: () => void;
/**
* Navigates to the Ask AI screen.
*/
goToAskAi: () => void;
}
}

View file

@ -1,54 +0,0 @@
Cypress.Commands.add('modalIsVisibleAndFocused', () => {
cy.get('.DocSearch-Modal', { timeout: 10000 }).should('be.visible');
cy.get('.DocSearch-Input').should('be.focus');
});
Cypress.Commands.add('modalIsNotVisible', () => {
cy.get('body').should('not.have.class', 'DocSearch--active');
cy.get('.DocSearch-Modal').should('not.exist');
});
Cypress.Commands.add('darkmode', () => {
cy.get('.react-toggle').click({ force: true });
cy.get('.react-toggle-screenreader-only').blur();
cy.get('html.dark').should('be.visible');
});
Cypress.Commands.add('waitLoad', () => {
cy.get('.DocSearch-Button', { timeout: 10000 }).should('be.visible');
});
Cypress.Commands.add('openModal', () => {
cy.get('.DocSearch-Button').should('be.visible').click();
cy.modalIsVisibleAndFocused();
});
Cypress.Commands.add('closeModal', () => {
cy.get('body').type('{esc}');
cy.modalIsNotVisible();
});
Cypress.Commands.add('search', (query: string) => {
cy.get('.DocSearch-Input').should('be.visible').type(query);
});
Cypress.Commands.add('typeQueryMatching', () => {
cy.search('g');
});
Cypress.Commands.add('typeQueryNotMatching', () => {
cy.search('zzz');
});
Cypress.Commands.add('clearSearch', () => {
cy.get('.DocSearch-Input').clear();
});
Cypress.Commands.add('returnToSearch', () => {
cy.get('.DocSearch-Input').type('{esc}');
});
Cypress.Commands.add('goToAskAi', () => {
cy.typeQueryMatching();
cy.get('#docsearch-AskAi-Section').click();
});

View file

@ -1,20 +0,0 @@
// ***********************************************************
// This example support/index.js is processed and
// loaded automatically before your test files.
//
// This is a great place to put global configuration and
// behavior that modifies Cypress.
//
// You can change the location of this file or turn off
// automatically serving support files with the
// 'supportFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/configuration
// ***********************************************************
// Import commands.js using ES2015 syntax:
import './commands';
// Alternatively you can use CommonJS syntax:
// require('./commands')

View file

@ -1,7 +0,0 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": ["cypress"]
},
"exclude": []
}

71
e2e/a11y.test.ts Normal file
View file

@ -0,0 +1,71 @@
import { test, expect, gatherA11yViolations } from './fixtures';
test.describe('a11y > Modal', () => {
test.beforeEach(async ({ docSearch }) => {
await docSearch.goto();
await docSearch.waitForLoad();
});
test('Smoke test', async ({ docSearch, axe }, testInfo) => {
await docSearch.openModal();
const scanResults = await axe().include('.DocSearch-Container').analyze();
await testInfo.attach('a11y-scan-results-modal', {
body: JSON.stringify(scanResults.violations, null, 2),
contentType: 'application/json',
});
// 6 is the current number of reported violations
expect(
gatherA11yViolations(scanResults.violations).length
).toBeLessThanOrEqual(6);
});
test('Search results', async ({ docSearch, axe }, testInfo) => {
await docSearch.openModal();
await docSearch.typeQueryMatching();
await expect(docSearch.hits).toBeVisible();
const scanResults = await axe()
.include('#docsearch-hits_docsearch_0-list')
.analyze();
await testInfo.attach('a11y-scan-results-modal-search-results', {
body: JSON.stringify(scanResults.violations, null, 2),
contentType: 'application/json',
});
// 24 is the current number of reported violations
expect(
gatherA11yViolations(scanResults.violations).length
).toBeLessThanOrEqual(24);
});
});
test.describe('a11y > Sidepanel', () => {
test.beforeEach(async ({ docSearch, sidepanel }) => {
await docSearch.goto();
await sidepanel.waitForLoad();
});
test('Smoke test', async ({ sidepanel, axe }, testInfo) => {
await sidepanel.openSidepanel();
const scanResults = await axe()
.include('.DocSearch-Sidepanel-Container')
.analyze();
await testInfo.attach('a11y-scan-results-sidepanel', {
body: JSON.stringify(scanResults.violations, null, 2),
contentType: 'application/json',
});
// 4 is the current number of reported violations
expect(
gatherA11yViolations(scanResults.violations).length
).toBeLessThanOrEqual(4);
});
});

53
e2e/askai.spec.ts Normal file
View file

@ -0,0 +1,53 @@
import { test, expect } from './fixtures';
test.describe.skip('Ask AI', () => {
test.beforeEach(async ({ docSearch }) => {
await docSearch.goto();
await docSearch.openModal();
});
test('Results are displayed after a query', async ({ docSearch }) => {
await docSearch.typeQueryMatching();
await expect(docSearch.hits).toBeVisible();
});
test('Shows Ask AI as a hit on search', async ({ docSearch, page }) => {
await docSearch.typeQueryMatching();
await expect(page.locator('.DocSearch-AskAi-Section')).toBeVisible();
});
test('Opens Ask AI on enter key', async ({ docSearch, page }) => {
await docSearch.typeQueryMatching();
await page.keyboard.press('Enter');
await expect(page.locator('.DocSearch-AskAiScreen')).toBeVisible();
});
test('Opens Ask AI on click', async ({ docSearch, page }) => {
await docSearch.typeQueryMatching();
await page.locator('#docsearch-askAI-item-0').click();
await expect(page.locator('.DocSearch-AskAiScreen')).toBeVisible();
});
test('Streams response after query', async ({ docSearch, page }) => {
await docSearch.goToAskAi();
await expect(page.locator('.DocSearch-AskAiScreen-Query')).toBeVisible();
await expect(page.locator('.DocSearch-AskAiScreen-Message')).toBeVisible();
});
test('Copy button copies the response to the clipboard', async ({
docSearch,
page,
context,
}) => {
// Grant clipboard permissions
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
await docSearch.goToAskAi();
await expect(page.locator('.DocSearch-AskAiScreen-Response')).toBeVisible();
const clipboardText = await page.evaluate(() =>
navigator.clipboard.readText()
);
expect(clipboardText.length).toBeGreaterThan(0);
});
});

150
e2e/fixtures.ts Normal file
View file

@ -0,0 +1,150 @@
// oxlint-disable max-classes-per-file react-hooks/rules-of-hooks
import AxeBuilder from '@axe-core/playwright';
import {
test as base,
expect,
type Page,
type Locator,
} from '@playwright/test';
export class DocSearchPage {
readonly page: Page;
readonly searchButton: Locator;
readonly modal: Locator;
readonly input: Locator;
readonly hits: Locator;
readonly clearButton: Locator;
readonly firstHit: Locator;
constructor(page: Page) {
this.page = page;
this.searchButton = page.locator('.DocSearch-Button');
this.modal = page.locator('.DocSearch-Modal');
this.input = page.locator('.DocSearch-Input');
this.hits = page.locator('.DocSearch-Hits').first();
this.clearButton = page.locator('.DocSearch-Clear');
this.firstHit = page
.locator('#docsearch-hits_docsearch_0-list .DocSearch-Hit a')
.first();
}
async goto(): Promise<void> {
await this.page.goto('/docs/what-is-docsearch');
}
async waitForLoad(): Promise<void> {
await expect(this.searchButton).toBeVisible({ timeout: 10000 });
}
async openModal(): Promise<void> {
await this.searchButton.click();
await this.expectModalVisibleAndFocused();
}
async closeModal(): Promise<void> {
await this.page.keyboard.press('Escape');
await this.expectModalNotVisible();
}
async expectModalVisibleAndFocused(): Promise<void> {
await expect(this.modal).toBeVisible({ timeout: 10000 });
await expect(this.input).toBeFocused();
}
async expectModalNotVisible(): Promise<void> {
await expect(this.page.locator('body')).not.toHaveClass(
/DocSearch--active/
);
await expect(this.modal).not.toBeVisible();
}
async search(query: string): Promise<void> {
await this.input.fill(query);
}
async typeQueryMatching(): Promise<void> {
await this.search('g');
}
async typeQueryNotMatching(): Promise<void> {
await this.search('zzz');
}
async clearSearch(): Promise<void> {
await this.input.clear();
}
async clickFirstHit(): Promise<void> {
await expect(this.hits).toBeVisible();
await this.firstHit.click({ force: true });
}
async enableDarkMode(): Promise<void> {
await this.page.locator('.react-toggle').click({ force: true });
await this.page.locator('.react-toggle-screenreader-only').blur();
await expect(this.page.locator('html.dark')).toBeVisible();
}
async goToAskAi(): Promise<void> {
await this.typeQueryMatching();
await this.page.locator('#docsearch-AskAi-Section').click();
}
}
export class SidepanelPage {
readonly page: Page;
readonly sidepanelButton: Locator;
readonly sidepanel: Locator;
constructor(page: Page) {
this.page = page;
this.sidepanelButton = page.locator('.DocSearch-SidepanelButton');
this.sidepanel = page.locator('.DocSearch-Sidepanel-Container');
}
async waitForLoad(): Promise<void> {
await expect(this.sidepanelButton).toBeVisible({ timeout: 10000 });
}
async openSidepanel(): Promise<void> {
await this.sidepanelButton.click();
await expect(this.sidepanel).toHaveClass(/is-open/, {
timeout: 10000,
});
}
}
export const test = base.extend<{
docSearch: DocSearchPage;
sidepanel: SidepanelPage;
axe: () => AxeBuilder;
}>({
docSearch: async ({ page }, use) => {
const docSearch = new DocSearchPage(page);
await use(docSearch);
},
sidepanel: async ({ page }, use) => {
const sidepanel = new SidepanelPage(page);
await use(sidepanel);
},
axe: async ({ page }, use) => {
const makeBuilder = () =>
new AxeBuilder({ page }).withTags([
'wcag2a',
'wcag2aa',
'wcag21a',
'wcag21aa',
'wcag22a',
'wcag22aa',
]);
await use(makeBuilder);
},
});
type AxeScanResults = Awaited<ReturnType<AxeBuilder['analyze']>>;
function gatherA11yViolations(violations: AxeScanResults['violations']) {
return violations.flatMap((v) => v.nodes);
}
export { expect, gatherA11yViolations };

Some files were not shown because too many files have changed in this diff Show more