lint all uses of translations
This commit is contained in:
parent
42e2a58642
commit
82674d8718
145
eslint/locale.js
Normal file
145
eslint/locale.js
Normal file
@ -0,0 +1,145 @@
|
||||
/* This is a ESLint rule to report use of the `i18n.ts` and `i18n.tsx`
|
||||
* objects that reference translation items that don't actually exist
|
||||
* in the lexicon (the `locale/` files)
|
||||
*/
|
||||
|
||||
/* given a MemberExpression node, collects all the member names
|
||||
*
|
||||
* e.g. for a bit of code like `foo=one.two.three`, `collectMembers`
|
||||
* called on the node for `three` would return `['one', 'two',
|
||||
* 'three']`
|
||||
*/
|
||||
function collectMembers(node) {
|
||||
if (!node) return [];
|
||||
if (node.type !== 'MemberExpression') return [];
|
||||
return [ node.property.name, ...collectMembers(node.parent) ];
|
||||
}
|
||||
|
||||
/* given an object and an array of names, recursively descends the
|
||||
* object via those names
|
||||
*
|
||||
* e.g. `walkDown({one:{two:{three:15}}},['one','two','three'])` would
|
||||
* return 15
|
||||
*/
|
||||
function walkDown(locale, path) {
|
||||
if (!locale) return null;
|
||||
if (!path || path.length === 0) return locale;
|
||||
return walkDown(locale[path[0]], path.slice(1));
|
||||
}
|
||||
|
||||
/* given a MemberExpression node, returns its attached CallExpression
|
||||
* node if present
|
||||
*
|
||||
* e.g. for a bit of code like `foo=one.two.three()`,
|
||||
* `findCallExpression` called on the node for `three` would return
|
||||
* the node for function call (which is the parent of the `one` and
|
||||
* `two` nodes, and holds the nodes for the argument list)
|
||||
*
|
||||
* if the code had been `foo=one.two.three`, `findCallExpression`
|
||||
* would have returned null, because there's no function call attached
|
||||
* to the MemberExpressions
|
||||
*/
|
||||
function findCallExpression(node) {
|
||||
if (node.type === 'CallExpression') return node
|
||||
if (node.parent?.type === 'CallExpression') return node.parent;
|
||||
if (node.parent?.type === 'MemberExpression') return findCallExpression(node.parent);
|
||||
return null;
|
||||
}
|
||||
|
||||
/* the actual rule body
|
||||
*/
|
||||
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];
|
||||
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
|
||||
// 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 matchingNode = walkDown(locale, path);
|
||||
if (!matchingNode) {
|
||||
context.report({
|
||||
node,
|
||||
message: `translation missing for ${pathStr}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// some more checks on how the translation is called
|
||||
if (method == 'ts') {
|
||||
if (matchingNode.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 (!matchingNode.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;
|
||||
}
|
||||
|
||||
const parameterCount = [...matchingNode.matchAll(/\{/g)].length ?? 0;
|
||||
const argumentCount = callExpression.arguments.length;
|
||||
if (parameterCount !== argumentCount) {
|
||||
context.report({
|
||||
node,
|
||||
message: `translation for ${pathStr} has ${parameterCount} parameters, but is called with ${argumentCount} arguments`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description: 'assert that all translations used are present in the locale files',
|
||||
},
|
||||
schema: [
|
||||
// here we declare that we need the locale/translation as a
|
||||
// generic object
|
||||
{ type: 'object', additionalProperties: true },
|
||||
],
|
||||
},
|
||||
create: theRule,
|
||||
};
|
29
eslint/locale.test.js
Normal file
29
eslint/locale.test.js
Normal file
@ -0,0 +1,29 @@
|
||||
const {RuleTester} = require("eslint");
|
||||
const localeRule = require("./locale");
|
||||
|
||||
const locale = { foo: { bar: 'ok', baz: 'good {x}' }, top: '123' };
|
||||
|
||||
const ruleTester = new RuleTester();
|
||||
|
||||
ruleTester.run(
|
||||
'sharkey-locale',
|
||||
localeRule,
|
||||
{
|
||||
valid: [
|
||||
{code: 'i18n.ts.foo.bar', options: [locale] },
|
||||
{code: 'i18n.ts.top', options: [locale] },
|
||||
{code: 'i18n.tsx.foo.baz(1)', options: [locale] },
|
||||
{code: 'whatever.i18n.ts.blah.blah', options: [locale] },
|
||||
{code: 'whatever.i18n.tsx.does.not.matter', options: [locale] },
|
||||
],
|
||||
invalid: [
|
||||
{code: 'i18n.ts.not', options: [locale], errors: 1 },
|
||||
{code: 'i18n.tsx.deep.not', options: [locale], errors: 1 },
|
||||
{code: 'i18n.tsx.deep.not(12)', options: [locale], errors: 1 },
|
||||
{code: 'i18n.tsx.top(1)', options: [locale], errors: 1 },
|
||||
{code: 'i18n.ts.foo.baz', options: [locale], errors: 1 },
|
||||
{code: 'i18n.tsx.foo.baz', options: [locale], errors: 1 },
|
||||
],
|
||||
},
|
||||
);
|
||||
|
@ -4,6 +4,8 @@ import parser from 'vue-eslint-parser';
|
||||
import pluginVue from 'eslint-plugin-vue';
|
||||
import pluginMisskey from '@misskey-dev/eslint-plugin';
|
||||
import sharedConfig from '../shared/eslint.config.js';
|
||||
import localeRule from '../../eslint/locale.js';
|
||||
import { build as buildLocales } from '../../locales/index.js';
|
||||
|
||||
export default [
|
||||
...sharedConfig,
|
||||
@ -14,6 +16,7 @@ export default [
|
||||
...pluginVue.configs['flat/recommended'],
|
||||
{
|
||||
files: ['{src,test,js,@types}/**/*.{ts,vue}'],
|
||||
plugins: { sharkey: { rules: { locale: localeRule } } },
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...Object.fromEntries(Object.entries(globals.node).map(([key]) => [key, 'off'])),
|
||||
@ -44,6 +47,8 @@ export default [
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
'sharkey/locale': ['error', buildLocales()['ja-JP']],
|
||||
|
||||
'@typescript-eslint/no-empty-interface': ['error', {
|
||||
allowSingleExtends: true,
|
||||
}],
|
||||
|
Loading…
Reference in New Issue
Block a user