= { [P in K]?: V[K] };
- export type T = (input: I, parentKey?: string) => Output;
+ export type T = (input: I, preserveArrays?: boolean, parentKey?: string) => Output;
}
export module Logger {
@@ -22,8 +22,23 @@ export module Logger {
};
export type FactoryProps = {
+ /**
+ * You can setup your custom logger using this property.
+ *
+ * @default console
+ */
logger?: Logger.T;
+ /**
+ * You can manage log level using this property.
+ *
+ * @default 'warn'
+ */
level?: Logger.Level;
+ /**
+ * You can prefix output logs using this property.
+ *
+ * @default '[i18n]: '
+ */
prefix?: Logger.Prefix;
};
}
@@ -42,18 +57,61 @@ export module Config {
export type FallbackValue = any;
export type T = {
+ /**
+ * You can use loaders to define your asyncronous translation load. All loaded data are stored so loader is triggered only once – in case there is no previous version of the translation. It can get refreshed according to `config.cache`.
+ */
loaders?: Loader[];
+ /**
+ * Locale-indexed translations, which should be in place before loaders will trigger. It's useful for static pages and synchronous translations – for example locally defined language names which are the same for all of the language mutations.
+ *
+ * @example {
+ * "en": {"lang": {"en": "English", "cs": "Česky"}}
+ * "cs": {"lang": {"en": "English", "cs": "Česky"}}
+ * }
+ */
translations?: Translations.T;
+ /**
+ * If you set this property, translations will be initialized immediately using this locale.
+ */
initLocale?: InitLocale;
+ /**
+ * If you set this property, translations are automatically loaded not for current `$locale` only, but for this locale as well. In case there is no translation for current `$locale`, fallback locale translation is used instead of translation key placeholder. This is also used as a fallback when unknown locale is set.
+ */
fallbackLocale?: FallbackLocale;
+ /**
+ * By default, translation key is returned in case no translation is found for given translation key. For example, `$t('unknown.key')` will result in `'unknown.key'` output. You can set this output value using this config prop.
+ */
fallbackValue?: FallbackValue;
+ /**
+ * Preprocessor strategy or a custom function. Defines, how to transform the translation data immediately after the load.
+ * @default 'full'
+ *
+ * @example 'full'
+ * {a: {b: [{c: {d: 1}}, {c: {d: 2}}]}} => {"a.b.0.c.d": 1, "a.b.1.c.d": 2}
+ *
+ * @example 'preserveArrays'
+ * {a: {b: [{c: {d: 1}}, {c: {d: 2}}]}} => {"a.b": [{"c.d": 1}, {"c.d": 2}]}
+ *
+ * @example 'none'
+ * {a: {b: [{c: {d: 1}}, {c: {d: 2}}]}} => {a: {b: [{c: {d: 1}}, {c: {d: 2}}]}}
+ */
+ preprocess?: 'full' | 'preserveArrays' | 'none' | ((input: Translations.Input) => any);
+ /**
+ * This property defines translation syntax you want to use.
+ */
parser: Parser.T
;
+ /**
+ * When you are running your app on Node.js server, translations are loaded only once during the SSR. This property allows you to setup a refresh period in milliseconds when your translations are refetched on the server.
+ *
+ * @default 86400000 // 24 hours
+ *
+ * @tip You can set to `Number.POSITIVE_INFINITY` to disable server-side refreshing.
+ */
cache?: number;
- log?: {
- level?: Logger.Level;
- logger?: Logger.T;
- prefix?: Logger.Prefix;
- };
+ /**
+ * Custom logger configuration.
+ */
+ log?: Logger.FactoryProps;
};
}
@@ -67,10 +125,22 @@ export module Loader {
export type IndexedKeys = Translations.LocaleIndexed;
export type LoaderModule = {
+ /**
+ * Represents the translation namespace. This key is used as a translation prefix so it should be module-unique. You can access your translation later using `$t('key.yourTranslation')`. It shouldn't include `.` (dot) character.
+ */
key: Key;
+ /**
+ * Locale (e.g. `en`, `de`) which is this loader for.
+ */
locale: Locale;
- routes?: Route[];
+ /**
+ * Function returning a `Promise` with translation data. You can use it to load files locally, fetch it from your API etc...
+ */
loader: T;
+ /**
+ * Define routes this loader should be triggered for. You can use Regular expressions too. For example `[/\/.ome/]` will be triggered for `/home` and `/rome` route as well (but still only once). Leave this `undefined` in case you want to load this module with any route (useful for common translations).
+ */
+ routes?: Route[];
};
export type T = () => Promise;
@@ -88,21 +158,38 @@ export module Parser {
export type Output = any;
export type Parse = (
+ /**
+ * Translation value from the definitions.
+ */
value: Value,
+ /**
+ * Array of rest parameters given by user (e.g. payload variables etc...)
+ */
params: P,
+ /**
+ * Locale of translated message.
+ */
locale: Locale,
+ /**
+ * This key is serialized path to translation (e.g., `home.content.title`)
+ */
key: Key,
) => Output;
export type T
= {
+ /**
+ * Parse function deals with interpolation of user payload and returns interpolated message.
+ */
parse: Parse
;
};
}
export module Translations {
- export type Locales = string[];
+ export type Locales = T[];
+
+ export type SerializedTranslations = LocaleIndexed;
- export type SerializedTranslations = LocaleIndexed;
+ export type TranslationData = Loader.LoaderModule & { data: T };
export type FetchTranslations = (loaders: Loader.LoaderModule[]) => Promise;
@@ -120,9 +207,9 @@ export module Translations {
fallbackValue?: Config.FallbackValue;
}) => string;
- export type Input ={ [K in any]: Input | V };
+ export type Input = { [K in any]: Input | V };
- export type LocaleIndexed = { [locale in Locales[number]]: V };
+ export type LocaleIndexed = { [locale in Locales[number]]: V };
- export type T = LocaleIndexed >;
+ export type T = LocaleIndexed , L>;
}
\ No newline at end of file
diff --git a/src/utils.ts b/src/utils.ts
index 9473411..0369a0b 100644
--- a/src/utils.ts
+++ b/src/utils.ts
@@ -28,7 +28,7 @@ export const translate: Translations.Translate = ({
return parser.parse(text, params, locale, key);
};
-export const sanitizeLocales = (...locales: any[]): string[] => {
+export const sanitizeLocales = (...locales: any[]) => {
if (!locales.length) return [];
return locales.filter((locale) => !!locale).map((locale) => {
@@ -47,18 +47,35 @@ export const sanitizeLocales = (...locales: any[]): string[] => {
});
};
-export const toDotNotation: DotNotation.T = (input, parentKey) => Object.keys(input || {}).reduce((acc, key) => {
+export const toDotNotation: DotNotation.T = (input, preserveArrays, parentKey) => Object.keys(input || {}).reduce((acc, key) => {
const value = (input as any)[key];
const outputKey = parentKey ? `${parentKey}.${key}` : `${key}`;
- if (value && typeof value === 'object') return ({ ...acc, ...toDotNotation(value, outputKey) });
+ if (preserveArrays && Array.isArray(value)) return ({ ...acc, [outputKey]: value.map(v => toDotNotation(v, preserveArrays)) });
+
+ if (value && typeof value === 'object') return ({ ...acc, ...toDotNotation(value, preserveArrays, outputKey) });
return ({ ...acc, [outputKey]: value });
}, {});
+export const serialize = (input: Translations.TranslationData[]) => {
+ return input.reduce((acc, { key, data, locale }) => {
+ if (!data) return acc;
+
+ const [validLocale] = sanitizeLocales(locale);
+
+ const output = { ...(acc[validLocale] || {}), [key]: data };
+
+ return ({
+ ...acc,
+ [validLocale]: output,
+ });
+ }, {} as Translations.SerializedTranslations);
+};
+
export const fetchTranslations: Translations.FetchTranslations = async (loaders) => {
try {
- const response = await Promise.all(loaders.map(({ loader, ...rest }) => new Promise(async (res) => {
+ const response = await Promise.all(loaders.map(({ loader, ...rest }) => new Promise(async (res) => {
let data;
try {
data = await loader();
@@ -69,16 +86,7 @@ export const fetchTranslations: Translations.FetchTranslations = async (loaders)
res({ loader, ...rest, data });
})));
- return response.reduce((acc, { key, data, locale }) => {
- if (!data) return acc;
-
- const [validLocale] = sanitizeLocales(locale);
-
- return ({
- ...acc,
- [validLocale]: toDotNotation({ ...(acc[validLocale] || {}), [key]: data }),
- });
- }, {} as DotNotation.Input);
+ return serialize(response);
} catch (error) {
logger.error(error);
}
@@ -106,7 +114,7 @@ export const checkProps = (props: any, object: any) => {
).every(
(key) => props[key] === object[key],
);
- } catch (error) {}
+ } catch (error) { }
return out;
};
diff --git a/tests/data/index.ts b/tests/data/index.ts
index caee643..efaa930 100644
--- a/tests/data/index.ts
+++ b/tests/data/index.ts
@@ -1,5 +1,5 @@
import type { Config } from '../../src';
-export { default as TRANSLATIONS } from './translations';
+export { default as getTranslations } from './translations';
export const CONFIG: Config.T = {
initLocale: 'en',
@@ -13,24 +13,24 @@ export const CONFIG: Config.T = {
{
key: 'common',
locale: 'EN',
- loader: async () => (import('../data/translations/en/common.json')),
+ loader: async () => (await import('../data/translations/en/common.json')).default,
},
{
key: 'route1',
locale: 'EN',
routes: [/./],
- loader: async () => (import('../data/translations/en/route.json')),
+ loader: async () => (await import('../data/translations/en/route.json')).default,
},
{
key: 'route2',
locale: 'EN',
routes: ['/path#hash?a=b&c=d'],
- loader: async () => (import('../data/translations/en/route.json')),
+ loader: async () => (await import('../data/translations/en/route.json')).default,
},
{
key: 'common',
locale: 'zh-Hans',
- loader: async () => (import('../data/translations/zh-Hans/common.json')),
+ loader: async () => (await import('../data/translations/zh-Hans/common.json')).default,
},
],
};
\ No newline at end of file
diff --git a/tests/data/translations/en/common.json b/tests/data/translations/en/common.json
index baca64c..708bbd7 100644
--- a/tests/data/translations/en/common.json
+++ b/tests/data/translations/en/common.json
@@ -13,5 +13,10 @@
"modifier_date": "{{value:date;}}",
"modifier_ago": "{{value:ago;}}",
"modifier_custom": "{{data:test;}}",
- "modifier_escaped": "{{va\\:lue; option\\:1:VA\\;\\{\\{LUE\\}\\}\\:1; option\\:2:VA\\;\\{\\{LUE\\}\\}\\:2; default:DEFAULT \\{\\{VALUE\\}\\}\\;}}"
+ "modifier_escaped": "{{va\\:lue; option\\:1:VA\\;\\{\\{LUE\\}\\}\\:1; option\\:2:VA\\;\\{\\{LUE\\}\\}\\:2; default:DEFAULT \\{\\{VALUE\\}\\}\\;}}",
+ "preprocess": [
+ {
+ "test": "passed"
+ }
+ ]
}
\ No newline at end of file
diff --git a/tests/data/translations/index.ts b/tests/data/translations/index.ts
index b812014..3e862a0 100644
--- a/tests/data/translations/index.ts
+++ b/tests/data/translations/index.ts
@@ -1,21 +1,29 @@
import { toDotNotation } from '../../../src/utils';
import type { Translations } from '../../../src/types';
-import * as common from './en/common.json';
-import * as route from './en/route.json';
-import * as common_ku from './ku/common.json';
-import * as common_zhHans from './zh-Hans/common.json';
+import { default as common } from './en/common.json';
+import { default as route } from './en/route.json';
+import { default as common_ku } from './ku/common.json';
+import { default as common_zhHans } from './zh-Hans/common.json';
-export default ({
- en: toDotNotation({
+export default (preprocess = 'full') => {
+ const en = {
common,
route1: route,
route2: route,
- }),
- 'zh-Hans': toDotNotation({
+ };
+
+ const zhHans = {
common: common_zhHans,
- }),
- ku: toDotNotation({
+ };
+
+ const ku = {
common: common_ku,
- }),
-}) as Translations.T;
\ No newline at end of file
+ };
+
+ return ({
+ en: preprocess === 'none' ? en : toDotNotation(en, preprocess === 'preserveArays'),
+ 'zh-Hans': preprocess === 'none' ? zhHans : toDotNotation(zhHans, preprocess === 'preserveArays'),
+ ku: preprocess === 'none' ? ku : toDotNotation(ku, preprocess === 'preserveArays'),
+ }) as Translations.T;
+};
\ No newline at end of file
diff --git a/tests/specs/index.spec.ts b/tests/specs/index.spec.ts
index 18e7a31..d4a46ad 100644
--- a/tests/specs/index.spec.ts
+++ b/tests/specs/index.spec.ts
@@ -1,8 +1,10 @@
import { get } from 'svelte/store';
import i18n from '../../src/index';
-import { CONFIG, TRANSLATIONS } from '../data';
+import { CONFIG, getTranslations } from '../data';
import { filterTranslationKeys } from '../utils';
+const TRANSLATIONS = getTranslations();
+
const { initLocale = '', loaders = [], parser, log } = CONFIG;
describe('i18n instance', () => {
@@ -21,6 +23,8 @@ describe('i18n instance', () => {
toHaveProperty('loadConfig');
toHaveProperty('loadTranslations');
toHaveProperty('addTranslations');
+ toHaveProperty('setLocale');
+ toHaveProperty('setRoute');
});
it('`setRoute` method does not trigger loading if locale is not set', async () => {
const { initialized, setRoute, loading, locale } = new i18n({ loaders, parser, log });
@@ -42,25 +46,40 @@ describe('i18n instance', () => {
const $loading = loading.get();
expect($loading).toBe(true);
- const $initialized = get(initialized);
+ let $initialized = get(initialized);
expect($initialized).toBe(false);
+
+ await loading.toPromise();
+
+ $initialized = get(initialized);
+ expect($initialized).toBe(true);
});
it('`setLocale` method does not trigger loading when route is not set', async () => {
- const { setLocale, loading } = new i18n({ loaders, parser, log });
+ const { setLocale, loading, translations } = new i18n({ loaders, parser, log });
setLocale(initLocale);
const $loading = loading.get();
expect($loading).toBe(false);
+
+ await loading.toPromise();
+
+ const $translations = translations.get();
+ expect(Object.keys($translations).length).toBe(0);
});
it('`setLocale` method triggers loading when route is set', async () => {
- const { setLocale, setRoute, loading } = new i18n({ loaders, parser, log });
+ const { setLocale, setRoute, loading, translations } = new i18n({ loaders, parser, log });
await setRoute('');
setLocale(initLocale);
const $loading = loading.get();
expect($loading).toBe(true);
+
+ await loading.toPromise();
+
+ const $translations = translations.get();
+ expect(Object.keys($translations).length).toBeGreaterThan(0);
});
it('`setLocale` does not set `unknown` locale', async () => {
const { setLocale, loading, locale } = new i18n({ loaders, parser, log });
@@ -119,7 +138,7 @@ describe('i18n instance', () => {
});
it('`locale` can be non-standard', async () => {
const nonStandardLocale = 'ku';
- const { loading, locale, locales, setRoute, initialized, translations } = new i18n({ loaders: [{ key: 'common', locale: `${nonStandardLocale}`.toUpperCase(), loader: () => import(`../data/translations/${nonStandardLocale}/common.json`) }], parser, log });
+ const { loading, locale, locales, setRoute, initialized, translations } = new i18n({ loaders: [{ key: 'common', locale: `${nonStandardLocale}`.toUpperCase(), loader: async () => (await import(`../data/translations/${nonStandardLocale}/common.json`)).default }], parser, log });
await setRoute('');
locale.set(nonStandardLocale);
@@ -151,7 +170,7 @@ describe('i18n instance', () => {
const keys = loaders.filter(({ routes }) => !routes).map(({ key }) => key);
expect(translations[initLocale]).toEqual(
- expect.objectContaining(filterTranslationKeys(TRANSLATIONS[initLocale], keys)),
+ expect.objectContaining(filterTranslationKeys(getTranslations('none')[initLocale], keys)),
);
expect($initialized).toBe(false);
@@ -166,7 +185,7 @@ describe('i18n instance', () => {
expect.objectContaining(TRANSLATIONS),
);
});
- it('`addTranslations` prevents duplicit `loading`', async () => {
+ it('`addTranslations` prevents duplicit load', async () => {
const { addTranslations, loadTranslations, loading } = new i18n({ loaders, parser, log });
addTranslations(TRANSLATIONS);
@@ -174,6 +193,33 @@ describe('i18n instance', () => {
expect(loading.get()).toBe(false);
});
+ it('`preprocess` works when set to `full`', async () => {
+ const { loadTranslations, translations } = new i18n({ loaders, parser, log, preprocess: 'full' });
+
+ await loadTranslations(initLocale);
+
+ const $translations = translations.get();
+
+ expect($translations[initLocale]['common.preprocess.0.test']).toBe('passed');
+ });
+ it('`preprocess` works when set to `preserveArrays`', async () => {
+ const { loadTranslations, translations } = new i18n({ loaders, parser, log, preprocess: 'preserveArrays' });
+
+ await loadTranslations(initLocale);
+
+ const $translations = translations.get();
+
+ expect($translations[initLocale]['common.preprocess'][0].test).toBe('passed');
+ });
+ it('`preprocess` works when set to `none`', async () => {
+ const { loadTranslations, translations } = new i18n({ loaders, parser, log, preprocess: 'none' });
+
+ await loadTranslations(initLocale);
+
+ const $translations = translations.get();
+
+ expect($translations[initLocale].common.preprocess[0].test).toBe('passed');
+ });
it('initializes properly with `initLocale`', async () => {
const { initialized, loadConfig } = new i18n();
@@ -339,8 +385,8 @@ describe('i18n instance', () => {
expect($t(key)).toBe(key);
});
it('logger works as expected', async () => {
- const debug = jest.spyOn(console, 'debug');
- const warn = jest.spyOn(console, 'warn');
+ const debug = import.meta.jest.spyOn(console, 'debug');
+ const warn = import.meta.jest.spyOn(console, 'warn');
const { loading } = new i18n({
...CONFIG,
diff --git a/tsconfig.json b/tsconfig.json
index 59182fd..6701cb8 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -3,7 +3,6 @@
"module": "ESNext",
"noImplicitReturns": true,
"noUnusedLocals": true,
- "removeComments": true,
"outDir": "lib",
"sourceMap": false,
"strict": true,
@@ -11,6 +10,7 @@
"moduleResolution": "node",
"noEmit": true,
"resolveJsonModule": true,
+ "allowSyntheticDefaultImports": true
},
"include": [
"./src",
diff --git a/tsup.config.js b/tsup.config.js
index 70f2e7c..dee05fd 100644
--- a/tsup.config.js
+++ b/tsup.config.js
@@ -1,14 +1,12 @@
-// eslint-disable-next-line import/no-extraneous-dependencies
+/* eslint-disable */
import { defineConfig } from 'tsup';
-export default defineConfig((options) => {
- return {
- clean: true,
- dts: true,
- format: ['cjs', 'esm'],
- entry: ['src/index.ts'],
- watch: options.watch && ['src/*'],
- minify: !options.watch,
- sourcemap: options.watch,
- };
-});
\ No newline at end of file
+export default defineConfig((options) => ({
+ clean: true,
+ dts: true,
+ format: ['cjs', 'esm'],
+ entry: ['src/index.ts'],
+ watch: options.watch && ['src/*'],
+ minify: !options.watch,
+ sourcemap: options.watch,
+}));
\ No newline at end of file