-
Notifications
You must be signed in to change notification settings - Fork 25.7k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(core): add schematic to escape block syntax characters (#51905)
#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
Showing
9 changed files
with
555 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
34 changes: 34 additions & 0 deletions
34
packages/core/schematics/migrations/block-template-entities/BUILD.bazel
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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"], | ||
) |
27 changes: 27 additions & 0 deletions
27
packages/core/schematics/migrations/block-template-entities/README.md
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 {} | ||
``` |
63 changes: 63 additions & 0 deletions
63
packages/core/schematics/migrations/block-template-entities/index.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
170
packages/core/schematics/migrations/block-template-entities/util.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> = { | ||
'@': '@', | ||
'}': '}', | ||
}; | ||
|
||
/** 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); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.