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 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