Last active
October 10, 2023 22:32
-
-
Save medihack/f7c45d80e45f5ae62b510fb06044fe00 to your computer and use it in GitHub Desktop.
A fully typed i18n micro library
This file contains 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
/** | |
* A fully typed i18n micro library. The react import is optional for the hook (see below). | |
* | |
* Idea from https://dev.to/halolab/implementing-the-translate-function-with-typescript-5d8d | |
* | |
* TODO: check if we can get i18n-ally working: https://github.com/lokalise/i18n-ally/issues/678#issuecomment-947338325 | |
*/ | |
import { useEffect, useState } from "react" | |
type PathKeys<T> = T extends string | |
? [] | |
: { | |
[K in keyof T]: [K, ...PathKeys<T[K]>] | |
}[keyof T] | |
type Join<T extends string[], Delimiter extends string> = T extends [] | |
? never | |
: T extends [infer F] | |
? F | |
: T extends [infer F, ...infer Other] | |
? F extends string | |
? `${F}${Delimiter}${Join<Extract<Other, string[]>, Delimiter>}` | |
: never | |
: string | |
type Trim<A extends string> = A extends ` ${infer B}` | |
? Trim<B> | |
: A extends `${infer C} ` | |
? Trim<C> | |
: A | |
type SearchForVariable<T extends string> = T extends `${infer A}{${infer B}}${infer C}` | |
? SearchForVariable<A> | Trim<B> | SearchForVariable<C> | |
: never | |
type Variables< | |
T extends string | object, | |
Path extends string, | |
Delimiter extends string, | |
> = Path extends `${infer A}${Delimiter}${infer O}` | |
? A extends keyof T | |
? Variables<Extract<T[A], string | object>, O, Delimiter> | |
: never | |
: Path extends `${infer A}` | |
? A extends keyof T | |
? SearchForVariable<Extract<T[A], string>> | |
: never | |
: never | |
type Resource = { [key: string]: Resource | string } | |
export type Resources = { [lng: string]: Resource } | |
interface FlattenedResource { | |
[key: string]: string | |
} | |
function flattenResource( | |
resource: Resource, | |
prefix: string = "", | |
res: FlattenedResource = {} | |
): FlattenedResource { | |
Object.keys(resource).forEach((key) => { | |
const value = resource[key] | |
if (typeof value === "string") { | |
res[`${prefix}${key}`] = value | |
} else { | |
flattenResource(value, `${prefix}${key}.`, res) | |
} | |
}) | |
return res | |
} | |
function hasSameKeys( | |
res1: FlattenedResource, | |
res2: FlattenedResource | |
): { same: boolean; missingInRes1: string[]; missingInRes2: string[] } { | |
const keys1 = Object.keys(res1).sort() | |
const keys2 = Object.keys(res2).sort() | |
const missingInRes1 = keys2.filter((key) => !keys1.includes(key)) | |
const missingInRes2 = keys1.filter((key) => !keys2.includes(key)) | |
return { | |
same: missingInRes1.length === 0 && missingInRes2.length === 0, | |
missingInRes1, | |
missingInRes2, | |
} | |
} | |
function checkResourcesForSameKeys(resources: Resources): boolean { | |
const lngs = Object.keys(resources) | |
const len = lngs.length | |
let valid = true | |
for (let i = 0; i < len - 1; i++) { | |
for (let j = i + 1; j < len; j++) { | |
const res1 = flattenResource(resources[lngs[i]]) | |
const res2 = flattenResource(resources[lngs[j]]) | |
const { same, missingInRes1, missingInRes2 } = hasSameKeys(res1, res2) | |
if (!same) { | |
valid = false | |
missingInRes1 && | |
// eslint-disable-next-line no-console | |
console.error(`Resource '${lngs[i]}' is missing keys: ${missingInRes1.join(", ")}`) | |
missingInRes2 && | |
// eslint-disable-next-line no-console | |
console.error(`Resource '${lngs[j]}' is missing keys: ${missingInRes2.join(", ")}`) | |
} | |
} | |
} | |
return valid | |
} | |
function hasSameVariables( | |
trans1: string, | |
trans2: string | |
): { same: boolean; missingInRes1: string[]; missingInRes2: string[] } { | |
const regex = /\{\s*([^}]+?)\s*\}/g | |
const vars1 = [...trans1.matchAll(regex)].map((match) => match[1]) | |
const vars2 = [...trans2.matchAll(regex)].map((match) => match[1]) | |
const missingInRes1 = vars2.filter((v) => !vars1.includes(v)) | |
const missingInRes2 = vars1.filter((v) => !vars2.includes(v)) | |
return { | |
same: missingInRes1.length === 0 && missingInRes2.length === 0, | |
missingInRes1, | |
missingInRes2, | |
} | |
} | |
function checkResourcesForSameVariables(resources: Resources): boolean { | |
const lngs = Object.keys(resources) | |
const len = lngs.length | |
let valid = true | |
for (let i = 0; i < len - 1; i++) { | |
for (let j = i + 1; j < len; j++) { | |
const res1 = flattenResource(resources[lngs[i]]) | |
const res2 = flattenResource(resources[lngs[j]]) | |
// At this point we already checked that both resources have the same keys | |
for (const key of Object.keys(res1)) { | |
const value1 = res1[key] | |
const value2 = res2[key] | |
const { same, missingInRes1, missingInRes2 } = hasSameVariables(value1, value2) | |
if (!same) { | |
valid = false | |
missingInRes1 && | |
// eslint-disable-next-line no-console | |
console.error( | |
`Resource '${lngs[i]}' has missing variables in key '${key}': ${missingInRes1.join( | |
", " | |
)}` | |
) | |
missingInRes2 && | |
// eslint-disable-next-line no-console | |
console.error( | |
`Resource '${lngs[j]}' has missing variables in key '${key}': ${missingInRes2.join( | |
", " | |
)}` | |
) | |
} | |
} | |
} | |
} | |
return valid | |
} | |
export class MicroI18n<T extends Resources> { | |
private lng: keyof T | |
private listeners: { | |
languageChanged: ((lng: keyof T) => void)[] | |
missingKey: ((key: string) => void)[] | |
} = { | |
languageChanged: [], | |
missingKey: [], | |
} | |
constructor( | |
private resources: T, | |
private debugMode: boolean = false, | |
private missingDefault: string = "" | |
) { | |
;[this.lng] = Object.keys(resources) | |
if (this.debugMode) { | |
const valid = checkResourcesForSameKeys(this.resources) | |
if (valid) { | |
checkResourcesForSameVariables(this.resources) | |
} | |
} | |
} | |
on(eventName: "languageChanged", handler: (lng: keyof T) => void): void | |
on(eventName: "missingKey", handler: (key: string) => void): void | |
on(eventName: string, handler: (...args: any[]) => void): void { | |
if (eventName in this.listeners) { | |
const listeners = (this.listeners as any)[eventName] as Function[] | |
listeners.push(handler) | |
} | |
} | |
off(eventName: string, handler?: (...args: any[]) => void): void { | |
if (eventName in this.listeners) { | |
const listeners = (this.listeners as any)[eventName] as Function[] | |
if (!handler) { | |
listeners.length = 0 | |
} else { | |
const index = listeners.indexOf(handler) | |
if (index > -1) { | |
listeners.splice(index, 1) | |
} | |
} | |
} | |
} | |
get currentLanguage(): keyof T { | |
return this.lng | |
} | |
changeLanguage(lng: keyof T): void { | |
this.lng = lng | |
for (const listener of this.listeners.languageChanged) { | |
listener(lng) | |
} | |
} | |
get supportedLanguages(): (keyof T)[] { | |
return Object.keys(this.resources) | |
} | |
t<P extends Join<PathKeys<T[keyof T]>, ".">>( | |
// Rest parameters allow here to check if variables are present in the translation | |
// string and then require a second parameter | |
// https://stackoverflow.com/a/75509515/166229 | |
...args: Variables<T[keyof T], P, "."> extends never | |
? [P] | |
: [P, Record<Variables<T[keyof T], P, ".">, string>] | |
): string { | |
const paths = args[0] | |
const keys = paths.split(".") | |
const resource: Resource = this.resources[this.lng] | |
const result = keys.reduce((value: string | Resource | undefined, key: string) => { | |
if (typeof value === "object" && key in value) { | |
return value[key] | |
} | |
return undefined | |
}, resource) | |
let value = this.missingDefault | |
if (typeof result === "string") { | |
value = result | |
} else { | |
for (const listener of this.listeners.missingKey) { | |
listener(keys.join(".")) | |
} | |
} | |
const vars = args[1] | |
vars && | |
Object.keys(vars).forEach((key) => { | |
const variable = (vars as Record<string, string>)[key] as string | |
const regex = new RegExp(`{\\s*${key}\\s*}`, "g") | |
value = value.replace(regex, variable) | |
}) | |
return value | |
} | |
} | |
/** | |
* A react hook to use this library inside a component. | |
*/ | |
export const useMicroTranslation = <T extends Resources>(i18n: MicroI18n<T>) => { | |
// Use a state trigger a re-render of those components that uses the hook when the language changes | |
const [currentLng, setCurrentLng] = useState<keyof T>(i18n.currentLanguage) | |
useEffect(() => { | |
const handler = (i18nLng: keyof T) => setCurrentLng(i18nLng) | |
i18n.on("languageChanged", handler) | |
return () => i18n.off("languageChanged", handler) | |
}, [i18n]) | |
return { | |
t: i18n.t, | |
changeLanguage: i18n.changeLanguage, | |
currentLanguage: currentLng, | |
supportedLanguages: i18n.supportedLanguages, | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment