diff --git a/packages/compiler/src/render3/view/i18n/context.ts b/packages/compiler/src/render3/view/i18n/context.ts index d2573d6f4c..2c770813f3 100644 --- a/packages/compiler/src/render3/view/i18n/context.ts +++ b/packages/compiler/src/render3/view/i18n/context.ts @@ -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; } diff --git a/packages/compiler/src/render3/view/template.ts b/packages/compiler/src/render3/view/template.ts index 552dfbd675..d7316a84af 100644 --- a/packages/compiler/src/render3/view/template.ts +++ b/packages/compiler/src/render3/view/template.ts @@ -483,6 +483,9 @@ export class TemplateDefinitionBuilder implements t.Visitor, LocalResolver } this.creationInstruction(ngContent.sourceSpan, R3.projection, parameters); + if (this.i18n) { + this.i18n.appendProjection(ngContent.i18n !, slot); + } } diff --git a/packages/core/src/render3/i18n.ts b/packages/core/src/render3/i18n.ts index e3291dcfa8..bdf88e081e 100644 --- a/packages/core/src/render3/i18n.ts +++ b/packages/core/src/render3/i18n.ts @@ -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 = `�`; const ICU_BLOCK_REGEXP = /^\s*(�\d+:?\d*�)\s*,\s*(select|plural)\s*,/; const SUBTEMPLATE_REGEXP = /�\/?\*(\d+:\d+)�/gi; -const PH_REGEXP = /�(\/?[#*]\d+):?\d*�/gi; +const PH_REGEXP = /�(\/?[#*!]\d+):?\d*�/gi; const BINDING_REGEXP = /�(\d+):?\d*�/gi; const ICU_REGEXP = /({\s*�\d+:?\d*�\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. + * - `�!{index}(:{block})�`/`�/!{index}(:{block})�`: *Projection Placeholder*: Marks the + * beginning and end of 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. * - `�*{index}:{block}�`/`�/*{index}:{block}�`: *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); } /** diff --git a/packages/core/src/render3/instructions/projection.ts b/packages/core/src/render3/instructions/projection.ts index 4a76d6e843..87bfb0628b 100644 --- a/packages/core/src/render3/instructions/projection.ts +++ b/packages/core/src/render3/instructions/projection.ts @@ -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( // `` 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)); + } } diff --git a/packages/core/test/acceptance/i18n_spec.ts b/packages/core/test/acceptance/i18n_spec.ts index 4e9bc759b5..16c37d6a39 100644 --- a/packages/core/test/acceptance/i18n_spec.ts +++ b/packages/core/test/acceptance/i18n_spec.ts @@ -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, `
text
`); @@ -990,6 +993,170 @@ onlyInIvy('Ivy i18n logic').describe('runtime i18n', () => { expect(fixture.nativeElement.innerHTML) .toEqual('Contenu'); }); + + it('should project content in i18n blocks', () => { + @Component({ + selector: 'child', + template: `
Content projected from
` + }) + class Child { + } + + @Component({selector: 'parent', template: `{{name}}`}) + 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(`
Contenu projeté depuis Parent
`); + + fixture.componentRef.instance.name = 'Parent component'; + fixture.detectChanges(); + expect(fixture.nativeElement.innerHTML) + .toEqual(`
Contenu projeté depuis Parent component
`); + }); + + it('should project content in i18n blocks with placeholders', () => { + @Component({ + selector: 'child', + template: `
Content projected from
` + }) + class Child { + } + + @Component({selector: 'parent', template: `{{name}}`}) + 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(`
Parent a projeté le contenu
`); + }); + + it('should project translated content in i18n blocks', () => { + @Component( + {selector: 'child', template: `
Child content
`}) + class Child { + } + + @Component({selector: 'parent', template: `and projection from {{name}}`}) + 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(`
Contenu enfant et projection depuis Parent
`); + }); + + it('should project bare ICU expressions', () => { + @Component({selector: 'child', template: '
'}) + class Child { + } + + @Component({ + selector: 'parent', + template: ` + { + value // i18n(ph = "blah"), + plural, + =1 {one} + other {at least {{value}} .} + }` + }) + 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: `
Child content
`}) + class Child { + } + + @Component({ + selector: 'parent', + template: + `and projection from {name, select, angular {Angular} other {{{name}}}}` + }) + 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( + `
Contenu enfant et projection depuis Parent
`); + + fixture.componentRef.instance.name = 'angular'; + fixture.detectChanges(); + expect(fixture.nativeElement.innerHTML) + .toEqual( + `
Contenu enfant et projection depuis Angular
`); + }); + + it(`shouldn't project deleted projections in i18n blocks`, () => { + @Component( + {selector: 'child', template: `
Child content
`}) + class Child { + } + + @Component({selector: 'parent', template: `and projection from {{name}}`}) + 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(`
Contenu enfant
`); + }); }); describe('queries', () => { diff --git a/packages/core/test/render3/i18n_spec.ts b/packages/core/test/render3/i18n_spec.ts index 154e974cde..0da67146a4 100644 --- a/packages/core/test/render3/i18n_spec.ts +++ b/packages/core/test/render3/i18n_spec.ts @@ -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`;