15. Internacionalização
Este documento apresenta estratégias e práticas para implementar internacionalização (i18n) em soluções web desenvolvidas com Astro 5, permitindo que seu site atenda a usuários de diferentes idiomas e regiões.
Fundamentos da Internacionalização
Conceitos Básicos
- Internacionalização (i18n): Processo de design e desenvolvimento que permite a adaptação para diferentes idiomas e regiões.
- Localização (l10n): Processo de adaptar o conteúdo para um idioma ou região específica.
- Tradução: Conversão de texto de um idioma para outro.
- Pluralização: Adaptação de mensagens para formas singular e plural, que variam entre idiomas.
- Formatação de Data/Hora/Números: Apresentação de datas, horas e números de acordo com convenções regionais.
Benefícios da Internacionalização
- Alcance Global: Amplia o público potencial do seu site.
- Experiência do Usuário: Melhora a experiência para usuários não nativos do idioma principal.
- SEO: Melhora o posicionamento em mecanismos de busca para consultas em diferentes idiomas.
- Conformidade Legal: Atende a requisitos legais em certas regiões.
Implementação com Astro
Estrutura de Diretórios
Organize seu projeto para suportar múltiplos idiomas:
src/├── i18n/│ ├── ui.ts # Definições de UI para todos os idiomas│ ├── translations/ # Traduções por idioma│ │ ├── pt-BR.ts│ │ ├── en.ts│ │ └── es.ts│ └── utils.ts # Utilitários de i18n├── layouts/│ └── BaseLayout.astro # Layout com suporte a i18n├── pages/│ ├── [lang]/ # Páginas com parâmetro de idioma│ │ ├── index.astro│ │ ├── about.astro│ │ └── contact.astro│ └── index.astro # Redirecionamento para idioma padrão└── components/ └── LanguageSwitcher.astroConfiguração Básica
export const languages = { 'pt-BR': 'Português', 'en': 'English', 'es': 'Español',};
export const defaultLanguage = 'pt-BR';
// Tipo para as chaves de traduçãoexport type UI = typeof import('./translations/pt-BR').default;Traduções
export default { nav: { home: 'Início', about: 'Sobre', contact: 'Contato', }, home: { title: 'Bem-vindo ao nosso site', description: 'Somos uma empresa especializada em soluções web.', cta: 'Saiba mais', }, about: { title: 'Sobre nós', description: 'Conheça nossa história e valores.', }, contact: { title: 'Entre em contato', name: 'Nome', email: 'E-mail', message: 'Mensagem', submit: 'Enviar', success: 'Mensagem enviada com sucesso!', error: 'Ocorreu um erro ao enviar sua mensagem.', }, footer: { rights: 'Todos os direitos reservados.', },};
// src/i18n/translations/en.tsexport default { nav: { home: 'Home', about: 'About', contact: 'Contact', }, home: { title: 'Welcome to our website', description: 'We are a company specialized in web solutions.', cta: 'Learn more', }, about: { title: 'About us', description: 'Learn about our history and values.', }, contact: { title: 'Contact us', name: 'Name', email: 'Email', message: 'Message', submit: 'Submit', success: 'Message sent successfully!', error: 'An error occurred while sending your message.', }, footer: { rights: 'All rights reserved.', },};Utilitários de i18n
import { defaultLanguage, languages, type UI } from './ui';
export function getLanguageFromUrl(url: URL) { const [, lang] = url.pathname.split('/'); if (lang in languages) return lang as keyof typeof languages; return defaultLanguage;}
export function useTranslations(lang: keyof typeof languages) { return function t(key: keyof UI) { const keys = key.split('.'); let value: any = import(`./translations/${lang}.ts`).default;
for (const k of keys) { value = value[k]; if (!value) break; }
return value || key; };}
export function getLocalizedPath(path: string, lang: keyof typeof languages) { return `/${lang}${path.startsWith('/') ? path : `/${path}`}`;}
export function getAlternateLinks(path: string) { return Object.keys(languages).map((lang) => ({ href: getLocalizedPath(path, lang as keyof typeof languages), hreflang: lang, }));}Páginas Localizadas
---import BaseLayout from '../../layouts/BaseLayout.astro';import { getLanguageFromUrl, useTranslations } from '../../i18n/utils';import { languages } from '../../i18n/ui';
export async function getStaticPaths() { return Object.keys(languages).map((lang) => ({ params: { lang }, }));}
const { lang } = Astro.params;const t = useTranslations(lang as keyof typeof languages);---
<BaseLayout title={t('home.title')} description={t('home.description')}> <main> <h1>{t('home.title')}</h1> <p>{t('home.description')}</p> <a href={`/${lang}/about`} class="cta-button">{t('home.cta')}</a> </main></BaseLayout>Redirecionamento para Idioma Padrão
---import { defaultLanguage } from '../i18n/ui';
// Redireciona para o idioma padrãoreturn Astro.redirect(`/${defaultLanguage}`);---Layout Base com Suporte a i18n
---import { getLanguageFromUrl, getAlternateLinks } from '../i18n/utils';import LanguageSwitcher from '../components/LanguageSwitcher.astro';import { languages } from '../i18n/ui';
const { title, description } = Astro.props;const lang = getLanguageFromUrl(Astro.url);const alternateLinks = getAlternateLinks(Astro.url.pathname.replace(`/${lang}`, ''));---
<html lang={lang}><head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>{title}</title> <meta name="description" content={description}>
<!-- Links alternativos para outros idiomas --> {alternateLinks.map(({ href, hreflang }) => ( <link rel="alternate" href={Astro.site ? new URL(href, Astro.site).href : href} hreflang={hreflang} /> ))}
<!-- Canonical para o idioma atual --> <link rel="canonical" href={Astro.url.href}>
<slot name="head" /></head><body> <header> <nav> <LanguageSwitcher currentLang={lang} /> </nav> </header>
<slot />
<footer> <p>© {new Date().getFullYear()} - {languages[lang as keyof typeof languages]}</p> </footer></body></html>Seletor de Idioma
---import { languages } from '../i18n/ui';import { getLocalizedPath } from '../i18n/utils';
const { currentLang } = Astro.props;const currentPath = Astro.url.pathname.replace(`/${currentLang}`, '') || '/';---
<div class="language-switcher"> <select id="language-select" aria-label="Selecionar idioma"> {Object.entries(languages).map(([lang, label]) => ( <option value={getLocalizedPath(currentPath, lang as keyof typeof languages)} selected={lang === currentLang}> {label} </option> ))} </select></div>
<script> // Redirecionar ao mudar o idioma const select = document.getElementById('language-select') as HTMLSelectElement; if (select) { select.addEventListener('change', () => { window.location.href = select.value; }); }</script>
<style> .language-switcher { margin: 1rem 0; }
select { padding: 0.5rem; border-radius: 4px; border: 1px solid #ccc; background-color: white; font-size: 0.9rem; }</style>Formatação Localizada
Datas e Números
export function formatDate(date: Date | string, lang: string, options?: Intl.DateTimeFormatOptions) { const defaultOptions: Intl.DateTimeFormatOptions = { year: 'numeric', month: 'long', day: 'numeric', };
const dateToFormat = typeof date === 'string' ? new Date(date) : date;
return new Intl.DateTimeFormat(lang, { ...defaultOptions, ...options }).format(dateToFormat);}
export function formatNumber(number: number, lang: string, options?: Intl.NumberFormatOptions) { const defaultOptions: Intl.NumberFormatOptions = { style: 'decimal', };
return new Intl.NumberFormat(lang, { ...defaultOptions, ...options }).format(number);}
export function formatCurrency(amount: number, lang: string, currency: string = 'BRL') { return new Intl.NumberFormat(lang, { style: 'currency', currency, }).format(amount);}Uso dos formatadores:
---import BaseLayout from '../../layouts/BaseLayout.astro';import { getLanguageFromUrl, useTranslations } from '../../i18n/utils';import { formatDate, formatCurrency } from '../../i18n/formatters';import { languages } from '../../i18n/ui';
const lang = getLanguageFromUrl(Astro.url);const t = useTranslations(lang as keyof typeof languages);
const product = { name: 'Produto Exemplo', price: 99.99, releaseDate: new Date('2023-01-15'),};---
<BaseLayout title={product.name}> <main> <h1>{product.name}</h1> <p class="price">{formatCurrency(product.price, lang)}</p> <p class="date"> {t('product.releaseDate')}: {formatDate(product.releaseDate, lang)} </p> </main></BaseLayout>Pluralização e Formatação Complexa
Implementação de Pluralização
// src/i18n/utils.ts (adição)export function plural(count: number, forms: { one: string; other: string; zero?: string }, lang: string) { const pluralRules = new Intl.PluralRules(lang); const rule = pluralRules.select(count);
if (rule === 'zero' && forms.zero) { return forms.zero.replace('{count}', count.toString()); }
if (rule === 'one') { return forms.one.replace('{count}', count.toString()); }
return forms.other.replace('{count}', count.toString());}Uso da pluralização:
---import { getLanguageFromUrl, useTranslations, plural } from '../../i18n/utils';import { languages } from '../../i18n/ui';
const lang = getLanguageFromUrl(Astro.url);const t = useTranslations(lang as keyof typeof languages);
const itemCount = 5;---
<p> {plural(itemCount, { zero: t('cart.empty'), one: t('cart.oneItem'), other: t('cart.manyItems'), }, lang)}</p>Detecção Automática de Idioma
Middleware para Detecção de Idioma
import { defaultLanguage, languages } from './i18n/ui';
export function onRequest({ request, redirect }, next) { const url = new URL(request.url); const [, lang] = url.pathname.split('/');
// Se o caminho já tem um idioma válido, continue if (lang && Object.keys(languages).includes(lang)) { return next(); }
// Detectar idioma preferido do navegador const acceptLanguage = request.headers.get('accept-language') || ''; const browserLang = acceptLanguage .split(',') .map(lang => lang.split(';')[0].trim()) .find(lang => Object.keys(languages).includes(lang)) || defaultLanguage;
// Redirecionar para o idioma detectado return redirect(`/${browserLang}${url.pathname}`);}SEO para Sites Multilíngues
Metadados Específicos por Idioma
---import { getAlternateLinks } from '../i18n/utils';import { languages } from '../i18n/ui';
const { title, description, image, lang, path} = Astro.props;
const alternateLinks = getAlternateLinks(path);const siteUrl = Astro.site || 'https://exemplo.com';---
<!-- Metadados básicos --><title>{title}</title><meta name="description" content={description}>
<!-- Canonical e alternates --><link rel="canonical" href={new URL(`/${lang}${path}`, siteUrl).href}>{alternateLinks.map(({ href, hreflang }) => ( <link rel="alternate" href={new URL(href, siteUrl).href} hreflang={hreflang} />))}<link rel="alternate" href={new URL(`/${lang}${path}`, siteUrl).href} hreflang="x-default" />
<!-- Open Graph --><meta property="og:title" content={title}><meta property="og:description" content={description}><meta property="og:url" content={new URL(`/${lang}${path}`, siteUrl).href}><meta property="og:locale" content={lang}>{Object.keys(languages).filter(l => l !== lang).map(l => ( <meta property="og:locale:alternate" content={l}>))}<meta property="og:image" content={image ? new URL(image, siteUrl).href : new URL('/images/default-og.jpg', siteUrl).href}>Uso do componente SEO:
---import BaseLayout from '../../layouts/BaseLayout.astro';import SeoHead from '../../components/SeoHead.astro';import { getLanguageFromUrl, useTranslations } from '../../i18n/utils';import { languages } from '../../i18n/ui';
export async function getStaticPaths() { return Object.keys(languages).map((lang) => ({ params: { lang }, }));}
const { lang } = Astro.params;const t = useTranslations(lang as keyof typeof languages);const path = '/about';---
<BaseLayout> <SeoHead slot="head" title={t('about.title')} description={t('about.description')} lang={lang} path={path} />
<main> <h1>{t('about.title')}</h1> <p>{t('about.description')}</p> </main></BaseLayout>Integração com Sistemas de Tradução
Integração com i18next
# Instalar i18nextnpm install i18nextimport i18next from 'i18next';import ptBR from './translations/pt-BR';import en from './translations/en';import es from './translations/es';
i18next.init({ lng: 'pt-BR', fallbackLng: 'pt-BR', resources: { 'pt-BR': { translation: ptBR }, 'en': { translation: en }, 'es': { translation: es }, }, interpolation: { escapeValue: false, },});
export default i18next;
export function t(key: string, options?: any) { return i18next.t(key, options);}
export function changeLanguage(lang: string) { return i18next.changeLanguage(lang);}Uso com i18next:
---import BaseLayout from '../../layouts/BaseLayout.astro';import { languages } from '../../i18n/ui';import { t, changeLanguage } from '../../i18n/i18next';
export async function getStaticPaths() { return Object.keys(languages).map((lang) => ({ params: { lang }, }));}
const { lang } = Astro.params;await changeLanguage(lang);
const username = 'João';const count = 3;---
<BaseLayout> <main> <!-- Interpolação simples --> <p>{t('welcome', { name: username })}</p>
<!-- Pluralização --> <p>{t('items', { count })}</p>
<!-- Formatação de data --> <p>{t('lastLogin', { date: new Date(), formatParams: { date: { month: 'long', day: 'numeric' } } })}</p> </main></BaseLayout>Gestão de Conteúdo Multilíngue
Estrutura de Conteúdo com Astro Content Collections
import { defineCollection, z } from 'astro:content';
const blogCollection = defineCollection({ schema: z.object({ title: z.string(), description: z.string(), pubDate: z.date(), image: z.string().optional(), lang: z.enum(['pt-BR', 'en', 'es']), // Para relacionar traduções do mesmo post translationKey: z.string(), }),});
export const collections = { 'blog': blogCollection,};Estrutura de arquivos de conteúdo:
src/content/└── blog/ ├── pt-BR/ │ ├── primeiro-post.md │ └── segundo-post.md ├── en/ │ ├── first-post.md │ └── second-post.md └── es/ ├── primer-post.md └── segundo-post.mdExemplo de post com traduções:
---title: "Meu Primeiro Post"description: "Este é meu primeiro post no blog."pubDate: 2023-01-15image: "/images/post1.jpg"lang: "pt-BR"translationKey: "first-post"---
Conteúdo do post em português...---title: "My First Post"description: "This is my first blog post."pubDate: 2023-01-15image: "/images/post1.jpg"lang: "en"translationKey: "first-post"---
Post content in English...Listagem de posts por idioma:
---import { getCollection } from 'astro:content';import BaseLayout from '../../../layouts/BaseLayout.astro';import { getLanguageFromUrl, useTranslations } from '../../../i18n/utils';import { languages } from '../../../i18n/ui';import { formatDate } from '../../../i18n/formatters';
export async function getStaticPaths() { return Object.keys(languages).map((lang) => ({ params: { lang }, }));}
const { lang } = Astro.params;const t = useTranslations(lang as keyof typeof languages);
// Buscar posts no idioma atualconst posts = await getCollection('blog', ({ data }) => { return data.lang === lang;});
// Ordenar por data de publicaçãoconst sortedPosts = posts.sort((a, b) => b.data.pubDate.getTime() - a.data.pubDate.getTime());---
<BaseLayout title={t('blog.title')} description={t('blog.description')}> <main> <h1>{t('blog.title')}</h1>
<ul class="post-list"> {sortedPosts.map(post => ( <li> <a href={`/${lang}/blog/${post.slug}`}> <h2>{post.data.title}</h2> <p class="date">{formatDate(post.data.pubDate, lang)}</p> <p>{post.data.description}</p> </a> </li> ))} </ul> </main></BaseLayout>Página de post com links para traduções:
---import { getCollection } from 'astro:content';import BaseLayout from '../../../layouts/BaseLayout.astro';import { getLanguageFromUrl } from '../../../i18n/utils';import { languages } from '../../../i18n/ui';import { formatDate } from '../../../i18n/formatters';
export async function getStaticPaths() { const allPosts = await getCollection('blog');
return allPosts.map(post => ({ params: { lang: post.data.lang, slug: post.slug, }, props: { post }, }));}
const { post } = Astro.props;const { lang } = Astro.params;const { Content } = await post.render();
// Buscar traduções disponíveis deste postconst translations = await getCollection('blog', ({ data }) => { return data.translationKey === post.data.translationKey && data.lang !== post.data.lang;});---
<BaseLayout title={post.data.title} description={post.data.description}> <main> <article> <h1>{post.data.title}</h1> <p class="date">{formatDate(post.data.pubDate, lang)}</p>
{translations.length > 0 && ( <div class="translations"> <p>Disponível também em:</p> <ul> {translations.map(translation => ( <li> <a href={`/${translation.data.lang}/blog/${translation.slug}`}> {languages[translation.data.lang as keyof typeof languages]} </a> </li> ))} </ul> </div> )}
<div class="content"> <Content /> </div> </article> </main></BaseLayout>Lista de Verificação de Internacionalização
Use esta lista para verificar a implementação de internacionalização do seu site:
Configuração Básica
- Estrutura de diretórios organizada por idioma
- Arquivos de tradução para cada idioma suportado
- Utilitários de i18n para acesso a traduções
- Redirecionamento para idioma padrão ou detecção automática
Conteúdo e UI
- Todas as strings de UI são externalizadas e traduzidas
- Formatação localizada de datas, números e moedas
- Suporte a pluralização
- Seletor de idioma funcional
SEO e Metadados
- Tags
<html lang>corretas para cada idioma - Links
<link rel="alternate" hreflang>para todas as versões - Metadados Open Graph com atributos
og:locale - URLs localizadas e amigáveis
Conteúdo Dinâmico
- Sistema para gerenciar conteúdo em múltiplos idiomas
- Links entre versões traduzidas do mesmo conteúdo
- Fallback para conteúdo não traduzido
Testes
- Verificação de strings não traduzidas
- Testes em diferentes configurações regionais
- Validação de formatação de datas e números
Conclusão
A implementação de internacionalização em soluções web desenvolvidas com Astro 5 permite que seu site alcance um público global, oferecendo uma experiência personalizada para usuários de diferentes idiomas e regiões. Ao seguir as práticas recomendadas neste documento, você estará criando uma base sólida para expandir seu site para novos mercados.
Lembre-se de que a internacionalização é um processo contínuo. À medida que seu site evolui, você precisará manter as traduções atualizadas e possivelmente adicionar suporte para novos idiomas.
Os próximos documentos detalharão outros aspectos do desenvolvimento:
- Análise de Dados: Estratégias para coletar e analisar dados de usuários
- Manutenção e Evolução: Práticas para manter e evoluir sua solução web
Última atualização: 21 de março de 2025