feat(ivy): implement `$localize()` global function (#31609)
PR Close #31609
This commit is contained in:
parent
b34bdf5c42
commit
b21397bde9
|
@ -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",
|
||||||
|
],
|
||||||
|
)
|
|
@ -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;
|
||||||
|
}
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
|
@ -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",
|
||||||
|
],
|
||||||
|
)
|
|
@ -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';
|
|
@ -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
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
|
@ -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",
|
||||||
|
],
|
||||||
|
)
|
|
@ -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!');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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;
|
|
@ -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;
|
||||||
|
}
|
|
@ -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",
|
||||||
|
],
|
||||||
|
)
|
|
@ -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];
|
||||||
|
};
|
Loading…
Reference in New Issue