Refactor i18n setup and improve structure for maintainability
- Relocated `i18n` configuration files to `src/lib/i18n` for better organization. - Removed obsolete `request.ts` and `routing.ts` files, simplifying `i18n` setup within the project. - Added extensive tests for `i18n/utils` to validate locale-related utilities, including locale name, native name, and flag retrieval. - Introduced a detailed `I18N_IMPLEMENTATION_PLAN.md` to document implementation phases, decisions, and recommendations for future extensions. - Enhanced TypeScript definitions and modularity across i18n utilities for improved developer experience.
This commit is contained in:
528
docs/I18N_IMPLEMENTATION_PLAN.md
Normal file
528
docs/I18N_IMPLEMENTATION_PLAN.md
Normal file
@@ -0,0 +1,528 @@
|
|||||||
|
# Internationalization (i18n) Implementation Plan
|
||||||
|
## State-of-the-Art Next.js 15 + FastAPI i18n System (2025)
|
||||||
|
|
||||||
|
**Last Updated**: 2025-11-17
|
||||||
|
**Status**: ✅ Approved - Ready for Implementation
|
||||||
|
**Languages**: English (en) - Default, Italian (it) - Showcase
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 EXECUTIVE SUMMARY
|
||||||
|
|
||||||
|
This document provides a comprehensive plan for implementing production-grade internationalization (i18n) in the FastNext Template. The implementation showcases best practices with 2 languages (English and Italian), making it easy for users to extend with additional languages.
|
||||||
|
|
||||||
|
### Technology Stack (2025 Best Practices)
|
||||||
|
|
||||||
|
- **Frontend**: next-intl 4.0 (ESM, TypeScript-first, App Router native)
|
||||||
|
- **Backend**: FastAPI with BCP 47 locale storage in PostgreSQL
|
||||||
|
- **Testing**: Playwright (parameterized locale tests) + Jest (i18n test utils)
|
||||||
|
- **SEO**: Automatic hreflang tags, sitemap generation, metadata per locale
|
||||||
|
- **Validation**: Automated translation key validation in CI/CD
|
||||||
|
|
||||||
|
### Why Only 2 Languages?
|
||||||
|
|
||||||
|
This is a **template showcase**, not a production deployment:
|
||||||
|
|
||||||
|
✅ Clean example of i18n implementation
|
||||||
|
✅ Easy to understand patterns
|
||||||
|
✅ Users can add languages by copying the Italian example
|
||||||
|
✅ Faster testing and implementation
|
||||||
|
✅ Smaller bundle size for demonstration
|
||||||
|
|
||||||
|
### Quality Standards
|
||||||
|
|
||||||
|
- ✅ **Test Coverage**: Backend ≥97%, comprehensive E2E tests
|
||||||
|
- ✅ **Zero Breaking Changes**: All existing 743 backend + 56 frontend tests must pass
|
||||||
|
- ✅ **Type Safety**: Full autocomplete for translation keys
|
||||||
|
- ✅ **Performance**: Core Web Vitals maintained (LCP < 2.5s, INP < 200ms, CLS < 0.1)
|
||||||
|
- ✅ **SEO**: Lighthouse SEO score 100 for both locales
|
||||||
|
- ✅ **GDPR Compliant**: User locale preferences handled appropriately
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 IMPLEMENTATION PHASES
|
||||||
|
|
||||||
|
### Phase 0: Documentation & Planning (2 hours)
|
||||||
|
- Create this implementation plan document
|
||||||
|
- Document architecture decisions
|
||||||
|
|
||||||
|
### Phase 1: Backend Foundation (4 hours)
|
||||||
|
- Add `locale` column to User model
|
||||||
|
- Create database migration
|
||||||
|
- Update Pydantic schemas
|
||||||
|
- Create locale detection dependency
|
||||||
|
- Add backend tests
|
||||||
|
|
||||||
|
### Phase 2: Frontend Setup (4 hours)
|
||||||
|
- Install and configure next-intl
|
||||||
|
- Create translation files (EN, IT)
|
||||||
|
- Configure TypeScript autocomplete
|
||||||
|
- Restructure App Router for [locale] pattern
|
||||||
|
- Fix tests
|
||||||
|
|
||||||
|
### Phase 3: Component Translation (4 hours)
|
||||||
|
- Create LocaleSwitcher component
|
||||||
|
- Translate auth components
|
||||||
|
- Translate navigation components
|
||||||
|
- Translate settings components
|
||||||
|
- Review and test
|
||||||
|
|
||||||
|
### Phase 4: SEO & Metadata (3 hours)
|
||||||
|
- Implement locale-aware metadata
|
||||||
|
- Generate multilingual sitemap
|
||||||
|
- Configure robots.txt
|
||||||
|
- SEO validation
|
||||||
|
|
||||||
|
### Phase 5: Performance Optimization (3 hours)
|
||||||
|
- Measure Core Web Vitals baseline
|
||||||
|
- Optimize translation loading
|
||||||
|
- Prevent CLS with font loading
|
||||||
|
- Performance validation
|
||||||
|
|
||||||
|
### Phase 6: Comprehensive Testing (4 hours)
|
||||||
|
- Backend integration tests
|
||||||
|
- Frontend E2E locale tests
|
||||||
|
- Frontend unit tests
|
||||||
|
- Translation validation automation
|
||||||
|
|
||||||
|
### Phase 7: Documentation & Polish (2 hours)
|
||||||
|
- Update technical documentation
|
||||||
|
- Create migration guide
|
||||||
|
- Final SEO and performance validation
|
||||||
|
|
||||||
|
### Phase 8: Deployment Prep (2 hours)
|
||||||
|
- Update README
|
||||||
|
- Create release notes
|
||||||
|
- Deployment checklist
|
||||||
|
|
||||||
|
**Total Time**: ~28 hours (~3.5 days) + 20% buffer = **4 days**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏗️ ARCHITECTURE DECISIONS
|
||||||
|
|
||||||
|
### 1. Locale Format: BCP 47
|
||||||
|
|
||||||
|
**Decision**: Use BCP 47 language tags (e.g., "en", "it", "en-US", "it-IT")
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- Industry standard (used by HTTP Accept-Language, HTML lang attribute, ICU libraries)
|
||||||
|
- Based on ISO 639-1 (language) + ISO 3166-1 (region)
|
||||||
|
- Flexible: start simple with 2-letter codes, add region qualifiers when needed
|
||||||
|
- Future-proof for dialects and scripts (e.g., "zh-Hans" for Simplified Chinese)
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```python
|
||||||
|
# Backend validation
|
||||||
|
SUPPORTED_LOCALES = {"en", "it", "en-US", "en-GB", "it-IT"}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. URL Structure: Subdirectory
|
||||||
|
|
||||||
|
**Decision**: `/[locale]/[path]` format (e.g., `/en/about`, `/it/about`)
|
||||||
|
|
||||||
|
**Alternatives Considered**:
|
||||||
|
- ❌ Subdomain (`en.example.com`) - Too complex for template
|
||||||
|
- ❌ Country-code TLD (`example.it`) - Too expensive, not needed for template
|
||||||
|
- ❌ URL parameters (`?lang=en`) - Poor SEO, harder to crawl
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- ✅ **Best SEO**: Google explicitly recommends subdirectories for most sites
|
||||||
|
- ✅ **Simple Infrastructure**: Single domain, single deployment
|
||||||
|
- ✅ **Low Cost**: No multiple domain purchases
|
||||||
|
- ✅ **Easy to Maintain**: Centralized analytics and tooling
|
||||||
|
- ✅ **Clear URLs**: Users can easily switch locales by changing URL
|
||||||
|
|
||||||
|
**SEO Benefits**:
|
||||||
|
- Domain authority consolidates to one domain
|
||||||
|
- Backlinks benefit all language versions
|
||||||
|
- Easier to build authority than multiple domains
|
||||||
|
- Works seamlessly with hreflang tags
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. Database Schema: Dedicated `locale` Column
|
||||||
|
|
||||||
|
**Decision**: Add `locale VARCHAR(10)` column to `users` table, NOT JSONB
|
||||||
|
|
||||||
|
**Alternatives Considered**:
|
||||||
|
- ❌ Store in `preferences` JSONB field - 2-10x slower queries, no optimizer statistics
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- ✅ **Performance**: B-tree index vs GIN index (smaller, faster)
|
||||||
|
- ✅ **Query Optimization**: PostgreSQL can maintain statistics on column values
|
||||||
|
- ✅ **Disk Space**: JSONB stores keys repeatedly (inefficient for common fields)
|
||||||
|
- ✅ **Update Performance**: Updating JSONB requires rewriting entire field + indexes
|
||||||
|
|
||||||
|
**Schema**:
|
||||||
|
```sql
|
||||||
|
ALTER TABLE users ADD COLUMN locale VARCHAR(10) DEFAULT NULL;
|
||||||
|
CREATE INDEX ix_users_locale ON users(locale);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why Nullable?**
|
||||||
|
- Distinguish "never set" (NULL) from "explicitly set to English"
|
||||||
|
- Allows lazy loading on first request (more accurate than backfilling)
|
||||||
|
|
||||||
|
**Why No Database DEFAULT?**
|
||||||
|
- Application-level default provides more flexibility
|
||||||
|
- Can implement locale detection logic (Accept-Language header)
|
||||||
|
- Easier to change default without migration
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4. Locale Detection Priority
|
||||||
|
|
||||||
|
**Decision**: Three-tier fallback system
|
||||||
|
|
||||||
|
1. **User's Saved Preference** (highest priority)
|
||||||
|
- If authenticated and `user.locale` is set, use it
|
||||||
|
- Persists across sessions and devices
|
||||||
|
|
||||||
|
2. **Accept-Language Header** (second priority)
|
||||||
|
- For unauthenticated users
|
||||||
|
- Parse `Accept-Language: it-IT,it;q=0.9,en;q=0.8` → "it-IT"
|
||||||
|
- Validate against supported locales
|
||||||
|
|
||||||
|
3. **Default to English** (fallback)
|
||||||
|
- If no user preference and no header match
|
||||||
|
|
||||||
|
**Implementation** (Backend):
|
||||||
|
```python
|
||||||
|
async def get_locale(
|
||||||
|
request: Request,
|
||||||
|
current_user: User | None = Depends(get_optional_current_user)
|
||||||
|
) -> str:
|
||||||
|
if current_user and current_user.locale:
|
||||||
|
return current_user.locale
|
||||||
|
|
||||||
|
accept_language = request.headers.get("accept-language", "")
|
||||||
|
if accept_language:
|
||||||
|
locale = accept_language.split(',')[0].split(';')[0].strip()
|
||||||
|
lang_code = locale.split('-')[0].lower()
|
||||||
|
if lang_code in {"en", "it"}:
|
||||||
|
return locale.lower()
|
||||||
|
|
||||||
|
return "en"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 5. Translation Storage: Server-Side Only (next-intl)
|
||||||
|
|
||||||
|
**Decision**: Use next-intl's server-component pattern, NOT client-side translation loading
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- ✅ **Zero Client Bundle Overhead**: Translations never sent to browser
|
||||||
|
- ✅ **Instant Page Loads**: No translation parsing on client
|
||||||
|
- ✅ **Better SEO**: Fully rendered HTML for search engines
|
||||||
|
- ✅ **Reduced Bandwidth**: Especially important for mobile users
|
||||||
|
|
||||||
|
**Implementation**:
|
||||||
|
```typescript
|
||||||
|
// Server Component (preferred 95% of the time)
|
||||||
|
import { getTranslations } from 'next-intl/server';
|
||||||
|
|
||||||
|
export default async function Page() {
|
||||||
|
const t = await getTranslations('HomePage');
|
||||||
|
return <h1>{t('title')}</h1>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client Component (only when needed for interactivity)
|
||||||
|
'use client';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
|
||||||
|
export function LoginForm() {
|
||||||
|
const t = useTranslations('auth.Login');
|
||||||
|
return <form>{/* ... */}</form>;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Performance Impact**:
|
||||||
|
- next-intl core: ~9.2kb gzipped
|
||||||
|
- Translation files: 0kb on client (server-side only)
|
||||||
|
- Tree-shaking: Automatic with ESM build
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 6. Translation File Structure: Nested Namespaces
|
||||||
|
|
||||||
|
**Decision**: Use nested JSON structure with namespaces, not flat keys
|
||||||
|
|
||||||
|
**Bad (Flat)**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"auth_login_title": "Sign in",
|
||||||
|
"auth_login_email_label": "Email",
|
||||||
|
"auth_register_title": "Sign up"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Good (Nested)**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"auth": {
|
||||||
|
"Login": {
|
||||||
|
"title": "Sign in",
|
||||||
|
"emailLabel": "Email"
|
||||||
|
},
|
||||||
|
"Register": {
|
||||||
|
"title": "Sign up"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- ✅ **Better Organization**: Logical grouping by feature
|
||||||
|
- ✅ **Easier Maintenance**: Find related translations quickly
|
||||||
|
- ✅ **Type Safety**: Full autocomplete with TypeScript integration
|
||||||
|
- ✅ **Scalability**: Easy to split into separate files later
|
||||||
|
|
||||||
|
**Usage**:
|
||||||
|
```typescript
|
||||||
|
const t = useTranslations('auth.Login');
|
||||||
|
t('title'); // "Sign in"
|
||||||
|
t('emailLabel'); // "Email"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 7. SEO Strategy: Hreflang + Sitemap + Metadata
|
||||||
|
|
||||||
|
**Decision**: Dual implementation for comprehensive SEO
|
||||||
|
|
||||||
|
**1. HTML `<link>` Tags** (via `generateMetadata`):
|
||||||
|
```typescript
|
||||||
|
export async function generateMetadata({ params: { locale } }) {
|
||||||
|
return {
|
||||||
|
alternates: {
|
||||||
|
canonical: `/${locale}`,
|
||||||
|
languages: {
|
||||||
|
'x-default': '/en',
|
||||||
|
'en': '/en',
|
||||||
|
'it': '/it',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. XML Sitemap** (with hreflang):
|
||||||
|
```typescript
|
||||||
|
export default function sitemap() {
|
||||||
|
return routes.flatMap(route =>
|
||||||
|
locales.map(locale => ({
|
||||||
|
url: `${baseUrl}/${locale}${route.path}`,
|
||||||
|
alternates: {
|
||||||
|
languages: { en: `/en${route.path}`, it: `/it${route.path}` }
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why Both?**
|
||||||
|
- HTML tags: Google's preferred method for page-level precision
|
||||||
|
- Sitemap: Helps discovery, provides backup if HTML tags malfunction
|
||||||
|
- **Never use HTTP headers** - avoid confusion with mixed methods
|
||||||
|
|
||||||
|
**x-default Locale**:
|
||||||
|
- Points to English (`/en`) as fallback for unsupported locales
|
||||||
|
- Used when user's language doesn't match any hreflang tags
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 8. Testing Strategy: Smoke Tests + Parameterization
|
||||||
|
|
||||||
|
**Decision**: Don't test all translations, test the i18n mechanism works
|
||||||
|
|
||||||
|
**Backend Tests**:
|
||||||
|
- 10 new integration tests covering locale CRUD, validation, detection
|
||||||
|
- Test both EN and IT locale values
|
||||||
|
- Test Accept-Language header parsing
|
||||||
|
|
||||||
|
**Frontend E2E Tests**:
|
||||||
|
- 12 new parameterized tests for locale switching
|
||||||
|
- Test critical flows in both EN and IT (login, register)
|
||||||
|
- **NOT** duplicating all 56 existing tests per locale
|
||||||
|
- Use parameterization: `for (const locale of ['en', 'it']) { test(...) }`
|
||||||
|
|
||||||
|
**Frontend Unit Tests**:
|
||||||
|
- 8 new component tests with i18n wrappers
|
||||||
|
- Test LocaleSwitcher functionality
|
||||||
|
- Test translated components render correctly
|
||||||
|
|
||||||
|
**Translation Validation**:
|
||||||
|
- Automated CI check for missing keys
|
||||||
|
- Validate Italian has all keys that English has
|
||||||
|
- Detect unused keys
|
||||||
|
|
||||||
|
**Rationale**:
|
||||||
|
- ✅ **Efficient**: Test mechanism, not content
|
||||||
|
- ✅ **Maintainable**: Adding Italian tests doesn't double test time
|
||||||
|
- ✅ **Comprehensive**: Critical paths tested in both locales
|
||||||
|
- ✅ **Fast CI**: ~13-18 minutes total (vs 60+ if we duplicate everything)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 9. Performance Budget
|
||||||
|
|
||||||
|
**Core Web Vitals Targets** (both EN and IT):
|
||||||
|
- **LCP** (Largest Contentful Paint): < 2.5s
|
||||||
|
- **INP** (Interaction to Next Paint): < 200ms
|
||||||
|
- **CLS** (Cumulative Layout Shift): < 0.1
|
||||||
|
|
||||||
|
**Bundle Size Impact**:
|
||||||
|
- next-intl: ~9.2kb gzipped (acceptable)
|
||||||
|
- Translation files: 0kb on client (server-side)
|
||||||
|
- Total increase: < 15kb
|
||||||
|
|
||||||
|
**Lighthouse Scores**:
|
||||||
|
- Performance: ≥ 90
|
||||||
|
- Accessibility: ≥ 95
|
||||||
|
- SEO: 100
|
||||||
|
|
||||||
|
**Font Loading Strategy**:
|
||||||
|
- Use `display: swap` to prevent CLS
|
||||||
|
- Preload Inter font with Latin + Latin-ext subsets (for Italian accents: à, è, ì, ò, ù)
|
||||||
|
- Fallback to system fonts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 10. GDPR Compliance
|
||||||
|
|
||||||
|
**Classification**: User locale preference IS personal data (GDPR Article 4)
|
||||||
|
|
||||||
|
**Lawful Basis**: Legitimate interest (service improvement) ✅
|
||||||
|
|
||||||
|
**User Rights**:
|
||||||
|
- ✅ **Access**: User can view locale in profile (GET /users/me)
|
||||||
|
- ✅ **Rectification**: User can update locale (PATCH /users/me)
|
||||||
|
- ✅ **Erasure**: Locale deleted when user deleted (CASCADE)
|
||||||
|
- ✅ **Portability**: Included in user data export
|
||||||
|
|
||||||
|
**Privacy Policy Requirements**:
|
||||||
|
- "We store your language preference to personalize your experience"
|
||||||
|
- "You can change this in Settings > Profile at any time"
|
||||||
|
|
||||||
|
**Data Minimization**: ✅ PASS
|
||||||
|
- Locale is necessary for service personalization
|
||||||
|
- No excessive data collection (not storing geolocation)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 GETTING STARTED
|
||||||
|
|
||||||
|
### For Developers Implementing This Plan
|
||||||
|
|
||||||
|
1. **Read This Document**: Understand the architecture decisions and rationale
|
||||||
|
2. **Follow Phases Sequentially**: Each phase builds on the previous
|
||||||
|
3. **Run Tests After Each Phase**: Ensure no regressions
|
||||||
|
4. **Use Parallel Agents**: Where indicated in the plan for efficiency
|
||||||
|
5. **Document Decisions**: Update this file if you deviate from the plan
|
||||||
|
|
||||||
|
### For Users of the Template
|
||||||
|
|
||||||
|
1. **See `/docs/I18N.md`**: User-facing guide on using i18n
|
||||||
|
2. **See `/docs/I18N_MIGRATION_GUIDE.md`**: Deploying to existing projects
|
||||||
|
3. **Adding Languages**: Copy the Italian example, follow `/docs/I18N.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 REFERENCES
|
||||||
|
|
||||||
|
### Official Documentation
|
||||||
|
- [next-intl](https://next-intl.dev) - Next.js 15 i18n library
|
||||||
|
- [BCP 47 Language Tags](https://www.rfc-editor.org/rfc/bcp/bcp47.txt) - Locale format standard
|
||||||
|
- [Google Multilingual SEO](https://developers.google.com/search/docs/specialty/international) - SEO guidelines
|
||||||
|
|
||||||
|
### Research Sources
|
||||||
|
- next-intl 4.0 release notes (2025)
|
||||||
|
- Next.js 15 App Router i18n patterns
|
||||||
|
- PostgreSQL performance: JSONB vs columns
|
||||||
|
- Lighthouse CI best practices
|
||||||
|
- Playwright i18n testing patterns
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ COMPLETION CHECKLIST
|
||||||
|
|
||||||
|
Use this checklist to verify implementation is complete:
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
- [ ] `locale` column added to `users` table
|
||||||
|
- [ ] Database migration created and tested
|
||||||
|
- [ ] Pydantic schemas updated (UserUpdate, UserResponse)
|
||||||
|
- [ ] Locale detection dependency created
|
||||||
|
- [ ] 10+ backend tests added
|
||||||
|
- [ ] All existing 743 tests still pass
|
||||||
|
- [ ] Coverage ≥97% maintained
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
- [ ] next-intl installed and configured
|
||||||
|
- [ ] Translation files created (en.json, it.json)
|
||||||
|
- [ ] TypeScript autocomplete working
|
||||||
|
- [ ] App Router restructured to `[locale]` pattern
|
||||||
|
- [ ] LocaleSwitcher component created
|
||||||
|
- [ ] All components translated (auth, navigation, settings)
|
||||||
|
- [ ] All existing 56 E2E tests still pass
|
||||||
|
- [ ] 12+ new E2E locale tests added
|
||||||
|
- [ ] 8+ new unit tests added
|
||||||
|
|
||||||
|
### SEO
|
||||||
|
- [ ] Metadata implemented per locale
|
||||||
|
- [ ] Sitemap generated with hreflang
|
||||||
|
- [ ] robots.txt configured
|
||||||
|
- [ ] Lighthouse SEO = 100 (both EN and IT)
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
- [ ] Core Web Vitals measured (both locales)
|
||||||
|
- [ ] LCP < 2.5s
|
||||||
|
- [ ] INP < 200ms
|
||||||
|
- [ ] CLS < 0.1
|
||||||
|
- [ ] Bundle analysis shows minimal impact
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
- [ ] This implementation plan complete
|
||||||
|
- [ ] `/docs/I18N.md` created (user guide)
|
||||||
|
- [ ] `/docs/I18N_MIGRATION_GUIDE.md` created
|
||||||
|
- [ ] `CLAUDE.md` updated with i18n patterns
|
||||||
|
- [ ] README.md updated with i18n feature
|
||||||
|
- [ ] CHANGELOG.md updated
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
- [ ] Translation validation in CI
|
||||||
|
- [ ] All tests passing
|
||||||
|
- [ ] No flaky tests
|
||||||
|
- [ ] Coverage targets met
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 NEXT STEPS
|
||||||
|
|
||||||
|
After completing this implementation:
|
||||||
|
|
||||||
|
1. **Deploy to Staging**: Test in production-like environment
|
||||||
|
2. **Gather Feedback**: From team and early users
|
||||||
|
3. **Optimize Further**: Based on real-world usage data
|
||||||
|
4. **Add Languages**: If needed, follow the Italian example
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 CHANGE LOG
|
||||||
|
|
||||||
|
| Date | Author | Change |
|
||||||
|
|------|--------|--------|
|
||||||
|
| 2025-11-17 | Claude | Initial plan created based on 2025 research |
|
||||||
|
| 2025-11-17 | Claude | Updated to 2 languages (EN, IT) per user request |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**End of Implementation Plan**
|
||||||
|
|
||||||
|
This plan represents state-of-the-art i18n implementation for 2025. It balances best practices, performance, SEO, and developer experience while remaining simple enough for a template showcase.
|
||||||
|
|
||||||
|
For questions or clarifications, refer to the detailed task descriptions in each phase.
|
||||||
@@ -14,7 +14,7 @@ const customJestConfig = {
|
|||||||
},
|
},
|
||||||
testMatch: ['<rootDir>/tests/**/*.test.ts', '<rootDir>/tests/**/*.test.tsx'],
|
testMatch: ['<rootDir>/tests/**/*.test.ts', '<rootDir>/tests/**/*.test.tsx'],
|
||||||
transformIgnorePatterns: [
|
transformIgnorePatterns: [
|
||||||
'node_modules/(?!(react-syntax-highlighter|refractor|hastscript|hast-.*|unist-.*|property-information|space-separated-tokens|comma-separated-tokens|web-namespaces)/)',
|
'node_modules/(?!(react-syntax-highlighter|refractor|hastscript|hast-.*|unist-.*|property-information|space-separated-tokens|comma-separated-tokens|web-namespaces|next-intl|use-intl)/)',
|
||||||
],
|
],
|
||||||
collectCoverageFrom: [
|
collectCoverageFrom: [
|
||||||
'src/**/*.{js,jsx,ts,tsx}',
|
'src/**/*.{js,jsx,ts,tsx}',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { NextConfig } from 'next';
|
|||||||
import createNextIntlPlugin from 'next-intl/plugin';
|
import createNextIntlPlugin from 'next-intl/plugin';
|
||||||
|
|
||||||
// Initialize next-intl plugin with i18n request config path
|
// Initialize next-intl plugin with i18n request config path
|
||||||
const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts');
|
const withNextIntl = createNextIntlPlugin('./src/lib/i18n/request.ts');
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
output: 'standalone',
|
output: 'standalone',
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { getRequestConfig } from 'next-intl/server';
|
import { getRequestConfig } from 'next-intl/server';
|
||||||
import { routing } from './routing';
|
import { routing } from '@/lib/i18n/routing';
|
||||||
|
|
||||||
export default getRequestConfig(async ({ locale }) => {
|
export default getRequestConfig(async ({ locale }) => {
|
||||||
// Validate that the incoming `locale` parameter is valid
|
// Validate that the incoming `locale` parameter is valid
|
||||||
@@ -2,11 +2,10 @@
|
|||||||
/**
|
/**
|
||||||
* Utility functions for internationalization.
|
* Utility functions for internationalization.
|
||||||
*
|
*
|
||||||
* This file demonstrates type-safe translation usage.
|
* This file provides pure utility functions for i18n without React dependencies.
|
||||||
|
* For React hooks, see hooks.ts
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the display name for a locale code.
|
* Get the display name for a locale code.
|
||||||
*
|
*
|
||||||
@@ -54,28 +53,6 @@ export function getLocaleFlag(locale: string): string {
|
|||||||
return flags[locale] || flags.en;
|
return flags[locale] || flags.en;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to get common translations.
|
|
||||||
* This demonstrates type-safe usage of useTranslations.
|
|
||||||
*
|
|
||||||
* @returns Object with commonly used translation functions
|
|
||||||
*/
|
|
||||||
export function useCommonTranslations() {
|
|
||||||
const t = useTranslations('common');
|
|
||||||
|
|
||||||
return {
|
|
||||||
loading: () => t('loading'),
|
|
||||||
error: () => t('error'),
|
|
||||||
success: () => t('success'),
|
|
||||||
cancel: () => t('cancel'),
|
|
||||||
save: () => t('save'),
|
|
||||||
delete: () => t('delete'),
|
|
||||||
edit: () => t('edit'),
|
|
||||||
close: () => t('close'),
|
|
||||||
confirm: () => t('confirm'),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format a relative time string (e.g., "2 hours ago").
|
* Format a relative time string (e.g., "2 hours ago").
|
||||||
* This is a placeholder for future implementation with next-intl's date/time formatting.
|
* This is a placeholder for future implementation with next-intl's date/time formatting.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import type { NextRequest } from 'next/server';
|
import type { NextRequest } from 'next/server';
|
||||||
import createMiddleware from 'next-intl/middleware';
|
import createMiddleware from 'next-intl/middleware';
|
||||||
import { routing } from './i18n/routing';
|
import { routing } from './lib/i18n/routing';
|
||||||
|
|
||||||
// Create next-intl middleware for locale handling
|
// Create next-intl middleware for locale handling
|
||||||
const intlMiddleware = createMiddleware(routing);
|
const intlMiddleware = createMiddleware(routing);
|
||||||
|
|||||||
242
frontend/tests/lib/i18n/utils.test.ts
Normal file
242
frontend/tests/lib/i18n/utils.test.ts
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
/**
|
||||||
|
* Tests for i18n utility functions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
getLocaleName,
|
||||||
|
getLocaleNativeName,
|
||||||
|
getLocaleFlag,
|
||||||
|
formatRelativeTime,
|
||||||
|
} from '@/lib/i18n/utils';
|
||||||
|
|
||||||
|
describe('i18n Utility Functions', () => {
|
||||||
|
describe('getLocaleName', () => {
|
||||||
|
it('should return correct name for English locale', () => {
|
||||||
|
expect(getLocaleName('en')).toBe('English');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return correct name for Italian locale', () => {
|
||||||
|
expect(getLocaleName('it')).toBe('Italiano');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return English for unsupported locale', () => {
|
||||||
|
expect(getLocaleName('fr')).toBe('English');
|
||||||
|
expect(getLocaleName('de')).toBe('English');
|
||||||
|
expect(getLocaleName('es')).toBe('English');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty string', () => {
|
||||||
|
expect(getLocaleName('')).toBe('English');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle undefined as string', () => {
|
||||||
|
expect(getLocaleName('undefined')).toBe('English');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle locale codes with region (fallback)', () => {
|
||||||
|
expect(getLocaleName('en-US')).toBe('English');
|
||||||
|
expect(getLocaleName('en-GB')).toBe('English');
|
||||||
|
expect(getLocaleName('it-IT')).toBe('English'); // Not exact match, falls back
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getLocaleNativeName', () => {
|
||||||
|
it('should return native name for English locale', () => {
|
||||||
|
expect(getLocaleNativeName('en')).toBe('English');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return native name for Italian locale', () => {
|
||||||
|
expect(getLocaleNativeName('it')).toBe('Italiano');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return English for unsupported locale', () => {
|
||||||
|
expect(getLocaleNativeName('fr')).toBe('English');
|
||||||
|
expect(getLocaleNativeName('de')).toBe('English');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should match getLocaleName output for supported locales', () => {
|
||||||
|
expect(getLocaleNativeName('en')).toBe(getLocaleName('en'));
|
||||||
|
expect(getLocaleNativeName('it')).toBe(getLocaleName('it'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle case variations (fallback behavior)', () => {
|
||||||
|
expect(getLocaleNativeName('EN')).toBe('English');
|
||||||
|
expect(getLocaleNativeName('IT')).toBe('English');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getLocaleFlag', () => {
|
||||||
|
it('should return US flag for English locale', () => {
|
||||||
|
expect(getLocaleFlag('en')).toBe('🇺🇸');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return Italian flag for Italian locale', () => {
|
||||||
|
expect(getLocaleFlag('it')).toBe('🇮🇹');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return US flag for unsupported locale', () => {
|
||||||
|
expect(getLocaleFlag('fr')).toBe('🇺🇸');
|
||||||
|
expect(getLocaleFlag('de')).toBe('🇺🇸');
|
||||||
|
expect(getLocaleFlag('es')).toBe('🇺🇸');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return valid emoji flags', () => {
|
||||||
|
const enFlag = getLocaleFlag('en');
|
||||||
|
const itFlag = getLocaleFlag('it');
|
||||||
|
|
||||||
|
// Check that these are unicode emoji characters
|
||||||
|
expect(enFlag).toMatch(/[\u{1F1E6}-\u{1F1FF}]{2}/u);
|
||||||
|
expect(itFlag).toMatch(/[\u{1F1E6}-\u{1F1FF}]{2}/u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle empty string gracefully', () => {
|
||||||
|
expect(getLocaleFlag('')).toBe('🇺🇸');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatRelativeTime', () => {
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
describe('English locale', () => {
|
||||||
|
it('should format "just now" for times less than 60 seconds', () => {
|
||||||
|
const date = new Date(now.getTime() - 30 * 1000); // 30 seconds ago
|
||||||
|
expect(formatRelativeTime(date, 'en')).toBe('just now');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format minutes correctly', () => {
|
||||||
|
const date1 = new Date(now.getTime() - 1 * 60 * 1000); // 1 minute ago
|
||||||
|
expect(formatRelativeTime(date1, 'en')).toBe('1 minute ago');
|
||||||
|
|
||||||
|
const date2 = new Date(now.getTime() - 5 * 60 * 1000); // 5 minutes ago
|
||||||
|
expect(formatRelativeTime(date2, 'en')).toBe('5 minutes ago');
|
||||||
|
|
||||||
|
const date3 = new Date(now.getTime() - 30 * 60 * 1000); // 30 minutes ago
|
||||||
|
expect(formatRelativeTime(date3, 'en')).toBe('30 minutes ago');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format hours correctly', () => {
|
||||||
|
const date1 = new Date(now.getTime() - 1 * 60 * 60 * 1000); // 1 hour ago
|
||||||
|
expect(formatRelativeTime(date1, 'en')).toBe('1 hour ago');
|
||||||
|
|
||||||
|
const date2 = new Date(now.getTime() - 5 * 60 * 60 * 1000); // 5 hours ago
|
||||||
|
expect(formatRelativeTime(date2, 'en')).toBe('5 hours ago');
|
||||||
|
|
||||||
|
const date3 = new Date(now.getTime() - 23 * 60 * 60 * 1000); // 23 hours ago
|
||||||
|
expect(formatRelativeTime(date3, 'en')).toBe('23 hours ago');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format days correctly', () => {
|
||||||
|
const date1 = new Date(now.getTime() - 1 * 24 * 60 * 60 * 1000); // 1 day ago
|
||||||
|
expect(formatRelativeTime(date1, 'en')).toBe('1 day ago');
|
||||||
|
|
||||||
|
const date2 = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); // 7 days ago
|
||||||
|
expect(formatRelativeTime(date2, 'en')).toBe('7 days ago');
|
||||||
|
|
||||||
|
const date3 = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); // 30 days ago
|
||||||
|
expect(formatRelativeTime(date3, 'en')).toBe('30 days ago');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default to English when locale not specified', () => {
|
||||||
|
const date = new Date(now.getTime() - 2 * 60 * 1000); // 2 minutes ago
|
||||||
|
expect(formatRelativeTime(date)).toBe('2 minutes ago');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Italian locale', () => {
|
||||||
|
it('should format "proprio ora" for times less than 60 seconds', () => {
|
||||||
|
const date = new Date(now.getTime() - 45 * 1000); // 45 seconds ago
|
||||||
|
expect(formatRelativeTime(date, 'it')).toBe('proprio ora');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format minutes correctly with Italian grammar', () => {
|
||||||
|
const date1 = new Date(now.getTime() - 1 * 60 * 1000); // 1 minuto ago
|
||||||
|
expect(formatRelativeTime(date1, 'it')).toBe('1 minuto fa');
|
||||||
|
|
||||||
|
const date2 = new Date(now.getTime() - 5 * 60 * 1000); // 5 minuti ago
|
||||||
|
expect(formatRelativeTime(date2, 'it')).toBe('5 minuti fa');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format hours correctly with Italian grammar', () => {
|
||||||
|
const date1 = new Date(now.getTime() - 1 * 60 * 60 * 1000); // 1 ora ago
|
||||||
|
expect(formatRelativeTime(date1, 'it')).toBe('1 ora fa');
|
||||||
|
|
||||||
|
const date2 = new Date(now.getTime() - 5 * 60 * 60 * 1000); // 5 ore ago
|
||||||
|
expect(formatRelativeTime(date2, 'it')).toBe('5 ore fa');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should format days correctly with Italian grammar', () => {
|
||||||
|
const date1 = new Date(now.getTime() - 1 * 24 * 60 * 60 * 1000); // 1 giorno ago
|
||||||
|
expect(formatRelativeTime(date1, 'it')).toBe('1 giorno fa');
|
||||||
|
|
||||||
|
const date2 = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); // 7 giorni ago
|
||||||
|
expect(formatRelativeTime(date2, 'it')).toBe('7 giorni fa');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Edge cases', () => {
|
||||||
|
it('should handle dates exactly at boundaries', () => {
|
||||||
|
// Exactly 60 seconds
|
||||||
|
const date1 = new Date(now.getTime() - 60 * 1000);
|
||||||
|
const result1 = formatRelativeTime(date1, 'en');
|
||||||
|
expect(result1).toBe('1 minute ago');
|
||||||
|
|
||||||
|
// Exactly 1 hour
|
||||||
|
const date2 = new Date(now.getTime() - 60 * 60 * 1000);
|
||||||
|
const result2 = formatRelativeTime(date2, 'en');
|
||||||
|
expect(result2).toBe('1 hour ago');
|
||||||
|
|
||||||
|
// Exactly 24 hours
|
||||||
|
const date3 = new Date(now.getTime() - 24 * 60 * 60 * 1000);
|
||||||
|
const result3 = formatRelativeTime(date3, 'en');
|
||||||
|
expect(result3).toBe('1 day ago');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle future dates (negative time)', () => {
|
||||||
|
// Date in the future - implementation treats it as "just now" or "0 units ago"
|
||||||
|
const futureDate = new Date(now.getTime() + 60 * 1000);
|
||||||
|
const result = formatRelativeTime(futureDate, 'en');
|
||||||
|
// Depending on implementation, might show negative or just now
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle very old dates', () => {
|
||||||
|
const oldDate = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000); // 1 year ago
|
||||||
|
const result = formatRelativeTime(oldDate, 'en');
|
||||||
|
expect(result).toBe('365 days ago');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Unsupported locale fallback', () => {
|
||||||
|
it('should fallback to English for unsupported locales', () => {
|
||||||
|
const date = new Date(now.getTime() - 2 * 60 * 1000); // 2 minutes ago
|
||||||
|
expect(formatRelativeTime(date, 'fr')).toBe('2 minutes ago');
|
||||||
|
expect(formatRelativeTime(date, 'de')).toBe('2 minutes ago');
|
||||||
|
expect(formatRelativeTime(date, 'es')).toBe('2 minutes ago');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Locale code consistency', () => {
|
||||||
|
it('should handle the same locale codes across all functions', () => {
|
||||||
|
const locales = ['en', 'it'];
|
||||||
|
|
||||||
|
locales.forEach((locale) => {
|
||||||
|
// All functions should return non-empty strings
|
||||||
|
expect(getLocaleName(locale)).toBeTruthy();
|
||||||
|
expect(getLocaleNativeName(locale)).toBeTruthy();
|
||||||
|
expect(getLocaleFlag(locale)).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have consistent fallback behavior', () => {
|
||||||
|
const unsupportedLocales = ['fr', 'de', 'es', 'invalid', ''];
|
||||||
|
|
||||||
|
unsupportedLocales.forEach((locale) => {
|
||||||
|
// All should fall back to English
|
||||||
|
expect(getLocaleName(locale)).toBe('English');
|
||||||
|
expect(getLocaleNativeName(locale)).toBe('English');
|
||||||
|
expect(getLocaleFlag(locale)).toBe('🇺🇸');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user