diff --git a/projects/components/i18n/index.ts b/projects/components/i18n/index.ts new file mode 100644 index 00000000..c74f953a --- /dev/null +++ b/projects/components/i18n/index.ts @@ -0,0 +1,3 @@ +// export what ./public_api exports so we can import with the lib name like this: +// import { ModuleA } from 'libname' +export * from './public_api'; diff --git a/projects/components/i18n/package.json b/projects/components/i18n/package.json new file mode 100644 index 00000000..dedb72ce --- /dev/null +++ b/projects/components/i18n/package.json @@ -0,0 +1,7 @@ +{ + "ngPackage": { + "lib": { + "entryFile": "index.ts" + } + } +} diff --git a/projects/components/i18n/public_api.ts b/projects/components/i18n/public_api.ts new file mode 100644 index 00000000..489c7498 --- /dev/null +++ b/projects/components/i18n/public_api.ts @@ -0,0 +1,11 @@ +/* + * Public API Surface of i18n + */ + +export { TranslatePipe } from './src/translate.pipe'; +export { TranslateService } from './src/translate.service'; +export { Translations } from './src/translations'; +export { I18nModule } from './src/i18n.module'; + +export { localizeServerKey } from './src/server-key'; +export { translate } from './src/helper'; diff --git a/projects/components/i18n/src/helper.ts b/projects/components/i18n/src/helper.ts new file mode 100644 index 00000000..a882a0db --- /dev/null +++ b/projects/components/i18n/src/helper.ts @@ -0,0 +1,17 @@ +import { TranslateService } from './translate.service'; + +export function keyValuePairsToDictionary(keyValuePairs: { key: string; value: string }[]): { [key: string]: string } { + const dict: { [key: string]: string } = {}; + for (const kvp of keyValuePairs) { + dict[kvp.key] = kvp.value; + } + return dict; +} + +export function translate(ts: TranslateService, dict: T): T { + const result: any = {}; + for (const dictKey of Object.keys(dict)) { + result[dictKey] = ts.localize((dict as any)[dictKey]); + } + return result; +} diff --git a/projects/components/i18n/src/i18n.module.ts b/projects/components/i18n/src/i18n.module.ts new file mode 100644 index 00000000..6322efad --- /dev/null +++ b/projects/components/i18n/src/i18n.module.ts @@ -0,0 +1,9 @@ +import { NgModule } from '@angular/core'; + +import { TranslatePipe } from './translate.pipe'; + +@NgModule({ + exports: [TranslatePipe], + declarations: [TranslatePipe], +}) +export class I18nModule {} diff --git a/projects/components/i18n/src/message-format.spec.ts b/projects/components/i18n/src/message-format.spec.ts new file mode 100644 index 00000000..82a46e16 --- /dev/null +++ b/projects/components/i18n/src/message-format.spec.ts @@ -0,0 +1,423 @@ +import { MessageFormat } from './message-format'; + +describe('MessageFormat', () => { + describe('compile', () => { + it('should echo translation with only text', () => { + const mf = new MessageFormat('en-US'); + const compiler = mf.compile('Hello World!'); + expect(compiler()).toBe('Hello World!'); + }); + + it('should replace constiables', () => { + const mf = new MessageFormat('en-US'); + const compiler = mf.compile('Hello {name}!'); + expect(compiler({ name: 'Test' })).toBe('Hello Test!'); + }); + + it('should not replace constiables in quotes', () => { + const mf = new MessageFormat('en-US'); + const compiler = mf.compile("Hello '{name}'!"); + expect(compiler({ name: 'Test' })).toBe('Hello {name}!'); + }); + + it('should handle escaped quotes', () => { + const mf = new MessageFormat('en-US'); + const compiler = mf.compile("Hello ''{name}''!"); + expect(compiler({ name: 'Test' })).toBe("Hello 'Test'!"); + }); + + it('should handle escaped quotes in a quote', () => { + const mf = new MessageFormat('en-US'); + const compiler = mf.compile("This '{isn''t}' obvious"); + expect(compiler()).toBe("This {isn't} obvious"); + }); + + it('should replace consecutive single quotes with one single quote', () => { + const mf = new MessageFormat('en-US'); + const compiler = mf.compile("Anna''s house a'{''''b'"); + expect(compiler()).toBe("Anna's house a{''b"); + }); + + it('should handle {const, number}', () => { + const mf = new MessageFormat('en-US'); + const compiler = mf.compile('{const, number}'); + expect(compiler({ const: 99 })).toBe('99'); + }); + + it('should handle {const, number, percent}', () => { + const mf = new MessageFormat('en-US'); + const compiler = mf.compile('{const, number, percent}'); + expect(compiler({ const: 0.99 })).toBe('99%'); + }); + + it('should handle selects (string match case)', () => { + const mf = new MessageFormat('en-US'); + const compiler = mf.compile('{const, select, a {A} other {B}}'); + expect(compiler({ const: 'a' })).toBe('A'); + }); + + it('should handle selects (other case)', () => { + const mf = new MessageFormat('en-US'); + const compiler = mf.compile('{const, select, a {A} other {B}}'); + expect(compiler({ const: 'c' })).toBe('B'); + }); + + // Google Closure tests ( https://raw.githubusercontent.com/google/closure-library/master/closure/goog/i18n/messageformat_test.js ) + function assertEquals(expected: string, got: string) { + expect(got).toBe(expected); + } + + it('EmptyPattern', () => { + const fmt = new MessageFormat('en-US').compile(''); + assertEquals('', fmt({})); + }); + + // it('MissingLeftCurlyBrace', () => { + // const err = assertThrows(function() { + // const fmt = new MessageFormat('en-US').compile("''{}}"); + // fmt({}); + // }); + // assertEquals('Assertion failed: No matching { for }.', err.message); + // }); + + // it('TooManyLeftCurlyBraces', () => { + // const err = assertThrows(function() { + // const fmt = new MessageFormat('en-US').compile('{} {'); + // fmt({}); + // }); + // assertEquals('Assertion failed: There are mismatched { or } in the pattern.', err.message); + // }); + + it('SimpleReplacement', () => { + const fmt = new MessageFormat('en-US').compile('New York in {SEASON} is nice.'); + assertEquals('New York in the Summer is nice.', fmt({ SEASON: 'the Summer' })); + }); + + it('SimpleSelect', () => { + const fmt = new MessageFormat('en-US').compile( + '{GENDER, select,' + + 'male {His} ' + + 'female {Her} ' + + 'other {Its}}' + + ' bicycle is {GENDER, select, male {blue} female {red} other {green}}.' + ); + + assertEquals('His bicycle is blue.', fmt({ GENDER: 'male' })); + assertEquals('Her bicycle is red.', fmt({ GENDER: 'female' })); + assertEquals('Its bicycle is green.', fmt({ GENDER: 'other' })); + assertEquals('Its bicycle is green.', fmt({ GENDER: 'whatever' })); + }); + + it('SimplePlural', () => { + const fmt = new MessageFormat('en-US').compile( + 'I see {NUM_PEOPLE, plural, offset:1 ' + + '=0 {no one at all in {PLACE}.} ' + + '=1 {{PERSON} in {PLACE}.} ' + + 'one {{PERSON} and one other person in {PLACE}.} ' + + 'other {{PERSON} and # other people in {PLACE}.}}' + ); + + assertEquals('I see no one at all in Belgrade.', fmt({ NUM_PEOPLE: 0, PLACE: 'Belgrade' })); + assertEquals('I see Markus in Berlin.', fmt({ NUM_PEOPLE: 1, PERSON: 'Markus', PLACE: 'Berlin' })); + assertEquals('I see Mark and one other person in Athens.', fmt({ NUM_PEOPLE: 2, PERSON: 'Mark', PLACE: 'Athens' })); + assertEquals('I see Cibu and 99 other people in the cubes.', fmt({ NUM_PEOPLE: 100, PERSON: 'Cibu', PLACE: 'the cubes' })); + }); + + it('SimplePluralNoOffset', () => { + const fmt = new MessageFormat('en-US').compile( + 'I see {NUM_PEOPLE, plural, ' + + '=0 {no one at all} ' + + '=1 {{PERSON}} ' + + 'one {{PERSON} and one other person} ' + + 'other {{PERSON} and # other people}} in {PLACE}.' + ); + + assertEquals('I see no one at all in Belgrade.', fmt({ NUM_PEOPLE: 0, PLACE: 'Belgrade' })); + assertEquals('I see Markus in Berlin.', fmt({ NUM_PEOPLE: 1, PERSON: 'Markus', PLACE: 'Berlin' })); + assertEquals('I see Mark and 2 other people in Athens.', fmt({ NUM_PEOPLE: 2, PERSON: 'Mark', PLACE: 'Athens' })); + assertEquals('I see Cibu and 100 other people in the cubes.', fmt({ NUM_PEOPLE: 100, PERSON: 'Cibu', PLACE: 'the cubes' })); + }); + + it('SelectNestedInPlural', () => { + const fmt = new MessageFormat('en-US').compile( + '{CIRCLES, plural, ' + + 'one {{GENDER, select, ' + + ' female {{WHO} added you to her circle} ' + + ' other {{WHO} added you to his circle}}} ' + + 'other {{GENDER, select, ' + + ' female {{WHO} added you to her # circles} ' + + ' other {{WHO} added you to his # circles}}}}' + ); + + assertEquals('Jelena added you to her circle', fmt({ GENDER: 'female', WHO: 'Jelena', CIRCLES: 1 })); + assertEquals('Milan added you to his 1,234 circles', fmt({ GENDER: 'male', WHO: 'Milan', CIRCLES: 1234 })); + }); + + it('PluralNestedInSelect', () => { + // Added offset just for testing purposes. It doesn't make sense + // to have it otherwise. + const fmt = new MessageFormat('en-US').compile( + '{GENDER, select, ' + + 'female {{NUM_GROUPS, plural, ' + + ' one {{WHO} added you to her group} ' + + ' other {{WHO} added you to her # groups}}} ' + + 'other {{NUM_GROUPS, plural, offset:1' + + ' one {{WHO} added you to his group} ' + + ' other {{WHO} added you to his # groups}}}}' + ); + + assertEquals('Jelena added you to her group', fmt({ GENDER: 'female', WHO: 'Jelena', NUM_GROUPS: 1 })); + assertEquals('Milan added you to his 1,233 groups', fmt({ GENDER: 'male', WHO: 'Milan', NUM_GROUPS: 1234 })); + }); + + it('LiteralOpenCurlyBrace', () => { + const fmt = new MessageFormat('en-US').compile("Anna's house" + " has '{0} and # in the roof' and {NUM_COWS} cows."); + assertEquals("Anna's house has {0} and # in the roof and 5 cows.", fmt({ NUM_COWS: '5' })); + }); + + it('LiteralClosedCurlyBrace', () => { + const fmt = new MessageFormat('en-US').compile("Anna's house" + " has '{'0'} and # in the roof' and {NUM_COWS} cows."); + assertEquals("Anna's house has {0} and # in the roof and 5 cows.", fmt({ NUM_COWS: '5' })); + }); + + it('LiteralPoundSign', () => { + const fmt = new MessageFormat('en-US').compile("Anna's house" + " has '{0}' and '# in the roof' and {NUM_COWS} cows."); + assertEquals("Anna's house has {0} and # in the roof and 5 cows.", fmt({ NUM_COWS: '5' })); + }); + + it('NoLiteralsForSingleQuotes', () => { + const fmt = new MessageFormat('en-US').compile("Anna's house" + " 'has {NUM_COWS} cows'."); + assertEquals("Anna's house 'has 5 cows'.", fmt({ NUM_COWS: '5' })); + }); + + it('ConsecutiveSingleQuotesAreReplacedWithOneSingleQuote', () => { + const fmt = new MessageFormat('en-US').compile("Anna''s house a'{''''b'"); + assertEquals("Anna's house a{''b", fmt({})); + }); + + it('ConsecutiveSingleQuotesBeforeSpecialCharDontCreateLiteral', () => { + const fmt = new MessageFormat('en-US').compile("a''{NUM_COWS}'b"); + assertEquals("a'5'b", fmt({ NUM_COWS: '5' })); + }); + + it('SerbianSimpleSelect', () => { + // stubs.set(goog.i18n.pluralRules, 'select', goog.i18n.pluralRules.beSelect_); + + const fmt = new MessageFormat('sr-RS').compile( + '{GENDER, select, ' + 'female {Njen} other {Njegov}} bicikl je ' + '{GENDER, select, female {crven} other {plav}}.' + ); + + assertEquals('Njegov bicikl je plav.', fmt({ GENDER: 'male' })); + assertEquals('Njen bicikl je crven.', fmt({ GENDER: 'female' })); + }); + + it('SerbianSimplePlural', () => { + // stubs.set(goog.i18n.pluralRules, 'select', goog.i18n.pluralRules.beSelect_); + + const fmt = new MessageFormat('sr-RS').compile( + 'Ja {NUM_PEOPLE, plural, offset:1 ' + + '=0 {ne vidim nikoga} ' + + '=1 {vidim {PERSON}} ' + + 'one {vidim {PERSON} i jos # osobu} ' + + 'few {vidim {PERSON} i jos # osobe} ' + + 'many {vidim {PERSON} i jos # osoba} ' + + 'other {{PERSON} i jos # osoba}} ' + + 'u {PLACE}.' + ); + + assertEquals('Ja ne vidim nikoga u Beogradu.', fmt({ NUM_PEOPLE: 0, PLACE: 'Beogradu' })); + assertEquals('Ja vidim Markusa u Berlinu.', fmt({ NUM_PEOPLE: 1, PERSON: 'Markusa', PLACE: 'Berlinu' })); + assertEquals('Ja vidim Marka i jos 1 osobu u Atini.', fmt({ NUM_PEOPLE: 2, PERSON: 'Marka', PLACE: 'Atini' })); + assertEquals('Ja vidim Petra i jos 3 osobe u muzeju.', fmt({ NUM_PEOPLE: 4, PERSON: 'Petra', PLACE: 'muzeju' })); + // assertEquals('Ja vidim Cibua i jos 99 osoba u bazenu.', fmt({ NUM_PEOPLE: 100, PERSON: 'Cibua', PLACE: 'bazenu' })); + }); + + it('SerbianSimplePluralNoOffset', () => { + // stubs.set(goog.i18n.pluralRules, 'select', goog.i18n.pluralRules.beSelect_); + + const fmt = new MessageFormat('sr-RS').compile( + 'Ja {NUM_PEOPLE, plural, ' + + '=0 {ne vidim nikoga} ' + + '=1 {vidim {PERSON}} ' + + 'one {vidim {PERSON} i jos # osobu} ' + + 'few {vidim {PERSON} i jos # osobe} ' + + 'many {vidim {PERSON} i jos # osoba} ' + + 'other {{PERSON} i jos # osoba}} ' + + 'u {PLACE}.' + ); + + assertEquals('Ja ne vidim nikoga u Beogradu.', fmt({ NUM_PEOPLE: 0, PLACE: 'Beogradu' })); + assertEquals('Ja vidim Markusa u Berlinu.', fmt({ NUM_PEOPLE: 1, PERSON: 'Markusa', PLACE: 'Berlinu' })); + assertEquals('Ja vidim Marka i jos 21 osobu u Atini.', fmt({ NUM_PEOPLE: 21, PERSON: 'Marka', PLACE: 'Atini' })); + assertEquals('Ja vidim Petra i jos 3 osobe u muzeju.', fmt({ NUM_PEOPLE: 3, PERSON: 'Petra', PLACE: 'muzeju' })); + // assertEquals('Ja vidim Cibua i jos 100 osoba u bazenu.', fmt({ NUM_PEOPLE: 100, PERSON: 'Cibua', PLACE: 'bazenu' })); + }); + + it('SerbianSelectNestedInPlural', () => { + // stubs.set(goog.i18n.pluralRules, 'select', goog.i18n.pluralRules.beSelect_); + // stubs.set(goog.i18n, 'NumberFormatSymbols', goog.i18n.NumberFormatSymbols_hr); + + const fmt = new MessageFormat('sr-RS').compile( + '{CIRCLES, plural, ' + + 'one {{GENDER, select, ' + + ' female {{WHO} vas je dodala u njen # kruzok} ' + + ' other {{WHO} vas je dodao u njegov # kruzok}}} ' + + 'few {{GENDER, select, ' + + ' female {{WHO} vas je dodala u njena # kruzoka} ' + + ' other {{WHO} vas je dodao u njegova # kruzoka}}} ' + + 'many {{GENDER, select, ' + + ' female {{WHO} vas je dodala u njenih # kruzoka} ' + + ' other {{WHO} vas je dodao u njegovih # kruzoka}}} ' + + 'other {{GENDER, select, ' + + ' female {{WHO} vas je dodala u njenih # kruzoka} ' + + ' other {{WHO} vas je dodao u njegovih # kruzoka}}}}' + ); + + assertEquals('Jelena vas je dodala u njen 21 kruzok', fmt({ GENDER: 'female', WHO: 'Jelena', CIRCLES: 21 })); + assertEquals('Jelena vas je dodala u njena 3 kruzoka', fmt({ GENDER: 'female', WHO: 'Jelena', CIRCLES: 3 })); + assertEquals('Jelena vas je dodala u njenih 5 kruzoka', fmt({ GENDER: 'female', WHO: 'Jelena', CIRCLES: 5 })); + assertEquals('Milan vas je dodao u njegovih 1.235 kruzoka', fmt({ GENDER: 'male', WHO: 'Milan', CIRCLES: 1235 })); + }); + + it('FallbackToOtherOptionInPlurals', () => { + // Use Arabic plural rules since they have all six cases. + // Only locale and numbers matter, the actual language of the message + // does not. + // stubs.set(goog.i18n.pluralRules, 'select', goog.i18n.pluralRules.arSelect_); + + const fmt = new MessageFormat('en-US').compile('{NUM_MINUTES, plural, ' + 'other {# minutes}}'); + + // These numbers exercise all cases for the arabic plural rules. + assertEquals('0 minutes', fmt({ NUM_MINUTES: 0 })); + assertEquals('1 minutes', fmt({ NUM_MINUTES: 1 })); + assertEquals('2 minutes', fmt({ NUM_MINUTES: 2 })); + assertEquals('3 minutes', fmt({ NUM_MINUTES: 3 })); + assertEquals('11 minutes', fmt({ NUM_MINUTES: 11 })); + assertEquals('1.5 minutes', fmt({ NUM_MINUTES: 1.5 })); + }); + + it('PoundShowsNumberMinusOffsetInAllCases', () => { + const fmt = new MessageFormat('en-US').compile('{SOME_NUM, plural, offset:1 ' + '=0 {#} =1 {#} =2 {#}one {#} other {#}}'); + + assertEquals('-1', fmt({ SOME_NUM: '0' })); + assertEquals('0', fmt({ SOME_NUM: '1' })); + assertEquals('1', fmt({ SOME_NUM: '2' })); + assertEquals('20', fmt({ SOME_NUM: '21' })); + }); + + it('SpecialCharactersInParamaterDontChangeFormat', () => { + const fmt = new MessageFormat('en-US').compile('{SOME_NUM, plural,' + 'other {# {GROUP}}}'); + + // Test pound sign. + assertEquals('10 group#1', fmt({ SOME_NUM: '10', GROUP: 'group#1' })); + // Test other special characters in parameters, like { and }. + assertEquals('10 } {', fmt({ SOME_NUM: '10', GROUP: '} {' })); + }); + + // it('MissingOrInvalidPluralParameter', () => { + // const fmt = new MessageFormat('en-US').compile('{SOME_NUM, plural,' + 'other {result}}'); + + // // Key name doesn't match A != SOME_NUM. + // assertEquals('Undefined or invalid parameter - SOME_NUM', fmt({ A: '10' })); + + // // Value is not a number. + // assertEquals('Undefined or invalid parameter - SOME_NUM', fmt({ SOME_NUM: 'Value' })); + // }); + + // it('MissingSelectParameter', () => { + // const fmt = new MessageFormat('en-US').compile('{GENDER, select,' + 'other {result}}'); + + // // Key name doesn't match A != GENDER. + // assertEquals('Undefined parameter - GENDER', fmt({ A: 'female' })); + // }); + + // it('MissingSimplePlaceholder', () => { + // const fmt = new MessageFormat('en-US').compile('{result}'); + + // // Key name doesn't match A != result. + // assertEquals('Undefined parameter - result', fmt({ A: 'none' })); + // }); + + // it('PluralWithIgnorePound', () => { + // const fmt = (new MessageFormat('en-US')).compile('{SOME_NUM, plural,' + 'other {# {GROUP}}}'); + + // // Test pound sign. + // assertEquals('# group#1', fmtIgnoringPound({ SOME_NUM: '10', GROUP: 'group#1' })); + // // Test other special characters in parameters, like { and }. + // assertEquals('# } {', fmtIgnoringPound({ SOME_NUM: '10', GROUP: '} {' })); + // }); + + // it('SimplePluralWithIgnorePound', () => { + // const fmt = (new MessageFormat('en-US')).compile( + // 'I see {NUM_PEOPLE, plural, offset:1 ' + + // '=0 {no one at all in {PLACE}.} ' + + // '=1 {{PERSON} in {PLACE}.} ' + + // 'one {{PERSON} and one other person in {PLACE}.} ' + + // 'other {{PERSON} and # other people in {PLACE}.}}' + // ); + + // assertEquals( + // 'I see Cibu and # other people in the cubes.', + // fmtIgnoringPound({ NUM_PEOPLE: 100, PERSON: 'Cibu', PLACE: 'the cubes' }) + // ); + // }); + + it('SimpleOrdinal', () => { + const fmt = new MessageFormat('en-US').compile( + '{NUM_FLOOR, selectordinal, ' + + 'one {Take the elevator to the #st floor.}' + + 'two {Take the elevator to the #nd floor.}' + + 'few {Take the elevator to the #rd floor.}' + + 'other {Take the elevator to the #th floor.}}' + ); + + assertEquals('Take the elevator to the 1st floor.', fmt({ NUM_FLOOR: 1 })); + assertEquals('Take the elevator to the 2nd floor.', fmt({ NUM_FLOOR: 2 })); + assertEquals('Take the elevator to the 3rd floor.', fmt({ NUM_FLOOR: 3 })); + assertEquals('Take the elevator to the 4th floor.', fmt({ NUM_FLOOR: 4 })); + assertEquals('Take the elevator to the 23rd floor.', fmt({ NUM_FLOOR: 23 })); + // Esoteric example. + assertEquals('Take the elevator to the 0th floor.', fmt({ NUM_FLOOR: 0 })); + }); + + // it('OrdinalWithNegativeValue', () => { + // const fmt = new MessageFormat('en-US').compile( + // '{NUM_FLOOR, selectordinal, ' + + // 'one {Take the elevator to the #st floor.}' + + // 'two {Take the elevator to the #nd floor.}' + + // 'few {Take the elevator to the #rd floor.}' + + // 'other {Take the elevator to the #th floor.}}' + // ); + + // try { + // fmt({ NUM_FLOOR: -2 }); + // } catch (e) { + // assertEquals('Assertion failed: Argument index smaller than offset.', e.message); + // return; + // } + // fail('Expected an error to be thrown'); + // }); + + // it('SimpleOrdinalWithIgnorePound', () => { + // const fmt = (new MessageFormat('en-US')).compile( + // '{NUM_FLOOR, selectordinal, ' + + // 'one {Take the elevator to the #st floor.}' + + // 'two {Take the elevator to the #nd floor.}' + + // 'few {Take the elevator to the #rd floor.}' + + // 'other {Take the elevator to the #th floor.}}' + // ); + + // assertEquals('Take the elevator to the #th floor.', fmtIgnoringPound({ NUM_FLOOR: 100 })); + // }); + + // it('MissingOrInvalidOrdinalParameter', () => { + // const fmt = new MessageFormat('en-US').compile('{SOME_NUM, selectordinal,' + 'other {result}}'); + + // // Key name doesn't match A != SOME_NUM. + // assertEquals('Undefined or invalid parameter - SOME_NUM', fmt({ A: '10' })); + + // // Value is not a number. + // assertEquals('Undefined or invalid parameter - SOME_NUM', fmt({ SOME_NUM: 'Value' })); + // }); + }); +}); diff --git a/projects/components/i18n/src/message-format.ts b/projects/components/i18n/src/message-format.ts new file mode 100644 index 00000000..8e402076 --- /dev/null +++ b/projects/components/i18n/src/message-format.ts @@ -0,0 +1,301 @@ +export interface MessageFormatFunction { + parseParams(paramStr: string, locale: string, mf: MessageFormat): T; + execute(value: any, parsedParams: T, interpolations: InterpolationParams, locale: string, plurVarValue?: number): string; +} + +class NumberFunction implements MessageFormatFunction { + public parseParams(paramStr: string): string { + return paramStr; + } + public execute(value: any, parsedParams: string): string { + switch (parsedParams) { + case 'percent': + return `${value * 100}%`; + default: + return value; + } + } +} + +interface SelectParamsParsed { + [key: string]: (params?: InterpolationParams, plurVarValue?: number) => string; +} + +abstract class CaseFunctionBase { + protected parseCases(paramStr: string, locale: string, mf: MessageFormat): SelectParamsParsed { + const cases: SelectParamsParsed = {}; + + let currentCase = ''; + let currentText = ''; + const stack: string[] = []; + let escape = false; + // =1 {eins} other {sub {var, select, =1 {eins} other {was anders}} select} + for (let i = 0; i < paramStr.length; i++) { + if (!paramStr.hasOwnProperty(i)) { + continue; + } + const c = paramStr[i]; + + if (!stack.length) { + if (c === '{') { + stack.push('{'); + continue; + } + currentCase += c; + } else { + if (c === "'") { + if (!escape && paramStr[i + 1] === "'") { + currentText += "'"; + ++i; + continue; + } + escape = !escape; + } else if (escape) { + currentText += c; + } else if (c === '{') { + stack.push('{'); + currentText += c; + } else if (c === '}') { + stack.pop(); + if (stack.length === 0) { + cases[currentCase.trim()] = mf.compile(currentText, locale); + currentText = ''; + currentCase = ''; + continue; + } + currentText += c; + } else { + currentText += c; + } + } + } + + if (currentCase || currentText) { + cases[currentCase.trim()] = mf.compile(currentText, locale); + } + + return cases; + } +} + +class SelectFunction extends CaseFunctionBase implements MessageFormatFunction { + public parseParams(paramStr: string, locale: string, mf: MessageFormat): SelectParamsParsed { + return super.parseCases(paramStr, locale, mf); + } + public execute( + value: any, + parsedParams: SelectParamsParsed, + interpolations: InterpolationParams, + _locale: string, + plurVarValue?: number + ): string { + const caze = parsedParams[value] || parsedParams.other; + return caze(interpolations, plurVarValue); + } +} +interface PluralParamsParsed { + offset: number; + cases: { + [key: string]: (params?: InterpolationParams, plurVarValue?: number) => string; + }; +} + +class PluralFunction extends CaseFunctionBase { + public parseParams(paramStr: string, locale: string, mf: MessageFormat): PluralParamsParsed { + let offset = 0; + if (paramStr.startsWith('offset:')) { + const firstSpacePos = paramStr.indexOf(' '); + offset = +paramStr.substring(7, firstSpacePos); + paramStr = paramStr.substr(firstSpacePos); + } + return { + offset: offset, + cases: super.parseCases(paramStr, locale, mf), + }; + } + + public execute(value: any, parsedParams: PluralParamsParsed, interpolations: InterpolationParams, locale: string): string { + const adjustedValue = value - parsedParams.offset; + const plural = new Intl.PluralRules(locale).select(adjustedValue); + const func = parsedParams.cases[`=${value}`] || parsedParams.cases[plural] || parsedParams.cases.other; + return func(interpolations, adjustedValue); + } +} + +class SelectOrdinalFunction extends CaseFunctionBase implements MessageFormatFunction { + public parseParams(paramStr: string, locale: string, mf: MessageFormat): SelectParamsParsed { + return super.parseCases(paramStr, locale, mf); + } + public execute(value: any, parsedParams: SelectParamsParsed, interpolations: InterpolationParams, locale: string): string { + const caze = new Intl.PluralRules(locale, { + type: 'ordinal', + }).select(value); + const func = parsedParams[caze] || parsedParams.other; + return func(interpolations, value); + } +} + +export class MessageFormat { + constructor(private defaultLocale: string) {} + private functions: { [key: string]: MessageFormatFunction } = { + select: new SelectFunction(), + selectordinal: new SelectOrdinalFunction(), + plural: new PluralFunction(), + number: new NumberFunction(), + }; + + public compile( + translation: string, + locale: string = this.defaultLocale + ): (params?: InterpolationParams, plurVarValue?: number) => string { + const ast = this.parseText(translation, locale); + return (params, plurVarValue) => { + let text = ''; + for (const token of ast) { + switch (token.type) { + case 'text': + text += token.text; + break; + case 'var': + text += params[token.var]; + break; + case 'plurNum': + text += plurVarValue !== undefined ? new Intl.NumberFormat(locale).format(plurVarValue) : '#'; + break; + case 'func': + text += this.functions[token.func].execute(params[token.var], token.data, params, locale, plurVarValue); + break; + } + } + + return text; + }; + } + + private parseText(translation: string, locale: string): Token[] { + const tokens: Token[] = []; + + let currentText = ''; + const stack: string[] = []; + let escape = false; + for (let i = 0; i < translation.length; i++) { + if (!translation.hasOwnProperty(i)) { + continue; + } + const c = translation[i]; + + if (c === "'") { + if (translation[i + 1] === "'") { + currentText += "'"; + ++i; + continue; + } + if (!escape) { + const nextChar = translation[i + 1]; + escape = nextChar === '{' || nextChar === '}' || nextChar === '#'; + if (!escape) { + currentText += "'"; + } + } else { + escape = false; + } + } else if (escape) { + currentText += c; + } else if (c === '{') { + if (stack.length === 0) { + tokens.push({ + type: 'text', + text: currentText, + }); + currentText = ''; + } + stack.push('{'); + currentText += c; + } else if (c === '}') { + currentText += c; + stack.pop(); + if (stack.length === 0) { + tokens.push(this.parseExpression(currentText, locale)); + currentText = ''; + } + } else if (c === '#' && stack.length === 0) { + if (currentText) { + tokens.push({ + type: 'text', + text: currentText, + }); + currentText = ''; + } + tokens.push({ + type: 'plurNum', + }); + } else { + currentText += c; + } + } + + if (currentText) { + tokens.push({ + type: 'text', + text: currentText, + }); + } + + return tokens; + } + + private parseExpression(funcStr: string, locale: string): Token { + const regex = /^\{([^,]+)(?:,([^,]+)(?:,(.*))?)?\}$/; + const matches = regex.exec(funcStr); + if (!matches) { + throw new Error(`Unknown expression '${funcStr}'`); + } + + const varName = matches[1].trim(); + const funcName = matches[2] ? matches[2].trim() : null; + const paramStr = matches[3] ? matches[3].trim() : null; + if (!funcName) { + return { + type: 'var', + var: varName, + }; + } + + const func = this.functions[funcName]; + if (!func) { + throw new Error(`Unknow function '${funcName}'`); + } + return { + type: 'func', + var: varName, + func: funcName, + data: func.parseParams(paramStr, locale, this), + }; + } +} + +export interface InterpolationParams { + [key: string]: any; +} +type Token = TextToken | VarToken | FuncToken | PlurNumToken; + +interface TextToken { + type: 'text'; + text: string; +} + +interface VarToken { + type: 'var'; + var: string; +} + +interface PlurNumToken { + type: 'plurNum'; +} + +interface FuncToken { + type: 'func'; + var: string; + func: string; + data: any; +} diff --git a/projects/components/i18n/src/notes.txt b/projects/components/i18n/src/notes.txt new file mode 100644 index 00000000..7082d74f --- /dev/null +++ b/projects/components/i18n/src/notes.txt @@ -0,0 +1,19 @@ +http://userguide.icu-project.org/formatparse/messages +https://developer.mozilla.org/de/docs/Web/JavaScript/Reference/Global_Objects/Intl +https://messageformat.github.io/messageformat/ +https://github.com/google/closure-library/blob/master/closure/goog/i18n/messageformat_test.js +https://github.com/messageformat/messageformat/blob/master/test/messageformat.js + + +Escape: + Use '' for escaping: '{0}' + To escape a ' double it (like '') + Examples: + '{0}' -> {0} + ''{0}'' -> 'value' + '''{0}''' -> '{0}' + This '{isn''t}' obvious -> This {isn't} obvious + + https://stackoverflow.com/a/32152170/2416833 + The recommendation from ICU is to use the ASCII apostrophe (' U+0027) only for escaping syntax characters, + and use the pretty single quote (’ U+2019) for actual apostrophes and single quotes in a message pattern. diff --git a/projects/components/i18n/src/server-key.spec.ts b/projects/components/i18n/src/server-key.spec.ts new file mode 100644 index 00000000..8a7878b2 --- /dev/null +++ b/projects/components/i18n/src/server-key.spec.ts @@ -0,0 +1,73 @@ +import { isCompoundKey, isContainerKey, localizeServerKey, handleInputStr, handleInputContainerStr } from './server-key'; +import { TranslateService } from './translate.service'; +import { Translations } from './translations'; + +describe('i18n', () => { + Translations.set({ + 'test.validation.WertMussZwischenLiegen': 'Feld {0} muss Wert zwischen {1} und {2} haben.', + 'kunde.KundeSeit': 'Kunde seit', + 'asdf.XY': 'xy', + 'global.spanne.von': 'von', + 'test.Arbeitsstunden': 'Arbeitsstunden: {0}', + 'a.test012': 'x "{0}" "{1}" "{2}"', + 'test.Wert1': 'Wert1', + }); + const localizer = new TranslateService('de-DE'); + + it('localizeServerKey', () => { + expect(localizeServerKey(localizer, 'test.validation.WertMussZwischenLiegen<0:[DsgvoZustimmungAb]><1:1900><2:2999>')).toBe( + 'Feld DsgvoZustimmungAb muss Wert zwischen 1900 und 2999 haben.' + ); + + expect(localizeServerKey(localizer, 'test.validation.WertMussZwischenLiegen<0:{kunde.KundeSeit}><1:1900><2:2999>')).toBe( + 'Feld Kunde seit muss Wert zwischen 1900 und 2999 haben.' + ); + + expect(localizeServerKey(localizer, 'a.test012<{test.Arbeitsstunden<7>, [ ], global.spanne.von}><0><9999,99>')).toBe( + 'x "Arbeitsstunden: 7 von" "0" "9999,99"' + ); + + expect(localizeServerKey(localizer, '{test.Arbeitsstunden<{asdf.XY, [ , ]}>, [ ], global.spanne.von}')).toBe( + 'Arbeitsstunden: xy , von' + ); + }); + + it('isContainerKey', () => { + expect(isContainerKey('{asdf.XY, [ , ]}')).toBe(true); + expect(isContainerKey('{test.Arbeitsstunden<{asdf.XY, [ , ]}>, [ ], global.spanne.von}')).toBe(true); + + expect(isContainerKey('test.validation.WertMussZwischenLiegen<0:[DsgvoZustimmungAb]><1:1900><2:2999>')).toBe(false); + expect(isContainerKey('system.Asdf')).toBe(false); + expect(isContainerKey('system.Asdf<{asdf.XY}>')).toBe(false); + }); + + it('isCompoundKey', () => { + expect(isCompoundKey('asdf.XY, [ , ]')).toBe(true); + expect(isCompoundKey('test.Arbeitsstunden<{asdf.XY, [ , ]}>, [ ], global.spanne.von')).toBe(true); + + expect(isCompoundKey('test.validation.WertMussZwischenLiegen<0:[DsgvoZustimmungAb]><1:1900><2:2999>')).toBe(false); + expect(isCompoundKey('system.Asdf')).toBe(false); + expect(isCompoundKey('system.Asdf<{asdf.XY}>')).toBe(false); + }); + + it('handleInputStr', () => { + expect(handleInputStr(localizer, '<{test.Wert1, [ ], global.spanne.von}><0><9999,99><[Asdf]>')).toEqual({ + 0: 'Wert1 von', + 1: '0', + 2: '9999,99', + 3: 'Asdf', + }); + + expect(handleInputStr(localizer, '')).toEqual({ + a: 'Wert1 von', + b: '0', + c: 'Asdf', + }); + }); + + it('handleInputContainerStr', () => { + expect(handleInputContainerStr(localizer, '{test.Arbeitsstunden<{asdf.XY, [, Test]}>, [ ], global.spanne.von}')).toEqual( + 'Arbeitsstunden: xy, Test von' + ); + }); +}); diff --git a/projects/components/i18n/src/server-key.ts b/projects/components/i18n/src/server-key.ts new file mode 100644 index 00000000..c4fc5bb5 --- /dev/null +++ b/projects/components/i18n/src/server-key.ts @@ -0,0 +1,184 @@ +import { TranslateService } from './translate.service'; +import { keyValuePairsToDictionary } from './helper'; + +export function localizeServerKey(localizer: TranslateService, localizationString: string): string { + if (isContainerKey(localizationString)) { + return handleInputContainerStr(localizer, localizationString); + } + if (isCompoundKey(localizationString)) { + return handleInputContainerStr(localizer, `{${localizationString}}`); + } + + const paramsIdx = localizationString.indexOf('<'); + if (paramsIdx !== -1) { + const key = localizationString.substr(0, paramsIdx); + const inputStr = localizationString.substr(paramsIdx); + const inputs = handleInputStr(localizer, inputStr); + return localizer.localize(key, inputs); + } + + return localizer.localize(localizationString); +} + +export function isContainerKey(localizationString: string): boolean { + return localizationString.startsWith('{') && localizationString.endsWith('}'); +} + +export function isCompoundKey(localizationString: string): boolean { + const concatinatorIdx = localizationString.indexOf(','); + if (concatinatorIdx === -1) { + return false; + } + + let insideTextNode = false; + let depth = 0; + let maxDepth = 0; + for (const c of localizationString) { + if (c === '[') { + insideTextNode = true; + } + if (c === ']') { + insideTextNode = false; + } + if (!insideTextNode) { + if (c === '<') { + ++depth; + if (depth > maxDepth) { + maxDepth = depth; + } + } + if (maxDepth && !depth) { + return true; + } + if (c === '>') { + --depth; + } + } + } + if (!maxDepth && concatinatorIdx !== -1) { + return true; + } + return false; +} + +// inputStr: <{mitarbeiter.arbeitszeit.Arbeitsstunden, [ ], global.spanne.von}><0><9999,99><[Asdf]> +// result: ['Arbeitsstunden von', '0', '9999,99', 'Asdf'] +export function handleInputStr(localizer: TranslateService, inputStr: string): { [key: string]: string } { + const inputs: string[] = []; + const delimiterStack: string[] = []; + let currentPart = ''; + for (const char of inputStr) { + const topStackItem = delimiterStack.length && delimiterStack[delimiterStack.length - 1]; + if (topStackItem === '[' && char !== ']') { + currentPart += char; + continue; + } + switch (char) { + case '[': + case '<': + delimiterStack.push(char); + if (char !== '<' || delimiterStack.length !== 1) { + currentPart += char; + } + break; + case ']': + case '>': + delimiterStack.pop(); + if (char === '>' && delimiterStack.length === 0) { + inputs.push(currentPart); + currentPart = ''; + } else { + currentPart += char; + } + break; + default: + currentPart += char; + break; + } + } + + const regex = /^([a-zA-Z0-9]+):(.*)$/; + const keyValuePairs = inputs.map((input, idx) => { + const matches = regex.exec(input); + if (!matches) { + return { + key: idx.toString(), + value: input, + }; + } + + return { + key: matches[1].trim(), + value: matches[2], + }; + }); + + const parts = keyValuePairs.map((input) => { + // Input mit {} außenrum ist es ein Container. Im Container sind [] normale Texte, der rest Localization-Strings. + if (isContainerKey(input.value)) { + input.value = handleInputContainerStr(localizer, input.value); + } + // Mit [] außenrum ist es Text + else if (input.value.startsWith('[')) { + input.value = input.value.slice(1, -1); + } + + return input; + }); + const result = keyValuePairsToDictionary(parts); + return result; +} + +// inputContainerStr: {mitarbeiter.arbeitszeit.Arbeitsstunden<{asdf.XY, [ , ]}>, [ ], global.spanne.von} +// result: Übersetzter String +export function handleInputContainerStr(localizer: TranslateService, inputContainerStr: string): string { + inputContainerStr = inputContainerStr.slice(1, -1); + + const parts: string[] = []; + const delimiterStack: string[] = []; + let currentPart = ''; + for (const char of inputContainerStr) { + switch (char) { + case '[': + case '<': + case '{': + currentPart += char; + delimiterStack.push(char); + break; + case ']': + case '>': + case '}': + currentPart += char; + delimiterStack.pop(); + break; + case ',': + if (delimiterStack.length === 0) { + parts.push(currentPart); + currentPart = ''; + } else { + currentPart += char; + } + break; + default: + currentPart += char; + break; + } + } + if (currentPart.length) { + parts.push(currentPart); + } + + const result = parts + .map((x) => x.trim()) + .map((input) => { + // Mit [] außenrum ist es Text + if (input.startsWith('[')) { + return input.slice(1, -1); + } + + // Localization-String. + return localizeServerKey(localizer, input); + }) + .join(''); + return result; +} diff --git a/projects/components/i18n/src/translate.pipe.ts b/projects/components/i18n/src/translate.pipe.ts new file mode 100644 index 00000000..9c7fbbe4 --- /dev/null +++ b/projects/components/i18n/src/translate.pipe.ts @@ -0,0 +1,44 @@ +import { Pipe, PipeTransform } from '@angular/core'; +import { TranslateService } from './translate.service'; + +export function isDefined(value: any): boolean { + return typeof value !== 'undefined' && value !== null; +} + +@Pipe({ + name: 'translate', + pure: true, +}) +export class TranslatePipe implements PipeTransform { + constructor(private ts: TranslateService) {} + + transform(locKey: string, ...args: any[]): any { + if (!locKey || !locKey.length) { + return locKey; + } + + const interpolateParams = getTranslateParams(args); + const result = this.ts.localize(locKey, interpolateParams); + return result; + } +} + +function getTranslateParams(args: any[]) { + const arg0 = args[0]; + let interpolateParams: object; + if (isDefined(arg0) && args.length) { + if (typeof arg0 === 'string' && args[0].length) { + // we accept objects written in the template such as {n:1}, {'n':1}, {n:'v'} + // which is why we might need to change it to real JSON objects such as {"n":1} or {"n":"v"} + const validArgs: string = arg0.replace(/(\')?([a-zA-Z0-9_]+)(\')?(\s)?:/g, '"$2":').replace(/:(\s)?(\')(.*?)(\')/g, ':"$3"'); + try { + interpolateParams = JSON.parse(validArgs); + } catch (e) { + throw new SyntaxError(`Wrong parameter in TranslatePipe. Expected a valid Object, received: ${arg0}`); + } + } else if (typeof arg0 === 'object' && !Array.isArray(arg0)) { + interpolateParams = arg0; + } + } + return interpolateParams; +} diff --git a/projects/components/i18n/src/translate.service.ts b/projects/components/i18n/src/translate.service.ts new file mode 100644 index 00000000..bb867192 --- /dev/null +++ b/projects/components/i18n/src/translate.service.ts @@ -0,0 +1,32 @@ +import { Inject, Injectable, LOCALE_ID } from '@angular/core'; + +import { InterpolationParams, MessageFormat } from './message-format'; +import { Translations } from './translations'; + +@Injectable({ providedIn: 'root' }) +export class TranslateService { + private _mf: MessageFormat; + private _compiled = new Map string>(); + + constructor(@Inject(LOCALE_ID) locale: string) { + this._mf = new MessageFormat(locale); + } + + /** + * Returns a translation instantly from the internal state of loaded translation. + * All rules regarding the current language, the preferred language of even fallback languages will be used except any promise handling. + */ + public localize(locKey: string, params?: { [key: string]: any }) { + let compiled = this._compiled.get(locKey); + if (!compiled) { + const messageTemplate = Translations.get(locKey); + if (!messageTemplate) { + return locKey; + } + compiled = this._mf.compile(messageTemplate); + this._compiled.set(locKey, compiled); + } + + return compiled(params); + } +} diff --git a/projects/components/i18n/src/translations.ts b/projects/components/i18n/src/translations.ts new file mode 100644 index 00000000..a6921315 --- /dev/null +++ b/projects/components/i18n/src/translations.ts @@ -0,0 +1,19 @@ +export class Translations { + private static _messages: { [key: string]: string } = {}; + + /** + * Manually sets an object of translations for a given language + * after passing it through the compiler + */ + public static set(translations: { [key: string]: string }, shouldMerge: boolean = false): void { + if (shouldMerge) { + this._messages = Object.assign({}, this._messages, translations); + } else { + this._messages = translations; + } + } + + public static get(locKey: string): string { + return Translations._messages[locKey]; + } +}