diff --git a/.github/instructions/sass-migrations.instructions.md b/.github/instructions/sass-migrations.instructions.md new file mode 100644 index 00000000000..163a04224f3 --- /dev/null +++ b/.github/instructions/sass-migrations.instructions.md @@ -0,0 +1,20 @@ +--- +applyTo: "projects/igniteui-angular/migrations/**" +--- + +# Sass migrations + +The `ng update` migrations change Sass stylesheets through a text scanner in `migrations/common/sass/`, not a Sass parser. The scanner tells code from non-code the way Sass does, and the changes in `changes.ts` work on the code it finds. Both files open with a comment that states what they handle, and `changes.ts` lists its known limits. + +## Reviewing + +- Treat the known limits in `changes.ts` as intentional. Don't report a case they already cover, such as a call through another module's namespace, a dynamic reference through `meta.get-function()`, a name written with escapes, or a nested call to the same member in `updateArgument`. +- Report a scanning or matching problem only when it can happen in a stylesheet an Angular application would contain, and say what that stylesheet looks like. Input that the Sass compiler rejects isn't a finding: check that the example compiles. +- Give the most weight to output that is wrong without anyone noticing: code that a change removes or rewrites by mistake, or a change that leaves a stylesheet that no longer compiles. A change that skips an unusual call and leaves it as it was is less severe, and a call it reports for manual review is working as intended. +- Don't suggest replacing the scanner with ad hoc regular expressions over the raw text. Regular expressions run on the code the scanner masks, so that strings and comments stay out of reach. + +## Tests + +- The context matrix in `common/sass/changes.spec.ts` runs every change against the places a statement can appear: inert ones, such as comments and strings, and non-code that comes before code, such as `url()` tokens and escaped strings. The Sass compiler validates every context first. +- A new scanning case belongs in that matrix as a context, so that it covers every change at once, rather than in a test for one change. +- A migration for a release has its own spec, next to its `index.ts`, that runs it end to end. diff --git a/CHANGELOG.md b/CHANGELOG.md index fcec5bb04c2..c6eb6f60e0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ All notable changes for each version of this project will be documented in this file. +## 22.3.0 + +### Themes + +- **Breaking Change** - Updated to Ignite UI Theming 30.0.0. The `palette()` function builds palettes with the new `fitted` generator by default, which places every shade by contrast and emits it as a fixed color. The predefined palettes and the pre-built themes keep the `legacy` generator and are unchanged. + - `ng update` adds `$generator: 'legacy'` to existing `palette()` calls, so they keep the colors they produced before. Remove the argument to switch a palette to the `fitted` generator. + - The deprecated `light-theme`, `dark-theme`, and the other deprecated theme wrapper mixins build their palettes with the `legacy` generator. +- **Breaking Change** - Component themes take their background from the `base` surface role, `--ig-surface-base`, instead of `--ig-surface-500`. Override the surface roles (`--ig-surface-base`, `--ig-surface-raised`, `--ig-surface-overlay`, `--ig-surface-sunken`, `--ig-surface-container`) instead of `--ig-surface-500` to change the surface at runtime. + - The numbered surface shades, such as `--ig-surface-500`, are deprecated and will be removed in a future release. Palettes still emit them under both generators, and in a `fitted` palette `--ig-surface-500` points to `--ig-surface-base`. + ## Unreleased ### Behavioral Changes diff --git a/package-lock.json b/package-lock.json index 571b8968559..4e5d54b104a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,7 @@ "express": "^5.2.1", "fflate": "^0.8.1", "igniteui-i18n-core": "^1.0.2", - "igniteui-theming": "^29.0.0", + "igniteui-theming": "^30.0.0", "igniteui-trial-watermark": "^3.1.0", "jspdf": "^4.2.1", "lodash-es": "^4.17.21", @@ -14714,12 +14714,12 @@ } }, "node_modules/igniteui-theming": { - "version": "29.1.0", - "resolved": "https://registry.npmjs.org/igniteui-theming/-/igniteui-theming-29.1.0.tgz", - "integrity": "sha512-TjXJGNaVtMcFEO4WuK4WG5pv2K3yQquyPsxKXo/XabMEvRaEVR9kcEh7DG+oFx6iNBWjv0Q+xCd0T3ESIsFeYA==", + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/igniteui-theming/-/igniteui-theming-30.0.0.tgz", + "integrity": "sha512-hccCKuxbn1CvQO0IGB1BZn1GvxHyWHcfWEN9CzHreEikeaDPiAi/EUt37h9U2ZJtnwYKU1Bdejajo8stnqUfOQ==", "license": "MIT", "dependencies": { - "@modelcontextprotocol/sdk": "^1.28.0", + "@modelcontextprotocol/sdk": "^1.32.1", "sass-embedded": "~1.92.1", "zod": "^3.25.76" }, diff --git a/package.json b/package.json index c678e3d557b..8d89ad562f5 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,6 @@ "express": "^5.2.1", "fflate": "^0.8.1", "igniteui-i18n-core": "^1.0.2", - "igniteui-theming": "^29.0.0", "igniteui-trial-watermark": "^3.1.0", "jspdf": "^4.2.1", "lodash-es": "^4.17.21", @@ -137,6 +136,7 @@ "igniteui-grid-lite": "~0.11.0", "igniteui-i18n-resources": "^1.0.5", "igniteui-sassdoc-theme": "^2.2.0", + "igniteui-theming": "^30.0.0", "igniteui-webcomponents": "^7.4.0", "lit-html": "^3.2.1", "ng-packagr": "^22.0.0", diff --git a/projects/igniteui-angular/checkbox/src/checkbox/themes/dark/_bootstrap.scss b/projects/igniteui-angular/checkbox/src/checkbox/themes/dark/_bootstrap.scss index 96431d89622..fb2de72e1a8 100644 --- a/projects/igniteui-angular/checkbox/src/checkbox/themes/dark/_bootstrap.scss +++ b/projects/igniteui-angular/checkbox/src/checkbox/themes/dark/_bootstrap.scss @@ -14,7 +14,7 @@ $_theme: digest-schema($dark-bootstrap-checkbox); @include m(disabled) { @include e(composite) { - background: color($color: 'surface', $variant: 500); + background: color($color: 'surface'); } @include e(composite-mark) { diff --git a/projects/igniteui-angular/core/src/core/styles/themes/generators/_base.scss b/projects/igniteui-angular/core/src/core/styles/themes/generators/_base.scss index f12fc265133..5c99d942d13 100644 --- a/projects/igniteui-angular/core/src/core/styles/themes/generators/_base.scss +++ b/projects/igniteui-angular/core/src/core/styles/themes/generators/_base.scss @@ -604,6 +604,7 @@ $error: color($palette, error), $surface: if($surface != #fff, $surface, #fff), $gray: if($gray != #9e9e9e, $gray, #000), + $generator: 'legacy', ); @include theme( @@ -633,6 +634,7 @@ $error: color($palette, error), $surface: if($surface != #fff, $surface, #222), $gray: if($gray != #9e9e9e, $gray, #fff), + $generator: 'legacy', ); @include theme( diff --git a/projects/igniteui-angular/core/src/core/styles/themes/generators/_bootstrap.scss b/projects/igniteui-angular/core/src/core/styles/themes/generators/_bootstrap.scss index b2d67fc8ad5..f945bc5920c 100644 --- a/projects/igniteui-angular/core/src/core/styles/themes/generators/_bootstrap.scss +++ b/projects/igniteui-angular/core/src/core/styles/themes/generators/_bootstrap.scss @@ -38,7 +38,8 @@ $info: $info, $success: $success, $warn: $warn, - $error: $error + $error: $error, + $generator: 'legacy' ), $schema: $light-bootstrap-schema, $exclude: $exclude, @@ -76,7 +77,8 @@ $info: $info, $success: $success, $warn: $warn, - $error: $error + $error: $error, + $generator: 'legacy' ), $schema: $dark-bootstrap-schema, $exclude: $exclude, diff --git a/projects/igniteui-angular/core/src/core/styles/themes/generators/_fluent.scss b/projects/igniteui-angular/core/src/core/styles/themes/generators/_fluent.scss index 939ac05bd41..01975a29b01 100644 --- a/projects/igniteui-angular/core/src/core/styles/themes/generators/_fluent.scss +++ b/projects/igniteui-angular/core/src/core/styles/themes/generators/_fluent.scss @@ -38,7 +38,8 @@ $info: $info, $success: $success, $warn: $warn, - $error: $error + $error: $error, + $generator: 'legacy' ), $schema: $light-fluent-schema, $exclude: $exclude, @@ -76,7 +77,8 @@ $info: $info, $success: $success, $warn: $warn, - $error: $error + $error: $error, + $generator: 'legacy' ), $schema: $dark-fluent-schema, $exclude: $exclude, diff --git a/projects/igniteui-angular/core/src/core/styles/themes/generators/_indigo.scss b/projects/igniteui-angular/core/src/core/styles/themes/generators/_indigo.scss index db86ea6464a..b9a98ce592b 100644 --- a/projects/igniteui-angular/core/src/core/styles/themes/generators/_indigo.scss +++ b/projects/igniteui-angular/core/src/core/styles/themes/generators/_indigo.scss @@ -39,7 +39,8 @@ $info: $info, $success: $success, $warn: $warn, - $error: $error + $error: $error, + $generator: 'legacy' ), $schema: $light-indigo-schema, $exclude: $exclude, @@ -78,7 +79,8 @@ $info: $info, $success: $success, $warn: $warn, - $error: $error + $error: $error, + $generator: 'legacy' ), $schema: $dark-indigo-schema, $exclude: $exclude, diff --git a/projects/igniteui-angular/grids/themes/_base.scss b/projects/igniteui-angular/grids/themes/_base.scss index 5f53c341efd..4a9ebb4ef12 100644 --- a/projects/igniteui-angular/grids/themes/_base.scss +++ b/projects/igniteui-angular/grids/themes/_base.scss @@ -1984,8 +1984,8 @@ padding-block: pad-block(4px, 6px, 8px); padding-inline: pad-inline(4px, 8px, 16px); min-width: rem(320px); - color: var(--ig-grid-foreground, contrast-color($color: 'surface', $variant: 500)); - background: var(--ig-grid-background, color($color: 'surface', $variant: 500)); + color: var(--ig-grid-foreground, contrast-color($color: 'surface')); + background: var(--ig-grid-background, color($color: 'surface')); border-radius: 0; igc-icon, diff --git a/projects/igniteui-angular/migrations/common/UpdateChanges.spec.ts b/projects/igniteui-angular/migrations/common/UpdateChanges.spec.ts index 788d504d69d..55894866046 100644 --- a/projects/igniteui-angular/migrations/common/UpdateChanges.spec.ts +++ b/projects/igniteui-angular/migrations/common/UpdateChanges.spec.ts @@ -2309,6 +2309,117 @@ $my-other-theme: my-namespace.function1($color1: contrast-color($palette: palett expect(appTree.readContent('test.component.scss')).toBe(expectedFileContent); }); + it('should add a theme property only to exact calls of the owner, and report what it cannot change', () => { + const themeChangesJson: ThemeChanges = { + changes: [ + { + name: '$generator', add: `'legacy'`, type: ThemeType.Property, + owner: 'palette', exactOwner: true, ownerType: ThemeType.Function, parameterIndex: 10 + }, + { name: 'card-typography', type: ThemeType.Mixin, remove: true } + ] + }; + const jsonPath = path.join(__dirname, 'changes', 'theme-changes.json'); + vi.spyOn(fs, 'existsSync').mockImplementation((filePath: fs.PathLike) => filePath === jsonPath); + vi.spyOn(fs, 'readFileSync').mockImplementation(() => JSON.stringify(themeChangesJson)); + + appTree.create('styles.scss', +`$a: palette(red, blue, white); +$b: color-palette(red); +$c: palette($args...); +@include palette($a); +.card { @include card-typography { color: red; } } +`); + appTree.create('theme.sass', +`$a: palette(red, blue, white) +:root + +palette($a) +`); + + const warn = vi.fn(); + const context = { logger: { warn, info: () => {} } } as any; + new UnitUpdateChanges(__dirname, appTree, context).applyChanges(); + + expect(appTree.readContent('styles.scss')).toEqual( +`$a: palette(red, blue, white, $generator: 'legacy'); +$b: color-palette(red); +$c: palette($args...); +@include palette($a); +.card { @include card-typography { color: red; } } +`); + expect(appTree.readContent('theme.sass')).toEqual( +`$a: palette(red, blue, white, $generator: 'legacy') +:root + +palette($a) +`); + const warnings = warn.mock.calls.map(args => args[0]); + expect(warnings).toContain(` - /styles.scss:3 - Add $generator: 'legacy' to the arguments this call of palette() spreads.`); + expect(warnings).toContain(' - /styles.scss:5 - Remove the include of the mixin card-typography, which passes a content block.'); + }); + + it('should rename variables that older changes name without their $', () => { + const themeChangesJson: ThemeChanges = { + changes: [ + { name: 'igx-elevation', replaceWith: 'elevation', type: ThemeType.Variable }, + { name: 'igx-elevations', replaceWith: 'elevations', type: ThemeType.Variable } + ] + }; + const jsonPath = path.join(__dirname, 'changes', 'theme-changes.json'); + vi.spyOn(fs, 'existsSync').mockImplementation((filePath: fs.PathLike) => filePath === jsonPath); + vi.spyOn(fs, 'readFileSync').mockImplementation(() => JSON.stringify(themeChangesJson)); + + appTree.create('styles.scss', `$a: $igx-elevations;\n$b: map.get($igx-elevations, $igx-elevation);\n`); + new UnitUpdateChanges(__dirname, appTree).applyChanges(); + + expect(appTree.readContent('styles.scss')).toEqual(`$a: $elevations;\n$b: map.get($elevations, $elevation);\n`); + }); + + it('should remove variables marked for removal instead of renaming them', () => { + const themeChangesJson: ThemeChanges = { + changes: [ + { name: '$browser-context', remove: true, type: ThemeType.Variable }, + { name: '$_indigo-elevation-navbar', replaceWith: '', type: ThemeType.Variable } + ] + }; + const jsonPath = path.join(__dirname, 'changes', 'theme-changes.json'); + vi.spyOn(fs, 'existsSync').mockImplementation((filePath: fs.PathLike) => filePath === jsonPath); + vi.spyOn(fs, 'readFileSync').mockImplementation(() => JSON.stringify(themeChangesJson)); + + appTree.create('styles.scss', `$browser-context: 16px;\n$_indigo-elevation-navbar: 2;\n.a { width: math.div(10px, $browser-context); }\n`); + + const warn = vi.fn(); + new UnitUpdateChanges(__dirname, appTree, { logger: { warn, info: () => {} } } as any).applyChanges(); + + expect(appTree.readContent('styles.scss')).toEqual(`.a { width: math.div(10px, $browser-context); }\n`); + expect(warn).toHaveBeenCalledWith(' - /styles.scss:1 - Replace this reference to the removed variable $browser-context.'); + }); + + it('should report what it cannot change at its line in the migrated stylesheet', () => { + const themeChangesJson: ThemeChanges = { + changes: [ + { name: '$generator', add: `'legacy'`, type: ThemeType.Property, owner: 'palette', exactOwner: true }, + { name: 'card-typography', type: ThemeType.Mixin, remove: true } + ] + }; + const jsonPath = path.join(__dirname, 'changes', 'theme-changes.json'); + vi.spyOn(fs, 'existsSync').mockImplementation((filePath: fs.PathLike) => filePath === jsonPath); + vi.spyOn(fs, 'readFileSync').mockImplementation(() => JSON.stringify(themeChangesJson)); + + // the include goes after the palette change is made, and takes three lines with it + appTree.create('styles.scss', +`@include card-typography( + $categories: (title: 'h5') +); +$c: palette($args...); +`); + + const warn = vi.fn(); + new UnitUpdateChanges(__dirname, appTree, { logger: { warn, info: () => {} } } as any).applyChanges(); + + expect(appTree.readContent('styles.scss')).toEqual(`$c: palette($args...);\n`); + expect(warn).toHaveBeenCalledWith(` - /styles.scss:1 - Add $generator: 'legacy' to the arguments this call of palette() spreads.`); + }); + it('should remove @use imports for deleted scss modules along with their member access', () => { const scssUseChangesJson: ScssUseChanges = { changes: [ diff --git a/projects/igniteui-angular/migrations/common/UpdateChanges.ts b/projects/igniteui-angular/migrations/common/UpdateChanges.ts index 34e4de47a5b..62336d227e1 100644 --- a/projects/igniteui-angular/migrations/common/UpdateChanges.ts +++ b/projects/igniteui-angular/migrations/common/UpdateChanges.ts @@ -6,7 +6,7 @@ import type { SchematicContext, Tree, FileVisitor, DirEntry } from '@angular-dev import type { WorkspaceSchema } from '@schematics/angular/utility/workspace-models'; import { ClassChanges, BindingChanges, SelectorChange, - SelectorChanges, ThemeChanges, ImportsChanges, MemberChanges, ThemeChange, ThemeType, ScssUseChanges + SelectorChanges, ThemeChanges, ImportsChanges, MemberChanges, ThemeType, ScssUseChanges } from './schema'; import { getLanguageService, getRenamePositions, getIdentifierPositions, @@ -17,10 +17,15 @@ import { getPackageManager, canResolvePackage, tryInstallPackage, tryUninstallPackage, getPackageVersion } from './util'; import { ServerHost } from './ServerHost'; +import { SassStylesheet } from './sass/stylesheet'; +import { normalizeName } from './sass/scanner'; +import { + addArgument, applyEditsWithMap, removeIncludes, removeVariable, renameMember, renameVariable, SassChangeResult, updateArgument, updateModule +} from './sass/changes'; + import { serviceContainer } from './project-service-container'; const TSCONFIG_PATH = 'tsconfig.json'; -const URL_TOKEN = 'url('; /** Dependency and build output folders never searched for stylesheets. */ const SKIPPED_STYLE_DIRS = ['node_modules', 'dist', '.angular', '.git']; @@ -28,32 +33,17 @@ export enum InputPropertyType { EVAL = 'eval', STRING = 'string' } +/** One configured Sass change, and the text a stylesheet mentions when the change can apply to it. */ +interface SassStep { + mention: string; + apply: (sheet: SassStylesheet) => SassChangeResult; +} declare type TransformFunction = (args: BoundPropertyObject) => void; export interface BoundPropertyObject { value: string; bindingType: InputPropertyType; } -interface AppliedChange { - overwrite: boolean, - fileContent: string -} - -/** A declaration, including its default arguments and body, which are evaluated when called. */ -interface SassDeclaration { - start: number; - end: number; -} - -/** The mixins and functions declared in one lexical scope of a stylesheet. */ -interface SassDeclarationScope { - start: number; - end: number; - mixins: Map; - functions: Map; - declarations: SassDeclaration[]; -} - /* eslint-disable arrow-parens */ export class UpdateChanges { protected tsconfigPath = TSCONFIG_PATH; @@ -93,6 +83,8 @@ export class UpdateChanges { protected themeChanges: ThemeChanges; protected importsChanges: ImportsChanges; protected scssUseChanges: ScssUseChanges; + /** Sass changes that could not be made automatically, as `path:line - what to do`. */ + protected manualSassChanges = new Set(); protected membersChanges: MemberChanges; protected conditionFunctions: Map any> = new Map any>(); protected valueTransforms: Map = new Map(); @@ -208,16 +200,16 @@ export class UpdateChanges { this.updateMembers(); } /** Sass files */ - if (this.themeChanges && this.themeChanges.changes.length) { + const sassChanges = this.sassChanges(); + if (sassChanges.length) { for (const entryPath of this.sassFiles) { - this.updateThemeProps(entryPath); - this.updateSassVariables(entryPath); - this.updateSassFunctionsAndMixins(entryPath); + this.updateStylesheet(entryPath, sassChanges); } } - if (this.scssUseChanges && this.scssUseChanges.changes.length) { - for (const entryPath of this.sassFiles) { - this.updateScssUseImports(entryPath); + if (this.manualSassChanges.size) { + this.context?.logger.warn('Some stylesheets need changes the migration could not make. Review the following:'); + for (const change of this.manualSassChanges) { + this.context?.logger.warn(` - ${change}`); } } @@ -417,489 +409,87 @@ export class UpdateChanges { } } - protected updateThemeProps(entryPath: string) { - let fileContent = this.host.read(entryPath).toString(); - let overwrite = false; - for (const change of this.themeChanges.changes) { - if (change.type !== ThemeType.Property) { - continue; - } - if (fileContent.indexOf(change.owner) === -1) { - continue; - } - /** owner-func:( * ) */ - const calls = this.findFunctionCalls(fileContent, change.owner, entryPath.endsWith('.sass')); - // rewrite back to front so the collected indices stay valid - for (const call of calls.reverse()) { - const rawBody = fileContent.substring(call.bodyStart, call.bodyEnd); - if (rawBody.indexOf(change.name) === -1) { - continue; - } - const name = escapeRegExp(change.name); - const replaceWith = change.replaceWith ? escapeRegExp(change.replaceWith) : undefined; - // both are matched at the argument's code start; Sass allows whitespace before the colon - const reg = new RegExp(String.raw`^${name}\s*:`); - const existing = new RegExp(String.raw`^${replaceWith}\s*:`); - // keep whatever sits in front of the closing bracket so the formatting is preserved - const trailing = /\s*$/.exec(rawBody).pop(); - const body = rawBody.substring(0, rawBody.length - trailing.length); - const eol = fileContent.includes('\r\n') ? '\r\n' : '\n'; - - const params = this.splitFunctionProps(body); - const codeStarts = params.map(param => this.leadingCommentsEnd(param)); - // renaming onto a name the call already passes would make it a duplicate argument - const duplicate = !!replaceWith && params.some((param, i) => existing.test(param.substring(codeStarts[i]))); - - // comments in front of a removed argument usually describe the previous one, so they are kept - let carried = ''; - const kept = params.reduce((arr, param, i) => { - const codeStart = codeStarts[i]; - if (reg.test(param.substring(codeStart))) { - if (!change.remove && !duplicate) { - // the name sits at the code start, so renaming must not search the comments - arr.push(carried + this.commentBreak(carried, param, eol) + param.substring(0, codeStart) - + change.replaceWith + param.substring(codeStart + change.name.length)); - carried = ''; - } else { - carried += param.substring(0, codeStart).trimEnd(); - } - } else { - arr.push(carried + this.commentBreak(carried, param, eol) + param); - carried = ''; - } - return arr; - }, []); - - fileContent = fileContent.substring(0, call.bodyStart) - + kept.join(',') + carried + this.commentBreak(carried, trailing, eol) - + trailing - + fileContent.substring(call.bodyEnd); - overwrite = true; - } - } - if (overwrite) { - this.host.overwrite(entryPath, fileContent); - } - } - - /** - * Returns the argument list boundaries of every top-level `owner(...)` call in the content. - * Strings, comments and `url()` tokens are scanned over, so an `owner(` that is only mentioned - * in one of those is not taken for a call, and anything the stylesheet declares itself is left - * alone. The brackets are tracked too, so a call nested in another one - - * `@include scrollbar(scrollbar-theme($sb-size: 6px))` - reports its own closing bracket - * rather than the one of the call surrounding it. - * - * An owner is matched anywhere in a name, because migrations rely on it: `circular-theme` is - * how `progress-circular-theme` is addressed, and `theme` covers every `*-theme` mixin. - */ - private findFunctionCalls(content: string, owner: string, indented: boolean): { bodyStart: number; bodyEnd: number }[] { - const calls: { bodyStart: number; bodyEnd: number }[] = []; - const opening = `${owner}(`; - const code = this.sassCode(content); - // Earlier changes can move both calls and scope boundaries, so collect them together. - const scopes = this.declarationScopes(content, code, indented); - let brackets = 0; - - for (let i = 0; i < content.length; i++) { - if (code[i] === '(' || code[i] === '[') { - brackets++; - } else if (code[i] === ')' || code[i] === ']') { - brackets--; - } - if (!code.startsWith(opening, i) || this.isOwnMember(code, i, owner, scopes, indented && !brackets)) { - continue; - } - - const bodyStart = i + opening.length; - const bodyEnd = this.findClosingBracket(content, bodyStart); - if (bodyEnd === -1) { - // unbalanced from here on, leave this call rather than the rest of the file - continue; - } - - calls.push({ bodyStart, bodyEnd }); - // a same-owner call nested in this one is already covered by it - i = bodyEnd; - } - - return calls; - } - /** - * Returns the index of the bracket closing the one `start` is inside of, or -1 when the - * content is unbalanced. Brackets in strings, comments and `url()` tokens are ignored. + * Every configured Sass change, in the order it is applied to each stylesheet: theme + * properties, variables, functions and mixins, then module rules. Each names the text a + * stylesheet has to mention for the change to apply, so that most stylesheets are skipped quickly. */ - private findClosingBracket(content: string, start: number): number { - let level = 0; - - for (let i = start; i < content.length; i++) { - const nonCodeEnd = this.skipNonCode(content, i); - if (nonCodeEnd !== -1) { - i = nonCodeEnd; - continue; - } - - const char = content[i]; - if (char === '(') { - level++; - } else if (char === ')') { - if (!level) { - return i; + protected sassChanges(): SassStep[] { + const theme = this.themeChanges?.changes ?? []; + const kindOf = (type: ThemeType) => type === ThemeType.Mixin ? 'mixin' : type === ThemeType.Function ? 'function' : undefined; + // some older changes name a variable without its `$` + const asVariable = (name: string) => name.startsWith('$') ? name : `$${name}`; + + return [ + ...theme.filter(change => change.type === ThemeType.Property).map(change => ({ + mention: change.owner, + apply: (sheet: SassStylesheet) => { + const match = change.exactOwner ? 'exact' : 'suffix'; + const kind = kindOf(change.ownerType); + return change.add !== undefined + ? addArgument(sheet, change.owner, { name: change.name, value: change.add, kind, parameterIndex: change.parameterIndex }, match) + : updateArgument(sheet, change.owner, change, match, kind); } - level--; - } - } - - return -1; - } - - /** Collects declarations within their brace or indentation scopes in code with non-code masked. */ - private declarationScopes(content: string, code: string, indented: boolean): SassDeclarationScope[] { - const scopes: SassDeclarationScope[] = []; - const stack: { scope: SassDeclarationScope; indentation: number; declaration?: SassDeclaration }[] = []; - let brackets = 0; - let pendingDeclaration: SassDeclaration; - const enterScope = (start: number, indentation = 0) => { - const scope: SassDeclarationScope = { - start, end: code.length, mixins: new Map(), functions: new Map(), declarations: [] - }; - scopes.push(scope); - // Braces in an interpolated default argument do not start the declaration's body. - const declaration = brackets ? undefined : pendingDeclaration; - stack.push({ scope, indentation, declaration }); - if (declaration) { - pendingDeclaration = undefined; - } - }; - const leaveScope = (end: number) => { - const { scope, declaration } = stack.pop(); - scope.end = end; - if (declaration) { - declaration.end = end; - } - }; - enterScope(0); - const declaration = /(?:@(mixin|function)\s+|=\s*)([\w-]+)/y; - - for (let i = 0; i < code.length; i++) { - // Indentation inside a multiline argument/list is not a new Sass scope. - if (indented && !brackets && (i === 0 || code[i - 1] === '\n')) { - const lineEnd = code.indexOf('\n', i); - const line = code.substring(i, lineEnd === -1 ? code.length : lineEnd); - if (line.trim()) { - // Masked comments are whitespace too, but are not part of the source indentation. - const indentation = /^[ \t]*/.exec(content.substring(i))[0].length; - // An indented mixin may have defaults but no body. - if (pendingDeclaration && indentation <= stack[stack.length - 1].indentation) { - pendingDeclaration.end = i; - pendingDeclaration = undefined; - } - while (stack.length > 1 && indentation < stack[stack.length - 1].indentation) { - leaveScope(i); - } - if (indentation > stack[stack.length - 1].indentation) { - enterScope(i, indentation); - } - } - } - - const char = code[i]; - if (!indented && char === '{') { - enterScope(i); - } else if (!indented && char === '}' && stack.length > 1) { - leaveScope(i); - } else if (char === '(' || char === '[') { - brackets++; - } else if (char === ')' || char === ']') { - brackets--; - } else if (char === '@' || (indented && !brackets && char === '=' - && this.keywordBefore(code, i + 1, true) === '@mixin')) { - declaration.lastIndex = i; - const match = declaration.exec(code); - if (match) { - const scope = stack[stack.length - 1].scope; - // Sass treats underscores and hyphens as the same identifier character. - const members = scope[match[1] === 'function' ? 'functions' : 'mixins']; - const name = match[2].replace(/_/g, '-'); - if (!members.has(name)) { - members.set(name, i); - } - pendingDeclaration = { start: i, end: code.length }; - scope.declarations.push(pendingDeclaration); - i = declaration.lastIndex - 1; - } - } - } - - return scopes; - } - - /** - * Tells whether the `owner` at `index` is part of a member the stylesheet declares itself. A - * user's own `app-grid-summary-theme` ends with a theme function name, so without this its - * declaration would lose a parameter its body reads. - * - * Only this stylesheet is read. Telling which member a call loaded from another one refers to - * needs the module rules of `@use` and `@forward` - re-exports, `hide` and `as prefix-*` - so a - * member of the app that another stylesheet declares is still taken for the Ignite UI one. - */ - private isOwnMember(content: string, index: number, owner: string, scopes: SassDeclarationScope[], indented: boolean): boolean { - let start = index; - while (start > 0 && /[\w-]/.test(content[start - 1])) { - start--; - } - - // a namespace holds the members of another stylesheet, never the ones declared here - if (content[start - 1] === '.') { - return false; - } - - const keyword = this.keywordBefore(content, start, indented); - if (keyword === '@mixin' || keyword === '@function') { - // a declaration, whose body reads the parameters it lists - return true; - } - - const name = content.substring(start, index + owner.length).replace(/_/g, '-'); - // Bodies and defaults can run after a later declaration becomes available. Keep their - // existing protection; declaration order is conclusive only for immediate calls. - const deferred = scopes.some(scope => scope.declarations.some(member => member.start <= index && index < member.end)); - - // a mixin and a function of one name are separate members, so only the called one shadows - return scopes.some(scope => { - const declaredAt = (keyword === '@include' ? scope.mixins : scope.functions).get(name); - return scope.start <= index && index < scope.end && declaredAt !== undefined - && (deferred || declaredAt <= index); - }); - } - - /** The at-rule before a member name, or indented shorthand when outside an expression. */ - private keywordBefore(content: string, start: number, indented: boolean): string { - let end = start; - while (end > 0 && /\s/.test(content[end - 1])) { - end--; - } - - // Shorthand starts a statement. Unlike '=', '+' must be adjacent to the mixin name. - const shorthand = content[end - 1]; - if (indented && (shorthand === '=' || (shorthand === '+' && end === start)) - && !content.substring(content.lastIndexOf('\n', end - 1) + 1, end - 1).trim()) { - return shorthand === '=' ? '@mixin' : '@include'; - } - - let keywordStart = end; - while (keywordStart > 0 && /[\w@-]/.test(content[keywordStart - 1])) { - keywordStart--; - } - - return content.substring(keywordStart, end); - } - - /** Masks non-code with whitespace, preserving offsets, line endings and the original stylesheet. */ - private sassCode(content: string): string { - const parts: string[] = []; - let start = 0; - - for (let i = 0; i < content.length; i++) { - const nonCodeEnd = this.skipNonCode(content, i); - if (nonCodeEnd !== -1) { - parts.push(content.substring(start, i), content.substring(i, nonCodeEnd + 1).replace(/[^\r\n]/g, ' ')); - start = nonCodeEnd + 1; - i = nonCodeEnd; - } - } - - parts.push(content.substring(start)); - return parts.join(''); - } - - /** Index where an argument's code starts, after its leading whitespace and comments. */ - private leadingCommentsEnd(param: string): number { - let i = 0; - while (i < param.length) { - if (/\s/.test(param[i])) { - i++; - } else if (param[i] === '/' && (param[i + 1] === '/' || param[i + 1] === '*')) { - i = this.skipNonCode(param, i) + 1; - } else { - break; - } - } - return i; - } - - /** The line break a carried `//` comment needs so that it does not swallow what follows it. */ - private commentBreak(carried: string, next: string, eol: string): string { - return /\/\/[^\n]*$/.test(carried) && !/^[ \t]*\r?\n/.test(next) ? eol : ''; - } - - /** - * When a string, a comment or a `url()` token starts at `index`, returns the index of its last - * character so that a scan can carry on past it. Returns -1 when `index` is on code. - */ - private skipNonCode(content: string, index: number): number { - const char = content[index]; - const next = content[index + 1]; - - if (char === '\'' || char === '"') { - return this.skipString(content, index); - } - if (char === '/' && next === '*') { - const end = content.indexOf('*/', index + 2); - - return end === -1 ? content.length : end + 1; - } - if (char === '/' && next === '/') { - const end = content.indexOf('\n', index + 2); - - return end === -1 ? content.length : end; - } - if (this.isUrlToken(content, index)) { - return this.skipUrl(content, index + URL_TOKEN.length); - } - - return -1; + })), + ...theme.filter(change => change.type === ThemeType.Variable && !('owner' in change)).map(change => ({ + mention: asVariable(change.name).substring(1), + // a variable without a replacement is gone from the library + apply: (sheet: SassStylesheet) => change.remove || !change.replaceWith + ? removeVariable(sheet, asVariable(change.name)) + : renameVariable(sheet, asVariable(change.name), asVariable(change.replaceWith)) + })), + ...theme.filter(change => change.type === ThemeType.Function || change.type === ThemeType.Mixin).map(change => ({ + mention: change.name, + apply: (sheet: SassStylesheet) => change.remove + ? removeIncludes(sheet, change.name) + : renameMember(sheet, change.name, change.replaceWith) + })), + // `replaceWith` moves a module in place, keeping its namespace and references; `remove` + // drops it with the includes of `member` made through its namespace + ...(this.scssUseChanges?.changes ?? []).map(change => ({ + mention: change.module, + apply: (sheet: SassStylesheet) => updateModule(sheet, change) + })) + ]; } /** - * Sass parses an unquoted `url()` as a single token, so a `//` inside one is part of the - * address - `url(//cdn.example.com/bg.png)` - and not the start of a comment. + * Applies Sass changes to a stylesheet one after another, each on the stylesheet the previous + * one left. What a change leaves for manual review is reported at its line in the final + * stylesheet, after every later change. */ - private isUrlToken(content: string, index: number): boolean { - if (content.substr(index, URL_TOKEN.length).toLowerCase() !== URL_TOKEN) { - return false; - } - - const previous = content[index - 1]; - - return previous === undefined || !/[\w-]/.test(previous); - } - - /** Returns the index of the bracket closing a `url(` whose contents start at `start`. */ - private skipUrl(content: string, start: number): number { - for (let i = start; i < content.length; i++) { - const char = content[i]; - - if (char === '\'' || char === '"') { - i = this.skipString(content, i); - } else if (char === ')') { - return i; - } - } - - return content.length; - } - - /** Returns the index of the quote closing the string opened at `start`. */ - private skipString(content: string, start: number): number { - const quote = content[start]; - - for (let i = start + 1; i < content.length; i++) { - if (content[i] === '\\') { - i++; - } else if (content[i] === quote) { - return i; - } - } - - return content.length; - } - - protected isNamedArgument(fileContent: string, i: number, occurrences: number[], change: ThemeChange) { - const openingBrackets = []; - const closingBrackets = []; - if (fileContent[(occurrences[i] + change.name.length)] !== ':' - || (fileContent[(occurrences[i] + change.name.length)] === ' ' - && fileContent[(occurrences[i] + change.name.length) + 1] === ':')) { - return false; - } - for (let j = occurrences[i]; j >= 0; j--) { - if (fileContent[j] === ')') { - closingBrackets.push(fileContent[j]); - } else if (fileContent[j] === '(') { - openingBrackets.push(fileContent[j]); - } - } - - return openingBrackets.length !== closingBrackets.length; - } - - protected updateSassVariables(entryPath: string) { - let fileContent = this.host.read(entryPath).toString(); - let overwrite = false; - const allowedStartCharacters = new RegExp(/(:|,)\s?/, 'g'); - // eslint-disable-next-line no-control-regex - const allowedEndCharacters = new RegExp('[;),: \r\n]', 'g'); - for (const change of this.themeChanges.changes) { - if (change.type !== ThemeType.Variable) { + private updateStylesheet(entryPath: string, steps: SassStep[]) { + const original = this.host.read(entryPath).toString(); + const indented = entryPath.endsWith('.sass'); + const manual: { index: number; message: string }[] = []; + let content = original; + // Sass treats underscores and hyphens in names as the same character + let mentions = normalizeName(content); + + for (const step of steps) { + if (!mentions.includes(normalizeName(step.mention))) { continue; } - if (!('owner' in change)) { - const occurrences = findMatches(fileContent, change.name); - for (let i = occurrences.length - 1; i >= 0; i--) { - const allowedStartEnd = fileContent[occurrences[i] - 1].match(allowedStartCharacters) - || fileContent[(occurrences[i] + change.name.length)].match(allowedEndCharacters); - if (allowedStartEnd && !this.isNamedArgument(fileContent, i, occurrences, change as ThemeChange)) { - fileContent = replaceMatch(fileContent, change.name, change.replaceWith, occurrences[i]); - overwrite = true; - } + const result = step.apply(new SassStylesheet(content, indented)); + manual.push(...result.manual); + if (result.edits.length) { + const applied = applyEditsWithMap(content, result.edits); + for (const change of manual) { + change.index = applied.map(change.index); } + content = applied.content; + mentions = normalizeName(content); } } - if (overwrite) { - this.host.overwrite(entryPath, fileContent); - } - } - - protected updateSassFunctionsAndMixins(entryPath: string) { - const aliases = this.getAliases(entryPath); - let fileContent = this.host.read(entryPath).toString(); - let overwrite = false; - for (const change of this.themeChanges.changes) { - if (change.type !== ThemeType.Function && change.type !== ThemeType.Mixin) { - continue; - } - let occurrences: number[] = []; - if (aliases.length > 0 && !aliases.includes('*')) { - aliases.forEach(a => occurrences = occurrences.concat(findMatches(fileContent, a + '.' + change.name))); - if (occurrences.length > 0) { - ({ overwrite, fileContent } = this.tryReplaceScssFunctionWithAlias(occurrences, aliases, fileContent, change, overwrite)); - continue; - } - } - - occurrences = findMatches(fileContent, change.name); - if (occurrences.length > 0) { - ({ overwrite, fileContent } = this.tryReplaceScssFunction(occurrences, fileContent, change, overwrite)); - } + for (const { index, message } of manual) { + this.manualSassChanges.add(`${entryPath}:${content.substring(0, index).split('\n').length} - ${message}`); } - if (overwrite) { - this.host.overwrite(entryPath, fileContent); + if (content !== original) { + this.host.overwrite(entryPath, content); } } - protected getAliases(entryPath: string) { - const fileContent = this.host.read(entryPath).toString(); - // B.P. 18/05/22 #11577 - Use RegEx to distinguish themed imports. - const matchers = [ - /@use(\s+)('|")igniteui-angular\/theming\2\1as\1(\w+)/g, - /@use(\s+)('|")igniteui-angular\/theme\2\1as\1(\w+)/g, - /@use(\s+)('|")igniteui-angular\/lib\/core\/styles\/themes\/index\2\1as\1(\w+)/g - ]; - - const aliases = []; - matchers.forEach(m => { - const match = m.exec(fileContent); - if (match) { - aliases.push(match[3]); // access the captured alias - } - }); - - return aliases; - } - protected updateImports(entryPath: string) { let fileContent = this.host.read(entryPath).toString(); let overwrite = false; @@ -962,163 +552,6 @@ export class UpdateChanges { } } - // TODO: combine both functions - private tryReplaceScssFunctionWithAlias(occurrences: number[], aliases: string[], fileContent: string, change: ThemeChange, overwrite: boolean): AppliedChange { - for (const alias of aliases) { - const aliasLength = alias.length + 1; // + 1 because of the dot - alias.member - for (let i = occurrences.length - 1; i >= 0; i--) { - const isOpenParenthesis = fileContent[occurrences[i] + aliasLength + change.name.length] === '('; - if (isOpenParenthesis) { - fileContent = change.remove ? - this.removeScssMixinCall(fileContent, occurrences[i] + aliasLength, change.name.length) : - replaceMatch(fileContent, change.name, change.replaceWith, occurrences[i] + aliasLength); - overwrite = true; - } - } - } - - return { overwrite, fileContent }; - } - private tryReplaceScssFunction(occurrences: number[], fileContent: string, change: ThemeChange, overwrite: boolean): AppliedChange { - for (let i = occurrences.length - 1; i >= 0; i--) { - const isOpenParenthesis = fileContent[occurrences[i] + change.name.length] === '('; - if (isOpenParenthesis) { - fileContent = change.remove ? - this.removeScssMixinCall(fileContent, occurrences[i], change.name.length) : - replaceMatch(fileContent, change.name, change.replaceWith, occurrences[i]); - overwrite = true; - } - } - - return { overwrite, fileContent }; - } - - /** Removes an entire `@include name(...);` mixin invocation (including a leading alias, e.g. `theme.name(...)`). */ - private removeScssMixinCall(fileContent: string, nameStart: number, nameLength: number): string { - const includeIdx = fileContent.lastIndexOf('@include', nameStart); - - if (includeIdx === -1) { - return fileContent; - } - - let depth = 0; - let closeParenIdx = -1; - - for (let i = nameStart + nameLength; i < fileContent.length; i++) { - if (fileContent[i] === '(') { - depth++; - } else if (fileContent[i] === ')') { - depth--; - - if (depth === 0) { - closeParenIdx = i; - break; - } - } - } - - if (closeParenIdx === -1) { - return fileContent; - } - - let end = closeParenIdx + 1; - const semiIdx = fileContent.indexOf(';', end); - - if (semiIdx !== -1 && /^\s*$/.test(fileContent.substring(end, semiIdx))) { - end = semiIdx + 1; - } - - // if the call is the only thing on its line, drop the whole line instead of leaving it blank - let start = includeIdx; - - while (start > 0 && (fileContent[start - 1] === ' ' || fileContent[start - 1] === '\t')) { - start--; - } - - let lineEnd = end; - - while (lineEnd < fileContent.length && (fileContent[lineEnd] === ' ' || fileContent[lineEnd] === '\t')) { - lineEnd++; - } - - const atLineStart = start === 0 || fileContent[start - 1] === '\n'; - const atLineEnd = lineEnd >= fileContent.length || fileContent[lineEnd] === '\n' || fileContent[lineEnd] === '\r'; - - if (atLineStart && atLineEnd) { - if (fileContent[lineEnd] === '\r' && fileContent[lineEnd + 1] === '\n') { - lineEnd += 2; - } else if (fileContent[lineEnd] === '\n' || fileContent[lineEnd] === '\r') { - lineEnd += 1; - } - - return fileContent.substring(0, start) + fileContent.substring(lineEnd); - } - - return fileContent.substring(0, includeIdx) + fileContent.substring(end); - } - - /** - * Applies changes to `@use '' as alias;` statements for Sass modules that moved or - * were removed. `replaceWith` rewrites the module path in place, leaving the alias and any - * `@include alias.;` calls untouched. `remove` drops the whole `@use` statement - * together with any `@include alias.;` call made through that import's namespace. - */ - protected updateScssUseImports(entryPath: string) { - let fileContent = this.host.read(entryPath).toString(); - let overwrite = false; - - for (const change of this.scssUseChanges.changes) { - if (fileContent.indexOf(change.module) === -1) { - continue; - } - - const modulePattern = escapeRegExp(change.module); - - if (change.replaceWith) { - const pathRegex = new RegExp(`(@use\\s+)(['"])${modulePattern}\\2`, 'g'); - const replaced = fileContent.replace(pathRegex, `$1$2${change.replaceWith}$2`); - - if (replaced !== fileContent) { - fileContent = replaced; - overwrite = true; - } - continue; - } - - if (!change.remove) { - continue; - } - - const useRegex = new RegExp( - `[ \\t]*@use\\s+(['"])${modulePattern}\\1(?:\\s+as\\s+([\\w-]+))?\\s*;[ \\t]*\\r?\\n?` - ); - const match = useRegex.exec(fileContent); - if (!match) { - continue; - } - - const alias = match[2] || change.module.split('/').pop(); - fileContent = fileContent.replace(useRegex, ''); - overwrite = true; - - if (change.member) { - const memberRegex = new RegExp( - `[ \\t]*@include\\s+${escapeRegExp(alias)}\\.${escapeRegExp(change.member)}\\s*(?:\\(\\s*\\))?\\s*;[ \\t]*\\r?\\n?`, - 'g' - ); - const withoutMember = fileContent.replace(memberRegex, ''); - if (withoutMember !== fileContent) { - fileContent = withoutMember; - overwrite = true; - } - } - } - - if (overwrite) { - this.host.overwrite(entryPath, fileContent); - } - } - private patchTsConfig(): void { this.ensureTsConfigPath(); if (this.serverHost.fileExists(this.tsconfigPath)) { @@ -1229,46 +662,6 @@ export class UpdateChanges { } } - /** - * Safe split by `','`, considering possible inner function calls. E.g.: - * ``` - * prop: inner-func(), - * prop2: inner2(inner-param: 3, inner-param: inner-func(..)) - * ``` - * - * Strings, comments and `url()` tokens are scanned over the same way the rest of the scanner - * does it, so a `','` inside one of them is not taken for an argument separator. - */ - private splitFunctionProps(body: string): string[] { - const parts = []; - let lastIndex = 0; - let level = 0; - - for (let i = 0; i < body.length; i++) { - const nonCodeEnd = this.skipNonCode(body, i); - if (nonCodeEnd !== -1) { - i = nonCodeEnd; - continue; - } - - const char = body[i]; - switch (char) { - case '(': level++; break; - case ')': level--; break; - case ',': - if (!level) { - parts.push(body.substring(lastIndex, i)); - lastIndex = i + 1; - } - break; - default: - break; - } - } - parts.push(body.substring(lastIndex)); - return parts; - } - private updateTemplateFiles() { if (this.selectorChanges && this.selectorChanges.changes.length) { for (const entryPath of this.templateFiles) { diff --git a/projects/igniteui-angular/migrations/common/sass/changes.spec.ts b/projects/igniteui-angular/migrations/common/sass/changes.spec.ts new file mode 100644 index 00000000000..b1435e5eb1f --- /dev/null +++ b/projects/igniteui-angular/migrations/common/sass/changes.spec.ts @@ -0,0 +1,535 @@ +import { + addArgument, applyEdits, applyEditsWithMap, removeIncludes, removeVariable, renameMember, renameVariable, SassChangeResult, updateArgument, + updateModule +} from './changes'; +import * as sass from 'sass-embedded'; +import { SassStylesheet } from './stylesheet'; + +type Operation = (sheet: SassStylesheet) => SassChangeResult; + +const apply = (operation: Operation, content: string, indented = false): string => + applyEdits(content, operation(new SassStylesheet(content, indented)).edits); + +const manual = (operation: Operation, content: string, indented = false): string[] => + operation(new SassStylesheet(content, indented)).manual.map(change => change.message); + +describe('Sass changes', () => { + /** Every operation, with a statement it changes and what it changes it to. */ + const operations: { name: string; operation: Operation; statement: string; changed: string }[] = [ + { + name: 'renaming a function', + operation: sheet => renameMember(sheet, 'igx-color', 'color'), + statement: '$a: igx-color(red);', + changed: '$a: color(red);' + }, + { + name: 'renaming a mixin', + operation: sheet => renameMember(sheet, 'igx-theme', 'theme'), + statement: '@include igx-theme($palette);', + changed: '@include theme($palette);' + }, + { + name: 'removing an include', + operation: sheet => removeIncludes(sheet, 'card-typography'), + statement: '.a { @include card-typography($t); }', + changed: '.a { }' + }, + { + name: 'renaming an argument', + operation: sheet => updateArgument(sheet, 'button-theme', { name: '$old', replaceWith: '$new' }), + statement: '$t: button-theme($old: red);', + changed: '$t: button-theme($new: red);' + }, + { + name: 'removing an argument', + operation: sheet => updateArgument(sheet, 'button-theme', { name: '$old', remove: true }), + statement: '$t: button-theme($a: 1, $old: red);', + changed: '$t: button-theme($a: 1);' + }, + { + name: 'adding an argument', + operation: sheet => addArgument(sheet, 'palette', { name: '$generator', value: `'legacy'`, kind: 'function' }), + statement: '$p: palette(red, blue, white);', + changed: `$p: palette(red, blue, white, $generator: 'legacy');` + }, + { + name: 'renaming a variable', + operation: sheet => renameVariable(sheet, '$old-palette', '$new-palette'), + statement: '$a: $old-palette;', + changed: '$a: $new-palette;' + }, + { + name: 'moving a module', + operation: sheet => updateModule(sheet, { module: 'igniteui-angular/old', replaceWith: 'igniteui-angular/new' }), + statement: `@use 'igniteui-angular/old' as old;`, + changed: `@use 'igniteui-angular/new' as old;` + } + ]; + + /** The statement with its quotes swapped, so that it fits in a string quoted with `quote`. */ + const quotable = (statement: string, quote: string) => statement.replace(new RegExp(quote, 'g'), quote === '"' ? '\'' : '"'); + + /** Places that mention a statement without Sass evaluating it. */ + const inert: { name: string; wrap: (statement: string) => string }[] = [ + { name: 'a line comment', wrap: s => `// ${s}` }, + { name: 'a line comment after code', wrap: s => `.a { color: red; } // ${s}` }, + { name: 'a block comment', wrap: s => `/* ${s} */` }, + { name: 'a block comment over several lines', wrap: s => `/*\n * ${s}\n */` }, + { name: 'a double-quoted string', wrap: s => `$s: "${quotable(s, '"')}";` }, + { name: 'a single-quoted string', wrap: s => `$s: '${quotable(s, '\'')}';` }, + { name: 'a string after an escaped quote', wrap: s => `$s: "\\" ${quotable(s, '"')}";` }, + { name: 'a string with an interpolation', wrap: s => `$s: "#{1 + 1} ${quotable(s, '"')}";` }, + { name: 'a string in an interpolation', wrap: s => `$s: "#{'${quotable(s, '\'')}'}";` }, + { name: 'a comment in an interpolation', wrap: s => `$s: "#{1 /* ${s} */}";` } + ]; + + /** Non-code that a scanner can end too early or too late, each followed by a statement that it must leave as code. */ + const preceding: { name: string; code: string }[] = [ + { name: 'an unquoted url() with //', code: '.a { background: url(//cdn.example.com/a.png); }' }, + { name: 'an unquoted url() with an escaped bracket', code: '.a { background: url(a\\)b.png); }' }, + { name: 'an unquoted url() padded with spaces', code: '.a { background: url( a.png ); }' }, + { name: 'an unquoted url() with an interpolation', code: '$h: cdn;\n.a { background: url(//#{$h}.example.com/a.png); }' }, + { name: 'a url() of a quoted string with a bracket', code: '.a { background: url("a)b.png"); }' }, + { name: 'a url() of a variable', code: '$i: "a.png";\n.a { background: url($i); }' }, + { name: 'a string with an escaped quote', code: '$s: "a\\"b";' }, + { name: 'a single-quoted string with an escaped quote', code: '$s: \'a\\\'b\';' }, + { name: 'a string with brackets', code: '$s: "url( { [ (";' }, + { name: 'a string with a quoted interpolation', code: '$s: "#{"}"}";' }, + { name: 'a block comment with //', code: '/* // */' }, + { name: 'a line comment with /*', code: '// /*' }, + { name: 'a selector with an escaped bracket', code: '.a\\(b { color: red; }' } + ]; + + // The contexts must be valid Sass, and the inert ones must stay inert, or the matrix tests + // the scanner against inputs no stylesheet contains. + describe('contexts', () => { + const undefinedMixin = '@include no-such-mixin;'; + const compiles = (content: string) => { + try { + sass.compileString(content); + return true; + } catch { + return false; + } + }; + + it('should hide a statement from Sass in every inert context', () => { + expect(compiles(undefinedMixin)).toBe(false); + for (const { name, wrap } of inert) { + expect(compiles(wrap(undefinedMixin)), name).toBe(true); + } + }); + + it('should be valid Sass in every preceding context', () => { + for (const { name, code } of preceding) { + expect(compiles(code), name).toBe(true); + } + }); + }); + + for (const { name, operation, statement, changed } of operations) { + describe(name, () => { + it('should change the statement', () => { + expect(apply(operation, statement)).toEqual(changed); + }); + + for (const context of inert) { + it(`should leave ${context.name} that mentions it`, () => { + const content = context.wrap(statement); + expect(apply(operation, content)).toEqual(content); + }); + } + + for (const context of preceding) { + it(`should change it after ${context.name}`, () => { + expect(apply(operation, `${context.code}\n${statement}`)).toEqual(`${context.code}\n${changed}`); + }); + } + + it('should keep Windows line endings and change every statement', () => { + const content = `// first\r\n${statement}\r\n.b { color: red; }\r\n`; + expect(apply(operation, content)).toEqual(`// first\r\n${changed}\r\n.b { color: red; }\r\n`); + }); + + it('should not touch code after an unbalanced bracket', () => { + const content = `${statement}\n$broken: fn(1, 2;\n${statement}`; + expect(apply(operation, content).startsWith(changed)).toBe(true); + }); + }); + } + + describe('renameMember', () => { + const rename = (content: string, indented = false) => apply(sheet => renameMember(sheet, 'igx-color', 'color'), content, indented); + + it('should rename only the whole name', () => { + const content = '$a: my-igx-color(red); $b: igx-color-x(red); $c: discard(red); $d: $igx-color;'; + expect(rename(content)).toEqual(content); + }); + + it('should rename a call through any namespace', () => { + expect(rename('$a: igx.igx-color(red); $b: my-theme.igx-color(red);')) + .toEqual('$a: igx.color(red); $b: my-theme.color(red);'); + }); + + it('should rename calls nested in each other', () => { + expect(rename('$a: igx-color(igx-color(red), igx.igx-color(blue));')) + .toEqual('$a: color(color(red), igx.color(blue));'); + }); + + it('should treat underscores and hyphens in the name as the same', () => { + expect(rename('$a: igx_color(red);')).toEqual('$a: color(red);'); + }); + + it('should leave a member the stylesheet declares itself', () => { + const content = '@function igx-color($c) { @return $c; }\n$a: igx-color(red);'; + expect(rename(content)).toEqual(content); + }); + + it('should rename an include without arguments and the indented shorthand', () => { + const renameMixin = (content: string, indented = false) => apply(sheet => renameMember(sheet, 'igx-theme', 'theme'), content, indented); + expect(renameMixin('.a { @include igx-theme; }')).toEqual('.a { @include theme; }'); + expect(renameMixin('.a\n +igx-theme($p)\n +igx.igx-theme', true)).toEqual('.a\n +theme($p)\n +igx.theme'); + }); + + it('should rename a mixin labelled as a function, as some older changes do', () => { + expect(rename('.a { @include igx-color(red); }')).toEqual('.a { @include color(red); }'); + }); + }); + + describe('removeIncludes', () => { + const remove = (content: string, indented = false) => apply(sheet => removeIncludes(sheet, 'card-typography'), content, indented); + + it('should remove the whole line of an include alone on it', () => { + expect(remove('.a {\n @include card-typography(\n $categories: (title: \'h5\')\n );\n color: red;\n}')) + .toEqual('.a {\n color: red;\n}'); + }); + + it('should remove an include without arguments, through a namespace and in the indented syntax', () => { + expect(remove('.a {\n @include card-typography;\n @include igx.card-typography();\n}')).toEqual('.a {\n}'); + expect(remove('.a\n +card-typography($t)\n color: red', true)).toEqual('.a\n color: red'); + }); + + it('should keep the bounds of an include whose arguments hold brackets in strings', () => { + expect(remove('.a { @include card-typography($label: \')\'); color: red; }')).toEqual('.a { color: red; }'); + }); + + it('should leave a function call and other mixins with similar names', () => { + const content = '.a { @include my-card-typography; @include card-typography-x; $v: card-typography(1); }'; + expect(remove(content)).toEqual(content); + }); + + it('should not take the braces of an interpolation for a content block', () => { + expect(remove('.a {\n @include card-typography($size: #{$x}px);\n}')).toEqual('.a {\n}'); + }); + + it('should leave an include that passes a content block for manual review', () => { + const content = '.a { @include card-typography { color: red; } }'; + expect(remove(content)).toEqual(content); + expect(manual(sheet => removeIncludes(sheet, 'card-typography'), content).length).toBe(1); + const indented = '.a\n +card-typography\n color: red'; + expect(remove(indented, true)).toEqual(indented); + }); + }); + + describe('updateArgument', () => { + const update = (content: string, change: { name: string; replaceWith?: string; remove?: boolean }) => + apply(sheet => updateArgument(sheet, 'button-theme', change), content); + + it('should match owners ending with the name, as theme changes need', () => { + expect(update('$t: flat-button-theme($old: 1);', { name: '$old', replaceWith: '$new' })).toEqual('$t: flat-button-theme($new: 1);'); + }); + + it('should match only the exact owner when asked to', () => { + const content = '$a: color-palette($old: 1); $b: palette($old: 1);'; + expect(apply(sheet => updateArgument(sheet, 'palette', { name: '$old', replaceWith: '$new' }, 'exact'), content)) + .toEqual('$a: color-palette($old: 1); $b: palette($new: 1);'); + }); + + it('should not split an argument at the commas of a list or a map', () => { + expect(update('$t: button-theme($shadow: [1px, 2px], $map: (a: 1, b: 2), $old: 1, $x: 2);', { name: '$old', remove: true })) + .toEqual('$t: button-theme($shadow: [1px, 2px], $map: (a: 1, b: 2), $x: 2);'); + }); + + it('should treat underscores and hyphens in the argument name as the same', () => { + expect(update('$t: button-theme($old_name: 1);', { name: '$old-name', replaceWith: '$new' })).toEqual('$t: button-theme($new: 1);'); + }); + + it('should not split an argument at the commas of an interpolation', () => { + expect(update('$t: button-theme($x: 1, $old: #{$a, $b}, $y: 2);', { name: '$old', remove: true })) + .toEqual('$t: button-theme($x: 1, $y: 2);'); + expect(update('$t: button-theme($old: #{$a, $b});', { name: '$old', replaceWith: '$new' })) + .toEqual('$t: button-theme($new: #{$a, $b});'); + }); + + it('should leave a removal from a call passing the argument by position for manual review', () => { + const change = { name: '$old', remove: true, parameterIndex: 2 }; + const remove = (sheet: SassStylesheet) => updateArgument(sheet, 'button-theme', change); + const content = '$a: button-theme(red, blue);\n$b: button-theme(red, $args...);\n$c: button-theme(red, $old: blue);\n$d: button-theme(red);'; + expect(apply(remove, content)).toEqual('$a: button-theme(red, blue);\n$b: button-theme(red, $args...);\n$c: button-theme(red);\n$d: button-theme(red);'); + expect(manual(remove, content).length).toBe(2); + }); + + it('should keep an argument passed by position through a rename', () => { + const content = '$a: button-theme(red, blue);'; + const rename = (sheet: SassStylesheet) => updateArgument(sheet, 'button-theme', { name: '$old', replaceWith: '$new', parameterIndex: 2 }); + expect(apply(rename, content)).toEqual(content); + expect(manual(rename, content)).toEqual([]); + }); + }); + + describe('addArgument', () => { + const generator = { name: '$generator', value: `'legacy'`, kind: 'function' as const, parameterIndex: 10 }; + const add = (content: string, indented = false) => apply(sheet => addArgument(sheet, 'palette', generator), content, indented); + + it('should add the argument to a call with a single argument', () => { + expect(add('\n\n$p: palette($primary: red);')).toEqual(`\n\n$p: palette($primary: red, $generator: 'legacy');`); + }); + + it('should add the argument on its own line to a call with one argument per line', () => { + expect(add( + `$p: palette( + $primary: red, + $surface: white +);` + )).toEqual( + `$p: palette( + $primary: red, + $surface: white, + $generator: 'legacy' +);` + ); + }); + + it('should keep a trailing comma', () => { + expect(add( + `$p: palette( + $primary: red, + $surface: white, +);` + )).toEqual( + `$p: palette( + $primary: red, + $surface: white, + $generator: 'legacy', +);` + ); + }); + + it('should keep a comment closing the last line', () => { + expect(add( + `$p: palette( + $primary: red, + $surface: white // (the page) +);` + )).toEqual( + `$p: palette( + $primary: red, + $surface: white, // (the page) + $generator: 'legacy' +);` + ); + }); + + it('should add the line after a block comment that runs over several lines', () => { + expect(add( + `$p: palette( + $primary: red, + $surface: white /* the page + and cards */ +);` + )).toEqual( + `$p: palette( + $primary: red, + $surface: white, /* the page + and cards */ + $generator: 'legacy' +);` + ); + }); + + it('should add the line after a block comment that runs over several lines, after a trailing comma', () => { + expect(add( + `$p: palette( + $primary: red, + $surface: white, /* the page + and cards */ +);` + )).toEqual( + `$p: palette( + $primary: red, + $surface: white, /* the page + and cards */ + $generator: 'legacy', +);` + ); + }); + + it('should add the argument to an empty call', () => { + expect(add('$p: palette();')).toEqual(`$p: palette($generator: 'legacy');`); + }); + + it('should leave a call that passes the argument by name or by position', () => { + const content = `$a: palette(red, blue, white, $generator : 'fitted');\n$b: palette(red, blue, white, null, red, red, red, red, null, 'fitted');`; + expect(add(content)).toEqual(content); + }); + + it('should leave the mixin of the same name and the members with similar names', () => { + const content = '@include palette($p);\n$a: color-palette(red);\n$b: palette-x(red);\n$c: map.get($palette, primary);'; + expect(add(content)).toEqual(content); + }); + + it('should add the argument to a call nested in another call of the owner', () => { + expect(add('$p: palette($primary: color(palette(red, blue, white), primary), $secondary: blue, $surface: white);')) + .toEqual(`$p: palette($primary: color(palette(red, blue, white, $generator: 'legacy'), primary), $secondary: blue, $surface: white, $generator: 'legacy');`); + }); + + it('should add the argument to calls nested in the include of the mixin', () => { + expect(add('@include palette(palette(red, blue, white));')) + .toEqual(`@include palette(palette(red, blue, white, $generator: 'legacy'));`); + }); + + it('should give an include without arguments the argument, unless only functions are asked for', () => { + const addToMixin = (kind?: 'mixin' | 'function') => (sheet: SassStylesheet) => + addArgument(sheet, 'theme', { name: '$palette', value: '$p', kind }); + expect(apply(addToMixin('mixin'), '.a { @include theme; @include igx.theme; }')) + .toEqual('.a { @include theme($palette: $p); @include igx.theme($palette: $p); }'); + expect(apply(addToMixin(), '.a\n +theme', true)).toEqual('.a\n +theme($palette: $p)'); + expect(apply(addToMixin('function'), '.a { @include theme; }')).toEqual('.a { @include theme; }'); + }); + + it('should leave a call that spreads its arguments for manual review', () => { + const content = '$p: palette($args...);'; + expect(add(content)).toEqual(content); + expect(manual(sheet => addArgument(sheet, 'palette', generator), content).length).toBe(1); + }); + + it('should add the argument in the indented syntax, and leave the mixin shorthand', () => { + expect(add('$p: palette(red, blue, white)\n:root\n +palette($p)', true)) + .toEqual(`$p: palette(red, blue, white, $generator: 'legacy')\n:root\n +palette($p)`); + }); + }); + + describe('renameVariable', () => { + const rename = (content: string) => apply(sheet => renameVariable(sheet, '$old-palette', '$new-palette'), content); + + it('should rename declarations, reads and namespaced reads', () => { + expect(rename('$old-palette: red;\n$a: igx.$old-palette;\n$b: fn($old-palette, $x: $old-palette);')) + .toEqual('$new-palette: red;\n$a: igx.$new-palette;\n$b: fn($new-palette, $x: $new-palette);'); + }); + + it('should rename the variable in an interpolation but not in literal text', () => { + expect(rename(`$a: '#{$old-palette} $old-palette';`)).toEqual(`$a: '#{$new-palette} $old-palette';`); + }); + + it('should rename the variable in an interpolation that quotes a string', () => { + expect(rename('$a: "#{map.get($old-palette, "key")} $old-palette";')) + .toEqual('$a: "#{map.get($new-palette, "key")} $old-palette";'); + }); + + it('should rename the variable in a url(), which Sass then reads as a function call', () => { + expect(rename('.a { background: url($old-palette); }')).toEqual('.a { background: url($new-palette); }'); + }); + + it('should rename the variable in an interpolation in an unquoted url()', () => { + expect(rename('.a { background: url(//#{$old-palette}/a.png); }')) + .toEqual('.a { background: url(//#{$new-palette}/a.png); }'); + }); + + it('should leave keyword arguments, map keys and longer names', () => { + const content = '$a: fn($old-palette: 1);\n$m: ($old-palette: 1);\n$b: $old-palette-2;'; + expect(rename(content)).toEqual(content); + }); + + it('should treat underscores and hyphens in the name as the same', () => { + expect(rename('$a: $old_palette;')).toEqual('$a: $new-palette;'); + }); + }); + + describe('removeVariable', () => { + const remove = (content: string, indented = false) => apply(sheet => removeVariable(sheet, '$browser-context'), content, indented); + const reported = (content: string) => manual(sheet => removeVariable(sheet, '$browser-context'), content); + + it('should remove declarations with their line, including multi-line and namespaced ones', () => { + expect(remove('$a: 1;\n$browser-context: 16px !default;\n$map: (\n a: 1\n);\n')).toEqual('$a: 1;\n$map: (\n a: 1\n);\n'); + expect(remove('$browser_context: (\n a: 1,\n b: 2\n);\n.a { config.$browser-context: 2; color: red; }')) + .toEqual('.a { color: red; }'); + expect(remove('$browser-context: 16px\n.a\n color: red', true)).toEqual('.a\n color: red'); + }); + + it('should remove a declaration after another one in the indented syntax', () => { + expect(remove('$other: 1\n$browser-context: 16px\n.a\n $browser-context: 2\n color: red', true)) + .toEqual('$other: 1\n.a\n color: red'); + expect(manual(sheet => removeVariable(sheet, '$browser-context'), '$other: 1\n$browser-context: 16px', true)).toEqual([]); + }); + + it('should remove a declaration whose value holds an interpolation', () => { + expect(remove('$browser-context: #{$size};\n$a: 1;')).toEqual('$a: 1;'); + expect(remove('$browser-context: #{$size}px\n$a: 1', true)).toEqual('$a: 1'); + }); + + it('should leave other references for manual review and never write a replacement', () => { + const content = `.a { width: rem(10px, $browser-context); content: '#{$browser-context}'; }`; + expect(remove(content)).toEqual(content); + expect(reported(content).length).toBe(2); + }); + + it('should leave keyword arguments, map keys and longer names alone', () => { + const content = '$a: fn($browser-context: 1);\n$m: ($browser-context: 1);\n$b: $browser-context-2;'; + expect(remove(content)).toEqual(content); + expect(reported(content)).toEqual([]); + }); + }); + + describe('updateModule', () => { + const remove = { module: 'igniteui-angular/old', remove: true, member: 'styles' }; + + it('should move every @use and @forward of the module, and only that module', () => { + expect(apply(sheet => updateModule(sheet, { module: 'igniteui-angular/old', replaceWith: 'igniteui-angular/new' }), + `@use "igniteui-angular/old" as o;\n@forward 'igniteui-angular/old';\n@use 'igniteui-angular/old-thing';`)) + .toEqual(`@use "igniteui-angular/new" as o;\n@forward 'igniteui-angular/new';\n@use 'igniteui-angular/old-thing';`); + }); + + it('should remove every @use of the module with the includes of its member', () => { + expect(apply(sheet => updateModule(sheet, remove), + `@use 'igniteui-angular/old' as a;\n@use 'igniteui-angular/old' with ($x: 1);\n.l {\n @include a.styles;\n @include old.styles();\n}`)) + .toEqual('.l {\n}'); + }); + + it('should leave other references through the namespace for manual review', () => { + const content = `@use 'igniteui-angular/old' as o;\n@include o.styles;\n$c: o.color();`; + expect(apply(sheet => updateModule(sheet, remove), content)).toEqual('$c: o.color();'); + expect(manual(sheet => updateModule(sheet, remove), content).length).toBe(1); + }); + + it('should remove the bare includes of a module used without a namespace', () => { + expect(apply(sheet => updateModule(sheet, remove), + `@use 'igniteui-angular/old' as *;\n.l {\n @include styles;\n @include other.styles;\n}`)) + .toEqual('.l {\n @include other.styles;\n}'); + const declared = `@use 'igniteui-angular/old' as *;\n@mixin styles() { color: red; }\n.l { @include styles; }`; + expect(apply(sheet => updateModule(sheet, remove), declared)).toEqual('@mixin styles() { color: red; }\n.l { @include styles; }'); + }); + + it('should remove a module in the indented syntax', () => { + expect(apply(sheet => updateModule(sheet, remove), `@use 'igniteui-angular/old' as o\n.a\n +o.styles\n color: red`, true)) + .toEqual('.a\n color: red'); + }); + }); + + describe('applyEditsWithMap', () => { + it('should map offsets past, inside and before the applied edits', () => { + const { content, map } = applyEditsWithMap('0123456789', [{ start: 2, end: 4, text: '' }, { start: 6, end: 6, text: 'ab' }]); + expect(content).toEqual('0145ab6789'); + expect([map(1), map(3), map(5), map(8)]).toEqual([1, 2, 3, 8]); + }); + }); + + describe('applyEdits', () => { + it('should put the edit collected first in front of another at the same offset', () => { + expect(applyEdits('ab', [{ start: 1, end: 1, text: '1' }, { start: 1, end: 1, text: '2' }])).toEqual('a12b'); + }); + + it('should drop an edit that overlaps one already applied', () => { + expect(applyEdits('abcdef', [{ start: 1, end: 4, text: 'X' }, { start: 3, end: 5, text: 'Y' }])).toEqual('abcYf'); + }); + }); +}); diff --git a/projects/igniteui-angular/migrations/common/sass/changes.ts b/projects/igniteui-angular/migrations/common/sass/changes.ts new file mode 100644 index 00000000000..eb98146811a --- /dev/null +++ b/projects/igniteui-angular/migrations/common/sass/changes.ts @@ -0,0 +1,490 @@ +/** + * The changes `ng update` makes to Sass stylesheets. Each one reads a stylesheet as text, through + * the scanner, and changes only code: strings, comments and unquoted URLs stay as written. + * + * Known limits. The changes work on one stylesheet at a time and match names as text, so they: + * - don't resolve modules: a call through any namespace matches, such as `mine.igx-color()` for + * `igx-color`, and a variable renamed globally is renamed where a local one shadows it too; + * - don't find dynamic references, such as `meta.get-function('igx-color')` or a name built + * with interpolation; + * - don't match a name written with escapes, such as `igx\-color()`; + * - update the arguments of the outermost call only (`updateArgument`), when calls to the same + * member are nested in each other; adding an argument and renaming reach the nested calls too. + * Calls whose arguments they can't change, such as `palette($args...)`, they report for manual + * review instead. + * + * Each of these would take resolving the stylesheet as Sass does, which a text scanner doesn't. + * Library stylesheets and themes don't write code like this, so the changes leave it as it is. + */ +import { + argumentName, commentBreak, leadingCommentsEnd, lineEnding, maskComments, maskNonCode, normalizeName, skipNonCode, splitArguments +} from './scanner'; +import { MemberKind, NameMatch, SassInclude, SassMemberReference, SassStylesheet } from './stylesheet'; +import { escapeRegExp } from '../util'; + +/** A replacement of `content.substring(start, end)` with `text`. */ +export interface SassEdit { + start: number; + end: number; + text: string; +} + +/** Something a change could not do on its own, for the person running the migration to review. */ +export interface SassManualChange { + /** Offset of the code to review. */ + index: number; + message: string; +} + +/** The edits a change makes to one stylesheet, and what it left for manual review. */ +export interface SassChangeResult { + edits: SassEdit[]; + manual: SassManualChange[]; +} + +/** + * The edits to apply, back to front, so the offsets they were collected at stay valid. Of two + * edits at one offset, the one collected first ends up in front. An edit reaching into one + * applied after it would corrupt it, so it is dropped. + */ +const editsToApply = (content: string, edits: SassEdit[]): SassEdit[] => { + const applied: SassEdit[] = []; + let limit = content.length; + + for (const edit of edits.map((edit, order) => ({ ...edit, order })) + .sort((a, b) => b.start - a.start || b.order - a.order)) { + if (edit.end <= limit) { + applied.push(edit); + limit = edit.start; + } + } + + return applied; +}; + +/** Applies edits to the content they were collected on. */ +export const applyEdits = (content: string, edits: SassEdit[]): string => + editsToApply(content, edits).reduce((result, { start, end, text }) => result.substring(0, start) + text + result.substring(end), content); + +/** + * Applies edits and returns the result with a function that maps an offset in the content to + * the matching offset in the result. An offset inside a replaced range maps to its start. + */ +export const applyEditsWithMap = (content: string, edits: SassEdit[]): { content: string; map: (offset: number) => number } => { + const applied = editsToApply(content, edits); + const result = applied.reduce((text, { start, end, text: replacement }) => text.substring(0, start) + replacement + text.substring(end), content); + const map = (offset: number) => applied.reduce((mapped, { start, end, text }) => { + if (end <= offset) { + return mapped + text.length - (end - start); + } + return start < offset ? mapped - (offset - start) : mapped; + }, offset); + + return { content: result, map }; +}; + +/** The range removing `start`-`end` takes: the whole line when nothing else is on it. */ +export const removalRange = (content: string, start: number, end: number): { start: number; end: number } => { + let lineStart = start; + while (lineStart > 0 && (content[lineStart - 1] === ' ' || content[lineStart - 1] === '\t')) { + lineStart--; + } + let lineEnd = end; + while (lineEnd < content.length && (content[lineEnd] === ' ' || content[lineEnd] === '\t')) { + lineEnd++; + } + + const atLineStart = lineStart === 0 || content[lineStart - 1] === '\n'; + const atLineEnd = lineEnd >= content.length || content[lineEnd] === '\n' || content[lineEnd] === '\r'; + if (!atLineStart || !atLineEnd) { + return { start, end }; + } + + if (content[lineEnd] === '\r' && content[lineEnd + 1] === '\n') { + lineEnd += 2; + } else if (content[lineEnd] === '\n' || content[lineEnd] === '\r') { + lineEnd += 1; + } + return { start: lineStart, end: lineEnd }; +}; + +/** References to a member, through calls and includes, each once and in source order. */ +const memberReferences = (sheet: SassStylesheet, name: string): SassMemberReference[] => { + const references = new Map(); + for (const reference of [...sheet.findCalls(name, { match: 'exact', nested: true }), ...sheet.findIncludes(name, 'exact')]) { + references.set(reference.nameStart, reference); + } + return [...references.values()].sort((a, b) => a.nameStart - b.nameStart); +}; + +/** + * Renames a function or a mixin wherever the stylesheet references it, bare or through any + * namespace, since an app can forward Ignite UI through a module of its own. Members the + * stylesheet declares itself, and other members whose names merely contain this one, are left. + */ +export const renameMember = (sheet: SassStylesheet, name: string, replaceWith: string): SassChangeResult => ({ + edits: memberReferences(sheet, name).map(({ nameStart, nameEnd }) => ({ start: nameStart, end: nameEnd, text: replaceWith })), + manual: [] +}); + +/** + * Removes the include statements of a mixin, together with their line when they are alone on it. + * With `namespace`, only the includes through it are removed; `null` removes only bare includes. + */ +export const removeIncludes = (sheet: SassStylesheet, name: string, namespace?: string | null): SassChangeResult => { + const result: SassChangeResult = { edits: [], manual: [] }; + const includes = sheet.findIncludes(name, 'exact') + .filter(include => namespace === undefined || include.namespace === (namespace ?? undefined)); + + for (const include of includes) { + if (include.hasContentBlock) { + result.manual.push({ index: include.start, message: `Remove the include of the mixin ${name}, which passes a content block.` }); + continue; + } + const range = removalRange(sheet.content, include.start, include.end); + result.edits.push({ ...range, text: '' }); + } + + return result; +}; + +/** A change to the arguments a function or mixin call passes. */ +export interface SassArgumentChange { + /** The argument, such as `$background`. */ + name: string; + replaceWith?: string; + remove?: boolean; + /** + * The argument's 1-based index in the owner's parameter list. It only detects calls that pass + * the argument by position: removing it from one of those would shift the arguments after it, + * so such a call is left for manual review. + */ + parameterIndex?: number; +} + +/** + * Renames or removes a keyword argument in every call of `owner`. Formatting and comments are + * kept: a comment in front of a removed argument usually describes the previous one, so it stays. + * Renaming onto a name the call already passes would make it a duplicate, so the argument is + * removed instead. A call passing the argument by position keeps it through a rename, and is left + * for manual review by a removal. + */ +export const updateArgument = (sheet: SassStylesheet, owner: string, change: SassArgumentChange, match: NameMatch = 'suffix', + kind?: MemberKind): SassChangeResult => { + const result: SassChangeResult = { edits: [], manual: [] }; + const content = sheet.content; + const eol = lineEnding(content); + const name = normalizeName(change.name); + const replaceWith = change.replaceWith ? normalizeName(change.replaceWith) : undefined; + + for (const call of sheet.findCalls(owner, { match, kind })) { + const rawBody = content.substring(call.bodyStart, call.bodyEnd); + // keep whatever sits in front of the closing bracket so the formatting is preserved + const trailing = /\s*$/.exec(rawBody).pop(); + const body = rawBody.substring(0, rawBody.length - trailing.length); + + const params = splitArguments(body); + const codeStarts = params.map(param => leadingCommentsEnd(param)); + const names = params.map((param, i) => { + const passed = argumentName(param.substring(codeStarts[i])); + return passed ? normalizeName(passed) : null; + }); + if (change.remove && change.parameterIndex) { + const positional = params.filter((param, i) => !names[i] && param.substring(codeStarts[i]).trim()); + const spreads = positional.some(param => param.trimEnd().endsWith('...')); + if (spreads || positional.length >= change.parameterIndex) { + result.manual.push({ + index: call.nameStart, + message: `Remove the ${change.name} argument this call of ${owner}() passes ` + + `${spreads ? 'in the arguments it spreads' : 'by position'}; the arguments after it move up one parameter.` + }); + } + } + if (!names.includes(name)) { + continue; + } + const duplicate = !!replaceWith && names.includes(replaceWith); + + let carried = ''; + const kept = params.reduce((arr, param, i) => { + const codeStart = codeStarts[i]; + if (names[i] === name) { + if (!change.remove && !duplicate) { + // the name sits at the code start, so renaming must not search the comments + arr.push(carried + commentBreak(carried, param, eol) + param.substring(0, codeStart) + + change.replaceWith + param.substring(codeStart + change.name.length)); + carried = ''; + } else { + carried += param.substring(0, codeStart).trimEnd(); + } + } else { + arr.push(carried + commentBreak(carried, param, eol) + param); + carried = ''; + } + return arr; + }, []); + + result.edits.push({ + start: call.bodyStart, + end: call.bodyEnd, + text: kept.join(',') + carried + commentBreak(carried, trailing, eol) + trailing + }); + } + + return result; +}; + +/** An argument to add to calls of a function or mixin. */ +export interface SassArgumentAddition { + /** The argument, such as `$generator`. */ + name: string; + /** The value to pass, as Sass source, such as `'legacy'`. */ + value: string; + /** Adds the argument only where the owner is called as a function or included as a mixin. */ + kind?: MemberKind; + /** + * The argument's 1-based index in the owner's parameter list. It only detects calls that pass + * the argument by position already, so they are left alone; the argument is always added by + * name after the last one. + */ + parameterIndex?: number; +} + +/** + * Adds a keyword argument to every call of `owner` that does not pass it already. A call with + * its arguments on one line gets it after the last one; a call with one argument per line gets + * it on a line of its own, in the same indentation. Trailing commas and a comment closing the + * last argument's line are kept. A call that spreads its arguments cannot take another one after + * them, so it is left for manual review. + */ +export const addArgument = (sheet: SassStylesheet, owner: string, addition: SassArgumentAddition, match: NameMatch = 'exact'): SassChangeResult => { + const result: SassChangeResult = { edits: [], manual: [] }; + const content = sheet.content; + // strings stay, since a string can be an argument, and comments are masked, since they are not + const code = maskComments(content); + const eol = lineEnding(content); + const argument = `${addition.name}: ${addition.value}`; + + // the edits are insertions, so a call nested in another one of the owner gets its own + for (const call of sheet.findCalls(owner, { match, kind: addition.kind, nested: true })) { + const { bodyStart, bodyEnd } = call; + const params = splitArguments(code.substring(bodyStart, bodyEnd)); + const separators: number[] = []; + params.slice(0, -1).reduce((offset, param) => { + separators.push(offset + param.length); + return offset + param.length + 1; + }, bodyStart); + + // a trailing comma leaves an empty last argument; the one before it is the real last one + const trailingComma = params.length > 1 && !params[params.length - 1].trim(); + const passed = trailingComma ? params.slice(0, -1) : params; + if (!passed.join('').trim()) { + result.edits.push({ start: bodyStart, end: bodyEnd, text: argument }); + continue; + } + + const names = passed.map(param => argumentName(param.trim())); + if (names.some(passedName => passedName && normalizeName(passedName) === normalizeName(addition.name))) { + continue; + } + if (passed[passed.length - 1].trimEnd().endsWith('...')) { + result.manual.push({ + index: call.nameStart, + message: `Add ${argument} to the arguments this call of ${owner}() spreads.` + }); + continue; + } + if (addition.parameterIndex && names.filter(passedName => !passedName).length >= addition.parameterIndex) { + continue; + } + + const last = separators[separators.length - 1]; + const argStart = trailingComma + ? (separators[separators.length - 2] ?? bodyStart - 1) + 1 + : (last === undefined ? bodyStart : last + 1); + const lastArg = code.substring(argStart, trailingComma ? last : bodyEnd); + const codeEnd = argStart + lastArg.trimEnd().length; + const leading = /^\s*/.exec(lastArg)[0]; + + if (!leading.includes('\n')) { + // arguments on one line: append after the last one + result.edits.push({ start: codeEnd, end: codeEnd, text: `, ${argument}` }); + continue; + } + + // one argument per line: add a line in the same indentation, after any comment that + // closes the last argument's line so the comma does not end up inside that comment + const line = `${eol}${leading.substring(leading.lastIndexOf('\n') + 1)}${argument}${trailingComma ? ',' : ''}`; + const anchor = trailingComma ? last + 1 : codeEnd; + // a block comment after the last argument can run over several lines; the line ends after it + let commentEnd = anchor; + for (let i = anchor; i < bodyEnd && content[i] !== '\n'; i++) { + const nonCodeEnd = skipNonCode(content, i); + if (nonCodeEnd !== -1) { + commentEnd = i = nonCodeEnd; + } + } + const lineEnd = content.indexOf('\n', commentEnd); + const comma = trailingComma ? '' : ','; + + if (lineEnd === -1 || lineEnd > bodyEnd) { + // the closing bracket shares the last argument's line + result.edits.push({ start: anchor, end: anchor, text: comma + line }); + } else { + if (comma) { + result.edits.push({ start: codeEnd, end: codeEnd, text: comma }); + } + const at = lineEnd - (content[lineEnd - 1] === '\r' ? 1 : 0); + result.edits.push({ start: at, end: at, text: line }); + } + } + + if (addition.kind !== 'function') { + // an include without arguments gets them + for (const include of sheet.findIncludes(owner, match)) { + if (include.bodyStart === undefined) { + result.edits.push({ start: include.nameEnd, end: include.nameEnd, text: `(${argument})` }); + } + } + } + + return result; +}; + +/** + * Renames a variable wherever the stylesheet reads or declares it, including interpolations in + * strings and references through a namespace. A name followed by a colon inside brackets is a + * keyword argument, a map key or a parameter, none of which is the variable, so it is left. + */ +export const renameVariable = (sheet: SassStylesheet, name: string, replaceWith: string): SassChangeResult => { + const result: SassChangeResult = { edits: [], manual: [] }; + const code = maskNonCode(sheet.content, true); + const target = normalizeName(name); + let depth = 0; + let scanned = 0; + + for (const match of code.matchAll(/\$[\w-]+/g)) { + for (; scanned < match.index; scanned++) { + if (code[scanned] === '(' || code[scanned] === '[') { + depth++; + } else if (code[scanned] === ')' || code[scanned] === ']') { + depth--; + } + } + if (normalizeName(match[0]) !== target || /[\w$-]/.test(code[match.index - 1] ?? '')) { + continue; + } + const end = match.index + match[0].length; + if (depth > 0 && /^\s*:/.test(code.substring(end))) { + continue; + } + result.edits.push({ start: match.index, end, text: replaceWith }); + } + + return result; +}; + +/** + * Removes a variable the library no longer has. A declaration of it overrode the library's + * value, so the statement goes, together with its line when it is alone on it. Any other + * reference has no automatic replacement, so it is left for manual review. + */ +export const removeVariable = (sheet: SassStylesheet, name: string): SassChangeResult => { + const result: SassChangeResult = { edits: [], manual: [] }; + const target = normalizeName(name); + const code = maskNonCode(sheet.content, true); + let depth = 0; + let scanned = 0; + + for (const match of code.matchAll(/(?:[\w-]+\.)?\$[\w-]+/g)) { + for (; scanned < match.index; scanned++) { + if (code[scanned] === '(' || code[scanned] === '[') { + depth++; + } else if (code[scanned] === ')' || code[scanned] === ']') { + depth--; + } + } + const variable = match[0].substring(match[0].indexOf('$')); + if (normalizeName(variable) !== target || /[\w$.-]/.test(code[match.index - 1] ?? '')) { + continue; + } + + const end = match.index + match[0].length; + // a declaration starts a statement: after a delimiter, or at the start of a line in the + // indented syntax, which has no delimiters + const statementStart = sheet.indented ? /(^|\n)[ \t]*$/ : /(^|[;{}])\s*$/; + const declaration = !depth && /^\s*:/.test(code.substring(end)) && statementStart.test(code.substring(0, match.index)); + if (declaration) { + const statement = sheet.statementEnd(end, match.index); + result.edits.push({ ...removalRange(sheet.content, match.index, statement.end), text: '' }); + } else if (!(depth && /^\s*:/.test(code.substring(end)))) { + // a keyword argument or a map key of the same name is not the variable + result.manual.push({ index: match.index, message: `Replace this reference to the removed variable ${name}.` }); + } + } + + return result; +}; + +/** A change to the URL of a module the stylesheet loads with `@use` or `@forward`. */ +export interface SassModuleChange { + module: string; + replaceWith?: string; + remove?: boolean; + /** With `remove`, a mixin of the module whose includes go with it. */ + member?: string; +} + +/** + * Moves or removes a module. A move rewrites the URL of every rule loading it. A removal drops + * those rules, and with `member`, the includes of that mixin through each namespace the module + * was used through; any other reference through such a namespace is left for manual review. + */ +export const updateModule = (sheet: SassStylesheet, change: SassModuleChange): SassChangeResult => { + const result: SassChangeResult = { edits: [], manual: [] }; + const rules = sheet.findModuleRules().filter(rule => rule.url === change.module); + + if (change.replaceWith) { + result.edits.push(...rules.map(rule => ({ start: rule.urlStart, end: rule.urlEnd, text: change.replaceWith }))); + return result; + } + if (!change.remove) { + return result; + } + + for (const rule of rules) { + result.edits.push({ ...removalRange(sheet.content, rule.start, rule.end), text: '' }); + if (rule.keyword !== '@use') { + continue; + } + if (rule.namespace === '*') { + // a module used without a namespace gave its members bare names + if (change.member) { + const includes = removeIncludes(sheet, change.member, null); + result.edits.push(...includes.edits); + result.manual.push(...includes.manual); + } + continue; + } + + let removed: SassInclude[] = []; + if (change.member) { + const includes = removeIncludes(sheet, change.member, rule.namespace); + result.edits.push(...includes.edits); + result.manual.push(...includes.manual); + removed = sheet.findIncludes(change.member, 'exact').filter(include => include.namespace === rule.namespace); + } + + const reference = new RegExp(`(? include.start <= match.index && match.index < include.end)) { + result.manual.push({ + index: match.index, + message: `Replace this reference to the removed module ${change.module}.` + }); + } + } + } + + return result; +}; diff --git a/projects/igniteui-angular/migrations/common/sass/scanner.ts b/projects/igniteui-angular/migrations/common/sass/scanner.ts new file mode 100644 index 00000000000..4957cbd37e0 --- /dev/null +++ b/projects/igniteui-angular/migrations/common/sass/scanner.ts @@ -0,0 +1,295 @@ +/** + * Low-level scanning of Sass source. Every function here works on the original text and keeps + * offsets intact, so its results can address the stylesheet directly. + * + * The scanner tells code from non-code the way Sass does: strings, with the interpolations in + * them as code; `//` and `/* *\/` comments; and unquoted URLs, with their escapes and the + * interpolations in them as code. A `url()` that holds anything else, such as `url($icon)`, is a + * function call and so code. The context matrix in changes.spec.ts checks each case against the + * Sass compiler. + */ + +const URL_TOKEN = 'url('; + +/** The line ending a stylesheet uses, so that inserted lines match the rest of it. */ +export const lineEnding = (content: string): string => content.includes('\r\n') ? '\r\n' : '\n'; + +/** + * Returns the index of the quote closing the string opened at `start`. A quote inside an + * interpolation, such as `"#{map.get($m, "key")}"`, belongs to the interpolation. + */ +export const skipString = (content: string, start: number): number => { + const quote = content[start]; + + for (let i = start + 1; i < content.length; i++) { + if (content[i] === '\\') { + i++; + } else if (content[i] === '#' && content[i + 1] === '{') { + i = skipInterpolation(content, i + 2); + } else if (content[i] === quote) { + return i; + } + } + + return content.length; +}; + +const isUrlStart = (content: string, index: number): boolean => { + if (content.substring(index, index + URL_TOKEN.length).toLowerCase() !== URL_TOKEN) { + return false; + } + + const previous = content[index - 1]; + + return previous === undefined || !/[\w-]/.test(previous); +}; + +/** The characters Sass allows in an unquoted URL, besides escapes and interpolations. */ +const isUrlChar = (char: string): boolean => { + const code = char.charCodeAt(0); + + return char === '!' || char === '%' || char === '&' || char === '#' || (code >= 0x2a && code <= 0x7e) || code >= 0x80; +}; + +/** + * Returns the index of the bracket closing an unquoted URL whose contents start at `start`, or -1 + * when the contents are not one. Sass reads `url()` as an unquoted URL only when it holds URL + * characters, escapes and interpolations: `url(//cdn.example.com/bg.png)` is one, so its `//` does + * not start a comment, while `url($icon)` and `url("bg.png")` are function calls. + */ +const skipUrl = (content: string, start: number): number => { + let i = start; + while (/\s/.test(content[i] ?? '')) { + i++; + } + + for (; i < content.length; i++) { + const char = content[i]; + + if (char === '\\') { + i++; + } else if (char === '#' && content[i + 1] === '{') { + i = skipInterpolation(content, i + 2); + } else if (char === ')') { + return i; + } else if (/\s/.test(char)) { + while (/\s/.test(content[i] ?? '')) { + i++; + } + return content[i] === ')' ? i : -1; + } else if (!isUrlChar(char)) { + return -1; + } + } + + return -1; +}; + +/** Returns the index of the bracket closing the unquoted URL that starts at `index`, or -1 when none does. */ +const urlEnd = (content: string, index: number): number => + isUrlStart(content, index) ? skipUrl(content, index + URL_TOKEN.length) : -1; + +/** + * When a string, a comment or an unquoted URL starts at `index`, returns the index of its last + * character so that a scan can carry on past it. Returns -1 when `index` is on code. + */ +export const skipNonCode = (content: string, index: number): number => { + const char = content[index]; + const next = content[index + 1]; + + if (char === '\'' || char === '"') { + return skipString(content, index); + } + if (char === '/' && next === '*') { + const end = content.indexOf('*/', index + 2); + + return end === -1 ? content.length : end + 1; + } + if (char === '/' && next === '/') { + const end = content.indexOf('\n', index + 2); + + return end === -1 ? content.length : end; + } + + return urlEnd(content, index); +}; + +/** Returns the index of the brace closing an interpolation whose contents start at `start`. */ +const skipInterpolation = (content: string, start: number): number => { + let level = 0; + + for (let i = start; i < content.length; i++) { + const nonCodeEnd = skipNonCode(content, i); + if (nonCodeEnd !== -1) { + i = nonCodeEnd; + } else if (content[i] === '{') { + level++; + } else if (content[i] === '}') { + if (!level) { + return i; + } + level--; + } + } + + return content.length; +}; + +/** + * Masks non-code with whitespace, preserving offsets and line endings. With `interpolation`, + * the `#{...}` parts of quoted strings and unquoted URLs stay unmasked, because Sass evaluates + * them as code. + */ +export const maskNonCode = (content: string, interpolation = false): string => { + const parts: string[] = []; + let start = 0; + const mask = (from: number, to: number) => parts.push(content.substring(from, to).replace(/[^\r\n]/g, ' ')); + + for (let i = 0; i < content.length; i++) { + const nonCodeEnd = skipNonCode(content, i); + if (nonCodeEnd === -1) { + continue; + } + + parts.push(content.substring(start, i)); + if (interpolation && content[i] !== '/') { + // keep each interpolation, masking the literal text around it + let from = i; + for (let j = i + 1; j < nonCodeEnd; j++) { + if (content[j] === '\\') { + j++; + } else if (content[j] === '#' && content[j + 1] === '{') { + const close = Math.min(skipInterpolation(content, j + 2), nonCodeEnd - 1); + mask(from, j + 2); + // an interpolation holds code, with strings and comments of its own + parts.push(maskNonCode(content.substring(j + 2, close), true)); + from = close; + j = close; + } + } + mask(from, nonCodeEnd + 1); + } else { + mask(i, nonCodeEnd + 1); + } + start = nonCodeEnd + 1; + i = nonCodeEnd; + } + + parts.push(content.substring(start)); + return parts.join(''); +}; + +/** Masks comments with whitespace, preserving offsets and line endings, and keeps strings. */ +export const maskComments = (content: string): string => { + let result = ''; + let start = 0; + + for (let i = 0; i < content.length; i++) { + const nonCodeEnd = skipNonCode(content, i); + if (nonCodeEnd === -1) { + continue; + } + if (content[i] === '/') { + result += content.substring(start, i) + content.substring(i, nonCodeEnd + 1).replace(/[^\r\n]/g, ' '); + start = nonCodeEnd + 1; + } + i = nonCodeEnd; + } + + return result + content.substring(start); +}; + +/** + * Returns the index of the bracket closing the one `start` is inside of, or -1 when the + * content is unbalanced. Brackets in strings, comments and unquoted URLs are ignored. + */ +export const findClosingBracket = (content: string, start: number): number => { + let level = 0; + + for (let i = start; i < content.length; i++) { + const nonCodeEnd = skipNonCode(content, i); + if (nonCodeEnd !== -1) { + i = nonCodeEnd; + continue; + } + + const char = content[i]; + if (char === '(') { + level++; + } else if (char === ')') { + if (!level) { + return i; + } + level--; + } + } + + return -1; +}; + +/** + * Safe split of an argument list by `','`, considering inner calls, maps, lists and + * interpolations. E.g.: + * ``` + * prop: inner-func(), + * prop2: inner2(inner-param: 3, inner-param: inner-func(..)), + * prop3: [1px, 2px], + * prop4: #{$a, $b} + * ``` + * + * Strings, comments and unquoted URLs are scanned over, so a `','` inside one of them is not + * taken for an argument separator. + */ +export const splitArguments = (body: string): string[] => { + const parts = []; + let lastIndex = 0; + let level = 0; + + for (let i = 0; i < body.length; i++) { + const nonCodeEnd = skipNonCode(body, i); + if (nonCodeEnd !== -1) { + i = nonCodeEnd; + continue; + } + + const char = body[i]; + if (char === '(' || char === '[' || char === '{') { + level++; + } else if (char === ')' || char === ']' || char === '}') { + level--; + } else if (char === ',' && !level) { + parts.push(body.substring(lastIndex, i)); + lastIndex = i + 1; + } + } + parts.push(body.substring(lastIndex)); + return parts; +}; + +/** Index where an argument's code starts, after its leading whitespace and comments. */ +export const leadingCommentsEnd = (param: string): number => { + let i = 0; + while (i < param.length) { + if (/\s/.test(param[i])) { + i++; + } else if (param[i] === '/' && (param[i + 1] === '/' || param[i + 1] === '*')) { + i = skipNonCode(param, i) + 1; + } else { + break; + } + } + return i; +}; + +/** The line break a carried `//` comment needs so that it does not swallow what follows it. */ +export const commentBreak = (carried: string, next: string, eol: string): string => + /\/\/[^\n]*$/.test(carried) && !/^[ \t]*\r?\n/.test(next) ? eol : ''; + +/** The name a keyword argument passes, such as `$color` for `$color: red`, or null for a positional one. */ +export const argumentName = (code: string): string | null => { + const match = /^(\$[\w-]+)\s*:/.exec(code); + return match ? match[1] : null; +}; + +/** Sass treats underscores and hyphens in identifiers as the same character. */ +export const normalizeName = (name: string): string => name.replace(/_/g, '-'); diff --git a/projects/igniteui-angular/migrations/common/sass/stylesheet.ts b/projects/igniteui-angular/migrations/common/sass/stylesheet.ts new file mode 100644 index 00000000000..2b0f9f055e6 --- /dev/null +++ b/projects/igniteui-angular/migrations/common/sass/stylesheet.ts @@ -0,0 +1,444 @@ +import { findClosingBracket, maskNonCode, normalizeName, skipString } from './scanner'; + +/** How a member name in a change is matched against the names in a stylesheet. */ +export type NameMatch = + /** The whole name, so `palette` matches `palette(` and `igx.palette(` but not `color-palette(`. */ + 'exact' | + /** + * Any name ending with it, so `circular-theme` also matches `progress-circular-theme(`. Theme + * property changes rely on this to address several theme functions with one owner. + */ + 'suffix'; + +/** Whether a member is called as a function or included as a mixin. */ +export type MemberKind = 'function' | 'mixin'; + +/** A member referenced in a stylesheet, through a call, an include, or both. */ +export interface SassMemberReference { + /** Start of the member name, after any namespace. */ + nameStart: number; + /** End of the member name. */ + nameEnd: number; + /** The namespace the member is referenced through, such as `igx` in `igx.palette(`. */ + namespace?: string; + kind: MemberKind; + /** Start of the argument list, after its opening bracket, when the reference passes one. */ + bodyStart?: number; + /** Index of the bracket closing the argument list. */ + bodyEnd?: number; +} + +/** An `@include` statement, from the keyword (or the `+` shorthand) to its end. */ +export interface SassInclude extends SassMemberReference { + start: number; + /** End of the statement, after its `;` in the SCSS syntax. */ + end: number; + /** Whether the include passes a content block, which a removal would have to take with it. */ + hasContentBlock: boolean; +} + +/** A `@use` or `@forward` rule. */ +export interface SassModuleRule { + keyword: '@use' | '@forward'; + start: number; + /** End of the rule, after its `;` in the SCSS syntax. */ + end: number; + url: string; + /** Start and end of the URL, inside its quotes. */ + urlStart: number; + urlEnd: number; + /** The namespace members of a used module are referenced through, `*` for none. */ + namespace?: string; +} + +/** A declaration, including its default arguments and body, which are evaluated when called. */ +interface SassDeclaration { + start: number; + end: number; +} + +/** The mixins and functions declared in one lexical scope of a stylesheet. */ +interface SassDeclarationScope { + start: number; + end: number; + mixins: Map; + functions: Map; + declarations: SassDeclaration[]; +} + +const IDENTIFIER = /[\w-]/; + +/** + * One stylesheet read for migrations. Strings, comments and `url()` tokens are masked once, so + * that nothing mentioned in one of them is taken for code, and the members the stylesheet + * declares itself are tracked by scope, so that a migration leaves them alone. + * + * Only this stylesheet is read. Telling which member a reference loaded from another one refers + * to needs the module rules of `@use` and `@forward` - re-exports, `hide` and `as prefix-*` - so a + * member of the app that another stylesheet declares is still taken for the Ignite UI one. + */ +export class SassStylesheet { + private _code: string; + private _scopes: SassDeclarationScope[]; + + constructor(public readonly content: string, public readonly indented: boolean) { } + + /** The stylesheet with strings, comments and `url()` tokens masked by whitespace. */ + public get code(): string { + return this._code ??= maskNonCode(this.content); + } + + private get scopes(): SassDeclarationScope[] { + return this._scopes ??= this.declarationScopes(); + } + + /** + * Returns every reference to `name(...)` that passes arguments. A reference nested in the + * arguments of another one reports its own closing bracket rather than the one of the + * reference surrounding it. A reference with the same name nested in one is covered by it, + * unless `nested` asks for those too. Members the stylesheet declares itself are skipped. + */ + public findCalls(name: string, options: { match: NameMatch; kind?: MemberKind; nested?: boolean }): SassMemberReference[] { + const calls: SassMemberReference[] = []; + const target = normalizeName(name); + const code = this.code; + let brackets = 0; + + for (let i = 0; i < code.length; i++) { + if (code[i] === '(' || code[i] === '[') { + brackets++; + } else if (code[i] === ')' || code[i] === ']') { + brackets--; + } + if (code[i + name.length] !== '(' || normalizeName(code.substring(i, i + name.length)) !== target) { + continue; + } + + const reference = this.reference(i, name, options.match, this.indented && !brackets); + if (!reference || (options.kind && reference.kind !== options.kind)) { + continue; + } + + const bodyStart = i + name.length + 1; + const bodyEnd = findClosingBracket(this.content, bodyStart); + if (bodyEnd === -1) { + // unbalanced from here on, leave this call rather than the rest of the file + continue; + } + + calls.push({ ...reference, bodyStart, bodyEnd }); + if (!options.nested) { + // a call of the same member nested in this one is already covered by it + i = bodyEnd; + } + } + + return calls; + } + + /** Returns every `@include` of the mixin `name`, with or without arguments. */ + public findIncludes(name: string, match: NameMatch): SassInclude[] { + const includes: SassInclude[] = []; + const code = this.code; + const keyword = this.indented ? /@include\s+|^[ \t]*\+(?=[\w-])/gm : /@include\s+/g; + + for (const statement of code.matchAll(keyword)) { + const shorthand = !statement[0].startsWith('@'); + const start = shorthand ? statement.index + statement[0].length - 1 : statement.index; + let nameStart = statement.index + statement[0].length; + let namespace: string; + const token = /^([\w-]+)(?:\.([\w-]+))?/.exec(code.substring(nameStart)); + if (!token) { + continue; + } + if (token[2] !== undefined) { + namespace = token[1]; + nameStart += namespace.length + 1; + } + + const member = token[2] ?? token[1]; + const matches = match === 'exact' ? normalizeName(member) === normalizeName(name) : member.endsWith(name); + if (!matches || this.isOwnMember(nameStart + member.length - name.length, name)) { + continue; + } + + const nameEnd = nameStart + member.length; + let end = nameEnd; + let bodyStart: number; + let bodyEnd: number; + if (code[end] === '(') { + bodyStart = end + 1; + bodyEnd = findClosingBracket(this.content, bodyStart); + if (bodyEnd === -1) { + continue; + } + end = bodyEnd + 1; + } + + const rest = this.statementEnd(end, start); + includes.push({ + start, end: rest.end, nameStart, nameEnd, namespace, kind: 'mixin', bodyStart, bodyEnd, + hasContentBlock: rest.block + }); + } + + return includes; + } + + /** Returns every `@use` and `@forward` rule of the stylesheet. */ + public findModuleRules(): SassModuleRule[] { + const rules: SassModuleRule[] = []; + + for (const match of this.code.matchAll(/@(use|forward)(?=\s)/g)) { + // the URL is masked in the code, so whitespace is skipped in the original + const urlQuote = match.index + match[0].length + /^\s*/.exec(this.content.substring(match.index + match[0].length))[0].length; + const quote = this.content[urlQuote]; + if (quote !== '\'' && quote !== '"') { + continue; + } + + const urlEnd = skipString(this.content, urlQuote); + const url = this.content.substring(urlQuote + 1, urlEnd); + const { end } = this.statementEnd(urlEnd + 1, match.index); + const keyword = match[1] === 'use' ? '@use' : '@forward'; + let namespace: string; + if (keyword === '@use') { + const as = /\bas\s+(\*|[\w-]+)/.exec(this.code.substring(urlEnd + 1, end)); + // without `as`, a module is used through the last part of its URL + namespace = as ? as[1] : url.split('/').pop().replace(/^_/, '').replace(/\.s[ac]ss$/, ''); + } + + rules.push({ keyword, start: match.index, end, url, urlStart: urlQuote + 1, urlEnd, namespace }); + } + + return rules; + } + + /** + * The member reference whose name ends with `name` at `index`, or null when the name there is + * not a reference: part of another name, a variable, or a member the stylesheet declares itself. + */ + private reference(index: number, name: string, match: NameMatch, statementLevel: boolean): Omit | null { + const code = this.code; + let nameStart = index; + while (nameStart > 0 && IDENTIFIER.test(code[nameStart - 1])) { + nameStart--; + } + const nameEnd = index + name.length; + const fullName = code.substring(nameStart, nameEnd); + if (code[nameStart - 1] === '$' || code[nameStart - 1] === '%' + || (match === 'exact' && normalizeName(fullName) !== normalizeName(name))) { + return null; + } + + let namespace: string; + let start = nameStart; + if (code[nameStart - 1] === '.') { + start = nameStart - 1; + while (start > 0 && IDENTIFIER.test(code[start - 1])) { + start--; + } + namespace = code.substring(start, nameStart - 1); + } + + const keyword = this.keywordBefore(code, start, statementLevel); + if (keyword === '@mixin' || keyword === '@function' || this.isOwnMember(index, name, statementLevel)) { + return null; + } + + return { nameStart, nameEnd, namespace, kind: keyword === '@include' ? 'mixin' : 'function' }; + } + + /** + * Where the statement that starts at `start` and continues at `index` ends, and whether a + * content block follows it. + */ + public statementEnd(index: number, start: number): { end: number; block: boolean } { + const code = this.code; + let depth = 0; + + for (let i = index; i < code.length; i++) { + const char = code[i]; + if (char === '#' && code[i + 1] === '{') { + // the braces of an interpolation are part of the statement, not a block + i = this.interpolationEnd(i + 2); + } else if (char === '(' || char === '[') { + depth++; + } else if (char === ')' || char === ']') { + depth--; + } else if (depth) { + continue; + } else if (char === ';') { + return { end: i + 1, block: false }; + } else if (char === '{') { + return { end: i, block: true }; + } else if (char === '}') { + return { end: i, block: false }; + } else if (char === '\n' && this.indented) { + // a next line deeper than the statement's own is a content block + const lineStart = code.lastIndexOf('\n', start - 1) + 1; + const indentation = /^[ \t]*/.exec(code.substring(lineStart))[0].length; + const next = /^([ \t]*)\S/m.exec(code.substring(i + 1)); + return { end: code[i - 1] === '\r' ? i - 1 : i, block: !!next && next[1].length > indentation }; + } + } + + return { end: code.length, block: false }; + } + + /** The index of the brace closing an interpolation whose contents start at `start`. */ + private interpolationEnd(start: number): number { + let level = 0; + for (let i = start; i < this.code.length; i++) { + if (this.code[i] === '{') { + level++; + } else if (this.code[i] === '}') { + if (!level) { + return i; + } + level--; + } + } + return this.code.length; + } + + /** + * Tells whether the `name` at `index` is part of a member the stylesheet declares itself. A + * user's own `app-grid-summary-theme` ends with a theme function name, so without this its + * declaration would lose a parameter its body reads. + */ + private isOwnMember(index: number, name: string, statementLevel = this.indented): boolean { + const code = this.code; + let start = index; + while (start > 0 && IDENTIFIER.test(code[start - 1])) { + start--; + } + + // a namespace holds the members of another stylesheet, never the ones declared here + if (code[start - 1] === '.') { + return false; + } + + const keyword = this.keywordBefore(code, start, statementLevel); + if (keyword === '@mixin' || keyword === '@function') { + // a declaration, whose body reads the parameters it lists + return true; + } + + const member = normalizeName(code.substring(start, index + name.length)); + // Bodies and defaults can run after a later declaration becomes available. Keep their + // existing protection; declaration order is conclusive only for immediate calls. + const deferred = this.scopes.some(scope => scope.declarations.some(declaration => declaration.start <= index && index < declaration.end)); + + // a mixin and a function of one name are separate members, so only the referenced one shadows + return this.scopes.some(scope => { + const declaredAt = (keyword === '@include' ? scope.mixins : scope.functions).get(member); + return scope.start <= index && index < scope.end && declaredAt !== undefined + && (deferred || declaredAt <= index); + }); + } + + /** The at-rule before a member name, or indented shorthand when outside an expression. */ + private keywordBefore(content: string, start: number, indented: boolean): string { + let end = start; + while (end > 0 && /\s/.test(content[end - 1])) { + end--; + } + + // Shorthand starts a statement. Unlike '=', '+' must be adjacent to the mixin name. + const shorthand = content[end - 1]; + if (indented && (shorthand === '=' || (shorthand === '+' && end === start)) + && !content.substring(content.lastIndexOf('\n', end - 1) + 1, end - 1).trim()) { + return shorthand === '=' ? '@mixin' : '@include'; + } + + let keywordStart = end; + while (keywordStart > 0 && /[\w@-]/.test(content[keywordStart - 1])) { + keywordStart--; + } + + return content.substring(keywordStart, end); + } + + /** Collects declarations within their brace or indentation scopes. */ + private declarationScopes(): SassDeclarationScope[] { + const { content, code, indented } = this; + const scopes: SassDeclarationScope[] = []; + const stack: { scope: SassDeclarationScope; indentation: number; declaration?: SassDeclaration }[] = []; + let brackets = 0; + let pendingDeclaration: SassDeclaration; + const enterScope = (start: number, indentation = 0) => { + const scope: SassDeclarationScope = { + start, end: code.length, mixins: new Map(), functions: new Map(), declarations: [] + }; + scopes.push(scope); + // Braces in an interpolated default argument do not start the declaration's body. + const declaration = brackets ? undefined : pendingDeclaration; + stack.push({ scope, indentation, declaration }); + if (declaration) { + pendingDeclaration = undefined; + } + }; + const leaveScope = (end: number) => { + const { scope, declaration } = stack.pop(); + scope.end = end; + if (declaration) { + declaration.end = end; + } + }; + enterScope(0); + const declaration = /(?:@(mixin|function)\s+|=\s*)([\w-]+)/y; + + for (let i = 0; i < code.length; i++) { + // Indentation inside a multiline argument/list is not a new Sass scope. + if (indented && !brackets && (i === 0 || code[i - 1] === '\n')) { + const lineEnd = code.indexOf('\n', i); + const line = code.substring(i, lineEnd === -1 ? code.length : lineEnd); + if (line.trim()) { + // Masked comments are whitespace too, but are not part of the source indentation. + const indentation = /^[ \t]*/.exec(content.substring(i))[0].length; + // An indented mixin may have defaults but no body. + if (pendingDeclaration && indentation <= stack[stack.length - 1].indentation) { + pendingDeclaration.end = i; + pendingDeclaration = undefined; + } + while (stack.length > 1 && indentation < stack[stack.length - 1].indentation) { + leaveScope(i); + } + if (indentation > stack[stack.length - 1].indentation) { + enterScope(i, indentation); + } + } + } + + const char = code[i]; + if (!indented && char === '{') { + enterScope(i); + } else if (!indented && char === '}' && stack.length > 1) { + leaveScope(i); + } else if (char === '(' || char === '[') { + brackets++; + } else if (char === ')' || char === ']') { + brackets--; + } else if (char === '@' || (indented && !brackets && char === '=' + && this.keywordBefore(code, i + 1, true) === '@mixin')) { + declaration.lastIndex = i; + const match = declaration.exec(code); + if (match) { + const scope = stack[stack.length - 1].scope; + // Sass treats underscores and hyphens as the same identifier character. + const members = scope[match[1] === 'function' ? 'functions' : 'mixins']; + const name = normalizeName(match[2]); + if (!members.has(name)) { + members.set(name, i); + } + pendingDeclaration = { start: i, end: code.length }; + scope.declarations.push(pendingDeclaration); + i = declaration.lastIndex - 1; + } + } + } + + return scopes; + } +} diff --git a/projects/igniteui-angular/migrations/common/schema/index.ts b/projects/igniteui-angular/migrations/common/schema/index.ts index 2c6bdf6643a..e5ce6c260e5 100644 --- a/projects/igniteui-angular/migrations/common/schema/index.ts +++ b/projects/igniteui-angular/migrations/common/schema/index.ts @@ -19,6 +19,14 @@ export interface ThemeChange extends ChangeAction { owner?: string; /** The type of the change: variable, function, mixin */ type: ThemeType; + /** Add the property with this value, as Sass source, to calls of the owner that do not pass it */ + add?: string; + /** Match only the owner's exact name. By default, any member whose name ends with the owner matches */ + exactOwner?: boolean; + /** Apply the property change only where the owner is called as a function or included as a mixin */ + ownerType?: ThemeType.Function | ThemeType.Mixin; + /** The property's 1-based index in the owner's parameters. It only detects calls that pass the property by position: add leaves them alone, and remove reports them for manual review */ + parameterIndex?: number; } export interface SelectorChanges { diff --git a/projects/igniteui-angular/migrations/common/schema/theme-changes.schema.json b/projects/igniteui-angular/migrations/common/schema/theme-changes.schema.json index 9f72415c4bb..9638393f332 100644 --- a/projects/igniteui-angular/migrations/common/schema/theme-changes.schema.json +++ b/projects/igniteui-angular/migrations/common/schema/theme-changes.schema.json @@ -3,6 +3,14 @@ "definitions": { "ThemeChange": { "properties": { + "add": { + "description": "Add the property with this value, as Sass source, to calls of the owner that do not pass it", + "type": "string" + }, + "exactOwner": { + "description": "Match only the owner's exact name. By default, any member whose name ends with the owner matches", + "type": "boolean" + }, "name": { "description": "Name of the theme property", "type": "string" @@ -11,6 +19,18 @@ "description": "Theming function this parameter belongs to", "type": "string" }, + "ownerType": { + "description": "Apply the property change only where the owner is called as a function or included as a mixin", + "enum": [ + "function", + "mixin" + ], + "type": "string" + }, + "parameterIndex": { + "description": "The property's 1-based index in the owner's parameters. It only detects calls that pass the property by position: add leaves them alone, and remove reports them for manual review", + "type": "number" + }, "remove": { "description": "Remove directive/component/property", "type": "boolean" @@ -54,4 +74,3 @@ ], "type": "object" } - diff --git a/projects/igniteui-angular/migrations/migration-collection.json b/projects/igniteui-angular/migrations/migration-collection.json index da6b5aad112..d91231ae3b6 100644 --- a/projects/igniteui-angular/migrations/migration-collection.json +++ b/projects/igniteui-angular/migrations/migration-collection.json @@ -319,6 +319,11 @@ "version": "22.2.0", "description": "Removes the grid-summary border width properties now driven by the grid's own borders", "factory": "./update-22_2_0_grid-summary-borders" + }, + "migration-63": { + "version": "22.3.0", + "description": "Pins existing palette() calls to the legacy shade generator, which Ignite UI Theming 30.0.0 no longer uses by default", + "factory": "./update-22_3_0_palette-generator" } } } diff --git a/projects/igniteui-angular/migrations/update-22_3_0_palette-generator/changes/theme-changes.json b/projects/igniteui-angular/migrations/update-22_3_0_palette-generator/changes/theme-changes.json new file mode 100644 index 00000000000..086894c559c --- /dev/null +++ b/projects/igniteui-angular/migrations/update-22_3_0_palette-generator/changes/theme-changes.json @@ -0,0 +1,14 @@ +{ + "$schema": "../../common/schema/theme-changes.schema.json", + "changes": [ + { + "name": "$generator", + "add": "'legacy'", + "type": "property", + "owner": "palette", + "exactOwner": true, + "ownerType": "function", + "parameterIndex": 10 + } + ] +} diff --git a/projects/igniteui-angular/migrations/update-22_3_0_palette-generator/index.spec.ts b/projects/igniteui-angular/migrations/update-22_3_0_palette-generator/index.spec.ts new file mode 100644 index 00000000000..0b5059d68e9 --- /dev/null +++ b/projects/igniteui-angular/migrations/update-22_3_0_palette-generator/index.spec.ts @@ -0,0 +1,200 @@ +import * as path from 'path'; + +import { SchematicTestRunner, UnitTestTree } from '@angular-devkit/schematics/testing/index.js'; +import { setupTestTree } from '../common/setup.spec'; + +const version = '22.3.0'; + +describe(`Update to ${version}`, () => { + let appTree: UnitTestTree; + const schematicRunner = new SchematicTestRunner('ig-migrate', path.join(__dirname, '../migration-collection.json')); + const testFilePath = `/testSrc/appPrefix/component/test.component.scss`; + + beforeEach(() => { + appTree = setupTestTree(); + }); + + const migrationName = 'migration-63'; + + const migrate = async (content: string, filePath = testFilePath): Promise => { + appTree.create(filePath, content); + const tree = await schematicRunner.runSchematic(migrationName, {}, appTree); + return tree.readContent(filePath); + }; + + it('should pin a palette call on one line to the legacy generator', async () => { + expect(await migrate( + `$my-palette: palette($primary: red, $secondary: blue, $surface: white);` + )).toEqual( + `$my-palette: palette($primary: red, $secondary: blue, $surface: white, $generator: 'legacy');` + ); + }); + + it('should pin a palette call with positional arguments', async () => { + expect(await migrate( + `$my-palette: palette(#09f, #df1b74, #fff);` + )).toEqual( + `$my-palette: palette(#09f, #df1b74, #fff, $generator: 'legacy');` + ); + }); + + it('should add the generator on its own line to a palette call with one argument per line', async () => { + expect(await migrate( + `$my-palette: palette( + $primary: red, + $secondary: blue, + $surface: white +);` + )).toEqual( + `$my-palette: palette( + $primary: red, + $secondary: blue, + $surface: white, + $generator: 'legacy' +);` + ); + }); + + it('should keep a trailing comma', async () => { + expect(await migrate( + `$my-palette: palette( + $primary: red, + $secondary: blue, + $surface: white, +);` + )).toEqual( + `$my-palette: palette( + $primary: red, + $secondary: blue, + $surface: white, + $generator: 'legacy', +);` + ); + }); + + it('should keep a comment that closes the last argument line on that argument', async () => { + expect(await migrate( + `$my-palette: palette( + $primary: red, + $secondary: blue, + $surface: white // the page (and cards) +);` + )).toEqual( + `$my-palette: palette( + $primary: red, + $secondary: blue, + $surface: white, // the page (and cards) + $generator: 'legacy' +);` + ); + }); + + it('should add the generator when the closing bracket follows the last argument', async () => { + expect(await migrate( + `$my-palette: palette( + $primary: red, + $secondary: blue, + $surface: white);` + )).toEqual( + `$my-palette: palette( + $primary: red, + $secondary: blue, + $surface: white, + $generator: 'legacy');` + ); + }); + + it('should keep Windows line endings', async () => { + expect(await migrate( + `$my-palette: palette(\r\n $primary: red,\r\n $secondary: blue,\r\n $surface: white\r\n);` + )).toEqual( + `$my-palette: palette(\r\n $primary: red,\r\n $secondary: blue,\r\n $surface: white,\r\n $generator: 'legacy'\r\n);` + ); + }); + + it('should pin namespaced palette calls', async () => { + expect(await migrate( + `@use "igniteui-angular/theming" as igx; +$my-palette: igx.palette($primary: red, $secondary: blue, $surface: white);` + )).toEqual( + `@use "igniteui-angular/theming" as igx; +$my-palette: igx.palette($primary: red, $secondary: blue, $surface: white, $generator: 'legacy');` + ); + }); + + it('should pin palette calls nested in other calls and leave the palette mixin alone', async () => { + expect(await migrate( + `@include palette($my-palette); +@include palette(palette(red, blue, white)); +@include theme($palette: palette(red, blue, white), $schema: $light-material-schema); +.dark { @include igx.palette(igx.palette(red, blue, #222)); }` + )).toEqual( + `@include palette($my-palette); +@include palette(palette(red, blue, white, $generator: 'legacy')); +@include theme($palette: palette(red, blue, white, $generator: 'legacy'), $schema: $light-material-schema); +.dark { @include igx.palette(igx.palette(red, blue, #222, $generator: 'legacy')); }` + ); + }); + + it('should pin a palette call with a single argument', async () => { + expect(await migrate( + `\n\n$my-palette: palette($args);` + )).toEqual( + `\n\n$my-palette: palette($args, $generator: 'legacy');` + ); + }); + + it('should leave a palette call that passes the generator by position', async () => { + const content = `$my-palette: palette(red, blue, white, null, red, red, red, red, null, 'fitted');`; + expect(await migrate(content)).toEqual(content); + }); + + it('should leave palette calls that already pick a generator', async () => { + const content = `$fitted: palette(red, blue, white, $generator: 'fitted'); +$legacy: palette( + $primary: red, + $secondary: blue, + $surface: white, + $generator : 'legacy' +);`; + expect(await migrate(content)).toEqual(content); + }); + + it('should leave other members, comments and strings alone', async () => { + const content = `$a: color-palette(red); +$b: my_palette(red); +$c: map.get($palette, 'primary'); +// $d: palette(red, blue, white); +/* $e: palette(red, blue, white); */ +$f: 'palette(red, blue, white)';`; + expect(await migrate(content)).toEqual(content); + }); + + it('should leave a stylesheet that declares its own palette function alone', async () => { + const content = `@function palette($primary, $secondary, $surface) { + @return ($primary, $secondary, $surface); +} +$my-palette: palette(red, blue, white);`; + expect(await migrate(content)).toEqual(content); + }); + + it('should leave palette calls that spread their arguments', async () => { + const content = `$args: (primary: red, secondary: blue, surface: white); +$my-palette: palette($args...);`; + expect(await migrate(content)).toEqual(content); + }); + + it('should pin palette calls in the indented syntax and leave the mixin shorthand alone', async () => { + const sassFilePath = `/testSrc/appPrefix/component/test.component.sass`; + expect(await migrate( + `$my-palette: palette($primary: red, $secondary: blue, $surface: white) +:root + +palette($my-palette)`, + sassFilePath + )).toEqual( + `$my-palette: palette($primary: red, $secondary: blue, $surface: white, $generator: 'legacy') +:root + +palette($my-palette)` + ); + }); +}); diff --git a/projects/igniteui-angular/migrations/update-22_3_0_palette-generator/index.ts b/projects/igniteui-angular/migrations/update-22_3_0_palette-generator/index.ts new file mode 100644 index 00000000000..c798add439f --- /dev/null +++ b/projects/igniteui-angular/migrations/update-22_3_0_palette-generator/index.ts @@ -0,0 +1,20 @@ +import type { + Rule, + SchematicContext, + Tree +} from '@angular-devkit/schematics'; +import { UpdateChanges } from '../common/UpdateChanges'; + +const version = '22.3.0'; + +/** + * Ignite UI Theming 30 builds palettes with the `fitted` generator by default. Existing + * `palette()` calls are pinned to the `legacy` generator, so they keep producing the colors, and + * the runtime behavior, they had before the update. + */ +export default (): Rule => async (host: Tree, context: SchematicContext) => { + context.logger.info(`Applying migration for Ignite UI for Angular to version ${version}`); + + const update = new UpdateChanges(__dirname, host, context); + update.applyChanges(); +}; diff --git a/projects/igniteui-angular/package.json b/projects/igniteui-angular/package.json index b72bdd7b08d..a614983dd39 100644 --- a/projects/igniteui-angular/package.json +++ b/projects/igniteui-angular/package.json @@ -75,7 +75,7 @@ "igniteui-trial-watermark": "^3.1.0", "jspdf": "^4.2.1", "lodash-es": "^4.17.21", - "igniteui-theming": "^29.0.0", + "igniteui-theming": "^30.0.0", "igniteui-i18n-core": "^1.0.5", "@igniteui/material-icons-extended": "^3.1.0" }, diff --git a/src/app/styleguide/colors/color.sample.html b/src/app/styleguide/colors/color.sample.html index d7b37999bc6..ba02eadbf83 100644 --- a/src/app/styleguide/colors/color.sample.html +++ b/src/app/styleguide/colors/color.sample.html @@ -1,10 +1,10 @@
-

