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 }) => {