fix(seo): locale edge cases, manifest and structured data polish #31

Closed
opened 2026-08-07 19:50:59 +03:00 by slaid098 · 0 comments
Owner

Контекст

Остаточные SEO/UX edge cases после issue #29 (routing/404) и issue #30 (hreflang/OG/robots). Uppercase locale /RU bypass → redirect на 404, manifest.ts hardcoded lang: "ru" и start_url: "/" для en-пользователей и PWA launch, [slug] использует og:type: "website" вместо article, sitemap lastmod = createdAt (не updatedAt), switchLocalePath не валидирует существование counterpart, metadataBase missing в root layout. По отдельности low-severity, но для цели «ни одного косяка» — все должны быть закрыты.

Текущее состояние (подтверждено аудитом актуального кода):

  1. /RU, /En uppercasesrc/middleware.ts:56 matcher case-sensitive. /RU не матчит ru|en в lookahead → middleware РАБОТАЕТ на /RUisLocale("RU") возвращает false (строки 317-319 проверяют lowercase) → redirect 308 → /ru/RU[slug]getShowcaseRepo("RU") → null → 404. Должно: /RU → redirect на /ru (case-insensitive locale detection). Live: curl -sI https://slaid098.dev/Ru → 200 (баг — должно redirect).

  2. src/app/manifest.ts:12lang: "ru" hardcoded. Manifest единственный на оба locale (/manifest.webmanifest). En-пользователи PWA получают lang: "ru".

  3. src/app/manifest.ts:8start_url: "/" → middleware redirect → /{locale}. PWA launch всегда hit redirect. Должно: start_url: "/ru" (default locale) — или locale-aware (невозможно с single manifest, но можно убрать redirect hop).

  4. src/app/[locale]/[slug]/page.tsx:46openGraph.type: "website". Для detail-страницы software repo — og:type: "article" семантически правильнее (enables published_time, author, richer social cards).

  5. src/app/sitemap.ts:37-39lastModified = new Date(r.createdAt). DiscoveredRepo (src/lib/readme-discovery.ts:10-25) имеет только createdAt (нет updatedAt). Sitemap lastmod никогда не меняется после создания repo → Google recrawl реже. Нужен updatedAt из Forgejo API detail (поле updated_at).

  6. src/lib/i18n.ts:321-331switchLocalePath меняет locale префикс без проверки что counterpart URL существует. switchLocalePath("/ru/some-slug", "en")/en/some-slug без проверки. src/components/language-switcher.tsx:10 использует это → internal links на потенциальные 404.

  7. src/app/layout.tsx — нет metadataBase. Root not-found.tsx metadata без metadataBase → URL resolve against Vercel default. [locale]/layout.tsx:28 имеет metadataBase, но root — нет.

