feat: Loader + plugin to load & transform translations from i18n custom block

This commit is contained in:
Zaytsev Kirill 2021-11-17 14:16:09 +03:00
parent fdf1b689a4
commit 0df8e0a745
19 changed files with 5881 additions and 1 deletions

7
.babelrc Normal file
View File

@ -0,0 +1,7 @@
{
"env": {
"test": {
"plugins": ["@babel/plugin-transform-modules-commonjs"]
}
}
}

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
.idea
/node_modules/
/yarn-error.log

5
.npmignore Normal file
View File

@ -0,0 +1,5 @@
.gitignore
.idea
.npmignore
jest.config.js
/tests

View File

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

1
index.js Normal file
View File

@ -0,0 +1 @@
module.exports

15
jest.conf.js Normal file
View File

@ -0,0 +1,15 @@
module.exports = {
rootDir: __dirname,
moduleFileExtensions: [
'js',
],
modulePaths: [
'<rootDir>',
],
transform: {
'.*\\.js$': 'babel-jest',
},
testEnvironment: 'jsdom',
testPathIgnorePatterns : ['/__fixtures__/'],
testRunner: 'jest-jasmine2',
}

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

View 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
View 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
View 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
View 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"
}
}

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

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

View 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

View File

@ -0,0 +1,15 @@
<template>
<div>{{ $t('test') }}</div>
</template>
<i18n locale="en_GB">
{
"test": "Test"
}
</i18n>
<i18n locale="ru_RU">
{
"test": "Тест"
}
</i18n>

View 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
View 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')
})
})

4997
yarn.lock Normal file

File diff suppressed because it is too large Load Diff