From 8687a391e3837751e7a552123e9ce0b99fb3b854 Mon Sep 17 00:00:00 2001 From: Enea Jahollari Date: Mon, 15 Jul 2024 15:11:24 +0200 Subject: [PATCH] feat: add migration for self-closing tags (#416) docs: add docs for self-closing-tags migration Co-authored-by: Chau Tran --- docs/src/content/docs/index.mdx | 1 + .../utilities/Migrations/self-closing-tags.md | 47 +++ libs/ngxtension/.eslintrc.json | 1 + libs/plugin/generators.json | 10 + .../__snapshots__/generator.spec.ts.snap | 98 ++++++ .../convert-to-self-closing-tag/compat.ts | 4 + .../generator.spec.ts | 178 +++++++++++ .../convert-to-self-closing-tag/generator.ts | 280 ++++++++++++++++++ .../convert-to-self-closing-tag/schema.d.ts | 4 + .../convert-to-self-closing-tag/schema.json | 17 ++ 10 files changed, 640 insertions(+) create mode 100644 docs/src/content/docs/utilities/Migrations/self-closing-tags.md create mode 100644 libs/plugin/src/generators/convert-to-self-closing-tag/__snapshots__/generator.spec.ts.snap create mode 100644 libs/plugin/src/generators/convert-to-self-closing-tag/compat.ts create mode 100644 libs/plugin/src/generators/convert-to-self-closing-tag/generator.spec.ts create mode 100644 libs/plugin/src/generators/convert-to-self-closing-tag/generator.ts create mode 100644 libs/plugin/src/generators/convert-to-self-closing-tag/schema.d.ts create mode 100644 libs/plugin/src/generators/convert-to-self-closing-tag/schema.json diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index f61b59d6..4beb3503 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -77,6 +77,7 @@ import { Card, CardGrid, Steps } from '@astrojs/starlight/components'; - [output() Migration - migrate to the new output()](/utilities/migrations/new-outputs-migration/) - [inject() Migration - migrate to inject() based DI](/utilities/migrations/inject-migration/) - [viewChild(), contentChild(), viewChildren(), contentChildren() Migration - migrate to the new template queries()](/utilities/migrations/queries-migration/) + - [self-closing tag - migrate to the new self-closing tag](/utilities/migrations/self-closing-tags/) diff --git a/docs/src/content/docs/utilities/Migrations/self-closing-tags.md b/docs/src/content/docs/utilities/Migrations/self-closing-tags.md new file mode 100644 index 00000000..a94df176 --- /dev/null +++ b/docs/src/content/docs/utilities/Migrations/self-closing-tags.md @@ -0,0 +1,47 @@ +--- +title: Self Closing Tags Migration +description: Schematics for migrating non-self-closing tags to self-closing tags. +entryPoint: convert-to-self-closing-tag +badge: stable +contributors: ['enea-jahollari'] +--- + +Angular supports self-closing tags. This means that you can write tags like `` instead of ``. +This is a feature that was introduced in Angular 16. + +### How it works? + +The moment you run the schematics, it will look for all the tags that are not self-closing and convert them to self-closing tags. + +- It will look for all the tags that don't have any content inside them. +- It will only convert components that have "-" in their name. + +### Usage + +In order to run the schematics for all the project in the app you have to run the following script: + +```bash +ng g ngxtension:convert-to-self-closing-tag +``` + +If you want to specify the project name you can pass the `--project` param. + +```bash +ng g ngxtension:convert-to-self-closing-tag --project= +``` + +If you want to run the schematic for a specific component or directive you can pass the `--path` param. + +```bash +ng g ngxtension:convert-to-self-closing-tag --path= +``` + +### Usage with Nx + +To use the schematics on a Nx monorepo you just swap `ng` with `nx` + +Example: + +```bash +nx g ngxtension:convert-to-self-closing-tag --project= +``` diff --git a/libs/ngxtension/.eslintrc.json b/libs/ngxtension/.eslintrc.json index 6f5cc381..155d9774 100644 --- a/libs/ngxtension/.eslintrc.json +++ b/libs/ngxtension/.eslintrc.json @@ -29,6 +29,7 @@ { "files": ["*.html"], "extends": ["plugin:@nx/angular-template"], + "excludedFiles": ["*inline-template-*.component.html"], "rules": {} }, { diff --git a/libs/plugin/generators.json b/libs/plugin/generators.json index 2e9af282..7e2055e6 100644 --- a/libs/plugin/generators.json +++ b/libs/plugin/generators.json @@ -26,6 +26,11 @@ "factory": "./src/generators/convert-queries/generator", "schema": "./src/generators/convert-queries/schema.json", "description": "libs/plugin/src/generators/convert-queries/ generator" + }, + "convert-to-self-closing-tag": { + "factory": "./src/generators/convert-to-self-closing-tag/generator", + "schema": "./src/generators/convert-to-self-closing-tag/schema.json", + "description": "libs/plugin/src/generators/convert-to-self-closing-tag/ generator" } }, "schematics": { @@ -60,6 +65,11 @@ "factory": "./src/generators/convert-queries/compat", "schema": "./src/generators/convert-queries/schema.json", "description": "libs/plugin/src/generators/convert-queries/ generator" + }, + "convert-to-self-closing-tag": { + "factory": "./src/generators/convert-to-self-closing-tag/compat", + "schema": "./src/generators/convert-to-self-closing-tag/schema.json", + "description": "libs/plugin/src/generators/convert-to-self-closing-tag/ generator" } } } diff --git a/libs/plugin/src/generators/convert-to-self-closing-tag/__snapshots__/generator.spec.ts.snap b/libs/plugin/src/generators/convert-to-self-closing-tag/__snapshots__/generator.spec.ts.snap new file mode 100644 index 00000000..481dd584 --- /dev/null +++ b/libs/plugin/src/generators/convert-to-self-closing-tag/__snapshots__/generator.spec.ts.snap @@ -0,0 +1,98 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`convertToSelfClosingTagGenerator should convert properly for inline template 1`] = ` +" +import { Component, Input } from '@angular/core'; + +@Component({ + template: \` + + \` +}) +export class MyCmp { +} +" +`; + +exports[`convertToSelfClosingTagGenerator should convert properly for inline template 2`] = `undefined`; + +exports[`convertToSelfClosingTagGenerator should convert properly for templateUrl 1`] = ` +" +import { Component, Input } from '@angular/core'; + +@Component({ + templateUrl: './my-file.html' +}) +export class MyCmp { +} +" +`; + +exports[`convertToSelfClosingTagGenerator should convert properly for templateUrl 2`] = ` +" +
Hello
+123 +123 +123 + + 123 + + + 123 + + + 123 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +" +`; diff --git a/libs/plugin/src/generators/convert-to-self-closing-tag/compat.ts b/libs/plugin/src/generators/convert-to-self-closing-tag/compat.ts new file mode 100644 index 00000000..224b53a6 --- /dev/null +++ b/libs/plugin/src/generators/convert-to-self-closing-tag/compat.ts @@ -0,0 +1,4 @@ +import { convertNxGenerator } from '@nx/devkit'; +import convertGenerator from './generator'; + +export default convertNxGenerator(convertGenerator); diff --git a/libs/plugin/src/generators/convert-to-self-closing-tag/generator.spec.ts b/libs/plugin/src/generators/convert-to-self-closing-tag/generator.spec.ts new file mode 100644 index 00000000..4edf4631 --- /dev/null +++ b/libs/plugin/src/generators/convert-to-self-closing-tag/generator.spec.ts @@ -0,0 +1,178 @@ +import { Tree } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; + +import convertToSelfClosingTagGenerator from './generator'; +import { ConvertToSelfClosingTagGeneratorSchema } from './schema'; + +const template = ` +
Hello
+123 +123 +123 + + 123 + + + 123 + + + 123 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; + +const filesMap = { + notComponent: ` +import { Injectable } from '@angular/core'; + +@Injectable() +export class MyService {} +`, + componentNoTemplate: ` +import { Component } from '@angular/core'; + +@Component({}) +export class MyCmp {} +`, + + componentWithTemplateUrl: ` +import { Component, Input } from '@angular/core'; + +@Component({ + templateUrl: './my-file.html' +}) +export class MyCmp { +} +`, + + componentWithInlineTemplate: ` +import { Component, Input } from '@angular/core'; + +@Component({ + template: \` + + \` +}) +export class MyCmp { +} +`, +} as const; + +describe('convertToSelfClosingTagGenerator', () => { + let tree: Tree; + const options: ConvertToSelfClosingTagGeneratorSchema = { + path: 'libs/my-file.ts', + }; + + function setup(file: keyof typeof filesMap) { + tree = createTreeWithEmptyWorkspace({ layout: 'apps-libs' }); + tree.write('package.json', `{"dependencies": {"@angular/core": "17.1.0"}}`); + tree.write(`libs/my-file.ts`, filesMap[file]); + + if (file === 'componentWithTemplateUrl') { + tree.write(`libs/my-file.html`, template); + return () => { + return [ + tree.read('libs/my-file.ts', 'utf8'), + filesMap[file], + tree.read('libs/my-file.html', 'utf8'), + template, + ]; + }; + } + + return () => { + return [tree.read('libs/my-file.ts', 'utf8'), filesMap[file]]; + }; + } + + it('should not do anything if not component/directive', async () => { + const readContent = setup('notComponent'); + await convertToSelfClosingTagGenerator(tree, options); + const [updated, original] = readContent(); + expect(updated).toEqual(original); + }); + + it('should not do anything if no template', async () => { + const readContent = setup('componentNoTemplate'); + await convertToSelfClosingTagGenerator(tree, options); + const [updated, original] = readContent(); + expect(updated).toEqual(original); + }); + + it('should convert properly for templateUrl', async () => { + const readContent = setup('componentWithTemplateUrl'); + await convertToSelfClosingTagGenerator(tree, options); + const [updated, , updatedHtml] = readContent(); + expect(updated).toMatchSnapshot(); + expect(updatedHtml).toMatchSnapshot(); + }); + + it('should convert properly for inline template', async () => { + const readContent = setup('componentWithInlineTemplate'); + await convertToSelfClosingTagGenerator(tree, options); + const [updated, , updatedHtml] = readContent(); + expect(updated).toMatchSnapshot(); + expect(updatedHtml).toMatchSnapshot(); + }); +}); diff --git a/libs/plugin/src/generators/convert-to-self-closing-tag/generator.ts b/libs/plugin/src/generators/convert-to-self-closing-tag/generator.ts new file mode 100644 index 00000000..d88295ff --- /dev/null +++ b/libs/plugin/src/generators/convert-to-self-closing-tag/generator.ts @@ -0,0 +1,280 @@ +import { + HtmlParser, + ParseTreeResult, + RecursiveVisitor, + Text, + visitAll, +} from '@angular-eslint/bundled-angular-compiler'; +import { Element as Element_2 } from '@angular/compiler'; +import { + Tree, + formatFiles, + getProjects, + joinPathFragments, + logger, + readJson, + readProjectConfiguration, + visitNotIgnoredFiles, +} from '@nx/devkit'; +import { readFileSync } from 'node:fs'; +import { dirname } from 'node:path'; +import { exit } from 'node:process'; +import { Node, SyntaxKind } from 'ts-morph'; +import { ContentsStore } from '../shared-utils/contents-store'; +import { ConvertToSelfClosingTagGeneratorSchema } from './schema'; + +function trackContents( + tree: Tree, + contentsStore: ContentsStore, + fullPath: string, +) { + if (fullPath.endsWith('.ts')) { + const fileContent = + tree.read(fullPath, 'utf8') || readFileSync(fullPath, 'utf8'); + if (!fileContent.includes('@Component')) return; + if ( + fileContent.includes('template') || + fileContent.includes('templateUrl') + ) { + contentsStore.track(fullPath, fileContent); + } + } +} + +export async function convertToSelfClosingTagGenerator( + tree: Tree, + options: ConvertToSelfClosingTagGeneratorSchema, +) { + const contentsStore = new ContentsStore(); + const packageJson = readJson(tree, 'package.json'); + const angularCorePackage = + packageJson['dependencies']['@angular/core'] || + packageJson['devDependencies']['@angular/core']; + + if (!angularCorePackage) { + logger.error(`[ngxtension] No @angular/core detected`); + return exit(1); + } + + const { path, project } = options; + + if (path && project) { + logger.error( + `[ngxtension] Cannot pass both "path" and "project" to convertToSelfClosingTagGenerator`, + ); + return exit(1); + } + + if (path) { + if (!tree.exists(path)) { + logger.error(`[ngxtension] "${path}" does not exist`); + return exit(1); + } + + trackContents(tree, contentsStore, path); + } else if (project) { + try { + const projectConfiguration = readProjectConfiguration(tree, project); + + if (!projectConfiguration) { + throw `"${project}" project not found`; + } + + visitNotIgnoredFiles(tree, projectConfiguration.root, (path) => { + trackContents(tree, contentsStore, path); + }); + } catch (err) { + logger.error(`[ngxtension] ${err}`); + return; + } + } else { + const projects = getProjects(tree); + for (const project of projects.values()) { + visitNotIgnoredFiles(tree, project.root, (path) => { + trackContents(tree, contentsStore, path); + }); + } + } + + for (const { path: sourcePath } of contentsStore.collection) { + const sourceFile = contentsStore.project.getSourceFile(sourcePath)!; + + const classes = sourceFile.getClasses(); + + for (const targetClass of classes) { + const applicableDecorator = targetClass.getDecorator((decoratorDecl) => { + return ['Component'].includes(decoratorDecl.getName()); + }); + if (!applicableDecorator) continue; + + // process decorator metadata references + const decoratorArg = applicableDecorator.getArguments()[0]; + if (Node.isObjectLiteralExpression(decoratorArg)) { + decoratorArg + .getChildrenOfKind(SyntaxKind.PropertyAssignment) + .forEach((property) => { + const decoratorPropertyName = property.getName(); + + if (decoratorPropertyName === 'template') { + let originalText = property.getFullText(); + originalText = migrateComponentToSelfClosingTags(originalText); + + if (originalText !== property.getFullText()) { + property.replaceWithText(originalText.trimStart()); + } + } else if (decoratorPropertyName === 'templateUrl') { + const dir = dirname(sourcePath); + const templatePath = joinPathFragments( + dir, + property + .getInitializer() + .getText() + .slice(1, property.getInitializer().getText().length - 1), + ); + let templateText = tree.exists(templatePath) + ? tree.read(templatePath, 'utf8') + : ''; + + if (templateText) { + templateText = migrateComponentToSelfClosingTags(templateText); + tree.write(templatePath, templateText); + } + } + }); + } + } + + tree.write(sourcePath, sourceFile.getFullText()); + } + + if (contentsStore.withTransforms.size) { + logger.info( + ` +[ngxtension] The following classes have had some Inputs with "transform" converted. Please double check the type arguments on the "transform" Inputs +`, + ); + contentsStore.withTransforms.forEach((className) => { + logger.info(`- ${className}`); + }); + } + + await formatFiles(tree); + + logger.info( + ` +[ngxtension] Conversion completed. Please check the content and run your formatter as needed. +`, + ); +} + +export function migrateComponentToSelfClosingTags(template: string): string { + const parsedTemplate: ParseTreeResult = new HtmlParser().parse( + template, + 'template.html', + { tokenizeBlocks: true }, + ); + + const visitor = new ElementCollector(); + visitAll(visitor, parsedTemplate.rootNodes); + + let changedOffset = 0; + visitor.elements.forEach((element) => { + const { start, end, tagName } = element; + + const currentLength = template.length; + const templatePart = template.slice( + start + changedOffset, + end + changedOffset, + ); + + function replaceWithSelfClosingTag(html, tagName) { + const pattern = new RegExp( + `<\\s*${tagName}\\s*([^>]*?(?:"[^"]*"|'[^']*'|[^'">])*)\\s*>([\\s\\S]*?)<\\s*/\\s*${tagName}\\s*>`, + 'gi', + ); + const replacement = `<${tagName} $1 />`; + return html.replace(pattern, replacement); + } + + const convertedTemplate = replaceWithSelfClosingTag(templatePart, tagName); + + // if the template has changed, replace the original template with the new one + if (convertedTemplate.length !== templatePart.length) { + template = replaceTemplate( + template, + convertedTemplate, + start, + end, + changedOffset, + ); + + changedOffset += template.length - currentLength; + } + }); + + return template; +} + +class ElementCollector extends RecursiveVisitor { + readonly elements: ElementToMigrate[] = []; + + constructor() { + super(); + } + + override visitElement(element: Element_2, context: any) { + if (element.children.length) { + if (element.children.length === 1) { + const child = element.children[0]; + + if (child instanceof Text) { + if (child.value.trim() === '' || child.value.trim() === '\n') { + if (element.name.includes('-')) { + this.elements.push({ + tagName: element.name, + start: element.sourceSpan.start.offset, + end: element.sourceSpan.end.offset, + }); + } + } + } + } + + return super.visitElement(element, context); + } + + if (element.name.includes('-')) { + this.elements.push({ + tagName: element.name, + start: element.sourceSpan.start.offset, + end: element.sourceSpan.end.offset, + }); + } + return super.visitElement(element, context); + } +} + +/** + * 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) + ); +} + +export interface ElementToMigrate { + tagName: string; + start: number; + end: number; +} + +export default convertToSelfClosingTagGenerator; diff --git a/libs/plugin/src/generators/convert-to-self-closing-tag/schema.d.ts b/libs/plugin/src/generators/convert-to-self-closing-tag/schema.d.ts new file mode 100644 index 00000000..76035f3e --- /dev/null +++ b/libs/plugin/src/generators/convert-to-self-closing-tag/schema.d.ts @@ -0,0 +1,4 @@ +export interface ConvertToSelfClosingTagGeneratorSchema { + project?: string; + path?: string; +} diff --git a/libs/plugin/src/generators/convert-to-self-closing-tag/schema.json b/libs/plugin/src/generators/convert-to-self-closing-tag/schema.json new file mode 100644 index 00000000..0d6af254 --- /dev/null +++ b/libs/plugin/src/generators/convert-to-self-closing-tag/schema.json @@ -0,0 +1,17 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "", + "title": "", + "type": "object", + "properties": { + "project": { + "type": "string", + "aliases": ["name", "projectName"], + "description": "Project for which to convert to self closing tags." + }, + "path": { + "type": "string", + "description": "Path to the component/directive you want to convert to self closing tags" + } + } +}