Skip to content

Commit

Permalink
feat(ivy): implement $localize() global function (angular#31609)
Browse files Browse the repository at this point in the history
PR Close angular#31609
  • Loading branch information
petebacondarwin authored and mhevery committed Aug 30, 2019
1 parent b34bdf5 commit b21397b
Show file tree
Hide file tree
Showing 13 changed files with 723 additions and 0 deletions.
33 changes: 33 additions & 0 deletions packages/localize/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -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",
],
)
77 changes: 77 additions & 0 deletions packages/localize/index.ts
Original file line number Diff line number Diff line change
@@ -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;
}
26 changes: 26 additions & 0 deletions packages/localize/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
19 changes: 19 additions & 0 deletions packages/localize/run_time/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -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",
],
)
9 changes: 9 additions & 0 deletions packages/localize/run_time/index.ts
Original file line number Diff line number Diff line change
@@ -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';
12 changes: 12 additions & 0 deletions packages/localize/run_time/package.json
Original file line number Diff line number Diff line change
@@ -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
}
161 changes: 161 additions & 0 deletions packages/localize/run_time/src/translate.ts
Original file line number Diff line number Diff line change
@@ -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;
}
25 changes: 25 additions & 0 deletions packages/localize/run_time/test/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -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",
],
)
Loading

0 comments on commit b21397b

Please sign in to comment.