Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@
node_modules/
dist/
build/

__typetests__/
8 changes: 8 additions & 0 deletions jest.config.tsd.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default {
displayName: {
color: 'blue',
name: 'types',
},
runner: 'jest-runner-tsd',
testMatch: ['**/__typetests__/*.test.ts'],
};
7 changes: 6 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"@rollup/plugin-node-resolve": "13.3.0",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "13.3.0",
"@tsd/typescript": "^4.7.4",
"@types/jest": "28.1.7",
"@types/node": "17.0.31",
"@types/prop-types": "15.7.5",
Expand All @@ -31,6 +32,7 @@
"jest-environment-jsdom": "28.1.3",
"jest-junit": "14.0.0",
"jest-localstorage-mock": "2.4.22",
"jest-runner-tsd": "^3.1.0",
"lerna": "5.4.3",
"lint-staged": "13.0.3",
"mockdate": "3.0.5",
Expand All @@ -41,6 +43,7 @@
"rollup": "2.78.1",
"rollup-plugin-dts": "4.2.2",
"rollup-plugin-visualizer": "5.7.1",
"tsd-lite": "^0.5.6",
"typescript": "4.8.2",
"wait-for-expect": "3.0.2"
},
Expand All @@ -54,6 +57,7 @@
"test": "TZ=UTC jest",
"test:watch": "pnpm run test --watch",
"test:coverage": "pnpm run test --coverage",
"test:types": "jest -c jest.config.tsd.mjs",
"prepare": "husky install"
},
"pnpm": {
Expand Down Expand Up @@ -96,7 +100,8 @@
"packages/*/src/**/*.{ts,tsx,js,jsx}"
],
"modulePathIgnorePatterns": [
"locales"
"locales",
"__typetests__"
],
"coverageReporters": [
"text",
Expand Down
4 changes: 3 additions & 1 deletion packages/use-i18n/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,14 @@
"@formatjs/fast-memoize": "1.2.6",
"date-fns": "2.29.2",
"filesize": "9.0.11",
"international-types": "0.3.3",
"intl-messageformat": "10.1.3",
"prop-types": "15.8.1"
},
"peerDependencies": {
"date-fns": "2.x",
"react": "18.x",
"react-dom": "18.x"
"react-dom": "18.x",
"international-types": "0.3.3"
}
}
52 changes: 52 additions & 0 deletions packages/use-i18n/src/__typetests__/namespaceTranslation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { expectError, expectType } from 'tsd-lite'
import { useI18n } from '../usei18n'

// eslint-disable-next-line react-hooks/rules-of-hooks
const { namespaceTranslation } = useI18n<{
hello: 'world'
'doe.john': 'John Doe'
'doe.jane': 'Jane Doe'
'doe.child': 'Child is {name}'
'describe.john': '{name} is {age} years old'
}>()

// Single key
expectError(namespaceTranslation('hello'))

// Multiple keys
expectError(namespaceTranslation('doe.john'))
const scopedT1 = namespaceTranslation('doe')
expectType<string>(scopedT1('john'))
expectError(scopedT1('doesnotexists'))

// With a param
const scopedT2 = namespaceTranslation('doe')
expectError(scopedT2('child'))
expectType<string>(
scopedT2('child', {
name: 'Name',
}),
)
expectError(
scopedT2('doesnotexists', {
name: 'Name',
}),
)
expectError(
scopedT2('child', {
doesnotexists: 'Name',
}),
)
expectError(scopedT2('child', {}))
expectError(scopedT2('child'))

// With multiple params
const scopedT3 = namespaceTranslation('describe')
expectType<string>(
scopedT3('john', {
age: '30',
name: 'John',
}),
)
expectError(scopedT3('john', {}))
expectError(scopedT3('john'))
49 changes: 49 additions & 0 deletions packages/use-i18n/src/__typetests__/t.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { expectError, expectType } from 'tsd-lite'
import { useI18n } from '../usei18n'

// eslint-disable-next-line react-hooks/rules-of-hooks
const { t } = useI18n<{
hello: 'world'
'doe.john': 'John Doe'
'doe.jane': 'Jane Doe'
'doe.child': 'Child is {name}'
'describe.john': '{name} is {age} years old'
}>()

// Single key
expectType<string>(t('hello'))
expectError(t('keydoesnotexists'))

// Multiple keys
expectType<string>(t('doe.john'))
expectError(t('doe.doesnotexists'))

// With a param
expectError(t('doe.child'))
expectType<string>(
t('doe.child', {
name: 'Name',
}),
)
expectError(
t('doe.doesnotexists', {
name: 'Name',
}),
)
expectError(
t('doe.child', {
doesnotexists: 'Name',
}),
)
expectError(t('doe.child', {}))
expectError(t('doe.child'))

// With multiple params
expectType<string>(
t('describe.john', {
age: '30',
name: 'John',
}),
)
expectError(t('describe.john', {}))
expectError(t('describe.john'))
1 change: 1 addition & 0 deletions packages/use-i18n/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import I18nContextProvider from './usei18n'

export * from './usei18n'
export * from './types'

export default I18nContextProvider
29 changes: 29 additions & 0 deletions packages/use-i18n/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type {
BaseLocale,
LocaleKeys,
LocaleValue,
Params,
ParamsObject,
ScopedValue,
Scopes,
} from 'international-types'

