feat(migrations): add migration to convert templates to use self-closing tags (#57342)

This schematic helps developers to convert their templates to use self-closing tags mostly as a aesthetic change.

PR Close #57342
This commit is contained in:
Enea Jahollari 2024-08-12 02:27:41 +02:00 committed by Jessica Janiuk
parent b6fa69f2c0
commit 1cd3a7db83
19 changed files with 984 additions and 22 deletions

View file

@ -1233,6 +1233,11 @@ const REFERENCE_SUB_NAVIGATION_DATA: NavigationItem[] = [
path: 'reference/migrations/cleanup-unused-imports',
contentPath: 'reference/migrations/cleanup-unused-imports',
},
{
label: 'Self-closing tags',
path: 'reference/migrations/self-closing-tags',
contentPath: 'reference/migrations/self-closing-tags',
},
],
},
];

View file

@ -27,4 +27,7 @@ Learn about how you can migrate your existing angular project to the latest feat
<docs-card title="Cleanup unused imports" link="Try it now" href="reference/migrations/cleanup-unused-imports">
Clean up unused imports in your project.
</docs-card>
<docs-card title="Self-closing tags" link="Migrate now" href="reference/migrations/self-closing-tags">
Convert component templates to use self-closing tags where possible.
</docs-card>
</docs-card-container>

View file

