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

+26 -6
View File
@@ -22,9 +22,29 @@ const HEADER_MAP_EN = {
"LEDs": "LEDs",
};
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",
};
export default function BoardCard({ board }) {
const pathname = usePathname() || "";
const isEn = pathname === "/en" || pathname.startsWith("/en/");
const isEs = pathname === "/es" || pathname.startsWith("/es/");
const mainFields = ["Nome", "Link", "Imagem", "Loja Oficial"];
@@ -47,21 +67,21 @@ export default function BoardCard({ board }) {
{lojaOficial && (
<span className="chip chip-accent mb-3 w-fit">
<Award className="h-3.5 w-3.5" aria-hidden="true" />
{isEn ? "Official store" : "Loja oficial"}
{isEn ? "Official store" : isEs ? "Tienda oficial" : "Loja oficial"}
</span>
)}
<h3 className="line-clamp-2 font-display text-lg font-semibold leading-snug tracking-[-0.02em] text-ink">
{board.Nome || (isEn ? "Generic board" : "Placa genérica")}
{board.Nome || (isEn ? "Generic board" : isEs ? "Placa genérica" : "Placa genérica")}
</h3>
{recursos.length > 0 && (
<div className="mt-4">
<p className="text-xs font-medium text-muted">{isEn ? "On-board features" : "Recursos embarcados"}</p>
<p className="text-xs font-medium text-muted">{isEn ? "On-board features" : isEs ? "Recursos integrados" : "Recursos embarcados"}</p>
<ul className="mt-2 flex flex-wrap gap-1.5">
{recursos.map((campo) => {
const opcional = valor(campo) === "opcional";
const label = isEn ? (HEADER_MAP_EN[campo] || campo) : campo;
const label = isEn ? (HEADER_MAP_EN[campo] || campo) : isEs ? (HEADER_MAP_ES[campo] || campo) : campo;
return (
<li key={campo} className={`chip ${opcional ? "text-gold" : "text-ink-soft"}`}>
{opcional ? (
@@ -80,9 +100,9 @@ export default function BoardCard({ board }) {
{board.Link && (
<a href={board.Link} target="_blank" rel="noopener noreferrer" className="btn btn-primary mt-6 w-full">
{isEn ? "View on AliExpress" : "Ver no AliExpress"}
{isEn ? "View on AliExpress" : isEs ? "Ver en AliExpress" : "Ver no AliExpress"}
<ArrowUpRight className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">{isEn ? " (opens in new tab)" : " (abre em nova aba)"}</span>
<span className="sr-only">{isEn ? " (opens in new tab)" : isEs ? " (abre en una pestaña nueva)" : " (abre em nova aba)"}</span>
</a>
)}
</div>
+29 -17
View File
@@ -7,6 +7,8 @@ import { usePathname } from "next/navigation";
export default function CodeExample({ framework }) {
const pathname = usePathname() || "";
const isEn = pathname === "/en" || pathname.startsWith("/en/");
const isEs = pathname === "/es" || pathname.startsWith("/es/");
const loc = isEn ? "en" : isEs ? "es" : "pt";
const examplesUrl = framework?.exemplos;
@@ -27,15 +29,17 @@ export default function CodeExample({ framework }) {
setSample(
isEn
? `// Full examples: ${examplesUrl || "see repository"}\n`
: `// Exemplos completos: ${examplesUrl || "ver repositório"}\n`
: isEs
? `// Ejemplos completos: ${examplesUrl || "ver repositorio"}\n`
: `// Exemplos completos: ${examplesUrl || "ver repositório"}\n`
);
return;
}
// Se locale for en, busca primeiro em /exemplos/en/
// Se locale for diferente de pt, busca primeiro em /exemplos/<loc>/
let url = exemploPath.startsWith("/") ? exemploPath : `/${exemploPath}`;
if (isEn && url.startsWith("/exemplos/") && !url.startsWith("/exemplos/en/")) {
url = url.replace("/exemplos/", "/exemplos/en/");
if (loc !== "pt" && url.startsWith("/exemplos/") && !url.startsWith(`/exemplos/${loc}/`)) {
url = url.replace("/exemplos/", `/exemplos/${loc}/`);
}
let cancelled = false;
@@ -64,9 +68,11 @@ export default function CodeExample({ framework }) {
setSample(
isEn
? "// The example file is empty. Refer to the official repository.\n"
: "// O arquivo de exemplo está vazio. Consulte o repositório oficial.\n"
: isEs
? "// El archivo de ejemplo está vacío. Consulte el repositorio oficial.\n"
: "// O arquivo de exemplo está vazio. Consulte o repositório oficial.\n"
);
setError(isEn ? "empty file" : "arquivo vazio");
setError(isEn ? "empty file" : isEs ? "archivo vacío" : "arquivo vazio");
}
})
.catch((err) => {
@@ -74,9 +80,11 @@ export default function CodeExample({ framework }) {
setSample(
isEn
? "// Could not load example.\n// Refer to official repository for complete examples.\n"
: "// Não foi possível carregar o exemplo.\n// Consulte o repositório oficial para exemplos completos.\n"
: isEs
? "// No fue posible cargar el ejemplo.\n// Consulte el repositorio oficial para ver los ejemplos completos.\n"
: "// Não foi possível carregar o exemplo.\n// Consulte o repositório oficial para exemplos completos.\n"
);
setError(err.message || (isEn ? "load failure" : "falha ao carregar"));
setError(err.message || (isEn ? "load failure" : isEs ? "fallo al cargar" : "falha ao carregar"));
})
.finally(() => {
if (!cancelled) setLoading(false);
@@ -85,7 +93,7 @@ export default function CodeExample({ framework }) {
return () => {
cancelled = true;
};
}, [framework?.exemplo, framework?.exemplos, isEn]);
}, [framework?.exemplo, framework?.exemplos, isEn, isEs, loc]);
const lines = (sample || "").replace(/\n$/, "").split("\n");
@@ -102,25 +110,27 @@ export default function CodeExample({ framework }) {
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h2 className="font-display text-xl font-semibold tracking-[-0.03em] text-ink">
{isEn ? "Code example" : "Exemplo de código"}
{isEn ? "Code example" : isEs ? "Ejemplo de código" : "Exemplo de código"}
</h2>
<p className="mt-1 text-sm text-muted">
{isEn
? `Starter snippet in ${framework?.linguagem}. Adjust pins and configuration for your board.`
: `Trecho de partida em ${framework?.linguagem}. Ajuste pinos e configuração à sua placa.`}
: isEs
? `Fragmento de inicio en ${framework?.linguagem}. Ajuste los pines y la configuración para su placa.`
: `Trecho de partida em ${framework?.linguagem}. Ajuste pinos e configuração à sua placa.`}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<button type="button" onClick={handleCopy} className="btn btn-ghost btn-sm">
{copied ? <Check className="h-4 w-4 text-ok" aria-hidden="true" /> : <Copy className="h-4 w-4" aria-hidden="true" />}
<span role="status">{copied ? (isEn ? "Copied" : "Copiado") : (isEn ? "Copy" : "Copiar")}</span>
<span role="status">{copied ? (isEn ? "Copied" : isEs ? "Copiado" : "Copiado") : (isEn ? "Copy" : isEs ? "Copiar" : "Copiar")}</span>
</button>
{examplesUrl && (
<a href={examplesUrl} target="_blank" rel="noopener noreferrer" className="btn btn-ghost btn-sm">
{isEn ? "More examples" : "Mais exemplos"}
{isEn ? "More examples" : isEs ? "Más ejemplos" : "Mais exemplos"}
<ExternalLink className="h-4 w-4" aria-hidden="true" />
<span className="sr-only">{isEn ? " (opens in new tab)" : " (abre em nova aba)"}</span>
<span className="sr-only">{isEn ? " (opens in new tab)" : isEs ? " (abre en una pestaña nueva)" : " (abre em nova aba)"}</span>
</a>
)}
</div>
@@ -129,9 +139,9 @@ export default function CodeExample({ framework }) {
<div className="code-panel mt-5" aria-busy={loading}>
<div className="flex items-center justify-between border-b border-white/10 px-4 py-2.5 text-xs text-[#a6afbd]">
<span className="font-mono">{framework?.nome}</span>
<span>{loading ? (isEn ? "Loading example…" : "Carregando exemplo…") : framework?.linguagem}</span>
<span>{loading ? (isEn ? "Loading example…" : isEs ? "Cargando ejemplo…" : "Carregando exemplo…") : framework?.linguagem}</span>
</div>
<pre tabIndex={0} aria-label={isEn ? `Code example in ${framework?.linguagem}` : `Código de exemplo em ${framework?.linguagem}`}>
<pre tabIndex={0} aria-label={isEn ? `Code example in ${framework?.linguagem}` : isEs ? `Código de ejemplo en ${framework?.linguagem}` : `Código de exemplo em ${framework?.linguagem}`}>
<code>
{lines.map((line, idx) => (
<span key={idx} className="flex">
@@ -147,7 +157,9 @@ export default function CodeExample({ framework }) {
<p role="alert" className="callout callout-warn mt-4">
{isEn
? `Could not load local example (${error}). Use “More examples” to view official code.`
: `Não foi possível carregar o exemplo local (${error}). Use “Mais exemplos” para ver o código oficial.`}
: isEs
? `No se pudo cargar el ejemplo local (${error}). Utilice “Más ejemplos” para ver el código oficial.`
: `Não foi possível carregar o exemplo local (${error}). Use “Mais exemplos” para ver o código oficial.`}
</p>
)}
</div>
+72 -35
View File
@@ -56,6 +56,29 @@ const FILTER_CATEGORIES_EN = [
{ id: "Antena", name: "Antenna", color: "#06b6d4" },
];
const FILTER_CATEGORIES_ES = [
{ id: "All", name: "Todos", color: "#8b5cf6" },
{ id: "GND", name: "Tierra (GND)", color: "#64748b" },
{ id: "Power", name: "Alimentación", color: "#ef4444" },
{ id: "GPIO", name: "GPIO", color: "#22c55e" },
{ id: "Low Power", name: "GPIO LP", color: "#f59e0b" },
{ id: "ADC", name: "ADC", color: "#ec4899" },
{ id: "DAC", name: "DAC", color: "#a855f7" },
{ id: "Touch", name: "Touch", color: "#06b6d4" },
{ id: "UART", name: "UART", color: "#3b82f6" },
{ id: "SPI", name: "SPI", color: "#f43f5e" },
{ id: "USB", name: "USB", color: "#a855f7" },
{ id: "Clock", name: "Reloj", color: "#eab308" },
{ id: "JTAG", name: "JTAG", color: "#6366f1" },
{ id: "Strapping", name: "Arranque (Boot)", color: "#dc2626" },
{ id: "DSI", name: "Pantalla (DSI)", color: "#f97316" },
{ id: "CSI", name: "Cámara (CSI)", color: "#14b8a6" },
{ id: "Ethernet", name: "Ethernet", color: "#0284c7" },
{ id: "SD", name: "Tarjeta SD", color: "#10b981" },
{ id: "I2C", name: "I2C", color: "#f59e0b" },
{ id: "Antena", name: "Antena", color: "#06b6d4" },
];
const type_colors = {
power: "#ef4444", // high-voltage red
ground: "#64748b", // slate gray
@@ -82,6 +105,7 @@ function ConnectionsDiagramInner({ connections, serie }) {
const params = useSearchParams();
const pathname = usePathname();
const isEn = pathname?.startsWith("/en");
const isEs = pathname?.startsWith("/es");
const router = useRouter();
const pinosFiltro = useMemo(() => new Set((params.get("pinos") ?? "").split(",").filter(Boolean)), [params]);
const motivoFiltro = params.get("motivo") ?? "";
@@ -93,7 +117,7 @@ function ConnectionsDiagramInner({ connections, serie }) {
const dialogRef = useRef(null);
const gatilhoRef = useRef(null);
const filterCategories = isEn ? FILTER_CATEGORIES_EN : FILTER_CATEGORIES;
const filterCategories = isEn ? FILTER_CATEGORIES_EN : isEs ? FILTER_CATEGORIES_ES : FILTER_CATEGORIES;
useEffect(() => {
setMounted(true);
@@ -115,11 +139,15 @@ function ConnectionsDiagramInner({ connections, serie }) {
};
const nomeCategoria = (cat) => {
if (cat === "Strapping") return isEn ? "Boot mode" : "Inicialização";
if (cat === "Strapping") return isEn ? "Boot mode" : isEs ? "Arranque (Boot)" : "Inicialização";
if (isEn) {
const found = FILTER_CATEGORIES_EN.find((f) => f.id === cat);
if (found) return found.name;
}
if (isEs) {
const found = FILTER_CATEGORIES_ES.find((f) => f.id === cat);
if (found) return found.name;
}
return cat;
};
@@ -176,10 +204,10 @@ function ConnectionsDiagramInner({ connections, serie }) {
<div className="px-4 py-14 text-center">
<Sliders className="mx-auto h-10 w-10 text-muted" aria-hidden="true" />
<p className="mt-4 font-display text-xl font-semibold text-ink">
{isEn ? "Pinout diagram unavailable" : "Diagrama de conexões indisponível"}
{isEn ? "Pinout diagram unavailable" : isEs ? "Diagrama de conexiones no disponible" : "Diagrama de conexões indisponível"}
</p>
<p className="mt-1 text-sm text-muted">
{isEn ? "This model does not yet have a detailed pinout and bus map." : "Este modelo ainda não tem um mapa detalhado de conexões e barramentos."}
{isEn ? "This model does not yet have a detailed pinout and bus map." : isEs ? "Este modelo todavía no dispone de un mapa detallado de conexiones y buses." : "Este modelo ainda não tem um mapa detalhado de conexões e barramentos."}
</p>
</div>
);
@@ -191,11 +219,13 @@ function ConnectionsDiagramInner({ connections, serie }) {
<div className="space-y-6">
<div>
<h2 className="font-display text-xl font-semibold tracking-[-0.03em] text-ink">
{isEn ? "Interactive pinout diagram" : "Diagrama de conexões interativo"}
{isEn ? "Interactive pinout diagram" : isEs ? "Diagrama de conexiones interactivo" : "Diagrama de conexões interativo"}
</h2>
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-muted">
{isEn ? (
`All connections and multiplexing for the ${serie.nome_completo}. Search by name or number, filter by function, and open a pin to see details.`
) : isEs ? (
`Todas las conexiones y la multiplexación del ${serie.nome_completo}. Busque por nombre o número, filtre por función y abra un pin para ver los detalles.`
) : (
`Todas as conexões e a multiplexação do ${serie.nome_completo}. Busque por nome ou número, filtre por função e abra um pino para ver os detalhes.`
)}
@@ -205,11 +235,11 @@ function ConnectionsDiagramInner({ connections, serie }) {
{pinosFiltro.size > 0 && (
<div role="status" className="callout flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-ink">
<strong className="font-semibold">{isEn ? "Filtered by search:" : "Filtrado pela busca:"}</strong> {motivoFiltro || (isEn ? "selected pins" : "pinos selecionados")} ({pinosFiltro.size}{" "}
{pinosFiltro.size === 1 ? (isEn ? "pin" : "pino") : (isEn ? "pins" : "pinos")})
<strong className="font-semibold">{isEn ? "Filtered by search:" : isEs ? "Filtrado por la búsqueda:" : "Filtrado pela busca:"}</strong> {motivoFiltro || (isEn ? "selected pins" : isEs ? "pines seleccionados" : "pinos selecionados")} ({pinosFiltro.size}{" "}
{pinosFiltro.size === 1 ? (isEn ? "pin" : isEs ? "pin" : "pino") : (isEn ? "pins" : isEs ? "pines" : "pinos")})
</p>
<button type="button" onClick={mostrarTodos} className="btn btn-ghost btn-sm">
{isEn ? "Show all pins" : "Mostrar todos os pinos"}
{isEn ? "Show all pins" : isEs ? "Mostrar todos los pines" : "Mostrar todos os pinos"}
</button>
</div>
)}
@@ -217,43 +247,45 @@ function ConnectionsDiagramInner({ connections, serie }) {
<div className="flex flex-col gap-3 sm:flex-row">
<div className="relative flex-1">
<label htmlFor="busca-pino" className="sr-only">
{isEn ? "Search connection by name or number" : "Buscar conexão por nome ou número"}
{isEn ? "Search connection by name or number" : isEs ? "Buscar conexión por nombre o número" : "Buscar conexão por nome ou número"}
</label>
<Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted" aria-hidden="true" />
<input
id="busca-pino"
type="search"
placeholder={isEn ? "Search pin (e.g.: GPIO1, TXD, XTAL)" : "Buscar pino (ex.: GPIO1, TXD, XTAL)"}
placeholder={isEn ? "Search pin (e.g.: GPIO1, TXD, XTAL)" : isEs ? "Buscar pin (ej.: GPIO1, TXD, XTAL)" : "Buscar pino (ex.: GPIO1, TXD, XTAL)"}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
autoComplete="off"
className="field pl-11 pr-4 text-sm"
/>
</div>
<div role="group" aria-label={isEn ? "Display mode" : "Modo de exibição"} className="flex gap-2">
<div role="group" aria-label={isEn ? "Display mode" : isEs ? "Modo de visualización" : "Modo de exibição"} className="flex gap-2">
<button type="button" onClick={() => setViewMode("grid")} aria-pressed={viewMode === "grid"} className="chip-btn">
<Grid className="h-4 w-4" aria-hidden="true" />
{isEn ? "Grid" : "Grade"}
{isEn ? "Grid" : isEs ? "Cuadrícula" : "Grade"}
</button>
<button type="button" onClick={() => setViewMode("list")} aria-pressed={viewMode === "list"} className="chip-btn">
<List className="h-4 w-4" aria-hidden="true" />
{isEn ? "List" : "Lista"}
{isEn ? "List" : isEs ? "Lista" : "Lista"}
</button>
</div>
</div>
<div>
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-semibold text-ink">{isEn ? "Pin function" : "Função do pino"}</h3>
<h3 className="text-sm font-semibold text-ink">{isEn ? "Pin function" : isEs ? "Función del pin" : "Função do pino"}</h3>
<p role="status" aria-live="polite" className="text-sm text-muted">
{isEn ? (
`${visibleConnections.length} of ${connections.length} pins`
) : isEs ? (
`${visibleConnections.length} de ${connections.length} pines`
) : (
`${visibleConnections.length} de ${connections.length} pinos`
)}
</p>
</div>
<div role="group" aria-label={isEn ? "Filter by function" : "Filtrar por função"} className="mt-3 flex flex-wrap gap-2">
<div role="group" aria-label={isEn ? "Filter by function" : isEs ? "Filtrar por función" : "Filtrar por função"} className="mt-3 flex flex-wrap gap-2">
{filterCategories.map((filter) => {
const isActive = activeFilter === filter.id;
const count = connections.filter((c) => (filter.id === "All" ? true : c.categorias.includes(filter.id))).length;
@@ -272,10 +304,10 @@ function ConnectionsDiagramInner({ connections, serie }) {
{visibleConnections.length === 0 ? (
<div className="rounded-2xl border border-dashed border-line-strong px-6 py-12 text-center">
<Search className="mx-auto h-8 w-8 text-muted" aria-hidden="true" />
<p className="mt-3 font-semibold text-ink">{isEn ? "No connections found" : "Nenhuma conexão encontrada"}</p>
<p className="mt-1 text-sm text-muted">{isEn ? "Adjust your search or return to all functions." : "Ajuste a busca ou volte para todas as funções."}</p>
<p className="mt-3 font-semibold text-ink">{isEn ? "No connections found" : isEs ? "No se encontraron conexiones" : "Nenhuma conexão encontrada"}</p>
<p className="mt-1 text-sm text-muted">{isEn ? "Adjust your search or return to all functions." : isEs ? "Ajuste la búsqueda o vuelva a todas las funciones." : "Ajuste a busca ou volte para todas as funções."}</p>
<button type="button" onClick={() => { setActiveFilter("All"); setSearchQuery(""); if (pinosFiltro.size) mostrarTodos(); }} className="btn btn-primary btn-sm mt-5">
Limpar busca e filtros
{isEn ? "Clear search and filters" : isEs ? "Limpiar búsqueda y filtros" : "Limpar busca e filtros"}
</button>
</div>
) : viewMode === "grid" ? (
@@ -288,7 +320,7 @@ function ConnectionsDiagramInner({ connections, serie }) {
type="button"
onClick={(e) => abrir(connection, e)}
aria-haspopup="dialog"
aria-label={isEn ? `Pin ${connection.nome}, number ${connection.numero}${connection.avisos ? ", with usage restrictions" : ""}` : `Pino ${connection.nome}, número ${connection.numero}${connection.avisos ? ", com restrições de uso" : ""}`}
aria-label={isEn ? `Pin ${connection.nome}, number ${connection.numero}${connection.avisos ? ", with usage restrictions" : ""}` : isEs ? `Pin ${connection.nome}, número ${connection.numero}${connection.avisos ? ", con restricciones de uso" : ""}` : `Pino ${connection.nome}, número ${connection.numero}${connection.avisos ? ", com restrições de uso" : ""}`}
className="flex w-full flex-col items-center rounded-xl border border-line bg-surface p-3 transition-colors hover:bg-surface-2"
>
<span
@@ -317,14 +349,14 @@ function ConnectionsDiagramInner({ connections, serie }) {
<div className="overflow-x-auto rounded-xl border border-line">
<table className="w-full min-w-[40rem] border-collapse text-left text-sm">
<caption className="sr-only">
{isEn ? `Connections of ${serie.nome_completo}` : `Conexões do ${serie.nome_completo}`}
{isEn ? `Connections of ${serie.nome_completo}` : isEs ? `Conexiones del ${serie.nome_completo}` : `Conexões do ${serie.nome_completo}`}
</caption>
<thead>
<tr className="border-b border-line bg-surface-2 text-muted">
<th scope="col" className="px-4 py-3 font-medium">{isEn ? "Pin" : "Pino"}</th>
<th scope="col" className="px-4 py-3 font-medium">{isEn ? "Name" : "Nome"}</th>
<th scope="col" className="px-4 py-3 font-medium">{isEn ? "Functions" : "Funções"}</th>
<th scope="col" className="px-4 py-3 font-medium">{isEn ? "Usage" : "Uso"}</th>
<th scope="col" className="px-4 py-3 font-medium">{isEn ? "Pin" : isEs ? "Pin" : "Pino"}</th>
<th scope="col" className="px-4 py-3 font-medium">{isEn ? "Name" : isEs ? "Nombre" : "Nome"}</th>
<th scope="col" className="px-4 py-3 font-medium">{isEn ? "Functions" : isEs ? "Funciones" : "Funções"}</th>
<th scope="col" className="px-4 py-3 font-medium">{isEn ? "Usage" : isEs ? "Uso" : "Uso"}</th>
</tr>
</thead>
<tbody className="divide-y divide-line">
@@ -337,7 +369,7 @@ function ConnectionsDiagramInner({ connections, serie }) {
type="button"
onClick={(e) => abrir(connection, e)}
aria-haspopup="dialog"
aria-label={isEn ? `Open pin details for ${connection.nome}, number ${connection.numero}` : `Abrir detalhes do pino ${connection.nome}, número ${connection.numero}`}
aria-label={isEn ? `Open pin details for ${connection.nome}, number ${connection.numero}` : isEs ? `Abrir detalles del pin ${connection.nome}, número ${connection.numero}` : `Abrir detalhes do pino ${connection.nome}, número ${connection.numero}`}
className="grid h-11 w-11 place-items-center rounded-full border-2 bg-surface text-sm font-semibold tabular-nums text-ink hover:bg-surface-2"
style={{ borderColor: baseColor }}
>
@@ -352,12 +384,12 @@ function ConnectionsDiagramInner({ connections, serie }) {
{connection.avisos ? (
<span className="inline-flex items-center gap-1.5 font-medium text-danger">
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
{isEn ? "Restricted" : "Restrito"}
{isEn ? "Restricted" : isEs ? "Restringido" : "Restrito"}
</span>
) : (
<span className="inline-flex items-center gap-1.5 text-ok">
<CheckCircle className="h-4 w-4" aria-hidden="true" />
{isEn ? "Free" : "Livre"}
{isEn ? "Free" : isEs ? "Libre" : "Livre"}
</span>
)}
</td>
@@ -373,13 +405,18 @@ function ConnectionsDiagramInner({ connections, serie }) {
<div className="callout callout-warn flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-gold" aria-hidden="true" />
<div>
<p className="font-semibold text-ink">{isEn ? "Pinout notes" : "Notas sobre a pinagem"}</p>
<p className="font-semibold text-ink">{isEn ? "Pinout notes" : isEs ? "Notas sobre el patillaje" : "Notas sobre a pinagem"}</p>
<ul className="mt-1 list-disc space-y-1 pl-5">
{isEn ? (
<>
<li>Pins with a warning icon have special boot roles or analog limitations. Open the pin to see details.</li>
<li>Isolate capacitive touch traces from coupled RF noise.</li>
</>
) : isEs ? (
<>
<li>Los pines con icono de advertencia tienen un papel especial en el arranque o limitaciones analógicas. Abra el pin para ver los detalles.</li>
<li>Aísle las pistas táctiles capacitivas del ruido de RF acoplado.</li>
</>
) : (
<>
<li>Pinos com o ícone de alerta têm papel especial no boot ou limitações analógicas. Abra o pino para ver os detalhes.</li>
@@ -414,11 +451,11 @@ function ConnectionsDiagramInner({ connections, serie }) {
{selectedConnection.nome}
</h3>
<p className="text-sm text-muted">
{isEn ? `Physical pin ${selectedConnection.numero}` : `Pino físico ${selectedConnection.numero}`}
{isEn ? `Physical pin ${selectedConnection.numero}` : isEs ? `Pin físico ${selectedConnection.numero}` : `Pino físico ${selectedConnection.numero}`}
</p>
</div>
</div>
<button type="button" onClick={fechar} aria-label={isEn ? "Close pin details" : "Fechar detalhes do pino"} className="grid h-11 w-11 shrink-0 place-items-center rounded-full text-muted transition-colors hover:bg-surface-2 hover:text-ink">
<button type="button" onClick={fechar} aria-label={isEn ? "Close pin details" : isEs ? "Cerrar detalles del pin" : "Fechar detalhes do pino"} className="grid h-11 w-11 shrink-0 place-items-center rounded-full text-muted transition-colors hover:bg-surface-2 hover:text-ink">
<X className="h-5 w-5" aria-hidden="true" />
</button>
</div>
@@ -426,7 +463,7 @@ function ConnectionsDiagramInner({ connections, serie }) {
<div className="flex-1 space-y-6 overflow-y-auto p-5 sm:p-7">
{selectedConnection.categorias.length > 0 && (
<section>
<h4 className="text-sm font-semibold text-ink">{isEn ? "Categories" : "Categorias"}</h4>
<h4 className="text-sm font-semibold text-ink">{isEn ? "Categories" : isEs ? "Categorías" : "Categorias"}</h4>
<ul className="mt-3 flex flex-wrap gap-2">
{selectedConnection.categorias.map((cat) => (
<li key={cat} className="chip">
@@ -442,7 +479,7 @@ function ConnectionsDiagramInner({ connections, serie }) {
<section>
<h4 className="flex items-center gap-2 text-sm font-semibold text-ink">
<Zap className="h-4 w-4 text-danger" aria-hidden="true" />
{isEn ? "Power domain" : "Domínio de alimentação"}
{isEn ? "Power domain" : isEs ? "Dominio de alimentación" : "Domínio de alimentação"}
</h4>
<ul className="tile mt-3 space-y-2">
{selectedConnection.alimentacao.map((source) => (
@@ -453,7 +490,7 @@ function ConnectionsDiagramInner({ connections, serie }) {
)}
<section>
<h4 className="text-sm font-semibold text-ink">{isEn ? "Available functions" : "Funções disponíveis"}</h4>
<h4 className="text-sm font-semibold text-ink">{isEn ? "Available functions" : isEs ? "Funciones disponibles" : "Funções disponíveis"}</h4>
<ul className="tile mt-3 grid gap-2.5 sm:grid-cols-2">
{selectedConnection.funcoes.map((func) => (
<li key={func} className="flex items-center gap-2.5 text-sm text-ink-soft">
@@ -468,7 +505,7 @@ function ConnectionsDiagramInner({ connections, serie }) {
<section className="callout callout-danger">
<h4 className="flex items-center gap-2 font-semibold text-ink">
<AlertTriangle className="h-4 w-4 text-danger" aria-hidden="true" />
{isEn ? "Engineering notes" : "Avisos de engenharia"}
{isEn ? "Engineering notes" : isEs ? "Notas de ingeniería" : "Avisos de engenharia"}
</h4>
<ul className="mt-2 list-disc space-y-1.5 pl-5">
{selectedConnection.avisos.map((aviso) => (
@@ -481,7 +518,7 @@ function ConnectionsDiagramInner({ connections, serie }) {
<div className="border-t border-line p-4 sm:px-7">
<button type="button" onClick={fechar} className="btn btn-primary w-full">
{isEn ? "Close" : "Fechar"}
{isEn ? "Close" : isEs ? "Cerrar" : "Fechar"}
</button>
</div>
</div>
+45 -7
View File
@@ -67,6 +67,35 @@ const COLUMNS_EN = [
},
];
const COLUMNS_ES = [
{
title: "Explorar",
links: [
{ href: "/series", label: "Series ESP32" },
{ href: "/comparacao", label: "Comparador" },
{ href: "/catalogo", label: "Catálogo de placas" },
],
},
{
title: "Herramientas",
links: [
{ href: "/seletor", label: "Selector inteligente" },
{ href: "/diagnostico", label: "Diagnóstico" },
{ href: "/componentes", label: "Component Registry" },
{ href: "/frameworks", label: "Frameworks y SDKs" },
],
},
{
title: "Proyecto",
links: [
{ href: "/sobre", label: "Acerca de ESPDocs" },
{ href: "/glossario", label: "Glosario" },
{ href: SITE.repositorio, label: "Repositorio", external: true },
{ href: `${SITE.repositorio}/issues`, label: "Reportar un problema", external: true },
],
},
];
export default function Footer() {
const pathname = usePathname() || "";
const year = new Date().getFullYear();
@@ -74,6 +103,8 @@ export default function Footer() {
const currentLocale =
LOCALES.find((l) => pathname === `/${l.code}` || pathname.startsWith(`/${l.code}/`))?.code || "pt";
const isPt = currentLocale === "pt";
const isEn = currentLocale === "en";
const isEs = currentLocale === "es";
const getLinkHref = (href) => getLocalizedHref(`/${currentLocale}${href}`, currentLocale);
@@ -82,14 +113,16 @@ export default function Footer() {
...column,
links: column.links.filter((link) => link.href !== "/livro" || isPt),
}))
: isEs
? COLUMNS_ES
: COLUMNS_EN;
return (
<footer className="mt-20 border-t border-line md:mt-28" aria-label={isPt ? "Rodapé do ESPDocs" : "ESPDocs footer"}>
<footer className="mt-20 border-t border-line md:mt-28" aria-label={isPt ? "Rodapé do ESPDocs" : isEs ? "Pie de página de ESPDocs" : "ESPDocs footer"}>
<div className="shell py-14 md:py-20">
<div className="grid gap-12 lg:grid-cols-[1.1fr_2fr]">
<div>
<Link href={getLinkHref("/")} className="inline-flex min-h-11 items-center gap-2.5" aria-label={isPt ? "ESPDocs, página inicial" : "ESPDocs, homepage"}>
<Link href={getLinkHref("/")} className="inline-flex min-h-11 items-center gap-2.5" aria-label={isPt ? "ESPDocs, página inicial" : isEs ? "ESPDocs, página de inicio" : "ESPDocs, homepage"}>
<Logo className="h-7 w-7" />
<span className="font-display text-2xl font-semibold tracking-[-0.045em] text-ink">ESPDocs</span>
</Link>
@@ -99,6 +132,11 @@ export default function Footer() {
Documentação independente do ecossistema ESP32, escrita em português brasileiro.
Dados públicos, curadoria editorial e links para as fontes oficiais.
</>
) : isEs ? (
<>
Documentación independiente del ecosistema ESP32.
Datos públicos, curaduría editorial y enlaces a las fuentes oficiales.
</>
) : (
<>
Independent documentation of the ESP32 ecosystem.
@@ -173,7 +211,7 @@ export default function Footer() {
))}
<div>
<h2 className="text-sm font-semibold text-ink">{isPt ? "Autor" : "Author"}</h2>
<h2 className="text-sm font-semibold text-ink">{isPt ? "Autor" : isEs ? "Autor" : "Author"}</h2>
<p className="mt-4 text-sm leading-relaxed text-muted">
Lucas Rayan Guerra
<br />
@@ -184,7 +222,7 @@ export default function Footer() {
href={SITE.repositorio}
target="_blank"
rel="noopener noreferrer"
aria-label={isPt ? "Gitea do projeto (abre em nova aba)" : "Project Gitea (opens in new tab)"}
aria-label={isPt ? "Gitea do projeto (abre em nova aba)" : isEs ? "Gitea del proyecto (abre en una pestaña nueva)" : "Project Gitea (opens in new tab)"}
className="grid h-11 w-11 place-items-center rounded-full border border-line text-ink transition-colors hover:border-line-strong hover:bg-surface"
>
<GiteaMark className="h-4 w-4" />
@@ -193,7 +231,7 @@ export default function Footer() {
href={SITE.autorLinkedin}
target="_blank"
rel="noopener noreferrer"
aria-label={isPt ? "LinkedIn do autor (abre em nova aba)" : "Author LinkedIn (opens in new tab)"}
aria-label={isPt ? "LinkedIn do autor (abre em nova aba)" : isEs ? "LinkedIn del autor (abre en una pestaña nueva)" : "Author LinkedIn (opens in new tab)"}
className="grid h-11 w-11 place-items-center rounded-full border border-line text-ink transition-colors hover:border-line-strong hover:bg-surface"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
@@ -206,7 +244,7 @@ export default function Footer() {
</div>
<div className="mt-14 flex flex-col gap-4 border-t border-line pt-6 text-xs leading-relaxed text-faint md:flex-row md:items-center md:justify-between">
<p>© {year} {isPt ? "ESPDocs. Projeto independente, sem afiliação com a Espressif Systems." : "ESPDocs. Independent project, not affiliated with Espressif Systems."}</p>
<p>© {year} {isPt ? "ESPDocs. Projeto independente, sem afiliação com a Espressif Systems." : isEs ? "ESPDocs. Proyecto independiente, sin afiliación con Espressif Systems." : "ESPDocs. Independent project, not affiliated with Espressif Systems."}</p>
<p className="flex flex-wrap items-center gap-x-5 gap-y-2">
<a href={SITE.blog} target="_blank" rel="noopener noreferrer" className="inline-flex min-h-11 items-center gap-1 transition-colors hover:text-accent">
Ciência Embarcada
@@ -216,7 +254,7 @@ export default function Footer() {
lucasrguerra.dev.br
<ArrowUpRight className="h-3 w-3" aria-hidden="true" />
</a>
<span>{isPt ? "Fontes oficiais: Espressif Systems." : "Official sources: Espressif Systems."}</span>
<span>{isPt ? "Fontes oficiais: Espressif Systems." : isEs ? "Fuentes oficiales: Espressif Systems." : "Official sources: Espressif Systems."}</span>
</p>
</div>
</div>
+61 -17
View File
@@ -88,6 +88,36 @@ const MENUS_EN = [
},
];
const MENUS_ES = [
{
id: "hardware",
label: "Hardware",
items: [
{ href: "/series", label: "Series ESP32", desc: "12 chips, patillaje y especificaciones", icon: Cpu },
{ href: "/comparacao", label: "Comparador", desc: "Hasta cuatro series frente a frente", icon: Scale },
{ href: "/catalogo", label: "Catálogo de placas", desc: "DevKits, pines y dónde comprar", icon: Box },
],
},
{
id: "tools",
label: "Herramientas",
items: [
{ href: "/seletor", label: "Selector inteligente", desc: "El chip adecuado para su proyecto", icon: SlidersHorizontal },
{ href: "/diagnostico", label: "Diagnóstico", desc: "Brownout, bootloop, WDT y más", icon: AlertTriangle },
{ href: "/componentes", label: "Component Registry", desc: "Drivers y manifiestos idf_component.yml", icon: Search },
],
},
{
id: "library",
label: "Biblioteca",
items: [
{ href: "/frameworks", label: "Frameworks y SDKs", desc: "ESP-IDF, Arduino, Rust y Matter", icon: BookOpen },
{ href: "/glossario", label: "Glosario técnico", desc: "Términos que aparecen en las hojas de datos", icon: BookA },
{ href: "/sobre", label: "Acerca del proyecto", desc: "Autoría, método y código abierto", icon: Sparkles },
],
},
];
const MOBILE_LINKS_PT = [
{ href: "/series", label: "Séries ESP32" },
{ href: "/comparacao", label: "Comparador" },
@@ -113,6 +143,18 @@ const MOBILE_LINKS_EN = [
{ href: "/sobre", label: "About" },
];
const MOBILE_LINKS_ES = [
{ href: "/series", label: "Series ESP32" },
{ href: "/comparacao", label: "Comparador" },
{ href: "/seletor", label: "Selector inteligente" },
{ href: "/diagnostico", label: "Diagnóstico" },
{ href: "/frameworks", label: "Frameworks y SDKs" },
{ href: "/componentes", label: "Component Registry" },
{ href: "/glossario", label: "Glosario" },
{ href: "/catalogo", label: "Catálogo de placas" },
{ href: "/sobre", label: "Acerca de" },
];
function isActivePath(pathname, href, currentLocale) {
const localized = getLocalizedHref(`/${currentLocale}${href}`, currentLocale);
if (href === "/" || localized === `/${currentLocale}/` || localized === `/${currentLocale}`) {
@@ -133,6 +175,8 @@ export default function Header() {
const currentLocale =
LOCALES.find((l) => pathname === `/${l.code}` || pathname.startsWith(`/${l.code}/`))?.code || "pt";
const isPt = currentLocale === "pt";
const isEn = currentLocale === "en";
const isEs = currentLocale === "es";
const getLinkHref = (href) => getLocalizedHref(`/${currentLocale}${href}`, currentLocale);
@@ -172,19 +216,19 @@ export default function Header() {
};
}, [drawerOpen]);
const visibleMobileLinks = isPt ? MOBILE_LINKS_PT : MOBILE_LINKS_EN;
const MENUS = isPt ? MENUS_PT : MENUS_EN;
const visibleMobileLinks = isPt ? MOBILE_LINKS_PT : isEs ? MOBILE_LINKS_ES : MOBILE_LINKS_EN;
const MENUS = isPt ? MENUS_PT : isEs ? MENUS_ES : MENUS_EN;
return (
<header className="sticky top-0 z-50 pt-3 sm:pt-4">
<div className="shell">
<div className="nav-pill flex h-16 items-center justify-between gap-3 rounded-full px-3 sm:px-4">
<Link href={getLinkHref("/")} className="flex min-h-11 shrink-0 items-center gap-2.5 rounded-full pl-1 pr-2" aria-label={isPt ? "ESPDocs, página inicial" : "ESPDocs, homepage"}>
<Link href={getLinkHref("/")} className="flex min-h-11 shrink-0 items-center gap-2.5 rounded-full pl-1 pr-2" aria-label={isPt ? "ESPDocs, página inicial" : isEs ? "ESPDocs, página de inicio" : "ESPDocs, homepage"}>
<Logo className="h-[30px] w-[30px]" />
<span className="font-display text-[1.3rem] font-semibold leading-none tracking-[-0.045em] text-ink">ESPDocs</span>
</Link>
<nav ref={navRef} className="hidden items-center gap-0.5 lg:flex" aria-label={isPt ? "Navegação principal" : "Main navigation"}>
<nav ref={navRef} className="hidden items-center gap-0.5 lg:flex" aria-label={isPt ? "Navegação principal" : isEs ? "Navegación principal" : "Main navigation"}>
{MENUS.map((menu) => {
const active = menu.items.some((item) => isActivePath(pathname, item.href, currentLocale));
const expanded = openMenu === menu.id;
@@ -237,23 +281,23 @@ export default function Header() {
</Link>
)}
<Link href={getLinkHref("/sobre")} className={`nav-link ${isActivePath(pathname, "/sobre", currentLocale) ? "is-active" : ""}`} aria-current={isActivePath(pathname, "/sobre", currentLocale) ? "page" : undefined}>
{isPt ? "Sobre" : "About"}
{isPt ? "Sobre" : isEs ? "Acerca de" : "About"}
</Link>
</nav>
<div className="flex items-center gap-1.5">
<Link href={getLinkHref("/seletor")} className="btn btn-primary btn-sm hidden lg:inline-flex">
<SlidersHorizontal className="h-4 w-4" aria-hidden="true" />
{isPt ? "Selecionar chip" : "Select chip"}
{isPt ? "Selecionar chip" : isEs ? "Seleccionar chip" : "Select chip"}
</Link>
<SearchDialog />
<LanguageSelector />
<ThemeToggle isPt={isPt} />
<ThemeToggle isPt={isPt} isEs={isEs} />
<button
ref={menuButtonRef}
type="button"
className="grid h-11 w-11 place-items-center rounded-full border border-line text-ink transition-colors hover:bg-surface-2 lg:hidden"
aria-label={isPt ? "Abrir menu de navegação" : "Open navigation menu"}
aria-label={isPt ? "Abrir menu de navegação" : isEs ? "Abrir menú de navegación" : "Open navigation menu"}
aria-expanded={drawerOpen}
onClick={() => setDrawerOpen(true)}
>
@@ -276,7 +320,7 @@ export default function Header() {
<div
role="dialog"
aria-modal="true"
aria-label={isPt ? "Menu de navegação" : "Navigation menu"}
aria-label={isPt ? "Menu de navegação" : isEs ? "Menú de navegación" : "Navigation menu"}
aria-hidden={!drawerOpen}
inert={!drawerOpen}
className={`fixed inset-y-0 right-0 z-[101] flex w-[min(23rem,94vw)] flex-col rounded-l-[2rem] border-l border-line bg-canvas transition-transform duration-300 ${
@@ -285,15 +329,15 @@ export default function Header() {
>
<div className="flex items-start justify-between px-5 py-5">
<div>
<p className="text-xs font-semibold text-muted">{isPt ? "Navegação" : "Navigation"}</p>
<p className="mt-1 font-display text-2xl font-semibold tracking-[-0.04em] text-ink">{isPt ? "Para onde ir" : "Where to go"}</p>
<p className="text-xs font-semibold text-muted">{isPt ? "Navegação" : isEs ? "Navegación" : "Navigation"}</p>
<p className="mt-1 font-display text-2xl font-semibold tracking-[-0.04em] text-ink">{isPt ? "Para onde ir" : isEs ? "A dónde ir" : "Where to go"}</p>
</div>
<button
ref={closeRef}
type="button"
onClick={() => setDrawerOpen(false)}
className="grid h-10 w-10 place-items-center rounded-full border border-line text-ink transition-colors hover:bg-surface-2"
aria-label={isPt ? "Fechar menu" : "Close menu"}
aria-label={isPt ? "Fechar menu" : isEs ? "Cerrar menú" : "Close menu"}
>
<X className="h-5 w-5" aria-hidden="true" />
</button>
@@ -325,7 +369,7 @@ export default function Header() {
</div>
)}
<nav className="flex-1 overflow-y-auto px-3 py-2" aria-label={isPt ? "Navegação móvel" : "Mobile navigation"}>
<nav className="flex-1 overflow-y-auto px-3 py-2" aria-label={isPt ? "Navegação móvel" : isEs ? "Navegación móvil" : "Mobile navigation"}>
{visibleMobileLinks.map((link) => (
<Link
key={link.href}
@@ -343,7 +387,7 @@ export default function Header() {
<div className="p-4 pt-2">
<Link href={getLinkHref("/seletor")} onClick={() => setDrawerOpen(false)} className="btn btn-primary w-full">
<SlidersHorizontal className="h-4 w-4" aria-hidden="true" />
{isPt ? "Selecionar meu chip" : "Select my chip"}
{isPt ? "Selecionar meu chip" : isEs ? "Seleccionar mi chip" : "Select my chip"}
</Link>
</div>
</div>
@@ -354,7 +398,7 @@ export default function Header() {
);
}
function ThemeToggle({ isPt = true }) {
function ThemeToggle({ isPt = true, isEs = false }) {
const { resolvedTheme, setTheme } = useTheme();
const [mounted, setMounted] = useState(false);
@@ -371,8 +415,8 @@ function ThemeToggle({ isPt = true }) {
type="button"
onClick={() => setTheme(dark ? "light" : "dark")}
className="grid h-11 w-11 place-items-center rounded-full border border-line text-ink transition-colors hover:bg-surface-2"
aria-label={dark ? (isPt ? "Ativar tema claro" : "Switch to light theme") : (isPt ? "Ativar tema escuro" : "Switch to dark theme")}
title={dark ? (isPt ? "Tema claro" : "Light theme") : (isPt ? "Tema escuro" : "Dark theme")}
aria-label={dark ? (isPt ? "Ativar tema claro" : isEs ? "Activar tema claro" : "Switch to light theme") : (isPt ? "Ativar tema escuro" : isEs ? "Activar tema oscuro" : "Switch to dark theme")}
title={dark ? (isPt ? "Tema claro" : isEs ? "Tema claro" : "Light theme") : (isPt ? "Tema escuro" : isEs ? "Tema oscuro" : "Dark theme")}
>
{dark ? <Sun className="h-4 w-4" aria-hidden="true" /> : <Moon className="h-4 w-4" aria-hidden="true" />}
</button>
+2 -2
View File
@@ -63,7 +63,7 @@ export default function LanguageSelector() {
className="flex h-11 min-w-[44px] items-center gap-1.5 rounded-full border border-line bg-surface px-3 text-sm font-semibold text-ink transition-colors hover:bg-surface-2 focus-visible:outline-2 focus-visible:outline-accent"
aria-haspopup="listbox"
aria-expanded={open}
aria-label={currentLocaleCode === "en" ? `Current language: ${currentLocale.label}. Click to switch language.` : `Idioma atual: ${currentLocale.label}. Clique para trocar de idioma.`}
aria-label={currentLocaleCode === "en" ? `Current language: ${currentLocale.label}. Click to switch language.` : currentLocaleCode === "es" ? `Idioma actual: ${currentLocale.label}. Haga clic para cambiar de idioma.` : `Idioma atual: ${currentLocale.label}. Clique para trocar de idioma.`}
title={currentLocaleCode === "en" ? `Language: ${currentLocale.label}` : `Idioma: ${currentLocale.label}`}
>
<Globe className="h-4 w-4 text-muted" aria-hidden="true" />
@@ -77,7 +77,7 @@ export default function LanguageSelector() {
{open && (
<div
role="listbox"
aria-label={currentLocaleCode === "en" ? "Available languages" : "Idiomas disponíveis"}
aria-label={currentLocaleCode === "en" ? "Available languages" : currentLocaleCode === "es" ? "Idiomas disponibles" : "Idiomas disponíveis"}
className="absolute right-0 top-full mt-2 w-48 rounded-2xl border border-line bg-surface p-1.5 shadow-[0_24px_60px_-20px_rgba(15,23,42,0.35)] dark:shadow-[0_24px_60px_-20px_rgba(0,0,0,0.8)] z-50"
>
{LOCALES.map((loc) => {
+10 -3
View File
@@ -14,6 +14,7 @@ export default function PageHero({
align = "left",
children,
isEn: isEnProp,
isEs: isEsProp,
locale: localeProp,
}) {
const center = align === "center";
@@ -23,13 +24,19 @@ export default function PageHero({
: localeProp
? localeProp === "en"
: trail.some((t) => t.isEn || (t.href && t.href.startsWith("/en")));
const isEs =
isEsProp !== undefined
? isEsProp
: localeProp
? localeProp === "es"
: trail.some((t) => t.isEs || (t.href && t.href.startsWith("/es")));
return (
<section className="shell pt-8 sm:pt-12">
{trail.length > 0 && (
<nav aria-label={isEn ? "Breadcrumb navigation" : "Trilha de navegação"} className="flex flex-wrap items-center gap-2 text-sm text-muted">
<Link href={isEn ? "/en" : "/"} className="inline-flex min-h-11 min-w-11 items-center justify-center transition-colors hover:text-accent">
{isEn ? "Home" : "Início"}
<nav aria-label={isEn ? "Breadcrumb navigation" : isEs ? "Navegación por migas de pan" : "Trilha de navegação"} className="flex flex-wrap items-center gap-2 text-sm text-muted">
<Link href={isEn ? "/en" : isEs ? "/es" : "/"} className="inline-flex min-h-11 min-w-11 items-center justify-center transition-colors hover:text-accent">
{isEn ? "Home" : isEs ? "Inicio" : "Início"}
</Link>
{trail.map((item, i) => (
<span key={item.label} className="flex min-h-11 items-center gap-2">
+79 -9
View File
@@ -140,14 +140,75 @@ const GRUPOS_EN = [
},
];
const GRUPOS_ES = [
{
id: "entrada",
titulo: "Solo entrada",
icone: ArrowDownToLine,
cor: "text-accent",
explicacao:
"Aceptan únicamente lectura. No pueden configurarse como salida y no poseen resistencias de pull-up o pull-down internas. Si necesita una, agréguela en el circuito externo.",
combina: (avisos) => /solo de entrada|input[- ]only/i.test(avisos),
},
{
id: "flash",
titulo: "Conectados a la memoria flash/PSRAM",
icone: HardDrive,
cor: "text-copper",
explicacao:
"Ya están conectados a la memoria desde donde se lee el firmware. Usarlos como GPIO común normalmente bloquea el chip o corrompe la ejecución. En módulos con flash interna ni siquiera llegan a los pines del encapsulado.",
combina: (avisos) =>
/(flash|psram)/i.test(avisos) &&
/(dedicado|utilizado por la mem|no debe|no se recomienda|no recomendado|connected to|dedicated to)/i.test(avisos),
},
{
id: "strapping",
titulo: "Strapping (modo de arranque)",
icone: Power,
cor: "text-gold",
explicacao:
"El chip lee el nivel lógico de estos pines en el instante del reset para decidir cómo inicializarse. Pueden usarse después del arranque, pero el circuito externo no debe forzar el nivel incorrecto durante el reset, de lo contrario la placa no iniciará o entrará en modo de descarga inesperadamente.",
combina: (avisos, cats) =>
cats.includes("Strapping") ||
/strapping|modo de arranque|modo de inicialización|boot mode/i.test(avisos),
},
{
id: "debug",
titulo: "Grabación y depuración",
icone: Bug,
cor: "text-accent",
explicacao:
"Son la UART de grabación y las líneas de JTAG. Siguen utilizables como GPIO, pero al reutilizarlos pierde la posibilidad de grabar y depurar la placa sin desconectar lo que esté conectado allí.",
combina: (avisos, cats) => /depuración|grabación|debugging/i.test(avisos) || cats.includes("JTAG"),
},
{
id: "usb",
titulo: "USB nativo",
icone: Usb,
cor: "text-accent",
explicacao:
"Salen de fábrica conectados al USB Serial/JTAG interno. Para usarlos como GPIO es necesario desactivar esa función, lo que también elimina la grabación por USB directo.",
combina: (avisos) => /usb serial\/jtag|usb_d[+-]/i.test(avisos),
},
{
id: "eletrico",
titulo: "No deben quedar flotando",
icone: Zap,
cor: "text-gold",
explicacao:
"Requieren un nivel lógico definido por hardware: pull-up externo o circuito RC de retardo. Dejarlos sueltos causa reinicios aleatorios o un arranque inestable.",
combina: (avisos) => /flotando|floating/i.test(avisos),
},
];
/** Nome do pino + o GPIO correspondente, quando o nome é uma função (ex.: SENSOR_VP). */
function rotuloDoPino(pino) {
const gpio = (pino.funcoes || []).find((f) => /^GPIO\d+$/.test(f));
return gpio && gpio !== pino.nome ? `${pino.nome} · ${gpio}` : pino.nome;
}
function derivarGrupos(conexoes, isEn) {
const listaGrupos = isEn ? GRUPOS_EN : GRUPOS_PT;
function derivarGrupos(conexoes, isEn, isEs) {
const listaGrupos = isEn ? GRUPOS_EN : isEs ? GRUPOS_ES : GRUPOS_PT;
return listaGrupos.map((grupo) => {
const vistos = new Map();
for (const pino of conexoes) {
@@ -163,10 +224,13 @@ function derivarGrupos(conexoes, isEn) {
}).filter((g) => g.pinos.length > 0);
}
export default function PinRestrictions({ conexoes, serieKey, isEn = false }) {
export default function PinRestrictions({ conexoes, serieKey, isEn = false, isEs = false, locale }) {
if (!conexoes?.length) return null;
const grupos = derivarGrupos(conexoes, isEn);
const effectiveIsEn = isEn || locale === "en";
const effectiveIsEs = isEs || locale === "es";
const grupos = derivarGrupos(conexoes, effectiveIsEn, effectiveIsEs);
if (!grupos.length) return null;
const totalPinos = new Set(grupos.flatMap((g) => g.pinos)).size;
@@ -177,14 +241,18 @@ export default function PinRestrictions({ conexoes, serieKey, isEn = false }) {
<span className="grid h-9 w-9 place-items-center rounded-full bg-gold-soft text-gold">
<AlertTriangle className="h-4 w-4" aria-hidden="true" />
</span>
{isEn ? "Pins with usage restrictions" : "Pinos com restrição de uso"}
{effectiveIsEn ? "Pins with usage restrictions" : effectiveIsEs ? "Pines con restricción de uso" : "Pinos com restrição de uso"}
</h2>
<p className="mt-3 max-w-3xl text-sm leading-relaxed text-muted">
{isEn ? (
{effectiveIsEn ? (
<>
Not every pin on the {serieKey} is free for general-purpose use. {totalPinos} of them have conditions: occupied by memory, sampled during boot, or function as input only. Check before finalizing your schematic.
</>
) : effectiveIsEs ? (
<>
No todos los pines del {serieKey} están libres para uso general. {totalPinos} de ellos tienen alguna condición: ya están ocupados por memoria, se leen durante el arranque o solo funcionan como entrada. Conviene comprobarlos antes de cerrar el esquemático.
</>
) : (
<>
Nem todo pino do {serieKey} está livre para uso geral. {totalPinos} deles têm alguma
@@ -201,7 +269,7 @@ export default function PinRestrictions({ conexoes, serieKey, isEn = false }) {
<Icone className={`h-4 w-4 shrink-0 ${corIcone}`} aria-hidden="true" />
<h3 className="text-sm font-semibold text-ink">{titulo}</h3>
<span className="ml-auto text-xs text-muted tabular-nums">
{pinos.length} {pinos.length === 1 ? (isEn ? "pin" : "pino") : (isEn ? "pins" : "pinos")}
{pinos.length} {pinos.length === 1 ? (effectiveIsEn ? "pin" : effectiveIsEs ? "pin" : "pino") : (effectiveIsEn ? "pins" : effectiveIsEs ? "pines" : "pinos")}
</span>
</div>
@@ -220,14 +288,16 @@ export default function PinRestrictions({ conexoes, serieKey, isEn = false }) {
<div className="mt-6 flex flex-col justify-between gap-4 border-t border-line pt-5 sm:flex-row sm:items-center">
<p className="max-w-xl text-sm leading-relaxed text-muted">
{isEn ? (
{effectiveIsEn ? (
"These groups are derived from the same data as the pinout diagram. There you can see every pin's full functions, power domain, and complete engineering notes."
) : effectiveIsEs ? (
"Estos grupos proceden de los mismos datos que el diagrama de conexiones. Allí podrá consultar pino a pino todas las funciones, el dominio de alimentación y los avisos completos."
) : (
"Estes grupos vêm dos mesmos dados do diagrama de conexões. Lá você vê pino a pino todas as funções, o domínio de alimentação e os avisos completos."
)}
</p>
<Link href="#connections" className="btn btn-ghost shrink-0">
{isEn ? "Open pinout diagram" : "Abrir diagrama de pinos"}
{effectiveIsEn ? "Open pinout diagram" : effectiveIsEs ? "Abrir diagrama de pines" : "Abrir diagrama de pinos"}
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</Link>
</div>
+34 -21
View File
@@ -43,9 +43,21 @@ const TIPOS_EN = {
componente: { label: "Component", Icon: Box, cor: "bg-surface-2 text-ink-soft" },
};
const TIPOS_ES = {
resposta: { label: "Respuesta", Icon: Sparkles, cor: "bg-accent text-accent-contrast" },
pino: { label: "Pines", Icon: Plug, cor: "bg-accent text-accent-contrast" },
pagina: { label: "Página", Icon: FileText, cor: "bg-surface-2 text-ink-soft" },
serie: { label: "Serie", Icon: Cpu, cor: "bg-accent-soft text-accent" },
framework: { label: "Framework", Icon: Terminal, cor: "bg-teal-soft text-teal" },
glossario: { label: "Glosario", Icon: BookOpen, cor: "bg-copper-soft text-copper" },
erro: { label: "Error", Icon: AlertTriangle, cor: "bg-gold-soft text-gold" },
componente: { label: "Componente", Icon: Box, cor: "bg-surface-2 text-ink-soft" },
};
const INTELIGENTES = new Set(["resposta", "pino"]);
const EXEMPLOS_EN = ["flash s3", "usb pins c6", "gpio0 esp32", "brownout", "LVGL", "FreeRTOS"];
const EXEMPLOS_ES = ["flash s3", "pines usb c6", "gpio0 esp32", "brownout", "LVGL", "FreeRTOS"];
function Destaque({ texto, termos }) {
if (!termos.length) return texto;
@@ -98,11 +110,12 @@ function Pilula({ children, onClick, forte = false }) {
export default function SearchDialog() {
const pathname = usePathname() || "";
const locale = pathname === "/en" || pathname.startsWith("/en/") ? "en" : "pt";
const locale = pathname === "/en" || pathname.startsWith("/en/") ? "en" : (pathname === "/es" || pathname.startsWith("/es/") ? "es" : "pt");
const isEn = locale === "en";
const isEs = locale === "es";
const TIPOS = isEn ? TIPOS_EN : TIPOS_PT;
const EXEMPLOS = isEn ? EXEMPLOS_EN : EXEMPLOS_DE_BUSCA;
const TIPOS = isEn ? TIPOS_EN : isEs ? TIPOS_ES : TIPOS_PT;
const EXEMPLOS = isEn ? EXEMPLOS_EN : isEs ? EXEMPLOS_ES : EXEMPLOS_DE_BUSCA;
const [aberto, setAberto] = useState(false);
const [montado, setMontado] = useState(false);
@@ -143,7 +156,7 @@ export default function SearchDialog() {
const carregarIndice = useCallback(() => {
setErro(false);
let cancelado = false;
const url = isEn ? "/api/busca/en?v=2" : "/api/busca?v=2";
const url = isEn ? "/api/busca/en?v=2" : isEs ? "/api/busca/es?v=2" : "/api/busca?v=2";
fetch(url, { cache: "no-cache" })
.then((r) => {
if (!r.ok) {
@@ -167,7 +180,7 @@ export default function SearchDialog() {
return () => {
cancelado = true;
};
}, [isEn]);
}, [isEn, isEs]);
// Carrega o índice (textos + especificações + pinos) na primeira abertura.
useEffect(() => {
@@ -238,7 +251,7 @@ export default function SearchDialog() {
id="botao-busca"
onClick={() => setAberto(true)}
className="flex h-11 min-w-11 shrink-0 cursor-pointer items-center justify-center gap-2 rounded-full border border-line px-3 text-ink transition-colors hover:bg-surface-2 xl:px-4"
aria-label={isEn ? "Search ESPDocs (Ctrl+K)" : "Buscar no ESPDocs (Ctrl+K)"}
aria-label={isEn ? "Search ESPDocs (Ctrl+K)" : isEs ? "Buscar en ESPDocs (Ctrl+K)" : "Buscar no ESPDocs (Ctrl+K)"}
>
<Search className="h-4 w-4" aria-hidden="true" />
<span className="hidden text-sm font-medium xl:inline">{isEn ? "Search" : "Buscar"}</span>
@@ -248,7 +261,7 @@ export default function SearchDialog() {
{montado &&
aberto &&
createPortal(
<div className="fixed inset-0 z-[120] flex items-start justify-center px-4 pt-[8vh]" role="dialog" aria-modal="true" aria-label={isEn ? "Search ESPDocs" : "Buscar no ESPDocs"}>
<div className="fixed inset-0 z-[120] flex items-start justify-center px-4 pt-[8vh]" role="dialog" aria-modal="true" aria-label={isEn ? "Search ESPDocs" : isEs ? "Buscar en ESPDocs" : "Buscar no ESPDocs"}>
<div className="dialog-scrim absolute inset-0 bg-black/45 backdrop-blur-sm" onClick={fechar} />
<div className="dialog-panel relative flex max-h-[80vh] w-full max-w-2xl flex-col overflow-hidden rounded-3xl border border-line bg-canvas shadow-2xl">
<div className="flex items-center gap-3 border-b border-line px-5">
@@ -260,17 +273,17 @@ export default function SearchDialog() {
value={consulta}
onChange={(e) => setConsulta(e.target.value)}
onKeyDown={onInputKey}
placeholder={isEn ? "Ask ESPDocs: “flash s3”, “usb pins c6”, “gpio0 esp32”…" : "Pergunte ao ESPDocs: “flash s3”, “pinos usb c6”, “gpio0 esp32”…"}
placeholder={isEn ? "Ask ESPDocs: “flash s3”, “usb pins c6”, “gpio0 esp32”…" : isEs ? "Pregunte a ESPDocs: “flash s3”, “pines usb c6”, “gpio0 esp32”…" : "Pergunte ao ESPDocs: “flash s3”, “pinos usb c6”, “gpio0 esp32”…"}
className="flex-1 bg-transparent py-5 text-base text-ink outline-none placeholder:text-muted"
autoComplete="off"
spellCheck={false}
aria-label={isEn ? "Search query" : "Termo de busca"}
aria-label={isEn ? "Search query" : isEs ? "Término de búsqueda" : "Termo de busca"}
/>
<button
type="button"
onClick={fechar}
className="grid h-9 w-9 cursor-pointer place-items-center rounded-full text-muted transition-colors hover:bg-surface-2 hover:text-ink"
aria-label={isEn ? "Close search" : "Fechar busca"}
aria-label={isEn ? "Close search" : isEs ? "Cerrar búsqueda" : "Fechar busca"}
>
<X className="h-4 w-4" />
</button>
@@ -278,7 +291,7 @@ export default function SearchDialog() {
{entendeu && (
<div className="flex flex-wrap items-center gap-2 border-b border-line bg-surface px-5 py-2.5">
<span className="text-xs font-semibold text-muted">{isEn ? "Understood:" : "Entendi:"}</span>
<span className="text-xs font-semibold text-muted">{isEn ? "Understood:" : isEs ? "Entendido:" : "Entendi:"}</span>
{interpretacao.chips.map((c) => (
<Pilula key={c.chave} forte>{c.nome}</Pilula>
))}
@@ -290,7 +303,7 @@ export default function SearchDialog() {
{sugestoes.length > 0 && (
<div className="flex flex-wrap items-center gap-2 border-b border-line px-5 py-2.5">
<span className="text-xs font-semibold text-muted">{interpretacao.chips.length ? (isEn ? "Ask about:" : "Perguntar sobre:") : (isEn ? "Which chip?" : "Para qual chip?")}</span>
<span className="text-xs font-semibold text-muted">{interpretacao.chips.length ? (isEn ? "Ask about:" : isEs ? "Preguntar sobre:" : "Perguntar sobre:") : (isEn ? "Which chip?" : isEs ? "¿Para qué chip?" : "Para qual chip?")}</span>
{sugestoes.map((s) => (
<Pilula key={s.rotulo} onClick={() => anexar(s.anexar)}>
{s.rotulo}
@@ -302,21 +315,21 @@ export default function SearchDialog() {
<div ref={listaRef} className="flex-1 overflow-y-auto p-2" role="listbox">
{erro && (
<div className="p-6 text-center">
<p className="text-sm font-semibold text-danger">{isEn ? "Could not load search index." : "Não foi possível carregar o índice de busca."}</p>
<p className="text-sm font-semibold text-danger">{isEn ? "Could not load search index." : isEs ? "No se pudo cargar el índice de búsqueda." : "Não foi possível carregar o índice de busca."}</p>
<button
type="button"
onClick={carregarIndice}
className="btn btn-primary btn-sm mt-3"
>
{isEn ? "Try again" : "Tentar novamente"}
{isEn ? "Try again" : isEs ? "Reintentar" : "Tentar novamente"}
</button>
</div>
)}
{!erro && !indice && <p className="p-6 text-center text-sm text-muted">{isEn ? "Loading…" : "Carregando…"}</p>}
{!erro && !indice && <p className="p-6 text-center text-sm text-muted">{isEn ? "Loading…" : isEs ? "Cargando…" : "Carregando…"}</p>}
{indice && vazia && (
<div className="px-3 pb-3 pt-1">
<p className="text-xs font-semibold text-muted">{isEn ? "Try asking" : "Experimente perguntar"}</p>
<p className="text-xs font-semibold text-muted">{isEn ? "Try asking" : isEs ? "Pruebe a preguntar" : "Experimente perguntar"}</p>
<div className="mt-2 flex flex-wrap gap-2">
{EXEMPLOS.map((ex) => (
<Pilula key={ex} onClick={() => setConsulta(ex)}>
@@ -324,14 +337,14 @@ export default function SearchDialog() {
</Pilula>
))}
</div>
<p className="mt-4 text-xs font-semibold text-muted">{isEn ? "Go to" : "Ir para"}</p>
<p className="mt-4 text-xs font-semibold text-muted">{isEn ? "Go to" : isEs ? "Ir a" : "Ir para"}</p>
</div>
)}
{indice && !vazia && resultados.length === 0 && (
<div className="p-8 text-center">
<p className="font-display text-lg font-semibold text-ink">{isEn ? `No results for “${consulta}”` : `Nenhum resultado para “${consulta}”`}</p>
<p className="mt-1 text-sm text-muted">{isEn ? "Try “brownout”, “flash s3”, “usb pins c6” or “LVGL”." : "Tente “brownout”, “flash s3”, “pinos usb c6” ou “LVGL”."}</p>
<p className="font-display text-lg font-semibold text-ink">{isEn ? `No results for “${consulta}”` : isEs ? `Sin resultados para “${consulta}”` : `Nenhum resultado para “${consulta}”`}</p>
<p className="mt-1 text-sm text-muted">{isEn ? "Try “brownout”, “flash s3”, “usb pins c6” or “LVGL”." : isEs ? "Pruebe “brownout”, “flash s3”, “pines usb c6” o “LVGL”." : "Tente “brownout”, “flash s3”, “pinos usb c6” ou “LVGL”."}</p>
</div>
)}
@@ -396,8 +409,8 @@ export default function SearchDialog() {
<div className="hidden items-center gap-4 border-t border-line bg-surface px-4 py-2.5 text-xs text-muted md:flex">
<span className="flex items-center gap-1"><ArrowUp className="h-3 w-3" /><ArrowDown className="h-3 w-3" /> {isEn ? "navigate" : "navegar"}</span>
<span className="flex items-center gap-1"><CornerDownLeft className="h-3 w-3" /> {isEn ? "open" : "abrir"}</span>
<span><kbd className="font-mono font-semibold">Esc</kbd> {isEn ? "close" : "fechar"}</span>
<span className="ml-auto">{vazia ? (isEn ? "Tip: press / on any page" : "Dica: pressione / em qualquer página") : `${resultados.length} ${isEn ? "result(s)" : "resultado(s)"}`}</span>
<span><kbd className="font-mono font-semibold">Esc</kbd> {isEn ? "close" : isEs ? "cerrar" : "fechar"}</span>
<span className="ml-auto">{vazia ? (isEn ? "Tip: press / on any page" : isEs ? "Consejo: presione / en cualquier página" : "Dica: pressione / em qualquer página") : `${resultados.length} ${isEn ? "result(s)" : "resultado(s)"}`}</span>
</div>
</div>
</div>,
+4 -2
View File
@@ -2,7 +2,9 @@
import { useEffect, useRef, useState } from "react";
export default function SeriesTabMenu({ tabs, isEn = false }) {
export default function SeriesTabMenu({ tabs, isEn = false, isEs = false, locale }) {
const effectiveIsEn = isEn || locale === "en";
const effectiveIsEs = isEs || locale === "es";
const availableTabs = tabs.filter((tab) => tab.available);
const [activeTab, setActiveTab] = useState(availableTabs[0]?.id);
const refs = useRef({});
@@ -59,7 +61,7 @@ export default function SeriesTabMenu({ tabs, isEn = false }) {
return (
<div id="diagrama-abas" className="scroll-mt-28">
<div role="tablist" aria-label={isEn ? "Board and diagram" : "Placa e diagrama"} className="no-scrollbar flex gap-2 overflow-x-auto pb-1">
<div role="tablist" aria-label={effectiveIsEn ? "Board and diagram" : effectiveIsEs ? "Placa y diagrama" : "Placa e diagrama"} className="no-scrollbar flex gap-2 overflow-x-auto pb-1">
{availableTabs.map((tab, indice) => (
<button
key={tab.id}