Skip to content

Commit

Permalink
Update i18n: Support custom translation dict as argument (#12)
Browse files Browse the repository at this point in the history
* Update i18n to take dict as input

* Remove defaultLocaleKey

* Update i18n config type

* Add list of locale codes

* Only keep primary in list of locale codes

* Add locales & default locale types

* Export i18n config with const type

* Fix locales list type to generalize

* Use Locales type for i18n function

* Add tranlations for "or"

* Refactor Tail

* Refactor recursive type functions

* Fix types to support function overloads type

* Use explicit dict for some translations in index
  • Loading branch information
matiboux authored Mar 10, 2024
1 parent bc6b5d5 commit 1463d44
Show file tree
Hide file tree
Showing 7 changed files with 144 additions and 30 deletions.
28 changes: 17 additions & 11 deletions app/app/config/i18n.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,4 @@
const i18n =
{
defaultLocale: 'en',
locales: [
'en',
'fr',
],
routing: {
prefixDefaultLocale: false,
},
} as {
type I18nConfig = {
readonly defaultLocale: string,
readonly locales: readonly (
| string
Expand All @@ -22,4 +12,20 @@ const i18n =
}
}

const i18n =
{
defaultLocale: 'en',
locales: [
'en',
'fr',
],
routing: {
prefixDefaultLocale: false,
},
} as const satisfies I18nConfig

export default i18n

export type {
I18nConfig,
}
61 changes: 54 additions & 7 deletions app/app/src/i18n/i18n.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,33 @@
import en from './locales/en'
import fr from './locales/fr'
import { defaultLocaleKey, type I18n } from './type'
import { defaultLocale, type I18n } from './type'
import type { Locales } from './type'

const locales = { en, fr } as const
const globalDictionary = {
'en': en,
'fr': fr,
} as const satisfies Partial<Record<Locales, I18n>>

function i18n(
locale: keyof typeof locales,
locale: Locales,
key: keyof I18n,
...args: string[]
)
function i18n(
locale: Locales,
dict: Partial<Record<Locales, string>>,
...args: string[]
)
function i18n(
locale: Locales,
data: keyof I18n | Partial<Record<Locales, string>>,
...args: string[]
)
{
const value = (locales[locale]?.[key] ?? key) as I18n[keyof I18n]
const value =
typeof data === 'object'
? data[locale]
: (globalDictionary[locale]?.[data] ?? data) satisfies I18n[keyof I18n]

if (typeof value !== 'string')
{
Expand All @@ -25,11 +42,41 @@ function i18n(
)
}

type Tail<T extends any[]> = ((...args: T) => any) extends (arg: any, ...tail: infer U) => any ? U : never
// From `ToTuple<Union>`: https://stackoverflow.com/a/70061272
type UnionToParm<U> = U extends any ? (k: U) => void : never
type UnionToSect<U> = UnionToParm<U> extends ((k: infer I) => void) ? I : never
type ExtractParm<F> = F extends { (a: infer A): void } ? A : never
type SpliceOne<Union> = Exclude<Union, ExtractOne<Union>>
type ExtractOne<Union> = ExtractParm<UnionToSect<UnionToParm<Union>>>
type UnionToTuple<Union, Rslt extends any[] = []> =
SpliceOne<Union> extends never
? [ExtractOne<Union>, ...Rslt]
: UnionToTuple<SpliceOne<Union>, [ExtractOne<Union>, ...Rslt]>

type TupleToUnion<Tuple extends any[], Rslt extends any = never> =
Tuple extends [infer Head, ...infer Tail]
? TupleToUnion<Tail, Head | Rslt>
: Rslt

// From https://stackoverflow.com/a/52761156
type OverloadedParameters<T> =
T extends { (...args: infer A1): any; (...args: infer A2): any } ? A1 | A2 :
T extends (...args: infer A) => any ? A :
any

type ListTail<List extends any[]> = List extends [any, ...infer Tail] ? Tail : never

type ListRecursiveTail<List extends any[], Rslt extends any[] = []> =
List extends [infer Head extends any[], ...infer Tail]
? ListRecursiveTail<Tail, [...Rslt, ListTail<Head>]>
: Rslt
type UnionRecursiveTail<Union, Rslt extends any[] = []> =
TupleToUnion<ListRecursiveTail<UnionToTuple<Union>, Rslt>>

function i18nFactory(locale: Parameters<typeof i18n>[0] | undefined)
function i18nFactory(locale: OverloadedParameters<typeof i18n>[0] | undefined)
{
return (...args: Tail<Parameters<typeof i18n>>) => i18n(locale ?? defaultLocaleKey, ...args)
return (...args: UnionRecursiveTail<OverloadedParameters<typeof i18n>>) =>
i18n.apply(i18n, [locale ?? defaultLocale, ...args])
}

export default i18n
Expand Down
3 changes: 1 addition & 2 deletions app/app/src/i18n/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,12 @@ import getLocaleByUrl from './getLocaleByUrl'
import getLocaleUrlList from './getLocaleUrlList'
import getUrlWithoutLocale from './getUrlWithoutLocale'
import i18n, { i18nFactory } from './i18n'
import { defaultLocale, defaultLocaleKey } from './type'
import { defaultLocale } from './type'

export default i18n

export {
defaultLocale,
defaultLocaleKey,
i18nFactory,
getLocaleByPath,
getLocaleByUrl,
Expand Down
1 change: 1 addition & 0 deletions app/app/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const defaultLocale =
// Global
'Back to home page',
'and',
'or',

// Footer
'Open-source repository',
Expand Down
1 change: 1 addition & 0 deletions app/app/src/i18n/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const locale =
// Global
'Back to home page': 'Retour à la page d\'accueil',
'and': 'et',
'or': 'ou',

// Footer
'Open-source repository': 'Dépôt open-source',
Expand Down
60 changes: 55 additions & 5 deletions app/app/src/i18n/type.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,71 @@
import i18nConfig from '/config/i18n'
import type { I18nConfig } from '/config/i18n'

import defaultLocaleData from './locales/en'

const defaultLocale = i18nConfig.defaultLocale

const defaultLocaleKey = 'en' as const

type I18n = Readonly<Record<keyof typeof defaultLocaleData, string>>

type Diff<T, U> = T extends U ? never : T

type DeepWriteable<T> = { -readonly [P in keyof T]: DeepWriteable<T[P]> }

// Inspired from `ToTuple<Union>`: https://stackoverflow.com/a/70061272
type LocalesToUnion<
Locales extends any[] | readonly any[],
Rslt extends any = never,
> =
DeepWriteable<Locales> extends [infer Head, ...infer Tail]
? LocalesToUnion<
Tail,
Rslt | (
DeepWriteable<Head> extends { codes: string[] }
? DeepWriteable<Head>['codes'][0]
: Head
)
>
: Rslt

type Locales = LocalesToUnion<typeof i18nConfig.locales>

type LocalesToList<
Locales extends any[] | readonly any[],
Rslt extends readonly any[] = [],
> =
DeepWriteable<Locales> extends [infer Head, ...infer Tail]
? LocalesToList<
Tail,
[
...Rslt,
(
DeepWriteable<Head> extends { codes: string[] }
? DeepWriteable<Head>['codes'][0]
: Head
),
]
>
: Rslt

type LocalesList = LocalesToList<typeof i18nConfig.locales>

const locales: LocalesList =
(i18nConfig.locales satisfies I18nConfig['locales'] as I18nConfig['locales'])
.map(
locale => typeof locale === 'string' ? locale : locale.codes[0]
) satisfies string[] as LocalesList

type DefaultLocale = Locales & typeof i18nConfig.defaultLocale

const defaultLocale: DefaultLocale = i18nConfig.defaultLocale

export {
defaultLocale,
defaultLocaleKey,
locales,
}

export type {
I18n,
Diff,
Locales,
LocalesList,
DefaultLocale,
}
20 changes: 15 additions & 5 deletions app/app/src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import yaml from 'js-yaml'
import Default from '~/layouts/Default.astro'
import { defaultLocale } from '~/i18n'
import { i18nFactory, defaultLocale } from '~/i18n'
const _ = i18nFactory(Astro.currentLocale as any)
const _url = (path?: string) => getAbsoluteLocaleUrl(Astro.currentLocale ?? defaultLocale, path)
import aboutYml from '~/data/about.yml?raw'
Expand Down Expand Up @@ -49,10 +50,19 @@ const title = 'Mathieu GUÉRIN'
)}
</ul>
<p>
You can contact me via: <br />
<i class={about.social.telegram.icon}></i> <a href={about.social.telegram.href}>Telegram</a>, or <br />
<i class={about.social.discord.icon}></i> <a href={about.social.discord.href}>Discord</a> ({about.social.discord.handle}), or <br />
<i class={about.social.proemail.icon}></i> <a href={about.social.proemail.href}>Email</a>, for professional inquiries
<Fragment set:html={_({
en: `You can contact me via:`,
fr: `Vous pouvez me contacter via :`,
})} /> <br />
<i class={about.social.telegram.icon}></i> <a href={about.social.telegram.href}>Telegram</a>,
<Fragment set:html={_('or')} /> <br />
<i class={about.social.discord.icon}></i> <a href={about.social.discord.href}>Discord</a> ({about.social.discord.handle}),
<Fragment set:html={_('or')} /> <br />
<i class={about.social.proemail.icon}></i> <a href={about.social.proemail.href}>Email</a>
(<Fragment set:html={_({
en: `for professional inquiries`,
fr: `pour les demandes professionnelles`,
})} />)
</p>
</div>
<div class="col-lg-7">
Expand Down

0 comments on commit 1463d44

Please sign in to comment.