Teu progresso
0 / 83 módulos0%
Estágio 02 · 02-19
BloqueadoSoftware global. Logística pode crescer pra Brasil + México + EU em poucos anos. Mas i18n quase sempre é after-thought: dev escreve "You have ${n} orders" hardcoded em English; PR aprova; meses depois, time inteiro reescreve telas pra suportar pt-BR/es-MX/de-DE/he-IL/ar-SA. Custa 2-5x mais que ter feito desde o início.
i18n não é "tradução do front-end". Inclui: Unicode correctness (UTF-8/16, normalization, combining chars, emoji), pluralization (10+ regras em russo, polonês, árabe), gender-aware messages, number/currency/date format por locale, timezone math, RTL layouts, fonts com glyphs corretos, sorting/search com collation, input methods, certificados e regulamentação por país (LGPD, GDPR, CCPA), tax calc por região (já em 02-18), nomes pessoais cross-cultural (não há "first name + last name" universal), endereços com formatos divergentes.
Este módulo é o ofício de produzir software que não quebra no segundo locale: Unicode profundo, ICU MessageFormat, biblioteca i18n moderna (FormatJS, i18next, Lingui), Intl APIs do browser, RTL com CSS logical properties, locale-aware sorting, timezone correto (sempre UTC + tz id), e l10n process (translation memory, glossary, contractor workflow).
Charset universal (1.1M code points, 150k assigned). Encodings:
Code point ≠ grapheme cluster. "é" pode ser 1 code point (precomposed) ou 2 (e + combining acute). Família emoji 👨👩👧 é 1 grapheme com 5 code points.
str.length em JS conta UTF-16 code units. Errado pra emoji + accents. Use Intl.Segmenter (modern) ou grapheme-splitter lib.
Same string aparente, bytes diferentes. Forms:
é precomposto).e + combining).Comparação requer normalização. str.normalize('NFC'). Bug clássico: usuário cadastra "café" (NFD), login compara com "café" (NFC), mismatch silencioso.
Sort cultural-aware. "ç" em Português < "z"; em Castellano antigo, "ñ" tinha posição própria.
String.localeCompare(b, locale, opts) ou Intl.Collator(locale, opts). Opts:
sensitivity: 'base'/'accent'/'case'/'variant'.numeric: true: "10" > "2" como números.Postgres collation: schemas, índices baseados. Trocar collation pode invalidar índice, caso real causando incidents.
English: 0/1 vs N+. Russian: 1, 2-4, 5+. Polish: 1, 2-4, 5+ except 22-24, 25-29 different. Árabe: 6 categorias (zero, one, two, few, many, other).
ICU CLDR define regras. Use:
new Intl.PluralRules('pl').select(5); // → 'many'
Frameworks: ICU MessageFormat, FormatJS, i18next, Lingui.
{n, plural,
=0 {Sem pedidos}
one {Um pedido}
other {# pedidos}
}
ICU select:
{gender, select,
female {Ela viu seu perfil}
male {Ele viu seu perfil}
other {Visualizaram seu perfil}
}
Cross-language: em Português, gender afeta artigo/adjetivo. Em English, raramente. Regras divergem.
Intl.NumberFormat:
1234.5 → en-US: 1,234.5; pt-BR: 1.234,5; de-DE: 1.234,5; fr-FR: 1 234,5; ar-EG: ١٬٢٣٤٫٥ (não-Latin digits).Currency:
$1,234.50 (en-US) vs R$ 1.234,50 (pt-BR) vs 1 234,50 € (fr-FR) vs €1.234,50 (de-DE).Intl.NumberFormat('pt-BR', {style: 'currency', currency: 'BRL'}).Dates:
new Intl.DateTimeFormat('ja-JP', {dateStyle: 'long'}).format(d) → "2026年4月28日".UTC sempre. Persist UTC. Display in user tz.
Tz id (IANA): America/Sao_Paulo. NÃO offset (-03:00); offset muda com DST.
Intl.DateTimeFormat('pt-BR', {timeZone: 'America/Sao_Paulo'}).
Libs: date-fns-tz, dayjs-timezone, Luxon. JS native Temporal API (chegando, parcial).
DST: Brasil não tem desde 2019. Ainda assim, world has. Calculation com horário "futuro" envolve atualizações IANA tzdata. Mantenha tzdata atualizado (geralmente 2-4 releases/ano).
Common bugs:
Asia/Kolkata UTC+5:30; Iran Asia/Tehran UTC+3:30; Nepal Asia/Kathmandu UTC+5:45; Australian Central UTC+9:30. Filters como WHERE EXTRACT(HOUR FROM ts) = 9 quebram. Use EXTRACT(EPOCH FROM ts) / 60 quando precisa precisão sub-hora.DateTime("2019-11-03 02:30 America/Sao_Paulo") → ambíguo, libs típicas pulam pra 3:30 ou rejeitam. Schedule de courier marcado pra esse minuto: bug silencioso.tzdata >= 2025b em Dockerfile + audit anual.Africa/Asmera é alias de Africa/Asmara; US/Pacific é alias de America/Los_Angeles. Use canônico; aliases somem em IANA updates.pg_cron roda em DB tz. "Backup às 3 AM Brasília" requer cuidado.(rrule_text, tz_id) separados; gera ocorrências on-the-fly.1990-05-15 (sem tz). Não converta pra UTC midnight; perde 1 dia em half-tz. Use type date puro.// Padrão sano com Luxon
import { DateTime } from 'luxon';
// Persist UTC sempre
const persistedAt = DateTime.now().toUTC().toISO();
// Display em tz do user
const display = DateTime.fromISO(persistedAt, { zone: 'utc' })
.setZone(user.tz)
.toLocaleString(DateTime.DATETIME_MED_WITH_WEEKDAY, { locale: user.locale });
// "Tomorrow at 9 AM in user tz" — converte pra UTC pra schedule
const scheduleAt = DateTime.now().setZone(user.tz)
.plus({ days: 1 }).set({ hour: 9, minute: 0, second: 0 })
.toUTC().toISO();
// DST safety check: se ambíguo, escolha early
const ambiguous = DateTime.fromISO('2025-11-02T01:30', { zone: 'America/New_York' });
if (!ambiguous.isValid) throw new Error('Ambiguous local time during DST transition');
Temporal API (TC39 Stage 3, 2026): supersedes Luxon na padronização; Temporal.ZonedDateTime, Temporal.PlainDate, Temporal.Duration. Polyfill estável; rollout nativo browser-by-browser.
Anti-pattern: "You have " + n + " orders". Concatena gramática.
Pattern: chave + ICU template.
t('orders.count', { count: n });
Locales:
// en
{"orders.count": "You have {count, plural, =0 {no orders} one {one order} other {# orders}}"}
// pt-BR
{"orders.count": "Você {count, plural, =0 {não tem pedidos} one {tem um pedido} other {tem # pedidos}}"}
Árabe, hebraico, persa, urdu são RTL. Layout inverte.
CSS logical properties:
margin-inline-start em vez de margin-left.text-align: start em vez de left.padding-inline-end.border-inline-start.dir="rtl" em html. Browser flips layout. Imagens direcionais (setas, ícones de "voltar") precisam mirror.
Não-Latin scripts: Cyrillic (russo), Greek, CJK (Chinese/Japanese/Korean), Arabic, Hebrew, Devanagari (hindi), Thai, etc.
System fonts: cobrem só subset. Custom font precisa coverage. Google Fonts, Noto family ("no tofu"; cobertura ampla), Source Sans/Han.
CJK = renders pesados. Use subsetting + variable fonts.
Emoji: cada plataforma renders diferente. Twemoji (Twitter), Noto Color Emoji.
Chinese/Japanese/Korean usam IME (Input Method Editor): user digita romaji/pinyin, sugere candidates, confirma. UI deve não processar keydown durante composition (use compositionstart/compositionend).
Bug clássico: search-as-you-type dispara em cada keydown durante IME composition, query incompleta vai pro backend.
Postgres: CREATE COLLATION pt_BR (locale = 'pt_BR.UTF-8'). Diferença em sorting/comparison.
pg_trgm para fuzzy. Unaccent extension pra search ignorando acentos: unaccent('café') = 'cafe'.
Elasticsearch / Meilisearch (02-15): tokenizer + stemmer language-aware. RSLP (português) ≠ Snowball English.
Já em 02-18, recall:
Edge: zero-decimal currencies (JPY, KRW). 100 yen é 100, não 1.00.
Stripe / PSPs lidam minor units: 100 USD = 10000 cents; 100 JPY = 100 (no decimals).
| Tipo | Exponent | Exemplos | Minor unit pra 1 unit |
|---|---|---|---|
| Zero-decimal | 0 | JPY, KRW, VND, CLP, ISK, UGX | 1 yen = 1 |
| Two-decimal (default) | 2 | USD, EUR, BRL, GBP, CAD | 1 dollar = 100 cents |
| Three-decimal | 3 | KWD (Kuwait), BHD (Bahrain), OMR (Oman), JOD (Jordan), TND (Tunisia), LYD (Libya), IQD (Iraq) | 1 KWD = 1000 fils |
| Four-decimal (raro, mercados FX) | 4 | UYI (Uruguay indexed), CLF (Chile UF) | 1 unit = 10000 sub |
Hard-coding * 100 quebra em 3-decimals. Padrão correto:
// API Stripe-style
const exponentByCurrency: Record<string, number> = {
JPY: 0, KRW: 0, VND: 0, CLP: 0, ISK: 0, UGX: 0,
KWD: 3, BHD: 3, OMR: 3, JOD: 3, TND: 3, LYD: 3, IQD: 3,
// default: 2
};
function toMinorUnits(amount: string | number, currency: string): bigint {
const exp = exponentByCurrency[currency] ?? 2;
// Use Decimal lib pra evitar float drift; aqui simplificado
const factor = 10n ** BigInt(exp);
const [whole, frac = ''] = String(amount).split('.');
const fracPadded = frac.padEnd(exp, '0').slice(0, exp);
return BigInt(whole) * factor + BigInt(fracPadded || '0');
}
toMinorUnits('100', 'USD'); // 10000n
toMinorUnits('100', 'JPY'); // 100n
toMinorUnits('100.123', 'KWD'); // 100123n
toMinorUnits('100', 'BRL'); // 10000n
Intl.NumberFormat faz display correto automaticamente:
new Intl.NumberFormat('ja-JP', { style: 'currency', currency: 'JPY' }).format(100); // "¥100"
new Intl.NumberFormat('ar-KW', { style: 'currency', currency: 'KWD' }).format(0.123); // "د.ك. ٠٫١٢٣"
new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(99.9); // "R$ 99,90"
Anti-patterns:
Padrão production-ready:
// FX rates persistidos por dia + provider versioned
type FxRate = {
base: string; // "USD"
quote: string; // "BRL"
rate: string; // decimal as string ("5.1234")
provider: string; // "openexchangerates" | "ecb" | "fixer"
fetched_at: Date;
effective_date: string; // "2026-05-01"
};
// Multi-provider fallback chain
const providers = [
{ name: 'ecb', fetch: fetchEcb }, // free, daily, EUR base
{ name: 'openexchangerates', fetch: fetchOXR }, // paga, hourly, multi-base
{ name: 'fixer', fetch: fetchFixer }, // backup
];
async function refreshFxDaily() {
for (const p of providers) {
try {
const rates = await p.fetch();
await db.upsert('fx_rates', rates.map(r => ({ ...r, provider: p.name })));
log.info({ provider: p.name, count: rates.length }, 'FX refreshed');
return;
} catch (err) {
log.warn({ provider: p.name, err }, 'FX provider failed, trying next');
}
}
await alertOps('All FX providers failed; rates stale');
// NÃO catch silencioso — dados financeiros stale são bug crítico
}
Pra settlement (cobrança/pagamento real):
INSERT INTO orders (..., fx_rate_used, fx_provider, fx_at) VALUES (...). Cliente pediu reembolso 30 dias depois? Use rate snapshot, não atual.oz tr).Se Logística vai operar em LATAM e oferecer pagamento em moeda indexada, separe money currencies (BRL, USD, EUR) de indexed/commodity (CLF, XAU) em schema; lógica de conversão é diferente.
Cruza com 02-18 (payments deep), 04-09 §2.14 (cost categories de cross-border), 04-16 §2.7 (unit economics requer FX correto).
US: street, city, state, zip. Brasil: street, número, complemento, bairro, cidade, estado, CEP. Japão: zip, prefecture, city, street, building (orderem inversa). UK: street, town, postcode (state ausente).
Backend: persist como structured JSON ou separated fields. Front-end: render com template por país. Don't impose US shape.
Lib: i18n-postal-address, libphonenumber pra phone.
"First name + last name" não é universal. Spanish: dois sobrenomes (paterno + materno). Indonesian: muitos só 1 nome. Hungarian: family name first. Chinese: family name first.
Persist full_name (string) + opcional display_name. Don't force first/last separation se não preciso.
Falabamos como 2025+ pattern: Patrick McKenzie's "Falsehoods Programmers Believe About Names" é leitura obrigatória.
Engenharia: dependendo do mercado, multi-region storage, audit logs, data residency, right-to-be-forgotten endpoints.
orders.list.empty).if (locale === 'en') ... else ... em código.localeCompare.lang attribute (a11y + tools).i18n maduro 2026 não é "JSON com chaves traduzidas". É CLDR 44 (ICU 73+) pra pluralization/gender, Intl APIs Baseline pra format, locale negotiation RFC 4647 no edge, RTL via CSS logical properties, e Temporal API (Stage 3 → stable mid-2026) pra timezone. Cada peça tem pegadinha que só aparece no segundo locale.
Naive t('orders.count', { n }) → "1 pedidos" falha em pluralização (1 vs 2+), gênero, ordem de palavras. ICU MessageFormat (Unicode CLDR) tem regras gramaticais embutidas: one/two/few/many/other, select por gender, nesting. Stack 2026: formatjs (React Intl), next-intl (Next.js native), lingui-js, @formatjs/intl (vanilla TS), @vocab/core (build-time).
{count, plural,
=0 {Nenhum pedido}
one {# pedido}
other {# pedidos}
}
=0 literal match precede categories. Português tem 2 categorias (one/other); russo 4 (one/few/many/other); árabe 6 (zero/one/two/few/many/other); polonês 4; japonês/chinês/coreano só other (sem agreement). NUNCA hardcode if count === 1 — locale-specific failure.
Gender + nested:
{gender, select,
male {Ele entregou {count, plural, one {# pedido} other {# pedidos}}}
female {Ela entregou {count, plural, one {# pedido} other {# pedidos}}}
other {Pessoa entregou {count, plural, one {# pedido} other {# pedidos}}}
}
// messages/pt-BR.json
{
"orders.count": "{count, plural, =0 {Nenhum pedido} one {# pedido} other {# pedidos}}",
"orders.total": "Total: {amount, number, ::currency/BRL .00}",
"orders.created": "Criado em {createdAt, date, ::yyyy-MM-dd HH:mm}"
}
// component
import { useTranslations } from 'next-intl';
const t = useTranslations();
t('orders.count', { count: 5 }); // "5 pedidos"
t('orders.total', { amount: 1234.5 }); // "Total: R$ 1.234,50"
t('orders.created', { createdAt: new Date('2026-05-06T14:30') }); // "Criado em 2026-05-06 14:30"
Skeleton ::currency/BRL .00 e ::yyyy-MM-dd HH:mm vêm do ICU 73+ DateTimeSkeleton; mais flexível que pattern legacy {amount, number, currency}.
| Locale | Categorias | Exemplo prático |
|---|---|---|
| en, de, nl | one (1), other | 1 item / 2+ items |
| pt, es, fr | one (1), other | 1 item / 2+ items |
| ru, uk | one (1, 21, 31...), few (2-4, 22-24...), many (resto), other (fracionários) | 4 |
| ar | zero, one, two, few, many, other | 6 |
| ja, zh, ko | other | sem agreement |
| pl | one, few, many, other | regras complexas |
Verificação em test:
new Intl.PluralRules('ru').select(5); // 'many'
new Intl.PluralRules('ru').select(22); // 'few'
new Intl.PluralRules('ar').select(0); // 'zero'
new Intl.PluralRules('pl').select(2); // 'few'
Regression test obrigatório: snapshot por locale × [0, 1, 2, 5, 11, 21, 22, 100]. Releases que adicionam russo/árabe quebram silenciosamente sem essa cobertura.
CSS Logical Properties (Baseline 2024 widely): padding-inline-start (LTR=left, RTL=right), margin-inline-end, border-start-start-radius, inset-inline-start. NUNCA padding-left em UI cross-locale.
/* errado */
.card { padding-left: 12px; border-left: 1px solid; text-align: left; }
/* certo */
.card { padding-inline-start: 12px; border-inline-start: 1px solid; text-align: start; }
<html dir="rtl" lang="ar"> ativa RTL globalmente; browser flips layout. Tailwind v4 expõe dir-* variants nativos; v3 usa plugin tailwindcss-rtl com prefixo rtl:.
Mirror em RTL: setas, chevrons, ícone de "voltar" (transform: scaleX(-1) quando inline-direction-dependent). NÃO mirror: logos, fotos, audio waveforms, code snippets, números (digits LTR mesmo em texto RTL).
Bidirectional text (nome árabe + número en): trust browser bidi (Unicode UAX#9), mas isolate user content em <bdi> ou unicode-bidi: isolate. Usuário hostil pode injetar RLO/LRO override e reordenar UI vizinha.
<!-- user content em UI mixed-direction -->
<p>Pedido de <bdi>{{ user.displayName }}</bdi> entregue às 14:30.</p>
import { match } from '@formatjs/intl-localematcher';
const supported = ['pt-BR', 'es-419', 'en'];
const requested = req.headers['accept-language']; // "pt-PT,pt;q=0.9,en;q=0.8"
const negotiated = match(parseAcceptLanguage(requested), supported, 'en');
// pt-PT → pt-BR (best match em Portuguese family); fallback 'en'
Strategy preference: URL-based (/pt-BR/orders) > query (/orders?lang=pt-BR) > cookie > Accept-Language. URL é SEO-friendly e cacheable. Per-user override sempre vence Accept-Language.
Pegadinha CDN: Vary: Accept-Language raw causa cardinality explosion (1000+ valores únicos em prod). Normalize em Worker antes do cache key (cruza com ../03-infraestrutura/03-10-cdn-edge.md §2.20):
// Cloudflare Worker, antes do cache lookup
const accept = request.headers.get('accept-language') ?? '';
const normalized = match(parseAcceptLanguage(accept), ['pt-BR', 'es-419', 'en'], 'en');
const cacheKey = new Request(url + '?_lang=' + normalized, request);
// Intl.DateTimeFormat — locale + timezone explícitos
new Intl.DateTimeFormat('pt-BR', {
dateStyle: 'long', timeStyle: 'short', timeZone: 'America/Sao_Paulo'
}).format(new Date()); // "6 de maio de 2026 às 14:30"
// Intl.NumberFormat — currency, percent, decimals
new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(1234.5); // "R$ 1.234,50"
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'BRL' }).format(1234.5); // "R$1,234.50"
// Intl.RelativeTimeFormat
new Intl.RelativeTimeFormat('pt-BR', { numeric: 'auto' }).format(-2, 'hour'); // "há 2 horas"
// Intl.ListFormat (Baseline 2024)
new Intl.ListFormat('pt-BR', { type: 'conjunction' }).format(['A', 'B', 'C']); // "A, B e C"
new Intl.ListFormat('en', { type: 'conjunction' }).format(['A', 'B', 'C']); // "A, B, and C"
Temporal proposal (TC39 Stage 3 → stable mid-2026): substitui Date com timezone-aware API. Temporal.ZonedDateTime, Temporal.PlainDate, Temporal.Duration. Polyfill estável via @js-temporal/polyfill; rollout nativo browser-by-browser em 2026.
Date.toISOString() é UTC sempre; rendering deve format pra user TZ via Intl.DateTimeFormat({ timeZone }). Storage SEMPRE UTC em DB (TIMESTAMPTZ Postgres); render com user TZ no client/edge. Use IANA names (America/Sao_Paulo), NUNCA offset fixo (-03:00) — DST e mudanças políticas (Brasil aboliu DST em 2019; Mexico em 2022) viram bug silencioso em código que tinha if (month >= 10 && month <= 2).
Locales: pt-BR (primary), es-419 (Latin America Spanish), en (US/global). next-intl + messages JSON per locale + ICU MessageFormat + Intl APIs. URL strategy /pt-BR/orders/:id, /es/orders/:id, /en/orders/:id. Currency BRL primário; tenant config opcional pra USD/EUR em cliente cross-border. Address: pt-BR usa CEP 8 dígitos + estado siglas (SP, RJ); en usa ZIP + state code; phone via libphonenumber-js. Pluralização: {count, plural, one {1 entrega hoje} other {# entregas hoje}}.
if (count === 1) return "1 item" em vez de ICU plural — quebra em ru/ar/pl.padding-left: 12px em vez de padding-inline-start — quebra RTL.Date.toLocaleString() sem timeZone — renders em server TZ; horror em SSR multi-region.t('today_is') + ' ' + day — ordem errada em ja/de/ar.<bdi> em mixed-direction — algoritmo bidi trip + RLO injection.Vary: Accept-Language raw em CDN — cardinality explosion.Date com offset -03:00 — quebra após mudança DST/política.'en' sem RFC 4647 lookup — pt-BR user vê en quando pt disponível.one/other hardcoded sem testar Intl.PluralRules — release ru/ar quebra.Cruza com 02-02 §a11y RTL (dir attribute + screen reader announce), 02-09 (Postgres TIMESTAMPTZ + collation), 02-05 (Next.js app router locale strategy), ../03-infraestrutura/03-09-frontend-perf.md (locale-specific bundle splitting via dynamic import), ../03-infraestrutura/03-10-cdn-edge.md §2.20 (Vary normalization no edge), ../04-produto/04-16-product-engineering.md §2.7 (locale market expansion business case + unit economics FX).
§2.17-2.19 cobriu fundamentos: Unicode, ICU MessageFormat, RTL, locale negotiation, pluralização, formatação. Falta o lado humano + máquina da produção de localization: como fonte de verdade dos strings sai do repositório, atravessa um TMS (Translation Management System), passa por machine translation (DeepL / GPT-4o / Claude 3.7 Sonnet), volta revisada por humanos, e retorna ao repositório sem drift. 2024-2026 quebrou o equilíbrio antigo: AI raw já produz tier 1 com glossário forte; humano virou post-editor + reviewer em vez de tradutor primário. Throughput humano puro: ~2000 palavras/dia a $0.10-0.20/palavra. AI + post-edit: ~8000 palavras/dia a $0.01/palavra raw + $0.04 review. Custo cai 5-10x. Mas qualidade só sustenta se TMS + glossary + style guide + MQM rubric estiverem operando como single source of truth.
TMS comparison matrix 2026 (escolha não é trivial — depende de open-source vs SaaS, GitHub-native, in-context, AI workflow nativo):
| TMS | Modelo | GitHub sync | AI built-in | In-context | Forte em |
|---|---|---|---|---|---|
| Lokalise (acquired SmartCat 2024) | SaaS | Action oficial | DeepL + GPT | Plugin Figma + web | UI rich, mid-large teams, JSON/ICU/XLIFF |
| Phrase TMS (Memsource rebrand) | Enterprise SaaS | API + CLI | AI workflows GA 2024 | Limited | Enterprise compliance, MQM nativo, XLIFF heavy |
| Crowdin | SaaS | Action oficial | DeepL + OpenAI | Crowdin In-Context | Community translation (gamification), open-source projects |
| Tolgee 2.x | Open-source self-hosted ou Cloud | CLI + Action | OpenAI + DeepL plugin | Chrome extension overlay (best-in-class) | Self-hosted, in-context superior, dev-first |
| Transifex | Enterprise SaaS | API | AI add-on | Yes | Enterprise legacy, mature, conservadora |
Logística aplicada: Tolgee self-hosted (Postgres + Docker compose, controle total, $0 SaaS) + Claude 3.7 Sonnet via API para tier 1 EN→pt-BR/es-MX/pt-PT + revisor nativo humano em pt-BR (interno) e contractor em es-MX/pt-PT.
GitHub Action sync pattern. Source language em Git (e.g. locales/en.json); TMS espelha; PR auto-aberto quando traduções voltam:
# .github/workflows/i18n-sync.yml
name: i18n sync (Tolgee)
on:
push:
branches: [main]
paths: ['locales/en.json', 'locales/en/**']
schedule:
- cron: '0 6 * * 1' # Monday 06:00 UTC: pull translated strings
workflow_dispatch:
jobs:
push-source:
if: github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Push source EN to Tolgee
uses: tolgee/tolgee-action@v2
with:
api-key: ${{ secrets.TOLGEE_API_KEY }}
api-url: https://tolgee.logistica.internal
command: push
languages: en
files-pattern: 'locales/en/**.json'
override-key-descriptions: true # screenshots + context preserved
pull-translations:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Pull translated locales
uses: tolgee/tolgee-action@v2
with:
api-key: ${{ secrets.TOLGEE_API_KEY }}
api-url: https://tolgee.logistica.internal
command: pull
languages: pt-BR,pt-PT,es-MX
path: locales
- name: Open PR
uses: peter-evans/create-pull-request@v6
with:
commit-message: 'i18n: sync translations from Tolgee'
branch: i18n/sync-${{ github.run_id }}
title: 'i18n: weekly translation sync'
body: 'Auto-sync from Tolgee. Review string diffs before merge.'
labels: i18n,automated
Push é eager (toda mudança no en propaga); pull é batched semanal + PR (humano revisa o diff antes de mergear — captura quebra de variável ICU, contagem de placeholders divergente, encoding bug).
Locale file conventions. JSON nested por feature/page namespace (não flat com chaves english-as-key):
// locales/en/checkout.json
{
"checkout": {
"header": "Review your order",
"total_label": "Total: {amount, number, ::currency/USD}",
"items_count": "{count, plural, =0 {No items} one {1 item} other {# items}}",
"submit_cta": "Place order",
"terms_accept": "I agree to the <link>Terms of Service</link>"
}
}
Namespace por feature (checkout, dashboard, auth) evita translation memory cross-pollination indevida e permite lazy-load por rota. ICU MessageFormat para plurals/select. Nunca chave english-as-key ({ "Place order": "Place order" }) — translator vê chave igual ao valor e traduz mecanicamente, sem context.
AI translation pipeline 2026. Claude 3.7 Sonnet com glossary + style guide + tone embutidos no system prompt. TMS chama API ou worker bate em batch:
// scripts/ai-translate.ts — invoked from Tolgee webhook on new untranslated key
import Anthropic from '@anthropic-ai/sdk';
const client = new Anthropic();
const GLOSSARY = {
'Logistica': 'Logistica', // brand: NEVER translate
'order': { 'pt-BR': 'pedido', 'es-MX': 'pedido', 'pt-PT': 'encomenda' },
'shipment': { 'pt-BR': 'envio', 'es-MX': 'envío', 'pt-PT': 'envio' },
'tracking number': { 'pt-BR': 'codigo de rastreio', 'es-MX': 'numero de rastreo', 'pt-PT': 'codigo de seguimento' },
};
const STYLE_GUIDE = {
'pt-BR': 'Use "voce" (informal). Tom direto, conciso. Evite gerundio em titulos. Capitalize apenas primeira palavra em CTAs.',
'pt-PT': 'Use "voce" formal. Vocabulario distinto de pt-BR (encomenda, ecra, ficheiro). Acordo Ortografico 1990.',
'es-MX': 'Use "usted" em CTAs formais, "tu" em microcopy informal. Vocabulario neutro mexicano.',
};
export async function translate(key: string, source: string, targetLang: string, context?: string) {
const glossaryHints = Object.entries(GLOSSARY)
.filter(([term]) => source.toLowerCase().includes(term.toLowerCase()))
.map(([term, val]) => {
const target = typeof val === 'string' ? val : val[targetLang];
return `- "${term}" -> "${target}"`;
})
.join('\n');
const msg = await client.messages.create({
model: 'claude-3-7-sonnet-20250219',
max_tokens: 1024,
system: `You translate UI strings from English to ${targetLang} for Logistica (logistics SaaS).
Style guide: ${STYLE_GUIDE[targetLang]}
Glossary (MUST follow):
${glossaryHints || '(no glossary terms in this string)'}
Rules:
- Preserve ICU MessageFormat syntax: {var}, {n, plural, ...}, {x, select, ...}.
- Preserve XML-like tags: <link>, <bold>.
- Match string length within 1.3x of source (UI space constraints).
- Never translate brand names: Logistica, Stripe, AWS.
- Output ONLY the translated string, no quotes, no explanation.`,
messages: [{
role: 'user',
content: `Key: ${key}\nContext: ${context ?? 'general UI'}\nSource (EN): ${source}\nTranslate to ${targetLang}:`,
}],
});
return (msg.content[0] as { type: 'text'; text: string }).text.trim();
}
Output entra em Tolgee como machine-translated draft (estado MT), não auto-publica. Reviewer humano valida e promove para REVIEWED.
Glossary + term base + translation memory. Tres conceitos distintos, frequentemente confundidos:
In-context translation (Tolgee Chrome ext, killer feature). Tradutor abre app em staging, vê strings renderizadas; clica num botão "Place order" e edita inline com contexto visual completo (tamanho do botão, posição, vizinhança). Resolve string-only ambiguity (e.g. "Open" — verbo "abrir" ou adjetivo "aberto"?). Reduz erros de tradução em 30-40%. Setup: instalar ext + apontar pra Tolgee API + adicionar SDK no app:
// app/layout.tsx (staging only)
import { TolgeeProvider, DevTools, Tolgee, FormatIcu } from '@tolgee/react';
const tolgee = Tolgee()
.use(DevTools())
.use(FormatIcu())
.init({
apiUrl: process.env.NEXT_PUBLIC_TOLGEE_API_URL,
apiKey: process.env.NEXT_PUBLIC_TOLGEE_API_KEY, // staging only, never prod
language: 'en',
});
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<TolgeeProvider tolgee={tolgee} fallback="Loading...">
{children}
</TolgeeProvider>
);
}
Em produção, build com flag NEXT_PUBLIC_TOLGEE_DEV=false que tree-shakes DevTools fora do bundle.
Quality measurement: MQM (Multidimensional Quality Metrics). ASTM/ISO standard. Reviewer marca erros em rubric ponderada:
| Categoria | Severity | Peso |
|---|---|---|
| Accuracy (mistranslation, omission, addition) | minor=1 / major=5 / critical=25 | 1.0 |
| Fluency (grammar, spelling, register) | minor=1 / major=5 | 1.0 |
| Style (style guide adherence, tone) | minor=0.5 / major=2 | 0.5 |
| Terminology (glossary deviation) | minor=1 / major=5 | 1.0 |
MQM Score = 100 - (sum_errors / word_count) * 100. Tier 1 (CTAs, errors, billing) gate em MQM ≥ 95. Tier 2 (marketing, long-form) gate em ≥ 90. Spot-check 10% das strings AI-translated tier 1 — se > 2 strings com major error em sample de 50, reject batch e re-prompt com glossary reforçado.
Stack Logistica produção:
tolgee.logistica.internal.locales/en/**.json), GitHub Action push on merge to main.i18n/glossary.json versionado, sync to Tolgee via CLI on change.10 anti-patterns:
{"placeOrder":"Place order","placeOrderDashboard":"Place order"}): translator confuso (mesma string, contextos divergentes), TM cross-polluted entre features, lazy-load impossível.{"Place order":"Place order"}): translator não sabe se chave é literal ou identifier; quando EN muda, todas as chaves shiftam (chave nova, traduções perdidas).Cruza com §2.4 (pluralization — input do MT prompt), §2.6 (number/currency/date — não traduzir, format por locale), §2.7 (timezones — strings de "X minutes ago" precisam locale-aware), §2.17 (i18n process + tooling intro), §2.18 (anti-patterns base), §2.19 (ICU MessageFormat + RTL + locale negotiation), 02-05 §2.23 (Next 15 Document Metadata por locale via TMS), ../03-infraestrutura/03-04-ci-cd.md §2.21 (release-please reconhecendo translation file changes como chore(i18n) no changelog), ../04-produto/04-10-ai-product-engineering.md §2.23 (MCP — expor TMS como MCP server pra Claude Desktop puxar status de tradução), ../04-produto/04-16-product-engineering.md §2.21 (SaaS pricing tiers por locale: locale availability como gate de plan).
Você precisa, sem consultar:
Intl.PluralRules pra polonês.Internationalize a Logística para 3 locales: pt-BR (default), en-US, es-MX.
Intl.timezone em profile.pt_BR.UTF-8 em campos relevantes.en-XA que expande strings 30% e adiciona acentos. Use pra detect overflow.TRANSLATION-PROCESS.md definindo workflow.<html> por locale.Intl namespace.Threshold de Maestria
Acerte todas as 5 pra marcar o módulo como concluído. Sem pressa, sem timer. Tudo fica salvo no teu navegador.
Q1Por que `str.length` em JavaScript não conta corretamente caracteres como emoji ZWJ ou letras com acento?
Q2Por que persistir timestamps em UTC + IANA tz id (`America/Sao_Paulo`), e não em offset fixo (`-03:00`)?
Q3Por que `if (count === 1) return '1 item' else return count + ' items'` é um anti-pattern em i18n?
Q4Por que `padding-left: 12px` é um anti-pattern em UI cross-locale?
Q5Em FX e multi-currency, por que snapshotar a rate dentro da transação de pagamento é decisivo?
Destrava
02-19 é prereq dos seguintes módulos: