feat(i18n): Spanish localization (phase 4)
CI / Lint e auditorias (push) Successful in 1m31s
CI / Trivy (dependências e segredos) (push) Successful in 10s
CI / Imagem Docker (linux/arm64) (push) Successful in 2m23s

Complete implementation of Spanish (es-419) language support:
- Canonical catalogs and data: messages/es.json, data/es/**, lib/data/es/**, public/exemplos/es/**
- Canonical slugs: /es/series, /es/catalogo, /es/comparacion, /es/componentes, /es/diagnostico, /es/frameworks, /es/glosario, /es/selector, /es/acerca
- Dedicated search index at /api/busca/es with Spanish accent normalization
- Strict exclusion of book references in Spanish routes and catalogs
- Language selector and root negotiation with es active (ENABLED_LOCALES = ['pt', 'en', 'es'])
- Extended verification suite: check-i18n, smoke-imagem and verifica-vazamentos

Co-Authored-By: Antigravity <noreply@google.com>
This commit is contained in:
Lucas RayanandAntigravity committed 2026-11-01 19:50:20 -03:00
1 parent 0940814522
commit 660b28c09d
72 files changed
+17848 -947

No files matched your search

+53 -18
View File
@@ -33,9 +33,35 @@ const VALUE_MAP_EN = {
"Opcional": "Optional",
};
const HEADER_MAP_ES = {
"Nome": "Nombre",
"Descrição": "Descripción",
"Série": "Serie",
"Loja Oficial": "Tienda oficial",
"Fabricante": "Fabricante",
"Processador": "Procesador",
"Memória Flash": "Memoria flash",
"PSRAM": "PSRAM",
"Conector USB": "Conector USB",
"Interface USB": "Interfaz USB",
"Antena": "Antena",
"Bateria": "Batería",
"Display": "Pantalla",
"Sensores": "Sensores",
"Botões": "Botones",
"LEDs": "LEDs",
};
const VALUE_MAP_ES = {
"Sim": "Sí",
"Não": "No",
"Opcional": "Opcional",
};
export default function PlacasPage() {
const pathname = usePathname() || "";
const isEn = pathname === "/en" || pathname.startsWith("/en/");
const isEs = pathname === "/es" || pathname.startsWith("/es/");
const [boards, setBoards] = useState([]);
const [filteredBoards, setFilteredBoards] = useState([]);
@@ -64,7 +90,7 @@ export default function PlacasPage() {
const response = await fetch("/api/placas");
if (!response.ok) {
throw new Error(isEn ? "Failed to load board data" : "Erro ao carregar dados das placas");
throw new Error(isEn ? "Failed to load board data" : isEs ? "Error al cargar los datos de las placas" : "Erro ao carregar dados das placas");
}
const result = await response.json();
@@ -168,13 +194,16 @@ export default function PlacasPage() {
const hero = (
<PageHero
isEn={isEn}
trail={[{ label: isEn ? "Board Catalog" : "Catálogo de placas" }]}
isEs={isEs}
trail={[{ label: isEn ? "Board Catalog" : isEs ? "Catálogo de placas" : "Catálogo de placas" }]}
icon={ShoppingCart}
label={isEn ? "Real DevKits and modules" : "DevKits e módulos reais"}
title={isEn ? "ESP32 Board Catalog" : "Catálogo de placas ESP32"}
label={isEn ? "Real DevKits and modules" : isEs ? "DevKits y módulos reales" : "DevKits e módulos reais"}
title={isEn ? "ESP32 Board Catalog" : isEs ? "Catálogo de placas ESP32" : "Catálogo de placas ESP32"}
lede={
isEn
? "Compare official and third-party boards for your project: USB interface, on-board sensors and direct store links."
: isEs
? "Compare placas oficiales y alternativas para su proyecto: interfaz USB, sensores integrados y enlace directo a la tienda."
: "Compare placas oficiais e alternativas para o seu projeto: interface USB, sensores integrados e link direto para a loja."
}
/>
@@ -186,14 +215,14 @@ export default function PlacasPage() {
<Header />
<main id="conteudo">
{hero}
<section className="shell section pt-10" aria-busy="true" aria-label={isEn ? "Loading boards" : "Carregando placas"}>
<section className="shell section pt-10" aria-busy="true" aria-label={isEn ? "Loading boards" : isEs ? "Cargando placas" : "Carregando placas"}>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="card h-72 animate-pulse bg-surface-2" />
))}
</div>
<p role="status" className="mt-6 text-center text-sm text-muted">
{isEn ? "Querying boards database…" : "Consultando o banco de placas…"}
{isEn ? "Querying boards database…" : isEs ? "Consultando la base de datos de placas…" : "Consultando o banco de placas…"}
</p>
</section>
</main>
@@ -213,14 +242,14 @@ export default function PlacasPage() {
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-danger" aria-hidden="true" />
<div>
<p className="font-semibold text-ink">
{isEn ? "Could not load boards" : "Não foi possível carregar as placas"}
{isEn ? "Could not load boards" : isEs ? "No se pudieron cargar las placas" : "Não foi possível carregar as placas"}
</p>
<p className="mt-1">
{error}. {isEn ? "Check your connection and try again." : "Verifique a conexão e tente de novo."}
{error}. {isEn ? "Check your connection and try again." : isEs ? "Compruebe la conexión y vuelva a intentarlo." : "Verifique a conexão e tente de novo."}
</p>
<button type="button" onClick={fetchBoards} className="btn btn-primary btn-sm mt-4">
<RefreshCw className="h-4 w-4" aria-hidden="true" />
{isEn ? "Try again" : "Tentar novamente"}
{isEn ? "Try again" : isEs ? "Reintentar" : "Tentar novamente"}
</button>
</div>
</div>
@@ -235,14 +264,14 @@ export default function PlacasPage() {
<div className="space-y-6">
<div>
<label htmlFor="busca-placa" className="text-sm font-semibold text-ink">
{isEn ? "Search board" : "Buscar placa"}
{isEn ? "Search board" : isEs ? "Buscar placa" : "Buscar placa"}
</label>
<div className="relative mt-2">
<Search className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2 text-muted" aria-hidden="true" />
<input
id="busca-placa"
type="search"
placeholder={isEn ? "Name or description" : "Nome ou descrição"}
placeholder={isEn ? "Name or description" : isEs ? "Nombre o descripción" : "Nome ou descrição"}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="field pl-10 text-sm"
@@ -253,7 +282,7 @@ export default function PlacasPage() {
{filterableColumns.map((column) => {
const filterData = filterOptions[column];
if (!filterData) return null;
const colLabel = isEn ? (HEADER_MAP_EN[column] || column) : column;
const colLabel = isEn ? (HEADER_MAP_EN[column] || column) : isEs ? (HEADER_MAP_ES[column] || column) : column;
if (filterData.isBinary) {
return (
@@ -277,7 +306,7 @@ export default function PlacasPage() {
</legend>
<div className="mt-2 max-h-44 space-y-0.5 overflow-y-auto pr-1">
{filterData.values.map((option) => {
const optLabel = isEn ? (VALUE_MAP_EN[option] || option) : option;
const optLabel = isEn ? (VALUE_MAP_EN[option] || option) : isEs ? (VALUE_MAP_ES[option] || option) : option;
return (
<label key={option} className="flex min-h-10 cursor-pointer items-center gap-3 rounded-lg px-2 text-sm text-muted transition-colors hover:bg-surface-2 hover:text-ink">
<input
@@ -304,14 +333,14 @@ export default function PlacasPage() {
<main id="conteudo">
{hero}
<section className="shell section pt-10 sm:pt-12" aria-label={isEn ? "Boards" : "Placas"}>
<section className="shell section pt-10 sm:pt-12" aria-label={isEn ? "Boards" : isEs ? "Placas" : "Placas"}>
<div className="grid gap-8 lg:grid-cols-[17rem_1fr] lg:gap-10">
<aside className="lg:sticky lg:top-28 lg:self-start">
<details className="panel" open={filtrosAbertos} onToggle={(e) => setFiltrosAbertos(e.currentTarget.open)}>
<summary className="flex min-h-12 cursor-pointer list-none items-center justify-between gap-3 px-5 py-3 lg:cursor-default">
<span className="flex items-center gap-2 text-sm font-semibold text-ink">
<SlidersHorizontal className="h-4 w-4 text-accent" aria-hidden="true" />
{isEn ? "Filters" : "Filtros"}
{isEn ? "Filters" : isEs ? "Filtros" : "Filtros"}
{activeFilterCount > 0 && <span className="chip chip-accent px-2 py-0">{activeFilterCount}</span>}
</span>
{activeFilterCount > 0 && (
@@ -323,7 +352,7 @@ export default function PlacasPage() {
}}
className="text-sm font-medium text-accent hover:text-accent-strong"
>
{isEn ? "Clear" : "Limpar"}
{isEn ? "Clear" : isEs ? "Limpiar" : "Limpar"}
</button>
)}
</summary>
@@ -338,6 +367,10 @@ export default function PlacasPage() {
? filteredBoards.length === 1
? "board found"
: "boards found"
: isEs
? filteredBoards.length === 1
? "placa encontrada"
: "placas encontradas"
: filteredBoards.length === 1
? "placa encontrada"
: "placas encontradas"}
@@ -347,15 +380,17 @@ export default function PlacasPage() {
<div className="card mt-4 px-6 py-14 text-center">
<Search className="mx-auto h-10 w-10 text-muted" aria-hidden="true" />
<h2 className="mt-4 font-display text-xl font-semibold text-ink">
{isEn ? "No boards match these filters" : "Nenhuma placa com esses filtros"}
{isEn ? "No boards match these filters" : isEs ? "Ninguna placa coincide con estos filtros" : "Nenhuma placa com esses filtros"}
</h2>
<p className="mx-auto mt-2 max-w-md text-sm text-muted">
{isEn
? "Remove some filters or search by another name."
: isEs
? "Elimine algunos filtros o busque por otro nombre."
: "Remova alguns filtros ou busque por outro nome."}
</p>
<button type="button" onClick={clearFilters} className="btn btn-primary mt-6">
{isEn ? "Clear filters" : "Limpar filtros"}
{isEn ? "Clear filters" : isEs ? "Limpiar filtros" : "Limpar filtros"}
</button>
</div>
) : (