From 5ab71fc92ba26f6255e5a5c00e374709ff58d19d Mon Sep 17 00:00:00 2001 From: Charles Lyding <19598772+clydin@users.noreply.github.com> Date: Tue, 27 Feb 2024 17:09:14 -0500 Subject: [PATCH] feat(@schematics/angular): update CSS/Sass import/use specifiers in application migration When using the newly introduced migration to convert an application to use the new esbuild-based `application` builder, CSS and Sass stylesheet will now have any import and/or use specifiers adjusted to remove any Webpack-specific prefixes. This includes both the tilde and caret. Tilde usage is fully removed as package resolution is natively supported. The caret is also removed and for each such specifier an external dependencies entry is added to maintain existing behavior of keep the specifier unchanged. Further, if any Sass imports are detected that assumed a workspace root path as a relative import location then an entry is added to the `includePaths` array within the `stylePreprocessorOptions` build option. This allows these import specifiers to continue to function. --- .../migrations/update-17/css-import-lexer.ts | 129 +++++++++++++ .../update-17/use-application-builder.ts | 123 ++++++++++++- .../update-17/use-application-builder_spec.ts | 170 +++++++++++++++++- 3 files changed, 419 insertions(+), 3 deletions(-) create mode 100644 packages/schematics/angular/migrations/update-17/css-import-lexer.ts diff --git a/packages/schematics/angular/migrations/update-17/css-import-lexer.ts b/packages/schematics/angular/migrations/update-17/css-import-lexer.ts new file mode 100644 index 000000000000..6b0a8baed063 --- /dev/null +++ b/packages/schematics/angular/migrations/update-17/css-import-lexer.ts @@ -0,0 +1,129 @@ +/** + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +/** + * Determines if a unicode code point is a CSS whitespace character. + * @param code The unicode code point to test. + * @returns true, if the code point is CSS whitespace; false, otherwise. + */ +function isWhitespace(code: number): boolean { + // Based on https://www.w3.org/TR/css-syntax-3/#whitespace + switch (code) { + case 0x0009: // tab + case 0x0020: // space + case 0x000a: // line feed + case 0x000c: // form feed + case 0x000d: // carriage return + return true; + default: + return false; + } +} + +/** + * Scans a CSS or Sass file and locates all valid import/use directive values as defined by the + * syntax specification. + * @param contents A string containing a CSS or Sass file to scan. + * @returns An iterable that yields each CSS directive value found. + */ +export function* findImports( + contents: string, + sass: boolean, +): Iterable<{ start: number; end: number; specifier: string; fromUse?: boolean }> { + yield* find(contents, '@import '); + if (sass) { + for (const result of find(contents, '@use ')) { + yield { ...result, fromUse: true }; + } + } +} + +/** + * Scans a CSS or Sass file and locates all valid function/directive values as defined by the + * syntax specification. + * @param contents A string containing a CSS or Sass file to scan. + * @param prefix The prefix to start a valid segment. + * @returns An iterable that yields each CSS url function value found. + */ +function* find( + contents: string, + prefix: string, +): Iterable<{ start: number; end: number; specifier: string }> { + let pos = 0; + let width = 1; + let current = -1; + const next = () => { + pos += width; + current = contents.codePointAt(pos) ?? -1; + width = current > 0xffff ? 2 : 1; + + return current; + }; + + // Based on https://www.w3.org/TR/css-syntax-3/#consume-ident-like-token + while ((pos = contents.indexOf(prefix, pos)) !== -1) { + // Set to position of the last character in prefix + pos += prefix.length - 1; + width = 1; + + // Consume all leading whitespace + while (isWhitespace(next())) { + /* empty */ + } + + // Initialize URL state + const url = { start: pos, end: -1, specifier: '' }; + let complete = false; + + // If " or ', then consume the value as a string + if (current === 0x0022 || current === 0x0027) { + const ending = current; + // Based on https://www.w3.org/TR/css-syntax-3/#consume-string-token + while (!complete) { + switch (next()) { + case -1: // EOF + return; + case 0x000a: // line feed + case 0x000c: // form feed + case 0x000d: // carriage return + // Invalid + complete = true; + break; + case 0x005c: // \ -- character escape + // If not EOF or newline, add the character after the escape + switch (next()) { + case -1: + return; + case 0x000a: // line feed + case 0x000c: // form feed + case 0x000d: // carriage return + // Skip when inside a string + break; + default: + // TODO: Handle hex escape codes + url.specifier += String.fromCodePoint(current); + break; + } + break; + case ending: + // Full string position should include the quotes for replacement + url.end = pos + 1; + complete = true; + yield url; + break; + default: + url.specifier += String.fromCodePoint(current); + break; + } + } + + next(); + continue; + } + } +} diff --git a/packages/schematics/angular/migrations/update-17/use-application-builder.ts b/packages/schematics/angular/migrations/update-17/use-application-builder.ts index ee79cb06f428..b72cce3f0831 100644 --- a/packages/schematics/angular/migrations/update-17/use-application-builder.ts +++ b/packages/schematics/angular/migrations/update-17/use-application-builder.ts @@ -8,6 +8,7 @@ import type { workspaces } from '@angular-devkit/core'; import { + DirEntry, Rule, SchematicContext, SchematicsException, @@ -15,10 +16,11 @@ import { chain, externalSchematic, } from '@angular-devkit/schematics'; -import { dirname, join } from 'node:path/posix'; +import { basename, dirname, extname, join } from 'node:path/posix'; import { JSONFile } from '../../utility/json-file'; import { allTargetOptions, updateWorkspace } from '../../utility/workspace'; import { Builders, ProjectType } from '../../utility/workspace-models'; +import { findImports } from './css-import-lexer'; function* updateBuildTarget( projectName: string, @@ -193,12 +195,131 @@ function updateProjects(tree: Tree, context: SchematicContext) { break; } } + + // Update CSS/Sass import specifiers + const projectSourceRoot = join(project.root, project.sourceRoot ?? 'src'); + updateStyleImports(tree, projectSourceRoot, buildTarget); } return chain(rules); }); } +function* visit( + directory: DirEntry, +): IterableIterator<[fileName: string, contents: string, sass: boolean]> { + for (const path of directory.subfiles) { + const sass = path.endsWith('.scss'); + if (path.endsWith('.css') || sass) { + const entry = directory.file(path); + if (entry) { + const content = entry.content; + + yield [entry.path, content.toString(), sass]; + } + } + } + + for (const path of directory.subdirs) { + if (path === 'node_modules' || path.startsWith('.')) { + continue; + } + + yield* visit(directory.dir(path)); + } +} + +// Based on https://github.com/sass/dart-sass/blob/44d6bb6ac72fe6b93f5bfec371a1fffb18e6b76d/lib/src/importer/utils.dart +function* potentialSassImports( + specifier: string, + base: string, + fromImport: boolean, +): Iterable { + const directory = join(base, dirname(specifier)); + const extension = extname(specifier); + const hasStyleExtension = extension === '.scss' || extension === '.sass' || extension === '.css'; + // Remove the style extension if present to allow adding the `.import` suffix + const filename = basename(specifier, hasStyleExtension ? extension : undefined); + + if (hasStyleExtension) { + if (fromImport) { + yield join(directory, filename + '.import' + extension); + yield join(directory, '_' + filename + '.import' + extension); + } + yield join(directory, filename + extension); + yield join(directory, '_' + filename + extension); + } else { + if (fromImport) { + yield join(directory, filename + '.import.scss'); + yield join(directory, filename + '.import.sass'); + yield join(directory, filename + '.import.css'); + yield join(directory, '_' + filename + '.import.scss'); + yield join(directory, '_' + filename + '.import.sass'); + yield join(directory, '_' + filename + '.import.css'); + } + yield join(directory, filename + '.scss'); + yield join(directory, filename + '.sass'); + yield join(directory, filename + '.css'); + yield join(directory, '_' + filename + '.scss'); + yield join(directory, '_' + filename + '.sass'); + yield join(directory, '_' + filename + '.css'); + } +} + +function updateStyleImports( + tree: Tree, + projectSourceRoot: string, + buildTarget: workspaces.TargetDefinition, +) { + const external = new Set(); + let needWorkspaceIncludePath = false; + for (const file of visit(tree.getDir(projectSourceRoot))) { + const [path, content, sass] = file; + const relativeBase = dirname(path); + + let updater; + for (const { start, specifier, fromUse } of findImports(content, sass)) { + if (specifier[0] === '~') { + updater ??= tree.beginUpdate(path); + // start position includes the opening quote + updater.remove(start + 1, 1); + } else if (specifier[0] === '^') { + updater ??= tree.beginUpdate(path); + // start position includes the opening quote + updater.remove(start + 1, 1); + // Add to externalDependencies + external.add(specifier.slice(1)); + } else if ( + sass && + [...potentialSassImports(specifier, relativeBase, !fromUse)].every( + (v) => !tree.exists(v), + ) && + [...potentialSassImports(specifier, '/', !fromUse)].some((v) => tree.exists(v)) + ) { + needWorkspaceIncludePath = true; + } + } + if (updater) { + tree.commitUpdate(updater); + } + } + + if (needWorkspaceIncludePath) { + buildTarget.options ??= {}; + buildTarget.options['stylePreprocessorOptions'] ??= {}; + ((buildTarget.options['stylePreprocessorOptions'] as { includePaths?: string[] })[ + 'includePaths' + ] ??= []).push('.'); + } + + if (external.size > 0) { + buildTarget.options ??= {}; + ((buildTarget.options['externalDependencies'] as string[] | undefined) ??= []).push( + ...external, + ); + } +} + function deleteFile(path: string): Rule { return (tree) => { tree.delete(path); diff --git a/packages/schematics/angular/migrations/update-17/use-application-builder_spec.ts b/packages/schematics/angular/migrations/update-17/use-application-builder_spec.ts index 855a23918466..ff0a9b6e541e 100644 --- a/packages/schematics/angular/migrations/update-17/use-application-builder_spec.ts +++ b/packages/schematics/angular/migrations/update-17/use-application-builder_spec.ts @@ -15,8 +15,8 @@ function createWorkSpaceConfig(tree: UnitTestTree) { version: 1, projects: { app: { - root: '/project/lib', - sourceRoot: '/project/app/src', + root: '/project/app', + sourceRoot: 'src', projectType: ProjectType.Application, prefix: 'app', architect: { @@ -101,4 +101,170 @@ describe(`Migration to use the application builder`, () => { media: 'resources', }); }); + + it('should remove tilde prefix from CSS @import specifiers', async () => { + // Replace outputPath + tree.create( + '/project/app/src/styles.css', + '@import "~@angular/material";\n@import "./abc.css"\n', + ); + + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + const content = newTree.readText('/project/app/src/styles.css'); + + expect(content).toEqual('@import "@angular/material";\n@import "./abc.css"\n'); + }); + + it('should remove caret prefix from CSS @import specifiers and as external dependency', async () => { + // Replace outputPath + tree.create( + '/project/app/src/styles.css', + '@import "^@angular/material";\n@import "./abc.css"\n', + ); + + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + const content = newTree.readText('/project/app/src/styles.css'); + + expect(content).toEqual('@import "@angular/material";\n@import "./abc.css"\n'); + const { + projects: { app }, + } = JSON.parse(newTree.readContent('/angular.json')); + + const { externalDependencies } = app.architect['build'].options; + expect(externalDependencies).toEqual(['@angular/material']); + }); + + it('should remove tilde prefix from SCSS @import specifiers', async () => { + // Replace outputPath + tree.create('/project/app/src/styles.scss', '@import "~@angular/material";\n@import "./abc"\n'); + + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + const content = newTree.readText('/project/app/src/styles.scss'); + + expect(content).toEqual('@import "@angular/material";\n@import "./abc"\n'); + }); + + it('should remove tilde prefix from SCSS @use specifiers', async () => { + // Replace outputPath + tree.create('/project/app/src/styles.scss', '@use "~@angular/material";\n@import "./abc"\n'); + + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + const content = newTree.readText('/project/app/src/styles.scss'); + + expect(content).toEqual('@use "@angular/material";\n@import "./abc"\n'); + }); + + it('should remove caret prefix from SCSS @import specifiers and as external dependency', async () => { + // Replace outputPath + tree.create('/project/app/src/styles.scss', '@import "^@angular/material";\n@import "./abc"\n'); + + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + const content = newTree.readText('/project/app/src/styles.scss'); + + expect(content).toEqual('@import "@angular/material";\n@import "./abc"\n'); + const { + projects: { app }, + } = JSON.parse(newTree.readContent('/angular.json')); + + const { externalDependencies } = app.architect['build'].options; + expect(externalDependencies).toEqual(['@angular/material']); + }); + + it('should remove caret prefix from SCSS @use specifiers and as external dependency', async () => { + // Replace outputPath + tree.create('/project/app/src/styles.scss', '@use "^@angular/material";\n@import "./abc"\n'); + + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + const content = newTree.readText('/project/app/src/styles.scss'); + + expect(content).toEqual('@use "@angular/material";\n@import "./abc"\n'); + const { + projects: { app }, + } = JSON.parse(newTree.readContent('/angular.json')); + + const { externalDependencies } = app.architect['build'].options; + expect(externalDependencies).toEqual(['@angular/material']); + }); + + it('should add SCSS workspace include path for root referenced @import specifiers', async () => { + // Replace outputPath + tree.create( + '/project/app/src/styles.scss', + '@use "@angular/material";\n@import "some/path/abc"\n', + ); + tree.create('/some/path/abc.scss', ''); + + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + const content = newTree.readText('/project/app/src/styles.scss'); + + expect(content).toEqual('@use "@angular/material";\n@import "some/path/abc"\n'); + const { + projects: { app }, + } = JSON.parse(newTree.readContent('/angular.json')); + + const { stylePreprocessorOptions } = app.architect['build'].options; + expect(stylePreprocessorOptions).toEqual({ includePaths: ['.'] }); + }); + + it('should add SCSS workspace include path for root referenced @use specifiers', async () => { + // Replace outputPath + tree.create( + '/project/app/src/styles.scss', + '@use "@angular/material";\n@use "some/path/abc"\n', + ); + tree.create('/some/path/abc.scss', ''); + + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + const content = newTree.readText('/project/app/src/styles.scss'); + + expect(content).toEqual('@use "@angular/material";\n@use "some/path/abc"\n'); + const { + projects: { app }, + } = JSON.parse(newTree.readContent('/angular.json')); + + const { stylePreprocessorOptions } = app.architect['build'].options; + expect(stylePreprocessorOptions).toEqual({ includePaths: ['.'] }); + }); + + it('should not add SCSS workspace include path for root referenced @import specifiers with ".import" local file', async () => { + // Replace outputPath + tree.create( + '/project/app/src/styles.scss', + '@use "@angular/material";\n@import "some/path/abc"\n', + ); + tree.create('/some/path/abc.scss', ''); + tree.create('/project/app/src/some/path/abc.import.scss', ''); + + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + const content = newTree.readText('/project/app/src/styles.scss'); + + expect(content).toEqual('@use "@angular/material";\n@import "some/path/abc"\n'); + const { + projects: { app }, + } = JSON.parse(newTree.readContent('/angular.json')); + + const { stylePreprocessorOptions } = app.architect['build'].options; + expect(stylePreprocessorOptions).toBeUndefined(); + }); + + it('should add SCSS workspace include path for root referenced @use specifiers with ".import" local file', async () => { + // Replace outputPath + tree.create( + '/project/app/src/styles.scss', + '@use "@angular/material";\n@use "some/path/abc"\n', + ); + tree.create('/some/path/abc.scss', ''); + tree.create('/project/app/src/some/path/abc.import.scss', ''); + + const newTree = await schematicRunner.runSchematic(schematicName, {}, tree); + const content = newTree.readText('/project/app/src/styles.scss'); + + expect(content).toEqual('@use "@angular/material";\n@use "some/path/abc"\n'); + const { + projects: { app }, + } = JSON.parse(newTree.readContent('/angular.json')); + + const { stylePreprocessorOptions } = app.architect['build'].options; + expect(stylePreprocessorOptions).toEqual({ includePaths: ['.'] }); + }); });