#!/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 estrita de strings idênticas ao pt sem allowlist (em messages, data e lib/data). * 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; // Allowlist estrita: nomes próprios, marcas, siglas e termos técnicos aceitos como idênticos const TECHNICAL_ALLOWLIST = new Set([ "ESPDocs", "Lucas Rayan Guerra", "Ciência Embarcada", "Hardware", "Component Registry", "ESP Component Registry", "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", "ESP-ADF", "ESP-SR", "ESP-IoT-Solution", "ESP-NOW & Mesh", "ESP-Zigbee-SDK", "ESP-WHO", "ESP-NN", "ESP-DSP", "Rust on ESP32", "Arduino Core", "Xtensa", "RISC-V", "Wi-Fi", "Bluetooth", "BLE", "Zigbee", "Thread", "USB", "GPIO", "SPI", "I2C", "I2S", "UART", "CAN", "TWAI", "ADC", "DAC", "PWM", "JTAG", "Gitea", "GitHub", "LinkedIn", "MIT", "DevKit", "SoC", "PSRAM", "SRAM", "ROM", "Flash", "OTA", "DMA", "WDT", "TWDT", "IWDT", "LDO", "DCDC", "LVGL", "IDF", "TinyUSB", "LittleFS", "CBOR", "RFC 8949", "Cloud & AIoT Framework", "Smart Home Framework", "Smart Home & Mesh", "{count} frameworks", "C / C++", "C++", "Python", "12", "78", "100%", "1", "2", "3", "4", "5", "6", ]); function isTechnicalAllowlisted(val, path = "") { if (typeof val !== "string") return true; const s = val.trim(); if (TECHNICAL_ALLOWLIST.has(s)) return true; // Campos que são metadados técnicos, URLs, identificadores ou configs de código if (/(^|\.)(id|namespace|name|nome_completo|versao|categoria|oficial|popular|urlRegistry|urlRepo|exemploYaml|icone|cor|placa|site_oficial|repositorio_github|documentacao_oficial|datasheet|manual_tecnico|guia_de_programacao|gpio|uart|i2c|i2s|spi|adc|dac|touch|pino|funcoes|rotulo|chave|slug)(\[\d+\])*$/i.test(path)) { return true; } if (s.startsWith("http://") || s.startsWith("https://") || s.startsWith("/") || s.startsWith("mailto:")) return true; // Versões semver, tags e identificadores if (/^\^?[0-9]+(\.[0-9]+)*([a-zA-Z0-9.-]*)$/.test(s)) return true; // Emojis / Pictogramas / Símbolos Unicode if (/^(\p{Extended_Pictographic}|\p{Emoji_Presentation}|\s)+$/u.test(s)) return true; // Medidas, números, percentuais, frequências, consumos, intervalos if (/^[-+]?\d+([.,]\d+)?(\s*[%]|(\s*(MHz|GHz|KB|MB|GB|µA|mA|A|V|mV|mW|W|dBm|°C|mm|ohms|kΩ|µF|nF)))?$/i.test(s)) return true; if (/^[-+]?\d+([.,]\d+)?\s*(a|to|-)\s*[-+]?\d+([.,]\d+)?(\s*(MHz|GHz|KB|MB|GB|µA|mA|A|V|mV|mW|W|dBm|°C|mm|ohms|kΩ|µF|nF))?$/i.test(s)) return true; if (/^\d+\s*mW\s*\([^)]*\)$/i.test(s)) return true; // Hardware specs / contagem de periféricos: "1 (CAN 2.0, TWAI®)", "1 (Single-core)", "2 (Dual-core)" if (/^\d+(\s*\(.*\))?(\s*(\+|\/|,)\s*\d+(\s*\(.*\))?)*$/i.test(s)) return true; // Acrônimos de segurança / criptografia if (/^(AES|SHA|RSA|ECDSA|XTS|HMAC|ECC|Digital Signature|V\d+(\s*\(.*\))?)[A-Za-z0-9_/ -]*$/i.test(s)) return true; // Identificadores de módulos / produtos / encapsulamentos / pinos if (/^[A-Za-z0-9_+-]+(\/[A-Za-z0-9_.-]+)*(\^[0-9.]+)?$/.test(s)) return true; if (/^v\d+(\.\d+)*$/i.test(s)) return true; if (/^QFN\d+(\s*\(\d+×\d+\s*mm\))?$/i.test(s)) return true; // Lista de módulos / chips separados por vírgula ou + if (/^([A-Za-z0-9_().-]+(,\s*|\s*\+\s*|\s+))+[A-Za-z0-9_().-]+$/.test(s)) return true; // Cores hex if (/^#?[0-9a-fA-F]{3,8}$/.test(s)) return true; // Blocos de comandos e YAML if (s.startsWith("dependencies:") || s.startsWith("idf.py") || s.startsWith("pip install")) return true; // Sinais de hardware / categorias de pinos if (/^(USB|JTAG|UART|SPI|I2C|I2S|GPIO|MIPI|Low Power|Strapping|Digital|Analog|Power)[A-Za-z0-9_.+/ -]*$/i.test(s)) return true; // Nomes próprios de componentes / SDKs if (/^(ESP|ESP32|LVGL|TinyUSB|FreeRTOS|Rust|Arduino)[A-Za-z0-9_()./ -]+$/i.test(s)) return true; return false; } // Chaves específicas do livro (seção 7: apenas permitidas no pt) const BOOK_KEYS = new Set([ "Header.book", "Header.bookNew", "Header.bookTitle", "Header.bookSubtitle", "Footer.bookNew", "Footer.bookTitle", "Footer.bookSubtitle", "Footer.amazon", "Footer.uiclap", "Home.bookChapters", "Home.bookTitle", "Home.bookSubtitle", "Home.bookNewBadge", "Home.bookDescription", "Home.bookParts", "Home.bookChaptersCount", "Home.buyDigital", "Home.buyPrint", "Home.fullRecord", "Livro.title", "Livro.subtitle", "Livro.badge", "Livro.description", "Livro.buyDigital", "Livro.buyPrint", "Livro.chaptersTitle", ]); 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++; } function info(msg) { console.log(` ${CORES.cinza}ℹ${CORES.reset} ${msg}`); } /** 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/4] 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 & Chaves por Idioma // --------------------------------------------------------------------------- function verificarOutrosIdiomas(ptMessages) { console.log(`\n${CORES.negrito}${CORES.ciano}[2/4] 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 { info(`Catálogo '${loc}' (${pathMessages}) ainda não criado (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)) { // Chaves exclusivas do livro não são esperadas fora do pt (§7) if (BOOK_KEYS.has(k) || k.startsWith("Livro.")) { continue; } if (!(k in chavesLoc)) { faltando++; } else { const vLoc = chavesLoc[k]; if (typeof vLoc === "string" && vLoc.trim() === "") { vazios++; } else if (vLoc === vPt && typeof vPt === "string") { if (!isTechnicalAllowlisted(vPt, k)) { iguaisSemAllowlist++; falha(`'${loc}': chave '${k}' tem valor idêntico ao pt fora da allowlist: "${vPt}"`); } } } } for (const k of Object.keys(chavesLoc)) { if (BOOK_KEYS.has(k) || k.startsWith("Livro.")) { falha(`'${loc}': chave proibida do livro '${k}' presente no catálogo fora de pt.`); } else 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 (faltando === 0 && vazios === 0 && iguaisSemAllowlist === 0) { sucesso(`Catálogo '${loc}' ativo possui paridade e tradução completas com 'pt'.`); } } else { if (faltando === 0 && vazios === 0) { sucesso(`Catálogo '${loc}' (inativo) possui paridade estrutural completa com 'pt'.`); } } } } // --------------------------------------------------------------------------- // 3. Verificação de Dados por Idioma (data/ e lib/data/) // --------------------------------------------------------------------------- function encontrarIdenticos(pt, en, path = "") { const problemas = []; if (typeof pt === "string" && typeof en === "string") { if (pt === en && !isTechnicalAllowlisted(pt, path)) { problemas.push({ path, value: pt }); } } else if (Array.isArray(pt) && Array.isArray(en)) { for (let i = 0; i < Math.min(pt.length, en.length); i++) { problemas.push(...encontrarIdenticos(pt[i], en[i], `${path}[${i}]`)); } } else if (typeof pt === "object" && pt !== null && typeof en === "object" && en !== null) { for (const k of Object.keys(en)) { if (k in pt) { problemas.push(...encontrarIdenticos(pt[k], en[k], path ? `${path}.${k}` : k)); } } } return problemas; } async function verificarDadosPorIdioma() { console.log(`\n${CORES.negrito}${CORES.ciano}[3/4] Verificação de Dados (data/ e lib/data/) por Idioma${CORES.reset}`); for (const loc of ENABLED_LOCALES) { if (loc === "pt") continue; // 1. data//series.json vs data/pt/series.json const ptSeriesPath = "data/pt/series.json"; const locSeriesPath = `data/${loc}/series.json`; if (existsSync(locSeriesPath)) { const ptSeries = JSON.parse(readFileSync(ptSeriesPath, "utf8")); const locSeries = JSON.parse(readFileSync(locSeriesPath, "utf8")); const problemas = encontrarIdenticos(ptSeries, locSeries, `data/${loc}/series.json`); if (problemas.length > 0) { falha(`'${loc}': ${problemas.length} valor(es) string em series.json idênticos ao pt fora da allowlist.`); for (const p of problemas.slice(0, 5)) { console.log(` ${CORES.cinza}${p.path}: "${p.value}"${CORES.reset}`); } } else { sucesso(`'${loc}': data/${loc}/series.json validado sem strings vazadas.`); } } // 2. data//frameworks.json vs data/pt/frameworks.json const ptFwPath = "data/pt/frameworks.json"; const locFwPath = `data/${loc}/frameworks.json`; if (existsSync(locFwPath)) { const ptFw = JSON.parse(readFileSync(ptFwPath, "utf8")); const locFw = JSON.parse(readFileSync(locFwPath, "utf8")); const problemas = encontrarIdenticos(ptFw, locFw, `data/${loc}/frameworks.json`); if (problemas.length > 0) { falha(`'${loc}': ${problemas.length} valor(es) string em frameworks.json idênticos ao pt fora da allowlist.`); for (const p of problemas.slice(0, 5)) { console.log(` ${CORES.cinza}${p.path}: "${p.value}"${CORES.reset}`); } } else { sucesso(`'${loc}': data/${loc}/frameworks.json validado sem strings vazadas.`); } } // 3. data//conexoes/*.json vs public/conexoes/*.json const locConexoesDir = `data/${loc}/conexoes`; if (existsSync(locConexoesDir)) { let totalConexoesProblemas = 0; const arqs = readdirSync(locConexoesDir).filter((f) => f.endsWith(".json")); for (const f of arqs) { const ptPath = existsSync(`data/pt/conexoes/${f}`) ? `data/pt/conexoes/${f}` : `public/conexoes/${f}`; if (existsSync(ptPath)) { const pt = JSON.parse(readFileSync(ptPath, "utf8")); const en = JSON.parse(readFileSync(join(locConexoesDir, f), "utf8")); const problemas = encontrarIdenticos(pt, en, `conexoes/${f}`); totalConexoesProblemas += problemas.length; if (problemas.length > 0) { for (const p of problemas.slice(0, 3)) { console.log(` ${CORES.cinza}${p.path}: "${p.value}"${CORES.reset}`); } } } } if (totalConexoesProblemas > 0) { falha(`'${loc}': ${totalConexoesProblemas} valor(es) em conexoes idênticos ao pt fora da allowlist.`); } else { sucesso(`'${loc}': ${arqs.length} arquivos de conexoes validados sem strings vazadas.`); } } // 4. lib/data// try { const ptComp = (await import("../lib/data/pt/componentes.js")).COMPONENTES_REGISTRY; const locComp = (await import(`../lib/data/${loc}/componentes.js`)).COMPONENTES_REGISTRY; const problemasComp = encontrarIdenticos(ptComp, locComp, `lib/data/${loc}/componentes.js`); if (problemasComp.length > 0) { falha(`'${loc}': ${problemasComp.length} string(s) em componentes.js idênticas ao pt fora da allowlist.`); for (const p of problemasComp.slice(0, 3)) { console.log(` ${CORES.cinza}${p.path}: "${p.value}"${CORES.reset}`); } } else { sucesso(`'${loc}': lib/data/${loc}/componentes.js validado sem strings vazadas.`); } } catch (e) { falha(`Erro ao validar lib/data/${loc}/componentes.js: ${e.message}`); } try { const ptDiag = (await import("../lib/data/pt/diagnostico.js")).ERROS_COMUNS; const locDiag = (await import(`../lib/data/${loc}/diagnostico.js`)).ERROS_COMUNS; const problemasDiag = encontrarIdenticos(ptDiag, locDiag, `lib/data/${loc}/diagnostico.js`); if (problemasDiag.length > 0) { falha(`'${loc}': ${problemasDiag.length} string(s) em diagnostico.js idênticas ao pt fora da allowlist.`); } else { sucesso(`'${loc}': lib/data/${loc}/diagnostico.js validado sem strings vazadas.`); } } catch (e) { falha(`Erro ao validar lib/data/${loc}/diagnostico.js: ${e.message}`); } try { const ptGloss = (await import("../lib/data/pt/glossario.js")).categoriasGlossario; const locGloss = (await import(`../lib/data/${loc}/glossario.js`)).categoriasGlossario; const problemasGloss = encontrarIdenticos(ptGloss, locGloss, `lib/data/${loc}/glossario.js`); if (problemasGloss.length > 0) { falha(`'${loc}': ${problemasGloss.length} string(s) em glossario.js idênticas ao pt fora da allowlist.`); } else { sucesso(`'${loc}': lib/data/${loc}/glossario.js validado sem strings vazadas.`); } } catch (e) { falha(`Erro ao validar lib/data/${loc}/glossario.js: ${e.message}`); } } } // Usos técnicos legítimos permitidos (seção 10: allowlist explícita) const TECHNICAL_BOOK_ALLOWLIST = [ "https://esp-rs.github.io/book/", ]; function sanitizarTextoParaLivro(conteudo) { let limpo = conteudo; for (const termo of TECHNICAL_BOOK_ALLOWLIST) { limpo = limpo.split(termo).join(""); } return limpo; } // --------------------------------------------------------------------------- // 4. Verificação de Regras do Livro (§7 da spec: ausência fora do pt) // --------------------------------------------------------------------------- function verificarRegrasLivro() { console.log(`\n${CORES.negrito}${CORES.ciano}[4/4] 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/.json const pathMsg = `messages/${loc}.json`; if (existsSync(pathMsg)) { const conteudo = sanitizarTextoParaLivro(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// const dirData = `data/${loc}`; if (existsSync(dirData)) { const arquivos = readdirSync(dirData).filter((f) => f.endsWith(".json")); for (const arq of arquivos) { const conteudo = sanitizarTextoParaLivro(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++; } } const dirConexoes = join(dirData, "conexoes"); if (existsSync(dirConexoes)) { const arqsConexoes = readdirSync(dirConexoes).filter((f) => f.endsWith(".json")); for (const arq of arqsConexoes) { const conteudo = sanitizarTextoParaLivro(readFileSync(join(dirConexoes, arq), "utf8")); const m = conteudo.match(FORBIDDEN_BOOK_TERMS); if (m) { falha(`Termo proibido do livro '${m[0]}' encontrado em '${dirConexoes}/${arq}'.`); violacoes++; } } } } // Checar lib/data// const dirLibData = `lib/data/${loc}`; if (existsSync(dirLibData)) { const arquivos = readdirSync(dirLibData).filter((f) => f.endsWith(".js")); for (const arq of arquivos) { const conteudo = sanitizarTextoParaLivro(readFileSync(join(dirLibData, arq), "utf8")); const m = conteudo.match(FORBIDDEN_BOOK_TERMS); if (m) { falha(`Termo proibido do livro '${m[0]}' encontrado em '${dirLibData}/${arq}'.`); violacoes++; } } } // Checar public/exemplos// const dirExemplos = `public/exemplos/${loc}`; if (existsSync(dirExemplos)) { const arquivos = readdirSync(dirExemplos); for (const arq of arquivos) { const conteudo = sanitizarTextoParaLivro(readFileSync(join(dirExemplos, arq), "utf8")); const m = conteudo.match(FORBIDDEN_BOOK_TERMS); if (m) { falha(`Termo proibido do livro '${m[0]}' encontrado em '${dirExemplos}/${arq}'.`); violacoes++; } } } } if (violacoes === 0) { sucesso("Nenhuma menção ao livro encontrada em catálogos ou dados fora do português."); } } async 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); await verificarDadosPorIdioma(); verificarRegrasLivro(); console.log(`\n${CORES.negrito}------------------------------------------------------${CORES.reset}`); if (totalErros === 0 && totalAvisos === 0) { console.log(`${CORES.verde}${CORES.negrito}🎉 TODAS AS VERIFICAÇÕES DE I18N PASSARAM! (0 erros, 0 avisos)${CORES.reset}\n`); return 0; } else if (totalErros === 0) { console.log(`${CORES.amarelo}${CORES.negrito}✔ Verificações passaram com ${totalAvisos} aviso(s).${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 = await main();