// Four image-optimization checks emitted as `unoptimized-image` findings.
// `subtype` distinguishes raw-img / global-unoptimized / image-fill-no-sizes
// / image-svg-no-unoptimized so the recommender can frame each separately.
export const metadata = {
id: 'unoptimized-image',
title: 'Image optimization gap (raw
, global flag, missing sizes, or SVG mis-routed)',
severity: 'high',
billingDimension: 'image-optimization',
trafficIndependent: false,
description:
'Four shapes of image-cost waste: raw
tags bypass the framework Image component; `images.unoptimized: true` disables Vercel image optimization globally; without `sizes` forces serving the largest source variant; without `unoptimized` routes vector data through the raster pipeline.',
fix:
'For raw
: switch to next/image, enhanced-img (SvelteKit), (Astro), or NuxtImg. For global unoptimized:true: remove the flag unless the project is hosted outside Vercel. For fill without sizes: add `sizes="(max-width: 768px) 100vw, 50vw"` or whatever matches your layout. For SVG: add `unoptimized` so the raw SVG ships instead of rastering it.',
citations: [
'https://nextjs.org/docs/app/api-reference/components/image',
'https://vercel.com/docs/image-optimization',
],
excludeGlobs: ['node_modules/**', '.next/**', 'dist/**', '__tests__/**', 'cypress/**', '*.test.*'],
includeGlobs: ['**/*.{tsx,jsx,html,svelte,astro,vue,js,mjs,ts}'],
};
const IMG_RE = /
]*src\s*=\s*["'{`]/g;
const GLOBAL_UNOPT_RE = /images\s*:\s*\{[^}]*\bunoptimized\s*:\s*true/;
const IMAGE_TAG_RE = /]*?\/?>/g;
const NEXT_IMAGE_IMPORT_RE = /from\s+['"]next\/image['"]/;
export function scan({ files }) {
const out = [];
for (const { path, content } of files) {
if (isJsxLike(path)) {
let m;
IMG_RE.lastIndex = 0;
while ((m = IMG_RE.exec(content)) !== null) {
out.push({
pattern: metadata.id,
subtype: 'raw-img',
file: path,
line: lineOf(content, m.index),
evidence: snippet(content, m.index),
trafficIndependent: metadata.trafficIndependent,
});
}
}
if (isNextConfig(path)) {
const match = GLOBAL_UNOPT_RE.exec(content);
if (match) {
out.push({
pattern: metadata.id,
subtype: 'global-unoptimized',
file: path,
line: lineOf(content, match.index),
evidence: 'images: { unoptimized: true } — disables Vercel image optimization for the entire project',
// Config-level flag affects every image regardless of route.
trafficIndependent: true,
});
}
}
// Only fire if next/image is imported — otherwise `Image` is some
// other component.
if (isJsxLike(path) && NEXT_IMAGE_IMPORT_RE.test(content)) {
let m;
IMAGE_TAG_RE.lastIndex = 0;
while ((m = IMAGE_TAG_RE.exec(content)) !== null) {
const tag = m[0];
const hasFill = /\bfill\b/.test(tag);
const hasSizes = /\bsizes\s*=/.test(tag);
if (hasFill && !hasSizes) {
out.push({
pattern: metadata.id,
subtype: 'image-fill-no-sizes',
file: path,
line: lineOf(content, m.index),
evidence: tag.slice(0, 200),
trafficIndependent: metadata.trafficIndependent,
});
}
// Inline data: URLs never round-trip through the optimizer.
const srcMatch = /\bsrc\s*=\s*["']([^"']+)["']/.exec(tag);
if (srcMatch) {
const src = srcMatch[1];
if (/\.svg(\?|$)/i.test(src) && !src.startsWith('data:') && !/\bunoptimized\b/.test(tag)) {
out.push({
pattern: metadata.id,
subtype: 'image-svg-no-unoptimized',
file: path,
line: lineOf(content, m.index),
evidence: tag.slice(0, 200),
trafficIndependent: metadata.trafficIndependent,
});
}
}
}
}
}
return out;
}
import { lineOf } from '../util.mjs';
function isJsxLike(path) {
return /\.(tsx|jsx|html|svelte|astro|vue)$/.test(path);
}
function isNextConfig(path) {
return /(?:^|\/)next\.config\.(js|mjs|ts|cjs)$/.test(path);
}
function snippet(text, idx) {
const start = text.lastIndexOf('\n', idx) + 1;
const end = text.indexOf('\n', idx);
return text.slice(start, end === -1 ? text.length : end).trim().slice(0, 160);
}