Skip to content

Commit 13b95c1

Browse files
committed
refactor(compiler): support passing content to specific foreign component props
Add `@content(propName)` blocks for passing template content to foreign component properties by name. Previously, only a single set of direct children could be passed to a foreign component via the default `children` property. With this change, developers can project distinct template content to multiple specific properties on the foreign component: ```html <FancyButton [label]="title"> @content(icon) { <span>Icon</span> } @content(description) { <span>Description text</span> } <span>Other children</span> </FancyButton> ``` Specifically: - Add support to the HTML lexer for `@content` blocks. - Introduce `ContentBlock` AST node to represent `@content` blocks. - Implement validation ensuring `@content` blocks have exactly one parameter representing a valid JS identifier. - Throw an error during ingestion if a `@content` block is placed anywhere other than as a direct child of a foreign component. - Map `@content` blocks to properties of the props object passed to `ɵɵforeignComponent`. - Update compliance and unit tests to cover these changes. ```
1 parent c5c4546 commit 13b95c1

20 files changed

Lines changed: 330 additions & 30 deletions

File tree

packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/GOLDEN_PARTIAL.js

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -421,7 +421,13 @@ export class TestCmpChildren {
421421
static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: TestCmpChildren, deps: [], target: i0.ɵɵFactoryTarget.Component });
422422
static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: TestCmpChildren, isStandalone: true, selector: "main-children", ngImport: i0, template: `
423423
<FancyButton [label]="title">
424-
<span>Click me!</span>
424+
@content(icon) {
425+
<span>Icon!</span>
426+
}
427+
@content(description) {
428+
<span>Description text</span>
429+
}
430+
<span>Other children</span>
425431
</FancyButton>
426432
`, isInline: true });
427433
}
@@ -431,7 +437,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDE
431437
selector: 'main-children',
432438
template: `
433439
<FancyButton [label]="title">
434-
<span>Click me!</span>
440+
@content(icon) {
441+
<span>Icon!</span>
442+
}
443+
@content(description) {
444+
<span>Description text</span>
445+
}
446+
<span>Other children</span>
435447
</FancyButton>
436448
`,
437449
// @ts-ignore: @angular/core does not expose the `foreignImports` property.

packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.js

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,23 @@
1-
function TestCmpChildren_Children_0_Template(rf, ctx) {
1+
function TestCmpChildren_Icon_0_Template(rf, ctx) {
22
if (rf & 1) {
33
i0.ɵɵdomElementStart(0, "span");
4-
i0.ɵɵtext(1, "Click me!");
4+
i0.ɵɵtext(1, "Icon!");
5+
i0.ɵɵdomElementEnd();
6+
}
7+
}
8+
9+
function TestCmpChildren_Description_1_Template(rf, ctx) {
10+
if (rf & 1) {
11+
i0.ɵɵdomElementStart(0, "span");
12+
i0.ɵɵtext(1, "Description text");
13+
i0.ɵɵdomElementEnd();
14+
}
15+
}
16+
17+
function TestCmpChildren_Children_2_Template(rf, ctx) {
18+
if (rf & 1) {
19+
i0.ɵɵdomElementStart(0, "span");
20+
i0.ɵɵtext(1, "Other children");
521
i0.ɵɵdomElementEnd();
622
}
723
}
@@ -31,12 +47,12 @@ export class TestCmpChildren {
3147
static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({
3248
type: TestCmpChildren,
3349
selectors: [["main-children"]],
34-
decls: 2,
50+
decls: 4,
3551
vars: 0,
3652
template: function TestCmpChildren_Template(rf, ctx) {
3753
if (rf & 1) {
38-
i0.ɵɵdomTemplate(0, TestCmpChildren_Children_0_Template, 2, 0);
39-
i0.ɵɵforeignComponent(1, frameworkImport(FancyButton), { label: ctx.title, children: i0.ɵɵforeignContent(0) });
54+
i0.ɵɵdomTemplate(0, TestCmpChildren_Icon_0_Template, 2, 0)(1, TestCmpChildren_Description_1_Template, 2, 0)(2, TestCmpChildren_Children_2_Template, 2, 0);
55+
i0.ɵɵforeignComponent(3, frameworkImport(FancyButton), { label: ctx.title, icon: i0.ɵɵforeignContent(0), description: i0.ɵɵforeignContent(1), children: i0.ɵɵforeignContent(2) });
4056
}
4157
},
4258
encapsulation: 2

packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.local.js

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,23 @@
1-
function TestCmpChildren_Children_0_Template(rf, ctx) {
1+
function TestCmpChildren_Icon_0_Template(rf, ctx) {
22
if (rf & 1) {
33
i0.ɵɵelementStart(0, "span");
4-
i0.ɵɵtext(1, "Click me!");
4+
i0.ɵɵtext(1, "Icon!");
5+
i0.ɵɵelementEnd();
6+
}
7+
}
8+
9+
function TestCmpChildren_Description_1_Template(rf, ctx) {
10+
if (rf & 1) {
11+
i0.ɵɵelementStart(0, "span");
12+
i0.ɵɵtext(1, "Description text");
13+
i0.ɵɵelementEnd();
14+
}
15+
}
16+
17+
function TestCmpChildren_Children_2_Template(rf, ctx) {
18+
if (rf & 1) {
19+
i0.ɵɵelementStart(0, "span");
20+
i0.ɵɵtext(1, "Other children");
521
i0.ɵɵelementEnd();
622
}
723
}
@@ -31,12 +47,12 @@ export class TestCmpChildren {
3147
static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({
3248
type: TestCmpChildren,
3349
selectors: [["main-children"]],
34-
decls: 2,
50+
decls: 4,
3551
vars: 0,
3652
template: function TestCmpChildren_Template(rf, ctx) {
3753
if (rf & 1) {
38-
i0.ɵɵtemplate(0, TestCmpChildren_Children_0_Template, 2, 0);
39-
i0.ɵɵforeignComponent(1, frameworkImport(FancyButton), { label: ctx.title, children: i0.ɵɵforeignContent(0) });
54+
i0.ɵɵtemplate(0, TestCmpChildren_Icon_0_Template, 2, 0)(1, TestCmpChildren_Description_1_Template, 2, 0)(2, TestCmpChildren_Children_2_Template, 2, 0);
55+
i0.ɵɵforeignComponent(3, frameworkImport(FancyButton), { label: ctx.title, icon: i0.ɵɵforeignContent(0), description: i0.ɵɵforeignContent(1), children: i0.ɵɵforeignContent(2) });
4056
}
4157
},
4258
encapsulation: 2

packages/compiler-cli/test/compliance/test_cases/r3_compiler_compliance/components_and_directives/standalone/foreign_component.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,13 @@ export class TestCmp {
3131
selector: 'main-children',
3232
template: `
3333
<FancyButton [label]="title">
34-
<span>Click me!</span>
34+
@content(icon) {
35+
<span>Icon!</span>
36+
}
37+
@content(description) {
38+
<span>Description text</span>
39+
}
40+
<span>Other children</span>
3541
</FancyButton>
3642
`,
3743
// @ts-ignore: @angular/core does not expose the `foreignImports` property.

packages/compiler/src/combined_visitor.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ export class CombinedRecursiveAstVisitor extends RecursiveAstVisitor implements
4646
this.visitAllTemplateNodes(content.children);
4747
}
4848

49+
visitContentBlock(block: t.ContentBlock): void {
50+
this.visitAllTemplateNodes(block.children);
51+
}
52+
4953
visitBoundAttribute(attribute: t.BoundAttribute): void {
5054
this.visit(attribute.value);
5155
}

packages/compiler/src/compiler.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,7 @@ export {
188188
HostElement as TmplAstHostElement,
189189
Component as TmplAstComponent,
190190
Directive as TmplAstDirective,
191+
ContentBlock as TmplAstContentBlock,
191192
visitAll as tmplAstVisitAll,
192193
Visitor as TmplAstVisitor,
193194
} from './render3/r3_ast';

packages/compiler/src/ml_parser/lexer.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ const SUPPORTED_BLOCKS = [
153153
'@placeholder',
154154
'@loading',
155155
'@error',
156+
'@content',
156157
] as const;
157158

158159
const INTERPOLATION = {start: '{{', end: '}}'} as const;

packages/compiler/src/render3/r3_ast.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,24 @@ export class DeferredBlockError extends BlockNode implements Node {
339339
}
340340
}
341341

342+
export class ContentBlock extends BlockNode implements Node {
343+
constructor(
344+
public name: string,
345+
public children: Node[],
346+
nameSpan: ParseSourceSpan,
347+
sourceSpan: ParseSourceSpan,
348+
startSourceSpan: ParseSourceSpan,
349+
endSourceSpan: ParseSourceSpan | null,
350+
public i18n?: I18nMeta,
351+
) {
352+
super(nameSpan, sourceSpan, startSourceSpan, endSourceSpan);
353+
}
354+
355+
visit<Result>(visitor: Visitor<Result>): Result {
356+
return visitor.visitContentBlock(this);
357+
}
358+
}
359+
342360
export interface DeferredBlockTriggers {
343361
when?: BoundDeferredTrigger;
344362
idle?: IdleDeferredTrigger;
@@ -762,6 +780,7 @@ export interface Visitor<Result = any> {
762780
visitLetDeclaration(decl: LetDeclaration): Result;
763781
visitComponent(component: Component): Result;
764782
visitDirective(directive: Directive): Result;
783+
visitContentBlock(block: ContentBlock): Result;
765784
}
766785

767786
export class RecursiveVisitor implements Visitor<void> {
@@ -846,6 +865,9 @@ export class RecursiveVisitor implements Visitor<void> {
846865
visitDeferredTrigger(trigger: DeferredTrigger): void {}
847866
visitUnknownBlock(block: UnknownBlock): void {}
848867
visitLetDeclaration(decl: LetDeclaration): void {}
868+
visitContentBlock(block: ContentBlock): void {
869+
visitAll(this, block.children);
870+
}
849871
}
850872

851873
export function visitAll<Result>(visitor: Visitor<Result>, nodes: Node[]): Result[] {
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import * as html from '../ml_parser/ast';
10+
import {ParseError} from '../parse_util';
11+
12+
import * as t from './r3_ast';
13+
import {IDENTIFIER_PATTERN} from './util';
14+
15+
/** Creates a content block from an HTML AST node. */
16+
export function createContentBlock(
17+
ast: html.Block,
18+
visitor: html.Visitor,
19+
): {node: t.ContentBlock | null; errors: ParseError[]} {
20+
const errors: ParseError[] = [];
21+
if (ast.parameters.length !== 1) {
22+
errors.push(
23+
new ParseError(ast.startSourceSpan, '@content block must have exactly one parameter'),
24+
);
25+
return {node: null, errors};
26+
}
27+
28+
const param = ast.parameters[0];
29+
let expr = param.expression.trim();
30+
if (expr.startsWith('(') && expr.endsWith(')')) {
31+
expr = expr.slice(1, -1).trim();
32+
}
33+
34+
const parts = expr.split(',').map((p) => p.trim());
35+
if (parts.length !== 1 || parts[0] === '') {
36+
errors.push(
37+
new ParseError(ast.startSourceSpan, '@content block must have exactly one parameter'),
38+
);
39+
return {node: null, errors};
40+
}
41+
42+
const name = parts[0];
43+
if (!IDENTIFIER_PATTERN.test(name)) {
44+
errors.push(
45+
new ParseError(param.sourceSpan, '@content name must be a valid JavaScript identifier'),
46+
);
47+
return {node: null, errors};
48+
}
49+
50+
const node = new t.ContentBlock(
51+
name,
52+
html.visitAll(visitor, ast.children, ast.children),
53+
ast.nameSpan,
54+
ast.sourceSpan,
55+
ast.startSourceSpan,
56+
ast.endSourceSpan,
57+
ast.i18n,
58+
);
59+
return {node, errors};
60+
}

packages/compiler/src/render3/r3_control_flow.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {ParseError, ParseSourceSpan} from '../parse_util';
1212
import {BindingParser} from '../template_parser/binding_parser';
1313

1414
import * as t from './r3_ast';
15+
import {IDENTIFIER_PATTERN} from './util';
1516

1617
/** Pattern for the expression in a for loop block. */
1718
const FOR_LOOP_EXPRESSION_PATTERN = /^\s*([0-9A-Za-z_$]*)\s+of\s+([\S\s]*)/;
@@ -28,9 +29,6 @@ const ELSE_IF_PATTERN = /^else[^\S\r\n]+if/;
2829
/** Pattern used to identify a `let` parameter. */
2930
const FOR_LOOP_LET_PATTERN = /^let\s+([\S\s]*)/;
3031

31-
/** Pattern used to validate a JavaScript identifier. */
32-
const IDENTIFIER_PATTERN = /^[$A-Z_][0-9A-Z_$]*$/i;
33-
3432
/**
3533
* Pattern to group a string into leading whitespace, non whitespace, and trailing whitespace.
3634
* Useful for getting the variable name span when a span can contain leading and trailing space.

0 commit comments

Comments
 (0)