Last active
September 14, 2022 11:53
-
-
Save osdiab/23f197ae82621cadc07ee8bd57e5101e to your computer and use it in GitHub Desktop.
Type Safe remix-i18next
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
// 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(); |
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 { 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 }, | |
}; |
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 { 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; |
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 { 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), | |
}; | |
} |
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
// 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" | |
} | |
} |
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 { 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>; | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
i recommend gitignoring/eslintignoring/prettierignoring the generated files btw.