Improve i18n examples

This commit is contained in:
Fuma Nama
2024-12-27 16:47:53 +08:00
parent 96b400e4f1
commit 969da268f1
21 changed files with 284 additions and 232 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'fumadocs-core': patch
'@fumadocs/cli': patch
'fumadocs-ui': patch
---
Improve i18n api
@@ -49,24 +49,6 @@ export const source = loader({
});
```
Update the usages to your source to include a locale code:
```ts
import { source } from '@/lib/source';
// get page tree
source.pageTree[params.lang];
// get page
source.getPage(params.slug, params.lang);
// get pages
source.getPages(params.lang);
```
Note that without providing a locale code, it uses your default locale instead.
You can see [Source API](/docs/headless/source-api) for other usages.
### Middleware
Create a middleware that redirects users to appropriate locale.
@@ -83,28 +65,57 @@ Create a middleware that redirects users to appropriate locale.
See [Middleware](/docs/headless/internationalization#middleware) for customisable options.
### Root Layout
### Routing
Create a dynamic route `/app/[lang]`, and move all special files from `/app` to
the folder.
A `I18nProvider` is needed for localization. Wrap the root provider inside your I18n provider.
A `I18nProvider` is needed for localization.
Wrap the root provider inside your I18n provider, and provide available languages & translations to it.
Note that only English translations are provided by default.
```tsx
import { RootProvider } from 'fumadocs-ui/provider';
import { I18nProvider } from 'fumadocs-ui/i18n';
import { I18nProvider, type Translations } from 'fumadocs-ui/i18n';
export default function RootLayout({
const cn: Translations = {
search: 'Translated Content',
// other props
};
// available languages that will be displayed on UI
// make sure `locale` is consistent with your i18n config
const locales = [
{
name: 'English',
locale: 'en',
},
{
name: 'Chinese',
locale: 'cn',
},
];
export default async function RootLayout({
params,
children,
}: {
params: { lang: string };
params: Promise<{ lang: string }>;
children: React.ReactNode;
}) {
return (
<html lang={params.lang}>
<html lang={(await params).lang}>
<body>
<I18nProvider locale={params.lang}>
<I18nProvider
locale={(await params).lang}
locales={locales}
translations={
{
cn,
}[(await params).lang]
}
>
<RootProvider>{children}</RootProvider>
</I18nProvider>
</body>
@@ -113,6 +124,46 @@ export default function RootLayout({
}
```
### Source
Update the usages to your source to include a locale code:
```ts
import { source } from '@/lib/source';
// get page tree
source.pageTree[params.lang];
// get page
source.getPage(params.slug, params.lang);
// get pages
source.getPages(params.lang);
```
like:
```tsx title="app/[lang]/layout.tsx"
import { source } from '@/lib/source';
import { DocsLayout } from 'fumadocs-ui/docs';
import type { ReactNode } from 'react';
export default async function Layout({
params,
children,
}: {
params: Promise<{ lang: string }>;
children: ReactNode;
}) {
const pageTree = source.pageTree[(await params).lang];
return <DocsLayout pageTree={pageTree}>{children}</DocsLayout>;
}
```
Note that without providing a locale code, it uses your default locale instead.
You can see [Source API](/docs/headless/source-api) for other usages.
### Writing Documents
see [Page Conventions](/docs/ui/page-conventions#internationalization) to learn how to organize your documents.
@@ -121,38 +172,12 @@ see [Page Conventions](/docs/ui/page-conventions#internationalization) to learn
Configure i18n on your search solution.
You don't need further changes if you're using the `createFromSource` shortcut.
For the built-in Orama search, see [Search I18n](/docs/headless/search/orama#internationalization).
### Adding Translations
We only provide English translation by default, you have to pass your translations to the provider.
```tsx
import { I18nProvider } from 'fumadocs-ui/i18n';
<I18nProvider
locales={[
{
name: 'English',
locale: 'en',
},
{
name: 'Chinese',
locale: 'cn',
},
]}
translations={
{
cn: {
search: 'Translated Content',
},
}[locale]
}
// other props
/>;
```
- Built-in Search (Orama):
- For `createFromSource` and most languages, no further changes are needed.
- For special languages like Chinese & Japanese, they require additional config.
See [Orama I18n](/docs/headless/search/orama#internationalization) guide.
- Cloud Solutions (e.g. Algolia):
- They usually have official support for multilingual.
### Add Language Switch
+1 -1
View File
@@ -4,7 +4,7 @@ import { createFromSource } from 'fumadocs-core/search/server';
import { createTokenizer } from '@orama/tokenizers/mandarin';
import { stopwords } from '@orama/stopwords/mandarin';
export const { GET, search } = createFromSource(source, undefined, {
export const { GET } = createFromSource(source, undefined, {
localeMap: {
// the prop name should be its locale code in your i18n config, (e.g. `cn`)
cn: {
+2 -2
View File
@@ -27,10 +27,10 @@ export const i18nPlugin: Plugin = {
type: 'code',
title: 'page.tsx',
code: `
export default function Page({
export default async function Page({
params,
}: {
${picocolors.underline(picocolors.bold('params: { lang: string; slug?: string[] };'))}
${picocolors.underline(picocolors.bold('params: Promise<{ lang: string; slug?: string[] }>'))}
})
`.trim(),
},
+10 -5
View File
@@ -6,10 +6,12 @@ import {
type RawData,
} from '@orama/orama';
import { type SortedResult } from '@/server';
import { searchSimple } from '@/search/search/simple';
import { searchAdvanced } from '@/search/search/advanced';
import { type advancedSchema } from '@/search/create-db';
import { type schema } from '@/search/create-db-simple';
import { searchSimple } from '@/search/orama/search/simple';
import { searchAdvanced } from '@/search/orama/search/advanced';
import {
type advancedSchema,
type simpleSchema,
} from '@/search/orama/create-db';
export interface StaticOptions {
/**
@@ -86,7 +88,10 @@ export function createStaticClient({
if (!cached) return [];
if (cached.type === 'simple')
return searchSimple(cached as unknown as Orama<typeof schema>, query);
return searchSimple(
cached as unknown as Orama<typeof simpleSchema>,
query,
);
return searchAdvanced(
cached.db as Orama<typeof advancedSchema>,
@@ -1,44 +0,0 @@
import {
create,
insertMultiple,
type Orama,
type TypedDocument,
} from '@orama/orama';
import { type SimpleOptions } from '@/search/server';
export type SimpleDocument = TypedDocument<Orama<typeof schema>>;
export const schema = {
url: 'string',
title: 'string',
description: 'string',
content: 'string',
keywords: 'string',
} as const;
export async function createDBSimple({
indexes,
tokenizer,
...rest
}: SimpleOptions): Promise<Orama<typeof schema>> {
const items = typeof indexes === 'function' ? await indexes() : indexes;
const db = (await create({
schema,
components: {
tokenizer,
},
...rest,
})) as unknown as Orama<typeof schema>;
await insertMultiple(
db,
items.map((page) => ({
title: page.title,
description: page.description,
url: page.url,
content: page.content,
keywords: page.keywords,
})),
);
return db;
}
@@ -5,7 +5,7 @@ import {
type PartialSchemaDeep,
type TypedDocument,
} from '@orama/orama';
import { type AdvancedOptions } from '@/search/server';
import { type AdvancedOptions, type SimpleOptions } from '@/search/server';
export type AdvancedDocument = TypedDocument<Orama<typeof advancedSchema>>;
export const advancedSchema = {
@@ -85,3 +85,40 @@ export async function createDB({
await insertMultiple(db, mapTo);
return db;
}
export type SimpleDocument = TypedDocument<Orama<typeof simpleSchema>>;
export const simpleSchema = {
url: 'string',
title: 'string',
description: 'string',
content: 'string',
keywords: 'string',
} as const;
export async function createDBSimple({
indexes,
tokenizer,
...rest
}: SimpleOptions): Promise<Orama<typeof simpleSchema>> {
const items = typeof indexes === 'function' ? await indexes() : indexes;
const db = (await create({
schema: simpleSchema,
components: {
tokenizer,
},
...rest,
})) as unknown as Orama<typeof simpleSchema>;
await insertMultiple(
db,
items.map((page) => ({
title: page.title,
description: page.description,
url: page.url,
content: page.content,
keywords: page.keywords,
})),
);
return db;
}
@@ -12,7 +12,7 @@ import {
type Page,
} from '@/source';
import { type StructuredData } from '@/mdx-plugins';
import { type LocaleMap } from '@/search/i18n-api';
import { type LocaleMap } from '@/search/orama/create-i18n';
function pageToIndex(page: Page): AdvancedIndex {
if (!('structuredData' in page.data)) {
@@ -10,9 +10,9 @@ import {
type SearchServer,
type SimpleOptions,
} from '@/search/server';
import { createEndpoint } from '@/search/create-endpoint';
import { createEndpoint } from '@/search/orama/create-endpoint';
import { type I18nConfig } from '@/i18n';
import { STEMMERS } from '@/search/_stemmers';
import { STEMMERS } from '@/search/orama/_stemmers';
export type LocaleMap<O> = Record<string, Language | O>;
@@ -1,5 +1,8 @@
import { getByID, type Orama, search, type SearchParams } from '@orama/orama';
import { type AdvancedDocument, type advancedSchema } from '@/search/create-db';
import {
type AdvancedDocument,
type advancedSchema,
} from '@/search/orama/create-db';
import { removeUndefined } from '@/utils/remove-undefined';
import type { SortedResult } from '@/server';
@@ -1,11 +1,16 @@
import { type Orama, search, type SearchParams } from '@orama/orama';
import type { SortedResult } from '@/server';
import { type schema, type SimpleDocument } from '@/search/create-db-simple';
import {
type simpleSchema,
type SimpleDocument,
} from '@/search/orama/create-db';
export async function searchSimple(
db: Orama<typeof schema>,
db: Orama<typeof simpleSchema>,
query: string,
params: Partial<SearchParams<Orama<typeof schema>, SimpleDocument>> = {},
params: Partial<
SearchParams<Orama<typeof simpleSchema>, SimpleDocument>
> = {},
): Promise<SortedResult[]> {
const result = await search(db, {
term: query,
+9 -11
View File
@@ -2,19 +2,17 @@ import { type Orama, type SearchParams, save, create } from '@orama/orama';
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';
import { createEndpoint } from '@/search/orama/create-endpoint';
import {
type AdvancedDocument,
type advancedSchema,
createDB,
} from '@/search/create-db';
import { searchSimple } from '@/search/search/simple';
import { searchAdvanced } from '@/search/search/advanced';
import {
createDBSimple,
type schema,
type SimpleDocument,
} from './create-db-simple';
simpleSchema,
} from '@/search/orama/create-db';
import { searchSimple } from '@/search/orama/search/simple';
import { searchAdvanced } from '@/search/orama/search/advanced';
export interface SearchServer {
search: (
@@ -57,7 +55,7 @@ export interface SimpleOptions extends SharedOptions {
/**
* Customise search options on server
*/
search?: Partial<SearchParams<Orama<typeof schema>, SimpleDocument>>;
search?: Partial<SearchParams<Orama<typeof simpleSchema>, SimpleDocument>>;
}
export interface AdvancedOptions extends SharedOptions {
@@ -133,7 +131,7 @@ export function initAdvancedSearch(options: AdvancedOptions): SearchServer {
async export() {
return {
type: 'advanced',
...save(await get),
...(await save(await get)),
};
},
async search(query, searchOptions) {
@@ -144,5 +142,5 @@ export function initAdvancedSearch(options: AdvancedOptions): SearchServer {
};
}
export { createFromSource } from './create-from-source';
export { createI18nSearchAPI } from './i18n-api';
export { createFromSource } from './orama/create-from-source';
export { createI18nSearchAPI } from './orama/create-i18n';
@@ -6,8 +6,12 @@ import {
} from 'fumadocs-core/search/client';
import { type ReactNode, useState } from 'react';
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
import { SearchDialog, type SharedProps } from './search';
import { type TagItem, TagsList } from './tag-list';
import {
SearchDialog,
type SharedProps,
TagsList,
type TagItem,
} from './search';
export interface AlgoliaSearchDialogProps extends SharedProps {
index: AlgoliaOptions['index'];
@@ -4,8 +4,12 @@ import { useDocsSearch } from 'fumadocs-core/search/client';
import { type ReactNode, useState } from 'react';
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
import { useI18n } from '@/contexts/i18n';
import { SearchDialog, type SharedProps } from './search';
import { type TagItem, TagsList } from './tag-list';
import {
SearchDialog,
type SharedProps,
type TagItem,
TagsList,
} from './search';
export interface DefaultSearchDialogProps extends SharedProps {
/**
@@ -6,8 +6,12 @@ import {
} from 'fumadocs-core/search/client';
import { type ReactNode, useState } from 'react';
import { useOnChange } from 'fumadocs-core/utils/use-on-change';
import { SearchDialog, type SharedProps } from './search';
import { type TagItem, TagsList } from './tag-list';
import {
SearchDialog,
type SharedProps,
type TagItem,
TagsList,
} from './search';
export interface OramaSearchDialogProps extends SharedProps {
client: OramaCloudOptions['client'];
@@ -10,6 +10,7 @@ import {
useRef,
type ButtonHTMLAttributes,
useCallback,
type HTMLAttributes,
} from 'react';
import { useI18n } from '@/contexts/i18n';
import { cn } from '@/utils/cn';
@@ -23,6 +24,7 @@ import {
DialogTitle,
} from '@radix-ui/react-dialog';
import type { SortedResult } from 'fumadocs-core/server';
import { cva } from 'class-variance-authority';
export type SearchLink = [name: string, href: string];
@@ -287,3 +289,68 @@ function CommandItem({
</button>
);
}
export interface TagItem {
name: string;
value: string | undefined;
props?: HTMLAttributes<HTMLButtonElement>;
}
export interface TagsListProps extends HTMLAttributes<HTMLDivElement> {
tag?: string;
onTagChange: (tag: string | undefined) => void;
allowClear?: boolean;
items: TagItem[];
}
const itemVariants = cva(
'rounded-md border px-2 py-0.5 text-xs font-medium text-fd-muted-foreground transition-colors',
{
variants: {
active: {
true: 'bg-fd-accent text-fd-accent-foreground',
},
},
},
);
export function TagsList({
tag,
onTagChange,
items,
allowClear,
...props
}: TagsListProps) {
return (
<div
{...props}
className={cn('flex flex-row items-center gap-1', props.className)}
>
{items.map((item) => (
<button
key={item.value}
type="button"
data-active={tag === item.value}
className={cn(
itemVariants({ active: tag === item.value }),
item.props?.className,
)}
onClick={() => {
if (tag === item.value && allowClear) {
onTagChange(undefined);
} else {
onTagChange(item.value);
}
}}
tabIndex={-1}
{...item.props}
>
{item.name}
</button>
))}
{props.children}
</div>
);
}
@@ -1,68 +0,0 @@
import type { HTMLAttributes } from 'react';
import { cva } from 'class-variance-authority';
import { cn } from '@/utils/cn';
export interface TagItem {
name: string;
value: string | undefined;
props?: HTMLAttributes<HTMLButtonElement>;
}
export interface TagsListProps extends HTMLAttributes<HTMLDivElement> {
tag?: string;
onTagChange: (tag: string | undefined) => void;
allowClear?: boolean;
items: TagItem[];
}
const itemVariants = cva(
'rounded-md border px-2 py-0.5 text-xs font-medium text-fd-muted-foreground transition-colors',
{
variants: {
active: {
true: 'bg-fd-accent text-fd-accent-foreground',
},
},
},
);
export function TagsList({
tag,
onTagChange,
items,
allowClear,
...props
}: TagsListProps) {
return (
<div
{...props}
className={cn('flex flex-row items-center gap-1', props.className)}
>
{items.map((item) => (
<button
key={item.value}
type="button"
data-active={tag === item.value}
className={cn(
itemVariants({ active: tag === item.value }),
item.props?.className,
)}
onClick={() => {
if (tag === item.value && allowClear) {
onTagChange(undefined);
} else {
onTagChange(item.value);
}
}}
tabIndex={-1}
{...item.props}
>
{item.name}
</button>
))}
{props.children}
</div>
);
}
+14 -12
View File
@@ -28,19 +28,21 @@ interface I18nContextType {
locales?: LocaleItem[];
}
export const defaultTranslations: Translations = {
search: 'Search',
searchNoResult: 'No results found',
toc: 'On this page',
tocNoHeadings: 'No Headings',
lastUpdate: 'Last updated on',
chooseLanguage: 'Choose a language',
nextPage: 'Next',
previousPage: 'Previous',
chooseTheme: 'Theme',
editOnGithub: 'Edit on GitHub',
};
export const I18nContext = createContext<I18nContextType>({
text: {
search: 'Search',
searchNoResult: 'No results found',
toc: 'On this page',
tocNoHeadings: 'No Headings',
lastUpdate: 'Last updated on',
chooseLanguage: 'Choose a language',
nextPage: 'Next',
previousPage: 'Previous',
chooseTheme: 'Theme',
editOnGithub: 'Edit on GitHub',
},
text: defaultTranslations,
});
export function I18nLabel(props: { label: keyof Translations }): string {
+21 -18
View File
@@ -1,11 +1,12 @@
'use client';
import { useCallback, type ReactNode } from 'react';
import { useCallback, type ReactNode, useRef } from 'react';
import { useRouter, usePathname } from 'next/navigation';
import {
useI18n,
type Translations,
I18nContext,
defaultTranslations,
type LocaleItem,
} from './contexts/i18n';
@@ -40,24 +41,26 @@ export function I18nProvider({
}: I18nProviderProps) {
const context = useI18n();
const router = useRouter();
const segments = usePathname()
.split('/')
.filter((v) => v.length > 0);
const pathname = usePathname();
const onChange = useCallback(
(v: string) => {
// If locale prefix hidden
if (segments[0] !== locale) {
segments.unshift(v);
} else {
segments[0] = v;
}
const onChangeCallback = (locale: string) => {
const segments = pathname.split('/').filter((v) => v.length > 0);
router.push(`/${segments.join('/')}`);
router.refresh();
},
[locale, segments, router],
);
// If locale prefix hidden
if (segments[0] !== locale) {
segments.unshift(locale);
} else {
segments[0] = locale;
}
router.push(`/${segments.join('/')}`);
router.refresh();
};
const onChangeRef = useRef(onChangeCallback);
onChangeRef.current = onChangeCallback;
const onChange = useCallback((v: string) => onChangeRef.current(v), []);
return (
<I18nContext.Provider
@@ -76,4 +79,4 @@ export function I18nProvider({
);
}
export { type Translations };
export { defaultTranslations, type Translations };