User asks to SEO/PageSpeed optimize a React/Vite SPA, or a PageSpeed report reveals issues.
curl -sI) — check x-robots-tag, CSP, caching_headers, _redirects, robots.txt, sitemap.xml in the project@font-face analysis in CSS| Issue | Fix |
|---|---|
| ------- | ----- |
x-robots-tag: noindex | Add X-Robots-Tag: index, follow to _headers for /*.html and / |
| Missing hreflang | Add for all language variants + x-default |
| No Baidu verification | Get code from ziyuan.baidu.com, add to HTML |
| Missing OG tags | Add og:site_name, og:image:alt, og:locale |
Before: Inter 400/500/600/700 + Noto Serif Latin 500/600 + Noto Serif CJK 500/600 + JetBrains Mono 400/500
After: Inter 400/500/600/700 + Noto Serif CJK 500/600 ONLY
Savings: ~78KB + 2-4 fewer font requests
Steps:
@font-face — replace with "SF Mono", "Consolas", monospace in CSS classes.font-mono { font-family: "JetBrains Mono"... } with system monospace.label-text font-family similarlyfont-display: swap to font-display: optional for Latin fonts — prevents font-swap CLS (body-wide ~0.586) on all text.CJK font packages are ~1.5MB per font weight. Two weights = 3MB.
Fix: Remove entirely. System CJK serif fonts (Songti SC on Mac, SimSun on Windows) ship on every OS. Update font-family to use system fonts only, rebuild, verify no woff2 in dist.
Alternative (subset): Google Fonts API with text= parameter limits to needed characters (5-15KB instead of 1.5MB).
If PageSpeed reports "Reduce unused JavaScript" for the vendor bundle, split React chunks:
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules/react-dom')) return 'vendor-dom';
if (id.includes('node_modules/react')) return 'vendor-react';
if (id.includes('node_modules/')) return 'vendor-misc';
},
},
},
Before: one vendor.js (59KB, 25KB flagged as unused)
After: vendor-react (8KB) + vendor-dom (180KB) + vendor-misc (7KB)
Pitfall: react-dom's size is not unused code — it's the hydration runtime. The PSI warning for vendor chunks is a false positive for SPAs.
When "Reduce unused JavaScript" persists even after chunk splitting (react-dom still 180KB, 22-25KB unused), swap React for Preact via aliases. Zero code changes needed — Preact is API-compatible.
// vite.config.ts — add to resolve.alias
resolve: {
alias: {
'react': 'preact/compat',
'react-dom': 'preact/compat',
'react-dom/client': 'preact/compat',
'react/jsx-runtime': 'preact/jsx-runtime',
},
},
After the alias, update manualChunks since there's no separate react-dom chunk anymore:
manualChunks(id) {
if (id.includes('node_modules/@emailjs')) return 'vendor-email';
if (id.includes('node_modules/')) return 'vendor-misc';
},
Results from real project (55KB gzip total JS → 12.8KB gzip main + 7.8KB misc):
| Chunk | Before (React) | After (Preact) |
|---|---|---|
| ------- | --------------- | ---------------- |
| vendor-dom | 180KB (56KB gzip) | vanishes entirely 🗑️ |
| vendor-react | 8KB (3KB gzip) | vanishes entirely 🗑️ |
| Index (main) | 34KB (13KB gzip) | 33KB (12.8KB gzip) — includes Preact runtime |
Total gzip JS: 75KB → 22KB. Lighthouse "unused JS" warning goes from 22.5KB to ~0.
Installation: npm install preact
Pitfall: Preact does NOT include SyntheticEvent (React's cross-browser event wrapper). If code accesses e.nativeEvent or relies on event pooling, it may break. For standard form submissions and click handlers, Preact works identically.
When Lighthouse or PageSpeed flags an external vendor chunk (e.g., @emailjs/browser at 3.5KB), check if the library's REST API is simple enough to call directly.
EmailJS example — replace the SDK with raw fetch():
- import emailjs from "@emailjs/browser";
- await emailjs.send(SERVICE_ID, TEMPLATE_ID, params, { publicKey: KEY });
+ const res = await fetch("https://api.emailjs.com/api/v1.0/email/send", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ service_id: SERVICE_ID,
+ template_id: TEMPLATE_ID,
+ user_id: PUBLIC_KEY,
+ template_params: { name, email, ... },
+ }),
+ });
+ if (!res.ok) throw new Error(`EmailJS responded with ${res.status}`);
Savings: ~3.5KB removed (SDK + dependency tree), 31 modules vs 51 modules transformed.
⚠️ CSP update required: Add the API endpoint to connect-src in _headers:
- connect-src 'self'
+ connect-src 'self' https://api.emailjs.com
Rule of thumb: Before adding any npm SDK, check if the service exposes a simple REST API. If yes, raw fetch() + key exposed in browser contexts anyway — no benefit from the SDK wrapper.
esbuild is the Vite default (faster builds) but terser does better dead-code elimination. Switch when:
drop_console is desired (strips all console.log from production bundles)Config:
build: {
minify: 'terser', // replaces 'esbuild'
terserOptions: {
compress: {
drop_console: true, // strips console.log/debug/error
drop_debugger: true,
passes: 2, // multiple optimization passes
},
format: { // ⚠️ use 'format' NOT 'output' (deprecated in terser 6)
comments: false,
},
},
},
Installation: npm install -D terser
Results: vendor-dom 180.29KB (56.27KB gzip) → 178.55KB (55.73KB gzip). Modest but meaningful, and the console stripping prevents accidental debug log leaks.
index.html)<!-- Resource Hints: preconnect to third-party origins -->
<link rel="dns-prefetch" href="//static.cloudflareinsights.com" />
<link rel="preconnect" href="https://static.cloudflareinsights.com" />
<!-- Preload hero image (LCP element) -->
<link rel="preload" href="./images/hero-showcase.webp" as="image" fetchpriority="high" />
Preconnect rules: 3-4 max. Always pair dns-prefetch + preconnect. Check CSP first — preconnecting to a blocked origin wastes the connection.
Common origins: static.cloudflareinsights.com, Google Fonts/Analytics (if CSP allows), CDN font origins.
Vite post-build plugins (like html-transform) that inject/replace content via regex silently fail when the source HTML format changes (e.g., comment style vs ).
Fix pattern: Match the ACTUAL comment text in source index.html, not a stylized delimiter:
// BROKEN: plugin regex looks for delimiters the source doesn't use
html.replace(/<!-- ===== Critical CSS ===== -->[\s\S]*?<\/style>/, replacement)
// FIXED: match the real comment text in source index.html
html.replace(/<!-- Inline critical CSS to prevent CLS -->[\s\S]*?<\/style>/, replacement)
Verification: Always inspect dist/index.html after build — grep for the injected classes:
grep -c 'font-display' dist/index.html # > 0 = critical CSS expanded
grep -c 'preconnect' dist/index.html # > 0 = hints survived
grep -c 'media="print"' dist/index.html # > 0 = async CSS loading
ls dist/assets/*.woff2 # font files exist
In the same html-transform plugin, parse the built HTML to find the main JS entry (index-*.js) and add it as an explicit before the script tag. This lets the browser discover and download the main JS entry even sooner:
// Inside html-transform plugin's closeBundle handler
const mainJsMatch = html.match(/<script type="module" crossorigin src="(\.\/assets\/index-[^"]+\.js)">/);
if (mainJsMatch) {
const mainJsUrl = mainJsMatch[1];
if (!html.includes(`href="${mainJsUrl}"`)) {
html = html.replace(
mainJsMatch[0],
`<link rel="modulepreload" href="${mainJsUrl}" />\n ${mainJsMatch[0]}`,
);
}
}
This adds critical-path discovery without changing the execution order. Verify:
grep -c 'modulepreload.*index-' dist/index.html # > 0 = JS preload active
Verify current state before applying optimizations. This user explicitly asks to re-read the latest report and compare with current source/dist state before making any changes. Do not jump to fixes — first audit what's actually different between report claims and local files. Use execute_code or parallel read_file + terminal calls to generate a side-by-side comparison and present findings before writing code.
When PageSpeed/Lighthouse returns a list of suggestions, do NOT apply them blindly — the report may be stale or based on cached content.
Image dimensions: Lighthouse reports HTML attribute values, not actual file dimensions. Cross-check:
# macOS (sips)
sips -g pixelWidth -g pixelHeight public/images/*.webp | grep -E "(pixelWidth|pixelHeight|\.webp)"
# ImageMagick
identify public/images/*.webp
Common finding: Images were already resized to display size but HTML width/height attributes reference old larger values.
Fix: Update React component attributes to match actual file dimensions:
// Before: stale attributes (wrong aspect-ratio → CLS)
width="1344" height="768"
// After: matches actual file on disk
width="1200" height="686"
See references/image-resize-workflow.md. Key addition: After resizing images, ALWAYS sync the width/height attributes in React components (Hero.tsx, Industries.tsx, Cases.tsx are common sites).
After verifying dimensions are correct (Step 1), re-encode ALL resized images at quality 75 for additional byte savings beyond dimension-only reduction:
from PIL import Image
import os, glob
images_dir = 'public/images'
for f in sorted(glob.glob(f'{images_dir}/*.webp')):
name = os.path.basename(f)
if 'qr' in name: continue # skip tiny files
before = os.path.getsize(f)
img = Image.open(f)
img.save(f, 'WEBP', quality=75, method=6)
after = os.path.getsize(f)
print(f'{name}: {before/1024:.1f} KiB -> {after/1024:.1f} KiB')
⚠️ Re-compression trap: Re-encoding an already-optimized WebP at the same or higher quality can INCREASE file size (double-compression artifacts). Always use quality ≤ original. Test 3-5 images first before batch-running. Skip images that grow (revert with git checkout).
Typical results from this session:
| Image type | Before resize | After resize+compress | Total savings |
|---|---|---|---|
| ----------- | -------------- | ---------------------- | -------------- |
| hero-showcase (1344x768) | 28 KiB | 9.8 KiB (800×457, q75) | -65% |
| case images (1184x635) | 49-82 KiB | 5.5-16 KiB (480×350, q75) | -70-87% |
| industry images (1184×864) | 23-74 KiB | 7-21 KiB (600×438, originals) | -70-90% |
# CLEAN rebuild — ensures no stale images/files in dist/
rm -rf dist && npm run build
# Verify everything survived the post-build plugin
grep -c 'font-display' dist/index.html
grep -c 'preconnect' dist/index.html
grep -c 'modulepreload.*index-' dist/index.html # JS entry preload (if added)
# Verify fresh images were copied from public/
sips -g pixelWidth -g pixelHeight dist/images/hero-showcase.webp 2>/dev/null | grep pixel
ls -lh dist/images/*.webp | head -5
Always include these schema types when applicable:
#heroPlace AFTER tag (NOT in ):
<noscript>
<h1>Site Name — Description</h1>
<p>Summary paragraph...</p>
<h2>Services</h2>
<ul><li>Service 1</li></ul>
<p>Contact: email@example.com</p>
</noscript>
⚠️ Vite's HTML parser rejects block elements inside in . Always place in .
Add xmlns:image="http://www.google.com/schemas/sitemap-image/1.1" to . Each image needs block with and .
_headers must include X-Robots-Tag: index, follow — Cloudflare may add noindex by default on Pagesscript-src 'self' blocks Google Analytics/GTM — update if needed_redirects: /* /index.html 200// Get ALL resource transfer sizes
performance.getEntriesByType('resource').map(r => ({
name: r.name.split('/').pop(),
size: r.transferSize,
type: r.initiatorType,
duration: r.duration.toFixed(0) + 'ms'
}))
// Get total page transfer
performance.getEntriesByType('resource').reduce((s, r) => s + (r.transferSize || 0), 0)
// Filter for fonts specifically
performance.getEntriesByType('resource').filter(r => r.name.includes('woff2'))
.map(r => ({ name: r.name.split('/').pop(), size: r.transferSize }))
PSI free tier has a daily query limit (~25K/day). On HTTP 429, use curl-based offline analysis:
curl -sI https://example.com # headers, caching
curl -o /dev/null -s -w "TTFB: %{time_starttransfer}\n" url # TTFB
curl -s url | grep -E '(title|meta|script type="application/ld|link rel=")' # SEO meta
curl -sI url | grep -i cf-cache-status # CDN status
Rule: One PSI API call per session — for final verification only.
React SPAs suffer CLS from three distinct root causes. Fix all three to get CLS below 0.1.
The traditional fix is a Fix: Remove the app-shell entirely. No shell = nothing to swap. How this eliminates CLS: Update critical CSS — remove the Result: CLS drops from >1.0 to <0.05 on desktop Lighthouse. Bonus: index.html shrinks by ~1.7KB (no more embedded SVG). Fix: Use Even with Fix: Add a system-serif fallback to BOTH the source index.html's inline Why Georgia: 0KB (pre-installed on every OS), renders within the LCP measurement window (2.5s). Without it, the hero title stays invisible until the display font loads. Double-check this rule survived the post-build plugin by grepping dist/index.html after build. Images with CSS overrides ( Fix: Add to inline critical CSS: If a Vite post-build plugin replaces the inline Tailwind's Fix: Update BOTH Light-tint badges often fail WCAG AA (4.5:1 for 12px text). Don't darken the tint — introduce a separate CSS variable: React sections on Rule of thumb for Fix pattern for Fix checklist when PSI reports contrast failures on dark-themed sections: See ⚠️ CRITICAL: How to check: Common pattern that silently fails: Verification after deploy: Compare JS chunk hashes between local Cloudflare CDN caches images with Verification script available: Method A: Cache purge (admin access required) Method B: Image rename (no admin access) ⭐ When cache purge is unavailable (no API token, can't access dashboard), rename all image files to new filenames and update all source references. This creates entirely new URLs that bypass the CDN cache: Then update ALL source references (TSX files + index.html preload): Impact: Old files remain on the server (unreferenced until TTL expiry), new files serve immediately with correct cache headers. Verify with Pitfall: Every image URL change means a new hash in JS bundles → larger upload each deployment. Batch all image renames in one commit, not incrementally. Method C: _headers race-deploy (moderate) When cache purge is unavailable and image rename is too heavy, temporarily change the image cache policy to Why this works: Cloudflare Pages respects the Pitfall: Requires two deployments. The window of no-cache lasts only between deploys (typically 30-120 seconds). Old cached copies on user browsers are NOT invalidated — only the CDN edge cache. Which method to choose? A helper script These items push a Lighthouse Accessibility audit from 96→100. Each corresponds to a specific automated check. Common miss: Newsletter email inputs with only Skip link boilerplate:, elements, brand text, inline styles) never exactly matches the React-rendered — any structural or dimensional difference causes a measurable CLS during hydration.- <div id="root">
- <div id="app-shell" style="position:fixed;...">svg...</div>
- </div>
+ <div id="root" style="padding-top:72px;min-height:100vh;"></div>
#root has padding-top: 72px — content area starts 72px from the top on top of the padding area#app-shell rule: #root { min-height: 100vh; padding-top: 72px; }
-#app-shell { position: fixed; top: 0; left: 0; right: 0; z-index: 50; height: 72px; }
Root Cause 2: Font-Swap CLS (Latin fonts)
font-display: swap on Latin fonts (Inter, 24KB each) causes body-wide CLS (~0.586).font-display: optional instead of swap for preloaded Latin fonts. Preloaded fonts still arrive in time on fast connections; on slow connections the fallback stays. Zero CLS either way.LCP Hero Text Font Fallback
font-display: optional, the hero title (often the LCP element) may use a serif display font (.font-display class) that the browser hasn't loaded yet. Without a system-serif fallback in critical CSS, the title text renders invisibly — no CLS, but delayed LCP measurement. AND the post-build plugin's criticalCSS string:.font-display { font-family: Georgia, 'Times New Roman', serif; }
Root Cause 3: Image CLS (aspect-ratio override)
w-full h-auto) ignore HTML dimensions.img[width][height] { aspect-ratio: attr(width) / attr(height); }
html-transform Plugin Trap
block via regex, it silently drops styles in the original index.html that aren't in the plugin's criticalCSS template. Both index.html AND the plugin must contain the same critical CSS.Tailwind fontFamily Config Duplicate
fontFamily.display in tailwind.config.js generates its own .font-display utility. If you ALSO define .font-display in @layer components, both get emitted and Tailwind's takes precedence.tailwind.config.js fontFamily array AND manual class definitions. Verify:grep -o "font-display{font-family[^}]*}" dist/assets/*.css
Accessibility Contrast Fix for Badges
:root { --accent-badge-text: #7A3B10; } /* 5.7:1 on #EED5C0 */
.dark { --accent-badge-text: #E6A070; } /* light orange on 20% opaque bg */
Dark-Surface Text: rgba Opacity Fix (common PSI fail)
--surface-dark (#1A1A1A in light mode / #0A0A0A in dark) frequently use semi-transparent white text like rgba(245,240,235,0.5). These opacity values produce effective colors that FAIL WCAG AA (4.5:1 for normal text).rgba(245,240,235,X) on #1A1A1A:Opacity Effective color WCAG AA (4.5:1) Use for --------- ---------------- ----------------- --------- 0.4 #716F6E (2.6:1) ❌ FAIL Never 0.5 #878582 (3.5:1) ❌ FAIL Never for text 0.55 #94918F (4.1:1) ⚠️ Borderline Strikethrough / decorative 0.6 #9D9A97 (4.6:1) ✅ PASS Secondary text (barely) 0.65 #A8A5A2 (5.1:1) ✅ PASS Body text / links 0.7 #B3AFAC (5.8:1) ✅ PASS Primary text 0.75 #C0BCB8 (6.5:1) ✅ PASS Label text (use this instead of accent-color on dark) 0.85 #DCD8D4 (8.5:1) ✅ PASS Section headings 1.0 #F5F0EB (12.2:1) ✅ PASS Pure white text-white/XX on accent backgrounds (#B85C28):text-white/90 on #B85C28 → 4.57:1 (barely passes AA)text-white (full opacity) → 5.37:1 ✅rgba(245,240,235,X) values in the section's TSX filestext-white/90 on accent bg, change to text-whitelabel-text on dark surfaces, change from var(--accent-text) (#8A4A1F, 2.3:1) to var(--accent-color) (#B85C28, 6.5:1)references/dark-surface-contrast-calc.md for the per-component opacity values.Deployment
wrangler CLI
# Deploy to production (use the configured production branch — typically "main")
npx wrangler pages deploy dist --project-name NAME --branch main
--branch production creates a PREVIEW, not production. Cloudflare Pages's production branch is typically "main" (or "master"), NOT "production". Using --branch production when the production branch is "main" creates a Preview deployment only accessible at — the custom domain is NOT updated.npx wrangler pages deployment list --project-name NAME
# Look for "Environment" column — "Production" is the one serving the custom domain
# ❌ Creates a PREVIEW on branch "production" — custom domain NOT updated
npx wrangler pages deploy dist --project-name NAME --branch production
# ✅ Deploys to PRODUCTION (branch "main" — the configured production branch)
npx wrangler pages deploy dist --project-name NAME --branch main
dist/ and the live site:grep -o 'index-[^.]*' dist/index.html
curl -s https://yoursite.com/ | grep -o 'index-[^.]*'
# If they match, your deployment is live. If they don't, you deployed to the wrong branch.
Post-deploy cache purge
max-age=31536000 (1 year) + immutable — meaning once cached, browsers and edge nodes never re-request for a full year. After resizing, the new content is invisible to users.scripts/verify-deployment.py under this skill. Call it with python3 scripts/verify-deployment.py https://yourdomain.com path/to/dist to auto-compare JS chunk hashes between local and deployed — catching stale CDN cache or incomplete deployment without manual curl/grep.POST /zones/{zone_id}/purge_cache --data '{"purge_everything":true}'# In public/images/, create new versions with a suffix
cd public/images
for f in *.webp; do
base="${f%.webp}"
cp "$f" "${base}-opt.webp" # or use a version number
done
- src="./images/hero-showcase.webp"
+ src="./images/hero-showcase-opt.webp"
- <link rel="preload" href="./images/hero-showcase.webp" ... />
+ <link rel="preload" href="./images/hero-showcase-opt.webp" ... />
curl -sI https://domain.com/images/hero-showcase-opt.webp | grep content-length.max-age=0, must-revalidate, deploy, then revert:# Step 1: In public/_headers, change image cache to immediate revalidation
# /images/* → Cache-Control: public, max-age=0, must-revalidate
# /og-image.webp → Cache-Control: public, max-age=0, must-revalidate
# Step 2: Rebuild + deploy
rm -rf dist && npm run build && wrangler pages deploy dist --project-name NAME --branch production
# Step 3: Revert _headers back to immutable once cache is refreshed
# /images/* → Cache-Control: public, max-age=31536000, immutable
# Step 4: Deploy again
wrangler pages deploy dist --project-name NAME --branch production
_headers file. Each deployment triggers a fresh origin fetch from the CDN edge. With max-age=0, the CDN revalidates on every request, immediately serving the new content. After cache refresh, revert to long TTL.Scenario Method ---------- -------- Admin access (API key / Dashboard) A: Purge cache Can't access admin, OK with one large commit B: Image rename Can't access admin, want minimal changes C: _headers race-deploy Need to invalidate user browser caches Only Method B works (new URL = new cache key) Deployment Verification Script
scripts/verify-deployment.py under this skill automates hash comparison between local dist/ and the deployed site. It:dist/index.htmlpython3 scripts/verify-deployment.py https://yourdomain.com /path/to/dist
# Example output:
# Deployed main JS: index-D-5wANjw.js
# ✅ Main JS hash MATCHES local — deployment is live
# ✅ All 7 lazy chunk imports match local
# ✅ Sample chunk WhyUs-CimLE5wr.js: deployed=2341B, local=2341B
Contrast (largest scoring category)
Check Rule Fix ------- ------ ----- Body text ≥ 4.5:1 vs background Use contrast table in main SKILL.md Large text (≥18px / ≥14px bold) ≥ 3:1 Same table, lower threshold Text on dark surfaces Opacity ≥ 0.6 body, ≥ 0.65 secondary See references/dark-surface-contrast-calc.mdText on accent bg Use pure #fff, not text-white/90text-white/90 on #B85C28 = 4.57:1 (barely passes); pure white = 5.37:1 ✅label-text class on dark bgvar(--accent-text) (#8A4A1F) = 2.3:1 ❌rgba(245,240,235,0.75) = 6.5:1 ✅. Pitfall: var(--accent-color) (#B85C28) on #1A1A1A = only 3.81:1 — STILL fails WCAG AA for small text. Do NOT use accent color for label text on dark surfaces.Form Labels
, , needs a (visible or .sr-only)aria-label matching the visible placeholderplaceholder:+ <label htmlFor="newsletter-email" className="sr-only">{t("footer.emailPlaceholder")}</label>
- <input type="email" placeholder={t("footer.emailPlaceholder")} ... />
+ <input id="newsletter-email" type="email" placeholder={t("footer.emailPlaceholder")}
+ aria-label={t("footer.emailPlaceholder")} ... />
aria-required="true" alongside requiredrole="alert"Document and Landmarks
has lang attribute exists with id="main-content" has aria-label="Main navigation", , landmark elements used<!-- In index.html, first child of <body> -->
<a href="#main-content" class="skip-link"
style="position:absolute;top:-9999px;left:-9999px;z-index:9999;
padding:12px 24px;background:var(--accent-color,#B85C28);color:#fff;
font-size:14px;font-weight:500;border-radius:0 0 8px 0;text-decoration:none;">
Skip to main content
</a>
.skip-link:focus { top: 0 !important; left: 0 !important; position: fixed !important; }
Keyboard and Focus
:focus-visible with 2px accent-color ring + 2px offset:focus:not(:focus-visible) removes outline for mouse users only*:focus-visible { outline: 2px solid var(--accent-color); outline-offset: 2px; border-radius: 4px; }
*:focus:not(:focus-visible) { outline: none; }
Images
has meaningful alt (even alt="" for decorative)role="img" + or aria-labelHeadings
per page / , not plain
共 2 个版本