Skip to content

Instantly share code, notes, and snippets.

@osdiab
Last active September 14, 2022 11:53
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save osdiab/23f197ae82621cadc07ee8bd57e5101e to your computer and use it in GitHub Desktop.
Save osdiab/23f197ae82621cadc07ee8bd57e5101e to your computer and use it in GitHub Desktop.
Type Safe remix-i18next
// this script reads through app/locales/*/*.json, copies them to public/locales,
// and generates a typescript file that can be used for making nice typings.
// it can be extended in the future to actually read the contents to allow for
// typesafe interpolation of variables.
import { join, relative } from "node:path";
import { cp, rm, writeFile } from "node:fs/promises";
import { promisify } from "node:util";
import { glob } from "glob";
import camelCase from "camelcase";
async function replacePublicDir(
appLocalesPath: string,
publicLocalesPath: string
) {
console.info("Copying app/locales to public/locales");
await rm(publicLocalesPath, { recursive: true, force: true });
await cp(appLocalesPath, publicLocalesPath, { recursive: true });
}
async function generateTranslationsFile(appLocalesPath: string) {
console.info("Generating translations TypeScript file");
const paths = await promisify(glob)(join(appLocalesPath, "**/*"), {
nodir: true,
absolute: false,
});
const foundPaths = paths.map((p) => relative(appLocalesPath, p));
const localeFiles: {
[namespace: string]: {
[locale: string]: {
filePath: string;
importPath: string;
importName: string;
};
};
} = {};
const badPath = foundPaths.find((filePath) => {
const split = filePath.split("/");
return split.length !== 2 || !split[1]?.endsWith(".json");
});
if (badPath) {
throw new Error(
`Paths supposed to be in format "locale/namespace.json", but found ${badPath}`
);
}
for (const filePath of foundPaths) {
const [locale, namespacePath] = filePath.split("/");
const namespace = namespacePath?.replace(/\.json$/, "");
if (!locale || !namespace) {
throw new Error(
`unexpected, locale (${locale}) or namespace (${namespace}) were missing`
);
}
localeFiles[namespace] = {
...localeFiles[namespace],
[locale]: {
filePath,
importPath: join("~/locales", filePath),
importName: camelCase([locale, namespace].join("-")),
},
};
}
const generatedImports = Object.entries(localeFiles)
.flatMap(([, filesByLocale]) =>
Object.values(filesByLocale).map(
({ importName, importPath }) =>
`import ${importName} from "${importPath}"`
)
)
.join("\n");
const generatedObject = `export const allTranslations = {
${Object.entries(localeFiles)
.map(
([namespace, filesByLocale]) =>
`"${namespace}": { ${Object.entries(filesByLocale)
.map(([locale, { importName }]) => `"${locale}": ${importName}`)
.join(", ")} }`
)
.join(",\n\t")}
};`;
const generatedTypeScript = [generatedImports, generatedObject].join("\n\n");
const outputPath = join(appLocalesPath, "..", "i18n-translations.gen.ts");
console.info("Writing updated TypeScript to", outputPath);
await writeFile(outputPath, generatedTypeScript);
}
async function run() {
const appLocalesPath = join(__dirname, "..", "app", "locales");
const publicLocalesPath = join(__dirname, "..", "public", "locales");
await Promise.all([
replacePublicDir(appLocalesPath, publicLocalesPath),
generateTranslationsFile(appLocalesPath),
]);
}
run();
import type { InitOptions, Resource } from "i18next";
import { allTranslations } from "~/i18n-translations.gen";
import type { SupportedLanguage } from "~/utils/language";
import { defaultLanguage, supportedLanguageSchema } from "~/utils/language";
export type AllTranslations = typeof allTranslations;
/**
* All translations but organized how i18next expects it, keyed by language then
* by namespace
*/
export const i18nextResource: Resource = {};
for (const [namespace, byLocale] of Object.entries(allTranslations)) {
for (const [locale, translations] of Object.entries(byLocale)) {
i18nextResource[locale] = {
...i18nextResource[locale],
[namespace]: translations,
};
}
}
export type I18nNamespace = keyof AllTranslations;
// we dont bother with translation keys that don't have an explicit namespace
// since I like how explicit it is, but it can be done.
export type KeysForNamespace<Namespace extends I18nNamespace> = {
[key in Namespace]: `${key}:${keyof AllTranslations[key]["en"] extends string
? keyof AllTranslations[key]["en"]
: never}`;
}[Namespace];
export const defaultI18nNamespace = "common";
export type DefaultI18nNamespace = typeof defaultI18nNamespace;
interface Config extends Omit<InitOptions, "supportedLngs" | "fallbackLng"> {
supportedLngs: SupportedLanguage[];
fallbackLng: SupportedLanguage;
}
export const config: Config = {
// This is the list of languages your application supports
supportedLngs: supportedLanguageSchema.options,
// This is the language you want to use in case
// if the user language is not in the supportedLngs
fallbackLng: defaultLanguage,
// The default namespace of i18next is "translation", but you can customize it here
defaultNS: defaultI18nNamespace,
// Disabling suspense is recommended
react: { useSuspense: false },
};
import { RemixI18Next } from "remix-i18next";
import { i18nextResource, config as i18nConfig } from "~/i18n";
const i18next = new RemixI18Next({
detection: {
supportedLanguages: i18nConfig.supportedLngs,
fallbackLanguage: i18nConfig.fallbackLng,
},
// This is the configuration for i18next used
// when translating messages server-side only
i18next: {
...i18nConfig,
resources: i18nextResource, // load translations from memory
},
});
export default i18next;
import type { RouteMatch } from "@remix-run/react";
import { useMatches } from "@remix-run/react";
import type { TOptions } from "i18next";
import { useTranslation } from "react-i18next";
import { z } from "zod";
import type {
DefaultI18nNamespace,
I18nNamespace,
KeysForNamespace,
} from "~/i18n";
// this can probably be derived from the generated file
export const supportedLanguageSchema = z.enum(["en", "ja"]);
export type SupportedLanguage = z.infer<typeof supportedLanguageSchema>;
export const defaultLanguage: SupportedLanguage = "en";
export interface I18nHandle<Namespace extends I18nNamespace> {
i18n?: Namespace | Namespace[];
}
export function makeI18nHandle<Namespace extends I18nNamespace>(
namespaces: Namespace | Namespace[]
): I18nHandle<Namespace | I18nNamespace> {
return { i18n: namespaces };
}
export type I18nNamespaceForHandle<Handle> = Handle extends I18nHandle<
infer Namespace
>
? Namespace
: DefaultI18nNamespace;
export function useTranslationSafe<
Namespaces extends I18nNamespace = "common"
>() {
const { t, ...rest } = useTranslation();
return {
...rest,
t: (key: KeysForNamespace<Namespaces>, options?: TOptions) =>
t(key, options),
};
}
// truncated to just have the relevant stuff
{
"scripts": {
"gen:i18n": "node -r @swc-node/register scripts/generate-all-translations.ts",
"dev:i18n": "chokidar \"app/locales/**/*.json\" \"scripts/generate-all-translations.ts\" --command \"yarn gen:i18n\"",
},
"devDependencies": {
"@swc-node/register": "^1.5.1",
"camelcase": "6.3.0", // need to lock the version because of ES Modules error
"chokidar-cli": "^3.0.0",
"glob": "^8.0.3"
}
}
import type { I18nNamespaceForHandle } from "~/utils/language";
import { useTranslationSafe, makeI18nHandle, useLang } from "~/utils/language";
export const handle = makeI18nHandle("test-mutations");
type AvailableNamespaces = I18nNamespaceForHandle<typeof handle>;
export default function MyPage() {
const { t } = useTranslationSafe();
return <div>{t("common:greeting")}</div>;
}
@osdiab
Copy link
Author

osdiab commented Sep 14, 2022

i recommend gitignoring/eslintignoring/prettierignoring the generated files btw.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment