From b21397bde9c6b05e5ea168d987862a2a44807e08 Mon Sep 17 00:00:00 2001 From: Pete Bacon Darwin Date: Tue, 30 Jul 2019 18:02:17 +0100 Subject: [PATCH] feat(ivy): implement `$localize()` global function (#31609) PR Close #31609 --- packages/localize/BUILD.bazel | 33 ++++ packages/localize/index.ts | 77 +++++++++ packages/localize/package.json | 26 +++ packages/localize/run_time/BUILD.bazel | 19 +++ packages/localize/run_time/index.ts | 9 + packages/localize/run_time/package.json | 12 ++ packages/localize/run_time/src/translate.ts | 161 ++++++++++++++++++ packages/localize/run_time/test/BUILD.bazel | 25 +++ .../localize/run_time/test/translate_spec.ts | 86 ++++++++++ packages/localize/src/global.ts | 24 +++ packages/localize/src/localize.ts | 127 ++++++++++++++ packages/localize/test/BUILD.bazel | 24 +++ packages/localize/test/localize_spec.ts | 100 +++++++++++ 13 files changed, 723 insertions(+) create mode 100644 packages/localize/BUILD.bazel create mode 100644 packages/localize/index.ts create mode 100644 packages/localize/package.json create mode 100644 packages/localize/run_time/BUILD.bazel create mode 100644 packages/localize/run_time/index.ts create mode 100644 packages/localize/run_time/package.json create mode 100644 packages/localize/run_time/src/translate.ts create mode 100644 packages/localize/run_time/test/BUILD.bazel create mode 100644 packages/localize/run_time/test/translate_spec.ts create mode 100644 packages/localize/src/global.ts create mode 100644 packages/localize/src/localize.ts create mode 100644 packages/localize/test/BUILD.bazel create mode 100644 packages/localize/test/localize_spec.ts diff --git a/packages/localize/BUILD.bazel b/packages/localize/BUILD.bazel new file mode 100644 index 0000000000..c27a8aa673 --- /dev/null +++ b/packages/localize/BUILD.bazel @@ -0,0 +1,33 @@ +load("//tools:defaults.bzl", "ng_package", "ts_library") + +package(default_visibility = ["//visibility:public"]) + +ts_library( + name = "localize", + srcs = glob( + [ + "*.ts", + "src/**/*.ts", + ], + ), + module_name = "@angular/localize", + deps = [ + "@npm//@types/node", + ], +) + +ng_package( + name = "npm_package", + srcs = [ + "package.json", + "//packages/localize/run_time:package.json", + ], + entry_point = ":index.ts", + tags = [ + "release-with-framework", + ], + deps = [ + ":localize", + "//packages/localize/run_time", + ], +) diff --git a/packages/localize/index.ts b/packages/localize/index.ts new file mode 100644 index 0000000000..6b76f3b6a8 --- /dev/null +++ b/packages/localize/index.ts @@ -0,0 +1,77 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {_global} from './src/global'; +import {$localize as _localize, LocalizeFn, TranslateFn} from './src/localize'; + +// Attach $localize to the global context, as a side-effect of this module. +_global.$localize = _localize; + +export {LocalizeFn, TranslateFn}; + +// `declare global` allows us to escape the current module and place types on the global namespace +declare global { + /** + * Tag a template literal string for localization. + * + * For example: + * + * ```ts + * $localize `some string to localize` + * ``` + * + * **Naming placeholders** + * + * If the template literal string contains expressions then you can optionally name the + * placeholder + * associated with each expression. Do this by providing the placeholder name wrapped in `:` + * characters directly after the expression. These placeholder names are stripped out of the + * rendered localized string. + * + * For example, to name the `item.length` expression placeholder `itemCount` you write: + * + * ```ts + * $localize `There are ${item.length}:itemCount: items`; + * ``` + * + * If you need to use a `:` character directly an expression you must either provide a name or you + * can escape the `:` by preceding it with a backslash: + * + * For example: + * + * ```ts + * $localize `${label}:label:: ${}` + * // or + * $localize `${label}\: ${}` + * ``` + * + * **Processing localized strings:** + * + * There are three scenarios: + * + * * **compile-time inlining**: the `$localize` tag is transformed at compile time by a + * transpiler, + * removing the tag and replacing the template literal string with a translated literal string + * from a collection of translations provided to the transpilation tool. + * + * * **run-time evaluation**: the `$localize` tag is a run-time function that replaces and + * reorders + * the parts (static strings and expressions) of the template literal string with strings from a + * collection of translations loaded at run-time. + * + * * **pass-through evaluation**: the `$localize` tag is a run-time function that simply evaluates + * the original template literal string without applying any translations to the parts. This + * version + * is used during development or where there is no need to translate the localized template + * literals. + * + * @param messageParts a collection of the static parts of the template string. + * @param expressions a collection of the values of each placeholder in the template string. + * @returns the translated string, with the `messageParts` and `expressions` interleaved together. + */ + const $localize: LocalizeFn; +} diff --git a/packages/localize/package.json b/packages/localize/package.json new file mode 100644 index 0000000000..879a415146 --- /dev/null +++ b/packages/localize/package.json @@ -0,0 +1,26 @@ +{ + "name": "@angular/localize", + "version": "0.0.0-PLACEHOLDER", + "description": "Angular - library for localizing messages", + "main": "./bundles/localize.umd.js", + "module": "./fesm5/localize.js", + "es2015": "./fesm2015/localize.js", + "esm5": "./esm5/localize.js", + "esm2015": "./esm2015/localize.js", + "fesm5": "./fesm5/localize.js", + "fesm2015": "./fesm2015/localize.js", + "typings": "./index.d.ts", + "author": "angular", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/angular/angular.git" + }, + "ng-update": { + "packageGroup": "NG_UPDATE_PACKAGE_GROUP" + }, + "sideEffects": true, + "engines": { + "node": ">=8.0" + } +} diff --git a/packages/localize/run_time/BUILD.bazel b/packages/localize/run_time/BUILD.bazel new file mode 100644 index 0000000000..0fedff66bf --- /dev/null +++ b/packages/localize/run_time/BUILD.bazel @@ -0,0 +1,19 @@ +load("//tools:defaults.bzl", "ts_library") + +package(default_visibility = ["//visibility:public"]) + +exports_files(["package.json"]) + +ts_library( + name = "run_time", + srcs = glob( + [ + "**/*.ts", + ], + ), + module_name = "@angular/localize/run_time", + deps = [ + "//packages/localize", + "@npm//@types/node", + ], +) diff --git a/packages/localize/run_time/index.ts b/packages/localize/run_time/index.ts new file mode 100644 index 0000000000..24b88c6c0a --- /dev/null +++ b/packages/localize/run_time/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export {clearTranslations, loadTranslations} from './src/translate'; diff --git a/packages/localize/run_time/package.json b/packages/localize/run_time/package.json new file mode 100644 index 0000000000..e3380819f8 --- /dev/null +++ b/packages/localize/run_time/package.json @@ -0,0 +1,12 @@ +{ + "name": "@angular/localize/run_time", + "typings": "./index.d.ts", + "main": "../bundles/localize-run_time.umd.js", + "module": "../fesm5/run_time.js", + "es2015": "../fesm2015/run_time.js", + "esm5": "../esm5/run_time/run_time.js", + "esm2015": "../esm2015/run_time/run_time.js", + "fesm5": "../fesm5/run_time.js", + "fesm2015": "../fesm2015/run_time.js", + "sideEffects": false +} diff --git a/packages/localize/run_time/src/translate.ts b/packages/localize/run_time/src/translate.ts new file mode 100644 index 0000000000..27c926e0ce --- /dev/null +++ b/packages/localize/run_time/src/translate.ts @@ -0,0 +1,161 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import {LocalizeFn} from '@angular/localize'; + +/** + * We augment the `$localize` object to also store the translations. + * + * Note that because the TRANSLATIONS are attached to a global object, they will be shared between + * all applications that are running in a single page of the browser. + */ +declare const $localize: LocalizeFn&{TRANSLATIONS: {[key: string]: ParsedTranslation}}; + +/** + * A map of translations. + * + * The key is the original translation message, the value is the translated message. + * + * The format of these translation message strings uses `{$marker}` to indicate a placeholder. + */ +export interface Translations { [translationKey: string]: string; } + +/** + * A translation message that has been processed to extract the message parts and placeholders. + * + * This is the format used by the runtime inlining to translate messages. + */ +export interface ParsedTranslation { + messageParts: TemplateStringsArray; + placeholderNames: string[]; +} + +/** + * A localized message that has been processed to compute the translation key for looking up the + * appropriate translation. + */ +export interface ParsedMessage { + translationKey: string; + substitutions: {[placeholderName: string]: any}; +} + +/** + * The character used to mark the start and end of a placeholder name. + */ +const PLACEHOLDER_NAME_MARKER = ':'; + +/** + * Load translations for `$localize`. + * + * The given `translations` are processed and added to a lookup based on their translation key. + * A new translation will overwrite a previous translation if it has the same key. + */ +export function loadTranslations(translations: Translations) { + // Ensure the translate function exists + if (!$localize.translate) { + $localize.translate = translate; + } + if (!$localize.TRANSLATIONS) { + $localize.TRANSLATIONS = {}; + } + Object.keys(translations).forEach(key => { + $localize.TRANSLATIONS[key] = parseTranslation(translations[key]); + }); +} + +/** + * Remove all translations for `$localize`. + */ +export function clearTranslations() { + $localize.TRANSLATIONS = {}; +} + +/** + * Translate the text of the given message, using the loaded translations. + * + * This function may reorder (or remove) substitutions as indicated in the matching translation. + */ +export function translate(messageParts: TemplateStringsArray, substitutions: readonly any[]): + [TemplateStringsArray, readonly any[]] { + const message = parseMessage(messageParts, substitutions); + const translation = $localize.TRANSLATIONS[message.translationKey]; + const result: [TemplateStringsArray, readonly any[]] = + (translation === undefined ? [messageParts, substitutions] : [ + translation.messageParts, + translation.placeholderNames.map(placeholder => message.substitutions[placeholder]) + ]); + return result; +} + +///////////// +// Helpers + +/** + * Parse the `messageParts` and `placeholderNames` out of a translation key. + * + * @param translationKey the message to be parsed. + */ +export function parseTranslation(translationKey: string): ParsedTranslation { + const parts = translationKey.split(/{\$([^}]*)}/); + const messageParts = [parts[0]]; + const placeholderNames: string[] = []; + for (let i = 1; i < parts.length - 1; i += 2) { + placeholderNames.push(parts[i]); + messageParts.push(`${parts[i + 1]}`); + } + const rawMessageParts = + messageParts.map(part => part.charAt(0) === PLACEHOLDER_NAME_MARKER ? '\\' + part : part); + return {messageParts: makeTemplateObject(messageParts, rawMessageParts), placeholderNames}; +} + +/** + * Process the `messageParts` and `substitutions` that were passed to the `$localize` tag in order + * to match it to a translation. + * + * Specifically this function computes: + * * the `translationKey` for looking up an appropriate translation for this message. + * * a map of placeholder names to substitutions values. + */ +export function parseMessage( + messageParts: TemplateStringsArray, expressions: readonly any[]): ParsedMessage { + const replacements: {[placeholderName: string]: any} = {}; + let translationKey = messageParts[0]; + for (let i = 1; i < messageParts.length; i++) { + const messagePart = messageParts[i]; + const expression = expressions[i - 1]; + // There is a problem with synthesizing template literals in TS. + // It is not possible to provide raw values for the `messageParts` and TS is not able to compute + // them since this requires access to the string in its original (non-existent) source code. + // Therefore we fall back on the non-raw version if the raw string is empty. + // This should be OK because synthesized nodes only come from the template compiler and they + // will always contain placeholder name information. + // So there will be no escaped placeholder marker character (`:`) directly after a substitution. + if ((messageParts.raw[i] || messagePart).charAt(0) === PLACEHOLDER_NAME_MARKER) { + const endOfPlaceholderName = messagePart.indexOf(PLACEHOLDER_NAME_MARKER, 1); + const placeholderName = messagePart.substring(1, endOfPlaceholderName); + translationKey += `{$${placeholderName}}${messagePart.substring(endOfPlaceholderName + 1)}`; + replacements[placeholderName] = expression; + } else { + const placeholderName = `ph_${i}`; + translationKey += `{$${placeholderName}}${messagePart}`; + replacements[placeholderName] = expression; + } + } + return {translationKey, substitutions: replacements}; +} + +/** + * Make an array of `cooked` strings that also holds the `raw` strings in an additional property. + * + * @param cooked The actual values of the `messagePart` strings. + * @param raw The original raw values of the `messagePart` strings, before escape characters are + * processed. + */ +function makeTemplateObject(cooked: string[], raw: string[]): TemplateStringsArray { + Object.defineProperty(cooked, 'raw', {value: raw}); + return cooked as any; +} diff --git a/packages/localize/run_time/test/BUILD.bazel b/packages/localize/run_time/test/BUILD.bazel new file mode 100644 index 0000000000..a1d6e7ddde --- /dev/null +++ b/packages/localize/run_time/test/BUILD.bazel @@ -0,0 +1,25 @@ +load("//tools:defaults.bzl", "jasmine_node_test", "ts_library") + +ts_library( + name = "test_lib", + testonly = True, + srcs = glob( + ["*_spec.ts"], + ), + deps = [ + "//packages:types", + "//packages/localize", + "//packages/localize/run_time", + ], +) + +jasmine_node_test( + name = "test", + bootstrap = [ + "angular/tools/testing/init_node_no_angular_spec.js", + ], + deps = [ + ":test_lib", + "//tools/testing:node_no_angular", + ], +) diff --git a/packages/localize/run_time/test/translate_spec.ts b/packages/localize/run_time/test/translate_spec.ts new file mode 100644 index 0000000000..78eaa8422f --- /dev/null +++ b/packages/localize/run_time/test/translate_spec.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +// Ensure that `$localize` is loaded to the global scope. +import '@angular/localize'; +import {clearTranslations, loadTranslations} from '../src/translate'; + +describe('$localize tag with translations', () => { + describe('identities', () => { + beforeEach(() => { + loadTranslations({ + 'abc': 'abc', + 'abc{$ph_1}': 'abc{$ph_1}', + 'abc{$ph_1}def': 'abc{$ph_1}def', + 'abc{$ph_1}def{$ph_2}': 'abc{$ph_1}def{$ph_2}', + 'Hello, {$ph_1}!': 'Hello, {$ph_1}!', + }); + }); + afterEach(() => { clearTranslations(); }); + + it('should render template literals as-is', () => { + expect($localize `abc`).toEqual('abc'); + expect($localize `abc${1 + 2 + 3}`).toEqual('abc6'); + expect($localize `abc${1 + 2 + 3}def`).toEqual('abc6def'); + expect($localize `abc${1 + 2 + 3}def${4 + 5 + 6}`).toEqual('abc6def15'); + const getName = () => 'World'; + expect($localize `Hello, ${getName()}!`).toEqual('Hello, World!'); + }); + }); + + describe('to upper-case messageParts', () => { + beforeEach(() => { + loadTranslations({ + 'abc': 'ABC', + 'abc{$ph_1}': 'ABC{$ph_1}', + 'abc{$ph_1}def': 'ABC{$ph_1}DEF', + 'abc{$ph_1}def{$ph_2}': 'ABC{$ph_1}DEF{$ph_2}', + 'Hello, {$ph_1}!': 'HELLO, {$ph_1}!', + }); + }); + afterEach(() => { clearTranslations(); }); + + it('should render template literals with messages upper-cased', () => { + expect($localize `abc`).toEqual('ABC'); + expect($localize `abc${1 + 2 + 3}`).toEqual('ABC6'); + expect($localize `abc${1 + 2 + 3}def`).toEqual('ABC6DEF'); + expect($localize `abc${1 + 2 + 3}def${4 + 5 + 6}`).toEqual('ABC6DEF15'); + const getName = () => 'World'; + expect($localize `Hello, ${getName()}!`).toEqual('HELLO, World!'); + }); + }); + + describe('to reverse expressions', () => { + beforeEach(() => { + loadTranslations({ + 'abc{$ph_1}def{$ph_2} - Hello, {$ph_3}!': 'abc{$ph_3}def{$ph_2} - Hello, {$ph_1}!', + }); + }); + afterEach(() => { clearTranslations(); }); + + it('should render template literals with expressions reversed', () => { + const getName = () => 'World'; + expect($localize `abc${1 + 2 + 3}def${4 + 5 + 6} - Hello, ${getName()}!`) + .toEqual('abcWorlddef15 - Hello, 6!'); + }); + }); + + describe('to remove expressions', () => { + beforeEach(() => { + loadTranslations({ + 'abc{$ph_1}def{$ph_2} - Hello, {$ph_3}!': 'abc{$ph_1} - Hello, {$ph_3}!', + }); + }); + afterEach(() => { clearTranslations(); }); + + it('should render template literals with expressions removed', () => { + const getName = () => 'World'; + expect($localize `abc${1 + 2 + 3}def${4 + 5 + 6} - Hello, ${getName()}!`) + .toEqual('abc6 - Hello, World!'); + }); + }); +}); diff --git a/packages/localize/src/global.ts b/packages/localize/src/global.ts new file mode 100644 index 0000000000..ea120571f7 --- /dev/null +++ b/packages/localize/src/global.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +// ********************************************************************************************** +// This code to access the global object is mostly copied from `packages/core/src/util/global.ts` + +declare global { + var WorkerGlobalScope: any; +} + +const __globalThis = typeof globalThis !== 'undefined' && globalThis; +const __window = typeof window !== 'undefined' && window; +const __self = typeof self !== 'undefined' && typeof WorkerGlobalScope !== 'undefined' && + self instanceof WorkerGlobalScope && self; +const __global = typeof global !== 'undefined' && global; +// Always use __globalThis if available; this is the spec-defined global variable across all +// environments. +// Then fallback to __global first; in Node tests both __global and __window may be defined. +export const _global: any = __globalThis || __global || __window || __self; diff --git a/packages/localize/src/localize.ts b/packages/localize/src/localize.ts new file mode 100644 index 0000000000..952e68acab --- /dev/null +++ b/packages/localize/src/localize.ts @@ -0,0 +1,127 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +const PLACEHOLDER_NAME_MARKER = ':'; + +export interface LocalizeFn { + (messageParts: TemplateStringsArray, ...expressions: readonly any[]): string; + + /** + * A function that converts an input "message with expressions" into a translated "message with + * expressions". + * + * The conversion may be done in place, modifying the array passed to the function, so + * don't assume that this has no side-effects. + * + * The expressions must be passed in since it might be they need to be reordered for + * different translations. + */ + translate?: TranslateFn; +} + +export interface TranslateFn { + (messageParts: TemplateStringsArray, + expressions: readonly any[]): [TemplateStringsArray, readonly any[]]; +} + +/** + * Tag a template literal string for localization. + * + * For example: + * + * ```ts + * $localize `some string to localize` + * ``` + * + * **Naming placeholders** + * + * If the template literal string contains expressions then you can optionally name the placeholder + * associated with each expression. Do this by providing the placeholder name wrapped in `:` + * characters directly after the expression. These placeholder names are stripped out of the + * rendered localized string. + * + * For example, to name the `item.length` expression placeholder `itemCount` you write: + * + * ```ts + * $localize `There are ${item.length}:itemCount: items`; + * ``` + * + * If you need to use a `:` character directly an expression you must either provide a name or you + * can escape the `:` by preceding it with a backslash: + * + * For example: + * + * ```ts + * $localize `${label}:label:: ${}` + * // or + * $localize `${label}\: ${}` + * ``` + * + * **Processing localized strings:** + * + * There are three scenarios: + * + * * **compile-time inlining**: the `$localize` tag is transformed at compile time by a transpiler, + * removing the tag and replacing the template literal string with a translated literal string + * from a collection of translations provided to the transpilation tool. + * + * * **run-time evaluation**: the `$localize` tag is a run-time function that replaces and reorders + * the parts (static strings and expressions) of the template literal string with strings from a + * collection of translations loaded at run-time. + * + * * **pass-through evaluation**: the `$localize` tag is a run-time function that simply evaluates + * the original template literal string without applying any translations to the parts. This version + * is used during development or where there is no need to translate the localized template + * literals. + * + * @param messageParts a collection of the static parts of the template string. + * @param expressions a collection of the values of each placeholder in the template string. + * @returns the translated string, with the `messageParts` and `expressions` interleaved together. + */ +export const $localize: LocalizeFn = function( + messageParts: TemplateStringsArray, ...expressions: readonly any[]) { + if ($localize.translate) { + // Don't use array expansion here to avoid the compiler adding `__read()` helper unnecessarily. + const translation = $localize.translate(messageParts, expressions); + messageParts = translation[0]; + expressions = translation[1]; + } + let message = messageParts[0]; + for (let i = 1; i < messageParts.length; i++) { + message += expressions[i - 1] + stripPlaceholderName(messageParts[i], messageParts.raw[i]); + } + return message; +}; + +/** + * Strip the placeholder name from the start of the `messagePart`, if it is found. + * + * Placeholder marker characters (:) may appear after a substitution that does not provide an + * explicit placeholder name. In this case the character must be escaped with a backslash, `\:`. + * We can check for this by looking at the `raw` messagePart, which should still contain the + * backslash. + * + * If the template literal was synthesized then its raw array will only contain empty strings. + * This is because TS needs the original source code to find the raw text and in the case of + * synthesize AST nodes, there is no source code. + * + * The workaround is to assume that the template literal did not contain an escaped placeholder + * name, and fall back on checking the cooked array instead. + * + * This should be OK because synthesized nodes (from the Angular template compiler) will always + * provide explicit placeholder names and so will never need to escape placeholder name markers. + * + * @param messagePart The cooked message part to process. + * @param rawMessagePart The raw message part to check. + * @returns the message part with the placeholder name stripped, if found. + */ +function stripPlaceholderName(messagePart: string, rawMessagePart: string) { + return (rawMessagePart || messagePart).charAt(0) === PLACEHOLDER_NAME_MARKER ? + messagePart.substring(messagePart.indexOf(PLACEHOLDER_NAME_MARKER, 1) + 1) : + messagePart; +} diff --git a/packages/localize/test/BUILD.bazel b/packages/localize/test/BUILD.bazel new file mode 100644 index 0000000000..aaf6c460eb --- /dev/null +++ b/packages/localize/test/BUILD.bazel @@ -0,0 +1,24 @@ +load("//tools:defaults.bzl", "jasmine_node_test", "ts_library") + +ts_library( + name = "test_lib", + testonly = True, + srcs = glob( + ["*_spec.ts"], + ), + deps = [ + "//packages:types", + "//packages/localize", + ], +) + +jasmine_node_test( + name = "test", + bootstrap = [ + "angular/tools/testing/init_node_no_angular_spec.js", + ], + deps = [ + ":test_lib", + "//tools/testing:node_no_angular", + ], +) diff --git a/packages/localize/test/localize_spec.ts b/packages/localize/test/localize_spec.ts new file mode 100644 index 0000000000..7c67808f8d --- /dev/null +++ b/packages/localize/test/localize_spec.ts @@ -0,0 +1,100 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ +import '..'; // Ensure $localize is attached to the global scope +import {TranslateFn} from '../src/localize'; + +describe('$localize tag', () => { + describe('with no `translate()` defined (the default)', () => { + it('should render template literals as-is', () => { + expect($localize.translate).toBeUndefined(); + expect($localize `abc`).toEqual('abc'); + expect($localize `abc${1 + 2 + 3}`).toEqual('abc6'); + expect($localize `abc${1 + 2 + 3}def`).toEqual('abc6def'); + expect($localize `abc${1 + 2 + 3}def${4 + 5 + 6}`).toEqual('abc6def15'); + const getName = () => 'World'; + expect($localize `Hello, ${getName()}!`).toEqual('Hello, World!'); + }); + + it('should strip placeholder names from message parts', () => { + expect($localize.translate).toBeUndefined(); + expect($localize `abc${1 + 2 + 3}:ph1:def${4 + 5 + 6}:ph2:`).toEqual('abc6def15'); + }); + + it('should ignore escaped placeholder name marker', () => { + expect($localize.translate).toBeUndefined(); + expect($localize `abc${1 + 2 + 3}\:ph1:def${4 + 5 + 6}\:ph2:`).toEqual('abc6:ph1:def15:ph2:'); + }); + }); + + describe('with `translate()` defined as an identity', () => { + beforeEach(() => { $localize.translate = identityTranslate; }); + afterEach(() => { $localize.translate = undefined; }); + + it('should render template literals as-is', () => { + + expect($localize `abc`).toEqual('abc'); + expect($localize `abc${1 + 2 + 3}`).toEqual('abc6'); + expect($localize `abc${1 + 2 + 3}def`).toEqual('abc6def'); + expect($localize `abc${1 + 2 + 3}def${4 + 5 + 6}`).toEqual('abc6def15'); + const getName = () => 'World'; + expect($localize `Hello, ${getName()}!`).toEqual('Hello, World!'); + }); + }); + + describe('with `translate()` defined to upper-case messageParts', () => { + beforeEach(() => { $localize.translate = upperCaseTranslate; }); + afterEach(() => { $localize.translate = undefined; }); + + it('should render template literals with messages upper-cased', () => { + + expect($localize `abc`).toEqual('ABC'); + expect($localize `abc${1 + 2 + 3}`).toEqual('ABC6'); + expect($localize `abc${1 + 2 + 3}def`).toEqual('ABC6DEF'); + expect($localize `abc${1 + 2 + 3}def${4 + 5 + 6}`).toEqual('ABC6DEF15'); + const getName = () => 'World'; + expect($localize `Hello, ${getName()}!`).toEqual('HELLO, World!'); + }); + }); + + describe('with `translate()` defined to reverse expressions', () => { + beforeEach(() => { $localize.translate = reverseTranslate; }); + afterEach(() => { $localize.translate = undefined; }); + + it('should render template literals with expressions reversed', () => { + const getName = () => 'World'; + expect($localize `abc${1 + 2 + 3}def${4 + 5 + 6} - Hello, ${getName()}!`) + .toEqual('abcWorlddef15 - Hello, 6!'); + }); + }); +}); + +function makeTemplateObject(cooked: string[], raw: string[]): TemplateStringsArray { + Object.defineProperty(cooked, 'raw', {value: raw}); + return cooked as any; +} + +const identityTranslate: TranslateFn = function( + messageParts: TemplateStringsArray, expressions: readonly any[]) { + return [messageParts, expressions]; +}; + +const upperCaseTranslate: TranslateFn = function( + messageParts: TemplateStringsArray, expressions: readonly any[]) { + return [ + makeTemplateObject( + Array.from(messageParts).map((part: string) => part.toUpperCase()), + messageParts.raw.map((part: string) => part.toUpperCase())), + expressions + ]; +}; + +const reverseTranslate: TranslateFn = function( + messageParts: TemplateStringsArray, expressions: readonly any[]) { + expressions = Array.from(expressions).reverse(); + return [messageParts, expressions]; +};