prepare(). * * These read-only audit counts have no persistent cache (each run must see the * live library). Only the non-injection DirectDatabaseQuery sniffs are * suppressed file-wide: * * phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Read-only audit counts over the media library that must see live state each run (no persistent cache). Non-injection sniffs only; identifiers bound with %i, values with %s via $wpdb->prepare(); the PreparedSQL sniffs are never suppressed. */ declare(strict_types=1); namespace AetosSeo\Audit\Checks; if (!defined('ABSPATH')) { exit; } use AetosSeo\Utils\I18n; use AetosSeo\Audit\AbstractCheck; use AetosSeo\Audit\RunContext; use AetosSeo\Security\Redactor; class MediaCheck extends AbstractCheck { public function id(): string { return 'media_check'; } public function run(RunContext $ctx): array { global $wpdb; $findings = []; $posts_table = $wpdb->posts; $postmeta_table = $wpdb->postmeta; $total_images = (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(ID) FROM %i WHERE post_type='attachment' AND post_mime_type LIKE %s", $posts_table, 'image/%' )); $missing_alt = (int) $wpdb->get_var($wpdb->prepare( "SELECT COUNT(p.ID) FROM %i p LEFT JOIN %i pm ON pm.post_id = p.ID AND pm.meta_key = '_wp_attachment_image_alt' WHERE p.post_type='attachment' AND p.post_mime_type LIKE %s AND (pm.meta_value IS NULL OR pm.meta_value = '')", $posts_table, $postmeta_table, 'image/%' )); // Cross-check rendered on indexable pages before flagging // library-level missing alt. The library count includes decorative // SVGs/icons, generated screenshots, logos, cached uploads — most of // which are never rendered on indexable pages. Library hygiene matters // less than rendered alt on actual published pages, so: // - If we have enough rendered images sampled (>= 10) AND rendered // missing-alt ratio < 5%, the site is shipping clean alt and we // suppress the finding entirely. // - If we have fewer than 10 rendered images sampled, skip the // suppression branch — the sample is too small to vouch for the // library finding either way, so report it (the operator can act // on the library data without false confidence from a tiny sample). // - Otherwise (large enough sample, but rendered ratio is dirty) // emit at low severity with library-vs-rendered split shown. if ($total_images > 0) { $ratio = $missing_alt / $total_images; if ($missing_alt > 0 && $ratio >= 0.20) { $sample_pages_for_alt = (array) $ctx->get('sample_pages', []); $rendered_total = 0; $rendered_missing = 0; foreach ($sample_pages_for_alt as $sp) { if (!is_array($sp)) { continue; } $sp_assets = isset($sp['asset_counts']) && is_array($sp['asset_counts']) ? $sp['asset_counts'] : []; $sp_imgs = (int) ($sp_assets['images_total'] ?? $sp['images_total'] ?? 0); $sp_no_alt = (int) ($sp_assets['images_missing_alt'] ?? $sp['images_missing_alt'] ?? 0); if ($sp_imgs > 0) { $rendered_total += $sp_imgs; $rendered_missing += $sp_no_alt; } } $rendered_ratio = $rendered_total > 0 ? ($rendered_missing / $rendered_total) : 0.0; $rendered_sample_sufficient = ($rendered_total >= 10); $rendered_clean = $rendered_sample_sufficient && $rendered_ratio < 0.05; if (!$rendered_clean) { $samples = $wpdb->get_results($wpdb->prepare( "SELECT p.ID, p.guid FROM %i p LEFT JOIN %i pm ON pm.post_id = p.ID AND pm.meta_key = '_wp_attachment_image_alt' WHERE p.post_type='attachment' AND p.post_mime_type LIKE %s AND (pm.meta_value IS NULL OR pm.meta_value = '') ORDER BY p.ID DESC LIMIT %d", $posts_table, $postmeta_table, $wpdb->esc_like('image/') . '%', 10 ), ARRAY_A); $findings[] = $this->finding( 'media_missing_alt_many', I18n::tr('Many library images have no alt text (library count, not only published pages)', 'نسبة كبيرة من صور المكتبة بلا نص بديل (على مستوى المكتبة وليس بالضرورة على الصفحات المنشورة)'), 'Media', 'low', -2, [ 'source' => 'db', 'details' => sprintf( I18n::tr('Media library: %1$d of %2$d images have no alt text (%3$.1f%%). Images shown on the checked pages: %4$d of %5$d without alt text (%6$.1f%%). This check measures media-library metadata, not the actual alt on indexable pages. If the rendered ratio is under 5%% the site is doing fine and the library just has unused images (decorative SVGs, logos, cached screenshots).', 'مكتبة الوسائط: الصور بلا نص بديل %1$d من %2$d (%3$.1f%%). الصور الظاهرة في الصفحات المفحوصة: %4$d بلا نص بديل من %5$d (%6$.1f%%). يقيس هذا الفحص مكتبة الوسائط وليس النص البديل الفعلي في الصفحات، فإذا كانت نسبة الصور الظاهرة أقل من 5%% فالموقع في حالة جيدة، والمكتبة تحتوي صورًا غير مستخدمة مثل الرسوم الزخرفية والشعارات.'), $missing_alt, $total_images, $ratio * 100, $rendered_missing, $rendered_total, $rendered_ratio * 100 ), 'library_total' => $total_images, 'library_missing_alt' => $missing_alt, 'library_missing_ratio' => round($ratio, 3), 'rendered_total' => $rendered_total, 'rendered_missing_alt' => $rendered_missing, 'rendered_missing_ratio' => round($rendered_ratio, 3), 'rendered_sample_sufficient' => $rendered_sample_sufficient, 'samples' => Redactor::redact_array($samples), ], I18n::tr('ALT helps accessibility and search engines. But this check measures only the media library — if rendered alt is good on published pages, you do not need to bulk-fill every library image (especially decorative icons / logos / screenshots).', 'النص البديل يساعد إمكانية الوصول ومحركات البحث. لكن هذا الفحص يقيس مكتبة الوسائط فقط. وإذا كان النص البديل جيدًا على الصفحات المنشورة فلا داعي لملء كل صور المكتبة دفعة واحدة وخاصة الأيقونات الزخرفية والشعارات ولقطات الشاشة.'), [ I18n::tr('Focus on images actually used on important pages (commercial / service / product targets).', 'ركّز على الصور المستخدمة فعلًا في الصفحات المهمة، مثل صفحات الخدمات والمنتجات.'), I18n::tr('In WP Admin → Media → Bulk Edit, filter by "missing alt" and edit only what is worth it.', 'من لوحة ووردبريس افتح الوسائط، واختر الصور التي بلا نص بديل، وعدّل المهم منها فقط.'), I18n::tr('Decorative SVGs/icons should have alt="" (explicitly empty, not null).', 'الأيقونات والرسوم الزخرفية يجب أن يكون نصها البديل فارغًا صراحة (alt="").'), I18n::tr('Header logos are usually fine with alt="" if there is accompanying text.', 'شعار رأس الموقع يُقبل غالبًا بنص بديل فارغ إذا كان بجانبه نص يوضحه.'), ], [ 'how' => I18n::tr('Run the audit. Check the rendered ratio in the details.', 'شغّل الفحص ثم راجع نسبة الصور على الصفحات المنشورة في التفاصيل.'), 'pass_condition' => I18n::tr('Fewer than 5% of the images shown on your pages lack alt text.', 'rendered_missing_ratio أقل من 5% أي أن النص البديل على الصفحات القابلة للفهرسة في حالة جيدة.'), ], $this->id() ); } } } // Oversized images heuristic (sample 50 newest images). $large = []; $rows = $wpdb->get_results($wpdb->prepare("SELECT ID FROM %i WHERE post_type='attachment' AND post_mime_type LIKE %s ORDER BY ID DESC LIMIT 50", $posts_table, 'image/%'), ARRAY_A); foreach ($rows as $r) { $id = (int) ($r['ID'] ?? 0); if ($id <= 0) { continue; } $file = get_attached_file($id); if (!$file || !is_string($file) || !file_exists($file)) { continue; } $size = (int) filesize($file); if ($size >= 1024 * 1024) { // >= 1MB $large[] = [ 'attachment_id' => $id, 'file' => wp_make_link_relative($file), 'bytes' => $size, ]; } if (count($large) >= 10) { break; } } // ── Page-level checks: srcset + next-gen formats ── $pages = $this->parsed_html_pages((array) $ctx->get('sample_pages', [])); $no_srcset_pages = []; $no_nextgen_pages = []; $page_count = count($pages); foreach ($pages as $p) { if (!is_array($p)) { continue; } $purl = (string) ($p['url'] ?? ''); // v1.0.76: read from both top-level (live crawl) and asset_counts // (DB reuse) to handle both data formats correctly. $assets = isset($p['asset_counts']) && is_array($p['asset_counts']) ? $p['asset_counts'] : []; $imgs = (int) ($assets['images_total'] ?? $p['images_total'] ?? 0); if ($imgs < 3) { continue; } $srcset = (int) ($p['srcset_count'] ?? 0); $webp = (int) ($assets['webp_images'] ?? $p['webp_images'] ?? 0); $avif = (int) ($assets['avif_images'] ?? $p['avif_images'] ?? 0); $nextgen = $webp + $avif; if ($srcset === 0) { $no_srcset_pages[] = ['url' => $purl, 'images_total' => $imgs]; } if ($nextgen === 0) { $no_nextgen_pages[] = ['url' => $purl, 'images_total' => $imgs]; } } if (!empty($no_srcset_pages) && $page_count > 0) { $ratio = count($no_srcset_pages) / $page_count; if ($ratio >= 0.5) { $findings[] = $this->finding( 'media_no_responsive_srcset', I18n::tr('Images do not use srcset (responsive images)', 'الصور لا تستخدم مقاسات متجاوبة (srcset)'), 'Media', 'med', -4, [ 'source' => 'html', 'details' => sprintf(I18n::tr('%.0f%% of checked pages with 3 or more images use no responsive image sizes (srcset).', '%.0f%% من الصفحات التي فيها 3 صور فأكثر لا تستخدم مقاسات صور متجاوبة (srcset).'), $ratio * 100), 'samples' => array_slice($no_srcset_pages, 0, 8), ], I18n::tr('srcset lets the browser pick the best image size for the user\'s screen — reducing bandwidth and improving LCP, especially on mobile.', 'المقاسات المتجاوبة تتيح للمتصفح اختيار حجم الصورة المناسب لشاشة الزائر، فتقل البيانات المحمّلة ويظهر المحتوى أسرع خاصة على الجوال.'), [ I18n::tr('WordPress adds srcset automatically for images uploaded via the Media Library — make sure the theme does not use manual tags without srcset.', 'ووردبريس يضيف المقاسات المتجاوبة تلقائيًا للصور المرفوعة، فتأكد أن القالب لا يكتب وسوم الصور يدويًا دونها.'), I18n::tr('Use wp_get_attachment_image() instead of in templates.', 'في القالب استخدم الدالة wp_get_attachment_image() بدلًا من كتابة وسم الصورة يدويًا.'), I18n::tr('Add a sizes attribute with srcset for the best result.', 'أضف خاصية sizes مع المقاسات المتجاوبة للحصول على أفضل نتيجة.'), ], [ 'how' => I18n::tr('Run the audit after updating the theme.', 'شغّل فحصًا جديدًا بعد تعديل القالب.'), 'pass_condition' => I18n::tr('Most images include srcset.', 'أغلب الصور تحتوي srcset.'), ], $this->id() ); } } if (!empty($no_nextgen_pages) && $page_count > 0) { $ratio = count($no_nextgen_pages) / $page_count; if ($ratio >= 0.6) { // v1.0.58: cross-reference active image optimizers from adapters // so the recommendation is specific instead of generic ("install // an image plugin"). If Imagify/ShortPixel/Smush is already // installed, we know the issue is configuration, not absence. $adapters = (array) $ctx->get('adapters', []); $imagify_active = isset($adapters['imagify']); $shortpixel_active = isset($adapters['shortpixel']); $smush_active = isset($adapters['smush']); $ewww_active = isset($adapters['ewww']); $webpexpress_active = isset($adapters['webp_express']); $sg_optimizer_active = isset($adapters['sg_optimizer']); $any_optimizer_active = $imagify_active || $shortpixel_active || $smush_active || $ewww_active || $webpexpress_active || $sg_optimizer_active; $steps = []; if ($imagify_active) { $steps[] = I18n::tr('Imagify is active, but WebP delivery is not working on these images. Go to WP Admin > Imagify > Settings > WebP, then enable WebP generation.', 'إضافة Imagify مفعّلة لكن صيغة WebP لا تُقدَّم لهذه الصور. افتح Imagify ثم الإعدادات ثم WebP، وفعّل إنشاء WebP.' ); $steps[] = I18n::tr('Then open Imagify > Bulk Optimization and optimize the Media Library.', 'بعد ذلك افتح التحسين الجماعي في Imagify وحسّن صور مكتبة الوسائط.' ); $steps[] = I18n::tr('If WebP display rewriting is disabled, enable WebP delivery through picture elements in the same settings section.', 'إذا كان عرض WebP معطلًا ففعّل تقديمه عبر عناصر picture من الإعدادات نفسها.' ); } if ($shortpixel_active) { $steps[] = I18n::tr('ShortPixel is active. Open Settings > ShortPixel > Advanced > WebP, then enable WebP generation and delivery through picture elements.', 'إضافة ShortPixel مفعّلة. افتح إعداداتها المتقدمة، وفعّل إنشاء WebP وتقديمه عبر عناصر picture.' ); } if ($smush_active) { $steps[] = I18n::tr('Smush is active. Confirm that your Smush plan includes WebP delivery; otherwise use an optimizer that provides it.', 'إضافة Smush مفعّلة. تأكد أن خطتك تدعم تقديم WebP، وإلا فاستخدم محسّن صور يوفر ذلك.' ); } if ($ewww_active) { $steps[] = I18n::tr('EWWW is active. Open Settings > EWWW Image Optimizer > Local, enable WebP Conversion, and choose a supported WebP delivery method.', 'إضافة EWWW مفعّلة. فعّل تحويل WebP من إعداداتها، ثم اختر طريقة تقديم مدعومة.' ); } if ($sg_optimizer_active) { $steps[] = I18n::tr('SG Optimizer is active. Open SG Optimizer > Media Optimization, enable WebP Generation, and generate the missing variants.', 'إضافة SG Optimizer مفعّلة. فعّل إنشاء WebP من قسم تحسين الوسائط، ثم أنشئ النسخ الناقصة.' ); } if (!$any_optimizer_active) { $steps[] = I18n::tr( 'No image optimizer was detected. Install an optimizer that supports WebP or AVIF generation and delivery.', 'لم يتم رصد محسّن صور. ثبّت إضافة تدعم إنشاء WebP أو AVIF وتسليمهما.' ); $steps[] = I18n::tr( 'After activation, run bulk optimization for the Media Library.', 'بعد التفعيل، شغّل التحسين الجماعي لمكتبة الوسائط.' ); } $steps[] = I18n::tr('If your CDN plan includes image optimization, enable its next-generation image format feature, such as Cloudflare Polish.', 'إذا كانت شبكة توزيع المحتوى لديك تحسّن الصور ففعّل خيار الصيغ الحديثة فيها.' ); $detected_optimizers = []; if ($imagify_active) $detected_optimizers[] = 'Imagify'; if ($shortpixel_active) $detected_optimizers[] = 'ShortPixel'; if ($smush_active) $detected_optimizers[] = 'Smush'; if ($ewww_active) $detected_optimizers[] = 'EWWW'; if ($webpexpress_active) $detected_optimizers[] = 'WebP Express'; if ($sg_optimizer_active) $detected_optimizers[] = 'SG Optimizer'; $findings[] = $this->finding( 'media_no_nextgen_formats', I18n::tr('Images do not use WebP/AVIF (next-gen formats)', 'الصور لا تستخدم WebP/AVIF (next-gen formats)'), 'Media', 'med', -5, [ 'source' => 'html', 'details' => sprintf(I18n::tr('%.0f%% of checked pages with 3 or more images use no WebP or AVIF image.', '%.0f%% من الصفحات التي فيها 3 صور فأكثر لا تحتوي أي صورة WebP أو AVIF.'), $ratio * 100), 'samples' => array_slice($no_nextgen_pages, 0, 8), 'detected_optimizers' => $detected_optimizers, 'cross_reference_note' => $any_optimizer_active ? 'optimizer plugin is already installed but WebP delivery is not configured' : 'no image optimizer plugin detected', ], I18n::tr('WebP/AVIF are 25-50% smaller than JPEG/PNG at the same quality — a big bandwidth saving and a noticeable LCP improvement.', 'صيغتا WebP وAVIF أصغر بنسبة 25 إلى 50% من JPEG وPNG بالجودة نفسها، فيقل حجم الصفحة ويظهر المحتوى أسرع.') . ($any_optimizer_active ? I18n::tr(' The site already has an optimizer installed (', ' الموقع لديه أداة تحسين مثبتة بالفعل (') . implode(', ', $detected_optimizers) . I18n::tr(') but the WebP delivery settings are not enabled.', ') لكن إعدادات تسليم WebP غير مفعّلة.') : ''), $steps, [ 'how' => I18n::tr('Run the audit after enabling WebP/AVIF.', 'شغّل فحصًا جديدًا بعد تفعيل الصيغ الحديثة.'), 'pass_condition' => I18n::tr('Most images are in WebP or AVIF format.', 'أغلب الصور بصيغة WebP أو AVIF.'), ], $this->id() ); } } // Image dimensions missing — CLS risk $missing_dims_pages = []; foreach ($pages as $p) { if (!is_array($p)) continue; $no_dims = (int) ($p['images_without_dimensions'] ?? 0); $total = (int) ($p['images_total'] ?? 0); if ($total >= 2 && $no_dims > 0 && ($no_dims / $total) >= 0.5) { $missing_dims_pages[] = [ 'url' => (string) ($p['url'] ?? ''), 'images_without_dimensions' => $no_dims, 'images_total' => $total, ]; } } if (!empty($missing_dims_pages) && count($missing_dims_pages) >= 3) { $findings[] = $this->finding( 'media_images_missing_dimensions', I18n::tr('Images without width/height — a major cause of high CLS', 'صور بلا عرض وارتفاع محددين، فتتحرك الصفحة أثناء التحميل'), 'Media', 'med', -6, [ 'source' => 'html', 'details' => count($missing_dims_pages) . ' pages have 50%+ images without explicit width/height attributes', 'samples' => array_slice($missing_dims_pages, 0, 8), ], I18n::tr('Images without width and height cause Cumulative Layout Shift (CLS) because the browser does not reserve the space in advance — a direct impact on Core Web Vitals and Google ranking.', 'الصورة التي لا يُحدد عرضها وارتفاعها تجعل عناصر الصفحة تتحرك أثناء التحميل (CLS)، لأن المتصفح لا يحجز مكانها مسبقًا، وهذا يضر تجربة الزائر وترتيب الصفحة.'), [ I18n::tr('Add width and height to every , matching the image\'s actual dimensions.', 'أضف العرض والارتفاع لكل صورة بحسب أبعادها الفعلية.'), I18n::tr('WordPress 5.5+ adds dimensions automatically for images uploaded via wp_get_attachment_image().', 'WordPress 5.5+ يضيف الأبعاد تلقائياً للصور المرفوعة عبر wp_get_attachment_image().'), I18n::tr('If images are responsive: add height="auto" in CSS instead of removing the dimensions.', 'إذا كانت الصور متجاوبة فاجعل الارتفاع تلقائيًا في التنسيق بدلًا من حذف الأبعاد.'), I18n::tr('Use aspect-ratio in CSS to set the width-to-height ratio.', 'استخدم خاصية aspect-ratio في التنسيق لتحديد نسبة العرض إلى الارتفاع.'), ], [ 'how' => I18n::tr('Run the audit after adding dimensions.', 'شغّل فحصًا جديدًا بعد إضافة الأبعاد.'), 'pass_condition' => I18n::tr('Fewer than half of the images on each page lack width and height.', 'images_without_dimensions < 50% لكل صفحة.'), ], $this->id() ); } // v1.0.80: Image format adoption summary — always emitted as info so // the bot knows the current WebP/AVIF state even when it's good. $total_page_images = 0; $total_webp = 0; $total_avif = 0; foreach ($pages as $p) { if (!is_array($p)) continue; $assets = isset($p['asset_counts']) && is_array($p['asset_counts']) ? $p['asset_counts'] : []; $total_page_images += (int) ($assets['images_total'] ?? $p['images_total'] ?? 0); $total_webp += (int) ($assets['webp_images'] ?? $p['webp_images'] ?? 0); $total_avif += (int) ($assets['avif_images'] ?? $p['avif_images'] ?? 0); } if ($total_page_images > 0) { $nextgen_ratio = ($total_webp + $total_avif) / $total_page_images; $findings[] = $this->finding( 'media_format_adoption_stats', sprintf(I18n::tr('Image formats: WebP %1$d and AVIF %2$d of %3$d images (%4$.0f%% in a modern format)', 'صيغ الصور: WebP %1$d وAVIF %2$d من إجمالي %3$d (%4$.0f%% بصيغة حديثة)'), $total_webp, $total_avif, $total_page_images, $nextgen_ratio * 100), 'Media', 'info', 0, [ 'source' => 'html', 'details' => sprintf(I18n::tr('Checked pages: %1$d. Images: %2$d, of which WebP %3$d and AVIF %4$d (%5$.1f%% in a modern format).', 'الصفحات المفحوصة: %1$d. الصور: %2$d، منها بصيغة WebP %3$d وبصيغة AVIF %4$d (%5$.1f%% بصيغة حديثة).'), $page_count, $total_page_images, $total_webp, $total_avif, $nextgen_ratio * 100), 'samples' => [ 'total_images' => $total_page_images, 'webp' => $total_webp, 'avif' => $total_avif, 'nextgen_ratio' => round($nextgen_ratio, 3), ], ], I18n::tr('A summary of modern image-format usage on the checked pages. This information helps assess site performance.', 'ملخص استخدام صيغ الصور الحديثة في صفحات العينة. تساعد هذه المعلومات في تقييم أداء الموقع.'), [I18n::tr('Review the WebP/AVIF ratio. Target: 80%+ of images in a modern format.', 'راجع نسبة WebP/AVIF. الهدف: 80%+ من الصور بصيغة حديثة.')], ['how' => I18n::tr('Run the audit after optimizing the images.', 'شغّل فحصًا جديدًا بعد تحسين الصور.'), 'pass_condition' => I18n::tr('80% or more of images use a modern format.', '80% أو أكثر من الصور بصيغة حديثة.')], $this->id() ); } if (!empty($large)) { $findings[] = $this->finding( 'media_large_images_detected', I18n::tr('Large images (1 MB or more) among the latest uploads', 'صور كبيرة الحجم (1 ميجابايت أو أكثر) ضمن أحدث الصور'), 'Media', 'low', -2, [ 'source' => 'filesystem', 'details' => I18n::tr('Checked the last 50 uploaded images. Images of 1 MB or more: ', 'فُحصت آخر 50 صورة مرفوعة، وعدد ما حجمه 1MB أو أكثر: ') . count($large), 'samples' => Redactor::redact_array($large), ], I18n::tr('Large images can hurt LCP and the user experience, especially on mobile.', 'الصور الكبيرة تؤخر ظهور الصفحة وتضر تجربة الزائر، خاصة على الجوال.'), [ I18n::tr('Compress images and convert them to WebP/AVIF where possible.', 'اضغط الصور وحوّلها إلى WebP/AVIF إن أمكن.'), I18n::tr('Use responsive sizes (srcset) and make sure the displayed image is not larger than needed.', 'استخدم المقاسات المتجاوبة وتأكد أن الصورة المعروضة ليست أكبر من الحاجة.'), ], [ 'how' => I18n::tr('Re-run the audit after optimizing the images.', 'شغّل فحصًا جديدًا بعد تحسين الصور.'), 'pass_condition' => I18n::tr('Fewer images of 1 MB or more on the checked pages.', 'انخفاض عدد الصور >=1MB في العينات.'), ], $this->id() ); } return $findings; } }