fix(seo): hreflang open graph robots and redirect chains #33

Merged
slaid098 merged 5 commits from fix/seo/hreflang-og-robots-chains into main 2026-08-07 20:35:17 +03:00
Owner

Что сделано

Реализована issue #30 (зависела от PR#32, который уже в main). 6 пунктов спеки:

  1. In-HTML hreflang — во все generateMetadata (home, apps, contacts, subscriptions, [slug]) добавлен alternates: { languages: { ru, en, "x-default" } }. Next.js эмитит <link rel="alternate" hreflang="..." href="..."> в <head>. Для [slug] — hreflang только если repo найден (возвращает {} без hreflang для 404).

  2. Legacy redirect цепочки — убраны non-locale legacy redirects из next.config.ts (/youtube, /take_break, /support), перенесены в middleware с 308 и locale из getLocale(request): один hop GET /youtube → 308 → /ru/yt-video-downloader (вместо двух-hop цепочки 308→307). Locale-prefixed legacy redirects (/ru/youtube, /en/youtube и т.д.) оставлены в next.config.ts — matcher middleware исключает /ru/* и /en/* (CDN serve static, по дизайну PR#32), middleware не может их ловить в production. При прямом вызове middleware ловит и locale-prefixed legacy (тест покрывает). Координация с PR#32: LEGACY_REDIRECTS добавлен обратно как живой код (PR#32 удалил dead-code версию).

  3. og:image — добавлен images: [{ url: "/og-image.png", width: 1024, height: 1024 }] в openGraph для apps/contacts/subscriptions (child page override'ил родительский layout, images терялись).

  4. robots.txt disallowrules: { userAgent: "*", allow: "/", disallow: ["/api/"] }. __404__ route удалён в PR#32 → disallow только /api/.

  5. JSON-LD @id коллизияapps/page.tsx: @type WebSiteCollectionPage, @id ${baseUrl}/${loc}${baseUrl}/${loc}/apps, добавлен isPartOf: { "@type": "WebSite", "@id": ${baseUrl}/${loc} }. Удалён дублирующий Person JSON-LD (остался только на home).

  6. trailingSlashtrailingSlash: false явно в next.config.ts.

Почему

Сайт терял SEO-сигналы: Google не понимал связь ru/en (нет in-HTML hreflang), redirect-цепочки dilute link equity (2-hop вместо 1), плохие social cards на apps/contacts/subscriptions (нет og:image), crawl budget waste (robots.txt не disallow /api/), JSON-LD @id коллизия (home и apps оба WebSite @id=/ru → Google merge/confuse entities).

Watch out

— Locale-prefixed legacy redirects (/ru/youtube и т.д.) остались в next.config.ts, НЕ в middleware — matcher исключает /ru/*//en/* (PR#32 дизайн, CDN serve static). Non-locale (/youtube) — в middleware (один hop с locale). Это split design, не oversight.
CollectionPage JSON-LD использует d.apps.title(repos.length) для name (локализованный заголовок с count), а не hardcoded "slaid098.dev" (как было в WebSite).

Pending

Closes #30

## Что сделано Реализована issue #30 (зависела от PR#32, который уже в main). 6 пунктов спеки: 1. **In-HTML hreflang** — во все `generateMetadata` (home, apps, contacts, subscriptions, `[slug]`) добавлен `alternates: { languages: { ru, en, "x-default" } }`. Next.js эмитит `<link rel="alternate" hreflang="..." href="...">` в `<head>`. Для `[slug]` — hreflang только если repo найден (возвращает `{}` без hreflang для 404). 2. **Legacy redirect цепочки** — убраны non-locale legacy redirects из `next.config.ts` (`/youtube`, `/take_break`, `/support`), перенесены в middleware с 308 и locale из `getLocale(request)`: один hop `GET /youtube` → 308 → `/ru/yt-video-downloader` (вместо двух-hop цепочки 308→307). Locale-prefixed legacy redirects (`/ru/youtube`, `/en/youtube` и т.д.) оставлены в `next.config.ts` — matcher middleware исключает `/ru/*` и `/en/*` (CDN serve static, по дизайну PR#32), middleware не может их ловить в production. При прямом вызове middleware ловит и locale-prefixed legacy (тест покрывает). Координация с PR#32: `LEGACY_REDIRECTS` добавлен обратно как живой код (PR#32 удалил dead-code версию). 3. **og:image** — добавлен `images: [{ url: "/og-image.png", width: 1024, height: 1024 }]` в `openGraph` для apps/contacts/subscriptions (child page override'ил родительский layout, images терялись). 4. **robots.txt disallow** — `rules: { userAgent: "*", allow: "/", disallow: ["/api/"] }`. `__404__` route удалён в PR#32 → disallow только `/api/`. 5. **JSON-LD @id коллизия** — `apps/page.tsx`: `@type` `WebSite` → `CollectionPage`, `@id` `${baseUrl}/${loc}` → `${baseUrl}/${loc}/apps`, добавлен `isPartOf: { "@type": "WebSite", "@id": ${baseUrl}/${loc} }`. Удалён дублирующий `Person` JSON-LD (остался только на home). 6. **trailingSlash** — `trailingSlash: false` явно в `next.config.ts`. ## Почему Сайт терял SEO-сигналы: Google не понимал связь ru/en (нет in-HTML hreflang), redirect-цепочки dilute link equity (2-hop вместо 1), плохие social cards на apps/contacts/subscriptions (нет og:image), crawl budget waste (robots.txt не disallow `/api/`), JSON-LD @id коллизия (home и apps оба `WebSite @id=/ru` → Google merge/confuse entities). ## Watch out — Locale-prefixed legacy redirects (`/ru/youtube` и т.д.) остались в `next.config.ts`, НЕ в middleware — matcher исключает `/ru/*`/`/en/*` (PR#32 дизайн, CDN serve static). Non-locale (`/youtube`) — в middleware (один hop с locale). Это split design, не oversight. — `CollectionPage` JSON-LD использует `d.apps.title(repos.length)` для `name` (локализованный заголовок с count), а не hardcoded "slaid098.dev" (как было в WebSite). ## Pending — Closes #30
Author
Owner

Code Review Summary

PR реализует все 6 пунктов спеки issue #30 (hreflang, redirect chains, og:image, robots disallow, JSON-LD @id collision, trailingSlash). CI green, тесты middleware расширены (+10 кейсов для legacy redirects), cross-file impact чистый (writer+reader в одном PR).

Positives

  • hreflang consistency — все 5 generateMetadata (home, apps, contacts, subscriptions, [slug]) добавляют alternates.languages с ru/en/x-default. Для [slug] hreflang только если repo найден (if (!repo) return {} — корректно, нет hreflang на 404). x-default = ru (default locale) — консистентно.
  • Redirect chains — один hop — non-locale legacy (/youtube, /take_break, /support) перенесены из next.config.ts в middleware с 308 + locale из getLocale(request) (cookie → Accept-Language → default). Раньше было 2-hop (308 next.config → 307 middleware), теперь 1-hop (308 middleware). Locale-prefixed legacy (/ru/youtube) оставлены в next.config.ts — matcher middleware исключает /ru/*/en/* (CDN serve static, дизайн PR#32). Split design задокументирован в PR body "Watch out" — не oversight.
  • JSON-LD @id уникальностьapps/page.tsx: WebSite @id=${baseUrl}/${loc}CollectionPage @id=${baseUrl}/${loc}/apps + isPartOf: { WebSite, @id=${baseUrl}/${loc} }. Коллизия с home устранена. Дублирующий Person JSON-LD удалён из apps (остался только на home). Все @id теперь уникальны per page: home=/${loc}, apps=/${loc}/apps, contacts=/${loc}/contacts, subscriptions=/${loc}/subscriptions, [slug]=/${loc}/${slug}.
  • og:image — добавлен images: [{ url: "/og-image.png", width: 1024, height: 1024 }] в apps/contacts/subscriptions. Файл src/app/og-image.png существует (tracked, PR#20), Next.js file-metadata convention обслуживает /og-image.png. og:url = canonical URL — консистентно во всех страницах.
  • robots disallowdisallow: ["/api/"] (раньше не было). __404__ route удалён в PR#32 → disallow только /api/.
  • Тесты — 10 новых кейсов: non-locale legacy (default + Accept-Language), locale-prefixed legacy (called directly, matcher-skipped in prod), passthrough /ru/apps. Все кейсы покрывают split design.
  • PR hygiene — title fix(seo): ... соответствует конвенции, body содержит Что сделано/Почему/Watch out/Pending заполнены осмысленно, Closes #30 присутствует.

Suggestions (info, not blocking)

  • src/middleware.ts:31 [style] const locale = first as Locale; — избыточный cast. isLocale(first) это type guard (str is Locale), поэтому внутри if (first !== undefined && isLocale(first)) TypeScript уже сужает first до Locale. Cast можно убрать: const locale = first;. Не влияет на runtime, чисто type-level.
  • hreflang pattern duplication [style] Блок alternates: { canonical, languages: { ru, en, "x-default" } } повторяется в 5 файлах с разными path-значениями. Можно вынести в хелпер makeAlternates(baseUrl, loc, path), но т.к. значения различаются (path per page) — абстракция добавит мало пользы. Оставлено на усмотрение автора.

Verdict: APPROVE

## Code Review Summary PR реализует все 6 пунктов спеки issue #30 (hreflang, redirect chains, og:image, robots disallow, JSON-LD @id collision, trailingSlash). CI green, тесты middleware расширены (+10 кейсов для legacy redirects), cross-file impact чистый (writer+reader в одном PR). ### Positives - **hreflang consistency** — все 5 `generateMetadata` (home, apps, contacts, subscriptions, `[slug]`) добавляют `alternates.languages` с ru/en/x-default. Для `[slug]` hreflang только если repo найден (`if (!repo) return {}` — корректно, нет hreflang на 404). x-default = ru (default locale) — консистентно. - **Redirect chains — один hop** — non-locale legacy (`/youtube`, `/take_break`, `/support`) перенесены из `next.config.ts` в middleware с 308 + locale из `getLocale(request)` (cookie → Accept-Language → default). Раньше было 2-hop (308 next.config → 307 middleware), теперь 1-hop (308 middleware). Locale-prefixed legacy (`/ru/youtube`) оставлены в `next.config.ts` — matcher middleware исключает `/ru/*`/`en/*` (CDN serve static, дизайн PR#32). Split design задокументирован в PR body "Watch out" — не oversight. - **JSON-LD @id уникальность** — `apps/page.tsx`: `WebSite @id=${baseUrl}/${loc}` → `CollectionPage @id=${baseUrl}/${loc}/apps` + `isPartOf: { WebSite, @id=${baseUrl}/${loc} }`. Коллизия с home устранена. Дублирующий `Person` JSON-LD удалён из apps (остался только на home). Все @id теперь уникальны per page: home=`/${loc}`, apps=`/${loc}/apps`, contacts=`/${loc}/contacts`, subscriptions=`/${loc}/subscriptions`, [slug]=`/${loc}/${slug}`. - **og:image** — добавлен `images: [{ url: "/og-image.png", width: 1024, height: 1024 }]` в apps/contacts/subscriptions. Файл `src/app/og-image.png` существует (tracked, PR#20), Next.js file-metadata convention обслуживает `/og-image.png`. og:url = canonical URL — консистентно во всех страницах. - **robots disallow** — `disallow: ["/api/"]` (раньше не было). `__404__` route удалён в PR#32 → disallow только `/api/`. - **Тесты** — 10 новых кейсов: non-locale legacy (default + Accept-Language), locale-prefixed legacy (called directly, matcher-skipped in prod), passthrough `/ru/apps`. Все кейсы покрывают split design. - **PR hygiene** — title `fix(seo): ...` соответствует конвенции, body содержит Что сделано/Почему/Watch out/Pending заполнены осмысленно, `Closes #30` присутствует. ### Suggestions (info, not blocking) - **src/middleware.ts:31** [style] `const locale = first as Locale;` — избыточный cast. `isLocale(first)` это type guard (`str is Locale`), поэтому внутри `if (first !== undefined && isLocale(first))` TypeScript уже сужает `first` до `Locale`. Cast можно убрать: `const locale = first;`. Не влияет на runtime, чисто type-level. - **hreflang pattern duplication** [style] Блок `alternates: { canonical, languages: { ru, en, "x-default" } }` повторяется в 5 файлах с разными path-значениями. Можно вынести в хелпер `makeAlternates(baseUrl, loc, path)`, но т.к. значения различаются (path per page) — абстракция добавит мало пользы. Оставлено на усмотрение автора. ### Verdict: APPROVE
slaid098 deleted branch fix/seo/hreflang-og-robots-chains 2026-08-07 20:35:17 +03:00
Sign in to join this conversation.
No reviewers
No labels
bug
performance
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
slaid098/slaid098-dev!33
No description provided.