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: ` + + `, + after: ` + + `, + }); + }); + + 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,