Compare commits
4
Commits
dcd4ef7e1b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22b1f26aed | ||
|
|
d479a8d361 | ||
|
|
3a1a095e97 | ||
|
|
cbf724db2a |
No files matched your search
@@ -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 \
|
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
|
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
|
- name: A imagem sobe e responde
|
||||||
if: ${{ steps.plano.outputs.reuse != 'true' }}
|
if: ${{ steps.plano.outputs.reuse != 'true' }}
|
||||||
run: |
|
run: |
|
||||||
docker run -d --name espdocs-ci --network host -e PORT=3999 espdocs:ci
|
docker run -d --name espdocs-ci --network host -e PORT=3999 espdocs:ci
|
||||||
for i in $(seq 1 30); do
|
status=0
|
||||||
curl -fsS http://127.0.0.1:3999/ >/dev/null && ok=1 && break
|
scripts/smoke-imagem.sh http://127.0.0.1:3999 || status=1
|
||||||
sleep 2
|
docker logs espdocs-ci | tail -15
|
||||||
done
|
# O Next registra falhas de render no log mesmo quando responde.
|
||||||
docker logs espdocs-ci | tail -5
|
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
|
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.
|
# Passou em tudo: publica a mesma imagem que foi testada, sem reconstruir.
|
||||||
- name: Publica inputs-<hash>
|
- name: Publica inputs-<hash>
|
||||||
|
|||||||
+24
-1
@@ -6,6 +6,8 @@ Tudo o que muda de relevante no ESPDocs está aqui. O formato segue o
|
|||||||
|
|
||||||
## [Não lançado]
|
## [Não lançado]
|
||||||
|
|
||||||
|
## [1.2.0] - 2026-11-01
|
||||||
|
|
||||||
### Adicionado
|
### Adicionado
|
||||||
|
|
||||||
- Infraestrutura de internacionalização com `next-intl` (Fase 1):
|
- 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.
|
- 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`.
|
- 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.
|
- 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
|
## [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
|
otimização de imagens (GHSA-2464-fhr4-w9xw), e `postcss` fixado acima da cópia
|
||||||
embutida no `next`.
|
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.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
|
[1.0.0]: https://git.lucasrguerra.dev.br/lucasrguerra/ESPDocs/releases/tag/v1.0.0
|
||||||
+12
-3
@@ -3,6 +3,7 @@ import { JetBrains_Mono, Manrope, Space_Grotesk } from "next/font/google";
|
|||||||
import { NextIntlClientProvider } from "next-intl";
|
import { NextIntlClientProvider } from "next-intl";
|
||||||
import { getMessages } from "next-intl/server";
|
import { getMessages } from "next-intl/server";
|
||||||
import { routing } from "@/i18n/routing";
|
import { routing } from "@/i18n/routing";
|
||||||
|
import { ENABLED_LOCALES } from "@/i18n/links";
|
||||||
import "../globals.css";
|
import "../globals.css";
|
||||||
import Providers from "@/components/Providers";
|
import Providers from "@/components/Providers";
|
||||||
import { SITE, jsonLd } from "@/lib/seo";
|
import { SITE, jsonLd } from "@/lib/seo";
|
||||||
@@ -38,7 +39,7 @@ const LANG_MAP = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function generateStaticParams() {
|
export function generateStaticParams() {
|
||||||
return routing.locales.map((locale) => ({ locale }));
|
return ENABLED_LOCALES.map((locale) => ({ locale }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({ params }) {
|
export async function generateMetadata({ params }) {
|
||||||
@@ -52,6 +53,8 @@ export async function generateMetadata({ params }) {
|
|||||||
"zh-CN": "zh_CN",
|
"zh-CN": "zh_CN",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const alternateLocales = ENABLED_LOCALES.filter((l) => l !== locale).map((l) => ogLocaleMap[l]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
metadataBase: new URL(SITE.url),
|
metadataBase: new URL(SITE.url),
|
||||||
title: {
|
title: {
|
||||||
@@ -71,10 +74,16 @@ export async function generateMetadata({ params }) {
|
|||||||
"comparar ESP32", "qual ESP32 escolher", "datasheet ESP32",
|
"comparar ESP32", "qual ESP32 escolher", "datasheet ESP32",
|
||||||
"microcontrolador", "Espressif", "ESP-IDF", "IoT", "sistemas embarcados",
|
"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: {
|
openGraph: {
|
||||||
type: "website",
|
type: "website",
|
||||||
locale: ogLocaleMap[locale] || "pt_BR",
|
locale: ogLocaleMap[locale] || "pt_BR",
|
||||||
|
alternateLocale: alternateLocales.length > 0 ? alternateLocales : undefined,
|
||||||
url: `${SITE.url}/${locale}`,
|
url: `${SITE.url}/${locale}`,
|
||||||
siteName: SITE.nome,
|
siteName: SITE.nome,
|
||||||
title: "ESPDocs · Documentação do ESP32 em português",
|
title: "ESPDocs · Documentação do ESP32 em português",
|
||||||
@@ -127,7 +136,7 @@ export const viewport = {
|
|||||||
|
|
||||||
export default async function RootLayout({ children, params }) {
|
export default async function RootLayout({ children, params }) {
|
||||||
const { locale } = await params;
|
const { locale } = await params;
|
||||||
if (!routing.locales.includes(locale)) {
|
if (!ENABLED_LOCALES.includes(locale)) {
|
||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+55
-28
@@ -1,51 +1,78 @@
|
|||||||
import seriesData from "@/public/series.json";
|
import seriesData from "@/public/series.json";
|
||||||
import frameworksData from "@/public/frameworks.json";
|
import frameworksData from "@/public/frameworks.json";
|
||||||
import { SITE } from "@/lib/seo";
|
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.
|
* Sitemap gerado a partir dos próprios dados do site e dos idiomas ativos.
|
||||||
*
|
* Inclui URLs apenas para ENABLED_LOCALES, com hreflang/alternates correspondentes.
|
||||||
* Antes não existia sitemap nenhum, então o Google só chegava às páginas de
|
* O livro só entra para a versão em português.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
export default function sitemap() {
|
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 dataRevisao = new Date("2026-08-25T00:00:00.000Z");
|
||||||
|
|
||||||
const fixas = [
|
const fixas = [
|
||||||
{ caminho: "", prioridade: 1.0, frequencia: "weekly", lastModified: dataRevisao },
|
{ rota: "", prioridade: 1.0, frequencia: "weekly" },
|
||||||
{ caminho: "/series", prioridade: 0.9, frequencia: "weekly", lastModified: dataRevisao },
|
{ rota: "/series", prioridade: 0.9, frequencia: "weekly" },
|
||||||
{ caminho: "/comparacao", prioridade: 0.9, frequencia: "weekly", lastModified: dataRevisao },
|
{ rota: "/comparacao", prioridade: 0.9, frequencia: "weekly" },
|
||||||
{ caminho: "/diagnostico", prioridade: 0.9, frequencia: "weekly", lastModified: dataRevisao },
|
{ rota: "/diagnostico", prioridade: 0.9, frequencia: "weekly" },
|
||||||
{ caminho: "/seletor", prioridade: 0.8, frequencia: "monthly", lastModified: dataRevisao },
|
{ rota: "/seletor", prioridade: 0.8, frequencia: "monthly" },
|
||||||
{ caminho: "/frameworks", prioridade: 0.8, frequencia: "monthly", lastModified: dataRevisao },
|
{ rota: "/frameworks", prioridade: 0.8, frequencia: "monthly" },
|
||||||
{ caminho: "/componentes", prioridade: 0.8, frequencia: "weekly", lastModified: dataRevisao },
|
{ rota: "/componentes", prioridade: 0.8, frequencia: "weekly" },
|
||||||
{ caminho: "/livro", prioridade: 0.7, frequencia: "monthly", lastModified: dataRevisao },
|
{ rota: "/livro", prioridade: 0.7, frequencia: "monthly", apenasPt: true },
|
||||||
{ caminho: "/glossario", prioridade: 0.7, frequencia: "monthly", lastModified: dataRevisao },
|
{ rota: "/glossario", prioridade: 0.7, frequencia: "monthly" },
|
||||||
{ caminho: "/catalogo", prioridade: 0.6, frequencia: "weekly", lastModified: dataRevisao },
|
{ rota: "/catalogo", prioridade: 0.6, frequencia: "weekly" },
|
||||||
{ caminho: "/sobre", prioridade: 0.4, frequencia: "yearly", lastModified: dataRevisao },
|
{ rota: "/sobre", prioridade: 0.4, frequencia: "yearly" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const series = Object.keys(seriesData).map((chave) => ({
|
const series = Object.keys(seriesData).map((chave) => ({
|
||||||
caminho: `/series/${chave}`,
|
rota: `/series/${chave}`,
|
||||||
prioridade: 0.9,
|
prioridade: 0.9,
|
||||||
frequencia: "monthly",
|
frequencia: "monthly",
|
||||||
lastModified: dataRevisao,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const frameworks = Object.keys(frameworksData).map((chave) => ({
|
const frameworks = Object.keys(frameworksData).map((chave) => ({
|
||||||
caminho: `/frameworks/${encodeURIComponent(chave)}`,
|
rota: `/frameworks/${encodeURIComponent(chave)}`,
|
||||||
prioridade: 0.6,
|
prioridade: 0.6,
|
||||||
frequencia: "monthly",
|
frequencia: "monthly",
|
||||||
lastModified: dataRevisao,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return [...fixas, ...series, ...frameworks].map(({ caminho, prioridade, frequencia, lastModified }) => ({
|
const todasRotas = [...fixas, ...series, ...frameworks];
|
||||||
url: `${SITE.url}${caminho}`,
|
const entries = [];
|
||||||
lastModified,
|
|
||||||
changeFrequency: frequencia,
|
for (const item of todasRotas) {
|
||||||
priority: prioridade,
|
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;
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,10 @@ import { LOCALES, getLocalizedHref } from "@/i18n/links";
|
|||||||
export { LOCALES, getLocalizedHref };
|
export { LOCALES, getLocalizedHref };
|
||||||
|
|
||||||
export default function LanguageSelector() {
|
export default function LanguageSelector() {
|
||||||
|
if (LOCALES.length <= 1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const pathname = usePathname() || "";
|
const pathname = usePathname() || "";
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [hash, setHash] = useState("");
|
const [hash, setHash] = useState("");
|
||||||
|
|||||||
+27
-7
@@ -3,15 +3,23 @@
|
|||||||
* Módulo puro (sem 'use client') seguro para Server e Client Components.
|
* Módulo puro (sem 'use client') seguro para Server e Client Components.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const LOCALES = [
|
// Ponto único de configuração de idiomas ATIVOS.
|
||||||
{ code: "pt", label: "Português", short: "PT", lang: "pt-BR" },
|
// Para ativar um novo idioma no site, basta adicionar seu código aqui (ex.: ["pt", "en"]).
|
||||||
{ code: "en", label: "English", short: "EN", lang: "en" },
|
export const ENABLED_LOCALES = ["pt"];
|
||||||
{ code: "es", label: "Español", short: "ES", lang: "es-419" },
|
|
||||||
{ code: "fr", label: "Français", short: "FR", lang: "fr" },
|
export const ALL_LOCALES = [
|
||||||
{ code: "de", label: "Deutsch", short: "DE", lang: "de" },
|
{ code: "pt", label: "Português", short: "PT", lang: "pt-BR", og: "pt_BR" },
|
||||||
{ code: "zh-CN", label: "简体中文", short: "中", lang: "zh-CN" },
|
{ 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 = [
|
export const CANONICAL_ROUTES = [
|
||||||
{
|
{
|
||||||
id: "series_item",
|
id: "series_item",
|
||||||
@@ -127,3 +135,15 @@ export function getLocalizedHref(pathname, targetLocale) {
|
|||||||
}
|
}
|
||||||
return `/${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
@@ -1,8 +1,9 @@
|
|||||||
import { defineRouting } from "next-intl/routing";
|
import { defineRouting } from "next-intl/routing";
|
||||||
|
import { ENABLED_LOCALES } from "./links";
|
||||||
|
|
||||||
export const routing = defineRouting({
|
export const routing = defineRouting({
|
||||||
locales: ["pt", "en", "es", "fr", "de", "zh-CN"],
|
locales: ENABLED_LOCALES,
|
||||||
defaultLocale: "en",
|
defaultLocale: ENABLED_LOCALES.includes("en") ? "en" : (ENABLED_LOCALES[0] || "pt"),
|
||||||
localePrefix: "always",
|
localePrefix: "always",
|
||||||
pathnames: {
|
pathnames: {
|
||||||
"/": "/",
|
"/": "/",
|
||||||
|
|||||||
+37
-14
@@ -1,11 +1,10 @@
|
|||||||
import createMiddleware from "next-intl/middleware";
|
import createMiddleware from "next-intl/middleware";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { routing } from "./i18n/routing";
|
import { routing } from "./i18n/routing";
|
||||||
|
import { ALL_LOCALE_CODES, ENABLED_LOCALES, getPtPath } from "./i18n/links";
|
||||||
|
|
||||||
const intlMiddleware = createMiddleware(routing);
|
const intlMiddleware = createMiddleware(routing);
|
||||||
|
|
||||||
const SUPPORTED_LOCALES = routing.locales;
|
|
||||||
|
|
||||||
function parseAcceptLanguage(header) {
|
function parseAcceptLanguage(header) {
|
||||||
if (!header) return [];
|
if (!header) return [];
|
||||||
return header
|
return header
|
||||||
@@ -37,16 +36,18 @@ function matchLocale(lang) {
|
|||||||
|
|
||||||
function resolveRootLocale(request) {
|
function resolveRootLocale(request) {
|
||||||
const cookieValue = request.cookies.get("NEXT_LOCALE")?.value;
|
const cookieValue = request.cookies.get("NEXT_LOCALE")?.value;
|
||||||
if (cookieValue && SUPPORTED_LOCALES.includes(cookieValue)) {
|
if (cookieValue && ENABLED_LOCALES.includes(cookieValue)) {
|
||||||
return cookieValue;
|
return cookieValue;
|
||||||
}
|
}
|
||||||
const acceptLang = request.headers.get("accept-language");
|
const acceptLang = request.headers.get("accept-language");
|
||||||
const parsed = parseAcceptLanguage(acceptLang);
|
const parsed = parseAcceptLanguage(acceptLang);
|
||||||
for (const { lang } of parsed) {
|
for (const { lang } of parsed) {
|
||||||
const matched = matchLocale(lang);
|
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) {
|
export default function middleware(request) {
|
||||||
@@ -64,10 +65,10 @@ export default function middleware(request) {
|
|||||||
return NextResponse.next();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Raiz: negociação de idioma (307)
|
// 1. Raiz: negociação de idioma (307) considerando apenas idiomas ATIVOS
|
||||||
if (pathname === "/") {
|
if (pathname === "/") {
|
||||||
const targetLocale = resolveRootLocale(request);
|
const targetLocale = resolveRootLocale(request);
|
||||||
const url = new URL(`/${targetLocale}/`, request.url);
|
const url = new URL(`/${targetLocale}`, request.url);
|
||||||
url.search = search;
|
url.search = search;
|
||||||
const response = NextResponse.redirect(url, 307);
|
const response = NextResponse.redirect(url, 307);
|
||||||
response.headers.set("Vary", "Accept-Language, Cookie");
|
response.headers.set("Vary", "Accept-Language, Cookie");
|
||||||
@@ -75,23 +76,45 @@ export default function middleware(request) {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Livro fora do português: 404
|
// 2. Idiomas INATIVOS: prefixos (/en, /es, /fr, /de, /zh-CN e qualquer subcaminho)
|
||||||
if (/^\/(en|es|fr|de|zh-CN)\/livro(\/.*)?$/.test(pathname)) {
|
// Redirecionam com 307 para o mesmo caminho em /pt quando o slug existir lá; se não souber mapear o slug, para /pt.
|
||||||
return new NextResponse(null, { status: 404 });
|
// 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>
|
// 3. Livro fora do português para idiomas ATIVOS que não são pt: 404
|
||||||
const hasLocalePrefix = SUPPORTED_LOCALES.some(
|
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}/`)
|
(loc) => pathname === `/${loc}` || pathname.startsWith(`/${loc}/`)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!hasLocalePrefix) {
|
if (!hasActivePrefix) {
|
||||||
const url = new URL(`/pt${pathname}`, request.url);
|
const url = new URL(`/pt${pathname}`, request.url);
|
||||||
url.search = search;
|
url.search = search;
|
||||||
return NextResponse.redirect(url, 308);
|
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);
|
return intlMiddleware(request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ const nextConfig = {
|
|||||||
// A imagem Docker liga isto (NEXT_OUTPUT=standalone) para empacotar só o que
|
// 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.
|
// o servidor usa. Fora dela o build continua igual: `next start`/pm2.
|
||||||
output: process.env.NEXT_OUTPUT === "standalone" ? "standalone" : undefined,
|
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: {
|
experimental: {
|
||||||
optimizePackageImports: [
|
optimizePackageImports: [
|
||||||
"lucide-react",
|
"lucide-react",
|
||||||
|
|||||||
+26
-9
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { ENABLED_LOCALES } from "../i18n/links.js";
|
||||||
|
|
||||||
const CORES = {
|
const CORES = {
|
||||||
reset: "\x1b[0m",
|
reset: "\x1b[0m",
|
||||||
@@ -209,10 +210,15 @@ function verificarOutrosIdiomas(ptMessages) {
|
|||||||
|
|
||||||
for (const loc of LOCALES) {
|
for (const loc of LOCALES) {
|
||||||
if (loc === "pt") continue;
|
if (loc === "pt") continue;
|
||||||
|
const isEnabled = ENABLED_LOCALES.includes(loc);
|
||||||
|
|
||||||
const pathMessages = `messages/${loc}.json`;
|
const pathMessages = `messages/${loc}.json`;
|
||||||
if (!existsSync(pathMessages)) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,15 +256,26 @@ function verificarOutrosIdiomas(ptMessages) {
|
|||||||
if (!(k in chavesPt)) extras++;
|
if (!(k in chavesPt)) extras++;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (faltando > 0) falha(`'${loc}': ${faltando} chave(s) ausente(s) em relação ao pt.`);
|
if (isEnabled) {
|
||||||
if (extras > 0) aviso(`'${loc}': ${extras} chave(s) excedente(s) em relação ao pt.`);
|
if (faltando > 0) falha(`'${loc}': ${faltando} chave(s) ausente(s) em relação ao pt.`);
|
||||||
if (vazios > 0) falha(`'${loc}': ${vazios} valor(es) vazio(s).`);
|
if (extras > 0) aviso(`'${loc}': ${extras} chave(s) excedente(s) em relação ao pt.`);
|
||||||
if (iguaisSemAllowlist > 0) {
|
if (vazios > 0) falha(`'${loc}': ${vazios} valor(es) vazio(s).`);
|
||||||
aviso(`'${loc}': ${iguaisSemAllowlist} valor(es) idêntico(s) ao pt sem allowlist.`);
|
if (iguaisSemAllowlist > 0) {
|
||||||
}
|
aviso(`'${loc}': ${iguaisSemAllowlist} valor(es) idêntico(s) ao pt sem allowlist.`);
|
||||||
|
}
|
||||||
|
|
||||||
if (faltando === 0 && vazios === 0) {
|
if (faltando === 0 && vazios === 0) {
|
||||||
sucesso(`Catálogo '${loc}' possui paridade estrutural completa com 'pt'.`);
|
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'.`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+90
@@ -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))
|
||||||
Reference in new issue
Block a user