From d479a8d361f20a5069cbfc367e4654329188830f Mon Sep 17 00:00:00 2001 From: Lucas Rayan Date: Sun, 1 Nov 2026 12:10:25 -0300 Subject: [PATCH] feat(i18n): restrict public routes and metadata to enabled locales - 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 --- CHANGELOG.md | 6 +++ app/[locale]/layout.jsx | 15 ++++-- app/sitemap.js | 83 ++++++++++++++++++++++----------- components/LanguageSelector.jsx | 4 ++ i18n/links.js | 34 +++++++++++--- i18n/routing.js | 5 +- middleware.js | 49 +++++++++++++------ scripts/check-i18n.mjs | 35 ++++++++++---- scripts/smoke-imagem.sh | 67 +++++++++++++++++++------- 9 files changed, 219 insertions(+), 79 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1afb7df..aa50362 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,12 @@ 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 diff --git a/app/[locale]/layout.jsx b/app/[locale]/layout.jsx index 31e60b2..b930060 100644 --- a/app/[locale]/layout.jsx +++ b/app/[locale]/layout.jsx @@ -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(); } diff --git a/app/sitemap.js b/app/sitemap.js index 3fd2cf9..514aa71 100644 --- a/app/sitemap.js +++ b/app/sitemap.js @@ -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; } diff --git a/components/LanguageSelector.jsx b/components/LanguageSelector.jsx index ba81f97..e1e5431 100644 --- a/components/LanguageSelector.jsx +++ b/components/LanguageSelector.jsx @@ -8,6 +8,10 @@ import { LOCALES, getLocalizedHref } from "@/i18n/links"; 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(""); diff --git a/i18n/links.js b/i18n/links.js index 9ff221e..77d0591 100644 --- a/i18n/links.js +++ b/i18n/links.js @@ -3,15 +3,23 @@ * Módulo puro (sem 'use client') seguro para Server e Client Components. */ -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" }, +// 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", @@ -127,3 +135,15 @@ export function getLocalizedHref(pathname, 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"; +} diff --git a/i18n/routing.js b/i18n/routing.js index 5c26bd6..2bef9a2 100644 --- a/i18n/routing.js +++ b/i18n/routing.js @@ -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: { "/": "/", diff --git a/middleware.js b/middleware.js index 4609dd3..5aa8ae1 100644 --- a/middleware.js +++ b/middleware.js @@ -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,7 +65,7 @@ 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); @@ -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/ - 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/ + 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); } diff --git a/scripts/check-i18n.mjs b/scripts/check-i18n.mjs index 68a6c62..ff7194f 100644 --- a/scripts/check-i18n.mjs +++ b/scripts/check-i18n.mjs @@ -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'.`); + } } } } diff --git a/scripts/smoke-imagem.sh b/scripts/smoke-imagem.sh index 58b7436..291ba76 100755 --- a/scripts/smoke-imagem.sh +++ b/scripts/smoke-imagem.sh @@ -1,7 +1,7 @@ #!/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:3999 +# 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 @@ -26,28 +26,61 @@ destino() { # O servidor leva alguns segundos para subir. for _ in $(seq 1 30); do curl -fsS -o /dev/null "$base/pt" && break; sleep 2; done -# Páginas que existem em todos os idiomas ativos (a home é a que mais depende do render no servidor). -for l in pt en es fr de zh-CN; do status 200 "/$l"; done -for p in /series /sobre /livro; do status 200 "/pt$p"; 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" -# Negociação de idioma na raiz. -destino 307 / /pt 'Accept-Language: pt-BR,pt;q=0.9' -destino 307 / /es 'Accept-Language: es-MX,es;q=0.9,en;q=0.5' -destino 307 / /fr 'Accept-Language: fr-CA,fr;q=0.9' -destino 307 / /de 'Accept-Language: de-AT,de;q=0.8' -destino 307 / /zh-CN 'Accept-Language: zh-CN,zh;q=0.9' -destino 307 / /en 'Accept-Language: it-IT' -destino 307 / /pt 'Accept-Language: en;q=0.5,pt;q=0.9' -destino 307 / /es 'Cookie: NEXT_LOCALE=es' +# 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 -# URLs antigas (sem prefixo) eram em português. +# 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' -# O livro existe só em português. -for l in en es fr de zh-CN; do status 404 "/$l/livro"; done +# 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 -# Metadata no inicial (title, canonical, hreflang), para qualquer cliente. +# 6. Metadata no inicial (title, canonical), para qualquer cliente html=$(curl -s -A 'Mozilla/5.0 Chrome/130' "$base/pt/sobre") head=${html%%*} for marca in '' 'rel="canonical"'; do