diff --git a/src/cdk/schematics/update-tool/target-version.ts b/src/cdk/schematics/update-tool/target-version.ts index a6371d05e001..299332563558 100644 --- a/src/cdk/schematics/update-tool/target-version.ts +++ b/src/cdk/schematics/update-tool/target-version.ts @@ -11,6 +11,7 @@ // tslint:disable-next-line:prefer-const-enum export enum TargetVersion { V18 = 'version 18', + V19 = 'version 19', } /** diff --git a/src/material/button/_button-theme.scss b/src/material/button/_button-theme.scss index f1dac5fed58c..613242ffc145 100644 --- a/src/material/button/_button-theme.scss +++ b/src/material/button/_button-theme.scss @@ -280,16 +280,22 @@ @include _theme-from-tokens(inspection.get-theme-tokens($theme, typography)); } @else { @include sass-utils.current-selector-or-root() { - @include mdc-button-text-theme.theme(tokens-mdc-text-button.get-typography-tokens($theme)); - @include mdc-button-filled-theme.theme( - tokens-mdc-filled-button.get-typography-tokens($theme) + @include token-utils.create-token-values( + tokens-mdc-text-button.$prefix, + tokens-mdc-text-button.get-typography-tokens($theme) ); - @include mdc-button-outlined-theme.theme( - tokens-mdc-outlined-button.get-typography-tokens($theme) + @include token-utils.create-token-values( + tokens-mdc-filled-button.$prefix, + tokens-mdc-filled-button.get-typography-tokens($theme) ); - @include mdc-button-protected-theme.theme( + @include token-utils.create-token-values( + tokens-mdc-protected-button.$prefix, tokens-mdc-protected-button.get-typography-tokens($theme) ); + @include token-utils.create-token-values( + tokens-mdc-outlined-button.$prefix, + tokens-mdc-outlined-button.get-typography-tokens($theme) + ); @include token-utils.create-token-values( tokens-mat-text-button.$prefix, @@ -318,30 +324,37 @@ @include _theme-from-tokens(inspection.get-theme-tokens($theme, density)); } @else { @include sass-utils.current-selector-or-root() { - @include mdc-button-text-theme.theme(tokens-mdc-text-button.get-density-tokens($theme)); - @include mdc-button-filled-theme.theme(tokens-mdc-filled-button.get-density-tokens($theme)); - @include mdc-button-outlined-theme.theme( - tokens-mdc-outlined-button.get-density-tokens($theme) + @include token-utils.create-token-values( + tokens-mdc-text-button.$prefix, + tokens-mdc-text-button.get-typography-tokens($theme) ); - @include mdc-button-protected-theme.theme( - tokens-mdc-protected-button.get-density-tokens($theme) + @include token-utils.create-token-values( + tokens-mdc-filled-button.$prefix, + tokens-mdc-filled-button.get-typography-tokens($theme) + ); + @include token-utils.create-token-values( + tokens-mdc-protected-button.$prefix, + tokens-mdc-protected-button.get-typography-tokens($theme) + ); + @include token-utils.create-token-values( + tokens-mdc-outlined-button.$prefix, + tokens-mdc-outlined-button.get-typography-tokens($theme) ); - @include token-utils.create-token-values( tokens-mat-text-button.$prefix, - tokens-mat-text-button.get-density-tokens($theme) + tokens-mat-text-button.get-typography-tokens($theme) ); @include token-utils.create-token-values( tokens-mat-filled-button.$prefix, - tokens-mat-filled-button.get-density-tokens($theme) + tokens-mat-filled-button.get-typography-tokens($theme) ); @include token-utils.create-token-values( tokens-mat-protected-button.$prefix, - tokens-mat-protected-button.get-density-tokens($theme) + tokens-mat-protected-button.get-typography-tokens($theme) ); @include token-utils.create-token-values( tokens-mat-outlined-button.$prefix, - tokens-mat-outlined-button.get-density-tokens($theme) + tokens-mat-outlined-button.get-typography-tokens($theme) ); } } diff --git a/src/material/button/_fab-theme.scss b/src/material/button/_fab-theme.scss index ae9bc25b11c3..80d0290a3835 100644 --- a/src/material/button/_fab-theme.scss +++ b/src/material/button/_fab-theme.scss @@ -123,7 +123,10 @@ @include _theme-from-tokens(inspection.get-theme-tokens($theme, typography)); } @else { @include sass-utils.current-selector-or-root() { - @include mdc-extended-fab-theme.theme(tokens-mdc-extended-fab.get-typography-tokens($theme)); + @include token-utils.create-token-values( + tokens-mdc-extended-fab.$prefix, + tokens-mdc-extended-fab.get-typography-tokens($theme) + ); @include token-utils.create-token-values( tokens-mat-fab.$prefix, tokens-mat-fab.get-typography-tokens($theme) @@ -257,9 +260,13 @@ tokens-mat-fab-small.$prefix, $options... ); - @include mdc-extended-fab-theme.theme($mdc-extended-fab-tokens); - @include mdc-fab-theme.theme($mdc-fab-tokens); - @include mdc-fab-small-theme.theme($mdc-fab-small-tokens); + + @include token-utils.create-token-values( + tokens-mdc-extended-fab.$prefix, + $mdc-extended-fab-tokens + ); + @include token-utils.create-token-values(tokens-mdc-fab.$prefix, $mdc-fab-tokens); + @include token-utils.create-token-values(tokens-mdc-fab-small.$prefix, $mdc-fab-small-tokens); @include token-utils.create-token-values(tokens-mat-fab.$prefix, $mat-fab-tokens); @include token-utils.create-token-values(tokens-mat-fab-small.$prefix, $mat-fab-small-tokens); } diff --git a/src/material/core/theming/tests/m3-theme.spec.ts b/src/material/core/theming/tests/m3-theme.spec.ts index 51d9e11e4cd6..b91bb16ac5cd 100644 --- a/src/material/core/theming/tests/m3-theme.spec.ts +++ b/src/material/core/theming/tests/m3-theme.spec.ts @@ -286,7 +286,7 @@ describe('M3 theme', () => { ( (namespace: (mat, minimal-pseudo-checkbox), prefix: 'minimal-'), (mat, full-pseudo-checkbox) - ), + ), ( minimal-selected-checkmark-color: magenta, selected-checkmark-color: cyan @@ -310,7 +310,7 @@ describe('M3 theme', () => { $theme: token-utils.extend-theme($theme, ( (namespace: (mat, minimal-pseudo-checkbox), prefix: 'minimal-'), - ), + ), ( selected-checkmark-color: magenta ) @@ -334,7 +334,7 @@ describe('M3 theme', () => { ( (namespace: (mat, minimal-pseudo-checkbox), prefix: 'both-'), (namespace: (mat, full-pseudo-checkbox), prefix: 'both-') - ), + ), ( both-selected-checkmark-color: magenta ) diff --git a/src/material/schematics/migration.json b/src/material/schematics/migration.json index 0fa2742f3b45..4c7657475b18 100644 --- a/src/material/schematics/migration.json +++ b/src/material/schematics/migration.json @@ -5,6 +5,11 @@ "version": "18.0.0-0", "description": "Updates Angular Material to v18", "factory": "./ng-update/index_bundled#updateToV18" + }, + "migration-v19": { + "version": "19.0.0-0", + "description": "Updates Angular Material to v19", + "factory": "./ng-update/index_bundled#updateToV19" } } } diff --git a/src/material/schematics/ng-update/index.ts b/src/material/schematics/ng-update/index.ts index f3ffc7b66686..975353cf6b64 100644 --- a/src/material/schematics/ng-update/index.ts +++ b/src/material/schematics/ng-update/index.ts @@ -8,21 +8,33 @@ import {Rule, SchematicContext} from '@angular-devkit/schematics'; import { - createMigrationSchematicRule, NullableDevkitMigration, TargetVersion, + createMigrationSchematicRule, } from '@angular/cdk/schematics'; -import {materialUpgradeData} from './upgrade-data'; import {M2ThemingMigration} from './migrations/m2-theming-v18'; +import {TokenOverridesMigration} from './migrations/token-overrides-v19'; +import {materialUpgradeData} from './upgrade-data'; -const materialMigrations: NullableDevkitMigration[] = [M2ThemingMigration]; +const materialMigrationsV18: NullableDevkitMigration[] = [M2ThemingMigration]; +const materialMigrationsV19: NullableDevkitMigration[] = [TokenOverridesMigration]; /** Entry point for the migration schematics with target of Angular Material v18 */ export function updateToV18(): Rule { return createMigrationSchematicRule( TargetVersion.V18, - materialMigrations, + materialMigrationsV18, + materialUpgradeData, + onMigrationComplete, + ); +} + +/** Entry point for the migration schematics with target of Angular Material v19 */ +export function updateToV19(): Rule { + return createMigrationSchematicRule( + TargetVersion.V19, + materialMigrationsV19, materialUpgradeData, onMigrationComplete, ); diff --git a/src/material/schematics/ng-update/migrations/token-overrides-v19/index.ts b/src/material/schematics/ng-update/migrations/token-overrides-v19/index.ts new file mode 100644 index 000000000000..9dc4feca52e7 --- /dev/null +++ b/src/material/schematics/ng-update/migrations/token-overrides-v19/index.ts @@ -0,0 +1,43 @@ +/** + * @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 {extname} from '@angular-devkit/core'; +import {DevkitMigration, ResolvedResource, TargetVersion} from '@angular/cdk/schematics'; +import {migrateTokenOverridesUsages} from './migration'; + +/** Migration that updates usages of the token overrides APIs in v19. */ +export class TokenOverridesMigration extends DevkitMigration { + private _potentialThemes: ResolvedResource[] = []; + + /** Whether to run this migration. */ + enabled = this.targetVersion === TargetVersion.V19; + + override visitStylesheet(stylesheet: ResolvedResource): void { + if ( + extname(stylesheet.filePath) === '.scss' && + // Note: intended to exclude `@angular/material-experimental`. + stylesheet.content.match(/@angular\/material["']/) + ) { + this._potentialThemes.push(stylesheet); + } + } + + override postAnalysis(): void { + for (const theme of this._potentialThemes) { + const migrated = migrateTokenOverridesUsages(theme.content); + + if (migrated !== theme.content) { + this.fileSystem + .edit(theme.filePath) + .remove(0, theme.content.length) + .insertLeft(0, migrated); + this.fileSystem.commitEdits(); + } + } + } +} diff --git a/src/material/schematics/ng-update/migrations/token-overrides-v19/migration.ts b/src/material/schematics/ng-update/migrations/token-overrides-v19/migration.ts new file mode 100644 index 000000000000..d711b6f6fcbe --- /dev/null +++ b/src/material/schematics/ng-update/migrations/token-overrides-v19/migration.ts @@ -0,0 +1,510 @@ +/** + * @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 + */ + +/** Map of override mixins that had some of their tokens renamed. */ +const RENAMED_TOKEN_OVERRIDES: {[key: string]: {[token: string]: string[]}} = { + 'core-overrides': { + 'background-color': ['app-background-color'], + 'text-color': ['app-text-color'], + 'elevation-shadow-level-0': ['app-elevation-shadow-level-0'], + 'elevation-shadow-level-1': ['app-elevation-shadow-level-1'], + 'elevation-shadow-level-2': ['app-elevation-shadow-level-2'], + 'elevation-shadow-level-3': ['app-elevation-shadow-level-3'], + 'elevation-shadow-level-4': ['app-elevation-shadow-level-4'], + 'elevation-shadow-level-5': ['app-elevation-shadow-level-5'], + 'elevation-shadow-level-6': ['app-elevation-shadow-level-6'], + 'elevation-shadow-level-7': ['app-elevation-shadow-level-7'], + 'elevation-shadow-level-8': ['app-elevation-shadow-level-8'], + 'elevation-shadow-level-9': ['app-elevation-shadow-level-9'], + 'elevation-shadow-level-10': ['app-elevation-shadow-level-10'], + 'elevation-shadow-level-11': ['app-elevation-shadow-level-11'], + 'elevation-shadow-level-12': ['app-elevation-shadow-level-12'], + 'elevation-shadow-level-13': ['app-elevation-shadow-level-13'], + 'elevation-shadow-level-14': ['app-elevation-shadow-level-14'], + 'elevation-shadow-level-15': ['app-elevation-shadow-level-15'], + 'elevation-shadow-level-16': ['app-elevation-shadow-level-16'], + 'elevation-shadow-level-17': ['app-elevation-shadow-level-17'], + 'elevation-shadow-level-18': ['app-elevation-shadow-level-18'], + 'elevation-shadow-level-19': ['app-elevation-shadow-level-19'], + 'elevation-shadow-level-20': ['app-elevation-shadow-level-20'], + 'elevation-shadow-level-21': ['app-elevation-shadow-level-21'], + 'elevation-shadow-level-22': ['app-elevation-shadow-level-22'], + 'elevation-shadow-level-23': ['app-elevation-shadow-level-23'], + 'elevation-shadow-level-24': ['app-elevation-shadow-level-24'], + 'color': ['ripple-color'], + 'selected-state-label-text-color': ['option-selected-state-label-text-color'], + 'label-text-color': ['option-label-text-color', 'optgroup-label-text-color'], + 'hover-state-layer-color': ['option-hover-state-layer-color'], + 'focus-state-layer-color': ['option-focus-state-layer-color'], + 'selected-state-layer-color': ['option-selected-state-layer-color'], + 'label-text-font': ['option-label-text-font', 'optgroup-label-text-font'], + 'label-text-line-height': ['option-label-text-line-height', 'optgroup-label-text-line-height'], + 'label-text-size': ['option-label-text-size', 'optgroup-label-text-size'], + 'label-text-tracking': ['option-label-text-tracking', 'optgroup-label-text-tracking'], + 'label-text-weight': ['option-label-text-weight', 'optgroup-label-text-weight'], + 'selected-icon-color': ['pseudo-checkbox-full-selected-icon-color'], + 'selected-checkmark-color': [ + 'pseudo-checkbox-full-selected-checkmark-color', + 'pseudo-checkbox-minimal-selected-checkmark-color', + ], + 'unselected-icon-color': ['pseudo-checkbox-full-unselected-icon-color'], + 'disabled-selected-checkmark-color': [ + 'pseudo-checkbox-full-disabled-selected-checkmark-color', + 'pseudo-checkbox-minimal-disabled-selected-checkmark-color', + ], + 'disabled-unselected-icon-color': ['pseudo-checkbox-full-disabled-unselected-icon-color'], + 'disabled-selected-icon-color': ['pseudo-checkbox-full-disabled-selected-icon-color'], + }, + 'pseudo-checkbox-overrides': { + 'selected-icon-color': ['full-selected-icon-color'], + 'selected-checkmark-color': [ + 'full-selected-checkmark-color', + 'minimal-selected-checkmark-color', + ], + 'unselected-icon-color': ['full-unselected-icon-color'], + 'disabled-selected-checkmark-color': [ + 'full-disabled-selected-checkmark-color', + 'minimal-disabled-selected-checkmark-color', + ], + 'disabled-unselected-icon-color': ['full-disabled-unselected-icon-color'], + 'disabled-selected-icon-color': ['full-disabled-selected-icon-color'], + }, + 'button-overrides': { + 'container-shape': [ + 'filled-container-shape', + 'outlined-container-shape', + 'protected-container-shape', + 'text-container-shape', + ], + 'keep-touch-target': [ + 'filled-keep-touch-target', + 'outlined-keep-touch-target', + 'protected-keep-touch-target', + 'text-keep-touch-target', + ], + 'container-color': ['filled-container-color', 'protected-container-color'], + 'label-text-color': [ + 'filled-label-text-color', + 'outlined-label-text-color', + 'protected-label-text-color', + 'text-label-text-color', + ], + 'disabled-container-color': [ + 'filled-disabled-container-color', + 'protected-disabled-container-color', + ], + 'disabled-label-text-color': [ + 'filled-disabled-label-text-color', + 'outlined-disabled-label-text-color', + 'protected-disabled-label-text-color', + 'text-disabled-label-text-color', + ], + 'label-text-font': [ + 'filled-label-text-font', + 'outlined-label-text-font', + 'protected-label-text-font', + 'text-label-text-font', + ], + 'label-text-size': [ + 'filled-label-text-size', + 'outlined-label-text-size', + 'protected-label-text-size', + 'text-label-text-size', + ], + 'label-text-tracking': [ + 'filled-label-text-tracking', + 'outlined-label-text-tracking', + 'protected-label-text-tracking', + 'text-label-text-tracking', + ], + 'label-text-weight': [ + 'filled-label-text-weight', + 'outlined-label-text-weight', + 'protected-label-text-weight', + 'text-label-text-weight', + ], + 'label-text-transform': [ + 'filled-label-text-transform', + 'outlined-label-text-transform', + 'protected-label-text-transform', + 'text-label-text-transform', + ], + 'container-height': [ + 'filled-container-height', + 'outlined-container-height', + 'protected-container-height', + 'text-container-height', + ], + 'horizontal-padding': [ + 'filled-horizontal-padding', + 'outlined-horizontal-padding', + 'protected-horizontal-padding', + 'text-horizontal-padding', + ], + 'icon-spacing': [ + 'filled-icon-spacing', + 'outlined-icon-spacing', + 'protected-icon-spacing', + 'text-icon-spacing', + ], + 'icon-offset': [ + 'filled-icon-offset', + 'outlined-icon-offset', + 'protected-icon-offset', + 'text-icon-offset', + ], + 'state-layer-color': [ + 'filled-state-layer-color', + 'outlined-state-layer-color', + 'protected-state-layer-color', + 'text-state-layer-color', + ], + 'disabled-state-layer-color': [ + 'filled-disabled-state-layer-color', + 'outlined-disabled-state-layer-color', + 'protected-disabled-state-layer-color', + 'text-disabled-state-layer-color', + ], + 'ripple-color': [ + 'filled-ripple-color', + 'outlined-ripple-color', + 'protected-ripple-color', + 'text-ripple-color', + ], + 'hover-state-layer-opacity': [ + 'filled-hover-state-layer-opacity', + 'outlined-hover-state-layer-opacity', + 'protected-hover-state-layer-opacity', + 'text-hover-state-layer-opacity', + ], + 'focus-state-layer-opacity': [ + 'filled-focus-state-layer-opacity', + 'outlined-focus-state-layer-opacity', + 'protected-focus-state-layer-opacity', + 'text-focus-state-layer-opacity', + ], + 'pressed-state-layer-opacity': [ + 'filled-pressed-state-layer-opacity', + 'outlined-pressed-state-layer-opacity', + 'protected-pressed-state-layer-opacity', + 'text-pressed-state-layer-opacity', + ], + 'touch-target-display': [ + 'filled-touch-target-display', + 'outlined-touch-target-display', + 'protected-touch-target-display', + 'text-touch-target-display', + ], + 'outline-width': ['outlined-outline-width'], + 'disabled-outline-color': ['outlined-disabled-outline-color'], + 'outline-color': ['outlined-outline-color'], + 'container-elevation': ['protected-container-elevation'], + 'disabled-container-elevation': ['protected-disabled-container-elevation'], + 'focus-container-elevation': ['protected-focus-container-elevation'], + 'hover-container-elevation': ['protected-hover-container-elevation'], + 'pressed-container-elevation': ['protected-pressed-container-elevation'], + 'container-shadow-color': ['protected-container-shadow-color'], + 'with-icon-horizontal-padding': ['text-with-icon-horizontal-padding'], + }, + 'fab-overrides': { + 'container-shape': ['container-shape', 'small-container-shape', 'extended-container-shape'], + 'icon-size': ['icon-size', 'small-icon-size'], + 'container-color': ['container-color', 'small-container-color'], + 'container-elevation': [ + 'container-elevation', + 'small-container-elevation', + 'extended-container-elevation', + ], + 'focus-container-elevation': [ + 'focus-container-elevation', + 'small-focus-container-elevation', + 'extended-focus-container-elevation', + ], + 'hover-container-elevation': [ + 'hover-container-elevation', + 'small-hover-container-elevation', + 'extended-hover-container-elevation', + ], + 'pressed-container-elevation': [ + 'pressed-container-elevation', + 'small-pressed-container-elevation', + 'extended-pressed-container-elevation', + ], + 'container-shadow-color': [ + 'container-shadow-color', + 'small-container-shadow-color', + 'extended-container-shadow-color', + ], + 'container-height': ['extended-container-height'], + 'label-text-font': ['extended-label-text-font'], + 'label-text-size': ['extended-label-text-size'], + 'label-text-tracking': ['extended-label-text-tracking'], + 'label-text-weight': ['extended-label-text-weight'], + 'foreground-color': ['foreground-color', 'small-foreground-color'], + 'state-layer-color': ['state-layer-color', 'small-state-layer-color'], + 'disabled-state-layer-color': [ + 'disabled-state-layer-color', + 'small-disabled-state-layer-color', + ], + 'ripple-color': ['ripple-color', 'small-ripple-color'], + 'hover-state-layer-opacity': ['hover-state-layer-opacity', 'small-hover-state-layer-opacity'], + 'focus-state-layer-opacity': ['focus-state-layer-opacity', 'small-focus-state-layer-opacity'], + 'pressed-state-layer-opacity': [ + 'pressed-state-layer-opacity', + 'small-pressed-state-layer-opacity', + ], + 'disabled-state-container-color': [ + 'disabled-state-container-color', + 'small-disabled-state-container-color', + ], + 'disabled-state-foreground-color': [ + 'disabled-state-foreground-color', + 'small-disabled-state-foreground-color', + ], + 'touch-target-display': ['touch-target-display', 'small-touch-target-display'], + }, + 'card-overrides': { + 'container-shape': ['elevated-container-shape', 'outlined-container-shape'], + 'container-color': ['elevated-container-color', 'outlined-container-color'], + 'container-elevation': ['elevated-container-elevation', 'outlined-container-elevation'], + 'outline-width': ['outlined-outline-width'], + 'outline-color': ['outlined-outline-color'], + 'undefined': [''], + }, +}; + +/** Possible pairs of comment characters in a Sass file. */ +const COMMENT_PAIRS = new Map([ + ['/*', '*/'], + ['//', '\n'], +]); + +/** Prefix for the placeholder that will be used to escape comments. */ +const COMMENT_PLACEHOLDER_START = '__< b - a); + for (const usageStart of usages) { + const usageEnd = + findMatchingClose(content, usageStart + mixin.length + namespace.length + 2) + 1; + let usageText = content.slice(usageStart, usageEnd); + for (const [token, replacements] of Object.entries(tokens)) { + usageText = migrateTokenNameInOverride(usageText, token, replacements); + } + content = content.slice(0, usageStart) + usageText + content.slice(usageEnd); + } + return content; +} + +/** + * Update the name of a token in a call to an override mixin. + */ +function migrateTokenNameInOverride( + content: string, + token: string, + replacements: string[], +): string { + const match = new RegExp(String.raw`[,(]\s*${token}:`).exec(content); + if (match === null) { + return content; + } + const start = match.index + 1; + let end = content.indexOf(',', start + 1); + if (end === -1) { + end = content.length - 2; + } + const tokenProp = content.slice(start, end); + const hasLeadingWhitespace = /\s/.test(content[start]); + const hasTrailingComma = content[end - 1] === ','; + return ( + content.slice(0, start) + + replacements + .map((replacement, i) => { + let result = tokenProp.replace(token, replacement); + if (!hasLeadingWhitespace && i > 0) { + result = ' ' + result; + } + if (!hasTrailingComma && i < replacements.length - 1) { + result = result.trimEnd() + ','; + } + return result; + }) + .join('') + + content.slice(end) + ); +} + +/** + * Find the index of the matching close paren for the open paren at the given index. + */ +function findMatchingClose(content: string, start: number) { + let index = start; + if (content[index] !== '(') { + throw Error('Expected open parenthesis.'); + } + + let count = 1; + while (count > 0 && index < content.length) { + index++; + if (content[index] === '(') { + count++; + } else if (content[index] === ')') { + count--; + } + } + + if (count !== 0) { + throw Error('Could not find matching close parenthesis.'); + } + + return index; +} + +/** + * Find all indicies of the given pattern in the content. + */ +function findAllIndicies(content: string, pattern: RegExp) { + const indicies = []; + let m: RegExpExecArray | null; + while ((m = pattern.exec(content)) !== null) { + indicies.push(m.index); + } + return indicies; +} + +/** + * Replaces all the comments in a Sass file with placeholders and + * returns the list of placeholders, so they can be restored later. + */ +function escapeComments(content: string): {content: string; placeholders: Record} { + const placeholders: Record = {}; + let commentCounter = 0; + let [openIndex, closeIndex] = findComment(content); + + while (openIndex > -1 && closeIndex > -1) { + const placeholder = COMMENT_PLACEHOLDER_START + commentCounter++ + COMMENT_PLACEHOLDER_END; + placeholders[placeholder] = content.slice(openIndex, closeIndex); + content = content.slice(0, openIndex) + placeholder + content.slice(closeIndex); + [openIndex, closeIndex] = findComment(content); + } + + return {content, placeholders}; +} + +/** Finds the start and end index of a comment in a file. */ +function findComment(content: string): [openIndex: number, closeIndex: number] { + // Add an extra new line at the end so that we can correctly capture single-line comments + // at the end of the file. It doesn't really matter that the end index will be out of bounds, + // because `String.prototype.slice` will clamp it to the string length. + content += '\n'; + + for (const [open, close] of COMMENT_PAIRS.entries()) { + const openIndex = content.indexOf(open); + + if (openIndex > -1) { + const closeIndex = content.indexOf(close, openIndex + 1); + return closeIndex > -1 ? [openIndex, closeIndex + close.length] : [-1, -1]; + } + } + + return [-1, -1]; +} + +/** Restores the comments that have been escaped by `escapeComments`. */ +function restoreComments(content: string, placeholders: Record): string { + Object.keys(placeholders).forEach(key => (content = content.replace(key, placeholders[key]))); + return content; +} + +/** Parses out the namespace from a Sass `@use` statement. */ +function extractNamespaceFromUseStatement(fullImport: string): string { + const closeQuoteIndex = Math.max(fullImport.lastIndexOf(`"`), fullImport.lastIndexOf(`'`)); + + if (closeQuoteIndex > -1) { + const asExpression = 'as '; + const asIndex = fullImport.indexOf(asExpression, closeQuoteIndex); + + // If we found an ` as ` expression, we consider the rest of the text as the namespace. + if (asIndex > -1) { + return fullImport + .slice(asIndex + asExpression.length) + .split(';')[0] + .trim(); + } + + // Otherwise the namespace is the name of the file that is being imported. + const lastSlashIndex = fullImport.lastIndexOf('/', closeQuoteIndex); + + if (lastSlashIndex > -1) { + const fileName = fullImport + .slice(lastSlashIndex + 1, closeQuoteIndex) + // Sass allows for leading underscores to be omitted and it technically supports .scss. + .replace(/^_|(\.import)?\.scss$|\.import$/g, ''); + + // Sass ignores `/index` and infers the namespace as the next segment in the path. + if (fileName === 'index') { + const nextSlashIndex = fullImport.lastIndexOf('/', lastSlashIndex - 1); + + if (nextSlashIndex > -1) { + return fullImport.slice(nextSlashIndex + 1, lastSlashIndex); + } + } else { + return fileName; + } + } + } + + throw Error(`Could not extract namespace from import "${fullImport}".`); +} + +/** Gets all the namespaces that a module is available under in a specific file. */ +function getNamespaces(moduleName: string, content: string): string[] { + const namespaces = new Set(); + const escapedName = moduleName.replace(/([.*+?^=!:${}()|[\]\/\\])/g, '\\$1'); + const pattern = new RegExp(`@use +['"]${escapedName}['"].*;?\\r?\\n`, 'g'); + let match: RegExpExecArray | null = null; + + while ((match = pattern.exec(content))) { + namespaces.add(extractNamespaceFromUseStatement(match[0])); + } + + return Array.from(namespaces); +} diff --git a/src/material/schematics/ng-update/test-cases/token-overrides.spec.ts b/src/material/schematics/ng-update/test-cases/token-overrides.spec.ts new file mode 100644 index 000000000000..f83f944c2d98 --- /dev/null +++ b/src/material/schematics/ng-update/test-cases/token-overrides.spec.ts @@ -0,0 +1,100 @@ +import {createTestCaseSetup} from '@angular/cdk/schematics/testing'; +import {MIGRATION_PATH} from '../../paths'; + +describe('Token overrides migration', () => { + async function setup(originalSource: string): Promise { + const themePath = 'projects/cdk-testing/theme.scss'; + const {runFixers, writeFile, appTree} = await createTestCaseSetup( + 'migration-v19', + MIGRATION_PATH, + [], + ); + + writeFile(themePath, originalSource); + await runFixers(); + return appTree.readContent(themePath); + } + + it('should update token names', async () => { + const result = await setup( + [ + `@use '@angular/material' as mat;`, + + `@include mat.core-overrides((`, + ` background-color: red,`, + `));`, + ].join('\n'), + ); + + expect(result.split('\n')).toEqual([ + `@use '@angular/material' as mat;`, + + `@include mat.core-overrides((`, + ` app-background-color: red,`, + `));`, + ]); + }); + + it('should split ambiguous tokens into multiple assignments', async () => { + const result = await setup( + [ + `@use '@angular/material' as mat;`, + + `@include mat.button-overrides((`, + ` container-shape: 5px,`, + `));`, + ].join('\n'), + ); + + expect(result.split('\n')).toEqual([ + `@use '@angular/material' as mat;`, + + `@include mat.button-overrides((`, + ` filled-container-shape: 5px,`, + ` outlined-container-shape: 5px,`, + ` protected-container-shape: 5px,`, + ` text-container-shape: 5px,`, + `));`, + ]); + }); + + it('should update multiple token names', async () => { + const result = await setup( + [ + `@use '@angular/material' as mat;`, + + `@include mat.core-overrides((`, + ` background-color: red,`, + ` label-text-color: blue`, + `));`, + ].join('\n'), + ); + + expect(result.split('\n')).toEqual([ + `@use '@angular/material' as mat;`, + + `@include mat.core-overrides((`, + ` app-background-color: red,`, + ` option-label-text-color: blue,`, + ` optgroup-label-text-color: blue`, + `));`, + ]); + }); + + it('should work with single-line formatting', async () => { + const result = await setup( + [ + `@use '@angular/material' as mat;`, + + `@include mat.button-overrides((container-color: green));`, + ].join('\n'), + ); + + expect(result.split('\n')).toEqual([ + `@use '@angular/material' as mat;`, + + `@include mat.button-overrides((` + + `filled-container-color: green, protected-container-color: green));`, + ]); + }); +});