Skip to content

Commit

Permalink
feat!: typed i18n (#6343)
Browse files Browse the repository at this point in the history
  • Loading branch information
AlessioGr committed May 14, 2024
1 parent 353c2b0 commit f716122
Show file tree
Hide file tree
Showing 84 changed files with 1,232 additions and 581 deletions.
1 change: 1 addition & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,7 @@ jobs:
- fields__collections__Lexical
- live-preview
- localization
- i18n
- plugin-cloud-storage
- plugin-form-builder
- plugin-nested-docs
Expand Down
13 changes: 7 additions & 6 deletions docs/admin/components.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -565,10 +565,11 @@ These are the props that will be passed to your custom Label.
#### Example

```tsx
'use client'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { getTranslation } from '@payloadcms/translations'
import { useTranslation } from '@payloadcms/ui/providers/Translation'

import { getTranslation } from 'payload/utilities/getTranslation'

type Props = {
htmlFor?: string
Expand Down Expand Up @@ -680,21 +681,21 @@ To make use of Payload SCSS variables / mixins to use directly in your own compo

### Getting the current language

When developing custom components you can support multiple languages to be consistent with Payload's i18n support. The best way to do this is to add your translation resources to the [i18n configuration](https://payloadcms.com/docs/configuration/i18n) and import `useTranslation` from `react-i18next` in your components.
When developing custom components you can support multiple languages to be consistent with Payload's i18n support. The best way to do this is to add your translation resources to the [i18n configuration](https://payloadcms.com/docs/configuration/i18n) and import `useTranslation` from `@payloadcms/ui/providers/Translation` in your components.

For example:

```tsx
import { useTranslation } from 'react-i18next'
import { useTranslation } from '@payloadcms/ui/providers/Translation'

const CustomComponent: React.FC = () => {
// highlight-start
const { t, i18n } = useTranslation('namespace1')
const { t, i18n } = useTranslation()
// highlight-end

return (
<ul>
<li>{t('key', { variable: 'value' })}</li>
<li>{t('namespace1:key', { variable: 'value' })}</li>
<li>{t('namespace2:key', { variable: 'value' })}</li>
<li>{i18n.language}</li>
</ul>
Expand Down
72 changes: 64 additions & 8 deletions docs/configuration/i18n.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ desc: Manage and customize internationalization support in your CMS editor exper
keywords: internationalization, i18n, config, configuration, documentation, Content Management System, cms, headless, javascript, node, react, express
---

Not only does Payload support managing localized content, it also has internationalization support so that admin users can work in their preferred language. Payload's i18n support is built on top of [i18next](https://www.i18next.com). It comes included by default and can be extended in your config.
Not only does Payload support managing localized content, it also has internationalization support so that admin users can work in their preferred language. It comes included by default and can be extended in your config.

While Payload's built-in features come translated, you may want to also translate parts of your project's configuration too. This is possible in places like collections and globals labels and groups, field labels, descriptions and input placeholder text. The admin UI will display all the correct translations you provide based on the user's language.

Expand Down Expand Up @@ -72,9 +72,7 @@ After a user logs in, they can change their language selection in the `/account`

Payload's backend uses express middleware to set the language on incoming requests before they are handled. This allows backend validation to return error messages in the user's own language or system generated emails to be sent using the correct translation. You can make HTTP requests with the `accept-language` header and Payload will use that language.

Anywhere in your Payload app that you have access to the `req` object, you can access i18next's extensive internationalization features assigned to `req.i18n`. To access text translations you can use `req.t('namespace:key')`.

Read the i18next [API documentation](https://www.i18next.com/overview/api) to learn more.
Anywhere in your Payload app that you have access to the `req` object, you can access payload's extensive internationalization features assigned to `req.i18n`. To access text translations you can use `req.t('namespace:key')`.

### Configuration Options

Expand All @@ -88,9 +86,8 @@ import { buildConfig } from 'payload/config'
export default buildConfig({
//...
i18n: {
fallbackLng: 'en', // default
debug: false, // default
resources: {
fallbackLanguage: 'en', // default
translations: {
en: {
custom: {
// namespace can be anything you want
Expand All @@ -107,4 +104,63 @@ export default buildConfig({
})
```

See the i18next [configuration options](https://www.i18next.com/overview/configuration-options) to learn more.
## Types for custom translations

In order to use custom translations in your project, you need to provide the types for the translations. Here is an example of how you can define the types for the custom translations in a custom react component:

```ts
'use client'
import type { NestedKeysStripped } from '@payloadcms/translations'
import type React from 'react'

import { useTranslation } from '@payloadcms/ui/providers/Translation'

const customTranslations = {
en: {
general: {
test: 'Custom Translation',
},
},
}

type CustomTranslationObject = typeof customTranslations.en
type CustomTranslationKeys = NestedKeysStripped<CustomTranslationObject>

export const MyComponent: React.FC = () => {
const { i18n, t } = useTranslation<CustomTranslationObject, CustomTranslationKeys>() // These generics merge your custom translations with the default client translations

return t('general:test')
}

```

Additionally, payload exposes the `t` function in various places, for example in labels. Here is how you would type those:

```ts
import type {
DefaultTranslationKeys,
NestedKeysStripped,
TFunction,
} from '@payloadcms/translations'
import type { Field } from 'payload/types'

const customTranslations = {
en: {
general: {
test: 'Custom Translation',
},
},
}

type CustomTranslationObject = typeof customTranslations.en
type CustomTranslationKeys = NestedKeysStripped<CustomTranslationObject>

const field: Field = {
name: 'myField',
type: 'text',
label: (
{ t }: { t: TFunction<CustomTranslationKeys | DefaultTranslationKeys> }, // The generic passed to TFunction does not automatically merge the custom translations with the default translations. We need to merge them ourselves here
) => t('fields:addLabel'),
}
```

1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@
"docker:start": "docker compose -f packages/plugin-cloud-storage/docker-compose.yml up -d",
"docker:stop": "docker compose -f packages/plugin-cloud-storage/docker-compose.yml down",
"fix": "eslint \"packages/**/*.ts\" --fix",
"generate:types": "PAYLOAD_CONFIG_PATH=./test/_community/config.ts node --no-deprecation ./packages/payload/bin.js generate:types",
"lint": "eslint \"packages/**/*.ts\"",
"lint-staged": "lint-staged",
"obliterate-playwright-cache": "rm -rf ~/Library/Caches/ms-playwright && find /System/Volumes/Data/private/var/folders -type d -name 'playwright*' -exec rm -rf {} +",
Expand Down
8 changes: 6 additions & 2 deletions packages/next/src/layouts/Root/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { AcceptedLanguages } from '@payloadcms/translations'
import type { AcceptedLanguages, I18nClient } from '@payloadcms/translations'
import type { SanitizedConfig } from 'payload/types'

import { rtlLanguages } from '@payloadcms/translations'
Expand Down Expand Up @@ -50,7 +50,11 @@ export const RootLayout = async ({
})

const payload = await getPayloadHMR({ config })
const i18n = await initI18n({ config: config.i18n, context: 'client', language: languageCode })
const i18n: I18nClient = await initI18n({
config: config.i18n,
context: 'client',
language: languageCode,
})
const clientConfig = await createClientConfig({ config, t: i18n.t })

const dir = (rtlLanguages as unknown as AcceptedLanguages[]).includes(languageCode)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { FieldSchemaMap } from './types.js'
type Args = {
config: SanitizedConfig
fields: Field[]
i18n: I18n
i18n: I18n<any, any>
schemaMap: FieldSchemaMap
schemaPath: string
validRelationships: string[]
Expand Down
2 changes: 1 addition & 1 deletion packages/next/src/utilities/getNextRequestI18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { cookies, headers } from 'next/headers.js'
import { getRequestLanguage } from './getRequestLanguage.js'

/**
* In the context of NextJS, this function initializes the i18n object for the current request.
* In the context of Next.js, this function initializes the i18n object for the current request.
*
* It must be called on the server side, and within the lifecycle of a request since it relies on the request headers and cookies.
*/
Expand Down
3 changes: 2 additions & 1 deletion packages/next/src/utilities/initPage/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { I18nClient } from '@payloadcms/translations'
import type { InitPageResult, PayloadRequestWithData, VisibleEntities } from 'payload/types'

import { initI18n } from '@payloadcms/translations'
Expand Down Expand Up @@ -40,7 +41,7 @@ export const initPage = async ({
const cookies = parseCookies(headers)
const language = getRequestLanguage({ config: payload.config, cookies, headers })

const i18n = await initI18n({
const i18n: I18nClient = await initI18n({
config: i18nConfig,
context: 'client',
language,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { I18n } from '@payloadcms/translations'
import type { I18nClient } from '@payloadcms/translations'
import type { SelectFieldProps } from '@payloadcms/ui/fields/Select'
import type { MappedField } from '@payloadcms/ui/utilities/buildComponentMap'
import type { OptionObject, SelectField } from 'payload/types'
Expand Down Expand Up @@ -35,7 +35,7 @@ const getOptionsToRender = (

const getTranslatedOptions = (
options: (OptionObject | string)[] | OptionObject | string,
i18n: I18n,
i18n: I18nClient,
): string => {
if (Array.isArray(options)) {
return options
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { I18n } from '@payloadcms/translations'
import type { I18nClient } from '@payloadcms/translations'
import type { FieldMap, MappedField } from '@payloadcms/ui/utilities/buildComponentMap'
import type { FieldPermissions } from 'payload/auth'
import type React from 'react'
Expand All @@ -13,7 +13,7 @@ export type Props = {
disableGutter?: boolean
field: MappedField
fieldMap: FieldMap
i18n: I18n
i18n: I18nClient
isRichText?: boolean
locale?: string
locales?: string[]
Expand Down
4 changes: 2 additions & 2 deletions packages/next/src/views/Version/RenderFieldsToDiff/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { I18n } from '@payloadcms/translations'
import type { I18nClient } from '@payloadcms/translations'
import type { FieldMap, MappedField } from '@payloadcms/ui/utilities/buildComponentMap'
import type { FieldPermissions } from 'payload/auth'
import type { DiffMethod } from 'react-diff-viewer-continued'
Expand All @@ -10,7 +10,7 @@ export type Props = {
diffComponents: DiffComponents
fieldMap: FieldMap
fieldPermissions: Record<string, FieldPermissions>
i18n: I18n
i18n: I18nClient
locales: string[]
version: Record<string, any>
}
Expand Down
8 changes: 4 additions & 4 deletions packages/payload/src/admin/RichText.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { I18n } from '@payloadcms/translations'
import type { I18nClient } from '@payloadcms/translations'
import type { JSONSchema4 } from 'json-schema'

import type { Config, SanitizedConfig } from '../config/types.js'
import type { SanitizedConfig } from '../config/types.js'
import type { Field, FieldBase, RichTextField, Validate } from '../fields/config/types.js'
import type { PayloadRequestWithData, RequestContext } from '../types/index.js'
import type { WithServerSideProps } from './elements/WithServerSideProps.js'
Expand All @@ -22,12 +22,12 @@ type RichTextAdapterBase<
generateComponentMap: (args: {
WithServerSideProps: WithServerSideProps
config: SanitizedConfig
i18n: I18n
i18n: I18nClient
schemaPath: string
}) => Map<string, React.ReactNode>
generateSchemaMap?: (args: {
config: SanitizedConfig
i18n: I18n
i18n: I18nClient
schemaMap: Map<string, Field[]>
schemaPath: string
}) => Map<string, Field[]>
Expand Down
4 changes: 2 additions & 2 deletions packages/payload/src/admin/views/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { SupportedLanguages } from '@payloadcms/translations'
import type { ClientTranslationsObject } from '@payloadcms/translations'

import type { Permissions } from '../../auth/index.js'
import type { SanitizedCollectionConfig } from '../../collections/config/types.js'
Expand Down Expand Up @@ -43,7 +43,7 @@ export type InitPageResult = {
locale: Locale
permissions: Permissions
req: PayloadRequestWithData
translations: SupportedLanguages
translations: ClientTranslationsObject
visibleEntities: VisibleEntities
}

Expand Down
4 changes: 3 additions & 1 deletion packages/payload/src/config/sanitize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,9 @@ export const sanitizeConfig = async (incomingConfig: Config): Promise<SanitizedC
i18nConfig.fallbackLanguage = supportedLangKeys.includes(fallbackLang)
? fallbackLang
: supportedLangKeys[0]
i18nConfig.translations = incomingConfig.i18n?.translations || i18nConfig.translations
i18nConfig.translations =
(incomingConfig.i18n?.translations as SanitizedConfig['i18n']['translations']) ||
i18nConfig.translations
}

config.i18n = i18nConfig
Expand Down
4 changes: 2 additions & 2 deletions packages/payload/src/config/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { I18nOptions, TFunction } from '@payloadcms/translations'
import type { DefaultTranslationsObject, I18nOptions, TFunction } from '@payloadcms/translations'
import type { Options as ExpressFileUploadOptions } from 'express-fileupload'
import type GraphQL from 'graphql'
import type { Metadata as NextMetadata } from 'next'
Expand Down Expand Up @@ -580,7 +580,7 @@ export type Config = {
afterError?: AfterErrorHook
}
/** i18n config settings */
i18n?: I18nOptions
i18n?: I18nOptions<{} | DefaultTranslationsObject> // loosen the type here to allow for custom translations
/** Automatically index all sortable top-level fields in the database to improve sort performance and add database compatibility for Azure Cosmos and similar. */
indexSortableFields?: boolean
/**
Expand Down
4 changes: 2 additions & 2 deletions packages/payload/src/fields/validations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,11 +65,11 @@ export const text: Validate<string | string[], unknown, unknown, TextField> = (
const length = stringValue?.length || 0

if (typeof maxLength === 'number' && length > maxLength) {
return t('validation:shorterThanMax', { label: t('value'), maxLength, stringValue })
return t('validation:shorterThanMax', { label: t('general:value'), maxLength, stringValue })
}

if (typeof minLength === 'number' && length < minLength) {
return t('validation:longerThanMin', { label: t('value'), minLength, stringValue })
return t('validation:longerThanMin', { label: t('general:value'), minLength, stringValue })
}
}

Expand Down
3 changes: 2 additions & 1 deletion packages/plugin-seo/src/fields/MetaDescription.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { useLocale } from '@payloadcms/ui/providers/Locale'
import { useTranslation } from '@payloadcms/ui/providers/Translation'
import React, { useCallback } from 'react'

import type { PluginSEOTranslationKeys, PluginSEOTranslations } from '../translations/index.js'
import type { GenerateDescription } from '../types.js'

import { defaults } from '../defaults.js'
Expand All @@ -30,7 +31,7 @@ export const MetaDescription: React.FC<MetaDescriptionProps> = (props) => {
const { CustomLabel, hasGenerateDescriptionFn, label, labelProps, path, required } = props
const { path: pathFromContext } = useFieldProps()

const { t } = useTranslation()
const { t } = useTranslation<PluginSEOTranslations, PluginSEOTranslationKeys>()

const locale = useLocale()
const [fields] = useAllFormFields()
Expand Down
3 changes: 2 additions & 1 deletion packages/plugin-seo/src/fields/MetaImage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { useLocale } from '@payloadcms/ui/providers/Locale'
import { useTranslation } from '@payloadcms/ui/providers/Translation'
import React, { useCallback } from 'react'

import type { PluginSEOTranslationKeys, PluginSEOTranslations } from '../translations/index.js'
import type { GenerateImage } from '../types.js'

import { Pill } from '../ui/Pill.js'
Expand All @@ -27,7 +28,7 @@ export const MetaImage: React.FC<MetaImageProps> = (props) => {

const field: FieldType<string> = useField(props as Options)

const { t } = useTranslation()
const { t } = useTranslation<PluginSEOTranslations, PluginSEOTranslationKeys>()

const locale = useLocale()
const [fields] = useAllFormFields()
Expand Down
3 changes: 2 additions & 1 deletion packages/plugin-seo/src/fields/MetaTitle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { useLocale } from '@payloadcms/ui/providers/Locale'
import { useTranslation } from '@payloadcms/ui/providers/Translation'
import React, { useCallback } from 'react'

import type { PluginSEOTranslationKeys, PluginSEOTranslations } from '../translations/index.js'
import type { GenerateTitle } from '../types.js'

import { defaults } from '../defaults.js'
Expand All @@ -31,7 +32,7 @@ export const MetaTitle: React.FC<MetaTitleProps> = (props) => {
const { CustomLabel, hasGenerateTitleFn, label, labelProps, path, required } = props || {}
const { path: pathFromContext } = useFieldProps()

const { t } = useTranslation()
const { t } = useTranslation<PluginSEOTranslations, PluginSEOTranslationKeys>()

const field: FieldType<string> = useField({
path,
Expand Down
Loading

0 comments on commit f716122

Please sign in to comment.