Files
ESPDocs/scripts/check-i18n.mjs
T
Lucas RayanandAntigravity d479a8d361
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
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 <noreply@google.com>
2026-11-01 12:10:25 -03:00

345 lines
9.7 KiB
JavaScript

#!/usr/bin/env node
/**
* ESPDocs - Script de Verificação Automatizada de Internacionalização (check:i18n)
*
* Implementa os critérios de aceite da seção 10 (itens 1 e 2) da spec docs/specs/i18n.md:
* 1. Consistência estrutural de catálogos e dados (chaves idênticas ao pt).
* 2. Ausência de valores vazios.
* 3. Detecção de strings idênticas ao pt sem estarem na allowlist técnica.
* 4. Verificação estrita de ausência de referências ao livro fora do pt (seção 7).
*
* Uso: npm run check:i18n
*/
import { existsSync, readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { ENABLED_LOCALES } from "../i18n/links.js";
const CORES = {
reset: "\x1b[0m",
verde: "\x1b[32m",
vermelho: "\x1b[31m",
amarelo: "\x1b[33m",
azul: "\x1b[34m",
ciano: "\x1b[36m",
negrito: "\x1b[1m",
cinza: "\x1b[90m",
};
const LOCALES = ["pt", "en", "es", "fr", "de", "zh-CN"];
const FORBIDDEN_BOOK_TERMS = /\b(livro|libro|livre|buch|书|book|isbn|amazon|uiclap|capítulo|capitulo|chapter|chapitre|kapitel|章节)\b/i;
// Termos técnicos e nomes próprios permitidos permanecerem iguais ao pt
const TECHNICAL_ALLOWLIST = new Set([
"ESPDocs",
"Lucas Rayan Guerra",
"Ciência Embarcada",
"ESP32",
"ESP32-S2",
"ESP32-S3",
"ESP32-S31",
"ESP32-C2",
"ESP32-C3",
"ESP32-C5",
"ESP32-C6",
"ESP32-C61",
"ESP32-P4",
"ESP32-H2",
"ESP32-H4",
"ESP-IDF",
"Arduino",
"MicroPython",
"FreeRTOS",
"Rust",
"Matter",
"ESP-RainMaker",
"ESP-Matter",
"Xtensa",
"RISC-V",
"Wi-Fi",
"Bluetooth",
"BLE",
"Zigbee",
"Thread",
"USB",
"GPIO",
"SPI",
"I2C",
"I2S",
"UART",
"CAN",
"TWAI",
"ADC",
"DAC",
"PWM",
"JTAG",
"Gitea",
"GitHub",
"MIT",
"DevKit",
"SoC",
"PSRAM",
"SRAM",
"Flash",
"OTA",
"DMA",
"WDT",
"TWDT",
"IWDT",
"LDO",
"DCDC",
"LVGL",
"IDF",
"12",
"78",
"100%",
"1",
"2",
"3",
"4",
"5",
"6",
]);
let totalErros = 0;
let totalAvisos = 0;
function sucesso(msg) {
console.log(` ${CORES.verde}✔${CORES.reset} ${msg}`);
}
function falha(msg) {
console.log(` ${CORES.vermelho}✖${CORES.reset} ${msg}`);
totalErros++;
}
function aviso(msg) {
console.log(` ${CORES.amarelo}⚠${CORES.reset} ${msg}`);
totalAvisos++;
}
/** Achata um objeto aninhado em chaves dot-notation */
function achatarObjeto(obj, prefixo = "") {
const resultado = {};
for (const [k, v] of Object.entries(obj)) {
const chave = prefixo ? `${prefixo}.${k}` : k;
if (v !== null && typeof v === "object" && !Array.isArray(v)) {
Object.assign(resultado, achatarObjeto(v, chave));
} else {
resultado[chave] = v;
}
}
return resultado;
}
// ---------------------------------------------------------------------------
// 1. Verificação da Base Canônica em Português (pt)
// ---------------------------------------------------------------------------
function verificarBasePt() {
console.log(`\n${CORES.negrito}${CORES.ciano}[1/3] Verificação da Fonte Canônica (pt)${CORES.reset}`);
// messages/pt.json
const messagesPtPath = "messages/pt.json";
if (!existsSync(messagesPtPath)) {
falha(`Arquivo de mensagens '${messagesPtPath}' não encontrado.`);
return null;
}
let ptMessages;
try {
ptMessages = JSON.parse(readFileSync(messagesPtPath, "utf8"));
sucesso(`Catálogo '${messagesPtPath}' carregado com sucesso (${Object.keys(ptMessages).length} namespaces).`);
} catch (e) {
falha(`Erro ao processar JSON em '${messagesPtPath}': ${e.message}`);
return null;
}
// data/pt/
const dataFiles = ["series.json", "frameworks.json"];
for (const f of dataFiles) {
const p = join("data/pt", f);
if (existsSync(p)) {
try {
JSON.parse(readFileSync(p, "utf8"));
sucesso(`Arquivo de dados '${p}' validado com sucesso.`);
} catch (e) {
falha(`Erro em '${p}': ${e.message}`);
}
} else {
falha(`Arquivo esperado '${p}' não encontrado.`);
}
}
// data/pt/conexoes
const conexoesDir = "data/pt/conexoes";
if (existsSync(conexoesDir)) {
const arquivos = readdirSync(conexoesDir).filter((f) => f.endsWith(".json"));
if (arquivos.length >= 11) {
sucesso(`Diretório '${conexoesDir}' contém ${arquivos.length} arquivos de conexões validados.`);
} else {
aviso(`Diretório '${conexoesDir}' contém apenas ${arquivos.length} arquivos.`);
}
} else {
falha(`Diretório '${conexoesDir}' não encontrado.`);
}
// lib/data/pt/
const libDataFiles = ["glossario.js", "diagnostico.js", "componentes.js", "topicos.js"];
for (const f of libDataFiles) {
const p = join("lib/data/pt", f);
if (existsSync(p)) {
sucesso(`Módulo de dados '${p}' verificado.`);
} else {
falha(`Módulo esperado '${p}' não encontrado.`);
}
}
return ptMessages;
}
// ---------------------------------------------------------------------------
// 2. Verificação de Catálogos e Dados de Outros Idiomas (quando presentes)
// ---------------------------------------------------------------------------
function verificarOutrosIdiomas(ptMessages) {
console.log(`\n${CORES.negrito}${CORES.ciano}[2/3] Verificação de Catálogos & Chaves por Idioma${CORES.reset}`);
if (!ptMessages) return;
const chavesPt = achatarObjeto(ptMessages);
const totalChavesPt = Object.keys(chavesPt).length;
sucesso(`Catálogo 'pt' contém ${totalChavesPt} chaves de tradução ativas.`);
for (const loc of LOCALES) {
if (loc === "pt") continue;
const isEnabled = ENABLED_LOCALES.includes(loc);
const pathMessages = `messages/${loc}.json`;
if (!existsSync(pathMessages)) {
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;
}
let locMessages;
try {
locMessages = JSON.parse(readFileSync(pathMessages, "utf8"));
} catch (e) {
falha(`Erro de parsing em '${pathMessages}': ${e.message}`);
continue;
}
const chavesLoc = achatarObjeto(locMessages);
let faltando = 0;
let extras = 0;
let vazios = 0;
let iguaisSemAllowlist = 0;
for (const [k, vPt] of Object.entries(chavesPt)) {
if (!(k in chavesLoc)) {
faltando++;
} else {
const vLoc = chavesLoc[k];
if (typeof vLoc === "string" && vLoc.trim() === "") {
vazios++;
} else if (vLoc === vPt && typeof vPt === "string") {
const limpo = vPt.trim();
if (!TECHNICAL_ALLOWLIST.has(limpo) && !/^\d+$/.test(limpo)) {
iguaisSemAllowlist++;
}
}
}
}
for (const k of Object.keys(chavesLoc)) {
if (!(k in chavesPt)) extras++;
}
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}' 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'.`);
}
}
}
}
// ---------------------------------------------------------------------------
// 3. Verificação de Regras do Livro (§7 da spec: ausência fora do pt)
// ---------------------------------------------------------------------------
function verificarRegrasLivro() {
console.log(`\n${CORES.negrito}${CORES.ciano}[3/3] Verificação Estrita de Referências ao Livro (Seção 7)${CORES.reset}`);
let violacoes = 0;
for (const loc of LOCALES) {
if (loc === "pt") continue;
// Checar messages/<loc>.json
const pathMsg = `messages/${loc}.json`;
if (existsSync(pathMsg)) {
const conteudo = readFileSync(pathMsg, "utf8");
const m = conteudo.match(FORBIDDEN_BOOK_TERMS);
if (m) {
falha(`Termo proibido do livro '${m[0]}' encontrado em '${pathMsg}'.`);
violacoes++;
}
}
// Checar data/<loc>/
const dirData = `data/${loc}`;
if (existsSync(dirData)) {
const arquivos = readdirSync(dirData).filter((f) => f.endsWith(".json"));
for (const arq of arquivos) {
const conteudo = readFileSync(join(dirData, arq), "utf8");
const m = conteudo.match(FORBIDDEN_BOOK_TERMS);
if (m) {
falha(`Termo proibido do livro '${m[0]}' encontrado em '${dirData}/${arq}'.`);
violacoes++;
}
}
}
}
if (violacoes === 0) {
sucesso("Nenhuma menção ao livro encontrada em catálogos ou dados fora do português.");
}
}
function main() {
console.log(`${CORES.negrito}${CORES.azul}======================================================${CORES.reset}`);
console.log(`${CORES.negrito}${CORES.azul} ESPDocs · Verificação de Internacionalização (i18n) ${CORES.reset}`);
console.log(`${CORES.negrito}${CORES.azul}======================================================${CORES.reset}`);
const ptMessages = verificarBasePt();
verificarOutrosIdiomas(ptMessages);
verificarRegrasLivro();
console.log(`\n${CORES.negrito}------------------------------------------------------${CORES.reset}`);
if (totalErros === 0) {
console.log(`${CORES.verde}${CORES.negrito}🎉 TODAS AS VERIFICAÇÕES DE I18N PASSARAM! (0 erros)${CORES.reset}\n`);
return 0;
} else {
console.log(`${CORES.vermelho}${CORES.negrito}✖ Foram encontrados ${totalErros} erro(s) de i18n que bloqueiam o aceite.${CORES.reset}\n`);
return 1;
}
}
process.exitCode = main();