From 18cfa04317230f934ccba798c080543bb389725f Mon Sep 17 00:00:00 2001 From: Alan Agius Date: Thu, 24 Jun 2021 15:07:38 +0200 Subject: [PATCH] feat(@angular-devkit/build-angular): add support to inline Adobe Fonts With this change we add support to inline external Adobe fonts into the index html, we also add a `preconnect` hint which helps improve page load speed. Closes #21186 --- .../build_angular/src/browser/schema.json | 2 +- .../src/utils/index-file/inline-fonts.ts | 189 ++++++++++++------ .../src/utils/index-file/inline-fonts_spec.ts | 150 +++++++++----- 3 files changed, 230 insertions(+), 111 deletions(-) diff --git a/packages/angular_devkit/build_angular/src/browser/schema.json b/packages/angular_devkit/build_angular/src/browser/schema.json index 1e11000c23e1..814eb83d473b 100644 --- a/packages/angular_devkit/build_angular/src/browser/schema.json +++ b/packages/angular_devkit/build_angular/src/browser/schema.json @@ -108,7 +108,7 @@ "properties": { "inline": { "type": "boolean", - "description": "Reduce render blocking requests by inlining external Google fonts and icons CSS definitions in the application's HTML index file. This option requires internet access. `HTTPS_PROXY` environment variable can be used to specify a proxy server.", + "description": "Reduce render blocking requests by inlining external Google Fonts and Adobe Fonts CSS definitions in the application's HTML index file. This option requires internet access. `HTTPS_PROXY` environment variable can be used to specify a proxy server.", "default": true } }, diff --git a/packages/angular_devkit/build_angular/src/utils/index-file/inline-fonts.ts b/packages/angular_devkit/build_angular/src/utils/index-file/inline-fonts.ts index 13dc064462b0..15c5e40e5958 100644 --- a/packages/angular_devkit/build_angular/src/utils/index-file/inline-fonts.ts +++ b/packages/angular_devkit/build_angular/src/utils/index-file/inline-fonts.ts @@ -25,18 +25,33 @@ const enum UserAgent { IE = 'Mozilla/5.0 (Windows NT 10.0; Trident/7.0; rv:11. 0) like Gecko', } -const SUPPORTED_PROVIDERS = ['fonts.googleapis.com']; +interface FontProviderDetails { + preconnectUrl: string; + seperateRequestForWOFF: boolean; +} export interface InlineFontsOptions { minify?: boolean; WOFFSupportNeeded: boolean; } +const SUPPORTED_PROVIDERS: Record = { + 'fonts.googleapis.com': { + seperateRequestForWOFF: true, + preconnectUrl: 'https://fonts.gstatic.com', + }, + 'use.typekit.net': { + seperateRequestForWOFF: false, + preconnectUrl: 'https://use.typekit.net', + }, +}; + export class InlineFontsProcessor { constructor(private options: InlineFontsOptions) {} async process(content: string): Promise { const hrefList: string[] = []; + const existingPreconnect = new Set(); // Collector link tags with href const { rewriter: collectorStream } = await htmlRewritingStream(content); @@ -48,20 +63,63 @@ export class InlineFontsProcessor { return; } - // name === 'rel' && value === 'stylesheet') && - attrs.find(({ name }) => name === 'href')?.value; - - if (href) { - hrefList.push(href); + let hrefValue: string | undefined; + let relValue: string | undefined; + for (const { name, value } of attrs) { + switch (name) { + case 'rel': + relValue = value; + break; + + case 'href': + hrefValue = value; + break; + } + + if (hrefValue && relValue) { + switch (relValue) { + case 'stylesheet': + // + hrefList.push(hrefValue); + break; + + case 'preconnect': + // + existingPreconnect.add(hrefValue.replace(/\/$/, '')); + break; + } + + return; + } } }); await new Promise((resolve) => collectorStream.on('finish', resolve)); // Download stylesheets - const hrefsContent = await this.processHrefs(hrefList); + const hrefsContent = new Map(); + const newPreconnectUrls = new Set(); + + for (const hrefItem of hrefList) { + const url = this.createNormalizedUrl(hrefItem); + if (!url) { + continue; + } + + const content = await this.processHref(url); + if (content === undefined) { + continue; + } + + hrefsContent.set(hrefItem, content); + + // Add preconnect + const preconnectUrl = this.getFontProviderDetails(url)?.preconnectUrl; + if (preconnectUrl && !existingPreconnect.has(preconnectUrl)) { + newPreconnectUrls.add(preconnectUrl); + } + } + if (hrefsContent.size === 0) { return content; } @@ -71,21 +129,31 @@ export class InlineFontsProcessor { rewriter.on('startTag', (tag) => { const { tagName, attrs } = tag; - if (tagName !== 'link') { - rewriter.emitStartTag(tag); - - return; - } - - const hrefAttr = - attrs.some(({ name, value }) => name === 'rel' && value === 'stylesheet') && - attrs.find(({ name, value }) => name === 'href' && hrefsContent.has(value)); - if (hrefAttr) { - const href = hrefAttr.value; - const cssContent = hrefsContent.get(href); - rewriter.emitRaw(``); - } else { - rewriter.emitStartTag(tag); + switch (tagName) { + case 'head': + rewriter.emitStartTag(tag); + for (const url of newPreconnectUrls) { + rewriter.emitRaw(``); + } + break; + + case 'link': + const hrefAttr = + attrs.some(({ name, value }) => name === 'rel' && value === 'stylesheet') && + attrs.find(({ name, value }) => name === 'href' && hrefsContent.has(value)); + if (hrefAttr) { + const href = hrefAttr.value; + const cssContent = hrefsContent.get(href); + rewriter.emitRaw(``); + } else { + rewriter.emitStartTag(tag); + } + break; + + default: + rewriter.emitStartTag(tag); + + break; } }); @@ -152,47 +220,50 @@ export class InlineFontsProcessor { return data; } - private async processHrefs(hrefList: string[]): Promise> { - const hrefsContent = new Map(); + private async processHref(url: URL): Promise { + const provider = this.getFontProviderDetails(url); + if (!provider) { + return undefined; + } - for (const hrefPath of hrefList) { - // Need to convert '//' to 'https://' because the URL parser will fail with '//'. - const normalizedHref = hrefPath.startsWith('//') ? `https:${hrefPath}` : hrefPath; - if (!normalizedHref.startsWith('http')) { - // Non valid URL. - // Example: relative path styles.css. - continue; - } + // The order IE -> Chrome is important as otherwise Chrome will load woff1. + let cssContent = ''; + if (this.options.WOFFSupportNeeded && provider.seperateRequestForWOFF) { + cssContent += await this.getResponse(url, UserAgent.IE); + } - const url = new URL(normalizedHref); - // Force HTTPS protocol - url.protocol = 'https:'; + cssContent += await this.getResponse(url, UserAgent.Chrome); - if (!SUPPORTED_PROVIDERS.includes(url.hostname)) { - // Provider not supported. - continue; - } + if (this.options.minify) { + cssContent = cssContent + // Comments. + .replace(/\/\*([\s\S]*?)\*\//g, '') + // New lines. + .replace(/\n/g, '') + // Safe spaces. + .replace(/\s?[\{\:\;]\s+/g, (s) => s.trim()); + } - // The order IE -> Chrome is important as otherwise Chrome will load woff1. - let cssContent = ''; - if (this.options.WOFFSupportNeeded) { - cssContent += await this.getResponse(url, UserAgent.IE); - } - cssContent += await this.getResponse(url, UserAgent.Chrome); - - if (this.options.minify) { - cssContent = cssContent - // Comments. - .replace(/\/\*([\s\S]*?)\*\//g, '') - // New lines. - .replace(/\n/g, '') - // Safe spaces. - .replace(/\s?[\{\:\;]\s+/g, (s) => s.trim()); - } + return cssContent; + } + + private getFontProviderDetails(url: URL): FontProviderDetails | undefined { + return SUPPORTED_PROVIDERS[url.hostname]; + } - hrefsContent.set(hrefPath, cssContent); + private createNormalizedUrl(value: string): URL | undefined { + // Need to convert '//' to 'https://' because the URL parser will fail with '//'. + const normalizedHref = value.startsWith('//') ? `https:${value}` : value; + if (!normalizedHref.startsWith('http')) { + // Non valid URL. + // Example: relative path styles.css. + return undefined; } - return hrefsContent; + const url = new URL(normalizedHref); + // Force HTTPS protocol + url.protocol = 'https:'; + + return url; } } diff --git a/packages/angular_devkit/build_angular/src/utils/index-file/inline-fonts_spec.ts b/packages/angular_devkit/build_angular/src/utils/index-file/inline-fonts_spec.ts index c69d25a0380e..46a7db401c4d 100644 --- a/packages/angular_devkit/build_angular/src/utils/index-file/inline-fonts_spec.ts +++ b/packages/angular_devkit/build_angular/src/utils/index-file/inline-fonts_spec.ts @@ -9,7 +9,8 @@ import { InlineFontsProcessor } from './inline-fonts'; describe('InlineFontsProcessor', () => { - const content = ` + describe('Google fonts', () => { + const content = ` @@ -17,13 +18,23 @@ describe('InlineFontsProcessor', () => { `; - it('should inline supported fonts and icons in HTML', async () => { - const inlineFontsProcessor = new InlineFontsProcessor({ - minify: false, - WOFFSupportNeeded: false, + it('works with // protocol', async () => { + const inlineFontsProcessor = new InlineFontsProcessor({ + WOFFSupportNeeded: false, + minify: false, + }); + + const html = await inlineFontsProcessor.process(content.replace('https://', '//')); + expect(html).toContain(`format('woff2');`); }); - const html = await inlineFontsProcessor.process(` + it('should inline supported fonts and icons in HTML', async () => { + const inlineFontsProcessor = new InlineFontsProcessor({ + minify: false, + WOFFSupportNeeded: false, + }); + + const html = await inlineFontsProcessor.process(` @@ -33,20 +44,22 @@ describe('InlineFontsProcessor', () => { `); - expect(html).not.toContain('href="https://fonts.googleapis.com/css?family=Roboto:300,400,500"'); - expect(html).not.toContain('href="https://fonts.googleapis.com/icon?family=Material+Icons"'); - expect(html).toContain('href="theme.css"'); - expect(html).toContain(`font-family: 'Roboto'`); - expect(html).toContain(`font-family: 'Material Icons'`); - }); - - it('should inline multiple fonts from a single request with minification enabled', async () => { - const inlineFontsProcessor = new InlineFontsProcessor({ - minify: true, - WOFFSupportNeeded: false, + expect(html).not.toContain( + 'href="https://fonts.googleapis.com/css?family=Roboto:300,400,500"', + ); + expect(html).not.toContain('href="https://fonts.googleapis.com/icon?family=Material+Icons"'); + expect(html).toContain('href="theme.css"'); + expect(html).toContain(`font-family: 'Roboto'`); + expect(html).toContain(`font-family: 'Material Icons'`); }); - const html = await inlineFontsProcessor.process(` + it('should inline multiple fonts from a single request with minification enabled', async () => { + const inlineFontsProcessor = new InlineFontsProcessor({ + minify: true, + WOFFSupportNeeded: false, + }); + + const html = await inlineFontsProcessor.process(` @@ -55,51 +68,86 @@ describe('InlineFontsProcessor', () => { `); - expect(html).toContain(`'Google Sans'`); - expect(html).toContain(`'Roboto'`); - expect(html).toContain(`'Roboto Mono'`); - expect(html).toContain(`'Material Icons'`); - }); + expect(html).toContain(`'Google Sans'`); + expect(html).toContain(`'Roboto'`); + expect(html).toContain(`'Roboto Mono'`); + expect(html).toContain(`'Material Icons'`); + }); + + it('works with http protocol', async () => { + const inlineFontsProcessor = new InlineFontsProcessor({ + WOFFSupportNeeded: false, + minify: false, + }); - it('works with http protocol', async () => { - const inlineFontsProcessor = new InlineFontsProcessor({ - WOFFSupportNeeded: false, - minify: false, + const html = await inlineFontsProcessor.process(content.replace('https://', 'http://')); + expect(html).toContain(`format('woff2');`); }); - const html = await inlineFontsProcessor.process(content.replace('https://', 'http://')); - expect(html).toContain(`format('woff2');`); - }); + it('should include WOFF1 definitions when `WOFF1SupportNeeded` is true', async () => { + const inlineFontsProcessor = new InlineFontsProcessor({ + WOFFSupportNeeded: true, + minify: false, + }); - it('works with // protocol', async () => { - const inlineFontsProcessor = new InlineFontsProcessor({ - WOFFSupportNeeded: false, - minify: false, + const html = await inlineFontsProcessor.process(content); + expect(html).toContain(`format('woff2');`); + expect(html).toContain(`format('woff');`); }); - const html = await inlineFontsProcessor.process(content.replace('https://', '//')); - expect(html).toContain(`format('woff2');`); - }); + it('should remove comments and line breaks when `minifyInlinedCSS` is true', async () => { + const inlineFontsProcessor = new InlineFontsProcessor({ + WOFFSupportNeeded: false, + minify: true, + }); - it('should include WOFF1 definitions when `WOFF1SupportNeeded` is true', async () => { - const inlineFontsProcessor = new InlineFontsProcessor({ - WOFFSupportNeeded: true, - minify: false, + const html = await inlineFontsProcessor.process(content); + expect(html).not.toContain('/*'); + expect(html).toContain(';font-style:normal;'); }); - const html = await inlineFontsProcessor.process(content); - expect(html).toContain(`format('woff2');`); - expect(html).toContain(`format('woff');`); + it('should add preconnect hint', async () => { + const inlineFontsProcessor = new InlineFontsProcessor({ + WOFFSupportNeeded: true, + minify: false, + }); + + const html = await inlineFontsProcessor.process(content); + expect(html).toContain( + ``, + ); + }); }); - it('should remove comments and line breaks when `minifyInlinedCSS` is true', async () => { - const inlineFontsProcessor = new InlineFontsProcessor({ - WOFFSupportNeeded: false, - minify: true, + describe('Adobe Typekit fonts', () => { + const content = ` + + + + + + `; + + it('should include WOFF1 definitions when `WOFF1SupportNeeded` is true', async () => { + const inlineFontsProcessor = new InlineFontsProcessor({ + WOFFSupportNeeded: true, + minify: false, + }); + + const html = await inlineFontsProcessor.process(content); + expect(html).not.toContain('href="https://use.typekit.net/plm1izr.css"'); + expect(html).toContain(`format("woff2")`); + expect(html).toContain(`format("woff")`); }); - const html = await inlineFontsProcessor.process(content); - expect(html).not.toContain('/*'); - expect(html).toContain(';font-style:normal;'); + it('should add preconnect hint', async () => { + const inlineFontsProcessor = new InlineFontsProcessor({ + WOFFSupportNeeded: true, + minify: false, + }); + + const html = await inlineFontsProcessor.process(content); + expect(html).toContain(``); + }); }); });