From 7e79fc4c66d0f8b282f757cc18a6da5c9036fd4e Mon Sep 17 00:00:00 2001 From: Christian Kaisermann Date: Mon, 30 Jul 2018 19:57:05 -0300 Subject: [PATCH] Add package for deep access --- package-lock.json | 5 ++ package.json | 3 +- src/formatter.ts | 123 +++++++++++++++++++++++++++++++++ src/svelte-i18n.ts | 14 ++-- src/utils.ts | 19 +++-- test/svelte-i18n.test.ts | 146 +++++++++++++++++++++++++++++++-------- 6 files changed, 264 insertions(+), 46 deletions(-) create mode 100644 src/formatter.ts diff --git a/package-lock.json b/package-lock.json index 0c6ff0d..55ec513 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7326,6 +7326,11 @@ "integrity": "sha1-xUYBd4rVYPEULODgG8yotW0TQm0=", "dev": true }, + "object-resolve-path": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-resolve-path/-/object-resolve-path-1.1.1.tgz", + "integrity": "sha1-p/j5Poogr4DkQhe6fbVDFtnRIjI=" + }, "object-visit": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz", diff --git a/package.json b/package.json index c4cb00f..deac385 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,7 @@ "validate-commit-msg": "^2.12.2" }, "dependencies": { - "deepmerge": "^2.1.1" + "deepmerge": "^2.1.1", + "object-resolve-path": "^1.1.1" } } diff --git a/src/formatter.ts b/src/formatter.ts new file mode 100644 index 0000000..c473589 --- /dev/null +++ b/src/formatter.ts @@ -0,0 +1,123 @@ +/** + * Adapted from 'https://github.com/kazupon/vue-i18n/blob/dev/src/format.js' + * Copyright (c) 2016 kazuya kawaguchi + **/ +import { isObject, warn } from './utils' + +const RE_TOKEN_LIST_VALUE: RegExp = /^(\d)+/ +const RE_TOKEN_NAMED_VALUE: RegExp = /^(\w)+/ + +type Token = { + type: 'text' | 'named' | 'list' | 'unknown' + value: string +} + +export default class Formatter { + _caches: { [key: string]: Array } + + constructor() { + this._caches = Object.create(null) + } + + interpolate(message: string, values: any): Array { + if (!values) { + return [message] + } + + let tokens: Array = this._caches[message] + if (!tokens) { + tokens = parse(message) + this._caches[message] = tokens + } + + return compile(tokens, values) + } +} + +/** Parse a identification string into cached Tokens */ +export function parse(format: string): Array { + const tokens: Array = [] + let position: number = 0 + let currentText: string = '' + + while (position < format.length) { + let char: string = format[position++] + + /** If found any character that's not a '{' (does not include '\{'), assume text */ + if (char !== '{' || (position > 0 && char[position - 1] === '\\')) { + currentText += char + } else { + /** Beginning of a interpolation */ + if (currentText.length) { + tokens.push({ type: 'text', value: currentText }) + } + + /** Reset the current text string because we're dealing interpolation entry */ + currentText = '' + + /** Key name */ + let namedKey: string = '' + char = format[position++] + + while (char !== '}') { + namedKey += char + char = format[position++] + } + + const type = RE_TOKEN_LIST_VALUE.test(namedKey) + ? 'list' + : RE_TOKEN_NAMED_VALUE.test(namedKey) + ? 'named' + : 'unknown' + + tokens.push({ value: namedKey, type }) + } + } + + /** If there's any text left, push it to the tokens list */ + if (currentText) { + tokens.push({ type: 'text', value: currentText }) + } + + return tokens +} + +export function compile(tokens: Array, values: { [id: string]: any }): Array { + const compiled: Array = [] + let index: number = 0 + + const mode: string = Array.isArray(values) ? 'list' : isObject(values) ? 'named' : 'unknown' + + if (mode === 'unknown') { + return compiled + } + + while (index < tokens.length) { + const token: Token = tokens[index++] + switch (token.type) { + case 'text': + compiled.push(token.value) + break + case 'list': + compiled.push(values[parseInt(token.value, 10)]) + break + case 'named': + if (mode === 'named') { + compiled.push(values[token.value]) + } else { + if (process.env.NODE_ENV !== 'production') { + warn(`Type of token '${token.type}' and format of value '${mode}' don't match!`) + } + } + break + + case 'unknown': + if (process.env.NODE_ENV !== 'production') { + warn(`Detect 'unknown' type of token!`) + } + break + } + } + + return compiled +} diff --git a/src/svelte-i18n.ts b/src/svelte-i18n.ts index 8df5ae0..dbd5def 100644 --- a/src/svelte-i18n.ts +++ b/src/svelte-i18n.ts @@ -1,15 +1,21 @@ -import { InterpolationObj, Sveltei18n, SvelteStore, LocaleDictionary, Locales } from './interfaces' -import { capitalize, titlelize, upper, lower, getNestedProp } from './utils' import deepmerge from 'deepmerge' +import resolvePath from 'object-resolve-path' + +import { InterpolationObj, Sveltei18n, SvelteStore, LocaleDictionary, Locales } from './interfaces' +import { capitalize, titlelize, upper, lower } from './utils' +import Formatter from './formatter' export default function(store: SvelteStore, localesList: Array) { + const formatter = new Formatter() const locales: Locales = deepmerge.all(localesList) - store.locale = (locale: string) => store.fire('locale', locale) + store.setLocale = (locale: string) => store.fire('locale', locale) store.on('locale', function(locale: string) { const localeDict: LocaleDictionary = locales[locale] const _ = function(id, values) { - return getNestedProp(localeDict, id) || id + let message = resolvePath(localeDict, id) || id + message = formatter.interpolate(message, values).join('') + return message } _.capitalize = (id, values) => capitalize(_(id, values)) diff --git a/src/utils.ts b/src/utils.ts index c316052..95b8fb4 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -3,16 +3,13 @@ export const titlelize = (str: string) => str.replace(/(^|\s)\S/g, l => l.toUppe export const upper = (str: string) => str.toLocaleUpperCase() export const lower = (str: string) => str.toLocaleLowerCase() -export const getNestedProp = (obj: { [prop: string]: any }, path: string) => { - try { - return path - .replace('[', '.') - .replace(']', '') - .split('.') - .reduce(function(o, property) { - return o[property] - }, obj) - } catch (err) { - return undefined +export const isObject = (obj: any) => obj !== null && typeof obj === 'object' + +export function warn(msg: string, err?: Error): void { + if (typeof console !== 'undefined') { + console.warn(`[svelte-i18n] ${msg}`) + if (err) { + console.warn(err.stack) + } } } diff --git a/test/svelte-i18n.test.ts b/test/svelte-i18n.test.ts index 801d4a7..3895452 100644 --- a/test/svelte-i18n.test.ts +++ b/test/svelte-i18n.test.ts @@ -1,21 +1,27 @@ import i18n from '../src/svelte-i18n' import { Store } from 'svelte/store.umd' -import { capitalize, titlelize, upper, lower, getNestedProp } from '../src/utils' +import { capitalize, titlelize, upper, lower, isObject } from '../src/utils' const store = new Store() const locales = { 'pt-br': { test: 'teste', - phrase: 'Adoro banana', + phrase: 'adoro banana', phrases: ['Frase 1', 'Frase 2'], + interpolation: { + key: 'Olá, {0}! Como está {1}?', + named: 'Olá, {name}! Como está {time}?' + }, + wow: { + much: { + deep: { + list: [, 'muito profundo'] + } + } + }, obj: { a: 'a' } - }, - po: { - test: 'prøve', - phrase: 'Jeg elsker banan', - phrases: ['sætning 1', 'sætning 2'] } } @@ -33,32 +39,112 @@ i18n(store, [ /** * Dummy test */ -describe('utils', () => { - it('works', () => { - expect(getNestedProp(store['pt-br'], 'phrases[3]')).toBe(undefined) +describe('Utilities', () => { + it('should check if a variable is an object', () => { + expect(isObject({})).toBe(true) + expect(isObject(1)).toBe(false) }) }) -describe('Dummy test', () => { - it('works if true is truthy', () => { - expect(store.get().locale).toBeFalsy() - expect(store.get()._).toBeFalsy() +describe('Localization', () => { + it('should start with a clean store', () => { + const { _, locale } = store.get() + expect(locale).toBeFalsy() + expect(_).toBeFalsy() + }) + + it('should change the locale after a "locale" store event', () => { store.fire('locale', 'en') - expect(store.get().locale).toBe('en') - store.locale('pt-br') - expect(store.get().locale).toBe('pt-br') - expect(store.get()._).toBeInstanceOf(Function) - expect(store.get()._('non-existent')).toBe('non-existent') - expect(store.get()._('test')).toBe(locales['pt-br'].test) - expect(store.get()._('obj.b')).toBe('b') - store.fire('locale', 'po') - expect(store.get()._('test')).not.toBe(locales['pt-br'].test) - expect(store.get()._('test')).toBe(locales.po.test) - expect(store.get()._('phrases[1]')).toBe(locales.po.phrases[1]) - expect(store.get()._('phrases[2]')).toBe('phrases[2]') - expect(store.get()._.capitalize('phrase')).toBe(capitalize(locales.po.phrase)) - expect(store.get()._.titlelize('phrase')).toBe(titlelize(locales.po.phrase)) - expect(store.get()._.upper('phrase')).toBe(upper(locales.po.phrase)) - expect(store.get()._.lower('phrase')).toBe(lower(locales.po.phrase)) + const { locale, _ } = store.get() + + expect(locale).toBe('en') + expect(_).toBeInstanceOf(Function) + }) + + it('should have a .setLocale() method', () => { + expect(store.setLocale).toBeInstanceOf(Function) + + store.setLocale('pt-br') + const { locale } = store.get() + + expect(locale).toBe('pt-br') + }) + + it('should return the message id when no message identified by it was found', () => { + store.setLocale('pt-br') + const { locale, _ } = store.get() + + expect(_('non.existent')).toBe('non.existent') + }) + + it('should get a message by its id', () => { + const { _ } = store.get() + expect(_('test')).toBe(locales['pt-br'].test) + }) + + it('should get a deep nested message by its string path', () => { + store.setLocale('pt-br') + const { locale, _ } = store.get() + + expect(_('obj.b')).toBe('b') + }) + + it('should get a message within an array by its index', () => { + store.setLocale('pt-br') + const { locale, _ } = store.get() + + expect(_('phrases[1]')).toBe(locales['pt-br'].phrases[1]) + + /** Not found */ + expect(_('phrases[2]')).toBe('phrases[2]') + }) + + it('should interpolate with {numeric} placeholders', () => { + store.setLocale('pt-br') + const { locale, _ } = store.get() + + expect(_('interpolation.key', ['Chris', 'o dia'])).toBe('Olá, Chris! Como está o dia?') + }) + + it('should interpolate with {named} placeholders', () => { + store.setLocale('pt-br') + const { locale, _ } = store.get() + + expect( + _('interpolation.named', { + name: 'Chris', + time: 'o dia' + }) + ).toBe('Olá, Chris! Como está o dia?') + }) +}) + +describe('Localization utilities', () => { + it('should capitalize a translated message', () => { + store.setLocale('pt-br') + const { _ } = store.get() + + expect(_.capitalize('phrase')).toBe('Adoro banana') + }) + + it('should titlelize a translated message', () => { + store.setLocale('pt-br') + const { _ } = store.get() + + expect(_.titlelize('phrase')).toBe('Adoro Banana') + }) + + it('should lowercase a translated message', () => { + store.setLocale('pt-br') + const { _ } = store.get() + + expect(_.lower('phrase')).toBe('adoro banana') + }) + + it('should uppercase a translated message', () => { + store.setLocale('pt-br') + const { _ } = store.get() + + expect(_.upper('phrase')).toBe('ADORO BANANA') }) })