Instalei o Polylang no nosso blog de produção e imediatamente me arrependi. Então construí nosso próprio plugin multilíngue para WordPress em dois dias com o Claude Code.
Contexto: queríamos começar a publicar versões em português dos nossos posts em inglês no blog de engenharia da Codeminer42. O Polylang parecia a escolha óbvia, mas no momento que ativei, os permalinks quebraram. Posts retornavam 404. A homepage morreu. Tive que ir em Configurações > Links permanentes e clicar em “Salvar alterações” (sem mudar nada) só para fazer o site funcionar novamente. A cada reativação, a mesma coisa. Além disso, tradução por IA está trancada atrás do tier premium, e pedir para autores traduzirem manualmente artigos técnicos de 2000 palavras não ia escalar.
Então abri o Claude Code e comecei a construir.
O Plano
Abri o Claude Code e descrevi o que queria:
- Um plugin WordPress para posts multilíngues
- Tradução alimentada por IA (Ollama, Anthropic, Gemini)
- Nenhuma tabela personalizada no banco – usar taxonomia WordPress e meta de post
- Widget seletor de idioma com bandeiras
- Prefixos de URL (/pt-br/meu-post/)
- Tags SEO hreflang
- REST API com filtragem por idioma
- Suporte tanto para Gutenberg quanto Editor Clássico

O Claude Code dividiu em cinco fases. Começamos escrevendo testes primeiro.
O Ambiente de Desenvolvimento
Antes de tudo, uma observação sobre ferramentas. Desenvolvimento de plugin WordPress tem uma história de testes que a maioria das pessoas não conhece. O wp-env é uma ferramenta oficial que sobe uma instalação WordPress completa dentro do Docker – com uma instância de teste separada que tem PHPUnit e o framework de teste do WordPress pré-configurados.
Rodar testes significa executar PHPUnit dentro do container de teste:
npm run wp-env start
npm run test:unit
Isso importa porque nossa suite de testes usa factories do WordPress ($this->factory()->post->create()), queries de taxonomia reais, objetos WP_Query reais. Não mocks – internos do WordPress reais rodando contra um banco de teste. Foi assim que pegamos bugs que mocks teriam perdido.
Dia 1: A Arquitetura
O primeiro commit estabeleceu a arquitetura central. A decisão de design chave foi nenhuma tabela personalizada. Tudo usa primitivos WordPress:
- Uma taxonomia
cm_languagepara marcar posts com seu idioma - Meta de post
_cm_translation_groupcom UUID compartilhado para vincular traduções - Um padrão de provider para backends de tradução IA
14 classes, 1.581 linhas de PHP, zero tabelas personalizadas no banco.
Por que nenhuma tabela personalizada? Plugins WordPress que criam suas próprias tabelas são um saco de manter. Migrações, limpeza de desinstalação, compatibilidade multisite – é toda uma categoria de bugs. O sistema de taxonomia já faz o que precisamos: marcar posts com metadados e fazer query por eles. O trade-off é performance em escala – uma tax_query com NOT EXISTS é mais lenta que um lookup direto em tabela. Para um blog com ~500 posts, isso é irrelevante. Para um site com 100.000 posts, precisaríamos revisitar. Escolhemos simplicidade em vez de otimização prematura.
Sobre os providers de IA: Ollama, Anthropic e Gemini. Esses são os que eu uso. OpenAI funcionaria também, mas não uso no dia a dia, então não entrou no primeiro release. O plugin será lançado publicamente depois deste período de teste no nosso blog, e vou adicionar mais providers então.
Ollama merece seu próprio parágrafo. Você pode rodar modelos localmente de graça durante desenvolvimento, mas o Ollama Cloud também te dá acesso a modelos como Minimax e Kimi K2.5 que não cabem num laptop. A série Qwen é ótima para tradução também. Então você tem dev local sem custos de API e acesso a modelos maiores quando precisar.
O segundo commit adicionou 90 testes PHPUnit. Testes primeiro, implementação depois. O Claude Code seguiu TDD – escrever o teste, ver falhar, implementar o código mínimo para fazê-lo passar. Aqui está como um teste típico se parece:
public function test_default_language_query_includes_unlabeled_posts() {
$english_post = $this->factory()->post->create();
$this->assign_language($english_post, 'en');
$unlabeled_post = $this->factory()->post->create();
$query = new WP_Query(['post_type' => 'post']);
$this->multilingual->filter_by_language($query);
$this->assertContains($english_post, wp_list_pluck($query->posts, 'ID'));
$this->assertContains($unlabeled_post, wp_list_pluck($query->posts, 'ID'));
}
Este teste verifica que a query do idioma padrão usa uma condição OR: mostra posts que estão marcados como inglês OU não têm idioma nenhum. Essa segunda condição é crítica – quando você instala o plugin num blog com 450 posts existentes, nenhum deles tem idioma atribuído ainda. Sem NOT EXISTS, todos eles sumiriam da homepage.
Ao final do dia 1, tínhamos:
- Configuração de idioma com suporte a idioma padrão
- Vinculação de tradução (bidirecional, baseada em UUID)
- Todos os três providers de tradução IA com mocking HTTP nos testes
- Reescrita de URL com prefixos de idioma
- Seletor de idioma (widget, shortcode, bloco Gutenberg)
- Geração de tags hreflang
- REST API com filtragem
?lang= - 90 testes passando
Dia 2: O Editor Clássico e Bugs do Mundo Real
Dia 2 foi sobre fazer funcionar no mundo real. Nosso blog usa o Editor Clássico com Markdown (via WP Githuber MD), não Gutenberg. Então pedi ao Claude Code para adicionar uma meta box do Editor Clássico com a mesma funcionalidade do painel lateral do Gutenberg.

