feat(i18n): canonical strings, data extraction and validation (phase 2)
Implement Phase 2 of i18n spec: - Extract UI interface strings to messages/pt.json across 15 namespaces - Extract canonical data files to data/pt/ (series, frameworks, conexoes) - Create lib/data/pt/ modules (glossario, diagnostico, componentes, topicos) - Implement lib/data/index.js async data loaders by locale - Add automated check:i18n script to validate key parity and enforce no-book rules Co-Authored-By: Antigravity <noreply@google.com>
This commit is contained in:
1 parent
83c0ba3e74
commit
fb59015fca
22 files changed
+12766
-2
No files matched your search
@@ -0,0 +1,327 @@
|
||||
#!/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";
|
||||
|
||||
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 pathMessages = `messages/${loc}.json`;
|
||||
if (!existsSync(pathMessages)) {
|
||||
aviso(`Catálogo '${loc}' (${pathMessages}) ainda não criado (esperado para sua fase de tradução).`);
|
||||
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 (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'.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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();
|
||||
Reference in new issue
Block a user