Skip to content

Commit

Permalink
feat(compiler): support tagged template literals in expressions (angu…
Browse files Browse the repository at this point in the history
…lar#59947)

Adds support for using tagged template literals in Angular templates.

Ex:
```
@component({
  template: '{{ greet`Hello, ${name()}` }}'
})
export class MyComp {
  name = input();

  greet(strings: TemplateStringsArray, name: string) {
    return strings[0] + name + strings[1] + '!';
  }
}
```

PR Close angular#59947
  • Loading branch information
mmalerba committed Feb 28, 2025
1 parent f9043e2 commit 51b8ff2
Show file tree
Hide file tree
Showing 20 changed files with 547 additions and 88 deletions.
19 changes: 10 additions & 9 deletions adev/src/content/guide/templates/expression-syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,16 @@ Angular supports a subset of [literal values](https://developer.mozilla.org/en-U

### Supported value literals

| Literal type | Example values |
| --------------- | ------------------------------- |
| String | `'Hello'`, `"World"` |
| Boolean | `true`, `false` |
| Number | `123`, `3.14` |
| Object | `{name: 'Alice'}` |
| Array | `['Onion', 'Cheese', 'Garlic']` |
| null | `null` |
| Template string | `` `Hello ${name}` `` |
| Literal type | Example values |
| ---------------------- | ------------------------------- |
| String | `'Hello'`, `"World"` |
| Boolean | `true`, `false` |
| Number | `123`, `3.14` |
| Object | `{name: 'Alice'}` |
| Array | `['Onion', 'Cheese', 'Garlic']` |
| null | `null` |
| Template string | `` `Hello ${name}` `` |
| Tagged template string | `` tag`Hello ${name}` `` |

### Unsupported literals

Expand Down
15 changes: 12 additions & 3 deletions packages/compiler-cli/src/ngtsc/typecheck/src/expression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
SafeCall,
SafeKeyedRead,
SafePropertyRead,
TaggedTemplateLiteral,
TemplateLiteral,
TemplateLiteralElement,
ThisReceiver,
Expand All @@ -38,12 +39,9 @@ import {
VoidExpression,
} from '@angular/compiler';
import ts from 'typescript';

import {TypeCheckingConfig} from '../api';

import {addParseSpanInfo, wrapForDiagnostics, wrapForTypeChecker} from './diagnostics';
import {tsCastToAny, tsNumericExpression} from './ts_util';

/**
* Expression that is cast to any. Currently represented as `0 as any`.
*
Expand Down Expand Up @@ -484,6 +482,14 @@ class AstTranslator implements AstVisitor {
throw new Error('Method not implemented');
}

visitTaggedTemplateLiteral(ast: TaggedTemplateLiteral): ts.TaggedTemplateExpression {
return ts.factory.createTaggedTemplateExpression(
this.translate(ast.tag),
undefined,
this.visitTemplateLiteral(ast.template),
);
}

private convertToSafeCall(
ast: Call | SafeCall,
expr: ts.Expression,
Expand Down Expand Up @@ -615,4 +621,7 @@ class VeSafeLhsInferenceBugDetector implements AstVisitor {
visitTemplateLiteralElement(ast: TemplateLiteralElement, context: any) {
return false;
}
visitTaggedTemplateLiteral(ast: TaggedTemplateLiteral, context: any) {
return false;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,16 @@ describe('type check blocks', () => {
);
});

it('should handle tagged template literals', () => {
expect(tcb('{{ tag`hello world` }}')).toContain('"" + (((this).tag) `hello world`);');
expect(tcb('{{ tag`hello \\${name}!!!` }}')).toContain(
'"" + (((this).tag) `hello \\${name}!!!`);',
);
expect(tcb('{{ tag`${a} - ${b} - ${c}` }}')).toContain(
'"" + (((this).tag) `${((this).a)} - ${((this).b)} - ${((this).c)}`);',
);
});

describe('type constructors', () => {
it('should handle missing property bindings', () => {
const TEMPLATE = `<div dir [inputA]="foo"></div>`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -780,3 +780,62 @@ export declare class MyApp {
static ɵcmp: i0.ɵɵComponentDeclaration<MyApp, "my-app", never, {}, {}, never, never, true, never>;
}

/****************************************************************************************************
* PARTIAL FILE: tagged_template_literals.js
****************************************************************************************************/
import { Component, Pipe } from '@angular/core';
import * as i0 from "@angular/core";
export class UppercasePipe {
transform(value) {
return value.toUpperCase();
}
}
UppercasePipe.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: UppercasePipe, deps: [], target: i0.ɵɵFactoryTarget.Pipe });
UppercasePipe.ɵpipe = i0.ɵɵngDeclarePipe({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: UppercasePipe, isStandalone: true, name: "uppercase" });
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: UppercasePipe, decorators: [{
type: Pipe,
args: [{ name: 'uppercase' }]
}] });
export class MyApp {
constructor() {
this.name = 'Frodo';
this.timeOfDay = 'morning';
this.tag = (strings, ...args) => '';
}
}
MyApp.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, deps: [], target: i0.ɵɵFactoryTarget.Component });
MyApp.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "0.0.0-PLACEHOLDER", type: MyApp, isStandalone: true, selector: "my-app", ngImport: i0, template: `
<div>No interpolations: {{ tag\`hello world \` }}</div>
<span>With interpolations: {{ tag\`hello \${name}, it is currently \${timeOfDay}!\` }}</span>
<p>With pipe: {{ tag\`hello \${name}\` | uppercase }}</p>
`, isInline: true, dependencies: [{ kind: "pipe", type: UppercasePipe, name: "uppercase" }] });
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "0.0.0-PLACEHOLDER", ngImport: i0, type: MyApp, decorators: [{
type: Component,
args: [{
selector: 'my-app',
template: `
<div>No interpolations: {{ tag\`hello world \` }}</div>
<span>With interpolations: {{ tag\`hello \${name}, it is currently \${timeOfDay}!\` }}</span>
<p>With pipe: {{ tag\`hello \${name}\` | uppercase }}</p>
`,
imports: [UppercasePipe],
}]
}] });

