Skip to content

Commit 1e56524

Browse files
authored
fix(material/schematics): handle input level in typography migration (#25949)
Expands the logic in the MDC theming migration to be able to discern both keys and values of named Sass function parameters. The new logic is used to correctly migrate users away from the `input` typography levels which doesn't have an equivalent in the new system.
1 parent 5367013 commit 1e56524

File tree

3 files changed

+253
-12
lines changed

3 files changed

+253
-12
lines changed

src/material/schematics/ng-generate/mdc-migration/rules/components/typography-config.spec.ts

Lines changed: 108 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,6 @@ describe('typography migrations', () => {
103103
$body-1: $body-1,
104104
$caption: $caption,
105105
$button: $button,
106-
$input: $input,
107106
),
108107
));
109108
`,
@@ -130,7 +129,6 @@ describe('typography migrations', () => {
130129
$body-2: $body-1,
131130
$caption: $caption,
132131
$button: $button,
133-
$input: $input,
134132
),
135133
));
136134
`,
@@ -196,4 +194,112 @@ describe('typography migrations', () => {
196194
`,
197195
);
198196
});
197+
198+
it('should migrate a typography config with a mixture of positional and named arguments', async () => {
199+
await runMigrationTest(
200+
THEME_FILE,
201+
`
202+
@use '@angular/material' as mat;
203+
204+
$sample-project-theme: mat.define-light-theme((
205+
color: (
206+
primary: $sample-project-primary,
207+
accent: $sample-project-accent,
208+
warn: $sample-project-warn,
209+
),
210+
typography: mat.define-legacy-typography-config($font-family, $display-4: $custom-display-4, $display-3),
211+
));
212+
`,
213+
`
214+
@use '@angular/material' as mat;
215+
216+
$sample-project-theme: mat.define-light-theme((
217+
color: (
218+
primary: $sample-project-primary,
219+
accent: $sample-project-accent,
220+
warn: $sample-project-warn,
221+
),
222+
typography: mat.define-typography-config($font-family, $headline-1: $custom-display-4, $display-3),
223+
));
224+
`,
225+
);
226+
});
227+
228+
it('should replace the `input` level with `body-1` if there is no `body-1` in the config', async () => {
229+
await runMigrationTest(
230+
THEME_FILE,
231+
`
232+
@use '@angular/material' as mat;
233+
234+
$sample-project-theme: mat.define-light-theme((
235+
color: (
236+
primary: $sample-project-primary,
237+
accent: $sample-project-accent,
238+
warn: $sample-project-warn,
239+
),
240+
typography: mat.define-legacy-typography-config(
241+
$display-4: $display-4,
242+
$display-3: $display-3,
243+
$input: $input,
244+
),
245+
));
246+
`,
247+
`
248+
@use '@angular/material' as mat;
249+
250+
$sample-project-theme: mat.define-light-theme((
251+
color: (
252+
primary: $sample-project-primary,
253+
accent: $sample-project-accent,
254+
warn: $sample-project-warn,
255+
),
256+
typography: mat.define-typography-config(
257+
$headline-1: $display-4,
258+
$headline-2: $display-3,
259+
$body-1: $input,
260+
),
261+
));
262+
`,
263+
);
264+
});
265+
266+
it('should comment out the `input` level if a `body-1` already exists', async () => {
267+
await runMigrationTest(
268+
THEME_FILE,
269+
`
270+
@use '@angular/material' as mat;
271+
272+
$sample-project-theme: mat.define-light-theme((
273+
color: (
274+
primary: $sample-project-primary,
275+
accent: $sample-project-accent,
276+
warn: $sample-project-warn,
277+
),
278+
typography: mat.define-legacy-typography-config(
279+
$display-4: $display-4,
280+
$display-3: $display-3,
281+
$input: $input,
282+
$subheading-1: $subheading-1,
283+
),
284+
));
285+
`,
286+
`
287+
@use '@angular/material' as mat;
288+
289+
$sample-project-theme: mat.define-light-theme((
290+
color: (
291+
primary: $sample-project-primary,
292+
accent: $sample-project-accent,
293+
warn: $sample-project-warn,
294+
),
295+
typography: mat.define-typography-config(
296+
$headline-1: $display-4,
297+
$headline-2: $display-3,
298+
/* TODO(mdc-migration): No longer supported. Use \`body-1\` instead. $input: $input, */
299+
$body-1: $subheading-1,
300+
),
301+
));
302+
`,
303+
);
304+
});
199305
});

src/material/schematics/ng-generate/mdc-migration/rules/components/typography-hierarchy/constants.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,6 @@ export const RENAMED_TYPOGRAPHY_LEVELS = new Map(mappings);
2929
export const RENAMED_TYPOGRAPHY_CLASSES = new Map(
3030
mappings.map(m => ['mat-' + m[0], 'mat-' + m[1]]),
3131
);
32+
33+
/** Typography levels that have been combined into other levels with no replacement. */
34+
export const COMBINED_TYPOGRAPHY_LEVELS = new Map([['input', 'body-1']]);

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

Lines changed: 142 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import {SchematicContext} from '@angular-devkit/schematics';
1111
import * as postcss from 'postcss';
1212
import * as scss from 'postcss-scss';
1313
import {ComponentMigrator, MIGRATORS, PERMANENT_MIGRATORS} from '.';
14-
import {RENAMED_TYPOGRAPHY_LEVELS} from './components/typography-hierarchy/constants';
14+
import {
15+
COMBINED_TYPOGRAPHY_LEVELS,
16+
RENAMED_TYPOGRAPHY_LEVELS,
17+
} from './components/typography-hierarchy/constants';
1518
import {StyleMigrator} from './style-migrator';
1619

1720
const COMPONENTS_MIXIN_NAME = /\.([^(;]*)/;
@@ -259,23 +262,48 @@ function migrateTypographyConfigs(content: string, namespace: string): string {
259262
const replacements: {start: number; end: number; text: string}[] = [];
260263

261264
calls.forEach(({name, args}) => {
262-
const argContent = content.slice(args.start, args.end);
263-
replacements.push({start: name.start, end: name.end, text: newFunctionName});
265+
const parameters = extractNamedParameters(content, args);
266+
const addedParameters = new Set<string>();
264267

265268
RENAMED_TYPOGRAPHY_LEVELS.forEach((newName, oldName) => {
266-
const pattern = new RegExp(`\\$(${oldName}) *:`, 'g');
267-
let match: RegExpExecArray | null;
269+
const correspondingParam = parameters.get(oldName);
268270

269-
// Technically each argument can only match once, but keep going just in case.
270-
while ((match = pattern.exec(argContent))) {
271-
const start = args.start + match.index + 1;
271+
if (correspondingParam) {
272+
addedParameters.add(newName);
272273
replacements.push({
273-
start,
274-
end: start + match[1].length,
274+
start: correspondingParam.key.start + 1, // + 1 to skip over the $ in the parameter name.
275+
end: correspondingParam.key.end,
275276
text: newName,
276277
});
277278
}
278279
});
280+
281+
COMBINED_TYPOGRAPHY_LEVELS.forEach((newName, oldName) => {
282+
const correspondingParam = parameters.get(oldName);
283+
284+
if (correspondingParam) {
285+
if (addedParameters.has(newName)) {
286+
const fullContent = content.slice(
287+
correspondingParam.key.start,
288+
correspondingParam.value.fullEnd,
289+
);
290+
replacements.push({
291+
start: correspondingParam.key.start,
292+
end: correspondingParam.value.fullEnd,
293+
text: `/* TODO(mdc-migration): No longer supported. Use \`${newName}\` instead. ${fullContent} */`,
294+
});
295+
} else {
296+
addedParameters.add(newName);
297+
replacements.push({
298+
start: correspondingParam.key.start + 1, // + 1 to skip over the $ in the parameter name.
299+
end: correspondingParam.key.end,
300+
text: newName,
301+
});
302+
}
303+
}
304+
});
305+
306+
replacements.push({start: name.start, end: name.end, text: newFunctionName});
279307
});
280308

281309
replacements
@@ -330,3 +358,107 @@ function extractFunctionCalls(name: string, content: string) {
330358

331359
return results;
332360
}
361+
362+
/** Extracts all of the named parameters and their values from a string. */
363+
function extractNamedParameters(content: string, argsRange: {start: number; end: number}) {
364+
let escapeCount = 0;
365+
366+
const args = content
367+
.slice(argsRange.start, argsRange.end)
368+
// The top-level function parameters can contain function calls with named parameters of their
369+
// own (e.g. `$display-4: mat.define-typography-level($font-family: $foo))` which we don't want to
370+
// extract. Escape everything between parentheses to make it easier to parse out the value later
371+
// on. Note that we escape with an equal-length string so that the string indexes remain the same.
372+
.replace(/\(.*\)/g, current => ++escapeCount + '◬'.repeat(current.length - 1));
373+
374+
let colonIndex = args.indexOf(':');
375+
376+
const params = new Map<
377+
string,
378+
{key: {start: number; end: number}; value: {start: number; end: number; fullEnd: number}}
379+
>();
380+
381+
while (colonIndex > -1) {
382+
const keyRange = extractKeyRange(args, colonIndex);
383+
const valueRange = extractValueRange(args, colonIndex);
384+
385+
if (keyRange && valueRange) {
386+
// + 1 to exclude the $ in the key name.
387+
params.set(args.slice(keyRange.start + 1, keyRange.end), {
388+
// Add the argument start offset since the indexes are relative to the argument string.
389+
key: {start: keyRange.start + argsRange.start, end: keyRange.end + argsRange.start},
390+
value: {
391+
start: valueRange.start + argsRange.start,
392+
end: valueRange.end + argsRange.start,
393+
fullEnd: valueRange.fullEnd + argsRange.start,
394+
},
395+
});
396+
}
397+
398+
colonIndex = args.indexOf(':', colonIndex + 1);
399+
}
400+
401+
return params;
402+
}
403+
404+
/**
405+
* Extracts the text range that contains the key of a named Sass parameter, including the leading $.
406+
* @param content Text content in which to search.
407+
* @param colonIndex Index of the colon between the key and value.
408+
* Used as a starting point for the search.
409+
*/
410+
function extractKeyRange(content: string, colonIndex: number) {
411+
let index = colonIndex - 1;
412+
let start = -1;
413+
let end = -1;
414+
415+
while (index > -1) {
416+
const char = content[index];
417+
if (char !== ' ' && char !== '\n') {
418+
if (end === -1) {
419+
end = index + 1;
420+
} else if (char === '$') {
421+
start = index;
422+
break;
423+
}
424+
}
425+
index--;
426+
}
427+
428+
return start > -1 && end > -1 ? {start, end} : null;
429+
}
430+
431+
/**
432+
* Extracts the text range that contains the value of a named Sass parameter.
433+
* @param content Text content in which to search.
434+
* @param colonIndex Index of the colon between the key and value.
435+
* Used as a starting point for the search.
436+
*/
437+
function extractValueRange(content: string, colonIndex: number) {
438+
let index = colonIndex + 1;
439+
let start = -1;
440+
let end = -1;
441+
let fullEnd = -1; // This is the end including any separators (e.g. commas).
442+
443+
while (index < content.length) {
444+
const char = content[index];
445+
const isWhitespace = char === ' ' || char === '\n';
446+
447+
if (!isWhitespace && start === -1) {
448+
start = index;
449+
} else if (start > -1 && (isWhitespace || char === ',')) {
450+
end = index;
451+
fullEnd = index + 1;
452+
break;
453+
}
454+
455+
if (start > -1 && index === content.length - 1) {
456+
fullEnd = end = content.length;
457+
break;
458+
}
459+
460+
index++;
461+
}
462+
463+
return start > -1 && end > -1 ? {start, end, fullEnd} : null;
464+
}

0 commit comments

Comments
 (0)