Skip to content
This repository has been archived by the owner on Jul 30, 2018. It is now read-only.

Implement locale message caching. #20

Merged
merged 3 commits into from
Oct 18, 2016
Merged
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
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,39 @@ i18n(bundle, context).then(function (messages: Messages) {

If an unsupported locale is passed to `i18n`, then the default messages are returned. Further, any messages not provided by the locale-specific bundle will also fall back to their defaults. As such, the default bundle should contain _all_ message keys used by any of the locale-specific bundles.

Once locale dictionaries for a bundle have been loaded, they are cached and can be accessed synchronously via `getCachedMessages`:

```
const messages = getCachedMessages(bundle, 'fr');
console.log(messages.hello); // "Bonjour"
console.log(messages.goodbye); // "Au revoir"
```

`getCachedMessages` will look up the bundle's supported `locales` to determine whether the default messages should be returned. Locales are also normalized to their most specific messages. For example, if the 'fr' locale is supported, but 'fr-CA' is not, `getCachedMessages` will return the messages for the 'fr' locale:


```
const frenchMessages = getCachedMessages(bundle, 'fr-CA');
console.log(frenchMessages.hello); // "Bonjour"
console.log(frenchMessages.goodbye); // "Au revoir"

const madeUpLocaleMessages = getCachedMessages(bundle, 'made-up-locale');
console.log(madeUpLocaleMessages.hello); // "Hello"
console.log(madeUpLocaleMessages.goodbye); // "Goodbye"
```

If need be, bundle caches can be cleared with `invalidate`. If called with a bundle path, only the messages for that particular bundle are removed from the cache. Otherwise, all messages are cleared:

```
import i18n from 'dojo-i18n/main';
import bundle from 'nls/common';

i18n(bundle, 'ar').then(() => {
invalidate(bundle.bundlePath);
console.log(getCachedMessages(bundle, 'ar')); // undefined
});
```

### Determining the Current Locale

The current locale can be accessed via the read-only property `i18n.locale`, which will always be either the locale set via `switchLocale` (see below) or the `systemLocale`. `systemLocale` is always set to the user's default locale.
Expand Down
66 changes: 57 additions & 9 deletions src/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import has from 'dojo-core/has';
import global from 'dojo-core/global';
import { Handle } from 'dojo-core/interfaces';
import { assign } from 'dojo-core/lang';
import Map from 'dojo-shim/Map';
import Promise from 'dojo-shim/Promise';

/**
Expand All @@ -28,10 +29,6 @@ export interface Bundle<T extends Messages> {
readonly messages: T;
}

interface BundleMap<T extends Messages> {
[key: string]: T;
}

export interface I18n<T extends Messages> {
(bundle: Bundle<T>): Promise<T>;
(bundle: Bundle<T>, context: LocaleContext<LocaleState>): Promise<T>;
Expand Down Expand Up @@ -90,6 +87,7 @@ export interface Messages {
const PATH_SEPARATOR: string = has('host-node') ? global.require('path').sep : '/';
const VALID_PATH_PATTERN = new RegExp(PATH_SEPARATOR + '[^' + PATH_SEPARATOR + ']+$');
const contextObjects: LocaleContext<LocaleState>[] = [];
const bundleMap = new Map<string, Map<string, Messages>>();
let rootLocale: string;

/**
Expand Down Expand Up @@ -266,6 +264,32 @@ function validatePath(path: string): void {
}
}

/**
* Return the cached messages for the specified bundle and locale. If messages have not been previously loaded for the
* specified locale, no value will be returned.
*
* @param bundle
* The default bundle that is used to determine where the locale-specific bundles are located.
*
* @param locale
* The locale of the desired messages.
*
* @return The cached messages object, if it exists.
*/
export function getCachedMessages<T extends Messages>(bundle: Bundle<T>, locale: string): T | void {
const { bundlePath, locales } = bundle;
const supportedLocales = getSupportedLocales(locale, bundle.locales);

if (!supportedLocales.length) {
return bundle.messages;
}

const cached = bundleMap.get(bundlePath);
if (cached) {
return cached.get(supportedLocales[supportedLocales.length - 1]) as T;
}
}

/**
* Load locale-specific messages for the specified bundle and locale.
*
Expand Down Expand Up @@ -305,15 +329,23 @@ function i18n<T extends Messages>(bundle: Bundle<T>, context?: any): Promise<T>
});
}

const localePaths = resolveLocalePaths(path, locale, locales);

if (!localePaths.length) {
return Promise.resolve(messages);
const cachedMessages = getCachedMessages(bundle, locale);
if (cachedMessages) {
return Promise.resolve(cachedMessages);
}

const localePaths = resolveLocalePaths(path, locale, locales);
return loadLocaleBundles(localePaths).then((bundles: T[]): T => {
return bundles.reduce((previous: T, partial: T, i: number): T => {
return bundles.reduce((previous: T, partial: T): T => {
const localeMessages = assign({}, previous, partial);
let localeCache = bundleMap.get(bundlePath);

if (!localeCache) {
localeCache = new Map<string, Messages>();
bundleMap.set(bundlePath, localeCache);
}

localeCache.set(locale, Object.freeze(localeMessages));
return localeMessages;
}, messages);
});
Expand All @@ -327,6 +359,22 @@ Object.defineProperty(i18n, 'locale', {

export default i18n as I18n<Messages>;

/**
* Invalidate the cache for a particular bundle, or invalidate the entire cache. Note that cached messages for all
* locales for a given bundle will be cleared.
*
* @param bundlePath
* The optional path of the bundle to invalidate. If no path is provided, then the cache is cleared for all bundles.
*/
export function invalidate(bundlePath?: string) {
if (bundlePath) {
bundleMap.delete(bundlePath);
}
else {
bundleMap.clear();
}
}

/**
* Change the root locale, and invalidate any registered statefuls.
*
Expand Down
4 changes: 4 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import i18n, {
Bundle,
getCachedMessages,
invalidate,
LocaleContext,
Messages,
switchLocale,
Expand All @@ -10,6 +12,8 @@ export default i18n;

export {
Bundle,
getCachedMessages,
invalidate,
LocaleContext,
Messages,
switchLocale,
Expand Down
2 changes: 1 addition & 1 deletion tests/support/mocks/common/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const locales = [

// TODO: The default loader attempts to use the native Node.js `require` when running on Node. However, the Intern
// suite uses the dojo-loader, in which case the context for requires is the location of the loader module; or in
// this case, `node_modules/dojo-loader/loader.min..js'. Is there a better, less hacky way to handle this?
// this case, `node_modules/dojo-loader/loader.min.js'. Is there a better, less hacky way to handle this?
const basePath = has('host-node') ? '../_build/' : '';
const bundlePath = basePath + 'tests/support/mocks/common/main';

Expand Down
68 changes: 67 additions & 1 deletion tests/unit/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,15 @@ import has from 'dojo-core/has';
import { Handle } from 'dojo-core/interfaces';
import * as registerSuite from 'intern!object';
import * as assert from 'intern/chai!assert';
import i18n, { LocaleContext, LocaleState, Messages, switchLocale, systemLocale } from '../../src/i18n';
import i18n, { getCachedMessages, invalidate, LocaleContext, LocaleState, Messages, switchLocale, systemLocale } from '../../src/i18n';
import bundle from '../support/mocks/common/main';

registerSuite({
name: 'i18n',

afterEach() {
switchLocale('');
invalidate();
},

systemLocale() {
Expand All @@ -27,6 +28,35 @@ registerSuite({
assert.strictEqual(systemLocale, expected);
},

getCachedMessages: {
'assert unregistered locale'() {
assert.isUndefined(getCachedMessages(bundle, 'ar'));
},

'assert supported locale'() {
return i18n(bundle, 'ar').then(() => {
assert.deepEqual(getCachedMessages(bundle, 'ar'), {
hello: 'السلام عليكم',
helloReply: 'و عليكم السام',
goodbye: 'مع السلامة'
}, 'Locale messages can be retrieved with a bundle object.');
});
},

'assert unsupported locale'(this: any) {
const cached = getCachedMessages(bundle, 'bogus-locale');
assert.deepEqual(cached, bundle.messages, 'Default messages returned for unsupported locale.');
},

'assert most specific supported locale returned'() {
return i18n(bundle, 'ar').then(() => {
const cached = getCachedMessages(bundle, 'ar');
assert.deepEqual(getCachedMessages(bundle, 'ar-IR'), cached,
'Messages are returned for the most specific supported locale.');
});
}
},

i18n: {
'assert invalid path'() {
const pathless = {
Expand Down Expand Up @@ -134,6 +164,42 @@ registerSuite({
goodbye: 'Goodbye'
}, 'Default messages returned when bundle provides no locales.');
});
},

'assert messages cached'() {
return i18n(bundle, 'ar-JO').then(function () {
return i18n(bundle, 'ar-JO');
}).then((messages: Messages) => {
const cached = getCachedMessages(bundle, 'ar-JO');

assert.strictEqual(cached, messages, 'Message dictionaries are cached.');
});
},

'assert message dictionaries are frozen'() {
return i18n(bundle, 'ar-JO').then(function () {
const cached = getCachedMessages(bundle, 'ar-JO');

assert.throws(() => {
cached['hello'] = 'Hello';
});
});
}
},

invalidate: {
'assert with a bundle path'() {
return i18n(bundle, 'ar').then((messages: Messages) => {
invalidate(bundle.bundlePath);
assert.isUndefined(getCachedMessages(bundle, 'ar'), 'The cache is invalidated for the specified bundle.');
});
},

'assert without a bundle path'() {
return i18n(bundle, 'ar').then((messages: Messages) => {
invalidate();
assert.isUndefined(getCachedMessages(bundle, 'ar'), 'The cache is invalidated for all bundles.');
});
}
},

Expand Down
4 changes: 3 additions & 1 deletion tests/unit/main.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import * as registerSuite from 'intern!object';
import * as assert from 'intern/chai!assert';
import i18n, { switchLocale, systemLocale } from '../../src/main';
import i18n, { getCachedMessages, invalidate, switchLocale, systemLocale } from '../../src/main';

registerSuite({
name: 'main',

i18n() {
assert.isFunction(getCachedMessages, 'getCachedMessages is exported.');
assert.isFunction(invalidate, 'invalidate is exported.');
assert.isFunction(i18n, 'i18n is exported.');
assert.isFunction(switchLocale, 'switchLocale is exported.');
assert.isString(systemLocale, 'systemLocale is exported.');
Expand Down