-
Notifications
You must be signed in to change notification settings - Fork 9.4k
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
new-audit(font-display): enforce font-display optional #3831
Changes from 20 commits
ea05b81
159ab99
79e771c
cdc36f5
46f771b
4da7368
012be10
3294b42
090c650
ae18d66
20d036e
62b9cea
7d00459
9f32363
da7277b
9782386
0d2f4a3
d08c5fb
600675a
c1e6405
8122f7e
95af1d2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
<html> | ||
<head> | ||
<meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1"> | ||
<style> | ||
@font-face { | ||
font-family: 'Lobster'; | ||
font-style: normal; | ||
font-weight: 400; | ||
src: local('Lobster'), url('./lobster-v20-latin-regular.eot?#iefix') format('eot'), url('./lobster-v20-latin-regular.woff2') format('woff2'); | ||
} | ||
@font-face { | ||
font-family: 'Lobster Two'; | ||
font-style: normal; | ||
font-weight: 700; | ||
font-display: optional; | ||
src: local("Lobster Two"), url("./lobster-two-v10-latin-700.woff2?delay=4000") format('woff2'); | ||
} | ||
.webfont { | ||
font-family: Lobster, sans-serif; | ||
} | ||
strong.webfont { | ||
font-family: Lobster Two, sans-serif; | ||
} | ||
.nofont { | ||
font-family: Unknown, sans-serif; | ||
} | ||
</style> | ||
</head> | ||
<body> | ||
<p class="webfont">Let's load some sweet webfonts...</p> | ||
<p><strong class="webfont">Let's load some sweet webfonts...</strong></p> | ||
<p class"nofont">Some lovely text that uses the fallback font</p> | ||
</body> | ||
</html> |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,80 @@ | ||
/** | ||
* @license Copyright 2017 Google Inc. All Rights Reserved. | ||
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 | ||
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. | ||
*/ | ||
'use strict'; | ||
|
||
const Audit = require('./audit'); | ||
const Util = require('../report/v2/renderer/util'); | ||
const WebInspector = require('../lib/web-inspector'); | ||
const allowedFontFaceDisplays = ['block', 'fallback', 'optional', 'swap']; | ||
|
||
class FontDisplay extends Audit { | ||
/** | ||
* @return {!AuditMeta} | ||
*/ | ||
static get meta() { | ||
return { | ||
name: 'font-display', | ||
description: 'All text remains visible during webfont loads', | ||
failureDescription: 'Avoid invisible text while webfonts are loading', | ||
helpText: 'Leverage the font-display CSS feature to ensure text is user-visible while ' + | ||
'webfonts are loading.', | ||
requiredArtifacts: ['devtoolsLogs', 'Fonts'], | ||
}; | ||
} | ||
|
||
/** | ||
* @param {!Artifacts} artifacts | ||
* @return {!AuditResult} | ||
*/ | ||
static audit(artifacts) { | ||
const devtoolsLogs = artifacts.devtoolsLogs[this.DEFAULT_PASS]; | ||
const fontFaces = artifacts.Fonts; | ||
|
||
// Filter font-faces that do not have a display tag with optional or swap | ||
const fontsWithoutProperDisplay = fontFaces.filter(fontFace => | ||
!fontFace.display || !allowedFontFaceDisplays.includes(fontFace.display) | ||
); | ||
|
||
return artifacts.requestNetworkRecords(devtoolsLogs).then((networkRecords) => { | ||
const results = networkRecords.filter(record => { | ||
const isFont = record._resourceType === WebInspector.resourceTypes.Font; | ||
|
||
return isFont; | ||
}) | ||
.filter(fontRecord => { | ||
// find the fontRecord of a font | ||
return !!fontsWithoutProperDisplay.find(fontFace => { | ||
return fontFace.src.find(src => fontRecord.url === src); | ||
}); | ||
}) | ||
// calculate wasted time | ||
.map(record => { | ||
// In reality the end time should be calculated with paint time included | ||
// all browsers wait 3000ms to block text so we make sure 3000 is our max wasted time | ||
const wastedTime = Math.min((record._endTime - record._startTime) * 1000, 3000); | ||
|
||
return { | ||
url: record.url, | ||
wastedTime: Util.formatMilliseconds(wastedTime, 1), | ||
}; | ||
}); | ||
|
||
const headings = [ | ||
{key: 'url', itemType: 'url', text: 'Font URL'}, | ||
{key: 'wastedTime', itemType: 'text', text: 'Font download time'}, | ||
]; | ||
const details = Audit.makeTableDetails(headings, results); | ||
|
||
return { | ||
score: results.length === 0, | ||
rawValue: results.length === 0, | ||
details, | ||
}; | ||
}); | ||
} | ||
} | ||
|
||
module.exports = FontDisplay; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,157 @@ | ||
/** | ||
* @license Copyright 2017 Google Inc. All Rights Reserved. | ||
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 | ||
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. | ||
*/ | ||
'use strict'; | ||
|
||
const Gatherer = require('./gatherer'); | ||
const fontFaceDescriptors = [ | ||
'display', | ||
'family', | ||
'featureSettings', | ||
'stretch', | ||
'style', | ||
'unicodeRange', | ||
'variant', | ||
'weight', | ||
]; | ||
|
||
/* eslint-env browser*/ | ||
/** | ||
* Collect applied webfont data from `document.fonts` | ||
* @param {string[]} | ||
* @return {{}} | ||
*/ | ||
/* istanbul ignore next */ | ||
function getAllLoadedFonts(descriptors) { | ||
const getFont = fontFace => { | ||
const fontRule = {}; | ||
descriptors.forEach(descriptor => { | ||
fontRule[descriptor] = fontFace[descriptor]; | ||
}); | ||
|
||
return fontRule; | ||
}; | ||
|
||
return document.fonts.ready.then(() => { | ||
return Array.from(document.fonts).filter(fontFace => fontFace.status === 'loaded') | ||
.map(getFont); | ||
}); | ||
} | ||
|
||
/** | ||
* Collect authored webfont data from the `CSSFontFaceRule`s present in document.styleSheets | ||
* @return {{}} | ||
*/ | ||
/* istanbul ignore next */ | ||
function getFontFaceFromStylesheets() { | ||
/** | ||
* Get full data about each CSSFontFaceRule within a styleSheet object | ||
* @param {StyleSheet} stylesheet | ||
* @return {{}} | ||
*/ | ||
function getSheetsFontFaces(stylesheet) { | ||
const fontUrlRegex = 'url\\((?:")([^"]+)(?:"|\')\\)'; | ||
const fontFaceRules = []; | ||
if (stylesheet.cssRules) { | ||
for (const rule of stylesheet.cssRules) { | ||
if (rule instanceof CSSFontFaceRule) { | ||
const fontsObject = { | ||
display: rule.style.fontDisplay || 'auto', | ||
family: rule.style.fontFamily.replace(/"|'/g, ''), | ||
stretch: rule.style.fontStretch || 'normal', | ||
style: rule.style.fontStyle || 'normal', | ||
weight: rule.style.fontWeight || 'normal', | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. from https://github.com/GoogleChrome/lighthouse/pull/3831/files/bf0d8e3cc3c9a16b93ff26b59048563193db85be#r159987426 i think you still need to collect There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. lgtm |
||
variant: rule.style.fontVariant || 'normal', | ||
unicodeRange: rule.style.unicodeRange || 'U+0-10FFFF', | ||
featureSettings: rule.style.featureSettings || 'normal', | ||
src: [], | ||
}; | ||
|
||
if (rule.style.src) { | ||
const matches = rule.style.src.match(new RegExp(fontUrlRegex, 'g')); | ||
if (matches) { | ||
fontsObject.src = matches.map(match => { | ||
const res = new RegExp(fontUrlRegex).exec(match); | ||
return new URL(res[1], location.href).href; | ||
}); | ||
} | ||
} | ||
|
||
fontFaceRules.push(fontsObject); | ||
} | ||
} | ||
} | ||
|
||
return fontFaceRules; | ||
} | ||
|
||
/** | ||
* Provided a <link rel=stylesheet> element, it attempts to reload the asset with CORS headers. | ||
* Without CORS headers, a cross-origin stylesheet will have node.styleSheet.cssRules === null. | ||
* @param {Element} oldNode | ||
* @return {<!Promise>} | ||
*/ | ||
function loadStylesheetWithCORS(oldNode) { | ||
const newNode = oldNode.cloneNode(true); | ||
|
||
return new Promise(resolve => { | ||
newNode.addEventListener('load', function onload() { | ||
newNode.removeEventListener('load', onload); | ||
resolve(getFontFaceFromStylesheets()); | ||
}); | ||
newNode.crossOrigin = 'anonymous'; | ||
oldNode.parentNode.insertBefore(newNode, oldNode); | ||
oldNode.remove(); | ||
}); | ||
} | ||
|
||
const promises = []; | ||
// Get all loaded stylesheets | ||
for (const stylesheet of document.styleSheets) { | ||
try { | ||
// Cross-origin stylesheets don't expose cssRules by default. We reload them w/ CORS headers. | ||
if (stylesheet.cssRules === null && stylesheet.href && stylesheet.ownerNode && | ||
!stylesheet.ownerNode.crossOrigin) { | ||
promises.push(loadStylesheetWithCORS(stylesheet.ownerNode)); | ||
} else { | ||
promises.push(Promise.resolve(getSheetsFontFaces(stylesheet))); | ||
} | ||
} catch (err) { | ||
promises.push(loadStylesheetWithCORS(stylesheet.ownerNode)); | ||
} | ||
} | ||
// Flatten results | ||
return Promise.all(promises).then(fontFaces => [].concat(...fontFaces)); | ||
} | ||
/* eslint-env node */ | ||
|
||
class Fonts extends Gatherer { | ||
_findSameFontFamily(fontFace, fontFacesList) { | ||
return fontFacesList.find(fontItem => { | ||
return !fontFaceDescriptors.find(descriptor => { | ||
return fontFace[descriptor] !== fontItem[descriptor]; | ||
}); | ||
}); | ||
} | ||
|
||
afterPass({driver}) { | ||
const args = JSON.stringify(fontFaceDescriptors); | ||
return Promise.all( | ||
[ | ||
driver.evaluateAsync(`(${getAllLoadedFonts.toString()})(${args})`), | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ❤️ |
||
driver.evaluateAsync(`(${getFontFaceFromStylesheets.toString()})()`), | ||
] | ||
).then(([loadedFonts, fontFaces]) => { | ||
return loadedFonts.map(fontFace => { | ||
const fontFaceItem = this._findSameFontFamily(fontFace, fontFaces); | ||
fontFace.src = fontFaceItem.src || []; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. running this on paulirish.com i'm getting an exception here that to help debug, the call into
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. figured it out and wrote the patch. :) diff --git a/lighthouse-core/gather/gatherers/fonts.js b/lighthouse-core/gather/gatherers/fonts.js
index 9b3f82bc..363f8217 100644
--- a/lighthouse-core/gather/gatherers/fonts.js
+++ b/lighthouse-core/gather/gatherers/fonts.js
@@ -23,7 +23,11 @@ function getAllLoadedFonts() {
});
}
function getFontFaceFromStylesheets() {
+ let resolve;
+ const promise = new Promise(fulfill => { resolve = fulfill; });
+
function resolveUrl(url) {
const link = document.createElement('a');
link.href = url;
@@ -34,8 +38,24 @@ function getFontFaceFromStylesheets() {
const fontUrlRegex = new RegExp('url\\((?:"|\')([^"]+)(?:"|\')\\)');
const fontFaceRules = [];
// get all loaded stylesheets
for (let sheet = 0; sheet < document.styleSheets.length; sheet++) {
const stylesheet = document.styleSheets[sheet];
+
+ // Cross-origin stylesheets don't expose cssRules by default. We reload them with CORS headers.
+ if (stylesheet.cssRules === null && stylesheet.href && stylesheet.ownerNode && !stylesheet.ownerNode.crossOrigin) {
+ const oldNode = stylesheet.ownerNode;
+ const newNode = oldNode.cloneNode(true);
+ newNode.addEventListener('load', function onload(){
+ newNode.removeEventListener('load', onload);
+ resolve(getFontFaceFromStylesheets());
+ });
+ newNode.crossOrigin = 'anonymous';
+ oldNode.parentNode.insertBefore(newNode, oldNode);
+ oldNode.remove();
+ return promise;
+ }
+
for (let i = 0; stylesheet.cssRules && i < stylesheet.cssRules.length; i++) {
var rule = stylesheet.cssRules[i];
@@ -61,7 +81,7 @@ function getFontFaceFromStylesheets() {
}
}
- return fontFaceRules;
+ return Promise.resolve(fontFaceRules);
}
/* eslint-enable */
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. separately i think you'll need some error handling for the case that we have a mismatch. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nice catch, let me cleanup this audit a bit :) it was just a WIP. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I did a bit differently as I think the way I did it is more readable. I also got a security exception when running |
||
|
||
return fontFace; | ||
}); | ||
}); | ||
} | ||
} | ||
|
||
module.exports = Fonts; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
let's throw the
/* istanbul ignore next */
on these like paul just did in #4396