Core: Support headless search servers

This commit is contained in:
fuma-nama
2024-08-18 18:50:05 +08:00
parent e9f5843c25
commit 0e0ef8ceac
8 changed files with 163 additions and 100 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': patch
---
Support headless search servers
+1 -2
View File
@@ -2,11 +2,10 @@ import type * as Breadcrumb from 'fumadocs-core/breadcrumb';
import type * as TOC from 'fumadocs-core/toc';
import type * as Server from 'fumadocs-core/server';
import type * as Sidebar from 'fumadocs-core/sidebar';
import type { SortedResult as OriginalSortedResult } from 'fumadocs-core/search/shared';
import type { ElementType } from 'react';
import type * as MDX from 'fumadocs-core/mdx-plugins';
export type SortedResult = OriginalSortedResult;
export type SortedResult = Server.SortedResult;
export type StructureOptions = MDX.StructureOptions;
@@ -176,3 +176,19 @@ function Dialog() {
| ---------------- | ----------------------------------------------- |
| `empty` | If the searching text is empty or blank |
| `SortedResult[]` | Array of matching pages, headings and contents. |
## Headless
You can also host the search server on Express or Elysia, without Next.js.
```ts
import { initAdvancedSearch } from 'fumadocs-core/search/server';
const server = initAdvancedSearch({
// options
});
server.search('query', {
// you can specify `locale` and `tag` here
});
```
+6 -5
View File
@@ -1,14 +1,15 @@
import { NextResponse } from 'next/server';
import { type SearchAPI } from '@/search/server';
import { type SearchAPI, type SearchServer } from '@/search/server';
export function createEndpoint(server: SearchServer): SearchAPI {
const { search } = server;
export function createEndpoint(search: SearchAPI['search']): SearchAPI {
return {
search,
async GET(request) {
const query = request.nextUrl.searchParams.get('query');
if (!query) return NextResponse.json([]);
if (!query) return Response.json([]);
return NextResponse.json(
return Response.json(
await search(query, {
tag: request.nextUrl.searchParams.get('tag') ?? undefined,
locale: request.nextUrl.searchParams.get('locale') ?? undefined,
+48 -22
View File
@@ -1,10 +1,12 @@
import {
type AdvancedIndex,
type AdvancedOptions,
createSearchAPI,
type Dynamic,
type Index,
initAdvancedSearch,
initSimpleSearch,
type SearchAPI,
type SearchServer,
type SimpleOptions,
} from '@/search/server';
import { createEndpoint } from '@/search/create-endpoint';
@@ -30,30 +32,54 @@ export function createI18nSearchAPI<T extends 'simple' | 'advanced'>(
type: T,
options: T extends 'simple' ? I18nSimpleOptions : I18nAdvancedOptions,
): SearchAPI {
const map = new Map<string, SearchAPI>();
const map = new Map<string, SearchServer>();
return createEndpoint(async (query, searchOptions) => {
if (map.size === 0) {
const indexes =
typeof options.indexes === 'function'
? await options.indexes()
: options.indexes;
for (const locale of options.i18n.languages) {
const api = createSearchAPI(type, {
...options,
language: locale,
indexes: indexes.filter((index) => index.locale === locale),
});
map.set(locale, api);
}
async function init(): Promise<void> {
if (options.i18n.languages.length === 0) {
return;
}
const handler = map.get(
searchOptions?.locale ?? options.i18n.defaultLanguage,
);
const indexes =
typeof options.indexes === 'function'
? await options.indexes()
: options.indexes;
if (handler) return handler.search(query, searchOptions);
return [];
for (const locale of options.i18n.languages) {
const localeIndexes = indexes.filter((index) => index.locale === locale);
if (type === 'simple') {
map.set(
locale,
initSimpleSearch({
...options,
language: locale,
indexes: localeIndexes as SimpleOptions['indexes'],
}),
);
continue;
}
map.set(
locale,
initAdvancedSearch({
...options,
language: locale,
indexes: localeIndexes as AdvancedOptions['indexes'],
}),
);
}
}
return createEndpoint({
search: async (query, searchOptions) => {
if (map.size === 0) await init();
const handler = map.get(
searchOptions?.locale ?? options.i18n.defaultLanguage,
);
if (handler) return handler.search(query, searchOptions);
return [];
},
});
}
+8 -6
View File
@@ -41,13 +41,15 @@ export function createI18nSearchAPI<T extends 'simple' | 'advanced'>(
);
}
return createEndpoint(async (query, searchOptions) => {
if (searchOptions?.locale) {
const handler = map.get(searchOptions.locale);
return createEndpoint({
search: async (query, searchOptions) => {
if (searchOptions?.locale) {
const handler = map.get(searchOptions.locale);
if (handler) return handler.search(query, searchOptions);
}
if (handler) return handler.search(query, searchOptions);
}
return [];
return [];
},
});
}
+78 -64
View File
@@ -1,11 +1,18 @@
import { Document } from 'flexsearch';
import { type NextRequest, type NextResponse } from 'next/server';
import { type NextRequest } from 'next/server';
import type { StructuredData } from '@/mdx-plugins/remark-structure';
import type { SortedResult } from '@/server/types';
import { createEndpoint } from '@/search/create-endpoint';
export interface SearchAPI {
GET: (request: NextRequest) => Promise<NextResponse<SortedResult[]>>;
export interface SearchServer {
search: (
query: string,
options?: { locale?: string; tag?: string },
) => Promise<SortedResult[]>;
}
export interface SearchAPI extends SearchServer {
GET: (request: NextRequest) => Promise<Response>;
search: (
query: string,
@@ -40,10 +47,10 @@ export function createSearchAPI<T extends 'simple' | 'advanced'>(
options: T extends 'simple' ? SimpleOptions : AdvancedOptions,
): SearchAPI {
if (type === 'simple') {
return initSearchAPI(options as SimpleOptions);
return createEndpoint(initSimpleSearch(options as SimpleOptions));
}
return initSearchAPIAdvanced(options as AdvancedOptions);
return createEndpoint(initAdvancedSearch(options as AdvancedOptions));
}
export interface Index {
@@ -54,7 +61,10 @@ export interface Index {
keywords?: string;
}
export function initSearchAPI({ indexes, language }: SimpleOptions): SearchAPI {
export function initSimpleSearch({
indexes,
language,
}: SimpleOptions): SearchServer {
const store = ['title', 'url'];
async function getDocument(): Promise<Document<Index, string[]>> {
@@ -110,21 +120,23 @@ export function initSearchAPI({ indexes, language }: SimpleOptions): SearchAPI {
}
const doc = getDocument();
return createEndpoint(async (query) => {
const results = (await doc).search(query, 5, {
enrich: true,
suggest: true,
});
return {
search: async (query) => {
const results = (await doc).search(query, 5, {
enrich: true,
suggest: true,
});
if (results.length === 0) return [];
if (results.length === 0) return [];
return results[0].result.map<SortedResult>((page) => ({
type: 'page',
content: page.doc.title,
id: page.doc.url,
url: page.doc.url,
}));
});
return results[0].result.map<SortedResult>((page) => ({
type: 'page',
content: page.doc.title,
id: page.doc.url,
url: page.doc.url,
}));
},
};
}
export interface AdvancedIndex {
@@ -156,11 +168,11 @@ interface InternalIndex {
keywords?: string;
}
export function initSearchAPIAdvanced({
export function initAdvancedSearch({
indexes,
language,
tag = false,
}: AdvancedOptions): SearchAPI {
}: AdvancedOptions): SearchServer {
const store = ['id', 'url', 'content', 'page_id', 'type', 'keywords'];
async function getDocument(): Promise<Document<InternalIndex, string[]>> {
@@ -241,58 +253,60 @@ export function initSearchAPIAdvanced({
const doc = getDocument();
return createEndpoint(async (query, options) => {
const index = await doc;
const results = index.search(query, 5, {
enrich: true,
tag: options?.tag,
limit: 6,
});
return {
search: async (query, options) => {
const index = await doc;
const results = index.search(query, 5, {
enrich: true,
tag: options?.tag,
limit: 6,
});
const map = new Map<string, SortedResult[]>();
const map = new Map<string, SortedResult[]>();
for (const item of results[0]?.result ?? []) {
if (item.doc.type === 'page') {
if (!map.has(item.doc.id)) {
map.set(item.doc.id, []);
for (const item of results[0]?.result ?? []) {
if (item.doc.type === 'page') {
if (!map.has(item.doc.id)) {
map.set(item.doc.id, []);
}
continue;
}
continue;
const list = map.get(item.doc.page_id) ?? [];
list.push({
id: item.doc.id,
content: item.doc.content,
type: item.doc.type,
url: item.doc.url,
});
map.set(item.doc.page_id, list);
}
const list = map.get(item.doc.page_id) ?? [];
const sortedResult: SortedResult[] = [];
for (const [id, items] of map.entries()) {
const page = (
index as unknown as {
get: (id: string) => InternalIndex | null;
}
).get(id);
list.push({
id: item.doc.id,
content: item.doc.content,
type: item.doc.type,
url: item.doc.url,
});
if (!page) continue;
map.set(item.doc.page_id, list);
}
sortedResult.push({
id: page.id,
content: page.content,
type: 'page',
url: page.url,
});
sortedResult.push(...items);
}
const sortedResult: SortedResult[] = [];
for (const [id, items] of map.entries()) {
const page = (
index as unknown as {
get: (id: string) => InternalIndex | null;
}
).get(id);
if (!page) continue;
sortedResult.push({
id: page.id,
content: page.content,
type: 'page',
url: page.url,
});
sortedResult.push(...items);
}
return sortedResult;
});
return sortedResult;
},
};
}
// TODO: Use new i18n API (major)
@@ -47,7 +47,7 @@ export function useAnchorObserver(watch: string[], single: boolean): string[] {
setActiveAnchor((active) => {
const last = active.at(-1);
return last ? watch.slice(watch.indexOf(last)) : active;
return last ? watch.slice(watch.indexOf(last)) : watch.slice(-1);
});
}
}