mirror of
https://github.com/hanzo-docs/docs.git
synced 2026-10-25 04:20:02 +00:00
Core: Support headless search servers
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'fumadocs-core': patch
|
||||
---
|
||||
|
||||
Support headless search servers
|
||||
@@ -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
|
||||
});
|
||||
```
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 [];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 [];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user