Core & UI: Support Orama Cloud integration

This commit is contained in:
Fuma Nama
2024-10-30 22:03:02 +08:00
parent 593be5c574
commit e248a0f813
19 changed files with 459 additions and 49 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'fumadocs-core': minor
'fumadocs-ui': minor
---
Support Orama Cloud integration
+1 -1
View File
@@ -5,7 +5,7 @@ export const organizationUsers = ['marclave', 'rishi-raj-jain', 'handotdev'];
export const sponsors = [
{
url: '',
url: 'https://orama.com',
label: 'Orama Search',
tier: 'golden',
logo: (
+13 -4
View File
@@ -1,23 +1,32 @@
import { NextResponse } from 'next/server';
import { type DocumentRecord } from 'fumadocs-core/search/algolia';
import { source } from '@/app/source';
import type { StructuredData } from 'fumadocs-core/mdx-plugins';
export const revalidate = false;
export type Static = {
id: string;
structured: StructuredData;
tag: string;
url: string;
title: string;
description?: string;
};
export async function GET(): Promise<Response> {
const pages = source.getPages();
const results = await Promise.all<DocumentRecord>(
const results = await Promise.all(
pages.map(async (page) => {
const { structuredData } = await page.data.load();
return {
_id: page.url,
id: page.url,
structured: structuredData,
tag: page.slugs[0],
url: page.url,
title: page.data.title,
description: page.data.description,
};
} satisfies Static;
}),
);
+8 -13
View File
@@ -1,26 +1,20 @@
'use client';
import algo from 'algoliasearch/lite';
import { OramaClient } from '@oramacloud/client';
import type { SharedProps } from 'fumadocs-ui/components/dialog/search';
import SearchDialog from 'fumadocs-ui/components/dialog/search-algolia';
import SearchDialog from 'fumadocs-ui/components/dialog/search-orama';
import { useMode } from '@/app/layout.client';
const appId = process.env.NEXT_PUBLIC_ALGOLIA_APP_ID;
const apiKey = process.env.NEXT_PUBLIC_ALGOLIA_API_KEY;
const indexName = process.env.NEXT_PUBLIC_ALGOLIA_INDEX;
if (!appId || !apiKey || !indexName) throw new Error('Algolia credentials');
const client = algo(appId, apiKey);
const index = client.initIndex(indexName);
const client = new OramaClient({
endpoint: 'https://cloud.orama.run/v1/indexes/docs-fk97oe',
api_key: 'oPZjdlFbq5BpR54bV5Vj57RYt83Xosk7',
});
export default function CustomSearchDialog(
props: SharedProps,
): React.ReactElement {
return (
<SearchDialog
index={index}
{...props}
defaultTag={useMode() ?? 'ui'}
allowClear
@@ -38,7 +32,8 @@ export default function CustomSearchDialog(
value: 'mdx',
},
]}
showAlgolia
client={client}
showOrama
/>
);
}
+1 -1
View File
@@ -1,5 +1,5 @@
import env from '@next/env';
import { updateSearchIndexes } from './update-index.mjs';
import { updateSearchIndexes } from './update-orama-index.mjs';
import { updateOramaAi } from '@/scripts/update-orama-ai.mjs';
env.loadEnvConfig(process.cwd());
+1 -3
View File
@@ -26,8 +26,6 @@ export async function updateOramaAi(): Promise<void> {
const manager = new CloudManager({ api_key: apiKey });
const indexManager = manager.index(index);
await indexManager.empty();
const files = await fg([
'./content/docs/**/*.mdx',
'!*.model.mdx',
@@ -65,7 +63,7 @@ export async function updateOramaAi(): Promise<void> {
await Promise.all(scan);
console.log(`added ${records.length} records`);
await indexManager.insert(records);
await indexManager.snapshot(records);
await indexManager.deploy();
}
+25
View File
@@ -0,0 +1,25 @@
import { sync } from 'fumadocs-core/search/orama-cloud';
import * as fs from 'node:fs/promises';
import type { Static } from '@/app/static.json/route';
import { CloudManager } from '@oramacloud/client';
export async function updateSearchIndexes(): Promise<void> {
const apiKey = process.env.ORAMA_PRIVATE_API_KEY;
if (!apiKey) {
console.log('no api key for Orama found, skipping');
return;
}
const content = await fs.readFile('./out/static.json');
const records = JSON.parse(content.toString()) as Static[];
const manager = new CloudManager({ api_key: apiKey });
await sync(manager, {
index: 'twr98yz9itca86121ukrqber',
documents: records,
});
console.log(`search updated: ${records.length} records`);
}
+21
View File
@@ -0,0 +1,21 @@
'use client';
import algo from 'algoliasearch/lite';
import type { SharedProps } from 'fumadocs-ui/components/dialog/search';
import SearchDialog from 'fumadocs-ui/components/dialog/search-algolia';
const appId = process.env.NEXT_PUBLIC_ALGOLIA_APP_ID;
const apiKey = process.env.NEXT_PUBLIC_ALGOLIA_API_KEY;
const indexName = process.env.NEXT_PUBLIC_ALGOLIA_INDEX;
if (!appId || !apiKey || !indexName) throw new Error('Algolia credentials');
const client = algo(appId, apiKey);
const index = client.initIndex(indexName);
export default function CustomSearchDialog(
props: SharedProps,
): React.ReactElement {
return <SearchDialog index={index} {...props} showAlgolia />;
}
+1
View File
@@ -19,6 +19,7 @@
"@types/mdx": "^2.0.13",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"algoliasearch": "4.24.0",
"typescript": "^5.6.3"
}
}
+9
View File
@@ -36,6 +36,10 @@
"import": "./dist/search/algolia.js",
"types": "./dist/search/algolia.d.ts"
},
"./search/orama-cloud": {
"import": "./dist/search/orama-cloud.js",
"types": "./dist/search/orama-cloud.d.ts"
},
"./server": {
"import": "./dist/server/index.js",
"types": "./dist/server/index.d.ts"
@@ -94,6 +98,7 @@
"devDependencies": {
"@algolia/client-search": "4.24.0",
"@mdx-js/mdx": "^3.1.0",
"@oramacloud/client": "^1.3.18",
"@types/estree-jsx": "^1.0.5",
"@types/hast": "^3.0.4",
"@types/mdast": "^4.0.3",
@@ -114,11 +119,15 @@
},
"peerDependencies": {
"algoliasearch": "4.24.0",
"@oramacloud/client": "1.x.x",
"next": "14.x.x || 15.x.x",
"react": ">= 18",
"react-dom": ">= 18"
},
"peerDependenciesMeta": {
"@oramacloud/client": {
"optional": true
},
"algoliasearch": {
"optional": true
},
+15 -3
View File
@@ -5,6 +5,7 @@ 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';
import { type OramaCloudOptions } from '@/search/client/orama-cloud';
interface UseDocsSearch {
search: string;
@@ -27,7 +28,10 @@ type Client =
} & StaticOptions)
| ({
type: 'algolia';
} & AlgoliaOptions);
} & AlgoliaOptions)
| ({
type: 'orama-cloud';
} & OramaCloudOptions);
let staticClient: StaticClient | undefined;
@@ -55,8 +59,8 @@ export function useDocsSearch(
const onStart = useRef<() => void>();
const cacheKey = useMemo(() => {
return key ?? JSON.stringify([client, debouncedValue, locale, tag]);
}, [client, debouncedValue, locale, tag, key]);
return key ?? JSON.stringify([client.type, debouncedValue, locale, tag]);
}, [client.type, debouncedValue, locale, tag, key]);
useOnChange(cacheKey, () => {
const cached = cache.get(cacheKey);
@@ -94,6 +98,12 @@ export function useDocsSearch(
return searchDocs(index, debouncedValue, tag, rest);
}
if (client.type === 'orama-cloud') {
const { searchDocs } = await import('./client/orama-cloud');
return searchDocs(debouncedValue, tag, client);
}
const { createStaticClient } = await import('./client/static');
if (!staticClient) staticClient = createStaticClient(client);
@@ -118,3 +128,5 @@ export function useDocsSearch(
return { search, setSearch, query: { isLoading, data: results, error } };
}
export type { OramaCloudOptions, FetchOptions, StaticOptions, AlgoliaOptions };
@@ -0,0 +1,62 @@
import type { SortedResult } from '@/server';
import type { OramaClient, ClientSearchParams } from '@oramacloud/client';
import { removeUndefined } from '@/utils/remove-undefined';
import type { OramaIndex } from '@/search/orama-cloud';
export interface OramaCloudOptions {
client: OramaClient;
params?: ClientSearchParams;
}
export async function searchDocs(
query: string,
tag: string | undefined,
options: OramaCloudOptions,
): Promise<SortedResult[]> {
const { client, params: extraParams = {} } = options;
const params: ClientSearchParams = {
...extraParams,
term: query,
where: removeUndefined({
tag,
...extraParams.where,
}),
groupBy: {
properties: ['page_id'],
maxResult: 7,
...extraParams.groupBy,
},
};
const result = await client.search(params);
if (!result) return [];
const list: SortedResult[] = [];
for (const item of result.groups ?? []) {
let addedHead = false;
for (const hit of item.result) {
const doc = hit.document as OramaIndex;
if (!addedHead) {
list.push({
id: doc.page_id,
type: 'page',
content: doc.title,
url: doc.url,
});
addedHead = true;
}
list.push({
id: doc.id,
content: doc.content,
type: doc.content === doc.section ? 'heading' : 'text',
url: doc.url,
});
}
}
return list;
}
+167
View File
@@ -0,0 +1,167 @@
import type { CloudManager } from '@oramacloud/client';
import type { StructuredData } from '@/mdx-plugins';
export interface SyncOptions {
/**
* Index name to sync
*/
index: string;
documents: OramaDocument[];
/**
* Deploy changes
*
* @defaultValue true
*/
autoDeploy?: boolean;
}
export type I18nSyncOptions = Omit<SyncOptions, 'index' | 'documents'> & {
/**
* Indexes to sync.
*
* Pairs of `locale`-`index`.
**/
indexes: Record<string, string>;
documents: {
locale: string;
items: OramaDocument[];
}[];
};
export interface OramaDocument {
/**
* The ID of document, must be unique
*/
id: string;
title: string;
description?: string;
/**
* URL to the page
*/
url: string;
structured: StructuredData;
/**
* Tag to filter results
*/
tag?: string;
/**
* Data to be added to each section index
*/
extra_data?: object;
}
export interface OramaIndex {
id: string;
title: string;
url: string;
tag?: string;
/**
* The id of page, used for `group by`
*/
page_id: string;
/**
* Heading content
*/
section?: string;
/**
* Heading (anchor) id
*/
section_id?: string;
content: string;
}
export async function sync(
cloudManager: CloudManager,
options: SyncOptions,
): Promise<void> {
const { autoDeploy = true } = options;
const index = cloudManager.index(options.index);
await index.snapshot(options.documents.flatMap(toIndex));
if (autoDeploy) await index.deploy();
}
const a = {
id: 'string',
title: 'string',
url: 'string',
tag: 'string',
page_id: 'string',
section: 'string',
section_id: 'string',
content: 'string',
};
export async function syncI18n(
cloudManager: CloudManager,
options: I18nSyncOptions,
): Promise<void> {
const { autoDeploy = true } = options;
const tasks = options.documents.map(async (document) => {
const index = cloudManager.index(options.indexes[document.locale]);
await index.snapshot(document.items.flatMap(toIndex));
if (autoDeploy) await index.deploy();
});
await Promise.all(tasks);
}
function toIndex(page: OramaDocument): OramaIndex[] {
let id = 0;
const indexes: OramaIndex[] = [];
const scannedHeadings = new Set<string>();
function createIndex(
section: string | undefined,
sectionId: string | undefined,
content: string,
): OramaIndex {
return {
id: `${page.id}-${(id++).toString()}`,
title: page.title,
url: page.url,
page_id: page.id,
tag: page.tag,
section,
section_id: sectionId,
content,
...page.extra_data,
};
}
if (page.description)
indexes.push(createIndex(undefined, undefined, page.description));
page.structured.contents.forEach((p) => {
const heading = p.heading
? page.structured.headings.find((h) => p.heading === h.id)
: null;
const index = createIndex(heading?.content, heading?.id, p.content);
if (heading && !scannedHeadings.has(heading.id)) {
scannedHeadings.add(heading.id);
indexes.push(createIndex(heading.content, heading.id, heading.content));
}
indexes.push(index);
});
return indexes;
}
+1
View File
@@ -18,6 +18,7 @@ export default defineConfig({
'src/search/client.ts',
'src/search/server.ts',
'src/search/algolia.ts',
'src/search/orama-cloud.ts',
'src/utils/use-on-change.ts',
'src/utils/use-shiki.tsx',
'src/i18n/index.ts',
+6 -7
View File
@@ -81,12 +81,10 @@
"tailwind-merge": "^2.5.4"
},
"devDependencies": {
"@algolia/client-search": "4.24.0",
"@fumadocs/cli": "workspace:*",
"@next/eslint-plugin-next": "^15.0.1",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"algoliasearch": "4.24.0",
"eslint-config-custom": "workspace:*",
"lucide-react": "^0.453.0",
"next": "15.0.2",
@@ -99,13 +97,14 @@
"peerDependencies": {
"next": "14.x.x || 15.x.x",
"react": ">= 18",
"react-dom": ">= 18"
},
"optionalDependencies": {
"@algolia/client-search": "^4.0.0",
"algoliasearch": "^4.24.0",
"react-dom": ">= 18",
"tailwindcss": "^3.4.14"
},
"peerDependenciesMeta": {
"tailwindcss": {
"optional": true
}
},
"publishConfig": {
"access": "public"
}
@@ -1,10 +1,11 @@
'use client';
import type { SearchIndex } from 'algoliasearch/lite';
import { useDocsSearch } from 'fumadocs-core/search/client';
import {
useDocsSearch,
type AlgoliaOptions,
} from 'fumadocs-core/search/client';
import { type ReactNode, useState } from 'react';
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
import type { SearchOptions } from '@algolia/client-search';
import {
SearchDialog,
type SharedProps,
@@ -13,8 +14,8 @@ import {
} from './search';
export interface AlgoliaSearchDialogProps extends SharedProps {
index: SearchIndex;
searchOptions?: SearchOptions;
index: AlgoliaOptions['index'];
searchOptions?: Omit<AlgoliaOptions, 'index'>;
footer?: ReactNode;
defaultTag?: string;
@@ -43,7 +44,7 @@ export default function AlgoliaSearchDialog({
showAlgolia = false,
allowClear = false,
...props
}: AlgoliaSearchDialogProps): React.ReactElement {
}: AlgoliaSearchDialogProps): ReactNode {
const [tag, setTag] = useState(defaultTag);
const { search, setSearch, query } = useDocsSearch(
{
@@ -87,7 +88,7 @@ export default function AlgoliaSearchDialog({
);
}
function AlgoliaTitle(): React.ReactNode {
function AlgoliaTitle(): ReactNode {
return (
<a
href="https://algolia.com"
@@ -0,0 +1,104 @@
'use client';
import {
useDocsSearch,
type OramaCloudOptions,
} from 'fumadocs-core/search/client';
import { type ReactNode, useState } from 'react';
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
import {
SearchDialog,
type SharedProps,
type TagItem,
TagsList,
} from './search';
export interface OramaSearchDialogProps extends SharedProps {
client: OramaCloudOptions['client'];
searchOptions?: OramaCloudOptions['params'];
footer?: ReactNode;
defaultTag?: string;
tags?: TagItem[];
/**
* Add the "Powered by Orama" label
*
* @defaultValue false
*/
showOrama?: boolean;
/**
* Allow to clear tag filters
*
* @defaultValue false
*/
allowClear?: boolean;
}
/**
* Orama Cloud integration
*/
export default function OramaSearchDialog({
client,
searchOptions,
tags,
defaultTag,
showOrama = false,
allowClear = false,
...props
}: OramaSearchDialogProps): ReactNode {
const [tag, setTag] = useState(defaultTag);
const { search, setSearch, query } = useDocsSearch(
{
type: 'orama-cloud',
client,
params: searchOptions,
},
undefined,
tag,
);
useOnChange(defaultTag, (v) => {
setTag(v);
});
return (
<SearchDialog
search={search}
onSearchChange={setSearch}
results={query.data ?? []}
isLoading={query.isLoading}
{...props}
footer={
tags ? (
<>
<TagsList
tag={tag}
onTagChange={setTag}
items={tags}
allowClear={allowClear}
>
{showOrama ? <Label /> : null}
</TagsList>
{props.footer}
</>
) : (
props.footer
)
}
/>
);
}
function Label(): ReactNode {
return (
<a
href="https://orama.com"
rel="noreferrer noopener"
className="ms-auto text-xs text-fd-muted-foreground"
>
Search powered by Orama
</a>
);
}
+1
View File
@@ -18,6 +18,7 @@ const exportedComponents = [
'dialog/search',
'dialog/search-default',
'dialog/search-algolia',
'dialog/search-orama',
'layout/root-toggle',
'layout/language-toggle',
];
+9 -10
View File
@@ -342,6 +342,9 @@ importers:
'@types/react-dom':
specifier: ^18.3.1
version: 18.3.1
algoliasearch:
specifier: 4.24.0
version: 4.24.0
typescript:
specifier: ^5.6.3
version: 5.6.3
@@ -559,6 +562,9 @@ importers:
'@mdx-js/mdx':
specifier: ^3.1.0
version: 3.1.0([email protected])
'@oramacloud/client':
specifier: ^1.3.18
version: 1.3.18([email protected])([email protected])
'@types/estree-jsx':
specifier: ^1.0.5
version: 1.0.5
@@ -1134,16 +1140,6 @@ importers:
tailwind-merge:
specifier: ^2.5.4
version: 2.5.4
optionalDependencies:
'@algolia/client-search':
specifier: ^4.0.0
version: 4.24.0
algoliasearch:
specifier: ^4.24.0
version: 4.24.0
tailwindcss:
specifier: ^3.4.14
version: 3.4.14
devDependencies:
'@fumadocs/cli':
specifier: workspace:*
@@ -1175,6 +1171,9 @@ importers:
postcss-lightningcss:
specifier: ^1.0.1
version: 1.0.1([email protected])
tailwindcss:
specifier: ^3.4.13
version: 3.4.14
tsconfig:
specifier: workspace:*
version: link:../tsconfig