mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
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:
parent
b6fa69f2c0
commit
1cd3a7db83
19 changed files with 984 additions and 22 deletions
|
|
@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
26
adev/src/content/reference/migrations/self-closing-tags.md
Normal file
26
adev/src/content/reference/migrations/self-closing-tags.md
Normal 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>
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@
|
|||
*/
|
||||
|
||||
import ts from 'typescript';
|
||||
import assert from 'assert';
|
||||
import {
|
||||
confirmAsSerializable,
|
||||
MigrationStats,
|
||||
|
|
|
|||
|
|
@ -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"},
|
||||
)
|
||||
|
|
@ -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 {}
|
||||
`;
|
||||
}
|
||||
|
|
@ -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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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: []};
|
||||
}
|
||||
|
|
@ -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",
|
||||
],
|
||||
)
|
||||
|
|
@ -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" />
|
||||
```
|
||||
|
||||
|
|
@ -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.`,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
|
@ -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": "./"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 />');
|
||||
});
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue