refactor(ivy): update the compiler to emit $localize
tags (#31609)
This commit changes the Angular compiler (ivy-only) to generate `$localize` tagged strings for component templates that use `i18n` attributes. BREAKING CHANGE Since `$localize` is a global function, it must be included in any applications that use i18n. This is achieved by importing the `@angular/localize` package into an appropriate bundle, where it will be executed before the renderer needs to call `$localize`. For CLI based projects, this is best done in the `polyfills.ts` file. ```ts import '@angular/localize'; ``` For non-CLI applications this could be added as a script to the index.html file or another suitable script file. PR Close #31609
This commit is contained in:

committed by
Misko Hevery

parent
b21397bde9
commit
fa79f51645
@ -37,3 +37,14 @@ export * from './core_render3_private_export';
|
||||
export {SecurityContext} from './sanitization/security';
|
||||
export {Sanitizer} from './sanitization/sanitizer';
|
||||
export * from './codegen_private_exports';
|
||||
|
||||
import {global} from './util/global';
|
||||
if (ngDevMode) {
|
||||
// This helper is to give a reasonable error message to people upgrading to v9 that have not yet
|
||||
// installed `@angular/localize` in their app.
|
||||
// tslint:disable-next-line: no-toplevel-property-access
|
||||
global.$localize = global.$localize || function() {
|
||||
throw new Error(
|
||||
'The global function `$localize` is missing. Please add `import \'@angular/localize\';` to your polyfills.ts file.');
|
||||
};
|
||||
}
|
||||
|
@ -5,7 +5,6 @@
|
||||
* 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 '../util/ng_i18n_closure_mode';
|
||||
import {DEFAULT_LOCALE_ID, getPluralCase} from '../i18n/localization';
|
||||
import {SRCSET_ATTRS, URI_ATTRS, VALID_ATTRS, VALID_ELEMENTS, getTemplateContent} from '../sanitization/html_sanitizer';
|
||||
@ -13,6 +12,7 @@ import {InertBodyHelper} from '../sanitization/inert_body';
|
||||
import {_sanitizeUrl, sanitizeSrcset} from '../sanitization/url_sanitizer';
|
||||
import {addAllToArray} from '../util/array_utils';
|
||||
import {assertDataInRange, assertDefined, assertEqual, assertGreaterThan} from '../util/assert';
|
||||
import {global} from '../util/global';
|
||||
import {attachPatchData} from './context_discovery';
|
||||
import {bind, setDelayProjection} from './instructions/all';
|
||||
import {attachI18nOpCodesDebug} from './instructions/lview_debug';
|
||||
@ -1317,40 +1317,86 @@ function replaceNgsp(value: string): string {
|
||||
return value.replace(NGSP_UNICODE_REGEXP, ' ');
|
||||
}
|
||||
|
||||
let TRANSLATIONS: {[key: string]: string} = {};
|
||||
export interface I18nLocalizeOptions { translations: {[key: string]: string}; }
|
||||
|
||||
/**
|
||||
* Set the configuration for `i18nLocalize`.
|
||||
* Provide translations for `$localize`.
|
||||
*
|
||||
* @deprecated this method is temporary & should not be used as it will be removed soon
|
||||
*/
|
||||
export function i18nConfigureLocalize(options: I18nLocalizeOptions = {
|
||||
translations: {}
|
||||
}) {
|
||||
TRANSLATIONS = options.translations;
|
||||
}
|
||||
type TranslationInfo = {messageParts: TemplateStringsArray, placeholderNames: string[]};
|
||||
type MessageInfo = {translationKey: string, replacements: {[placeholderName: string]: any}};
|
||||
const PLACEHOLDER_MARKER = ':';
|
||||
const TRANSLATIONS: {[key: string]: TranslationInfo} = {};
|
||||
|
||||
const LOCALIZE_PH_REGEXP = /\{\$(.*?)\}/g;
|
||||
Object.keys(options.translations).forEach(key => {
|
||||
TRANSLATIONS[key] = splitMessage(options.translations[key]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A goog.getMsg-like function for users that do not use Closure.
|
||||
*
|
||||
* This method is required as a *temporary* measure to prevent i18n tests from being blocked while
|
||||
* running outside of Closure Compiler. This method will not be needed once runtime translation
|
||||
* service support is introduced.
|
||||
*
|
||||
* @codeGenApi
|
||||
* @deprecated this method is temporary & should not be used as it will be removed soon
|
||||
*/
|
||||
export function ɵɵi18nLocalize(input: string, placeholders?: {[key: string]: string}) {
|
||||
if (typeof TRANSLATIONS[input] !== 'undefined') { // to account for empty string
|
||||
input = TRANSLATIONS[input];
|
||||
if (ngDevMode) {
|
||||
if (global.$localize === undefined) {
|
||||
throw new Error(
|
||||
'The global function `$localize` is missing. Please add `import \'@angular/localize\';` to your polyfills.ts file.');
|
||||
}
|
||||
}
|
||||
if (placeholders !== undefined && Object.keys(placeholders).length) {
|
||||
return input.replace(LOCALIZE_PH_REGEXP, (_, key) => placeholders[key] || '');
|
||||
$localize.translate = function(messageParts: TemplateStringsArray, expressions: readonly any[]):
|
||||
[TemplateStringsArray, readonly any[]] {
|
||||
const message = parseMessage(messageParts, expressions);
|
||||
const translation = TRANSLATIONS[message.translationKey];
|
||||
const result: [TemplateStringsArray, readonly any[]] =
|
||||
(translation === undefined ? [messageParts, expressions] : [
|
||||
translation.messageParts,
|
||||
translation.placeholderNames.map(placeholder => message.replacements[placeholder])
|
||||
]);
|
||||
return result;
|
||||
};
|
||||
|
||||
function splitMessage(message: string): TranslationInfo {
|
||||
const parts = message.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_MARKER ? '\\' + part : part);
|
||||
return {messageParts: makeTemplateObject(messageParts, rawMessageParts), placeholderNames};
|
||||
}
|
||||
|
||||
function parseMessage(
|
||||
messageParts: TemplateStringsArray, expressions: readonly any[]): MessageInfo {
|
||||
const PLACEHOLDER_NAME_MARKER = ':';
|
||||
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 synthesized template literals in TS where the raw version
|
||||
// cannot be found, since there is no original source code to read it from.
|
||||
// In that case we just fall back on the non-raw version.
|
||||
// This should be OK because synthesized nodes (from the template compiler) will always have
|
||||
// placeholder names provided.
|
||||
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 {
|
||||
translationKey += messagePart;
|
||||
replacements[`ph_${i}`] = expression;
|
||||
}
|
||||
}
|
||||
return {translationKey, replacements};
|
||||
}
|
||||
|
||||
function makeTemplateObject(cooked: string[], raw: string[]): TemplateStringsArray {
|
||||
Object.defineProperty(cooked, 'raw', {value: raw});
|
||||
return cooked as any;
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -149,7 +149,6 @@ export {
|
||||
ɵɵi18nApply,
|
||||
ɵɵi18nPostprocess,
|
||||
i18nConfigureLocalize,
|
||||
ɵɵi18nLocalize,
|
||||
getLocaleId,
|
||||
setLocaleId,
|
||||
} from './i18n';
|
||||
|
@ -27,6 +27,7 @@ ts_library(
|
||||
"//packages/core/src/reflection",
|
||||
"//packages/core/src/util",
|
||||
"//packages/core/testing",
|
||||
"//packages/localize",
|
||||
"//packages/platform-browser",
|
||||
"//packages/platform-browser-dynamic",
|
||||
"//packages/platform-browser/animations",
|
||||
|
@ -5,7 +5,7 @@
|
||||
* 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 '@angular/localize';
|
||||
import {registerLocaleData} from '@angular/common';
|
||||
import localeRo from '@angular/common/locales/ro';
|
||||
import {Component, ContentChild, ContentChildren, Directive, HostBinding, Input, LOCALE_ID, QueryList, TemplateRef, Type, ViewChild, ViewContainerRef, ɵi18nConfigureLocalize, Pipe, PipeTransform} from '@angular/core';
|
||||
@ -1130,6 +1130,7 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => {
|
||||
TestBed.configureTestingModule({declarations: [ClsDir, MyApp]});
|
||||
ɵi18nConfigureLocalize({
|
||||
translations: {
|
||||
// Not that this translation switches the order of the expressions!
|
||||
'start {$interpolation} middle {$interpolation_1} end':
|
||||
'début {$interpolation_1} milieu {$interpolation} fin',
|
||||
'{VAR_PLURAL, plural, =0 {no {START_BOLD_TEXT}emails{CLOSE_BOLD_TEXT}!} =1 {one {START_ITALIC_TEXT}email{CLOSE_ITALIC_TEXT}} other {{INTERPOLATION} emails}}':
|
||||
|
@ -17,6 +17,7 @@ ng_module(
|
||||
"//packages/common",
|
||||
"//packages/core",
|
||||
"//packages/core/test/bundling/util:reflect_metadata",
|
||||
"//packages/localize",
|
||||
],
|
||||
)
|
||||
|
||||
@ -47,6 +48,7 @@ ts_library(
|
||||
"//packages/compiler",
|
||||
"//packages/core",
|
||||
"//packages/core/testing",
|
||||
"//packages/localize",
|
||||
"//packages/private/testing",
|
||||
],
|
||||
)
|
||||
|
@ -5,8 +5,10 @@
|
||||
* 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 '@angular/core/test/bundling/util/src/reflect_metadata';
|
||||
// Make the `$localize()` global function available to the compiled templates, and the direct calls
|
||||
// below. This would normally be done inside the application `polyfills.ts` file.
|
||||
import '@angular/localize';
|
||||
/**
|
||||
* TODO(ocombe): replace this with the real runtime i18n service configuration
|
||||
* For now we define inline translations that are added with the function `ɵi18nConfigureLocalize`,
|
||||
@ -16,31 +18,26 @@ import '@angular/core/test/bundling/util/src/reflect_metadata';
|
||||
*/
|
||||
import './translations';
|
||||
import {CommonModule} from '@angular/common';
|
||||
import {Component, Injectable, NgModule, ViewEncapsulation, ɵmarkDirty as markDirty, ɵrenderComponent as renderComponent, ɵɵi18nLocalize as localize} from '@angular/core';
|
||||
import {Component, Injectable, NgModule, ViewEncapsulation, ɵmarkDirty as markDirty, ɵrenderComponent as renderComponent} from '@angular/core';
|
||||
|
||||
class Todo {
|
||||
editing: boolean;
|
||||
|
||||
// TODO(issue/24571): remove '!'.
|
||||
private _title !: string;
|
||||
get title() { return this._title; }
|
||||
set title(value: string) { this._title = value.trim(); }
|
||||
|
||||
constructor(title: string, public completed: boolean = false) {
|
||||
this.editing = false;
|
||||
this.title = title;
|
||||
}
|
||||
constructor(private _title: string, public completed: boolean = false) { this.editing = false; }
|
||||
}
|
||||
|
||||
@Injectable({providedIn: 'root'})
|
||||
class TodoStore {
|
||||
todos: Array<Todo> = [
|
||||
new Todo(localize('Demonstrate Components')),
|
||||
new Todo(localize('Demonstrate Structural Directives'), true),
|
||||
new Todo($localize `Demonstrate Components`),
|
||||
new Todo($localize `Demonstrate Structural Directives`, true),
|
||||
// Using a placeholder
|
||||
new Todo(localize('Demonstrate {$value}', {value: 'NgModules'})),
|
||||
new Todo(localize('Demonstrate zoneless change detection')),
|
||||
new Todo(localize('Demonstrate internationalization')),
|
||||
new Todo($localize `Demonstrate ${'NgModules'}:value:`),
|
||||
new Todo($localize `Demonstrate zoneless change detection`),
|
||||
new Todo($localize `Demonstrate internationalization`),
|
||||
];
|
||||
|
||||
private getWithCompleted(completed: boolean) {
|
||||
|
@ -19,6 +19,12 @@ describe('functional test for todo i18n', () => {
|
||||
BUNDLES.forEach(bundle => {
|
||||
describe(bundle, () => {
|
||||
it('should render todo i18n', withBody('<todo-app></todo-app>', async() => {
|
||||
// We need to delete the dummy `$localize` that was added because of the import of
|
||||
// `@angular/core` at the top of this file.
|
||||
// Also to clear out the translations from the previous test.
|
||||
// This would not be needed in normal applications since the import of
|
||||
// `@angular/localize` would be in polyfill.ts before any other import.
|
||||
($localize as any) = undefined;
|
||||
require(path.join(PACKAGE, bundle));
|
||||
const toDoAppComponent = getComponent(document.querySelector('todo-app') !);
|
||||
expect(document.body.textContent).toContain('liste de tâches');
|
||||
|
@ -5,8 +5,7 @@
|
||||
* 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 '@angular/localize';
|
||||
import {AfterContentInit, AfterViewInit, Component, ContentChildren, Directive, Input, QueryList, ViewChildren, ɵivyEnabled as ivyEnabled} from '@angular/core';
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {isCommentNode} from '@angular/platform-browser/testing/src/browser_util';
|
||||
|
@ -13,6 +13,7 @@ ng_module(
|
||||
"//packages:types",
|
||||
"//packages/compiler",
|
||||
"//packages/core",
|
||||
"//packages/localize",
|
||||
"@npm//@types/jasmine",
|
||||
"@npm//zone.js",
|
||||
],
|
||||
|
Reference in New Issue
Block a user