Ambas UIs chamam os mesmos endpoints da REST API. Mesmo resultado, apresentação diferente. A meta box usa JavaScript vanilla; a barra lateral usa React. A REST API não se importa:
// Ambas interfaces fazem chamadas API idênticas
fetch('/wp-json/cm-multilingual/v1/translate', {
method: 'POST',
body: JSON.stringify({
post_id: currentPostId,
target_language: 'pt-br'
})
})
Então vieram os bugs do mundo real. Quando fiz deploy em produção, descobri que o filtro de query estava usando o código do idioma (pt_BR) em vez do slug da taxonomia (pt-br) na query de taxonomia. Este código:
// Errado - pt_BR não bate com o slug do termo de taxonomia
$tax_query[] = [
'taxonomy' => 'cm_language',
'field' => 'slug',
'terms' => [$language_code], // pt_BR
];
Deveria ter sido:
// Certo - resolve o slug primeiro
$language_term = get_term_by('slug', $language_slug, 'cm_language');
$tax_query[] = [
'taxonomy' => 'cm_language',
'field' => 'term_id',
'terms' => [$language_term->term_id],
];
Um bug sutil que só apareceu com códigos de idioma não-ASCII como pt_BR (slug: pt-br). Inglês funcionou bem porque o código e slug eram ambos en.
A maior lição veio depois, durante a configuração de produção. Nossa homepage usa blocos Query Loop do WordPress com filtro de categoria (taxQuery: {"category": [21]}). Meu plugin estava substituindo todo o tax_query com o filtro de idioma – apagando o filtro de categoria do bloco. Posts do Dev Weekly começaram a aparecer na homepage porque a exclusão de categoria sumiu.
A correção: fazer merge no tax_query existente, nunca substituir:
// Pega tax_query existente e faz merge do nosso filtro
$existing_tax_query = $query->get('tax_query') ?: [];
$existing_tax_query[] = $language_filter;
$query->set('tax_query', $existing_tax_query);
Este é o tipo de bug que você só encontra num site real com plugins reais e configurações de bloco reais. Testes contra uma instalação WordPress limpa nunca pegariam isso.
A Suite de Testes
Depois de todas as correções de produção, os números finais:
| Métrica | Contagem |
|---|---|
| Arquivos de teste | 12 |
| Métodos de teste | 156 |
| Asserções | 323 |
| Cobertura de linha | 57% (903/1.581 linhas) |
| Cobertura de método | 56% (69/124 métodos) |
Cobertura por classe:
| Classe | Linhas | Métodos |
|---|---|---|
| CM_Translations | 97.6% | 81.8% |
| CM_Provider_Ollama | 98.0% | 83.3% |
| CM_Provider_Anthropic | 97.7% | 75.0% |
| CM_Provider_Gemini | 97.8% | 75.0% |
| CM_Translation_Provider | 96.6% | 66.7% |
| CM_REST_API | 89.8% | 66.7% |
| CM_Query | 80.8% | 62.5% |
| CM_Languages | 100% | 100% |
| CM_Language | 100% | 100% |
| CM_Switcher | 70.0% | 88.9% |
| CM_Links | 45.6% | 42.9% |
| CM_Meta_Box | 57.8% | 16.7% |
| CM_Admin | 10.2% | 16.7% |
| CM_Multilingual | 6.5% | 25.0% |
A lógica central (traduções, providers, filtragem de query, REST API) está bem coberta. As classes de UI admin e orquestrador são menores porque dependem muito de hooks admin do WordPress que são difíceis de testar unitariamente. Os providers estão em ~98% porque chamadas HTTP são mockadas via filtro pre_http_request do WordPress – testamos a construção de requisição e parsing de resposta sem bater nas APIs reais.
O Fluxo de Tradução IA
Aqui está como a tradução funciona:

