Skip to content

Commit

Permalink
chore: wip working on localization
Browse files Browse the repository at this point in the history
  • Loading branch information
YuukanOO committed Nov 23, 2023
1 parent 7f6d044 commit 36c780b
Show file tree
Hide file tree
Showing 5 changed files with 222 additions and 6 deletions.
7 changes: 7 additions & 0 deletions cmd/serve/front/src/app.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ declare namespace App {
}

type Maybe<T> = T | undefined;

type DateValue = string | number | Date;

type KeysOfType<O, T> = {
[K in keyof O]: O[K] extends T ? K : never;
}[keyof O];

type Patch<T> = Maybe<T> | null;

type HtmlInputType =
Expand Down
14 changes: 14 additions & 0 deletions cmd/serve/front/src/lib/localization/en.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { Locale, Translations } from '$lib/localization';

const translations = {
'auth.signin.title': 'Sign in',
'auth.signin.description': 'Please fill the form below to access your dashboard.',
'shared.label.email': 'Email',
'shared.label.password': 'Password'
} satisfies Translations;

export default {
code: 'en',
displayName: 'English',
translations
} as const satisfies Locale<typeof translations>;
13 changes: 13 additions & 0 deletions cmd/serve/front/src/lib/localization/fr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { AppTranslations, Locale } from '$lib/localization';

export default {
code: 'fr',
displayName: 'Français',
translations: {
'auth.signin.title': 'Connexion',
'auth.signin.description':
'Remplissez le formulaire ci-dessous pour accéder au tableau de bord.',
'shared.label.email': 'Email',
'shared.label.password': 'Mot de passe'
}
} as const satisfies Locale<AppTranslations>;
181 changes: 181 additions & 0 deletions cmd/serve/front/src/lib/localization/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { browser } from '$app/environment';
import en from '$lib/localization/en';
import fr from '$lib/localization/fr';

/**
* Provides formatting methods for a specific locale.
*/
export interface FormatProvider {
format(format: 'date' | 'datetime', value: DateValue): string;
format(format: 'duration', start: DateValue, end: DateValue): string;
}

export type TranslationsArgs<T> = T extends (...args: infer P) => string ? P : never;
export type TranslationFunc = (this: FormatProvider, ...args: any[]) => string;
export type Translations = Record<string, string | TranslationFunc>;

/**
* Represents a single locale in the application.
*/
export type Locale<T extends Translations> = {
code: string;
displayName: string;
translations: T;
};

export type LocaleCode<T extends Locale<Translations>[]> = T[number]['code'];

/**
* Localize resources accross the application.
*/
export interface LocalizationService<T extends Translations, TLocales extends Locale<T>[]>
extends FormatProvider {
/** Sets the current locale */
locale(code: LocaleCode<TLocales>): void;
/** Gets the current locale */
locale(): LocaleCode<TLocales>;
/** Gets all the locales supported by this localization service */
locales(): TLocales;
/** Translate a given key */
translate<TKey extends KeysOfType<T, string>>(key: TKey): string;
/** Translate a given key with arguments */
translate<TKey extends KeysOfType<T, TranslationFunc>>(
key: TKey,
args: TranslationsArgs<T[TKey]>
): string;
}

export type LocalLocalizationOptions<T extends Translations, TLocales extends Locale<T>[]> = {
onLocaleChanged?(locale: LocaleCode<TLocales>, oldLocale: Maybe<LocaleCode<TLocales>>): void;
default?: string;
fallback: LocaleCode<TLocales>;
locales: TLocales;
};

