Skip to content
Snippets Groups Projects
Commit 82674d87 authored by dakkar's avatar dakkar
Browse files

lint all uses of translations

parent 42e2a586
No related branches found
No related tags found
No related merge requests found
/* 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,
};
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,
}],
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment