feat: localization validator for missing internationalization

fix: trackerEditor.js doesn't have syntax issues anymore.
This commit is contained in:
Alamion
2026-03-02 20:54:15 +03:00
parent 75c8f9b63a
commit 933d78e192
6 changed files with 298 additions and 8 deletions
+1
View File
@@ -24,3 +24,4 @@ node_modules/
# Claude # Claude
CLAUDE.md CLAUDE.md
yarn.lock
+6
View File
@@ -0,0 +1,6 @@
{
"name": "rpg-companion-sillytavern",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "rpg-complanion-sillytavern",
"version": "3.7.3",
"description": "",
"main": "index.js",
"scripts": {
"validate_locale": "node src/i18n/validator.js --watch",
"validate_locale_once": "node src/i18n/validator.js"
},
"keywords": [],
"author": "",
"license": "MIT",
"devDependencies": {
"chokidar": "^5.0.0",
"fs-extra": "^11.3.3",
"glob": "^13.0.6"
},
"dependencies": {}
}
+264
View File
@@ -0,0 +1,264 @@
const fs = require('fs-extra');
const path = require('path');
const chokidar = require('chokidar');
const glob = require('glob');
const COMPILED_DIR = __dirname // path.join(__dirname, 'compiled');
function findUnlocalizedText() {
const srcArg = process.argv.find(arg => arg.startsWith('--src='));
const srcDir = srcArg ? srcArg.split('=')[1] : '.';
console.log(`\n🔎 Scanning for unlocalized text in ${srcDir}...`);
const files = glob.sync(`${srcDir}/**/*.{html,js,jsx}`, {
ignore: ['**/node_modules/**', '**/dist/**', '**/build/**']
});
if (files.length === 0) {
console.log('⚠️ No .html/.js/.jsx files found');
return;
}
let totalFound = 0;
for (const file of files) {
const content = fs.readFileSync(file, 'utf8');
const lines = content.split('\n');
const relPath = path.relative(process.cwd(), file);
// Searching for string number
lines.forEach((line, index) => {
let match;
const localPattern = /<([a-zA-Z][a-zA-Z0-9]*)(?:\s(?:[^>](?!data-i18n-key))*)?>([\p{L}\p{N}\s\-.,!?:'"()]+)<\/\1>/gu;
while ((match = localPattern.exec(line)) !== null) {
const text = match[2].trim();
if (!text) continue;
// Passing JSX expressions like {someVar}
if (text.includes('{') || text.includes('}')) continue;
// Passing if tag has data-i18n-key
if (match[0].includes('data-i18n-key')) continue;
console.log(` - ${relPath}:${index + 1} — <${match[1]}> "${text}"`);
totalFound++;
}
});
}
if (totalFound === 0) {
console.log('✅ No unlocalized text found!');
} else {
console.log(`\n📋 Found ${totalFound} potentially unlocalized text node(s)`);
}
}
// Function to validate translations
function validateTranslations() {
console.log('🔍 Validating translation files...');
// Parse --locales=en,fr argument
const localesArg = process.argv.find(arg => arg.startsWith('--locales='));
const selectedLocales = localesArg
? localesArg.split('=')[1].split(',').map(l => l.trim())
: null;
const files = fs.readdirSync(COMPILED_DIR)
.filter(file => file.endsWith('.json'))
.filter(file => {
const locale = path.basename(file, '.json');
return !selectedLocales || selectedLocales.includes(locale);
});
if (files.length === 0) {
console.log('⚠️ No compiled translation files found');
return;
}
// Load all translation data
const translations = {};
for (const file of files) {
const locale = path.basename(file, '.json');
const filePath = path.join(COMPILED_DIR, file);
translations[locale] = fs.readJsonSync(filePath);
}
// Get all locales
const locales = Object.keys(translations);
console.log(`📁 Found ${locales.length} locales: ${locales.join(', ')}`);
if (locales.length < 2) {
console.log('⚠️ Need at least 2 locales to compare');
return;
}
// Choose the first locale as reference
const referenceLocale = locales[0];
console.log(`🔑 Using ${referenceLocale} as reference locale`);
// Get all keys from reference locale
const referenceKeys = Object.keys(translations[referenceLocale]);
console.log(`🔢 Reference locale has ${referenceKeys.size} unique keys`);
// Track statistics
const stats = {
missingKeys: {},
extraKeys: {},
typeErrors: {}
};
// Initialize stats for each locale
for (const locale of locales) {
if (locale !== referenceLocale) {
stats.missingKeys[locale] = [];
stats.extraKeys[locale] = [];
stats.typeErrors[locale] = [];
}
}
// Check each locale against the reference
for (const locale of locales) {
if (locale === referenceLocale) continue;
const localeKeys = Object.keys(translations[locale]);
// Check for missing keys
for (const key of referenceKeys) {
if (!key in translations[locale]) {
stats.missingKeys[locale].push(key);
} else {
// Check for type mismatches
const refValue = translations[referenceLocale][key];
const localeValue = translations[locale][key];
if (typeof refValue !== typeof localeValue) {
stats.typeErrors[locale].push({
key,
refType: typeof refValue,
localeType: typeof localeValue
});
}
}
}
// Check for extra keys
for (const key of localeKeys) {
if (!key in translations[referenceLocale]) {
stats.extraKeys[locale].push(key);
}
}
}
// Print results
let hasIssues = false;
// Print missing keys
for (const locale in stats.missingKeys) {
const missing = stats.missingKeys[locale];
if (missing.length > 0) {
hasIssues = true;
console.log(`${locale} is missing ${missing.length} keys:`);
missing.forEach(key => {
console.log(` - ${key}`);
});
}
}
// Print extra keys
for (const locale in stats.extraKeys) {
const extra = stats.extraKeys[locale];
if (extra.length > 0) {
hasIssues = true;
console.log(`⚠️ ${locale} has ${extra.length} extra keys:`);
extra.forEach(key => {
console.log(` - ${key}`);
});
}
}
// Print type errors
for (const locale in stats.typeErrors) {
const typeErrors = stats.typeErrors[locale];
if (typeErrors.length > 0) {
hasIssues = true;
console.log(`⚠️ ${locale} has ${typeErrors.length} type mismatches:`);
typeErrors.forEach(err => {
console.log(` - ${err.key}: expected ${err.refType}, got ${err.localeType}`);
});
}
}
// Print empty values check if needed
console.log('\n📊 Checking for empty values...');
for (const locale of locales) {
checkEmptyValues(translations[locale], locale);
}
if (!hasIssues) {
console.log('✅ All locales have consistent structure!');
}
return hasIssues;
}
// Function to check for empty values
function checkEmptyValues(obj, locale, prefix = '') {
for (const key in obj) {
const fullKey = prefix ? `${prefix}.${key}` : key;
const value = obj[key];
if (value === '') {
console.log(`⚠️ ${locale} has empty string at ${fullKey}`);
} else if (value === null) {
console.log(`⚠️ ${locale} has null value at ${fullKey}`);
} else if (typeof value === 'object' && !Array.isArray(value)) {
checkEmptyValues(value, locale, fullKey);
}
}
}
// Main function
function main() {
// Create compiled directory if it doesn't exist
fs.ensureDirSync(COMPILED_DIR);
// Run validation
validateTranslations();
// Find unlocalized text
findUnlocalizedText();
}
// Watch mode
if (process.argv.includes('--watch')) {
console.log('👀 Watching for changes...');
let debounceTimer;
const debounceDelay = 100;
// Initial validation
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
main();
}, debounceDelay);
// Watch for changes in the compiled directory
chokidar.watch(COMPILED_DIR, {
ignoreInitial: true,
ignored: /.*~$/, // Игнорировать скрытые файлы
}).on('all', (event, path) => {
if (event === 'change' || event === 'add' || event === 'unlink') {
clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
console.log(`🔁 Detected changes in ${path} (${event}), revalidating...`);
main();
}, debounceDelay);
}
});
} else {
// Run once
main();
}
+1 -1
View File
@@ -213,7 +213,7 @@ export function renderUserStats() {
if (!lastGeneratedData.userStats && !committedTrackerData.userStats) { if (!lastGeneratedData.userStats && !committedTrackerData.userStats) {
// Always render to the #rpg-user-stats container (mobile layout just moves it around in DOM) // Always render to the #rpg-user-stats container (mobile layout just moves it around in DOM)
$userStatsContainer.html('<div class="rpg-inventory-empty">No statuses generated yet</div>'); $userStatsContainer.html('<div class="rpg-inventory-empty" data-i18n-key="warnings.no-statuses">No statuses generated yet</div>');
return; return;
} }
+7 -7
View File
@@ -58,14 +58,14 @@ function set_ids_names(list_with_stats, index, value) {
if (extensionSettings.userStats && Object.prototype.hasOwnProperty.call(extensionSettings.userStats, oldId)) { if (extensionSettings.userStats && Object.prototype.hasOwnProperty.call(extensionSettings.userStats, oldId)) {
extensionSettings.userStats[newId] = extensionSettings.userStats[oldId]; extensionSettings.userStats[newId] = extensionSettings.userStats[oldId];
delete extensionSettings.userStats[oldId]; delete extensionSettings.userStats[oldId];
function setIdsNames(listWithStats, index, value) {
listWithStats[index].name = value;
const ids = listWithStats.toSpliced(index, 1).map(stat => stat.id);
const snakeValue = safeToSnake(value); // new id format
if (snakeValue !== value && !ids.includes(snakeValue)) { // check if this id already exists
listWithStats[index].id = snakeValue;
} }
return listWithStats;
if (extensionSettings.classicStats && Object.prototype.hasOwnProperty.call(extensionSettings.classicStats, oldId)) {
extensionSettings.classicStats[newId] = extensionSettings.classicStats[oldId];
delete extensionSettings.classicStats[oldId];
}
}
return list_with_stats;
} }