Color Palettes

+

Color Palettes {{generator}} generator

@for (color of colors; track color.name) {
-

{{color.name}}

+

{{color.name}}

@for (variant of color.variants; track variant.shade) {
('snackbar'); public copiedHex = ''; + public generator: Generator = 'legacy'; public colors: ColorGroup[] = [ buildGroup('primary', COLOR_SHADES), buildGroup('secondary', COLOR_SHADES), - buildGroup('surface', COLOR_SHADES), + buildGroup('surface', surfaceShades(this.generator)), buildGroup('gray', GRAY_SHADES), buildGroup('success', COLOR_SHADES), buildGroup('info', COLOR_SHADES), @@ -76,8 +85,21 @@ export class ColorsSampleComponent implements OnDestroy { } } + /** The color shown in the title of a group: `base` for the surface, `500` for the rest. */ + public titleShade(name: string): string | number { + return name === 'surface' ? 'base' : 500; + } + private refreshHexValues(): void { const styles = getComputedStyle(this.el.nativeElement); + const generator = this.detectGenerator(styles); + + if (generator !== this.generator) { + this.generator = generator; + this.colors = this.colors.map(color => + color.name === 'surface' ? buildGroup('surface', surfaceShades(generator)) : color + ); + } for (const color of this.colors) { for (const variant of color.variants) { @@ -88,6 +110,15 @@ export class ColorsSampleComponent implements OnDestroy { this.cdr.markForCheck(); } + /** + * A legacy palette emits its shades as relative colors of `500`, such as + * `hsl(from var(--ig-primary-500) ...)`, while a fitted palette emits fixed colors. + */ + private detectGenerator(styles: CSSStyleDeclaration): Generator { + const shade = styles.getPropertyValue('--ig-primary-600'); + return /\(\s*from\s/.test(shade) ? 'legacy' : 'fitted'; + } + private resolveHex(styles: CSSStyleDeclaration, name: string, shade: string | number): string { const raw = styles.getPropertyValue(`--ig-${name}-${shade}`).trim(); if (!raw) { @@ -125,7 +156,9 @@ export class ColorsSampleComponent implements OnDestroy { ctx.fillStyle = '#000'; ctx.fillStyle = color; ctx.fillRect(0, 0, 1, 1); - const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data; - return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase()}`; + const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data; + const hex = `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).toUpperCase()}`; + // a translucent color, such as the surface container, keeps its alpha + return a < 255 ? `${hex}${a.toString(16).padStart(2, '0').toUpperCase()}` : hex; } }