Задача

  1. Uppercase localesrc/middleware.ts — в начале middleware() функции (после парсинга segments, строка 24), добавь case-insensitive locale detection: const lowerFirst = first?.toLowerCase(); if (lowerFirst === "ru" || lowerFirst === "en") { if (first !== lowerFirst) { return NextResponse.redirect(new URL(\/${lowerFirst}${pathname.slice(first.length)}`, request.url), 308); } }. Это редиректит /RU/foo/ru/foo(308). Не ломает existingisLocale()(который проверяет lowercase). Matcher regex не меняй (он исключает/ru//en` lowercase — для uppercase middleware сработает и сделает case-correction redirect). Обнови тесты.

  2. src/app/manifest.tslang: "ru" → оставь "ru" (default locale, single manifest — нет способа сделать locale-aware без двух manifest файлов). start_url: "/" (строка 8) → start_url: "/ru" (избежать redirect hop для default-locale PWA). Если хочешь i18n manifest — это out of scope (отдельная фича).

  3. src/app/[locale]/[slug]/page.tsxopenGraph.type: "website" (строка 46) → "article". Добавь openGraph.publishedTime: repo.createdAt (если есть), authors: ["Sergey"] (опционально). Article type требует published_time для rich results.

  4. Sitemap lastmodsrc/lib/readme-discovery.ts — добавь updatedAt: string | null поле в DiscoveredRepo тип (строка 10-25), заполни из Forgejo API detail (поле updated_at — проверь ForgejoRepoDetail тип, строки 53-55, нужно добавить updated_at: string). discoverSingleRepo и buildDiscoveredRepo — пробрось updatedAt (из detail.updated_at). makeErrorRepoupdatedAt: null. src/app/sitemap.ts:37-39new Date(r.updatedAt ?? r.createdAt) вместо new Date(r.createdAt) (fallback на createdAt если updatedAt null).

  5. switchLocalePathsrc/lib/i18n.ts:321-331 — оставь функцию sync (pure), но добавь комментарий/документацию что функция предполагает same-slug-both-locales (repo существует в обоих locale с одинаковым slug; статические роуты apps/contacts/subscriptions существуют в обоих locale всегда). Actual validation не нужна для текущей модели. Real async validation — out of scope (требует async refactor language-switcher).

  6. src/app/layout.tsx — добавь metadataBase: new URL(baseUrl) (тот же baseUrl что в [locale]/layout.tsx:13). Это нужно для root not-found.tsx metadata URL resolution.

Контракты

  • GET /RU → 308 → /ru
  • GET /RU/foo → 308 → /ru/foo
  • GET /En → 308 → /en
  • GET /En/foo → 308 → /en/foo
  • /manifest.webmanifeststart_url: "/ru" (не /)
  • HTML /ru/opencode-configmeta property="og:type" content="article"
  • HTML /ru/opencode-configmeta property="article:published_time" присутствует
  • sitemap.xml — <lastmod> для repo URLs = updated_at (не created_at), с fallback на created_at если updated_at null
  • Root not-found.tsx metadata resolves URLs against https://slaid098.dev

Инварианты

  • Locale detection case-insensitive в middleware (но canonical URLs всегда lowercase /ru, /en)
  • manifest.ts — single manifest, lang: "ru" (default), start_url: "/ru" (default, без redirect)
  • og:type: "article" только для [slug] detail pages (home/apps/contacts = "website")
  • Sitemap lastmod fallback: updatedAt ?? createdAt
  • switchLocalePath остаётся sync (pure) — инвариант same-slug-both-locales задокументирован

Граничные случаи

  • /RU/apps → 308 → /ru/apps (case correction + сохранение path)
  • /Ru (mixed case) → 308 → /ru
  • /EN/foo → 308 → /en/foo
  • Repo без updated_at (API возвращает null) → sitemap fallback на createdAt
  • og:type: "article" без published_time — валидно (опциональное поле), но лучше указать если есть

Влияние на связанные компоненты

  • src/middleware.ts — case-insensitive locale detection (новый блок в начале функции)
  • src/__tests__/middleware.test.ts — тесты uppercase locale
  • src/app/manifest.tsstart_url
  • src/app/[locale]/[slug]/page.tsxog:type: "article", publishedTime
  • src/lib/readme-discovery.tsupdatedAt поле в DiscoveredRepo + ForgejoRepoDetail + buildDiscoveredRepo + makeErrorRepo
  • src/app/sitemap.tslastmod = updatedAt ?? createdAt
  • src/lib/i18n.tsswitchLocalePath комментарий/документация
  • src/app/layout.tsxmetadataBase

Вне scope

  • Locale-aware manifest (два manifest файла) — отдельная фича
  • Async switchLocalePath с real validation — требует refactor language-switcher
  • Matcher regex fix (issue #29)
  • 307 → 308 (issue #29)
  • dynamicParams (issue #29)
  • hreflang (issue #30)
  • og:image на статических pages (issue #30)
  • robots.txt disallow (issue #30)
  • Legacy redirect chains (issue #30)

Критерии приемки

  • curl -sI https://slaid098.dev/RU → 308 → /ru
  • curl -sI https://slaid098.dev/RU/apps → 308 → /ru/apps
  • curl -sI https://slaid098.dev/En → 308 → /en
  • curl -s https://slaid098.dev/manifest.webmanifeststart_url: "/ru"
  • HTML /ru/opencode-configmeta property="og:type" content="article"
  • sitemap.xml — <lastmod> для /ru/opencode-config = updated_at дата (не created_at)
  • npm run lint, npm run typecheck, npm test проходят
## Контекст Остаточные SEO/UX edge cases после issue #29 (routing/404) и issue #30 (hreflang/OG/robots). Uppercase locale `/RU` bypass → redirect на 404, manifest.ts hardcoded `lang: "ru"` и `start_url: "/"` для en-пользователей и PWA launch, `[slug]` использует `og:type: "website"` вместо `article`, sitemap `lastmod` = `createdAt` (не `updatedAt`), `switchLocalePath` не валидирует существование counterpart, `metadataBase` missing в root layout. По отдельности low-severity, но для цели «ни одного косяка» — все должны быть закрыты. Текущее состояние (подтверждено аудитом актуального кода): 1. **`/RU`, `/En` uppercase** — `src/middleware.ts:56` matcher case-sensitive. `/RU` не матчит `ru|en` в lookahead → middleware РАБОТАЕТ на `/RU` → `isLocale("RU")` возвращает false (строки 317-319 проверяют lowercase) → redirect 308 → `/ru/RU` → `[slug]` → `getShowcaseRepo("RU")` → null → 404. Должно: `/RU` → redirect на `/ru` (case-insensitive locale detection). Live: `curl -sI https://slaid098.dev/Ru` → 200 (баг — должно redirect). 2. **`src/app/manifest.ts:12`** — `lang: "ru"` hardcoded. Manifest единственный на оба locale (`/manifest.webmanifest`). En-пользователи PWA получают `lang: "ru"`. 3. **`src/app/manifest.ts:8`** — `start_url: "/"` → middleware redirect → `/{locale}`. PWA launch всегда hit redirect. Должно: `start_url: "/ru"` (default locale) — или locale-aware (невозможно с single manifest, но можно убрать redirect hop). 4. **`src/app/[locale]/[slug]/page.tsx:46`** — `openGraph.type: "website"`. Для detail-страницы software repo — `og:type: "article"` семантически правильнее (enables `published_time`, `author`, richer social cards). 5. **`src/app/sitemap.ts:37-39`** — `lastModified = new Date(r.createdAt)`. `DiscoveredRepo` (`src/lib/readme-discovery.ts:10-25`) имеет только `createdAt` (нет `updatedAt`). Sitemap `lastmod` никогда не меняется после создания repo → Google recrawl реже. Нужен `updatedAt` из Forgejo API detail (поле `updated_at`). 6. **`src/lib/i18n.ts:321-331`** — `switchLocalePath` меняет locale префикс без проверки что counterpart URL существует. `switchLocalePath("/ru/some-slug", "en")` → `/en/some-slug` без проверки. `src/components/language-switcher.tsx:10` использует это → internal links на потенциальные 404. 7. **`src/app/layout.tsx`** — нет `metadataBase`. Root `not-found.tsx` metadata без metadataBase → URL resolve against Vercel default. `[locale]/layout.tsx:28` имеет `metadataBase`, но root — нет. ## Задача 1. **Uppercase locale** — `src/middleware.ts` — в начале `middleware()` функции (после парсинга segments, строка 24), добавь case-insensitive locale detection: `const lowerFirst = first?.toLowerCase(); if (lowerFirst === "ru" || lowerFirst === "en") { if (first !== lowerFirst) { return NextResponse.redirect(new URL(\`/${lowerFirst}${pathname.slice(first.length)}\`, request.url), 308); } }`. Это редиректит `/RU/foo` → `/ru/foo` (308). Не ломает existing `isLocale()` (который проверяет lowercase). Matcher regex не меняй (он исключает `/ru`/`/en` lowercase — для uppercase middleware сработает и сделает case-correction redirect). Обнови тесты. 2. **`src/app/manifest.ts`** — `lang: "ru"` → оставь `"ru"` (default locale, single manifest — нет способа сделать locale-aware без двух manifest файлов). `start_url: "/"` (строка 8) → `start_url: "/ru"` (избежать redirect hop для default-locale PWA). Если хочешь i18n manifest — это out of scope (отдельная фича). 3. **`src/app/[locale]/[slug]/page.tsx`** — `openGraph.type: "website"` (строка 46) → `"article"`. Добавь `openGraph.publishedTime: repo.createdAt` (если есть), `authors: ["Sergey"]` (опционально). Article type требует `published_time` для rich results. 4. **Sitemap lastmod** — `src/lib/readme-discovery.ts` — добавь `updatedAt: string | null` поле в `DiscoveredRepo` тип (строка 10-25), заполни из Forgejo API detail (поле `updated_at` — проверь `ForgejoRepoDetail` тип, строки 53-55, нужно добавить `updated_at: string`). `discoverSingleRepo` и `buildDiscoveredRepo` — пробрось `updatedAt` (из `detail.updated_at`). `makeErrorRepo` — `updatedAt: null`. `src/app/sitemap.ts:37-39` — `new Date(r.updatedAt ?? r.createdAt)` вместо `new Date(r.createdAt)` (fallback на `createdAt` если `updatedAt` null). 5. **`switchLocalePath`** — `src/lib/i18n.ts:321-331` — оставь функцию sync (pure), но добавь комментарий/документацию что функция предполагает same-slug-both-locales (repo существует в обоих locale с одинаковым slug; статические роуты apps/contacts/subscriptions существуют в обоих locale всегда). Actual validation не нужна для текущей модели. Real async validation — out of scope (требует async refactor language-switcher). 6. **`src/app/layout.tsx`** — добавь `metadataBase: new URL(baseUrl)` (тот же `baseUrl` что в `[locale]/layout.tsx:13`). Это нужно для root `not-found.tsx` metadata URL resolution. ## Контракты - `GET /RU` → 308 → `/ru` - `GET /RU/foo` → 308 → `/ru/foo` - `GET /En` → 308 → `/en` - `GET /En/foo` → 308 → `/en/foo` - `/manifest.webmanifest` — `start_url: "/ru"` (не `/`) - HTML `/ru/opencode-config` — `meta property="og:type" content="article"` - HTML `/ru/opencode-config` — `meta property="article:published_time"` присутствует - sitemap.xml — `<lastmod>` для repo URLs = `updated_at` (не `created_at`), с fallback на `created_at` если `updated_at` null - Root `not-found.tsx` metadata resolves URLs against `https://slaid098.dev` ## Инварианты - Locale detection case-insensitive в middleware (но canonical URLs всегда lowercase `/ru`, `/en`) - `manifest.ts` — single manifest, `lang: "ru"` (default), `start_url: "/ru"` (default, без redirect) - `og:type: "article"` только для `[slug]` detail pages (home/apps/contacts = "website") - Sitemap `lastmod` fallback: `updatedAt ?? createdAt` - `switchLocalePath` остаётся sync (pure) — инвариант same-slug-both-locales задокументирован ## Граничные случаи - `/RU/apps` → 308 → `/ru/apps` (case correction + сохранение path) - `/Ru` (mixed case) → 308 → `/ru` - `/EN/foo` → 308 → `/en/foo` - Repo без `updated_at` (API возвращает null) → sitemap fallback на `createdAt` - `og:type: "article"` без `published_time` — валидно (опциональное поле), но лучше указать если есть ## Влияние на связанные компоненты - `src/middleware.ts` — case-insensitive locale detection (новый блок в начале функции) - `src/__tests__/middleware.test.ts` — тесты uppercase locale - `src/app/manifest.ts` — `start_url` - `src/app/[locale]/[slug]/page.tsx` — `og:type: "article"`, `publishedTime` - `src/lib/readme-discovery.ts` — `updatedAt` поле в `DiscoveredRepo` + `ForgejoRepoDetail` + `buildDiscoveredRepo` + `makeErrorRepo` - `src/app/sitemap.ts` — `lastmod` = `updatedAt ?? createdAt` - `src/lib/i18n.ts` — `switchLocalePath` комментарий/документация - `src/app/layout.tsx` — `metadataBase` ## Вне scope - ❌ Locale-aware manifest (два manifest файла) — отдельная фича - ❌ Async `switchLocalePath` с real validation — требует refactor language-switcher - ❌ Matcher regex fix (issue #29) - ❌ 307 → 308 (issue #29) - ❌ dynamicParams (issue #29) - ❌ hreflang (issue #30) - ❌ og:image на статических pages (issue #30) - ❌ robots.txt disallow (issue #30) - ❌ Legacy redirect chains (issue #30) ## Критерии приемки - [ ] `curl -sI https://slaid098.dev/RU` → 308 → `/ru` - [ ] `curl -sI https://slaid098.dev/RU/apps` → 308 → `/ru/apps` - [ ] `curl -sI https://slaid098.dev/En` → 308 → `/en` - [ ] `curl -s https://slaid098.dev/manifest.webmanifest` → `start_url: "/ru"` - [ ] HTML `/ru/opencode-config` — `meta property="og:type" content="article"` - [ ] sitemap.xml — `<lastmod>` для `/ru/opencode-config` = `updated_at` дата (не `created_at`) - [ ] `npm run lint`, `npm run typecheck`, `npm test` проходят
Sign in to join this conversation.
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#31
No description provided.