diff --git a/package-lock.json b/package-lock.json index 3c28542d55..8a713174da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "docs", "version": "0.0.0", "dependencies": { + "@docsearch/react": "^3.9.0", "@docusaurus/core": "^3.7.0", "@docusaurus/faster": "^3.7.0", "@docusaurus/preset-classic": "^3.7.0", @@ -3230,11 +3231,43 @@ } }, "node_modules/@docsearch/css": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.8.3.tgz", - "integrity": "sha512-1nELpMV40JDLJ6rpVVFX48R1jsBFIQ6RnEQDsLFGmzOjPWTOMlZqUcXcvRx8VmYV/TqnS1l784Ofz+ZEb+wEOQ==", + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/@docsearch/css/-/css-3.9.0.tgz", + "integrity": "sha512-cQbnVbq0rrBwNAKegIac/t6a8nWoUAn8frnkLFW6YARaRmAQr5/Eoe6Ln2fqkUCZ40KpdrKbpSAmgrkviOxuWA==", "license": "MIT" }, + "node_modules/@docsearch/react": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.9.0.tgz", + "integrity": "sha512-mb5FOZYZIkRQ6s/NWnM98k879vu5pscWqTLubLFBO87igYYT4VzVazh4h5o/zCvTIZgEt3PvsCOMOswOUo9yHQ==", + "license": "MIT", + "dependencies": { + "@algolia/autocomplete-core": "1.17.9", + "@algolia/autocomplete-preset-algolia": "1.17.9", + "@docsearch/css": "3.9.0", + "algoliasearch": "^5.14.2" + }, + "peerDependencies": { + "@types/react": ">= 16.8.0 < 20.0.0", + "react": ">= 16.8.0 < 20.0.0", + "react-dom": ">= 16.8.0 < 20.0.0", + "search-insights": ">= 1 < 3" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "search-insights": { + "optional": true + } + } + }, "node_modules/@docusaurus/babel": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/@docusaurus/babel/-/babel-3.7.0.tgz", @@ -3988,38 +4021,6 @@ "react-dom": "^18.0.0 || ^19.0.0" } }, - "node_modules/@docusaurus/theme-search-algolia/node_modules/@docsearch/react": { - "version": "3.8.3", - "resolved": "https://registry.npmjs.org/@docsearch/react/-/react-3.8.3.tgz", - "integrity": "sha512-6UNrg88K7lJWmuS6zFPL/xgL+n326qXqZ7Ybyy4E8P/6Rcblk3GE8RXxeol4Pd5pFpKMhOhBhzABKKwHtbJCIg==", - "license": "MIT", - "dependencies": { - "@algolia/autocomplete-core": "1.17.9", - "@algolia/autocomplete-preset-algolia": "1.17.9", - "@docsearch/css": "3.8.3", - "algoliasearch": "^5.14.2" - }, - "peerDependencies": { - "@types/react": ">= 16.8.0 < 19.0.0", - "react": ">= 16.8.0 < 19.0.0", - "react-dom": ">= 16.8.0 < 19.0.0", - "search-insights": ">= 1 < 3" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - }, - "search-insights": { - "optional": true - } - } - }, "node_modules/@docusaurus/theme-translations": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/@docusaurus/theme-translations/-/theme-translations-3.7.0.tgz", diff --git a/package.json b/package.json index 06e9055f64..70204c7584 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "format": "prettier --write ." }, "dependencies": { + "@docsearch/react": "^3.9.0", "@docusaurus/core": "^3.7.0", "@docusaurus/faster": "^3.7.0", "@docusaurus/preset-classic": "^3.7.0", diff --git a/src/css/all.css b/src/css/all.css index de12271703..c24242ad49 100644 --- a/src/css/all.css +++ b/src/css/all.css @@ -812,6 +812,10 @@ content: "\f1c0"; } +.fa-robot::before { + content: "\f544" +} + .fa-discord::before { content: "\f392"; } diff --git a/src/css/theming.css b/src/css/theming.css index 9132325660..d48a56896d 100644 --- a/src/css/theming.css +++ b/src/css/theming.css @@ -34,6 +34,7 @@ --ifm-link-hover-decoration: none; --ifm-hover-overlay: var(--indigo-700); --ifm-color-primary: var(--indigo-600); + --ifm-color-primary-hover: var(--indigo-400); --ifm-heading-color: var(--ifm-color-content); --ifm-menu-color-background-active: var(--main-bgd-color); --ifm-navbar-sidebar-width: 384px; @@ -118,6 +119,7 @@ --code-edit-bg-color: #c3dafe; --code-result-bg-color: #e7edf3; --ifm-color-primary: var(--indigo-600); + --ifm-color-primary-hover: var(--indigo-400); /* Buttons */ --red-color: #ff4f56; --green-color: #15bd76; @@ -309,6 +311,7 @@ html[data-theme="dark"] { --ifm-link-hover-decoration: none; --ifm-hover-overlay: var(--indigo-700); --ifm-color-primary: var(--indigo-600); + --ifm-color-primary-hover: var(--indigo-400); --ifm-heading-color: var(--ifm-color-content); --ifm-navbar-background-color: #fff; --docsearch-key-shadow: inset 0 -2px 0 0 #cdcde6, inset 0 0 1px 1px #fff, @@ -382,6 +385,7 @@ html[data-theme="dark"] { --code-edit-bg-color: #c3dafe; --code-result-bg-color: #e7edf3; --ifm-color-primary: var(--indigo-600); + --ifm-color-primary-hover: var(--indigo-400); /* Buttons */ --red-color: #ff4f56; --green-color: #15bd76; diff --git a/src/theme/SearchBar/index.tsx b/src/theme/SearchBar/index.tsx new file mode 100644 index 0000000000..9813510291 --- /dev/null +++ b/src/theme/SearchBar/index.tsx @@ -0,0 +1,485 @@ +import React, { useCallback, useMemo, useRef, useState, type ReactNode } from "react"; +import { createPortal } from "react-dom"; +import { DocSearchButton, useDocSearchKeyboardEvents } from "@docsearch/react"; +import Head from "@docusaurus/Head"; +import Link from "@docusaurus/Link"; +import { useHistory } from "@docusaurus/router"; +import { isRegexpStringMatch, useSearchLinkCreator } from "@docusaurus/theme-common"; +import { + useAlgoliaContextualFacetFilters, + useSearchResultUrlProcessor, +} from "@docusaurus/theme-search-algolia/client"; +import Translate from "@docusaurus/Translate"; +import useDocusaurusContext from "@docusaurus/useDocusaurusContext"; +import translations from "@theme/SearchTranslations"; +import type { + InternalDocSearchHit, + DocSearchModal as DocSearchModalType, + DocSearchModalProps, + StoredDocSearchHit, + DocSearchTransformClient, + DocSearchHit, +} from "@docsearch/react"; + +import type { AutocompleteState } from "@algolia/autocomplete-core"; +import type { FacetFilters } from "algoliasearch/lite"; + +// Constants +const MODAL_CLOSE_DELAY_MS = 100; +const DOM_CHECK_INTERVAL_MS = 100; + +// Extend Window interface to include Kapa +declare global { + interface Window { + Kapa?: { + open: (options: { query: string; submit: boolean }) => void; + }; + } +} + +type DocSearchProps = Omit & { + contextualSearch?: string; + externalUrlRegex?: string; + searchPagePath: boolean | string; +}; + +// Add custom CSS for the Kapa AI button +const kapaStyles = ` +.kapa-ai-button { + display: flex; + align-items: center; + width: 100%; + margin: 0 0 0 auto; + width: fit-content; + border: none; + background: transparent; font-size: 14px; + color: var(--docsearch-highlight-color); + cursor: pointer; + transition: all 0.2s ease; + position: relative; + overflow: hidden; + border: transparent; +} + +.kapa-ai-button > i { + transition: transform 150ms ease-in-out; +} + +.kapa-ai-button:hover > i:last-child { + transform: translateX(4px); +} + +.kapa-ai-text { + font-weight: 700; + display: flex; + margin-right: 8px; + align-items: center; + text-decoration: underline; + text-underline-offset: 4px; + margin-left: 8px; +} + +/* Override any external styles */ +.DocSearch-KapaWrapper .kapa-ai-icon { + flex-shrink: 0; + width: 32px !important; + height: 32px !important; + max-width: 32px !important; + max-height: 32px !important; + object-fit: cover !important; + object-position: 0 0 !important; + display: inline-block !important; + margin: 0 !important; + padding: 0 !important; +} + +/* Light mode logo */ +[data-theme='light'] .kapa-ai-icon { + content: url('https://www.prisma.io/docs/img/logo-dark.svg'); + width: 32px !important; + height: 32px !important; + max-width: 32px !important; + max-height: 32px !important; + object-fit: cover !important; + object-position: 0 0 !important; +} + +.DocSearch-KapaWrapper { + padding: 0; + margin: 12px 0 0 0; + position: relative; + order: -1; + z-index: 10; +} + +/* Make sure the Kapa wrapper appears at the top of the dropdown */ +.DocSearch-Dropdown { + display: flex; + flex-direction: column; +} + +.DocSearch-Dropdown-Container { + order: 0; +} + +.DocSearch-ResultsFooter { + display: flex; + flex-direction: column; + padding: 0 1rem 1rem; +} +`; + +let DocSearchModal: typeof DocSearchModalType | null = null; + +function importDocSearchModalIfNeeded() { + if (DocSearchModal) { + return Promise.resolve(); + } + return Promise.all([ + import("@docsearch/react/modal") as Promise, + import("@docsearch/react/style"), + import("./styles.css"), + ]).then(([{ DocSearchModal: Modal }]) => { + DocSearchModal = Modal; + }); +} + +function useNavigator({ externalUrlRegex }: Pick) { + const history = useHistory(); + const [navigator] = useState(() => { + return { + navigate(params) { + // Algolia results could contain URL's from other domains which cannot + // be served through history and should navigate with window.location + if (isRegexpStringMatch(externalUrlRegex, params.itemUrl)) { + window.location.href = params.itemUrl; + } else { + history.push(params.itemUrl); + } + }, + }; + }); + return navigator; +} + +function useTransformSearchClient(): DocSearchModalProps["transformSearchClient"] { + const { + siteMetadata: { docusaurusVersion }, + } = useDocusaurusContext(); + return useCallback( + (searchClient: DocSearchTransformClient) => { + searchClient.addAlgoliaAgent("docusaurus", docusaurusVersion); + return searchClient; + }, + [docusaurusVersion] + ); +} + +function useTransformItems(props: Pick) { + const processSearchResultUrl = useSearchResultUrlProcessor(); + const [transformItems] = useState(() => { + return (items: DocSearchHit[]) => + props.transformItems + ? // Custom transformItems + props.transformItems(items) + : // Default transformItems + items.map((item) => ({ + ...item, + url: processSearchResultUrl(item.url), + })); + }); + return transformItems; +} + +function Hit({ + hit, + children, +}: { + hit: InternalDocSearchHit | StoredDocSearchHit; + children: React.ReactNode; +}) { + return {children}; +} + +type ResultsFooterProps = { + state: AutocompleteState; + onClose: () => void; +}; + +function ResultsFooter({ state, onClose }: ResultsFooterProps) { + const createSearchLink = useSearchLinkCreator(); + + return ( +
+ + + {"See all {count} results"} + + +
+ ); +} + +function useResultsFooterComponent({ + closeModal, +}: { + closeModal: () => void; +}): DocSearchProps["resultsFooterComponent"] { + return useMemo( + () => + ({ state }) => , + [closeModal] + ); +} + +function useSearchParameters({ + contextualSearch, + ...props +}: DocSearchProps): DocSearchProps["searchParameters"] { + function mergeFacetFilters(f1: FacetFilters, f2: FacetFilters): FacetFilters { + const normalize = (f: FacetFilters): FacetFilters => (typeof f === "string" ? [f] : f); + return [...normalize(f1), ...normalize(f2)]; + } + + const contextualSearchFacetFilters = useAlgoliaContextualFacetFilters() as FacetFilters; + + const configFacetFilters: FacetFilters = props.searchParameters?.facetFilters ?? []; + + const facetFilters: FacetFilters = contextualSearch + ? // Merge contextual search filters with config filters + mergeFacetFilters(contextualSearchFacetFilters, configFacetFilters) + : // ... or use config facetFilters + configFacetFilters; + + // We let users override default searchParameters if they want to + return { + ...props.searchParameters, + facetFilters, + }; +} + +function DocSearch({ externalUrlRegex, ...props }: DocSearchProps) { + const navigator = useNavigator({ externalUrlRegex }); + const searchParameters = useSearchParameters({ ...props }); + const transformItems = useTransformItems(props); + const transformSearchClient = useTransformSearchClient(); + + const searchContainer = useRef(null); + // TODO remove "as any" after React 19 upgrade + const searchButtonRef = useRef(null as any); + const [isOpen, setIsOpen] = useState(false); + const [initialQuery, setInitialQuery] = useState(undefined); + + // Define closeModal first to avoid reference issues + const closeModal = useCallback(() => { + setIsOpen(false); + searchButtonRef.current?.focus(); + setInitialQuery(undefined); + }, []); + + // Function to insert the Kapa button + const insertKapaButton = useCallback( + (query: string) => { + if (!query) return; + + // Find the dropdown container + const dropdown = document.querySelector(".DocSearch-Dropdown"); + if (!dropdown) return; + + // See if we already have a Kapa wrapper + let kapaWrapper = document.querySelector(".DocSearch-KapaWrapper"); + + // If not, create one + if (!kapaWrapper) { + kapaWrapper = document.createElement("div"); + kapaWrapper.className = "DocSearch-KapaWrapper"; + + // Create a button element + const button = document.createElement("button"); + button.className = "kapa-ai-button"; + button.setAttribute("type", "button"); + button.onclick = () => { + // Grab the CURRENT query value from the input element right before closing + const searchInput = document.querySelector(".DocSearch-Input"); + const currentQuery = searchInput instanceof HTMLInputElement ? searchInput.value : query; + + closeModal(); + setTimeout(() => { + if (window.Kapa) { + window.Kapa.open({ query: currentQuery, submit: true }); + } + }, MODAL_CLOSE_DELAY_MS); + }; + + // Add the icon + button.innerHTML = ` + + Ask AI about this topic + + `; + + kapaWrapper.appendChild(button); + + // Insert it at the top of the dropdown + dropdown.insertBefore(kapaWrapper, dropdown.firstChild); + } else { + // Update the query text + const textSpan = kapaWrapper.querySelector(".kapa-ai-text"); + if (textSpan) { + textSpan.textContent = `Ask AI about this topic`; + } + } + }, + [closeModal] + ); + + // Set up detection for when the search modal is ready and when query changes + React.useEffect(() => { + if (!isOpen) return; + + // Wait for the search modal to be fully rendered + const checkForSearchBox = setInterval(() => { + const searchBox = document.querySelector(".DocSearch-Form"); + const searchInput = document.querySelector(".DocSearch-Input"); + + if (searchBox && searchInput instanceof HTMLInputElement) { + clearInterval(checkForSearchBox); + + // If there's already text in the search input, use it + if (searchInput.value) { + insertKapaButton(searchInput.value); + } + + // Listen for input events directly + const handleInputChange = () => { + if (searchInput.value) { + insertKapaButton(searchInput.value); + } + }; + + // Add input event listener for real-time updates + searchInput.addEventListener("input", handleInputChange); + + // Clean up this listener when the modal closes or component unmounts + return () => { + searchInput.removeEventListener("input", handleInputChange); + }; + } + }, DOM_CHECK_INTERVAL_MS); + + // Clean up interval if component unmounts or modal closes + return () => { + clearInterval(checkForSearchBox); + }; + }, [isOpen, insertKapaButton]); + + // Inject Kapa AI button styles + React.useEffect(() => { + const styleId = "kapa-ai-styles"; + + // Only inject styles once + if (!document.getElementById(styleId)) { + const styleEl = document.createElement("style"); + styleEl.id = styleId; + styleEl.textContent = kapaStyles; + document.head.appendChild(styleEl); + } + + return () => { + // Clean up styles on unmount + const styleEl = document.getElementById(styleId); + if (styleEl) { + styleEl.remove(); + } + }; + }, []); + + 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 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 resultsFooterComponent = useResultsFooterComponent({ closeModal }); + + useDocSearchKeyboardEvents({ + isOpen, + onOpen: openModal, + onClose: closeModal, + onInput: handleInput, + searchButtonRef, + }); + + return ( + <> + + {/* This hints the browser that the website will load data from Algolia, + and allows it to preconnect to the DocSearch cluster. It makes the first + query faster, especially on mobile. */} + + + + + + {isOpen && + DocSearchModal && + searchContainer.current && + createPortal( + , + searchContainer.current + )} + + ); +} + +export default function SearchBar(): ReactNode { + const { siteConfig } = useDocusaurusContext(); + return ; +} diff --git a/src/theme/SearchBar/styles.css b/src/theme/SearchBar/styles.css new file mode 100644 index 0000000000..fdf8dff9a4 --- /dev/null +++ b/src/theme/SearchBar/styles.css @@ -0,0 +1,14 @@ +:root { + --docsearch-primary-color: var(--ifm-color-primary); + --docsearch-text-color: var(--ifm-font-color-base); +} + +.DocSearch-Button { + margin: 0; + transition: all var(--ifm-transition-fast) + var(--ifm-transition-timing-default); +} + +.DocSearch-Container { + z-index: calc(var(--ifm-z-index-fixed) + 1); +}