@ -0,0 +1,26 @@
# Migration to self-closing tags
Self-closing tags are supported in Angular templates since [v16](https://blog.angular.dev/angular-v16-is-here-4d7a28ec680d#7065). .
This schematic migrates the templates in your application to use self-closing tags.
Run the schematic using the following command:
<docs-code language="shell">
ng generate @angular/core:self-closing-tag
</docs-code>
#### Before
<docs-code language="angular-html">
<!-- Before -->
<hello-world></hello-world>
<!-- After -->
<hello-world />
</docs-code>

View file

@ -18,6 +18,7 @@ pkg_npm(
"//packages/core/schematics/ng-generate/inject-migration:static_files",
"//packages/core/schematics/ng-generate/output-migration:static_files",
"//packages/core/schematics/ng-generate/route-lazy-loading:static_files",
"//packages/core/schematics/ng-generate/self-closing-tags-migration:static_files",
"//packages/core/schematics/ng-generate/signal-input-migration:static_files",
"//packages/core/schematics/ng-generate/signal-queries-migration:static_files",
"//packages/core/schematics/ng-generate/signals:static_files",
@ -43,6 +44,7 @@ rollup_bundle(
"//packages/core/schematics/ng-generate/signal-input-migration:index.ts": "signal-input-migration",
"//packages/core/schematics/ng-generate/signal-queries-migration:index.ts": "signal-queries-migration",
"//packages/core/schematics/ng-generate/output-migration:index.ts": "output-migration",
"//packages/core/schematics/ng-generate/self-closing-tags-migration:index.ts": "self-closing-tags-migration",
"//packages/core/schematics/migrations/explicit-standalone-flag:index.ts": "explicit-standalone-flag",
"//packages/core/schematics/migrations/pending-tasks:index.ts": "pending-tasks",
"//packages/core/schematics/migrations/provide-initializer:index.ts": "provide-initializer",
@ -63,6 +65,7 @@ rollup_bundle(
"//packages/core/schematics/ng-generate/inject-migration",
"//packages/core/schematics/ng-generate/output-migration",
"//packages/core/schematics/ng-generate/route-lazy-loading",
"//packages/core/schematics/ng-generate/self-closing-tags-migration",
"//packages/core/schematics/ng-generate/signal-input-migration",
"//packages/core/schematics/ng-generate/signal-queries-migration",
"//packages/core/schematics/ng-generate/signals",

View file

@ -51,6 +51,12 @@
"description": "Removes unused imports from standalone components.",
"factory": "./bundles/cleanup-unused-imports#migrate",
"schema": "./ng-generate/cleanup-unused-imports/schema.json"
},
"self-closing-tags-migration": {
"description": "Updates the components templates to use self-closing tags where possible",
"factory": "./bundles/self-closing-tags-migration#migrate",
"schema": "./ng-generate/self-closing-tags-migration/schema.json",
"aliases": ["self-closing-tag"]
}
}
}

View file

@ -7,7 +7,6 @@
*/
import ts from 'typescript';
import assert from 'assert';
import {
confirmAsSerializable,
MigrationStats,

View file

@ -0,0 +1,48 @@
load("//tools:defaults.bzl", "jasmine_node_test", "ts_library")
ts_library(
name = "migration",
srcs = glob(
["**/*.ts"],
exclude = ["*.spec.ts"],
),
visibility = [
"//packages/core/schematics/ng-generate/self-closing-tags-migration:__pkg__",
"//packages/language-service/src/refactorings:__pkg__",
],
deps = [
"//packages/compiler",
"//packages/compiler-cli",
"//packages/compiler-cli/private",
"//packages/compiler-cli/src/ngtsc/annotations",
"//packages/compiler-cli/src/ngtsc/annotations/directive",
"//packages/compiler-cli/src/ngtsc/file_system",
"//packages/compiler-cli/src/ngtsc/imports",
"//packages/compiler-cli/src/ngtsc/metadata",
"//packages/compiler-cli/src/ngtsc/reflection",
"//packages/core/schematics/utils",
"//packages/core/schematics/utils/tsurge",
"@npm//@types/node",
"@npm//typescript",
],
)
ts_library(
name = "test_lib",
testonly = True,
srcs = glob(
["**/*.spec.ts"],
),
deps = [
":migration",
"//packages/compiler-cli",
"//packages/compiler-cli/src/ngtsc/file_system/testing",
"//packages/core/schematics/utils/tsurge",
],
)
jasmine_node_test(
name = "test",
srcs = [":test_lib"],
env = {"FORCE_COLOR": "3"},
)

View file

@ -0,0 +1,285 @@
/**
* @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.dev/license
*/
import {initMockFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system/testing';
import {runTsurgeMigration, diffText} from '../../utils/tsurge/testing';
import {absoluteFrom} from '@angular/compiler-cli';
import {SelfClosingTagsMigration} from './self-closing-tags-migration';
describe('self-closing tags', () => {
beforeEach(() => {
initMockFileSystem('Native');
});
describe('self-closing tags migration', () => {
it('should skip dom elements', async () => {
await verifyDeclarationNoChange(`<button id="123"></button>`);
});
it('should skip custom elements with content', async () => {
await verifyDeclarationNoChange(`<my-cmp>1</my-cmp>`);
});
it('should skip already self-closing custom elements', async () => {
await verifyDeclarationNoChange(`<my-cmp /> <my-cmp with="attributes" />`);
});
it('should migrate custom elements', async () => {
await verifyDeclaration({
before: `<my-cmp></my-cmp>`,
after: `<my-cmp />`,
});
});
it('should migrate custom elements with attributes', async () => {
await verifyDeclaration({
before: `<my-cmp attr="value"></my-cmp>`,
after: `<my-cmp attr="value" />`,
});
});
it('should migrate multiple custom elements in the template', async () => {
await verifyDeclaration({
before: `<my-cmp></my-cmp><my-cmp></my-cmp>`,
after: `<my-cmp /><my-cmp />`,
});
});
it('should migrate a template that contains directives, pipes, inputs, outputs, and events', async () => {
await verifyDeclaration({
before: `
<app-management *ngIf="
categoryList &&
((test1 && test1.length > 0) ||
(test && test.length > 0))
"
[test]="test > 2"
[test2]="test | uppercase"
(click)="test.length > 0 ? test($event) : null"
(testEvent)="test1($event)"></app-management>
`,
after: `
<app-management *ngIf="
categoryList &&
((test1 && test1.length > 0) ||
(test && test.length > 0))
"
[test]="test > 2"
[test2]="test | uppercase"
(click)="test.length > 0 ? test($event) : null"
(testEvent)="test1($event)" />
`,
});
});
it('should migrate multiple cases of spacing', async () => {
await verifyDeclaration({
before: `<app-my-cmp1> </app-my-cmp1>`,
after: `<app-my-cmp1 />`,
});
await verifyDeclaration({
before: `
<app-my-cmp1 test="hello">
</app-my-cmp1>
`,
after: `<app-my-cmp1 test="hello" />`,
});
await verifyDeclarationNoChange(`
<app-my-cmp4
test="hello"
>
123
</app-my-cmp4>
`);
await verifyDeclaration({
before: `
<app-my-cmp1 hello="world">
<app-my-cmp1 hello="world">
</app-my-cmp1>
</app-my-cmp1>
`,
after: `
<app-my-cmp1 hello="world">
<app-my-cmp1 hello="world" />
</app-my-cmp1>
`,
});
await verifyDeclaration({
before: `
<app-my-cmp10 test="hello"
[test]="hello"
(test)="hello()"
>
</app-my-cmp10>
`,
after: `
<app-my-cmp10 test="hello"
[test]="hello"
(test)="hello()"
/>
`,
});
});
it('should migrate the template with multiple nested elements', async () => {
await verifyDeclaration({
before: `
<hello-world12>
<hello-world13>
<hello-world14 count="1" [test]="hello" (test)="test" ></hello-world14>
<hello-world15>
<hello-world16 count="1" [test]="hello" (test)="test" />
<hello-world17 count="1" [test]="hello" (test)="test" ></hello-world17>
<hello-world18
count="1" [test]="hello"
(test)="test"
>
</hello-world18>
</hello-world15>
</hello-world13>
</hello-world12>
`,
after: `
<hello-world12>
<hello-world13>
<hello-world14 count="1" [test]="hello" (test)="test" />
<hello-world15>
<hello-world16 count="1" [test]="hello" (test)="test" />
<hello-world17 count="1" [test]="hello" (test)="test" />
<hello-world18 count="1" [test]="hello"
(test)="test"
/>
</hello-world15>
</hello-world13>
</hello-world12>
`,
});
});
it('should migrate multiple components in a file', async () => {
await verify({
before: `
import {Component} from '@angular/core';
@Component({ template: '<my-cmp></my-cmp>' })
export class Cmp1 {}
@Component({ template: '<my-cmp></my-cmp><my-cmp></my-cmp>' })
export class Cmp2 {}
`,
after: `
import {Component} from '@angular/core';
@Component({ template: '<my-cmp />' })
export class Cmp1 {}
@Component({ template: '<my-cmp /><my-cmp />' })
export class Cmp2 {}
`,
});
});
it('should migrate an external template file', async () => {
const templateFileContent = `
<app-my-cmp1> </app-my-cmp1>
<app-my-cmp1>
</app-my-cmp1>
<app-my-cmp1 hello="world">
<app-my-cmp1 hello="world">
</app-my-cmp1>
</app-my-cmp1>
`;
const templateFileExpected = `
<app-my-cmp1 />
<app-my-cmp1 />
<app-my-cmp1 hello="world">
<app-my-cmp1 hello="world" />
</app-my-cmp1>
`;
const tsFileContent = `
import {Component} from '@angular/core';
@Component({ templateUrl: 'app.component.html' })
export class AppComponent {}
`;
const {fs} = await runTsurgeMigration(new SelfClosingTagsMigration(), [
{
name: absoluteFrom('/app.component.ts'),
isProgramRootFile: true,
contents: tsFileContent,
},
{
name: absoluteFrom('/app.component.html'),
contents: templateFileContent,
},
]);
const componentTsFile = fs.readFile(absoluteFrom('/app.component.ts')).trim();
const actualComponentHtmlFile = fs.readFile(absoluteFrom('/app.component.html')).trim();
const expectedTemplate = templateFileExpected.trim();
// no changes should be made to the component TS file
expect(componentTsFile).toEqual(tsFileContent.trim());
expect(actualComponentHtmlFile)
.withContext(diffText(expectedTemplate, actualComponentHtmlFile))
.toEqual(expectedTemplate);
});
});
});
async function verifyDeclaration(testCase: {before: string; after: string}) {
await verify({
before: populateDeclarationTestCase(testCase.before.trim()),
after: populateExpectedResult(testCase.after.trim()),
});
}
async function verifyDeclarationNoChange(beforeAndAfter: string) {
await verifyDeclaration({before: beforeAndAfter, after: beforeAndAfter});
}
async function verify(testCase: {before: string; after: string}) {
const {fs} = await runTsurgeMigration(new SelfClosingTagsMigration(), [
{
name: absoluteFrom('/app.component.ts'),
isProgramRootFile: true,
contents: testCase.before,
},
]);
const actual = fs.readFile(absoluteFrom('/app.component.ts')).trim();
const expected = testCase.after.trim();
expect(actual).withContext(diffText(expected, actual)).toEqual(expected);
}
function populateDeclarationTestCase(declaration: string): string {
return `
import {Component} from '@angular/core';
@Component({ template: \`${declaration}\` })
export class AppComponent {}
`;
}
function populateExpectedResult(declaration: string): string {
return `
import {Component} from '@angular/core';
@Component({ template: \`${declaration}\` })
export class AppComponent {}
`;
}

View file

@ -0,0 +1,161 @@
/**
* @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.dev/license
*/
import ts from 'typescript';
import {
confirmAsSerializable,
MigrationStats,
ProgramInfo,
projectFile,
ProjectFile,
Replacement,
Serializable,
TextUpdate,
TsurgeFunnelMigration,
} from '../../utils/tsurge';
import {NgComponentTemplateVisitor} from '../../utils/ng_component_template';
import {migrateTemplateToSelfClosingTags} from './to-self-closing-tags';
import {AbsoluteFsPath} from '../../../../compiler-cli';
export interface MigrationConfig {
/**
* Whether to migrate this component template to self-closing tags.
*/
shouldMigrate?: (containingFile: ProjectFile) => boolean;
}
export interface SelfClosingTagsMigrationData {
file: ProjectFile;
replacementCount: number;
replacements: Replacement[];
}
export interface SelfClosingTagsCompilationUnitData {
tagReplacements: Array<SelfClosingTagsMigrationData>;
}
export class SelfClosingTagsMigration extends TsurgeFunnelMigration<
SelfClosingTagsCompilationUnitData,
SelfClosingTagsCompilationUnitData
> {
constructor(private readonly config: MigrationConfig = {}) {
super();
}
override async analyze(
info: ProgramInfo,
): Promise<Serializable<SelfClosingTagsCompilationUnitData>> {
const {sourceFiles, program} = info;
const typeChecker = program.getTypeChecker();
const tagReplacements: Array<SelfClosingTagsMigrationData> = [];
for (const sf of sourceFiles) {
ts.forEachChild(sf, (node: ts.Node) => {
if (!ts.isClassDeclaration(node)) {
return;
}
const file = projectFile(node.getSourceFile(), info);
if (this.config.shouldMigrate && this.config.shouldMigrate(file) === false) {
return;
}
const templateVisitor = new NgComponentTemplateVisitor(typeChecker);
templateVisitor.visitNode(node);
templateVisitor.resolvedTemplates.forEach((template) => {
const {migrated, changed, replacementCount} = migrateTemplateToSelfClosingTags(
template.content,
);
if (changed) {
const fileToMigrate = template.inline
? file
: projectFile(template.filePath as AbsoluteFsPath, info);
const end = template.start + template.content.length;
const replacements = [
prepareTextReplacement(fileToMigrate, migrated, template.start, end),
];
const fileReplacements = tagReplacements.find(
(tagReplacement) => tagReplacement.file === file,
);
if (fileReplacements) {
fileReplacements.replacements.push(...replacements);
fileReplacements.replacementCount += replacementCount;
} else {
tagReplacements.push({file, replacements, replacementCount});
}
}
});
});
}
return confirmAsSerializable({tagReplacements});
}
override async combine(
unitA: SelfClosingTagsCompilationUnitData,
unitB: SelfClosingTagsCompilationUnitData,
): Promise<Serializable<SelfClosingTagsCompilationUnitData>> {
return confirmAsSerializable({
tagReplacements: unitA.tagReplacements.concat(unitB.tagReplacements),
});
}
override async globalMeta(
combinedData: SelfClosingTagsCompilationUnitData,
): Promise<Serializable<SelfClosingTagsCompilationUnitData>> {
const globalMeta: SelfClosingTagsCompilationUnitData = {
tagReplacements: combinedData.tagReplacements,
};
return confirmAsSerializable(globalMeta);
}
override async stats(
globalMetadata: SelfClosingTagsCompilationUnitData,
): Promise<MigrationStats> {
const touchedFilesCount = globalMetadata.tagReplacements.length;
const replacementCount = globalMetadata.tagReplacements.reduce(
(acc, cur) => acc + cur.replacementCount,
0,
);
return {
counters: {
touchedFilesCount,
replacementCount,
},
};
}
override async migrate(globalData: SelfClosingTagsCompilationUnitData) {
return {replacements: globalData.tagReplacements.flatMap(({replacements}) => replacements)};
}
}
function prepareTextReplacement(
file: ProjectFile,
replacement: string,
start: number,
end: number,
): Replacement {
return new Replacement(
file,
new TextUpdate({
position: start,
end: end,
toInsert: replacement,
}),
);
}

View file

@ -0,0 +1,131 @@
/**
* @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.dev/license
*/
import {
DomElementSchemaRegistry,
Element,
RecursiveVisitor,
Text,
visitAll,
} from '@angular/compiler';
import {parseTemplate} from './util';
export function migrateTemplateToSelfClosingTags(template: string): {
replacementCount: number;
migrated: string;
changed: boolean;
} {
let parsed = parseTemplate(template);
if (parsed.tree === undefined) {
return {migrated: template, changed: false, replacementCount: 0};
}
const visitor = new AngularElementCollector();
visitAll(visitor, parsed.tree.rootNodes);
let newTemplate = template;
let changedOffset = 0;
let replacementCount = 0;
for (let element of visitor.elements) {
const {start, end, tagName} = element;
const currentLength = newTemplate.length;
const templatePart = newTemplate.slice(start + changedOffset, end + changedOffset);
const convertedTemplate = replaceWithSelfClosingTag(templatePart, tagName);
// if the template has changed, replace the original template with the new one
if (convertedTemplate.length !== templatePart.length) {
newTemplate = replaceTemplate(newTemplate, convertedTemplate, start, end, changedOffset);
changedOffset += newTemplate.length - currentLength;
replacementCount++;
}
}
return {migrated: newTemplate, changed: changedOffset !== 0, replacementCount};
}
function replaceWithSelfClosingTag(html: string, tagName: string) {
const pattern = new RegExp(
`<\\s*${tagName}\\s*([^>]*?(?:"[^"]*"|'[^']*'|[^'">])*)\\s*>([\\s\\S]*?)<\\s*/\\s*${tagName}\\s*>`,
'gi',
);
return html.replace(pattern, (_, content) => `<${tagName}${content ? ` ${content}` : ''} />`);
}
/**
* Replace the value in the template with the new value based on the start and end position + offset
*/
function replaceTemplate(
template: string,
replaceValue: string,
start: number,
end: number,
offset: number,
) {
return template.slice(0, start + offset) + replaceValue + template.slice(end + offset);
}
interface ElementToMigrate {
tagName: string;
start: number;
end: number;
}
const ALL_HTML_TAGS = new DomElementSchemaRegistry().allKnownElementNames();
export class AngularElementCollector extends RecursiveVisitor {
readonly elements: ElementToMigrate[] = [];
constructor() {
super();
}
override visitElement(element: Element) {
const isHtmlTag = ALL_HTML_TAGS.includes(element.name);
if (isHtmlTag) {
return;
}
const hasNoContent = this.elementHasNoContent(element);
const hasNoClosingTag = this.elementHasNoClosingTag(element);
if (hasNoContent && !hasNoClosingTag) {
this.elements.push({
tagName: element.name,
start: element.sourceSpan.start.offset,
end: element.sourceSpan.end.offset,
});
}
return super.visitElement(element, null);
}
private elementHasNoContent(element: Element) {
if (!element.children?.length) {
return true;
}
if (element.children.length === 1) {
const child = element.children[0];
return child instanceof Text && /^\s*$/.test(child.value);
}
return false;
}
private elementHasNoClosingTag(element: Element) {
const {startSourceSpan, endSourceSpan} = element;
if (!endSourceSpan) {
return true;
}
return (
startSourceSpan.start.offset === endSourceSpan.start.offset &&
startSourceSpan.end.offset === endSourceSpan.end.offset
);
}
}

View file

@ -0,0 +1,46 @@
/**
* @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.dev/license
*/
import {HtmlParser, ParseTreeResult} from '@angular/compiler';
type MigrateError = {
type: string;
error: unknown;
};
interface ParseResult {
tree: ParseTreeResult | undefined;
errors: MigrateError[];
}
export function parseTemplate(template: string): ParseResult {
let parsed: ParseTreeResult;
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.
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: true,
preserveLineEndings: true,
});
// Don't migrate invalid templates.
if (parsed.errors && parsed.errors.length > 0) {
const errors = parsed.errors.map((e) => ({type: 'parse', error: e}));
return {tree: undefined, errors};
}
} catch (e: any) {
return {tree: undefined, errors: [{type: 'parse', error: e}]};
}
return {tree: parsed, errors: []};
}

