mirror of
https://github.com/retailcrm/vue-i18n-loader.git
synced 2024-11-24 22:36:04 +03:00
feat: Loader + plugin to load & transform translations from i18n custom block
This commit is contained in:
parent
fdf1b689a4
commit
0df8e0a745
7
.babelrc
Normal file
7
.babelrc
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"env": {
|
||||||
|
"test": {
|
||||||
|
"plugins": ["@babel/plugin-transform-modules-commonjs"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
.idea
|
||||||
|
/node_modules/
|
||||||
|
/yarn-error.log
|
5
.npmignore
Normal file
5
.npmignore
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
.gitignore
|
||||||
|
.idea
|
||||||
|
.npmignore
|
||||||
|
jest.config.js
|
||||||
|
/tests
|
@ -1 +1,5 @@
|
|||||||
# vue-i18n-loader
|
# vue-i18n-loader
|
||||||
|
|
||||||
|
vue-i18n-loader is a set of webpack loader & plugin to load translations from
|
||||||
|
`<i18n>` custom blocks
|
||||||
|
|
||||||
|
15
jest.conf.js
Normal file
15
jest.conf.js
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
module.exports = {
|
||||||
|
rootDir: __dirname,
|
||||||
|
moduleFileExtensions: [
|
||||||
|
'js',
|
||||||
|
],
|
||||||
|
modulePaths: [
|
||||||
|
'<rootDir>',
|
||||||
|
],
|
||||||
|
transform: {
|
||||||
|
'.*\\.js$': 'babel-jest',
|
||||||
|
},
|
||||||
|
testEnvironment: 'jsdom',
|
||||||
|
testPathIgnorePatterns : ['/__fixtures__/'],
|
||||||
|
testRunner: 'jest-jasmine2',
|
||||||
|
}
|
45
lib/dependencies/VueI18NDependency.js
Normal file
45
lib/dependencies/VueI18NDependency.js
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
const { ReplaceSource } = require('webpack-sources')
|
||||||
|
const NullDependency = require('webpack/lib/dependencies/NullDependency')
|
||||||
|
|
||||||
|
class VueI18NDependency extends NullDependency {
|
||||||
|
/**
|
||||||
|
* @param {string} id
|
||||||
|
* @param {string} path
|
||||||
|
* @param range
|
||||||
|
* @param {VueI18NMetadata} metadata
|
||||||
|
*/
|
||||||
|
constructor(id, path, range, metadata) {
|
||||||
|
super()
|
||||||
|
this._VueI18N_data = {
|
||||||
|
id,
|
||||||
|
path,
|
||||||
|
range,
|
||||||
|
metadata,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class VueI18NDependencyTemplate {
|
||||||
|
/**
|
||||||
|
* @param {VueI18NDependency} dep
|
||||||
|
* @param {ReplaceSource} source
|
||||||
|
*/
|
||||||
|
apply (dep, source) {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
path,
|
||||||
|
range,
|
||||||
|
metadata,
|
||||||
|
} = dep._VueI18N_data
|
||||||
|
|
||||||
|
const knownPaths = metadata.getPaths(id)
|
||||||
|
|
||||||
|
if (knownPaths.includes(path)) {
|
||||||
|
source.replace(range[0], range[1] - 1, `"${id}.${path.replace(/"/, '\\"')}"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VueI18NDependency.Template = VueI18NDependencyTemplate
|
||||||
|
|
||||||
|
module.exports = VueI18NDependency
|
155
lib/loader.js
Normal file
155
lib/loader.js
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
const qs = require('querystring')
|
||||||
|
|
||||||
|
const JSON5 = require('json5')
|
||||||
|
const yaml = require('js-yaml')
|
||||||
|
|
||||||
|
const { createId } = require('./utils')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {T|Record<string, T>} SelfSimilar
|
||||||
|
* @typedef {string} Locale
|
||||||
|
* @typedef {SelfSimilar<string>} Messages
|
||||||
|
* @typedef {Record<Locale, Messages>} Translations
|
||||||
|
* @template T
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {webpack.loader.Loader}
|
||||||
|
* @param {string|Buffer} source
|
||||||
|
* @param sourceMap
|
||||||
|
*/
|
||||||
|
const loader = function (source, sourceMap) {
|
||||||
|
/** @type {webpack.loader.LoaderContext} */
|
||||||
|
const loaderContext = this
|
||||||
|
|
||||||
|
const {
|
||||||
|
rootContext,
|
||||||
|
resourcePath,
|
||||||
|
resourceQuery,
|
||||||
|
version,
|
||||||
|
/** @type {VueI18NMetadata|undefined} */
|
||||||
|
__vueI18NMetadata
|
||||||
|
} = loaderContext
|
||||||
|
|
||||||
|
const rootPath = rootContext || process.cwd()
|
||||||
|
|
||||||
|
const query = qs.parse(resourceQuery)
|
||||||
|
|
||||||
|
if (version && Number(version) >= 2) {
|
||||||
|
try {
|
||||||
|
loaderContext.cacheable && loaderContext.cacheable()
|
||||||
|
|
||||||
|
const translations = parse(source, query)
|
||||||
|
|
||||||
|
if (typeof __vueI18NMetadata !== 'undefined') {
|
||||||
|
const id = createId(rootPath, resourcePath)
|
||||||
|
|
||||||
|
/** @type {Translations} */
|
||||||
|
const prefixed = Object.keys(translations).reduce((prefixed, locale) => {
|
||||||
|
return Object.assign({}, prefixed, { [locale]: { [id]: translations[locale] } })
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
__vueI18NMetadata.addPaths(id, getPaths(translations))
|
||||||
|
|
||||||
|
loaderContext.callback(
|
||||||
|
null,
|
||||||
|
`module.exports = ${getCode(prefixed)}`,
|
||||||
|
sourceMap,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
loaderContext.callback(
|
||||||
|
null,
|
||||||
|
`module.exports = ${getCode(translations)}`,
|
||||||
|
sourceMap,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
loaderContext.emitError(err.message)
|
||||||
|
loaderContext.callback(err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const message = 'supports loader API version 2 and later'
|
||||||
|
loaderContext.emitError(message)
|
||||||
|
loaderContext.callback(new Error(message))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Translations} translations
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
function getCode(translations) {
|
||||||
|
const value = JSON.stringify(translations)
|
||||||
|
.replace(/\u2028/g, '\\u2028')
|
||||||
|
.replace(/\u2029/g, '\\u2029')
|
||||||
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/\u0027/g, '\\u0027')
|
||||||
|
|
||||||
|
return `function (component) {
|
||||||
|
component.options.__i18n = component.options.__i18n || []
|
||||||
|
component.options.__i18n.push('${value}')
|
||||||
|
delete component.options._Ctor
|
||||||
|
}\n`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Translations} translations
|
||||||
|
* @return {string[]}
|
||||||
|
*/
|
||||||
|
function getPaths (translations) {
|
||||||
|
const paths = []
|
||||||
|
|
||||||
|
Object.values(translations).forEach(messages => {
|
||||||
|
const extract = (messages, prevPath) => {
|
||||||
|
Object.keys(messages).forEach(key => {
|
||||||
|
const currPath = prevPath ? prevPath + '.' + key : key
|
||||||
|
|
||||||
|
if (typeof messages[key] === 'string' && !paths.includes(currPath)) {
|
||||||
|
paths.push(currPath)
|
||||||
|
} else if (typeof messages[key] === 'object') {
|
||||||
|
extract(messages[key], currPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
extract(messages)
|
||||||
|
})
|
||||||
|
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string|Buffer} source
|
||||||
|
* @param {Record<string, unknown>} query
|
||||||
|
* @return {Translations}
|
||||||
|
*/
|
||||||
|
function parse (source, query) {
|
||||||
|
const value = JSON.parse(convert(source, query.lang))
|
||||||
|
|
||||||
|
if (query.locale && typeof query.locale === 'string') {
|
||||||
|
return Object.assign({}, { [query.locale]: value })
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string|Buffer} source
|
||||||
|
* @param {string} lang
|
||||||
|
* @return {string}
|
||||||
|
*/
|
||||||
|
function convert(source, lang) {
|
||||||
|
const value = Buffer.isBuffer(source) ? source.toString() : source
|
||||||
|
|
||||||
|
switch (lang) {
|
||||||
|
case 'yaml':
|
||||||
|
case 'yml':
|
||||||
|
return JSON.stringify(yaml.load(value), undefined, '\t')
|
||||||
|
case 'json5':
|
||||||
|
return JSON.stringify(JSON5.parse(value))
|
||||||
|
default:
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = loader
|
19
lib/metadata/VueI18NMetadata.js
Normal file
19
lib/metadata/VueI18NMetadata.js
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
module.exports = class VueI18NMetadata {
|
||||||
|
constructor () {
|
||||||
|
this.paths = new Map()
|
||||||
|
}
|
||||||
|
|
||||||
|
getPaths (id) {
|
||||||
|
if (this.paths.has(id)) {
|
||||||
|
return this.paths.get(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
addPaths (id, paths) {
|
||||||
|
const _knownPaths = this.getPaths(id)
|
||||||
|
|
||||||
|
this.paths.set(id, _knownPaths.concat(paths.filter(p => !_knownPaths.includes(p))))
|
||||||
|
}
|
||||||
|
}
|
247
lib/plugin.js
Normal file
247
lib/plugin.js
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
const qs = require('querystring')
|
||||||
|
const walk = require('acorn-walk')
|
||||||
|
|
||||||
|
const NullFactory = require('webpack/lib/NullFactory')
|
||||||
|
|
||||||
|
const VueI18NDependency = require('./dependencies/VueI18NDependency')
|
||||||
|
const VueI18NMetadata = require('./metadata/VueI18NMetadata')
|
||||||
|
|
||||||
|
const utils = require('./utils')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {webpack.Compiler} Compiler
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {webpack.compilation.normalModuleFactory.Parser} Parser
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {webpack.compilation.Module} Module
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} Loader
|
||||||
|
* @property {string} loader
|
||||||
|
* @property {*} options
|
||||||
|
* @property {string|undefined} indent
|
||||||
|
* @property {string|undefined} type
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @typedef {Object} NormalModuleExtend
|
||||||
|
* @property {string|undefined} resource
|
||||||
|
* @property {string|undefined} request
|
||||||
|
* @property {string|undefined} userRequest
|
||||||
|
* @property {Parser} parser
|
||||||
|
* @property {Array<Loader>} loaders
|
||||||
|
* @method addDependency(dep): void
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Module & NormalModuleExtend} NormalModule
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {CallExpression} e
|
||||||
|
* @param objName
|
||||||
|
* @param propName
|
||||||
|
* @return {boolean}
|
||||||
|
*/
|
||||||
|
const matchesVarCall = (e, [objName, propName]) => e.callee.type === 'MemberExpression'
|
||||||
|
&& e.callee.object.type === 'Identifier'
|
||||||
|
&& e.callee.object.name === objName
|
||||||
|
&& e.callee.property.name === propName
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {CallExpression} e
|
||||||
|
* @param objName
|
||||||
|
* @param propName
|
||||||
|
* @return {boolean}
|
||||||
|
*/
|
||||||
|
const matchesThisCall = (e, [objName, propName]) => e.callee.type === 'MemberExpression'
|
||||||
|
&& e.callee.object.type === 'MemberExpression'
|
||||||
|
&& e.callee.object.object.type === 'ThisExpression'
|
||||||
|
&& e.callee.object.property.name === objName
|
||||||
|
&& e.callee.property.name === propName
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {CallExpression} e
|
||||||
|
* @param componentName
|
||||||
|
* @return {boolean}
|
||||||
|
*/
|
||||||
|
const matchesComponentCall = (e, componentName) => {
|
||||||
|
const { callee, arguments: [calledComponentName] } = e
|
||||||
|
|
||||||
|
return callee.type === 'Identifier'
|
||||||
|
&& callee.name === '_c'
|
||||||
|
&& calledComponentName
|
||||||
|
&& calledComponentName.type === 'Literal'
|
||||||
|
&& calledComponentName.value === componentName
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {CallExpression} e
|
||||||
|
* @returns {null|ObjectExpression}
|
||||||
|
*/
|
||||||
|
const getDirectiveObject = (e) => {
|
||||||
|
/** @type {ObjectExpression} */
|
||||||
|
const objectExpression = e.arguments[1]
|
||||||
|
|
||||||
|
if (e.callee.type === 'Identifier'
|
||||||
|
&& e.callee.name === '_c'
|
||||||
|
&& isObjectExpression(objectExpression)
|
||||||
|
) {
|
||||||
|
/** @type {Property} */
|
||||||
|
const directives = objectExpression.properties.find(prop => hasPropertyWithDirectives(prop))
|
||||||
|
|
||||||
|
if (isArrayProp(directives)) {
|
||||||
|
/** @type {ObjectExpression} */
|
||||||
|
const object = directives.value.elements.find(elem => isObjectExpression(elem))
|
||||||
|
/** @type {Property} */
|
||||||
|
const prop = object.properties.find(prop => prop.value.value === 'v-t')
|
||||||
|
|
||||||
|
return prop ? object : null
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {ObjectExpression} directiveObject
|
||||||
|
* @return {Property}
|
||||||
|
*/
|
||||||
|
const getPathToDirectiveValue = (directiveObject) => {
|
||||||
|
/** @type {Property} */
|
||||||
|
let property = directiveObject.properties.find(prop => prop.key.name === 'value')
|
||||||
|
|
||||||
|
// если директива используется как: <p v-t="{ path: 'directive_detected' }"/>
|
||||||
|
if (property.value.type === 'ObjectExpression') {
|
||||||
|
property = property.value.properties.find(prop => prop.key.name === 'path')
|
||||||
|
}
|
||||||
|
|
||||||
|
return property
|
||||||
|
}
|
||||||
|
|
||||||
|
const isLiteral = node => node && node.type === 'Literal'
|
||||||
|
|
||||||
|
const isObjectExpression = node => node && node.type === 'ObjectExpression'
|
||||||
|
|
||||||
|
const hasPropertyWithDirectives = property => property && property.key.name === 'directives'
|
||||||
|
|
||||||
|
const isArrayProp = property => property && property.value.type === 'ArrayExpression'
|
||||||
|
|
||||||
|
module.exports = class VueI18NLoaderPlugin {
|
||||||
|
constructor () {
|
||||||
|
this.name = 'VueI18NLoaderPlugin'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Compiler} compiler
|
||||||
|
*/
|
||||||
|
apply (compiler) {
|
||||||
|
const metadata = new VueI18NMetadata()
|
||||||
|
|
||||||
|
const rootPath = compiler.options.context || process.cwd()
|
||||||
|
|
||||||
|
const createId = resourcePath => utils.createId(rootPath, resourcePath)
|
||||||
|
|
||||||
|
compiler.hooks.compilation.tap(this.name, (compilation, { normalModuleFactory }) => {
|
||||||
|
compilation.hooks.normalModuleLoader.tap(this.name, loaderCtx => {
|
||||||
|
loaderCtx.__vueI18NMetadata = metadata
|
||||||
|
})
|
||||||
|
|
||||||
|
compilation.dependencyFactories.set(VueI18NDependency, new NullFactory())
|
||||||
|
compilation.dependencyTemplates.set(VueI18NDependency, new VueI18NDependency.Template())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Parser} parser
|
||||||
|
*/
|
||||||
|
const handler = (parser) => {
|
||||||
|
parser.hooks.program.tap(this.name, ast => {
|
||||||
|
const [resourcePath, resourceQuery] = parser.state.module.resource.split('?')
|
||||||
|
|
||||||
|
const id = createId(resourcePath)
|
||||||
|
const query = qs.parse(resourceQuery)
|
||||||
|
|
||||||
|
if (resourcePath.endsWith('.vue') && ['template', 'script'].includes(query.type)) {
|
||||||
|
const addDependency = path => {
|
||||||
|
const dep = new VueI18NDependency(id, path.value, path.range, metadata)
|
||||||
|
dep.loc = path.loc
|
||||||
|
|
||||||
|
parser.state.current.addDependency(dep)
|
||||||
|
}
|
||||||
|
|
||||||
|
walk.simple(ast, {
|
||||||
|
/**
|
||||||
|
* @param {CallExpression} expression
|
||||||
|
*/
|
||||||
|
CallExpression (expression) {
|
||||||
|
// @TODO:
|
||||||
|
// Calculate object name _vm - seek `var _vm = this` in ast
|
||||||
|
// Maybe another visitor method could help, it will be called earlier,
|
||||||
|
// cause of AST structure
|
||||||
|
// Suggestion: seek `$createElement` renaming & extract identifier from it
|
||||||
|
// (to be sure it is done correctly)
|
||||||
|
// VariableDeclarator, id / init, init.type = ThisExpression
|
||||||
|
if ((
|
||||||
|
matchesVarCall(expression, ['_vm', '$t']) // _vm.$t
|
||||||
|
|| matchesVarCall(expression, ['_vm', '$tc']) // _vm.$tc
|
||||||
|
|| matchesThisCall(expression, ['$i18n', 't']) // this.$i18n.t
|
||||||
|
|| matchesThisCall(expression, ['$i18n', 'tc']) // this.$i18n.tc
|
||||||
|
)) {
|
||||||
|
const [path] = expression.arguments
|
||||||
|
|
||||||
|
if (isLiteral(path)) {
|
||||||
|
addDependency(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchesComponentCall(expression, 'i18n')) { // _c('i18n', ...)
|
||||||
|
/** @type {ObjectExpression} */
|
||||||
|
const vNodeData = expression.arguments[1]
|
||||||
|
|
||||||
|
/** @type {Property} */
|
||||||
|
const attrs = vNodeData.properties.find(prop => prop.key.name === 'attrs')
|
||||||
|
|
||||||
|
if (!attrs) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {Property} */
|
||||||
|
const path = attrs.value.properties.find(prop => prop.key.name === 'path')
|
||||||
|
|
||||||
|
if (path && isLiteral(path.value)) {
|
||||||
|
addDependency(path.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @type {ObjectExpression} */
|
||||||
|
const directives = getDirectiveObject(expression)
|
||||||
|
|
||||||
|
if (directives) {
|
||||||
|
/** @type {Property} */
|
||||||
|
const path = getPathToDirectiveValue(directives)
|
||||||
|
|
||||||
|
if (isLiteral(path.value)) {
|
||||||
|
addDependency(path.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const parser = normalModuleFactory.hooks.parser
|
||||||
|
|
||||||
|
parser.for('javascript/auto').tap(this.name, handler)
|
||||||
|
parser.for('javascript/dynamic').tap(this.name, handler)
|
||||||
|
parser.for('javascript/esm').tap(this.name, handler)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
13
lib/utils.js
Normal file
13
lib/utils.js
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
const hash = require('hash-sum')
|
||||||
|
const path = require('path')
|
||||||
|
|
||||||
|
const createId = (rootPath, resourcePath) => hash(
|
||||||
|
path
|
||||||
|
.relative(rootPath, resourcePath)
|
||||||
|
.replace(/^(\.\.[\/\\])+/, '')
|
||||||
|
.replace(/\\/g, '/')
|
||||||
|
)
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createId,
|
||||||
|
}
|
42
package.json
Normal file
42
package.json
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
{
|
||||||
|
"name": "@retailcrm/vue-i18n-loader",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"description": "Webpack loader for vue i18n custom block",
|
||||||
|
"license": "MIT",
|
||||||
|
"main": "index.js",
|
||||||
|
"author": "RetailDriverLLC <integration@retailcrm.ru>",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+ssh://git@github.com/retailcrm/vue-formulario.git"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"webpack": "^4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.16.0",
|
||||||
|
"@babel/plugin-transform-modules-commonjs": "^7.16.0",
|
||||||
|
"@types/estree": "^0.0.50",
|
||||||
|
"@types/jest": "^27.0.2",
|
||||||
|
"@types/webpack": "^4",
|
||||||
|
"@vue/test-utils": "^1.3.0",
|
||||||
|
"acorn-walk": "^8.2.0",
|
||||||
|
"babel-jest": "^27.3.1",
|
||||||
|
"core-js": "^2.5.7",
|
||||||
|
"hash-sum": "^2.0.0",
|
||||||
|
"jest": "^27.2.4",
|
||||||
|
"jest-raw-loader": "^1.0.1",
|
||||||
|
"js-yaml": "^4.1.0",
|
||||||
|
"jsdom": "^16.7.0",
|
||||||
|
"json5": "^2.2.0",
|
||||||
|
"memory-fs": "^0.5.0",
|
||||||
|
"vue": "^2.6.14",
|
||||||
|
"vue-i18n": "^8.26.7",
|
||||||
|
"vue-loader": "^15.9.8",
|
||||||
|
"vue-template-compiler": "^2.6.14",
|
||||||
|
"webpack": "^4",
|
||||||
|
"webpack-sources": "^1.4.1"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "NODE_ENV=test jest --config ./jest.conf.js"
|
||||||
|
}
|
||||||
|
}
|
19
tests/__fixtures__/component_i18n.vue
Normal file
19
tests/__fixtures__/component_i18n.vue
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
<template>
|
||||||
|
<i18n tag="div" path="hello">
|
||||||
|
<b>{{ $t('world') }}</b>
|
||||||
|
</i18n>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<i18n locale="en_GB">
|
||||||
|
{
|
||||||
|
"hello": "Hello, {0}!",
|
||||||
|
"world": "world"
|
||||||
|
}
|
||||||
|
</i18n>
|
||||||
|
|
||||||
|
<i18n locale="ru_RU">
|
||||||
|
{
|
||||||
|
"hello": "Привет, {0}!",
|
||||||
|
"world": "мир"
|
||||||
|
}
|
||||||
|
</i18n>
|
20
tests/__fixtures__/directive_v_t.vue
Normal file
20
tests/__fixtures__/directive_v_t.vue
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<p v-t="'directive_1'"/>
|
||||||
|
<p v-t="{ path: 'directive_2' }"/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<i18n locale="en_GB">
|
||||||
|
{
|
||||||
|
"directive_1": "Directive 1",
|
||||||
|
"directive_2": "Directive 2"
|
||||||
|
}
|
||||||
|
</i18n>
|
||||||
|
|
||||||
|
<i18n locale="ru_RU">
|
||||||
|
{
|
||||||
|
"directive_1": "Директива 1",
|
||||||
|
"directive_2": "Директива 2"
|
||||||
|
}
|
||||||
|
</i18n>
|
9
tests/__fixtures__/entry.js
Normal file
9
tests/__fixtures__/entry.js
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import Component from '~target'
|
||||||
|
import * as exports from '~target'
|
||||||
|
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.module = Component
|
||||||
|
window.exports = exports
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Component
|
15
tests/__fixtures__/method_t.vue
Normal file
15
tests/__fixtures__/method_t.vue
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
<template>
|
||||||
|
<div>{{ $t('test') }}</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<i18n locale="en_GB">
|
||||||
|
{
|
||||||
|
"test": "Test"
|
||||||
|
}
|
||||||
|
</i18n>
|
||||||
|
|
||||||
|
<i18n locale="ru_RU">
|
||||||
|
{
|
||||||
|
"test": "Тест"
|
||||||
|
}
|
||||||
|
</i18n>
|
18
tests/__fixtures__/method_tc.vue
Normal file
18
tests/__fixtures__/method_tc.vue
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div data-qa-1>{{ $tc('apples', 1) }}</div>
|
||||||
|
<div data-qa-2>{{ $tc('apples', 10) }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<i18n locale="en_GB">
|
||||||
|
{
|
||||||
|
"apples": "{count} apple|{count} apples"
|
||||||
|
}
|
||||||
|
</i18n>
|
||||||
|
|
||||||
|
<i18n locale="ru_RU">
|
||||||
|
{
|
||||||
|
"apples": "{count} яблоко|{count} яблок"
|
||||||
|
}
|
||||||
|
</i18n>
|
246
tests/plugin.test.js
Normal file
246
tests/plugin.test.js
Normal file
@ -0,0 +1,246 @@
|
|||||||
|
import 'core-js'
|
||||||
|
|
||||||
|
import path from 'path'
|
||||||
|
import webpack from 'webpack'
|
||||||
|
|
||||||
|
import FsInMemory from 'memory-fs'
|
||||||
|
|
||||||
|
import { JSDOM, VirtualConsole } from 'jsdom'
|
||||||
|
|
||||||
|
import VueLoaderPlugin from 'vue-loader/lib/plugin'
|
||||||
|
import VueI18NLoaderPlugin from '../lib/plugin'
|
||||||
|
|
||||||
|
import { createLocalVue, mount } from '@vue/test-utils'
|
||||||
|
import VueI18N from 'vue-i18n'
|
||||||
|
|
||||||
|
import { createId } from '../lib/utils'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} BundleResolve
|
||||||
|
* @property {string} code
|
||||||
|
* @property {webpack.Stats} stats
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} RunResolve
|
||||||
|
* @property {any} instance
|
||||||
|
* @property {Window} window
|
||||||
|
* @property {any} module
|
||||||
|
* @property {any} exports
|
||||||
|
* @property {any} error
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} fixture
|
||||||
|
* @return {Promise<BundleResolve>}
|
||||||
|
*/
|
||||||
|
function compile (fixture) {
|
||||||
|
const compiler = webpack({
|
||||||
|
mode: 'development',
|
||||||
|
|
||||||
|
devtool: 'source-map',
|
||||||
|
|
||||||
|
context: __dirname,
|
||||||
|
|
||||||
|
entry: './__fixtures__/entry.js',
|
||||||
|
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'~target': path.resolve(__dirname, fixture),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
output: {
|
||||||
|
path: '/',
|
||||||
|
filename: 'bundle.js'
|
||||||
|
},
|
||||||
|
|
||||||
|
module: {
|
||||||
|
rules: [{
|
||||||
|
test: /\.vue$/,
|
||||||
|
loader: 'vue-loader',
|
||||||
|
}, {
|
||||||
|
resourceQuery: /blockType=i18n/,
|
||||||
|
type: 'javascript/auto',
|
||||||
|
use: [path.resolve(__dirname, '../lib/loader.js')],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
|
||||||
|
plugins: [
|
||||||
|
new VueLoaderPlugin(),
|
||||||
|
new VueI18NLoaderPlugin(),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const mfs = new FsInMemory()
|
||||||
|
compiler.outputFileSystem = mfs
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
compiler.run((err, stats) => {
|
||||||
|
if (err) {
|
||||||
|
return reject(err)
|
||||||
|
}
|
||||||
|
if (stats.hasErrors()) {
|
||||||
|
return reject(new Error(stats.toJson().errors.join(' | ')))
|
||||||
|
}
|
||||||
|
resolve({ code: mfs.readFileSync('/bundle.js').toString(), stats })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} code
|
||||||
|
* @param config
|
||||||
|
* @return {Promise<RunResolve>}
|
||||||
|
*/
|
||||||
|
async function run (code, config = {}) {
|
||||||
|
let dom = null
|
||||||
|
let error
|
||||||
|
|
||||||
|
try {
|
||||||
|
// noinspection HtmlRequiredLangAttribute, HtmlRequiredTitleElement
|
||||||
|
dom = new JSDOM('<!DOCTYPE html><html><head></head><body></body></html>', {
|
||||||
|
runScripts: 'outside-only',
|
||||||
|
virtualConsole: new VirtualConsole(),
|
||||||
|
})
|
||||||
|
dom.window.eval(code)
|
||||||
|
} catch (e) {
|
||||||
|
console.error(`JSDOM error:\n${e.stack}`)
|
||||||
|
error = e
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dom) {
|
||||||
|
return Promise.reject(new Error('Cannot assigned JSDOM instance'))
|
||||||
|
}
|
||||||
|
|
||||||
|
const { window } = dom
|
||||||
|
const { module, exports } = window
|
||||||
|
|
||||||
|
const instance = {}
|
||||||
|
|
||||||
|
if (module && module.beforeCreate) {
|
||||||
|
module.beforeCreate.forEach(hook => hook.call(instance))
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve({
|
||||||
|
window,
|
||||||
|
module,
|
||||||
|
exports,
|
||||||
|
instance,
|
||||||
|
error,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('VueI18NLoaderPlugin', () => {
|
||||||
|
const localVue = createLocalVue()
|
||||||
|
|
||||||
|
localVue.use(VueI18N)
|
||||||
|
|
||||||
|
test('Method $t', async () => {
|
||||||
|
const id = createId('/', '/__fixtures__/method_t.vue')
|
||||||
|
|
||||||
|
const { code } = await compile('./__fixtures__/method_t.vue')
|
||||||
|
const { module } = await run(code)
|
||||||
|
|
||||||
|
const i18n = new VueI18N({ locale: 'en_GB' })
|
||||||
|
|
||||||
|
const wrapper = mount({ render: h => h({ ...module, i18n }) }, { localVue })
|
||||||
|
const component = wrapper.vm.$children[0]
|
||||||
|
|
||||||
|
expect(component).toBeTruthy()
|
||||||
|
expect(component.$i18n.messages).toEqual({
|
||||||
|
'en_GB': { [id]: {'test':'Test'} },
|
||||||
|
'ru_RU': { [id]: {'test':'Тест'} },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.text()).toEqual('Test')
|
||||||
|
|
||||||
|
component.$i18n.locale = 'ru_RU'
|
||||||
|
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toEqual('Тест')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Method $tc', async () => {
|
||||||
|
const id = createId('/', '/__fixtures__/method_tc.vue')
|
||||||
|
|
||||||
|
const { code } = await compile('./__fixtures__/method_tc.vue')
|
||||||
|
const { module } = await run(code)
|
||||||
|
|
||||||
|
const i18n = new VueI18N({ locale: 'en_GB' })
|
||||||
|
|
||||||
|
const wrapper = mount({ render: h => h({ ...module, i18n }) }, { localVue })
|
||||||
|
const component = wrapper.vm.$children[0]
|
||||||
|
|
||||||
|
expect(component).toBeTruthy()
|
||||||
|
expect(component.$i18n.messages).toEqual({
|
||||||
|
'en_GB': { [id]: { 'apples': '{count} apple|{count} apples' } },
|
||||||
|
'ru_RU': { [id]: { 'apples': '{count} яблоко|{count} яблок' } },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-qa-1]').text()).toEqual('1 apple')
|
||||||
|
expect(wrapper.find('[data-qa-2]').text()).toEqual('10 apples')
|
||||||
|
|
||||||
|
component.$i18n.locale = 'ru_RU'
|
||||||
|
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.find('[data-qa-1]').text()).toEqual('1 яблоко')
|
||||||
|
expect(wrapper.find('[data-qa-2]').text()).toEqual('10 яблок')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Interpolation component i18n', async () => {
|
||||||
|
const id = createId('/', '/__fixtures__/component_i18n.vue')
|
||||||
|
|
||||||
|
const { code } = await compile('./__fixtures__/component_i18n.vue')
|
||||||
|
const { module } = await run(code)
|
||||||
|
|
||||||
|
const i18n = new VueI18N({ locale: 'en_GB' })
|
||||||
|
|
||||||
|
const wrapper = mount({ render: h => h({ ...module, i18n }) }, { localVue })
|
||||||
|
const component = wrapper.vm.$children[0]
|
||||||
|
|
||||||
|
expect(component).toBeTruthy()
|
||||||
|
expect(component.$i18n.messages).toEqual({
|
||||||
|
'en_GB': { [id]: { 'hello': 'Hello, {0}!', 'world': 'world' } },
|
||||||
|
'ru_RU': { [id]: { 'hello': 'Привет, {0}!', 'world': 'мир' } },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.text()).toEqual('Hello, world!')
|
||||||
|
|
||||||
|
component.$i18n.locale = 'ru_RU'
|
||||||
|
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toEqual('Привет, мир!')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('Directive v-t', async () => {
|
||||||
|
const id = createId('/', '/__fixtures__/directive_v_t.vue')
|
||||||
|
|
||||||
|
const { code } = await compile('./__fixtures__/directive_v_t.vue')
|
||||||
|
const { module } = await run(code)
|
||||||
|
|
||||||
|
const i18n = new VueI18N({ locale: 'en_GB' })
|
||||||
|
|
||||||
|
const wrapper = mount({ render: h => h({ ...module, i18n }) }, { localVue })
|
||||||
|
const component = wrapper.vm.$children[0]
|
||||||
|
|
||||||
|
expect(component).toBeTruthy()
|
||||||
|
expect(component.$i18n.messages).toEqual({
|
||||||
|
'en_GB': { [id]: { 'directive_1': 'Directive 1', 'directive_2': 'Directive 2' } },
|
||||||
|
'ru_RU': { [id]: { 'directive_1': 'Директива 1', 'directive_2': 'Директива 2' } },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Directive 1')
|
||||||
|
expect(wrapper.text()).toContain('Directive 2')
|
||||||
|
|
||||||
|
component.$i18n.locale = 'ru_RU'
|
||||||
|
|
||||||
|
await wrapper.vm.$nextTick()
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain('Директива 1')
|
||||||
|
expect(wrapper.text()).toContain('Директива 2')
|
||||||
|
})
|
||||||
|
})
|
Loading…
Reference in New Issue
Block a user