Skip to content

Commit

Permalink
feat(compiler): support untagged template literals in expressions (#5…
Browse files Browse the repository at this point in the history
…9230)

Updates the compiler to support untagged template literals inside of the expression syntax (e.g. ``hello ${world}``).

PR Close #59230
  • Loading branch information
crisbeto authored and AndrewKushnir committed Jan 21, 2025
1 parent 6960ec0 commit fe8a683
Show file tree
Hide file tree
Showing 9 changed files with 197 additions and 1 deletion.
Original file line number Diff line number Diff line change
Expand Up @@ -723,3 +723,60 @@ export declare class MyModule {
static ɵinj: i0.ɵɵInjectorDeclaration<MyModule>;
}

/****************************************************************************************************
* PARTIAL FILE: 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';
}
}
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: {{ \`hello world \` }}</div>
<span>With interpolations: {{ \`hello \${name}, it is currently \${timeOfDay}!\` }}</span>
<p>With pipe: {{\`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: {{ \`hello world \` }}</div>
<span>With interpolations: {{ \`hello \${name}, it is currently \${timeOfDay}!\` }}</span>
<p>With pipe: {{\`hello \${name}\` | uppercase}}</p>
`,
imports: [UppercasePipe],
}]
}] });

/****************************************************************************************************
* PARTIAL FILE: 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;
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 @@ -290,6 +290,20 @@
]
}
]
},
{
"description": "should support template literals",
"inputFiles": [
"template_literals.ts"
],
"expectations": [
{
"failureMessage": "Invalid template literal binding",
"files": [
"template_literals.js"
]
}
]
}
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
if (rf & 2) {
$r3$.ɵɵadvance();
$r3$.ɵɵtextInterpolate1("No interpolations: ", `hello world `, "");
$r3$.ɵɵadvance(2);
$r3$.ɵɵtextInterpolate1("With interpolations: ", `hello ${ctx.name}, it is currently ${ctx.timeOfDay}!`, "");
$r3$.ɵɵadvance(2);
$r3$.ɵɵtextInterpolate1("With pipe: ", $r3$.ɵɵpipeBind1(6, 3, `hello ${ctx.name}`), "");
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
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: {{ \`hello world \` }}</div>
<span>With interpolations: {{ \`hello \${name}, it is currently \${timeOfDay}!\` }}</span>
<p>With pipe: {{\`hello \${name}\` | uppercase}}</p>
`,
imports: [UppercasePipe],
})
export class MyApp {
name = 'Frodo';
timeOfDay = 'morning';
}
52 changes: 52 additions & 0 deletions packages/compiler-cli/test/ngtsc/template_typecheck_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3352,6 +3352,58 @@ runInEachFileSystem(() => {
expect(diags.length).toBe(0);
});

describe('template literals', () => {
it('should treat template literals as strings', () => {
env.write(
'test.ts',
`
import {Component} from '@angular/core';
@Component({
template: 'Result: {{getValue(\`foo\`)}}',
standalone: true,
})
export class Main {
getValue(value: number) {
return value;
}
}
`,
);

const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(diags[0].messageText).toBe(
`Argument of type 'string' is not assignable to parameter of type 'number'.`,
);
});

it('should check interpolations inside template literals', () => {
env.write(
'test.ts',
`
import {Component} from '@angular/core';
@Component({
template: '{{\`Hello \${getName(123)}\`}}',
standalone: true,
})
export class Main {
getName(value: string) {
return value;
}
}
`,
);

const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect(diags[0].messageText).toBe(
`Argument of type 'number' is not assignable to parameter of type 'string'.`,
);
});
});

describe('legacy schema checking with the DOM schema', () => {
beforeEach(() => {
env.tsconfig({fullTemplateTypeCheck: false});
Expand Down
4 changes: 4 additions & 0 deletions packages/compiler/src/template/pipeline/ir/src/expression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1305,6 +1305,10 @@ export function transformExpressionsInExpression(
}
} else if (expr instanceof o.WrappedNodeExpr) {
// TODO: Do we need to transform any TS nodes nested inside of this expression?
} else if (expr instanceof o.TemplateLiteralExpr) {
for (let i = 0; i < expr.expressions.length; i++) {
expr.expressions[i] = transformExpressionsInExpression(expr.expressions[i], transform, flags);
}
} else if (
expr instanceof o.ReadVarExpr ||
expr instanceof o.ExternalExpr ||
Expand Down
11 changes: 11 additions & 0 deletions packages/compiler/src/template/pipeline/src/ingest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1164,6 +1164,17 @@ function convertAst(
);
} else if (ast instanceof e.TypeofExpression) {
return o.typeofExpr(convertAst(ast.expression, job, baseSourceSpan));
} else if (ast instanceof e.TemplateLiteral) {
return new o.TemplateLiteralExpr(
ast.elements.map((el) => {
return new o.TemplateLiteralElementExpr(
el.text,
convertSourceSpan(el.span, baseSourceSpan),
);
}),
ast.expressions.map((expr) => convertAst(expr, job, baseSourceSpan)),
convertSourceSpan(ast.span, baseSourceSpan),
);
} else {
throw new Error(
`Unhandled expression type "${ast.constructor.name}" in file "${baseSourceSpan?.start.file.url}"`,
Expand Down
20 changes: 20 additions & 0 deletions packages/core/test/acceptance/integration_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2715,6 +2715,26 @@ describe('acceptance integration tests', () => {
expect(() => TestBed.createComponent(Comp).detectChanges()).not.toThrow();
});

it('should support template literals in expressions', () => {
@Component({
standalone: true,
template: 'Message: {{`Hello, ${name} - ${value}`}}',
})
class TestComponent {
name = 'Frodo';
value = 0;
}

const fixture = TestBed.createComponent(TestComponent);
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Message: Hello, Frodo - 0');

fixture.componentInstance.value++;
fixture.componentInstance.name = 'Bilbo';
fixture.detectChanges();
expect(fixture.nativeElement.textContent).toContain('Message: Hello, Bilbo - 1');
});

describe('tView.firstUpdatePass', () => {
function isFirstUpdatePass() {
const lView = getLView();
Expand Down
8 changes: 8 additions & 0 deletions packages/language-service/test/quick_info_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,14 @@ describe('quick info', () => {
expectedDisplayString: '(property) name: "name"',
});
});

it('should work for template literal interpolations', () => {
expectQuickInfo({
templateOverride: `<div *ngFor="let name of constNames">{{\`Hello \${na¦me}\`}}</div>`,
expectedSpanText: 'name',
expectedDisplayString: '(variable) name: { readonly name: "name"; }',
});
});
});

describe('pipes', () => {
Expand Down

0 comments on commit fe8a683

Please sign in to comment.