diff --git a/eslint/locale.js b/eslint/locale.js new file mode 100644 index 0000000000..b6f6f762be --- /dev/null +++ b/eslint/locale.js @@ -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, +}; diff --git a/eslint/locale.test.js b/eslint/locale.test.js new file mode 100644 index 0000000000..cf64961054 --- /dev/null +++ b/eslint/locale.test.js @@ -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 }, + ], + }, +); + diff --git a/packages/frontend/eslint.config.js b/packages/frontend/eslint.config.js index 28796e8d6b..2841a5592a 100644 --- a/packages/frontend/eslint.config.js +++ b/packages/frontend/eslint.config.js @@ -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, }],