diff --git a/.changeset/rare-garlics-hammer.md b/.changeset/rare-garlics-hammer.md new file mode 100644 index 000000000..c61705ae7 --- /dev/null +++ b/.changeset/rare-garlics-hammer.md @@ -0,0 +1,7 @@ +--- +'fumadocs-core': patch +'@fumadocs/cli': patch +'fumadocs-ui': patch +--- + +Improve i18n api diff --git a/apps/docs/content/docs/ui/internationalization.mdx b/apps/docs/content/docs/ui/internationalization.mdx index 195e1e328..891c76ffb 100644 --- a/apps/docs/content/docs/ui/internationalization.mdx +++ b/apps/docs/content/docs/ui/internationalization.mdx @@ -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'; + +const cn: Translations = { + search: 'Translated Content', + // other props +}; -export default function RootLayout({ +// 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 ( - + - + {children} @@ -113,47 +124,61 @@ export default function RootLayout({ } ``` -### Writing Documents +### Source -see [Page Conventions](/docs/ui/page-conventions#internationalization) to learn how to organize your documents. +Update the usages to your source to include a locale code: -### Search +```ts +import { source } from '@/lib/source'; -Configure i18n on your search solution. +// get page tree +source.pageTree[params.lang]; -You don't need further changes if you're using the `createFromSource` shortcut. +// get page +source.getPage(params.slug, params.lang); -For the built-in Orama search, see [Search I18n](/docs/headless/search/orama#internationalization). +// get pages +source.getPages(params.lang); +``` -### Adding Translations +like: -We only provide English translation by default, you have to pass your translations to the provider. +```tsx title="app/[lang]/layout.tsx" +import { source } from '@/lib/source'; +import { DocsLayout } from 'fumadocs-ui/docs'; +import type { ReactNode } from 'react'; -```tsx -import { I18nProvider } from 'fumadocs-ui/i18n'; - -; +export default async function Layout({ + params, + children, +}: { + params: Promise<{ lang: string }>; + children: ReactNode; +}) { + const pageTree = source.pageTree[(await params).lang]; + + return {children}; +} ``` +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. + +### Search + +Configure i18n on your search solution. + +- 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 To allow users changing their language, enable `i18n` on your layouts. diff --git a/examples/i18n/app/api/search/route.ts b/examples/i18n/app/api/search/route.ts index e38b20464..a15d0f483 100644 --- a/examples/i18n/app/api/search/route.ts +++ b/examples/i18n/app/api/search/route.ts @@ -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: { diff --git a/packages/cli/src/plugins/i18n.ts b/packages/cli/src/plugins/i18n.ts index 19a859e36..3b37e5307 100644 --- a/packages/cli/src/plugins/i18n.ts +++ b/packages/cli/src/plugins/i18n.ts @@ -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(), }, diff --git a/packages/core/src/search/client/static.ts b/packages/core/src/search/client/static.ts index 3addb9017..0c2b09988 100644 --- a/packages/core/src/search/client/static.ts +++ b/packages/core/src/search/client/static.ts @@ -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, query); + return searchSimple( + cached as unknown as Orama, + query, + ); return searchAdvanced( cached.db as Orama, diff --git a/packages/core/src/search/create-db-simple.ts b/packages/core/src/search/create-db-simple.ts deleted file mode 100644 index ef8e484a1..000000000 --- a/packages/core/src/search/create-db-simple.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { - create, - insertMultiple, - type Orama, - type TypedDocument, -} from '@orama/orama'; -import { type SimpleOptions } from '@/search/server'; - -export type SimpleDocument = TypedDocument>; -export const schema = { - url: 'string', - title: 'string', - description: 'string', - content: 'string', - keywords: 'string', -} as const; - -export async function createDBSimple({ - indexes, - tokenizer, - ...rest -}: SimpleOptions): Promise> { - const items = typeof indexes === 'function' ? await indexes() : indexes; - const db = (await create({ - schema, - components: { - tokenizer, - }, - ...rest, - })) as unknown as Orama; - - await insertMultiple( - db, - items.map((page) => ({ - title: page.title, - description: page.description, - url: page.url, - content: page.content, - keywords: page.keywords, - })), - ); - - return db; -} diff --git a/packages/core/src/search/_stemmers.ts b/packages/core/src/search/orama/_stemmers.ts similarity index 100% rename from packages/core/src/search/_stemmers.ts rename to packages/core/src/search/orama/_stemmers.ts diff --git a/packages/core/src/search/create-db.ts b/packages/core/src/search/orama/create-db.ts similarity index 68% rename from packages/core/src/search/create-db.ts rename to packages/core/src/search/orama/create-db.ts index a7a598b3f..bc78097b0 100644 --- a/packages/core/src/search/create-db.ts +++ b/packages/core/src/search/orama/create-db.ts @@ -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>; export const advancedSchema = { @@ -85,3 +85,40 @@ export async function createDB({ await insertMultiple(db, mapTo); return db; } + +export type SimpleDocument = TypedDocument>; +export const simpleSchema = { + url: 'string', + title: 'string', + description: 'string', + content: 'string', + keywords: 'string', +} as const; + +export async function createDBSimple({ + indexes, + tokenizer, + ...rest +}: SimpleOptions): Promise> { + const items = typeof indexes === 'function' ? await indexes() : indexes; + const db = (await create({ + schema: simpleSchema, + components: { + tokenizer, + }, + ...rest, + })) as unknown as Orama; + + await insertMultiple( + db, + items.map((page) => ({ + title: page.title, + description: page.description, + url: page.url, + content: page.content, + keywords: page.keywords, + })), + ); + + return db; +} diff --git a/packages/core/src/search/create-endpoint.ts b/packages/core/src/search/orama/create-endpoint.ts similarity index 100% rename from packages/core/src/search/create-endpoint.ts rename to packages/core/src/search/orama/create-endpoint.ts diff --git a/packages/core/src/search/create-from-source.ts b/packages/core/src/search/orama/create-from-source.ts similarity index 96% rename from packages/core/src/search/create-from-source.ts rename to packages/core/src/search/orama/create-from-source.ts index 3d59edde3..85773b446 100644 --- a/packages/core/src/search/create-from-source.ts +++ b/packages/core/src/search/orama/create-from-source.ts @@ -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)) { diff --git a/packages/core/src/search/i18n-api.ts b/packages/core/src/search/orama/create-i18n.ts similarity index 96% rename from packages/core/src/search/i18n-api.ts rename to packages/core/src/search/orama/create-i18n.ts index 734daa52d..896a33358 100644 --- a/packages/core/src/search/i18n-api.ts +++ b/packages/core/src/search/orama/create-i18n.ts @@ -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 = Record; diff --git a/packages/core/src/search/search/advanced.ts b/packages/core/src/search/orama/search/advanced.ts similarity index 94% rename from packages/core/src/search/search/advanced.ts rename to packages/core/src/search/orama/search/advanced.ts index 6bfe7326d..2b8622264 100644 --- a/packages/core/src/search/search/advanced.ts +++ b/packages/core/src/search/orama/search/advanced.ts @@ -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'; diff --git a/packages/core/src/search/search/simple.ts b/packages/core/src/search/orama/search/simple.ts similarity index 72% rename from packages/core/src/search/search/simple.ts rename to packages/core/src/search/orama/search/simple.ts index 7e97be0d9..a9c565057 100644 --- a/packages/core/src/search/search/simple.ts +++ b/packages/core/src/search/orama/search/simple.ts @@ -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, + db: Orama, query: string, - params: Partial, SimpleDocument>> = {}, + params: Partial< + SearchParams, SimpleDocument> + > = {}, ): Promise { const result = await search(db, { term: query, diff --git a/packages/core/src/search/server.ts b/packages/core/src/search/server.ts index ce5cddc28..ab9968ef4 100644 --- a/packages/core/src/search/server.ts +++ b/packages/core/src/search/server.ts @@ -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, SimpleDocument>>; + search?: Partial, 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'; diff --git a/packages/ui/src/components/dialog/search-algolia.tsx b/packages/ui/src/components/dialog/search-algolia.tsx index 108532b8a..babe04808 100644 --- a/packages/ui/src/components/dialog/search-algolia.tsx +++ b/packages/ui/src/components/dialog/search-algolia.tsx @@ -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']; diff --git a/packages/ui/src/components/dialog/search-default.tsx b/packages/ui/src/components/dialog/search-default.tsx index f6732e346..bc3d3e691 100644 --- a/packages/ui/src/components/dialog/search-default.tsx +++ b/packages/ui/src/components/dialog/search-default.tsx @@ -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 { /** diff --git a/packages/ui/src/components/dialog/search-orama.tsx b/packages/ui/src/components/dialog/search-orama.tsx index 12de7c689..7f17fcda5 100644 --- a/packages/ui/src/components/dialog/search-orama.tsx +++ b/packages/ui/src/components/dialog/search-orama.tsx @@ -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']; diff --git a/packages/ui/src/components/dialog/search.tsx b/packages/ui/src/components/dialog/search.tsx index 53252befa..e45e9ea2a 100644 --- a/packages/ui/src/components/dialog/search.tsx +++ b/packages/ui/src/components/dialog/search.tsx @@ -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({ ); } + +export interface TagItem { + name: string; + value: string | undefined; + + props?: HTMLAttributes; +} + +export interface TagsListProps extends HTMLAttributes { + 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 ( +
+ {items.map((item) => ( + + ))} + {props.children} +
+ ); +} diff --git a/packages/ui/src/components/dialog/tag-list.tsx b/packages/ui/src/components/dialog/tag-list.tsx deleted file mode 100644 index 59078b913..000000000 --- a/packages/ui/src/components/dialog/tag-list.tsx +++ /dev/null @@ -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; -} - -export interface TagsListProps extends HTMLAttributes { - 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 ( -
- {items.map((item) => ( - - ))} - {props.children} -
- ); -} diff --git a/packages/ui/src/contexts/i18n.tsx b/packages/ui/src/contexts/i18n.tsx index bce100392..71ba9d0ea 100644 --- a/packages/ui/src/contexts/i18n.tsx +++ b/packages/ui/src/contexts/i18n.tsx @@ -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({ - 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 { diff --git a/packages/ui/src/i18n.tsx b/packages/ui/src/i18n.tsx index 0fb7b12e1..ac8b70f11 100644 --- a/packages/ui/src/i18n.tsx +++ b/packages/ui/src/i18n.tsx @@ -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 (