6 Commits
Author SHA1 Message Date
Lucas RayanandClaude Sonnet 5.5 22b1f26aed docs: changelog 1.2.0
Release / Plano (reaproveitar a imagem?) (push) Successful in 5s
Release / ci (push) Skipped
Release / Publica a imagem (linux/arm64) (push) Successful in 17s
Release / Cria a release no Gitea (push) Successful in 5s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VAyr8dT9k6tS5BbPrzYvAv
2026-11-01 12:28:06 -03:00
Lucas RayanandAntigravity d479a8d361 feat(i18n): restrict public routes and metadata to enabled locales
CI / Lint e auditorias (push) Successful in 41s
CI / Trivy (dependências e segredos) (push) Successful in 10s
CI / Imagem Docker (linux/arm64) (push) Successful in 2m5s
- Add ENABLED_LOCALES configuration in i18n/links.js (currently ["pt"])
- Redirect inactive locale prefixes to /pt via 307 in middleware.js
- Restrict root locale negotiation to active locales with fallback to pt
- Hide LanguageSelector when only one locale is enabled
- Generate sitemap, hreflang alternates, and og:locale only for active locales
- Generate static SSG pages only for active locales in build
- Adapt check:i18n and smoke-imagem.sh to validate active vs inactive locales

Co-Authored-By: Antigravity <noreply@google.com>
2026-11-01 12:10:25 -03:00
Lucas RayanandClaude Sonnet 5.5 3a1a095e97 ci: a real smoke test for the localized routes, and a single redirect from the root
CI / Lint e auditorias (push) Successful in 42s
CI / Trivy (dependências e segredos) (push) Successful in 8s
CI / Imagem Docker (linux/arm64) (push) Successful in 2m11s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 10:33:40 -03:00
Lucas RayanandClaude Sonnet 5.5 cbf724db2a fix(i18n): keep metadata in the initial <head> for every client
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 10:30:51 -03:00
Lucas RayanandAntigravity dcd4ef7e1b fix(i18n): extract navigation links module and restore verbatim pt footer
CI / Lint e auditorias (push) Successful in 44s
CI / Trivy (dependências e segredos) (push) Successful in 9s
CI / Imagem Docker (linux/arm64) (push) Successful in 3m56s
- Move getLocalizedHref, LOCALES, and CANONICAL_ROUTES to i18n/links.js to resolve SSR 500 error on home
- Import from @/i18n/links in page.jsx, Header.jsx, Footer.jsx, and LanguageSelector.jsx
- Restore exact production footer texts, author links, and SVG icons in pt
- Verify 100% visible text parity on all pt pages against production with cmp-pt.py

Co-Authored-By: Antigravity <noreply@google.com>
2026-11-01 10:19:22 -03:00
Lucas RayanandClaude Sonnet 5.5 02dee65b2b fix: regenerate the lockfile with npm 10 so npm ci passes on Node 22
CI / Lint e auditorias (push) Successful in 1m26s
CI / Trivy (dependências e segredos) (push) Successful in 9s
CI / Imagem Docker (linux/arm64) (push) Successful in 2m44s
next-intl pulls @swc/helpers 0.5.23, and the lockfile written by npm 12 (Node 24)
left it out, which npm 10 on the CI and in the Dockerfile rejects.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 10:03:55 -03:00
15 changed files with 465 additions and 256 deletions

No files matched your search

