Next.js website for Rocky Mountain Vending company featuring: - Product catalog with Stripe integration - Service areas and parts pages - Admin dashboard with Clerk authentication - SEO optimized pages with JSON-LD structured data Co-authored-by: Cursor <cursoragent@cursor.com>
358 lines
13 KiB
Text
358 lines
13 KiB
Text
/**
|
|
* @license
|
|
* Copyright 2020 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
/**
|
|
* @fileoverview Checks to see if the size of the visible images used on
|
|
* the page are large enough with respect to the pixel ratio. The
|
|
* audit will list all visible images that are too small.
|
|
*/
|
|
|
|
|
|
import {Audit} from './audit.js';
|
|
import {ImageRecords} from '../computed/image-records.js';
|
|
import {NetworkRecords} from '../computed/network-records.js';
|
|
import UrlUtils from '../lib/url-utils.js';
|
|
import * as i18n from '../lib/i18n/i18n.js';
|
|
|
|
/** @typedef {LH.Artifacts.ImageElement & Required<Pick<LH.Artifacts.ImageElement, 'naturalDimensions'>>} ImageWithNaturalDimensions */
|
|
|
|
const UIStrings = {
|
|
/** Title of a Lighthouse audit that provides detail on the size of visible images on the page. This descriptive title is shown to users when all images have correct sizes. */
|
|
title: 'Serves images with appropriate resolution',
|
|
/** Title of a Lighthouse audit that provides detail on the size of visible images on the page. This descriptive title is shown to users when not all images have correct sizes. */
|
|
failureTitle: 'Serves images with low resolution',
|
|
/** Description of a Lighthouse audit that tells the user why they should maintain an appropriate size for all images. This is displayed after a user expands the section to see more. No character length limits. The last sentence starting with 'Learn' becomes link text to additional documentation. */
|
|
description: 'Image natural dimensions should be proportional to the display size and the ' +
|
|
'pixel ratio to maximize image clarity. ' +
|
|
'[Learn how to provide responsive images](https://web.dev/articles/serve-responsive-images).',
|
|
/** Label for a column in a data table; entries in the column will be a string representing the displayed size of the image. */
|
|
columnDisplayed: 'Displayed size',
|
|
/** Label for a column in a data table; entries in the column will be a string representing the actual size of the image. */
|
|
columnActual: 'Actual size',
|
|
/** Label for a column in a data table; entries in the column will be a string representing the expected size of the image. */
|
|
columnExpected: 'Expected size',
|
|
};
|
|
|
|
const str_ = i18n.createIcuMessageFn(import.meta.url, UIStrings);
|
|
|
|
// Factors used to allow for smaller effective density.
|
|
// A factor of 1 means the actual device pixel density will be used.
|
|
// A factor of 0.5, means half the density is required. For example if the device pixel ratio is 3,
|
|
// then the images should have at least a density of 1.5.
|
|
const SMALL_IMAGE_FACTOR = 1.0;
|
|
const LARGE_IMAGE_FACTOR = 0.75;
|
|
|
|
// An image has must have both its dimensions lower or equal to the threshold in order to be
|
|
// considered SMALL.
|
|
const SMALL_IMAGE_THRESHOLD = 64;
|
|
|
|
/** @typedef {{url: string, node: LH.Audit.Details.NodeValue, displayedSize: string, actualSize: string, actualPixels: number, expectedSize: string, expectedPixels: number}} Result */
|
|
|
|
/**
|
|
* @param {{top: number, bottom: number, left: number, right: number}} imageRect
|
|
* @param {{innerWidth: number, innerHeight: number}} viewportDimensions
|
|
* @return {boolean}
|
|
*/
|
|
function isVisible(imageRect, viewportDimensions) {
|
|
return (
|
|
(imageRect.bottom - imageRect.top) * (imageRect.right - imageRect.left) > 0 &&
|
|
imageRect.top <= viewportDimensions.innerHeight &&
|
|
imageRect.bottom >= 0 &&
|
|
imageRect.left <= viewportDimensions.innerWidth &&
|
|
imageRect.right >= 0
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param {{top: number, bottom: number, left: number, right: number}} imageRect
|
|
* @param {{innerWidth: number, innerHeight: number}} viewportDimensions
|
|
* @return {boolean}
|
|
*/
|
|
function isSmallerThanViewport(imageRect, viewportDimensions) {
|
|
return (
|
|
(imageRect.bottom - imageRect.top) <= viewportDimensions.innerHeight &&
|
|
(imageRect.right - imageRect.left) <= viewportDimensions.innerWidth
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @param {LH.Artifacts.ImageElement} image
|
|
* @param {LH.Artifacts.ImageElementRecord | undefined} imageRecord
|
|
* @return {boolean}
|
|
*/
|
|
function isCandidate(image, imageRecord) {
|
|
/** image-rendering solution for pixel art scaling.
|
|
* https://developer.mozilla.org/en-US/docs/Games/Techniques/Crisp_pixel_art_look
|
|
*/
|
|
const artisticImageRenderingValues = ['pixelated', 'crisp-edges'];
|
|
// https://html.spec.whatwg.org/multipage/images.html#pixel-density-descriptor
|
|
const densityDescriptorRegex = / \d+(\.\d+)?x/;
|
|
if (image.displayedWidth <= 1 || image.displayedHeight <= 1) {
|
|
return false;
|
|
}
|
|
if (
|
|
!image.naturalDimensions ||
|
|
!image.naturalDimensions.width ||
|
|
!image.naturalDimensions.height
|
|
) {
|
|
return false;
|
|
}
|
|
// Check the actual mimeType before guessing, since file extension is not guaranteed
|
|
if (imageRecord?.mimeType === 'image/svg+xml') {
|
|
return false;
|
|
}
|
|
if (UrlUtils.guessMimeType(image.src) === 'image/svg+xml') {
|
|
return false;
|
|
}
|
|
if (image.isCss) {
|
|
return false;
|
|
}
|
|
if (image.computedStyles.objectFit !== 'fill') {
|
|
return false;
|
|
}
|
|
// Check if pixel art scaling is used.
|
|
if (artisticImageRenderingValues.includes(image.computedStyles.imageRendering)) {
|
|
return false;
|
|
}
|
|
// Check if density descriptor is used.
|
|
if (densityDescriptorRegex.test(image.srcset)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Type check to ensure that the ImageElement has natural dimensions.
|
|
*
|
|
* @param {LH.Artifacts.ImageElement} image
|
|
* @return {image is ImageWithNaturalDimensions}
|
|
*/
|
|
function imageHasNaturalDimensions(image) {
|
|
return !!image.naturalDimensions;
|
|
}
|
|
|
|
/**
|
|
* @param {ImageWithNaturalDimensions} image
|
|
* @param {number} DPR
|
|
* @return {boolean}
|
|
*/
|
|
function imageHasRightSize(image, DPR) {
|
|
const [expectedWidth, expectedHeight] =
|
|
allowedImageSize(image.displayedWidth, image.displayedHeight, DPR);
|
|
return image.naturalDimensions.width >= expectedWidth &&
|
|
image.naturalDimensions.height >= expectedHeight;
|
|
}
|
|
|
|
/**
|
|
* @param {ImageWithNaturalDimensions} image
|
|
* @param {number} DPR
|
|
* @return {Result}
|
|
*/
|
|
function getResult(image, DPR) {
|
|
const [expectedWidth, expectedHeight] =
|
|
expectedImageSize(image.displayedWidth, image.displayedHeight, DPR);
|
|
return {
|
|
url: UrlUtils.elideDataURI(image.src),
|
|
node: Audit.makeNodeItem(image.node),
|
|
displayedSize: `${image.displayedWidth} x ${image.displayedHeight}`,
|
|
actualSize: `${image.naturalDimensions.width} x ${image.naturalDimensions.height}`,
|
|
actualPixels: image.naturalDimensions.width * image.naturalDimensions.height,
|
|
expectedSize: `${expectedWidth} x ${expectedHeight}`,
|
|
expectedPixels: expectedWidth * expectedHeight,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Compute the size an image should have given the display dimensions and pixel density in order to
|
|
* pass the audit.
|
|
*
|
|
* For smaller images, typically icons, the size must be proportional to the density.
|
|
* For larger images some tolerance is allowed as in those cases the perceived degradation is not
|
|
* that bad.
|
|
*
|
|
* @param {number} displayedWidth
|
|
* @param {number} displayedHeight
|
|
* @param {number} DPR
|
|
* @return {[number, number]}
|
|
*/
|
|
function allowedImageSize(displayedWidth, displayedHeight, DPR) {
|
|
let factor = SMALL_IMAGE_FACTOR;
|
|
if (displayedWidth > SMALL_IMAGE_THRESHOLD || displayedHeight > SMALL_IMAGE_THRESHOLD) {
|
|
factor = LARGE_IMAGE_FACTOR;
|
|
}
|
|
const requiredDpr = quantizeDpr(DPR);
|
|
const width = Math.ceil(factor * requiredDpr * displayedWidth);
|
|
const height = Math.ceil(factor * requiredDpr * displayedHeight);
|
|
return [width, height];
|
|
}
|
|
|
|
/**
|
|
* Compute the size an image should have given the display dimensions and pixel density.
|
|
*
|
|
* @param {number} displayedWidth
|
|
* @param {number} displayedHeight
|
|
* @param {number} DPR
|
|
* @return {[number, number]}
|
|
*/
|
|
function expectedImageSize(displayedWidth, displayedHeight, DPR) {
|
|
const width = Math.ceil(quantizeDpr(DPR) * displayedWidth);
|
|
const height = Math.ceil(quantizeDpr(DPR) * displayedHeight);
|
|
return [width, height];
|
|
}
|
|
|
|
/**
|
|
* Remove repeated entries for the same source.
|
|
*
|
|
* It will keep the entry with the largest expected size.
|
|
*
|
|
* @param {Result[]} results
|
|
* @return {Result[]}
|
|
*/
|
|
function deduplicateResultsByUrl(results) {
|
|
results.sort((a, b) => a.url === b.url ? 0 : (a.url < b. url ? -1 : 1));
|
|
/** @type {Result[]} */
|
|
const deduplicated = [];
|
|
for (const r of results) {
|
|
const previousResult = deduplicated[deduplicated.length - 1];
|
|
if (previousResult && previousResult.url === r.url) {
|
|
// If the URL was the same, this is a duplicate. Keep the largest image.
|
|
if (previousResult.expectedPixels < r.expectedPixels) {
|
|
deduplicated[deduplicated.length - 1] = r;
|
|
}
|
|
} else {
|
|
deduplicated.push(r);
|
|
}
|
|
}
|
|
return deduplicated;
|
|
}
|
|
|
|
/**
|
|
* Sort entries in descending order by the magnitude of the size deficit, i.e. most pressing issues listed first.
|
|
*
|
|
* @param {Result[]} results
|
|
* @return {Result[]}
|
|
*/
|
|
function sortResultsBySizeDelta(results) {
|
|
return results.sort(
|
|
(a, b) => (b.expectedPixels - b.actualPixels) - (a.expectedPixels - a.actualPixels));
|
|
}
|
|
|
|
class ImageSizeResponsive extends Audit {
|
|
/**
|
|
* @return {LH.Audit.Meta}
|
|
*/
|
|
static get meta() {
|
|
return {
|
|
id: 'image-size-responsive',
|
|
title: str_(UIStrings.title),
|
|
failureTitle: str_(UIStrings.failureTitle),
|
|
description: str_(UIStrings.description),
|
|
requiredArtifacts: ['ImageElements', 'ViewportDimensions'],
|
|
__internalOptionalArtifacts: ['DevtoolsLog'],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @param {LH.Artifacts} artifacts
|
|
* @param {LH.Audit.Context} context
|
|
* @return {Promise<LH.Audit.Product>}
|
|
*/
|
|
static async audit(artifacts, context) {
|
|
const DPR = artifacts.ViewportDimensions.devicePixelRatio;
|
|
|
|
// Prepare ImageElementRecord map for retrieving the real mimeType
|
|
// Derived from ./is-on-https.js and ./byte-efficiency/uses-responsive-images.js
|
|
/** @type {Map<string, LH.Artifacts.ImageElementRecord>} */
|
|
const imageRecordsByURL = new Map();
|
|
|
|
if (artifacts.DevtoolsLog) {
|
|
// https://github.com/GoogleChrome/lighthouse/blob/main/docs/plugins.md#using-network-requests
|
|
// if DevtoolsLog is provided, use it to fetch image networkRecords
|
|
// else the empty imageRecordsByURL map will satisfy isCandidate with an undefined image and fallback to the original logic
|
|
const networkRecords = await NetworkRecords.request(artifacts.DevtoolsLog, context);
|
|
const images = await ImageRecords.request({
|
|
ImageElements: artifacts.ImageElements,
|
|
networkRecords,
|
|
}, context);
|
|
images.forEach(img => imageRecordsByURL.set(img.src, img));
|
|
}
|
|
|
|
const results = Array
|
|
.from(artifacts.ImageElements)
|
|
.filter(image => isCandidate(image, imageRecordsByURL.get(image.src)))
|
|
.filter(imageHasNaturalDimensions)
|
|
.filter(image => !imageHasRightSize(image, DPR))
|
|
.filter(image => isVisible(image.clientRect, artifacts.ViewportDimensions))
|
|
.filter(image => isSmallerThanViewport(image.clientRect, artifacts.ViewportDimensions))
|
|
.map(image => getResult(image, DPR));
|
|
|
|
/** @type {LH.Audit.Details.Table['headings']} */
|
|
const headings = [
|
|
{key: 'node', valueType: 'node', label: ''},
|
|
{key: 'url', valueType: 'url', label: str_(i18n.UIStrings.columnURL)},
|
|
{key: 'displayedSize', valueType: 'text', label: str_(UIStrings.columnDisplayed)},
|
|
{key: 'actualSize', valueType: 'text', label: str_(UIStrings.columnActual)},
|
|
{key: 'expectedSize', valueType: 'text', label: str_(UIStrings.columnExpected)},
|
|
];
|
|
|
|
const finalResults = sortResultsBySizeDelta(deduplicateResultsByUrl(results));
|
|
|
|
return {
|
|
score: Number(results.length === 0),
|
|
details: Audit.makeTableDetails(headings, finalResults),
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return a quantized version of the DPR.
|
|
*
|
|
* This is to relax the required size of the image.
|
|
* There's strong evidence that 3 DPR images are not perceived to be significantly better to mobile users than
|
|
* 2 DPR images. The additional high byte cost (3x images are ~225% the file size of 2x images) makes this practice
|
|
* difficult to recommend.
|
|
*
|
|
* Human minimum visual acuity angle = 0.016 degrees (see Sun Microsystems paper)
|
|
* Typical phone operating distance from eye = 12 in
|
|
*
|
|
* A
|
|
* _
|
|
* \ | B
|
|
* \|
|
|
* θ
|
|
* A = minimum observable pixel size = ?
|
|
* B = viewing distance = 12 in
|
|
* θ = human minimum visual acuity angle = 0.016 degrees
|
|
*
|
|
* tan θ = A / B ---- Solve for A
|
|
* A = tan (0.016 degrees) * B = 0.00335 in
|
|
*
|
|
* Moto G4 display width = 2.7 in
|
|
* Moto G4 horizontal 2x resolution = 720 pixels
|
|
* Moto G4 horizontal 3x resolution = 1080 pixels
|
|
*
|
|
* Moto G4 1x pixel size = 2.7 / 360 = 0.0075 in
|
|
* Moto G4 2x pixel size = 2.7 / 720 = 0.00375 in
|
|
* Moto G4 3x pixel size = 2.7 / 1080 = 0.0025 in
|
|
*
|
|
* Wasted additional pixels in 3x image = (.00335 - .0025) / (.00375 - .0025) = 68% waste
|
|
*
|
|
*
|
|
* @see https://www.swift.ac.uk/about/files/vision.pdf
|
|
* @param {number} dpr
|
|
* @return {number}
|
|
*/
|
|
function quantizeDpr(dpr) {
|
|
if (dpr >= 2) {
|
|
return 2;
|
|
}
|
|
if (dpr >= 1.5) {
|
|
return 1.5;
|
|
}
|
|
return 1.0;
|
|
}
|
|
|
|
export default ImageSizeResponsive;
|
|
export {UIStrings};
|