Skip to content

Commit 6cee17e

Browse files
committed
fix(material/schematics): migrate named arguments in define-typography-config (#25907)
`define-typography-config` changed the names of the typography levels in MDC which is a breaking change. This fix adds a migration to remap the arguments to their new names. (cherry picked from commit 350fa13)
1 parent cd2f13c commit 6cee17e

File tree

2 files changed

+294
-11
lines changed

2 files changed

+294
-11
lines changed

src/material/schematics/ng-generate/mdc-migration/rules/components/multiple-components-styles.spec.ts

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,5 +201,185 @@ describe('multiple component styles', () => {
201201
`,
202202
);
203203
});
204+
205+
it('should migrate an empty typography config call', async () => {
206+
await runMigrationTest(
207+
['checkbox', 'radio'],
208+
`
209+
@use '@angular/material' as mat;
210+
211+
$sample-project-theme: mat.define-light-theme((
212+
color: (
213+
primary: $sample-project-primary,
214+
accent: $sample-project-accent,
215+
warn: $sample-project-warn,
216+
),
217+
typography: mat.define-legacy-typography-config(),
218+
));
219+
`,
220+
`
221+
@use '@angular/material' as mat;
222+
223+
$sample-project-theme: mat.define-light-theme((
224+
color: (
225+
primary: $sample-project-primary,
226+
accent: $sample-project-accent,
227+
warn: $sample-project-warn,
228+
),
229+
typography: mat.define-typography-config(),
230+
));
231+
`,
232+
);
233+
});
234+
235+
it('should migrate a typography config with positional arguments', async () => {
236+
await runMigrationTest(
237+
['checkbox', 'radio'],
238+
`
239+
@use '@angular/material' as mat;
240+
241+
$sample-project-theme: mat.define-light-theme((
242+
color: (
243+
primary: $sample-project-primary,
244+
accent: $sample-project-accent,
245+
warn: $sample-project-warn,
246+
),
247+
typography: mat.define-legacy-typography-config($font-family, $display-4, $display-3),
248+
));
249+
`,
250+
`
251+
@use '@angular/material' as mat;
252+
253+
$sample-project-theme: mat.define-light-theme((
254+
color: (
255+
primary: $sample-project-primary,
256+
accent: $sample-project-accent,
257+
warn: $sample-project-warn,
258+
),
259+
typography: mat.define-typography-config($font-family, $display-4, $display-3),
260+
));
261+
`,
262+
);
263+
});
264+
265+
it('should migrate a typography config with named arguments', async () => {
266+
await runMigrationTest(
267+
['checkbox', 'radio'],
268+
`
269+
@use '@angular/material' as mat;
270+
271+
$sample-project-theme: mat.define-light-theme((
272+
color: (
273+
primary: $sample-project-primary,
274+
accent: $sample-project-accent,
275+
warn: $sample-project-warn,
276+
),
277+
typography: mat.define-legacy-typography-config(
278+
$font-family: $font-family,
279+
$display-4: $display-4,
280+
$display-3: $display-3,
281+
$display-2: $display-2,
282+
$display-1: $display-1,
283+
$headline: $headline,
284+
$title: $title,
285+
$subheading-2: $subheading-2,
286+
$subheading-1: $subheading-1,
287+
$body-2: $body-2,
288+
$body-1: $body-1,
289+
$caption: $caption,
290+
$button: $button,
291+
$input: $input,
292+
),
293+
));
294+
`,
295+
`
296+
@use '@angular/material' as mat;
297+
298+
$sample-project-theme: mat.define-light-theme((
299+
color: (
300+
primary: $sample-project-primary,
301+
accent: $sample-project-accent,
302+
warn: $sample-project-warn,
303+
),
304+
typography: mat.define-typography-config(
305+
$font-family: $font-family,
306+
$headline-1: $display-4,
307+
$headline-2: $display-3,
308+
$headline-3: $display-2,
309+
$headline-4: $display-1,
310+
$headline-5: $headline,
311+
$headline-6: $title,
312+
$subtitle-1: $subheading-2,
313+
$body-1: $subheading-1,
314+
$subtitle-2: $body-2,
315+
$body-2: $body-1,
316+
$caption: $caption,
317+
$button: $button,
318+
$input: $input,
319+
),
320+
));
321+
`,
322+
);
323+
});
324+
325+
it('should migrate multiple typography configs with named arguments within the same file', async () => {
326+
await runMigrationTest(
327+
['checkbox', 'radio'],
328+
`
329+
@use '@angular/material' as mat;
330+
331+
$sample-project-theme: mat.define-light-theme((
332+
color: (
333+
primary: $sample-project-primary,
334+
accent: $sample-project-accent,
335+
warn: $sample-project-warn,
336+
),
337+
typography: mat.define-legacy-typography-config(
338+
$display-4: $display-4,
339+
$title: $title,
340+
),
341+
));
342+
343+
$other-sample-project-theme: mat.define-light-theme((
344+
color: (
345+
primary: $sample-project-primary,
346+
accent: $sample-project-accent,
347+
warn: $sample-project-warn,
348+
),
349+
typography: mat.define-legacy-typography-config(
350+
$display-2: $display-2,
351+
$subheading-2: $subheading-2,
352+
),
353+
));
354+
`,
355+
`
356+
@use '@angular/material' as mat;
357+
358+
$sample-project-theme: mat.define-light-theme((
359+
color: (
360+
primary: $sample-project-primary,
361+
accent: $sample-project-accent,
362+
warn: $sample-project-warn,
363+
),
364+
typography: mat.define-typography-config(
365+
$headline-1: $display-4,
366+
$headline-6: $title,
367+
),
368+
));
369+
370+
$other-sample-project-theme: mat.define-light-theme((
371+
color: (
372+
primary: $sample-project-primary,
373+
accent: $sample-project-accent,
374+
warn: $sample-project-warn,
375+
),
376+
typography: mat.define-typography-config(
377+
$headline-3: $display-2,
378+
$subtitle-1: $subheading-2,
379+
),
380+
));
381+
`,
382+
);
383+
});
204384
});
205385
});

src/material/schematics/ng-generate/mdc-migration/rules/theming-styles.ts

Lines changed: 114 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,35 @@ import {ComponentMigrator, MIGRATORS} from '.';
1414

1515
const COMPONENTS_MIXIN_NAME = /\.([^(;]*)/;
1616

17+
/**
18+
* Mapping between the renamed legacy typography levels and their new non-legacy names. Based on
19+
* the mappings in `private-typography-to-2018-config` from `core/typography/_typography.scss`.
20+
*/
21+
const RENAMED_TYPOGRAPHY_LEVELS = new Map([
22+
['display-4', 'headline-1'],
23+
['display-3', 'headline-2'],
24+
['display-2', 'headline-3'],
25+
['display-1', 'headline-4'],
26+
['headline', 'headline-5'],
27+
['title', 'headline-6'],
28+
['subheading-2', 'subtitle-1'],
29+
['body-2', 'subtitle-2'],
30+
['subheading-1', 'body-1'],
31+
['body-1', 'body-2'],
32+
]);
33+
1734
export class ThemingStylesMigration extends Migration<ComponentMigrator[], SchematicContext> {
1835
enabled = true;
19-
namespace: string;
36+
private _namespace: string;
2037

2138
override visitStylesheet(stylesheet: ResolvedResource) {
22-
const migratedContent = this.migrate(stylesheet.content, stylesheet.filePath).replace(
23-
new RegExp(`${this.namespace}.define-legacy-typography-config\\(`, 'g'),
24-
`${this.namespace}.define-typography-config(`,
25-
);
39+
let migratedContent = this.migrate(stylesheet.content, stylesheet.filePath);
40+
41+
// Note: needs to run after `migrate` so that the `namespace` has been resolved.
42+
if (this._namespace) {
43+
migratedContent = migrateTypographyConfigs(migratedContent, this._namespace);
44+
}
45+
2646
this.fileSystem
2747
.edit(stylesheet.filePath)
2848
.remove(stylesheet.start, stylesheet.content.length)
@@ -52,16 +72,16 @@ export class ThemingStylesMigration extends Migration<ComponentMigrator[], Schem
5272

5373
atUseHandler(atRule: postcss.AtRule) {
5474
if (isAngularMaterialImport(atRule)) {
55-
this.namespace = parseNamespace(atRule);
75+
this._namespace = parseNamespace(atRule);
5676
}
5777
}
5878

5979
atIncludeHandler(atRule: postcss.AtRule) {
6080
const migrator = this.upgradeData.find(m => {
61-
return m.styles.isLegacyMixin(this.namespace, atRule);
81+
return m.styles.isLegacyMixin(this._namespace, atRule);
6282
});
6383
if (migrator) {
64-
const mixinChange = migrator.styles.getMixinChange(this.namespace, atRule);
84+
const mixinChange = migrator.styles.getMixinChange(this._namespace, atRule);
6585
if (mixinChange) {
6686
if (mixinChange.new) {
6787
replaceAtRuleWithMultiple(atRule, mixinChange.old, mixinChange.new);
@@ -79,14 +99,14 @@ export class ThemingStylesMigration extends Migration<ComponentMigrator[], Schem
7999
return;
80100
}
81101
}
82-
replaceCrossCuttingMixin(atRule, this.namespace);
102+
replaceCrossCuttingMixin(atRule, this._namespace);
83103
}
84104
}
85105

86106
isCrossCuttingMixin(mixinText: string) {
87107
return [
88-
`${this.namespace}\\.all-legacy-component-`,
89-
`${this.namespace}\\.legacy-core([^-]|$)`,
108+
`${this._namespace}\\.all-legacy-component-`,
109+
`${this._namespace}\\.legacy-core([^-]|$)`,
90110
].some(r => new RegExp(r).test(mixinText));
91111
}
92112

@@ -235,3 +255,86 @@ function replaceAtRuleWithMultiple(
235255
}
236256
atRule.remove();
237257
}
258+
259+
/**
260+
* Migrates all of the `define-legacy-typography-config` calls within a file.
261+
* @param content Content of the file to be migrated.
262+
* @param namespace Namespace under which `define-legacy-typography-config` is being used.
263+
*/
264+
function migrateTypographyConfigs(content: string, namespace: string): string {
265+
const calls = extractFunctionCalls(`${namespace}.define-legacy-typography-config`, content);
266+
const newFunctionName = `${namespace}.define-typography-config`;
267+
const replacements: {start: number; end: number; text: string}[] = [];
268+
269+
calls.forEach(({name, args}) => {
270+
const argContent = content.slice(args.start, args.end);
271+
replacements.push({start: name.start, end: name.end, text: newFunctionName});
272+
273+
RENAMED_TYPOGRAPHY_LEVELS.forEach((newName, oldName) => {
274+
const pattern = new RegExp(`\\$(${oldName}) *:`, 'g');
275+
let match: RegExpExecArray | null;
276+
277+
// Technically each argument can only match once, but keep going just in case.
278+
while ((match = pattern.exec(argContent))) {
279+
const start = args.start + match.index + 1;
280+
replacements.push({
281+
start,
282+
end: start + match[1].length,
283+
text: newName,
284+
});
285+
}
286+
});
287+
});
288+
289+
replacements
290+
.sort((a, b) => b.start - a.start)
291+
.forEach(
292+
({start, end, text}) => (content = content.slice(0, start) + text + content.slice(end)),
293+
);
294+
295+
return content;
296+
}
297+
298+
/**
299+
* Extracts the spans of all calls of a specific Sass function within a file.
300+
* @param name Name of the function to look for.
301+
* @param content Content of the file being searched.
302+
*/
303+
function extractFunctionCalls(name: string, content: string) {
304+
const results: {name: {start: number; end: number}; args: {start: number; end: number}}[] = [];
305+
const callString = name + '(';
306+
let index = content.indexOf(callString);
307+
308+
// This would be much simpler with a regex, but it can be fragile when it comes to nested
309+
// parentheses. We use this manual parsing which should be more reliable.
310+
while (index > -1) {
311+
let openParens = 0;
312+
let endIndex = -1;
313+
let nameEnd = index + callString.length - 1; // -1 to exclude the opening paren.
314+
315+
for (let i = nameEnd; i < content.length; i++) {
316+
const char = content[i];
317+
318+
if (char === '(') {
319+
openParens++;
320+
} else if (char === ')') {
321+
openParens--;
322+
323+
if (openParens === 0) {
324+
endIndex = i;
325+
break;
326+
}
327+
}
328+
}
329+
330+
// Invalid call, skip over it.
331+
if (endIndex === -1) {
332+
index = content.indexOf(callString, nameEnd + 1);
333+
} else {
334+
results.push({name: {start: index, end: nameEnd}, args: {start: nameEnd + 1, end: endIndex}});
335+
index = content.indexOf(callString, endIndex);
336+
}
337+
}
338+
339+
return results;
340+
}

0 commit comments

Comments
 (0)