|
| 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 | +} |
0 commit comments