diff --git a/src/material/schematics/ng-generate/mdc-migration/BUILD.bazel b/src/material/schematics/ng-generate/mdc-migration/BUILD.bazel index 043a23c8c5c6..b798d6033cc4 100644 --- a/src/material/schematics/ng-generate/mdc-migration/BUILD.bazel +++ b/src/material/schematics/ng-generate/mdc-migration/BUILD.bazel @@ -17,6 +17,8 @@ ts_library( "//src/cdk/schematics", "@npm//@angular-devkit/schematics", "@npm//@types/node", + "@npm//postcss", + "@npm//postcss-scss", ], ) diff --git a/src/material/schematics/ng-generate/mdc-migration/index.ts b/src/material/schematics/ng-generate/mdc-migration/index.ts index d4989982f4ac..3651e0f9ce7a 100644 --- a/src/material/schematics/ng-generate/mdc-migration/index.ts +++ b/src/material/schematics/ng-generate/mdc-migration/index.ts @@ -10,7 +10,9 @@ import {Rule, SchematicContext, Tree} from '@angular-devkit/schematics'; import {Schema} from './schema'; import {DevkitFileSystem, UpdateProject, findStylesheetFiles} from '@angular/cdk/schematics'; import {ThemingStylesMigration} from './rules/theming-styles'; +import {MIGRATORS} from './rules'; import {dirname} from 'path'; +import {StyleMigrator} from './rules/style-migrator'; /** Groups of components that must be migrated together. */ const migrationGroups = [ @@ -59,6 +61,13 @@ export default function (options: Schema): Rule { console.log('Migrating:', [...componentsToMigrate]); console.log('Directory:', migrationDir); + const migrators: StyleMigrator[] = []; + for (let i = 0; i < MIGRATORS.length; i++) { + if (componentsToMigrate.has(MIGRATORS[i].component)) { + migrators.push(MIGRATORS[i]); + } + } + return (tree: Tree, context: SchematicContext) => { const fileSystem = new DevkitFileSystem(tree); const program = UpdateProject.createProgramFromTsconfig( @@ -71,7 +80,7 @@ export default function (options: Schema): Rule { const {hasFailures} = project.migrate( [ThemingStylesMigration], null, - null, + migrators, additionalStylesheetPaths, ); diff --git a/src/material/schematics/ng-generate/mdc-migration/rules/button-styles.spec.ts b/src/material/schematics/ng-generate/mdc-migration/rules/button-styles.spec.ts new file mode 100644 index 000000000000..77ac4743d463 --- /dev/null +++ b/src/material/schematics/ng-generate/mdc-migration/rules/button-styles.spec.ts @@ -0,0 +1,196 @@ +import {createTestApp, patchDevkitTreeToExposeTypeScript} from '@angular/cdk/schematics/testing'; +import {SchematicTestRunner, UnitTestTree} from '@angular-devkit/schematics/testing'; +import {Schema} from '../schema'; +import {runfiles} from '@bazel/runfiles'; + +describe('button styles', () => { + let runner: SchematicTestRunner; + let cliAppTree: UnitTestTree; + const tsconfig = '/projects/material/tsconfig.app.json'; + const themeFile = '/projects/material/src/theme.scss'; + + async function runMigrationTest(oldFileContent: string, newFileContent: string) { + cliAppTree.create(themeFile, oldFileContent); + const tree = await migrate({tsconfig, components: ['button']}); + expect(tree.readContent(themeFile)).toBe(newFileContent); + } + + beforeEach(async () => { + runner = new SchematicTestRunner( + '@angular/material', + runfiles.resolveWorkspaceRelative('src/material/schematics/collection.json'), + ); + cliAppTree = patchDevkitTreeToExposeTypeScript(await createTestApp(runner)); + }); + + async function migrate(options: Schema): Promise { + return await runner.runSchematicAsync('mdcMigration', options, cliAppTree).toPromise(); + } + + describe('mixin migrations', () => { + it('should replace the old theme with the new ones', async () => { + await runMigrationTest( + ` + @use '@angular/material' as mat; + $theme: (); + @include mat.button-theme($theme); + `, + ` + @use '@angular/material' as mat; + $theme: (); + @include mat.mdc-button-theme($theme); + @include mat.mdc-button-typography($theme); + @include mat.mdc-fab-theme($theme); + @include mat.mdc-fab-typography($theme); + @include mat.mdc-icon-theme($theme); + @include mat.mdc-icon-typography($theme); + `, + ); + }); + + it('should use the correct namespace', async () => { + await runMigrationTest( + ` + @use '@angular/material' as arbitrary; + $theme: (); + @include arbitrary.button-theme($theme); + `, + ` + @use '@angular/material' as arbitrary; + $theme: (); + @include arbitrary.mdc-button-theme($theme); + @include arbitrary.mdc-button-typography($theme); + @include arbitrary.mdc-fab-theme($theme); + @include arbitrary.mdc-fab-typography($theme); + @include arbitrary.mdc-icon-theme($theme); + @include arbitrary.mdc-icon-typography($theme); + `, + ); + }); + + it('should handle updating multiple themes', async () => { + await runMigrationTest( + ` + @use '@angular/material' as mat; + $light-theme: (); + $dark-theme: (); + @include mat.button-theme($light-theme); + @include mat.button-theme($dark-theme); + `, + ` + @use '@angular/material' as mat; + $light-theme: (); + $dark-theme: (); + @include mat.mdc-button-theme($light-theme); + @include mat.mdc-button-typography($light-theme); + @include mat.mdc-fab-theme($light-theme); + @include mat.mdc-fab-typography($light-theme); + @include mat.mdc-icon-theme($light-theme); + @include mat.mdc-icon-typography($light-theme); + @include mat.mdc-button-theme($dark-theme); + @include mat.mdc-button-typography($dark-theme); + @include mat.mdc-fab-theme($dark-theme); + @include mat.mdc-fab-typography($dark-theme); + @include mat.mdc-icon-theme($dark-theme); + @include mat.mdc-icon-typography($dark-theme); + `, + ); + }); + + it('should preserve whitespace', async () => { + await runMigrationTest( + ` + @use '@angular/material' as mat; + $theme: (); + + + @include mat.button-theme($theme); + + + `, + ` + @use '@angular/material' as mat; + $theme: (); + + + @include mat.mdc-button-theme($theme); + @include mat.mdc-button-typography($theme); + @include mat.mdc-fab-theme($theme); + @include mat.mdc-fab-typography($theme); + @include mat.mdc-icon-theme($theme); + @include mat.mdc-icon-typography($theme); + + + `, + ); + }); + }); + + describe('selector migrations', () => { + it('should update the legacy mat-button classname', async () => { + await runMigrationTest( + ` + .mat-button { + padding: 50px; + } + `, + ` + .mat-mdc-button { + padding: 50px; + } + `, + ); + }); + + it('should update multiple legacy classnames', async () => { + await runMigrationTest( + ` + .mat-button { + padding: 50px; + } + .mat-button-base { + padding: 25px; + } + `, + ` + .mat-mdc-button { + padding: 50px; + } + .mat-mdc-button-base { + padding: 25px; + } + `, + ); + }); + + it('should update a legacy classname w/ multiple selectors', async () => { + await runMigrationTest( + ` + .some-class.mat-button, .another-class { + padding: 50px; + } + `, + ` + .some-class.mat-mdc-button, .another-class { + padding: 50px; + } + `, + ); + }); + + it('should preserve the whitespace of multiple selectors', async () => { + await runMigrationTest( + ` + .some-class, + .mat-button, + .another-class { padding: 50px; } + `, + ` + .some-class, + .mat-mdc-button, + .another-class { padding: 50px; } + `, + ); + }); + }); +}); diff --git a/src/material/schematics/ng-generate/mdc-migration/rules/button-styles.ts b/src/material/schematics/ng-generate/mdc-migration/rules/button-styles.ts new file mode 100644 index 000000000000..12ffef937405 --- /dev/null +++ b/src/material/schematics/ng-generate/mdc-migration/rules/button-styles.ts @@ -0,0 +1,38 @@ +/** + * @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.io/license + */ + +import {ClassNameChange, StyleMigrator} from './style-migrator'; + +export class ButtonStylesMigrator extends StyleMigrator { + component = 'button'; + + mixinChanges = [ + { + old: 'button-theme', + new: [ + 'mdc-button-theme', + 'mdc-button-typography', + 'mdc-fab-theme', + 'mdc-fab-typography', + 'mdc-icon-theme', + 'mdc-icon-typography', + ], + }, + ]; + + classChanges: ClassNameChange[] = [ + {old: '.mat-button-base', new: '.mat-mdc-button-base'}, + {old: '.mat-button', new: '.mat-mdc-button'}, + {old: '.mat-raised-button', new: '.mat-mdc-raised-button'}, + {old: '.mat-icon-button', new: '.mat-mdc-icon-button'}, + {old: '.mat-fab', new: '.mat-mdc-fab'}, + {old: '.mat-mini-fab', new: '.mat-mdc-mini-fab'}, + {old: '.mat-stroked-button', new: '.mat-mdc-outlined-button'}, + {old: '.mat-flat-button', new: '.mat-mdc-flat-button'}, + ]; +} diff --git a/src/material/schematics/ng-generate/mdc-migration/rules/index.ts b/src/material/schematics/ng-generate/mdc-migration/rules/index.ts new file mode 100644 index 000000000000..a3ced4075dc8 --- /dev/null +++ b/src/material/schematics/ng-generate/mdc-migration/rules/index.ts @@ -0,0 +1,12 @@ +/** + * @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.io/license + */ + +import {ButtonStylesMigrator} from './button-styles'; +import {StyleMigrator} from './style-migrator'; + +export const MIGRATORS: StyleMigrator[] = [new ButtonStylesMigrator()]; diff --git a/src/material/schematics/ng-generate/mdc-migration/rules/style-migrator.ts b/src/material/schematics/ng-generate/mdc-migration/rules/style-migrator.ts new file mode 100644 index 000000000000..4f0fcb9090ab --- /dev/null +++ b/src/material/schematics/ng-generate/mdc-migration/rules/style-migrator.ts @@ -0,0 +1,110 @@ +/** + * @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.io/license + */ + +import * as postcss from 'postcss'; + +/** The changes to a class names. */ +export interface ClassNameChange { + /** The legacy class name. */ + old: string; + + /** The new class name. */ + new: string; +} + +/** The changes to an scss mixin. */ +export interface MixinChange { + /** The name of the legacy scss mixin. */ + old: string; + + /** The name(s) of the new scss mixin(s). */ + new: string[]; +} + +/** StyleMigrator implements the basic case for migrating old component styles to new ones. */ +export abstract class StyleMigrator { + /** The name of the component that this migration handles. */ + abstract component: string; + + /** The old and new class names of this component. */ + abstract classChanges: ClassNameChange[]; + + /** The old mixins and their replacements. */ + abstract mixinChanges: MixinChange[]; + + /** + * Returns whether the given at-include at-rule is a use of a legacy mixin for this component. + * + * @param namespace the namespace being used for angular/material. + * @param atRule a postcss at-include at-rule. + * @returns `true` if the given at-rule is a use of a legacy mixin for this component. + */ + isLegacyMixin(namespace: string, atRule: postcss.AtRule): boolean { + return this.mixinChanges.some(change => atRule.params.includes(`${namespace}.${change.old}`)); + } + + /** + * Replaces a legacy mixin for this component with the new mixin(s). + * + * @param namespace the namespace being used for angular/material. + * @param atRule an at-include at-rule of a legacy mixin for this component. + */ + replaceMixin(namespace: string, atRule: postcss.AtRule): void { + const change = this.mixinChanges.find(c => { + return atRule.params.includes(`${namespace}.${c.old}`); + }); + + if (!change) { + return; + } + + // Cloning & inserting the first node before changing the + // indentation preserves the indentation of the first node (e.g. 3 newlines). + atRule.cloneBefore({ + params: atRule.params.replace(change.old, change.new[0]), + }); + + // We change the indentation before inserting all of the other nodes + // because the additional @includes should only be separated by a single newline. + const indentation = atRule.raws.before?.split('\n').pop(); + atRule.raws.before = '\n' + indentation; + + // Note: It may be more efficient to create an array of clones and then insert + // them all at once. If we are having performance issues, we should revisit this. + for (let i = 1; i < change.new.length; i++) { + atRule.cloneBefore({ + params: atRule.params.replace(change.old, change.new[i]), + }); + } + atRule.remove(); + } + + /** + * Returns whether the given postcss rule uses a legacy selector of this component. + * + * @param rule a postcss rule. + * @returns `true` if the given Rule uses a legacy selector of this component. + */ + isLegacySelector(rule: postcss.Rule): boolean { + return this.classChanges.some(change => rule.selector.includes(change.old)); + } + + /** + * Replaces a legacy selector of this component with the new one. + * + * @param rule a postcss rule. + */ + replaceLegacySelector(rule: postcss.Rule): void { + for (let i = 0; i < this.classChanges.length; i++) { + const change = this.classChanges[i]; + if (rule.selector.includes(change.old)) { + rule.selector = rule.selector.replace(change.old, change.new); + } + } + } +} diff --git a/src/material/schematics/ng-generate/mdc-migration/rules/theming-styles.spec.ts b/src/material/schematics/ng-generate/mdc-migration/rules/theming-styles.spec.ts deleted file mode 100644 index 7f04fc410ee8..000000000000 --- a/src/material/schematics/ng-generate/mdc-migration/rules/theming-styles.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -import {createTestApp, patchDevkitTreeToExposeTypeScript} from '@angular/cdk/schematics/testing'; -import {SchematicTestRunner, UnitTestTree} from '@angular-devkit/schematics/testing'; -import {Schema} from '../schema'; -import {runfiles} from '@bazel/runfiles'; - -describe('theming styles', () => { - let runner: SchematicTestRunner; - let cliAppTree: UnitTestTree; - - beforeEach(async () => { - runner = new SchematicTestRunner( - '@angular/material', - runfiles.resolveWorkspaceRelative('src/material/schematics/collection.json'), - ); - cliAppTree = patchDevkitTreeToExposeTypeScript(await createTestApp(runner)); - }); - - async function migrate(options: Schema): Promise { - return await runner.runSchematicAsync('mdcMigration', options, cliAppTree).toPromise(); - } - - it('should work', async () => { - cliAppTree.create( - '/projects/material/src/theme.scss', - ` - @use '@angular/material' as mat; - - $my-theme: (); - @include mat.all-component-themes($my-theme); - `, - ); - - const result = await migrate({ - tsconfig: '/projects/material/tsconfig.app.json', - components: ['all'], - }); - - expect(result.readContent('/projects/material/src/theme.scss')).toContain('$some-var:'); - }); -}); diff --git a/src/material/schematics/ng-generate/mdc-migration/rules/theming-styles.ts b/src/material/schematics/ng-generate/mdc-migration/rules/theming-styles.ts index e136d1e3a61e..92e7fdeed86a 100644 --- a/src/material/schematics/ng-generate/mdc-migration/rules/theming-styles.ts +++ b/src/material/schematics/ng-generate/mdc-migration/rules/theming-styles.ts @@ -8,12 +8,69 @@ import {Migration, ResolvedResource} from '@angular/cdk/schematics'; import {SchematicContext} from '@angular-devkit/schematics'; +import {StyleMigrator} from './style-migrator'; +import * as postcss from 'postcss'; +import * as scss from 'postcss-scss'; -export class ThemingStylesMigration extends Migration { +export class ThemingStylesMigration extends Migration { enabled = true; + namespace: string; override visitStylesheet(stylesheet: ResolvedResource) { - // TODO: Implement this migration. This is just a placeholder currently. - this.fileSystem.edit(stylesheet.filePath).insertRight(0, '$some-var: #fff;'); + const processor = new postcss.Processor([ + { + postcssPlugin: 'theming-styles-migration-plugin', + AtRule: { + use: this.atUseHandler.bind(this), + include: this.atIncludeHandler.bind(this), + }, + Rule: this.ruleHandler.bind(this), + }, + ]); + + const result = processor.process(stylesheet.content, {syntax: scss}); + this.fileSystem.overwrite(stylesheet.filePath, result.toString()); + } + + atUseHandler(atRule: postcss.AtRule) { + if (isAngularMaterialImport(atRule)) { + this.namespace = parseNamespace(atRule); + } + } + + atIncludeHandler(atRule: postcss.AtRule) { + const migrator = this.upgradeData.find(m => { + return m.isLegacyMixin(this.namespace, atRule); + }); + migrator?.replaceMixin(this.namespace, atRule); } + + ruleHandler(rule: postcss.Rule) { + const migrator = this.upgradeData.find(m => { + return m.isLegacySelector(rule); + }); + migrator?.replaceLegacySelector(rule); + } +} + +/** + * Returns whether the given AtRule is an import for @angular/material styles. + * + * @param atRule a postcss AtRule node. + * @returns true if the given AtRule is an import for @angular/material styles. + */ +function isAngularMaterialImport(atRule: postcss.AtRule): boolean { + const params = postcss.list.space(atRule.params); + return params[0] === "'@angular/material'"; +} + +/** + * Parses the given @use AtRule and returns the namespace being used. + * + * @param atRule a postcss @use AtRule. + * @returns the namespace being used. + */ +function parseNamespace(atRule: postcss.AtRule): string { + const params = postcss.list.space(atRule.params); + return params[params.length - 1]; }