diff --git a/docs/superpowers/specs/2026-05-15-lighthouse-optimization-design.md b/docs/superpowers/specs/2026-05-15-lighthouse-optimization-design.md new file mode 100644 index 0000000..b8fecd3 --- /dev/null +++ b/docs/superpowers/specs/2026-05-15-lighthouse-optimization-design.md @@ -0,0 +1,170 @@ +# Design Spec: Lighthouse Optimization (~100) + +**Date:** 2026-05-15 +**Status:** Draft — awaiting user review + +## Problem + +Lighthouse score далеко от 100 из-за: +- Изображения загружаются в полном размере (7–15 МБ файлы для карточек 200px) +- Нет адаптивных изображений (srcset/sizes) +- Нет lazy loading на большинстве изображений +- Нет Cache-Control для `/uploads/` +- JS бандл 2.3 МБ без code splitting +- Минимальные meta-теги в HTML + +## Architecture + +### 1. Серверный ресайз изображений + +#### Интеграция с Fastify + +Регистрируем маршрут `GET /uploads-resized/*` ПЕРЕД `fastify-static`: +- Если запрошен `.avif` или `.webp` с `?w=` → sharp конвертирует +- Если запрошен оригинал (без `?w=` или с неподдерживаемым форматом) → редирект на `/uploads/...` +- `fastify-static` остаётся для прямой отдачи `/uploads/` (обратная совместимость) + +**URL формат:** `/uploads-resized/:relativePath/:filename.:format?w=WIDTH` + +- `:relativePath` — опциональная подпапка (например `reviews/`) +- `:filename` — UUID оригинала +- `:format` — `avif` или `webp` +- `?w=` — целевая ширина (320, 640, 1024, 1600) + +**Примеры:** +- `/uploads-resized/abc123.avif?w=640` — AVIF, 640px +- `/uploads-resized/reviews/def456.webp?w=320` — отзыв, WebP, 320px + +**Логика:** +1. Парсим URL → находим оригинал в `/uploads/:relativePath/:filename.*` +2. Проверяем кеш: `/uploads/.cache/:relativePath/:filename_w{WIDTH}.{format}` +3. Если кеш есть — отдаём с `Cache-Control: public, max-age=31536000, immutable` +4. Если нет — sharp конвертирует, сохраняет в кеш, отдаёт +5. Без `?w=` — отдаёт оригинал с `Cache-Control: public, max-age=86400` + +**Зависимость:** `sharp` (npm package) + +#### Разделение папок + +| Источник | Путь сохранения | +|---|---| +| Админка (товары, галерея) | `/uploads/` (без изменений) | +| Отзывы | `/uploads/reviews/` | + +**Изменения:** +- `server/src/routes/api/public-reviews.js` — `POST /api/reviews/upload-image` → `saveImageBufferToUploads` в `/uploads/reviews/` +- `server/src/lib/upload-images.js` — `saveImageBufferToUploads` принимает опциональный `subdir` параметр + +### 2. Клиент: `` компонент + +**Расположение:** `client/src/shared/ui/OptimizedImage.tsx` + +**Props:** +```ts +type OptimizedImageProps = { + src: string // оригинальный URL (/uploads/...) + alt: string + widths?: number[] // по умолчанию [320, 640, 1024, 1600] + sizes?: string // HTML sizes attribute + aspectRatio?: string // CSS aspect-ratio + sx?: SxProps // MUI sx + priority?: boolean // true = loading="eager", false = "lazy" + isReview?: boolean // true = путь /uploads/reviews/ +} +``` + +**Генерация srcset:** +``` +/uploads-resized/abc.avif?w=320 320w, +/uploads-resized/abc.avif?w=640 640w, +/uploads-resized/abc.avif?w=1024 1024w, +/uploads-resized/abc.avif?w=1600 1600w +``` + +**Fallback для Safari без AVIF:** `` с `` + `` + +**Места замены:** +- `ProductCard` — карточки товаров (mediaHeight определяет нужный size) +- `ProductPage` — страница товара + отзывы +- `CatalogSlider` — слайдер каталога +- `GalleryGrid` — галерея админки +- `AdminProductsPage` / `AdminPage` — превью в админке +- `GallerySliderSection` — превью слайдов + +### 3. Cache-Control для статики + +**Server (`server/src/index.js`):** +- `/uploads/` (оригиналы) → `Cache-Control: public, max-age=86400` +- `/uploads-resized/` → `Cache-Control: public, max-age=31536000, immutable` +- `/uploads/.cache/` → `Cache-Control: public, max-age=31536000, immutable` + +### 4. Code splitting + +**Vite (`client/vite.config.ts`):** +``` +manualChunks: +- vendor-react: react, react-dom, react-router-dom +- vendor-mui: @mui/*, @emotion/* +- vendor-swiper: swiper +- vendor-map: maplibre-gl, react-map-gl +- vendor-tiptap: @tiptap/* +- vendor-effector: effector, effector-react +- vendor-query: @tanstack/react-query +``` + +**Route-level lazy loading:** +- `/admin/*` → `React.lazy()` — админка не нужна обычным пользователям +- `/me/*` → `React.lazy()` — личный кабинет + +### 5. HTML meta оптимизации + +**`client/index.html`:** +- `` +- `` для внешних ресурсов +- OG tags (title, description, image) +- Canonical URL + +## Data Flow + +``` +[Browser] → GET /uploads-resized/abc.avif?w=640 + ↓ +[Fastify route /uploads-resized/*] → файл есть в .cache? + ↓ NO + → читаем оригинал /uploads/abc.{png|jpg|webp} + → sharp конвертирует в AVIF, width=640 + → сохраняем в /uploads/.cache/abc_w640.avif + ↓ YES + → читаем из .cache + ↓ + → отдаём с Cache-Control: public, max-age=31536000, immutable +``` + +``` +[ProductCard] → + ↓ +[Browser] → выбирает нужный размер из srcset по viewport + ↓ +[Server] → отдаёт AVIF из кеша или генерирует +``` + +## Error Handling + +- Оригинал не найден → 404 +- Sharp недоступен (не установлен) → fallback на оригинал + warn в лог +- Конвертация失败 → fallback на оригинал +- Невалидный `?w=` → игнорировать, использовать оригинал + +## Testing + +- Server: тест ресайза — файл создаётся, формат правильный, размер соответствует +- Server: тест кеша — повторный запрос не вызывает повторную конвертацию +- Client: OptimizedImage рендерится с правильным srcset +- Client: fallback для non-AVIF браузеров + +## Migration Notes + +- Старые файлы в `/uploads/` остаются без изменений +- Новые отзывы → `/uploads/reviews/` +- Конвертация происходит on-demand при первом запросе +- `.gitignore`: `/uploads/.cache/`