Skip to content

Commit

Permalink
feat(@schematics/angular): update CSS/Sass import/use specifiers in a…
Browse files Browse the repository at this point in the history
…pplication 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.
  • Loading branch information
clydin authored and alan-agius4 committed Mar 5, 2024
1 parent 9401337 commit 5ab71fc
Show file tree
Hide file tree
Showing 3 changed files with 419 additions and 3 deletions.
129 changes: 129 additions & 0 deletions packages/schematics/angular/migrations/update-17/css-import-lexer.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,19 @@

import type { workspaces } from '@angular-devkit/core';
import {
DirEntry,
Rule,
SchematicContext,
SchematicsException,
Tree,
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,
Expand Down Expand Up @@ -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<string> {
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<string>();
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);
Expand Down
Loading

0 comments on commit 5ab71fc

Please sign in to comment.