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 7d5d294
Show file tree
Hide file tree
Showing 7 changed files with 513 additions and 299 deletions.
568 changes: 283 additions & 285 deletions cmd/serve/front/package-lock.json

Large diffs are not rendered by default.

16 changes: 8 additions & 8 deletions cmd/serve/front/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,22 @@
"devDependencies": {
"@sveltejs/adapter-static": "2.0.2",
"@sveltejs/kit": "1.20.2",
"@typescript-eslint/eslint-plugin": "^5.59.11",
"@typescript-eslint/parser": "^5.59.11",
"eslint": "^8.42.0",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"eslint": "^8.54.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-svelte3": "^4.0.0",
"prettier": "^2.8.8",
"prettier-plugin-svelte": "^2.10.1",
"svelte": "^3.59.1",
"svelte-check": "^3.4.3",
"svelte-preprocess": "^5.0.4",
"svelte": "^3.59.2",
"svelte-check": "^3.6.1",
"svelte-preprocess": "^5.1.1",
"svelte-preprocess-cssmodules": "^2.2.4",
"tslib": "^2.5.3",
"tslib": "^2.6.2",
"typescript": "^4.9.5",
"vite": "^4.3.9",
"vitest": "^0.29.8",
"the-new-css-reset": "^1.8.6"
"the-new-css-reset": "^1.11.2"
},
"type": "module"
}
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] 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 7d5d294

Please sign in to comment.