diff --git a/src/material/schematics/ng-update/index.ts b/src/material/schematics/ng-update/index.ts index d89f7212ea25..e9f51ee593a6 100644 --- a/src/material/schematics/ng-update/index.ts +++ b/src/material/schematics/ng-update/index.ts @@ -15,8 +15,12 @@ import { import {materialUpgradeData} from './upgrade-data'; import {MatCoreMigration} from './migrations/mat-core-removal'; +import {ExplicitSystemVariablePrefixMigration} from './migrations/explicit-system-variable-prefix'; -const materialMigrations: NullableDevkitMigration[] = [MatCoreMigration]; +const materialMigrations: NullableDevkitMigration[] = [ + MatCoreMigration, + ExplicitSystemVariablePrefixMigration, +]; /** Entry point for the migration schematics with target of Angular Material v19 */ export function updateToV19(): Rule { diff --git a/src/material/schematics/ng-update/migrations/explicit-system-variable-prefix.ts b/src/material/schematics/ng-update/migrations/explicit-system-variable-prefix.ts new file mode 100644 index 000000000000..f49a8962bec1 --- /dev/null +++ b/src/material/schematics/ng-update/migrations/explicit-system-variable-prefix.ts @@ -0,0 +1,125 @@ +/** + * @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 {DevkitContext, Migration, ResolvedResource, UpgradeData} from '@angular/cdk/schematics'; + +/** + * Migration that adds `system-variables-prefix` to apps that have `use-system-variables` enabled. + */ +export class ExplicitSystemVariablePrefixMigration extends Migration { + override enabled = true; + + override visitStylesheet(stylesheet: ResolvedResource): void { + if (!stylesheet.filePath.endsWith('.scss')) { + return; + } + + const content = this.fileSystem.read(stylesheet.filePath); + if (!content || !content.includes('@angular/material')) { + return; + } + + const changes = this._getChanges(content); + + if (changes.length > 0) { + const update = this.fileSystem.edit(stylesheet.filePath); + + for (let i = changes.length - 1; i > -1; i--) { + update.insertRight(changes[i].start, changes[i].text); + } + + this.fileSystem.commitEdits(); + } + } + + /** Gets the changes that should be applied to a file. */ + private _getChanges(content: string) { + const key = 'use-system-variables'; + const prefixKey = 'system-variables-prefix'; + const changes: {start: number; text: string}[] = []; + let index = content.indexOf(key); + + // Note: this migration is a bit rudimentary, because Sass doesn't expose a proper AST. + while (index > -1) { + const colonIndex = content.indexOf(':', index); + const valueEnd = colonIndex === -1 ? -1 : this._getValueEnd(content, colonIndex); + + if (valueEnd === -1) { + index = content.indexOf(key, index + key.length); + continue; + } + + const value = content.slice(colonIndex + 1, valueEnd + 1).trim(); + if (value.startsWith('true') && !this._hasSystemPrefix(content, index, prefixKey)) { + changes.push({ + start: this._getInsertIndex(content, valueEnd), + text: `${value.endsWith(',') ? '' : ','}\n ${prefixKey}: sys,`, + }); + } + + index = content.indexOf(key, valueEnd); + } + + return changes; + } + + /** + * Gets the end index of a Sass map key. + * @param content Content of the file. + * @param startIndex Index at which to start the search. + */ + private _getValueEnd(content: string, startIndex: number): number { + for (let i = startIndex + 1; i < content.length; i++) { + const char = content[i]; + + if (char === ',' || char === '\n' || char === ')') { + return i; + } + } + + return -1; + } + + /** + * Gets the index at which to insert the migrated content. + * @param content Initial file content. + * @param valueEnd Index at which the value of the system variables opt-in ends. + */ + private _getInsertIndex(content: string, valueEnd: number): number { + for (let i = valueEnd; i < content.length; i++) { + if (content[i] === '\n') { + return i; + } else if (content[i] === ')') { + return i; + } + } + + return valueEnd; + } + + /** + * Determines if a map that enables system variables is using system variables already. + * @param content Full file contents. + * @param keyIndex Index at which the systems variable key is defined. + * @param prefixKey Name of the key that defines the prefix. + */ + private _hasSystemPrefix(content: string, keyIndex: number, prefixKey: string): boolean { + // Note: technically this can break if there are other inline maps, but it should be rare. + const mapEnd = content.indexOf(')', keyIndex); + + if (mapEnd > -1) { + for (let i = keyIndex; i > -1; i--) { + if (content[i] === '(') { + return content.slice(i, mapEnd).includes(prefixKey); + } + } + } + + return false; + } +} diff --git a/src/material/schematics/ng-update/test-cases/v19-explicit-system-variable-prefix.spec.ts b/src/material/schematics/ng-update/test-cases/v19-explicit-system-variable-prefix.spec.ts new file mode 100644 index 000000000000..5a7a86afc73f --- /dev/null +++ b/src/material/schematics/ng-update/test-cases/v19-explicit-system-variable-prefix.spec.ts @@ -0,0 +1,215 @@ +import {UnitTestTree} from '@angular-devkit/schematics/testing'; +import {createTestCaseSetup} from '@angular/cdk/schematics/testing'; +import {MIGRATION_PATH} from '../../paths'; + +const THEME_FILE_PATH = '/projects/cdk-testing/src/theme.scss'; + +describe('v19 explicit system variable prefix migration', () => { + let tree: UnitTestTree; + let writeFile: (filename: string, content: string) => void; + let runMigration: () => Promise; + + function stripWhitespace(content: string): string { + return content.replace(/\s/g, ''); + } + + beforeEach(async () => { + const testSetup = await createTestCaseSetup('migration-v19', MIGRATION_PATH, []); + tree = testSetup.appTree; + writeFile = testSetup.writeFile; + runMigration = testSetup.runFixers; + }); + + it('should add an explicit system variables prefix', async () => { + writeFile( + THEME_FILE_PATH, + ` + @use '@angular/material' as mat; + + $theme: mat.define-theme(( + color: ( + theme-type: 'light', + primary: mat.$azure-palette, + tertiary: mat.$red-palette, + use-system-variables: true + ), + typography: ( + use-system-variables: true + ), + density: ( + scale: -1 + ), + )); + + @include mat.all-component-themes($theme); + `, + ); + + await runMigration(); + + expect(stripWhitespace(tree.readText(THEME_FILE_PATH))).toBe( + stripWhitespace(` + @use '@angular/material' as mat; + + $theme: mat.define-theme(( + color: ( + theme-type: 'light', + primary: mat.$azure-palette, + tertiary: mat.$red-palette, + use-system-variables: true, + system-variables-prefix: sys, + ), + typography: ( + use-system-variables: true, + system-variables-prefix: sys, + ), + density: ( + scale: -1 + ), + )); + + @include mat.all-component-themes($theme); + `), + ); + }); + + it('should add an explicit system variables prefix if the value is using trailing commas', async () => { + writeFile( + THEME_FILE_PATH, + ` + @use '@angular/material' as mat; + + $theme: mat.define-theme(( + color: ( + theme-type: 'light', + primary: mat.$azure-palette, + tertiary: mat.$red-palette, + use-system-variables: true, + ), + typography: ( + use-system-variables: true, + ), + density: ( + scale: -1 + ), + )); + + @include mat.all-component-themes($theme); + `, + ); + + await runMigration(); + + expect(stripWhitespace(tree.readText(THEME_FILE_PATH))).toBe( + stripWhitespace(` + @use '@angular/material' as mat; + + $theme: mat.define-theme(( + color: ( + theme-type: 'light', + primary: mat.$azure-palette, + tertiary: mat.$red-palette, + use-system-variables: true, + system-variables-prefix: sys, + ), + typography: ( + use-system-variables: true, + system-variables-prefix: sys, + ), + density: ( + scale: -1 + ), + )); + + @include mat.all-component-themes($theme); + `), + ); + }); + + it('should not add an explicit system variables prefix if the map has one already', async () => { + writeFile( + THEME_FILE_PATH, + ` + @use '@angular/material' as mat; + + $theme: mat.define-theme(( + color: ( + theme-type: 'light', + primary: mat.$azure-palette, + tertiary: mat.$red-palette, + use-system-variables: true + ), + typography: ( + use-system-variables: true, + system-variables-prefix: foo + ), + density: ( + scale: -1 + ), + )); + + @include mat.all-component-themes($theme); + `, + ); + + await runMigration(); + + expect(stripWhitespace(tree.readText(THEME_FILE_PATH))).toBe( + stripWhitespace(` + @use '@angular/material' as mat; + + $theme: mat.define-theme(( + color: ( + theme-type: 'light', + primary: mat.$azure-palette, + tertiary: mat.$red-palette, + use-system-variables: true, + system-variables-prefix: sys, + ), + typography: ( + use-system-variables: true, + system-variables-prefix: foo + ), + density: ( + scale: -1 + ), + )); + + @include mat.all-component-themes($theme); + `), + ); + }); + + it('should handle a single-line map', async () => { + writeFile( + THEME_FILE_PATH, + ` + @use '@angular/material' as mat; + + $theme: mat.define-theme(( + color: (theme-type: 'light', primary: mat.$azure-palette, use-system-variables: true), + typography: (use-system-variables: true), + density: (scale: -1), + )); + + @include mat.all-component-themes($theme); + `, + ); + + await runMigration(); + + expect(stripWhitespace(tree.readText(THEME_FILE_PATH))).toBe( + stripWhitespace(` + @use '@angular/material' as mat; + + $theme: mat.define-theme(( + color: (theme-type: 'light', primary: mat.$azure-palette, use-system-variables: true, system-variables-prefix: sys,), + typography: (use-system-variables: true, system-variables-prefix: sys,), + density: (scale: -1), + )); + + @include mat.all-component-themes($theme); + `), + ); + }); +});