View file

@ -0,0 +1,29 @@
load("//tools:defaults.bzl", "ts_library")
package(
default_visibility = [
"//packages/core/schematics:__pkg__",
"//packages/core/schematics/migrations/google3:__pkg__",
"//packages/core/schematics/test:__pkg__",
],
)
filegroup(
name = "static_files",
srcs = ["schema.json"],
)
ts_library(
name = "self-closing-tags-migration",
srcs = glob(["**/*.ts"]),
tsconfig = "//packages/core/schematics:tsconfig.json",
deps = [
"//packages/compiler-cli/src/ngtsc/file_system",
"//packages/core/schematics/migrations/self-closing-tags-migration:migration",
"//packages/core/schematics/utils",
"//packages/core/schematics/utils/tsurge",
"//packages/core/schematics/utils/tsurge/helpers/angular_devkit",
"@npm//@angular-devkit/schematics",
"@npm//@types/node",
],
)

View file

@ -0,0 +1,30 @@
# Self-closing tags migration
This schematic helps developers to convert component selectors in the templates to self-closing tags.
This is a purely aesthetic change and does not affect the behavior of the application.
## How to run this migration?
The migration can be run using the following command:
```bash
ng generate @angular/core:self-closing-tag
```
By default, the migration will go over the entire application. If you want to apply this migration to a subset of the files, you can pass the path argument as shown below:
```bash
ng generate @angular/core:self-closing-tag --path src/app/sub-component
```
### How does it work?
The schematic will attempt to find all the places in the templates where the component selectors are used. And check if they can be converted to self-closing tags.
Example:
```html
<!-- Before -->
<app-home hello="world"></app-home>
<!-- After -->
<app-home hello="world" />
```

