Skip to content

Instantly share code, notes, and snippets.

@temoncher
Created May 17, 2024 19:38
Show Gist options
  • Save temoncher/79d8b7cdf3e8afb49c150c849bc6bb6c to your computer and use it in GitHub Desktop.
Save temoncher/79d8b7cdf3e8afb49c150c849bc6bb6c to your computer and use it in GitHub Desktop.
small typesafe i18n
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>;
}
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