Skip to content

Commit 5ab71fc

Browse files
clydinalan-agius4
authored andcommitted
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.
1 parent 9401337 commit 5ab71fc

File tree

3 files changed

+419
-3
lines changed

3 files changed

+419
-3
lines changed
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.io/license
7+
*/
8+
9+
/**
10+
* Determines if a unicode code point is a CSS whitespace character.
11+
* @param code The unicode code point to test.
12+
* @returns true, if the code point is CSS whitespace; false, otherwise.
13+
*/
14+
function isWhitespace(code: number): boolean {
15+
// Based on https://www.w3.org/TR/css-syntax-3/#whitespace
16+
switch (code) {
17+
case 0x0009: // tab
18+
case 0x0020: // space
19+
case 0x000a: // line feed
20+
case 0x000c: // form feed
21+
case 0x000d: // carriage return
22+
return true;
23+
default:
24+
return false;
25+
}
26+
}
27+
28+
/**
29+
* Scans a CSS or Sass file and locates all valid import/use directive values as defined by the
30+
* syntax specification.
31+
* @param contents A string containing a CSS or Sass file to scan.
32+
* @returns An iterable that yields each CSS directive value found.
33+
*/
34+
export function* findImports(
35+
contents: string,
36+
sass: boolean,
37+
): Iterable<{ start: number; end: number; specifier: string; fromUse?: boolean }> {
38+
yield* find(contents, '@import ');
39+
if (sass) {
40+
for (const result of find(contents, '@use ')) {
41+
yield { ...result, fromUse: true };
42+
}
43+
}
44+
}
45+
46+
/**
47+
* Scans a CSS or Sass file and locates all valid function/directive values as defined by the
48+
* syntax specification.
49+
* @param contents A string containing a CSS or Sass file to scan.
50+
* @param prefix The prefix to start a valid segment.
51+
* @returns An iterable that yields each CSS url function value found.
52+
*/
53+
function* find(
54+
contents: string,
55+
prefix: string,
56+
): Iterable<{ start: number; end: number; specifier: string }> {
57+
let pos = 0;
58+
let width = 1;
59+
let current = -1;
60+
const next = () => {
61+
pos += width;
62+
current = contents.codePointAt(pos) ?? -1;
63+
width = current > 0xffff ? 2 : 1;
64+
65+
return current;
66+
};
67+
68+
// Based on https://www.w3.org/TR/css-syntax-3/#consume-ident-like-token
69+
while ((pos = contents.indexOf(prefix, pos)) !== -1) {
70+
// Set to position of the last character in prefix
71+
pos += prefix.length - 1;
72+
width = 1;
73+
74+
// Consume all leading whitespace
75+
while (isWhitespace(next())) {
76+
/* empty */
77+
}
78+
79+
// Initialize URL state
80+
const url = { start: pos, end: -1, specifier: '' };
81+
let complete = false;
82+
83+
// If " or ', then consume the value as a string
84+
if (current === 0x0022 || current === 0x0027) {
85+
const ending = current;
86+
// Based on https://www.w3.org/TR/css-syntax-3/#consume-string-token
87+
while (!complete) {
88+
switch (next()) {
89+
case -1: // EOF
90+
return;
91+
case 0x000a: // line feed
92+
case 0x000c: // form feed
93+
case 0x000d: // carriage return
94+
// Invalid
95+
complete = true;
96+
break;
97+
case 0x005c: // \ -- character escape
98+
// If not EOF or newline, add the character after the escape
99+
switch (next()) {
100+
case -1:
101+
return;
102+
case 0x000a: // line feed
103+
case 0x000c: // form feed
104+
case 0x000d: // carriage return
105+
// Skip when inside a string
106+
break;
107+
default:
108+
// TODO: Handle hex escape codes
109+
url.specifier += String.fromCodePoint(current);
110+
break;
111+
}
112+
break;
113+
case ending:
114+
// Full string position should include the quotes for replacement
115+
url.end = pos + 1;
116+
complete = true;
117+
yield url;
118+
break;
119+
default:
120+
url.specifier += String.fromCodePoint(current);
121+
break;
122+
}
123+
}
124+
125+
next();
126+
continue;
127+
}
128+
}
129+
}

packages/schematics/angular/migrations/update-17/use-application-builder.ts

Lines changed: 122 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,19 @@
88

99
import type { workspaces } from '@angular-devkit/core';
1010
import {
11+
DirEntry,
1112
Rule,
1213
SchematicContext,
1314
SchematicsException,
1415
Tree,
1516
chain,
1617
externalSchematic,
1718
} from '@angular-devkit/schematics';
18-
import { dirname, join } from 'node:path/posix';
19+
import { basename, dirname, extname, join } from 'node:path/posix';
1920
import { JSONFile } from '../../utility/json-file';
2021
import { allTargetOptions, updateWorkspace } from '../../utility/workspace';
2122
import { Builders, ProjectType } from '../../utility/workspace-models';
23+
import { findImports } from './css-import-lexer';
2224

2325
function* updateBuildTarget(
2426
projectName: string,
@@ -193,12 +195,131 @@ function updateProjects(tree: Tree, context: SchematicContext) {
193195
break;
194196
}
195197
}
198+
199+
// Update CSS/Sass import specifiers
200+
const projectSourceRoot = join(project.root, project.sourceRoot ?? 'src');
201+
updateStyleImports(tree, projectSourceRoot, buildTarget);
196202
}
197203

198204
return chain(rules);
199205
});
200206
}
201207

208+
function* visit(
209+
directory: DirEntry,
210+
): IterableIterator<[fileName: string, contents: string, sass: boolean]> {
211+
for (const path of directory.subfiles) {
212+
const sass = path.endsWith('.scss');
213+
if (path.endsWith('.css') || sass) {
214+
const entry = directory.file(path);
215+
if (entry) {
216+
const content = entry.content;
217+
218+
yield [entry.path, content.toString(), sass];
219+
}
220+
}
221+
}
222+
223+
for (const path of directory.subdirs) {
224+
if (path === 'node_modules' || path.startsWith('.')) {
225+
continue;
226+
}
227+
228+
yield* visit(directory.dir(path));
229+
}
230+
}
231+
232+
// Based on https://github.com/sass/dart-sass/blob/44d6bb6ac72fe6b93f5bfec371a1fffb18e6b76d/lib/src/importer/utils.dart
233+
function* potentialSassImports(
234+
specifier: string,
235+
base: string,
236+
fromImport: boolean,
237+
): Iterable<string> {
238+
const directory = join(base, dirname(specifier));
239+
const extension = extname(specifier);
240+
const hasStyleExtension = extension === '.scss' || extension === '.sass' || extension === '.css';
241+
// Remove the style extension if present to allow adding the `.import` suffix
242+
const filename = basename(specifier, hasStyleExtension ? extension : undefined);
243+
244+
if (hasStyleExtension) {
245+
if (fromImport) {
246+
yield join(directory, filename + '.import' + extension);
247+
yield join(directory, '_' + filename + '.import' + extension);
248+
}
249+
yield join(directory, filename + extension);
250+
yield join(directory, '_' + filename + extension);
251+
} else {
252+
if (fromImport) {
253+
yield join(directory, filename + '.import.scss');
254+
yield join(directory, filename + '.import.sass');
255+
yield join(directory, filename + '.import.css');
256+
yield join(directory, '_' + filename + '.import.scss');
257+
yield join(directory, '_' + filename + '.import.sass');
258+
yield join(directory, '_' + filename + '.import.css');
259+
}
260+
yield join(directory, filename + '.scss');
261+
yield join(directory, filename + '.sass');
262+
yield join(directory, filename + '.css');
263+
yield join(directory, '_' + filename + '.scss');
264+
yield join(directory, '_' + filename + '.sass');
265+
yield join(directory, '_' + filename + '.css');
266+
}
267+
}
268+
269+
function updateStyleImports(
270+
tree: Tree,
271+
projectSourceRoot: string,
272+
buildTarget: workspaces.TargetDefinition,
273+
) {
274+
const external = new Set<string>();
275+
let needWorkspaceIncludePath = false;
276+
for (const file of visit(tree.getDir(projectSourceRoot))) {
277+
const [path, content, sass] = file;
278+
const relativeBase = dirname(path);
279+
280+
let updater;
281+
for (const { start, specifier, fromUse } of findImports(content, sass)) {
282+
if (specifier[0] === '~') {
283+
updater ??= tree.beginUpdate(path);
284+
// start position includes the opening quote
285+
updater.remove(start + 1, 1);
286+
} else if (specifier[0] === '^') {
287+
updater ??= tree.beginUpdate(path);
288+
// start position includes the opening quote
289+
updater.remove(start + 1, 1);
290+
// Add to externalDependencies
291+
external.add(specifier.slice(1));
292+
} else if (
293+
sass &&
294+
[...potentialSassImports(specifier, relativeBase, !fromUse)].every(
295+
(v) => !tree.exists(v),
296+
) &&
297+
[...potentialSassImports(specifier, '/', !fromUse)].some((v) => tree.exists(v))
298+
) {
299+
needWorkspaceIncludePath = true;
300+
}
301+
}
302+
if (updater) {
303+
tree.commitUpdate(updater);
304+
}
305+
}
306+
307+
if (needWorkspaceIncludePath) {
308+
buildTarget.options ??= {};
309+
buildTarget.options['stylePreprocessorOptions'] ??= {};
310+
((buildTarget.options['stylePreprocessorOptions'] as { includePaths?: string[] })[
311+
'includePaths'
312+
] ??= []).push('.');
313+
}
314+
315+
if (external.size > 0) {
316+
buildTarget.options ??= {};
317+
((buildTarget.options['externalDependencies'] as string[] | undefined) ??= []).push(
318+
...external,
319+
);
320+
}
321+
}
322+
202323
function deleteFile(path: string): Rule {
203324
return (tree) => {
204325
tree.delete(path);

0 commit comments

Comments
 (0)