Skip to content

Commit 871dd6a

Browse files
alan-agius4mgechev
authored andcommitted
feat(@angular-devkit/build-angular): enable font inlining optimizations
With this change we inline Google fonts and icons in the index html file when optimization is enabled. **Before** ```html <link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons"> ``` **After** ```html <style> @font-face { font-family: 'Material Icons'; font-style: normal; font-weight: 400; src: url(https://fonts.gstatic.com/s/materialicons/v55/flUhRq6tzZclQEJ-Vdg-IuiaDsNcIhQ8tQ.woff2) format('woff2'); } .material-icons { font-family: 'Material Icons'; font-weight: normal; font-style: normal; font-size: 24px; line-height: 1; letter-spacing: normal; text-transform: none; display: inline-block; white-space: nowrap; word-wrap: normal; direction: ltr; } </style> ``` To opt-out of this feature set `optimization.fonts: false` or `optimization.fonts.inline: false` in the browser builder options. Example: ```js "configurations": { "production": { "fileReplacements": [ { "replace": "src/environments/environment.ts", "with": "src/environments/environment.prod.ts" } ], "optimization": { "fonts": false }, ``` More information about the motivation for this feature can be found: #18730 Note: internet access is required during the build for this optimization to work.
1 parent d8f7587 commit 871dd6a

File tree

11 files changed

+285
-97
lines changed

11 files changed

+285
-97
lines changed

packages/angular/cli/lib/config/schema.json

+20
Original file line numberDiff line numberDiff line change
@@ -715,6 +715,26 @@
715715
"type": "boolean",
716716
"description": "Enables optimization of the styles output.",
717717
"default": true
718+
},
719+
"fonts": {
720+
"description": "Enables optimization for fonts. This requires internet access.",
721+
"default": true,
722+
"oneOf": [
723+
{
724+
"type": "object",
725+
"properties": {
726+
"inline": {
727+
"type": "boolean",
728+
"description": "Reduce render blocking requests by inlining external fonts in the application's HTML index file. This requires internet access.",
729+
"default": true
730+
}
731+
},
732+
"additionalProperties": false
733+
},
734+
{
735+
"type": "boolean"
736+
}
737+
]
718738
}
719739
},
720740
"additionalProperties": false

packages/angular_devkit/build_angular/src/browser/index.ts

+9-2
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { copyAssets } from '../utils/copy-assets';
3434
import { cachingDisabled } from '../utils/environment-options';
3535
import { i18nInlineEmittedFiles } from '../utils/i18n-inlining';
3636
import { I18nOptions } from '../utils/i18n-options';
37+
import { getHtmlTransforms } from '../utils/index-file/transforms';
3738
import {
3839
IndexHtmlTransform,
3940
writeIndexHtml,
@@ -290,6 +291,12 @@ export function buildWebpackBrowser(
290291
switchMap(({ config, projectRoot, projectSourceRoot, i18n, buildBrowserFeatures, isDifferentialLoadingNeeded, target }) => {
291292
const useBundleDownleveling = isDifferentialLoadingNeeded && !options.watch;
292293
const startTime = Date.now();
294+
const normalizedOptimization = normalizeOptimization(options.optimization);
295+
const indexTransforms = getHtmlTransforms(
296+
normalizedOptimization,
297+
buildBrowserFeatures,
298+
transforms.indexHtml,
299+
);
293300

294301
return runWebpack(config, context, {
295302
webpackFactory: require('webpack') as typeof webpack,
@@ -366,7 +373,7 @@ export function buildWebpackBrowser(
366373
// Common options for all bundle process actions
367374
const sourceMapOptions = normalizeSourceMaps(options.sourceMap || false);
368375
const actionOptions: Partial<ProcessBundleOptions> = {
369-
optimize: normalizeOptimization(options.optimization).scripts,
376+
optimize: normalizedOptimization.scripts,
370377
sourceMaps: sourceMapOptions.scripts,
371378
hiddenSourceMaps: sourceMapOptions.hidden,
372379
vendorSourceMaps: sourceMapOptions.vendor,
@@ -748,7 +755,7 @@ export function buildWebpackBrowser(
748755
sri: options.subresourceIntegrity,
749756
scripts: options.scripts,
750757
styles: options.styles,
751-
postTransform: transforms.indexHtml,
758+
postTransforms: indexTransforms,
752759
crossOrigin: options.crossOrigin,
753760
// i18nLocale is used when Ivy is disabled
754761
lang: locale || options.i18nLocale,

packages/angular_devkit/build_angular/src/browser/schema.json

+20
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,26 @@
7373
"type": "boolean",
7474
"description": "Enables optimization of the styles output.",
7575
"default": true
76+
},
77+
"fonts": {
78+
"description": "Enables optimization for fonts. This requires internet access.",
79+
"default": true,
80+
"oneOf": [
81+
{
82+
"type": "object",
83+
"properties": {
84+
"inline": {
85+
"type": "boolean",
86+
"description": "Reduce render blocking requests by inlining external fonts in the application's HTML index file. This requires internet access.",
87+
"default": true
88+
}
89+
},
90+
"additionalProperties": false
91+
},
92+
{
93+
"type": "boolean"
94+
}
95+
]
7696
}
7797
},
7898
"additionalProperties": false
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* @license
3+
* Copyright Google Inc. 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+
import { Architect } from '@angular-devkit/architect';
9+
import { browserBuild, createArchitect, host } from '../../test-utils';
10+
11+
describe('Browser Builder font optimization', () => {
12+
const target = { project: 'app', target: 'build' };
13+
const overrides = {
14+
optimization: {
15+
styles: false,
16+
fonts: true,
17+
},
18+
};
19+
20+
let architect: Architect;
21+
22+
beforeEach(async () => {
23+
await host.initialize().toPromise();
24+
architect = (await createArchitect(host.root())).architect;
25+
26+
host.replaceInFile(
27+
'/src/index.html',
28+
'<head>',
29+
`<head><link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500" rel="stylesheet">`,
30+
);
31+
});
32+
33+
afterEach(async () => host.restore().toPromise());
34+
35+
it('works', async () => {
36+
const { files } = await browserBuild(architect, host, target, overrides);
37+
const html = await files['index.html'];
38+
expect(html).not.toContain('href="https://fonts.googleapis.com/css?family=Roboto:300,400,500"');
39+
expect(html).toContain(`font-family: 'Roboto'`);
40+
});
41+
42+
it('should not add woff when IE support is not needed', async () => {
43+
const { files } = await browserBuild(architect, host, target, overrides);
44+
const html = await files['index.html'];
45+
expect(html).toContain(`format('woff2');`);
46+
expect(html).not.toContain(`format('woff');`);
47+
});
48+
49+
it('should add woff when IE support is needed', async () => {
50+
host.writeMultipleFiles({
51+
'.browserslistrc': 'IE 11',
52+
});
53+
54+
const { files } = await browserBuild(architect, host, target, overrides);
55+
const html = await files['index.html'];
56+
expect(html).toContain(`format('woff2');`);
57+
expect(html).toContain(`format('woff');`);
58+
});
59+
60+
it('should remove comments and line breaks when styles optimization is true', async () => {
61+
const { files } = await browserBuild(architect, host, target, {
62+
optimization: {
63+
styles: true,
64+
fonts: true,
65+
},
66+
});
67+
const html = await files['index.html'];
68+
expect(html).not.toContain('/*');
69+
expect(html).toContain(';font-style:normal;');
70+
});
71+
72+
it('should not remove comments and line breaks when styles optimization is false', async () => {
73+
const { files } = await browserBuild(architect, host, target, {
74+
optimization: {
75+
styles: false,
76+
fonts: true,
77+
},
78+
});
79+
80+
const html = await files['index.html'];
81+
expect(html).toContain('/*');
82+
expect(html).toContain(' font-style: normal;\n');
83+
});
84+
});

packages/angular_devkit/build_angular/src/dev-server/index.ts

+9-3
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import { BuildBrowserFeatures, normalizeOptimization } from '../utils';
2929
import { findCachePath } from '../utils/cache-path';
3030
import { checkPort } from '../utils/check-port';
3131
import { I18nOptions } from '../utils/i18n-options';
32+
import { getHtmlTransforms } from '../utils/index-file/transforms';
3233
import { IndexHtmlTransform } from '../utils/index-file/write-index-html';
3334
import { generateEntryPoints } from '../utils/package-chunk-sort';
3435
import { createI18nPlugins } from '../utils/process-bundle';
@@ -40,7 +41,6 @@ import { normalizeExtraEntryPoints } from '../webpack/configs';
4041
import { IndexHtmlWebpackPlugin } from '../webpack/plugins/index-html-webpack-plugin';
4142
import { createWebpackLoggingCallback } from '../webpack/utils/stats';
4243
import { Schema } from './schema';
43-
const open = require('open');
4444

4545
export type DevServerBuilderOptions = Schema & json.JsonObject;
4646

@@ -189,6 +189,8 @@ export function serveWebpackBrowser(
189189
});
190190
}
191191

192+
const normalizedOptimization = normalizeOptimization(browserOptions.optimization);
193+
192194
if (browserOptions.index) {
193195
const { scripts = [], styles = [], baseHref, tsConfig } = browserOptions;
194196
const { options: compilerOptions } = readTsconfig(tsConfig, context.workspaceRoot);
@@ -210,14 +212,17 @@ export function serveWebpackBrowser(
210212
deployUrl: browserOptions.deployUrl,
211213
sri: browserOptions.subresourceIntegrity,
212214
noModuleEntrypoints: ['polyfills-es5'],
213-
postTransform: transforms.indexHtml,
215+
postTransforms: getHtmlTransforms(
216+
normalizedOptimization,
217+
buildBrowserFeatures,
218+
transforms.indexHtml,
219+
),
214220
crossOrigin: browserOptions.crossOrigin,
215221
lang: browserOptions.i18nLocale,
216222
}),
217223
);
218224
}
219225

220-
const normalizedOptimization = normalizeOptimization(browserOptions.optimization);
221226
if (normalizedOptimization.scripts || normalizedOptimization.styles) {
222227
context.logger.error(tags.stripIndents`
223228
****************************************************************************************
@@ -257,6 +262,7 @@ export function serveWebpackBrowser(
257262
`);
258263

259264
if (options.open) {
265+
const open = require('open');
260266
open(serverAddress);
261267
}
262268
}

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

+36-37
Original file line numberDiff line numberDiff line change
@@ -7,39 +7,37 @@
77
*/
88

99
import * as cacache from 'cacache';
10-
import { readFile } from 'fs';
10+
import { readFile as readFileAsync } from 'fs';
1111
import * as https from 'https';
12+
import { URL } from 'url';
1213
import { promisify } from 'util';
1314
import { findCachePath } from '../cache-path';
1415
import { cachingDisabled } from '../environment-options';
1516
import { htmlRewritingStream } from './html-rewriting-stream';
1617

17-
const cacheFontsPath = cachingDisabled ? undefined : findCachePath('angular-build-fonts');
18+
const cacheFontsPath: string | undefined = cachingDisabled ? undefined : findCachePath('angular-build-fonts');
1819
const packageVersion = require('../../../package.json').version;
20+
const readFile = promisify(readFileAsync);
1921

2022
const enum UserAgent {
21-
Chrome = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko)',
22-
IE = 'Mozilla/5.0 (Windows NT 10.0; Trident/7.0; rv:11.0) like Gecko',
23+
Chrome = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/85.0.4183.121 Safari/537.36',
24+
IE = 'Mozilla/5.0 (Windows NT 10.0; Trident/7.0; rv:11. 0) like Gecko',
2325
}
2426

2527
const SUPPORTED_PROVIDERS = [
26-
'https://fonts.googleapis.com/',
28+
'fonts.googleapis.com',
2729
];
2830

2931
export interface InlineFontsOptions {
30-
content: string;
3132
minifyInlinedCSS: boolean;
32-
WOFF1SupportNeeded: boolean;
33+
WOFFSupportNeeded: boolean;
3334
}
3435

3536
export class InlineFontsProcessor {
36-
async process(options: InlineFontsOptions): Promise<string> {
37-
const {
38-
content,
39-
minifyInlinedCSS,
40-
WOFF1SupportNeeded,
41-
} = options;
4237

38+
constructor(private options: InlineFontsOptions) { }
39+
40+
async process(content: string): Promise<string> {
4341
const hrefList: string[] = [];
4442

4543
// Collector link tags with href
@@ -64,7 +62,7 @@ export class InlineFontsProcessor {
6462
await new Promise(resolve => collectorStream.on('finish', resolve));
6563

6664
// Download stylesheets
67-
const hrefsContent = await this.processHrefs(hrefList, minifyInlinedCSS, WOFF1SupportNeeded);
65+
const hrefsContent = await this.processHrefs(hrefList);
6866
if (hrefsContent.size === 0) {
6967
return content;
7068
}
@@ -94,13 +92,13 @@ export class InlineFontsProcessor {
9492
return transformedContent;
9593
}
9694

97-
private async getResponse(url: string, userAgent: UserAgent): Promise<string> {
95+
private async getResponse(url: URL, userAgent: UserAgent): Promise<string> {
9896
const key = `${packageVersion}|${url}|${userAgent}`;
9997

10098
if (cacheFontsPath) {
10199
const entry = await cacache.get.info(cacheFontsPath, key);
102100
if (entry) {
103-
return promisify(readFile)(entry.path, 'utf8');
101+
return readFile(entry.path, 'utf8');
104102
}
105103
}
106104

@@ -116,7 +114,7 @@ export class InlineFontsProcessor {
116114
res => {
117115
res
118116
.on('data', chunk => rawResponse += chunk)
119-
.on('end', () => resolve(rawResponse.toString()));
117+
.on('end', () => resolve(rawResponse));
120118
},
121119
)
122120
.on('error', e => reject(e));
@@ -129,44 +127,45 @@ export class InlineFontsProcessor {
129127
return data;
130128
}
131129

132-
private async processHrefs(hrefList: string[], minifyInlinedCSS: boolean, WOFF1SupportNeeded: boolean): Promise<Map<string, string>> {
130+
private async processHrefs(hrefList: string[]): Promise<Map<string, string>> {
133131
const hrefsContent = new Map<string, string>();
134132

135-
for (const href of hrefList) {
136-
// Normalize protocols to https://
137-
let normalizedHref = href;
138-
if (!href.startsWith('https://')) {
139-
if (href.startsWith('//')) {
140-
normalizedHref = 'https:' + href;
141-
} else if (href.startsWith('http://')) {
142-
normalizedHref = href.replace('http:', 'https:');
143-
} else {
144-
// Unsupported CSS href.
145-
continue;
146-
}
133+
for (const hrefPath of hrefList) {
134+
// Need to convert '//' to 'https://' because the URL parser will fail with '//'.
135+
const normalizedHref = hrefPath.startsWith('//') ? `https:${hrefPath}` : hrefPath;
136+
if (!normalizedHref.startsWith('http')) {
137+
// Non valid URL.
138+
// Example: relative path styles.css.
139+
continue;
147140
}
148141

149-
if (!SUPPORTED_PROVIDERS.some(url => normalizedHref.startsWith(url))) {
142+
const url = new URL(normalizedHref);
143+
// Force HTTPS protocol
144+
url.protocol = 'https:';
145+
146+
if (!SUPPORTED_PROVIDERS.includes(url.hostname)) {
150147
// Provider not supported.
151148
continue;
152149
}
153150

154151
// The order IE -> Chrome is important as otherwise Chrome will load woff1.
155152
let cssContent = '';
156-
if (WOFF1SupportNeeded) {
157-
cssContent += await this.getResponse(normalizedHref, UserAgent.IE);
153+
if (this.options.WOFFSupportNeeded) {
154+
cssContent += await this.getResponse(url, UserAgent.IE);
158155
}
159-
cssContent += await this.getResponse(normalizedHref, UserAgent.Chrome);
156+
cssContent += await this.getResponse(url, UserAgent.Chrome);
160157

161-
if (minifyInlinedCSS) {
158+
if (this.options.minifyInlinedCSS) {
162159
cssContent = cssContent
160+
// New lines.
161+
.replace(/\n/g, '')
163162
// Comments and new lines.
164-
.replace(/(\n|\/\*\s.+\s\*\/)/g, '')
163+
.replace(/\/\*\s.+\s\*\//g, '')
165164
// Safe spaces.
166165
.replace(/\s?[\{\:\;]\s+/g, s => s.trim());
167166
}
168167

169-
hrefsContent.set(href, cssContent);
168+
hrefsContent.set(hrefPath, cssContent);
170169
}
171170

172171
return hrefsContent;

0 commit comments

Comments
 (0)