export class LocalLocalizationService<T extends Translations, TLocales extends Locale<T>[]>
implements LocalizationService<T, TLocales>
{
private _currentLocaleCode?: LocaleCode<TLocales>;
private _currentTranslations!: T;

private readonly _dateOptions: Intl.DateTimeFormatOptions = {
day: '2-digit',
month: '2-digit',
year: 'numeric'
};

private readonly _dateTimeOptions: Intl.DateTimeFormatOptions = {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
};

public constructor(private readonly _options: LocalLocalizationOptions<T, TLocales>) {
this.locale(_options.default ?? _options.fallback);
}

format(format: 'date' | 'datetime', value: DateValue): string;
format(format: 'duration', start: DateValue, end: DateValue): string;
format(
format: 'date' | 'datetime' | 'duration',
valueOrStart: DateValue,
end?: DateValue
): string {
switch (format) {
case 'date':
return valueOrStart.toLocaleString(this._currentLocaleCode, this._dateOptions);
case 'datetime':
return valueOrStart.toLocaleString(this._currentLocaleCode, this._dateTimeOptions);
case 'duration':
const diffInSeconds = Math.max(
Math.floor((new Date(end!).getTime() - new Date(valueOrStart).getTime()) / 1000),
0
);

const numberOfMinutes = Math.floor(diffInSeconds / 60);
const numberOfSeconds = diffInSeconds - numberOfMinutes * 60;

// FIXME: handle it better but since for now I only support french and english, this is not needed.
if (numberOfMinutes === 0) {
return `${numberOfSeconds}s`;
}

return `${numberOfMinutes}m ${numberOfSeconds}s`;
default:
throw new Error(`Unsupported format: ${format}`);
}
}

translate<TKey extends keyof T>(key: TKey, args?: TranslationsArgs<T[TKey]> | undefined): string {
const v = this._currentTranslations[key];

if (typeof v === 'function') {
return v.apply(this, args ?? []);
}

return v as string;
}

locale(code: LocaleCode<TLocales>): void;
locale(): LocaleCode<TLocales>;
locale(code?: LocaleCode<TLocales>) {
if (!code) {
return this._currentLocaleCode;
}

const targetLocale =
this._options.locales.find((l) => l.code === code) ??
this._options.locales.find((l) => l.code === this._options.fallback)!;

if (targetLocale.code === this._currentLocaleCode) {
return;
}

const oldCode = this._currentLocaleCode;

this._currentLocaleCode = targetLocale.code;
this._currentTranslations = targetLocale.translations;
this._options.onLocaleChanged?.(this._currentLocaleCode, oldCode);
}

locales(): TLocales {
return this._options.locales;
}
}

/** Type the application translations to provide strong typings. */
export type AppTranslations = (typeof en)['translations'];

const locales = [en, fr] as const satisfies Locale<AppTranslations>[];

export type AppLocales = typeof locales;
export type AppLocaleCodes = LocaleCode<AppLocales>;
export type AppTranslationsString = KeysOfType<AppTranslations, string>;
export type AppTranslationsFunc = KeysOfType<AppTranslations, TranslationFunc>;

const service: LocalizationService<AppTranslations, AppLocales> = new LocalLocalizationService({
onLocaleChanged(value, old) {
if (!browser) {
return;
}

localStorage.setItem('locale', value);

// Old not defined, this is the localization initialization so no need to reload the page.
if (!old) {
return;
}

// Reload the page to force the application to re-render with the new locale set.
// I don't want to make every translations reactive because it will bloat the application for nothing...
window.location.reload();
},
default: browser ? localStorage.getItem('locale') ?? undefined : undefined,
fallback: 'en',
locales
});

export default service;
13 changes: 7 additions & 6 deletions cmd/serve/front/src/routes/(auth)/signin/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import Stack from '$components/stack.svelte';
import TextInput from '$components/text-input.svelte';
import auth from '$lib/auth';
import l from '$lib/localization';
let email = '';
let password = '';
Expand All @@ -15,35 +16,35 @@
}
</script>

<PageTitle title="Sign in" />
<PageTitle title={l.translate('auth.signin.title')} />

<div class="signin">
<div class="signin-card">
<Form class="signin-form" handler={submit} let:submitting let:errors>
<Stack direction="column">
<div>
<h1 class="title">Sign in</h1>
<p>Please fill the form below to access your dashboard.</p>
<h1 class="title">{l.translate('auth.signin.title')}</h1>
<p>{l.translate('auth.signin.description')}</p>
</div>

<FormErrors {errors} />

<TextInput
label="Email"
label={l.translate('shared.label.email')}
type="email"
bind:value={email}
required
remoteError={errors?.email}
/>
<TextInput
label="Password"
label={l.translate('shared.label.password')}
type="password"
bind:value={password}
required
remoteError={errors?.password}
/>
<Stack justify="flex-end">
<Button type="submit" loading={submitting}>Sign in</Button>
<Button type="submit" loading={submitting}>{l.translate('auth.signin.title')}</Button>
</Stack>
</Stack>
</Form>
Expand Down

0 comments on commit 36c780b

Please sign in to comment.