View file

@ -0,0 +1,113 @@
/**
* @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.dev/license
*/
import {Rule, SchematicsException} from '@angular-devkit/schematics';
import {getProjectTsConfigPaths} from '../../utils/project_tsconfig_paths';
import {DevkitMigrationFilesystem} from '../../utils/tsurge/helpers/angular_devkit/devkit_filesystem';
import {groupReplacementsByFile} from '../../utils/tsurge/helpers/group_replacements';
import {setFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system';
import {ProjectRootRelativePath, TextUpdate} from '../../utils/tsurge';
import {synchronouslyCombineUnitData} from '../../utils/tsurge/helpers/combine_units';
import {
SelfClosingTagsCompilationUnitData,
SelfClosingTagsMigration,
} from '../../migrations/self-closing-tags-migration/self-closing-tags-migration';
interface Options {
path: string;
analysisDir: string;
}
export function migrate(options: Options): Rule {
return async (tree, context) => {
const {buildPaths, testPaths} = await getProjectTsConfigPaths(tree);
if (!buildPaths.length && !testPaths.length) {
throw new SchematicsException(
'Could not find any tsconfig file. Cannot run self-closing tags migration.',
);
}
const fs = new DevkitMigrationFilesystem(tree);
setFileSystem(fs);
const migration = new SelfClosingTagsMigration({
shouldMigrate: (file) => {
return (
file.rootRelativePath.startsWith(fs.normalize(options.path)) &&
!/(^|\/)node_modules\//.test(file.rootRelativePath)
);
},
});
const unitResults: SelfClosingTagsCompilationUnitData[] = [];
const programInfos = [...buildPaths, ...testPaths].map((tsconfigPath) => {
context.logger.info(`Preparing analysis for: ${tsconfigPath}..`);
const baseInfo = migration.createProgram(tsconfigPath, fs);
const info = migration.prepareProgram(baseInfo);
return {info, tsconfigPath};
});
// Analyze phase. Treat all projects as compilation units as
// this allows us to support references between those.
for (const {info, tsconfigPath} of programInfos) {
context.logger.info(`Scanning for component tags: ${tsconfigPath}..`);
unitResults.push(await migration.analyze(info));
}
context.logger.info(``);
context.logger.info(`Processing analysis data between targets..`);
context.logger.info(``);
const combined = await synchronouslyCombineUnitData(migration, unitResults);
if (combined === null) {
context.logger.error('Migration failed unexpectedly with no analysis data');
return;
}
const globalMeta = await migration.globalMeta(combined);
const replacementsPerFile: Map<ProjectRootRelativePath, TextUpdate[]> = new Map();
for (const {tsconfigPath} of programInfos) {
context.logger.info(`Migrating: ${tsconfigPath}..`);
const {replacements} = await migration.migrate(globalMeta);
const changesPerFile = groupReplacementsByFile(replacements);
for (const [file, changes] of changesPerFile) {
if (!replacementsPerFile.has(file)) {
replacementsPerFile.set(file, changes);
}
}
}
context.logger.info(`Applying changes..`);
for (const [file, changes] of replacementsPerFile) {
const recorder = tree.beginUpdate(file);
for (const c of changes) {
recorder
.remove(c.data.position, c.data.end - c.data.position)
.insertLeft(c.data.position, c.data.toInsert);
}
tree.commitUpdate(recorder);
}
const {
counters: {touchedFilesCount, replacementCount},
} = await migration.stats(globalMeta);
context.logger.info('');
context.logger.info(`Successfully migrated to self-closing tags 🎉`);
context.logger.info(
` -> Migrated ${replacementCount} components to self-closing tags in ${touchedFilesCount} component files.`,
);
};
}

View file

@ -0,0 +1,14 @@
{
"$schema": "http://json-schema.org/draft-07/schema",
"$id": "AngularSelfClosingTagMigration",
"title": "Angular Self Closing Tag Migration Schema",
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Path to the directory where all templates should be migrated.",
"x-prompt": "Which directory do you want to migrate?",
"default": "./"
}
}
}

