lint Vue templates as well
the argument detection doesn't work inside templates when invoked via the `<I18n>` component, because it's too complicated for me now
This commit is contained in:
parent
f11536c927
commit
b0bc24f01b
2 changed files with 168 additions and 120 deletions
241
eslint/locale.js
241
eslint/locale.js
|
@ -50,6 +50,15 @@ function findCallExpression(node) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// same, but for Vue expressions (`<I18n :src="i18n.ts.foo">`)
|
||||||
|
function findVueExpression(node) {
|
||||||
|
if (!node.parent) return null;
|
||||||
|
|
||||||
|
if (node.parent.type.match(/^VExpr/) && node.parent.expression === node) return node.parent;
|
||||||
|
if (node.parent.type === 'MemberExpression') return findVueExpression(node.parent);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function areArgumentsOneObject(node) {
|
function areArgumentsOneObject(node) {
|
||||||
return node.arguments.length === 1 &&
|
return node.arguments.length === 1 &&
|
||||||
node.arguments[0].type === 'ObjectExpression';
|
node.arguments[0].type === 'ObjectExpression';
|
||||||
|
@ -82,117 +91,141 @@ function setDifference(a,b) {
|
||||||
|
|
||||||
/* the actual rule body
|
/* the actual rule body
|
||||||
*/
|
*/
|
||||||
|
function theRuleBody(context,node) {
|
||||||
|
// we get the locale/translations via the options; it's the data
|
||||||
|
// that goes into a specific language's JSON file, see
|
||||||
|
// `scripts/build-assets.mjs`
|
||||||
|
const locale = context.options[0];
|
||||||
|
|
||||||
|
// sometimes we get MemberExpression nodes that have a
|
||||||
|
// *descendent* with the right identifier: skip them, we'll get
|
||||||
|
// the right ones as well
|
||||||
|
if (node.object?.name != 'i18n') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `method` is going to be `'ts'` or `'tsx'`, `path` is going to
|
||||||
|
// be the various translation steps/names
|
||||||
|
const [ method, ...path ] = collectMembers(node);
|
||||||
|
const pathStr = `i18n.${method}.${path.join('.')}`;
|
||||||
|
|
||||||
|
// does that path point to a real translation?
|
||||||
|
const translation = walkDown(locale, path);
|
||||||
|
if (!translation) {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
message: `translation missing for ${pathStr}`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// we hit something weird, assume the programmers know what
|
||||||
|
// they're doing (this is usually some complicated slicing of
|
||||||
|
// the translation structure)
|
||||||
|
if (typeof(translation) !== 'string') return;
|
||||||
|
|
||||||
|
const callExpression = findCallExpression(node);
|
||||||
|
const vueExpression = findVueExpression(node);
|
||||||
|
|
||||||
|
// some more checks on how the translation is called
|
||||||
|
if (method === 'ts') {
|
||||||
|
// the `<I18n> component gets parametric translations via
|
||||||
|
// `i18n.ts.*`, but we error out elsewhere
|
||||||
|
if (translation.match(/\{/) && !vueExpression) {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
message: `translation for ${pathStr} is parametric, but called via 'ts'`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (callExpression) {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
message: `translation for ${pathStr} is not parametric, but is called as a function`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (method === 'tsx') {
|
||||||
|
if (!translation.match(/\{/)) {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
message: `translation for ${pathStr} is not parametric, but called via 'tsx'`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!callExpression && !vueExpression) {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
message: `translation for ${pathStr} is parametric, but not called as a function`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// we're not currently checking arguments when used via the
|
||||||
|
// `<I18n>` component, because it's too complicated (also, it
|
||||||
|
// would have to be done inside the `if (method === 'ts')`)
|
||||||
|
if (!callExpression) return;
|
||||||
|
|
||||||
|
if (!areArgumentsOneObject(callExpression)) {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
message: `translation for ${pathStr} should be called with a single object as argument`,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const translationParameters = getTranslationParameters(translation);
|
||||||
|
const parameterCount = translationParameters.size;
|
||||||
|
const callArguments = getArgumentObjectProperties(callExpression);
|
||||||
|
const argumentCount = callArguments.size;
|
||||||
|
|
||||||
|
if (parameterCount !== argumentCount) {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
message: `translation for ${pathStr} has ${parameterCount} parameters, but is called with ${argumentCount} arguments`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// node 20 doesn't have `Set.difference`...
|
||||||
|
const extraArguments = setDifference(callArguments, translationParameters);
|
||||||
|
const missingArguments = setDifference(translationParameters, callArguments);
|
||||||
|
|
||||||
|
if (extraArguments.length > 0) {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
message: `translation for ${pathStr} passes unused arguments ${extraArguments.join(' ')}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missingArguments.length > 0) {
|
||||||
|
context.report({
|
||||||
|
node,
|
||||||
|
message: `translation for ${pathStr} does not pass arguments ${missingArguments.join(' ')}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function theRule(context) {
|
function theRule(context) {
|
||||||
// we get the locale/translations via the options; it's the data
|
// we get the locale/translations via the options; it's the data
|
||||||
// that goes into a specific language's JSON file, see
|
// that goes into a specific language's JSON file, see
|
||||||
// `scripts/build-assets.mjs`
|
// `scripts/build-assets.mjs`
|
||||||
const locale = context.options[0];
|
const locale = context.options[0];
|
||||||
return {
|
|
||||||
// for all object member access that have an identifier 'i18n'...
|
|
||||||
'MemberExpression:has(> Identifier[name=i18n])': (node) => {
|
|
||||||
// sometimes we get MemberExpression nodes that have a
|
|
||||||
// *descendent* with the right identifier: skip them, we'll get
|
|
||||||
// the right ones as well
|
|
||||||
if (node.object?.name != 'i18n') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// `method` is going to be `'ts'` or `'tsx'`, `path` is going to
|
// for all object member access that have an identifier 'i18n'...
|
||||||
// be the various translation steps/names
|
return context.getSourceCode().parserServices.defineTemplateBodyVisitor(
|
||||||
const [ method, ...path ] = collectMembers(node);
|
{
|
||||||
const pathStr = `i18n.${method}.${path.join('.')}`;
|
// this is for <template> bits, needs work
|
||||||
|
'MemberExpression:has(Identifier[name=i18n])': (node) => theRuleBody(context, node),
|
||||||
// does that path point to a real translation?
|
|
||||||
const translation = walkDown(locale, path);
|
|
||||||
if (!translation) {
|
|
||||||
context.report({
|
|
||||||
node,
|
|
||||||
message: `translation missing for ${pathStr}`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// we hit something weird, assume the programmers know what
|
|
||||||
// they're doing (this is usually some complicated slicing of
|
|
||||||
// the translation structure)
|
|
||||||
if (typeof(translation) !== 'string') return;
|
|
||||||
|
|
||||||
// some more checks on how the translation is called
|
|
||||||
if (method == 'ts') {
|
|
||||||
if (translation.match(/\{/)) {
|
|
||||||
context.report({
|
|
||||||
node,
|
|
||||||
message: `translation for ${pathStr} is parametric, but called via 'ts'`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (findCallExpression(node)) {
|
|
||||||
context.report({
|
|
||||||
node,
|
|
||||||
message: `translation for ${pathStr} is not parametric, but is called as a function`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (method == 'tsx') {
|
|
||||||
if (!translation.match(/\{/)) {
|
|
||||||
context.report({
|
|
||||||
node,
|
|
||||||
message: `translation for ${pathStr} is not parametric, but called via 'tsx'`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const callExpression = findCallExpression(node);
|
|
||||||
if (!callExpression) {
|
|
||||||
context.report({
|
|
||||||
node,
|
|
||||||
message: `translation for ${pathStr} is parametric, but not called as a function`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!areArgumentsOneObject(callExpression)) {
|
|
||||||
context.report({
|
|
||||||
node,
|
|
||||||
message: `translation for ${pathStr} should be called with a single object as argument`,
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const translationParameters = getTranslationParameters(translation);
|
|
||||||
const parameterCount = translationParameters.size;
|
|
||||||
const callArguments = getArgumentObjectProperties(callExpression);
|
|
||||||
const argumentCount = callArguments.size;
|
|
||||||
|
|
||||||
if (parameterCount !== argumentCount) {
|
|
||||||
context.report({
|
|
||||||
node,
|
|
||||||
message: `translation for ${pathStr} has ${parameterCount} parameters, but is called with ${argumentCount} arguments`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// node 20 doesn't have `Set.difference`...
|
|
||||||
const extraArguments = setDifference(callArguments, translationParameters);
|
|
||||||
const missingArguments = setDifference(translationParameters, callArguments);
|
|
||||||
|
|
||||||
if (extraArguments.length > 0) {
|
|
||||||
context.report({
|
|
||||||
node,
|
|
||||||
message: `translation for ${pathStr} passes unused arguments ${extraArguments.join(' ')}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (missingArguments.length > 0) {
|
|
||||||
context.report({
|
|
||||||
node,
|
|
||||||
message: `translation for ${pathStr} does not pass arguments ${missingArguments.join(' ')}`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
};
|
{
|
||||||
|
// this is for normal code
|
||||||
|
'MemberExpression:has(Identifier[name=i18n])': (node) => theRuleBody(context, node),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
|
|
@ -3,31 +3,46 @@ const localeRule = require("./locale");
|
||||||
|
|
||||||
const locale = { foo: { bar: 'ok', baz: 'good {x}' }, top: '123' };
|
const locale = { foo: { bar: 'ok', baz: 'good {x}' }, top: '123' };
|
||||||
|
|
||||||
const ruleTester = new RuleTester();
|
const ruleTester = new RuleTester({
|
||||||
|
languageOptions: {
|
||||||
|
parser: require('vue-eslint-parser'),
|
||||||
|
ecmaVersion: 2015,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function testCase(code,errors) {
|
||||||
|
return { code, errors, options: [ locale ], filename: 'test.ts' };
|
||||||
|
}
|
||||||
|
function testCaseVue(code,errors) {
|
||||||
|
return { code, errors, options: [ locale ], filename: 'test.vue' };
|
||||||
|
}
|
||||||
|
|
||||||
ruleTester.run(
|
ruleTester.run(
|
||||||
'sharkey-locale',
|
'sharkey-locale',
|
||||||
localeRule,
|
localeRule,
|
||||||
{
|
{
|
||||||
valid: [
|
valid: [
|
||||||
{code: 'i18n.ts.foo.bar', options: [locale] },
|
testCase('i18n.ts.foo.bar'),
|
||||||
// we don't detect the problem here, but should still accept it
|
// we don't detect the problem here, but should still accept it
|
||||||
{code: 'i18n.ts.foo["something"]', options: [locale] },
|
testCase('i18n.ts.foo["something"]'),
|
||||||
{code: 'i18n.ts.top', options: [locale] },
|
testCase('i18n.ts.top'),
|
||||||
{code: 'i18n.tsx.foo.baz({x:1})', options: [locale] },
|
testCase('i18n.tsx.foo.baz({x:1})'),
|
||||||
{code: 'whatever.i18n.ts.blah.blah', options: [locale] },
|
testCase('whatever.i18n.ts.blah.blah'),
|
||||||
{code: 'whatever.i18n.tsx.does.not.matter', options: [locale] },
|
testCase('whatever.i18n.tsx.does.not.matter'),
|
||||||
{code: 'whatever(i18n.ts.foo.bar)', options: [locale] },
|
testCase('whatever(i18n.ts.foo.bar)'),
|
||||||
|
testCaseVue('<template><p>{{ i18n.ts.foo.bar }}</p></template>'),
|
||||||
|
testCaseVue('<template><I18n :src="i18n.ts.foo.baz"/></template>'),
|
||||||
],
|
],
|
||||||
invalid: [
|
invalid: [
|
||||||
{code: 'i18n.ts.not', options: [locale], errors: 1 },
|
testCase('i18n.ts.not', 1),
|
||||||
{code: 'i18n.tsx.deep.not', options: [locale], errors: 1 },
|
testCase('i18n.tsx.deep.not', 1),
|
||||||
{code: 'i18n.tsx.deep.not({x:12})', options: [locale], errors: 1 },
|
testCase('i18n.tsx.deep.not({x:12})', 1),
|
||||||
{code: 'i18n.tsx.top({x:1})', options: [locale], errors: 1 },
|
testCase('i18n.tsx.top({x:1})', 1),
|
||||||
{code: 'i18n.ts.foo.baz', options: [locale], errors: 1 },
|
testCase('i18n.ts.foo.baz', 1),
|
||||||
{code: 'i18n.tsx.foo.baz', options: [locale], errors: 1 },
|
testCase('i18n.tsx.foo.baz', 1),
|
||||||
{code: 'i18n.tsx.foo.baz({y:2})', options: [locale], errors: 2 },
|
testCase('i18n.tsx.foo.baz({y:2})', 2),
|
||||||
|
testCaseVue('<template><p>{{ i18n.ts.not }}</p></template>', 1),
|
||||||
|
testCaseVue('<template><I18n :src="i18n.ts.not"/></template>', 1),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
Loading…
Reference in a new issue