mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
Core: New headless search client
This commit is contained in:
@@ -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
|
||||
},
|
||||
);
|
||||
```
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-core': major
|
||||
---
|
||||
|
||||
**Refactor import path of `fumadocs-core/search-algolia/server` to `fumadocs-core/search/algolia`**
|
||||
@@ -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> {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+8
-50
@@ -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 };
|
||||
}
|
||||
@@ -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[];
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user