forked from cardosofelipe/fast-next-template
Refactor i18n integration and update tests for improved localization
- Updated test components (`PasswordResetConfirmForm`, `PasswordChangeForm`) to use i18n keys directly, ensuring accurate validation messages. - Refined translations in `it.json` to standardize format and content. - Replaced text-based labels with localized strings in `PasswordResetRequestForm` and `RegisterForm`. - Introduced `generateLocalizedMetadata` utility and updated layout metadata generation for locale-aware SEO. - Enhanced e2e tests with locale-prefixed routes and updated assertions for consistency. - Added comprehensive i18n documentation (`I18N.md`) for usage, architecture, and testing.
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
import { Metadata } from 'next';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { generatePageMetadata, type Locale } from '@/lib/i18n/metadata';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
// Code-split LoginForm - heavy with react-hook-form + validation
|
||||
const LoginForm = dynamic(
|
||||
@@ -15,6 +18,17 @@ const LoginForm = dynamic(
|
||||
}
|
||||
);
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'auth.login' });
|
||||
|
||||
return generatePageMetadata(locale as Locale, t('title'), t('subtitle'), '/login');
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -3,9 +3,28 @@
|
||||
* Users set a new password using the token from their email
|
||||
*/
|
||||
|
||||
import { Metadata } from 'next';
|
||||
import { Suspense } from 'react';
|
||||
import { generatePageMetadata, type Locale } from '@/lib/i18n/metadata';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
import PasswordResetConfirmContent from './PasswordResetConfirmContent';
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'auth.passwordResetConfirm' });
|
||||
|
||||
return generatePageMetadata(
|
||||
locale as Locale,
|
||||
t('title'),
|
||||
t('instructions'),
|
||||
'/password-reset/confirm'
|
||||
);
|
||||
}
|
||||
|
||||
export default function PasswordResetConfirmPage() {
|
||||
return (
|
||||
<Suspense
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
* Users enter their email to receive reset instructions
|
||||
*/
|
||||
|
||||
import { Metadata } from 'next';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { generatePageMetadata, type Locale } from '@/lib/i18n/metadata';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
// Code-split PasswordResetRequestForm
|
||||
const PasswordResetRequestForm = dynamic(
|
||||
@@ -22,6 +25,17 @@ const PasswordResetRequestForm = dynamic(
|
||||
}
|
||||
);
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'auth.passwordReset' });
|
||||
|
||||
return generatePageMetadata(locale as Locale, t('title'), t('subtitle'), '/password-reset');
|
||||
}
|
||||
|
||||
export default function PasswordResetPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { Metadata } from 'next';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { generatePageMetadata, type Locale } from '@/lib/i18n/metadata';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
// Code-split RegisterForm (313 lines)
|
||||
const RegisterForm = dynamic(
|
||||
@@ -15,6 +18,17 @@ const RegisterForm = dynamic(
|
||||
}
|
||||
);
|
||||
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'auth.register' });
|
||||
|
||||
return generatePageMetadata(locale as Locale, t('title'), t('subtitle'), '/register');
|
||||
}
|
||||
|
||||
export default function RegisterPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
|
||||
@@ -3,16 +3,28 @@
|
||||
* Displayed when users try to access resources they don't have permission for
|
||||
*/
|
||||
|
||||
/* istanbul ignore next - Next.js type import for metadata */
|
||||
import type { Metadata } from 'next';
|
||||
import { Link } from '@/lib/i18n/routing';
|
||||
import { ShieldAlert } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { generatePageMetadata, type Locale } from '@/lib/i18n/metadata';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
export const metadata: Metadata = /* istanbul ignore next */ {
|
||||
title: '403 - Forbidden',
|
||||
description: 'You do not have permission to access this resource',
|
||||
};
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: 'errors' });
|
||||
|
||||
return generatePageMetadata(
|
||||
locale as Locale,
|
||||
t('unauthorized'),
|
||||
t('unauthorizedDescription'),
|
||||
'/forbidden'
|
||||
);
|
||||
}
|
||||
|
||||
export default function ForbiddenPage() {
|
||||
return (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { notFound } from 'next/navigation';
|
||||
import { routing } from '@/lib/i18n/routing';
|
||||
import { NextIntlClientProvider } from 'next-intl';
|
||||
import { getMessages } from 'next-intl/server';
|
||||
import { generateLocalizedMetadata, type Locale } from '@/lib/i18n/metadata';
|
||||
import '../globals.css';
|
||||
import { Providers } from '../providers';
|
||||
import { AuthProvider } from '@/lib/auth/AuthContext';
|
||||
@@ -23,10 +24,14 @@ const geistMono = Geist_Mono({
|
||||
preload: false, // Only preload primary font
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'FastNext Template',
|
||||
description: 'FastAPI + Next.js Template',
|
||||
};
|
||||
export async function generateMetadata({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ locale: string }>;
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
return generateLocalizedMetadata(locale as Locale);
|
||||
}
|
||||
|
||||
export default async function LocaleLayout({
|
||||
children,
|
||||
|
||||
21
frontend/src/app/robots.ts
Normal file
21
frontend/src/app/robots.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
|
||||
/**
|
||||
* Generate robots.txt
|
||||
* Configures search engine crawler behavior
|
||||
*/
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
|
||||
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: '*',
|
||||
allow: '/',
|
||||
// Disallow authenticated routes
|
||||
disallow: ['/admin/', '/settings/'],
|
||||
},
|
||||
],
|
||||
sitemap: `${baseUrl}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
49
frontend/src/app/sitemap.ts
Normal file
49
frontend/src/app/sitemap.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { MetadataRoute } from 'next';
|
||||
import { routing } from '@/lib/i18n/routing';
|
||||
|
||||
/**
|
||||
* Generate multilingual sitemap
|
||||
* Includes all public routes for each supported locale
|
||||
*/
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000';
|
||||
|
||||
// Define public routes that should be in sitemap
|
||||
const publicRoutes = [
|
||||
'', // homepage
|
||||
'/login',
|
||||
'/register',
|
||||
'/password-reset',
|
||||
'/demos',
|
||||
'/dev',
|
||||
'/dev/components',
|
||||
'/dev/layouts',
|
||||
'/dev/forms',
|
||||
'/dev/docs',
|
||||
];
|
||||
|
||||
// Generate sitemap entries for each locale
|
||||
const sitemapEntries: MetadataRoute.Sitemap = [];
|
||||
|
||||
routing.locales.forEach((locale) => {
|
||||
publicRoutes.forEach((route) => {
|
||||
const path = route === '' ? `/${locale}` : `/${locale}${route}`;
|
||||
|
||||
sitemapEntries.push({
|
||||
url: `${baseUrl}${path}`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: route === '' ? 'daily' : 'weekly',
|
||||
priority: route === '' ? 1.0 : 0.8,
|
||||
// Language alternates for this URL
|
||||
alternates: {
|
||||
languages: {
|
||||
en: `${baseUrl}/en${route}`,
|
||||
it: `${baseUrl}/it${route}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return sitemapEntries;
|
||||
}
|
||||
@@ -31,7 +31,7 @@ export function Breadcrumbs() {
|
||||
const breadcrumbs: BreadcrumbItem[] = [];
|
||||
|
||||
let currentPath = '';
|
||||
segments.forEach((segment) => {
|
||||
segments.forEach((segment: string) => {
|
||||
currentPath += `/${segment}`;
|
||||
const label = pathLabels[segment] || segment;
|
||||
breadcrumbs.push({
|
||||
|
||||
@@ -30,7 +30,7 @@ const createLoginSchema = (t: (key: string) => string) =>
|
||||
password: z
|
||||
.string()
|
||||
.min(1, t('validation.required'))
|
||||
.min(8, t('validation.minLength').replace('{count}', '8'))
|
||||
.min(8, t('validation.minLength'))
|
||||
.regex(/[0-9]/, t('errors.validation.passwordWeak'))
|
||||
.regex(/[A-Z]/, t('errors.validation.passwordWeak')),
|
||||
});
|
||||
|
||||
163
frontend/src/lib/i18n/metadata.ts
Normal file
163
frontend/src/lib/i18n/metadata.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* Locale-aware metadata utilities
|
||||
* Generates SEO-optimized metadata with proper internationalization
|
||||
*/
|
||||
|
||||
import { Metadata } from 'next';
|
||||
import { getTranslations } from 'next-intl/server';
|
||||
|
||||
export type Locale = 'en' | 'it';
|
||||
|
||||
/**
|
||||
* Base site configuration for metadata
|
||||
*/
|
||||
export const siteConfig = {
|
||||
name: {
|
||||
en: 'FastNext Template',
|
||||
it: 'FastNext Template',
|
||||
},
|
||||
description: {
|
||||
en: 'Production-ready FastAPI + Next.js full-stack template with authentication, admin panel, and comprehensive testing',
|
||||
it: 'Template full-stack pronto per produzione con FastAPI + Next.js con autenticazione, pannello admin e test completi',
|
||||
},
|
||||
url: process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000',
|
||||
ogImage: '/og-image.png',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Generate locale-aware metadata for pages
|
||||
* Includes Open Graph, Twitter Cards, and language alternates
|
||||
*/
|
||||
export async function generateLocalizedMetadata(
|
||||
locale: Locale,
|
||||
options?: {
|
||||
titleKey?: string;
|
||||
descriptionKey?: string;
|
||||
namespace?: string;
|
||||
path?: string;
|
||||
}
|
||||
): Promise<Metadata> {
|
||||
const { titleKey, descriptionKey, namespace = 'common', path = '' } = options || {};
|
||||
|
||||
// Get translations if keys provided, otherwise use site defaults
|
||||
let title: string = siteConfig.name[locale];
|
||||
let description: string = siteConfig.description[locale];
|
||||
|
||||
if (titleKey || descriptionKey) {
|
||||
const t = await getTranslations({ locale, namespace });
|
||||
if (titleKey) {
|
||||
title = t(titleKey);
|
||||
}
|
||||
if (descriptionKey) {
|
||||
description = t(descriptionKey);
|
||||
}
|
||||
}
|
||||
|
||||
const url = `${siteConfig.url}/${locale}${path}`;
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
metadataBase: new URL(siteConfig.url),
|
||||
alternates: {
|
||||
canonical: url,
|
||||
languages: {
|
||||
en: `/${path}`,
|
||||
it: `/it${path}`,
|
||||
'x-default': `/${path}`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url,
|
||||
siteName: siteConfig.name[locale],
|
||||
locale: locale === 'en' ? 'en_US' : 'it_IT',
|
||||
type: 'website',
|
||||
images: [
|
||||
{
|
||||
url: siteConfig.ogImage,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: title,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title,
|
||||
description,
|
||||
images: [siteConfig.ogImage],
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
'max-video-preview': -1,
|
||||
'max-image-preview': 'large',
|
||||
'max-snippet': -1,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate metadata for a specific page with custom title and description
|
||||
*/
|
||||
export async function generatePageMetadata(
|
||||
locale: Locale,
|
||||
title: string,
|
||||
description: string,
|
||||
path?: string
|
||||
): Promise<Metadata> {
|
||||
const url = `${siteConfig.url}/${locale}${path || ''}`;
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
metadataBase: new URL(siteConfig.url),
|
||||
alternates: {
|
||||
canonical: url,
|
||||
languages: {
|
||||
en: `${path || '/'}`,
|
||||
it: `/it${path || '/'}`,
|
||||
'x-default': `${path || '/'}`,
|
||||
},
|
||||
},
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
url,
|
||||
siteName: siteConfig.name[locale],
|
||||
locale: locale === 'en' ? 'en_US' : 'it_IT',
|
||||
type: 'website',
|
||||
images: [
|
||||
{
|
||||
url: siteConfig.ogImage,
|
||||
width: 1200,
|
||||
height: 630,
|
||||
alt: title,
|
||||
},
|
||||
],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title,
|
||||
description,
|
||||
images: [siteConfig.ogImage],
|
||||
},
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: {
|
||||
index: true,
|
||||
follow: true,
|
||||
'max-video-preview': -1,
|
||||
'max-image-preview': 'large',
|
||||
'max-snippet': -1,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user