/****************************************************************************************************
* PARTIAL FILE: tagged_template_literals.d.ts
****************************************************************************************************/
import * as i0 from "@angular/core";
export declare class UppercasePipe {
transform(value: string): string;
static ɵfac: i0.ɵɵFactoryDeclaration<UppercasePipe, never>;
static ɵpipe: i0.ɵɵPipeDeclaration<UppercasePipe, "uppercase", true>;
}
export declare class MyApp {
name: string;
timeOfDay: string;
tag: (strings: TemplateStringsArray, ...args: string[]) => string;
static ɵfac: i0.ɵɵFactoryDeclaration<MyApp, never>;
static ɵcmp: i0.ɵɵComponentDeclaration<MyApp, "my-app", never, {}, {}, never, never, true, never>;
}

Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@
"cases": [
{
"description": "should instantiate directives",
"inputFiles": [
"directives.ts"
],
"inputFiles": ["directives.ts"],
"expectations": [
{
"failureMessage": "Incorrect ChildComponent.ɵcmp",
Expand Down Expand Up @@ -62,16 +60,11 @@
]
}
],
"compilationModeFilter": [
"full compile",
"local compile"
]
"compilationModeFilter": ["full compile", "local compile"]
},
{
"description": "should support complex selectors",
"inputFiles": [
"complex_selectors.ts"
],
"inputFiles": ["complex_selectors.ts"],
"expectations": [
{
"failureMessage": "Incorrect SomeDirective.ɵdir",
Expand Down Expand Up @@ -113,23 +106,17 @@
},
{
"description": "should convert #my-app selector to [\"\", \"id\", \"my-app\"]",
"inputFiles": [
"id_selector.ts"
],
"inputFiles": ["id_selector.ts"],
"expectations": [
{
"failureMessage": "Incorrect SomeComponent.ɵcomp",
"files": [
"id_selector.js"
]
"files": ["id_selector.js"]
}
]
},
{
"description": "should support components without selector",
"inputFiles": [
"no_selector.ts"
],
"inputFiles": ["no_selector.ts"],
"expectations": [
{
"failureMessage": "Incorrect EmptyOutletComponent.ɵcmp",
Expand All @@ -153,9 +140,7 @@
},
{
"description": "should not treat ElementRef, ViewContainerRef, or ChangeDetectorRef specially when injecting",
"inputFiles": [
"view_tokens_di.ts"
],
"inputFiles": ["view_tokens_di.ts"],
"expectations": [
{
"failureMessage": "Incorrect MyComponent.ɵcmp",
Expand All @@ -179,9 +164,7 @@
},
{
"description": "should support structural directives",
"inputFiles": [
"structural_directives.ts"
],
"inputFiles": ["structural_directives.ts"],
"expectations": [
{
"failureMessage": "Incorrect IfDirective.ɵdir",
Expand Down Expand Up @@ -223,85 +206,71 @@
},
{
"description": "should support array literals",
"inputFiles": [
"array_literals.ts"
],
"inputFiles": ["array_literals.ts"],
"expectations": [
{
"failureMessage": "Invalid array emit",
"files": [
"array_literals.js"
]
"files": ["array_literals.js"]
}
]
},
{
"description": "should support 9+ bindings in array literals",
"inputFiles": [
"array_literals_many.ts"
],
"inputFiles": ["array_literals_many.ts"],
"expectations": [
{
"failureMessage": "Invalid array binding",
"files": [
"array_literals_many.js"
]
"files": ["array_literals_many.js"]
}
]
},
{
"description": "should support object literals",
"inputFiles": [
"object_literals.ts"
],
"inputFiles": ["object_literals.ts"],
"expectations": [
{
"failureMessage": "Invalid object literal binding",
"files": [
"object_literals.js"
]
"files": ["object_literals.js"]
}
]
},
{
"description": "should support expressions nested deeply in object/array literals",
"inputFiles": [
"literal_nested_expression.ts"
],
"inputFiles": ["literal_nested_expression.ts"],
"expectations": [
{
"failureMessage": "Invalid array/object literal binding",
"files": [
"literal_nested_expression.js"
]
"files": ["literal_nested_expression.js"]
}
]
},
{
"description": "should support number literals with separators",
"inputFiles": [
"number_separator.ts"
],
"inputFiles": ["number_separator.ts"],
"expectations": [
{
"failureMessage": "Invalid number literal",
"files": [
"number_separator.js"
]
"files": ["number_separator.js"]
}
]
},
{
"description": "should support template literals",
"inputFiles": [
"template_literals.ts"
],
"inputFiles": ["template_literals.ts"],
"expectations": [
{
"failureMessage": "Invalid template literal binding",
"files": [
"template_literals.js"
]
"files": ["template_literals.js"]
}
]
},
{
"description": "should support tagged template literals",
"inputFiles": ["tagged_template_literals.ts"],
"expectations": [
{
"failureMessage": "Invalid tagged template literal binding",
"files": ["tagged_template_literals.js"]
}
]
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
if (rf & 2) {
$r3$.ɵɵadvance();
$r3$.ɵɵtextInterpolate1("No interpolations: ", ctx.tag `hello world `, "");
$r3$.ɵɵadvance(2);
$r3$.ɵɵtextInterpolate1("With interpolations: ", ctx.tag `hello ${ctx.name}, it is currently ${ctx.timeOfDay}!`, "");
$r3$.ɵɵadvance(2);
$r3$.ɵɵtextInterpolate1("With pipe: ", $r3$.ɵɵpipeBind1(6, 3, ctx.tag `hello ${ctx.name}`), "");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import {Component, Pipe} from '@angular/core';

@Pipe({name: 'uppercase'})
export class UppercasePipe {
transform(value: string) {
return value.toUpperCase();
}
}

@Component({
selector: 'my-app',
template: `
<div>No interpolations: {{ tag\`hello world \` }}</div>
<span>With interpolations: {{ tag\`hello \${name}, it is currently \${timeOfDay}!\` }}</span>
<p>With pipe: {{ tag\`hello \${name}\` | uppercase }}</p>
`,
imports: [UppercasePipe],
})
export class MyApp {
name = 'Frodo';
timeOfDay = 'morning';
tag = (strings: TemplateStringsArray, ...args: string[]) => '';
}
Loading

0 comments on commit 51b8ff2

Please sign in to comment.