+9 -6
View File
@@ -99,17 +99,20 @@ jobs:
docker run --rm --network host -v trivy-cache:/root/.cache/ -e DOCKER_HOST=tcp://127.0.0.1:2375 aquasec/trivy:0.70.0 \
image --severity HIGH,CRITICAL --exit-code 1 espdocs:ci
# Rotas por idioma, redirects e regras do livro (scripts/smoke-imagem.sh).
# Um curl só na raiz não basta: ela redireciona, e uma página interna
# quebrada ainda passaria.
- name: A imagem sobe e responde
if: ${{ steps.plano.outputs.reuse != 'true' }}
run: |
docker run -d --name espdocs-ci --network host -e PORT=3999 espdocs:ci
for i in $(seq 1 30); do
curl -fsS http://127.0.0.1:3999/ >/dev/null && ok=1 && break
sleep 2
done
docker logs espdocs-ci | tail -5
status=0
scripts/smoke-imagem.sh http://127.0.0.1:3999 || status=1
docker logs espdocs-ci | tail -15
# O Next registra falhas de render no log mesmo quando responde.
if docker logs espdocs-ci 2>&1 | grep -q '⨯'; then echo "O servidor registrou erro de render"; status=1; fi
docker rm -f espdocs-ci >/dev/null
test "${ok:-0}" = 1
exit "$status"
# Passou em tudo: publica a mesma imagem que foi testada, sem reconstruir.
- name: Publica inputs-<hash>
+24 -1
View File
@@ -6,6 +6,8 @@ Tudo o que muda de relevante no ESPDocs está aqui. O formato segue o
## [Não lançado]
## [1.2.0] - 2026-11-01
### Adicionado
- Infraestrutura de internacionalização com `next-intl` (Fase 1):
@@ -17,6 +19,26 @@ Tudo o que muda de relevante no ESPDocs está aqui. O formato segue o
- Catálogo de mensagens canônico `messages/pt.json` cobrindo 15 namespaces de páginas e componentes sem alteração de conteúdo.
- Conjunto de dados canônicos em `data/pt/` (`series.json`, `frameworks.json`, `conexoes/*.json`) e módulos em `lib/data/pt/` (`glossario.js`, `diagnostico.js`, `componentes.js`, `topicos.js`) com carregadores por locale em `lib/data/index.js`.
- Script automatizado de validação `check:i18n` integrado ao `npm run check`, verificando paridade de chaves, ausência de valores vazios e garantia estrita de ausência de referências ao livro fora do português.
- Controle de idiomas ativos e proteção contra conteúdo não traduzido:
- Configuração centralizada em `i18n/links.js` (`ENABLED_LOCALES`), atualmente restrita a `["pt"]`.
- Redirecionamento 307 de qualquer prefixo de idioma inativo para `/pt` preservando o caminho ou mapeamento equivalente.
- Negociação de idioma na raiz selecionando unicamente entre os idiomas ativos (com fallback em `pt`).
- Seletor de idiomas no cabeçalho oculto enquanto apenas um idioma estiver ativo.
- Sitemap, hreflang/alternates e build SSG restritos aos idiomas ativos.
### Alterado
- Todas as páginas passam a viver sob `/pt/` (por exemplo `/pt/series`). As URLs
antigas, sem prefixo, redirecionam com 308 para o endereço novo, e a raiz `/`
redireciona para o idioma negociado. O conteúdo do site em português não mudou.
- O teste de subida da imagem na CI virou `scripts/smoke-imagem.sh`: confere as
rotas de cada idioma, a negociação de `Accept-Language`, os redirects 308 das
URLs antigas, o 404 do livro fora do português e a metadata no `<head>`, e
falha se o servidor registrar erro de render. O `curl` só na raiz deixou passar
uma home quebrada.
- A metadata (título, `canonical`, Open Graph) sempre sai no `<head>` do HTML
inicial, para qualquer cliente, como já era antes do i18n.
- A raiz `/` redireciona direto para `/<idioma>`, em um salto só.
## [1.1.0] - 2026-11-01
@@ -92,6 +114,7 @@ entrada resume tudo desde o primeiro commit, em 2025-10-21, até a imagem Docker
otimização de imagens (GHSA-2464-fhr4-w9xw), e `postcss` fixado acima da cópia
embutida no `next`.
[Não lançado]: https://git.lucasrguerra.dev.br/lucasrguerra/ESPDocs/compare/v1.1.0...HEAD
[Não lançado]: https://git.lucasrguerra.dev.br/lucasrguerra/ESPDocs/compare/v1.2.0...HEAD
[1.2.0]: https://git.lucasrguerra.dev.br/lucasrguerra/ESPDocs/compare/v1.1.0...v1.2.0
[1.1.0]: https://git.lucasrguerra.dev.br/lucasrguerra/ESPDocs/compare/v1.0.0...v1.1.0
[1.0.0]: https://git.lucasrguerra.dev.br/lucasrguerra/ESPDocs/releases/tag/v1.0.0
+12 -3
View File
@@ -3,6 +3,7 @@ import { JetBrains_Mono, Manrope, Space_Grotesk } from "next/font/google";
import { NextIntlClientProvider } from "next-intl";
import { getMessages } from "next-intl/server";
import { routing } from "@/i18n/routing";
import { ENABLED_LOCALES } from "@/i18n/links";
import "../globals.css";
import Providers from "@/components/Providers";
import { SITE, jsonLd } from "@/lib/seo";
@@ -38,7 +39,7 @@ const LANG_MAP = {
};
export function generateStaticParams() {
return routing.locales.map((locale) => ({ locale }));
return ENABLED_LOCALES.map((locale) => ({ locale }));
}
export async function generateMetadata({ params }) {
@@ -52,6 +53,8 @@ export async function generateMetadata({ params }) {
"zh-CN": "zh_CN",
};
const alternateLocales = ENABLED_LOCALES.filter((l) => l !== locale).map((l) => ogLocaleMap[l]);
return {
metadataBase: new URL(SITE.url),
title: {
@@ -71,10 +74,16 @@ export async function generateMetadata({ params }) {
"comparar ESP32", "qual ESP32 escolher", "datasheet ESP32",
"microcontrolador", "Espressif", "ESP-IDF", "IoT", "sistemas embarcados",
],
alternates: { canonical: `/${locale}` },
alternates: {
canonical: `/${locale}`,
languages: Object.fromEntries(
ENABLED_LOCALES.map((l) => [LANG_MAP[l] || l, `/${l}`])
),
},
openGraph: {
type: "website",
locale: ogLocaleMap[locale] || "pt_BR",
alternateLocale: alternateLocales.length > 0 ? alternateLocales : undefined,
url: `${SITE.url}/${locale}`,
siteName: SITE.nome,
title: "ESPDocs · Documentação do ESP32 em português",
@@ -127,7 +136,7 @@ export const viewport = {
export default async function RootLayout({ children, params }) {
const { locale } = await params;
if (!routing.locales.includes(locale)) {
if (!ENABLED_LOCALES.includes(locale)) {
notFound();
}
+1 -1
View File
@@ -5,7 +5,7 @@ import Link from "next/link";
import seriesData from "@/public/series.json";
import frameworksData from "@/public/frameworks.json";
import { SITE } from "@/lib/seo";
import { getLocalizedHref } from "@/components/LanguageSelector";
import { getLocalizedHref } from "@/i18n/links";
import {
AlertTriangle,
ArrowRight,
+55 -28
View File
@@ -1,51 +1,78 @@
import seriesData from "@/public/series.json";
import frameworksData from "@/public/frameworks.json";
import { SITE } from "@/lib/seo";
import { ENABLED_LOCALES, getLocalizedHref } from "@/i18n/links";
const LANG_MAP = {
pt: "pt-BR",
en: "en",
es: "es-419",
fr: "fr",
de: "de",
"zh-CN": "zh-CN",
};
/**
* Sitemap gerado a partir dos próprios dados do site.
*
* Antes não existia sitemap nenhum, então o Google só chegava às páginas de
* série e de framework se topasse com um link. Como são geradas a partir de
* JSON, uma série nova entra aqui sozinha.
* Sitemap gerado a partir dos próprios dados do site e dos idiomas ativos.
* Inclui URLs apenas para ENABLED_LOCALES, com hreflang/alternates correspondentes.
* O livro só entra para a versão em português.
*/
export default function sitemap() {
// Usar uma data de revisão estável evita que todo deploy force o Googlebot
// a reprocessar páginas idênticas ao mesmo tempo.
const dataRevisao = new Date("2026-08-25T00:00:00.000Z");
const fixas = [
{ caminho: "", prioridade: 1.0, frequencia: "weekly", lastModified: dataRevisao },
{ caminho: "/series", prioridade: 0.9, frequencia: "weekly", lastModified: dataRevisao },
{ caminho: "/comparacao", prioridade: 0.9, frequencia: "weekly", lastModified: dataRevisao },
{ caminho: "/diagnostico", prioridade: 0.9, frequencia: "weekly", lastModified: dataRevisao },
{ caminho: "/seletor", prioridade: 0.8, frequencia: "monthly", lastModified: dataRevisao },
{ caminho: "/frameworks", prioridade: 0.8, frequencia: "monthly", lastModified: dataRevisao },
{ caminho: "/componentes", prioridade: 0.8, frequencia: "weekly", lastModified: dataRevisao },
{ caminho: "/livro", prioridade: 0.7, frequencia: "monthly", lastModified: dataRevisao },
{ caminho: "/glossario", prioridade: 0.7, frequencia: "monthly", lastModified: dataRevisao },
{ caminho: "/catalogo", prioridade: 0.6, frequencia: "weekly", lastModified: dataRevisao },
{ caminho: "/sobre", prioridade: 0.4, frequencia: "yearly", lastModified: dataRevisao },
{ rota: "", prioridade: 1.0, frequencia: "weekly" },
{ rota: "/series", prioridade: 0.9, frequencia: "weekly" },
{ rota: "/comparacao", prioridade: 0.9, frequencia: "weekly" },
{ rota: "/diagnostico", prioridade: 0.9, frequencia: "weekly" },
{ rota: "/seletor", prioridade: 0.8, frequencia: "monthly" },
{ rota: "/frameworks", prioridade: 0.8, frequencia: "monthly" },
{ rota: "/componentes", prioridade: 0.8, frequencia: "weekly" },
{ rota: "/livro", prioridade: 0.7, frequencia: "monthly", apenasPt: true },
{ rota: "/glossario", prioridade: 0.7, frequencia: "monthly" },
{ rota: "/catalogo", prioridade: 0.6, frequencia: "weekly" },
{ rota: "/sobre", prioridade: 0.4, frequencia: "yearly" },
];
const series = Object.keys(seriesData).map((chave) => ({
caminho: `/series/${chave}`,
rota: `/series/${chave}`,
prioridade: 0.9,
frequencia: "monthly",
lastModified: dataRevisao,
}));
const frameworks = Object.keys(frameworksData).map((chave) => ({
caminho: `/frameworks/${encodeURIComponent(chave)}`,
rota: `/frameworks/${encodeURIComponent(chave)}`,
prioridade: 0.6,
frequencia: "monthly",
lastModified: dataRevisao,
}));
return [...fixas, ...series, ...frameworks].map(({ caminho, prioridade, frequencia, lastModified }) => ({
url: `${SITE.url}${caminho}`,
lastModified,
changeFrequency: frequencia,
priority: prioridade,
}));
const todasRotas = [...fixas, ...series, ...frameworks];
const entries = [];
for (const item of todasRotas) {
for (const loc of ENABLED_LOCALES) {
if (item.apenasPt && loc !== "pt") continue;
const href = getLocalizedHref(`/pt${item.rota}`, loc);
const url = `${SITE.url}${href}`;
const languages = {};
for (const l of ENABLED_LOCALES) {
if (item.apenasPt && l !== "pt") continue;
languages[LANG_MAP[l] || l] = `${SITE.url}${getLocalizedHref(`/pt${item.rota}`, l)}`;
}
entries.push({
url,
lastModified: dataRevisao,
changeFrequency: item.frequencia,
priority: item.prioridade,
alternates: {
languages,
},
});
}
}
return entries;
}
+36 -31
View File
@@ -6,16 +6,16 @@ import Logo from "@/components/Logo";
import GiteaMark from "@/components/GiteaMark";
import { ArrowUpRight } from "lucide-react";
import { SITE } from "@/lib/seo";
import { LOCALES, getLocalizedHref } from "@/components/LanguageSelector";
import { LOCALES, getLocalizedHref } from "@/i18n/links";
const BASE_COLUMNS = [
const COLUMNS = [
{
title: "Explorar",
links: [
{ href: "/series", label: "Séries ESP32" },
{ href: "/comparacao", label: "Comparador" },
{ href: "/catalogo", label: "Catálogo de placas" },
{ href: "/livro", label: "O livro", livroOnly: true },
{ href: "/livro", label: "O livro" },
],
},
{
@@ -48,9 +48,9 @@ export default function Footer() {
const getLinkHref = (href) => getLocalizedHref(`/${currentLocale}${href}`, currentLocale);
const columns = BASE_COLUMNS.map((col) => ({
...col,
links: col.links.filter((l) => !l.livroOnly || isPt),
const columns = COLUMNS.map((column) => ({
...column,
links: column.links.filter((link) => link.href !== "/livro" || isPt),
}));
return (
@@ -139,40 +139,45 @@ export default function Footer() {
<br />
<span className="text-xs text-faint">Ciência Embarcada</span>
</p>
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-sm">
<a href={SITE.autorUrl} target="_blank" rel="noopener noreferrer" className="inline-flex min-h-11 items-center text-muted transition-colors hover:text-accent">
Site pessoal
<div className="mt-4 flex items-center gap-2">
<a
href={SITE.repositorio}
target="_blank"
rel="noopener noreferrer"
aria-label="Gitea do projeto (abre em nova aba)"
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" />
</a>
<a href={SITE.autorLinkedin} target="_blank" rel="noopener noreferrer" className="inline-flex min-h-11 items-center text-muted transition-colors hover:text-accent">
LinkedIn
<a
href={SITE.autorLinkedin}
target="_blank"
rel="noopener noreferrer"
aria-label="LinkedIn do autor (abre em nova aba)"
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">
<path d="M4.98 3.5a2.5 2.5 0 1 1 0 5.001 2.5 2.5 0 0 1 0-5zM3 8.98h4v12H3v-12zm6.5 0h3.8v1.6h.1c.5-.9 1.8-1.9 3.6-1.9 3.8 0 4.5 2.5 4.5 5.8v6.5h-4v-5.8c0-1.4-.1-3.2-2-3.2-2 0-2.3 1.6-2.3 3.1v5.9h-4v-12z" />
</svg>
</a>
</div>
</div>
</div>
</div>
<div className="mt-12 flex flex-col items-center justify-between gap-4 border-t border-line pt-8 text-xs text-muted sm:flex-row">
<p>© {year} ESPDocs · Conteúdo sob licença MIT. Feito para a comunidade de sistemas embarcados.</p>
<div className="flex items-center gap-6">
<a
href={SITE.repositorio}
target="_blank"
rel="noopener noreferrer"
className="inline-flex min-h-11 items-center gap-1.5 transition-colors hover:text-accent"
>
<GiteaMark className="h-4 w-4" aria-hidden="true" />
<span>Gitea</span>
</a>
<span>·</span>
<a
href={SITE.blog}
target="_blank"
rel="noopener noreferrer"
className="inline-flex min-h-11 items-center transition-colors hover:text-accent"
>
<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} ESPDocs. Projeto independente, sem afiliação com a 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
<ArrowUpRight className="h-3 w-3" aria-hidden="true" />
</a>
</div>
<a href={SITE.autorUrl} target="_blank" rel="noopener noreferrer" className="inline-flex min-h-11 items-center gap-1 transition-colors hover:text-accent">
lucasrguerra.dev.br
<ArrowUpRight className="h-3 w-3" aria-hidden="true" />
</a>
<span>Fontes oficiais: Espressif Systems.</span>
</p>
</div>
</div>
</footer>
+2 -1
View File
@@ -5,7 +5,8 @@ import { createPortal } from "react-dom";
import Link from "next/link";
import Logo from "@/components/Logo";
import SearchDialog from "@/components/SearchDialog";
import LanguageSelector, { LOCALES, getLocalizedHref } from "@/components/LanguageSelector";
import LanguageSelector from "@/components/LanguageSelector";
import { LOCALES, getLocalizedHref } from "@/i18n/links";
import { usePathname } from "next/navigation";
import { useTheme } from "next-themes";
import {
+6 -124
View File
@@ -3,133 +3,15 @@
import { useEffect, useRef, useState } from "react";
import { usePathname } from "next/navigation";
import { ChevronDown, Globe } from "lucide-react";
import { LOCALES, getLocalizedHref } from "@/i18n/links";
export const LOCALES = [
{ code: "pt", label: "Português", short: "PT", lang: "pt-BR" },
{ code: "en", label: "English", short: "EN", lang: "en" },
{ code: "es", label: "Español", short: "ES", lang: "es-419" },
{ code: "fr", label: "Français", short: "FR", lang: "fr" },
{ code: "de", label: "Deutsch", short: "DE", lang: "de" },
{ code: "zh-CN", label: "简体中文", short: "中", lang: "zh-CN" },
];
const CANONICAL_ROUTES = [
{
id: "series_item",
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/(?:series|serien)\/([^/]+)/,
getSlug: (loc, key) => (loc === "de" ? `/serien/${key}` : `/series/${key}`),
},
{
id: "frameworks_item",
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/frameworks\/([^/]+)/,
getSlug: (loc, key) => `/frameworks/${key}`,
},
{
id: "series",
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/(?:series|serien)(?:\/|$)/,
getSlug: (loc) => (loc === "de" ? "/serien" : "/series"),
},
{
id: "catalogo",
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/(?:catalogo|catalog|catalogue|katalog)(?:\/|$)/,
getSlug: (loc) => {
if (loc === "de") return "/katalog";
if (loc === "fr") return "/catalogue";
if (loc === "en" || loc === "zh-CN") return "/catalog";
return "/catalogo";
},
},
{
id: "comparacao",
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/(?:comparacao|compare|comparacion|comparaison|vergleich)(?:\/|$)/,
getSlug: (loc) => {
if (loc === "en" || loc === "zh-CN") return "/compare";
if (loc === "es") return "/comparacion";
if (loc === "fr") return "/comparaison";
if (loc === "de") return "/vergleich";
return "/comparacao";
},
},
{
id: "componentes",
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/(?:componentes|components|composants|komponenten)(?:\/|$)/,
getSlug: (loc) => {
if (loc === "en" || loc === "zh-CN") return "/components";
if (loc === "fr") return "/composants";
if (loc === "de") return "/komponenten";
return "/componentes";
},
},
{
id: "diagnostico",
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/(?:diagnostico|diagnostics|diagnostic|diagnose)(?:\/|$)/,
getSlug: (loc) => {
if (loc === "en" || loc === "zh-CN") return "/diagnostics";
if (loc === "fr") return "/diagnostic";
if (loc === "de") return "/diagnose";
return "/diagnostico";
},
},
{
id: "frameworks",
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/frameworks(?:\/|$)/,
getSlug: () => "/frameworks",
},
{
id: "glossario",
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/(?:glossario|glossary|glosario|glossaire|glossar)(?:\/|$)/,
getSlug: (loc) => {
if (loc === "en" || loc === "zh-CN") return "/glossary";
if (loc === "es") return "/glosario";
if (loc === "fr") return "/glossaire";
if (loc === "de") return "/glossar";
return "/glossario";
},
},
{
id: "seletor",
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/(?:seletor|selector|selecteur|auswahl)(?:\/|$)/,
getSlug: (loc) => {
if (loc === "en" || loc === "es" || loc === "zh-CN") return "/selector";
if (loc === "fr") return "/selecteur";
if (loc === "de") return "/auswahl";
return "/seletor";
},
},
{
id: "sobre",
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/(?:sobre|about|acerca|a-propos|ueber-uns)(?:\/|$)/,
getSlug: (loc) => {
if (loc === "en" || loc === "zh-CN") return "/about";
if (loc === "es") return "/acerca";
if (loc === "fr") return "/a-propos";
if (loc === "de") return "/ueber-uns";
return "/sobre";
},
},
{
id: "livro",
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/livro(?:\/|$)/,
getSlug: (loc) => (loc === "pt" ? "/livro" : ""),
},
];
export function getLocalizedHref(pathname, targetLocale) {
if (!pathname || pathname === "/" || pathname === `/${targetLocale}` || pathname === `/${targetLocale}/`) {
return `/${targetLocale}/`;
}
for (const route of CANONICAL_ROUTES) {
const m = pathname.match(route.match);
if (m) {
const slug = route.getSlug(targetLocale, m[1]);
return slug ? `/${targetLocale}${slug}` : `/${targetLocale}/`;
}
}
return `/${targetLocale}/`;
}
export { LOCALES, getLocalizedHref };
export default function LanguageSelector() {
if (LOCALES.length <= 1) {
return null;
}
const pathname = usePathname() || "";
const [open, setOpen] = useState(false);
const [hash, setHash] = useState("");
+149
View File
@@ -0,0 +1,149 @@
/**
* Utilitários de rotas e navegação internacionalizada.
* Módulo puro (sem 'use client') seguro para Server e Client Components.
*/
// Ponto único de configuração de idiomas ATIVOS.
// Para ativar um novo idioma no site, basta adicionar seu código aqui (ex.: ["pt", "en"]).
export const ENABLED_LOCALES = ["pt"];
export const ALL_LOCALES = [
{ code: "pt", label: "Português", short: "PT", lang: "pt-BR", og: "pt_BR" },
{ code: "en", label: "English", short: "EN", lang: "en", og: "en_US" },
{ code: "es", label: "Español", short: "ES", lang: "es-419", og: "es_419" },
{ code: "fr", label: "Français", short: "FR", lang: "fr", og: "fr_FR" },
{ code: "de", label: "Deutsch", short: "DE", lang: "de", og: "de_DE" },
{ code: "zh-CN", label: "简体中文", short: "中", lang: "zh-CN", og: "zh_CN" },
];
export const ALL_LOCALE_CODES = ALL_LOCALES.map((l) => l.code);
export const LOCALES = ALL_LOCALES.filter((l) => ENABLED_LOCALES.includes(l.code));
export const CANONICAL_ROUTES = [
{
id: "series_item",
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/(?:series|serien)\/([^/]+)/,
getSlug: (loc, key) => (loc === "de" ? `/serien/${key}` : `/series/${key}`),
},
{
id: "frameworks_item",
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/frameworks\/([^/]+)/,
getSlug: (loc, key) => `/frameworks/${key}`,
},
{
id: "series",
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/(?:series|serien)(?:\/|$)/,
getSlug: (loc) => (loc === "de" ? "/serien" : "/series"),
},
{
id: "catalogo",
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/(?:catalogo|catalog|catalogue|katalog)(?:\/|$)/,
getSlug: (loc) => {
if (loc === "de") return "/katalog";
if (loc === "fr") return "/catalogue";
if (loc === "en" || loc === "zh-CN") return "/catalog";
return "/catalogo";
},
},
{
id: "comparacao",
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/(?:comparacao|compare|comparacion|comparaison|vergleich)(?:\/|$)/,
getSlug: (loc) => {
if (loc === "en" || loc === "zh-CN") return "/compare";
if (loc === "es") return "/comparacion";
if (loc === "fr") return "/comparaison";
if (loc === "de") return "/vergleich";
return "/comparacao";
},
},
{
id: "componentes",
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/(?:componentes|components|composants|komponenten)(?:\/|$)/,
getSlug: (loc) => {
if (loc === "en" || loc === "zh-CN") return "/components";
if (loc === "fr") return "/composants";
if (loc === "de") return "/komponenten";
return "/componentes";
},
},
{
id: "diagnostico",
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/(?:diagnostico|diagnostics|diagnostic|diagnose)(?:\/|$)/,
getSlug: (loc) => {
if (loc === "en" || loc === "zh-CN") return "/diagnostics";
if (loc === "fr") return "/diagnostic";
if (loc === "de") return "/diagnose";
return "/diagnostico";
},
},
{
id: "frameworks",
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/frameworks(?:\/|$)/,
getSlug: () => "/frameworks",
},
{
id: "glossario",
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/(?:glossario|glossary|glosario|glossaire|glossar)(?:\/|$)/,
getSlug: (loc) => {
if (loc === "en" || loc === "zh-CN") return "/glossary";
if (loc === "es") return "/glosario";
if (loc === "fr") return "/glossaire";
if (loc === "de") return "/glossar";
return "/glossario";
},
},
{
id: "seletor",
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/(?:seletor|selector|selecteur|auswahl)(?:\/|$)/,
getSlug: (loc) => {
if (loc === "en" || loc === "es" || loc === "zh-CN") return "/selector";
if (loc === "fr") return "/selecteur";
if (loc === "de") return "/auswahl";
return "/seletor";
},
},
{
id: "sobre",
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/(?:sobre|about|acerca|a-propos|ueber-uns)(?:\/|$)/,
getSlug: (loc) => {
if (loc === "en" || loc === "zh-CN") return "/about";
if (loc === "es") return "/acerca";
if (loc === "fr") return "/a-propos";
if (loc === "de") return "/ueber-uns";
return "/sobre";
},
},
{
id: "livro",
match: /^\/(?:pt|en|es|fr|de|zh-CN)\/livro(?:\/|$)/,
getSlug: (loc) => (loc === "pt" ? "/livro" : ""),
},
];
export function getLocalizedHref(pathname, targetLocale) {
if (!pathname || pathname === "/" || pathname === `/${targetLocale}` || pathname === `/${targetLocale}/`) {
return `/${targetLocale}/`;
}
for (const route of CANONICAL_ROUTES) {
const m = pathname.match(route.match);
if (m) {
const slug = route.getSlug(targetLocale, m[1]);
return slug ? `/${targetLocale}${slug}` : `/${targetLocale}/`;
}
}
return `/${targetLocale}/`;
}
export function getPtPath(pathname) {
if (!pathname || pathname === "/" || pathname === "/pt" || pathname === "/pt/") return "/pt";
for (const route of CANONICAL_ROUTES) {
const m = pathname.match(route.match);
if (m) {
const slug = route.getSlug("pt", m[1]);
return slug ? `/pt${slug}` : "/pt";
}
}
return "/pt";
}
+3 -2
View File
@@ -1,8 +1,9 @@
import { defineRouting } from "next-intl/routing";
import { ENABLED_LOCALES } from "./links";
export const routing = defineRouting({
locales: ["pt", "en", "es", "fr", "de", "zh-CN"],
defaultLocale: "en",
locales: ENABLED_LOCALES,
defaultLocale: ENABLED_LOCALES.includes("en") ? "en" : (ENABLED_LOCALES[0] || "pt"),
localePrefix: "always",
pathnames: {
"/": "/",
+37 -14
View File
@@ -1,11 +1,10 @@
import createMiddleware from "next-intl/middleware";
import { NextResponse } from "next/server";
import { routing } from "./i18n/routing";
import { ALL_LOCALE_CODES, ENABLED_LOCALES, getPtPath } from "./i18n/links";
const intlMiddleware = createMiddleware(routing);
const SUPPORTED_LOCALES = routing.locales;
function parseAcceptLanguage(header) {
if (!header) return [];
return header
@@ -37,16 +36,18 @@ function matchLocale(lang) {
function resolveRootLocale(request) {
const cookieValue = request.cookies.get("NEXT_LOCALE")?.value;
if (cookieValue && SUPPORTED_LOCALES.includes(cookieValue)) {
if (cookieValue && ENABLED_LOCALES.includes(cookieValue)) {
return cookieValue;
}
const acceptLang = request.headers.get("accept-language");
const parsed = parseAcceptLanguage(acceptLang);
for (const { lang } of parsed) {
const matched = matchLocale(lang);
if (matched) return matched;
if (matched && ENABLED_LOCALES.includes(matched)) {
return matched;
}
}
return "en";
return ENABLED_LOCALES.includes("en") ? "en" : (ENABLED_LOCALES[0] || "pt");
}
export default function middleware(request) {
@@ -64,10 +65,10 @@ export default function middleware(request) {
return NextResponse.next();
}
// 1. Raiz: negociação de idioma (307)
// 1. Raiz: negociação de idioma (307) considerando apenas idiomas ATIVOS
if (pathname === "/") {
const targetLocale = resolveRootLocale(request);
const url = new URL(`/${targetLocale}/`, request.url);
const url = new URL(`/${targetLocale}`, request.url);
url.search = search;
const response = NextResponse.redirect(url, 307);
response.headers.set("Vary", "Accept-Language, Cookie");
@@ -75,23 +76,45 @@ export default function middleware(request) {
return response;
}
// 2. Livro fora do português: 404
if (/^\/(en|es|fr|de|zh-CN)\/livro(\/.*)?$/.test(pathname)) {
return new NextResponse(null, { status: 404 });
// 2. Idiomas INATIVOS: prefixos (/en, /es, /fr, /de, /zh-CN e qualquer subcaminho)
// Redirecionam com 307 para o mesmo caminho em /pt quando o slug existir lá; se não souber mapear o slug, para /pt.
// O 404 do livro fora de pt não se aplica a inativos (redirecionam para /pt/livro aqui).
const inactiveLocales = ALL_LOCALE_CODES.filter((l) => !ENABLED_LOCALES.includes(l));
if (inactiveLocales.length > 0) {
const isInactive = inactiveLocales.some(
(loc) => pathname === `/${loc}` || pathname.startsWith(`/${loc}/`)
);
if (isInactive) {
const targetPath = getPtPath(pathname);
const url = new URL(targetPath, request.url);
url.search = search;
return NextResponse.redirect(url, 307);
}
}
// 3. URLs antigas sem prefixo reconhecido: redirecionamento 308 para /pt/<caminho>
const hasLocalePrefix = SUPPORTED_LOCALES.some(
// 3. Livro fora do português para idiomas ATIVOS que não são pt: 404
const activeNonPt = ENABLED_LOCALES.filter((l) => l !== "pt");
if (activeNonPt.length > 0) {
const isBookInOtherActiveLocale = activeNonPt.some(
(loc) => pathname === `/${loc}/livro` || pathname.startsWith(`/${loc}/livro/`)
);
if (isBookInOtherActiveLocale) {
return new NextResponse(null, { status: 404 });
}
}
// 4. URLs antigas sem prefixo reconhecido: redirecionamento 308 para /pt/<caminho>
const hasActivePrefix = ENABLED_LOCALES.some(
(loc) => pathname === `/${loc}` || pathname.startsWith(`/${loc}/`)
);
if (!hasLocalePrefix) {
if (!hasActivePrefix) {
const url = new URL(`/pt${pathname}`, request.url);
url.search = search;
return NextResponse.redirect(url, 308);
}
// 4. Delegação para o roteamento do next-intl
// 5. Delegação para o roteamento do next-intl para idiomas ativos
return intlMiddleware(request);
}
+4
View File
@@ -7,6 +7,10 @@ const nextConfig = {
// A imagem Docker liga isto (NEXT_OUTPUT=standalone) para empacotar só o que
// o servidor usa. Fora dela o build continua igual: `next start`/pm2.
output: process.env.NEXT_OUTPUT === "standalone" ? "standalone" : undefined,
// Metadata (title, canonical, hreflang, Open Graph) sempre no <head> do HTML
// inicial, para qualquer cliente. Sem isso o Next 15.5 a transmite no <body>
// para navegadores e Googlebot, e só os robôs de preview a recebem no <head>.
htmlLimitedBots: /.*/,
experimental: {
optimizePackageImports: [
"lucide-react",
+11 -36
View File
@@ -1285,9 +1285,6 @@
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1308,9 +1305,6 @@
"cpu": [
"arm"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1331,9 +1325,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1354,9 +1345,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1377,9 +1365,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1400,9 +1385,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -1582,9 +1564,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -1601,9 +1580,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -1620,9 +1596,6 @@
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -1639,9 +1612,6 @@
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -1658,9 +1628,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -1677,9 +1644,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0 AND MIT",
"optional": true,
"os": [
@@ -6175,6 +6139,17 @@
}
}
},
"node_modules/next-intl/node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"license": "Apache-2.0",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/next-themes": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz",
+26 -9
View File
@@ -13,6 +13,7 @@
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { ENABLED_LOCALES } from "../i18n/links.js";
const CORES = {
reset: "\x1b[0m",
@@ -209,10 +210,15 @@ function verificarOutrosIdiomas(ptMessages) {
for (const loc of LOCALES) {
if (loc === "pt") continue;
const isEnabled = ENABLED_LOCALES.includes(loc);
const pathMessages = `messages/${loc}.json`;
if (!existsSync(pathMessages)) {
aviso(`Catálogo '${loc}' (${pathMessages}) ainda não criado (esperado para sua fase de tradução).`);
if (isEnabled) {
falha(`Catálogo '${loc}' (${pathMessages}) não encontrado para idioma ativo.`);
} else {
aviso(`Catálogo '${loc}' (${pathMessages}) ainda não criado (esperado para idioma inativo).`);
}
continue;
}
@@ -250,15 +256,26 @@ function verificarOutrosIdiomas(ptMessages) {
if (!(k in chavesPt)) extras++;
}
if (faltando > 0) falha(`'${loc}': ${faltando} chave(s) ausente(s) em relação ao pt.`);
if (extras > 0) aviso(`'${loc}': ${extras} chave(s) excedente(s) em relação ao pt.`);
if (vazios > 0) falha(`'${loc}': ${vazios} valor(es) vazio(s).`);
if (iguaisSemAllowlist > 0) {
aviso(`'${loc}': ${iguaisSemAllowlist} valor(es) idêntico(s) ao pt sem allowlist.`);
}
if (isEnabled) {
if (faltando > 0) falha(`'${loc}': ${faltando} chave(s) ausente(s) em relação ao pt.`);
if (extras > 0) aviso(`'${loc}': ${extras} chave(s) excedente(s) em relação ao pt.`);
if (vazios > 0) falha(`'${loc}': ${vazios} valor(es) vazio(s).`);
if (iguaisSemAllowlist > 0) {
aviso(`'${loc}': ${iguaisSemAllowlist} valor(es) idêntico(s) ao pt sem allowlist.`);
}
if (faltando === 0 && vazios === 0) {
sucesso(`Catálogo '${loc}' possui paridade estrutural completa com 'pt'.`);
if (faltando === 0 && vazios === 0) {
sucesso(`Catálogo '${loc}' ativo possui paridade estrutural completa com 'pt'.`);
}
} else {
// Idioma inativo: valida integridade dos arquivos existentes sem quebrar se estiver incompleto
if (faltando > 0) aviso(`'${loc}' (inativo/em progresso): ${faltando} chave(s) ausente(s) em relação ao pt.`);
if (extras > 0) aviso(`'${loc}' (inativo): ${extras} chave(s) excedente(s) em relação ao pt.`);
if (vazios > 0) aviso(`'${loc}' (inativo): ${vazios} valor(es) vazio(s).`);
if (faltando === 0 && vazios === 0) {
sucesso(`Catálogo '${loc}' (inativo) possui paridade estrutural completa com 'pt'.`);
}
}
}
}
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# Teste de fumaça do servidor: rotas por idioma, redirects e regras do livro.
#
# scripts/smoke-imagem.sh http://127.0.0.1:3998
#
# Cada falha é impressa e o script sai com 1 no fim, para mostrar tudo de uma vez.
set -uo pipefail
base=${1:?uso: smoke-imagem.sh <url-base>}
falhas=0
# status <esperado> <caminho> [cabeçalho]
status() {
local got
got=$(curl -s -o /dev/null -w '%{http_code}' ${3:+-H "$3"} "$base$2")
[ "$got" = "$1" ] || { echo "FALHOU $2 ${3:-}: esperado $1, veio $got"; falhas=$((falhas + 1)); }
}
# destino <status> <caminho> <redirecionamento esperado> [cabeçalho]
destino() {
local out
out=$(curl -s -o /dev/null -w '%{http_code} %{redirect_url}' ${4:+-H "$4"} "$base$2")
[ "$out" = "$1 $base$3" ] || { echo "FALHOU $2 ${4:-}: esperado '$1 $3', veio '${out#* }'" ; falhas=$((falhas + 1)); }
}
# O servidor leva alguns segundos para subir.
for _ in $(seq 1 30); do curl -fsS -o /dev/null "$base/pt" && break; sleep 2; done
# Lista de idiomas ativos lida dinamicamente do módulo i18n/links.js
ENABLED_LOCALES=${ENABLED_LOCALES:-$(node -e 'import("./i18n/links.js").then(m => console.log(m.ENABLED_LOCALES.join(" ")))' 2>/dev/null || echo "pt")}
ALL_LOCALES="pt en es fr de zh-CN"
# 1. Páginas que existem em todos os idiomas ATIVOS
for l in $ENABLED_LOCALES; do
status 200 "/$l"
done
for p in /series /sobre /livro; do
status 200 "/pt$p"
done
# 2. Idiomas INATIVOS: redirecionam com 307 para /pt
for l in $ALL_LOCALES; do
if [[ ! " $ENABLED_LOCALES " =~ " $l " ]]; then
destino 307 "/$l" /pt
destino 307 "/$l/series" /pt/series
destino 307 "/$l/livro" /pt/livro
fi
done
# 3. Negociação de idioma na raiz (deve considerar apenas ativos)
if [[ " $ENABLED_LOCALES " == " pt " ]]; then
# Com só pt ativo, todo Accept-Language e cookie de idioma inativo cai em /pt
destino 307 / /pt 'Accept-Language: pt-BR,pt;q=0.9'
destino 307 / /pt 'Accept-Language: es-MX,es;q=0.9,en;q=0.5'
destino 307 / /pt 'Accept-Language: fr-CA,fr;q=0.9'
destino 307 / /pt 'Accept-Language: de-AT,de;q=0.8'
destino 307 / /pt 'Accept-Language: zh-CN,zh;q=0.9'
destino 307 / /pt 'Accept-Language: it-IT'
destino 307 / /pt 'Accept-Language: en;q=0.5,pt;q=0.9'
destino 307 / /pt 'Cookie: NEXT_LOCALE=es'
else
destino 307 / /pt 'Accept-Language: pt-BR,pt;q=0.9'
[[ " $ENABLED_LOCALES " =~ " es " ]] && destino 307 / /es 'Accept-Language: es-MX,es;q=0.9,en;q=0.5'
[[ " $ENABLED_LOCALES " =~ " fr " ]] && destino 307 / /fr 'Accept-Language: fr-CA,fr;q=0.9'
[[ " $ENABLED_LOCALES " =~ " de " ]] && destino 307 / /de 'Accept-Language: de-AT,de;q=0.8'
[[ " $ENABLED_LOCALES " =~ " zh-CN " ]] && destino 307 / /zh-CN 'Accept-Language: zh-CN,zh;q=0.9'
[[ " $ENABLED_LOCALES " =~ " en " ]] && destino 307 / /en 'Accept-Language: it-IT'
destino 307 / /pt 'Accept-Language: en;q=0.5,pt;q=0.9'
[[ " $ENABLED_LOCALES " =~ " es " ]] && destino 307 / /es 'Cookie: NEXT_LOCALE=es'
fi
# 4. URLs antigas (sem prefixo) continuam 308 permanente para /pt/...
destino 308 /series/ESP32-S3 /pt/series/ESP32-S3 'Accept-Language: en-US'
destino 308 /livro /pt/livro 'Accept-Language: en-US'
# 5. O livro dá 404 apenas em idiomas ATIVOS que não sejam pt
for l in $ENABLED_LOCALES; do
if [ "$l" != "pt" ]; then
status 404 "/$l/livro"
fi
done
# 6. Metadata no <head> inicial (title, canonical), para qualquer cliente
html=$(curl -s -A 'Mozilla/5.0 Chrome/130' "$base/pt/sobre")
head=${html%%</head>*}
for marca in '<title>' 'rel="canonical"'; do
case "$head" in *"$marca"*) ;; *) echo "FALHOU /pt/sobre: $marca fora do <head>"; falhas=$((falhas + 1));; esac
done
exit $((falhas > 0))