diff --git a/web/cspell.json b/web/cspell.json
index 940b7fae66..165130dd04 100644
--- a/web/cspell.json
+++ b/web/cspell.json
@@ -44,6 +44,7 @@
"mgmt",
"mmcblk",
"multipath",
+ "ngettext",
"onboot",
"partitioner",
"patternfly",
diff --git a/web/src/components/core/FileViewer.jsx b/web/src/components/core/FileViewer.jsx
index f23aab4f00..97370e1f22 100644
--- a/web/src/components/core/FileViewer.jsx
+++ b/web/src/components/core/FileViewer.jsx
@@ -23,12 +23,10 @@ import React, { useState, useEffect } from "react";
import { Popup } from "~/components/core";
import { Alert } from "@patternfly/react-core";
import { Loading } from "~/components/layout";
+import { _ } from "~/i18n";
import cockpit from "../../lib/cockpit";
-// FIXME: replace by a wrapper, this is just for testing
-const _ = cockpit.gettext;
-
export default function FileViewer({ file, title, onCloseCallback }) {
// the popup is visible
const [isOpen, setIsOpen] = useState(true);
diff --git a/web/src/i18n.js b/web/src/i18n.js
new file mode 100644
index 0000000000..275edb6663
--- /dev/null
+++ b/web/src/i18n.js
@@ -0,0 +1,103 @@
+/*
+ * 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.
+ */
+
+/**
+ * This is a wrapper module for i18n functions. Currently it uses the cockpit
+ * implementation but the wrapper allows easy transition to another backend if
+ * needed.
+ */
+
+import cockpit from "./lib/cockpit";
+
+/**
+ * Returns a translated text in the current locale or the original text if the
+ * translation is not found.
+ *
+ * @param {string} str the input string to translate
+ * @return {string} translated or original text
+ */
+const _ = (str) => cockpit.gettext(str);
+
+/**
+ * Similar to the _() function. This variant returns singular or plural form
+ * depending on an additional "num" argument.
+ *
+ * @see {@link _} for further information
+ * @param {string} str1 the input string in the singular form
+ * @param {string} strN the input string in the plural form
+ * @param {number} n the actual number which decides whether to use the
+ * singular or plural form
+ * @return {string} translated or original text
+ */
+const n_ = (str1, strN, n) => cockpit.ngettext(str1, strN, n);
+
+/**
+ * This is a no-op function, it can be used only for marking the text for
+ * translation so it is extracted to the POT file but the text itself is not
+ * translated. It needs to be translated by the _() function later.
+ *
+ * @example
Error messages
+ * try {
+ * ...
+ * // the exception contains untranslated string
+ * throw(N_("Download failed"));
+ * } catch (error) {
+ * // log the untranslated error
+ * console.log(error);
+ * // for users display the translated error
+ * return Error: {_(error)}
;
+ * }
+ *
+ * @example Constants
+ * // ERROR_MSG will not be translated, but the string will be found
+ * // by gettext when creating the POT file
+ * const ERROR_MSG = N_("Download failed");
+ * const OK_MSG = N_("Success");
+ *
+ * // assume that "result" contains one of the constants above
+ * const result = ...;
+ * // here the string will be translated using the current locale
+ * return Result: {_(result)}
;
+ *
+ * @param {string} str the input string
+ * @return {string} the input string
+ */
+const N_ = (str) => str;
+
+/**
+ * Similar to the N_() function, but for the singular and plural form.
+ *
+ * @see {@link N_} for further information
+ * @param {string} str1 the input string in the singular form
+ * @param {string} strN the input string in the plural form
+ * @param {number} n the actual number which decides whether to use the
+ * singular or plural form
+ * @return {string} the original text, either "string1" or "stringN" depending
+ * on the value "num"
+ */
+const Nn_ = (str1, strN, n) => (n === 1) ? str1 : strN;
+
+export {
+ _,
+ n_,
+ N_,
+ Nn_
+};
diff --git a/web/src/i18n.test.js b/web/src/i18n.test.js
new file mode 100644
index 0000000000..6cf3513155
--- /dev/null
+++ b/web/src/i18n.test.js
@@ -0,0 +1,78 @@
+/*
+ * 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.
+ */
+
+import { _, n_, N_, Nn_ } from "~/i18n";
+import cockpit from "./lib/cockpit";
+
+// mock the cockpit gettext functions
+jest.mock("./lib/cockpit");
+const gettextFn = jest.fn();
+cockpit.gettext.mockImplementation(gettextFn);
+const ngettextFn = jest.fn();
+cockpit.ngettext.mockImplementation(ngettextFn);
+
+// some testing texts
+const text = "text to translate";
+const singularText = "singular text to translate";
+const pluralText = "plural text to translate";
+
+describe("i18n", () => {
+ describe("_", () => {
+ it("calls the cockpit.gettext() implementation", () => {
+ _(text);
+
+ expect(gettextFn).toHaveBeenCalledWith(text);
+ });
+ });
+
+ describe("n_", () => {
+ it("calls the cockpit.ngettext() implementation", () => {
+ n_(singularText, pluralText, 1);
+
+ expect(ngettextFn).toHaveBeenCalledWith(singularText, pluralText, 1);
+ });
+ });
+
+ describe("N_", () => {
+ it("returns the original text and does not translate it", () => {
+ const val = N_(text);
+
+ // test the object identity
+ expect(Object.is(val, text)).toBe(true);
+ });
+ });
+
+ describe("Nn_", () => {
+ it("returns the singular form for value 1 and does not translate it", () => {
+ const val = Nn_(singularText, pluralText, 1);
+
+ // test the object identity
+ expect(Object.is(val, singularText)).toBe(true);
+ });
+
+ it("returns the plural form for value 42 and does not translate it", () => {
+ const val = Nn_(singularText, pluralText, 42);
+
+ // test the object identity
+ expect(Object.is(val, pluralText)).toBe(true);
+ });
+ });
+});