Spaces:
Running
Running
File size: 9,587 Bytes
94278cd | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 | """
Performance rules β detect HTML/CSS signals that hurt page load speed and
Core Web Vitals without requiring a real browser: missing font-display,
images without alt/dimensions/srcset, and absent responsive breakpoints.
"""
from __future__ import annotations
from backend.models.issue import Category, Issue, Severity
_CAT = Category.PERFORMANCE
def analyze(parsed_page: dict, thresholds: dict) -> list[Issue]:
t = thresholds.get("performance", {})
issues: list[Issue] = []
_check_font_display(parsed_page, issues)
_check_image_alt(parsed_page, issues)
_check_image_dimensions(parsed_page, issues)
_check_responsive_images(parsed_page, issues)
_check_mobile_breakpoints(parsed_page, t, issues)
return issues
# ββ P1 β missing font-display βββββββββββββββββββββββββββββββββββββββββββββββββ
def _check_font_display(parsed_page: dict, issues: list[Issue]) -> None:
web_fonts = parsed_page.get("web_fonts", [])
has_display = parsed_page.get("has_font_display", False)
if web_fonts and not has_display:
issues.append(Issue(
rule_id="P1_missing_font_display",
category=_CAT,
severity=Severity.MEDIUM,
confidence=0.90,
message=(
f"{len(web_fonts)} custom font(s) loaded via @font-face "
f"({', '.join(web_fonts[:3])}) without font-display declaration."
),
recommendation=(
"Add `font-display: swap;` to every @font-face block. "
"This renders text immediately with a fallback font while the custom "
"font loads, eliminating Flash of Invisible Text (FOIT)."
),
evidence=f"web_fonts={web_fonts[:3]}, has_font_display=False",
estimated_time="10 minutes",
why=(
"Without font-display, browsers block text rendering until the font file "
"downloads β causing FOIT (invisible text for 0β3 seconds). "
"font-display: swap shows fallback text instantly and swaps in the web font "
"when ready. Google's Lighthouse audit flags this, and it directly impacts "
"First Contentful Paint (FCP) and Core Web Vitals scores."
),
references=["MDN font-display", "Google Lighthouse", "web.dev/font-display"],
))
# ββ P2 β images missing alt text βββββββββββββββββββββββββββββββββββββββββββββ
def _check_image_alt(parsed_page: dict, issues: list[Issue]) -> None:
images = parsed_page.get("images", [])
missing_alt = [img for img in images if not img.get("has_alt")]
if missing_alt:
issues.append(Issue(
rule_id="P2_missing_image_alt",
category=Category.ACCESSIBILITY, # SEO + a11y overlap, primary is accessibility
severity=Severity.HIGH,
confidence=0.95,
message=(
f"{len(missing_alt)} image(s) missing alt attribute. "
"Screen readers will announce the filename; search engines can't index the content."
),
recommendation=(
"Add descriptive alt text to every informational image: "
"alt='Bar chart showing Q3 revenue growth of 42%'. "
"For purely decorative images use alt='' (empty string)."
),
evidence=f"images_missing_alt={len(missing_alt)}, total_images={len(images)}",
estimated_time="30 minutes",
why=(
"Alt text is the single most impactful accessibility improvement for "
"image-heavy pages. Screen readers read it aloud; without it users hear "
"'image.png' or nothing. Google also uses alt text for image search indexing "
"β missing alt text is a direct SEO penalty. WCAG 1.1.1 (Level A)."
),
references=["WCAG 1.1.1", "Google Image Best Practices", "WebAIM"],
))
# ββ P3 β images missing explicit dimensions (CLS) ββββββββββββββββββββββββββββ
def _check_image_dimensions(parsed_page: dict, issues: list[Issue]) -> None:
images = parsed_page.get("images", [])
no_dims = [
img for img in images
if not img.get("has_width") or not img.get("has_height")
]
if no_dims:
issues.append(Issue(
rule_id="P3_layout_shift_images",
category=_CAT,
severity=Severity.MEDIUM,
confidence=0.85,
message=(
f"{len(no_dims)} image(s) missing explicit width/height attributes β "
"causes Cumulative Layout Shift (CLS) as content jumps when images load."
),
recommendation=(
"Add width and height attributes to every <img> matching the image's "
"natural aspect ratio: <img src='...' width='800' height='450' alt='...'>. "
"CSS will still respect max-width: 100%; the browser reserves space before load."
),
evidence=f"images_no_dimensions={len(no_dims)}, total_images={len(images)}",
estimated_time="20 minutes",
why=(
"Without dimensions, the browser doesn't know how much space to reserve "
"for an image β content below the image shifts down when it loads (CLS). "
"CLS above 0.1 is a Core Web Vitals failure and directly impacts "
"Google Search ranking. Layout shift is also jarring for users β "
"they may click the wrong element as the page reflows."
),
references=["web.dev/cls", "Google Core Web Vitals", "MDN Aspect Ratio Box"],
))
# ββ P4 β responsive images (srcset) ββββββββββββββββββββββββββββββββββββββββββ
def _check_responsive_images(parsed_page: dict, issues: list[Issue]) -> None:
images = parsed_page.get("images", [])
no_srcset = [img for img in images if not img.get("has_srcset")]
if len(no_srcset) >= 2:
issues.append(Issue(
rule_id="P4_missing_srcset",
category=_CAT,
severity=Severity.LOW,
confidence=0.75,
message=(
f"{len(no_srcset)} image(s) lack srcset β mobile users download "
"full desktop-size images, wasting bandwidth and slowing load time."
),
recommendation=(
"Add srcset with multiple resolutions: "
"srcset='img-400.jpg 400w, img-800.jpg 800w, img-1200.jpg 1200w' "
"sizes='(max-width: 600px) 400px, 800px'. "
"Tools like Sharp or Cloudinary can auto-generate responsive variants."
),
evidence=f"images_no_srcset={len(no_srcset)}, total_images={len(images)}",
estimated_time="2 hours",
why=(
"A 2MB desktop hero image served to a 375px phone wastes ~1.8MB of data. "
"On 4G, that's an extra 1β2 second delay. srcset lets the browser pick "
"the right image for the viewport β a critical optimisation for mobile "
"users who make up 60%+ of web traffic. Google Lighthouse flags this as "
"'Serve images in next-gen formats' and 'Properly size images'."
),
references=["MDN Responsive Images", "Google Lighthouse", "web.dev/uses-responsive-images"],
))
# ββ P5 β no responsive CSS breakpoints βββββββββββββββββββββββββββββββββββββββ
def _check_mobile_breakpoints(parsed_page: dict, t: dict, issues: list[Issue]) -> None:
breakpoints = parsed_page.get("media_query_breakpoints", [])
required = t.get("required_breakpoints", [768])
missing = [bp for bp in required if not any(abs(b - bp) <= 64 for b in breakpoints)]
if missing:
issues.append(Issue(
rule_id="P5_missing_breakpoints",
category=_CAT,
severity=Severity.HIGH,
confidence=0.80,
message=(
f"No @media query found near {missing}px breakpoint(s). "
"The layout may not adapt for mobile/tablet viewports."
),
recommendation=(
f"Add @media (max-width: {missing[0]}px) rules to adapt typography, "
"layout, and spacing for smaller screens. "
"Minimum: one breakpoint at 768px (tablet) and one at 480px (phone)."
),
evidence=f"detected_breakpoints={breakpoints}, missing={missing}",
estimated_time="3 hours",
why=(
"60%+ of global web traffic is on mobile. A page without @media queries "
"renders its desktop layout on phones β creating the horizontal-overflow, "
"tiny-text, and overlapping-element issues that drive immediate bounce. "
"Google's mobile-first indexing means a non-responsive page ranks lower "
"in search regardless of its desktop score."
),
references=["Google Mobile-First Indexing", "MDN Responsive Design", "StatCounter Mobile Traffic"],
))
|