Skip to content

Commit

Permalink
feat(core): add schematic to escape block syntax characters (#51905)
Browse files Browse the repository at this point in the history
#51891 introduces a new syntax that assigns a new meaning to the `@` and `}` in Angular templates. This is problematic for existing apps which may have the characters in their templates already, because it can lead to syntax errors.

These changes add an `ng update` schematic that will replace any usages of the special characters with their HTML entities.

PR Close #51905
  • Loading branch information
crisbeto authored and dylhunn committed Sep 26, 2023
1 parent ac1afd8 commit c7127b9
Show file tree
Hide file tree
Showing 9 changed files with 555 additions and 0 deletions.
1 change: 1 addition & 0 deletions packages/compiler/src/compiler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export * from './ml_parser/tags';
export {ParseTreeResult, TreeError} from './ml_parser/parser';
export {LexerRange} from './ml_parser/lexer';
export * from './ml_parser/xml_parser';
export {TokenType as LexerTokenType} from './ml_parser/tokens';
export {ArrayType, DYNAMIC_TYPE, BinaryOperator, BinaryOperatorExpr, BuiltinType, BuiltinTypeName, CommaExpr, ConditionalExpr, DeclareFunctionStmt, DeclareVarStmt, Expression, ExpressionStatement, ExpressionType, ExpressionVisitor, ExternalExpr, ExternalReference, literalMap, FunctionExpr, IfStmt, InstantiateExpr, InvokeFunctionExpr, ArrowFunctionExpr, LiteralArrayExpr, LiteralExpr, LiteralMapExpr, MapType, NotExpr, NONE_TYPE, ReadKeyExpr, ReadPropExpr, ReadVarExpr, ReturnStatement, StatementVisitor, TaggedTemplateExpr, TemplateLiteral, TemplateLiteralElement, Type, TypeModifier, TypeVisitor, WrappedNodeExpr, WriteKeyExpr, WritePropExpr, WriteVarExpr, StmtModifier, Statement, STRING_TYPE, TypeofExpr, jsDocComment, leadingComment, LeadingComment, JSDocComment, UnaryOperator, UnaryOperatorExpr, LocalizedString, TransplantedType, DynamicImportExpr} from './output/output_ast';
export {EmitterVisitorContext} from './output/abstract_emitter';
export {JitEvaluator} from './output/output_jit';
Expand Down
1 change: 1 addition & 0 deletions packages/core/schematics/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ pkg_npm(
validate = False,
visibility = ["//packages/core:__pkg__"],
deps = [
"//packages/core/schematics/migrations/block-template-entities:bundle",
"//packages/core/schematics/migrations/guard-and-resolve-interfaces:bundle",
"//packages/core/schematics/migrations/remove-module-id:bundle",
"//packages/core/schematics/ng-generate/standalone-migration:bundle",
Expand Down
5 changes: 5 additions & 0 deletions packages/core/schematics/migrations.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@
"version": "16.0.0",
"description": "In Angular version 15.2, the guard and resolver interfaces (CanActivate, Resolve, etc) were deprecated. This migration removes imports and 'implements' clauses that contain them.",
"factory": "./migrations/guard-and-resolve-interfaces/bundle"
},
"migration-v17-block-template-entities": {
"version": "17.0.0-0",
"description": "Angular v17 introduces a new control flow syntax that uses the @ and } characters. This migration replaces the existing usages with their corresponding HTML entities.",
"factory": "./migrations/block-template-entities/bundle"
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
load("//tools:defaults.bzl", "esbuild", "ts_library")

package(
default_visibility = [
"//packages/core/schematics:__pkg__",
"//packages/core/schematics/migrations/google3:__pkg__",
"//packages/core/schematics/test:__pkg__",
],
)

ts_library(
name = "block-template-entities",
srcs = glob(["**/*.ts"]),
tsconfig = "//packages/core/schematics:tsconfig.json",
deps = [
"//packages/compiler",
"//packages/core/schematics/utils",
"@npm//@angular-devkit/schematics",
"@npm//@types/node",
"@npm//typescript",
],
)

esbuild(
name = "bundle",
entry_point = ":index.ts",
external = [
"@angular-devkit/*",
"typescript",
],
format = "cjs",
platform = "node",
deps = [":block-template-entities"],
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
## Block syntax template entities migration

Angular v17 introduces a new control flow syntax that uses the `@` and `}` characters.
This migration replaces the existing usages with their corresponding HTML entities.
Usages within HTML starting tags, interpolations and ICU expressions are preserved.


#### Before
```ts
import {Component} from '@angular/core';

@Component({
template: `My email is [email protected]. This is a brace -> }`
})
export class MyComp {}
```


#### After
```ts
import {Component} from '@angular/core';

@Component({
template: `My email is hello@hi.com. This is a brace -> }`
})
export class MyComp {}
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import {Rule, SchematicsException, Tree} from '@angular-devkit/schematics';
import {relative} from 'path';

import {getProjectTsConfigPaths} from '../../utils/project_tsconfig_paths';
import {canMigrateFile, createMigrationProgram} from '../../utils/typescript/compiler_host';

import {analyze, AnalyzedFile, migrateTemplate} from './util';

export default function(): Rule {
return async (tree: Tree) => {
const {buildPaths, testPaths} = await getProjectTsConfigPaths(tree);
const basePath = process.cwd();
const allPaths = [...buildPaths, ...testPaths];

if (!allPaths.length) {
throw new SchematicsException(
'Could not find any tsconfig file. Cannot run the block syntax template entities migration.');
}

for (const tsconfigPath of allPaths) {
runBlockTemplateEntitiesMigration(tree, tsconfigPath, basePath);
}
};
}

function runBlockTemplateEntitiesMigration(tree: Tree, tsconfigPath: string, basePath: string) {
const program = createMigrationProgram(tree, tsconfigPath, basePath);
const sourceFiles =
program.getSourceFiles().filter(sourceFile => canMigrateFile(basePath, sourceFile, program));
const analysis = new Map<string, AnalyzedFile>();

for (const sourceFile of sourceFiles) {
analyze(sourceFile, analysis);
}

for (const [path, file] of analysis) {
const ranges = file.getSortedRanges();
const relativePath = relative(basePath, path);
const content = tree.readText(relativePath);
const update = tree.beginUpdate(relativePath);

for (const [start, end] of ranges) {
const template = content.slice(start, end);
const length = (end ?? content.length) - start;
const migrated = migrateTemplate(template);

if (migrated !== null) {
update.remove(start, length);
update.insertLeft(start, migrated);
}
}

tree.commitUpdate(update);
}
}
170 changes: 170 additions & 0 deletions packages/core/schematics/migrations/block-template-entities/util.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/

import {HtmlParser, LexerTokenType, RecursiveVisitor, Text, visitAll} from '@angular/compiler';
import {dirname, join} from 'path';
import ts from 'typescript';

/**
* Represents a range of text within a file. Omitting the end
* means that it's until the end of the file.
*/
type Range = [start: number, end?: number];

/** Mapping between characters that need to be replaced and their replacements. */
const REPLACEMENTS: Record<string, string> = {
'@': '&#64;',
'}': '&#125;',
};

/** Represents a file that was analyzed by the migration. */
export class AnalyzedFile {
private ranges: Range[] = [];

/** Returns the ranges in the order in which they should be migrated. */
getSortedRanges(): Range[] {
return this.ranges.slice().sort(([aStart], [bStart]) => bStart - aStart);
}

/**
* Adds a text range to an `AnalyzedFile`.
* @param path Path of the file.
* @param analyzedFiles Map keeping track of all the analyzed files.
* @param range Range to be added.
*/
static addRange(path: string, analyzedFiles: Map<string, AnalyzedFile>, range: Range): void {
let analysis = analyzedFiles.get(path);

if (!analysis) {
analysis = new AnalyzedFile();
analyzedFiles.set(path, analysis);
}

const duplicate =
analysis.ranges.find(current => current[0] === range[0] && current[1] === range[1]);

if (!duplicate) {
analysis.ranges.push(range);
}
}
}

/**
* Analyzes a source file to find file that need to be migrated and the text ranges within them.
* @param sourceFile File to be analyzed.
* @param analyzedFiles Map in which to store the results.
*/
export function analyze(sourceFile: ts.SourceFile, analyzedFiles: Map<string, AnalyzedFile>) {
for (const node of sourceFile.statements) {
if (!ts.isClassDeclaration(node)) {
continue;
}

// Note: we have a utility to resolve the Angular decorators from a class declaration already.
// We don't use it here, because it requires access to the type checker which makes it more
// time-consuming to run internally.
const decorator = ts.getDecorators(node)?.find(dec => {
return ts.isCallExpression(dec.expression) && ts.isIdentifier(dec.expression.expression) &&
dec.expression.expression.text === 'Component';
}) as (ts.Decorator & {expression: ts.CallExpression}) |
undefined;

const metadata = decorator && decorator.expression.arguments.length > 0 &&
ts.isObjectLiteralExpression(decorator.expression.arguments[0]) ?
decorator.expression.arguments[0] :
null;

if (!metadata) {
continue;
}

for (const prop of metadata.properties) {
// All the properties we care about should have static
// names and be initialized to a static string.
if (!ts.isPropertyAssignment(prop) || !ts.isStringLiteralLike(prop.initializer) ||
(!ts.isIdentifier(prop.name) && !ts.isStringLiteralLike(prop.name))) {
continue;
}

switch (prop.name.text) {
case 'template':
// +1/-1 to exclude the opening/closing characters from the range.
AnalyzedFile.addRange(
sourceFile.fileName, analyzedFiles,
[prop.initializer.getStart() + 1, prop.initializer.getEnd() - 1]);
break;

case 'templateUrl':
// Leave the end as undefined which means that the range is until the end of the file.
const path = join(dirname(sourceFile.fileName), prop.initializer.text);
AnalyzedFile.addRange(path, analyzedFiles, [0]);
break;
}
}
}
}

/**
* Escapes the block syntax characters in a template string.
* Returns null if the migration failed (e.g. there was a syntax error).
*/
export function migrateTemplate(template: string): string|null {
try {
// Note: we use the HtmlParser here, instead of the `parseTemplate` function, because the
// latter returns an Ivy AST, not an HTML AST. The HTML AST has the advantage of preserving
// interpolated text as text nodes containing a mixture of interpolation tokens and text tokens,
// rather than turning them into `BoundText` nodes like the Ivy AST does. This allows us to
// easily get the text-only ranges without having to reconstruct the original text.
const parsed = new HtmlParser().parse(template, '', {
// Allows for ICUs to be parsed.
tokenizeExpansionForms: true,
// Explicitly disable blocks so that their characters are treated as plain text.
tokenizeBlocks: false,
});

// Don't migrate invalid templates.
if (parsed.errors && parsed.errors.length > 0) {
return null;
}

let result = template;
const visitor = new TextRangeCollector();
visitAll(visitor, parsed.rootNodes);
const sortedRanges = visitor.textRanges.sort(([aStart], [bStart]) => bStart - aStart);

for (const [start, end] of sortedRanges) {
const text = result.slice(start, end);
let replaced = '';

for (const char of text) {
replaced += REPLACEMENTS[char] || char;
}

result = result.slice(0, start) + replaced + result.slice(end);
}

return result;
} catch {
return null;
}
}

/** Finds all text-only ranges within an HTML AST. Skips over interpolations and ICUs. */
class TextRangeCollector extends RecursiveVisitor {
readonly textRanges: Range[] = [];

override visitText(text: Text): void {
for (const token of text.tokens) {
if (token.type === LexerTokenType.TEXT) {
this.textRanges.push([token.sourceSpan.start.offset, token.sourceSpan.end.offset]);
}
}

super.visitText(text, null);
}
}
2 changes: 2 additions & 0 deletions packages/core/schematics/test/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ jasmine_node_test(
data = [
"//packages/core/schematics:collection.json",
"//packages/core/schematics:migrations.json",
"//packages/core/schematics/migrations/block-template-entities",
"//packages/core/schematics/migrations/block-template-entities:bundle",
"//packages/core/schematics/migrations/guard-and-resolve-interfaces",
"//packages/core/schematics/migrations/guard-and-resolve-interfaces:bundle",
"//packages/core/schematics/migrations/remove-module-id",
Expand Down
Loading

0 comments on commit c7127b9

Please sign in to comment.