Skip to content

Commit f6e67df

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 #23054
1 parent bf5fbdd commit f6e67df

File tree

7 files changed

+231
-24
lines changed

7 files changed

+231
-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,77 @@
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+
14+
/**
15+
* Options for the createCssInlineFontsPlugin
16+
* @see createCssInlineFontsPlugin
17+
*/
18+
export interface CssInlineFontsPluginOptions {
19+
/** Disk cache normalized options */
20+
cacheOptions?: NormalizedCachedOptions;
21+
/** Load results cache. */
22+
cache?: LoadResultCache;
23+
}
24+
25+
/**
26+
* Creates an esbuild {@link Plugin} that inlines fonts imported via import-rule.
27+
* within the build configuration.
28+
*/
29+
export function createCssInlineFontsPlugin({
30+
cache,
31+
cacheOptions,
32+
}: CssInlineFontsPluginOptions): Plugin {
33+
return {
34+
name: 'angular-css-inline-fonts-plugin',
35+
setup(build: PluginBuild): void {
36+
const inlineFontsProcessor = new InlineFontsProcessor({ cache: cacheOptions, minify: false });
37+
38+
build.onResolve({ filter: /fonts\.googleapis\.com|use\.typekit\.net/ }, (args) => {
39+
// Only attempt to resolve import-rule tokens which only exist inside CSS.
40+
if (args.kind !== 'import-rule') {
41+
return null;
42+
}
43+
44+
if (!inlineFontsProcessor.canInlineRequest(args.path)) {
45+
return null;
46+
}
47+
48+
return {
49+
path: args.path,
50+
namespace: 'css-inline-fonts',
51+
};
52+
});
53+
54+
build.onLoad(
55+
{ filter: /./, namespace: 'css-inline-fonts' },
56+
createCachedLoad(cache, async (args) => {
57+
try {
58+
return {
59+
contents: await inlineFontsProcessor.processURL(args.path),
60+
loader: 'css',
61+
};
62+
} catch (error) {
63+
return {
64+
loader: 'css',
65+
errors: [
66+
{
67+
text: `Failed to inline external stylesheet '${args.path}'.`,
68+
detail: error,
69+
},
70+
],
71+
};
72+
}
73+
}),
74+
);
75+
},
76+
};
77+
}

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
}
@@ -258,13 +258,18 @@ export class InlineFontsProcessor {
258258
return data;
259259
}
260260

261-
private async processHref(url: URL): Promise<string | undefined> {
262-
const provider = this.getFontProviderDetails(url);
261+
async processURL(url: string | URL): Promise<string | undefined> {
262+
const normalizedURL = url instanceof URL ? url : this.createNormalizedUrl(url);
263+
if (!normalizedURL) {
264+
return;
265+
}
266+
267+
const provider = this.getFontProviderDetails(normalizedURL);
263268
if (!provider) {
264269
return undefined;
265270
}
266271

267-
let cssContent = await this.getResponse(url);
272+
let cssContent = await this.getResponse(normalizedURL);
268273

269274
if (this.options.minify) {
270275
cssContent = cssContent
@@ -279,23 +284,28 @@ export class InlineFontsProcessor {
279284
return cssContent;
280285
}
281286

287+
canInlineRequest(url: string): boolean {
288+
const normalizedUrl = this.createNormalizedUrl(url);
289+
290+
return normalizedUrl ? !!this.getFontProviderDetails(normalizedUrl) : false;
291+
}
292+
282293
private getFontProviderDetails(url: URL): FontProviderDetails | undefined {
283294
return SUPPORTED_PROVIDERS[url.hostname];
284295
}
285296

286297
private createNormalizedUrl(value: string): URL | undefined {
287298
// Need to convert '//' to 'https://' because the URL parser will fail with '//'.
288-
const normalizedHref = value.startsWith('//') ? `https:${value}` : value;
289-
if (!normalizedHref.startsWith('http')) {
290-
// Non valid URL.
291-
// Example: relative path styles.css.
292-
return undefined;
293-
}
299+
const url = new URL(value.startsWith('//') ? `https:${value}` : value, 'resolve://');
294300

295-
const url = new URL(normalizedHref);
296-
// Force HTTPS protocol
297-
url.protocol = 'https:';
301+
switch (url.protocol) {
302+
case 'http:':
303+
case 'https:':
304+
url.protocol = 'https:';
298305

299-
return url;
306+
return url;
307+
default:
308+
return undefined;
309+
}
300310
}
301311
}

0 commit comments

Comments
 (0)