Core: New headless search client

This commit is contained in:
fuma-nama
2024-09-14 20:04:59 +08:00
parent 979e301ddc
commit 9a0b09face
16 changed files with 192 additions and 157 deletions
+22
View File
@@ -0,0 +1,22 @@
---
'fumadocs-core': major
---
**Change usage of `useDocsSearch`**
**why:** Allow static search
**migrate:**
Pass client option, it can be algolia, static, or fetch (default).
```ts
import { useDocsSearch } from 'fumadocs-core/search/client';
const { search, setSearch, query } = useDocsSearch(
{
type: 'fetch',
api: '/api/search' // optional
},
);
```
+21
View File
@@ -0,0 +1,21 @@
---
'fumadocs-core': major
---
**Remove Algolia Search Client**
**why:** Replace by the new search client
**migrate:**
```ts
import { useDocsSearch } from 'fumadocs-core/search/client';
const { search, setSearch, query } = useDocsSearch(
{
type: 'algolia',
index,
...searchOptions,
},
);
```
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': major
---
**Refactor import path of `fumadocs-core/search-algolia/server` to `fumadocs-core/search/algolia`**
+1 -1
View File
@@ -1,5 +1,5 @@
import algosearch from 'algoliasearch';
import { DocumentRecord, sync } from 'fumadocs-core/search-algolia/server';
import { DocumentRecord, sync } from 'fumadocs-core/search/algolia';
import * as fs from 'node:fs/promises';
export async function updateSearchIndexes(): Promise<void> {
+5 -1
View File
@@ -1,4 +1,8 @@
import { source } from '@/lib/source';
import { createFromSource } from 'fumadocs-core/search/server';
export const { GET } = createFromSource(source);
const server = createFromSource(source);
export async function GET() {
return Response.json(await server.export());
}
+9 -1
View File
@@ -11,7 +11,15 @@ export default function Layout({ children }: { children: ReactNode }) {
return (
<html lang="en" className={inter.className}>
<body>
<RootProvider>{children}</RootProvider>
<RootProvider
search={{
options: {
type: 'static',
},
}}
>
{children}
</RootProvider>
</body>
</html>
);
+7 -54
View File
@@ -32,6 +32,10 @@
"import": "./dist/search/server.js",
"types": "./dist/search/server.d.ts"
},
"./search/algolia": {
"import": "./dist/search/algolia.js",
"types": "./dist/search/algolia.d.ts"
},
"./server": {
"import": "./dist/server/index.js",
"types": "./dist/server/index.d.ts"
@@ -56,65 +60,11 @@
"import": "./dist/mdx-plugins/index.js",
"types": "./dist/mdx-plugins/index.d.ts"
},
"./search-algolia/client": {
"import": "./dist/search-algolia/client.js",
"types": "./dist/search-algolia/client.d.ts"
},
"./search-algolia/server": {
"import": "./dist/search-algolia/server.js",
"types": "./dist/search-algolia/server.d.ts"
},
"./i18n": {
"import": "./dist/i18n/index.js",
"types": "./dist/i18n/index.d.ts"
}
},
"typesVersions": {
"*": {
"sidebar": [
"./dist/sidebar.d.ts"
],
"breadcrumb": [
"./dist/breadcrumb.d.ts"
],
"toc": [
"./dist/toc.d.ts"
],
"search/client": [
"./dist/search/client.d.ts"
],
"search/server": [
"./dist/search/server.d.ts"
],
"server": [
"./dist/server/index.d.ts"
],
"source": [
"./dist/source/index.d.ts"
],
"link": [
"./dist/link.d.ts"
],
"middleware": [
"./dist/middleware.d.ts"
],
"utils/use-on-change": [
"./dist/utils/use-on-change.d.ts"
],
"mdx-plugins": [
"./dist/mdx-plugins/index.d.ts"
],
"search-algolia/client": [
"./dist/search-algolia/client.d.ts"
],
"search-algolia/server": [
"./dist/search-algolia/server.d.ts"
],
"i18n": [
"./dist/i18n/index.d.ts"
]
}
},
"files": [
"dist/*"
],
@@ -164,6 +114,9 @@
"react": ">= 18",
"react-dom": ">= 18"
},
"optionalDependencies": {
"algoliasearch": "4.x.x"
},
"publishConfig": {
"access": "public"
}
+47 -7
View File
@@ -1,8 +1,10 @@
import { useRef, useState } from 'react';
import { useMemo, useRef, useState } from 'react';
import { useDebounce } from '@/utils/use-debounce';
import type { SortedResult } from '@/server/types';
import { fetchDocs } from '@/search/client/fetch';
import { type FetchOptions } from '@/search/client/fetch';
import { useOnChange } from '@/utils/use-on-change';
import { type StaticClient, type StaticOptions } from '@/search/client/static';
import { type AlgoliaOptions } from '@/search/client/algolia';
interface UseDocsSearch {
search: string;
@@ -16,17 +18,32 @@ interface UseDocsSearch {
const cache = new Map<string, SortedResult[] | 'empty'>();
type Client =
| ({
type: 'fetch';
} & FetchOptions)
| ({
type: 'static';
} & StaticOptions)
| ({
type: 'algolia';
} & AlgoliaOptions);
let staticClient: StaticClient | undefined;
/**
* @param client - search client
* @param locale - Filter with locale
* @param tag - Filter with specific tag
* @param api - The Search API URL
* @param delayMs - The debounced delay for performing a search.
* @param key - cache key
*/
export function useDocsSearch(
client: Client,
locale?: string,
tag?: string,
api = '/api/search',
delayMs = 100,
key?: string,
): UseDocsSearch {
const [search, setSearch] = useState('');
const [results, setResults] = useState<SortedResult[] | 'empty'>('empty');
@@ -35,10 +52,14 @@ export function useDocsSearch(
const debouncedValue = useDebounce(search, delayMs);
const onStart = useRef<() => void>();
useOnChange(debouncedValue, () => {
const cacheKey = useMemo(() => {
return key ?? JSON.stringify([client, debouncedValue, locale, tag]);
}, [client, debouncedValue, locale, tag, key]);
useOnChange(cacheKey, () => {
if (onStart.current) onStart.current();
const cached = cache.get(debouncedValue);
const cached = cache.get(cacheKey);
if (cached) {
setError(undefined);
setResults(cached);
@@ -51,7 +72,26 @@ export function useDocsSearch(
interrupt = true;
};
void fetchDocs(api, debouncedValue, locale, tag)
async function run(): Promise<SortedResult[] | 'empty'> {
if (client.type === 'fetch') {
const { fetchDocs } = await import('./client/fetch');
return fetchDocs(debouncedValue, locale, tag, client);
}
if (client.type === 'algolia') {
const { index, type: _, ...rest } = client;
const { searchDocs } = await import('./client/algolia');
return searchDocs(index, debouncedValue, tag, rest);
}
const { createStaticClient } = await import('./client/static');
if (!staticClient) staticClient = createStaticClient(client);
return staticClient.search(debouncedValue, locale, tag);
}
void run()
.then((res) => {
cache.set(debouncedValue, res);
if (interrupt) return;
@@ -1,25 +1,10 @@
import type { Hit, SearchOptions } from '@algolia/client-search';
import type { SearchIndex } from 'algoliasearch/lite';
import { useState } from 'react';
import useSWR, { type SWRResponse } from 'swr';
import type { SortedResult } from '@/server/types';
import { useDebounce } from '@/utils/use-debounce';
import type { BaseIndex } from './server';
import type { BaseIndex } from '@/search/algolia';
export interface Options extends SearchOptions {
/**
* Use `empty` as result if query is empty
*
* @defaultValue true
*/
allowEmpty?: boolean;
/**
* Delay to debounce (in ms)
*
* @defaultValue 300
*/
delay?: number;
export interface AlgoliaOptions extends SearchOptions {
index: SearchIndex;
}
export function groupResults(hits: Hit<BaseIndex>[]): SortedResult[] {
@@ -52,13 +37,18 @@ export function groupResults(hits: Hit<BaseIndex>[]): SortedResult[] {
export async function searchDocs(
index: SearchIndex,
query: string,
tag?: string,
options?: SearchOptions,
): Promise<SortedResult[]> {
if (query.length === 0) {
let filters = options?.filters;
if (tag) filters = filters ? `tag:${tag} AND (${filters})` : `tag:${tag}`;
const result = await index.search<BaseIndex>(query, {
distinct: 1,
hitsPerPage: 8,
...options,
filters,
});
return groupResults(result.hits).filter((hit) => hit.type === 'page');
@@ -72,35 +62,3 @@ export async function searchDocs(
return groupResults(result.hits);
}
interface UseAlgoliaSearch {
search: string;
setSearch: (v: string) => void;
query: SWRResponse<
SortedResult[] | 'empty',
Error,
{ keepPreviousData: true }
>;
}
export function useAlgoliaSearch(
index: SearchIndex,
{ allowEmpty = true, delay = 150, ...options }: Options = {},
): UseAlgoliaSearch {
const [search, setSearch] = useState('');
const debouncedValue = useDebounce(search, delay);
const query: UseAlgoliaSearch['query'] = useSWR(
['algolia-search', debouncedValue, allowEmpty, options],
async () => {
if (allowEmpty && debouncedValue.length === 0) return 'empty';
return searchDocs(index, debouncedValue, options);
},
{
keepPreviousData: true,
},
);
return { search, setSearch, query };
}
+11 -2
View File
@@ -1,10 +1,17 @@
import type { SortedResult } from '@/server';
export interface FetchOptions {
/**
* API route for search endpoint
*/
api?: string;
}
export async function fetchDocs(
api: string,
query: string,
locale: string | undefined,
tag: string | undefined,
options: FetchOptions,
): Promise<SortedResult[] | 'empty'> {
if (query.length === 0) return 'empty';
@@ -13,7 +20,9 @@ export async function fetchDocs(
if (locale) params.set('locale', locale);
if (tag) params.set('tag', tag);
const res = await fetch(`${api}?${params.toString()}`);
const res = await fetch(
`${options.api ?? '/api/search'}?${params.toString()}`,
);
if (!res.ok) throw new Error(await res.text());
return (await res.json()) as SortedResult[];
+24 -18
View File
@@ -11,7 +11,14 @@ import { searchAdvanced } from '@/search/search/advanced';
import { type advancedSchema } from '@/search/create-db';
import { type schema } from '@/search/create-db-simple';
interface Client {
export interface StaticOptions {
/**
* Where to download exported search indexes (URL)
*/
from?: string;
}
export interface StaticClient {
search: (
query: string,
locale: string | undefined,
@@ -28,20 +35,19 @@ type ExportedData =
data: Record<string, RawData & { type: Type }>;
};
// locale -> db
const dbs = new Map<
string,
{
type: 'simple' | 'advanced';
db: AnyOrama;
}
>();
let started = false;
export function createStaticClient({
from = '/api/search',
}: StaticOptions): StaticClient {
// locale -> db
const dbs = new Map<
string,
{
type: 'simple' | 'advanced';
db: AnyOrama;
}
>();
export function createStaticClient(from = '/api/search'): Client {
async function init(): Promise<void> {
if (started) return;
started = true;
const res = await fetch(from);
if (!res.ok)
@@ -78,14 +84,14 @@ export function createStaticClient(from = '/api/search'): Client {
return {
async search(query, locale, tag) {
await get;
const db = dbs.get(locale ?? '');
const cached = dbs.get(locale ?? '');
if (!db) return [];
if (db.type === 'simple')
return searchSimple(db as unknown as Orama<typeof schema>, query);
if (!cached) return [];
if (cached.type === 'simple')
return searchSimple(cached as unknown as Orama<typeof schema>, query);
return searchAdvanced(
db as unknown as Orama<typeof advancedSchema>,
cached.db as Orama<typeof advancedSchema>,
query,
tag,
);
@@ -61,6 +61,5 @@ export async function searchAdvanced(
});
}
}
return list;
}
+3 -2
View File
@@ -8,9 +8,10 @@ export default defineConfig({
entry: [
'src/{toc,link,breadcrumb,sidebar,dynamic-link}.tsx',
'src/{server,source,mdx-plugins}/index.ts',
'src/{search,search-algolia}/client.ts',
'src/search/client.ts',
'src/search/server.ts',
'src/search/algolia.ts',
'src/utils/use-on-change.ts',
'src/{search,search-algolia}/server.ts',
'src/middleware.ts',
'src/i18n/index.ts',
],
@@ -1,12 +1,10 @@
'use client';
import type { SearchIndex } from 'algoliasearch/lite';
import {
type Options,
useAlgoliaSearch,
} from 'fumadocs-core/search-algolia/client';
import { useDocsSearch } from 'fumadocs-core/search/client';
import { type ReactNode, useState } from 'react';
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
import { SearchOptions } from '@algolia/client-search';
import {
SearchDialog,
type SharedProps,
@@ -16,7 +14,7 @@ import {
export interface AlgoliaSearchDialogProps extends SharedProps {
index: SearchIndex;
searchOptions?: Options;
searchOptions?: SearchOptions;
footer?: ReactNode;
defaultTag?: string;
@@ -39,16 +37,15 @@ export default function AlgoliaSearchDialog({
...props
}: AlgoliaSearchDialogProps): React.ReactElement {
const [tag, setTag] = useState(defaultTag);
let filters = searchOptions?.filters;
if (tag) {
filters = filters ? `tag:${tag} AND (${filters})` : `tag:${tag}`;
}
const { search, setSearch, query } = useAlgoliaSearch(index, {
...searchOptions,
filters,
});
const { search, setSearch, query } = useDocsSearch(
{
type: 'algolia',
index,
...searchOptions,
},
undefined,
tag,
);
useOnChange(defaultTag, (v) => {
setTag(v);
@@ -13,11 +13,9 @@ import {
export interface DefaultSearchDialogProps extends SharedProps {
/**
* Search tag
*
* @deprecated Use Tags API instead
* @defaultValue 'fetch'
*/
tag?: string;
type?: 'fetch' | 'static';
defaultTag?: string;
tags?: TagItem[];
@@ -40,11 +38,25 @@ export default function DefaultSearchDialog({
tags,
api,
delayMs,
type = 'fetch',
...props
}: DefaultSearchDialogProps): React.ReactElement {
const { locale } = useI18n();
const [tag, setTag] = useState(defaultTag);
const { search, setSearch, query } = useDocsSearch(locale, tag, api, delayMs);
const { search, setSearch, query } = useDocsSearch(
type === 'fetch'
? {
type: 'fetch',
api,
}
: {
type: 'static',
from: api,
},
locale,
tag,
delayMs,
);
useOnChange(defaultTag, (v) => {
setTag(v);