summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordakkar <dakkar@thenautilus.net>2024-10-16 14:01:54 +0100
committerdakkar <dakkar@thenautilus.net>2024-10-22 12:02:23 +0100
commit82674d871874a4e86bca1e39b081bbcf05127f61 (patch)
tree86c968ecf9550a6d11b7c09ec3e371ced433c22c
parentmerge: Add followers tab to following feed + fix duplication (resolves #729) ... (diff)
downloadsharkey-82674d871874a4e86bca1e39b081bbcf05127f61.tar.gz
sharkey-82674d871874a4e86bca1e39b081bbcf05127f61.tar.bz2
sharkey-82674d871874a4e86bca1e39b081bbcf05127f61.zip
lint all uses of translations
-rw-r--r--eslint/locale.js145
-rw-r--r--eslint/locale.test.js29
-rw-r--r--packages/frontend/eslint.config.js5
3 files changed, 179 insertions, 0 deletions
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,
}],