├── restart.sh ├── tsconfig.client.json ├── .npmignore ├── .prettierrc ├── tsconfig.json ├── src ├── client │ ├── index.ts │ └── useUpstashThemeConfig.ts ├── types.d.ts ├── theme │ └── SearchBar │ │ ├── hooks │ │ ├── useDebounce.ts │ │ ├── useAiChat.ts │ │ └── useSearchLogic.ts │ │ ├── components │ │ ├── LoadingDots.tsx │ │ ├── Icons.tsx │ │ └── UpstashLogo.tsx │ │ ├── types.ts │ │ ├── utils │ │ └── formatContent.ts │ │ ├── styles.module.css │ │ └── index.tsx ├── validateThemeConfig.ts ├── index.ts ├── theme-ai-search.d.ts ├── __tests__ │ └── validateThemeConfig.test.ts └── scripts │ └── indexDocs.ts ├── tsconfig.base.client.json ├── .gitignore ├── .env.example ├── .prettierignore ├── admin └── scripts │ └── copyUntypedFiles.mjs ├── package.json ├── README.md └── tsconfig.base.json /restart.sh: -------------------------------------------------------------------------------- 1 | cd ../search-js 2 | 3 | bun run build 4 | 5 | cd ../docusaurus-theme-ai-search-upstash 6 | 7 | npm install @upstash/search@file:../search-js 8 | -------------------------------------------------------------------------------- /tsconfig.client.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.client.json", 3 | "include": ["src/theme", "src/client", "src/*.d.ts"], 4 | "exclude": ["**/__tests__/**"] 5 | } 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .tsbuildinfo* 2 | tsconfig* 3 | __tests__ 4 | .git 5 | .gitignore 6 | .prettierrc 7 | .prettierignore 8 | admin/ 9 | node_modules/ 10 | package-lock.json 11 | .env.example -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "bracketSpacing": false, 4 | "bracketSameLine": true, 5 | "printWidth": 80, 6 | "proseWrap": "never", 7 | "singleQuote": true, 8 | "trailingComma": "all" 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "references": [{"path": "./tsconfig.client.json"}], 4 | "compilerOptions": { 5 | "noEmit": false 6 | }, 7 | "include": ["src"], 8 | "exclude": ["src/client", "src/theme", "**/__tests__/**"] 9 | } 10 | -------------------------------------------------------------------------------- /src/client/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | export {useUpstashThemeConfig} from './useUpstashThemeConfig'; 9 | -------------------------------------------------------------------------------- /tsconfig.base.client.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "tsBuildInfoFile": "${configDir}/lib/.tsbuildinfo-client", 5 | "noEmit": false, 6 | "moduleResolution": "bundler", 7 | "module": "esnext", 8 | "target": "esnext" 9 | } 10 | } -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | /// 9 | /// 10 | /// 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | /node_modules 3 | 4 | # Production 5 | /build 6 | 7 | # Generated files 8 | .docusaurus 9 | .cache-loader 10 | 11 | # Misc 12 | .DS_Store 13 | .env 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | .vercel 23 | 24 | lib/.tsbuildinfo 25 | lib/.tsbuildinfo-client 26 | lib -------------------------------------------------------------------------------- /src/theme/SearchBar/hooks/useDebounce.ts: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | 3 | export function useDebounce(value: T, delay: number): T { 4 | const [debouncedValue, setDebouncedValue] = useState(value); 5 | 6 | useEffect(() => { 7 | const timer = setTimeout(() => setDebouncedValue(value), delay); 8 | return () => clearTimeout(timer); 9 | }, [value, delay]); 10 | 11 | return debouncedValue; 12 | } 13 | -------------------------------------------------------------------------------- /src/theme/SearchBar/components/LoadingDots.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { LoadingDotsProps } from '../types'; 3 | import styles from '../styles.module.css'; 4 | 5 | export const LoadingDots: React.FC = ({ 6 | text = 'Thinking', 7 | }) => ( 8 | 9 | {text} 10 | 11 | . 12 | . 13 | . 14 | 15 | 16 | ); 17 | -------------------------------------------------------------------------------- /src/theme/SearchBar/types.ts: -------------------------------------------------------------------------------- 1 | export type SearchMetadata = { 2 | title: string; 3 | path: string; 4 | level: number; 5 | type: string; 6 | content: string; 7 | documentTitle: string; 8 | [key: string]: string | number; 9 | }; 10 | 11 | export interface SearchResult { 12 | id: string; 13 | data: string; 14 | metadata: SearchMetadata; 15 | } 16 | 17 | export interface TypewriterTextProps { 18 | text: string; 19 | children: (typedText: string) => React.JSX.Element; 20 | } 21 | 22 | export interface LoadingDotsProps { 23 | text?: string; 24 | } 25 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Upstash Search Configuration for Documentation Indexing 2 | # Obtain these values from the Upstash console 3 | UPSTASH_SEARCH_REST_URL=your-upstash-search-rest-url 4 | UPSTASH_SEARCH_REST_TOKEN=your-upstash-search-rest-token 5 | 6 | # Optional: Specify an index name; default is 'docusaurus' 7 | UPSTASH_SEARCH_INDEX_NAME=your-upstash-search-index-name 8 | 9 | # Optional: Define the path to your documentation; default is 'docs' 10 | DOCS_PATH=your-docs-path 11 | 12 | # OpenAI Configuration for AI Chat 13 | # Obtain your API key from OpenAI 14 | OPENAI_API_KEY=your-openai-api-key -------------------------------------------------------------------------------- /src/client/useUpstashThemeConfig.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; 8 | import type {ThemeConfig} from '@upstash/docusaurus-theme-upstash-search'; 9 | 10 | export function useUpstashThemeConfig(): ThemeConfig { 11 | const { 12 | siteConfig: {themeConfig}, 13 | } = useDocusaurusContext(); 14 | return themeConfig as ThemeConfig; 15 | } 16 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .yarn 4 | build 5 | coverage 6 | .docusaurus 7 | .idea 8 | 9 | .svg 10 | *.svg 11 | 12 | jest/vendor 13 | 14 | packages/lqip-loader/lib/ 15 | packages/docusaurus/lib/ 16 | packages/docusaurus-*/lib/* 17 | packages/create-docusaurus/lib/* 18 | packages/create-docusaurus/templates/*/docusaurus.config.js 19 | packages/eslint-plugin/lib/ 20 | packages/stylelint-copyright/lib/ 21 | __fixtures__ 22 | 23 | website/i18n 24 | website/versions.json 25 | website/docusaurus.config.js 26 | website/versioned_sidebars/*.json 27 | 28 | examples/ 29 | website/static/katex/katex.min.css 30 | 31 | website/changelog 32 | website/_dogfooding/_swizzle_theme_tests 33 | website/_dogfooding/_asset-tests/badSyntax.js 34 | website/_dogfooding/_asset-tests/badSyntax.css 35 | -------------------------------------------------------------------------------- /src/theme/SearchBar/utils/formatContent.ts: -------------------------------------------------------------------------------- 1 | export const formatContent = (content: string): string => { 2 | return ( 3 | content 4 | // Remove triple backtick code blocks 5 | .replace(/```[\s\S]*?```/g, '') 6 | // Remove single backticks but keep the content 7 | .replace(/`([^`]+)`/g, '$1') 8 | // Remove bold/italic markers but keep the content 9 | .replace(/\*\*([^*]+)\*\*/g, '$1') 10 | .replace(/__([^_]+)__/g, '$1') 11 | .replace(/\*([^*]+)\*/g, '$1') 12 | .replace(/_([^_]+)_/g, '$1') 13 | // Replace markdown links with just the text 14 | .replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') 15 | // Remove HTML tags 16 | .replace(/<[^>]*>?/gm, '') 17 | // Remove extra whitespace 18 | .replace(/\s+/g, ' ') 19 | .trim() 20 | ); 21 | }; 22 | -------------------------------------------------------------------------------- /src/theme/SearchBar/components/Icons.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styles from '../styles.module.css'; 3 | 4 | export const SearchIcon: React.FC = () => ( 5 | 16 | 17 | 18 | 19 | ); 20 | 21 | export const ClearIcon: React.FC = () => ( 22 | 33 | 34 | 35 | 36 | ); 37 | -------------------------------------------------------------------------------- /src/validateThemeConfig.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | import {Joi} from '@docusaurus/utils-validation'; 9 | import type { 10 | ThemeConfig, 11 | ThemeConfigValidationContext, 12 | } from '@docusaurus/types'; 13 | 14 | export const Schema = Joi.object({ 15 | upstash: Joi.object({ 16 | enableAiChat: Joi.boolean(), 17 | aiChatApiEndpoint: Joi.string(), 18 | upstashSearchRestUrl: Joi.string().required(), 19 | upstashSearchReadOnlyRestToken: Joi.string().required(), 20 | upstashSearchIndexName: Joi.string().required(), 21 | }) 22 | .label('themeConfig.upstash') 23 | .required() 24 | .unknown(), 25 | }); 26 | 27 | export function validateThemeConfig({ 28 | validate, 29 | themeConfig, 30 | }: ThemeConfigValidationContext): ThemeConfig { 31 | return validate(Schema, themeConfig); 32 | } 33 | -------------------------------------------------------------------------------- /admin/scripts/copyUntypedFiles.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | import fs from 'fs-extra'; 9 | import path from 'path'; 10 | import chokidar from 'chokidar'; 11 | 12 | const srcDir = path.join(process.cwd(), 'src'); 13 | const libDir = path.join(process.cwd(), 'lib'); 14 | 15 | const ignoredPattern = /(?:__tests__|\.tsx?$)/; 16 | 17 | async function copy() { 18 | await fs.copy(srcDir, libDir, { 19 | filter(testedPath) { 20 | return !ignoredPattern.test(testedPath); 21 | }, 22 | }); 23 | } 24 | 25 | if (process.argv.includes('--watch')) { 26 | const watcher = chokidar.watch(srcDir, { 27 | ignored: ignoredPattern, 28 | ignoreInitial: true, 29 | persistent: true, 30 | }); 31 | ['add', 'change', 'unlink', 'addDir', 'unlinkDir'].forEach((event) => 32 | watcher.on(event, copy), 33 | ); 34 | } else { 35 | await copy(); 36 | } 37 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | import {readDefaultCodeTranslationMessages} from '@docusaurus/theme-translations'; 9 | 10 | import type {LoadContext, Plugin} from '@docusaurus/types'; 11 | 12 | export default function themeSearchUpstash(context: LoadContext): Plugin { 13 | const { 14 | i18n: {currentLocale}, 15 | } = context; 16 | 17 | return { 18 | name: '@upstash/docusaurus-theme-upstash-search', 19 | 20 | getThemePath() { 21 | return '../lib/theme'; 22 | }, 23 | getTypeScriptThemePath() { 24 | return '../src/theme'; 25 | }, 26 | 27 | getDefaultCodeTranslationMessages() { 28 | return readDefaultCodeTranslationMessages({ 29 | locale: currentLocale, 30 | name: '@upstash/docusaurus-theme-upstash-search', 31 | }); 32 | }, 33 | 34 | }; 35 | } 36 | 37 | export {validateThemeConfig} from './validateThemeConfig'; 38 | -------------------------------------------------------------------------------- /src/theme-ai-search.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | declare module '@upstash/docusaurus-theme-upstash-search' { 9 | import type {DeepPartial} from 'utility-types'; 10 | 11 | type ThemeConfigUpstash = { 12 | enableAiChat: boolean; 13 | aiChatApiEndpoint: string; 14 | upstashSearchRestUrl: string; 15 | upstashSearchReadOnlyRestToken: string; 16 | upstashSearchIndexName: string; 17 | }; 18 | 19 | export type ThemeConfig = { 20 | upstash: ThemeConfigUpstash; 21 | }; 22 | 23 | export type UserThemeConfig = DeepPartial; 24 | } 25 | 26 | declare module '@upstash/docusaurus-theme-upstash-search/client' { 27 | import type {ThemeConfig} from '@upstash/docusaurus-theme-upstash-search'; 28 | 29 | export function useUpstashThemeConfig(): ThemeConfig; 30 | } 31 | 32 | declare module '@theme/AiSearchBar' { 33 | import type {ReactNode} from 'react'; 34 | 35 | export default function SearchBar(): ReactNode; 36 | } 37 | -------------------------------------------------------------------------------- /src/theme/SearchBar/hooks/useAiChat.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | interface UseAiChatProps { 4 | aiChatApiEndpoint?: string; 5 | } 6 | 7 | export function useAiChat({ aiChatApiEndpoint = '' }: UseAiChatProps) { 8 | const [aiResponse, setAiResponse] = useState(null); 9 | const [isAiLoading, setIsAiLoading] = useState(false); 10 | 11 | const handleAiQuestion = async (question: string, context: any[]) => { 12 | setIsAiLoading(true); 13 | setAiResponse(null); 14 | try { 15 | console.log('sending request to', aiChatApiEndpoint); 16 | const res = await fetch(aiChatApiEndpoint, { 17 | method: 'POST', 18 | headers: { 'Content-Type': 'application/json' }, 19 | body: JSON.stringify({ question, context }), 20 | }); 21 | 22 | if (!res.ok) throw new Error('Failed to get AI response'); 23 | 24 | const reader = res.body?.getReader(); 25 | const decoder = new TextDecoder(); 26 | let done = false; 27 | let accumulatedText = ''; 28 | while (!done && reader) { 29 | const { value, done: doneReading } = await reader.read(); 30 | done = doneReading; 31 | const chunk = decoder.decode(value, { stream: true }); 32 | accumulatedText += chunk; 33 | setAiResponse(accumulatedText); 34 | } 35 | } catch (err) { 36 | throw new Error( 37 | err instanceof Error ? err.message : 'Failed to get AI response' 38 | ); 39 | } finally { 40 | setIsAiLoading(false); 41 | } 42 | }; 43 | 44 | return { aiResponse, isAiLoading, setAiResponse, handleAiQuestion }; 45 | } 46 | -------------------------------------------------------------------------------- /src/theme/SearchBar/components/UpstashLogo.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface UpstashLogoProps { 4 | className?: string; 5 | } 6 | 7 | export const UpstashLogo: React.FC = ({ className }) => { 8 | return ( 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@upstash/docusaurus-theme-upstash-search", 3 | "version": "1.0.0", 4 | "description": "Upstash Search component for Docusaurus.", 5 | "main": "lib/index.js", 6 | "sideEffects": [ 7 | "*.css" 8 | ], 9 | "exports": { 10 | "./client": { 11 | "types": "./lib/client/index.d.ts", 12 | "default": "./lib/client/index.js" 13 | }, 14 | ".": { 15 | "types": "./src/theme-ai-search.d.ts", 16 | "default": "./lib/index.js" 17 | } 18 | }, 19 | "types": "src/theme-ai-search.d.ts", 20 | "publishConfig": { 21 | "access": "public" 22 | }, 23 | "repository": { 24 | "type": "git", 25 | "url": "git+https://github.com/upstash/docusaurus-theme-ai-search-upstash.git" 26 | }, 27 | "license": "MIT", 28 | "bin": { 29 | "index-docs-upstash": "./lib/scripts/indexDocs.js" 30 | }, 31 | "scripts": { 32 | "build": "tsc --build && node ./admin/scripts/copyUntypedFiles.mjs && prettier --config ./.prettierrc --write \"lib/theme/**/*.js\"", 33 | "watch": "run-p -c copy:watch build:watch", 34 | "build:watch": "tsc --build --watch", 35 | "copy:watch": "node ./admin/scripts/copyUntypedFiles.mjs --watch" 36 | }, 37 | "dependencies": { 38 | "@docusaurus/core": "^3.7.0", 39 | "@docusaurus/logger": "3.7.0", 40 | "@docusaurus/plugin-content-docs": "3.7.0", 41 | "@docusaurus/theme-common": "3.7.0", 42 | "@docusaurus/theme-translations": "3.7.0", 43 | "@docusaurus/utils": "3.7.0", 44 | "@docusaurus/utils-validation": "3.7.0", 45 | "@mdx-js/react": "^3.1.0", 46 | "@upstash/search": "^0.1.2", 47 | "@upstash/vector": "^1.2.0", 48 | "clsx": "^2.0.0", 49 | "react-markdown": "^9.0.3", 50 | "utility-types": "^3.10.0" 51 | }, 52 | "devDependencies": { 53 | "@docusaurus/module-type-aliases": "3.7.0", 54 | "prettier": "^3.5.1" 55 | }, 56 | "peerDependencies": { 57 | "react": "^18.0.0 || ^19.0.0", 58 | "react-dom": "^18.0.0 || ^19.0.0" 59 | }, 60 | "engines": { 61 | "node": ">=18.0" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # `@upstash/docusaurus-theme-upstash-search` 2 | 3 | AI-powered search component for Docusaurus using Upstash Search. 4 | 5 | ## Features 6 | 7 | - 🤖 AI-powered search search results based on your documentation 8 | - 🎨 Modern and responsive UI 9 | - 🌜 Dark/Light mode support 10 | 11 | ## Installation 12 | 13 | To install the package, run: 14 | 15 | ```bash 16 | npm install @upstash/docusaurus-theme-upstash-search 17 | ``` 18 | 19 | ### Enabling the Searchbar 20 | 21 | To enable the searchbar, add the following to your docusaurus config file: 22 | 23 | ```js 24 | export default { 25 | themes: ['@upstash/docusaurus-theme-upstash-search'], 26 | // ... 27 | themeConfig: { 28 | // ... 29 | upstash: { 30 | upstashSearchRestUrl: "UPSTASH_SEARCH_REST_URL", 31 | upstashSearchReadOnlyRestToken: "UPSTASH_SEARCH_READ_ONLY_REST_TOKEN", 32 | upstashSearchIndexName: "UPSTASH_SEARCH_INDEX_NAME", 33 | }, 34 | }, 35 | }; 36 | ``` 37 | 38 | The default index name is `docusaurus`. You can override it by setting the `upstashSearchIndexName` option. 39 | 40 | You can fetch your URL and read only token from [Upstash Console](https://upstash.com/console). **Make sure to use the read only token!** 41 | 42 | If you do not have a search database yet, you can create one from [Upstash Console](https://upstash.com/console). Make sure to use Upstash generated embedding model. 43 | 44 | ### Indexing Your Docs 45 | 46 | To index your documentation, create a `.env` file with the following environment variables and run `npx index-docs-upstash`. 47 | 48 | ```bash 49 | UPSTASH_SEARCH_REST_URL= 50 | UPSTASH_SEARCH_REST_TOKEN= 51 | UPSTASH_SEARCH_INDEX_NAME= 52 | DOCS_PATH= 53 | ``` 54 | 55 | You can fetch your URL and token from [Upstash Console](https://upstash.com/console). This time **do not use the read only token** since we are upserting data. 56 | 57 | The indexing script looks for documentation in the `docs` directory by default. You can specify a different path using the `DOCS_PATH` option. 58 | 59 | The default index name is `docusaurus`. You can override it by setting the `UPSTASH_SEARCH_INDEX_NAME` option. Make sure the name you set while indexing matches with your themeConfig `upstashSearchIndexName` option. 60 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "${configDir}/src", 4 | "outDir": "${configDir}/lib", 5 | "composite": true, 6 | "incremental": true, 7 | "tsBuildInfoFile": "${configDir}/lib/.tsbuildinfo", 8 | 9 | /* Emit */ 10 | "target": "ES2020", 11 | "lib": ["ESNext", "DOM"], 12 | "declaration": true, 13 | // These two options will be selectively overridden in each project. 14 | // Utility libraries will have source maps on, but plugins will not. 15 | "declarationMap": false, 16 | "sourceMap": false, 17 | "jsx": "react-native", 18 | "importHelpers": true, 19 | "noEmitHelpers": true, 20 | // Will be overridden in client projects 21 | "module": "NodeNext", 22 | // Avoid accidentally using this config to build 23 | "noEmit": true, 24 | 25 | /* Strict Type-Checking Options */ 26 | "allowUnreachableCode": false, 27 | // Too hard to turn on 28 | "exactOptionalPropertyTypes": false, 29 | "noFallthroughCasesInSwitch": true, 30 | "noImplicitOverride": true, 31 | "noImplicitReturns": true, 32 | // `process.env` is usually accessed as property 33 | "noPropertyAccessFromIndexSignature": false, 34 | "noUncheckedIndexedAccess": true, 35 | /* strict family */ 36 | "strict": true, 37 | "alwaysStrict": true, 38 | "noImplicitAny": true, 39 | "noImplicitThis": true, 40 | "strictBindCallApply": true, 41 | "strictFunctionTypes": true, 42 | "strictNullChecks": true, 43 | "strictPropertyInitialization": true, 44 | "useUnknownInCatchVariables": true, 45 | /* Handled by ESLint */ 46 | "noUnusedLocals": false, 47 | "noUnusedParameters": false, 48 | "importsNotUsedAsValues": "remove", 49 | 50 | /* Module Resolution */ 51 | "moduleResolution": "NodeNext", 52 | "resolveJsonModule": true, 53 | "allowSyntheticDefaultImports": true, 54 | "esModuleInterop": true, 55 | "forceConsistentCasingInFileNames": true, 56 | "isolatedModules": true, 57 | "allowJs": true, 58 | "skipLibCheck": true // @types/webpack and webpack/types.d.ts are not the same thing 59 | }, 60 | "include": ["./**/*", "./**/.eslintrc.js"], 61 | "exclude": [ 62 | "node_modules", 63 | "coverage/**", 64 | "**/lib/**/*", 65 | "website/**", 66 | "**/__mocks__/**/*", 67 | "**/__fixtures__/**/*", 68 | "examples/**", 69 | "packages/create-docusaurus/templates/**" 70 | ] 71 | } -------------------------------------------------------------------------------- /src/theme/SearchBar/hooks/useSearchLogic.ts: -------------------------------------------------------------------------------- 1 | import { useState, useCallback, useEffect, useMemo } from 'react'; 2 | import { SearchResult } from '../types'; 3 | import { useDebounce } from './useDebounce'; 4 | import { Search } from '@upstash/search'; 5 | import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; 6 | import type { ThemeConfig } from '@upstash/docusaurus-theme-upstash-search'; 7 | import type { DocusaurusContext } from '@docusaurus/types'; 8 | 9 | const DEFAULT_INDEX_NAME = 'docusaurus'; 10 | 11 | export function useSearchLogic() { 12 | const [searchQuery, setSearchQuery] = useState(''); 13 | const [searchResults, setSearchResults] = useState([]); 14 | const [isLoading, setIsLoading] = useState(false); 15 | const [error, setError] = useState(null); 16 | 17 | const debouncedSearchQuery = useDebounce(searchQuery, 300); 18 | const { siteConfig } = useDocusaurusContext() as DocusaurusContext; 19 | const themeConfig = siteConfig.themeConfig as ThemeConfig; 20 | const themeConfigFields = themeConfig.upstash ?? {}; 21 | 22 | const { index, indexName } = useMemo(() => { 23 | const searchUrl = themeConfigFields.upstashSearchRestUrl; 24 | const searchToken = themeConfigFields.upstashSearchReadOnlyRestToken; 25 | const searchNamespace = themeConfigFields.upstashSearchIndexName ?? DEFAULT_INDEX_NAME; 26 | 27 | if (!searchUrl || !searchToken) { 28 | throw new Error('Upstash Search REST URL and Read-only token are required in themeConfig.upstash'); 29 | } 30 | 31 | const searchClient = new Search({ 32 | url: searchUrl, 33 | token: searchToken, 34 | }); 35 | 36 | const index = searchClient.index(searchNamespace); 37 | 38 | return { 39 | index, 40 | indexName: searchNamespace, 41 | }; 42 | }, [themeConfigFields]); 43 | 44 | const performSearch = useCallback(async (query: string) => { 45 | if (!query.trim()) { 46 | setSearchResults([]); 47 | return; 48 | } 49 | 50 | setIsLoading(true); 51 | setError(null); 52 | 53 | try { 54 | const results = await index.search({ 55 | query, 56 | limit: 15, 57 | }); 58 | 59 | setSearchResults( 60 | results.map((result: any) => ({ 61 | id: String(result.id), 62 | data: result.data, 63 | metadata: result.metadata, 64 | })) 65 | ); 66 | } catch (error) { 67 | console.error('Search error:', error); 68 | setError('An error occurred while searching. Please try again.'); 69 | setSearchResults([]); 70 | } finally { 71 | setIsLoading(false); 72 | } 73 | }, [index]); 74 | 75 | useEffect(() => { 76 | performSearch(debouncedSearchQuery); 77 | }, [debouncedSearchQuery, performSearch]); 78 | 79 | return { 80 | searchQuery, 81 | setSearchQuery, 82 | searchResults, 83 | isLoading, 84 | error, 85 | setSearchResults, 86 | setError, 87 | }; 88 | } 89 | -------------------------------------------------------------------------------- /src/__tests__/validateThemeConfig.test.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright (c) Facebook, Inc. and its affiliates. 3 | * 4 | * This source code is licensed under the MIT license found in the 5 | * LICENSE file in the root directory of this source tree. 6 | */ 7 | 8 | import {validateThemeConfig} from '../validateThemeConfig'; 9 | import type {Joi} from '@docusaurus/utils-validation'; 10 | 11 | function testValidateThemeConfig(themeConfig: {[key: string]: unknown}) { 12 | function validate( 13 | schema: Joi.ObjectSchema<{[key: string]: unknown}>, 14 | cfg: {[key: string]: unknown}, 15 | ) { 16 | const {value, error} = schema.validate(cfg, { 17 | convert: false, 18 | }); 19 | if (error) { 20 | throw error; 21 | } 22 | return value; 23 | } 24 | 25 | return validateThemeConfig({themeConfig, validate}); 26 | } 27 | 28 | describe('validateThemeConfig', () => { 29 | const validConfig = { 30 | upstash: { 31 | enableAiChat: true, 32 | aiChatApiEndpoint: 'https://example-function.upstash.io', 33 | upstashSearchRestUrl: 'https://example-rest.upstash.io/v1', 34 | upstashSearchReadOnlyRestToken: 'token', 35 | upstashSearchIndexName: 'docs', 36 | }, 37 | }; 38 | 39 | it('minimal valid config', () => { 40 | expect(testValidateThemeConfig(validConfig)).toEqual(validConfig); 41 | }); 42 | 43 | it('accepts unknown attributes', () => { 44 | const configWithExtra = { 45 | upstash: { 46 | ...validConfig.upstash, 47 | unknownKey: 'unknownValue', 48 | }, 49 | }; 50 | expect(testValidateThemeConfig(configWithExtra)).toEqual(configWithExtra); 51 | }); 52 | 53 | it('undefined config', () => { 54 | expect(() => 55 | testValidateThemeConfig({}), 56 | ).toThrowErrorMatchingInlineSnapshot(`""themeConfig.upstash" is required"`); 57 | }); 58 | 59 | it('missing enableAiChat', () => { 60 | const {enableAiChat, ...rest} = validConfig.upstash; 61 | expect(() => 62 | testValidateThemeConfig({upstash: rest}), 63 | ).toThrowErrorMatchingInlineSnapshot(`""upstash.enableAiChat" is required"`); 64 | }); 65 | 66 | it('missing aiChatApiEndpoint', () => { 67 | const {aiChatApiEndpoint, ...rest} = validConfig.upstash; 68 | expect(() => 69 | testValidateThemeConfig({upstash: rest}), 70 | ).toThrowErrorMatchingInlineSnapshot(`""upstash.aiChatApiEndpoint" is required"`); 71 | }); 72 | 73 | it('missing upstashSearchRestUrl', () => { 74 | const {upstashSearchRestUrl, ...rest} = validConfig.upstash; 75 | expect(() => 76 | testValidateThemeConfig({upstash: rest}), 77 | ).toThrowErrorMatchingInlineSnapshot(`""upstash.upstashSearchRestUrl" is required"`); 78 | }); 79 | 80 | it('missing upstashSearchReadOnlyRestToken', () => { 81 | const {upstashSearchReadOnlyRestToken, ...rest} = validConfig.upstash; 82 | expect(() => 83 | testValidateThemeConfig({upstash: rest}), 84 | ).toThrowErrorMatchingInlineSnapshot(`""upstash.upstashSearchReadOnlyRestToken" is required"`); 85 | }); 86 | 87 | it('missing upstashSearchIndexName', () => { 88 | const {upstashSearchIndexName, ...rest} = validConfig.upstash; 89 | expect(() => 90 | testValidateThemeConfig({upstash: rest}), 91 | ).toThrowErrorMatchingInlineSnapshot(`""upstash.upstashSearchIndexName" is required"`); 92 | }); 93 | 94 | it('invalid enableAiChat type', () => { 95 | expect(() => 96 | testValidateThemeConfig({ 97 | upstash: { 98 | ...validConfig.upstash, 99 | enableAiChat: 'true', // should be boolean 100 | }, 101 | }), 102 | ).toThrowErrorMatchingInlineSnapshot(`""upstash.enableAiChat" must be a boolean"`); 103 | }); 104 | }); 105 | -------------------------------------------------------------------------------- /src/theme/SearchBar/styles.module.css: -------------------------------------------------------------------------------- 1 | .searchContainer { 2 | position: relative; 3 | margin: 0 1rem; 4 | } 5 | 6 | .searchButton { 7 | display: flex; 8 | align-items: center; 9 | gap: 8px; 10 | padding: 8px 12px; 11 | background: var(--ifm-background-surface-color); 12 | border: 2px solid var(--ifm-color-emphasis-200); 13 | border-radius: 8px; 14 | color: var(--ifm-color-emphasis-700); 15 | cursor: pointer; 16 | font-size: 0.9rem; 17 | transition: all 0.2s ease; 18 | } 19 | 20 | .searchButton:hover { 21 | background: var(--ifm-color-emphasis-100); 22 | border-color: var(--ifm-color-primary-lighter); 23 | } 24 | 25 | .searchIcon { 26 | color: var(--ifm-color-emphasis-500); 27 | } 28 | 29 | .searchButtonText { 30 | margin-right: 8px; 31 | } 32 | 33 | .searchShortcut { 34 | padding: 2px 6px; 35 | background: var(--ifm-color-emphasis-100); 36 | border-radius: 4px; 37 | font-size: 0.8rem; 38 | color: var(--ifm-color-emphasis-600); 39 | } 40 | 41 | .modalOverlay { 42 | position: fixed; 43 | top: 0; 44 | left: 0; 45 | right: 0; 46 | bottom: 0; 47 | background: rgba(0, 0, 0, 0.5); 48 | backdrop-filter: blur(4px); 49 | display: flex; 50 | align-items: flex-start; 51 | justify-content: center; 52 | padding-top: 80px; 53 | z-index: 1000; 54 | } 55 | 56 | .modalContent { 57 | width: 100%; 58 | max-width: 600px; 59 | max-height: calc(100vh - 160px); 60 | margin: 0 20px; 61 | background: var(--ifm-background-surface-color); 62 | border-radius: 12px; 63 | box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); 64 | display: flex; 65 | flex-direction: column; 66 | overflow: hidden; 67 | } 68 | 69 | .modalHeader { 70 | padding: 16px; 71 | border-bottom: 1px solid var(--ifm-color-emphasis-200); 72 | flex-shrink: 0; 73 | } 74 | 75 | .searchForm { 76 | display: flex; 77 | align-items: center; 78 | width: 100%; 79 | } 80 | 81 | .inputWrapper { 82 | position: relative; 83 | width: 100%; 84 | display: flex; 85 | align-items: center; 86 | } 87 | 88 | .searchInput { 89 | width: 100%; 90 | padding: 12px 85px 12px 16px; 91 | border: 2px solid var(--ifm-color-emphasis-200); 92 | border-radius: 8px; 93 | font-size: 1rem; 94 | background-color: var(--ifm-background-surface-color); 95 | color: var(--ifm-color-emphasis-900); 96 | transition: all 0.2s ease; 97 | } 98 | 99 | .searchInput:focus { 100 | outline: none; 101 | border-color: var(--ifm-color-primary); 102 | box-shadow: 0 0 0 2px var(--ifm-color-primary-lightest); 103 | } 104 | 105 | .inputActions { 106 | position: absolute; 107 | right: 8px; 108 | top: 50%; 109 | transform: translateY(-50%); 110 | display: flex; 111 | align-items: center; 112 | gap: 4px; 113 | padding: 4px; 114 | } 115 | 116 | .divider { 117 | width: 1px; 118 | height: 20px; 119 | background-color: var(--ifm-color-emphasis-200); 120 | margin: 0 4px; 121 | } 122 | 123 | .clearButton, 124 | .closeButton { 125 | display: flex; 126 | align-items: center; 127 | padding: 4px 8px; 128 | background: none; 129 | border: none; 130 | color: var(--ifm-color-emphasis-600); 131 | cursor: pointer; 132 | border-radius: 4px; 133 | transition: all 0.2s ease; 134 | } 135 | 136 | .clearButton:hover, 137 | .closeButton:hover { 138 | background: var(--ifm-color-emphasis-100); 139 | color: var(--ifm-color-emphasis-800); 140 | } 141 | 142 | .escKey { 143 | font-size: 0.75rem; 144 | padding: 2px 6px; 145 | background: var(--ifm-color-emphasis-100); 146 | border-radius: 4px; 147 | color: var(--ifm-color-emphasis-600); 148 | } 149 | 150 | .clearIcon, 151 | .closeIcon { 152 | width: 16px; 153 | height: 16px; 154 | } 155 | 156 | .poweredBy { 157 | display: flex; 158 | align-items: center; 159 | justify-content: center; 160 | gap: 8px; 161 | font-size: 0.85rem; 162 | color: var(--ifm-color-emphasis-600); 163 | } 164 | 165 | .searchLogo { 166 | height: 24px; 167 | margin-left: 8px; 168 | filter: none; 169 | } 170 | 171 | /* Use Docusaurus's built-in dark mode class */ 172 | [data-theme='dark'] .searchLogo { 173 | filter: invert(1) brightness(1.8); 174 | } 175 | 176 | .searchLogo:hover { 177 | opacity: 0.8; 178 | } 179 | 180 | .searchResults { 181 | flex: 1; 182 | overflow-y: auto; 183 | padding: 16px; 184 | min-height: 100px; 185 | } 186 | 187 | .searchResultItem { 188 | padding: 1rem; 189 | border: 1px solid var(--ifm-color-emphasis-200); 190 | cursor: pointer; 191 | transition: all 0.2s ease; 192 | background-color: var(--ifm-background-surface-color); 193 | margin-bottom: 0.75rem; 194 | border-radius: 8px; 195 | } 196 | 197 | .searchResultItem:last-child { 198 | margin-bottom: 0; 199 | } 200 | 201 | .searchResultItem:hover { 202 | background-color: var(--ifm-color-emphasis-100); 203 | border-color: var(--ifm-color-primary-lighter); 204 | transform: translateY(-1px); 205 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); 206 | } 207 | 208 | .searchResultItem:hover .resultPreview { 209 | background-color: var(--ifm-color-emphasis-200); 210 | } 211 | 212 | .resultTitle { 213 | font-size: 1.1rem; 214 | font-weight: 600; 215 | color: var(--ifm-color-primary); 216 | margin-bottom: 0.35rem; 217 | } 218 | 219 | .resultPath { 220 | font-size: 0.85rem; 221 | color: var(--ifm-color-emphasis-600); 222 | font-family: monospace; 223 | margin-bottom: 0.5rem; 224 | } 225 | 226 | .resultPreview { 227 | font-size: 0.9rem; 228 | color: var(--ifm-color-emphasis-800); 229 | line-height: 1.5; 230 | white-space: nowrap; 231 | overflow: hidden; 232 | text-overflow: ellipsis; 233 | background-color: var(--ifm-color-emphasis-100); 234 | padding: 0.5rem; 235 | border-radius: 4px; 236 | transition: background-color 0.2s ease; 237 | } 238 | 239 | .loadingDots { 240 | display: inline-flex; 241 | align-items: center; 242 | } 243 | 244 | .dots { 245 | display: inline-flex; 246 | } 247 | 248 | .dots span { 249 | animation: loadingDots 1.4s infinite; 250 | margin-left: 2px; 251 | } 252 | 253 | .dots span:nth-child(2) { 254 | animation-delay: 0.2s; 255 | } 256 | 257 | .dots span:nth-child(3) { 258 | animation-delay: 0.4s; 259 | } 260 | 261 | @keyframes loadingDots { 262 | 0%, 263 | 80%, 264 | 100% { 265 | opacity: 0; 266 | } 267 | 40% { 268 | opacity: 1; 269 | } 270 | } 271 | 272 | .loadingText { 273 | display: flex; 274 | justify-content: center; 275 | align-items: center; 276 | padding: 2rem; 277 | color: var(--ifm-color-emphasis-600); 278 | } 279 | 280 | .noResults, 281 | .searchResultsPlaceholder, 282 | .error { 283 | padding: 1rem; 284 | text-align: center; 285 | color: var(--ifm-color-emphasis-600); 286 | } 287 | 288 | .error { 289 | color: var(--ifm-color-danger); 290 | } 291 | 292 | .modalFooter { 293 | padding: 12px 16px; 294 | border-top: 1px solid var(--ifm-color-emphasis-200); 295 | background: var(--ifm-background-surface-color); 296 | flex-shrink: 0; 297 | } 298 | 299 | .modalActions { 300 | display: flex; 301 | align-items: center; 302 | justify-content: space-between; 303 | padding: 0 4px; 304 | } 305 | 306 | .closeButton { 307 | display: flex; 308 | align-items: center; 309 | gap: 6px; 310 | padding: 6px; 311 | background: none; 312 | border: none; 313 | color: var(--ifm-color-emphasis-600); 314 | cursor: pointer; 315 | border-radius: 6px; 316 | transition: all 0.2s ease; 317 | } 318 | 319 | .closeButton:hover { 320 | background: var(--ifm-color-emphasis-100); 321 | color: var(--ifm-color-emphasis-800); 322 | } 323 | 324 | .escKey { 325 | font-size: 0.75rem; 326 | padding: 2px 4px; 327 | background: var(--ifm-color-emphasis-100); 328 | border-radius: 4px; 329 | color: var(--ifm-color-emphasis-600); 330 | } 331 | 332 | .aiSection { 333 | margin-bottom: 1rem; 334 | padding: 0.75rem 1rem; 335 | background-color: var(--ifm-background-surface-color); 336 | border: 1px solid var(--ifm-color-emphasis-200); 337 | border-radius: 8px; 338 | cursor: pointer; 339 | transition: all 0.2s ease; 340 | } 341 | 342 | .aiSection:hover:not(.aiSectionResponded) { 343 | background-color: var(--ifm-color-emphasis-100); 344 | border-color: var(--ifm-color-primary-lighter); 345 | transform: translateY(-1px); 346 | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); 347 | } 348 | 349 | .aiSectionResponded { 350 | cursor: default; 351 | } 352 | 353 | .aiQueryWrapper { 354 | display: flex; 355 | align-items: center; 356 | justify-content: space-between; 357 | gap: 12px; 358 | overflow: hidden; 359 | } 360 | 361 | .aiQueryInfo { 362 | display: flex; 363 | align-items: center; 364 | gap: 8px; 365 | color: var(--ifm-color-emphasis-700); 366 | } 367 | 368 | .aiLabel { 369 | font-weight: 800; 370 | font-size: 0.85rem; 371 | color: var(--ifm-color-emphasis-100); 372 | background: var(--ifm-color-primary); 373 | padding: 2px 6px; 374 | border-radius: 4px; 375 | letter-spacing: 0.5px; 376 | } 377 | 378 | .aiQueryTextWrapper { 379 | display: flex; 380 | flex-direction: column; 381 | padding-left: 2px; 382 | } 383 | 384 | .aiQueryText { 385 | font-size: 0.9rem; 386 | color: var(--ifm-color-emphasis-900); 387 | } 388 | 389 | .aiQueryHighlight { 390 | font-weight: 600; 391 | color: var(--ifm-color-primary); 392 | } 393 | 394 | .aiStatus { 395 | font-size: 0.9rem; 396 | color: var(--ifm-color-emphasis-600); 397 | } 398 | 399 | .aiResponseWrapper { 400 | margin-top: 1rem; 401 | overflow: hidden; 402 | animation: slideIn 0.3s ease-out forwards; 403 | } 404 | 405 | .aiResponse { 406 | padding: 1rem; 407 | background-color: var(--ifm-color-emphasis-100); 408 | border-radius: 6px; 409 | font-size: 0.9rem; 410 | line-height: 1.5; 411 | color: var(--ifm-color-emphasis-800); 412 | } 413 | 414 | .aiResponse span { 415 | display: inline-block; 416 | } 417 | 418 | .typing { 419 | border-right: 2px solid transparent; 420 | animation: blinkCursor 0.8s step-end infinite; 421 | } 422 | 423 | @keyframes blinkCursor { 424 | from, 425 | to { 426 | border-right-color: transparent; 427 | } 428 | 50% { 429 | border-right-color: var(--ifm-color-emphasis-600); 430 | } 431 | } 432 | 433 | @keyframes slideIn { 434 | from { 435 | opacity: 0; 436 | transform: translateX(30px); 437 | } 438 | to { 439 | opacity: 1; 440 | transform: translateX(0); 441 | } 442 | } 443 | 444 | @media (max-width: 768px) { 445 | .modalContent { 446 | margin: 0 16px; 447 | } 448 | 449 | .searchButton { 450 | padding: 6px 10px; 451 | } 452 | 453 | .searchButtonText { 454 | display: none; 455 | } 456 | 457 | .searchShortcut { 458 | display: none; 459 | } 460 | } 461 | -------------------------------------------------------------------------------- /src/scripts/indexDocs.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | /** 4 | * Docusaurus AI Search Indexing Script 5 | * 6 | * This script indexes Docusaurus documentation by: 7 | * 1. Finding all markdown/MDX files in the docs directory 8 | * 2. Splitting them into sections based on headings 9 | * 3. Storing the sections in Upstash Search for hybrid search 10 | */ 11 | 12 | import { Search } from '@upstash/search'; 13 | import 'dotenv/config'; 14 | import { promises as fs } from 'fs'; 15 | import path from 'path'; 16 | 17 | // Types and Interfaces 18 | interface SearchMetadata { 19 | title: string; 20 | path: string; 21 | level: number; 22 | type: string; 23 | content: string; 24 | documentTitle: string; 25 | chunkIndex?: number; 26 | totalChunks?: number; 27 | [key: string]: string | number | undefined; 28 | } 29 | 30 | interface DocumentSection { 31 | level?: number; 32 | title?: string; 33 | content?: string; 34 | } 35 | 36 | interface ContentChunk { 37 | content: string; 38 | chunkIndex: number; 39 | totalChunks: number; 40 | } 41 | 42 | // Configuration Constants 43 | const DEFAULT_INDEX_NAME = 'docusaurus'; 44 | const DEFAULT_DOCS_PATH = 'docs'; 45 | const indexName = process.env.UPSTASH_SEARCH_INDEX_NAME ?? DEFAULT_INDEX_NAME; 46 | const docsPath = process.env.DOCS_PATH ?? DEFAULT_DOCS_PATH; 47 | console.log('Index name:', indexName); 48 | console.log('Docs path:', docsPath); 49 | 50 | // Initialize Upstash Search client 51 | const searchClient = new Search({ 52 | url: process.env.UPSTASH_SEARCH_REST_URL!, 53 | token: process.env.UPSTASH_SEARCH_REST_TOKEN!, 54 | }); 55 | 56 | const index = searchClient.index(indexName) 57 | 58 | /** 59 | * Utility Functions 60 | */ 61 | 62 | /** 63 | * Converts text to a URL-friendly slug 64 | * @param text The text to slugify 65 | * @returns A URL-friendly version of the text 66 | */ 67 | function slugify(text: string): string { 68 | return text 69 | .toString() 70 | .toLowerCase() 71 | .normalize('NFD') 72 | .trim() 73 | .replace(/\./g, '') 74 | .replace(/\s+/g, '-') 75 | .replace(/[^\w-]+/g, '') 76 | .replace(/--+/g, '-'); 77 | } 78 | 79 | /** 80 | * Extracts title from markdown frontmatter or generates one from filename 81 | * @param content The markdown content 82 | * @param fileName The name of the file 83 | * @returns The extracted or generated title 84 | */ 85 | function extractTitle(content: string, fileName: string): string { 86 | const titleMatch = content.match( 87 | /^---[\s\S]*?\ntitle:\s*["']?(.*?)["']?\n[\s\S]*?---/ 88 | ); 89 | if (titleMatch?.[1]) { 90 | return titleMatch[1].replace(/['"]/g, '').trim(); 91 | } 92 | return path 93 | .basename(fileName, path.extname(fileName)) 94 | .split(/[-_]/) 95 | .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) 96 | .join(' '); 97 | } 98 | 99 | /** 100 | * Extracts ID from markdown frontmatter 101 | * @param content The markdown content 102 | * @returns The extracted ID or null if not found 103 | */ 104 | function extractId(content: string): string | null { 105 | const idMatch = content.match(/^---[\s\S]*?\nid:\s*["']?(.*?)["']?\n[\s\S]*?---/); 106 | return idMatch?.[1] || null; 107 | } 108 | 109 | /** 110 | * File Processing Functions 111 | */ 112 | 113 | /** 114 | * Recursively finds all markdown/MDX files in a directory 115 | * @param dir Directory to search in 116 | * @returns Array of file paths 117 | */ 118 | async function findMarkdownFiles(dir: string): Promise { 119 | const files = await fs.readdir(dir, { withFileTypes: true }); 120 | let markdownFiles: string[] = []; 121 | 122 | for (const file of files) { 123 | const fullPath = path.join(dir, file.name); 124 | 125 | if (file.isDirectory() && !file.name.startsWith('.')) { 126 | const subFiles = await findMarkdownFiles(fullPath); 127 | markdownFiles = [...markdownFiles, ...subFiles]; 128 | } else if (file.name.match(/\.(md|mdx)$/)) { 129 | markdownFiles.push(fullPath); 130 | } 131 | } 132 | 133 | return markdownFiles; 134 | } 135 | 136 | /** 137 | * Processes a single markdown file and returns its content and metadata 138 | * @param filePath Path to the markdown file 139 | */ 140 | async function processMarkdownFile(filePath: string) { 141 | const content = await fs.readFile(filePath, 'utf-8'); 142 | const dirPath = path.dirname(path.relative(process.cwd(), filePath)) 143 | .replace(docsPath, 'docs'); 144 | 145 | // Try to get ID from frontmatter, fallback to filename 146 | const id = extractId(content) || path.basename(filePath).replace(/\.(md|mdx)$/, ''); 147 | const relativePath = path.join(dirPath, id); 148 | 149 | const fileName = path.basename(filePath); 150 | const title = extractTitle(content, fileName); 151 | 152 | return { 153 | content, 154 | title, 155 | _meta: { 156 | path: relativePath, 157 | }, 158 | }; 159 | } 160 | 161 | /** 162 | * Content Processing Functions 163 | */ 164 | 165 | /** 166 | * Splits MDX content into sections based on headings 167 | * @param mdx The MDX content to split 168 | * @returns Array of sections with their headings and content 169 | */ 170 | function splitMdxByHeadings(mdx: string): DocumentSection[] { 171 | const sections = mdx.split(/(?=^#{1,6}\s)/m); 172 | 173 | return sections 174 | .map((section) => { 175 | const lines = section.trim().split('\n'); 176 | const headingMatch = lines[0]?.match(/^(#{1,6})\s+(.+)$/); 177 | 178 | if (!headingMatch) return null; 179 | 180 | const [, hashes, title] = headingMatch; 181 | const content = lines.slice(1).join('\n').trim(); 182 | 183 | return { 184 | level: hashes?.length, 185 | title, 186 | content, 187 | }; 188 | }) 189 | .filter(Boolean) as DocumentSection[]; 190 | } 191 | 192 | /** 193 | * Splits content into chunks with overlap to avoid character limits 194 | * @param content The content to split 195 | * @param maxChunkSize Maximum size of each chunk (default: 1200) 196 | * @param overlapSize Size of overlap between chunks (default: 200) 197 | * @returns Array of content chunks 198 | */ 199 | function splitContentIntoChunks( 200 | content: string, 201 | maxChunkSize: number = 1200, 202 | overlapSize: number = 200 203 | ): ContentChunk[] { 204 | if (content.length <= maxChunkSize) { 205 | return [{ content, chunkIndex: 0, totalChunks: 1 }]; 206 | } 207 | 208 | // Ensure overlap is not too large to prevent infinite loops 209 | overlapSize = Math.min(overlapSize, Math.floor(maxChunkSize * 0.3)); 210 | 211 | const chunks: ContentChunk[] = []; 212 | let startIndex = 0; 213 | let chunkIndex = 0; 214 | const maxIterations = Math.ceil(content.length / (maxChunkSize - overlapSize)) + 1; 215 | 216 | while (startIndex < content.length && chunkIndex < maxIterations) { 217 | let endIndex = Math.min(startIndex + maxChunkSize, content.length); 218 | 219 | // If this isn't the last chunk, try to break at a natural boundary 220 | if (endIndex < content.length) { 221 | // Look for sentence boundaries first 222 | const sentenceBreak = content.lastIndexOf('.', endIndex); 223 | if (sentenceBreak > startIndex + maxChunkSize * 0.5) { 224 | endIndex = sentenceBreak + 1; 225 | } else { 226 | // Look for word boundaries 227 | const wordBreak = content.lastIndexOf(' ', endIndex); 228 | if (wordBreak > startIndex + maxChunkSize * 0.5) { 229 | endIndex = wordBreak; 230 | } 231 | } 232 | } 233 | 234 | const chunkContent = content.slice(startIndex, endIndex).trim(); 235 | if (chunkContent) { 236 | chunks.push({ 237 | content: chunkContent, 238 | chunkIndex, 239 | totalChunks: 0, // Will be updated after all chunks are created 240 | }); 241 | } 242 | 243 | // Move start index forward, accounting for overlap 244 | // Ensure we always make progress to avoid infinite loops 245 | const nextStartIndex = Math.max(endIndex - overlapSize, startIndex + 1); 246 | if (nextStartIndex >= content.length) break; 247 | 248 | startIndex = nextStartIndex; 249 | chunkIndex++; 250 | } 251 | 252 | // Update total chunks count 253 | chunks.forEach(chunk => { 254 | chunk.totalChunks = chunks.length; 255 | }); 256 | 257 | return chunks; 258 | } 259 | 260 | /** 261 | * Main indexing function 262 | */ 263 | async function indexDocs() { 264 | try { 265 | console.log('Starting indexing process...'); 266 | console.log('Using index:', indexName); 267 | 268 | // Find and process markdown files 269 | console.log('Finding markdown files...'); 270 | const markdownFiles = await findMarkdownFiles( 271 | path.join(process.cwd(), docsPath) 272 | ); 273 | console.log(`Found ${markdownFiles.length} markdown files`); 274 | 275 | console.log('Processing markdown files...'); 276 | const allDocs = await Promise.all(markdownFiles.map(processMarkdownFile)); 277 | console.log(`Processed ${allDocs.length} documents`); 278 | 279 | // Reset the index for fresh indexing 280 | await index.reset(); 281 | console.log('Reset index for fresh indexing'); 282 | 283 | // Process each document 284 | for (const doc of allDocs) { 285 | try { 286 | const sections = splitMdxByHeadings(doc.content); 287 | 288 | for (const section of sections) { 289 | if (!section || !section.content) continue; 290 | 291 | // Split content into chunks if it's too long 292 | const contentChunks = splitContentIntoChunks(section.content); 293 | 294 | for (const chunk of contentChunks) { 295 | const chunkSuffix = contentChunks.length > 1 ? `-chunk-${chunk.chunkIndex + 1}` : ''; 296 | const headingId = `${doc._meta.path}#${slugify(section.title!)}${chunkSuffix}`; 297 | 298 | const metadata: SearchMetadata = { 299 | title: section.title ?? '', 300 | path: doc._meta.path, 301 | level: section.level ?? 2, 302 | type: contentChunks.length > 1 ? 'section-chunk' : 'section', 303 | content: chunk.content, 304 | documentTitle: doc.title, 305 | ...(contentChunks.length > 1 && { 306 | chunkIndex: chunk.chunkIndex, 307 | totalChunks: chunk.totalChunks, 308 | }), 309 | }; 310 | 311 | await index.upsert( 312 | { 313 | id: headingId, 314 | content: { title: section.title, content: chunk.content }, 315 | metadata, 316 | } 317 | ); 318 | } 319 | } 320 | 321 | console.log(`✅ Indexed document sections: ${doc.title}`); 322 | } catch (error) { 323 | console.error(`❌ Failed to index ${doc.title}:`, error); 324 | } 325 | } 326 | 327 | console.log('✅ Finished indexing docs'); 328 | } catch (error) { 329 | console.error('❌ Failed to index docs:', error); 330 | throw error; 331 | } 332 | } 333 | 334 | // Start the indexing process 335 | indexDocs().catch(console.error); 336 | -------------------------------------------------------------------------------- /src/theme/SearchBar/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | useCallback, 3 | useEffect, 4 | useRef, 5 | useState, 6 | Suspense, 7 | } from 'react'; 8 | import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; 9 | import type { DocusaurusContext } from '@docusaurus/types'; 10 | import type { ThemeConfig } from '@upstash/docusaurus-theme-upstash-search'; 11 | import { useHistory } from '@docusaurus/router'; 12 | import BrowserOnly from '@docusaurus/BrowserOnly'; 13 | import styles from './styles.module.css'; 14 | 15 | // Lazy loaded components 16 | const ReactMarkdown = React.lazy(() => import('react-markdown')); 17 | 18 | // Custom hooks 19 | import { useSearchLogic } from './hooks/useSearchLogic'; 20 | import { useAiChat } from './hooks/useAiChat'; 21 | 22 | // Components 23 | import { SearchIcon, ClearIcon } from './components/Icons'; 24 | import { LoadingDots } from './components/LoadingDots'; 25 | import { UpstashLogo } from './components/UpstashLogo'; 26 | 27 | // Types 28 | import { SearchResult } from './types'; 29 | import { formatContent } from './utils/formatContent'; 30 | 31 | const SearchBarContent: React.FC = () => { 32 | const [isModalOpen, setIsModalOpen] = useState(false); 33 | const searchInputRef = useRef(null); 34 | const searchResultsRef = useRef(null); 35 | const modalRef = useRef(null); 36 | const history = useHistory(); 37 | const { siteConfig } = useDocusaurusContext() as DocusaurusContext; 38 | const themeConfig = siteConfig.themeConfig as ThemeConfig; 39 | 40 | const { 41 | searchQuery, 42 | setSearchQuery, 43 | searchResults, 44 | isLoading, 45 | error, 46 | setSearchResults, 47 | setError, 48 | } = useSearchLogic(); 49 | 50 | const { aiResponse, isAiLoading, setAiResponse, handleAiQuestion } = 51 | useAiChat({ 52 | aiChatApiEndpoint: themeConfig.upstash?.aiChatApiEndpoint ?? '', 53 | }); 54 | 55 | const enableAiChat = themeConfig.upstash?.enableAiChat ?? false; 56 | 57 | const clearSearch = useCallback(() => { 58 | setSearchQuery(''); 59 | setSearchResults([]); 60 | setAiResponse(null); 61 | setError(null); 62 | }, [setSearchQuery, setSearchResults, setAiResponse, setError]); 63 | 64 | useEffect(() => { 65 | const handleKeyDown = (e: KeyboardEvent) => { 66 | if ( 67 | (e.key === 'Escape' || (e.key === 'k' && (e.metaKey || e.ctrlKey))) && 68 | isModalOpen 69 | ) { 70 | e.preventDefault(); 71 | setIsModalOpen(false); 72 | clearSearch(); 73 | } 74 | if (e.key === 'k' && (e.metaKey || e.ctrlKey) && !isModalOpen) { 75 | e.preventDefault(); 76 | setIsModalOpen(true); 77 | setTimeout(() => searchInputRef.current?.focus(), 100); 78 | } 79 | }; 80 | 81 | document.addEventListener('keydown', handleKeyDown); 82 | return () => document.removeEventListener('keydown', handleKeyDown); 83 | }, [isModalOpen, clearSearch]); 84 | 85 | useEffect(() => { 86 | setAiResponse(null); 87 | }, [searchQuery, setAiResponse]); 88 | 89 | const handleResultClick = useCallback( 90 | (result: SearchResult) => { 91 | history.push('/' + result.id); 92 | setIsModalOpen(false); 93 | clearSearch(); 94 | }, 95 | [history, clearSearch] 96 | ); 97 | 98 | const handleAiQuestionClick = useCallback( 99 | async (question: string) => { 100 | if (!isAiLoading && !aiResponse) { 101 | try { 102 | await handleAiQuestion( 103 | question, 104 | searchResults.map((result) => ({ 105 | content: result.data, 106 | metadata: result.metadata, 107 | })) 108 | ); 109 | } catch (error) { 110 | setError( 111 | error instanceof Error ? error.message : 'Failed to get AI response' 112 | ); 113 | } 114 | } 115 | }, 116 | [isAiLoading, aiResponse, handleAiQuestion, searchResults, setError] 117 | ); 118 | 119 | return ( 120 |
121 | 130 | 131 | {isModalOpen && ( 132 |
{ 135 | setIsModalOpen(false); 136 | clearSearch(); 137 | }} 138 | role="dialog" 139 | aria-modal="true" 140 | aria-label="Search documentation" 141 | > 142 |
e.stopPropagation()} 146 | > 147 |
148 |
e.preventDefault()} 151 | > 152 |
153 | setSearchQuery(e.target.value)} 159 | className={styles.searchInput} 160 | autoFocus 161 | aria-label="Search input" 162 | /> 163 |
164 | {searchQuery && ( 165 | 173 | )} 174 |
175 | 185 |
186 |
187 |
188 |
189 | 190 |
191 | {isLoading ? ( 192 |
193 | 194 |
195 | ) : error ? ( 196 |
197 | {error} 198 |
199 | ) : searchResults.length > 0 ? ( 200 | <> 201 | {enableAiChat && ( 202 |
handleAiQuestionClick(searchQuery)} 205 | role="button" 206 | tabIndex={0} 207 | aria-label="Ask AI about search results" 208 | > 209 |
210 |
211 | AI 212 |
213 | 214 | Tell me about{' '} 215 | 216 | {searchQuery} 217 | 218 | 219 |
220 |
221 | 222 | {isAiLoading ? ( 223 | 224 | ) : aiResponse ? ( 225 | 'Response →' 226 | ) : ( 227 | 'Ask →' 228 | )} 229 | 230 |
231 | {aiResponse && ( 232 |
233 |
234 | }> 235 | {aiResponse} 236 | 237 |
238 |
239 | )} 240 |
241 | )} 242 | {searchResults.map((result) => ( 243 |
handleResultClick(result)} 247 | role="button" 248 | tabIndex={0} 249 | aria-label={`Search result: ${result.metadata.title}`} 250 | > 251 |
252 | {result.metadata.title} 253 |
254 |
255 | {result.metadata.documentTitle} 256 |
257 |
258 | {formatContent(result.metadata.content)} 259 |
260 |
261 | ))} 262 | 263 | ) : searchQuery ? ( 264 |
No results found
265 | ) : ( 266 |
267 | Start typing to search... 268 |
269 | )} 270 |
271 | 272 |
273 |
274 | Powered by Upstash 275 | 276 |
277 |
278 |
279 |
280 | )} 281 |
282 | ); 283 | }; 284 | 285 | const SearchBar = () => { 286 | return {() => }; 287 | }; 288 | 289 | export default SearchBar; 290 | --------------------------------------------------------------------------------