Created
May 17, 2024 19:38
-
-
Save temoncher/79d8b7cdf3e8afb49c150c849bc6bb6c to your computer and use it in GitHub Desktop.
small typesafe i18n
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { type O } from 'ts-toolbelt'; | |
type InferInterpolation<TString> = | |
TString extends `${string}{${infer I}}${infer R}` | |
? O.Merge<{ [K in I]: string | number }, InferInterpolation<R>> | |
: object; | |
type TFunctionParams< | |
TNamespace, | |
TKey extends keyof TNamespace, | |
> = TNamespace[TKey] extends `${string}{${string}}${string}` | |
? [interpolation: InferInterpolation<TNamespace[TKey]>] | |
: []; | |
type MakeInterpolationHoles<TString> = | |
TString extends `${infer Start}{${string}}${infer R}` | |
? `${Start}${string}${MakeInterpolationHoles<R>}` | |
: TString; | |
type UnsafeTFunction = { | |
$: (key: string, interpolation?: object) => string; | |
}; | |
export type TProxy<TNamespace> = UnsafeTFunction & { | |
[K in keyof TNamespace]: TNamespace[K] extends string | |
? ( | |
...params: TFunctionParams<TNamespace, K> | |
) => MakeInterpolationHoles<TNamespace[K]> | null | |
: TProxy<TNamespace[K]>; | |
}; | |
function parseArgs([firstArg, secondArg]: unknown[]) { | |
if (typeof firstArg === 'string') { | |
// если первым аргументом передали строку, то объект для | |
// интерполяции лежит во втором аргументе, если нет, то в первом | |
return { argsKey: firstArg, interpolation: secondArg }; | |
} | |
return { interpolation: firstArg }; | |
} | |
export function createT<TNestedNamespace>( | |
actualNamespace: Record<string, string> | undefined, | |
previousPath: string[] = [] | |
): TProxy<TNestedNamespace> { | |
function processTranslation(...argArray: unknown[]) { | |
const { argsKey, interpolation } = parseArgs(argArray); | |
// если первым аргументом передали строку, то добавляем ее в путь | |
// иначе составляем путь из previousPath | |
const key = (argsKey ? [...previousPath, argsKey] : previousPath).join('.'); | |
const translation = actualNamespace?.[key]; | |
if (!translation) return null; | |
if (!interpolation) return translation; | |
let resultString = translation; | |
for (const [k, v] of Object.entries(interpolation)) { | |
resultString = resultString.replaceAll(`{${k}}`, String(v)); | |
} | |
return resultString; | |
} | |
// eslint-disable-next-line @typescript-eslint/no-empty-function | |
return new Proxy(function () {}, { | |
/** | |
* если пользователь пытается получить следующий ключ, то | |
* записываем его в previousPaths и передаем следующий прокси | |
*/ | |
get(target, key) { | |
// $ - специальный символ, который позволяет передать | |
// абсолютно любую строку, даже если она не подойдет по типам | |
if (key === '$') { | |
return processTranslation; | |
} | |
return createT(actualNamespace, [...previousPath, key as string]); | |
}, | |
/** | |
* если пользователь вызывает объект как функцию, то | |
* ищем перевод по собранному пути из previousPath | |
*/ | |
apply(target, thisArg, argArray) { | |
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument | |
return processTranslation(...argArray); | |
}, | |
}) as unknown as TProxy<TNestedNamespace>; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { useParams } from 'next/navigation'; | |
import { useAsync } from 'react-use'; | |
import { createT } from './createT'; | |
import { type AllTranslationNamespaces } from '../../i18nConfig'; | |
import { di } from '../di'; | |
type NonEmptyArray<T> = [T, ...T[]]; | |
async function fetchTranslations(lang: string, ...namespaces: string[]) { | |
const res = await Promise.all( | |
namespaces.map(async (namespace) => | |
di.queryClient.ensureQueryData(di.getLangsQO(namespace)) | |
) | |
); | |
return res.reduce( | |
(acc, response) => ({ | |
...acc, | |
...response.data.langs[lang], | |
}), | |
{} | |
); | |
} | |
type PickNs<Ns> = Ns extends [ | |
infer F extends keyof AllTranslationNamespaces, | |
...infer R, | |
] | |
? { [K in F]: AllTranslationNamespaces[F] } & PickNs<R> | |
: // eslint-disable-next-line @typescript-eslint/ban-types | |
{}; | |
export async function getTranslations< | |
const Ns extends NonEmptyArray<keyof AllTranslationNamespaces>, | |
>(lang: string, ...namespaces: Ns) { | |
const res = await fetchTranslations(lang, ...namespaces); | |
// мы не можем вернуть createT(res) напрямую, | |
// потому что прокси плохо работают с промисами | |
return { t: createT<PickNs<Ns>>(res) }; | |
} | |
export function useTranslations< | |
const Ns extends NonEmptyArray<keyof AllTranslationNamespaces>, | |
>(...namespaces: Ns) { | |
const params = useParams<{ locale: string }>(); | |
const tAsync = useAsync( | |
async () => fetchTranslations(params.locale, ...namespaces), | |
[params.locale, ...namespaces] | |
); | |
return { | |
t: createT<PickNs<Ns>>(tAsync.value), | |
isLoading: tAsync.loading, | |
}; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment