Skip to content

Commit 46f8334

Browse files
committed
feat(@angular-devkit/build-angular): inline Google and Adobe fonts located in stylesheets
`@import url()` to Google and Adobe fonts that are located in global and component CSS will now be inlined when using the esbuild based builders. Input ```css @import url(https://fonts.googleapis.com/css?family=Roboto:300,400,500); ``` Output ```css /* latin */ @font-face { font-family: 'Roboto'; font-style: normal; font-weight: 500; src: url(https://fonts.gstatic.com/s/roboto/v30/KFOlCnqEu92Fr1MmEU9fBBc4AMP6lQ.woff2) format('woff2'); unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } ``` Closes angular#23054
1 parent 204794c commit 46f8334

File tree

7 files changed

+238
-24
lines changed

7 files changed

+238
-24
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
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+
import { buildApplication } from '../../index';
10+
import { APPLICATION_BUILDER_INFO, BASE_OPTIONS, describeBuilder } from '../setup';
11+
12+
describeBuilder(buildApplication, APPLICATION_BUILDER_INFO, (harness) => {
13+
describe('Option: "fonts.inline"', () => {
14+
beforeEach(async () => {
15+
await harness.modifyFile('/src/index.html', (content) =>
16+
content.replace(
17+
'<head>',
18+
`<head><link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500" rel="stylesheet">`,
19+
),
20+
);
21+
22+
await harness.writeFile(
23+
'src/styles.css',
24+
'@import url(https://fonts.googleapis.com/css?family=Roboto:300,400,500);',
25+
);
26+
27+
await harness.writeFile(
28+
'src/app/app.component.css',
29+
'@import url(https://fonts.googleapis.com/css?family=Roboto:300,400,500);',
30+
);
31+
});
32+
33+
it(`should not inline fonts when fonts optimization is set to false`, async () => {
34+
harness.useTarget('build', {
35+
...BASE_OPTIONS,
36+
optimization: {
37+
scripts: true,
38+
styles: true,
39+
fonts: false,
40+
},
41+
styles: ['src/styles.css'],
42+
});
43+
44+
const { result } = await harness.executeOnce();
45+
46+
expect(result?.success).toBeTrue();
47+
for (const file of ['styles.css', 'index.html', 'main.js']) {
48+
harness
49+
.expectFile(`dist/browser/${file}`)
50+
.content.toContain(`https://fonts.googleapis.com/css?family=Roboto:300,400,500`);
51+
}
52+
});
53+
54+
it(`should inline fonts when fonts optimization is unset`, async () => {
55+
harness.useTarget('build', {
56+
...BASE_OPTIONS,
57+
optimization: {
58+
scripts: true,
59+
styles: true,
60+
fonts: undefined,
61+
},
62+
styles: ['src/styles.css'],
63+
});
64+
65+
const { result } = await harness.executeOnce();
66+
67+
expect(result?.success).toBeTrue();
68+
for (const file of ['styles.css', 'index.html', 'main.js']) {
69+
harness
70+
.expectFile(`dist/browser/${file}`)
71+
.content.not.toContain(`https://fonts.googleapis.com/css?family=Roboto:300,400,500`);
72+
harness
73+
.expectFile(`dist/browser/${file}`)
74+
.content.toMatch(/@font-face{font-family:'?Roboto/);
75+
}
76+
});
77+
78+
it(`should inline fonts when fonts optimization is true`, async () => {
79+
harness.useTarget('build', {
80+
...BASE_OPTIONS,
81+
optimization: {
82+
scripts: true,
83+
styles: true,
84+
fonts: true,
85+
},
86+
styles: ['src/styles.css'],
87+
});
88+
89+
const { result } = await harness.executeOnce();
90+
91+
expect(result?.success).toBeTrue();
92+
for (const file of ['styles.css', 'index.html', 'main.js']) {
93+
harness
94+
.expectFile(`dist/browser/${file}`)
95+
.content.not.toContain(`https://fonts.googleapis.com/css?family=Roboto:300,400,500`);
96+
harness
97+
.expectFile(`dist/browser/${file}`)
98+
.content.toMatch(/@font-face{font-family:'?Roboto/);
99+
}
100+
});
101+
});
102+
});

packages/angular_devkit/build_angular/src/tools/esbuild/angular/component-stylesheets.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ export class ComponentStylesheetBundler {
8888
namespace,
8989
};
9090
});
91-
build.onLoad({ filter: /^css;/, namespace }, async () => {
91+
build.onLoad({ filter: /^css;/, namespace }, () => {
9292
return {
9393
contents: data,
9494
loader: 'css',

packages/angular_devkit/build_angular/src/tools/esbuild/compiler-plugin-options.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@ export function createCompilerPluginOptions(
3333
advancedOptimizations,
3434
inlineStyleLanguage,
3535
jit,
36+
cacheOptions,
3637
tailwindConfiguration,
38+
publicPath,
3739
} = options;
3840

3941
return {
@@ -52,6 +54,7 @@ export function createCompilerPluginOptions(
5254
// Component stylesheet options
5355
styleOptions: {
5456
workspaceRoot,
57+
inlineFonts: !!optimizationOptions.fonts.inline,
5558
optimization: !!optimizationOptions.styles.minify,
5659
sourcemap:
5760
// Hidden component stylesheet sourcemaps are inaccessible which is effectively
@@ -65,7 +68,8 @@ export function createCompilerPluginOptions(
6568
inlineStyleLanguage,
6669
preserveSymlinks,
6770
tailwindConfiguration,
68-
publicPath: options.publicPath,
71+
cacheOptions,
72+
publicPath,
6973
},
7074
};
7175
}

packages/angular_devkit/build_angular/src/tools/esbuild/global-styles.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ export function createGlobalStylesBundleOptions(
2727
externalDependencies,
2828
stylePreprocessorOptions,
2929
tailwindConfiguration,
30+
cacheOptions,
31+
publicPath,
3032
} = options;
3133

3234
const namespace = 'angular:styles/global';
@@ -49,6 +51,7 @@ export function createGlobalStylesBundleOptions(
4951
{
5052
workspaceRoot,
5153
optimization: !!optimizationOptions.styles.minify,
54+
inlineFonts: !!optimizationOptions.fonts.inline,
5255
sourcemap: !!sourcemapOptions.styles,
5356
preserveSymlinks,
5457
target,
@@ -61,7 +64,8 @@ export function createGlobalStylesBundleOptions(
6164
},
6265
includePaths: stylePreprocessorOptions?.includePaths,
6366
tailwindConfiguration,
64-
publicPath: options.publicPath,
67+
cacheOptions,
68+
publicPath,
6569
},
6670
loadCache,
6771
);

packages/angular_devkit/build_angular/src/tools/esbuild/stylesheets/bundle-options.ts

+17-7
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import type { BuildOptions } from 'esbuild';
9+
import type { BuildOptions, Plugin } from 'esbuild';
1010
import path from 'node:path';
11+
import { NormalizedCachedOptions } from '../../../utils/normalize-cache';
1112
import { LoadResultCache } from '../load-result-cache';
13+
import { createCssInlineFontsPlugin } from './css-inline-fonts-plugin';
1214
import { CssStylesheetLanguage } from './css-language';
1315
import { createCssResourcePlugin } from './css-resource-plugin';
1416
import { LessStylesheetLanguage } from './less-language';
@@ -18,6 +20,7 @@ import { StylesheetPluginFactory } from './stylesheet-plugin-factory';
1820
export interface BundleStylesheetOptions {
1921
workspaceRoot: string;
2022
optimization: boolean;
23+
inlineFonts: boolean;
2124
preserveSymlinks?: boolean;
2225
sourcemap: boolean | 'external' | 'inline';
2326
outputNames: { bundles: string; media: string };
@@ -26,6 +29,7 @@ export interface BundleStylesheetOptions {
2629
target: string[];
2730
tailwindConfiguration?: { file: string; package: string };
2831
publicPath?: string;
32+
cacheOptions: NormalizedCachedOptions;
2933
}
3034

3135
export function createStylesheetBundleOptions(
@@ -48,6 +52,17 @@ export function createStylesheetBundleOptions(
4852
cache,
4953
);
5054

55+
const plugins: Plugin[] = [
56+
pluginFactory.create(SassStylesheetLanguage),
57+
pluginFactory.create(LessStylesheetLanguage),
58+
pluginFactory.create(CssStylesheetLanguage),
59+
createCssResourcePlugin(cache),
60+
];
61+
62+
if (options.inlineFonts) {
63+
plugins.push(createCssInlineFontsPlugin({ cache, cacheOptions: options.cacheOptions }));
64+
}
65+
5166
return {
5267
absWorkingDir: options.workspaceRoot,
5368
bundle: true,
@@ -66,11 +81,6 @@ export function createStylesheetBundleOptions(
6681
publicPath: options.publicPath,
6782
conditions: ['style', 'sass', 'less'],
6883
mainFields: ['style', 'sass'],
69-
plugins: [
70-
pluginFactory.create(SassStylesheetLanguage),
71-
pluginFactory.create(LessStylesheetLanguage),
72-
pluginFactory.create(CssStylesheetLanguage),
73-
createCssResourcePlugin(cache),
74-
],
84+
plugins,
7585
};
7686
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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+
import type { Plugin, PluginBuild } from 'esbuild';
10+
import { InlineFontsProcessor } from '../../../utils/index-file/inline-fonts';
11+
import { NormalizedCachedOptions } from '../../../utils/normalize-cache';
12+
import { LoadResultCache, createCachedLoad } from '../load-result-cache';
13+
import { assertIsError } from '@angular/cli/src/utilities/error';
14+
15+
/**
16+
* Options for the createCssInlineFontsPlugin
17+
* @see createCssInlineFontsPlugin
18+
*/
19+
export interface CssInlineFontsPluginOptions {
20+
/** Disk cache normalized options */
21+
cacheOptions?: NormalizedCachedOptions;
22+
/** Load results cache. */
23+
cache?: LoadResultCache;
24+
}
25+
26+
/**
27+
* Creates an esbuild {@link Plugin} that inlines fonts imported via import-rule.
28+
* within the build configuration.
29+
*/
30+
export function createCssInlineFontsPlugin({
31+
cache,
32+
cacheOptions,
33+
}: CssInlineFontsPluginOptions): Plugin {
34+
return {
35+
name: 'angular-css-inline-fonts-plugin',
36+
setup(build: PluginBuild): void {
37+
const inlineFontsProcessor = new InlineFontsProcessor({ cache: cacheOptions, minify: false });
38+
39+
build.onResolve({ filter: /fonts\.googleapis\.com|use\.typekit\.net/ }, (args) => {
40+
// Only attempt to resolve import-rule tokens which only exist inside CSS.
41+
if (args.kind !== 'import-rule') {
42+
return null;
43+
}
44+
45+
if (!inlineFontsProcessor.canInlineRequest(args.path)) {
46+
return null;
47+
}
48+
49+
return {
50+
path: args.path,
51+
namespace: 'css-inline-fonts',
52+
};
53+
});
54+
55+
build.onLoad(
56+
{ filter: /./, namespace: 'css-inline-fonts' },
57+
createCachedLoad(cache, async (args) => {
58+
try {
59+
return {
60+
contents: await inlineFontsProcessor.processURL(args.path),
61+
loader: 'css',
62+
};
63+
} catch (e) {
64+
assertIsError(e);
65+
66+
return {
67+
loader: 'css',
68+
errors: [
69+
{
70+
text: `Failed to inline external stylesheet '${args.path}'.`,
71+
notes: [
72+
{
73+
text: e.message,
74+
},
75+
],
76+
},
77+
],
78+
};
79+
}
80+
}),
81+
);
82+
},
83+
};
84+
}

packages/angular_devkit/build_angular/src/utils/index-file/inline-fonts.ts

+24-14
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ export class InlineFontsProcessor {
116116
continue;
117117
}
118118

119-
const content = await this.processHref(url);
119+
const content = await this.processURL(url);
120120
if (content === undefined) {
121121
continue;
122122
}
@@ -249,13 +249,18 @@ export class InlineFontsProcessor {
249249
return data;
250250
}
251251

252-
private async processHref(url: URL): Promise<string | undefined> {
253-
const provider = this.getFontProviderDetails(url);
252+
async processURL(url: string | URL): Promise<string | undefined> {
253+
const normalizedURL = url instanceof URL ? url : this.createNormalizedUrl(url);
254+
if (!normalizedURL) {
255+
return;
256+
}
257+
258+
const provider = this.getFontProviderDetails(normalizedURL);
254259
if (!provider) {
255260
return undefined;
256261
}
257262

258-
let cssContent = await this.getResponse(url);
263+
let cssContent = await this.getResponse(normalizedURL);
259264

260265
if (this.options.minify) {
261266
cssContent = cssContent
@@ -270,23 +275,28 @@ export class InlineFontsProcessor {
270275
return cssContent;
271276
}
272277

278+
canInlineRequest(url: string): boolean {
279+
const normalizedUrl = this.createNormalizedUrl(url);
280+
281+
return normalizedUrl ? !!this.getFontProviderDetails(normalizedUrl) : false;
282+
}
283+
273284
private getFontProviderDetails(url: URL): FontProviderDetails | undefined {
274285
return SUPPORTED_PROVIDERS[url.hostname];
275286
}
276287

277288
private createNormalizedUrl(value: string): URL | undefined {
278289
// Need to convert '//' to 'https://' because the URL parser will fail with '//'.
279-
const normalizedHref = value.startsWith('//') ? `https:${value}` : value;
280-
if (!normalizedHref.startsWith('http')) {
281-
// Non valid URL.
282-
// Example: relative path styles.css.
283-
return undefined;
284-
}
290+
const url = new URL(value.startsWith('//') ? `https:${value}` : value, 'resolve://');
285291

286-
const url = new URL(normalizedHref);
287-
// Force HTTPS protocol
288-
url.protocol = 'https:';
292+
switch (url.protocol) {
293+
case 'http:':
294+
case 'https:':
295+
url.protocol = 'https:';
289296

290-
return url;
297+
return url;
298+
default:
299+
return undefined;
300+
}
291301
}
292302
}

0 commit comments

Comments
 (0)