Core: Support flexsearch server & client

This commit is contained in:
Fuma Nama
2026-03-07 23:55:29 +08:00
parent 3fa6eaf4a2
commit ae3e7423db
17 changed files with 600 additions and 444 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'fumadocs-core': patch
---
Support flexsearch server & client
+6
View File
@@ -154,6 +154,7 @@
"@oramacloud/client": "^2.1.4",
"@tanstack/react-router": "1.166.2",
"@types/estree-jsx": "^1.0.5",
"@types/flexsearch": "^0.7.42",
"@types/hast": "^3.0.4",
"@types/mdast": "^4.0.4",
"@types/negotiator": "^0.6.4",
@@ -162,6 +163,7 @@
"@types/react-dom": "^19.2.3",
"algoliasearch": "5.49.1",
"eslint-config-custom": "workspace:*",
"flexsearch": "^0.8.212",
"lucide-react": "^0.577.0",
"next": "16.1.6",
"react-router": "^7.13.1",
@@ -185,6 +187,7 @@
"@types/mdast": "*",
"@types/react": "*",
"algoliasearch": "5.x.x",
"flexsearch": "*",
"lucide-react": "*",
"next": "16.x.x",
"react": "^19.2.0",
@@ -194,6 +197,9 @@
"zod": "4.x.x"
},
"peerDependenciesMeta": {
"flexsearch": {
"optional": true
},
"@mdx-js/mdx": {
"optional": true
},
+2 -2
View File
@@ -2,7 +2,7 @@ import { type DependencyList, useRef, useState } from 'react';
import { useDebounce } from '@/utils/use-debounce';
import { type FetchOptions } from '@/search/client/fetch';
import { useOnChange } from '@/utils/use-on-change';
import { type StaticOptions } from '@/search/client/static';
import { type StaticOptions } from '@/search/client/orama-static';
import { type AlgoliaOptions } from '@/search/client/algolia';
import { type OramaCloudOptions } from '@/search/client/orama-cloud';
import { type OramaCloudLegacyOptions } from '@/search/client/orama-cloud-legacy';
@@ -132,7 +132,7 @@ export function useDocsSearch(
return search(debouncedValue, client);
}
case 'static': {
const { search } = await import('./client/static');
const { search } = await import('./client/orama-static');
return search(debouncedValue, client);
}
default:
+125
View File
@@ -0,0 +1,125 @@
import type { SearchAPI } from './server';
import Search, { type DocumentData } from 'flexsearch';
import { createEndpoint } from './server/endpoint';
import { buildBreadcrumbsDefault, buildIndexDefault, type SharedIndex } from './server/build-index';
import { buildDocuments, SharedDocument } from './server/build-doc';
import type { SortedResult } from '.';
import type { LoaderConfig, LoaderOutput, Page } from '@/source';
import type { Awaitable } from '@/types';
export type Index = SharedIndex;
type Doc = SharedDocument & DocumentData;
export interface Options {
indexes: Index[] | (() => Awaitable<Index[]>);
}
export function flexsearch(options: Options): SearchAPI {
function initIndex(indexes: Index[]) {
const index = new Search.Document<Doc>({
tokenize: 'full',
document: {
id: 'id',
index: ['content'],
store: true,
},
});
for (const doc of buildDocuments(indexes)) {
index.add(doc.id, doc as Doc);
}
return index;
}
const indexPromise =
typeof options.indexes === 'function'
? Promise.resolve(options.indexes()).then(initIndex)
: initIndex(options.indexes);
return createEndpoint({
async export() {
const map = new Map<string, unknown>();
(await indexPromise).export((key, data) => {
map.set(key, data);
});
return map;
},
async search(query) {
const index = await indexPromise;
const arr = await index.searchAsync(query, {
index: 'content',
});
const out: SortedResult[] = [];
if (arr.length === 0) return out;
const results = arr[0].result;
// page id -> heading/content item
const grouped = new Map<string, Doc[]>();
for (const id of results) {
const doc = index.get(id);
if (!doc) continue;
let list = grouped.get(doc.page_id);
if (!list) {
list = [];
grouped.set(doc.page_id, list);
}
if (doc.type !== 'page') {
list.push(doc);
}
}
for (const [page_id, items] of grouped) {
const page = index.get(page_id);
if (!page) continue;
out.push({
id: page_id,
type: 'page',
content: page.content,
breadcrumbs: page.breadcrumbs,
url: page.url,
});
for (const item of items) {
out.push({
id: item.id,
content: item.content,
breadcrumbs: item.breadcrumbs,
type: item.type,
url: item.url,
});
}
}
return out;
},
});
}
export interface FromSourceOptions<C extends LoaderConfig> {
buildIndex?: (page: Page<C['source']['pageData']>) => Awaitable<Index>;
}
export function flexsearchFromSource<C extends LoaderConfig>(
loader: LoaderOutput<C>,
options: FromSourceOptions<NoInfer<C>> = {},
) {
const { buildIndex = buildIndexDefault } = options;
return flexsearch({
indexes() {
return Promise.all(
loader.getPages().map(async (page) => {
const index = await buildIndex(page);
if (index.breadcrumbs) return index;
return { ...index, breadcrumbs: buildBreadcrumbsDefault(loader, page) };
}),
);
},
});
}
+1 -1
View File
@@ -3,7 +3,7 @@ import type Mixedbread from '@mixedbread/sdk';
import type { StoreSearchParams, StoreSearchResponse } from '@mixedbread/sdk/resources/stores';
import removeMd from 'remove-markdown';
import Slugger from 'github-slugger';
import { createEndpoint } from '@/search/orama/create-endpoint';
import { createEndpoint } from '@/search/server/endpoint';
import type { SearchAPI } from '@/search/server';
export interface SearchMetadata {
+2 -53
View File
@@ -6,6 +6,7 @@ import {
type TypedDocument,
} from '@orama/orama';
import { type AdvancedOptions, type SimpleOptions } from '@/search/server';
import { buildDocuments } from '../server/build-doc';
export type SimpleDocument = TypedDocument<Orama<typeof simpleSchema>>;
export const simpleSchema = {
@@ -45,59 +46,7 @@ export async function createDB({
},
}) as Orama<typeof advancedSchema>;
const mapTo: PartialSchemaDeep<AdvancedDocument>[] = [];
items.forEach((page) => {
const pageTag = page.tag ?? [];
const tags = Array.isArray(pageTag) ? pageTag : [pageTag];
const data = page.structuredData;
let id = 0;
mapTo.push({
id: page.id,
page_id: page.id,
type: 'page',
content: page.title,
breadcrumbs: page.breadcrumbs,
tags,
url: page.url,
});
const nextId = () => `${page.id}-${id++}`;
if (page.description) {
mapTo.push({
id: nextId(),
page_id: page.id,
tags,
type: 'text',
url: page.url,
content: page.description,
});
}
for (const heading of data.headings) {
mapTo.push({
id: nextId(),
page_id: page.id,
type: 'heading',
tags,
url: `${page.url}#${heading.id}`,
content: heading.content,
});
}
for (const content of data.contents) {
mapTo.push({
id: nextId(),
page_id: page.id,
tags,
type: 'text',
url: content.heading ? `${page.url}#${content.heading}` : page.url,
content: content.content,
});
}
});
const mapTo: PartialSchemaDeep<AdvancedDocument>[] = buildDocuments(items);
await insertMultiple(db, mapTo);
return db;
}
@@ -2,70 +2,15 @@ import {
type AdvancedIndex,
type AdvancedOptions,
createI18nSearchAPI,
createSearchAPI,
type SearchAPI,
} from '@/search/server';
initAdvancedSearch,
} from './create-server';
import type { LoaderConfig, LoaderOutput, Page } from '@/source';
import { type StructuredData } from '@/mdx-plugins';
import { basename, extname } from '@/source/path';
import type { I18nConfig } from '@/i18n';
import type { Language } from '@orama/orama';
import { findPath } from '@/page-tree/utils';
import type { Awaitable } from '@/types';
async function buildIndexDefault(page: Page): Promise<AdvancedIndex> {
let structuredData: StructuredData | undefined;
if ('structuredData' in page.data) {
structuredData =
typeof page.data.structuredData === 'function'
? ((await page.data.structuredData()) as StructuredData)
: (page.data.structuredData as StructuredData);
} else if ('load' in page.data && typeof page.data.load === 'function') {
structuredData = (await page.data.load()).structuredData;
}
if (!structuredData)
throw new Error(
'Cannot find structured data from page, please define the page to index function.',
);
return {
title: page.data.title ?? basename(page.path, extname(page.path)),
description: page.data.description,
url: page.url,
id: page.url,
structuredData,
};
}
function isBreadcrumbItem(item: unknown): item is string {
return typeof item === 'string' && item.length > 0;
}
export function buildBreadcrumbsDefault<C extends LoaderConfig>(
source: LoaderOutput<C>,
page: Page,
): string[] | undefined {
const pageTree = source.getPageTree(page.locale);
const path = findPath(pageTree.children, (node) => node.type === 'page' && node.url === page.url);
if (path) {
const breadcrumbs: string[] = [];
path.pop();
if (isBreadcrumbItem(pageTree.name)) {
breadcrumbs.push(pageTree.name);
}
for (const segment of path) {
if (!isBreadcrumbItem(segment.name)) continue;
breadcrumbs.push(segment.name);
}
return breadcrumbs;
}
}
import { createEndpoint } from '../server/endpoint';
import type { SearchAPI } from '../server/types';
import { buildIndexDefault, buildBreadcrumbsDefault } from '../server/build-index';
interface Options<C extends LoaderConfig> extends Omit<AdvancedOptions, 'indexes'> {
localeMap?: {
@@ -109,17 +54,21 @@ export function createFromSource<C extends LoaderConfig>(
});
}
return createSearchAPI('advanced', {
...options,
async indexes() {
const indexes = source.getPages().map(async (page) => {
const index = await buildIndex(page);
index.breadcrumbs ??= buildBreadcrumbsDefault(source, page);
return createEndpoint(
initAdvancedSearch({
...options,
async indexes() {
const indexes = source.getPages().map(async (page) => {
const index = await buildIndex(page);
index.breadcrumbs ??= buildBreadcrumbsDefault(source, page);
return index;
});
return index;
});
return Promise.all(indexes);
},
});
return Promise.all(indexes);
},
}),
);
}
export { buildBreadcrumbsDefault };
@@ -1,139 +0,0 @@
import { type Language, type Tokenizer } from '@orama/orama';
import {
type AdvancedIndex,
type AdvancedOptions,
type Dynamic,
type Index,
initAdvancedSearch,
initSimpleSearch,
type SearchAPI,
type SearchServer,
type SimpleOptions,
} from '@/search/server';
import { createEndpoint } from '@/search/orama/create-endpoint';
import { type I18nConfig } from '@/i18n';
import { STEMMERS } from '@/search/orama/_stemmers';
type I18nOptions<O extends SimpleOptions | AdvancedOptions, Idx> = Omit<
O,
'language' | 'indexes'
> & {
i18n: I18nConfig;
/**
* Map locale name from i18n config to Orama compatible `language` or options
*/
localeMap?: Record<string, Language | Partial<O> | undefined>;
indexes: WithLocale<Idx>[] | Dynamic<WithLocale<Idx>>;
};
type I18nSimpleOptions = I18nOptions<SimpleOptions, Index>;
type I18nAdvancedOptions = I18nOptions<AdvancedOptions, AdvancedIndex>;
type WithLocale<T> = T & {
locale: string;
};
async function getTokenizer(
locale: string,
): Promise<{ language: string } | { tokenizer: Tokenizer }> {
return {
language: Object.keys(STEMMERS).find((lang) => STEMMERS[lang] === locale) ?? locale,
};
}
async function initSimple(options: I18nSimpleOptions): Promise<Map<string, SearchServer>> {
const map = new Map<string, SearchServer>();
if (options.i18n.languages.length === 0) {
return map;
}
const indexes = typeof options.indexes === 'function' ? await options.indexes() : options.indexes;
for (const locale of options.i18n.languages) {
const localeIndexes = indexes.filter((index) => index.locale === locale);
const mapped = options.localeMap?.[locale] ?? (await getTokenizer(locale));
map.set(
locale,
typeof mapped === 'object'
? initSimpleSearch({
...options,
...mapped,
indexes: localeIndexes,
})
: initSimpleSearch({
...options,
language: mapped,
indexes: localeIndexes,
}),
);
}
return map;
}
async function initAdvanced(options: I18nAdvancedOptions): Promise<Map<string, SearchServer>> {
const map = new Map<string, SearchServer>();
if (options.i18n.languages.length === 0) {
return map;
}
const indexes = typeof options.indexes === 'function' ? await options.indexes() : options.indexes;
for (const locale of options.i18n.languages) {
const localeIndexes = indexes.filter((index) => index.locale === locale);
const mapped = options.localeMap?.[locale] ?? (await getTokenizer(locale));
map.set(
locale,
typeof mapped === 'object'
? initAdvancedSearch({
...options,
indexes: localeIndexes,
...mapped,
})
: initAdvancedSearch({
...options,
language: mapped,
indexes: localeIndexes,
}),
);
}
return map;
}
export function createI18nSearchAPI<T extends 'simple' | 'advanced'>(
type: T,
options: T extends 'simple' ? I18nSimpleOptions : I18nAdvancedOptions,
): SearchAPI {
const get =
type === 'simple'
? initSimple(options as I18nSimpleOptions)
: initAdvanced(options as I18nAdvancedOptions);
return createEndpoint({
async export() {
const map = await get;
const entries = Array.from(map.entries()).map(async ([k, v]) => [
k,
await (v as SearchServer).export(),
]);
return {
type: 'i18n',
data: Object.fromEntries(await Promise.all(entries)),
};
},
async search(query, searchOptions) {
const map = await get;
const locale = searchOptions?.locale ?? options.i18n.defaultLanguage;
const handler = map.get(locale);
if (handler) return handler.search(query, searchOptions);
return [];
},
});
}
@@ -0,0 +1,239 @@
import {
create,
Orama,
RawData,
save,
SearchParams,
type Language,
type Tokenizer,
} from '@orama/orama';
import type { SearchAPI, SearchServer } from '@/search/server';
import type { I18nConfig } from '@/i18n';
import { STEMMERS } from '@/search/orama/_stemmers';
import { createEndpoint } from '../server/endpoint';
import {
AdvancedDocument,
advancedSchema,
createDB,
createDBSimple,
SimpleDocument,
simpleSchema,
} from './create-db';
import { searchSimple } from './search/simple';
import { searchAdvanced } from './search/advanced';
import type { SharedIndex } from '../server/build-index';
type OramaInput = Parameters<typeof create>[0];
type SharedOptions = Pick<OramaInput, 'sort' | 'components' | 'plugins'> & {
language?: string;
tokenizer?: Required<OramaInput>['components']['tokenizer'];
};
/**
* Resolve indexes dynamically
*/
export type Dynamic<T> = () => T[] | Promise<T[]>;
export interface SimpleOptions extends SharedOptions {
indexes: Index[] | Dynamic<Index>;
/**
* Customise search options on server
*/
search?: Partial<SearchParams<Orama<typeof simpleSchema>, SimpleDocument>>;
}
export interface AdvancedOptions extends SharedOptions {
indexes: AdvancedIndex[] | Dynamic<AdvancedIndex>;
/**
* Customise search options on server
*/
search?: Partial<SearchParams<Orama<typeof advancedSchema>, AdvancedDocument>>;
}
export interface Index {
title: string;
description?: string;
breadcrumbs?: string[];
content: string;
url: string;
keywords?: string;
}
export type ExportedData =
| (RawData & { type: 'simple' | 'advanced' })
| {
type: 'i18n';
data: Record<string, RawData & { type: 'simple' | 'advanced' }>;
};
export function initSimpleSearch(options: SimpleOptions): SearchServer {
const doc = createDBSimple(options);
return {
async export() {
return {
type: 'simple',
...save(await doc),
};
},
async search(query) {
const db = await doc;
return searchSimple(db, query, options.search);
},
};
}
export type AdvancedIndex = SharedIndex;
export function initAdvancedSearch(options: AdvancedOptions): SearchServer {
const get = createDB(options);
return {
async export() {
return {
type: 'advanced',
...save(await get),
};
},
async search(query, searchOptions) {
const db = await get;
const mode = searchOptions?.mode;
return searchAdvanced(db, query, searchOptions?.tag, {
...options.search,
mode: mode === 'vector' ? 'vector' : 'fulltext',
}).catch((err) => {
if (mode === 'vector') {
throw new Error(
'failed to search, make sure you have installed `@orama/plugin-embeddings` according to their docs.',
{
cause: err,
},
);
}
throw err;
});
},
};
}
export function createSearchAPI(type: 'simple', options: SimpleOptions): SearchAPI;
export function createSearchAPI(type: 'advanced', options: AdvancedOptions): SearchAPI;
export function createSearchAPI(
...args: ['simple', SimpleOptions] | ['advanced', AdvancedOptions]
): SearchAPI {
if (args[0] === 'simple') {
return createEndpoint(initSimpleSearch(args[1]));
}
return createEndpoint(initAdvancedSearch(args[1]));
}
type I18nOptions<O extends SimpleOptions | AdvancedOptions, Idx> = Omit<
O,
'language' | 'indexes'
> & {
i18n: I18nConfig;
/**
* Map locale name from i18n config to Orama compatible `language` or options
*/
localeMap?: Record<string, Language | Partial<O> | undefined>;
indexes: WithLocale<Idx>[] | Dynamic<WithLocale<Idx>>;
};
type I18nSimpleOptions = I18nOptions<SimpleOptions, Index>;
type I18nAdvancedOptions = I18nOptions<AdvancedOptions, AdvancedIndex>;
type WithLocale<T> = T & {
locale: string;
};
function getTokenizer(locale: string): { language: string } | { tokenizer: Tokenizer } {
return {
language: Object.keys(STEMMERS).find((lang) => STEMMERS[lang] === locale) ?? locale,
};
}
export function createI18nSearchAPI(type: 'simple', options: I18nSimpleOptions): SearchAPI;
export function createI18nSearchAPI(type: 'advanced', options: I18nAdvancedOptions): SearchAPI;
export function createI18nSearchAPI(
...[type, options]: ['simple', I18nSimpleOptions] | ['advanced', I18nAdvancedOptions]
): SearchAPI {
async function initSearchServers() {
const map = new Map<string, SearchServer>();
if (options.i18n.languages.length === 0) {
return map;
}
const indexes =
typeof options.indexes === 'function' ? await options.indexes() : options.indexes;
for (const locale of options.i18n.languages) {
const localeIndexes = indexes.filter((index) => index.locale === locale);
const mapped = options.localeMap?.[locale] ?? getTokenizer(locale);
if (type === 'simple') {
map.set(
locale,
typeof mapped === 'object'
? initSimpleSearch({
...options,
...mapped,
indexes: localeIndexes,
} as SimpleOptions)
: initSimpleSearch({
...options,
language: mapped,
indexes: localeIndexes,
} as SimpleOptions),
);
} else {
map.set(
locale,
typeof mapped === 'object'
? initAdvancedSearch({
...options,
indexes: localeIndexes,
...mapped,
} as AdvancedOptions)
: initAdvancedSearch({
...options,
language: mapped,
indexes: localeIndexes,
} as AdvancedOptions),
);
}
}
return map;
}
const get = initSearchServers();
return createEndpoint({
async export() {
const map = await get;
const entries = Array.from(map.entries()).map(async ([k, v]) => [k, await v.export()]);
return {
type: 'i18n',
data: Object.fromEntries(await Promise.all(entries)),
};
},
async search(query, searchOptions) {
const map = await get;
const locale = searchOptions?.locale ?? options.i18n.defaultLanguage;
const handler = map.get(locale);
if (handler) return handler.search(query, searchOptions);
return [];
},
});
}
+2 -172
View File
@@ -1,173 +1,3 @@
import { create, type Orama, type RawData, save, type SearchParams } from '@orama/orama';
import type { StructuredData } from '@/mdx-plugins/remark-structure';
import { createEndpoint } from '@/search/orama/create-endpoint';
import {
type AdvancedDocument,
type advancedSchema,
createDB,
createDBSimple,
type SimpleDocument,
simpleSchema,
} from '@/search/orama/create-db';
import { searchSimple } from '@/search/orama/search/simple';
import { searchAdvanced } from '@/search/orama/search/advanced';
import type { SortedResult } from '@/search';
type SearchType = 'simple' | 'advanced';
export type ExportedData =
| (RawData & { type: SearchType })
| {
type: 'i18n';
data: Record<string, RawData & { type: SearchType }>;
};
export interface SearchServer {
search: (
query: string,
options?: {
locale?: string;
tag?: string | string[];
mode?: 'vector' | 'full';
},
) => Promise<SortedResult[]>;
/**
* Export the database
*
* You can reference the exported database to implement client-side search
*/
export: () => Promise<ExportedData>;
}
export interface SearchAPI extends SearchServer {
GET: (request: Request) => Promise<Response>;
/**
* `GET` route handler that exports search indexes for static search.
*/
staticGET: () => Promise<Response>;
}
/**
* Resolve indexes dynamically
*/
export type Dynamic<T> = () => T[] | Promise<T[]>;
type OramaInput = Parameters<typeof create>[0];
type SharedOptions = Pick<OramaInput, 'sort' | 'components' | 'plugins'> & {
language?: string;
tokenizer?: Required<OramaInput>['components']['tokenizer'];
};
export interface SimpleOptions extends SharedOptions {
indexes: Index[] | Dynamic<Index>;
/**
* Customise search options on server
*/
search?: Partial<SearchParams<Orama<typeof simpleSchema>, SimpleDocument>>;
}
export interface AdvancedOptions extends SharedOptions {
indexes: AdvancedIndex[] | Dynamic<AdvancedIndex>;
/**
* Customise search options on server
*/
search?: Partial<SearchParams<Orama<typeof advancedSchema>, AdvancedDocument>>;
}
export function createSearchAPI<T extends SearchType>(
type: T,
options: T extends 'simple' ? SimpleOptions : AdvancedOptions,
): SearchAPI {
if (type === 'simple') {
return createEndpoint(initSimpleSearch(options as SimpleOptions));
}
return createEndpoint(initAdvancedSearch(options as AdvancedOptions));
}
export interface Index {
title: string;
description?: string;
breadcrumbs?: string[];
content: string;
url: string;
keywords?: string;
}
export function initSimpleSearch(options: SimpleOptions): SearchServer {
const doc = createDBSimple(options);
return {
async export() {
return {
type: 'simple',
...save(await doc),
};
},
async search(query) {
const db = await doc;
return searchSimple(db, query, options.search);
},
};
}
export interface AdvancedIndex {
id: string;
title: string;
description?: string;
breadcrumbs?: string[];
/**
* Required if tag filter is enabled
*/
tag?: string | string[];
/**
* preprocess mdx content with `structure`
*/
structuredData: StructuredData;
url: string;
}
export function initAdvancedSearch(options: AdvancedOptions): SearchServer {
const get = createDB(options);
return {
async export() {
return {
type: 'advanced',
...save(await get),
};
},
async search(query, searchOptions) {
const db = await get;
const mode = searchOptions?.mode;
return searchAdvanced(db, query, searchOptions?.tag, {
...options.search,
mode: mode === 'vector' ? 'vector' : 'fulltext',
}).catch((err) => {
if (mode === 'vector') {
throw new Error(
'failed to search, make sure you have installed `@orama/plugin-embeddings` according to their docs.',
{
cause: err,
},
);
}
throw err;
});
},
};
}
export { createFromSource } from './orama/create-from-source';
export { createI18nSearchAPI } from './orama/create-i18n';
export * from './index';
export * from './orama/create-server';
export * from './server/types';
@@ -0,0 +1,69 @@
import type { SharedIndex } from './build-index';
export interface SharedDocument {
id: string;
content: string;
page_id: string;
type: 'page' | 'heading' | 'text';
breadcrumbs?: string[];
tags: string[];
url: string;
}
export function buildDocuments(indexes: SharedIndex[]) {
const docs: SharedDocument[] = [];
for (const page of indexes) {
const pageTag = page.tag ?? [];
const tags = Array.isArray(pageTag) ? pageTag : [pageTag];
const data = page.structuredData;
let id = 0;
docs.push({
id: page.id,
page_id: page.id,
type: 'page',
content: page.title,
breadcrumbs: page.breadcrumbs,
tags,
url: page.url,
});
const nextId = () => `${page.id}-${id++}`;
if (page.description) {
docs.push({
id: nextId(),
page_id: page.id,
tags,
type: 'text',
url: page.url,
content: page.description,
});
}
for (const heading of data.headings) {
docs.push({
id: nextId(),
page_id: page.id,
type: 'heading',
tags,
url: `${page.url}#${heading.id}`,
content: heading.content,
});
}
for (const content of data.contents) {
docs.push({
id: nextId(),
page_id: page.id,
tags,
type: 'text',
url: content.heading ? `${page.url}#${content.heading}` : page.url,
content: content.content,
});
}
}
return docs;
}
@@ -0,0 +1,76 @@
import type { LoaderConfig, LoaderOutput, Page } from '@/source';
import type { StructuredData } from '@/mdx-plugins/remark-structure';
import { basename, extname } from '@/source/path';
import { findPath } from '@/page-tree/utils';
export interface SharedIndex {
id: string;
title: string;
description?: string;
breadcrumbs?: string[];
/**
* Required if tag filter is enabled
*/
tag?: string | string[];
/**
* preprocess mdx content with `structure`
*/
structuredData: StructuredData;
url: string;
}
export async function buildIndexDefault(page: Page): Promise<SharedIndex> {
let structuredData: StructuredData | undefined;
if ('structuredData' in page.data) {
structuredData =
typeof page.data.structuredData === 'function'
? ((await page.data.structuredData()) as StructuredData)
: (page.data.structuredData as StructuredData);
} else if ('load' in page.data && typeof page.data.load === 'function') {
structuredData = (await page.data.load()).structuredData;
}
if (!structuredData)
throw new Error(
'Cannot find structured data from page, please define the page to index function.',
);
return {
title: page.data.title ?? basename(page.path, extname(page.path)),
description: page.data.description,
url: page.url,
id: page.url,
structuredData,
};
}
function isBreadcrumbItem(item: unknown): item is string {
return typeof item === 'string' && item.length > 0;
}
export function buildBreadcrumbsDefault<C extends LoaderConfig>(
source: LoaderOutput<C>,
page: Page<C['source']['pageData']>,
): string[] | undefined {
const pageTree = source.getPageTree(page.locale);
const path = findPath(pageTree.children, (node) => node.type === 'page' && node.url === page.url);
if (path) {
const breadcrumbs: string[] = [];
path.pop();
if (isBreadcrumbItem(pageTree.name)) {
breadcrumbs.push(pageTree.name);
}
for (const segment of path) {
if (!isBreadcrumbItem(segment.name)) continue;
breadcrumbs.push(segment.name);
}
return breadcrumbs;
}
}
@@ -1,4 +1,4 @@
import { type SearchAPI, type SearchServer } from '@/search/server';
import type { SearchAPI, SearchServer } from './types';
export function createEndpoint(server: SearchServer): SearchAPI {
const { search } = server;
+28
View File
@@ -0,0 +1,28 @@
import type { SortedResult } from '..';
export interface SearchServer {
search: (
query: string,
options?: {
locale?: string;
tag?: string | string[];
mode?: 'vector' | 'full';
},
) => Promise<SortedResult[]>;
/**
* Export the database
*
* You can reference the exported database to implement client-side search
*/
export: () => Promise<unknown>;
}
export interface SearchAPI extends SearchServer {
GET: (request: Request) => Promise<Response>;
/**
* `GET` route handler that exports search indexes for static search.
*/
staticGET: () => Promise<Response>;
}
+1 -1
View File
@@ -15,7 +15,7 @@ export default defineConfig({
'src/source/{index,schema}.ts',
'src/source/client/*.{ts,tsx}',
'src/source/plugins/{lucide-icons,slugs,status-badges}.{ts,tsx}',
'src/search/{index,client,server,algolia,orama-cloud,orama-cloud-legacy,mixedbread}.ts',
'src/search/{index,client,server,algolia,orama-cloud,orama-cloud-legacy,mixedbread,flexsearch}.ts',
'src/utils/use-on-change.ts',
'src/utils/use-media-query.ts',
'src/i18n/*.ts',
+23 -4
View File
@@ -1079,7 +1079,7 @@ importers:
version: 19.2.3(@types/[email protected])
'@vitejs/plugin-rsc':
specifier: ^0.5.21
version: 0.5.21([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
version: 0.5.21([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
react-router-devtools:
specifier: ^6.2.0
version: 6.2.0(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected])([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected]))([email protected])([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
@@ -1902,6 +1902,9 @@ importers:
'@types/estree-jsx':
specifier: ^1.0.5
version: 1.0.5
'@types/flexsearch':
specifier: ^0.7.42
version: 0.7.42
'@types/hast':
specifier: ^3.0.4
version: 3.0.4
@@ -1926,6 +1929,9 @@ importers:
eslint-config-custom:
specifier: workspace:*
version: link:../eslint-config-custom
flexsearch:
specifier: ^0.8.212
version: 0.8.212
lucide-react:
specifier: ^0.577.0
version: 0.577.0([email protected])
@@ -6878,6 +6884,10 @@ packages:
'@types/[email protected]':
resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==}
'@types/[email protected]':
resolution: {integrity: sha512-FdKaw3dPgcCvqU9w8SGgqx8PrxFF2EKySRhEj1nc45HNe/2cBLff3pF52C67hzeUbNhq49t7XRIIibBd41vcIg==}
deprecated: This is a stub types definition. flexsearch provides its own type definitions, so you do not need this installed.
'@types/[email protected]':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
@@ -8760,6 +8770,9 @@ packages:
resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==}
engines: {node: '>=8'}
[email protected]:
resolution: {integrity: sha512-wSyJr1GUWoOOIISRu+X2IXiOcVfg9qqBRyCPRUdLMIGJqPzMo+jMRlvE83t14v1j0dRMEaBbER/adQjp6Du2pw==}
[email protected]:
resolution: {integrity: sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==}
engines: {node: '>=12'}
@@ -15206,7 +15219,7 @@ snapshots:
vite-node: 3.2.4(@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected])
optionalDependencies:
'@react-router/serve': 7.13.1([email protected]([email protected]([email protected]))([email protected]))([email protected])
'@vitejs/plugin-rsc': 0.5.21([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
'@vitejs/plugin-rsc': 0.5.21([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
react-server-dom-webpack: 19.2.4([email protected]([email protected]))([email protected])([email protected])
typescript: 5.9.3
transitivePeerDependencies:
@@ -16573,6 +16586,10 @@ snapshots:
'@types/[email protected]': {}
'@types/[email protected]':
dependencies:
flexsearch: 0.8.212
'@types/[email protected]': {}
'@types/[email protected]': {}
@@ -16793,7 +16810,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@vitejs/[email protected]([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))':
'@vitejs/[email protected]([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))':
dependencies:
'@rolldown/pluginutils': 1.0.0-rc.5
es-module-lexer: 2.0.0
@@ -18890,6 +18907,8 @@ snapshots:
[email protected]: {}
[email protected]: {}
[email protected]:
dependencies:
d3-selection: 3.0.0
@@ -23123,7 +23142,7 @@ snapshots:
dependencies:
'@hono/node-server': 1.19.11([email protected])
'@vitejs/plugin-react': 5.1.4([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
'@vitejs/plugin-rsc': 0.5.21([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected])([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
'@vitejs/plugin-rsc': 0.5.21([email protected]([email protected]))([email protected]([email protected]([email protected]))([email protected]))([email protected])([email protected](@types/[email protected])([email protected])([email protected])([email protected])([email protected])([email protected]))
dotenv: 17.3.1
hono: 4.12.5
magic-string: 0.30.21