export type TranslateFn<Locale extends BaseLocale> = <
Key extends LocaleKeys<Locale, undefined>,
Value extends LocaleValue = ScopedValue<Locale, undefined, Key>,
>(
key: Key,
...params: Params<Value>['length'] extends 0 ? [] : [ParamsObject<Value>]
) => string

export type ScopedTranslateFn<Locale extends BaseLocale> = <
Scope extends Scopes<Locale>,
>(
scope: Scope,
) => <
Key extends LocaleKeys<Locale, Scope>,
Value extends LocaleValue = ScopedValue<Locale, Scope, Key>,
>(
key: Key,
...params: Params<Value>['length'] extends 0 ? [] : [ParamsObject<Value>]
) => string
48 changes: 28 additions & 20 deletions packages/use-i18n/src/usei18n.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { NumberFormatOptions } from '@formatjs/ecma402-abstract'
import {
Locale,
Locale as DateFnsLocale,
formatDistanceToNow,
formatDistanceToNowStrict,
} from 'date-fns'
import type { BaseLocale, LocaleValue } from 'international-types'
import PropTypes from 'prop-types'
import {
ReactElement,
Expand All @@ -19,17 +20,20 @@ import ReactDOM from 'react-dom'
import dateFormat, { FormatDateOptions } from './formatDate'
import unitFormat, { FormatUnitOptions } from './formatUnit'
import formatters, { IntlListFormatOptions } from './formatters'
import type { ScopedTranslateFn, TranslateFn } from './types'

const LOCALE_ITEM_STORAGE = 'locale'

type PrimitiveType = string | number | boolean | null | undefined | Date
type TranslationsByLocales = Record<string, BaseLocale>

type Translations = Record<string, string> & { prefix?: string }
type TranslationsByLocales = Record<string, Translations>
type TranslateFn = (
export type InitialTranslateFn = (
key: string,
context?: Record<string, PrimitiveType>,
context?: Record<string, LocaleValue>,
) => string
export type InitialScopedTranslateFn = (
namespace: string,
t?: InitialTranslateFn,
) => InitialTranslateFn

const prefixKeys = (prefix: string) => (obj: { [key: string]: string }) =>
Object.keys(obj).reduce((acc: { [key: string]: string }, key) => {
Expand Down Expand Up @@ -65,9 +69,9 @@ const getCurrentLocale = ({
)
}

interface Context {
interface Context<Locale extends BaseLocale | undefined = undefined> {
currentLocale: string
dateFnsLocale?: Locale
dateFnsLocale?: DateFnsLocale
datetime: (
date: Date | number,
options?: Intl.DateTimeFormatOptions,
Expand All @@ -85,7 +89,9 @@ interface Context {
) => Promise<string>
locales: string[]
namespaces: string[]
namespaceTranslation: (namespace: string, t?: TranslateFn) => TranslateFn
namespaceTranslation: Locale extends BaseLocale
? ScopedTranslateFn<Locale>
: InitialScopedTranslateFn
relativeTime: (
date: Date | number,
options?: {
Expand All @@ -103,19 +109,21 @@ interface Context {
) => string
setTranslations: React.Dispatch<React.SetStateAction<TranslationsByLocales>>
switchLocale: (locale: string) => void
t: TranslateFn
t: Locale extends BaseLocale ? TranslateFn<Locale> : InitialTranslateFn
translations: TranslationsByLocales
}

const I18nContext = createContext<Context | undefined>(undefined)

export const useI18n = (): Context => {
export function useI18n<
Locale extends BaseLocale | undefined = undefined,
>(): Context<Locale> {
const context = useContext(I18nContext)
if (context === undefined) {
throw new Error('useI18n must be used within a I18nProvider')
}

return context
return context as unknown as Context<Locale>
}

export const useTranslation = (
Expand Down Expand Up @@ -149,7 +157,7 @@ type LoadTranslationsFn = ({
}: {
namespace: string
locale: string
}) => Promise<{ default: Translations }>
}) => Promise<{ default: BaseLocale }>
type LoadLocaleFn = (locale: string) => Promise<Locale>

const I18nContextProvider = ({
Expand Down Expand Up @@ -209,7 +217,7 @@ const I18nContextProvider = ({
namespace,
})

const trad: Translations = {
const trad: Record<string, string> = {
...result.defaultLocale.default,
...result[currentLocale].default,
}
Expand Down Expand Up @@ -321,9 +329,9 @@ const I18nContextProvider = ({
[dateFnsLocale],
)

const translate = useCallback<TranslateFn>(
(key: string, context?: Record<string, PrimitiveType>) => {
const value = translations[currentLocale]?.[key]
const translate = useCallback<InitialTranslateFn>(
(key, context) => {
const value = translations[currentLocale]?.[key] as string
if (!value) {
if (enableDebugKey) {
return key
Expand All @@ -342,9 +350,9 @@ const I18nContextProvider = ({
[currentLocale, translations, enableDebugKey],
)

const namespaceTranslation = useCallback(
(namespace: string, t: TranslateFn = translate) =>
(identifier: string, context?: Record<string, PrimitiveType>) =>
const namespaceTranslation = useCallback<InitialScopedTranslateFn>(
(namespace, t = translate) =>
(identifier, context) =>
t(`${namespace}.${identifier}`, context) || t(identifier, context),
[translate],
)
Expand Down
Loading