feat(i18n): routing and localization infrastructure (phase 1)

Implement Phase 1 of i18n spec:
- Configure next-intl with 6 locales (pt, en, es, fr, de, zh-CN) and pathnames
- Move routes to app/[locale] structure with localized layouts and metadata
- Add middleware for root language negotiation (307), legacy redirects (308), and 404 for non-pt book
- Implement language selector dropdown in header with cookie persistence
- Add system typography rules for zh-CN and word-break/hyphenation for de/fr

Co-Authored-By: Antigravity <noreply@google.com>
This commit is contained in:
Lucas RayanandAntigravity committed 2026-11-01 09:31:22 -03:00
1 parent e92aea0c84
commit 83c0ba3e74
38 files changed
+1699 -324

No files matched your search

-488
View File
@@ -1,488 +0,0 @@
import { paginaMeta, jsonLd } from "@/lib/seo";
import Link from "next/link";
import Image from "next/image";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import PageHero from "@/components/PageHero";
import { notFound } from "next/navigation";
import seriesData from "@/public/series.json";
import ConnectionsDiagram from "@/components/ConnectionsDiagram";
import SeriesTabMenu from "@/components/SeriesTabMenu";
import PinRestrictions from "@/components/PinRestrictions";
import {
AlertTriangle,
ArrowLeft,
ArrowRight,
ArrowUpRight,
Battery,
BookOpen,
Code,
Cpu,
Database,
ExternalLink,
FileText,
Lightbulb,
Package,
Scale,
ShieldCheck,
Sliders,
Thermometer,
Wifi,
} from "lucide-react";
export default async function SerieDetail({ params }) {
const { key } = await params;
const serie = seriesData[key];
if (!serie) {
notFound();
}
let conexoes = [];
try {
conexoes = require(`@/public/conexoes/${key}.json`);
} catch (error) {
console.log(`Arquivo de conexões não encontrado para ${key}`);
}
const seriesKeys = Object.keys(seriesData);
const currentIndex = seriesKeys.indexOf(key);
const previousKey = currentIndex > 0 ? seriesKeys[currentIndex - 1] : null;
const nextKey = currentIndex < seriesKeys.length - 1 ? seriesKeys[currentIndex + 1] : null;
const processadorSpecs = [
{ label: "Arquitetura", value: serie.arquitetura },
{ label: "Núcleos", value: serie.nucleos },
{ label: "Frequência", value: serie.frequencia },
{ label: "Coprocessador ULP", value: serie.coprocessador_ulp || "Não" },
{ label: "Aceleradores IA", value: serie.aceleradores_ia || "Não" },
];
const seg = serie.seguranca || {};
const segurancaSpecs = [
{ label: "AES", value: seg.aes || "Não" },
{ label: "SHA", value: seg.sha || "Não" },
{ label: "RSA / MPI", value: seg.rsa || "Não" },
{ label: "ECC (curvas elípticas)", value: seg.ecc || "Não" },
{ label: "ECDSA em hardware", value: seg.ecdsa || "Não" },
{ label: "HMAC", value: seg.hmac || "Não" },
{ label: "Assinatura Digital (DS)", value: seg.assinatura_digital || "Não" },
{ label: "Key Manager", value: seg.key_manager || "Não" },
{ label: "Gerador aleatório (RNG)", value: seg.rng || "Não" },
{ label: "Criptografia de flash", value: seg.criptografia_flash || "Não" },
{ label: "Secure Boot", value: seg.secure_boot || "Não" },
{ label: "Proteção contra DPA", value: seg.protecao_dpa || "Não" },
];
const ferramentasSpecs = [
{ label: "ESP-IDF mínimo", value: serie.esp_idf_minimo || "Não informado" },
{ label: "Core Arduino", value: serie.arduino_core || "Não informado" },
{ label: "Depuração", value: serie.depuracao || "Não informado" },
{ label: "Encapsulamento", value: serie.encapsulamento || "Não informado" },
{ label: "Módulos disponíveis", value: serie.modulos || "Não informado" },
];
const conectividadeSpecs = [
{ label: "Wi-Fi", value: serie.wifi || "Não" },
{ label: "Bluetooth", value: serie.bluetooth || "Não" },
{ label: "Zigbee/Thread", value: serie.zigbee_thread || "Não" },
{ label: "Matter", value: serie.matter || "Não" },
{ label: "Ethernet", value: serie.ethernet || serie.ethernet_mac || "Não" },
{ label: "CAN", value: serie.can || "Não" },
];
const memoriaSpecs = [
{ label: "SRAM", value: serie.memoria_sram },
{ label: "SRAM RTC", value: serie.memoria_sram_rtc },
{ label: "ROM", value: serie.memoria_rom },
{ label: "Flash Externa", value: serie.flash_externa },
{ label: "PSRAM Externa", value: serie.psram_externa || "Não" },
];
const perifericos = [
{ label: "GPIO", value: serie.gpio },
{ label: "ADC", value: serie.adc },
{ label: "DAC", value: serie.dac || "Não" },
{ label: "Touch", value: serie.touch || "Não" },
{ label: "UART", value: serie.uart },
{ label: "SPI", value: serie.spi },
{ label: "I2C", value: serie.i2c },
{ label: "I2S", value: serie.i2s },
{ label: "PWM", value: serie.pwm },
{ label: "USB", value: serie.usb || "Não" },
];
const interfacesEspeciais = [];
if (serie.lcd) interfacesEspeciais.push({ label: "LCD", value: serie.lcd });
if (serie.camera) interfacesEspeciais.push({ label: "Câmera", value: serie.camera });
if (serie.mipi_csi) interfacesEspeciais.push({ label: "MIPI CSI", value: serie.mipi_csi });
if (serie.mipi_dsi) interfacesEspeciais.push({ label: "MIPI DSI", value: serie.mipi_dsi });
if (serie.sdio) interfacesEspeciais.push({ label: "SDIO", value: serie.sdio });
if (serie.sensor_hall) interfacesEspeciais.push({ label: "Sensor Hall", value: serie.sensor_hall });
if (serie.sensor_temperatura) interfacesEspeciais.push({ label: "Sensor Temperatura", value: serie.sensor_temperatura });
// Consumo de Energia
const consumoEnergia = [];
if (serie.consumo_energia) {
Object.entries(serie.consumo_energia).forEach(([modo, consumo]) => {
const modoLabels = {
'active': 'Modo Ativo',
'active_cpu_240mhz': 'Ativo (CPU 240MHz)',
'active_cpu_160mhz': 'Ativo (CPU 160MHz)',
'active_wifi_tx': 'Ativo (Wi-Fi TX)',
'active_wifi_tx_peak': 'Ativo (Wi-Fi TX Pico)',
'active_wifi': 'Ativo (Wi-Fi)',
'active_wifi6': 'Ativo (Wi-Fi 6)',
'modem_sleep': 'Modem Sleep',
'modem_sleep_cpu_160mhz': 'Modem Sleep (CPU 160MHz)',
'light_sleep': 'Light Sleep',
'deep_sleep': 'Deep Sleep',
'hibernation': 'Hibernação'
};
consumoEnergia.push({
label: modoLabels[modo] || modo.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()),
value: consumo
});
});
}
const tabs = [
{
id: "devboard",
label: "Placa de desenvolvimento",
available: serie.placa,
content: (
<div className="card p-5 sm:p-7">
<h2 className="text-lg font-semibold text-ink">Kit de desenvolvimento oficial</h2>
<div className="mt-5 overflow-hidden rounded-xl border border-line bg-surface-2 p-4">
<Image
src={serie.placa}
alt={`Placa de desenvolvimento ${key}`}
width={1200}
height={800}
className="mx-auto h-auto max-h-[28rem] w-full rounded-lg object-contain"
priority
/>
</div>
<p className="mt-3 text-sm text-muted">Placa padrão da Espressif para a série {key}.</p>
</div>
),
},
{
id: "connections",
label: "Diagrama de conexões",
available: conexoes.length > 0,
content: (
<div className="card p-5 sm:p-7">
<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">Este é o diagrama do chip, não da placa</p>
<p className="mt-1">
O mapa mostra as conexões do silício do <strong>{key}</strong>. Cada placa de desenvolvimento
expõe só parte delas, ou as roteia de outra forma.
</p>
</div>
</div>
<div className="mt-6">
<ConnectionsDiagram connections={conexoes} serie={serie} />
</div>
<div className="callout mt-8">
<p className="flex items-center gap-2 font-semibold text-ink">
<Lightbulb className="h-4 w-4 text-accent" aria-hidden="true" />
Quase todo pino é reconfigurável
</p>
<p className="mt-1">
A matriz de IO do {key} permite mover SPI, I2C, UART, PWM e barramentos de câmera ou display
para a maioria dos pinos digitais. Para as restrições de strapping e dos pinos analógicos,
consulte o{" "}
<a
href={`https://docs.espressif.com/projects/esp-idf/en/latest/${String(key).toLowerCase().replace("-", "")}/api-reference/peripherals/gpio.html`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 font-medium text-accent underline decoration-accent/30 underline-offset-2 hover:decoration-accent"
>
guia oficial de GPIO
<ExternalLink className="h-3.5 w-3.5" aria-hidden="true" />
</a>
.
</p>
</div>
</div>
),
},
];
const secoes = [
{ id: "processador", titulo: "Processador", icon: Cpu, specs: processadorSpecs },
{ id: "conectividade", titulo: "Conectividade", icon: Wifi, specs: conectividadeSpecs },
{ id: "ferramentas", titulo: "Ferramentas e disponibilidade", icon: Package, specs: ferramentasSpecs },
{ id: "seguranca", titulo: "Segurança", icon: ShieldCheck, specs: segurancaSpecs },
{ id: "memoria", titulo: "Memória", icon: Database, specs: memoriaSpecs },
...(consumoEnergia.length > 0
? [{
id: "energia",
titulo: "Consumo de energia",
icon: Battery,
specs: consumoEnergia,
nota: "Valores típicos a 3,3 V e 25 °C. O consumo real depende do ciclo de trabalho do rádio e da frequência de clock configurada.",
}]
: []),
{ id: "perifericos", titulo: "Periféricos", icon: Sliders, specs: perifericos },
...(interfacesEspeciais.length > 0
? [{ id: "interfaces", titulo: "Interfaces especiais", icon: Sliders, specs: interfacesEspeciais }]
: []),
{
id: "temperatura",
titulo: "Limites de temperatura",
icon: Thermometer,
specs: [
{ label: "Operação", value: serie.temperatura_operacao },
{ label: "Armazenamento", value: serie.temperatura_armazenamento },
],
},
];
const fatos = [
{ label: "Processador", value: String(serie.nucleos).includes("2") ? "Dual-core" : "Single-core", detail: serie.frequencia },
{ label: "SRAM", value: serie.memoria_sram, detail: `+ ${serie.memoria_sram_rtc} de SRAM RTC` },
{ label: "GPIO", value: serie.gpio, detail: "pinos programáveis" },
{ label: "Arquitetura", value: String(serie.arquitetura).includes("RISC-V") ? "RISC-V" : "Xtensa", detail: serie.arquitetura },
];
return (
<div className="site-canvas min-h-screen">
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd.serie(key, serie)) }}
/>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{
__html: JSON.stringify(jsonLd.trilha([
{ nome: "Início", caminho: "/" },
{ nome: "Séries", caminho: "/series" },
{ nome: serie.nome_completo, caminho: `/series/${key}` },
])),
}}
/>
<Header />
<main id="conteudo">
<PageHero
trail={[{ label: "Séries", href: "/series" }, { label: key }]}
icon={Cpu}
label={serie.arquitetura}
title={key}
lede={serie.descricao}
>
{serie.datasheet ? (
<a href={serie.datasheet} target="_blank" rel="noopener noreferrer" className="btn btn-primary">
<FileText className="h-4 w-4" aria-hidden="true" />
Datasheet oficial
<ArrowUpRight className="h-4 w-4" aria-hidden="true" />
</a>
) : (
<span className="btn btn-ghost cursor-not-allowed opacity-70" aria-disabled="true">
<FileText className="h-4 w-4" aria-hidden="true" />
Datasheet ainda não publicado
</span>
)}
{serie.guia_de_programacao && (
<a href={serie.guia_de_programacao} target="_blank" rel="noopener noreferrer" className="btn btn-ghost">
<Code className="h-4 w-4" aria-hidden="true" />
Guia de programação
<ArrowUpRight className="h-4 w-4" aria-hidden="true" />
</a>
)}
{serie.manual_tecnico && (
<a href={serie.manual_tecnico} target="_blank" rel="noopener noreferrer" className="btn btn-ghost">
<BookOpen className="h-4 w-4" aria-hidden="true" />
Manual técnico
<ArrowUpRight className="h-4 w-4" aria-hidden="true" />
</a>
)}
</PageHero>
<section className="shell mt-10" aria-label="Resumo">
<dl className="grid grid-cols-2 gap-3 lg:grid-cols-4">
{fatos.map((fato) => (
<div key={fato.label} className="tile">
<dt className="text-xs font-medium text-muted">{fato.label}</dt>
<dd className="mt-1 font-display text-2xl font-semibold tracking-[-0.03em] text-ink">{fato.value}</dd>
<dd className="mt-0.5 text-xs text-muted">{fato.detail}</dd>
</div>
))}
</dl>
{serie.status_documentacao && (
<div className="callout callout-warn mt-4 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">Documentação preliminar</p>
<p className="mt-1">{serie.status_documentacao}</p>
</div>
</div>
)}
</section>
<div className="shell mt-10">
<SeriesTabMenu tabs={tabs} />
<PinRestrictions conexoes={conexoes} serieKey={key} />
</div>
<div className="shell mt-12">
<nav
aria-label="Seções da ficha"
className="nav-pill no-scrollbar sticky top-20 z-30 flex gap-1 overflow-x-auto rounded-full p-1.5"
>
{secoes.map((secao) => (
<a key={secao.id} href={`#${secao.id}`} className="nav-link shrink-0">
{secao.titulo}
</a>
))}
</nav>
<div className="mt-8 space-y-5">
{secoes.map((secao) => (
<SpecSection key={secao.id} {...secao} />
))}
</div>
</div>
<section className="shell section">
<div className="panel flex flex-col items-start justify-between gap-6 p-7 sm:p-10 md:flex-row md:items-center">
<div className="max-w-2xl">
<h2 className="section-title">Pronto para prototipar o {key}?</h2>
<p className="mt-3 text-base text-muted">
Veja as placas de desenvolvimento disponíveis para a série, com USB, sensores integrados e link para a loja oficial.
</p>
</div>
<Link href="/catalogo" className="btn btn-primary shrink-0">
Ver placas
<ArrowRight className="h-4 w-4" aria-hidden="true" />
</Link>
</div>
<nav aria-label="Outras séries" className="mt-6 grid gap-3 sm:grid-cols-[1fr_auto_1fr] sm:items-center">
{previousKey ? (
<Link href={`/series/${previousKey}`} className="card card-interactive group flex items-center gap-3 px-5 py-4">
<ArrowLeft className="h-4 w-4 text-muted transition-transform group-hover:-translate-x-1" aria-hidden="true" />
<span>
<span className="block text-xs text-muted">Série anterior</span>
<span className="block font-display text-lg font-semibold text-ink">{previousKey}</span>
</span>
</Link>
) : (
<span className="hidden sm:block" />
)}
<Link href="/comparacao" className="btn btn-ghost justify-self-center">
<Scale className="h-4 w-4" aria-hidden="true" />
Comparar séries
</Link>
{nextKey ? (
<Link href={`/series/${nextKey}`} className="card card-interactive group flex items-center justify-end gap-3 px-5 py-4 text-right">
<span>
<span className="block text-xs text-muted">Próxima série</span>
<span className="block font-display text-lg font-semibold text-ink">{nextKey}</span>
</span>
<ArrowRight className="h-4 w-4 text-muted transition-transform group-hover:translate-x-1" aria-hidden="true" />
</Link>
) : (
<span className="hidden sm:block" />
)}
</nav>
</section>
</main>
<Footer />
</div>
);
}
function SpecSection({ id, titulo, icon: Icon, specs, nota }) {
return (
<section id={id} aria-labelledby={`${id}-titulo`} className="card scroll-mt-36 p-5 sm:p-7">
<h2 id={`${id}-titulo`} className="flex items-center gap-3 font-display text-xl font-semibold tracking-[-0.03em] text-ink">
<span className="grid h-9 w-9 place-items-center rounded-full bg-accent-soft text-accent">
<Icon className="h-4 w-4" aria-hidden="true" />
</span>
{titulo}
</h2>
{nota && <p className="mt-3 text-sm text-muted">{nota}</p>}
<dl className="mt-5 grid grid-cols-2 gap-3 lg:grid-cols-3">
{specs.map((spec) => {
const negativo = /^n[ãa]o\b/i.test(String(spec.value));
return (
<div key={spec.label} className="tile">
<dt className="text-xs font-medium text-muted">{spec.label}</dt>
<dd className={`mt-1 text-sm font-semibold leading-snug ${negativo ? "text-muted" : "text-ink"}`}>{spec.value}</dd>
</div>
);
})}
</dl>
</section>
);
}
export async function generateMetadata({ params }) {
const { key } = await params;
const serie = seriesData[key];
if (!serie) {
return { title: "Série não encontrada", robots: { index: false, follow: false } };
}
// A descrição usa as especificações reais em vez de texto genérico: é o que
// aparece no resultado de busca e o que faz alguém clicar.
const radio = [
String(serie.wifi).startsWith("Não") ? null : serie.wifi.split(" (")[0],
String(serie.bluetooth).startsWith("Não") ? null : serie.bluetooth.split(" (")[0],
serie.zigbee_thread && !String(serie.zigbee_thread).startsWith("Não") ? "802.15.4" : null,
].filter(Boolean).join(", ");
const descricao =
`${serie.nome_completo}: ${serie.arquitetura}, ${serie.frequencia.replace("Até ", "até ")}, ` +
`${serie.memoria_sram} de SRAM e ${serie.gpio} GPIOs` +
(radio ? `. Rádio: ${radio}` : "") +
`. Pinagem completa, periféricos, segurança em hardware e pinos com restrição, em português.`;
// A pinagem da placa é a melhor prévia possível para uma série: quem
// compartilha o link já mostra o diagrama. Séries sem placa publicada caem
// na imagem padrão do site.
// Definir openGraph explicitamente impede a herança automática da imagem do
// site, então séries sem placa publicada (o H4) precisam do fallback aqui.
const imagem = serie.placa
? [{ url: serie.placa, width: 1600, height: 1091, alt: `Pinagem do ${serie.nome_completo}` }]
: [{ url: "/opengraph-image.png", width: 1200, height: 630, alt: "ESPDocs" }];
const meta = {
...paginaMeta({
titulo: `${serie.nome_completo}: especificações e pinagem`,
descricao,
caminho: `/series/${key}`,
tipo: "article",
keywords: [
serie.nome_completo, `${serie.nome_completo} pinout`,
`${serie.nome_completo} datasheet`, `${serie.nome_completo} especificações`,
`${serie.nome_completo} português`, "ESP32", "Espressif", "microcontrolador",
],
}),
other: { "article:section": "Séries ESP32" },
};
meta.openGraph = { ...meta.openGraph, images: imagem };
meta.twitter = { ...meta.twitter, images: imagem };
return meta;
}
export async function generateStaticParams() {
return Object.keys(seriesData).map((key) => ({
key: key,
}));
}