- Autor clica “Traduzir com IA” num post inglês
- Plugin adquire um lock (baseado em transient, TTL de 5 min) para prevenir traduções duplicadas
- Plugin envia título e conteúdo separadamente para o provider IA configurado
- Provider retorna as traduções
- Plugin cria um novo post rascunho com o conteúdo traduzido
- Posts são vinculados bidirecionalmente via UUID
_cm_translation_group - Categorias, tags, co-autores (Co-Authors Plus) e imagem destacada são copiados
O prompt importa mais que o provider que você escolhe. Aqui está o que realmente enviamos:
Você é um tradutor profissional. Traduza o seguinte texto
de {origem} para {destino}. Preserve todas as tags HTML, formatação
markdown e blocos de código exatamente como estão. Retorne apenas o
texto traduzido, sem explicações ou observações.
CRÍTICO: A tradução deve ler como se tivesse sido originalmente escrita
em {destino} por um falante nativo, não como um texto traduzido.
Essa é a versão curta. O prompt completo também tem regras de formatação: não adicione travessões que não estavam no original, não use aspas curvas, e preserve a capitalização de títulos. E uma lista de regras de linguagem que lê como um checklist anti-escrita-IA: não infle importância (“é” continua “é”, não “serve como” ou “representa”), não adicione frases de significância (“testemunho de”, “ressalta”), não use linguagem promocional (“vibrante”, “inovador”), não force a regra dos três, não alterne sinônimos.
As regras de formatação existem porque somos um blog de tecnologia. Nossos posts têm blocos de código, backticks inline, headers Markdown, embeds HTML. Sem essas instruções, providers IA vão “ajudar” convertendo seu Markdown para HTML, mesclando seus blocos de código em parágrafos, ou removendo backticks de código inline. Uma tradução ruim quebra cada exemplo de código num post de 2000 palavras.
As regras de linguagem resolvem um problema diferente. Tradução IA tende a produzir texto que lê como… tradução IA. Infla, ameniza e pule. Um post que diz “isso está quebrado” vira “isso representa um desafio significativo”. Essas regras impedem que a versão traduzida se afaste do que o autor realmente escreveu.
O Que o Claude Errou
Prometi uma história sem filtro, então aqui está o que não funcionou.
O bug do slug da taxonomia. Claude usou o código do idioma (pt_BR) em queries de taxonomia em vez do slug (pt-br). Os testes passaram porque usaram códigos simples onde o código e slug eram iguais. Só quebrou com português. Este é o tipo de caso limite que só aparece no mundo real, e é a razão de fazer deploy num site real cedo.
Substituir tax_query em vez de fazer merge. A primeira implementação do Claude de filter_by_language() fazia $query->set('tax_query', $new_array) – substituindo todo o tax_query. Numa instalação wp-env limpa, isso funciona bem. Em produção, onde a homepage usa blocos Query Loop com filtros de categoria, apagou a configuração do bloco. Posts do Dev Weekly começaram a aparecer em todo lugar. A correção foi uma mudança de uma linha (merge em vez de substituir), mas encontrá-la exigiu inspecionar o estado interno do editor de bloco no site vivo.
Tags meta OG. A primeira abordagem do Claude foi gerar nossas próprias tags OG no wp_head. Mas o AIOSEO gera suas tags depois, e crawlers sociais usam a última ocorrência. Tags duplicadas, dados errados. A segunda abordagem usou hooks de filtro do AIOSEO, que funcionou para título e descrição mas não para og:image (não existe filtro para isso). A terceira abordagem – output buffering no template_redirect para encontrar-e-substituir a URL da imagem no HTML cru – finalmente funcionou. Três iterações para acertar.
O padrão é consistente: Claude escreve código correto para o ambiente de teste, mas o ambiente de teste é limpo demais. Produção tem camadas de cache, plugins SEO, blocos Query Loop, cookies, CDN. Cada bug de produção veio da lacuna entre wp-env e o site real.
O Log do Git
2 dias para construir, 1 dia para fazer deploy em produção:
10 março - Commit inicial: Arquitetura central, 90 testes
11 março - Suporte ao Editor Clássico, abstrações de provider
20 março - Deploy de produção: correção de slug, merge tax_query, tags OG
10-11 de março foi a construção. 20 de março foi o dia de deploy de produção – suporte ao Editor Clássico, o bug do slug, o merge tax_query e as tags OG. O gap no meio foi intencional: esperei até ter um caso de uso real (uma tradução em português para publicar) antes de fazer deploy.
Principais Aprendizados
Faça deploy em produção cedo. wp-env é ótimo para TDD mas é limpo demais. Os bugs que importam só aparecem num site real com plugins reais, temas reais e cache real.
Faça merge, não substitua. Se seu plugin toca em tax_query, meta_query ou qualquer parâmetro de query WordPress, sempre pegue o valor existente primeiro e adicione a ele. Outros plugins e blocos dependem desses parâmetros.
Teste com curl, não apenas o browser. Crawlers sociais, motores de busca e proxies de cache veem HTML diferente do seu browser. curl -s URL -H 'User-Agent: facebookexternalhit/1.1' é seu amigo.
TDD funciona com IA. Escreva o teste primeiro, descreva o comportamento esperado, deixe o Claude implementar. Quando algo quebra em produção, escreva um teste que reproduza antes de corrigir. A suite de testes é sua rede de segurança para o próximo refactor.
Cada post novo recebe uma tradução em português (inclusive este que você está lendo). Não estamos fazendo traduções em massa ainda – a equipe do blog está usando em posts novos primeiro, verificando se o plugin se sente certo e se as traduções são realmente boas antes de voltarmos e traduzirmos o catálogo. Assim que tiverem confiança, vamos pegar os posts mais recentes e trabalhar para trás.
156 testes, dois idiomas, zero tabelas personalizadas. Aqui está o mais recente.
Obrigado pela leitura!
We want to work with you. Check out our Services page!

