Skip to content

Commit 78acf11

Browse files
committed
feat(@angular-devkit/build-angular): add font inliner
This is the base functionality needed to inline Google fonts and Icons in HTML. The processor does a couple of things: 1. When support for older devices is needed where woff2 is not supported it will inline definitions for both woff1 and woff2 2. Will remove comments and whitespaces when it's `minifyInlinedCSS` is enabled. 3. Cache responses so to resuse the font response during watch mode. Note: this is still an internal implementation which users cannot leverage just yet.
1 parent 677f600 commit 78acf11

File tree

2 files changed

+253
-0
lines changed

2 files changed

+253
-0
lines changed
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
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+
9+
import * as https from 'https';
10+
import { htmlRewritingStream } from './html-rewriting-stream';
11+
12+
const enum UserAgent {
13+
Chrome = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6) AppleWebKit/537.36 (KHTML, like Gecko)',
14+
IE = 'Mozilla/5.0 (Windows NT 10.0; Trident/7.0; rv:11.0) like Gecko',
15+
}
16+
17+
const SUPPORTED_PROVIDERS = [
18+
'https://fonts.googleapis.com/',
19+
];
20+
21+
export interface InlineFontsOptions {
22+
content: string;
23+
minifyInlinedCSS: boolean;
24+
WOFF1SupportNeeded: boolean;
25+
}
26+
27+
export class InlineFontsProcessor {
28+
private readonly ResponseCache = new Map<string, string>();
29+
30+
async process(options: InlineFontsOptions): Promise<string> {
31+
const {
32+
content,
33+
minifyInlinedCSS,
34+
WOFF1SupportNeeded,
35+
} = options;
36+
37+
const hrefList: string[] = [];
38+
39+
// Collector link tags with href
40+
const { rewriter: collectorStream } = await htmlRewritingStream(content);
41+
42+
collectorStream.on('startTag', tag => {
43+
const { tagName, attrs } = tag;
44+
45+
if (tagName !== 'link') {
46+
return;
47+
}
48+
49+
// <link tag with rel="stylesheet" and a href.
50+
const href = attrs.find(({ name, value }) => name === 'rel' && value === 'stylesheet')
51+
&& attrs.find(({ name }) => name === 'href')?.value;
52+
53+
if (href) {
54+
hrefList.push(href);
55+
}
56+
});
57+
58+
await new Promise(resolve => collectorStream.on('finish', resolve));
59+
60+
// Download stylesheets
61+
const hrefsContent = await this.processHrefs(hrefList, minifyInlinedCSS, WOFF1SupportNeeded);
62+
if (hrefsContent.size === 0) {
63+
return content;
64+
}
65+
66+
// Replace link with style tag.
67+
const { rewriter, transformedContent } = await htmlRewritingStream(content);
68+
rewriter.on('startTag', tag => {
69+
const { tagName, attrs } = tag;
70+
71+
if (tagName !== 'link') {
72+
rewriter.emitStartTag(tag);
73+
74+
return;
75+
}
76+
77+
const hrefAttr = attrs.some(({ name, value }) => name === 'rel' && value === 'stylesheet')
78+
&& attrs.find(({ name, value }) => name === 'href' && hrefsContent.has(value));
79+
if (hrefAttr) {
80+
const href = hrefAttr.value;
81+
const cssContent = hrefsContent.get(href);
82+
rewriter.emitRaw(`<style type="text/css" title="${href}">${cssContent}</style>`);
83+
} else {
84+
rewriter.emitStartTag(tag);
85+
}
86+
});
87+
88+
return transformedContent;
89+
}
90+
91+
private async getResponse(url: string, userAgent: UserAgent): Promise<string> {
92+
const key = url + userAgent;
93+
94+
if (this.ResponseCache.has(key)) {
95+
// tslint:disable-next-line: no-non-null-assertion
96+
return this.ResponseCache.get(key)!;
97+
}
98+
99+
return new Promise((resolve, reject) => {
100+
let rawResponse = '';
101+
102+
https.get(
103+
url,
104+
{
105+
headers: {
106+
'user-agent': userAgent,
107+
},
108+
},
109+
res => {
110+
res
111+
.on('data', chunk => rawResponse += chunk)
112+
.on('end', () => {
113+
const response = rawResponse.toString();
114+
this.ResponseCache.set(key, response);
115+
resolve(response);
116+
});
117+
},
118+
)
119+
.on('error', e => reject(e));
120+
});
121+
}
122+
123+
private async processHrefs(hrefList: string[], minifyInlinedCSS: boolean, WOFF1SupportNeeded: boolean): Promise<Map<string, string>> {
124+
const hrefsContent = new Map<string, string>();
125+
126+
for (const href of hrefList) {
127+
// Normalize protocols to https://
128+
let normalizedHref = href;
129+
if (!href.startsWith('https://')) {
130+
if (href.startsWith('//')) {
131+
normalizedHref = 'https:' + href;
132+
} else if (href.startsWith('http://')) {
133+
normalizedHref = href.replace('http:', 'https:');
134+
} else {
135+
// Unsupported CSS href.
136+
continue;
137+
}
138+
}
139+
140+
if (!SUPPORTED_PROVIDERS.some(url => normalizedHref.startsWith(url))) {
141+
// Provider not supported.
142+
continue;
143+
}
144+
145+
// The order IE -> Chrome is important as otherwise Chrome will load woff1.
146+
let cssContent = '';
147+
if (WOFF1SupportNeeded) {
148+
cssContent += await this.getResponse(normalizedHref, UserAgent.IE);
149+
}
150+
cssContent += await this.getResponse(normalizedHref, UserAgent.Chrome);
151+
152+
if (minifyInlinedCSS) {
153+
cssContent = cssContent
154+
// Comments and new lines.
155+
.replace(/(\n|\/\*\s.+\s\*\/)/g, '')
156+
// Safe spaces.
157+
.replace(/\s?[\{\:\;]\s+/g, s => s.trim());
158+
}
159+
160+
hrefsContent.set(href, cssContent);
161+
}
162+
163+
return hrefsContent;
164+
}
165+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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 { InlineFontsOptions, InlineFontsProcessor } from './inline-fonts';
9+
10+
describe('InlineFontsProcessor', () => {
11+
const inlineFontsOptions: InlineFontsOptions = {
12+
content: `
13+
<html>
14+
<head>
15+
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
16+
</head>
17+
<body></body>
18+
</html>`,
19+
minifyInlinedCSS: false,
20+
WOFF1SupportNeeded: false,
21+
};
22+
23+
it('should inline supported fonts and icons in HTML', async () => {
24+
const source = new InlineFontsProcessor().process({
25+
...inlineFontsOptions,
26+
content: `
27+
<html>
28+
<head>
29+
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
30+
<link href="theme.css" rel="stylesheet">
31+
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500" rel="stylesheet">
32+
</head>
33+
<body></body>
34+
</html>`,
35+
WOFF1SupportNeeded: false,
36+
});
37+
38+
const html = await source;
39+
expect(html).not.toContain('href="https://fonts.googleapis.com/css?family=Roboto:300,400,500"');
40+
expect(html).not.toContain('href="https://fonts.googleapis.com/icon?family=Material+Icons"');
41+
expect(html).toContain('href="theme.css"');
42+
expect(html).toContain(`font-family: 'Roboto'`);
43+
expect(html).toContain(`font-family: 'Material Icons'`);
44+
});
45+
46+
it('works with http protocol', async () => {
47+
const source = new InlineFontsProcessor().process({
48+
...inlineFontsOptions,
49+
content: inlineFontsOptions.content.replace('https://', 'http://'),
50+
WOFF1SupportNeeded: false,
51+
});
52+
53+
expect(await source).toContain(`format('woff2');`);
54+
});
55+
56+
57+
it('works with // protocol', async () => {
58+
const source = new InlineFontsProcessor().process({
59+
...inlineFontsOptions,
60+
content: inlineFontsOptions.content.replace('https://', '//'),
61+
WOFF1SupportNeeded: false,
62+
});
63+
64+
expect(await source).toContain(`format('woff2');`);
65+
});
66+
67+
it('should include WOFF1 definitions when `WOFF1SupportNeeded` is true', async () => {
68+
const source = new InlineFontsProcessor().process({
69+
...inlineFontsOptions,
70+
WOFF1SupportNeeded: true,
71+
});
72+
73+
const html = await source;
74+
expect(html).toContain(`format('woff2');`);
75+
expect(html).toContain(`format('woff');`);
76+
});
77+
78+
it('should remove comments and line breaks when `minifyInlinedCSS` is true', async () => {
79+
const source = new InlineFontsProcessor().process({
80+
...inlineFontsOptions,
81+
minifyInlinedCSS: true,
82+
});
83+
84+
const html = await source;
85+
expect(html).not.toContain('/*');
86+
expect(html).toContain(';font-style:normal;');
87+
});
88+
});

0 commit comments

Comments
 (0)