vue-translation-manager
Version:
Translate strings in your app in an interactive way
356 lines (296 loc) • 11 kB
JavaScript
const fs = require('fs')
const path = require('path')
const execall = require('execall')
const glob = require('glob')
const uniq = require('lodash.uniq')
/**
* Initialize the translation manager
* @param {object} opts Options
* @param {array} opts.languages The languages, e.g. ["en", "de"]
* @param {object} opts.adapter Adapter for storing and accessing the translations
* @param {string} opts.path Path to the translations
*/
function TranslationManager (opts) {
this.languages = opts.languages || []
if (this.languages.length === 0) throw new Error('No languages given')
this.adapter = opts.adapter
if (!this.adapter) throw new Error('No adapter given')
this.srcPath = opts.srcPath || process.cwd()
this.rootPath = opts.root || process.cwd()
this.adapter._setLanguages(this.languages)
}
module.exports = TranslationManager
module.exports.JSONAdapter = require('./adapter-json.js')
/**
* Get the languages configured
* @returns {array}
*/
TranslationManager.prototype.getLanguages = function () {
return this.languages
}
/**
* Get the configured src path
* @returns {string}
*/
TranslationManager.prototype.getSrcPath = function () {
return this.srcPath
}
/**
* Get the template part for a vue component
* @param {string} path Path to the vue single file component, null if there is none
* @returns {object}
*/
TranslationManager.prototype.getTemplateForSingleFileComponent = function (path) {
const contents = fs.readFileSync(path, { encoding: 'utf8' })
const templateResult = /<template>([\w\W]*)<\/template>/g.exec(contents)
if (!templateResult) return null
let template = ''
if (templateResult && templateResult[1]) template = templateResult[1]
return { template: template, offset: templateResult[0].indexOf(templateResult[1]) + templateResult.index }
}
/**
* Get all untranslated strings for a given vue component
* @param {string} pathToComponent Path to the vue component
*/
TranslationManager.prototype.getStringsForComponent = function (pathToComponent) {
var templateResult = this.getTemplateForSingleFileComponent(pathToComponent)
if (!templateResult) return []
var templateOffset = templateResult.offset
var template = templateResult.template
var matches = execall(/>([^<>]*)</gm, template)
function extractTemplateExpression (text) {
const indexOfOpening = text.indexOf('{{')
const indexOfClosing = text.indexOf('}}')
if (indexOfClosing === -1 || indexOfOpening === -1) {
return {
expression: null,
text: text
}
}
return {
index: indexOfOpening,
indexClosing: indexOfClosing,
expression: text.substring(indexOfOpening + 2, indexOfClosing).trim(),
text: '' + text.substring(0, indexOfOpening) + text.substring(indexOfClosing + 2)
}
}
function checkTemplateExpression (text) {
let currText = text
let expression = true
let expressions = []
let currentOffset = 0
while (expression !== null) {
let result = extractTemplateExpression(currText)
currText = result.text
expression = result.expression
if (expression !== null) {
expressions.push({
expr: expression,
indexStart: currentOffset + result.index,
indexEnd: currentOffset + result.indexClosing
})
currentOffset += (result.indexClosing - result.index) + 2
}
}
return {
staticText: currText.trim(),
hasStaticText: currText.trim().length > 0,
expressions
}
}
var textNodeMatches = matches.map((match) => {
let expressionsInfo = checkTemplateExpression(match.sub[0])
if (!expressionsInfo.hasStaticText) return
if (expressionsInfo.staticText.length < 3) return
return {
indexInTemplate: match.index + 1,
indexInFile: templateOffset + match.index + 1,
originalString: match.sub[0],
string: expressionsInfo.staticText,
stringLength: match.sub[0].length,
expressions: expressionsInfo.expressions,
where: 'textNode'
}
}).filter(Boolean)
var attributeResults = execall(/\s([a-z]*-)?(title|label|text|caption|placeholder)="([^"]*)"/gm, template)
var attributeMatches = attributeResults.map((match) => {
if (!match.sub[2] || match.sub[2].trim() === '') return
return {
indexInTemplate: match.index + match.match.indexOf(match.sub[2]),
indexInFile: templateOffset + match.index + match.match.indexOf(match.sub[2]),
originalString: match.sub[2].trim(),
string: match.sub[2].trim(),
stringLength: match.sub[2].length,
expressions: [],
where: 'attribute'
}
}).filter(Boolean)
return textNodeMatches.concat(...attributeMatches).sort((a, b) => {
if (a.indexInFile < b.indexInFile) return -1
if (a.indexInFile > b.indexInFile) return 1
return 0
})
}
/**
* Replace untranslated strings with their corresponding $t function call
* @param {string} pathToComponent Path to the vue component
* @param {array} strings The strings to replace
*/
TranslationManager.prototype.replaceStringsInComponent = function (pathToComponent, strings) {
var fileContents = fs.readFileSync(pathToComponent, { encoding: 'utf8' })
var contentsAfter = fileContents
var offset = 0
strings.map((str) => {
var translateFn = `{{ $t('${str.key}') }}`
if (str.expressions.length > 0) {
var params = []
for (var i = 0; i < str.expressions.length; i++) {
params.push(`'${i + 1}': ${str.expressions[i].expr}`)
}
translateFn = `{{ $t('${str.key}', { ${params.join(', ')} }) }}`
}
var firstPart = contentsAfter.substring(0, offset + str.indexInFile)
var secondPart = contentsAfter.substring(offset + str.indexInFile + str.stringLength)
if (str.where === 'attribute') {
translateFn = `$t('${str.key}')`
firstPart = firstPart.substring(0, firstPart.lastIndexOf(' ') + 1) + ':' + firstPart.substring(firstPart.lastIndexOf(' ') + 1)
offset += 1
}
contentsAfter = `${firstPart}${translateFn}${secondPart}`
offset += (translateFn.length - str.stringLength)
})
fs.writeFileSync(pathToComponent, contentsAfter)
}
/**
* Generate a suggested key (using dots) based on the given path
* @param {string} pathToFile Path to the file
* @param {string} text The text to be translated
* @param {array} usedKeys Optional, array of keys that have already been used
* @returns {string}
*/
TranslationManager.prototype.getSuggestedKey = async function (pathToFile, text, usedKeys) {
const ignoreWords = ['src', 'components', 'component', 'source', 'test']
var p = path.relative(this.rootPath, pathToFile)
var prefix = p
.split('/')
.filter((part) => ignoreWords.indexOf(part.trim()) < 0)
.map((key) => key.toLowerCase().split('.')[0])
.join('.')
var words = text.trim().split(' ')
if (words.length > 4) words = words.slice(0, 3)
let word = camelCase(words.join(' ').replace(/[^a-zA-Z ]/g, ''))
if (!word) word = Math.floor(Math.random() * 10000)
let proposedKey = await this.getCompatibleKey(`${prefix}.${word}`, usedKeys)
return proposedKey
}
TranslationManager.prototype.getCompatibleKey = async function (suggestedKey, usedKeys) {
let keys = await this.adapter.getAllKeys()
keys = Object.keys(keys).reduce((map, lang) => {
return map.concat(keys[lang])
}, [])
if (usedKeys && typeof Array.isArray(usedKeys)) {
keys = keys.concat(usedKeys)
}
let twitchIt = () => {
return keys.some((key) => {
let existingCheck = new RegExp('^(' + suggestedKey.replace(/\./g, '\\.') + ')(\\..*)?$')
let existingMatch = key.match(existingCheck)
if (existingMatch) {
let secondPart = suggestedKey.substring(existingMatch[1].length)
suggestedKey = `${increaseTrailingNumber(existingMatch[1])}${secondPart}`
return true
}
let reg = new RegExp('^' + key.replace(/\./g, '\\.') + '(\\..*)?$')
let match = suggestedKey.match(reg)
if (!match) return false
suggestedKey = increaseTrailingNumber(suggestedKey)
return true
})
}
while (twitchIt()) {}
return suggestedKey
}
/**
* Add a translated string to a messages resource
* @param {string} key The key for which the strings will be saved
* @param {object} translations Keys are the languages (e.g. "en", "de"), the values are the translated strings
*/
TranslationManager.prototype.addTranslatedString = function (key, translations) {
return this.adapter.addTranslations(key, translations)
}
TranslationManager.prototype.getUnusedTranslations = async function () {
var unusedTranslations = []
let allKeys = []
let keysInLanguages = await this.adapter.getAllKeys()
Object.keys(keysInLanguages).map((lang) => {
allKeys = allKeys.concat(keysInLanguages[lang])
})
allKeys = uniq(allKeys)
allKeys.map((translationKey) => {
var usages = this.getTranslationUsages(translationKey)
if (usages.length === 0) unusedTranslations.push(translationKey)
})
return unusedTranslations
}
TranslationManager.prototype.getTranslationsForKey = async function (key) {
return this.adapter.getTranslations(key)
}
TranslationManager.prototype.deleteTranslations = async function (key) {
return this.adapter.deleteTranslations([key])
}
TranslationManager.prototype.getTranslationUsages = function (translationKey) {
var files = glob.sync(`${this.srcPath}/**/*.vue`)
var usages = []
files.map((file) => {
var fileContents = fs.readFileSync(file)
if (fileContents.indexOf(`$t('${translationKey}'`) > -1) usages.push(file)
if (fileContents.indexOf(`$t("${translationKey}"`) > -1) usages.push(file)
})
return usages
}
TranslationManager.prototype.validate = async function () {
let missingKeys = {}
let allKeys = []
let keysInLanguages = await this.adapter.getAllKeys()
Object.keys(keysInLanguages).map((lang) => {
allKeys = allKeys.concat(keysInLanguages[lang])
})
allKeys = uniq(allKeys)
this.languages.forEach((lang) => {
for (let key of allKeys) {
if (!keysInLanguages[lang].includes(key)) {
if (!missingKeys.hasOwnProperty(lang)) {
missingKeys[lang] = []
}
missingKeys[lang].push(key)
}
}
})
return missingKeys
}
/**
* camelCase any string
* @param {string} text The string to be camelCased
* @returns {string} theStringInCamelCase
*/
function camelCase (text) {
return text
.trim()
.split(' ')
.map((word) => word.toLowerCase())
.map((word, i) => (i === 0 ? word : word[0].toUpperCase() + word.substring(1)))
.join('')
}
function increaseTrailingNumber (str) {
let chars = str.split('')
chars.reverse()
let numbers = 0
for (var i = 0; i < chars.length; i++) {
if (!isNaN(parseInt(chars[i]))) numbers++
break
}
chars = chars.reverse().join('')
let keyWithoutNumber = chars.substring(0, chars.length - numbers)
let currentNumber = parseInt(chars.substring(chars.length - numbers)) || 0
return `${keyWithoutNumber}${++currentNumber}`
}