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:
dakkar 2024-10-16 15:57:15 +01:00
parent f11536c927
commit b0bc24f01b
2 changed files with 168 additions and 120 deletions

View file

@ -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,14 +91,12 @@ function setDifference(a,b) {
/* the actual rule body /* the actual rule body
*/ */
function theRule(context) { function theRuleBody(context,node) {
// 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 // sometimes we get MemberExpression nodes that have a
// *descendent* with the right identifier: skip them, we'll get // *descendent* with the right identifier: skip them, we'll get
// the right ones as well // the right ones as well
@ -117,9 +124,14 @@ function theRule(context) {
// the translation structure) // the translation structure)
if (typeof(translation) !== 'string') return; if (typeof(translation) !== 'string') return;
const callExpression = findCallExpression(node);
const vueExpression = findVueExpression(node);
// some more checks on how the translation is called // some more checks on how the translation is called
if (method == 'ts') { if (method === 'ts') {
if (translation.match(/\{/)) { // the `<I18n> component gets parametric translations via
// `i18n.ts.*`, but we error out elsewhere
if (translation.match(/\{/) && !vueExpression) {
context.report({ context.report({
node, node,
message: `translation for ${pathStr} is parametric, but called via 'ts'`, message: `translation for ${pathStr} is parametric, but called via 'ts'`,
@ -127,7 +139,7 @@ function theRule(context) {
return; return;
} }
if (findCallExpression(node)) { if (callExpression) {
context.report({ context.report({
node, node,
message: `translation for ${pathStr} is not parametric, but is called as a function`, message: `translation for ${pathStr} is not parametric, but is called as a function`,
@ -135,7 +147,7 @@ function theRule(context) {
} }
} }
if (method == 'tsx') { if (method === 'tsx') {
if (!translation.match(/\{/)) { if (!translation.match(/\{/)) {
context.report({ context.report({
node, node,
@ -144,8 +156,7 @@ function theRule(context) {
return; return;
} }
const callExpression = findCallExpression(node); if (!callExpression && !vueExpression) {
if (!callExpression) {
context.report({ context.report({
node, node,
message: `translation for ${pathStr} is parametric, but not called as a function`, message: `translation for ${pathStr} is parametric, but not called as a function`,
@ -153,6 +164,11 @@ function theRule(context) {
return; 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)) { if (!areArgumentsOneObject(callExpression)) {
context.report({ context.report({
node, node,
@ -191,8 +207,25 @@ function theRule(context) {
}); });
} }
} }
}
function theRule(context) {
// 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];
// for all object member access that have an identifier 'i18n'...
return context.getSourceCode().parserServices.defineTemplateBodyVisitor(
{
// this is for <template> bits, needs work
'MemberExpression:has(Identifier[name=i18n])': (node) => theRuleBody(context, node),
}, },
}; {
// this is for normal code
'MemberExpression:has(Identifier[name=i18n])': (node) => theRuleBody(context, node),
},
);
} }
module.exports = { module.exports = {

View file

@ -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),
], ],
}, },
); );