UNPKG

@localazy/vue-i18n-extract

Version:
1,329 lines (1,095 loc) 36.6 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 vue-i18n-extract readme. vueFiles: './src/**/*.?(js|vue)', languageFiles: './lang/**/*.?(json|yaml|yml|js)', exclude: [], output: false, add: false, remove: false, ci: false, separator: '.', noEmptyTranslation: '', missingTranslationString: '' }; function initCommand() { fs.writeFileSync(path.resolve(process.cwd(), './vue-i18n-extract.config.ts'), `module.exports = ${JSON.stringify(defaultConfig, null, 2)}`); } function resolveConfig() { const argvOptions = cac().parse(process.argv, { run: false }).options; let options; try { const pathToConfigFile = path.resolve(process.cwd(), './vue-i18n-extract.config.ts'); // eslint-disable-next-line @typescript-eslint/no-var-requires const configOptions = require(pathToConfigFile).default; console.info(`\nUsing config file found at ${pathToConfigFile}`); options = _extends({}, configOptions, argvOptions); } catch (_unused) { options = argvOptions; } options.exclude = Array.isArray(options.exclude) ? options.exclude : [options.exclude]; return options; } var DetectionType; (function (DetectionType) { DetectionType["Missing"] = "missing"; DetectionType["Unused"] = "unused"; DetectionType["Dynamic"] = "dynamic"; })(DetectionType || (DetectionType = {})); function readVueFiles(src) { // Replace backslash path segments to make the path work with the glob package. // https://github.com/Spittal/vue-i18n-extract/issues/159 const normalizedSrc = src.replace(/\\/g, '/'); if (!isValidGlob(normalizedSrc)) { throw new Error(`vueFiles isn't a valid glob pattern.`); } const targetFiles = glob.sync(normalizedSrc); if (targetFiles.length === 0) { throw new Error('vueFiles 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[cm]?)\(\s*?(["'`])((?:[^\\]|\\.)*?)\1/g; return [...getMatches(file, methodRegExp, 2)]; } function extractComponentMatches(file) { // Updated: It excludes paths with "/" // My modification const componentRegExp = /(?:(?:<|h\()(?:i18n|Translation))(?:.|\n)*?(?:[^:]path(?:=|: )("|'))([^/\\]*?)\1/gi; // Their updated original // const componentRegExp = /(?:(?:<|h\()(?:i18n|Translation))(?:.|\n)*?(?:\s(?:(?:key)?)path(?:=|: )("|'))([^/\\]*?)\1/gi; // Their updated with my modification // const componentRegExp = /(?:(?:<|h\()(?:i18n|Translation))(?:.|\n)*?(?:\s(?:(?:key)?)path(?:=|: )("|'))((?:[^\\]|\\.)*?)\1/gi; return [...getMatches(file, componentRegExp, 2)]; } function extractDirectiveMatches(file) { const directiveRegExp = /\bv-t(?:\.[\w-]+)?="'((?:[^\\]|\\.)*?)'"/g; return [...getMatches(file, directiveRegExp)]; } function extractI18NItemsFromVueFiles(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 parseVueFiles(vueFiles) { return extractI18NItemsFromVueFiles(readVueFiles(vueFiles)); } function JsonRepairError(message, char) { if (!(this instanceof JsonRepairError)) { throw new SyntaxError('Constructor must be called with the new operator'); } this.message = message + ' (char ' + char + ')'; this.char = char; this.stack = new Error().stack; } JsonRepairError.prototype = new Error(); JsonRepairError.prototype.constructor = Error; var SINGLE_QUOTES = { '\'': true, // quote "\u2018": true, // quote left "\u2019": true, // quote right "`": true, // grave accent "\xB4": true // acute accent }; var DOUBLE_QUOTES = { '"': true, "\u201C": true, // double quote left "\u201D": true // double quote right }; /** * Check if the given character contains an alpha character, a-z, A-Z, _ * @param {string} c * @return {boolean} */ function isAlpha(c) { return ALPHA_REGEX.test(c); } var ALPHA_REGEX = /^[a-zA-Z_]$/; /** * Check if the given character contains a hexadecimal character 0-9, a-f, A-F * @param {string} c * @return {boolean} */ function isHex(c) { return HEX_REGEX.test(c); } var HEX_REGEX = /^[0-9a-fA-F]$/; /** * checks if the given char c is a digit * @param {string} c * @return {boolean} */ function isDigit(c) { return DIGIT_REGEX.test(c); } var DIGIT_REGEX = /^[0-9]$/; /** * Check if the given character is a whitespace character like space, tab, or * newline * @param {string} c * @return {boolean} */ function isWhitespace(c) { return c === ' ' || c === '\t' || c === '\n' || c === '\r'; } /** * Check if the given character is a special whitespace character, some * unicode variant * @param {string} c * @return {boolean} */ function isSpecialWhitespace(c) { return c === "\xA0" || c >= "\u2000" && c <= "\u200A" || c === "\u202F" || c === "\u205F" || c === "\u3000"; } /** * Replace speical whitespace characters with regular spaces * @param {string} text * @returns {string} */ function normalizeWhitespace(text) { var normalized = ''; for (var i = 0; i < text.length; i++) { var char = text[i]; normalized += isSpecialWhitespace(char) ? ' ' : char; } return normalized; } /** * Test whether the given character is a quote or double quote character. * Also tests for special variants of quotes. * @param {string} c * @returns {boolean} */ function isQuote(c) { return SINGLE_QUOTES[c] === true || DOUBLE_QUOTES[c] === true; } /** * Test whether the given character is a single quote character. * Also tests for special variants of single quotes. * @param {string} c * @returns {boolean} */ function isSingleQuote(c) { return SINGLE_QUOTES[c] === true; } /** * Test whether the given character is a double quote character. * Also tests for special variants of double quotes. * @param {string} c * @returns {boolean} */ function isDoubleQuote(c) { return DOUBLE_QUOTES[c] === true; } /** * Normalize special double or single quote characters to their regular * variant ' or " * @param {string} c * @returns {string} */ function normalizeQuote(c) { if (SINGLE_QUOTES[c] === true) { return '\''; } if (DOUBLE_QUOTES[c] === true) { return '"'; } return c; } /** * Strip last occurrence of textToStrip from text * @param {string} text * @param {string} textToStrip * @returns {string} */ function stripLastOccurrence(text, textToStrip) { var index = text.lastIndexOf(textToStrip); return index !== -1 ? text.substring(0, index) + text.substring(index + 1) : text; } /** * Insert textToInsert into text before the last whitespace in text * @param {string} text * @param {string} textToInsert * @returns {string} */ function insertBeforeLastWhitespace(text, textToInsert) { var index = text.length; if (!isWhitespace(text[index - 1])) { // no trailing whitespaces return text + textToInsert; } while (isWhitespace(text[index - 1])) { index--; } return text.substring(0, index) + textToInsert + text.substring(index); } /** * Insert textToInsert at index in text * @param {string} text * @param {string} textToInsert * @param {number} index * @returns {string} */ function insertAtIndex(text, textToInsert, index) { return text.substring(0, index) + textToInsert + text.substring(index); } var DELIMITER = 0; var NUMBER = 1; var STRING = 2; var SYMBOL = 3; var WHITESPACE = 4; var COMMENT = 5; var UNKNOWN = 6; /** * @typedef {DELIMITER | NUMBER | STRING | SYMBOL | WHITESPACE | COMMENT | UNKNOWN} TokenType */ // map with all delimiters var DELIMITERS = { '': true, '{': true, '}': true, '[': true, ']': true, ':': true, ',': true, // for JSONP and MongoDB data type notation '(': true, ')': true, ';': true, // for string concatenation '+': true }; // map with all escape characters var ESCAPE_CHARACTERS = { '"': '"', '\\': '\\', '/': '/', b: '\b', f: '\f', n: '\n', r: '\r', t: '\t' // \u is handled by getToken() }; // TODO: can we unify CONTROL_CHARACTERS and ESCAPE_CHARACTERS? var CONTROL_CHARACTERS = { '\b': '\\b', '\f': '\\f', '\n': '\\n', '\r': '\\r', '\t': '\\t' }; var SYMBOLS = { null: 'null', true: 'true', false: 'false' }; var PYTHON_SYMBOLS = { None: 'null', True: 'true', False: 'false' }; var input = ''; // current json text var output = ''; // generated output var index = 0; // current index in text var c = ''; // current token character in text var token = ''; // current token var tokenType = UNKNOWN; // type of current token /** * Repair a string containing an invalid JSON document. * For example changes JavaScript notation into JSON notation. * * Example: * * jsonrepair('{name: \'John\'}") // '{"name": "John"}' * * @param {string} text * @return {string} */ function jsonrepair(text) { // initialize input = text; output = ''; index = 0; c = input.charAt(0); token = ''; tokenType = UNKNOWN; // get first token processNextToken(); var rootLevelTokenType = tokenType; // parse everything parseObject(); // ignore trailing comma skipComma(); if (token === '') { // reached the end of the document properly return output; } if (rootLevelTokenType === tokenType && tokenIsStartOfValue()) { // start of a new value after end of the root level object: looks like // newline delimited JSON -> turn into a root level array var stashedOutput = ''; while (rootLevelTokenType === tokenType && tokenIsStartOfValue()) { output = insertBeforeLastWhitespace(output, ','); stashedOutput += output; output = ''; // parse next newline delimited item parseObject(); // ignore trailing comma skipComma(); } // wrap the output in an array return "[\n".concat(stashedOutput).concat(output, "\n]"); } throw new JsonRepairError('Unexpected characters', index - token.length); } /** * Get the next character from the expression. * The character is stored into the char c. If the end of the expression is * reached, the function puts an empty string in c. */ function next() { index++; c = input.charAt(index); // Note: not using input[index] because that returns undefined when index is out of range } /** * Special version of the function next, used to parse escaped strings */ function nextSkipEscape() { next(); if (c === '\\') { next(); } } /** * check whether the current token is the start of a value: * object, array, number, string, or symbol * @returns {boolean} */ function tokenIsStartOfValue() { return tokenType === DELIMITER && (token === '[' || token === '{') || tokenType === STRING || tokenType === NUMBER || tokenType === SYMBOL; } /** * check whether the current token is the start of a key (or possible key): * number, string, or symbol * @returns {boolean} */ function tokenIsStartOfKey() { return tokenType === STRING || tokenType === NUMBER || tokenType === SYMBOL; } /** * Process the previous token, and get next token in the current text */ function processNextToken() { output += token; tokenType = UNKNOWN; token = ''; getTokenDelimiter(); if (tokenType === WHITESPACE) { // we leave the whitespace as it is, except replacing special white // space character token = normalizeWhitespace(token); processNextToken(); } if (tokenType === COMMENT) { // ignore comments tokenType = UNKNOWN; token = ''; processNextToken(); } } function skipComma() { if (token === ',') { token = ''; tokenType = UNKNOWN; processNextToken(); } } // check for delimiters like ':', '{', ']' function getTokenDelimiter() { if (DELIMITERS[c]) { tokenType = DELIMITER; token = c; next(); return; } getTokenNumber(); } // check for a number like "2.3e+5" function getTokenNumber() { if (isDigit(c) || c === '-') { tokenType = NUMBER; if (c === '-') { token += c; next(); if (!isDigit(c)) { throw new JsonRepairError('Invalid number, digit expected', index); } } else if (c === '0') { token += c; next(); } else ; while (isDigit(c)) { token += c; next(); } if (c === '.') { token += c; next(); if (!isDigit(c)) { throw new JsonRepairError('Invalid number, digit expected', index); } while (isDigit(c)) { token += c; next(); } } if (c === 'e' || c === 'E') { token += c; next(); if (c === '+' || c === '-') { token += c; next(); } if (!isDigit(c)) { throw new JsonRepairError('Invalid number, digit expected', index); } while (isDigit(c)) { token += c; next(); } } return; } getTokenEscapedString(); } // get a token string like '\"hello world\"' function getTokenEscapedString() { if (c === '\\' && input.charAt(index + 1) === '"') { // an escaped piece of JSON next(); getTokenString(nextSkipEscape); } else { getTokenString(next); } } // get a token string like '"hello world"' function getTokenString(getNext) { if (isQuote(c)) { var quote = normalizeQuote(c); var isEndQuote = isSingleQuote(c) ? isSingleQuote : isDoubleQuote; token += '"'; // output valid double quote tokenType = STRING; getNext(); // eslint-disable-next-line no-unmodified-loop-condition while (c !== '' && !isEndQuote(c)) { if (c === '\\') { // handle escape characters getNext(); var unescaped = ESCAPE_CHARACTERS[c]; if (unescaped !== undefined) { token += '\\' + c; getNext(); } else if (c === 'u') { // parse escaped unicode character, like '\\u260E' token += "\\u"; getNext(); for (var u = 0; u < 4; u++) { if (!isHex(c)) { throw new JsonRepairError('Invalid unicode character', index - token.length); } token += c; getNext(); } } else if (c === '\'') { // escaped single quote character -> remove the escape character token += '\''; getNext(); } else { throw new JsonRepairError('Invalid escape character "\\' + c + '"', index); } } else if (CONTROL_CHARACTERS[c]) { // unescaped special character // fix by adding an escape character token += CONTROL_CHARACTERS[c]; getNext(); } else if (c === '"') { // unescaped double quote -> escape it token += '\\"'; getNext(); } else { // a regular character token += c; getNext(); } } if (normalizeQuote(c) !== quote) { throw new JsonRepairError('End of string expected', index - token.length); } token += '"'; // output valid double quote getNext(); return; } getTokenAlpha(); } // check for symbols (true, false, null) function getTokenAlpha() { if (isAlpha(c)) { tokenType = SYMBOL; while (isAlpha(c) || isDigit(c) || c === '$') { token += c; next(); } return; } getTokenWhitespace(); } // get whitespaces: space, tab, newline, and carriage return function getTokenWhitespace() { if (isWhitespace(c) || isSpecialWhitespace(c)) { tokenType = WHITESPACE; while (isWhitespace(c) || isSpecialWhitespace(c)) { token += c; next(); } return; } getTokenComment(); } function getTokenComment() { // find a block comment '/* ... */' if (c === '/' && input[index + 1] === '*') { tokenType = COMMENT; while (c !== '' && (c !== '*' || c === '*' && input[index + 1] !== '/')) { token += c; next(); } if (c === '*' && input[index + 1] === '/') { token += c; next(); token += c; next(); } return; } // find a comment '// ...' if (c === '/' && input[index + 1] === '/') { tokenType = COMMENT; while (c !== '' && c !== '\n') { token += c; next(); } return; } getTokenUnknown(); } // something unknown is found, wrong characters -> a syntax error function getTokenUnknown() { tokenType = UNKNOWN; while (c !== '') { token += c; next(); } throw new JsonRepairError('Syntax error in part "' + token + '"', index - token.length); } /** * Parse an object like '{"key": "value"}' * @return {*} */ function parseObject() { if (tokenType === DELIMITER && token === '{') { processNextToken(); // TODO: can we make this redundant? if (tokenType === DELIMITER && token === '}') { // empty object processNextToken(); return; } while (true) { // parse key if (tokenType === SYMBOL || tokenType === NUMBER) { // unquoted key -> add quotes around it, change it into a string tokenType = STRING; token = "\"".concat(token, "\""); } if (tokenType !== STRING) { // TODO: handle ambiguous cases like '[{"a":1,{"b":2}]' which could be an array with two objects or one throw new JsonRepairError('Object key expected', index - token.length); } processNextToken(); // parse colon (key/value separator) if (tokenType === DELIMITER && token === ':') { processNextToken(); } else { if (tokenIsStartOfValue()) { // we expect a colon here, but got the start of a value // -> insert a colon before any inserted whitespaces at the end of output output = insertBeforeLastWhitespace(output, ':'); } else { throw new JsonRepairError('Colon expected', index - token.length); } } // parse value parseObject(); // parse comma (key/value pair separator) if (tokenType === DELIMITER && token === ',') { processNextToken(); if (tokenType === DELIMITER && token === '}') { // we've just passed a trailing comma -> remove the trailing comma output = stripLastOccurrence(output, ','); break; } if (token === '') { // end of json reached, but missing } // Strip the missing comma (the closing bracket will be added later) output = stripLastOccurrence(output, ','); break; } } else { if (tokenIsStartOfKey()) { // we expect a comma here, but got the start of a new key // -> insert a comma before any inserted whitespaces at the end of output output = insertBeforeLastWhitespace(output, ','); } else { break; } } } if (tokenType === DELIMITER && token === '}') { processNextToken(); } else { // missing end bracket -> insert the missing bracket output = insertBeforeLastWhitespace(output, '}'); } return; } parseArray(); } /** * Parse an object like '["item1", "item2", ...]' */ function parseArray() { if (tokenType === DELIMITER && token === '[') { processNextToken(); if (tokenType === DELIMITER && token === ']') { // empty array processNextToken(); return; } while (true) { // parse item parseObject(); // parse comma (item separator) if (tokenType === DELIMITER && token === ',') { processNextToken(); if (tokenType === DELIMITER && token === ']') { // we've just passed a trailing comma -> remove the trailing comma output = stripLastOccurrence(output, ','); break; } if (token === '') { // end of json reached, but missing ] // Strip the missing comma (the closing bracket will be added later) output = stripLastOccurrence(output, ','); break; } } else { if (tokenIsStartOfValue()) { // we expect a comma here, but got the start of a new item // -> insert a comma before any inserted whitespaces at the end of output output = insertBeforeLastWhitespace(output, ','); } else { break; } } } if (tokenType === DELIMITER && token === ']') { processNextToken(); } else { // missing end bracket -> insert the missing bracket output = insertBeforeLastWhitespace(output, ']'); } return; } parseString(); } /** * Parse a string enclosed by double quotes "...". Can contain escaped quotes */ function parseString() { if (tokenType === STRING) { processNextToken(); while (tokenType === DELIMITER && token === '+') { // string concatenation like "hello" + "world" token = ''; // don't output the concatenation processNextToken(); if (tokenType === STRING) { // concatenate with the previous string var endIndex = output.lastIndexOf('"'); output = output.substring(0, endIndex) + token.substring(1); token = ''; processNextToken(); } } return; } parseNumber(); } /** * Parse a number */ function parseNumber() { if (tokenType === NUMBER) { processNextToken(); return; } parseSymbol(); } /** * Parse constants true, false, null */ function parseSymbol() { if (tokenType === SYMBOL) { // a supported symbol: true, false, null if (SYMBOLS[token]) { processNextToken(); return; } // for example replace None with null if (PYTHON_SYMBOLS[token]) { token = PYTHON_SYMBOLS[token]; processNextToken(); return; } // make a copy of the symbol, let's see what comes next var symbol = token; var symbolIndex = output.length; token = ''; processNextToken(); // if (tokenType === DELIMITER && token === '(') { if (tokenType === DELIMITER && token === '(') { // a MongoDB function call or JSONP call // Can be a MongoDB data type like in {"_id": ObjectId("123")} // token = '' // do not output the function name // processNextToken() // next() token = ''; // do not output the ( character processNextToken(); // process the part inside the brackets parseObject(); // skip the closing bracket ")" and ");" if (tokenType === DELIMITER && token === ')') { token = ''; // do not output the ) character processNextToken(); if (tokenType === DELIMITER && token === ';') { token = ''; // do not output the semicolon character processNextToken(); } } return; } // unknown symbol => turn into in a string // it is possible that by reading the next token we already inserted // extra spaces in the output which should be inside the string, // hence the symbolIndex output = insertAtIndex(output, "\"".concat(symbol), symbolIndex); while (tokenType === SYMBOL || tokenType === NUMBER) { processNextToken(); } output += '"'; return; } parseEnd(); } /** * Evaluated when the expression is not yet ended but expected to end */ function parseEnd() { if (token === '') { // syntax error or unexpected end of expression throw new JsonRepairError('Unexpected end of json string', index - token.length); } else { throw new JsonRepairError('Value expected', index - token.length); } } function readLanguageFiles(src) { // Replace backslash path segments to make the path work with the glob package. // https://github.com/Spittal/vue-i18n-extract/issues/159 const normalizedSrc = src.replace(/\\/g, '/'); if (!isValidGlob(normalizedSrc)) { throw new Error(`languageFiles isn't a valid glob pattern.`); } const targetFiles = glob.sync(normalizedSrc); if (targetFiles.length === 0) { throw new Error('languageFiles glob has no files.'); } return targetFiles.map(f => { const langPath = path.resolve(process.cwd(), f); const currentFolder = langPath.substring(0, langPath.lastIndexOf("/")); const localeIndexFile = `${currentFolder}/index.ts`; const extension = langPath.substring(langPath.lastIndexOf('.')).toLowerCase(); const isJSON = extension === '.json'; const isTS = extension === '.ts'; 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 if (isTS) { const content = fs.readFileSync(langPath, 'utf8'); const indexFile = fs.readFileSync(localeIndexFile, 'utf8'); const cleanContent = content.replace("export default", "").replace(/};/g, "}").replace(/} ;/g, "}").replace(/`/g, '"') // JSONRepair doesn't like apostrophes .replace(/’/g, "\\'"); // const keyPrefixRegex = new RegExp(/"xxa", { "?([@\w]*)"?:/g) const keyPrefixRegex = new RegExp(/module: '(.+)',/gm); const keyPrefix = keyPrefixRegex.exec(indexFile); if (keyPrefix && keyPrefix[1]) { langObj = JSON.parse(jsonrepair(cleanContent)); langObj = { [keyPrefix[1]]: langObj }; } else { throw new Error("Could not read key prefix from locale index file"); } } 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 => { accumulator[language].push({ path: key, file: file.fileName }); }); return accumulator; }, {}); } function writeMissingToLanguageFiles(parsedLanguageFiles, missingKeys, dot = Dot, noEmptyTranslation = '', missingTranslationString = '') { parsedLanguageFiles.forEach(languageFile => { const languageFileContent = JSON.parse(languageFile.content); missingKeys.forEach(item => { if (item.language && languageFile.fileName.includes(item.language) || !item.language) { const addDefaultTranslation = noEmptyTranslation && (noEmptyTranslation === '*' || noEmptyTranslation === item.language); dot.str(item.path, addDefaultTranslation ? item.path : missingTranslationString === 'null' ? null : missingTranslationString, languageFileContent); } }); writeLanguageFile(languageFile, languageFileContent); }); } function removeUnusedFromLanguageFiles(parsedLanguageFiles, unusedKeys, dot = Dot) { parsedLanguageFiles.forEach(languageFile => { const languageFileContent = JSON.parse(languageFile.content); unusedKeys.forEach(item => { if (item.language && languageFile.fileName.includes(item.language)) { dot.delete(item.path, languageFileContent); } }); writeLanguageFile(languageFile, languageFileContent); }); } function writeLanguageFile(languageFile, newLanguageFileContent) { const fileExtension = languageFile.fileName.substring(languageFile.fileName.lastIndexOf('.') + 1); const filePath = languageFile.path; const nestedContent = Object.values(newLanguageFileContent)[0]; 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 if (fileExtension === 'ts') { const nestedStringifiedContent = JSON.stringify(nestedContent, null, 2); const unquotedContent = unquotePropertiesFromJSONString(nestedStringifiedContent); const tsFile = `export default ${unquotedContent};`; fs.writeFileSync(filePath, tsFile); } else { throw new Error(`Language filetype of ${fileExtension} not supported.`); } } function unquotePropertiesFromJSONString(stringifiedObject) { return stringifiedObject.replace(/^[\t ]*"[^:\n\r]+(?<!\\)":/gm, function (match) { return match.replace(/"/g, ""); }); } // 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); } // 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(vueItems, languageFiles, detect) { const missingKeys = []; const unusedKeys = []; const maybeDynamicKeys = []; if (detect.includes(DetectionType.Dynamic)) { maybeDynamicKeys.push(...vueItems.filter(vueItem => mightBeDynamic(vueItem)).map(vueItem => stripBounding(vueItem))); } Object.keys(languageFiles).forEach(language => { const languageItems = languageFiles[language]; if (detect.includes(DetectionType.Missing)) { const missingKeysInLanguage = vueItems.filter(vueItem => !mightBeDynamic(vueItem)).filter(vueItem => !languageItems.some(languageItem => vueItem.path === languageItem.path)).map(vueItem => _extends({}, stripBounding(vueItem), { language })); missingKeys.push(...missingKeysInLanguage); } if (detect.includes(DetectionType.Unused)) { const unusedKeysInLanguage = languageItems.filter(languageItem => !vueItems.some(vueItem => languageItem.path === vueItem.path || languageItem.path.startsWith(vueItem.path + '.'))).map(languageItem => _extends({}, languageItem, { language })); unusedKeys.push(...unusedKeysInLanguage); } }); return { missingKeys, unusedKeys, maybeDynamicKeys }; } async function writeReportToFile(report, writePath) { const reportString = JSON.stringify(report, null, 2); return new Promise((resolve, reject) => { fs.writeFile(writePath, reportString, err => { if (err) { reject(err); return; } resolve(); }); }); } async function createI18NReport(options) { const { vueFiles: vueFilesGlob, languageFiles: languageFilesGlob, output, add, remove, exclude = [], ci, separator, noEmptyTranslation = '', missingTranslationString = '', detect = [DetectionType.Missing, DetectionType.Unused, DetectionType.Dynamic] } = options; if (!vueFilesGlob) throw new Error('Required configuration vueFiles is missing.'); if (!languageFilesGlob) throw new Error('Required configuration languageFiles is missing.'); let issuesToDetect = Array.isArray(detect) ? detect : [detect]; const invalidDetectOptions = issuesToDetect.filter(item => !Object.values(DetectionType).includes(item)); if (invalidDetectOptions.length) { throw new Error(`Invalid 'detect' value(s): ${invalidDetectOptions}`); } const dot = typeof separator === 'string' ? new Dot(separator) : Dot; const vueFiles = readVueFiles(path.resolve(process.cwd(), vueFilesGlob)); const languageFiles = readLanguageFiles(path.resolve(process.cwd(), languageFilesGlob)); const I18NItems = extractI18NItemsFromVueFiles(vueFiles); const I18NLanguage = extractI18NLanguageFromLanguageFiles(languageFiles, dot); const report = extractI18NReport(I18NItems, I18NLanguage, issuesToDetect); report.unusedKeys = report.unusedKeys.filter(key => !exclude.filter(excluded => key.path.startsWith(excluded)).length); 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.maybeDynamicKeys.length) console.warn('\nSuspected Dynamic Keys Found\nvue-i18n-extract does not compile Vue templates and therefore can not infer the correct key for the following keys.'), 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 (remove && report.unusedKeys.length) { removeUnusedFromLanguageFiles(languageFiles, report.unusedKeys, dot); console.info('\nThe unused keys have been removed from your language files.'); } if (add && report.missingKeys.length) { writeMissingToLanguageFiles(languageFiles, report.missingKeys, dot, noEmptyTranslation, missingTranslationString); console.info('\nThe missing keys have been added to 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.`); } return report; } process.on('uncaughtException', err => { console.error('[vue-i18n-extract]', err); process.exit(1); }); process.on('unhandledRejection', err => { console.error('[vue-i18n-extract]', err); process.exit(1); }); export { DetectionType, createI18NReport, extractI18NItemsFromVueFiles, extractI18NLanguageFromLanguageFiles, extractI18NReport, initCommand, parseVueFiles, parselanguageFiles, readLanguageFiles, readVueFiles, removeUnusedFromLanguageFiles, resolveConfig, writeMissingToLanguageFiles, writeReportToFile }; //# sourceMappingURL=vue-i18n-extract.modern.mjs.map