feat(ivy): support `ng-content` in runtime i18n translations (#30782)
Added a new syntax for projections (`¤` will represent `ng-content` nodes) so that we can treat them specifically. When we enter an i18n block with the instruction `i18nStart`, a new `delayProjection` variable is set to true to prevent the instruction `projection` from projecting the nodes. Once we reach the `i18nEnd` instruction and encounter a projection in the translation we will project its nodes. If a projection was removed from a translation, then its nodes won't be projected at all. The variable `delayProjection` is restored to `false` at the end of `i18nEnd` so that it doesn't stop projections outside of i18n blocks. FW-1261 #resolve PR Close #30782
This commit is contained in:
parent
337b6fe003
commit
00cc905b98
|
@ -14,7 +14,8 @@ import {assembleBoundTextPlaceholders, findIndex, getSeqNumberGenerator, updateP
|
|||
|
||||
enum TagType {
|
||||
ELEMENT,
|
||||
TEMPLATE
|
||||
TEMPLATE,
|
||||
PROJECTION
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -94,6 +95,12 @@ export class I18nContext {
|
|||
appendElement(node: i18n.AST, index: number, closed?: boolean) {
|
||||
this.appendTag(TagType.ELEMENT, node as i18n.TagPlaceholder, index, closed);
|
||||
}
|
||||
appendProjection(node: i18n.AST, index: number) {
|
||||
// add open and close tags at the same time,
|
||||
// since we process projected content separately
|
||||
this.appendTag(TagType.PROJECTION, node as i18n.TagPlaceholder, index, false);
|
||||
this.appendTag(TagType.PROJECTION, node as i18n.TagPlaceholder, index, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates an instance of a child context based on the root one,
|
||||
|
@ -181,6 +188,7 @@ function findTemplateFn(ctx: number, templateIndex: number | null) {
|
|||
function serializePlaceholderValue(value: any): string {
|
||||
const element = (data: any, closed?: boolean) => wrapTag('#', data, closed);
|
||||
const template = (data: any, closed?: boolean) => wrapTag('*', data, closed);
|
||||
const projection = (data: any, closed?: boolean) => wrapTag('!', data, closed);
|
||||
|
||||
switch (value.type) {
|
||||
case TagType.ELEMENT:
|
||||
|
@ -198,6 +206,9 @@ function serializePlaceholderValue(value: any): string {
|
|||
case TagType.TEMPLATE:
|
||||
return template(value, value.closed);
|
||||
|
||||
case TagType.PROJECTION:
|
||||
return projection(value, value.closed);
|
||||
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
|
|
|
@ -483,6 +483,9 @@ export class TemplateDefinitionBuilder implements t.Visitor<void>, LocalResolver
|
|||
}
|
||||
|
||||
this.creationInstruction(ngContent.sourceSpan, R3.projection, parameters);
|
||||
if (this.i18n) {
|
||||
this.i18n.appendProjection(ngContent.i18n !, slot);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -7,38 +7,42 @@
|
|||
*/
|
||||
|
||||
import '../util/ng_i18n_closure_mode';
|
||||
|
||||
import {getPluralCase} from '../i18n/localization';
|
||||
import {SRCSET_ATTRS, URI_ATTRS, VALID_ATTRS, VALID_ELEMENTS, getTemplateContent} from '../sanitization/html_sanitizer';
|
||||
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 {attachPatchData} from './context_discovery';
|
||||
import {elementAttributeInternal, ɵɵload, ɵɵtextBinding} from './instructions/all';
|
||||
import {elementAttributeInternal, setDelayProjection, ɵɵload, ɵɵtextBinding} from './instructions/all';
|
||||
import {attachI18nOpCodesDebug} from './instructions/lview_debug';
|
||||
import {allocExpando, elementPropertyInternal, getOrCreateTNode, setInputsForProperty} from './instructions/shared';
|
||||
import {LContainer, NATIVE} from './interfaces/container';
|
||||
import {COMMENT_MARKER, ELEMENT_MARKER, I18nMutateOpCode, I18nMutateOpCodes, I18nUpdateOpCode, I18nUpdateOpCodes, IcuType, TI18n, TIcu} from './interfaces/i18n';
|
||||
import {TElementNode, TIcuContainerNode, TNode, TNodeType} from './interfaces/node';
|
||||
import {TElementNode, TIcuContainerNode, TNode, TNodeType, TProjectionNode} from './interfaces/node';
|
||||
import {RComment, RElement, RText} from './interfaces/renderer';
|
||||
import {SanitizerFn} from './interfaces/sanitization';
|
||||
import {StylingContext} from './interfaces/styling';
|
||||
import {BINDING_INDEX, HEADER_OFFSET, LView, RENDERER, TVIEW, TView, T_HOST} from './interfaces/view';
|
||||
import {appendChild, createTextNode, nativeRemoveNode} from './node_manipulation';
|
||||
import {appendChild, appendProjectedNodes, createTextNode, nativeRemoveNode} from './node_manipulation';
|
||||
import {getIsParent, getLView, getPreviousOrParentTNode, setIsNotParent, setPreviousOrParentTNode} from './state';
|
||||
import {NO_CHANGE} from './tokens';
|
||||
import {renderStringify} from './util/misc_utils';
|
||||
import {findComponentView} from './util/view_traversal_utils';
|
||||
import {getNativeByIndex, getNativeByTNode, getTNode, isLContainer} from './util/view_utils';
|
||||
|
||||
|
||||
const MARKER = `<EFBFBD>`;
|
||||
const ICU_BLOCK_REGEXP = /^\s*(<28>\d+:?\d*<2A>)\s*,\s*(select|plural)\s*,/;
|
||||
const SUBTEMPLATE_REGEXP = /<2F>\/?\*(\d+:\d+)<29>/gi;
|
||||
const PH_REGEXP = /<2F>(\/?[#*]\d+):?\d*<2A>/gi;
|
||||
const PH_REGEXP = /<2F>(\/?[#*!]\d+):?\d*<2A>/gi;
|
||||
const BINDING_REGEXP = /<2F>(\d+):?\d*<2A>/gi;
|
||||
const ICU_REGEXP = /({\s*<2A>\d+:?\d*<2A>\s*,\s*\S{6}\s*,[\s\S]*})/gi;
|
||||
const enum TagType {
|
||||
ELEMENT = '#',
|
||||
TEMPLATE = '*',
|
||||
PROJECTION = '!',
|
||||
}
|
||||
|
||||
// i18nPostprocess consts
|
||||
const ROOT_TEMPLATE_ID = 0;
|
||||
|
@ -340,6 +344,10 @@ const parentIndexStack: number[] = [];
|
|||
* and end of DOM element that were embedded in the original translation block. The placeholder
|
||||
* `index` points to the element index in the template instructions set. An optional `block` that
|
||||
* matches the sub-template in which it was declared.
|
||||
* - `<EFBFBD>!{index}(:{block})<29>`/`<EFBFBD>/!{index}(:{block})<29>`: *Projection Placeholder*: Marks the
|
||||
* beginning and end of <ng-content> that was embedded in the original translation block.
|
||||
* The placeholder `index` points to the element index in the template instructions set.
|
||||
* An optional `block` that matches the sub-template in which it was declared.
|
||||
* - `<EFBFBD>*{index}:{block}<7D>`/`<EFBFBD>/*{index}:{block}<7D>`: *Sub-template Placeholder*: Sub-templates must be
|
||||
* split up and translated separately in each angular template function. The `index` points to the
|
||||
* `template` instruction index. A `block` that matches the sub-template in which it was declared.
|
||||
|
@ -354,6 +362,8 @@ export function ɵɵi18nStart(index: number, message: string, subTemplateIndex?:
|
|||
const tView = getLView()[TVIEW];
|
||||
ngDevMode && assertDefined(tView, `tView should be defined`);
|
||||
i18nIndexStack[++i18nIndexStackPointer] = index;
|
||||
// We need to delay projections until `i18nEnd`
|
||||
setDelayProjection(true);
|
||||
if (tView.firstTemplatePass && tView.data[index + HEADER_OFFSET] === null) {
|
||||
i18nStartFirstPass(tView, index, message, subTemplateIndex);
|
||||
}
|
||||
|
@ -398,7 +408,7 @@ function i18nStartFirstPass(
|
|||
// Odd indexes are placeholders (elements and sub-templates)
|
||||
if (value.charAt(0) === '/') {
|
||||
// It is a closing tag
|
||||
if (value.charAt(1) === '#') {
|
||||
if (value.charAt(1) === TagType.ELEMENT) {
|
||||
const phIndex = parseInt(value.substr(2), 10);
|
||||
parentIndex = parentIndexStack[--parentIndexPointer];
|
||||
createOpCodes.push(phIndex << I18nMutateOpCode.SHIFT_REF | I18nMutateOpCode.ElementEnd);
|
||||
|
@ -410,7 +420,7 @@ function i18nStartFirstPass(
|
|||
phIndex << I18nMutateOpCode.SHIFT_REF | I18nMutateOpCode.Select,
|
||||
parentIndex << I18nMutateOpCode.SHIFT_PARENT | I18nMutateOpCode.AppendChild);
|
||||
|
||||
if (value.charAt(0) === '#') {
|
||||
if (value.charAt(0) === TagType.ELEMENT) {
|
||||
parentIndexStack[++parentIndexPointer] = parentIndex = phIndex;
|
||||
}
|
||||
}
|
||||
|
@ -508,6 +518,14 @@ function appendI18nNode(tNode: TNode, parentTNode: TNode, previousTNode: TNode |
|
|||
cursor = cursor.next;
|
||||
}
|
||||
|
||||
// If the placeholder to append is a projection, we need to move the projected nodes instead
|
||||
if (tNode.type === TNodeType.Projection) {
|
||||
const tProjectionNode = tNode as TProjectionNode;
|
||||
appendProjectedNodes(
|
||||
viewData, tProjectionNode, tProjectionNode.projection, findComponentView(viewData));
|
||||
return tNode;
|
||||
}
|
||||
|
||||
appendChild(getNativeByTNode(tNode, viewData), tNode, viewData);
|
||||
|
||||
const slotValue = viewData[tNode.index];
|
||||
|
@ -632,6 +650,8 @@ export function ɵɵi18nEnd(): void {
|
|||
const tView = getLView()[TVIEW];
|
||||
ngDevMode && assertDefined(tView, `tView should be defined`);
|
||||
i18nEndFirstPass(tView);
|
||||
// Stop delaying projections
|
||||
setDelayProjection(false);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -12,7 +12,6 @@ import {appendProjectedNodes} from '../node_manipulation';
|
|||
import {getProjectAsAttrValue, isNodeMatchingSelectorList, isSelectorInSelectorList} from '../node_selector_matcher';
|
||||
import {getLView, setIsNotParent} from '../state';
|
||||
import {findComponentView} from '../util/view_traversal_utils';
|
||||
|
||||
import {getOrCreateTNode} from './shared';
|
||||
|
||||
|
||||
|
@ -103,6 +102,11 @@ export function ɵɵprojectionDef(projectionSlots?: ProjectionSlots): void {
|
|||
}
|
||||
}
|
||||
|
||||
let delayProjection = false;
|
||||
export function setDelayProjection(value: boolean) {
|
||||
delayProjection = value;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Inserts previously re-distributed projected nodes. This instruction must be preceded by a call
|
||||
|
@ -127,6 +131,9 @@ export function ɵɵprojection(
|
|||
// `<ng-content>` has no content
|
||||
setIsNotParent();
|
||||
|
||||
// re-distribution of projectable nodes is stored on a component's view level
|
||||
appendProjectedNodes(lView, tProjectionNode, selectorIndex, findComponentView(lView));
|
||||
// We might need to delay the projection of nodes if they are in the middle of an i18n block
|
||||
if (!delayProjection) {
|
||||
// re-distribution of projectable nodes is stored on a component's view level
|
||||
appendProjectedNodes(lView, tProjectionNode, selectorIndex, findComponentView(lView));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@
|
|||
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} from '@angular/core';
|
||||
import {setDelayProjection} from '@angular/core/src/render3/instructions/projection';
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {expect} from '@angular/platform-browser/testing/src/matchers';
|
||||
import {onlyInIvy} from '@angular/private/testing';
|
||||
|
@ -19,6 +20,8 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => {
|
|||
TestBed.configureTestingModule({declarations: [AppComp, DirectiveWithTplRef]});
|
||||
});
|
||||
|
||||
afterEach(() => { setDelayProjection(false); });
|
||||
|
||||
it('should translate text', () => {
|
||||
ɵi18nConfigureLocalize({translations: {'text': 'texte'}});
|
||||
const fixture = initWithTemplate(AppComp, `<div i18n>text</div>`);
|
||||
|
@ -990,6 +993,170 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => {
|
|||
expect(fixture.nativeElement.innerHTML)
|
||||
.toEqual('<child><span title="keepMe">Contenu</span></child>');
|
||||
});
|
||||
|
||||
it('should project content in i18n blocks', () => {
|
||||
@Component({
|
||||
selector: 'child',
|
||||
template: `<div i18n>Content projected from <ng-content></ng-content></div>`
|
||||
})
|
||||
class Child {
|
||||
}
|
||||
|
||||
@Component({selector: 'parent', template: `<child>{{name}}</child>`})
|
||||
class Parent {
|
||||
name: string = 'Parent';
|
||||
}
|
||||
TestBed.configureTestingModule({declarations: [Parent, Child]});
|
||||
ɵi18nConfigureLocalize({
|
||||
translations: {
|
||||
'Content projected from {$startTagNgContent}{$closeTagNgContent}':
|
||||
'Contenu projeté depuis {$startTagNgContent}{$closeTagNgContent}'
|
||||
}
|
||||
});
|
||||
|
||||
const fixture = TestBed.createComponent(Parent);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.innerHTML)
|
||||
.toEqual(`<child><div>Contenu projeté depuis Parent</div></child>`);
|
||||
|
||||
fixture.componentRef.instance.name = 'Parent component';
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.innerHTML)
|
||||
.toEqual(`<child><div>Contenu projeté depuis Parent component</div></child>`);
|
||||
});
|
||||
|
||||
it('should project content in i18n blocks with placeholders', () => {
|
||||
@Component({
|
||||
selector: 'child',
|
||||
template: `<div i18n>Content projected from <ng-content></ng-content></div>`
|
||||
})
|
||||
class Child {
|
||||
}
|
||||
|
||||
@Component({selector: 'parent', template: `<child><b>{{name}}</b></child>`})
|
||||
class Parent {
|
||||
name: string = 'Parent';
|
||||
}
|
||||
TestBed.configureTestingModule({declarations: [Parent, Child]});
|
||||
ɵi18nConfigureLocalize({
|
||||
translations: {
|
||||
'Content projected from {$startTagNgContent}{$closeTagNgContent}':
|
||||
'{$startTagNgContent}{$closeTagNgContent} a projeté le contenu'
|
||||
}
|
||||
});
|
||||
const fixture = TestBed.createComponent(Parent);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.innerHTML)
|
||||
.toEqual(`<child><div><b>Parent</b> a projeté le contenu</div></child>`);
|
||||
});
|
||||
|
||||
it('should project translated content in i18n blocks', () => {
|
||||
@Component(
|
||||
{selector: 'child', template: `<div i18n>Child content <ng-content></ng-content></div>`})
|
||||
class Child {
|
||||
}
|
||||
|
||||
@Component({selector: 'parent', template: `<child i18n>and projection from {{name}}</child>`})
|
||||
class Parent {
|
||||
name: string = 'Parent';
|
||||
}
|
||||
TestBed.configureTestingModule({declarations: [Parent, Child]});
|
||||
ɵi18nConfigureLocalize({
|
||||
translations: {
|
||||
'Child content {$startTagNgContent}{$closeTagNgContent}':
|
||||
'Contenu enfant {$startTagNgContent}{$closeTagNgContent}',
|
||||
'and projection from {$interpolation}': 'et projection depuis {$interpolation}'
|
||||
}
|
||||
});
|
||||
const fixture = TestBed.createComponent(Parent);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.innerHTML)
|
||||
.toEqual(`<child><div>Contenu enfant et projection depuis Parent</div></child>`);
|
||||
});
|
||||
|
||||
it('should project bare ICU expressions', () => {
|
||||
@Component({selector: 'child', template: '<div><ng-content></ng-content></div>'})
|
||||
class Child {
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'parent',
|
||||
template: `
|
||||
<child i18n>{
|
||||
value // i18n(ph = "blah"),
|
||||
plural,
|
||||
=1 {one}
|
||||
other {at least {{value}} .}
|
||||
}</child>`
|
||||
})
|
||||
class Parent {
|
||||
value = 3;
|
||||
}
|
||||
TestBed.configureTestingModule({declarations: [Parent, Child]});
|
||||
ɵi18nConfigureLocalize({translations: {}});
|
||||
|
||||
const fixture = TestBed.createComponent(Parent);
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(fixture.nativeElement.innerHTML).toContain('at least');
|
||||
});
|
||||
|
||||
it('should project ICUs in i18n blocks', () => {
|
||||
@Component(
|
||||
{selector: 'child', template: `<div i18n>Child content <ng-content></ng-content></div>`})
|
||||
class Child {
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'parent',
|
||||
template:
|
||||
`<child i18n>and projection from {name, select, angular {Angular} other {{{name}}}}</child>`
|
||||
})
|
||||
class Parent {
|
||||
name: string = 'Parent';
|
||||
}
|
||||
TestBed.configureTestingModule({declarations: [Parent, Child]});
|
||||
ɵi18nConfigureLocalize({
|
||||
translations: {
|
||||
'Child content {$startTagNgContent}{$closeTagNgContent}':
|
||||
'Contenu enfant {$startTagNgContent}{$closeTagNgContent}',
|
||||
'and projection from {$icu}': 'et projection depuis {$icu}'
|
||||
}
|
||||
});
|
||||
const fixture = TestBed.createComponent(Parent);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.innerHTML)
|
||||
.toEqual(
|
||||
`<child><div>Contenu enfant et projection depuis Parent<!--ICU 15--></div></child>`);
|
||||
|
||||
fixture.componentRef.instance.name = 'angular';
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.innerHTML)
|
||||
.toEqual(
|
||||
`<child><div>Contenu enfant et projection depuis Angular<!--ICU 15--></div></child>`);
|
||||
});
|
||||
|
||||
it(`shouldn't project deleted projections in i18n blocks`, () => {
|
||||
@Component(
|
||||
{selector: 'child', template: `<div i18n>Child content <ng-content></ng-content></div>`})
|
||||
class Child {
|
||||
}
|
||||
|
||||
@Component({selector: 'parent', template: `<child i18n>and projection from {{name}}</child>`})
|
||||
class Parent {
|
||||
name: string = 'Parent';
|
||||
}
|
||||
TestBed.configureTestingModule({declarations: [Parent, Child]});
|
||||
ɵi18nConfigureLocalize({
|
||||
translations: {
|
||||
'Child content {$startTagNgContent}{$closeTagNgContent}': 'Contenu enfant',
|
||||
'and projection from {$interpolation}': 'et projection depuis {$interpolation}'
|
||||
}
|
||||
});
|
||||
const fixture = TestBed.createComponent(Parent);
|
||||
fixture.detectChanges();
|
||||
expect(fixture.nativeElement.innerHTML).toEqual(`<child><div>Contenu enfant</div></child>`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('queries', () => {
|
||||
|
|
|
@ -8,13 +8,14 @@
|
|||
|
||||
import {noop} from '../../../compiler/src/render3/view/util';
|
||||
import {getTranslationForTemplate, ɵɵi18nAttributes, ɵɵi18nPostprocess, ɵɵi18nStart} from '../../src/render3/i18n';
|
||||
import {ɵɵelementEnd, ɵɵelementStart} from '../../src/render3/instructions/all';
|
||||
import {setDelayProjection, ɵɵelementEnd, ɵɵelementStart} from '../../src/render3/instructions/all';
|
||||
import {COMMENT_MARKER, ELEMENT_MARKER, I18nMutateOpCode, I18nUpdateOpCode, I18nUpdateOpCodes, TI18n} from '../../src/render3/interfaces/i18n';
|
||||
import {HEADER_OFFSET, LView, TVIEW} from '../../src/render3/interfaces/view';
|
||||
import {getNativeByIndex} from '../../src/render3/util/view_utils';
|
||||
import {TemplateFixture} from './render_util';
|
||||
|
||||
describe('Runtime i18n', () => {
|
||||
afterEach(() => { setDelayProjection(false); });
|
||||
describe('getTranslationForTemplate', () => {
|
||||
it('should crop messages for the selected template', () => {
|
||||
let message = `simple text`;
|
||||
|
|
Loading…
Reference in New Issue