Skip to content

Commit

Permalink
Drop Cockpit dependency for web translations (#1118)
Browse files Browse the repository at this point in the history
## Problem

- Drop Cockpit dependency for loading the web translations

## Solution

- Reimplement translation loading without Cockpit

## Notes

- The code still works with the Cockpit backend and can be merged to
`master` without breaking anything.
- It uses the same process and same translation loading mechanism as
Cockpit just without using the Cockpit library. This makes the
transition easy and simple.
- In the future we can optionally change that (and even use a different
framework like i18n-next).
- There are some comments marking the code for removal when the Cockpit
dependency is fully dropped.

## Testing

- Updated unit tests
- Tested manually
  • Loading branch information
lslezak authored Mar 26, 2024
1 parent 5416449 commit f143990
Show file tree
Hide file tree
Showing 9 changed files with 155 additions and 39 deletions.
4 changes: 2 additions & 2 deletions web/src/App.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ const changePhaseTo = phase => act(() => callbacks.onPhaseChange(phase));
describe("App", () => {
beforeEach(() => {
// setting the language through a cookie
document.cookie = "CockpitLang=en-us; path=/;";
document.cookie = "agamaLang=en-us; path=/;";
createClient.mockImplementation(() => {
return {
manager: {
Expand Down Expand Up @@ -94,7 +94,7 @@ describe("App", () => {

afterEach(() => {
// setting a cookie with already expired date removes it
document.cookie = "CockpitLang=; path=/; expires=" + new Date(0).toUTCString();
document.cookie = "agamaLang=; path=/; expires=" + new Date(0).toUTCString();
});

describe("when the software context is not initialized", () => {
Expand Down
107 changes: 107 additions & 0 deletions web/src/agama.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/*
* Copyright (c) [2024] 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 module provides a global "agama" object which can be use from other
* scripts like "po.js".
*/

const agama = {
// the current language
language: "en",
};

// mapping with the current translations
let translations = {};
// function used for computing the plural form index
let plural_fn;

// set the current translations, called from po.js
agama.locale = function locale(po) {
if (po) {
Object.assign(translations, po);

const header = po[""];
if (header) {
if (header["plural-forms"])
plural_fn = header["plural-forms"];
if (header.language)
agama.language = header.language;
}
} else if (po === null) {
translations = {};
}
};

/**
* get a translation for a singular text
* @param {string} str input text
* @return translated text or the original text if the translation is not found
*/
agama.gettext = function gettext(str) {
if (translations) {
const translated = translations[str];
// skip the `null` item in the list generated by cockpit-po-plugin
// TODO: get rid of that later
if (translated?.[1]) return translated[1];
}

// fallback, return the original text
return str;
};

/**
* get a translation for a plural text
* @param {string} str1 input singular text
* @param {string} strN input plural text
* @param {number} n the actual number which decides whether to use the
* singular or plural form (of which plural form if there are several of them)
* @return translated text or the original text if the translation is not found
*/
agama.ngettext = function ngettext(str1, strN, n) {
if (translations && plural_fn) {
// plural form translations are indexed by the singular variant
const translation = translations[str1];

if (translation) {
const plural_index = plural_fn(n);

// the plural function either returns direct index (integer) in the plural
// translations or a boolean indicating simple plural form which
// needs to be converted to index 0 (singular) or 1 (plural)
let index = (plural_index === true ? 1 : plural_index || 0);

// skip the `null` item in the list generated by cockpit-po-plugin
// TODO: get rid of that later
index += 1;

if (translation[index]) return translation[index];
}
}

// fallback, return the original text
return (n === 1) ? str1 : strN;
};

// register a global object so it can be accessed from a separate po.js script
window.agama = agama;

export default agama;
4 changes: 2 additions & 2 deletions web/src/components/l10n/InstallerKeymapSwitcher.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
import React from "react";
import { FormSelect, FormSelectOption } from "@patternfly/react-core";

import cockpit from "../../lib/cockpit";
import agama from "~/agama";

import { Icon } from "~/components/layout";
import { _ } from "~/i18n";
Expand All @@ -33,7 +33,7 @@ import { If } from "~/components/core";

const sort = (keymaps) => {
// sort the keymap names using the current locale
const lang = cockpit.language || "en";
const lang = agama.language || "en";
return keymaps.sort((k1, k2) => k1.name.localeCompare(k2.name, lang));
};

Expand Down
31 changes: 19 additions & 12 deletions web/src/context/installerL10n.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import React, { useCallback, useEffect, useState } from "react";
import { useCancellablePromise, locationReload, setLocationSearch } from "~/utils";
import cockpit from "../lib/cockpit";
import { useInstallerClient } from "./installer";
import agama from "~/agama";

const L10nContext = React.createContext(null);

Expand All @@ -49,37 +50,43 @@ function useInstallerL10n() {
}

/**
* Current language according to Cockpit (in xx_XX format).
* Current language (in xx_XX format).
*
* It takes the language from the CockpitLang cookie.
* It takes the language from the agamaLang cookie.
*
* @return {string|undefined} Undefined if language is not set.
*/
function cockpitLanguage() {
function agamaLanguage() {
// language from cookie, empty string if not set (regexp taken from Cockpit)
// https://github.com/cockpit-project/cockpit/blob/98a2e093c42ea8cd2431cf15c7ca0e44bb4ce3f1/pkg/shell/shell-modals.jsx#L91
const languageString = decodeURIComponent(document.cookie.replace(/(?:(?:^|.*;\s*)CockpitLang\s*=\s*([^;]*).*$)|^.*$/, "$1"));
const languageString = decodeURIComponent(document.cookie.replace(/(?:(?:^|.*;\s*)agamaLang\s*=\s*([^;]*).*$)|^.*$/, "$1"));
if (languageString) {
return languageString.toLowerCase();
}
}

/**
* Helper function for storing the Cockpit language.
* Helper function for storing the Agama language.
*
* Automatically converts the language from xx_XX to xx-xx, as it is the one used by Cockpit.
* Automatically converts the language from xx_XX to xx-xx, as it is the one used by Agama.
*
* @param {string} language - The new locale (e.g., "cs", "cs_CZ").
* @return {boolean} True if the locale was changed.
*/
function storeCockpitLanguage(language) {
const current = cockpitLanguage();
function storeAgamaLanguage(language) {
const current = agamaLanguage();
if (current === language) return false;

// Code taken from Cockpit.
const cookie = "CockpitLang=" + encodeURIComponent(language) + "; path=/; expires=Sun, 16 Jul 3567 06:23:41 GMT";
const cookie = "agamaLang=" + encodeURIComponent(language) + "; path=/; expires=Sun, 16 Jul 3567 06:23:41 GMT";
document.cookie = cookie;

// for backward compatibility, CockpitLang cookie is needed to load correct po.js content from Cockpit
// TODO: remove after dropping Cockpit completely
const cockpit_cookie = "CockpitLang=" + encodeURIComponent(language) + "; path=/; expires=Sun, 16 Jul 3567 06:23:41 GMT";
document.cookie = cockpit_cookie;
window.localStorage.setItem("cockpit.lang", language);

return true;
}

Expand Down Expand Up @@ -238,16 +245,16 @@ function InstallerL10nProvider({ children }) {
const wanted = lang || languageFromQuery();

if (wanted === "xx" || wanted === "xx-xx") {
cockpit.language = wanted;
agama.language = wanted;
setLanguage(wanted);
return;
}

const current = cockpitLanguage();
const current = agamaLanguage();
const candidateLanguages = [wanted, current].concat(navigatorLanguages()).filter(l => l);
const newLanguage = findSupportedLanguage(candidateLanguages) || "en-us";

let mustReload = storeCockpitLanguage(newLanguage);
let mustReload = storeAgamaLanguage(newLanguage);
mustReload = await storeInstallerLanguage(newLanguage) || mustReload;

if (mustReload) {
Expand Down
16 changes: 8 additions & 8 deletions web/src/context/installerL10n.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ jest.mock("~/lib/cockpit", () => ({
}));

// Helper component that displays a translated message depending on the
// CockpitLang value.
// agamaLang value.
const TranslatedContent = () => {
const text = {
"cs-cz": "ahoj",
Expand All @@ -65,7 +65,7 @@ const TranslatedContent = () => {
"es-ar": "hola!",
};

const regexp = /CockpitLang=([^;]+)/;
const regexp = /agamaLang=([^;]+)/;
const found = document.cookie.match(regexp);
if (!found) return <>{text["en-us"]}</>;

Expand All @@ -85,7 +85,7 @@ describe("InstallerL10nProvider", () => {
// remove the Cockpit language cookie after each test
afterEach(() => {
// setting a cookie with already expired date removes it
document.cookie = "CockpitLang=; path=/; expires=" + new Date(0).toUTCString();
document.cookie = "agamaLang=; path=/; expires=" + new Date(0).toUTCString();
});

describe("when no URL query parameter is set", () => {
Expand All @@ -95,7 +95,7 @@ describe("InstallerL10nProvider", () => {

describe("when the Cockpit language is already set", () => {
beforeEach(() => {
document.cookie = "CockpitLang=en-us; path=/;";
document.cookie = "agamaLang=en-us; path=/;";
getUILocaleFn.mockResolvedValueOnce("en_US.UTF-8");
});

Expand All @@ -115,7 +115,7 @@ describe("InstallerL10nProvider", () => {

describe("when the Cockpit language is set to an unsupported language", () => {
beforeEach(() => {
document.cookie = "CockpitLang=de-de; path=/;";
document.cookie = "agamaLang=de-de; path=/;";
getUILocaleFn.mockResolvedValueOnce("de_DE.UTF-8");
getUILocaleFn.mockResolvedValueOnce("es_ES.UTF-8");
});
Expand Down Expand Up @@ -200,7 +200,7 @@ describe("InstallerL10nProvider", () => {

describe("when the Cockpit language is already set to 'cs-cz'", () => {
beforeEach(() => {
document.cookie = "CockpitLang=cs-cz; path=/;";
document.cookie = "agamaLang=cs-cz; path=/;";
getUILocaleFn.mockResolvedValueOnce("cs_CZ.UTF-8");
});

Expand All @@ -215,15 +215,15 @@ describe("InstallerL10nProvider", () => {
await screen.findByText("ahoj");
expect(setUILocaleFn).not.toHaveBeenCalled();

expect(document.cookie).toEqual("CockpitLang=cs-cz");
expect(document.cookie).toMatch(/agamaLang=cs-cz/);
expect(utils.locationReload).not.toHaveBeenCalled();
expect(utils.setLocationSearch).not.toHaveBeenCalled();
});
});

describe("when the Cockpit language is set to 'en-us'", () => {
beforeEach(() => {
document.cookie = "CockpitLang=en-us; path=/;";
document.cookie = "agamaLang=en-us; path=/;";
getUILocaleFn.mockResolvedValueOnce("en_US");
getUILocaleFn.mockResolvedValueOnce("cs_CZ");
setUILocaleFn.mockResolvedValue();
Expand Down
14 changes: 7 additions & 7 deletions web/src/i18n.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,19 @@
*/

/**
* 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.
* This is a wrapper module for i18n functions. Currently it uses the
* implementation similar to cockpit but the wrapper allows easy transition to
* another backend if needed.
*/

import cockpit from "./lib/cockpit";
import agama from "~/agama";

/**
* Tests whether a special testing language is used.
*
* @returns {boolean} true if the testing language is set
*/
const isTestingLanguage = () => cockpit.language === "xx";
const isTestingLanguage = () => agama.language === "xx";

/**
* "Translate" the string to special "xx" testing language.
Expand Down Expand Up @@ -72,7 +72,7 @@ const xTranslate = (str) => {
* @param {string} str the input string to translate
* @return {string} translated or original text
*/
const _ = (str) => isTestingLanguage() ? xTranslate(str) : cockpit.gettext(str);
const _ = (str) => isTestingLanguage() ? xTranslate(str) : agama.gettext(str);

/**
* Similar to the _() function. This variant returns singular or plural form
Expand All @@ -88,7 +88,7 @@ const _ = (str) => isTestingLanguage() ? xTranslate(str) : cockpit.gettext(str);
const n_ = (str1, strN, n) => {
return isTestingLanguage()
? xTranslate((n === 1) ? str1 : strN)
: cockpit.ngettext(str1, strN, n);
: agama.ngettext(str1, strN, n);
};

/**
Expand Down
12 changes: 6 additions & 6 deletions web/src/i18n.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,14 @@
*/

import { _, n_, N_, Nn_ } from "~/i18n";
import cockpit from "./lib/cockpit";
import agama from "~/agama";

// mock the cockpit gettext functions
jest.mock("./lib/cockpit");
jest.mock("~/agama");
const gettextFn = jest.fn();
cockpit.gettext.mockImplementation(gettextFn);
agama.gettext.mockImplementation(gettextFn);
const ngettextFn = jest.fn();
cockpit.ngettext.mockImplementation(ngettextFn);
agama.ngettext.mockImplementation(ngettextFn);

// some testing texts
const text = "text to translate";
Expand All @@ -36,15 +36,15 @@ const pluralText = "plural text to translate";

describe("i18n", () => {
describe("_", () => {
it("calls the cockpit.gettext() implementation", () => {
it("calls the agama.gettext() implementation", () => {
_(text);

expect(gettextFn).toHaveBeenCalledWith(text);
});
});

describe("n_", () => {
it("calls the cockpit.ngettext() implementation", () => {
it("calls the agama.ngettext() implementation", () => {
n_(singularText, pluralText, 1);

expect(ngettextFn).toHaveBeenCalledWith(singularText, pluralText, 1);
Expand Down
2 changes: 1 addition & 1 deletion web/src/lib/webpack-po-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const path = require("path");
// @param res HTTP response
module.exports = function (req, res) {
// the regexp was taken from the original Cockpit code :-)
const language = req.headers.cookie.replace(/(?:(?:^|.*;\s*)CockpitLang\s*=\s*([^;]*).*$)|^.*$/, "$1") || "";
const language = req.headers.cookie.replace(/(?:(?:^|.*;\s*)agamaLang\s*=\s*([^;]*).*$)|^.*$/, "$1") || "";
// the cookie uses "pt-br" format while the PO file is "pt_BR" :-/
let [lang, country] = language.split("-");
country = country?.toUpperCase();
Expand Down
4 changes: 3 additions & 1 deletion web/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ const copy_files = [
const plugins = [
new Copy({ patterns: copy_files }),
new Extract({ filename: "[name].css" }),
new CockpitPoPlugin(),
// the wrapper sets the main code called in the po.js files,
// the PO_DATA tag is replaced by the real translation data
new CockpitPoPlugin({ wrapper: "agama.locale(PO_DATA);" }),
new CockpitRsyncPlugin({ dest: packageJson.name }),
development && new ReactRefreshWebpackPlugin({ overlay: false }),
// replace the "process.env.WEBPACK_SERVE" text in the source code by
Expand Down

0 comments on commit f143990

Please sign in to comment.