UNPKG

@mailbutler/i18n-utils

Version:

Manage i18n localization with static analysis

436 lines (374 loc) 14.9 kB
import cac from 'cac'; import fs from 'fs'; import path from 'path'; import isValidGlob from 'is-valid-glob'; import glob from 'glob'; import Dot from 'dot-object'; import yaml from 'js-yaml'; function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); } var defaultConfig = { // Options documented in i18n-utils readme. srcFiles: './src/**/*.?(js|ts|vue)', languageFiles: './lang/**/*.?(json|yaml|yml|js)', output: false, add: false, remove: false, normalize: false, ci: false, separator: '.', detectDuplicates: false }; function initCommand() { fs.writeFileSync(path.resolve(process.cwd(), './i18n-utils.config.js'), `module.exports = ${JSON.stringify(defaultConfig, null, 2)}`); } function resolveConfig() { const argvOptions = cac().parse(process.argv, { run: false }).options; try { const pathToConfigFile = path.resolve(process.cwd(), './i18n-utils.config.js'); // eslint-disable-next-line @typescript-eslint/no-var-requires const configFile = require(pathToConfigFile); console.info(`\nUsing config file found at ${pathToConfigFile}`); return _extends({}, configFile, argvOptions); } catch (_unused) { return argvOptions; } } function readSrcFiles(src) { if (!isValidGlob(src)) { throw new Error(`srcFiles isn't a valid glob pattern.`); } if (!Array.isArray(src)) { src = [src]; } const targetFiles = src.flatMap(targetFile => glob.sync(targetFile)).sort((a, b) => a > b ? -1 : 1); if (targetFiles.length === 0) { throw new Error('srcFiles glob has no files.'); } return targetFiles.map(f => { const fileName = f.replace(process.cwd(), '.'); return { fileName, path: f, content: fs.readFileSync(f, 'utf8') }; }); } function* getMatches(file, regExp, captureGroup = 1) { while (true) { const match = regExp.exec(file.content); if (match === null) { break; } const path = match[captureGroup]; const pathAtIndex = file.content.indexOf(path); const previousCharacter = file.content.charAt(pathAtIndex - 1); const nextCharacter = file.content.charAt(pathAtIndex + path.length); const line = (file.content.substring(0, match.index).match(/\n/g) || []).length + 1; yield { path, previousCharacter, nextCharacter, file: file.fileName, line }; } } /** * Extracts translation keys from methods such as `$t` and `$tc`. * * - **regexp pattern**: (?:[$\s.:"'`+\(\[\{]t[cm]?)\( * * **description**: Matches the sequence t(, tc( or tm(, optionally with either “$”, SPACE, “.”, “:”, “"”, “'”, * “`”, "+", "(", "[" or "{" in front of it. * * - **regexp pattern**: (["'`]) * * **description**: 1. capturing group. Matches either “"”, “'”, or “`”. * * - **regexp pattern**: ((?:[^\\]|\\.)*?) * * **description**: 2. capturing group. Matches anything except a backslash * *or* matches any backslash followed by any character (e.g. “\"”, “\`”, “\t”, etc.) * * - **regexp pattern**: \1 * * **description**: matches whatever was matched by capturing group 1 (e.g. the starting string character) * * @param file a file object * @returns a list of translation keys found in `file`. */ function extractMethodMatches(file) { const methodRegExp = /(?:[$\s.:"'`+\(\[\{]t[cmt]?)\(\s*?(["'`])((?:[^\\]|\\.)*?)\1/g; return [...getMatches(file, methodRegExp, 2)]; } function extractComponentMatches(file) { const componentRegExp = /(?:<(?:i18n|Translation))(?:.|\n)*?(?:[^:]path=("|'))((?:[^\\]|\\.)*?)\1/gi; return [...getMatches(file, componentRegExp, 2)]; } function extractDirectiveMatches(file) { const directiveRegExp = /v-t="'((?:[^\\]|\\.)*?)'"/g; return [...getMatches(file, directiveRegExp)]; } function extractI18NItemsFromSrcFiles(sourceFiles) { return sourceFiles.reduce((accumulator, file) => { const methodMatches = extractMethodMatches(file); const componentMatches = extractComponentMatches(file); const directiveMatches = extractDirectiveMatches(file); return [...accumulator, ...methodMatches, ...componentMatches, ...directiveMatches]; }, []); } // This is a convenience function for users implementing in their own projects, and isn't used internally function parseSrcFiles(srcFiles) { return extractI18NItemsFromSrcFiles(readSrcFiles(srcFiles)); } function sortObject(object) { const sortedObj = {}, keys = Object.keys(object); keys.sort((key1, key2) => { key1 = key1.toLowerCase(), key2 = key2.toLowerCase(); if (key1 < key2) return -1; if (key1 > key2) return 1; return 0; }); // recursively sort object keys.forEach(key => { if (typeof object[key] == 'object' && !(object[key] instanceof Array)) { sortedObj[key] = sortObject(object[key]); } else { sortedObj[key] = object[key]; } }); return sortedObj; } function readLanguageFiles(src) { if (!isValidGlob(src)) { throw new Error(`languageFiles isn't a valid glob pattern.`); } if (!Array.isArray(src)) { src = [src]; } const targetFiles = src.flatMap(targetFile => glob.sync(targetFile)); if (targetFiles.length === 0) { throw new Error('languageFiles glob has no files.'); } return targetFiles.map(f => { const langPath = path.resolve(process.cwd(), f); const extension = langPath.substring(langPath.lastIndexOf('.')).toLowerCase(); const isJSON = extension === '.json'; const isYAML = extension === '.yaml' || extension === '.yml'; let langObj; if (isJSON) { langObj = JSON.parse(fs.readFileSync(langPath, 'utf8')); } else if (isYAML) { langObj = yaml.load(fs.readFileSync(langPath, 'utf8')); } else { langObj = eval(fs.readFileSync(langPath, 'utf8')); } const fileName = f.replace(process.cwd(), '.'); return { path: f, fileName, content: JSON.stringify(langObj) }; }); } function extractI18NLanguageFromLanguageFiles(languageFiles, dot = Dot) { return languageFiles.reduce((accumulator, file) => { const language = file.fileName.substring(file.fileName.lastIndexOf('/') + 1, file.fileName.lastIndexOf('.')); if (!accumulator[language]) { accumulator[language] = []; } const flattenedObject = dot.dot(JSON.parse(file.content)); Object.keys(flattenedObject).forEach((key, index) => { accumulator[language].push({ path: key, file: file.fileName, line: index }); }); return accumulator; }, {}); } function writeMissingToLanguageFiles(parsedLanguageFiles, missingKeys, dot = Dot, normalize = false) { parsedLanguageFiles.forEach(languageFile => { let languageFileContent = JSON.parse(languageFile.content); missingKeys.forEach(item => { if (item.language && languageFile.fileName.includes(item.language) || !item.language) { dot.str(item.path, '', languageFileContent); } }); if (normalize) { languageFileContent = sortObject(languageFileContent); } writeLanguageFile(languageFile, languageFileContent); }); } function removeUnusedFromLanguageFiles(parsedLanguageFiles, unusedKeys, dot = Dot, normalize = false) { parsedLanguageFiles.forEach(languageFile => { let languageFileContent = JSON.parse(languageFile.content); unusedKeys.forEach(item => { if (item.language && languageFile.fileName.includes(item.language)) { dot.delete(item.path, languageFileContent); } }); if (normalize) { languageFileContent = sortObject(languageFileContent); } writeLanguageFile(languageFile, languageFileContent); }); } function writeLanguageFile(languageFile, newLanguageFileContent) { const fileExtension = languageFile.fileName.substring(languageFile.fileName.lastIndexOf('.') + 1); const filePath = languageFile.path; const stringifiedContent = JSON.stringify(newLanguageFileContent, null, 2); if (fileExtension === 'json') { fs.writeFileSync(filePath, stringifiedContent); } else if (fileExtension === 'js') { const jsFile = `module.exports = ${stringifiedContent}; \n`; fs.writeFileSync(filePath, jsFile); } else if (fileExtension === 'yaml' || fileExtension === 'yml') { const yamlFile = yaml.dump(newLanguageFileContent); fs.writeFileSync(filePath, yamlFile); } else { throw new Error(`Language filetype of ${fileExtension} not supported.`); } } // This is a convenience function for users implementing in their own projects, and isn't used internally function parselanguageFiles(languageFiles, dot = Dot) { return extractI18NLanguageFromLanguageFiles(readLanguageFiles(languageFiles), dot); } function stripBounding(item) { return { path: item.path, file: item.file, line: item.line }; } function mightBeDynamic(item) { return item.path.includes('${') && !!item.previousCharacter.match(/`/g) && !!item.nextCharacter.match(/`/g); } function dynamicRegex(item) { if (!item.path.match(/[\w\-]\$\{[^}]+\}/)) return new RegExp('^$'); // there must be more to the string then just the interpolated part return new RegExp(item.path.replace(/\$\{[^}]+\}/g, `\\w+`), 'i'); } // Looping through the arays multiple times might not be the most effecient, but it's the easiest to read and debug. Which at this scale is an accepted trade-off. function extractI18NReport(i18nItems, languageFiles) { const missingKeys = []; const unusedKeys = []; const duplicatedKeys = []; const maybeDynamicKeys = i18nItems.filter(item => mightBeDynamic(item)).map(item => stripBounding(item)); const dynamicRegexes = maybeDynamicKeys.filter(item => !!dynamicRegex(item)).map(item => dynamicRegex(item)); Object.keys(languageFiles).forEach(language => { const languageItems = languageFiles[language]; const duplicatedKeysInLanguage = languageItems.reduce((acc, languageItem) => { if (acc.find(item => item.path === languageItem.path)) return acc; const duplicates = languageItems.filter(item => item.path === languageItem.path); if (duplicates.length > 1) { acc.push({ path: languageItem.path, files: duplicates.map(({ file }) => file != null ? file : 'unknown'), language }); } return acc; }, []); const missingKeysInLanguage = i18nItems.filter(item => !mightBeDynamic(item)).filter(item => !languageItems.some(languageItem => item.path === languageItem.path)).map(item => _extends({}, stripBounding(item), { language })); const unusedKeysInLanguage = languageItems.filter(languageItem => !i18nItems.some(item => languageItem.path === item.path || languageItem.path.startsWith(item.path + '.')) && !dynamicRegexes.some(regex => regex.test(languageItem.path))).map(languageItem => _extends({}, languageItem, { language })); missingKeys.push(...missingKeysInLanguage); unusedKeys.push(...unusedKeysInLanguage); duplicatedKeys.push(...duplicatedKeysInLanguage); }); return { missingKeys, unusedKeys, duplicatedKeys, maybeDynamicKeys }; } async function writeReportToFile(report, writePath) { const reportString = JSON.stringify(report); return new Promise((resolve, reject) => { fs.writeFile(writePath, reportString, err => { if (err) { reject(err); return; } resolve(); }); }); } async function createI18NReport(options) { const { srcFiles: srcFilesGlob, languageFiles: languageFilesGlob, output, add, remove, normalize, ci, separator, detectDuplicates } = options; if (!srcFilesGlob) throw new Error('Required configuration srcFiles is missing.'); if (!languageFilesGlob) throw new Error('Required configuration languageFiles is missing.'); const dot = typeof separator === 'string' ? new Dot(separator) : Dot; const srcFilesGlobs = !Array.isArray(srcFilesGlob) ? [srcFilesGlob] : srcFilesGlob; const srcFiles = readSrcFiles(srcFilesGlobs.map(glob => path.resolve(process.cwd(), glob))); const languageFilesGlobs = !Array.isArray(languageFilesGlob) ? [languageFilesGlob] : languageFilesGlob; const languageFiles = readLanguageFiles(languageFilesGlobs.map(glob => path.resolve(process.cwd(), glob))); const I18NItems = extractI18NItemsFromSrcFiles(srcFiles); const I18NLanguage = extractI18NLanguageFromLanguageFiles(languageFiles, dot); const report = extractI18NReport(I18NItems, I18NLanguage); if (report.missingKeys.length) console.info('\nMissing Keys'), console.table(report.missingKeys); if (report.unusedKeys.length) console.info('\nUnused Keys'), console.table(report.unusedKeys); if (report.duplicatedKeys.length) console.info('\nDuplicated Keys'), console.table(report.duplicatedKeys); if (report.maybeDynamicKeys.length) console.warn("\nSuspected Dynamic Keys Found\ni18n-utils is extra cautious and won't consider any matching key as 'unused'."), console.table(report.maybeDynamicKeys); if (output) { await writeReportToFile(report, path.resolve(process.cwd(), output)); console.info(`\nThe report has been has been saved to ${output}`); } if (add && report.missingKeys.length) { writeMissingToLanguageFiles(languageFiles, report.missingKeys, dot, normalize); console.info('\nThe missing keys have been added to your language files.'); } if (remove && report.unusedKeys.length || normalize) { removeUnusedFromLanguageFiles(languageFiles, report.unusedKeys, dot, normalize); console.info('\nThe unused keys have been removed from your language files.'); } if (ci && report.missingKeys.length) { throw new Error(`${report.missingKeys.length} missing keys found.`); } if (ci && report.unusedKeys.length) { throw new Error(`${report.unusedKeys.length} unused keys found.`); } if (detectDuplicates && report.duplicatedKeys.length) { throw new Error(`${report.duplicatedKeys.length} duplicated keys found.`); } return report; } process.on('uncaughtException', err => { console.error('[i18n-utils]', err); process.exit(1); }); process.on('unhandledRejection', err => { console.error('[i18n-utils]', err); process.exit(1); }); export { createI18NReport, extractI18NItemsFromSrcFiles, extractI18NLanguageFromLanguageFiles, extractI18NReport, initCommand, parseSrcFiles, parselanguageFiles, readLanguageFiles, readSrcFiles, removeUnusedFromLanguageFiles, resolveConfig, writeMissingToLanguageFiles, writeReportToFile }; //# sourceMappingURL=i18n-utils.modern.js.map