Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow to initialize a non-singleton i18n client. #179

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions src/runtime/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { setContext, getContext, hasContext, onDestroy } from 'svelte';

import { applyOptions, getOptions } from './configs';
import { $isLoading, createLoadingStore } from './stores/loading';
import { $locale, createLocaleStore } from './stores/locale';
import {
$format,
$formatDate,
$formatNumber,
$formatTime,
$getJSON,
createFormattingStores,
} from './stores/formatters';

import type { Writable, Readable } from 'svelte/store';
import type {
MessageFormatter,
TimeFormatter,
DateFormatter,
NumberFormatter,
JSONGetter,
ConfigureOptionsInit,
} from './types';

export type I18nClient = {
locale: Writable<string | null | undefined>;
isLoading: Readable<boolean>;
format: Readable<MessageFormatter>;
t: Readable<MessageFormatter>;
_: Readable<MessageFormatter>;
time: Readable<TimeFormatter>;
date: Readable<DateFormatter>;
number: Readable<NumberFormatter>;
json: Readable<JSONGetter>;
};

export function createI18nClient(opts?: ConfigureOptionsInit): I18nClient {
const isLoading = createLoadingStore();

const options = { ...getOptions() };
const initialLocale = applyOptions(opts, options);

const { localeStore } = createLocaleStore(isLoading, options);

localeStore.set(initialLocale);

const { format, formatTime, formatDate, formatNumber, getJSON } =
createFormattingStores(localeStore, () => options);

return {
locale: localeStore,
isLoading,
format,
t: format,
_: format,
time: formatTime,
date: formatDate,
number: formatNumber,
json: getJSON,
};
}

const globalClient: I18nClient = {
locale: $locale,
isLoading: $isLoading,
format: $format,
t: $format,
_: $format,
time: $formatTime,
date: $formatDate,
number: $formatNumber,
json: $getJSON,
};

const key = {};

const lifecycleFuncsStyle = { hasContext, setContext, getContext, onDestroy };
let lifecycleFuncs: typeof lifecycleFuncsStyle | null = null;

// Need the user to init it once, since we can't get the relevant functions by ourself by the way svelte compiling works.
// That is due to the fact that svelte is not a runtime dependency, rather just a code generator.
// It means for example that the svelte function "hasContext" is different between what this library sees,
// and what the user uses.
export function initLifecycleFuncs(funcs: typeof lifecycleFuncsStyle) {
lifecycleFuncs = { ...funcs };
}

function verifyLifecycleFuncsInit() {
if (!lifecycleFuncs) {
throw "Error: Lifecycle functions aren't initialized! Use initLifecycleFuncs() before.";
}
}

type ClientContainer = { client: I18nClient | null };

// All the functions below can be called only in Svelte component initialization.

export function setI18nClientInContext(
i18nClient: I18nClient,
): ClientContainer {
verifyLifecycleFuncsInit();

const clientContainer = { client: i18nClient };

lifecycleFuncs!.setContext(key, clientContainer);

return clientContainer;
}

export function clearI18nClientInContext(clientContainer: ClientContainer) {
clientContainer.client = null;
}

// A shortcut function that initializes i18n client in context on component initialization
// and cleans it on component destruction.
export function setupI18nClientInComponentInit(
opts?: ConfigureOptionsInit,
): I18nClient {
verifyLifecycleFuncsInit();

const client = createI18nClient(opts);
const container = setI18nClientInContext(client);

// We clean the client from the context for robustness.
// Should svelte clean it by itself?
// Anyway it seems safer, because of the ability of the user to give custom lifecycle funcs.
lifecycleFuncs!.onDestroy(() => clearI18nClientInContext(container));

return client;
}

export function getI18nClientInComponentInit(): I18nClient {
// Notice that unlike previous functions, calling this one without initializing lifecycle function is fine.
// In this case, the global client will be returned.

if (lifecycleFuncs?.hasContext(key)) {
const { client } = lifecycleFuncs!.getContext<ClientContainer>(key);

if (client !== null) {
return client;
}
}
// otherwise

return globalClient;
}
35 changes: 27 additions & 8 deletions src/runtime/configs.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { $locale, getCurrentLocale, getPossibleLocales } from './stores/locale';
import { hasLocaleQueue } from './includes/loaderQueue';

import type {
ConfigureOptions,
ConfigureOptionsInit,
MissingKeyHandlerInput,
} from './types';
import { $locale, getCurrentLocale, getPossibleLocales } from './stores/locale';
import { hasLocaleQueue } from './includes/loaderQueue';

