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

added support for nested json #100

Open
wants to merge 1 commit into
base: master
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
61 changes: 59 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ ___
- [Wiring It Up](#wiring-it-up)
- [Module Setup](#module-setup)
- [Localization File Formats](#localization-file-formats)
- [Nested Json](#nested-json)
- [Usage Examples](#usage-examples)
- [i18n directive](#i18n-directive)
- [Localize Using the i18n attribute](#localize-using-the-i18n-attribute)
Expand Down Expand Up @@ -148,9 +149,12 @@ All overridable configuration options are part of `localeConf` within the `ngLoc
###### delimiter @ `::`
> the delimiter to be used when passing params along with the string token to the service, filter etc.

###### validTokens @ `\^[\\w\\.-]+\\.[\\w\\s\\.-]+\\w(:.*)?$\`
###### validTokens @ `\^([\\w-]+\\/)*([\\w-]+\\.)+([\\w\\s-])+\\w(:.*)?$\`
> a regular expression which is used to match the names of the keys, so that they do not contain invalid characters. If you want to support an extended character set for the key names you need to change this.

###### allowNestedJson @ `false`
> allows you to use nested json in your languages files. This changes how you must set your i18n tokens. Consult the [Nested Json](#nested-json) section for more details.

```js
angular.module('myApp', [
'ngLocalize',
Expand All @@ -164,7 +168,8 @@ angular.module('myApp', [
cookieName: 'COOKIE_LOCALE_LANG',
observableAttrs: new RegExp('^data-(?!ng-|i18n)'),
delimiter: '::',
validTokens: new RegExp('^[\\w\\.-]+\\.[\\w\\s\\.-]+\\w(:.*)?$')
validTokens: new RegExp('^([\\w-]+\\/)*([\\w-]+\\.)+([\\w\\s-])+\\w(:.*)?$'),
allowNestedJson: false
});
```

Expand Down Expand Up @@ -305,6 +310,58 @@ The `i18n` directive observes all non-directive `data-*` attributes and passes t

Whenever `user.name` is updated, it's indicator in the token `helloWorld` is subsitituted for the new value when the translation function gets called with an object, e.g. `{ name: 'Bob' }` as an argument and the element content is then updated accordingly.

### Nested json

If you set the ```allowNestedJson``` option to ```true``` in the ```localeConf```, there are few things that change.

First of all, to navigate files and folders, you need to use forward slashes to delimit instead of dots.
Dots now traverse potential objects in a json file instead of traversing the file system.

Example:

```html
<p data-i18n="common.helloWorld" data-name="{{ folder1/folder2/myFile.user.name }}"></p>

```

Will fetch the file `myFile` in the folder1/folder2 directories. Inside that file, it expects to find

```json
{
"user": {
"name": "Nom d'usager"
}
}
```

__Legacy support:__ By default, nested json is disallowed, which means that older project should not encounter any issues.
However, if you have a legacy project that you wish to change to nested json, a refactoring __will__ be necessary (mainly replacing the `.` by `/` in the i18n tokens. ie: `common.errors.required` would need to become `common/errors.required`);

__NOTE:__ When constructing its dictionary, it builds an object for each "namespace" (json files). If you have a file structure as such:

```
-languages
|-en-US
|-common
|-errors.lang.json
|-common.lang.json
|-en-FR
|-...
```

it will bundle up the keys from common and the sub keys from the common folder files together. This should not cause any issues unless you have conflicting keys. For example, if common.lang.json had this key:

```json
{
"errors": {
"required": "Requis",
"notFound": "Introuvable"
}
}
```

then the bundled `common` dictionnary would have conflicting common.errors objects. As a general guideline, avoiding folders and files of the same name at the same level will avoid these issues.

### i18nAttr directive

The i18n Attribute directive is pretty much the same as the i18n directive except that it expects a json-like object structure represented as a set of key-value pair combinations.
Expand Down
3 changes: 2 additions & 1 deletion src/localization.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ angular.module('ngLocalize.Config', [])
cookieName: 'COOKIE_LOCALE_LANG',
observableAttrs: new RegExp('^data-(?!ng-|i18n)'),
delimiter: '::',
validTokens: new RegExp('^[\\w\\.-]+\\.[\\w\\s\\.-]+\\w(:.*)?$')
validTokens: new RegExp('^([\\w-]+\\/)*([\\w-]+\\.)+([\\w\\s-])+\\w(:.*)?$'),
allowNestedJson: false
});
59 changes: 42 additions & 17 deletions src/localization.js
Original file line number Diff line number Diff line change
@@ -1,45 +1,56 @@
angular.module('ngLocalize')
.service('locale', function ($injector, $http, $q, $log, $rootScope, $window, localeConf, localeEvents, localeSupported, localeFallbacks) {
var TOKEN_REGEX = localeConf.validTokens || new RegExp('^[\\w\\.-]+\\.[\\w\\s\\.-]+\\w(:.*)?$'),
var TOKEN_REGEX = localeConf.validTokens || new RegExp('^([\\w-]+\\/)*([\\w-]+\\.)+([\\w\\s-])+\\w(:.*)?$'),
$html = angular.element(document.body).parent(),
currentLocale,
deferrences,
bundles,
cookieStore;
cookieStore,
sym = localeConf.allowNestedJson ? '/' : '.';

if (localeConf.persistSelection && $injector.has('$cookieStore')) {
cookieStore = $injector.get('$cookieStore');
}

function splitToken(tok){
var key, path;
if (localeConf.allowNestedJson){
var split = tok ? tok.split('.') : [];
key = split.length > 1 ? split.slice(1) : '';
path = split.length > 0 ? split[0].split(sym) : [];
path.push(split.length > 1 ? split[1] : '');
} else {
path = tok ? tok.split('/').join('.').split('.') : [];
key = path.length > 1 ? path[path.length - 1] : '';
}
return {
key: key,
path: path
};
}

function isToken(str) {
return (str && str.length && TOKEN_REGEX.test(str));
}

function getPath(tok) {
var path = tok ? tok.split('.') : '',
var path = splitToken(tok).path,
result = '';

if (path.length > 1) {
result = path.slice(0, -1).join('.');
result = path.slice(0, -1).join(sym);
}

return result;
}

function getKey(tok) {
var path = tok ? tok.split('.') : [],
result = '';

if (path.length) {
result = path[path.length - 1];
}

return result;
return splitToken(tok).key;
}

function getBundle(tok) {
var result = null,
path = tok ? tok.split('.') : [],
path = splitToken(tok).path,
i;

if (path.length > 1) {
Expand Down Expand Up @@ -78,7 +89,7 @@ angular.module('ngLocalize')
}

function loadBundle(token) {
var path = token ? token.split('.') : '',
var path = splitToken(token).path,
root = bundles,
parent,
locale = currentLocale,
Expand Down Expand Up @@ -156,7 +167,6 @@ angular.module('ngLocalize')

path = path || localeConf.langFile;
token = path + '._LOOKUP_';

bundle = getBundle(token);

if (!deferrences[path]) {
Expand Down Expand Up @@ -248,9 +258,24 @@ angular.module('ngLocalize')
bundle = getBundle(txt);
if (bundle && !bundle._loading) {
key = getKey(txt);

if (localeConf.allowNestedJson){
for (var i = 0; i < key.length - 1; i++) {
if (bundle[key[i]]) {
bundle = bundle[key[i]];
} else {
bundle = null;
break;
}
}
key = key[key.length -1];
}
if (bundle[key]) {
result = applySubstitutions(bundle[key], subs);
if (angular.isString(bundle[key])){
result = applySubstitutions(bundle[key], subs);
} else {
$log.info('[localizationService] Key is not a string: ' + txt + '. Is it a nested object?');
result = '%%KEY_NOT_STRING%%';
}
} else {
$log.info('[localizationService] Key not found: ' + txt);
result = '%%KEY_NOT_FOUND%%';
Expand Down
111 changes: 106 additions & 5 deletions tests/unit/serviceSpec.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ describe('service', function () {
beforeEach(module('ngLocalize'));

describe('locale', function () {
var $rootScope, mockWindow;
var $rootScope,
mockWindow,
localeConf;

// Mock $window
beforeEach(module(function ($provide) {
Expand All @@ -14,8 +16,9 @@ describe('service', function () {
$provide.value('$window', mockWindow);
}));

beforeEach(inject(function (_$rootScope_) {
beforeEach(inject(function (_$rootScope_, _localeConf_) {
$rootScope = _$rootScope_;
localeConf = _localeConf_;
}));
afterEach(inject(function($httpBackend) {
$rootScope.$apply();
Expand Down Expand Up @@ -73,9 +76,33 @@ describe('service', function () {
expect(locale.getPreferredBrowserLanguage()).toBe('ab-CD');
}));

it('should validate tokens with whitespace', inject(function (locale) {
expect(locale.isToken('test.hello world')).toBe(true);
}));
describe('token validation', function(){
it('should validate tokens with whitespace', inject(function (locale) {
expect(locale.isToken('test.hello world')).toBe(true);
}));
it('should allow only word letters as first character', inject(function(locale){
expect(locale.isToken('/common.hello')).toBe(false);
expect(locale.isToken('common.hello')).toBe(true);
}));
it('should allow forward slashes before the first dot', inject(function(locale){
expect(locale.isToken('common/folder1/folder2/file.hello')).toBe(true);
}));
it('should forbid forward slashes after the first dot', inject(function(locale){
expect(locale.isToken('common/folder1.folder2/file.hello')).toBe(false);
}));
it('should validate tokens with multiple dots', inject(function(locale){
expect(locale.isToken('common/folder1/folder2/file.nested.json.object.hello')).toBe(true);
}));
it('should validate tokens without forward slashes', inject(function(locale){
expect(locale.isToken('common.folder1.folder2.file.hello')).toBe(true);
}));
it('should invalidate tokens without letters between forward slashes or dots', inject(function(locale){
expect(locale.isToken('common/.folder1')).toBe(false);
}));
it('should invalidate tokens without letters between forward slashes or dots', inject(function(locale){
expect(locale.isToken('common..folder1')).toBe(false);
}));
});

it('should update the lang attribute of the html tag', inject(function (locale) {
locale.setLocale('en-US');
Expand Down Expand Up @@ -124,5 +151,79 @@ describe('service', function () {
});
$httpBackend.flush();
}));

describe('when allowing nested json', function(){
beforeEach(function(){
localeConf.allowNestedJson = true;
});

it('should use forward slashes instead of dots to navigate the file system', inject(function(locale, $httpBackend){
$httpBackend.expectGET('languages/en-US/common/folder1/folder2/file.lang.json').respond(200, {
"yes": "Yes"
});
locale.ready('common/folder1/folder2/file').then(function() {
expect(locale.getString('common/folder1/folder2/file.yes')).toBe('Yes');
});
$httpBackend.flush();
}));

it('should treat dots as object property delimiters and return the deeply nested string', inject(function(locale, $httpBackend){
$httpBackend.expectGET('languages/en-US/common/folder1/folder2/file.lang.json').respond(200, {
"nested": {
"json": {
"object": {
"yes": "Yes"
}
}
}
});
locale.ready('common/folder1/folder2/file').then(function() {
expect(locale.getString('common/folder1/folder2/file.nested.json.object.yes')).toBe('Yes');
});
$httpBackend.flush();
}));

it('should return %%KEY_NOT_STRING%% when the nested property is not a string', inject(function(locale, $httpBackend){
$httpBackend.expectGET('languages/en-US/common/folder1/folder2/file.lang.json').respond(200, {
"nested": {
"json": {
"object": {
"yes": "Yes"
}
}
}
});
locale.ready('common/folder1/folder2/file').then(function() {
expect(locale.getString('common/folder1/folder2/file.nested.json')).toBe('%%KEY_NOT_STRING%%');
});
$httpBackend.flush();
}));
});

describe('when not allowing nested json', function(){
beforeEach(function(){
localeConf.allowNestedJson = false;
});

it('should treat forward slashes as dots to navigate the file system', inject(function(locale, $httpBackend){
$httpBackend.expectGET('languages/en-US/common/folder1/folder2/file.lang.json').respond(200, {
"yes": "Yes"
});
locale.ready('common/folder1/folder2/file').then(function() {
expect(locale.getString('common/folder1/folder2/file.yes')).toBe('Yes');
});
$httpBackend.flush();
}));

it('should only use the last part of the token as a key', inject(function(locale, $httpBackend){
$httpBackend.expectGET('languages/en-US/common/folder1/folder2/file/not/nested/json/object.lang.json').respond(200, {
"yes": "Yes"
});
locale.ready('common.folder1.folder2.file.not.nested.json.object').then(function() {
expect(locale.getString('common/folder1/folder2/file.not.nested.json.object.yes')).toBe('Yes');
});
$httpBackend.flush();
}));
});
});
});