View file

@ -26,6 +26,7 @@ jasmine_node_test(
"//packages/core/schematics/ng-generate/inject-migration:static_files",
"//packages/core/schematics/ng-generate/output-migration:static_files",
"//packages/core/schematics/ng-generate/route-lazy-loading:static_files",
"//packages/core/schematics/ng-generate/self-closing-tags-migration:static_files",
"//packages/core/schematics/ng-generate/signal-input-migration:static_files",
"//packages/core/schematics/ng-generate/signal-queries-migration:static_files",
"//packages/core/schematics/ng-generate/signals:static_files",

View file

@ -0,0 +1,70 @@
/**
* @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.dev/license
*/
import {getSystemPath, normalize, virtualFs} from '@angular-devkit/core';
import {TempScopedNodeJsSyncHost} from '@angular-devkit/core/node/testing';
import {HostTree} from '@angular-devkit/schematics';
import {SchematicTestRunner, UnitTestTree} from '@angular-devkit/schematics/testing';
import {runfiles} from '@bazel/runfiles';
import shx from 'shelljs';
describe('self-closing-tags migration', () => {
let runner: SchematicTestRunner;
let host: TempScopedNodeJsSyncHost;
let tree: UnitTestTree;
let tmpDirPath: string;
let previousWorkingDir: string;
function writeFile(filePath: string, contents: string) {
host.sync.write(normalize(filePath), virtualFs.stringToFileBuffer(contents));
}
function runMigration(options?: {path?: string}) {
return runner.runSchematic('self-closing-tag', options, tree);
}
beforeEach(() => {
runner = new SchematicTestRunner('test', runfiles.resolvePackageRelative('../collection.json'));
host = new TempScopedNodeJsSyncHost();
tree = new UnitTestTree(new HostTree(host));
writeFile('/tsconfig.json', '{}');
writeFile(
'/angular.json',
JSON.stringify({
version: 1,
projects: {t: {root: '', architect: {build: {options: {tsConfig: './tsconfig.json'}}}}},
}),
);
previousWorkingDir = shx.pwd();
tmpDirPath = getSystemPath(host.root);
shx.cd(tmpDirPath);
});
afterEach(() => {
shx.cd(previousWorkingDir);
shx.rm('-r', tmpDirPath);
});
it('should work', async () => {
writeFile(
'/app.component.ts',
`
import {Component} from '@angular/core';
@Component({ template: '<my-cmp></my-cmp>' })
export class Cmp {}
`,
);
await runMigration();
const content = tree.readContent('/app.component.ts').replace(/\s+/g, ' ');
expect(content).toContain('<my-cmp />');
});
});

View file

@ -8,6 +8,7 @@ ts_library(
deps = [
"//packages/compiler",
"//packages/compiler-cli/private",
"//packages/compiler-cli/src/ngtsc/file_system",
"@npm//@angular-devkit/core",
"@npm//@angular-devkit/schematics",
"@npm//@types/node",

View file

@ -6,13 +6,12 @@
* found in the LICENSE file at https://angular.dev/license
*/
import {Tree} from '@angular-devkit/schematics';
import {dirname, relative, resolve} from 'path';
import ts from 'typescript';
import {extractAngularClassMetadata} from './extract_metadata';
import {computeLineStartsMap, getLineAndCharacterFromPosition} from './line_mappings';
import {getPropertyNameText} from './typescript/property_name';
import {AbsoluteFsPath, getFileSystem} from '@angular/compiler-cli/src/ngtsc/file_system';
export interface ResolvedTemplate {
/** Class declaration that contains this template. */
@ -24,7 +23,7 @@ export interface ResolvedTemplate {
/** Whether the given template is inline or not. */
inline: boolean;
/** Path to the file that contains this template. */
filePath: string;
filePath: string | AbsoluteFsPath;
/**
* Gets the character and line of a given position index in the template.
* If the template is declared inline within a TypeScript source file, the line and
@ -43,11 +42,9 @@ export interface ResolvedTemplate {
export class NgComponentTemplateVisitor {
resolvedTemplates: ResolvedTemplate[] = [];
constructor(
public typeChecker: ts.TypeChecker,
private _basePath: string,
private _tree: Tree,
) {}
private fs = getFileSystem();
constructor(public typeChecker: ts.TypeChecker) {}
visitNode(node: ts.Node) {
if (node.kind === ts.SyntaxKind.ClassDeclaration) {
@ -100,25 +97,19 @@ export class NgComponentTemplateVisitor {
});
}
if (propertyName === 'templateUrl' && ts.isStringLiteralLike(property.initializer)) {
const templateDiskPath = resolve(dirname(sourceFileName), property.initializer.text);
// TODO(devversion): Remove this when the TypeScript compiler host is fully virtual
// relying on the devkit virtual tree and not dealing with disk paths. This is blocked on
// providing common utilities for schematics/migrations, given this is done in the
// Angular CDK already:
// https://github.com/angular/components/blob/3704400ee67e0190c9783e16367587489c803ebc/src/cdk/schematics/update-tool/utils/virtual-host.ts.
const templateDevkitPath = relative(this._basePath, templateDiskPath);
// In case the template does not exist in the file system, skip this
// external template.
if (!this._tree.exists(templateDevkitPath)) {
const absolutePath = this.fs.resolve(
this.fs.dirname(sourceFileName),
property.initializer.text,
);
if (!this.fs.exists(absolutePath)) {
return;
}
const fileContent = this._tree.read(templateDevkitPath)!.toString();
const fileContent = this.fs.readFile(absolutePath);
const lineStartsMap = computeLineStartsMap(fileContent);
this.resolvedTemplates.push({
filePath: templateDiskPath,
filePath: absolutePath,
container: node,
content: fileContent,
inline: false,