From cad0584af39b7f5a8dca7bf8b59a375a649e5ffd Mon Sep 17 00:00:00 2001 From: Anthony G <1@anthony.sh> Date: Mon, 10 Aug 2026 15:29:26 +0200 Subject: [PATCH] docs: add i18n implementation plan (next-intl, 6 tasks) --- docs/superpowers/plans/2026-08-10-i18n.md | 749 ++++++++++++++++++++++ 1 file changed, 749 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-10-i18n.md diff --git a/docs/superpowers/plans/2026-08-10-i18n.md b/docs/superpowers/plans/2026-08-10-i18n.md new file mode 100644 index 0000000..94147b1 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-i18n.md @@ -0,0 +1,749 @@ +# i18n (next-intl, FR/EN/ES/IT) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add multilingual support (FR, EN, ES, IT) using next-intl, with URL-based locale routing (`/fr`, `/en`, `/es`, `/it`), auto-detection via `Accept-Language`, and a flag emoji switcher on all pages. + +**Architecture:** A Next.js middleware intercepts all non-API requests and redirects to the locale-prefixed URL detected from the browser's `Accept-Language` header (defaulting to `en`). All UI pages move under a `[locale]` dynamic segment. Components access translations via next-intl's `useTranslations`/`getTranslations` hooks backed by per-locale JSON files in `messages/`. + +**Tech Stack:** next-intl (latest), Next.js 16 App Router, React 19 + +## Global Constraints + +- Default locale (fallback): `en` +- Supported locales: `en`, `fr`, `es`, `it` +- Flags: 🇺🇸 (en), 🇫🇷 (fr), 🇪🇸 (es), 🇮🇹 (it) +- next-intl is pure JS — no native binaries, compatible with o2switch shared hosting +- API routes (`/api/*`) must NOT be locale-prefixed — middleware must exclude them +- No changes to `src/lib/`, `src/app/api/`, `worker/`, Prisma schema, or existing tests + +--- + +### Task 1: Install next-intl and create i18n configuration + +**Files:** +- Create: `src/i18n/routing.ts` +- Create: `src/i18n/request.ts` +- Create: `src/navigation.ts` +- Modify: `next.config.ts` +- Modify: `package.json` + `package-lock.json` (via npm install) + +**Interfaces:** +- Produces: `routing` exported from `src/i18n/routing.ts` — used by middleware, layout, and LanguageSwitcher +- Produces: `useRouter`, `usePathname`, `Link` locale-aware hooks from `src/navigation.ts` — used by SubmitForm and LanguageSwitcher + +- [ ] **Step 1: Install next-intl** + +```bash +npm install next-intl +``` + +- [ ] **Step 2: Create `src/i18n/routing.ts`** + +```typescript +import { defineRouting } from 'next-intl/routing' + +export const routing = defineRouting({ + locales: ['en', 'fr', 'es', 'it'], + defaultLocale: 'en', +}) +``` + +- [ ] **Step 3: Create `src/i18n/request.ts`** + +```typescript +import { getRequestConfig } from 'next-intl/server' +import { routing } from './routing' + +export default getRequestConfig(async ({ requestLocale }) => { + let locale = await requestLocale + if (!locale || !(routing.locales as readonly string[]).includes(locale)) { + locale = routing.defaultLocale + } + return { + locale, + messages: (await import(`../../messages/${locale}.json`)).default, + } +}) +``` + +- [ ] **Step 4: Create `src/navigation.ts`** + +```typescript +import { createNavigation } from 'next-intl/navigation' +import { routing } from './i18n/routing' + +export const { Link, redirect, usePathname, useRouter } = createNavigation(routing) +``` + +- [ ] **Step 5: Replace `next.config.ts`** + +```typescript +import type { NextConfig } from 'next' +import createNextIntlPlugin from 'next-intl/plugin' + +const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts') + +const nextConfig: NextConfig = {} + +export default withNextIntl(nextConfig) +``` + +- [ ] **Step 6: Commit** + +```bash +git add src/i18n/routing.ts src/i18n/request.ts src/navigation.ts next.config.ts package.json package-lock.json +git commit -m "feat: install next-intl and set up i18n routing config" +``` + +--- + +### Task 2: Add middleware for locale detection and redirect + +**Files:** +- Create: `src/middleware.ts` + +**Interfaces:** +- Consumes: `routing` from `src/i18n/routing.ts` +- Produces: middleware that reads `Accept-Language`, redirects `/` → `/en` (or detected locale), passes `/api/*` and static assets through unchanged + +- [ ] **Step 1: Create `src/middleware.ts`** + +```typescript +import createMiddleware from 'next-intl/middleware' +import { routing } from './i18n/routing' + +export default createMiddleware(routing) + +export const config = { + matcher: [ + '/((?!api|_next|_vercel|.*\\..*).*)', + ], +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/middleware.ts +git commit -m "feat: add locale-detection middleware" +``` + +--- + +### Task 3: Create translation message files + +**Files:** +- Create: `messages/en.json` +- Create: `messages/fr.json` +- Create: `messages/es.json` +- Create: `messages/it.json` + +**Interfaces:** +- Produces: namespaces `meta`, `home`, `status` consumed by `getTranslations`/`useTranslations` in Tasks 4 and 5 +- Key names must match exactly: `meta.title`, `home.heading`, `home.urlLabel`, `home.format`, `home.quality`, `home.subtitles`, `home.advanced`, `home.submit`, `home.submitting`, `home.errorRateLimit`, `home.errorGeneric`, `status.heading`, `status.notFound`, `status.loading`, `status.pending`, `status.processing`, `status.polling`, `status.ready`, `status.download`, `status.linkExpires`, `status.failed`, `status.deleted`, `status.unknown` + +- [ ] **Step 1: Create `messages/en.json`** + +```json +{ + "meta": { + "title": "Ombrora — Video downloader" + }, + "home": { + "heading": "Ombrora", + "urlLabel": "Video URL", + "format": "Format", + "quality": "Quality", + "subtitles": "Download subtitles (fr, en)", + "advanced": "Advanced options (optional, JSON)", + "submit": "Download", + "submitting": "Sending...", + "errorRateLimit": "Too many submissions. Try again in an hour.", + "errorGeneric": "Submission error." + }, + "status": { + "heading": "Download status", + "notFound": "Download not found.", + "loading": "Loading...", + "pending": "Pending", + "processing": "Processing...", + "polling": "This page refreshes automatically every 5 seconds.", + "ready": "Download ready", + "download": "Download", + "linkExpires": "Link valid until", + "failed": "Download failed.", + "deleted": "The file has expired and was deleted. The download is no longer available.", + "unknown": "Unknown status" + } +} +``` + +- [ ] **Step 2: Create `messages/fr.json`** + +```json +{ + "meta": { + "title": "Ombrora — Téléchargeur de vidéos" + }, + "home": { + "heading": "Ombrora", + "urlLabel": "URL de la vidéo", + "format": "Format", + "quality": "Qualité", + "subtitles": "Télécharger les sous-titres (fr, en)", + "advanced": "Options avancées (optionnel, JSON)", + "submit": "Télécharger", + "submitting": "Envoi...", + "errorRateLimit": "Trop de soumissions. Réessayez dans une heure.", + "errorGeneric": "Erreur lors de la soumission." + }, + "status": { + "heading": "Statut du téléchargement", + "notFound": "Téléchargement introuvable.", + "loading": "Chargement...", + "pending": "En attente", + "processing": "En cours...", + "polling": "Cette page se rafraîchit automatiquement toutes les 5 secondes.", + "ready": "Téléchargement prêt", + "download": "Télécharger", + "linkExpires": "Lien valable jusqu'au", + "failed": "Échec du téléchargement.", + "deleted": "Le fichier a expiré et a été supprimé. Le téléchargement n'est plus disponible.", + "unknown": "Statut inconnu" + } +} +``` + +- [ ] **Step 3: Create `messages/es.json`** + +```json +{ + "meta": { + "title": "Ombrora — Descargador de vídeos" + }, + "home": { + "heading": "Ombrora", + "urlLabel": "URL del vídeo", + "format": "Formato", + "quality": "Calidad", + "subtitles": "Descargar subtítulos (fr, en)", + "advanced": "Opciones avanzadas (opcional, JSON)", + "submit": "Descargar", + "submitting": "Enviando...", + "errorRateLimit": "Demasiadas peticiones. Inténtalo de nuevo en una hora.", + "errorGeneric": "Error al enviar." + }, + "status": { + "heading": "Estado de la descarga", + "notFound": "Descarga no encontrada.", + "loading": "Cargando...", + "pending": "Pendiente", + "processing": "En proceso...", + "polling": "Esta página se actualiza automáticamente cada 5 segundos.", + "ready": "Descarga lista", + "download": "Descargar", + "linkExpires": "Enlace válido hasta", + "failed": "Error en la descarga.", + "deleted": "El archivo ha expirado y fue eliminado. La descarga ya no está disponible.", + "unknown": "Estado desconocido" + } +} +``` + +- [ ] **Step 4: Create `messages/it.json`** + +```json +{ + "meta": { + "title": "Ombrora — Scaricatore di video" + }, + "home": { + "heading": "Ombrora", + "urlLabel": "URL del video", + "format": "Formato", + "quality": "Qualità", + "subtitles": "Scarica sottotitoli (fr, en)", + "advanced": "Opzioni avanzate (opzionale, JSON)", + "submit": "Scarica", + "submitting": "Invio...", + "errorRateLimit": "Troppe richieste. Riprova tra un'ora.", + "errorGeneric": "Errore durante l'invio." + }, + "status": { + "heading": "Stato del download", + "notFound": "Download non trovato.", + "loading": "Caricamento...", + "pending": "In attesa", + "processing": "In corso...", + "polling": "Questa pagina si aggiorna automaticamente ogni 5 secondi.", + "ready": "Download pronto", + "download": "Scarica", + "linkExpires": "Link valido fino al", + "failed": "Errore nel download.", + "deleted": "Il file è scaduto ed è stato eliminato. Il download non è più disponibile.", + "unknown": "Stato sconosciuto" + } +} +``` + +- [ ] **Step 5: Commit** + +```bash +git add messages/ +git commit -m "feat: add translation messages for en, fr, es, it" +``` + +--- + +### Task 4: Restructure app routes under [locale] + create LanguageSwitcher + +**Files:** +- Modify: `src/app/layout.tsx` (replace with minimal root pass-through) +- Create: `src/components/LanguageSwitcher.tsx` +- Create: `src/app/[locale]/layout.tsx` +- Create: `src/app/[locale]/page.tsx` +- Create: `src/app/[locale]/status/[uuid]/page.tsx` +- Delete: `src/app/page.tsx` +- Delete: `src/app/status/[uuid]/page.tsx` (whole `src/app/status/` tree) + +**Interfaces:** +- Consumes: `routing` from `@/i18n/routing` +- Consumes: `getTranslations`, `getMessages` from `next-intl/server` +- Consumes: `NextIntlClientProvider` from `next-intl` +- Consumes: `useRouter`, `usePathname` from `@/navigation` +- Consumes: message keys `meta.title`, `home.heading`, `status.heading` + +- [ ] **Step 1: Replace `src/app/layout.tsx` with a minimal root pass-through** + +The root layout must exist but should not render ``/`
` — those come from `[locale]/layout.tsx` so the `lang` attribute can be set dynamically. Overwrite the file with: + +```tsx +export default function RootLayout({ children }: { children: React.ReactNode }) { + return children +} +``` + +- [ ] **Step 2: Create `src/components/LanguageSwitcher.tsx`** + +```tsx +'use client' + +import { useLocale } from 'next-intl' +import { useRouter, usePathname } from '@/navigation' +import { routing } from '@/i18n/routing' + +const FLAGS: Record{t('notFound')}
+ if (!data) return{t('loading')}
+ + if (data.status === 'PENDING' || data.status === 'PROCESSING') { + return ( +{data.status === 'PENDING' ? t('pending') : t('processing')}
+{t('polling')}
+{t('ready')} {sizeMb}
+ + {t('download')} {data.fileName} + + {data.tokenExpiresAt && ( ++ {t('linkExpires')}{' '} + {new Date(data.tokenExpiresAt).toLocaleString(locale)} +
+ )} +{t('failed')}
+
+ {data.errorMsg}
+
+ {t('deleted')}
+ } + + return{t('unknown')}: {data.status}
+} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/components/SubmitForm.tsx src/components/StatusView.tsx +git commit -m "feat: translate SubmitForm and StatusView with next-intl" +``` + +--- + +### Task 6: Smoke test + +No automated tests exist for UI components in this project. Verify manually. + +- [ ] **Step 1: Start the dev server** + +```bash +npm run dev +``` + +- [ ] **Step 2: Verify locale redirect** + +Open `http://localhost:3000` in a browser. Confirm it redirects to `/en` (or your browser's locale if it is one of `fr`, `es`, `it`). + +- [ ] **Step 3: Verify flag switcher** + +Confirm four flag buttons appear at the top of the page: 🇺🇸 🇫🇷 🇪🇸 🇮🇹. Click each and confirm: +- The URL changes to `/fr`, `/en`, `/es`, `/it` +- The page text changes language +- The active flag is fully opaque; others are at 40% opacity + +- [ ] **Step 4: Verify form submission redirect** + +Submit a valid YouTube URL. Confirm the browser redirects to `/[current-locale]/status/[uuid]` (not `/status/[uuid]`). + +- [ ] **Step 5: Verify status page** + +Open a status URL directly (`/fr/status/some-uuid`). Confirm the heading and all status strings are in French. + +- [ ] **Step 6: Verify API routes are unaffected** + +Confirm `http://localhost:3000/api/downloads` still returns a JSON response (not a 404 or locale redirect).