Skip to content

Commit

Permalink
feat(@angular-devkit/build-angular): add support to inline Adobe Fonts
Browse files Browse the repository at this point in the history
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
  • Loading branch information
alan-agius4 committed Jun 28, 2021
1 parent 47a1ccc commit 18cfa04
Show file tree
Hide file tree
Showing 3 changed files with 230 additions and 111 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, FontProviderDetails> = {
'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<string> {
const hrefList: string[] = [];
const existingPreconnect = new Set<string>();

// Collector link tags with href
const { rewriter: collectorStream } = await htmlRewritingStream(content);
Expand All @@ -48,20 +63,63 @@ export class InlineFontsProcessor {
return;
}

// <link tag with rel="stylesheet" and a href.
const href =
attrs.find(({ name, value }) => 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':
// <link rel="stylesheet" href="https://example.com/main.css">
hrefList.push(hrefValue);
break;

case 'preconnect':
// <link rel="preconnect" href="https://example.com">
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<string, string>();
const newPreconnectUrls = new Set<string>();

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;
}
Expand All @@ -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(`<style type="text/css">${cssContent}</style>`);
} else {
rewriter.emitStartTag(tag);
switch (tagName) {
case 'head':
rewriter.emitStartTag(tag);
for (const url of newPreconnectUrls) {
rewriter.emitRaw(`<link rel="preconnect" href="${url}" crossorigin>`);
}
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(`<style type="text/css">${cssContent}</style>`);
} else {
rewriter.emitStartTag(tag);
}
break;

default:
rewriter.emitStartTag(tag);

break;
}
});

Expand Down Expand Up @@ -152,47 +220,50 @@ export class InlineFontsProcessor {
return data;
}

private async processHrefs(hrefList: string[]): Promise<Map<string, string>> {
const hrefsContent = new Map<string, string>();
private async processHref(url: URL): Promise<string | undefined> {
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;
}
}
Loading

0 comments on commit 18cfa04

Please sign in to comment.