diff --git a/.all-contributorsrc b/.all-contributorsrc index 701935e..771c139 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -521,6 +521,60 @@ "contributions": [ "maintenance" ] + }, + { + "login": "BandhiyaHardik", + "name": "HardikBandhiya", + "avatar_url": "https://avatars.githubusercontent.com/u/110784317?v=4", + "profile": "https://github.com/BandhiyaHardik", + "contributions": [ + "doc" + ] + }, + { + "login": "timotew", + "name": "Tim O. Peters", + "avatar_url": "https://avatars.githubusercontent.com/u/12928383?v=4", + "profile": "https://www.linkedin.com/in/timotew/", + "contributions": [ + "code" + ] + }, + { + "login": "hydRAnger", + "name": "Li Ming", + "avatar_url": "https://avatars.githubusercontent.com/u/1228449?v=4", + "profile": "https://github.com/hydRAnger", + "contributions": [ + "doc" + ] + }, + { + "login": "acidfernando", + "name": "Fernando García Hernández", + "avatar_url": "https://avatars.githubusercontent.com/u/86410308?v=4", + "profile": "https://github.com/acidfernando", + "contributions": [ + "code" + ] + }, + { + "login": "hichemfantar", + "name": "Hichem Fantar", + "avatar_url": "https://avatars.githubusercontent.com/u/34947993?v=4", + "profile": "https://www.hichemfantar.com/", + "contributions": [ + "code" + ] + }, + { + "login": "huseyinonalcom", + "name": "Huseyin Onal", + "avatar_url": "https://avatars.githubusercontent.com/u/65453275?v=4", + "profile": "https://github.com/huseyinonalcom", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, diff --git a/README.md b/README.md index 3a2137f..8fcc1f8 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ -follow on Twitter @@ -34,6 +34,7 @@ - [3. Configuration](#3-configuration) - [4. API](#4-api) - [useTranslation](#usetranslation) + - [createTranslation](#createtranslation) - [withTranslation](#withtranslation) - [Trans Component](#trans-component) - [DynamicNamespaces](#dynamicnamespaces) @@ -111,7 +112,7 @@ In order to do this we use a **webpack loader** that loads the necessary transla - **`getServerSideProps`**. This is the **default method for dynamic pages** like `[slug].js` or `[...catchall].js`. This is because for these pages it is necessary to define the `getStaticPaths` and there is no knowledge of how the slugs should be for each locale. Likewise, how is it by default, only that you write the getStaticPaths then it will already use the getStaticProps to load the translations. - **`getInitialProps`**. This is the **default method for these pages that use a HoC**. This is in order to avoid conflicts because HoC could overwrite a `getInitialProps`. -This **whole process is transparent**, so in your pages you can directly consume the `useTranslate` hook to use the namespaces, and you don't need to do anything else. +This **whole process is transparent**, so in your pages you can directly consume the `useTranslation` hook to use the namespaces, and you don't need to do anything else. If for some reason you use a `getInitialProps` in your `_app.js` file, then the translations will only be loaded into your `getInitialProps` from `_app.js`. We recommend that for optimization reasons you don't use this approach unless it is absolutely necessary. @@ -299,6 +300,15 @@ The `t` function: - **ns**: string - Namespace to use when none is embded in the `i18nKey`. - **Output**: string +### createTranslation + +Similar than `useTranslation` but without being a hook. This helper **only works** in **app dir**. + +```ts + const { t, lang } = createTranslation('ns1') // default namespace (optional) + const title = t('title') +``` + ### withTranslation **Size**: ~560b 📦 @@ -372,6 +382,7 @@ Or using `components` prop as a object: - `fallback` - string | string[] - Optional. Fallback i18nKey if the i18nKey doesn't match. - `defaultTrans` - string - Default translation for the key. If fallback keys are used, it will be used only after exhausting all the fallbacks. - `ns` - Namespace to use when none is embedded in `i18nKey` + - `returnObjects` - boolean - Get part of the JSON with all the translations. [See more](#7-nested-translations). In cases where we require the functionality of the `Trans` component, but need a **string** to be interpolated, rather than the output of the `t(props.i18nKey)` function, there is also a `TransText` component, which takes a `text` prop instead of `i18nKey`. @@ -769,7 +780,7 @@ In Trans Component: ## 9. Formatter -You can format params using the `interpolation.formatter` config function. +You can format params using the `interpolation.format` config function. in `i18n.js`: @@ -1141,6 +1152,12 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Honza
Honza

🚧 + HardikBandhiya
HardikBandhiya

📖 + Tim O. Peters
Tim O. Peters

💻 + Li Ming
Li Ming

📖 + Fernando García Hernández
Fernando García Hernández

💻 + Hichem Fantar
Hichem Fantar

💻 + Huseyin Onal
Huseyin Onal

💻 diff --git a/__tests__/Trans.test.js b/__tests__/Trans.test.js index e39f6ee..c217497 100644 --- a/__tests__/Trans.test.js +++ b/__tests__/Trans.test.js @@ -49,6 +49,75 @@ describe('Trans', () => { expect(container.textContent).toContain(expected) }) + test('should work with arrays', () => { + const i18nKey = 'ns:parent.child' + const expectedFirstElement = 'First element 42' + const expectedSecondElement = 'Second element 42' + const withSingular = { + parent: { + child: [ + '<0>First element {{num}}', + '<0>Second element {{num}}', + ], + }, + } + const { container } = render( + ]} + /> + ) + expect(container.innerHTML).toContain(expectedFirstElement) + expect(container.innerHTML).toContain(expectedSecondElement) + }) + + test('should work with arrays and singulars', () => { + const i18nKey = 'ns:withsingular' + const expected = 'The number is one' + const withSingular = { + withsingular_0: ['<0>The number is ZERO!'], + withsingular_one: ['<0>The number is one'], + withsingular_other: ['<0>The number is plural'], + } + + const { container } = render( + ]} + /> + ) + + expect(container.innerHTML).toContain(expected) + }) + + test('should work with arrays and plurals', () => { + const i18nKey = 'ns:withsingular' + const expected = 'The number is plural' + const withSingular = { + withsingular: ['<0>First is not zero'], + withsingular_0: ['<0>The number is ZERO!'], + withsingular_other: ['<0>The number is plural'], + } + + const { container } = render( + ]} + /> + ) + + expect(container.innerHTML).toContain(expected) + }) + test('should work with nested keys and custom keySeparator', () => { const i18nKey = 'ns:parent_child' const expected = 'The number is 42' diff --git a/docs/type-safety.md b/docs/type-safety.md index ea9daf0..43532dd 100644 --- a/docs/type-safety.md +++ b/docs/type-safety.md @@ -14,22 +14,32 @@ export interface TranslationsKeys { // Specify here all the namespaces you have... } -export interface TypeSafeTranslate +type TranslationNamespace = keyof TranslationsKeys; + +export interface TranslateFunction { + ( + key: TranslationsKeys[Namespace], + ...rest: Tail> + ): string + (template: TemplateStringsArray): string +}; + +export interface TypeSafeTranslate extends Omit { - t: { - ( - key: TranslationsKeys[Namespace], - ...rest: Tail> - ): string - (template: TemplateStringsArray): string - } + t: TranslateFunction } declare module 'next-translate/useTranslation' { export default function useTranslation< - Namespace extends keyof TranslationsKeys + Namespace extends TranslationNamespace >(namespace: Namespace): TypeSafeTranslate } + +declare module 'next-translate/getT' { + export default function getT< + Namespace extends TranslationNamespace + >(locale?: string, namespace: Namespace): Promise> +} ``` Then type safety should work: @@ -38,4 +48,4 @@ Then type safety should work: Screenshot 2023-07-17 at 19 22 00 -Reference: https://github.com/aralroca/next-translate/pull/1108 +Reference: diff --git a/examples/basic/package.json b/examples/basic/package.json index c68abaf..b5789d1 100644 --- a/examples/basic/package.json +++ b/examples/basic/package.json @@ -8,12 +8,12 @@ "build": "next build" }, "dependencies": { - "next": "13.4.7", + "next": "14.1.0", "next-translate": "link:../../", "react": "link:../../node_modules/react", "react-dom": "link:../../node_modules/react-dom" }, "devDependencies": { - "next-translate-plugin": "2.4.4" + "next-translate-plugin": "2.6.2" } } diff --git a/examples/complex/package.json b/examples/complex/package.json index d883e8c..90e93ca 100644 --- a/examples/complex/package.json +++ b/examples/complex/package.json @@ -9,20 +9,20 @@ "analyze": "ANALYZE=true yarn build" }, "dependencies": { - "@mdx-js/loader": "2.3.0", - "@mdx-js/react": "2.3.0", - "@next/mdx": "13.4.7", - "next": "13.4.7", + "@mdx-js/loader": "3.0.0", + "@mdx-js/react": "3.0.0", + "@next/mdx": "14.1.0", + "next": "14.1.0", "next-translate": "link:../../", "react": "link:../../node_modules/react", "react-dom": "link:../../node_modules/react-dom" }, "devDependencies": { - "@next/bundle-analyzer": "13.4.7", - "@types/node": "20.3.1", - "@types/react": "18.2.13", - "next-translate-plugin": "2.4.4", - "typescript": "5.1.3" + "@next/bundle-analyzer": "14.1.0", + "@types/node": "20.11.5", + "@types/react": "18.2.48", + "next-translate-plugin": "2.6.2", + "typescript": "5.3.3" }, "resolutions": { "webpack": "5.11.1" diff --git a/examples/with-app-directory/next-translate.d.ts b/examples/with-app-directory/next-translate.d.ts index ea3fac6..ed1185a 100644 --- a/examples/with-app-directory/next-translate.d.ts +++ b/examples/with-app-directory/next-translate.d.ts @@ -1,23 +1,37 @@ import type { Paths, I18n, Translate } from 'next-translate' +type Tail = T extends [unknown, ...infer Rest] ? Rest : never; + export interface TranslationsKeys { + // Example with "common" and "home" namespaces in "en" (the default language): common: Paths home: Paths + // Specify here all the namespaces you have... } -export interface TypeSafeTranslate +type TranslationNamespace = keyof TranslationsKeys; + +export interface TranslateFunction { + ( + key: TranslationsKeys[Namespace], + ...rest: Tail> + ): string + (template: TemplateStringsArray): string +}; + +export interface TypeSafeTranslate extends Omit { - t: { - ( - key: TranslationsKeys[Namespace], - ...rest: Tail> - ): string - (template: TemplateStringsArray): string - } + t: TranslateFunction } declare module 'next-translate/useTranslation' { export default function useTranslation< - Namespace extends keyof TranslationsKeys + Namespace extends TranslationNamespace >(namespace: Namespace): TypeSafeTranslate } + +declare module 'next-translate/getT' { + export default function getT< + Namespace extends TranslationNamespace + >(locale?: string, namespace: Namespace): Promise> +} diff --git a/examples/with-app-directory/package.json b/examples/with-app-directory/package.json index bbf52f4..d178ce2 100644 --- a/examples/with-app-directory/package.json +++ b/examples/with-app-directory/package.json @@ -10,20 +10,20 @@ "analyze": "ANALYZE=true yarn build" }, "dependencies": { - "@mdx-js/loader": "2.3.0", - "@mdx-js/react": "2.3.0", - "@next/mdx": "13.4.7", - "next": "13.4.7", + "@mdx-js/loader": "3.0.0", + "@mdx-js/react": "3.0.0", + "@next/mdx": "14.1.0", + "next": "14.1.0", "next-translate": "link:../../", "react": "link:../../node_modules/react", "react-dom": "link:../../node_modules/react-dom" }, "devDependencies": { - "@next/bundle-analyzer": "13.4.6", - "@types/node": "20.3.1", - "@types/react": "18.2.12", - "next-translate-plugin": "2.4.4", - "typescript": "5.1.3" + "@next/bundle-analyzer": "14.1.0", + "@types/node": "20.11.5", + "@types/react": "18.2.48", + "next-translate-plugin": "2.6.2", + "typescript": "5.3.3" }, "resolutions": { "webpack": "5.11.1" diff --git a/examples/with-app-directory/src/app/layout.tsx b/examples/with-app-directory/src/app/layout.tsx new file mode 100644 index 0000000..5bb08f6 --- /dev/null +++ b/examples/with-app-directory/src/app/layout.tsx @@ -0,0 +1,21 @@ +import useTranslation from 'next-translate/useTranslation' +import i18n from '../../i18n' +import { redirect } from 'next/navigation' + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + const { lang } = useTranslation('common') + + // Redirect to default locale if lang is not supported. /second-page -> /en/second-page + if (!i18n.locales.includes(lang)) redirect(`/${i18n.defaultLocale}/${lang}`) + + return ( + + + {children} + + ) +} diff --git a/examples/without-loader/package.json b/examples/without-loader/package.json index 0809478..b5789d1 100644 --- a/examples/without-loader/package.json +++ b/examples/without-loader/package.json @@ -8,12 +8,12 @@ "build": "next build" }, "dependencies": { - "next": "13.4.7", + "next": "14.1.0", "next-translate": "link:../../", "react": "link:../../node_modules/react", "react-dom": "link:../../node_modules/react-dom" }, "devDependencies": { - "next-translate-plugin": "2.4.0" + "next-translate-plugin": "2.6.2" } } diff --git a/package.json b/package.json index 55baa3b..8c9d11d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "next-translate", - "version": "2.5.3", + "version": "3.0.0-canary.2", "description": "Tiny and powerful i18n tools (Next plugin + API) to translate your Next.js pages.", "license": "MIT", "keywords": [ @@ -42,12 +42,13 @@ "useTranslation*", "setLanguage*", "index*", - "AppDirI18nProvider*" + "AppDirI18nProvider*", + "createTranslation*" ], "scripts": { "build": "yarn clean && cross-env NODE_ENV=production && yarn tsc", "clean": "yarn clean:build && yarn clean:examples", - "clean:build": "del lib appWith* Dynamic* I18n* index context loadNa* setLang* Trans* useT* withT* getP* getC* *.d.ts getT transC* wrapT* types formatElements isServer AppDirI18nProvider*", + "clean:build": "del lib appWith* Dynamic* I18n* index context loadNa* setLang* Trans* useT* withT* getP* getC* *.d.ts getT transC* wrapT* types formatElements isServer AppDirI18nProvider* createTrans*", "clean:examples": "del examples/**/.next examples/**/node_modules examples/**/yarn.lock", "example": "yarn example:complex", "example:basic": "yarn build && yarn --cwd examples/basic && yarn --cwd examples/basic dev", diff --git a/src/I18nProvider.tsx b/src/I18nProvider.tsx index cec17c0..8230eb3 100644 --- a/src/I18nProvider.tsx +++ b/src/I18nProvider.tsx @@ -24,7 +24,7 @@ export default function I18nProvider({ const lang = lng || parentLang || locale || defaultLocale || '' const config = { ...internal.config, ...newConfig } const localesToIgnore = config.localesToIgnore || ['default'] - const ignoreLang = localesToIgnore.includes(lang) + const ignoreLang = !lang || localesToIgnore.includes(lang) const pluralRules = new Intl.PluralRules(ignoreLang ? undefined : lang) const t = transCore({ config, allNamespaces, pluralRules, lang }) diff --git a/src/Trans.tsx b/src/Trans.tsx index 4d6bbe4..077e7d8 100644 --- a/src/Trans.tsx +++ b/src/Trans.tsx @@ -16,6 +16,7 @@ export default function Trans({ fallback, defaultTrans, ns, + returnObjects, }: TransProps): any { const { t, lang } = useTranslation(ns) @@ -23,11 +24,19 @@ export default function Trans({ * Memoize the transformation */ const result = useMemo(() => { - const text = t(i18nKey, values, { fallback, default: defaultTrans }) + const text = t(i18nKey, values, { + fallback, + default: defaultTrans, + returnObjects, + }) if (!text) return text - if (!components || components.length === 0) return text + if (!components || components.length === 0) + return Array.isArray(text) ? text.map((item) => item) : text + + if (Array.isArray(text)) + return text.map((item) => formatElements(item, components)) return formatElements(text, components) }, [i18nKey, values, components, lang]) as string diff --git a/src/context.tsx b/src/context.tsx index 7c6c131..5a34a73 100644 --- a/src/context.tsx +++ b/src/context.tsx @@ -1,13 +1,13 @@ -import { createContext } from 'react' import { I18n } from '.' +import React from 'react' let context // For serverComponents (app-dir), the context cannot be used and // this makes that all the imports to here don't break the build. // The use of this context is inside each util, depending pages-dir or app-dir. -if (typeof createContext === 'function') { - context = createContext({ +if (typeof React.createContext === 'function') { + context = React.createContext({ t: (k) => (Array.isArray(k) ? k[0] : k), lang: '', }) diff --git a/src/createTranslation.tsx b/src/createTranslation.tsx new file mode 100644 index 0000000..8509903 --- /dev/null +++ b/src/createTranslation.tsx @@ -0,0 +1,17 @@ +import transCore from './transCore' +import wrapTWithDefaultNs from './wrapTWithDefaultNs' + +// Only for App directory +export default function createTranslation(defaultNS?: string) { + const { lang, namespaces, config } = globalThis.__NEXT_TRANSLATE__ ?? {} + const localesToIgnore = config.localesToIgnore || ['default'] + const ignoreLang = !lang || localesToIgnore.includes(lang) + const t = transCore({ + config, + allNamespaces: namespaces, + pluralRules: new Intl.PluralRules(ignoreLang ? undefined : lang), + lang, + }) + + return { t: wrapTWithDefaultNs(t, defaultNS), lang } +} diff --git a/src/index.tsx b/src/index.tsx index 52ee3c8..a0cbdce 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -41,6 +41,7 @@ export interface TransProps { fallback?: string | string[] defaultTrans?: string ns?: string + returnObjects?: boolean } export type PageValue = string[] | ((context: object) => string[]) @@ -73,7 +74,7 @@ export interface I18nConfig extends NextI18nConfig { revalidate?: number pagesInDir?: string interpolation?: { - format?: Function + format?: (value: TranslationQuery[string], format: any, lang: string | undefined) => string prefix?: string suffix?: string } diff --git a/src/transCore.tsx b/src/transCore.tsx index 3f5a40a..9f06cdd 100644 --- a/src/transCore.tsx +++ b/src/transCore.tsx @@ -65,7 +65,14 @@ export default function transCore({ ) const dic = (namespace && allNamespaces[namespace]) || {} - const keyWithPlural = plural(pluralRules, dic, i18nKey, config, query) + const keyWithPlural = plural( + pluralRules, + dic, + i18nKey, + config, + query, + options + ) const dicValue = getDicValue(dic, keyWithPlural, config, options) const value = typeof dicValue === 'object' @@ -157,11 +164,14 @@ function getDicValue( if ( typeof value === 'string' || - ((value as unknown) instanceof Object && options.returnObjects) + ((value as unknown) instanceof Object && + options.returnObjects && + Object.keys(value).length > 0) ) { return value } + if (Array.isArray(value) && options.returnObjects) return value return undefined } @@ -173,23 +183,29 @@ function plural( dic: I18nDictionary, key: string, config: I18nConfig, - query?: TranslationQuery | null + query?: TranslationQuery | null, + options?: { + returnObjects?: boolean + fallback?: string | string[] + } ): string { if (!query || typeof query.count !== 'number') return key const numKey = `${key}_${query.count}` - if (getDicValue(dic, numKey, config) !== undefined) return numKey + if (getDicValue(dic, numKey, config, options) !== undefined) return numKey const pluralKey = `${key}_${pluralRules.select(query.count)}` - if (getDicValue(dic, pluralKey, config) !== undefined) { + if (getDicValue(dic, pluralKey, config, options) !== undefined) { return pluralKey } const nestedNumKey = `${key}.${query.count}` - if (getDicValue(dic, nestedNumKey, config) !== undefined) return nestedNumKey + if (getDicValue(dic, nestedNumKey, config, options) !== undefined) + return nestedNumKey const nestedKey = `${key}.${pluralRules.select(query.count)}` - if (getDicValue(dic, nestedKey, config) !== undefined) return nestedKey + if (getDicValue(dic, nestedKey, config, options) !== undefined) + return nestedKey return key } diff --git a/src/useTranslation.tsx b/src/useTranslation.tsx index 2341eb6..bd676ed 100644 --- a/src/useTranslation.tsx +++ b/src/useTranslation.tsx @@ -4,6 +4,7 @@ import wrapTWithDefaultNs from './wrapTWithDefaultNs' import I18nContext from './context' import isServer from './isServer' import transCore from './transCore' +import createTranslation from './createTranslation' function useTranslationInPages(defaultNS?: string): I18n { const ctx = useContext(I18nContext) @@ -36,6 +37,6 @@ function useTranslationAppDir(defaultNS?: string) { export default function useTranslation(defaultNS?: string): I18n { const appDir = globalThis.__NEXT_TRANSLATE__ - const useT = appDir?.config ? useTranslationAppDir : useTranslationInPages + const useT = appDir?.config ? createTranslation : useTranslationInPages return useT(defaultNS) }