Skip to content

Instantly share code, notes, and snippets.

@medihack
Last active October 10, 2023 22:32
Show Gist options
  • Save medihack/f7c45d80e45f5ae62b510fb06044fe00 to your computer and use it in GitHub Desktop.
Save medihack/f7c45d80e45f5ae62b510fb06044fe00 to your computer and use it in GitHub Desktop.
A fully typed i18n micro library
/**
* 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