diff --git a/lib/dom/performance/modernImageFormats.js b/lib/dom/performance/modernImageFormats.js deleted file mode 100644 index 44f4383..0000000 --- a/lib/dom/performance/modernImageFormats.js +++ /dev/null @@ -1,98 +0,0 @@ -(function (util) { - 'use strict'; - // AVIF and WebP routinely deliver 25–50% smaller files than JPEG and - // PNG at the same perceived quality. Sites can ship them via: - // - // * with one or more / - // "image/webp"> elements before the JPEG/PNG fallback ; - // * serving .avif / .webp candidates directly (with - // content-negotiation upstream choosing the right format). - // - // We flag elements that are NOT covered by either of those — - // i.e. plain JPEG/PNG/GIF with no modern alternative offered. - // Inline data: URIs and SVGs are skipped (different concerns). - const LEGACY_EXT_RE = /\.(jpe?g|png|gif|bmp)(?:[?#]|$)/i; - const MODERN_EXT_RE = /\.(avif|webp|jxl)(?:[?#]|$)/i; - - function hasModernSourceSibling(img) { - let parent = img.parentElement; - if (!parent || parent.tagName !== 'PICTURE') { - return false; - } - const sources = parent.querySelectorAll('source'); - for (const source of sources) { - const type = (source.getAttribute('type') || '').toLowerCase(); - if ( - type === 'image/avif' || - type === 'image/webp' || - type === 'image/jxl' - ) { - return true; - } - const srcset = source.getAttribute('srcset') || ''; - if (MODERN_EXT_RE.test(srcset)) { - return true; - } - } - return false; - } - - function hasModernSrcset(img) { - const srcset = img.getAttribute('srcset') || ''; - return MODERN_EXT_RE.test(srcset); - } - - const offending = []; - let legacyOnly = 0; - let total = 0; - const images = document.querySelectorAll('img'); - for (let i = 0, len = images.length; i < len; i++) { - const img = images[i]; - const src = img.currentSrc || img.src || ''; - if (!src || src.indexOf('data:') === 0) { - continue; - } - // The image's own URL is already a modern format — fine. - if (MODERN_EXT_RE.test(src)) { - total++; - continue; - } - // Not a recognised raster format we can speak about (e.g. .svg, - // unknown extension, content-negotiated URL with no extension). - if (!LEGACY_EXT_RE.test(src)) { - continue; - } - total++; - if (hasModernSourceSibling(img) || hasModernSrcset(img)) { - continue; - } - legacyOnly++; - offending.push(util.getAbsoluteURL(src)); - } - - let score = 100; - let advice = ''; - if (total > 0 && legacyOnly > 0) { - const ratio = legacyOnly / total; - score = Math.round(100 * (1 - ratio)); - advice = - 'The page ships ' + - util.plural(legacyOnly, 'image') + - ' (out of ' + - total + - ') in JPEG/PNG/GIF without a modern alternative. Wrap them in a with a or "image/webp" before the legacy , or serve modern formats from your image pipeline directly. AVIF and WebP usually deliver 25–50% smaller files at the same quality.'; - } - - return { - id: 'modernImageFormats', - title: 'Serve images in modern formats (AVIF, WebP)', - description: - 'AVIF and WebP routinely deliver 25–50% smaller files than JPEG and PNG at the same perceived quality, and every browser version still under support understands at least one of them. Ship modern formats either through a element with / "image/webp" entries in front of the legacy , or directly from a content-negotiating image pipeline that returns AVIF / WebP when the client accepts it. https://web.dev/articles/serve-images-webp', - advice: advice, - score: score, - weight: 4, - severity: 'warn', - offending: offending, - tags: ['performance', 'image'] - }; -})(util); diff --git a/lib/dom/util.js b/lib/dom/util.js index 9bff32b..29be5d8 100644 --- a/lib/dom/util.js +++ b/lib/dom/util.js @@ -215,6 +215,30 @@ const util = { ? entries[0].transferSize : 0; }, + + /** + * Get the Content-Type the server really answered with, via + * PerformanceResourceTiming.contentType. The only in-page signal for what + * was delivered when Accept negotiation answers a .jpg URL with webp. + * + * Empty string when we cannot know: browsers without the field, + * unrecognized MIME types, and cross-origin responses without + * Timing-Allow-Origin. Callers must read '' as "no information", + * never as "not a modern format". + * @memberof util + * @param {String} url The absolute URL of the asset. + * @returns {String} the served content type, or an empty string. + **/ + getResourceContentType: function (url) { + const entries = globalThis.performance.getEntriesByName(url, 'resource'); + for (const entry of entries) { + if (typeof entry.contentType === 'string' && entry.contentType !== '') { + return entry.contentType.toLowerCase(); + } + } + return ''; + }, + ms(ms) { return ms < 1000 ? ms + ' ms' : Number(ms / 1000).toFixed(3) + ' s'; } diff --git a/lib/har/performance/modernImageFormats.js b/lib/har/performance/modernImageFormats.js new file mode 100644 index 0000000..4ed8a3e --- /dev/null +++ b/lib/har/performance/modernImageFormats.js @@ -0,0 +1,98 @@ +import * as util from '../util.js'; + +// AVIF and WebP routinely deliver 25-50% smaller files than JPEG and PNG at +// the same perceived quality. This lives on the HAR side rather than in a DOM +// rule because only the HAR knows what was actually delivered: the response +// Content-Type is recorded for every asset whatever its origin, while a page +// script can read PerformanceResourceTiming.contentType for same-origin +// responses only. A URL extension is not the format either - an image +// pipeline negotiating on Accept answers a .jpg URL with image/webp, and +// judging that by the URL reports a site as broken while it is doing the +// right thing. +const LEGACY_CONTENT_TYPES = new Set([ + 'image/jpeg', + 'image/jpg', + 'image/png', + 'image/gif', + 'image/bmp' +]); + +// Below this there is nothing to win: a re-encode saves a few bytes at best, +// and AVIF / WebP container overhead can make a tiny GIF bigger. It is also +// where tracking pixels live - a 42 byte beacon is an image only by content +// type, and "convert your tracking pixel to WebP" is not advice worth giving. +const MIN_SIZE_BYTES = 1024; + +function getSize(asset) { + // transferSize is -1 in HARs that do not record it (DevTools exports), + // so fall back to the decoded body size before giving up. + for (const size of [asset.contentSize, asset.transferSize]) { + if (typeof size === 'number' && size > 0) { + return size; + } + } + return 0; +} + +function getContentType(asset) { + const headers = (asset.headers && asset.headers.response) || {}; + const value = headers['content-type'] && headers['content-type'][0]; + // Strip parameters and normalise: 'Image/JPEG; charset=x' -> 'image/jpeg'. + return value ? value.split(';')[0].trim().toLowerCase() : ''; +} + +export default { + id: 'modernImageFormats', + title: 'Serve images in modern formats (AVIF, WebP)', + description: + 'AVIF and WebP routinely deliver 25-50% smaller files than JPEG and PNG at the same perceived quality, and every browser version still under support understands at least one of them. Ship modern formats through a element with / "image/webp" entries, from a content-negotiating image pipeline that answers with AVIF / WebP when the client accepts it, or straight from modern URLs. This checks the Content-Type actually delivered, so an image negotiated on Accept counts as modern however its URL is spelled. https://web.dev/articles/serve-images-webp', + weight: 4, + severity: 'warn', + tags: ['performance', 'image'], + + processPage: function (page) { + const offending = []; + let legacy = 0; + let total = 0; + + for (const asset of page.assets) { + if (asset.type !== 'image') { + continue; + } + const contentType = getContentType(asset); + // No Content-Type means we cannot say what was delivered, so we say + // nothing rather than guessing from the URL. + if (!contentType) { + continue; + } + // A known-tiny image has nothing to gain from a modern format. An + // unknown size still gets judged, so we do not lose real images to a + // HAR that records no sizes at all. + const size = getSize(asset); + if (size > 0 && size < MIN_SIZE_BYTES) { + continue; + } + total++; + if (LEGACY_CONTENT_TYPES.has(contentType)) { + legacy++; + offending.push(asset.url); + } + } + + let advice = ''; + if (legacy > 0) { + advice = + 'The page delivers ' + + util.plural(legacy, 'image') + + ' (out of ' + + total + + ') as JPEG, PNG or GIF. Convert them to AVIF or WebP, either in your image pipeline or by negotiating on the Accept header, and expect 25-50% smaller files at the same quality.'; + } + + return { + score: total > 0 ? Math.round(100 * (1 - legacy / total)) : 100, + offending: offending, + advice: advice + }; + } +}; diff --git a/test/har/performance/modernImageFormatsTest.js b/test/har/performance/modernImageFormatsTest.js new file mode 100644 index 0000000..97f1aa8 --- /dev/null +++ b/test/har/performance/modernImageFormatsTest.js @@ -0,0 +1,140 @@ +import test from 'ava'; +import har from '../../help/har.js'; + +// Rewrite the Content-Type of every image response in a HAR clone, so we can +// exercise "delivered as JPEG" and "delivered as WebP from the same .jpg URL" +// against the same fixture. +function withImageContentType(clone, contentType) { + for (const entry of clone.log.entries) { + const header = entry.response.headers.find( + (h) => h.name.toLowerCase() === 'content-type' + ); + if (header && header.value.startsWith('image/')) { + header.value = contentType; + entry.response.content.mimeType = contentType; + } + } + return clone; +} + +async function cloneFixture() { + return JSON.parse( + JSON.stringify(await har.harFromTestFile('www.nytimes.com.har')) + ); +} + +test('Modern image formats / flags images delivered as JPEG or PNG', async (t) => { + const result = await har.firstAdviceForTestFile('www.nytimes.com.har'); + const advice = result.performance.adviceList.modernImageFormats; + t.is(advice.score, 0); + t.true(advice.offending.length > 0); + t.regex(advice.advice, /as JPEG, PNG or GIF/); +}); + +test('Modern image formats / a .jpg URL delivered as WebP is not flagged', async (t) => { + // The URLs in this fixture keep their .jpg / .png extensions — only the + // Content-Type changes, which is exactly Accept-based negotiation. + const clone = withImageContentType(await cloneFixture(), 'image/webp'); + const result = await har.firstAdviceForHar(clone); + const advice = result.performance.adviceList.modernImageFormats; + t.is(advice.score, 100); + t.deepEqual(advice.offending, []); + t.is(advice.advice, ''); +}); + +test('Modern image formats / AVIF counts as modern too', async (t) => { + const clone = withImageContentType(await cloneFixture(), 'image/avif'); + const result = await har.firstAdviceForHar(clone); + t.is(result.performance.adviceList.modernImageFormats.score, 100); +}); + +test('Modern image formats / Content-Type parameters and casing are ignored', async (t) => { + const clone = withImageContentType(await cloneFixture(), 'Image/WebP; charset=utf-8'); + const result = await har.firstAdviceForHar(clone); + t.is(result.performance.adviceList.modernImageFormats.score, 100); +}); + +test('Modern image formats / scores the share of legacy images', async (t) => { + const clone = await cloneFixture(); + let seen = 0; + for (const entry of clone.log.entries) { + const header = entry.response.headers.find( + (h) => h.name.toLowerCase() === 'content-type' + ); + if (header && header.value.startsWith('image/')) { + // Every other image gets a modern Content-Type. + const contentType = seen++ % 2 === 0 ? 'image/webp' : 'image/jpeg'; + header.value = contentType; + entry.response.content.mimeType = contentType; + } + } + t.true(seen > 0, 'the fixture should contain images to rewrite'); + const advice = (await har.firstAdviceForHar(clone)).performance.adviceList + .modernImageFormats; + // pagexray classifies a narrower asset set than the raw HAR entries (no + // favicons, no SVG), so assert the mix rather than an exact count. + t.true(advice.score > 0 && advice.score < 100); + t.true(advice.offending.length > 0); + t.true(advice.offending.every((url) => typeof url === 'string')); +}); + +test('Modern image formats / images without a Content-Type are not judged', async (t) => { + const clone = await cloneFixture(); + for (const entry of clone.log.entries) { + entry.response.headers = entry.response.headers.filter( + (h) => + !( + h.name.toLowerCase() === 'content-type' && + h.value.startsWith('image/') + ) + ); + } + const advice = (await har.firstAdviceForHar(clone)).performance.adviceList + .modernImageFormats; + t.is(advice.score, 100); + t.deepEqual(advice.offending, []); +}); + +test('Modern image formats / tracking pixels are not flagged', async (t) => { + // A 42 byte GIF beacon is an image only by content type. Re-encoding it + // saves nothing, so it should not appear as advice. + const clone = await cloneFixture(); + clone.log.entries.push({ + startedDateTime: '2026-09-08T10:00:01.000Z', + time: 10, + request: { + method: 'GET', + url: 'https://tracker.example.com/pixel.gif?id=1', + httpVersion: 'HTTP/2', + headers: [], + queryString: [], + cookies: [], + headersSize: -1, + bodySize: -1 + }, + response: { + status: 200, + statusText: 'OK', + httpVersion: 'HTTP/2', + headers: [{ name: 'content-type', value: 'image/gif' }], + cookies: [], + content: { size: 42, mimeType: 'image/gif' }, + redirectURL: '', + headersSize: -1, + bodySize: 42 + }, + cache: {}, + timings: { blocked: 0, dns: 0, connect: 0, send: 0, wait: 5, receive: 5 }, + pageref: clone.log.pages[0].id + }); + withImageContentType(clone, 'image/webp'); + // withImageContentType rewrote the beacon too, so put it back. + const beacon = clone.log.entries.at(-1); + beacon.response.headers[0].value = 'image/gif'; + beacon.response.content.mimeType = 'image/gif'; + + const advice = (await har.firstAdviceForHar(clone)).performance.adviceList + .modernImageFormats; + t.is(advice.score, 100); + t.false(advice.offending.some((url) => url.includes('pixel.gif'))); +}); diff --git a/test/help/webserver.js b/test/help/webserver.js index 945e9d9..39c845e 100644 --- a/test/help/webserver.js +++ b/test/help/webserver.js @@ -17,6 +17,8 @@ const MIME = { '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', + '.webp': 'image/webp', + '.avif': 'image/avif', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.txt': 'text/plain; charset=utf-8'