interface Formats {
number: Record<string, any>;
Expand Down Expand Up @@ -66,15 +67,27 @@ export const defaultOptions: ConfigureOptions = {
warnOnMissingMessages: true,
handleMissingMessage: undefined,
ignoreTag: true,
autoLangAttribute: true,
};

const options: ConfigureOptions = defaultOptions as any;
// Deep copy to options
const options: ConfigureOptions = JSON.parse(
JSON.stringify(defaultOptions),
) as any;

export function getOptions() {
return options;
}

export function init(opts: ConfigureOptionsInit) {
export function applyOptions(
opts: ConfigureOptionsInit | undefined,
target: ConfigureOptions,
) {
if (opts === undefined) {
return undefined;
}
// otherwise

const { formats, ...rest } = opts;
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const initialLocale = opts.initialLocale || opts.fallbackLocale;
Expand All @@ -91,21 +104,27 @@ export function init(opts: ConfigureOptionsInit) {
}
}

Object.assign(options, rest, { initialLocale });
Object.assign(target, rest, { initialLocale });

if (formats) {
if ('number' in formats) {
Object.assign(options.formats.number, formats.number);
Object.assign(target.formats.number, formats.number);
}

if ('date' in formats) {
Object.assign(options.formats.date, formats.date);
Object.assign(target.formats.date, formats.date);
}

if ('time' in formats) {
Object.assign(options.formats.time, formats.time);
Object.assign(target.formats.time, formats.time);
}
}

return initialLocale;
}

export function init(opts: ConfigureOptionsInit) {
const initialLocale = applyOptions(opts, getOptions());

return $locale.set(initialLocale);
}
93 changes: 54 additions & 39 deletions src/runtime/includes/formatters.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import IntlMessageFormat from 'intl-messageformat';
import IntlMessageFormat, { Formats } from 'intl-messageformat';

import type {
MemoizedIntlFormatter,
Expand Down Expand Up @@ -31,9 +31,8 @@ type MemoizedDateTimeFormatterFactoryOptional = MemoizedIntlFormatterOptional<
const getIntlFormatterOptions = (
type: 'time' | 'number' | 'date',
name: string,
formats: Formats,
): any => {
const { formats } = getOptions();

if (type in formats && name in formats[type]) {
return formats[type][name];
}
Expand All @@ -42,72 +41,88 @@ const getIntlFormatterOptions = (
};

const createNumberFormatter: MemoizedNumberFormatterFactory = monadicMemoize(
({ locale, format, ...options }) => {
if (locale == null) {
({ locale, ...options }) => {
if (!locale) {
throw new Error('[svelte-i18n] A "locale" must be set to format numbers');
}

if (format) {
options = getIntlFormatterOptions('number', format);
}

return new Intl.NumberFormat(locale, options);
},
);

const createDateFormatter: MemoizedDateTimeFormatterFactory = monadicMemoize(
({ locale, format, ...options }) => {
if (locale == null) {
({ locale, ...options }) => {
if (!locale) {
throw new Error('[svelte-i18n] A "locale" must be set to format dates');
}

if (format) {
options = getIntlFormatterOptions('date', format);
} else if (Object.keys(options).length === 0) {
options = getIntlFormatterOptions('date', 'short');
}

return new Intl.DateTimeFormat(locale, options);
},
);

const createTimeFormatter: MemoizedDateTimeFormatterFactory = monadicMemoize(
({ locale, format, ...options }) => {
if (locale == null) {
({ locale, ...options }) => {
if (!locale) {
throw new Error(
'[svelte-i18n] A "locale" must be set to format time values',
);
}

if (format) {
options = getIntlFormatterOptions('time', format);
} else if (Object.keys(options).length === 0) {
options = getIntlFormatterOptions('time', 'short');
}

return new Intl.DateTimeFormat(locale, options);
},
);

const createMessageFormatter = monadicMemoize(
(message: string, locale = getCurrentLocale(), formats = getOptions().formats,
ignoreTag = getOptions().ignoreTag) => {
return new IntlMessageFormat(message, locale, formats, {
ignoreTag,
})
},
);

export const getNumberFormatter: MemoizedNumberFormatterFactoryOptional = ({
locale = getCurrentLocale(),
...args
} = {}) => createNumberFormatter({ locale, ...args });
format = undefined as (string | undefined),
formats = getOptions().formats,
...options
} = {}) => {
if (format) {
options = getIntlFormatterOptions('number', format, formats);
}

return createNumberFormatter({ locale, ...options });
}

export const getDateFormatter: MemoizedDateTimeFormatterFactoryOptional = ({
locale = getCurrentLocale(),
...args
} = {}) => createDateFormatter({ locale, ...args });
format = undefined as (string | undefined),
formats = getOptions().formats,
...options
} = {}) => {
if (format) {
options = getIntlFormatterOptions('date', format, formats);
} else if (Object.keys(options).length === 0) {
options = getIntlFormatterOptions('date', 'short', formats);
}

return createDateFormatter({ locale, ...options });
}

export const getTimeFormatter: MemoizedDateTimeFormatterFactoryOptional = ({
locale = getCurrentLocale(),
...args
} = {}) => createTimeFormatter({ locale, ...args });

export const getMessageFormatter = monadicMemoize(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
(message: string, locale: string = getCurrentLocale()!) =>
new IntlMessageFormat(message, locale, getOptions().formats, {
ignoreTag: getOptions().ignoreTag,
}),
);
format = undefined as (string | undefined),
formats = getOptions().formats,
...options
} = {}) => {
if (format) {
options = getIntlFormatterOptions('time', format, formats);
} else if (Object.keys(options).length === 0) {
options = getIntlFormatterOptions('time', 'short', formats);
}

return createTimeFormatter({ locale, ...options });
}

export const getMessageFormatter = (message: string, locale: string = getCurrentLocale()!, options = getOptions()) =>
createMessageFormatter(message, locale, options.formats, options.ignoreTag);
9 changes: 9 additions & 0 deletions src/runtime/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import {
getTimeFormatter,
getMessageFormatter,
} from './includes/formatters';
import { createI18nClient, initLifecycleFuncs, setI18nClientInContext, clearI18nClientInContext,
setupI18nClientInComponentInit, getI18nClientInComponentInit } from './client';

// defineMessages allow us to define and extract dynamic message ids
export function defineMessages(i: Record<string, MessageObject>) {
Expand Down Expand Up @@ -64,4 +66,11 @@ export {
getLocaleFromNavigator,
getLocaleFromQueryString,
getLocaleFromHash,
// Client
createI18nClient,
initLifecycleFuncs,
setI18nClientInContext,
clearI18nClientInContext,
setupI18nClientInComponentInit,
getI18nClientInComponentInit,
};
Loading