Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(material/core): migrate to the Sass module system #21204

Merged
merged 3 commits into from
Feb 22, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
1 change: 0 additions & 1 deletion BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ package(default_visibility = ["//visibility:public"])

exports_files([
"LICENSE",
"scss-bundle.config.json",
])

genrule(
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@
"@types/node-fetch": "^2.5.5",
"@types/parse5": "^6.0.0",
"@types/semver": "^7.3.4",
"@types/sass": "^1.16.0",
"@types/send": "^0.14.5",
"@types/stylelint": "^9.10.1",
"@types/yaml": "^1.9.7",
Expand Down Expand Up @@ -156,7 +157,6 @@
"rollup-plugin-node-resolve": "^5.2.0",
"rollup-plugin-sourcemaps": "^0.6.3",
"sass": "^1.29.0",
"scss-bundle": "^3.1.2",
"selenium-webdriver": "^3.6.0",
"semver": "^7.3.4",
"send": "^0.17.1",
Expand Down
262 changes: 262 additions & 0 deletions scripts/migrate-sass-modules.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
const childProcess = require('child_process');
const path = require('path');
const fs = require('fs');
const {sync: glob} = require('glob');

crisbeto marked this conversation as resolved.
Show resolved Hide resolved
// Script that migrates the library source to the Sass module system while maintaining
// backwards-compatibility. The script assumes that `sass-migrator` is installed
// globally and that the results will be committed. Works by migrating the .scss files
// based on their position in the dependency tree, starting with the files that are depended
// upon the most and working downwards. Furthermore, because the `sass-migrator` isn't able to
// pick up imports from the `node_modules`, there is a workaround that comments out all of the
// imports from `@material/*`, runs the migration and re-adds the imports back. The script also
// sorts all remaining `@import` statements lower than `@use` statements to avoid compilation
// errors and auto-fixes some linting failures that are generated by the migrator.

const directory = path.join(__dirname, '../src');
const migratedFiles = new Set();
const ignorePatterns = [
'**/*.import.scss',
'**/test-theming-bundle.scss',
'material/_theming.scss'
];
const materialPrefixes = [
...getPrefixes('material', 'mat'),
...getPrefixes('material/core', 'mat'),
// Outliers that don't have a directory of their own.
'mat-pseudo-checkbox-',
'mat-elevation-',
'mat-optgroup-',
'mat-private-'
];
const mdcPrefixes = [
...getPrefixes('material-experimental', 'mat'),
...getPrefixes('material-experimental/mdc-core', 'mat'),
// Outliers that don't have a directory of their own.
'mat-mdc-optgroup-'
].map(prefix => prefix === 'mat-' ? 'mat-mdc-' : prefix);
const cdkPrefixes = getPrefixes('cdk', 'cdk');
const cdkExperimentalPrefixes = getPrefixes('cdk-experimental', 'cdk');

// Restore the source directory to a clean state.
run('git', ['clean', '-f', '-d'], false, true);
run('git', ['checkout', '--', directory], false, true);

// --reset is a utility to easily restore the repo to its initial state.
if (process.argv.indexOf('--reset') > -1) {
process.exit(0);
}

// Generate this after the repo has been reset.
const importsToAdd = extractImports();

// Run the migrations.

// Clean up any existing import files, because they interfere with the migration.
clearImportFiles();

// Migrate all the partials and forward any export symbols.
migrate('cdk/**/_*.scss', cdkPrefixes, true);
migrate('cdk-experimental/**/_*.scss', cdkExperimentalPrefixes, true);
migrate('material/core/**/_*.scss', materialPrefixes, true, ['**/_all-*.scss', '**/_core.scss']);
migrate('material/!(core)/**/_*.scss', materialPrefixes, true);
migrate('material/core/**/_*.scss', materialPrefixes, true);

// Comment out all MDC imports since the migrator script doesn't know how to find them.
commentOutMdc('material-experimental/**/*.scss');

// Migrate all of the MDC partials.
migrate('material-experimental/mdc-helpers/**/_*.scss', mdcPrefixes, true);
migrate('material-experimental/mdc-core/**/_*.scss', mdcPrefixes, true, ['**/_core.scss']);
migrate('material-experimental/**/_*.scss', mdcPrefixes, true);

// Migrate everything else without forwarding.
migrate('cdk/**/*.scss', cdkPrefixes);
migrate('cdk-experimental/**/*.scss', cdkExperimentalPrefixes);
migrate('material/**/*.scss', materialPrefixes);
migrate('material-experimental/**/*.scss', mdcPrefixes);

// Migrate whatever is left in the source files, assuming that it's not a public API.
migrate('**/*.scss');

// Restore the commented out MDC imports and sort `@use` above `@import`.
restoreAndSortMdc('material-experimental/**/*.scss');

// Clear the files that we don't want.
clearUnwantedFiles();

// Re-add all the imports for backwards compatibility.
reAddImports(importsToAdd);

// Try to auto-fix some of the lint issues using Stylelint.
run('yarn', ['stylelint', '--fix'], true, true);

// At this point most of the lint failures are going to be from long `@forward` statements inside
// .import.scss files. Try to auto-resolve them and then fix everything else manually.
fixSomeLongLines('**/*.import.scss', 100);

console.log(`Finished migrating ${migratedFiles.size} files.`);

function migrate(pattern, prefixes = [], forward = false, ignore = []) {
const args = ['module'];
forward && args.push('--forward=import-only');
prefixes.length && args.push(`--remove-prefix=${prefixes.join(',')}`);

// Note that while the migrator allows for multiple files to be passed in, we start getting
// some assertion errors along the way. Running it on a file-by-file basis works fine.
const files = glob(pattern, {cwd: directory, ignore: [...ignore, ...ignorePatterns]})
.filter(file => !migratedFiles.has(file));
const message = `Migrating ${files.length} unmigrated files matching ${pattern}.`;
console.log(ignore.length ? message + ` Ignoring ${ignore.join(', ')}.` : message);
run('sass-migrator', [...args, ...files]);
files.forEach(file => migratedFiles.add(file));
}

function run(name, args, canFail = false, silent = false) {
const result = childProcess.spawnSync(name, args, {shell: true, cwd: directory});
const output = result.stdout.toString();
!silent && output.length && console.log(output);

if (result.status !== 0 && !canFail) {
console.error(`Script error: ${(result.stderr || result.stdout)}`);
process.exit(1);
}
}

function getPrefixes(package, prefix) {
return fs.readdirSync(path.join(directory, package), {withFileTypes: true})
.filter(current => current.isDirectory())
.map(current => current.name)
.reduce((output, current) => [`${prefix}-${current}-`, ...output], [`${prefix}-`]);
}

function commentOutMdc(pattern) {
const files = glob(pattern, {cwd: directory, absolute: true});
console.log(`Commenting out @material imports from ${files.length} files matching ${pattern}.`);
files.forEach(file => {
const content = fs.readFileSync(file, 'utf8');
// Prefix the content with a marker so we know what to restore later.
fs.writeFileSync(file, content.replace(/(@use|@import) '@material/g, m => '//🚀 ' + m));
});
}

function restoreAndSortMdc(pattern) {
const files = glob(pattern, {cwd: directory, absolute: true});
console.log(`Re-adding and sorting @material imports from ${files.length} ` +
`files matching ${pattern}.`);

files.forEach(file => {
// Remove the commented out lines with the marker from `commentOutMdc`.
const content = fs.readFileSync(file, 'utf8').replace(/\/\/🚀 /g, '');
const lines = content.split('\n');
let headerStartIndex = -1;
let headerEndIndex = -1;

// Find where the comments start and end.
for (let i = lines.length - 1; i > -1; i--) {
if (lines[i].startsWith('@use') || lines[i].startsWith('@import')) {
headerStartIndex = i;

if (headerEndIndex === -1) {
headerEndIndex = i + 1;
}
}
}

// Sort the imports so that `@use` comes before `@import`. Otherwise Sass will throw an error.
if (headerStartIndex > -1 && headerEndIndex > -1) {
const headers = lines
.splice(headerStartIndex, headerEndIndex - headerStartIndex)
.sort((a, b) => a.startsWith('@use') && !b.startsWith('@use') ? -1 : 0);
lines.splice(headerStartIndex, 0, ...headers);
}

fs.writeFileSync(file, lines.join('\n'));
});
}

function clearImportFiles() {
const files = glob('**/*.import.scss', {cwd: directory, absolute: true});
console.log(`Clearing ${files.length} import files.`);
files.forEach(file => fs.unlinkSync(file));
}

function clearUnwantedFiles() {
// The migration script generates .import files even if we don't pass in the `--forward` when
// a file has top-level variables matching a prefix. Since we still want such files to be
// migrated, we clear the unwanted files afterwards.
const files = glob('**/*.import.scss', {cwd: directory, absolute: true, ignore: ['**/_*.scss']});
console.log(`Clearing ${files.length} unwanted files.`);
files.forEach(file => fs.unlinkSync(file));
}

function extractImports() {
return glob('**/*.scss', {cwd: directory, absolute: true}).reduce((result, file) => {
const content = fs.readFileSync(file, 'utf8');
const match = content.match(/@import '(.*)';/g);
const imports = match ? match.filter(dep => !dep.includes(` '@material/`)) : [];
if (imports.length) {
result[file] = imports;
}
return result;
}, {});
}


function reAddImports(mapping) {
Object.keys(mapping).forEach(fileName => {
const importEquivalentName = fileName.replace('.scss', '.import.scss');

if (fs.existsSync(importEquivalentName)) {
let content = fs.readFileSync(importEquivalentName, 'utf8');
mapping[fileName].forEach(importedFile => content += `\n${importedFile}`);
fs.writeFileSync(importEquivalentName, content);
}
});
}


function fixSomeLongLines(pattern, limit) {
const files = glob(pattern, {cwd: directory, absolute: true});
let count = 0;

files.forEach(file => {
const content = fs.readFileSync(file, 'utf8');
let lines = content.split('\n');
let fileChanged = false;

(function fixLines() {
const newLines = [];
let hasFixed = false;

lines.forEach(line => {
if (line.length > limit) {
const breakAt = line.lastIndexOf(' ', limit);
if (breakAt > -1) {
// Split the line in two at the limit.
newLines.push(line.slice(0, breakAt), line.slice(breakAt + 1));
fileChanged = hasFixed = true;
} else {
newLines.push(line);
}
} else {
newLines.push(line);
}
});

lines = newLines;

// Keep fixing until there's nothing left. Not particularly efficient...
if (hasFixed) {
fixLines();
}
})();

if (fileChanged) {
count++;
fs.writeFileSync(file, lines.join('\n'));
}
});

console.log(`Fixed long lines in ${count} files.`);
}
10 changes: 0 additions & 10 deletions scss-bundle.config.json

This file was deleted.

4 changes: 4 additions & 0 deletions src/cdk/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ rerootedStyles = [file for target in CDK_ENTRYPOINTS_WITH_STYLES for file in [
"_%s.scss" % target,
target,
],
[
"_%s.import.scss" % target,
target,
],
[
"%s-prebuilt.css" % target,
target,
Expand Down
3 changes: 2 additions & 1 deletion src/cdk/a11y/_a11y.import.scss
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
@forward 'a11y';
@forward 'a11y' hide a11y, high-contrast;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens if we remove this top line? My guess as to what's happening here is that it's attempting to forward cdk-optionally-nest-content, but that then gets hidden on the following line.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we removed this line, somebody using @import will get global mixins called a11y and high-contrast.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried something like this locally:

// parent.scss
@mixin one() { .one { color: red; } }
@mixin two() { .two { background: blue; } }
@mixin three() { .three { outline: yellow;} }
// child.scss
@forward 'parent' as num-* hide three;
// leaf.scss
@import 'child';
@include one();

Running sass ./leaf.scss errors here with Error: Undefined mixin. since the mixin is aliased as part of the @forward. Including num-one() instead works fine. I think that without explicitly forwarding the mixins, they aren't surfaced?

jelbourn marked this conversation as resolved.
Show resolved Hide resolved
@forward 'a11y' as cdk-* hide cdk-optionally-nest-content;
4 changes: 2 additions & 2 deletions src/cdk/a11y/_a11y.scss
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
@mixin cdk-a11y {
@mixin a11y {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could be in a follow-up PR, but I'd like to rename this mixin to something like visually-hidden since my original name here (cdk-a11y) was really way too vague (I had thought we'd add more stuff to it, but that ended up not happening).

(with the old name deprecated for backwards compatibility, of course)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It can be done in a follow-up. I'm trying to keep this PR only to the migration itself.

.cdk-visually-hidden {
border: 0;
clip: rect(0 0 0 0);
Expand Down Expand Up @@ -42,7 +42,7 @@
/// * `on` - works for `Emulated`, `Native`, and `ShadowDom`
/// * `off` - works for `None`
/// * `any` - works for all encapsulation modes by emitting the CSS twice (default).
@mixin cdk-high-contrast($target: active, $encapsulation: 'any') {
@mixin high-contrast($target: active, $encapsulation: 'any') {
@if ($target != 'active' and $target != 'black-on-white' and $target != 'white-on-black') {
@error 'Unknown cdk-high-contrast value "#{$target}" provided. ' +
'Allowed values are "active", "black-on-white", and "white-on-black"';
Expand Down
4 changes: 2 additions & 2 deletions src/cdk/a11y/a11y-prebuilt.scss
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
@import './a11y';
@use './a11y';

@include cdk-a11y();
@include a11y.a11y();
11 changes: 10 additions & 1 deletion src/cdk/overlay/_overlay.import.scss
Original file line number Diff line number Diff line change
@@ -1 +1,10 @@
@forward 'overlay';
@forward '../a11y/a11y' as cdk-*;
@forward 'overlay' hide $dark-backdrop-background, $z-index-overlay, $z-index-overlay-backdrop,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the cdk entry points with Sass (a11y, overlay, textfield), I think we need to also include these .import.scss files in the root of the cdk package. We currently copy each partial to the root, so we need to keep backwards compatibility there.

Separately, I think we should introduce a _cdk.scss file in the root of the package that should become the primary entry point into cdk styles to let people do stuff like

@use '~@angular/cdk';

@include cdk.a11y();
@include cdk.overlay();

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure I get this part:

We currently copy each partial to the root, so we need to keep backwards compatibility there.

My understanding is that all the Sass files get copied to the same place in the dist so as long as the .import file is next to the base .scss file, everything should work like before. Here's what the release output for cdk/overlay looks like:
overlay_2020-12-08_20-57-32

As for the proposal to add a cdk.scss, I agree but I'd rather do it in a separate PR so this one only has the migration-specific changes.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just tried this locally and everything does work as expected, I just don't understand why it works. We copy _a11y.scss, _overlay.scss, and _text-field.scss into the root of the package. Since the names of the mixins changed (e.g. cdk-overlay to overlay), I thought that the following would fail:

@import '~@angular/cdk/overlay';
@include cdk-overlay();

But it actually works just fine.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That doesn't sound right, you might have something cached which is preventing it from failing. I'll look into copying the .import files too.

$z-index-overlay-container, overlay;
@forward 'overlay' as cdk-* hide $cdk-backdrop-animation-duration,
$cdk-backdrop-animation-timing-function, $cdk-dark-backdrop-background;
@forward 'overlay' as cdk-overlay-* hide $cdk-overlay-backdrop-animation-duration,
$cdk-overlay-backdrop-animation-timing-function, $cdk-overlay-z-index-overlay,
$cdk-overlay-z-index-overlay-backdrop, $cdk-overlay-z-index-overlay-container, cdk-overlay-overlay;

@import '../a11y/a11y';
Loading