feat: 🎸 Support getting deep localized objects/arrays

 Closes: Closes #83
This commit is contained in:
Christian Kaisermann 2020-11-05 09:44:40 -03:00
parent 5c3191d424
commit ff541367f8
12 changed files with 6919 additions and 210 deletions

1
.nvmrc
View File

@ -1 +0,0 @@
13.2.0

View File

@ -77,6 +77,7 @@
"@babel/preset-env": "^7.11.5", "@babel/preset-env": "^7.11.5",
"@kiwi/eslint-config": "^1.2.0", "@kiwi/eslint-config": "^1.2.0",
"@kiwi/prettier-config": "^1.1.0", "@kiwi/prettier-config": "^1.1.0",
"@types/dlv": "^1.1.2",
"@types/estree": "0.0.45", "@types/estree": "0.0.45",
"@types/intl": "^1.2.0", "@types/intl": "^1.2.0",
"@types/jest": "^26.0.14", "@types/jest": "^26.0.14",
@ -102,6 +103,8 @@
}, },
"dependencies": { "dependencies": {
"commander": "^4.0.1", "commander": "^4.0.1",
"deepmerge": "^4.2.2",
"dlv": "^1.1.3",
"estree-walker": "^0.9.0", "estree-walker": "^0.9.0",
"intl-messageformat": "^7.5.2", "intl-messageformat": "^7.5.2",
"tiny-glob": "^0.2.6" "tiny-glob": "^0.2.6"

View File

@ -10,8 +10,8 @@ import {
import { walk } from 'estree-walker'; import { walk } from 'estree-walker';
import { Ast } from 'svelte/types/compiler/interfaces'; import { Ast } from 'svelte/types/compiler/interfaces';
import { parse } from 'svelte/compiler'; import { parse } from 'svelte/compiler';
import dlv from 'dlv';
import { deepGet } from './includes/deepGet';
import { deepSet } from './includes/deepSet'; import { deepSet } from './includes/deepSet';
import { getObjFromExpression } from './includes/getObjFromExpression'; import { getObjFromExpression } from './includes/getObjFromExpression';
import { Message } from './types'; import { Message } from './types';
@ -191,7 +191,7 @@ export function extractMessages(
} else { } else {
if ( if (
overwrite === false && overwrite === false &&
typeof deepGet(accumulator, message.meta.id) !== 'undefined' typeof dlv(accumulator, message.meta.id) !== 'undefined'
) { ) {
return; return;
} }

View File

@ -1,9 +0,0 @@
export const deepGet = (o: Record<string, any>, id: string) => {
return id.split('.').reduce((acc, path) => {
if (typeof acc !== 'object') {
return acc;
}
return acc[path];
}, o);
};

View File

@ -1,17 +0,0 @@
// could use a reduce, but a simple for-in has less footprint
export const flatObj = (obj: Record<string, any>, prefix = '') => {
const flatted: Record<string, string> = {};
for (const key in obj) {
const flatKey = prefix + key;
// we want plain objects and arrays
if (typeof obj[key] === 'object') {
Object.assign(flatted, flatObj(obj[key], `${flatKey}.`));
} else {
flatted[flatKey] = obj[key];
}
}
return flatted;
};

View File

@ -1,11 +1,12 @@
import { writable, derived } from 'svelte/store'; import { writable, derived } from 'svelte/store';
import deepmerge from 'deepmerge';
import dlv from 'dlv';
import { LocaleDictionary, DeepDictionary, Dictionary } from '../types/index'; import { LocaleDictionary, LocalesDictionary } from '../types/index';
import { flatObj } from '../includes/flatObj';
import { getFallbackOf } from './locale'; import { getFallbackOf } from './locale';
let dictionary: Dictionary; let dictionary: LocalesDictionary;
const $dictionary = writable<Dictionary>({}); const $dictionary = writable<LocalesDictionary>({});
export function getLocaleDictionary(locale: string) { export function getLocaleDictionary(locale: string) {
return (dictionary[locale] as LocaleDictionary) || null; return (dictionary[locale] as LocaleDictionary) || null;
@ -20,15 +21,15 @@ export function hasLocaleDictionary(locale: string) {
} }
export function getMessageFromDictionary(locale: string, id: string) { export function getMessageFromDictionary(locale: string, id: string) {
if (hasLocaleDictionary(locale)) { if (!hasLocaleDictionary(locale)) {
const localeDictionary = getLocaleDictionary(locale); return null;
if (id in localeDictionary) {
return localeDictionary[id];
}
} }
return null; const localeDictionary = getLocaleDictionary(locale);
const match = dlv(localeDictionary, id);
return match;
} }
export function getClosestAvailableLocale(locale: string): string | null { export function getClosestAvailableLocale(locale: string): string | null {
@ -37,11 +38,9 @@ export function getClosestAvailableLocale(locale: string): string | null {
return getClosestAvailableLocale(getFallbackOf(locale)); return getClosestAvailableLocale(getFallbackOf(locale));
} }
export function addMessages(locale: string, ...partials: DeepDictionary[]) { export function addMessages(locale: string, ...partials: LocaleDictionary[]) {
const flattedPartials = partials.map((partial) => flatObj(partial));
$dictionary.update((d) => { $dictionary.update((d) => {
d[locale] = Object.assign(d[locale] || {}, ...flattedPartials); d[locale] = deepmerge.all<LocaleDictionary>([d[locale] || {}, ...partials]);
return d; return d;
}); });

View File

@ -1,10 +1,11 @@
import { Formats } from 'intl-messageformat'; import { Formats } from 'intl-messageformat';
export interface DeepDictionary { export interface LocaleDictionary {
[key: string]: DeepDictionary | string | string[]; [key: string]: LocaleDictionary | LocaleDictionary[] | string | string[];
} }
export type LocaleDictionary = Record<string, string>; export type LocalesDictionary = {
export type Dictionary = Record<string, LocaleDictionary>; [key: string]: LocaleDictionary;
};
export interface MessageObject { export interface MessageObject {
id?: string; id?: string;

View File

@ -1,17 +0,0 @@
import { deepGet } from '../../src/cli/includes/deepGet'
describe('deep object handling', () => {
test('gets a deep property', () => {
const obj = {
a: { b: { c: { d: ['foo', 'bar'] } } },
}
expect(deepGet(obj, 'a.b.c.d.1')).toBe('bar')
})
test('returns undefined for if some property not found', () => {
const obj = {
a: { b: 1 },
}
expect(deepGet(obj, 'c.b')).toBe(undefined)
})
})

View File

@ -1,51 +1,82 @@
import { lookup, lookupCache } from '../../../src/runtime/includes/lookup' import { lookup, lookupCache } from '../../../src/runtime/includes/lookup';
import { $dictionary, addMessages } from '../../../src/runtime/stores/dictionary' import {
$dictionary,
addMessages,
} from '../../../src/runtime/stores/dictionary';
beforeEach(() => { beforeEach(() => {
$dictionary.set({}) $dictionary.set({});
}) });
test('returns null if no locale was passed', () => { test('returns null if no locale was passed', () => {
expect(lookup('message.id', undefined)).toBe(null) expect(lookup('message.id', undefined)).toBeNull();
expect(lookup('message.id', null)).toBe(null) expect(lookup('message.id', null)).toBeNull();
}) });
test('gets a shallow message of a locale dictionary', () => { test('gets a shallow message of a locale dictionary', () => {
addMessages('en', { field: 'name' }) addMessages('en', { field: 'name' });
expect(lookup('field', 'en')).toBe('name') expect(lookup('field', 'en')).toBe('name');
}) });
test('gets a deep message of a locale dictionary', () => { test('gets a deep message of a locale dictionary', () => {
addMessages('en', { deep: { field: 'lastname' } }) addMessages('en', { deep: { field: 'lastname' } });
expect(lookup('deep.field', 'en')).toBe('lastname') expect(lookup('deep.field', 'en')).toBe('lastname');
}) });
test('gets a message from the fallback dictionary', () => { test('gets a message from the fallback dictionary', () => {
addMessages('en', { field: 'name' }) addMessages('en', { field: 'name' });
expect(lookup('field', 'en-US')).toBe('name') expect(lookup('field', 'en-US')).toBe('name');
}) });
test('gets an array ', () => {
addMessages('en', {
careers: [
{
role: 'Role 1',
description: 'Description 1',
},
{
role: 'Role 2',
description: 'Description 2',
},
],
});
expect(lookup('careers', 'en-US')).toMatchInlineSnapshot(`
Array [
Object {
"description": "Description 1",
"role": "Role 1",
},
Object {
"description": "Description 2",
"role": "Role 2",
},
]
`);
});
test('caches found messages by locale', () => { test('caches found messages by locale', () => {
addMessages('en', { field: 'name' }) addMessages('en', { field: 'name' });
addMessages('pt', { field: 'nome' }) addMessages('pt', { field: 'nome' });
lookup('field', 'en-US') lookup('field', 'en-US');
lookup('field', 'pt') lookup('field', 'pt');
expect(lookupCache).toMatchObject({ expect(lookupCache).toMatchObject({
'en-US': { field: 'name' }, 'en-US': { field: 'name' },
pt: { field: 'nome' }, pt: { field: 'nome' },
}) });
}) });
test("doesn't cache falsy messages", () => { test("doesn't cache falsy messages", () => {
addMessages('en', { field: 'name' }) addMessages('en', { field: 'name' });
addMessages('pt', { field: 'nome' }) addMessages('pt', { field: 'nome' });
lookup('field_2', 'en-US') lookup('field_2', 'en-US');
lookup('field_2', 'pt') lookup('field_2', 'pt');
expect(lookupCache).not.toMatchObject({ expect(lookupCache).not.toMatchObject({
'en-US': { field_2: 'name' }, 'en-US': { field_2: 'name' },
pt: { field_2: 'nome' }, pt: { field_2: 'nome' },
}) });
}) });

View File

@ -4,71 +4,44 @@ import {
getLocaleFromNavigator, getLocaleFromNavigator,
getLocaleFromPathname, getLocaleFromPathname,
getLocaleFromHostname, getLocaleFromHostname,
} from '../../../src/runtime/includes/localeGetters' } from '../../../src/runtime/includes/localeGetters';
import { flatObj } from '../../../src/runtime/includes/flatObj'
describe('getting client locale', () => { describe('getting client locale', () => {
beforeEach(() => { beforeEach(() => {
delete window.location delete window.location;
window.location = { window.location = {
pathname: '/', pathname: '/',
hostname: 'example.com', hostname: 'example.com',
hash: '', hash: '',
search: '', search: '',
} as any } as any;
}) });
test('gets the locale based on the passed hash parameter', () => { it('gets the locale based on the passed hash parameter', () => {
window.location.hash = '#locale=en-US&lang=pt-BR' window.location.hash = '#locale=en-US&lang=pt-BR';
expect(getLocaleFromHash('lang')).toBe('pt-BR') expect(getLocaleFromHash('lang')).toBe('pt-BR');
}) });
test('gets the locale based on the passed search parameter', () => { it('gets the locale based on the passed search parameter', () => {
window.location.search = '?locale=en-US&lang=pt-BR' window.location.search = '?locale=en-US&lang=pt-BR';
expect(getLocaleFromQueryString('lang')).toBe('pt-BR') expect(getLocaleFromQueryString('lang')).toBe('pt-BR');
}) });
test('gets the locale based on the navigator language', () => { it('gets the locale based on the navigator language', () => {
expect(getLocaleFromNavigator()).toBe(window.navigator.language) expect(getLocaleFromNavigator()).toBe(window.navigator.language);
}) });
test('gets the locale based on the pathname', () => { it('gets the locale based on the pathname', () => {
window.location.pathname = '/en-US/foo/' window.location.pathname = '/en-US/foo/';
expect(getLocaleFromPathname(/^\/(.*?)\//)).toBe('en-US') expect(getLocaleFromPathname(/^\/(.*?)\//)).toBe('en-US');
}) });
test('gets the locale base on the hostname', () => { it('gets the locale base on the hostname', () => {
window.location.hostname = 'pt.example.com' window.location.hostname = 'pt.example.com';
expect(getLocaleFromHostname(/^(.*?)\./)).toBe('pt') expect(getLocaleFromHostname(/^(.*?)\./)).toBe('pt');
}) });
test('returns null if no locale was found', () => { it('returns null if no locale was found', () => {
expect(getLocaleFromQueryString('lang')).toBe(null) expect(getLocaleFromQueryString('lang')).toBeNull();
}) });
}) });
describe('deep object handling', () => {
test('flattens a deep object', () => {
const obj = {
a: { b: { c: { d: 'foo' } } },
e: { f: 'bar' },
}
expect(flatObj(obj)).toMatchObject({
'a.b.c.d': 'foo',
'e.f': 'bar',
})
})
test('flattens a deep object with array values', () => {
const obj = {
a: { b: { c: { d: ['foo', 'bar'] } } },
e: { f: ['foo', 'bar'] },
}
expect(flatObj(obj)).toMatchObject({
'a.b.c.d.0': 'foo',
'a.b.c.d.1': 'bar',
'e.f.0': 'foo',
'e.f.1': 'bar',
})
})
})

View File

@ -1,4 +1,4 @@
import { get } from 'svelte/store' import { get } from 'svelte/store';
import { import {
getDictionary, getDictionary,
@ -9,107 +9,105 @@ import {
$dictionary, $dictionary,
$locales, $locales,
getLocaleDictionary, getLocaleDictionary,
} from '../../../src/runtime/stores/dictionary' } from '../../../src/runtime/stores/dictionary';
beforeEach(() => { beforeEach(() => {
$dictionary.set({}) $dictionary.set({});
}) });
test('adds a new dictionary to a locale', () => { test('adds a new dictionary to a locale', () => {
addMessages('en', { field_1: 'name' }) addMessages('en', { field_1: 'name' });
addMessages('pt', { field_1: 'nome' }) addMessages('pt', { field_1: 'nome' });
expect(get($dictionary)).toMatchObject({ expect(get($dictionary)).toMatchObject({
en: { field_1: 'name' }, en: { field_1: 'name' },
pt: { field_1: 'nome' }, pt: { field_1: 'nome' },
}) });
}) });
test('gets the whole current dictionary', () => { test('gets the whole current dictionary', () => {
addMessages('en', { field_1: 'name' }) addMessages('en', { field_1: 'name' });
expect(getDictionary()).toMatchObject(get($dictionary)) expect(getDictionary()).toMatchObject(get($dictionary));
}) });
test('merges the existing dictionaries with new ones', () => { test('merges the existing dictionaries with new ones', () => {
addMessages('en', { field_1: 'name', deep: { prop1: 'foo' } }) addMessages('en', { field_1: 'name', deep: { prop1: 'foo' } });
addMessages('en', { field_2: 'lastname', deep: { prop2: 'foo' } }) addMessages('en', { field_2: 'lastname', deep: { prop2: 'foo' } });
addMessages('pt', { field_1: 'nome', deep: { prop1: 'foo' } }) addMessages('pt', { field_1: 'nome', deep: { prop1: 'foo' } });
addMessages('pt', { field_2: 'sobrenome', deep: { prop2: 'foo' } }) addMessages('pt', { field_2: 'sobrenome', deep: { prop2: 'foo' } });
expect(get($dictionary)).toMatchObject({ expect(get($dictionary)).toMatchObject({
en: { en: {
field_1: 'name', field_1: 'name',
field_2: 'lastname', field_2: 'lastname',
'deep.prop1': 'foo', deep: { prop1: 'foo', prop2: 'foo' },
'deep.prop2': 'foo',
}, },
pt: { pt: {
field_1: 'nome', field_1: 'nome',
field_2: 'sobrenome', field_2: 'sobrenome',
'deep.prop1': 'foo', deep: { prop1: 'foo', prop2: 'foo' },
'deep.prop2': 'foo',
}, },
}) });
}) });
test('gets the dictionary of a locale', () => { test('gets the dictionary of a locale', () => {
addMessages('en', { field_1: 'name' }) addMessages('en', { field_1: 'name' });
expect(getLocaleDictionary('en')).toMatchObject({ field_1: 'name' }) expect(getLocaleDictionary('en')).toMatchObject({ field_1: 'name' });
}) });
test('checks if a locale dictionary exists', () => { test('checks if a locale dictionary exists', () => {
addMessages('pt', { field_1: 'name' }) addMessages('pt', { field_1: 'name' });
expect(hasLocaleDictionary('en')).toBe(false) expect(hasLocaleDictionary('en')).toBe(false);
expect(hasLocaleDictionary('pt')).toBe(true) expect(hasLocaleDictionary('pt')).toBe(true);
}) });
test('gets the closest available locale', () => { test('gets the closest available locale', () => {
addMessages('pt', { field_1: 'name' }) addMessages('pt', { field_1: 'name' });
expect(getClosestAvailableLocale('pt-BR')).toBe('pt') expect(getClosestAvailableLocale('pt-BR')).toBe('pt');
}) });
test("returns null if there's no closest locale available", () => { test("returns null if there's no closest locale available", () => {
addMessages('pt', { field_1: 'name' }) addMessages('pt', { field_1: 'name' });
expect(getClosestAvailableLocale('it-IT')).toBe(null) expect(getClosestAvailableLocale('it-IT')).toBeNull();
}) });
test('lists all locales in the dictionary', () => { test('lists all locales in the dictionary', () => {
addMessages('en', {}) addMessages('en', {});
addMessages('pt', {}) addMessages('pt', {});
addMessages('pt-BR', {}) addMessages('pt-BR', {});
expect(get($locales)).toEqual(['en', 'pt', 'pt-BR']) expect(get($locales)).toEqual(['en', 'pt', 'pt-BR']);
}) });
describe('getting messages', () => { describe('getting messages', () => {
test('gets a message from a shallow dictionary', () => { it('gets a message from a shallow dictionary', () => {
addMessages('en', { message: 'Some message' }) addMessages('en', { message: 'Some message' });
expect(getMessageFromDictionary('en', 'message')).toBe('Some message') expect(getMessageFromDictionary('en', 'message')).toBe('Some message');
}) });
test('gets a message from a deep object in the dictionary', () => { it('gets a message from a deep object in the dictionary', () => {
addMessages('en', { messages: { message_1: 'Some message' } }) addMessages('en', { messages: { message_1: 'Some message' } });
expect(getMessageFromDictionary('en', 'messages.message_1')).toBe( expect(getMessageFromDictionary('en', 'messages.message_1')).toBe(
'Some message' 'Some message',
) );
}) });
test('gets a message from an array in the dictionary', () => { it('gets a message from an array in the dictionary', () => {
addMessages('en', { messages: ['Some message', 'Other message'] }) addMessages('en', { messages: ['Some message', 'Other message'] });
expect(getMessageFromDictionary('en', 'messages.0')).toBe('Some message') expect(getMessageFromDictionary('en', 'messages.0')).toBe('Some message');
expect(getMessageFromDictionary('en', 'messages.1')).toBe('Other message') expect(getMessageFromDictionary('en', 'messages.1')).toBe('Other message');
}) });
test('accepts english in dictionary keys', () => { it('accepts english in dictionary keys', () => {
addMessages('pt', { addMessages('pt', {
'Hey man, how are you today?': 'E ai cara, como você vai hoje?', 'Hey man, how are you today?': 'E ai cara, como você vai hoje?',
}) });
expect(getMessageFromDictionary('pt', 'Hey man, how are you today?')).toBe( expect(getMessageFromDictionary('pt', 'Hey man, how are you today?')).toBe(
'E ai cara, como você vai hoje?' 'E ai cara, como você vai hoje?',
) );
}) });
test('returns null for missing messages', () => { it('returns undefined for missing messages', () => {
addMessages('en', {}) addMessages('en', {});
expect(getMessageFromDictionary('en', 'foo')).toBe(null) expect(getMessageFromDictionary('en', 'foo')).toBeUndefined();
}) });
}) });

6748
yarn.lock Normal file

File diff suppressed because it is too large Load Diff