feat(ivy): implement `$localize()` global function (#31609)

PR Close #31609
This commit is contained in:
Pete Bacon Darwin 2019-07-30 18:02:17 +01:00 committed by Misko Hevery
parent b34bdf5c42
commit b21397bde9
13 changed files with 723 additions and 0 deletions

View File

@ -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",
],
)

View File

@ -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;
}

View File

@ -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"
}
}

View File

@ -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",
],
)

View File

@ -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';

View File

@ -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
}

View File

@ -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;
}

View File

@ -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",
],
)

View File

@ -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!');
});
});
});

View File

@ -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;

View File

@ -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;
}

View File

@ -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",
],
)

View File

@ -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];
};