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:
Felipe Cardoso
2025-11-18 07:23:54 +01:00
parent fe6a98c379
commit 55ae92c460
8 changed files with 776 additions and 29 deletions

View File

@@ -14,7 +14,7 @@ const customJestConfig = {
},
testMatch: ['<rootDir>/tests/**/*.test.ts', '<rootDir>/tests/**/*.test.tsx'],
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: [
'src/**/*.{js,jsx,ts,tsx}',

View File

@@ -2,7 +2,7 @@ import type { NextConfig } from 'next';
import createNextIntlPlugin from 'next-intl/plugin';
// 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 = {
output: 'standalone',

View File

@@ -14,7 +14,7 @@
*/
import { getRequestConfig } from 'next-intl/server';
import { routing } from './routing';
import { routing } from '@/lib/i18n/routing';
export default getRequestConfig(async ({ locale }) => {
// Validate that the incoming `locale` parameter is valid

View File

@@ -2,11 +2,10 @@
/**
* 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.
*
@@ -54,28 +53,6 @@ export function getLocaleFlag(locale: string): string {
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").
* This is a placeholder for future implementation with next-intl's date/time formatting.

View File

@@ -1,7 +1,7 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
import { routing } from './lib/i18n/routing';
// Create next-intl middleware for locale handling
const intlMiddleware = createMiddleware(routing);

View 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('🇺🇸');
});
});
});
});