diff --git a/doc/i18n.md b/doc/i18n.md index 3ff01e2f8c..ff9ba8a872 100644 --- a/doc/i18n.md +++ b/doc/i18n.md @@ -23,6 +23,7 @@ - [Formatting Texts](#formatting-texts) - [TRANSLATORS Comments](#translators-comments) - [Missing Translations](#missing-translations) + - [ESLint Plugin](#eslint-plugin) - [Testing Language](#testing-language) - [Building POT File](#building-pot-file) - [Cockpit Details](#cockpit-details) @@ -405,6 +406,12 @@ even work, no crash. But there are still translation problems with them. In both cases the strings will not be extracted to the POT file. +### ESLint Plugin + +The [eslint-plugin-agama-i18n](../web/eslint-plugin-agama-i18n/) subdirectory +contains an ESLint plugin which ensures that a string literal is passed +to the translation function. See more details there. + ### Testing Language The Agama web interface supports special testing `xx` language. That language diff --git a/web/.eslintrc.json b/web/.eslintrc.json index e3545f56c7..d86feed6d9 100644 --- a/web/.eslintrc.json +++ b/web/.eslintrc.json @@ -20,8 +20,9 @@ }, "sourceType": "module" }, - "plugins": ["flowtype", "i18next", "react", "react-hooks", "@typescript-eslint"], + "plugins": ["agama-i18n", "flowtype", "i18next", "react", "react-hooks", "@typescript-eslint"], "rules": { + "agama-i18n/string-literals": "error", "i18next/no-literal-string": "error", "indent": ["error", 2, { @@ -66,6 +67,13 @@ "rules": { "i18next/no-literal-string": "off" } + }, + { + // do not check translation arguments in the test, it checks some internals by passing variables + "files": ["i18n.test.js"], + "rules": { + "agama-i18n/string-literals": "off" + } } ], "globals": { diff --git a/web/eslint-plugin-agama-i18n/README.md b/web/eslint-plugin-agama-i18n/README.md new file mode 100644 index 0000000000..ff1b934658 --- /dev/null +++ b/web/eslint-plugin-agama-i18n/README.md @@ -0,0 +1,31 @@ +# The ESLint Plugin + +This directory contains an ESLint plugin which checks that only string literals +are passed to the translation functions. + +It is bundled here because it is closely tied to the Agama project and probably +does not make sense for other projects. + +## Disabling the Check + +In some rare cases using a variable instead of a string literal is correct. In +that case disable the check locally: + +```js +const SIZES = [ N_("small"), N_("medium"), N_("large") ]; + +// returns one of the sizes above +const sz = getSize(); + +// eslint-disable-next-line agama-i18n/string-literals +return {_(sz)}; +``` + +## Links + +- https://eslint.org/docs/latest/extend/custom-rule-tutorial - tutorial for + writing an ESLint plugin +- https://eslint.org/docs/latest/extend/custom-rules - documentation for + writing an ESLint plugin +- https://astexplorer.net - online tool for browsing a parsed AST tree, + useful for inspecting the properties of parsed source code diff --git a/web/eslint-plugin-agama-i18n/eslint-plugin-agama-i18n.js b/web/eslint-plugin-agama-i18n/eslint-plugin-agama-i18n.js new file mode 100644 index 0000000000..028eba6537 --- /dev/null +++ b/web/eslint-plugin-agama-i18n/eslint-plugin-agama-i18n.js @@ -0,0 +1,29 @@ +/* + * Copyright (c) [2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +const stringLiteralsRule = require("./string-literals"); + +module.exports = { + rules: { + // name of the rule + "string-literals": stringLiteralsRule + } +}; diff --git a/web/eslint-plugin-agama-i18n/package.json b/web/eslint-plugin-agama-i18n/package.json new file mode 100644 index 0000000000..34c70534e6 --- /dev/null +++ b/web/eslint-plugin-agama-i18n/package.json @@ -0,0 +1,5 @@ +{ + "name": "eslint-plugin-agama-i18n", + "version": "0.0.1", + "main": "eslint-plugin-agama-i18n.js" +} diff --git a/web/eslint-plugin-agama-i18n/string-literals.js b/web/eslint-plugin-agama-i18n/string-literals.js new file mode 100644 index 0000000000..3873671069 --- /dev/null +++ b/web/eslint-plugin-agama-i18n/string-literals.js @@ -0,0 +1,84 @@ +/* + * Copyright (c) [2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +// names of all translation functions +const translations = ["_", "n_", "N_", "Nn_"]; +// names of the plural translation functions +const plurals = ["n_", "Nn_"]; + +const errorMsgLiteral = "Use a string literal argument in the translation functions"; +const errorMsgMissing = "Missing argument"; + +/** + * Check whether the AST tree node is a string literal + * @param {Object} node the node + * @returns {boolean} true if the node is a string literal + */ +function isStringLiteral(node) { + if (!node) return false; + + return node.type === "Literal" && (typeof node.value === "string"); +} + +/** + * Check whether the ATS node is a string literal + * @param {Object} node the node to check + * @param {Object} parentNode parent node for reporting error if `node` is undefined + * @param {Object} context the context for reporting an error + */ +function checkNode(node, parentNode, context) { + if (node) { + // string literal? + if (!isStringLiteral(node)) { + context.report(node, errorMsgLiteral); + } + } else { + // missing argument + context.report(parentNode, errorMsgMissing); + } +} + +// define the eslint rule +module.exports = { + meta: { + type: "problem", + docs: { + description: "Check that the arguments if the translation functions are string literals.", + }, + }, + create: function (context) { + return { + // callback for handling function calls + CallExpression(node) { + // not a translation function, skip it + if (!translations.includes(node.callee.name)) return; + + // check the first argument + checkNode(node.arguments[0], node, context); + + // check also the second argument for the plural forms + if (plurals.includes(node.callee.name)) { + checkNode(node.arguments[1], node, context); + } + } + }; + } +}; diff --git a/web/eslint-plugin-agama-i18n/string-literals.test.js b/web/eslint-plugin-agama-i18n/string-literals.test.js new file mode 100644 index 0000000000..528eda51aa --- /dev/null +++ b/web/eslint-plugin-agama-i18n/string-literals.test.js @@ -0,0 +1,62 @@ +/* + * Copyright (c) [2023] SUSE LLC + * + * All Rights Reserved. + * + * This program is free software; you can redistribute it and/or modify it + * under the terms of version 2 of the GNU General Public License as published + * by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along + * with this program; if not, contact SUSE LLC. + * + * To contact SUSE LLC about this file by physical or electronic mail, you may + * find current contact information at www.suse.com. + */ + +const { RuleTester } = require("eslint"); +const stringLiteralsRule = require("./string-literals"); + +const ruleTester = new RuleTester({ + parserOptions: { ecmaVersion: 2015 } +}); + +ruleTester.run( + "string-literals", + stringLiteralsRule, + { + // valid code examples, these should pass + valid: [ + { code: "_(\"foo\")" }, + { code: "_('foo')" }, + { code: "n_(\"one\", \"many\", count)" }, + { code: "n_('one', 'many', count)" }, + ], + // invalid examples, these should fail + invalid: [ + // string literal errors + { code: "_(null)", errors: 1 }, + { code: "_(undefined)", errors: 1 }, + { code: "_(42)", errors: 1 }, + { code: "_(foo)", errors: 1 }, + { code: "_(foo())", errors: 1 }, + { code: "_(`foo`)", errors: 1 }, + { code: "_(\"foo\" + \"bar\")", errors: 1 }, + { code: "_('foo' + 'bar')", errors: 1 }, + // missing argument errors + { code: "_()", errors: 1 }, + { code: "n_('foo')", errors: 1 }, + { code: "n_(\"foo\")", errors: 1 }, + // string literal + missing argument errors + { code: "n_(foo)", errors: 2 }, + // string literal error twice + { code: "n_(foo, bar)", errors: 2 }, + { code: "Nn_(foo, bar)", errors: 2 }, + ], + } +); diff --git a/web/package-lock.json b/web/package-lock.json index a2116fe9fd..2259c40297 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -53,6 +53,7 @@ "eslint-config-standard": "^17.0.0", "eslint-config-standard-jsx": "^11.0.0", "eslint-config-standard-react": "^13.0.0", + "eslint-plugin-agama-i18n": "file:eslint-plugin-agama-i18n", "eslint-plugin-flowtype": "^8.0.3", "eslint-plugin-i18next": "^6.0.3", "eslint-plugin-import": "^2.22.1", @@ -97,6 +98,10 @@ "node": ">=18" } }, + "eslint-plugin-agama-i18n": { + "version": "0.1.0", + "dev": true + }, "node_modules/@aashutoshrathi/word-wrap": { "version": "1.2.6", "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", @@ -8503,6 +8508,10 @@ "ms": "^2.1.1" } }, + "node_modules/eslint-plugin-agama-i18n": { + "resolved": "eslint-plugin-agama-i18n", + "link": true + }, "node_modules/eslint-plugin-es-x": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/eslint-plugin-es-x/-/eslint-plugin-es-x-7.2.0.tgz", @@ -25128,6 +25137,9 @@ } } }, + "eslint-plugin-agama-i18n": { + "version": "file:eslint-plugin-agama-i18n" + }, "eslint-plugin-es-x": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/eslint-plugin-es-x/-/eslint-plugin-es-x-7.2.0.tgz", diff --git a/web/package.json b/web/package.json index 9501d20bdb..a7288adf79 100644 --- a/web/package.json +++ b/web/package.json @@ -54,6 +54,7 @@ "eslint-config-standard": "^17.0.0", "eslint-config-standard-jsx": "^11.0.0", "eslint-config-standard-react": "^13.0.0", + "eslint-plugin-agama-i18n": "file:eslint-plugin-agama-i18n", "eslint-plugin-flowtype": "^8.0.3", "eslint-plugin-i18next": "^6.0.3", "eslint-plugin-import": "^2.22.1", diff --git a/web/src/components/storage/VolumeForm.jsx b/web/src/components/storage/VolumeForm.jsx index 268fda26f1..6a103645a0 100644 --- a/web/src/components/storage/VolumeForm.jsx +++ b/web/src/components/storage/VolumeForm.jsx @@ -56,6 +56,7 @@ const SizeUnitFormSelect = ({ units, ...formSelectProps }) => { return ( {/* the unit values are marked for translation in the utils.js file */} + {/* eslint-disable-next-line agama-i18n/string-literals */} { units.map(unit => ) } ); @@ -295,6 +296,7 @@ const SizeOptions = ({ errors, formData, volume, onChange }) => {