diff --git a/adev/src/app/sub-navigation-data.ts b/adev/src/app/sub-navigation-data.ts
index 2aa7db92b06f9..cd6f56b79f118 100644
--- a/adev/src/app/sub-navigation-data.ts
+++ b/adev/src/app/sub-navigation-data.ts
@@ -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',
+ },
],
},
];
diff --git a/adev/src/content/reference/migrations/overview.md b/adev/src/content/reference/migrations/overview.md
index 1804aa60f7959..847cc99d6b4a3 100644
--- a/adev/src/content/reference/migrations/overview.md
+++ b/adev/src/content/reference/migrations/overview.md
@@ -27,4 +27,7 @@ Learn about how you can migrate your existing angular project to the latest feat
Clean up unused imports in your project.
+
+ Convert component templates to use self-closing tags where possible.
+
diff --git a/adev/src/content/reference/migrations/self-closing-tags.md b/adev/src/content/reference/migrations/self-closing-tags.md
new file mode 100644
index 0000000000000..d8a103007380e
--- /dev/null
+++ b/adev/src/content/reference/migrations/self-closing-tags.md
@@ -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:
+
+
+
+ng generate @angular/core:self-closing-tag
+
+
+
+
+#### Before
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/core/schematics/BUILD.bazel b/packages/core/schematics/BUILD.bazel
index 7180546febbc8..645f73f1e912c 100644
--- a/packages/core/schematics/BUILD.bazel
+++ b/packages/core/schematics/BUILD.bazel
@@ -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",
diff --git a/packages/core/schematics/collection.json b/packages/core/schematics/collection.json
index 1225726a17ffd..0a42953afcc82 100644
--- a/packages/core/schematics/collection.json
+++ b/packages/core/schematics/collection.json
@@ -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"]
}
}
}
diff --git a/packages/core/schematics/migrations/output-migration/output-migration.ts b/packages/core/schematics/migrations/output-migration/output-migration.ts
index 6b8daca344620..04522b30271f4 100644
--- a/packages/core/schematics/migrations/output-migration/output-migration.ts
+++ b/packages/core/schematics/migrations/output-migration/output-migration.ts
@@ -7,7 +7,6 @@
*/
import ts from 'typescript';
-import assert from 'assert';
import {
confirmAsSerializable,
MigrationStats,
diff --git a/packages/core/schematics/migrations/self-closing-tags-migration/BUILD.bazel b/packages/core/schematics/migrations/self-closing-tags-migration/BUILD.bazel
new file mode 100644
index 0000000000000..25e7b07ff687b
--- /dev/null
+++ b/packages/core/schematics/migrations/self-closing-tags-migration/BUILD.bazel
@@ -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"},
+)
diff --git a/packages/core/schematics/migrations/self-closing-tags-migration/self-closing-tags-migration.spec.ts b/packages/core/schematics/migrations/self-closing-tags-migration/self-closing-tags-migration.spec.ts
new file mode 100644
index 0000000000000..17e25e1b7fcb2
--- /dev/null
+++ b/packages/core/schematics/migrations/self-closing-tags-migration/self-closing-tags-migration.spec.ts
@@ -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(``);
+ });
+
+ it('should skip custom elements with content', async () => {
+ await verifyDeclarationNoChange(`1`);
+ });
+
+ it('should skip already self-closing custom elements', async () => {
+ await verifyDeclarationNoChange(` `);
+ });
+
+ it('should migrate custom elements', async () => {
+ await verifyDeclaration({
+ before: ``,
+ after: ``,
+ });
+ });
+
+ it('should migrate custom elements with attributes', async () => {
+ await verifyDeclaration({
+ before: ``,
+ after: ``,
+ });
+ });
+
+ it('should migrate multiple custom elements in the template', async () => {
+ await verifyDeclaration({
+ before: ``,
+ after: ``,
+ });
+ });
+
+ it('should migrate a template that contains directives, pipes, inputs, outputs, and events', async () => {
+ await verifyDeclaration({
+ before: `
+ 0) ||
+ (test && test.length > 0))
+ "
+ [test]="test > 2"
+ [test2]="test | uppercase"
+ (click)="test.length > 0 ? test($event) : null"
+ (testEvent)="test1($event)">
+ `,
+ after: `
+ 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: ` `,
+ after: ``,
+ });
+
+ await verifyDeclaration({
+ before: `
+
+
+
+ `,
+ after: ``,
+ });
+
+ await verifyDeclarationNoChange(`
+
+ 123
+
+ `);
+
+ await verifyDeclaration({
+ before: `
+
+
+
+
+ `,
+ after: `
+
+
+
+ `,
+ });
+
+ await verifyDeclaration({
+ before: `
+
+
+ `,
+ after: `
+
+ `,
+ });
+ });
+
+ it('should migrate the template with multiple nested elements', async () => {
+ await verifyDeclaration({
+ before: `
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ after: `
+
+
+
+
+
+
+
+
+
+
+ `,
+ });
+ });
+
+ it('should migrate multiple components in a file', async () => {
+ await verify({
+ before: `
+ import {Component} from '@angular/core';
+ @Component({ template: '' })
+ export class Cmp1 {}
+
+ @Component({ template: '' })
+ export class Cmp2 {}
+ `,
+ after: `
+ import {Component} from '@angular/core';
+ @Component({ template: '' })
+ export class Cmp1 {}
+
+ @Component({ template: '' })
+ export class Cmp2 {}
+ `,
+ });
+ });
+
+ it('should migrate an external template file', async () => {
+ const templateFileContent = `
+
+
+
+
+
+
+
+
+
+ `;
+
+ const templateFileExpected = `
+
+
+
+
+
+
+ `;
+
+ 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 {}
+ `;
+}
diff --git a/packages/core/schematics/migrations/self-closing-tags-migration/self-closing-tags-migration.ts b/packages/core/schematics/migrations/self-closing-tags-migration/self-closing-tags-migration.ts
new file mode 100644
index 0000000000000..cc36690f520ba
--- /dev/null
+++ b/packages/core/schematics/migrations/self-closing-tags-migration/self-closing-tags-migration.ts
@@ -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;
+}
+
+export class SelfClosingTagsMigration extends TsurgeFunnelMigration<
+ SelfClosingTagsCompilationUnitData,
+ SelfClosingTagsCompilationUnitData
+> {
+ constructor(private readonly config: MigrationConfig = {}) {
+ super();
+ }
+
+ override async analyze(
+ info: ProgramInfo,
+ ): Promise> {
+ const {sourceFiles, program} = info;
+ const typeChecker = program.getTypeChecker();
+ const tagReplacements: Array = [];
+
+ 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> {
+ return confirmAsSerializable({
+ tagReplacements: unitA.tagReplacements.concat(unitB.tagReplacements),
+ });
+ }
+
+ override async globalMeta(
+ combinedData: SelfClosingTagsCompilationUnitData,
+ ): Promise> {
+ const globalMeta: SelfClosingTagsCompilationUnitData = {
+ tagReplacements: combinedData.tagReplacements,
+ };
+
+ return confirmAsSerializable(globalMeta);
+ }
+
+ override async stats(
+ globalMetadata: SelfClosingTagsCompilationUnitData,
+ ): Promise {
+ 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,
+ }),
+ );
+}
diff --git a/packages/core/schematics/migrations/self-closing-tags-migration/to-self-closing-tags.ts b/packages/core/schematics/migrations/self-closing-tags-migration/to-self-closing-tags.ts
new file mode 100644
index 0000000000000..868bb5af56015
--- /dev/null
+++ b/packages/core/schematics/migrations/self-closing-tags-migration/to-self-closing-tags.ts
@@ -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
+ );
+ }
+}
diff --git a/packages/core/schematics/migrations/self-closing-tags-migration/util.ts b/packages/core/schematics/migrations/self-closing-tags-migration/util.ts
new file mode 100644
index 0000000000000..0bed1977e0c43
--- /dev/null
+++ b/packages/core/schematics/migrations/self-closing-tags-migration/util.ts
@@ -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: []};
+}
diff --git a/packages/core/schematics/ng-generate/self-closing-tags-migration/BUILD.bazel b/packages/core/schematics/ng-generate/self-closing-tags-migration/BUILD.bazel
new file mode 100644
index 0000000000000..88ed0defa6478
--- /dev/null
+++ b/packages/core/schematics/ng-generate/self-closing-tags-migration/BUILD.bazel
@@ -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",
+ ],
+)
diff --git a/packages/core/schematics/ng-generate/self-closing-tags-migration/README.md b/packages/core/schematics/ng-generate/self-closing-tags-migration/README.md
new file mode 100644
index 0000000000000..c6b3669c3f02e
--- /dev/null
+++ b/packages/core/schematics/ng-generate/self-closing-tags-migration/README.md
@@ -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
+
+
+
+
+
+```
+
diff --git a/packages/core/schematics/ng-generate/self-closing-tags-migration/index.ts b/packages/core/schematics/ng-generate/self-closing-tags-migration/index.ts
new file mode 100644
index 0000000000000..6b308e03a3c9d
--- /dev/null
+++ b/packages/core/schematics/ng-generate/self-closing-tags-migration/index.ts
@@ -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 = 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.`,
+ );
+ };
+}
diff --git a/packages/core/schematics/ng-generate/self-closing-tags-migration/schema.json b/packages/core/schematics/ng-generate/self-closing-tags-migration/schema.json
new file mode 100644
index 0000000000000..ac9107cf90705
--- /dev/null
+++ b/packages/core/schematics/ng-generate/self-closing-tags-migration/schema.json
@@ -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": "./"
+ }
+ }
+}
diff --git a/packages/core/schematics/test/BUILD.bazel b/packages/core/schematics/test/BUILD.bazel
index c84f22e440925..44f3df0a8be07 100644
--- a/packages/core/schematics/test/BUILD.bazel
+++ b/packages/core/schematics/test/BUILD.bazel
@@ -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",
diff --git a/packages/core/schematics/test/self_closing_tags_migration_spec.ts b/packages/core/schematics/test/self_closing_tags_migration_spec.ts
new file mode 100644
index 0000000000000..931dc501387c3
--- /dev/null
+++ b/packages/core/schematics/test/self_closing_tags_migration_spec.ts
@@ -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: '' })
+ export class Cmp {}
+ `,
+ );
+
+ await runMigration();
+
+ const content = tree.readContent('/app.component.ts').replace(/\s+/g, ' ');
+ expect(content).toContain('');
+ });
+});
diff --git a/packages/core/schematics/utils/BUILD.bazel b/packages/core/schematics/utils/BUILD.bazel
index 00a86bc306e83..6df9eeb2f7476 100644
--- a/packages/core/schematics/utils/BUILD.bazel
+++ b/packages/core/schematics/utils/BUILD.bazel
@@ -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",
diff --git a/packages/core/schematics/utils/ng_component_template.ts b/packages/core/schematics/utils/ng_component_template.ts
index 1322679091aaf..c22847a8abb24 100644
--- a/packages/core/schematics/utils/ng_component_template.ts
+++ b/packages/core/schematics/utils/ng_component_template.ts
@@ -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,