Skip to content

feat(@angular-devkit/build-angular): add support to inline Adobe Fonts #21189

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 28, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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